AngularJS – CRUD Data Grid II

The goal of this post is to extend the AngularJS CRUD Grid that we created in a previous post:  AngularJS – CRUD Grid I 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.

In that post we wrote a single page application (SPA) to display a list of items inside a grid. The grid allows all the CRUD operations: CreateRead, Update and Delete items. We will add now the following requirements:

  • # 1 Confirmation dialog before deleting an item.
  • # 2 Column ordering.
  • # 3 CRUD Grid as a directive
    • # 3.1 Dynamic columns generation.
    • # 3.2 Column options: Type, visibility, header, mandatory.
  • # 4 Cell editor directive, text and date mode. Use AngularJS date picker in date format columns

Results

You can see a demo here: AngularJS CRUD Grid II demo. I will share the interesting code of the application in this post and one of my next steps will be to upload all the code to GitHub.

Running application

Angular JS CRUD Grid. Dynamic columns

Delete confirmation

Angular JS CRUD Grid. Delete confirmation

Date picker

Angular JS CRUD Grid. Date picker.

Implementation

Server side

To do all the implementation we will use the same infrastructure we have used for the previous post at the server side. We will only add a couple of additional properties to test the dynamic column generation:

  • Description: String not required
  • Expire: DateTime not required
public class Item
   {
       [Key]
       public int Id { getset; }
 
       [MaxLength(200)] 
       [Index(IsUnique = true)]
       public string Name { getset; }
 
       public string Description { getset; }
 
       [DataType(DataType.Date)]
       public DateTime? Expire { getset; }
   }

# 1 Confirmation dialog before deleting an item.

I use $modal from UI Bootstrap AngularJS. image I created a Modal Window Factory with a ‘show’ method. This method has four parameters:

  • title: Window’s title.
  • msg: A message to show in the window’s body.
  • confirmCallback: A callback to execute when the user click “OK”.
  • cancelCallback: A callback to execute when the user click “Cancel”.

The crud grid controller use this factory when the user want to delete an item:

function _deleteItem(item) {
 
    var title = "Delete '" + item.name + "'";
    var msg = "Are you sure you want to remove this item?";
    var confirmCallback = function () {...};
 
    modalWindowFactory.show(title, msg, confirmCallback);
};

This is the factory:

stockModule.factory('modalWindowFactory'function ($modal) {
 
    var modalWindowController = _modalWindowController;
 
    return {
 
        // Show a modal window with the specified title and msg
        show: function (title, msg, confirmCallback, cancelCallback) {
 
            // Show window
            var modalInstance = $modal.open({
                templateUrl: 'partials/modalWindow.html',
                controller: modalWindowController,
                size: 'sm',
                resolve: {
                    title: function () {
                        return title;
                    },
                    body: function () {
                        return msg;
                    }
                }
            });
 
            // Register confirm and cancel callbacks
            modalInstance.result.then(
                // if any, execute confirm callback
                function() {
                    if (confirmCallback != undefined) {
                        confirmCallback();
                    }
                },
                // if any, execute cancel callback
                function () {
                    if (cancelCallback != undefined) {
                        cancelCallback();
                    }
                });
        }
    };
 
 
    // Internal controller used by the modal window
    function _modalWindowController($scope, $modalInstance, title, body) {
        $scope.title = "";
        $scope.body = "";
 
        // If specified, fill window title and message with parameters
        if (title) {
            $scope.title = title;
        }
        if (body) {
            $scope.body = body;
        }
 
        $scope.confirm = function () {
            $modalInstance.close();
        };
 
        $scope.cancel = function () {
            $modalInstance.dismiss();
        };
    };
 
 
});

The UI:

<div>
    <!--Header-->
    <div class="modal-header">
        <h3 class="modal-title">{{title}}</h3>
    </div>
    <!--Body-->
    <div class="modal-body">
        <p>{{body}}</p>
    </div>
    <!--OK and Cancel buttons-->
    <div class="modal-footer">
        <button class="btn btn-success" ng-click="confirm()" style="width100px;">
            <i class="glyphicon glyphicon-ok"></i>
            OK
        </button>
        <button class="btn btn-danger" ng-click="cancel()" style="width100px;">
            <i class="glyphicon glyphicon-remove"></i>
            Cancel
        </button>
    </div>
