The goal of this post is to write a single page application (SPA) to display a list of items inside a grid. This grid shall not only display a list of items but also allows all the CRUD operations: Create a new item and Read, Update and Delete for the contained items.
Additional requirements:
- Show a notification when a server request success or fail.
- Editing an item: Start editing with a double click and commit the edition with “Enter”.
- Everything shall be executed without refreshing the web page
- The complete list of items should only be requested one when the page is loaded.
UPDATE:
Results
Code is available on github: https://github.com/softwarejc/angularjs-crudgrid. Feel free to play with it, find errors and write improvements. You can see a live demo here: AngularJS CRUD Grid III demo.
This is a screenshot of the AngularJS Crud Grid that we are going to implement:
Implementation
Let’s get our hands dirty, we will cover the following points:
- Domain: Entity Framework + Domain Services
- Server side: WebAPI + ViewModels
- Client side: AngularJS + Bootstrap + Toastr
- What’s next?
The architecture of the application is the same I proposed on my previous post:
Domain
At the lower level of the application I use Entity Framework – Code first. Further details here are out of the scope of this post. I used the simplest domain class I could imagine to focus on the goal of this post. This class is call “Item” and just contains a string property “Name”. The only restriction is that this “Name” should be unique.
public class Item { [Key] public int Id { get; set; } [Index(IsUnique = true)] public string Name { get; set; } }
We could use directly the DbContext inside our WebAPI controllers but I think it is better to add an additional layer here with the domain logic. This layer should contain the logic to manipulate items. This is the box “Domain Services” of my Architecture Diagram.
This is the interface of this the service:
public interface IBaseService<T> { T Add(T item); Item Get(int id); IEnumerable<T> GetAll(); bool Update(T updatedItem); bool Remove(int id); } public interface IItemsService : IBaseService<Item> { Item Get(string name); bool Remove(string name); }
Server Side
At the server side I use a REST Service using Microsoft WebAPI:
public class KnownItemsController : ApiController { readonly IItemsService _itemsService = new ItemsService(); // Create item public HttpResponseMessage Post(ItemViewModel item) { if (!ModelState.IsValid || item == null || string.IsNullOrEmpty(item.Name)) { throw new HttpResponseException(HttpStatusCode.ExpectationFailed); } var addedItem = _itemsService.Add(item.MapToEntity()); return Request.CreateResponse(HttpStatusCode.Created, addedItem); } // Read all items public IEnumerable<ItemViewModel> Get() { return _itemsService.GetAll().Select(ItemViewModel.MapFromEntity); } // Read item by id public ItemViewModel Get(int id) { var item = _itemsService.Get(id); if (item == null) { throw new HttpResponseException(HttpStatusCode.NotFound); } return ItemViewModel.MapFromEntity(item); } // Update item public void Put([FromBody]ItemViewModel updatedItem) { if (!_itemsService.Update(updatedItem.MapToEntity())) { throw new HttpResponseException(HttpStatusCode.NotFound); } } // Remove item public bool Delete(int id) { var item = _itemsService.Get(id); if (item == null) { throw new HttpResponseException(HttpStatusCode.NotFound); } return _itemsService.Remove(id); } }
I use a view model that is mapped from and to the domain class. This view model could be used to send calculated fields to the client or to hide some information of our domain.
public class ItemViewModel : BaseValidatableViewModel<ItemViewModel, Item> { public int Id { get; set; } public string Name { get; set; } }
I use a base class to map between view model and domain. This class can also be used to validate the view model using the model.
Client Side
The CRUD Grid is already inside a html angular template that will be injected where the ui-view is defined.
<div class="row"> <!--AngularJS SPA CRUD table--> <table class="table table-condensed table-hover table-striped" ng-hide="loading"> <!-- Header--> <tr class="panel-title"> <!--Buttons column--> <th style="width: 100px;"> <!--Add + Cancel--> <div class="btn-toolbar"><i class="btn glyphicon glyphicon-plus" ng-click="toggleAddMode()" title="Add" ng-hide="addMode"></i></div> <div class="btn-toolbar"><i class="btn glyphicon glyphicon-minus" ng-click="toggleAddMode()" title="Cancel" ng-show="addMode"></i></div> </th> <!-- Content columns--> <th style="vertical-align:middle">Name</th> </tr> <!--Row with the new item--> <tr ng-show="addMode" style="background: rgb(251, 244, 222)"> <!--Buttons column--> <td> <div class="btn-toolbar"> <!--Create + Cancel--> <div class="btn-group"> <i class="btn glyphicon glyphicon-floppy-disk" ng-click="createItem()" title="Create" ng-disabled="addForm.$invalid"></i> <i class="btn glyphicon glyphicon-remove" ng-click="toggleAddMode()" title="Cancel"></i> </div> </div> </td> <!-- Content columns--> <td> <form name="addForm"> <input type="text" ng-model="newItem.name" class="form-control" required placeholder="new item..." ng-keydown="saveOnEnter(knownItem, $event)" required /> </form> </td> </tr> <!--Content--> <tr ng-repeat="knownItem in knownItems"> <!--Buttons column--> <td> <!--Edit + Delete--> <div class="btn-toolbar" ng-show="knownItem.editMode == null || knownItem.editMode == false"> <div class="btn-group"> <i class="btn glyphicon glyphicon-edit" ng-click="toggleEditMode(knownItem)" title="Edit"></i> <i class="btn glyphicon glyphicon-trash" ng-click="deleteItem(knownItem)" title="Delete" data-toggle="modal"></i> </div> </div> <!--Save + Cancel--> <div class="btn-toolbar" ng-show="knownItem.editMode"> <div class="btn-group"> <i class="btn glyphicon glyphicon-floppy-disk" ng-click="updateItem(knownItem)" title="Save" ng-disabled="editForm.$invalid"></i> <i class="btn glyphicon glyphicon-remove" ng-click="toggleEditMode(knownItem)" title="Cancel"></i> </div> </div> </td> <!-- Content columns--> <td style="vertical-align:middle"> <!--Name read mode--> <span ng-show="knownItem.editMode == null || knownItem.editMode == false" ng-dblclick="toggleEditMode(knownItem)"> {{knownItem.name}} </span> <!--Name edit mode--> <form name="editForm"> <input ng-model="knownItem.name" ng-show="knownItem.editMode" required ng-keydown="updateOnEnter(knownItem, $event)" class="form-control" /> </form> </td> </tr> </table> <!--Loading indicator--> <img src="../images/loading.gif" ng-show="loading" class="center-block" title="Loading..." /> </div>
This is my index file:
<!DOCTYPE html> <html ng-app="stockModule" xmlns="http://www.w3.org/1999/xhtml"> <head> <title>Angular JS - CRUD Grid (I)</title> <!--css--> <link href="Content/bootstrap.css" rel="stylesheet" /> <link href="Content/toastr.css" rel="stylesheet" /> <!--jquery--> <script src="Scripts/jquery-2.1.1.js"></script> <!--toaster--> <script src="Scripts/toastr.js"></script> <!--angular--> <script src="Scripts/angular.js"></script> <script src="Scripts/angular-resource.js"></script> <script src="Scripts/AngularUI/ui-router.js"></script> <!--my scripts--> <script src="Angular/StockModule.js"></script> <script src="Angular/NotificationFactory.js"></script> <script src="Angular/KnownItems/KnownItemsController.js"></script> <script src="Angular/KnownItems/KnownItemsFactory.js"></script> </head> <!--Angular APP container (ng-app)--> <body class="container" ng-cloak> <div class="navbar"> <div> <a href="#">Angular JS Crud Grid</a> <p>This is a running demo of the AngularJS CRUD Grid I describe in my blog:</p> <a href="http://www.softwarejuancarlos.com">softwarejuancarlos.com</a> </div> </div> <!--The grid will be injected here--> <div ui-view> </div> </body> </html>
Last but not least, the AngularJS code. The angular files:
- The angular module (AKA app) definition.
- The notifications factory to encapsulate the usage of toastr.
- The items factory to encapsulate all REST calls to the server using ng-resource.
- The items controller
Angular module/app definition:
var stockModule = angular.module('stockModule', ['ui.router','ngResource']); stockModule.config(function ($stateProvider, $urlRouterProvider) { // For any unmatched URL, redirect to stock $urlRouterProvider.otherwise("/knownItems"); $stateProvider // Known items .state('knownItems', { url: "/knownItems", templateUrl: "partials/knownItems.html", controller: "knownItemsController" }); });
Notification factory (toastr):
stockModule.factory('notificationFactory', function () { toastr.options = { "showDuration": "100", "hideDuration": "100", "timeOut": "2000", "extendedTimeOut": "5000", } return { success: function (text) { if (text === undefined) { text = ''; } toastr.success("Success. " + text); }, error: function (text) { if (text === undefined) { text = ''; } toastr.error("Error. " + text); }, }; });
Items factory (ng-Resource):
// Repository stockModule.factory("knownItemsFactory", function ($resource) { return $resource('/api/KnownItems/:id', { // default URL params // @ Indicates that the value should be obtained from a data property id: '@Id' }, { // add update to actions (is not defined by default) 'update': { method: 'PUT' } }); });
Angular controller, this is the most interesting file, I tried to add a lot of comments here:
stockModule.controller("knownItemsController", function ($scope, knownItemsFactory, notificationFactory) { // PRIVATE FUNCTIONS var requestSuccess = function () { notificationFactory.success(); } var requestError = function () { notificationFactory.error(); } var isNameDuplicated = function (itemName) { return $scope.knownItems.some(function (entry) { return entry.name.toUpperCase() == itemName.toUpperCase(); }); }; var isDirty = function(item) { return item.name != item.serverName; } // PUBLIC PROPERTIES // all the items $scope.knownItems = []; // the item being added $scope.newItem = { name: '' }; // indicates if the view is being loaded $scope.loading = false; // indicates if the view is in add mode $scope.addMode = false; // PUBLIC FUNCTIONS // Toggle the grid between add and normal mode $scope.toggleAddMode = function () { $scope.addMode = !$scope.addMode; // Default new item name is empty $scope.newItem.name = ''; }; // Toggle an item between normal and edit mode $scope.toggleEditMode = function (item) { // Toggle item.editMode = !item.editMode; // if item is not in edit mode anymore if (!item.editMode) { // Restore name item.name = item.serverName; } else { // save server name to restore it if the user cancel edition item.serverName = item.name; // Set edit mode = false and restore the name for the rest of items in edit mode // (there should be only one) $scope.knownItems.forEach(function (i) { // item is not the item being edited now and it is in edit mode if (item.id != i.id && i.editMode) { // Restore name i.name = i.serverName; i.editMode = false; } }); } }; // Creates the 'newItem' on the server $scope.createItem = function () { // Check if the item is already on the list var duplicated = isNameDuplicated($scope.newItem.name); if (!duplicated) { knownItemsFactory.save($scope.newItem, // success response function (createdItem) { // Add at the first position $scope.knownItems.unshift(createdItem); $scope.toggleAddMode(); requestSuccess(); }, requestError); } else { notificationFactory.error("The item already exists."); } } // Gets an item from the server using the id $scope.readItem = function (itemId) { knownItemsFactory.get({ id: itemId }, requestSuccess, requestError); } // Updates an item $scope.updateItem = function (item) { item.editMode = false; // Only update if there are changes if (isDirty(item)) { knownItemsFactory.update({ id: item.id }, item, function (success) { requestSuccess(); }, requestError); } } // Deletes an item $scope.deleteItem = function (item) { knownItemsFactory.delete({ id: item.id }, item, function (success) { requestSuccess(); // Remove from scope var index = $scope.knownItems.indexOf(item); $scope.knownItems.splice(index, 1); }, requestError); } // Get all items from the server $scope.getAllItems = function () { $scope.loading = true; $scope.knownItems = knownItemsFactory.query(function (success) { $scope.loading = false; }, requestError); }; // In edit mode, if user press ENTER, update item $scope.updateOnEnter = function (item, args) { // if key is enter if (args.keyCode == 13) { $scope.updateItem(item); // remove focus args.target.blur(); } }; // In add mode, if user press ENTER, add item $scope.saveOnEnter = function (item, args) { // if key is enter if (args.keyCode == 13) { $scope.createItem(); // remove focus args.target.blur(); } }; // LOADS ALL ITEMS $scope.getAllItems(); });
What’s next?:
- Add a confirmation dialog before deleting an item.
- Add the AngularJS Crud grid to a directive.
- Dynamic columns.
- Allow columns ordering.
Please leave a comment!
Your feedback is important…