See the demo to understand clearly what this is all about. You can also view the source code on Github.
Based from the demo, page/sort/modal state change on the table triggers a url change for bookmarking. All of these state changes are available through the browser’s back and forward button. A really simple but yet powerful feature especially if you have tons of records to display on your AngularJs table.
In a perfect world, I might be calling a RESTful API for every page or sort change to achieve the bookmark feature. However, what if I don’t have a backend REST API and all I have is an excel format of the data source needed. This is exactly the situation on my previous job. Huge organizations can be slow when it comes to web technology implementation due to departmental silos. It may take significant amount of time or even budget to request for some new API like this. If you’re like me, working with what you got is the fastest and sometimes best approach. This means converting the available data to a json format for the web application’s consumption.
Given a json file with complete U.S. states data, I can mimic a REST API call using AngularJs’ service component. However, I’ll also keep in mind that this is just a temporary solution. I also need to consider the availability of an external REST API in the future and how easy I can update my AngularJs code to use it.
angular.module('usStatesApp', [])
.constant('config', {
//change the value to 'external' if want to use externalStatesService
statesApi: 'internal'
}).service('externalStatesService', function(){
//service to use if the data source is external
this.totalCount = 50;
this.pageSize = 10;
this.getData = function(offset, limit, sort){
//write code here to call an external REST API
//should return a promise
};
this.renderTable = function(offset,limit,sort,callback){
this.getData(offset, limit, sort).then(function(states) {
callback(states);
});
};
}).service('internalStatesService', function(){
//service to use if the data source is internal like having a json file on our folder
this.totalCount = 50;
this.pageSize = 10;
this.getData = function(offset, limit, sort){
//more code here later
};
this.renderTable = function(offset,limit,sort,callback){
this.getData(offset, limit, sort).then(function(states) {
callback(states);
});
};
}).service('getStates', function(internalStatesService, externalStatesService, config){
if(config.StatesApi === 'external'){
return externalStatesService;
}
else{
return internalStatesService
}
}).service('States', function(getStates){
this.service = getStates;
}).controller('MainCtrl', function ($scope, States, $location) {
//controller content here
});
I have 4 services namely:
externalStatesService - holds the logic for querying external REST API.
internalStatesService - service to use if my data is internal ( states.json ) and it’s not calling a RESTful API.
getStates - determines what service to use between “externalStatesService” or “internalStatesService” based from the AngularJs constant property.
States - holds the common logic between “internalStatesService” and “externalStatesService” by calling the “getStates” service.
Take note that only “States” service directly interacts with the controller. Based from the architecture above, I can easily switch data source to an external REST API by changing a value in a single location ( inside the constant component ).
List of external modules:
angular-resource - creates a resource object that lets you interact with RESTful server-side data sources.
angular-strap - incorporate bootstrap in an AngularJs project.
angular-paging - AngularJs paging module.
I can use angular-route here but I want this to be as simple as possible for now.
With the external modules, I can now write my main states.html.
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>U.S. States List</title>
<meta name="description" content="">
<meta name="viewport" content="width=device-width">
<link href="//maxcdn.bootstrapcdn.com/bootstrap/3.3.7/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="styles/main.css">
<link rel="stylesheet" href="//maxcdn.bootstrapcdn.com/font-awesome/4.6.3/css/font-awesome.min.css" />
</head>
<body ng-app="usStatesApp" ng-controller="MainCtrl">
<div class="container-fluid">
<br>
<h2 class="text-center">A Bookmarkable State Change In A Data Table - AngularJs</h2><br>
<p class="text-center">After doing some data state changes ( clicking a page, opening a modal, sorting a property, etc. ) , use the browser's back/forward button to navigate to the initial state.</p>
<div class="row">
<div class="panel panel-info">
<div class="panel-heading">
<span class="glyphicon glyphicon-list"></span>
U.S. States
</div>
<div class="panel-body table-responsive">
<!-- table here -->
</div>
</div>
</div>
</div>
<script src="//cdnjs.cloudflare.com/ajax/libs/angular.js/1.2.1/angular.min.js"></script>
<script src="//ajax.googleapis.com/ajax/libs/angularjs/1.2.1/angular-resource.js"></script>
<script src="//cdnjs.cloudflare.com/ajax/libs/angular-strap/2.3.9/angular-strap.js"></script>
<script src="//cdnjs.cloudflare.com/ajax/libs/angular-strap/2.3.9/angular-strap.tpl.js"></script>
<script src="lib/angular-paging/paging.js"></script>
<script>
//custom js script here
</script>
</body>
</html>
Here’s the code for the “internalStatesService”:
...more code before here
}).service('internalStatesService', function($resource){
this.totalCount = 50;
this.pageSize = 10;
this.states = null; //cache states data from states.json
//handle data sort
var propSort = function(prop) {
var dir=1;
if(prop[0] === "-") {
dir = -1;
prop = prop.substr(1);
}
return function (a,b) {
var result = (a[prop] < b[prop]) ? -1 : (a[prop] > b[prop]) ? 1 : 0;
return result * dir;
}
};
//handle data filtering
var filterResult = function(offset, limit, sort, pagesize, result){
if (sort) {
result.sort(propSort(sort));
}
if (offset === undefined) {
offset=0;
} else {
offset = +offset;
}
if (limit === undefined) {
limit = pagesize;
} else {
limit = +limit;
}
if (limit > pagesize) {
limit = pagesize;
}
result = result.slice(offset, offset+limit);
return result;
};
//gets and cached data
this.getData = function(offset, limit, sort){
var self = this;
return $resource('states.json').query().$promise.then(function(result){
self.states = result; //cached states
return filterResult(offset, limit, sort, self.pageSize, result);
});
};
this.renderTable = function(offset,limit,sort,callback){
if(!this.states){
this.getData(offset, limit, sort).then(function(states) {
callback(states);
});
}
else{
var states = filterResult(offset, limit, sort, this.pageSize, this.states);
callback(states);
}
};
}).service ...more code after
Take note that the service is using angular-resource ( $resource ).
There are 2 private functions namely:
filterResult - responsible for filtering the data from the cached or states.json
propSort - responsible for sorting the data based from the given property and direction.
2 public functions:
getData - interacts with the internally stored “states.json” file using $resource and cached the result for later use.
renderTable - responsible for passing the result to the view thru its callback parameter function. Take note that it checks first if there’s an available cached data else call the “getData” function.
Take note that “States” is the service that interacts directly to the main controller (MainCtrl).
Here’s the full code:
...more code before here
}).service('States', function($modal, getStates){
this.service = getStates;
this.getSortValue = function(sortReverse){
if(!sortReverse){
return '';
}
return '-';
};
this.getSortDir = function(sortString){
if(sortString.indexOf('-') === -1){
return false;
}
return true;
};
this.getSingleState = function(states,stateAbbr){
var match_state = false,
i,
len = states.length,
state;
for (i = 0; i < len; i = i + 1) {
state = states[i];
if (state.abbreviation === stateAbbr){
match_state = state;
break;
}
}
return match_state;
};
this.stateModal = function(scope){
return $modal({
scope: scope,
templateUrl: 'views/templates/state-modal.html',
show: false
});
};
}).controller
...more code after
Take note of the service property. This exposes the getStates service to the controller which chooses between “internalStatesService” and “externalStatesService” depending on the configured value of the “statesApi” constant.
...more code before here
}).controller('MainCtrl', function ($scope, States, $location) {
var limit = function(){
return $scope.currentPage * $scope.pageSize;
},
offset = function(){
return limit() - $scope.pageSize;
},
hideModal = function(){
if($scope.state_modal){
$scope.state_modal.hide(); //hide this modal instance
}
},
showModal = function(state){
$scope.state = States.getSingleState($scope.states, state); //retrieve and store state detail on scope to be passed on our modal view
$scope.state_modal = States.stateModal($scope); //store modal on scope for future reference
$scope.state_modal.$promise.then($scope.state_modal.show);
};
//listen to successful changes on url including complete page load
$scope.$on('$locationChangeSuccess', function (scope, next, current) {
var query_string = $location.search(),
sort_param = query_string['sort'],
page_param = query_string['page'],
detail_param = query_string['detail'];
$scope.sortType = (!sort_param ? 'name' : sort_param.replace(/[^a-zA-Z]/g, "")); // set the default sort type
$scope.sortString = (!sort_param ? $scope.sortType : sort_param);
$scope.sortReverse = States.getSortDir($scope.sortString);
$scope.totalStates = States.service.totalCount;
$scope.pageSize = States.service.pageSize;
$scope.currentPage = (!page_param ? 1 : parseInt(page_param,10));
//re-render table
States.service.renderTable(offset(), limit(), $scope.sortString, function(result){
$scope.states = result;
if(detail_param){
showModal(detail_param);
}
else{
hideModal();
}
});
});
//update the url on page change
$scope.pageChange = function(page){
$location.url($location.path() + '?page='+ page +'&sort=' + $scope.sortString);
};
//update the url on sort direction change
$scope.sort = function(type){
$location.url($location.path() + '?page='+ $scope.currentPage +'&sort=' + States.getSortValue(!$scope.sortReverse) + type);
};
//update the url on showing modal
$scope.showStateModal = function (state) {
$location.url($location.path() + '?page='+ $scope.currentPage +'&sort=' + $scope.sortString + '&detail=' + state);
};
//update the url when closing modal
$scope.$on('modal.hide', function() {
//return state back to null
$scope.state = null;
States.stateModal($scope);
$location.url($location.path() + '?page='+ $scope.currentPage +'&sort=' + $scope.sortString);
});
});
As you see, we’re using the $location object in updating the browser’s url on page change, sort direction change, opening a modal, etc. This is how the bookmark feature is achieved.