</div>

# 2 Columns ordering

This portion of code does the ordering selection:

<!-- Content headers-->
              <th ng-repeat="column in itemsCtrl.columnsDefinition"
                  ng-hide="column.hidden"
                  style="vertical-align:middlecursorpointer">
                  <div ng-click="itemsCtrl.setOrderByColumn(column.binding)">
                      {{column.header}}
                      <i class="glyphicon"
                         ng-class="{'glyphicon glyphicon-arrow-up': !itemsCtrl.orderByReverse, 'glyphicon glyphicon-arrow-down': itemsCtrl.orderByReverse}"
                         ng-show="itemsCtrl.orderByColumn == column.binding"></i>
                  </div>
              </th>

When we click the header, it calls the controller with the column that was clicked has parameter to set it as active ordering column. The controller also checks if the column being ordered is the same, in this case it reverses the ordering direction:

function _setOrderByColumn(column) {
    if (self.orderByColumn == column) {
        // change order
        self.orderByReverse = !self.orderByReverse;
    } else {
        // order using new column
        self.orderByColumn = column;
        self.orderByReverse = false;
    }
}

To finish this requirement we add the ordering configuration to the ng-repeat that print a line for each item:

<tr ng-repeat="knownItem in itemsCtrl.allItems | orderBy:itemsCtrl.orderByColumn: itemsCtrl.orderByReverse">

# 3 CRUD Grid as a Directive

To use our data grid in different places we need it as a directive. This directive will also take care of dynamic columns generation. In our example we want to generate a column not only for the item name but also for the description and expire property. This is how I would like to use the grid:

  • I add the ‘crud-grid’ directive to the item where I want the grid.
  • I define the address where the REST service should access my WebAPI.
  • I add a column definition for each item property I want to see on my grid.

Each column definition can have this options:

  • Binding: The property of the received JSON that this column will display
  • Header: The header of this column
  • Type: The type of this column, currently text or date.
  • Required: If true this column cannot be empty before saving changes.
  • Hidden: If true this column is hidden. Used for example to add the id during implementation.

Sample of usage:

<div class="row">
    <div crud-grid
         server-url='/api/KnownItems'
         columns-definition='[
         {
            "binding"       :"id",
            "type"          :"text",
            "required"      :"true",
            "hidden"        :"true"
         },
         {
            "binding"       :"name",
            "header"        :"Name",
            "type"          :"text",
            "required"      :"true"
         },
         {
            "binding"       :"description",
            "header"        :"Description",
            "type"          :"text",
            "required"      :"false"
         },
         {
            "binding"       :"expire",
            "header"        :"Expire Date",
            "type"          :"date",
            "required"      :"false"
         }
         ]'>
    </div>
</div>

We will divide the implementation in 4 files:

  • Directive definition
  • Controller
  • Items Factory
  • View

Directive definition

stockModule.directive('crudGrid'function () {
	return {
		//	'A' - only matches attribute name
		//	'E' - only matches element name
		//	'C' - only matches class name
		restrict: 'A',
		// Don't replace the element that contains the attribute
		replace: false,
		// scope = false, parent scope
		// scope = true, get new scope
		// scope = {..}, isolated scope
		scope: true,
		// view
		templateUrl: '/app/directives/crud.grid/crud.grid.view.html',
		// controller
		controller: "crudgridController as itemsCtrl"
	}
});

Controller

stockModule.controller("crudgridController", itemsController);
 
