Manage tables data with AngularJS Part 2: nested directives

With the Angular directives you can build reusable components, witch can accept parameters and scope variables, and witch can have customized behaviours.

It can be nested and communicate between them with functions and parameters.

We can using the directives for implement a dynamic HTML table grid, with dynamics rows and columns.

Grid model

In order to display the content, a class model is needed.

This model need to have the list of columns and the row elements:

import models = AngularTablesDataManagerApp.Models;

export class Grid {
Title: string;
Columns: Array<MetadataProperty>;
Rows: Array<Row>;
}

export class Row {
Entity: models.IEntity;
Name: string;
Properties: Array<RowProperty>;

constructor(entity: models.IEntity, name: string, datas: Array<RowProperty>) {
this.Entity = entity;
this.Name = name;
this.Properties = datas;
}
}

export class MetadataProperty {
Name: string;
Type: string;
Nullable: boolean;
}

export class RowProperty {
Name: string;
Value: string;
Nullable: boolean;

constructor(name: string, value: string, nullable: boolean) {
this.Name = name;
this.Value = value;
this.Nullable = nullable;
}
}

A column is of type MetadataProperty, and have a name, a type and a nullable property.

A row is composed by the entity binded to the row, the name of the entity and an array of RowProperty; these properties contains the values of the fields, witch will be displayed in the grid.

Grid directive

The next step is the main Grid directive; the first element to define is the scope, that have the list, some other properties and the definition of the CRUD methods:

interface IGridItem {
item: models.Row;
}

interface IGridDirectiveScope extends ng.IScope {
list: models.Grid;
item: models.Row;
rowModel: models.Row;
newItem: boolean;

New(): void;
Save(item: IGridItem): void;
Delete(item: IGridItem): void;
Close(): void;
}

The implementation of the CRUD methods is in the grid directive controller:

class GridController {
$scope: IGridDirectiveScope;

constructor($scope: IGridDirectiveScope) {
this.$scope = $scope;
}

public Edit(item: models.Row) {
this.$scope.item = item;
this.$scope.newItem = false;
}

public New() {
this.$scope.item = new models.Row(angular.copy(this.$scope.rowModel.Entity), this.$scope.rowModel.Name, angular.copy(this.$scope.rowModel.Properties));
this.$scope.newItem = true;
}

public Save(item: models.Row) {
var obj: IGridItem = { item: item };

this.$scope.Save(obj);
}

public Delete(item: models.Row) {
this.$scope.item = null;
var obj: IGridItem = { item: item };

this.$scope.Delete(obj);
}

public Close() {
if (!this.$scope.newItem) {
for (var i = 0; i < this.$scope.item.Properties.length; i++) {
this.$scope.item.Properties[i].Value = (<any>this.$scope.item.Entity)[this.$scope.item.Properties[i].Name];
}
}

this.$scope.item = null;
}
}

export class GridDirective implements ng.IDirective {
public restrict = 'E';
public templateUrl = 'app/directives/grid.html';
public scope = {
list: '=',
rowModel: '=',
order: '@order',
New: '&new',
Save: '&save',
Delete: '&delete'
};
public controller = GridController;
}

The grid directive scope accept the list of the objects to show, the row model and the order of the grid; it also accept the references of the CRUD methods, because we’ll need to add the business logic; the & character means that we want to pass a parameter to the expression defined in the attribute.

In our case, we want to pass the object selected to the function.

Nested directives

The grid directive will hold the other directives necessary to render the grid:

interface IGridListDirectiveScope extends ng.IScope {
gridItem: models.Row;
New(): void;
}

export class GridListDirective implements ng.IDirective {
public require = '^grid';
public restrict = 'E';
public templateUrl = 'app/directives/gridList.html';
public scope = {
list: '=',
order: '='
};
public link = (scope: IGridListDirectiveScope, element: ng.IAugmentedJQuery, attrs: IArguments, gridCtrl: GridController) => {
scope.New = () => {
gridCtrl.New();
}
}
}

export class GridColumnDirective implements ng.IDirective {
public restrict = 'A';
public templateUrl = 'app/directives/gridColumn.html';
public scope = {
gridColumn: '='
};
}

interface IGridRowDirectiveScope extends ng.IScope {
gridRow: models.Row;
Edit(): void;
}

