9: Adding the teams entity, and links from clubs to team

In this portion of the tutorial we’re going to extend to the teams entity, and build the links between clubs and teams.  This means passing parameters to our list controller, and dealing with optionality.  We’ll let people create a team from within the context of a club – in which case we auto-populate the club, and we’ll let them create a team standalone and pick the club from a drop-down.

This tutorial mainly consolidates what we’ve already done, but it lays the groundwork for some more interesting functionality later, including user authentication and an editable grid.

If you’ve dropped into the middle of the tutorial, you can find the code for the previous section at github: PaulL : tutorial_8.  You can go to the index page for this tutorial, or you can hit the tutorial menu above and see all the posts in the Rails 4 tutorial.

At the end of this tutorial you should expect to have screens that look something like the following:

clubs with team link

teams from clubs

team from clubs

 

You’ll recall back in the second post we created a teams entity at the same time as we created the clubs entity.  The definition for this entity in rails looked like:

rails generate scaffold team club:references name:string captain:string date_created:datetime

This entity has a handful of fields on it:

  • club_id
  • name
  • captain
  • date_created

Initially we’d like to create a team entity in angular that is like the club entity, that is it is standalone.  We can do this by copying the entire club directory, and renaming everything in it from club to team, both the filenames, and everything inside the files.  When running search and replace, make sure to be careful on capitalisation – Club->Team, club->team.  Things to watch out for are:

  • You’ll also need to change the contact_officer field in the html templates and in the grid config to be captain – teams have a captain, clubs have a contact_officer
  • You’ll need to update the src/less/main.less to include team.less.  Strictly speaking we’re using the same styles at the moment, so once we tidy up the app we’ll aim to put all the less into a more sensible structure with a set of application-wide styles, and only styles specific to a particular entity turning up in that entity’s less
  • You need to change app.js to include your new module – league.team
  • You need to change index.html to include a block for teams.  I deleted the github block, and created a new block after club similar to the one we already included for clubs
                <li ui-route="/team" ng-class="{active:$uiRoute}">
                   <a href="#/team">
                     <i class="icon-github-alt"></i>
                     Teams
                   </a>
                 </li>
  • The unit test will mostly work once you search and replace club/team, but remember the JSON response on a server query needs to be updated (I got mine by just querying the rails server directly and looking at the response).  It will actually work without, but it’s better practice to replace to something more like you expect.
        scope.httpBackend.expect('GET', '../teams.json').respond([
          {"captain":"Captain 1","created_at":"2012-02-02T00:00:00Z","date_created":"2012-01-01T00:00:00Z","id":1,"name":"Team 1","updated_at":"2012-03-03T00:00:00Z"},
          {"captain":"Captain 2","created_at":"2012-02-02T00:00:00Z","date_created":"2012-01-01T00:00:00Z","id":2,"name":"Team 2","updated_at":"2012-03-03T00:00:00Z"}
        ]);
  • Be careful with the get in the unit tests for the detail page – the response needs to have id of 2 so that the update will be of id 2:
        scope.httpBackend.expectGET('../teams/2.json').respond({"captain":"Captain 2","created_at":"2012-02-02T00:00:00Z","date_created":"2012-01-01T00:00:00Z","id":2,"name":"Team 2","updated_at":"2012-03-03T00:00:00Z"});

Run grunt build and check that you now have a teams section of your app that behaves a lot like the clubs section did.

You’ll need to update your end-to-end tests to expect the captain and captain names, and update the home.part.scenario to have the teamsLink.  Run your end-to-end scripts with:

protractor protractor/protractor.scenario.tpl.js

You should find all your new end-to-end tests running, as well as the old ones.

Next, we’d like to have our application create a link between clubs and teams.  The way we’re going to do this initially is to use a drop-down on the teams detail page to allow you to select one of the clubs.

First, we need access to a list of clubs within our team detail controller.  We’re going to do that by using the ClubRes resource that we already defined in the clubs entity.  In our teams.js we make the following changes.  First, modify the module definition at the very top of your team.js file to include league.club as a dependency:

angular.module( 'league.team', [
  'ui.state',
  'league.club',
  'ngResource',
  'ngGrid'
])