function itemsController($element, $attrs, ajaxServiceFactory, notificationsFactory, modalWindowFactory) {
 
    'use strict';
    var self = this;
 
    //// ---------------- PUBLIC -----------------
 
    //// PUBLIC fields
 
    // Columns he grid should display
    self.columnsDefinition = [];
 
    // All items
    self.allItems = [];
 
    // The item being added
    self.newItem = {};
 
    // Indicates if the view is being loaded
    self.loading = false;
 
    // Indicates if the view is in add mode
    self.addMode = false;
 
    // The column used for ordering
    self.orderByColumn = '';
 
    // Indicates if the ordering is reversed or not
    self.orderByReverse = false;
 
    //// PUBLIC Methods
 
    // Initialize module
    self.initialize = _initialize;
 
    // Toggle the grid between add and normal mode
    self.toggleAddMode = _toggleAddMode;
 
    // Toggle an item between normal and edit mode
    self.toggleEditMode = _toggleEditMode;
 
    // Creates the 'newItem' on the server
    self.createItem = _createItem;
 
    // Gets an item from the server using the id
    self.readItem = _readItem;
 
    // Updates an item
    self.updateItem = _updateItem;
 
    // Deletes an item
    self.deleteItem = _deleteItem;
 
    // Get all items from the server
    self.getAllItems = _getAllItems;
 
    // In edit mode, if user press ENTER, update item
    self.updateModeKeyUp = _updateModeKeyUp;
 
    // In add mode, if user press ENTER, add item
    self.createModeKeyUp = _createModeKeyUp;
 
    // Set the order by column and order
    self.setOrderByColumn = _setOrderByColumn;
 
    //// ---------------- CODE TO RUN ------------
 
    self.initialize();
 
    //// ---------------- PRIVATE ----------------
 
    //// PRIVATE fields
 
    var _itemsService;
 
    //// PRIVATE Functions - Public Methods Implementation
 
    function _initialize() {
        // create a service to do the communication with the server
        _itemsService = ajaxServiceFactory.getService($attrs.serverUrl);
 
        // configured columns
        self.columnsDefinition = angular.fromJson($attrs.columnsDefinition);
 
        // Initialize
        self.getAllItems();
    }
 
    function _toggleAddMode() {
        self.addMode = !self.addMode;
 
        // Empty new item
        self.newItem = {};
 
        // Set an default id or the validation will crash
        self.newItem.id = 0;
        self.newItem.hasErrors = !_isValid(self.newItem);
    };
 
    function _toggleEditMode(item) {
        // Toggle
        item.editMode = !item.editMode;
 
        // if item is not in edit mode anymore
        if (!item.editMode) {
            // Undo changes
            _restoreServerValues(item);
        } else {
            // save server name to restore it if the user cancel edition
            item.serverValues = angular.toJson(item);
 
            // Set edit mode = false and restore the name for the rest of items in edit mode 
            // (there should be only one)
            self.allItems.forEach(function (i) {
                // item is not the item being edited now and it is in edit mode
                if (item.id != i.id && i.editMode) {
                    // Save current editing values 
                    self.updateItem(i);
                }
            });
        }
    };
 
    function _createItem(item) {
        if (_isValid(item)) {
            _itemsService.save(item,
                // success response
                function (createdItem) {
                    // Add at the first position
                    self.allItems.unshift(createdItem);
                    self.toggleAddMode();
 
                    _requestSuccess(response);
                },
                // error callback
                function (error) {
                    _requestError(error);
                });
        }
    };
 
    function _readItem(itemId) {
        _itemsService.get({ id: itemId }, _requestSuccess, _requestError);
    };
 
    function _updateItem(item) {
        if (_isValid(item)) {
            item.editMode = false;
 
            // Only update if there are changes
            if (_isDirty(item)) {
                _itemsService.update({ id: item.id }, item, function (response) {
                    // Refresh item with server values
                    _copyItem(response, item);
                    _requestSuccess();
                }, function (error) {
                    _requestError(error);
                    _restoreServerValues(item);
                });
            }
        }
    };
 
    function _deleteItem(item) {
 
        var title = "Delete '" + item.name + "'";
        var msg = "Are you sure you want to remove this item?";
        var confirmCallback = function () {
            return _itemsService.delete(
                // id
                { id: item.id },
                // item 
                item,
                // success callback
                function () {
                    _requestSuccess();
                    // Remove from scope
                    var index = self.allItems.indexOf(item);
                    self.allItems.splice(index, 1);
 
                },
                // error callback
                function (error) {
                    _requestError(error);
                });
        };
 
        modalWindowFactory.show(title, msg, confirmCallback);
    };
 
    function _getAllItems() {
        self.loading = true;
        self.allItems = _itemsService.query(function () {
            // success callback
            self.loading = false;
        },
            // error callback
            function () {
                _requestError("Error loading items.");
            });
    };
 
    function _updateModeKeyUp(args, item) {
        // if key is enter
        if (args.keyCode == 13) {
            // update
            self.updateItem(item);
            // remove focus
            args.target.blur();
        }
 
        // refresh validation
        item.hasErrors = !_isValid(item);
    };
 
    function _createModeKeyUp(args, item) {
        // if key is enter
        if (args.keyCode == 13) {
            // create
            self.createItem(item);
            // remove focus
            args.target.blur();
        }
 
        // refresh validation
        item.hasErrors = !_isValid(item);
    };
 
    function _setOrderByColumn(column) {
        if (self.orderByColumn == column) {
            // change order
            self.orderByReverse = !self.orderByReverse;
        } else {
            // order using new column
            self.orderByColumn = column;
            self.orderByReverse = false;
        }
    }
 
    //// PRIVATE Functions
 
    function _requestSuccess() {
        notificationsFactory.success();
    };
 
    function _requestError(error) {
        notificationsFactory.error(error.statusText);
    };
 
    function _isValid(item) {
        var isValid = true;
 
        // validate all columns
        self.columnsDefinition.forEach(function (column) {
            if (isValid) {
 
                // required validation
                if (column.required == 'true') {
                    isValid = item[column.binding] != undefined;
                }
            }
 
        });
 
        return isValid;
    };
 
    function _isDirty(item) {
        var serverItem = angular.fromJson(item.serverValues);
 
        var isDirty = false;
 
        self.columnsDefinition.forEach(function (column) {
            if (!isDirty && // short circuit if item is dirty
                (item[column.binding] != serverItem[column.binding])) {
                isDirty = true;
            }
        });
 
        return isDirty;
    };
 
    function _restoreServerValues(item) {
 
        var serverItem = angular.fromJson(item.serverValues);
 
        _copyItem(serverItem, item);
        self.columnsDefinition.forEach(function (column) {
            item[column.binding] = serverItem[column.binding];
        });
    }
 
    function _copyItem(itemSource, itemTarget) {
        self.columnsDefinition.forEach(function (column) {
            itemTarget[column.binding] = itemSource[column.binding];
        });
    }
 
};

