Thursday, June 26, 2014

Dancer2 and Yeoman integrating nicely

Dancer2 and Angular changes to
bin/app.pl

Generate the backend components...

  651  dancer2 gen -a CMA 
  652  cd CMA
  652  yo angular cma
Rearranging the public / dancer web directories.

  709  vi bin/app.psgi
    insert
BEGIN {
  $ENV{'DBIC_TRACE'}    = 1;
  $ENV{'RELATIVE'} = 1;
}
  710  cd app
  711  ln -s ../bower_components .
  712  cd ../lib && vi CMA.pm
    edit
use Cwd;
set auto_page => 1;
# set the new views directory to absolute path
set views => getcwd . "/app";


get '/' => sub {
 template 'index';
};

# catch anything that cannot be served html extension 
# with template, else just serve the files requested based on the absolute
# path of the files
any qr{.*} => sub {
  my $name = request->path;
  print "request for end of chain: $name\n";
  #print Dumper( config->{appdir} );
  if ( $name !~ /\.html$/ ) {
    send_file( config->{appdir} . "/app/$name", system_path => 1 );
  }
  else {
    template $name;
  }
};

  
  712  cd ..
  719  plackup -r bin/app.psgi 
After this you could remove the public and views directory since they have no purpose. The 'app' directory generated by Yeoman contains all the application files.

Sunday, June 15, 2014

Dancer::Plugin::Pagination, AngularJS Pagination Abstraction and Integration

Out of the box pagination with Dancer or Dancer2 using AngularJS as front-end.... I just got sick of copy and paste these functionalities... Ingredients:
  1. Dancer::Plugin::Pagination; class for implementing the pager() function in dancer with any DBIC class.
  2. Object to JSON mapping; decoupling the DBIC ResultSet to a portable JSON struct.
  3. Writing/Configuring the backend service for querying
At this point you should be able to get results back in the browser as a JSON resultset. Next is the AngularJS integration:
  1. yo angular:route mytable; the controller and base class for ng-grid
  2. yo angular:factory mytable; the service communicating with the Dancer backend
  3. defining the pagination controller; abstract class with basic functions

1. Dancer::Plugin::Pagination

package Dancer::Plugin::Pagination;

use strict;
use warnings;
use Dancer2;
use Dancer2::Plugin;
use Dancer2::Logger::Console;
use Data::Dumper;

our $AUTHORITY         = 'KAAN';
our $VERSION           = '0.01';
our $DEFAULT_PAGE_SIZE = 5;
our $logger            = Dancer2::Logger::Console->new;

sub mapper {
  my ($self, $defs, @rs) = @_;
  return &map_fields ( $defs, @rs);
}

sub map_fields {
  my ( $field_defs, @rs ) = @_;

  #print "MAPPING:" . Dumper($field_defs);
  my @result = ();
  foreach my $row (@rs) {
    my $rec = {};
    while ( my ( $k, $v ) = each %$field_defs ) {
      if ( ref($v) eq 'ARRAY' ) {
        my $base = $row;
        foreach my $method (@$v) {

     #printf "%s ->[%s] %s\n", join('->', @$v) , $method, Dumper($row->columns);
          $base = $base->$method if $base;
        }
        $rec->{$k} = $base;
      }
      else {
        $rec->{$k} = $row->$v;
      }
    }
    push @result, $rec;
  }
  return \@result;

}