Next, modify the detail controller definition to include the ClubRes as a dependency,:

.controller('TeamCtrl', function TeamController( $scope, TeamRes, ClubRes, $state, $stateParams ) {

Finally, when we enter the detail controller, we want to go and get the list of clubs from the ClubRes:

  if ($scope.teamId) {
    $scope.team = TeamRes.get({id: $scope.teamId});
  } else {
    $scope.team = new TeamRes();
  }  
  $scope.clubs = ClubRes.query();

Then we need to do the html template updates. We’re going to follow the advice we found from IEnableMuch, which suggests a tidy way to implement this. In the team.tpl.html add in club as a editable field. We’re using a select box, refer to the Angular documentation for information on settings you can use with a select dropdown.

  <div class="form-horizontal">
    <div class="control-group" ng-class="error.club ? 'error' : ''">
      <label class="control-label">Club:</label>
      <div class="controls">
        <select ng-model="team.club_id" ng-options="club.id as club.name for club in clubs"></select>
      </div>
      <div field_error errors="error" field="club"></div>
    </div>

Run grunt build, and see if you get a select dropdown working properly. You’ll probably get a unit test failure, as we have a new http request that the unit test isn’t expecting, so update the edit controller portion of our unit test to expect this query to happen:

    describe( 'Team detail controller base tests:', function() {  
      it('Initial detail controller render receives a team id, gets team, success', angular.mock.inject(function($controller){
        // use default teamId of 2
        $controller('TeamCtrl', { $scope: scope, $stateParams: scope.fakeStateParams });
        expect(scope.teamId).toEqual(2);

        scope.httpBackend.expectGET('../teams/2.json').respond({"captain":"Captain 2","created_at":"2012-02-02T00:00:00Z","date_created":"2012-01-01T00:00:00Z","id":2,"name":"Team 2","updated_at":"2012-03-03T00:00:00Z"});
+       scope.httpBackend.expect('GET', '../clubs.json').respond([]);

        scope.$digest();  
        scope.httpBackend.flush();
        expect(scope.team.name).toEqual('Team 2');     
      }));  

      it('Initial detail controller render does not receive a team id, creates new team, success', angular.mock.inject(function( $controller) {
        // set teamId to null
        scope.fakeStateParams.teamId = null;

        $controller('TeamCtrl', { $scope: scope, $stateParams: scope.fakeStateParams });
+       scope.httpBackend.expect('GET', '../clubs.json').respond([]);

+       scope.$digest();  
+       scope.httpBackend.flush();
        expect(scope.teamId).toBeNaN();
      }));             
    });

    describe( 'Team detail controller update method tests', function() {
      beforeEach(angular.mock.inject(function($controller){
        $controller('TeamCtrl', {$scope: scope, $stateParams: scope.fakeStateParams});

        // The initial render triggers a get, drain that before we start the test proper
        scope.httpBackend.expectGET('../teams/2.json').respond({"captain":"Captain 2","created_at":"2012-02-02T00:00:00Z","date_created":"2012-01-01T00:00:00Z","id":2,"name":"Team 2","updated_at":"2012-03-03T00:00:00Z"});
+       scope.httpBackend.expect('GET', '../clubs.json').respond([]);

        scope.$digest();
        scope.httpBackend.flush();
       }));
.....
    describe( 'Team detail controller save method tests', function() {
      beforeEach(angular.mock.inject(function($controller){
        scope.fakeStateParams.teamId = null;
        $controller('TeamCtrl', {$scope: scope, $stateParams: scope.fakeStateParams});
+       scope.httpBackend.expect('GET', '../clubs.json').respond([]);

+       scope.$digest();
+       scope.httpBackend.flush();
       }));

Your grunt build should pass.

The UI should now look broadly as follows:

team list

With a detail page like:

team detail

Next, we’d like a way to come from an individual club to see all the teams for the club.  There are a lot of ways to put this together, but for now we’re going to put a “show teams” button on the clubs list.  This will take us to the teams list view, and show the teams for just that club through using a filter.

First, we’re going to add the “show teams” button to the clubs controller.  Edit src/app/club/clubs.js to add the following code to the gridOptions configuration:

      {displayName: 'Delete', cellTemplate: '<button id="deleteBtn" type="button" ng-click="deleteClub(row.entity)" >Delete</button> '},
+     {displayName: 'Show Teams', cellTemplate: '<button id="showBtn" type="button" ng-click="showTeams(row.entity)" >Show Teams</button> '}
    ],

Then we’re going to create the showTeams method on the controller, at the moment this just takes us to the teams page, it doesn’t tell teams that we want to see only a specific club:

  $scope.showTeams = function(club) {
    $state.transitionTo('teams');
  };

Run grunt build and see if this method drives us to the teams page (albeit it isn’t showing us the clubs for our team yet).

Next, we want to add a filter capability to the teams list. The ngGrid documentation describes a filter function, and from this page I can see at very bottom a plunker that creates a standalone filter box, and in that filter box we can put column names with values.  So, for example, we could put “name:John” and it will filter on the column name for all Johns.  We’re going to have a go at using something like that for now –  later we would prefer to shift this to the server side.

First, add a filter box to src/app/team/teams.tpl.html, this is a text box that is bound to the “filterOptions.filterText” model variable:

<div>
+ <strong>Filter:</strong><input type="text" ng-model="filterOptions.filterText" />
  <div class="gridStyle" ng-grid="gridOptions"></div>
  <button ng-click="newTeam()" class="btn btn-primary" >New</button>  
</div>

Then, add a binding to the grid filtering using the filterOptions.  There are two additions here – we create a filterOptions variable right up front so the control has something to bind to, then we use that in the gridOptions configuration:

.controller( 'TeamsCtrl', function TeamsController( $scope, TeamRes, $state ) {
  $scope.teams = TeamRes.query();

+ $scope.filterOptions = {
+   filterText: ''
+ };

  $scope.gridOptions = {
    data: 'teams',
    columnDefs: [
      {field: 'id', displayName: 'Id'},
      {field: 'name', displayName: 'Team Name'},
      {field: 'captain', displayName: 'Captain'},
      {displayName: 'Edit', cellTemplate: '<button id="editBtn" type="button" ng-click="editTeam(row.entity)" >Edit</button> '},
      {displayName: 'Delete', cellTemplate: '<button id="deleteBtn" type="button" ng-click="deleteTeam(row.entity)" >Delete</button> '}
    ],
    multiSelect: false,
+   filterOptions: $scope.filterOptions
  };

Run grunt build to see how we’re going on the teams page. This should now allow filtering on the teams page, for example you can put “captain:A.N.” into the filter box, it should show just captains whose name contains A.N.

Our next extension is that we haven’t shown the club names or ids in the list, and while we’re at it we’d like to show and format the create date. Start back in the rails app/controllers/teams_controller.rb – we want to pull back the club name when we bring back the list of teams:

  def index
    @teams = Team.joins('LEFT OUTER JOIN clubs on teams.club_id = clubs.id').select('teams.*, clubs.name as club_name')
    render_with_protection @teams
  end

Call the rails service directly to verify the json is now showing the club_name in the result (http://localhost:3000/teams.json). We then edit our grid options in teams.js to show these extra columns, and to add some extra features to our grid, namely the ability for a user to decide which columns to display (a dropdown top right of the grid), and the ability to group by one or more columns.

I note that dragging and dropping columns into the grouping isn’t yet working for me, I think this is because of not (yet) setting the app to be html5, but the default columns do work:

  $scope.gridOptions = {
    data: 'teams',
    columnDefs: [
      {field: 'id', displayName: 'Id'},
+     {field: 'club_id', displayName: 'Club Id', visible: false},
+     {field: 'club_name', displayName: 'Club Name'},
      {field: 'name', displayName: 'Team Name'},
      {field: 'captain', displayName: 'Captain'},
+     {field: 'date_created', displayName: 'Date Created'},      
      {displayName: 'Edit', cellTemplate: '<button id="editBtn" type="button" ng-click="editTeam(row.entity)" >Edit</button> '},
      {displayName: 'Delete', cellTemplate: '<button id="deleteBtn" type="button" ng-click="deleteTeam(row.entity)" >Delete</button> '}
    ],
    multiSelect: false,
    filterOptions: $scope.filterOptions,
+   showColumnMenu: true    
  };

Run grunt build again, and see whether this now shows the additional columns.

Next, we don’t like the date formatting all that much, so we’re going to use a filter to change the date to a prettier format:

      {field: 'date_created', displayName: 'Date Created', cellFilter: "date:mediumDate"},

Run grunt build and check the date formatting.

We want to allow the club id to be a parameter on the URL, and to then put that parameter into the filter box automatically.  We can get at parameters that are passed in to us by using the $stateParams, which is part of the $stateProvider module. This replaces the standard $routeProvider module, and is the magic that allows us to move between the pages of our application. We tell our team controller that we have a dependency on $stateParams:

.controller( 'TeamsCtrl', function TeamsController( $scope, TeamRes, $state, $stateParams ) {

And we tell our state setup that we are able to accept a parameter on the URL, and to please put that into the $stateParams for us:

.config(function config( $stateProvider ) {
  $stateProvider.state( 'teams', {
    url: '/teams?clubId',
    views: {
      "main": {
        controller: 'TeamsCtrl',
        templateUrl: 'team/teams.tpl.html'
      }
    },
    data:{ pageTitle: 'Teams' }
  })

And in the controller changes we need to make use of the clubId that we then get:

.controller( 'TeamsCtrl', function TeamsController( $scope, TeamRes, $state, $stateParams ) {
  $scope.teams = TeamRes.query();

+ $scope.clubId = $stateParams.clubId;

+ if($scope.clubId) {
+   $scope.filterOptions = {
+     filterText: 'club_id:' + $scope.clubId
+   };
+ }
+ else {
+   $scope.filterOptions = {
+     filterText: ''
+   };
+ }

  $scope.gridOptions = {

Run grunt build and try modifying the URL on the teams page to provide a club_id, and see it update the filter: http://localhost:3000/teams?clubId=1, change it to http://localhost:3000/teams?clubId=2 and see the filter change.

Modify the club to pass in the id on the url, finishing this part of the integration:

  $scope.showTeams = function(club) {
    $state.transitionTo('teams', {clubId: club.id});
  };

Run grunt build, and check that using the “show teams” button on the club page takes you to a filtered list of the teams on the team page.

Finally, when a filter is set on the teams page, we want to apply that filter as a default value when we create a new team. Start off by passing the clubId when we click the newTeam button:

  $scope.newTeam = function() {
    $state.transitionTo('team', {clubId: $scope.clubId});
  };

Make clubId available as a parameter on the team detail state:

  .state( 'team', {
    url: '/team?teamId&clubId',
    views: {
      "main": {
        controller: 'TeamCtrl',
        templateUrl: 'team/team.tpl.html'
      }
    },
    data:{ pageTitle: 'Team'
    }
  });

Next, parse this on the way into the team detail controller:

+ $scope.clubId = parseInt($stateParams.clubId, 10);

  if ($scope.teamId) {
    $scope.team = TeamRes.get({id: $scope.teamId});
  } else {
    $scope.team = new TeamRes();
+   if($scope.clubId) {
+     $scope.team.club_id = $scope.clubId;
+   }
  }

Finally, update the new team unit test to expect clubId to be passed:

      it('Calls new', function() {
        // we expect it to call $state
        spyOn(scope.$state, "transitionTo").andCallThrough();

        // call new
        scope.newTeam();

        // we expect the team state to be called, passing in the id of the first item
        expect(scope.$state.transitionTo).toHaveBeenCalledWith('team', {clubId: undefined});
      });

And that should do it – when we create a new team it should use the current club if we passed one in on the URL.

You should have an application that has a teams UI, and linkages from the clubs to the teams including a dropdown.  It should default the filtered teams into a new team created.  It should broadly look like:

clubs with team link

teams from clubs

team from clubs

The code for this post in the tutorial series can be found at github: tutorial 9.

The next post in the series focuses on making our grid editable.

Advertisements

One thought on “9: Adding the teams entity, and links from clubs to team

  1. Pingback: AngularJS and Rails 4 CRUD application using ng-boilerplate and twitter bootstrap 3: Tutorial Index | technpol

Leave a Reply

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