CSC/ECE 517 Spring 2015 E1526

From Expertiza_Wiki
Jump to navigation Jump to search

E1526. Responsive web design for Expertiza

Strategy

We analyzed the three Expertiza pages that we need to modify for this project, as well as all of their dependencies, and tried to identify all the tasks that we need to complete for this project. It became clear to us that a large majority of this project will be based on AngularJS, while the small fraction will be related to Bootstrap. Therefore, our strategy was to jumpstart this project by having all 4 team members work together on adding AngularJS to the Login page. Once the Login page was completed, we split in two groups. One group worked on implementing AngularJS on Manage-course page, while the other group worked on introducing AngularJS to Manage-User page. The reasoning behind this strategy was that all of us working together on the Login page first allowed us to obtain some basic understanding of AngularJS and feel more comfortable using this impressively useful and powerful framework. We completed AngularJS constituents first by connecting some of the Rails controllers to AngularJS controllers and then, as a final step, we add Bootstrap. Our decision was based on the fact that Bootstrap does not require much work to be completed and it can be considered to be a pretty straight-forward element of the project.

Guide for future students

1. Learn how to integrate AngularJS into Rails, using Rails original routing system.

2. Learn AngularJS and Coffeescript, since the app.js is written in Coffeescript now.

3. Learn Bootstrap, including the grid system, responsive design and etc.

4. Read the code written by us, to know

   a. how to pass data from Rails controller and view to AngularJS; 
   b. how to pass data back to Rails view from AngularJS; 
   c. how to convert JSON formatted data using in AngularJS to Rails hash and vice versa.
   d. how to present the data to Rails view

Integration

This section represent the cornerstone of this project. It consciously explains how Angular and Rails can be integrated into a powerful system capable of providing solid control over both the backend and frontend development of your application.

Steps to interconnect Rails and AngularJS

These guidelines assume that you have a Rails project in place and that you are intending to add AngularJS to it now. Please follow the steps bellow:

1. In the Gemfile, ADD gem ‘bower-rails’ AND gem ‘angular-rails-templates’, then REMOVE ‘gem turbolinks'

2. run

 bundle install

3. Create a Bowerfile. It is used by AngularJS and it can be thought of as the Gemfile that Rails uses.

4. In the Bowerfile, ADD asset ‘angular’ , asset ‘bootstrap-sass-official’, asset ‘angular-route’ AND asset ‘angular-boostrap’(for ui.bootstrap injection)

5. run

 rake bower:install 

. Bower installs dependencies in vender/assets/bower_components.

6. ADD the following content to the config/application.rb

 config.assets.paths << Rails.root.join("vendor","assets","bower_components")
 
config.assets.paths << Rails.root.join("vendor","assets","bower_components","bootstrap-sass-official","assets","fonts")

config.assets.precompile << %r(.*.(?:eot|svg|ttf|woff|woff2)$) 

7. REMOVE //= require turbolinks AND ADD

//= require angular/angular
//= require angular-route/angular-route
//= require angular-rails-templates
//= require angular-bootstrap 

to assets/javascripts/application.js

8. Rename assets/stylesheets/application.css to assets/stylesheets/application.css.scss.

9. ADD

@import "bootstrap-sass-official/assets/stylesheets/bootstrap-sprockets"; and
@import "bootstrap-sass-official/assets/stylesheets/bootstrap"; 

to application.css.scss

Since we are did not get to build a new website using AngularJS from scratch, it was a good idea to retain the routing system of Rails rather than implement it in AngularJS. So, we added a new tag div ng-app='MPApp' into the app/views/layout/application.html.erb to include all pages into the scope of our AngularJS app, named ‘MPApp’. Then, for the home page app/views/content_pages/view.html.erb, we added a tag to make this page controlled by ‘testCtrl’, which was defined in app.coffee.

AngularJS's point of view

1. Retrieving data from database Because it is not appropriate to let AngularJS to directly fetch data from the MySQL database, the only way we could load data was through a Rails controller. For example, it is possible to do a http get/post request(GET /users/fetch_10_users) from AngularJS controller using $http injection or jQuery AJAX, which calls a new method ‘def fetch_10_users’ in the users_controller, which execute some database query via accessing methods in the user model, and returns json formatted data to the AngularJS controller. After getting the returning data, the AngularJS controller can present the data to the screen.