Items Factory

// Repository 
stockModule.service("ajaxServiceFactory", ajaxServiceFactory);
 
function ajaxServiceFactory($resource) {
    'use strict';
 
    //// PUBLIC METHODS - Definition
 
    this.getService = _getService;
 
    //// PUBLIC METHODS - Implementation
 
    function _getService(endPoint) {
        if (endPoint === '') {
            throw "Invalid end point";
        }
 
        // create resource to make AJAX calls
        return $resource(endPoint + '/:id',
        {
            id: '@Id' // default URL params, '@' Indicates that the value should be obtained from a data property 
        },
        {
            'update': { method: 'PUT' } // add update to actions (is not defined by default)
        });
    }
}

View

<div class="row">
 
    <!--AngularJS SPA CRUD table-->
    <form name="tableForm">
        <table class="table table-condensed table-hover table-striped" ng-hide="itemsCtrl.loading">
            <!-- Header-->
            <tr class="panel-title">
                <!--Buttons column-->
                <th style="width100px;">
                    <!--Add + Cancel-->
                    <div class="btn-toolbar"><i class="btn glyphicon glyphicon-plus" ng-click="itemsCtrl.toggleAddMode()" title="Add" ng-hide="itemsCtrl.addMode"></i></div>
                    <div class="btn-toolbar"><i class="btn glyphicon glyphicon-minus" ng-click="itemsCtrl.toggleAddMode()" title="Cancel" ng-show="itemsCtrl.addMode"></i></div>
                </th>
 
                <!-- Content headers-->
                <th ng-repeat="column in itemsCtrl.columnsDefinition"
                    ng-hide="column.hidden"
                    style="vertical-align:middlecursorpointer">
                    <div ng-click="itemsCtrl.setOrderByColumn(column.binding)">
                        {{column.header}}
                        <i class="glyphicon"
                           ng-class="{'glyphicon glyphicon-arrow-up': !itemsCtrl.orderByReverse, 'glyphicon glyphicon-arrow-down': itemsCtrl.orderByReverse}"
                           ng-show="itemsCtrl.orderByColumn == column.binding"></i>
                    </div>
                </th>
            </tr>
 
            <!--Row with the new item-->
            <tr ng-if="itemsCtrl.addMode" style="backgroundrgb(251, 244, 222)">
 
                <!--New item: Buttons column-->
                <td>
                    <div class="btn-toolbar">
                        <!--Create + Cancel-->
                        <div class="btn-group">
                            <i class="btn glyphicon glyphicon-floppy-disk" ng-click="itemsCtrl.createItem(itemsCtrl.newItem)" title="Create"
                               ng-disabled="itemsCtrl.newItem.hasErrors"></i>
                            <i class="btn glyphicon glyphicon-remove" ng-click="itemsCtrl.toggleAddMode()" title="Cancel"></i>
                        </div>
                    </div>
                </td>
 
                <!-- New item: Content columns-->
                <td ng-repeat="column in itemsCtrl.columnsDefinition" ng-hide="column.hidden" style="vertical-align:middle">
                    
                    <!--Show cell editor control for each column-->
                    <div cell-editor 
                         item="itemsCtrl.newItem"
                         column="column"
                         key-up-event="itemsCtrl.createModeKeyUp">
                    </div>
                </td>
            </tr>
 
            <!--Content-->
            <tr ng-repeat="knownItem in itemsCtrl.allItems | orderBy:itemsCtrl.orderByColumn: itemsCtrl.orderByReverse">
 
                <!--Buttons column-->
                <td class="col-xs-1">
                    <!--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="itemsCtrl.toggleEditMode(knownItem)" title="Edit"></i>
                            <i class="btn glyphicon glyphicon-trash" ng-click="itemsCtrl.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="itemsCtrl.updateItem(knownItem)" title="Save"
                               ng-disabled="knownItem.hasErrors"></i>
                            <i class="btn glyphicon glyphicon-remove" ng-click="itemsCtrl.toggleEditMode(knownItem)" title="Cancel"></i>
                        </div>
                    </div>
                </td>
 
                <!-- Content columns-->
                <td ng-repeat="column in itemsCtrl.columnsDefinition" ng-hide="column.hidden" style="vertical-align:middle" class="col-xs-2">
 
                    <!--Read mode-->
                    <span ng-show="knownItem.editMode == null || knownItem.editMode == false" ng-dblclick="itemsCtrl.toggleEditMode(knownItem)">
                        <!-- Each item as an array property / value, header is the value that this column display-->
                        <span ng-switch="column.type">
                            <!-- Text -->
                            <span ng-switch-default>{{knownItem[column.binding]}}</span>
                            <!-- Date -->
                            <span ng-switch-when="date">{{knownItem[column.binding]| date:'fullDate'}}</span>
                        </span>
                    </span>
 
                    <!--Show cell editor control for each column-->
                    <div ng-show="knownItem.editMode"
                         cell-editor
                         item="knownItem"
                         column="column"
                         key-up-event="itemsCtrl.updateModeKeyUp">
                        <!--updateModeKeyUp has no parenthesis to pass parameters-->
                    </div>
 
                </td>
 
            </tr>
 
        </table>
    </form>
 
    <!--Loading indicator-->
    <img src="/images/loading.gif" ng-show="itemsCtrl.loading" class="center-block" title="Loading..." />
 