sub pagination {
  my ( $self, $store, $table, $mapping, $subselect ) = @_;
  unless ($mapping) {
    die( "missing mapping for " . $table );
    return 1;
  }

  # deserialize the filter, page and sort options.
  my $pager  = $self->from_json( $self->params->{'pager'} );
  my $filter = $self->from_json( $self->params->{'filter'} );
  my $sort   = $self->from_json( $self->params->{'sort'} );

  my $limit  = $pager  && $pager->{'pageSize'}    || $DEFAULT_PAGE_SIZE;
  my $pagenr = $pager  && $pager->{'currentPage'} || 1;
  my $type   = $filter && $filter->{'matchType'}  || 'any';
  my $text   = $filter && $filter->{'filterText'} || '';
  my $field         = $filter && $filter->{'matchFields'};
  my $sortFields    = $sort   && $sort->{'fields'};
  my $sortDirection = $sort   && $sort->{'directions'} || ['ASC'];
  # establish default filtering
  $field = { 'name' => 1 } unless keys %$field;
  #
  my %where = ();
  if ( $text ne '' ) {
    if ( $type =~ /all/ ) {
      foreach my $f ( keys %$field ) {
        $where{ $mapping->{$f} }{'-like'} = $text . '%';
      }
    }
    else {
      if ( scalar keys %$field > 1 ) {
        foreach my $f ( keys %$field ) {
          push @{ $where{'-or'} },
            { $mapping->{$f} => { '-like' => $text . '%' } };
        }
      }
      else {
        foreach my $f ( keys %$field ) {
          $where{ $mapping->{$f} }{'-like'} = $text . '%';
        }
      }
    }
  }
  my @order = ();
  @order = map { $mapping->{$_} . ' ASC' } @$sortFields if $sortFields;
  my @rs = $store->resultset(
                             $table)->search(
                                              \%where,
                                              {
                                                page     => $pagenr,
                                                rows     => $limit,
                                                order_by => \@order
                                              }
                             );
  my $count = $store->resultset($table)->search( \%where )->count;

  my $result = &map_fields( $mapping, @rs );
  return { 'totalItems' => $count, 'items' => $result };
}

register get_lookups => \&get_lookups;
register pager       => \&pagination;
register mapper      => \&mapper;
register_plugin for_versions => [ 1, 2 ];

1;

2. Mapping Object to JSON

In my case I want to map address information to a JSON object ... in my application package I add the following:
our %mapping = (
  'address' => {
    'id' => 'crc',
    'street' => 'street',
    'city' => 'city',
    'state' => 'state',
    'zip' => 'postal_code',
    'lname' => 'last_name',
    'fname' => 'first_name',
    'acc' => 'accuracy',
    'status' => [qw/status id/],  # many-to-one fetch status->id
    'ministry' => [qw/ministry_record id/] # many-to-one fetch ministry_record->id
  }
);

3. Adding the request with the pager functionality

The base request would be a simple definition of the schema, table name/resultset in DBIC and supplying the mapping.
=head
  ################################
=cut
get '/address/list' => sub {
  my $store = schema 'default';
  return pager($store, 'TerritoryAddress', $mapping{'address'})
};

Result

When we start the module we and enter http://localhost:3000/address/list we get the JSON result back. The default result set is a number of 5 with the total count of items in a separate field.
{"totalItems":"370","items":[{"street":"136 Morse Rd.","status":"ARC","fname":"Andy","state":"CT","acc":8,"city":"East Montpelier","zip":"XXX","lname":"Goodman","id":"1","ministry":"ARC"},{"street":"89 Maple St","status":"D","fname":"Diane","state":"VA","acc":8,"city":"SomewhereElse","zip":"XXX","lname":"ABCDE","id":"4","ministry":"RD"},{"street":"1409 North Ave","status":"H","fname":"Tim & Tina","state":"NL","acc":8,"city":"OceanDeep","zip":"XXX","lname":"ABCDE","id":"5","ministry":"RD"},{"street":"54 Roseade Parkway","status":"ARC","fname":null,"state":"NB","acc":8,"city":"AnotherPlace","zip":"XXX","lname":"ABCDE","id":"6","ministry":"ARC"},{"street":"714 Riverside Ave Apt 2","status":"HH","fname":"Dennis","state":"NC","acc":9,"city":"Somewhere","zip":"XXX","lname":null,"id":"7","ministry":"MVD"}]}

4. and 5. Adding the service and controller for AngularJS

Now essential are the pageOptions, filterOptions and sortOptions. These options will control the backend pager. If you notice we are extending from a base controller 'paginationControler' which houses the common setup of the pagination, thus avoiding duplication when more screens need the same functionality.
'use strict';