2. Build custom directives Instead of using the original Rails page, we decided to replace some forms and components using AngularJS directives, which enabled us to do some live modifications to the pages without loading.

Rails' point of view

The initial problem was how to provide entries from the database to AngularJS. We decided to create methods in Rails controller which will listen to the incoming AngulaJS and use a select statement to fetch a couple of entries from the database. These entries are then stored in a hash, converted to json and passed to AngularJS controller. AngularJS will then send another query request to Rails for the next set of entries. This strategy greatly reduces the loading time, making the time needed for representing the page kept at the minimum.

The specifics

This section explains the very detailed actions and their results related to the 3 specific pages that were used to integrate AngularJS. Numerous snapshots are provided with the goal to make our approach very clear.

Login Page and Navigation Bar

Login procedure: ‘Login’ button clicked → auth_controller#login → auth_controller#after_login(user) → if user.role != ‘student’ → tree_display#drill → tree_display#list
If session[:menu] is defined, that is, when a user is logging in, the page will render the content of variable session[:menu].

<ul class="nav navbar-nav">
    <% if session[:menu] %>
       <%= render :partial => 'menu_items/suckerfish',
          :locals => { items: session[:menu].get_menu(0), level: 0 } %>
     <% end %>
</ul>

<ul class="nav navbar-nav">
   <% if session[:menu] %>
      <%= render :partial => 'menu_items/suckerfish',
         :locals => { items: session[:menu].get_menu(0), level: 0 } %>
  <% end %>
 </ul>

For each menu item in the nav bar, it is a dropdown menu now. For different level of dropdown menu, we apply different CSS style to it.

<% level += 1 %>
<% items.each do |item_id|
  item = session[:menu].get_item(item_id)
  selected = session[:menu].selected?(item.id)
  long_name = item.name.split(/\W/).collect{|word| word.capitalize}.join(' ')
%>
<% if level == 1 %>
<li class="dropdown first-level">
<% elsif level == 2 %>
<li class="dropdown-submenu">
<% else %>
<li class="dropdown">
<% end %>
  <a href=<%= URI.encode("/menu/#{item.name}") %>
  <%= if item.children
        ' class="daddy"'
      end %>title='<%= long_name %>'><%= item.label.html_safe %></a>
    <% if item.children -%>
    <% if level == 1 %>
    <ul class="dropdown-menu multi-level">
      <%= render :partial => 'menu_items/suckerfish', :locals => {:items => item.children, :level => level} %>
    </ul>
    <% else %>
    <ul class="dropdown-menu">
        <%= render :partial => 'menu_items/suckerfish', :locals => {:items => item.children, :level => level} %>
      </ul>
    <% end %>
    <% end -%>
</li>
<% end -%>

Please also refer to app/assets/application.css.scss for further details.

Manage-course page

Design

After logging in, only the table frame is loaded into the page by rails. Then AngularJS takes over in the background and populate the content into the table. After all first-level content is populated into the table, in this case, all courses info belongs to the first level and their assignment is the second-level content.

After all first level content is loaded into the table, the Angular controller starts to initialize POST request for second level content.

In all, rendering the tree_display page is divided into 3 steps:
1. render the table frame,
2. render the first level table content.
3. fetch deeper level data.

In the past, these 3 steps are done before rendering the page, which takes about 10-20 seconds. But now, it takes only 2-3 seconds to finish the first 2 steps, and 2 more seconds for the 3rd step.

Moreover, the sorting and table record filtering is integrated into the table. No redirecting or re-rendering is needed for sorting or filtering, which was needed in the past.

Code details

Pass Rails controller params to view in tree_display_controller#list

angularParams = {}
angularParams[:search] = @search
angularParams[:show] = @show
angularParams[:child_nodes] = @child_nodes
angularParams[:sortvar] = @sortvar
angularParams[:sortorder] = @sortorder
angularParams[:user_id] = session[:user].id
angularParams[:nodeType] = 'FolderNode'
@angularParamsJSON = angularParams.to_json

Then the view passes to AngularJS

<div ng-controller='TreeCtrl'>
<div ng-init="init('<%= @angularParamsJSON %>')"></div>

AngularJS handles it, after all basic elements are loaded onto the page. Then AngularJS initializes a POST request for retrieving more data to populate the table.