</div>

#4 Cell editor directive, text and date mode.

To display an editor for each cell depending on the column type I created a dedicated directive. This directive uses the AngularJS UI Datepicker when the column type is date. This directive has 3 files:

  • Directive definition
  • Controller
  • View

Directive definition

stockModule.directive('cellEditor'function () {
    return {
        //	'A' - only matches attribute name
        //	'E' - only matches element name
        //	'C' - only matches class name
        restrict: 'A',
        // Replace the element that contains the attribute
        replace: true,
        // scope = false, parent scope
        // scope = true, get new scope
        // scope = {...}, isolated scope>
        //		1. "@"   ( Text binding / one-way )
        //      2. "="   ( Model binding / two-way  )
        //      3. "&"   ( Method binding  )
        scope: {
            column: "=",        // object binding
            item: "=",          // object binding
            keyUpEvent: "&",    // method binding
        },
        // view
        templateUrl: '/app/directives/crud.grid/cell.editor/cell.editor.view.html',
        // controller
        controller: "cellEditorController as cellEditorCtrl"
    }
});

Controller

stockModule.controller("cellEditorController", cellEditorController);
 
function cellEditorController($scope) {
 
    'use strict';
    var self = this;
 
    //// ---------------- PUBLIC -----------------
    //// PUBLIC fields
 
    self.keyUpEvent = $scope.keyUpEvent;
    self.column = $scope.column;
    self.item = $scope.item;
 
    self.datePickerOpen = false;
    self.openDatePicker = _openDatePicker;
 
    self.fireKeyUpEvent = _fireKeyUpEvent;
 
    //// PUBLIC Methods
 
 
    //// ---------------- CODE TO RUN ------------
 
 
    //// ---------------- PRIVATE ----------------
 
    //// PRIVATE fields
 
    //// PRIVATE Functions - Public Methods Implementation
    function _fireKeyUpEvent(args, item) {
        // call method with parameters
        self.keyUpEvent()(args, item);
    };
 
    function _openDatePicker($event) {
        $event.preventDefault();
        $event.stopPropagation();
 
        self.datePickerOpen = true;
    };
 
    //// PRIVATE Functions
 
};

