-
Notifications
You must be signed in to change notification settings - Fork 0
Scenario Selecting, Adding, and Removing Modem Users
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.
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.
<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>
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 { }
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.
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.
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.
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; }
}
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.
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.
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; }
}
The frontend is quite straightforward as well. All we need to do is add a button to each user entry and process that click.
<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.
We hope you enjoyed this helpful Agent training document. If you see an error or omission please feel free to fork this repo to correct the change, and submit a pull request. Any questions? Contact Support.