$scope.init = (value) ->
    $scope.angularParams = JSON.parse(value)
    $http.post('/tree_display/get_children_node_ng', {
      "angularParams": $scope.angularParams
      })
    .success((data) ->
      $scope.tableContent = {}
      $scope.displayTableContent = {}
      $scope.lastLoadNum = {}
      $scope.typeList = []
      console.log data
      for nodeType, outerNode of data
        $scope.tableContent[nodeType] = []
        $scope.lastLoadNum[nodeType] = 0
        $scope.displayTableContent[nodeType] = []
        $scope.typeList.push nodeType
        # outerNode is the Assignments/Courses/Questionnaires
        for node in outerNode
          $scope.tableContent[nodeType].push node
          $scope.fetchCellContent(nodeType, node)
        $scope.getMoreContent(nodeType, 1)
      )

Fetch table cell content recursively and put them into a hash

$scope.fetchCellContent = (nodeType, node) ->
    $scope.newParams = {}
    $scope.newParams["sortvar"] = $scope.angularParams["sortvar"]
    $scope.newParams["sortorder"] = $scope.angularParams["sortorder"]
    $scope.newParams["search"] = $scope.angularParams["search"]
    $scope.newParams["show"] = $scope.angularParams["show"]
    $scope.newParams["user_id"] = $scope.angularParams["user_id"]
    key = node.name + "|" + node.directory
    $scope.newParams["key"] = key
    # console.log key
    if nodeType == 'Assignments'
      $scope.newParams["nodeType"] = 'AssignmentNode'
    else if nodeType == 'Courses'
      $scope.newParams["nodeType"] = 'CourseNode'
    else if nodeType == 'Questionnaires'
      $scope.newParams["nodeType"] = 'FolderNode'
    else
      $scope.newParams["nodeType"] = nodeType

    $scope.newParams["child_nodes"] = node.nodeinfo
    $http.post('/tree_display/get_children_node_2_ng', {
      "angularParams": $scope.newParams
      })
    .success((data) ->
      if data.length > 0
        for newNode in data
          if not $scope.allData[newNode.key]
            $scope.allData[newNode.key] = []
          $scope.display[newNode.key] = false
          $scope.allData[newNode.key].push newNode
          $scope.fetchCellContent(newNode.type, newNode)

      )

After all data is fetched, the Rails view is able to presenting them onto the page!

<tbody ng-repeat="record in displayTableContent['Courses'] | filter:searchText | orderBy:predicate:reverse">
          <tr ng-click="showCellContent(record.name, record.directory)" class="clickable-row">
            <td>{{record.name}}</td>
            <td>{{record.directory}}</td>
            <td>{{record.creation_date}}</td>
            <td>{{record.updated_date}}</td>
            <td>
              
            </td>

          </tr>
          <tr class="no-cursor-tr no-color-tr" ng-if="display[record.name+'|'+record.directory] && allData[record.name+'|'+record.directory]">
          <td colspan="3">
            <table class="normal-table table table-bordered table-striped compact-table" ng-repeat="cell in cellContent">
              <tbody>
                <tr class="no-cursor-tr no-color-tr">
                  <td><b>Name</b>: {{cell.name}}</td>
                </tr>
                <tr class="no-cursor-tr no-color-tr">
                  <td><b>Directory</b>: {{cell.directory}}</td>
                </tr>
                <tr class="no-cursor-tr no-color-tr">
                  <td><b>Creation Date</b>: {{cell.creation_date}}</td>
                </tr>
                <tr class="no-cursor-tr no-color-tr">
                  <td><b>Modified Date</b>: {{cell.updated_date}}</td>
                </tr>
              </tbody>
            </table>
          </td>
          </tr>
        </tbody>

Manage Users page

Design

Once logged in, choosing Manage/users option from the navbar, gets us routed to /users page which is defined in list.html.erb file. This file can be broken down to 5 constituents:

1) Fetching Users
2) Paginating Users
3) Searching Users
4) Showing Users
5) Editing and Deleting Users

Code Details

1) Fetching Users Fetching users was challenging at first due to an extremely large number of user entries (total of 5300). Fetch of such large data produced a very long delay that was one of the major UX problems in legacy version of Expertiza. This problem has been solved with Angular by sending a post request that gets 300 users and then another post request which gets all the users. This approach provides a quick response and the first 300 users can be seen right away in the form of 3 pages (default set to 100 users per page). While the user is looking at the first couple of entries, the rest of the entries are fetched in the background. This allows for a fast response, full functionality as well as complete data.

In app/assets/javascripts/app.coffee:

$scope.getUsers = (fn) ->

    $http.post('/users/get_users_ng', {
      'fetchNumber': fn
    })
    .success((receivedUsers) ->
      console.log receivedUsers
      for user in receivedUsers
        $scope.users.push user
        $scope.user_display[user.object.id] = false
        $scope.editProfileVisible[user.object.id] = false

      $scope.fetchNumber+=1
      console.log $scope.users.length
      console.log $scope.listSize

      if $scope.users.length < $scope.listSize
        $scope.getUsers($scope.fetchNumber)
      )

  $scope.getUserListSize = () ->
    $http.get('/users/get_users_list_ng')
    .success((listSize) ->
      $scope.listSize = listSize
      )

This process is initiated by init() command shown bellow:

$scope.init = (value) ->
    $scope.user_display = {}
    $scope.editProfileVisible = {}
    if $scope.users.length == $scope.listSize
      return
    else if $scope.users.length == 0
      $scope.listSize = 0
      $scope.getUserListSize()
      $scope.fetchNumber = 0
    $scope.getUsers(($scope.fetchNumber))
    $scope.currentPage = 0
    $scope.pagination(50)

The view resulting from this code structure can be seen bellow:


2) Paginating Users

In order to paginate users, a filter needs to be set in order to limit the number of users being shown:

In views/users/list.html.erb:

<tbody ng-repeat="user in users | filter:search | startFrom:currentPage*pageSize | limitTo:pageSize">
        <tr ng-click="showUser(user.object.id)">
          <td>{{user.object.name}}</td>
          <td>{{user.object.fullname}}</td>
          <td>{{user.object.email}}</td>
          <td>{{user.role}}</td>
          <td>{{user.parent}}</td>
          <td>{{user.email_on_review}}</td>
          <td>{{user.email_on_submission}}</td>
          <td>{{user.email_on_review_of_review}}</td>
          <td>{{user.leaderboard_privacy}}</td>
        </tr>
...

The startFrom:currentPage*pageSize portion of the filter calls the custom filter startFrom which simply splits the data to display based on the current page of pagination (currentPage), multiplied by the number of users to display (pageSize, which is set by the user selecting either All, 25, 50, 100, or 250).

In app/assets/javascripts/app.coffee:

app.filter 'startFrom', ->
  (input, start) ->
    start = +start
    return input.slice start

The limitTo:pageSize is a built in angular filter that says to stop displaying users after pageSize of them have been displayed.

The aforementioned pageSize is set by the user through a dropdown; This code tells the dropdown to set the default paginate value to 100 users per page, and upon an ng-change (user selecting a different value), the pagination method will be called in app.coffee, with the appropriate pageSize:

In views/users/list.html.erb:

<label>Paginate By: 
    <select class="form-control" ng-model="paginate" ng-init="paginate='50'" ng-change="pagination(paginate)">
      <option value="0">All</option>
      <option value="25">25</option>
      <option value="50">50</option>
      <option value="100">100</option>
      <option value="250">250</option>
    </select>
</label>

This code is in app.coffee, and it posts the pageSize to the set_page_size method in the users_controller, and then sets various variable values based on the results:

In app/assets/javascripts/app.coffee:

$scope.pagination = (ps) ->
    $http.post('/users/set_page_size', {
      'pageSize': ps
    })
    .success((value) ->
      $scope.pageSize = value[0]
      $scope.div = value[1]
      $scope.totalSize = value[2]
      )

This code is in users_controller and it takes in the user defined pageSize variable and then determines how many pages are required to divide the data into based off of the pageSize, then returns that variable, plus others:

In app/controllers/user_controller:

def set_page_size
    ps = params[:pageSize].to_i
    user = session[:user]
    role = Role.find(user.role_id)
    all_users = User.order('name').where( ['role_id in (?) or id = ?', role.get_available_roles, user.id])
    users_length = all_users.length
    if ps == 0
      pageSize = users_length
    else
      pageSize = ps
    end

    div = (users_length/pageSize.to_f).ceil
    
    respond_to do |format|
      format.html {render json: [pageSize, div, users_length]}
    end
 end

Once the number of pages and pageSize has been set, those numbers are then used to show the current and remaining pages to go along with the previous and next buttons:

In views/users/list.html.erb:

<span id="paginate-button">
  <button class="glyphicon glyphicon-chevron-left" ng-disabled="currentPage == 0" ng-click="currentPage=currentPage-1">
  </button>
  {{currentPage+1}}/{{div}}
  <button class="glyphicon glyphicon-chevron-right" ng-disabled="currentPage >= totalSize/pageSize - 1" ng-click="currentPage=currentPage+1">
  </button>
 </span>

3) Searching Users

In order to search users, a filter also needs to be set in order to limit the number of users being shown:

In views/users/list.html.erb:

<tbody ng-repeat="user in users | filter:search | startFrom:currentPage*pageSize | limitTo:pageSize">
        <tr ng-click="showUser(user.object.id)">
          <td>{{user.object.name}}</td>
          <td>{{user.object.fullname}}</td>
          <td>{{user.object.email}}</td>
          <td>{{user.role}}</td>
          <td>{{user.parent}}</td>
          <td>{{user.email_on_review}}</td>
          <td>{{user.email_on_submission}}</td>
          <td>{{user.email_on_review_of_review}}</td>
          <td>{{user.leaderboard_privacy}}</td>
        </tr>
...

The filter:search portion of this code simply applies the following filter criteria; the type of search is determined a dropdown (either search by any field, name, fullname, or email fields). Based on the dropdown selection, the correct search box will be shown, once that search box is show, the search criteria is set to correspond to the type of search specified by the dropdown:

In views/users/list.html.erb:

  <div class="pull-right form-group">
  <div class="form-inline">
  <label>Search By: 
    <select class="form-control" ng-model="searchtype" ng-init="searchtype='any'">
      <option value="any">Any</option>
      <option value="name">Name</option>
      <option value="fullname">Full Name</option>
      <option value="email">Email</option>
    </select>
  </label>
  <input class="form-control" ng-hide="searchtype != 'any'" ng-model="search.object.$">
  <input class="form-control" ng-hide="searchtype != 'name'" ng-model="search.object.name">
  <input class="form-control" ng-hide="searchtype != 'fullname'" ng-model="search.object.fullname">
  <input class="form-control" ng-hide="searchtype != 'email'" ng-model="search.object.email">
  </div>
  </div>

4) Showing Users Clicking on each user name in the table of users uncovers a template that shows the information about that specific user. This is accomplished in a very simple way by setting the profileVisible flag to true. The information about that specific user is extracted from displayedUser object provided by showUser() method from UsersPageCtrl controller in app.coffee file.

In views/users/list.html.erb:

<div ng-if="profileVisible == true">
  <h1><b>User: {{displayedUser.object.name}}</b></h1>
  <br>
  <h3><b>Full Name: </b>{{displayedUser.object.fullname}}</h3>
  <h3><b>Email Address: </b>{{displayedUser.object.email}}</h3>
  <h3><b>Role: </b>{{displayedUser.role}}</h3>
  <br>

  <div>
    <button ng-click="showTable(true)" type="button" class="btn btn-primary">Back</button>
    <button ng-click="editUser()" type="button" class="btn btn-success">Edit</button>
    <button ng-click="deleteUser()" type="button" class="btn btn-danger">Delete</button>
  </div>
</div>

In app/assets/javascripts/app.coffee:

  $scope.showUser = (user_id) ->
    $scope.user_display[user_id] = !$scope.user_display[user_id]
    $scope.editProfileVisible[user_id] = false

As you can see in the screenshot bellow, as an example, user 6 has been selected and now the editing information about this user is visible.


5) Editing and Deleting Users

Editing Users is implemented in a similar way as showing users. The template contains ng-if statement which allows us to display edit page for each user. editUser() method from UsersPageCtrl controller specified in app.coffee displays information of the displayedUser but only after the Save button is clicked it saves the information from displayedUsers into updatedUser variable. POST request with :id parameter is then sent to update method in users_controller of Rails, which then updates this user information in the database.

  $scope.editUser = (user) ->
    # $scope.showUser(false)
    $scope.editProfileVisible[user.object.id] = !$scope.editProfileVisible[user.object.id]
    $scope.updatedUser = user

The code above allows us to set the edit form visible. Bellow, you will find an example screenshot of this feature.


Resources

Github link: https://github.com/CSC517-FInalProject/expertiza/tree/pei-final
Wiki link: http://wiki.expertiza.ncsu.edu/index.php/CSC/ECE_517_Spring_2015_E1526_MPRI 
Project Demo: https://youtu.be/qsHYE9Rgx80