Skip to content

Scenario Selecting, Adding, and Removing Modem Users

Josh Arnold edited this page Jul 12, 2018 · 5 revisions

In this lesson we will learn how to relate ModemUsers to their parent Modem object. In this lesson will:

  • Show how to create a picker from which the application user will choose a ModemUser.
  • Once selected relate that ModemUser to the Modem using a POST.
  • Show how to remove a Modemuser from a Modem using a DELETE.

Along the way we will be introducing you to these concepts:

  • Client side full text search
  • Select2 pickers
  • How to save new Backbone models to create the relation between ModemUser and Modem.
  • How to destroy Backbone model to remove the relation between ModemUser and Modem.

Picking a ModemUser to "add" to the Modem.

We will be using client side search to populate a user interface for picking a ModemUser to add to the Modem. To do this we will need an endpoint which returns all eligible users.

Create a ModemUser ModelMap

<model name="modem-users">
  <query from="user" type="table">
   <addProperty key="id" field="objid" dataType="int" propertyType="identifier" />
   <traverseRelation name="user2employee">
     <addProperty key="firstName" field="first_name" dataType="string" />
     <addProperty key="lastName" field="last_name" dataType="string" />
     <addProperty key="name" dataType="string">
      <addTransform name="stringConat">
        <addArgument name="string0" property="firstName" />
        <addArgument name="string1" property="lastName" />
      </addTransform>
     </addProperty>
     <addProperty key="phone" field="phone" dataType="string" />
   </traverseRelation>
  </query>
</model>

Create the endpoint

The route for this endpoint will be GET custom/users/ and will return all users in the system who are active.