export class GridRowDirective implements ng.IDirective {
public require = '^grid';
public restrict = 'A';
public templateUrl = 'app/directives/gridRow.html';
public scope = {
gridRow: '='
};
public link = (scope: IGridRowDirectiveScope, element: ng.IAugmentedJQuery, attrs: IArguments, gridCtrl: GridController) => {
scope.Edit = () => {
gridCtrl.Edit(scope.gridRow);
}
}
}

export class GridCellDirective implements ng.IDirective {
public restrict = 'A';
public templateUrl = 'app/directives/gridCell.html';
public scope = {
gridCell: '='
};
}

interface IGridItemDirectiveScope extends ng.IScope {
item: models.Row;
metadata: Array<models.MetadataProperty>;
isNew: boolean;

GetMetadataProperty(Name: string): models.MetadataProperty;
Save(): void;
Delete(): void;
Close(): void;
}

export class GridItemDirective implements ng.IDirective {
$filter: ng.IFilterService;
public require = '^grid';
public restrict = 'E';
public templateUrl = 'app/directives/gridItem.html';
public scope = {
item: '=',
metadata: '=',
isNew: '='
};
public link = (scope: IGridItemDirectiveScope, element: ng.IAugmentedJQuery, attrs: IArguments, gridCtrl: GridController) => {
scope.Save = () => {
gridCtrl.Save(scope.item);
}

scope.Delete = () => {
gridCtrl.Delete(scope.item);
}

scope.Close = () => {
gridCtrl.Close();
}

scope.GetMetadataProperty = (Name: string) => {
return this.$filter('filter')(scope.metadata, { 'Name': Name })[0];
}
}

public constructor($filter: ng.IFilterService) {
this.$filter = $filter;
}
}

The require attribute with ^ character means that the directive searches for the controller on its own element or its parent.

So, in the link function of the directive you can call a function in the controller of the directive specified in the require attribute.

Grid directives views

The implementation of the views is simple; the first view is the grid:

<div ng-show="item == null">
<grid-list list="list" order="order"></grid-list>
</div>
<div ng-show="item != null">
<grid-item item="item" metadata="list.Columns" is-new="newItem"></grid-item>
</div>

The GridList view contains the columns and the rows of the grid:

<div class="row bootstrap-admin-no-edges-padding" style="margin-top: 10px;">
<div class="col-md-12">
<input type="button" id="btn-new" value="New" class="btn btn-primary" ng-click="New()" />
</div>
</div>
<div class="row bootstrap-admin-no-edges-padding" style="margin-top: 10px;">
<div class="col-md-12">
<div class="panel panel-default">
<div class="panel-heading">
<div class="text-muted bootstrap-admin-box-title">
{{list.Title}}
</div>
</div>
<div class="panel-body">
<table class="table table-hover">
<thead>
<tr>
<th ng-repeat="column in list.Columns" grid-column="column"></th>
</tr>
</thead>
<tbody>
<tr ng-repeat="row in list.Rows | orderBy: 'Entity.' + order" grid-row="row"></tr>
</tbody>
</table>
</div>
</div>
</div>
</div>

GridColumn, GridRow and GridCell compose the table structure; the GridItem view is the detail of the row:

<div class="row bootstrap-admin-no-edges-padding" style="margin-top: 10px;">
<div class="col-md-12">
<input type="button" id="btn-save" value="Save" class="btn btn-primary" ng-click="Save()" ng-disabled="form.$invalid" />
<input type="button" id="btn-delete" value="Delete" class="btn btn-danger" ng-click="Delete()" ng-show="!isNew" />
<input type="button" id="btn-close" value="Close" class="btn btn-default" ng-click="Close()" />
</div>
</div>
<div class="row bootstrap-admin-no-edges-padding" style="margin-top: 10px;">
<div class="col-md-12">
<form name="form" role="form">
<div class="panel panel-default">
<div class="panel-heading">
<div class="text-muted bootstrap-admin-box-title">
{{item.Name}}
</div>
</div>
<div class="panel-body">
<div class="form-group" ng-repeat="property in item.Properties">
<label for="{{property.Name}}">{{property.Name}}:</label>
<input field tipology="GetMetadataProperty(property.Name)" id="{{property.Name}}" class="form-control" ng-model="property.Value" ng-required="!property.Nullable" />
</div>
</div>
<div class="col-md-2"></div>
</div>
</form>
</div>
</div>