View

<div ng-switch="cellEditorCtrl.column.type" >
 
    <!--Text required-->
    <input ng-switch-default type="text" required ng-if="cellEditorCtrl.column.required != 'false'"
           ng-model="cellEditorCtrl.item[cellEditorCtrl.column.binding]"
           ng-keyup="cellEditorCtrl.fireKeyUpEvent($event, cellEditorCtrl.item)" class="form-control" />
 
    <!--Text not required-->
    <input ng-switch-default type="text" ng-if="cellEditorCtrl.column.required != 'true'"
           ng-model="cellEditorCtrl.item[cellEditorCtrl.column.binding]"
           ng-keyup="cellEditorCtrl.fireKeyUpEvent($event, cellEditorCtrl.item)" class="form-control" />
 
    <!--Date required-->
    <span class="input-group" ng-switch-when="date" ng-if="cellEditorCtrl.column.required == 'false'">
 
        <input type="text"
               class="form-control"
               datepicker-popup="fullDate"
               ng-model="cellEditorCtrl.item[cellEditorCtrl.column.binding]"
               is-open="cellEditorCtrl.datePickerOpen"
               ng-keyup="cellEditorCtrl.fireKeyUpEvent($event, cellEditorCtrl.item)" />
 
        <span class="input-group-btn">
            <button type="button" class="btn btn-default" ng-click="cellEditorCtrl.openDatePicker($event)">
                <i class="glyphicon glyphicon-calendar"></i>
            </button>
        </span>
 
    </span>
 
    <!--Date not required-->
    <span class="input-group" ng-switch-when="date" ng-if="cellEditorCtrl.column.required == 'true'">
 
        <input type="text"
               class="form-control"
               datepicker-popup="fullDate"
               ng-model="cellEditorCtrl.item[cellEditorCtrl.column.binding]"
               is-open="cellEditorCtrl.datePickerOpen"
               ng-keyup="cellEditorCtrl.fireKeyUpEvent($event, cellEditorCtrl.item)" />
 
        <span class="input-group-btn">
            <button type="button" class="btn btn-default" ng-click="cellEditorCtrl.openDatePicker($event)">
                <i class="glyphicon glyphicon-calendar"></i>
            </button>
        </span>
 
    </span>
  
 
</div>

What’s next?:

  • Date columns extended configuration: min and max date, date format.
  • Unit tests.
  • Upload code to GitHub.
  • Implement some of you ideas! Leave a comment.
Advertisements

14 thoughts on “AngularJS – CRUD Data Grid II

  1. Aviral Jain says:

    Hi, can you please put the whole code in plunker or git hub .

    It will be highly appriciated.

    Thanks in advanced.

    Like

  2. Eu Brandon says:

    Hi, I have saw your demo and it was wonderful! But sadly i did not manage to catch it. Could you give me a clearer instruction on how to implement the functions step by step?

    Like

Your feedback is important...

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s