angular
  .module('MyApp')
  .controller(
    'AddressMgtCtrl',
    function($scope, $controller, $modal, $http, $timeout, addressMgt) {
        // Initialize the super class and extend it.
        $.extend(this, $controller('paginationControler', {$scope: $scope}));

     $scope.filterOptions.fields = [ {
      id : 'street',
      desc : 'Street'
     }, {
      id : 'city',
      desc : 'City'
     }, {
      id : 'fname',
      desc : 'First Name'
     } ];

     $scope.sortOptions = {
      fields : [ "street" ],
      directions : [ "ASC" ]
     };

     $scope.pageSync = function() {
      setTimeout(function() {
       addressMgt.search($scope.pagingOptions,
         $scope.filterOptions, $scope.sortOptions)
         .success($scope.responseFn);
      }, 100);
     };

     $scope.gridOptions = {
      data : 'data',
      enableRowSelection : true,
      enablePaging : true,
      enableCellEditOnFocus : true,
      showFilter : true,
      showGroupPanel : true,
      showFooter : true,
      filterOptions : $scope.filterOptions,
      pagingOptions : $scope.pagingOptions,
      sortInfo : $scope.sortOptions,
      multiSelect : false,
      selectedItems : $scope.mySelections,
      totalServerItems : 'totalItems',
      columnDefs : [ {
       field : 'street',
       displayName : 'Street',
       enableCellEdit : false
      }, {
       field : 'city',
       displayName : 'City',
       enableCellEdit : false
      }, {
       field : 'state',
       displayName : 'State',
       enableCellEdit : false
      }, {
       field : 'zip',
       displayName : 'Zip',
       enableCellEdit : false
      }, {
       field : 'lname',
       displayName : 'Last Name',
       enableCellEdit : false
      }, {
       field : 'fname',
       displayName : 'First Name',
       enableCellEdit : false
      } ]
     };

     $scope.pageSync();
    });

5. The Service definition

Our 'addressMgt' service will handle all of our communication for this screen and thus the pagination as well. You notice the page, filter and sort options are send as parameters to the pager service on the backend.
'use strict';
angular.module('MyApp').factory('addressMgt', function($http) {
 var addressAPI = {};
 addressAPI.search = function(pageOpts, filterOpts, sortOpts) {
  return $http.get('/address/list', {
   params : {
    'filter' : filterOpts && angular.toJson(filterOpts) || '',
    'pager' : pageOpts && angular.toJson(pageOpts) || '',
    'sort' : sortOpts && angular.toJson(sortOpts) || ''
   }
  });
 };

 return addressAPI;

});

6. Putting it all together

The 'views/address_mgt.html' now would be a one liner...

7. The Abstract Pagination Controller in Angular

The abstract 'paginationController' is the last piece that would make pagination somewhat abstract. I choose to define the controller in the 'app.js' layer as a central top layer function.
'use strict';

angular.module(
  'myApp',
  [ 'ngCookies', 'ngResource', 'ngSanitize', 'ngRoute', 'ngGrid',
    'ui.bootstrap' ]).config(function($routeProvider) {
 $routeProvider.when('/', {
  templateUrl : 'views/main.html',
  controller : 'MainCtrl'
 }).when('/address_mgt', {
  templateUrl : 'views/address_mgt.html',
  controller : 'AddressMgtCtrl'
 }).when('/address_dtl', {
...
...
... ADD THIS BELOW  ....
}).controller('paginationControler', function($scope) {
 /**
  * PAGINATION CONTROLLER
  */
 $scope.filterOptions = {
  useExternalFilter : true,
  filterText : "",
  show : false,
  matchType : 'any',
  matchFields : {
   'street' : true,
   'city' : true
  },
  fields : []
 };

 $scope.totalItems = 0;

 $scope.pagingOptions = {
  pageSizes : [ 25, 50, 100 ],
  pageSize : 5,
  currentPage : 1
 };

 $scope.responseFn = function(response) {
  console.log("%o", response);
  if (response) {
   $scope.data = response['items'];
   $scope.totalItems = response['totalItems'];
  } else {
   $scope.list = [];
   $scope.totalItems = 0;
  }
  if (!$scope.$$phase) {
   $scope.$apply();
  }
 };
 $scope.watchAttribute = function(newVal, oldVal) {
  if (newVal !== oldVal && newVal.currentPage !== oldVal.currentPage) {
   $scope.pageSync();
  }
 };
 $scope.$watch('pagingOptions.pageSize', function(oldVal, newVal) {
  if (newVal !== oldVal) {
   $scope.pagingOptions.currentPage = 1;
   $scope.pageSync();
  }
 }, true);

 $scope.$watch('pagingOptions', $scope.watchAttribute, true);

});