Application

To use the directive you need to load the model, the rows and define the CRUD methods:

import ngr = ng.resource;
import commons = AngularTablesDataManagerApp.Commons;
import models = AngularTablesDataManagerApp.Models;
import services = AngularTablesDataManagerApp.Services;

export class CitiesController {
grid: models.Grid;
rowModel: models.Row;
toaster: ngtoaster.IToasterService;

private citiesService: services.CitiesService;
private constant: commons.Constants;

constructor(toaster: ngtoaster.IToasterService, CitiesService: services.CitiesService) {
this.citiesService = CitiesService;
this.constant = commons.Constants;
this.toaster = toaster;

this.grid = new models.Grid();
this.grid.Title = 'Cities';
this.Load();
}

private Load() {
var columns: Array<string> = new Array<string>('Name');
var vm = this;

this.citiesService.getMetadata(columns).then((data) => {
vm.grid.Columns = data;
vm.rowModel = this.citiesService.createGridData(data);

this.citiesService.getGridData(data).then((data) => {
vm.grid.Rows = data;
vm.toaster.success('Cities loaded successfully.');
return;
}, (error) => {
vm.toaster.error('Error loading cities', error.message);
});

}, (error) => {
vm.toaster.error('Error loading cities metadata', error.data.message);
});
}

public Save(item: models.Row) {
var vm = this;
var isNew: boolean = false;

if (item.Entity.Id == commons.Constants.GuidEmpty)
isNew = true;

this.citiesService.saveGridData(item).then((data: models.Row) => {
if (isNew)
vm.grid.Rows.push(data);

this.toaster.success("City saved successfully.");
}, (error: any) => {
this.toaster.error("Error saving city", error.data.message);
});
}

public Delete(item: models.Row) {
var vm = this;
this.citiesService.deleteGridData(item).then((data: any) => {
var index = vm.grid.Rows.indexOf(item);
vm.grid.Rows.splice(index, 1);

this.toaster.success("City deleted successfully.");
}, (error: any) => {
this.toaster.error("Error deleting city", error.data.message);
});
}
}

The final step is the view:

<grid list="vm.grid" row-model="vm.rowModel" order="Name" save="vm.Save(item)" delete="vm.Delete(item)"></grid>

 

You can find the source code here.

 

 

Manage tables data with AngularJS Part 2: nested directives

Manage tables data with AngularJS Part 1: tables metadata

During the deployment of an AngularJS app, we often develop controllers and views to manage data of basic tables,  such as zip, city, country and so on.

We need to offer to the users the CRUD operations of these tables; the functionalities and the structure of these controllers/view are very similar and you need to implement a lot of similar code to do that.

What you can do is implement the AngularJS directives that are able to show the content of basic tables and manage the CRUD operations.

The start point is a simple AngularJS SPA that consume ASP.NET Web API; the application using Entity Framework 6 code first and the only entity is city.

OData controller

Once the table is configured, what we need to do is implement an OData controller that will expose the CRUD operations of the Cities table and the metadata.

For this example we implement and endpoint that support OData Version 3:

public class CitiesController : ODataController
{
private Context db = new Context();

// GET: odata/Cities
[EnableQuery]
public IQueryable<City> GetCities()
{
return db.Cities;
}

// GET: odata/Cities(5)
[EnableQuery]
public SingleResult<City> GetCity([FromODataUri] Guid key)
{
return SingleResult.Create(db.Cities.Where(city => city.Id == key));
}

// PUT: odata/Cities(5)
public async Task<IHttpActionResult> Put([FromODataUri] Guid key, Delta<City> patch)
{
Validate(patch.GetEntity());

if (!ModelState.IsValid)
{
return BadRequest(ModelState);
}

City city = await db.Cities.FindAsync(key);
if (city == null)
{
return NotFound();
}

patch.Put(city);

try
{
await db.SaveChangesAsync();
}
catch (DbUpdateConcurrencyException)
{
if (!CityExists(key))
{
return NotFound();
}
else
{
throw;
}
}

return Updated(city);
}

// POST: odata/Cities
public async Task<IHttpActionResult> Post(City city)
{
if (!ModelState.IsValid)
{
return BadRequest(ModelState);
}

db.Cities.Add(city);

try
{
await db.SaveChangesAsync();
}
catch (DbUpdateException)
{
if (CityExists(city.Id))
{
return Conflict();
}
else
{
throw;
}
}

return Created(city);
}

// PATCH: odata/Cities(5)
[AcceptVerbs("PATCH", "MERGE")]
public async Task<IHttpActionResult> Patch([FromODataUri] Guid key, Delta<City> patch)
{
Validate(patch.GetEntity());

if (!ModelState.IsValid)
{
return BadRequest(ModelState);
}

City city = await db.Cities.FindAsync(key);
if (city == null)
{
return NotFound();
}

patch.Patch(city);

try
{
await db.SaveChangesAsync();
}
catch (DbUpdateConcurrencyException)
{
if (!CityExists(key))
{
return NotFound();
}
else
{
throw;
}
}

return Updated(city);
}

// DELETE: odata/Cities(5)
public async Task<IHttpActionResult> Delete([FromODataUri] Guid key)
{
City city = await db.Cities.FindAsync(key);
if (city == null)
{
return NotFound();
}

db.Cities.Remove(city);
await db.SaveChangesAsync();

return StatusCode(HttpStatusCode.NoContent);
}

protected override void Dispose(bool disposing)
{
if (disposing)
{
db.Dispose();
}
base.Dispose(disposing);
}

private bool CityExists(Guid key)
{
return db.Cities.Count(e => e.Id == key) > 0;
}
}

Then, you need to configure the OData Endpoint in the WebApiConfig.cs:

public static class WebApiConfig
{
public static void Register(HttpConfiguration config)
{
ODataConventionModelBuilder builder = new ODataConventionModelBuilder();
builder.EntitySet<City>("Cities");
config.Routes.MapODataServiceRoute("odata", "odata", builder.GetEdmModel());
}
}

OData controller is helpful to retrieve the metadata and you can using $metadata keyword:

http://host_name/odata/$metadata

We’ll receive a response like this:

<?xml version="1.0" encoding="utf-8"?>
<edmx:Edmx Version="1.0" xmlns:edmx="http://schemas.microsoft.com/ado/2007/06/edmx">
<edmx:DataServices m:DataServiceVersion="3.0" m:MaxDataServiceVersion="3.0" xmlns:m="http://schemas.microsoft.com/ado/2007/08/dataservices/metadata">
<Schema Namespace="AngularTablesDataManager.DataLayer" xmlns="http://schemas.microsoft.com/ado/2009/11/edm">
<EntityType Name="City">
<Key>
<PropertyRef Name="Id" />
</Key>
<Property Name="Id" Type="Edm.Guid" Nullable="false" />
<Property Name="Name" Type="Edm.String" />
</EntityType>
<EntityType Name="Zip">
<Key>
<PropertyRef Name="Id" />
</Key>
<Property Name="Id" Type="Edm.Guid" Nullable="false" />
<Property Name="Code" Type="Edm.Int16" Nullable="false" />
</EntityType>
</Schema>
<Schema Namespace="Default" xmlns="http://schemas.microsoft.com/ado/2009/11/edm">
<EntityContainer Name="Container" m:IsDefaultEntityContainer="true">
<EntitySet Name="Cities" EntityType="AngularTablesDataManager.DataLayer.City" />
<EntitySet Name="Zips" EntityType="AngularTablesDataManager.DataLayer.Zip" />
</EntityContainer>
</Schema>
</edmx:DataServices>
</edmx:Edmx>

AngularJS service

The next step is the implementation of the AngularJS service that parse the metadata; first of all, a class that define the structure of metadata is required:

export class MetadataProperty {
Name: string;
Type: string;
Nullable: boolean;
}

Then, the MetadataService service:

export class MetadataService {
private $http: ng.IHttpService;
private $q: ng.IQService;

constructor($http: ng.IHttpService, $q: ng.IQService) {
this.$http = $http;
this.$q = $q;
}

public getMetadata(entityName: string): ng.IPromise<Array<models.MetadataProperty>> {
var defer: ng.IDeferred<any> = this.$q.defer();

var req = {
method: 'GET',
url: '/odata/$metadata'
};

this.$http(req).then(function (result) {
var data: string = result.data.toString();
var xmlDoc: XMLDocument = $.parseXML(data);
var xml: JQuery = $(xmlDoc);
var properties: Array<models.MetadataProperty> = new Array<models.MetadataProperty>();

xml.find('EntityType').find('Property').each(function () {
var metadataProperty: models.MetadataProperty = new models.MetadataProperty();
metadataProperty.Name = $(this).attr('Name');
metadataProperty.Type = $(this).attr('Type');
metadataProperty.Nullable = ($(this).attr('Nullable') != null) && ($(this).attr('Nullable').toLowerCase() == 'true');

properties.push(metadataProperty);
});

return defer.resolve(properties);
});

return defer.promise;
}

static factory() {
return ($http: ng.IHttpService, $q: ng.IQService) => new MetadataService($http, $q);
}
}

To consume the OData Controller you can use the $resource factory, that allows to perform CRUD operations easily, and extend the service with an additional method called getMetadata:

const entityName: string = 'Cities';
import ngr = ng.resource;
import commons = AngularTablesDataManagerApp.Commons;
import models = AngularTablesDataManagerApp.Models;
import services = AngularTablesDataManagerApp.Services;

export interface ICitiesResourceClass extends ngr.IResourceClass<ngr.IResource<models.ICity>> {
create(order: models.ICity): ngr.IResource<models.ICity>;
}

export class CitiesService implements models.IService {
private resource: ICitiesResourceClass;
private $q: ng.IQService;
private metadataService: services.MetadataService;

constructor($resource: ngr.IResourceService, $q: ng.IQService, MetadataService: services.MetadataService) {
this.$q = $q;
this.metadataService = MetadataService;

this.resource = <ICitiesResourceClass>$resource('/odata/' + entityName + '/:id', { id: '@Id' }, {
get: { method: "GET" },
create: { method: "POST" },
save: { method: "PUT" },
query: { method: "GET", isArray: false },
delete: { method: "DELETE" }
});
}

public create(order: models.ICity) {
return this.resource.create(order);
}

public save(order: models.ICity) {
if (order.Id == commons.Constants.GuidEmpty) {
return this.resource.create(order);
}
else {
return this.resource.save(order);
}
}

public delete(order: models.ICity) {
return this.resource.remove(order);
}

public getAll() {
var datas: ngr.IResourceArray<ngr.IResource<models.ICity>>
var defer: ng.IDeferred<any> = this.$q.defer();

this.resource.query().$promise.then((data: any) => {
datas = data["value"];

return defer.resolve(datas);
}, (error) => {
return defer.reject(datas);
});

return defer.promise;
}

public getMetadata(): ng.IPromise<Array<models.MetadataProperty>> {
return this.metadataService.getMetadata(entityName);
}

static factory() {
return (r: ngr.IResourceService, $q: ng.IQService, MetadataService: services.MetadataService) => new CitiesService(r, $q, MetadataService);
}
}

This method can be called from a controller; we’ll obtain an array of MetadataProperty objects that represents the structure of the Cities table.

import ngr = ng.resource;
import commons = AngularTablesDataManagerApp.Commons;
import models = AngularTablesDataManagerApp.Models;
import services = AngularTablesDataManagerApp.Services;

export class CitiesController {
cities: ngr.IResourceArray<ngr.IResource<models.ICity>>;
city: models.ICity;
title: string;
toaster: ngtoaster.IToasterService;

private citiesService: services.CitiesService;
private constant: commons.Constants;
private metadataProperties: Array<models.MetadataProperty>;

constructor(toaster: ngtoaster.IToasterService, CitiesService: services.CitiesService) {
this.citiesService = CitiesService;
this.constant = commons.Constants;

this.toaster = toaster;
this.title = 'Cities';

this.Load();
}

private Load() {
this.citiesService.getMetadata().then((data) => {
this.metadataProperties = data;
}, (error) => {
this.toaster.error('Error loading cities medatad', error.data.message);
});

this.citiesService.getAll().then((data) => {
this.cities = data;
this.toaster.success('Cities loaded successfully.');
return;
}, (error) => {
this.toaster.error('Error loading cities', error.message);
});
}
}

AngularTablesDataManager.module.controller('CitiesController', CitiesController);

 

You can find the source code here.

 

Manage tables data with AngularJS Part 1: tables metadata