//NOTE : Your endpoint's constructor will need to take a dependency on IModelBuilder
public ModelData[] get_custom_users(GetAllUsersRequest request)
{
  return _modelBuilder.Get("modem-users", f=>f.Equals("status", 1);
}

Finally we add the InputModel for the request.

public class GetAllUsersRequest   { }

Cavet

Be careful when retrieving all (or in this case most) of an entity's objects unfiltered to the client. 100s or maybe 1000s of results are likely ok and will not get too large. *multiple 10s of thousands to millions is likely a bad idea and you should look at Dovetail Seeker or database filtering to limit things.

Frontend Picker

Now that we can get a list of all the users in the system, let's build a picker that allows you select one of the users. We are going to need to create a couple of new modules for this.

First, we need to create an addModemUser module. Remember that modules should have a single concern. the modemUsers module's concern is to display a list of users. This module's concern will be to add a user.

define([
  'app',
  'jquery',
  'underscore',
  'marionette',
  'app/custom/modemUserPicker',
  'hbs!templates/modem/addModemUserTpl'
],
function(app, $, _, Marionette, modemUserPicker, addModemUserTpl) {
  'use strict';

  var AddModemUser = Backbone.Model.extend({
    url: function() {
      return app.root + 'modems/' + this.get('modemId') + '/users';
    },

    parse: function(res) {
      return res.target;
    }
  });

So far, simple stuff. Basic Model that contains the url to POST to, and a parse method to handle the POST's response. Here's a look at the addModemUserTpl we'll use for the view:

<div class="form row-fluid">
  <div class="row-fluid">
    <div class="span4 control-group">
      <label for="userPickerId" class="control-label required">User</label>

      <div class="input controls">
        <table class="select-button-combo">
          <tr>
            <td class="select2-select-cell">
              <div class="user-picker"></div>
            </td>
          </tr>
        </table>
      </div>
    </div>
  </div>

  <div class="row-fluid">
    <button class="btn btn-primary btn-add-user">Add User</button>
    <button class="btn btn-default btn-cancel">Cancel</button>
  </div>
</div>

Most of this structure is Bootstrap classes for correct styling. The main element to notice is the <div class="user-picker"></div> that we'll use to attach a region to. This region will later be used to render the actual user picker.

  // Using a layout so that we can leverage regions
  var AddModemUserView = Marionette.Layout.extend({
    template: addModemUserTpl,

    regions: {
      pickerRegion: '.user-picker'
    },

    events: {
      'click .btn-add-user': 'submit',
      'click .btn-cancel': 'cancel'
    },

Again, simple View setup that we've done before. Now let's call our picker module that we'll create in a little bit. We'll do this in the onShow method. onShow is called during the rendering of the view, once the view is actually attached to the DOM. This allows for our region to be valid. If we were to try to use the region before the element it is attached to was in the DOM, we'd get an error.

    onShow: function() {
      var self = this;

      // Show our picker
      var picker = modemUserPicker.show(this.pickerRegion);

      // The picker will fire a 'change' event with a user has been picked
      this.listenTo(picker, 'change:userPickerId', function(model) {
        // Assign the picked userId to our addModemUser model
        self.model.set('userId', model.get('userPickerId'));
      });
    },

Now let's handle the user interaction in the form:

    // Executed when user clicks on the 'Save' button
    submit: function(e) {
      // Stub (filled in later)
    },

    // Remove the view from the DOM
    cancel: function(e) {
      e.preventDefault();

      this.destroy();
    }
  });

And finally, a basic controller:

  var Controller = Marionette.Controller.extend({
    show: function(region, id) {
      var addModemUser = new AddModemUser({
        modemId: id
      });

      var addModemUserView = new AddModemUserView({ model: addModemUser });

      region.show(addModemUserView);

      return addModemUser;
    }
  });

  var controller = new Controller();
  return controller;
});

Now that we have our addModemUser module that will contain the form to add a user, we need to create our modemUserPicker module that will handle presenting all of the possible users and the picking of one of them:

define([
  'app',
  'jquery',
  'lunr',
  'underscore',
  'marionette',
  'app/core/searchDropdown',
  'hbs!templates/modem/pickers/userPickerTpl',
  'hbs!templates/modem/pickers/userSearchResultTpl'
],
function(app, $, lunr, _, Marionette, searchDropdown, userPickerTpl, userSearchResultTpl) {
  'use strict';

  var UserPicker = Backbone.Model.extend({
    url: function() {
      return app.root + 'users/';
    },

    parse: function(res) {
      this.set('users', res);
    }
  });

The ItemView for this picker will handle most of the logic of the module. It will use a Select2 to allow for easy filtering of the list of users, and lunr.js for client-side searching.

First, here's the template for the picker:

<input name="UserId" id="user-picker-id" class="pick-user">

Simple right? All we need is an input field and the Select2 dropdown will handle populating the rest of the DOM correctly.

Now let's set up the ItemView. This will fetch the users from the backend, index them in lunr.js and create a select2 dropdown for easy filtering:

  var UserPickerView = Marionette.ItemView.extend({
    className: 'select2-inline-form',
    template: userPickerTpl,

    onRender: function () {
      var self = this;

      // Retrieve all the users from the system
      this.model.fetch({
        success: function() {
          // Set up lunr's index
          self.index = lunr(function() {
            this.ref('id');
            this.field('name', { boost: 10 });
            this.field('phone');
            this.field('email', { boost: 5 });
          });

          // Add all the returned users to the index
          self.model.get('users').forEach(function(user) {
            self.index.add({
              id: user.id,
              name: user.name,
              phone: user.phone,
              email:  user.email
            });
          });

          // Create the Select2 dropdown
          self.userSearch = new searchDropdown.Dropdown(self.$('#user-picker-id'), {
            allowClear: false,
            minimumInputLength: 0,

            // Main method that is called every time the user inputs data into
            // the field.
            query: function(options) {
              var results = [];
              var users = self.model.get('users');

              // If there is no query, just show all of the items
              if (_.isEmpty(options.term)) {
                options.callback({ results: self.model.get('users') });
                return;
              }

              // Get the search results from lunr for the term. These will only
              // be the ref's of the results, not the actual user objects
              var searchResults = self.index.search(options.term);

              // Iterate through each result, find the user that is associated
              // with the ref (id), and add the user to the results array
              _.each(searchResults, function(result) {
                var user = _.find(users, function(u) {
                  return u.id.toString() === result.ref;
                });

                results.push(user);
              });

              // Give command back to Select2 to render the results
              options.callback({ results: results });
            },

            // Called to render each search result
            formatResult: function(user) {
              return userSearchResultTpl(user);
            },

            // Called to render the selection inside the input box
            formatSelection: function(user) {
              // This will fire the 'change' event we are listening for in
              // addModemUser
              self.model.set('userPickerId', user.id);

              return _.escape(user.name);
            }
          });
        }
      });
    }
  });

  // Basic controller
  var Controller = Marionette.Controller.extend({
    show: function(region, id) {
      var userPicker = new UserPicker({ id: id });
      var userPickerView = new UserPickerView({ model: userPicker });

      region.show(userPickerView);

      // Very important for event propagation
      return userPicker;
    }
  });

  var controller = new Controller();
  return controller;
});

And there we go. Now we have client-side search for the users. When we select a user, an event on the model will be fired that the parent module is listening for. This allows the parent to properly set the value of its model and prepare it for submission. One more thing we need to do is enable the addModemUser module in the modemUsers module:

  // Replace the existing CollectionView with the new CompositeView
  var ModemUsersView = Marionette.CompositeView.extend({
    template: userTpl,

    itemView: ModemUserView,
    itemViewContainer: '.users',

    // Creates a custom region and calls 'show' on the addModemUser module, then
    // listens for a 'user:added' event so that we can add any added user to the
    // list
    addUser: function() {
      var self = this;

      var addUserRegion = new Marionette.Region({ el: '.add-user' });
      var addUser = addModemUser.show(addUserRegion, this.collection.id);

      this.listenTo(addUser, 'user:added', function(user) {
        // We want to make the Model type that our collection expects with the
        // attributes of the new user
        var modemUser = new ModemUser(user.attributes);
        self.collection.add(modemUser);

        self.stopListening(addUser);
      });
    }
  });

And we also need to add an addUser method to our controller (the module api):

    addUser: function() {
      this.modemUsersView.addUser();
    }

And lastly, call the modemUsers.addUser method from the tab callback:

  callbackPrivilege: 'Admin',
  callbackTitle: 'Add a User',

  callback: function() {
    modemUsers.addUser();
  }

Now we have our frontend setup to fetch all the users and present them in a picker format for easily choosing which one to add to the Modem. Next, we'll cover how to actually submit that data.

Adding a ModemUser

We've provided a way to select a Modem but we need an action that from the back end's point of view relates a modem to a user and from the front end's point of view creates a ModemUser.

Create an endpoint to relate a Modem to a User

The action we will create will be a POST to this URL /custom/modems/{Id}/users. You may have noticed this URL is the same one we previously used to GET all of a given modem's users. This is fine as we are attempting to be RESTful and treat this url as a way to get and modify the state of the web application.

The endpoint action below contains the Dovetail SDK code to relate two records.

//NOTE: your endpoint will need to take a dependency on IClarifySession and IModelBuilder<ModemUser>

public AjaxContinuation post_custom_modems_ModemId_users(AddModemUserRequest request)
{
  var dataset = _session.CreateDataSet();
  var modemGeneric = dataset.CreateGeneric("modem");
  var modemRow = modemGeneric.AddForUpdate(request.ModemId);
  modemRow.RelateByID(request.UserId, "modem2user");
  modemRow.Update();

  var user = _modemUserBuilder.GetOne(request.UserId);

  return new AjaxContinuation()
           .ForSuccess(user)
           .AddMessage("Added {0} to modem.".ToFormat(user.Name));
}

This code is simple for this example. Normally we would put this code into another class called a Toolkit and make it a bit more bulletproof around possible failures.

Finally we create the input model which takes both the Modem and User identifiers.

public class AddModemUserRequest
{
  public int UserId { get; set; }
  public int ModemId { get; set; }
}

Frontend

If you remember in the addModemUser module, we had a stub for submitting our data, submit. Let's fill that in now:

submit: function(e) {
  e.preventDefault();
  var self = this;

  // Since our model already has the information we need, namely userId, all we
  // need to do is submit it
  this.model.save({}, {
    success: function() {
      // Trigger a custom event so that parent modules know a user was added.
      // This is used by the modemUsers module to add the new user to the list
      self.model.trigger('user:added', self.model);

      // Remove the add user form from the DOM
      self.destroy();
    },

    error: function(res) {
      // Report that an error occurred. This can be filled in with more details
      // if desired
      console.log('error');
    }
  });
}

Before we continue, let's review why our model is ready for submission when the user clicks 'Save'. It starts in the modemUserPicker:

formatSelection: function(user) {
  self.model.set('userPickerId', user.id);

  return _.escape(user.name);
}

The self.model.set(...) command will fire a change event on the model. Now looking at addModemUser:

this.listenTo(picker, 'change:userPickerId', function(model) {
  self.model.set('userId', model.get('userPickerId'));
});

We are listening for that change event to happen and then setting our model's userId to be the new value. Now our model is ready for submission.

This shows how events can be used to propagate data upstream. It is perfectly ok for a parent to call into a child module, but in order to have loose coupling between the models, we try to not allow child modules to call into parent ones. Instead, we leverage events to do that communication. This allows for the child module to be reused by different parents, some of which might care about the event, and some might not. If the child called into the parent, we'd have to ensure that each parent had the expected method, even if it didn't care about the action.

To read more on Backbone events, see the documentation.

Removing a ModemUser

Heading down the home stretch we will show you how to add support for from the back end's point of view un-relating a Modem from a User and from the point of view of the front end destroying the ModemUser model.

Create an endpoint to unrelated a Modem and User

Note: Your endpoint will need to take a dependency on IClarifySession for doing this database updating.

The action we will create will be a DELETE to this URL /custom/modems/{ModemId}/users/{UserId}.

The endpoint action below contains the Dovetail SDK code to un-relate two records.

public AjaxContinuation delete_custom_modems_ModemId_users_UserId(RemoveModemUserRequest request)
{
  var dataset = _session.CreateDataSet();
  var modemGeneric = dataset.CreateGeneric("modem");
  var modemRow = modemGeneric.AddForUpdate(request.ModemId);
  var userGeneric = dataset.CreateGeneric("user");
  var userRow = userGeneric.AddForUpdate(request.UserId);
  modemRow.Unrelate(userRow, "modem2user");
  modemRow.Update();

  return new AjaxContinuation
  {
    Success = true,
    Message = "User removed from modem {0}".ToFormat(request.ModemId)
  };
}

Again, we really don't do much error handling here. This code is very simple for learning purposes.

Finally create the input model which like the Add action takes both the Modem and User identifiers.

public class RemoveModemUserRequest
{
  public int UserId { get; set; }
  public int ModemId { get; set; }
}

Frontend

The frontend is quite straightforward as well. All we need to do is add a button to each user entry and process that click.

userItemTpl.hbs

<div class="user">
  <div class="name">{{ name }}</div>
  <div class="phone">{{ phone }}</div>
  <div class="email">{{ email }}</div>

  <!-- Adding a 'remove' button -->
  <div>
    <button class="btn btn-primary remove-user">Remove</button>
  </div>

  <br />
</div>

Now let's process that click in our view:

var ModemUserView = Marionette.ItemView.extend({
  template: userItemTpl,

  events: {
    'click .remove-user': 'removeUser'
  },

  removeUser: function(e) {
    e.preventDefault();

    // This will call DELETE '/custom/modems/1/users/{model.id}', and then remove the
    // model from the collection
    this.model.destroy();
  }
});

And that's all we need. Now we can succesfully remove users from a modem.

Clone this wiki locally