Let's talk architecture. What you have here is a web flow. Ideally, what you want is to route to something we might call a WebFlowMediator
. You would probably call it RegistrationMediator
in your case. I'm going to stick to generic terms for the sake of simplicity.
You would then create views to be mediated by the WebFlowMediator
:
- registrationStep1.html, registrationStep1.js
- registrationStep2.html, registrationStep2.js
- registrationStep3.html, registrationStep2.js
Your mediator is essentially a finite state machine (FSM), moving between the state of being in step 1, the state of being in step2, etc., and perhaps other states.
We use this exact approach with our Datepicker. take a look at this video, and then look at the code below. Don't be intimidated by the code below. Pay particular attention to the line that starts with this.stateMachine = new machina.Fsm({
, and then, within the state machine, look at the states
property.
The Datepicker is doing exactly what you're doing: collecting up values from different steps. With a Datepicker, we're collecting a day/month/year from the first view, but then the user may wish to change just the month, or just the year. The mediator is responsible for assembling the information into a human-readable date, and then passing it out to the client.
We use PostalJS, MachinaJS, and the PostalJS plugin for MachinaJS. The latter is really powerful: it converts messages on the bus to internal state machine events. The gentleman who maintains these projects, Jim Cowart, is on the Telerik team.
NOTE: The individual views managed by the mediator are not child routes. They are compositions that are dynamically revealed or hidden based on the value in the currentView
observable. We composited the views this way so that all of them would be loaded into memory at once to improve performance. You could just as easily do this:
<div data-bind="compose: {
model: currentViewModel(),
view: currentView(),
activate: true,
activationData: activateWith }"></div>
</div>
Notice that the model and view themselves are observables.
THE BENEFIT
The benefit this approach is extensibility. You can add views without much effort, and you can even at states that allow for backpedaling, invalid responses, the notion of save for later, etc.
CODE
datepickerMediator.js
/**
* The `DatepickerMediator` sits in between the `DatepickerWidget` and each of the `DatepickerWidget`'s views, telling the views how to activate, listening to requests
* from the views that might be of interest to the `DatepickerWidget`, and handling the `DatepickerWidget`'s change of state. *State* in this case refers the current
* view, the animation associated with transitioning to that view, and what the user has selected so far from each view visited by the user.
*
* As its name suggests, the `DatepickerMediator` uses the Mediator Pattern (GoF). In that sense, it holds a reference to both the `DatepickerWidget` and each of the
* views (Month, Months, and Years). The reference, however, is merely a message bus channel. There is no hard reference. Consequently, we have high cohesion and loose
* coupling.
*
* An **internal message bus channel** creates a closed bus between the `DatepickerMediator` and each of its views. An **outlet message channel** creates a closed bus between
* the `DatepickerMediator` and the `DatepickerWidget`. The views cannot access the `DatepickerWidget`'s outlet message channel directly. Any message that must be
* passed up to the `DatepickerWidget` from the views must be relayed, first, by the `DatepickerMediator`. That is why, for example, the `DatepickerMediator` both subscribes
* *and* publishes a `popover_resize` message.
*
* {@img datepicker-relationship-diagram.png}
*
* @class widgets.common.datetime.DatepickerMediator
* @requires constants
* @requires knockout
* @requires moment
* @requires postal
* @requires messageTopics
* @requires underscore
* @requires utils
* @requires machina
* @requires machina.postal
*/
define(
[
'appservices/constants',
'knockout',
'moment',
'postal',
'messageTopics',
'underscore',
'utils',
'machina',
'machina.postal'
],
function (
k,
ko,
moment,
postal,
messagetopics,
_,
utils,
machina
) {
/**
* @method constructor
* Creates a new `DatepickerMediator`.
*/
var DatepickerMediator = function () {
var _self = this;
/**
* @property {Array} subscriptions
* Holds all of the subscriptions subscribed by the `DatepickerMediator`.
*/
this.subscriptions = [];
//TODO: Consider renaming to outlet
/**
* @property {Object} content
* Outlet to `DatepickerWidget`.
*/
this.content = {
outletMessageChannel: '',
queryable: null
};
/**
* @property {String} currentView
* String representation of view currently displaying in the `DatepickerWidget`.
* @observable
*/
this.currentView = ko.observable('');
//TODO: Consider renaming to activationParams
/**
* @property {Object} activateWith
* Pass-through activation parameters to the current view composition.
*/
this.activateWith = {
internalMessageChannel: utils.uniquify('widget/datepicker/'),
keyboardContext: ''
};
/**
* @property {machina.Fsm} stateMachine
* Finite state machine (FSM) that drives the changing of the `DatpickerWidget` views.
*
* The state machine has internal methods that are used to manipulate its state internally, and safely from outside the machine:
*
* + getSelectedDateRaw
* + getSelectedDateAsMoment
* + getSelectedDate
* + setSelectedDate
* + setSelectedMonth
* + setSelectedYear
* + setSelectedDay
* + publishViewReady
*/
this.stateMachine = new machina.Fsm({
initialState: k.uninitialized,
//Here, machina allows us to hook into the message bus
namespace: _self.activateWith.internalMessageChannel,
//An object that holds the constituent parts of the selected date
_currentSelectedDate: {
day: 0,
month: 0,
year: 0
},
//Returns the currently selected date in its three
//constituent parts
getSelectedDateRaw: function () {
return this._currentSelectedDate;
},
//Returns the currently selected date as a moment
getSelectedDateAsMoment: function () {
var _selectedDate = this._currentSelectedDate,
_fromArray = [
_selectedDate.year,
_selectedDate.month,
_selectedDate.day
];
//If all constituent parts of the selectedDate
//are 0, return the moment of the current date/time
if (_.reduce(_fromArray,
function (num, memo) {
return num + memo;
}, 0) === 0) {
return moment();
}
//return the selectedDate as a moment
return moment(_fromArray);
},
//Returns the currently selected date in all three formats--
//raw, as a moment, and as a native Date--as a convenience
getSelectedDate: function () {
return {
selectedDate: this.getSelectedDateRaw(),
selectedDateAsMoment: this.getSelectedDateAsMoment(),
selectedDateAsDate: this.getSelectedDateAsMoment().toDate()
};
},
//Break a dateObject into its constituent parts
setSelectedDate: function (dateObject) {
var _moment = dateObject || moment(), //Assume dateObject is a moment
_selectedDate = this._currentSelectedDate;
//If the date object is not a moment, make it one
if (!moment.isMoment(dateObject)) {
_moment = moment(dateObject);
}
//Throw if the dateObject is not a valid moment
if (!_moment.isValid(_moment)) {
throw 'DatepickerMediator: Invalid selected date.';
}
_selectedDate.day = _moment.date();
_selectedDate.month = _moment.month();
_selectedDate.year = _moment.year();
},
//Set the selected month: @month can be a string or a number
setSelectedMonth: function (month) {
//We must test this way since a month of 0 is valid, which is falsey.
var _hasMonthParam = !_.isUndefined(month) && !_.isNull(month) && month !== "";
this._currentSelectedDate.month = _hasMonthParam
? moment().month(month).month()
: moment().month();
},
//Set the selected year
setSelectedYear: function (year) {
this._currentSelectedDate.year = year
? moment().year(year).year()
: moment().year();
},
//Set the selected month: @month can be a string or a number
setSelectedDay: function (day) {
this._currentSelectedDate.day = day;
},
//Publish the selected date with all constituent parts
publishViewReady: function () {
postal.publish({
channel: _self.activateWith.internalMessageChannel,
topic: messagetopics.view_ready + this.state,
data: this.getSelectedDate()
});
},
states: {
'uninitialized': {},
'initialized': {
_onEnter: function () {
this.setSelectedDate(_self.content.queryable());
},
},
'month': {
_onEnter: function () {
_self.currentView(this.state);
if (this.priorState !== k.initialized) {
_self.publishZoom(k.in);
}
this.publishViewReady();
},
'/view/change/months': function (newMoment) {
var s = newMoment.selection;
//When transitioning to the months state, we need
//to set both the month and the year since both are
//relevant in the months state
this.setSelectedMonth(s.month());
this.setSelectedYear(s.year());
this.transition(k.months);
},
'/view/change/years': function (newMoment) {
this.setSelectedYear(newMoment.selection.year());
this.transition(k.years);
}
},
'months': {
_onEnter: function () {
_self.currentView(this.state);
_self.publishZoom(this.priorState === k.years ? k.in : k.out);
this.publishViewReady();
},
'/view/change/month': function (newMoment) {
var s = newMoment.selection;
this.setSelectedMonth(s.month());
this.setSelectedYear(s.year());
this.transition(k.month);
},
'/view/change/years': function (newMoment) {
this.setSelectedYear(newMoment.selection.year());
this.transition(k.years);
}
},
'years': {
_onEnter: function () {
_self.currentView(this.state);
_self.publishZoom(k.out);
this.publishViewReady();
},
'/view/change/months': function (newMoment) {
this.setSelectedYear(newMoment.selection.year());
this.transition(k.months);
}
}
}
});
};
/**
* @publishes animation_zoom
* An *animation zoom* message is used to tell a subscriber to perform a zoom animation (i.e. scale down or scale up). *
*/
/**
* @method publishZoom
* Publishes a message over the internal message channel telling the incoming view to zoom.
* @param {String} direction Direction of the zoom animation, *in* or *out*.
*/
DatepickerMediator.prototype.publishZoom = function (direction) {
postal.publish({
channel: this.activateWith.internalMessageChannel,
topic: messagetopics.animation_zoom,
data: {
direction: direction
}
});
};
/**
* @publishes popover_resize
* A *popover resize* message is used to tell a containing popover to resize itself based on changes in the popover's content.
*/
/**
* @method publishResizePopover
* Publishes a message over the outlet message channel telling the containing popover to resize.
*/
DatepickerMediator.prototype.publishResizePopover = function () {
postal.publish({
channel: this.content.outletMessageChannel,
topic: messagetopics.popover_resize
});
};
/**
* @publishes popover_close
* A *popover close* message is used to tell a containing to close based on a request from popover's content.
*/
/**
* @method publishClosePopover
* Publishes a message over the oulet message channel telling the containing popover to close.
*/
DatepickerMediator.prototype.publishClosePopover = function () {
postal.publish({
channel: this.content.outletMessageChannel,
topic: messagetopics.popover_close
});
};
//TODO: Create an activation contract
/**
* @method activate
* Durandal lifecycle handler. Read the Durandal documentation on `activate` and activation data [here](http://durandaljs.com/documentation/Hooking-Lifecycle-Callbacks.html#composition-lifecycle-callbacks).
* @param {Object} activationData Data to pass into this viewModel upon activation.
* @durandal
*/
DatepickerMediator.prototype.activate = function (activationData) {
this.subscribeAll();
//Import content
this.content.queryable = activationData.queryable;
this.content.outletMessageChannel = activationData.outletMessageChannel;
//Set the keyboard context for all layouts
this.activateWith.keyboardContext = activationData.keyboardContext;
//Transition to the initial state (FSM)
this.stateMachine.transition(k.initialized);
};
/**
* @method compositionComplete
* Durandal lifecycle handler. Read the Durandal documentation on `compositionComplete` [here](http://durandaljs.com/documentation/Hooking-Lifecycle-Callbacks.html#composition-lifecycle-callbacks).
* @param {HTMLElement} [view] The view (HTML) that corresponds to this viewModel.
* @param {HTMLElement} [parent] The view (HTML) that corresponds to the parent view of this child view.
* @durandal
*/
DatepickerMediator.prototype.compositionComplete = function () {
//We start out in the standard month view
this.stateMachine.transition(k.month);
};
/**
* @method detached
* Durandal lifecycle handler. Read the Durandal documentation on `detached` [here](http://durandaljs.com/documentation/Hooking-Lifecycle-Callbacks.html#composition-lifecycle-callbacks).
* @param {HTMLElement} [view] The view (HTML) that corresponds to this viewModel.
* @durandal
*/
DatepickerMediator.prototype.detached = function () {
utils.unregisterSubscriptions(this.subscriptions);
this.stateMachine.removeAllBusIntegration();
this.currentView = null;
this.stateMachine = null;
};
/**
* @method subscribeAll
* Register subscriptions with the message bus.
*/
DatepickerMediator.prototype.subscribeAll = function () {
var _mediator = this,
_internalMessageChannel = this.activateWith.internalMessageChannel,
_subscriptions = this.subscriptions;
/**
* @subscribes datepicker_executed
* Subscription responding to a date actually executed by the user. This date is published to the `DatepickerWidget` itself to insert into the input field.
*/
_subscriptions.push(postal.subscribe({
channel: _internalMessageChannel,
topic: messagetopics.datepicker_executed,
callback: function (data) {
/**
* @publishes widget_result
* A *widget result* message carries the payload of whatever the user selected.
*/
postal.publish({
channel: _mediator.content.outletMessageChannel,
topic: messagetopics.widget_result,
data: data.currentItem.asMoment.toString()
});
}
}));
/**
* @subscribes datepicker_dateSelected
* Subscription responding to a date selected by the user. Selection is not the same as execution. A user *selects* a date simply by navigating
* with the keyboard.
*/
_subscriptions.push(postal.subscribe({
channel: _internalMessageChannel,
topic: messagetopics.datepicker_dateSelected,
callback: function (data) {
_mediator.stateMachine.setSelectedDay(data);
}
}));
/**
* @subscribes datepicker_monthSelected
* Subscription responding to a month selected by the user. Selection is not the same as execution. A user *selects* a month simply by navigating
* with the keyboard.
*/
_subscriptions.push(postal.subscribe({
channel: _internalMessageChannel,
topic: messagetopics.datepicker_monthSelected,
callback: function (data) {
_mediator.stateMachine.setSelectedMonth(data);
}
}));
/**
* @subscribes datepicker_yearSelected
* Subscription responding to a year selected by the user. Selection is not the same as execution. A user *selects* a year simply by navigating
* with the keyboard.
*/
_subscriptions.push(postal.subscribe({
channel: _internalMessageChannel,
topic: messagetopics.datepicker_yearSelected,
callback: function (data) {
_mediator.stateMachine.setSelectedYear(data);
}
}));
/**
* @subscribes popover_resize
* Subscription responding to a request from the current view in the `DatepickerWidget` to resize the popover. This request is then republished
* up to the `DatepickerWidget`.
*/
_subscriptions.push(postal.subscribe({
channel: _internalMessageChannel,
topic: messagetopics.popover_resize,
callback: function () {
_mediator.publishResizePopover();
}
}));
/**
* @subscribes popover_close
* Subscription responding to a request from the current view in the `DatepickerWidget` to close the popover. This request is then republished
* up to the `DatepickerWidget`.
*/
_subscriptions.push(postal.subscribe({
channel: _internalMessageChannel,
topic: messagetopics.popover_close,
callback: function () {
_mediator.publishClosePopover();
}
}));
};
return DatepickerMediator;
}
);
datepickerMediator.html
<div>
<div data-bind="visible: currentView() === 'month'">
<div data-bind="compose: {
model: 'widgets/_common/datetime/datepickerMonth',
view: 'widgets/_common/datetime/datepickerMonth',
activate: true,
activationData: activateWith }"></div>
</div>
<div data-bind="visible: currentView() === 'months'">
<div data-bind="compose: {
model: 'widgets/_common/datetime/datepickerMonths',
view: 'widgets/_common/datetime/datepickerMonths',
activate: true,
activationData: activateWith }"></div>
</div>
<div data-bind="visible: currentView() === 'years'">
<div data-bind="compose: {
model: 'widgets/_common/datetime/datepickerYears',
view: 'widgets/_common/datetime/datepickerYears',
activate: true,
activationData: activateWith }"></div>
</div>
</div>