We mentioned that it was a good idea to design your application (starting with the data layer and then moving to the views) so that it's easier to understand the interactions that your controllers will have to deal with. When moving from the design to the code, the same applies, so we will write the user interface for this application and then later wire it up to the data via controllers.
First up, we need a viewport. We only have one page in the CMS, so the viewport is the container for all of the individual subviews (such as the tree and the detail panel). This application is fairly focused, so we're going to put all our views and associated classes under the ArchitectureCms.view.main.* namespace
. Here's the code for our ArchictureCms.view.main.Main
viewport:
// app/view/main/Main.js Ext.define('ArchitectureCms.view.main.Main', { extend: 'Ext.panel.Panel', requires: [ 'ArchitectureCms.view.main.Detail', 'ArchitectureCms.view.main.Tree' ], session: true, controller: 'main', viewModel: 'page', title: 'Architect CMS', bind: { title: 'Architect CMS - Currently Editing "{currentPage.text}"' }, layout: 'border', items: [ { xtype: 'page-detail', region: 'center', reference: 'detail' }, { xtype: 'page-tree', region: 'west', width: 300, reference: 'tree', split: true } ] });
This is mostly straightforward (we extend Ext.Panel
rather than Ext.Container
to give us support for a title bar). Next up, we require the view classes we're going to use in the viewport.
The session
option is set to true
. We'll discuss this in more detail shortly.
The view controller and view model are specified by their aliases; we'll create these classes later. Sencha Cmd knows that these are "auto-dependencies", so we will automatically require them without having to include them in the requires
array.
We create a default title, namely, Architect CMS
, but in the next line, we have our first use of the bind
option. Let's break down what's happening here. We've already specified a view model for this class and always have to bind to a value in a view model. Not only this, the bind
option is only triggered when the view model value changes, which is why we need to specify a default value via the title configuration. For the bind configuration, we specify the values we want to bind against (in this case title) and then provide a binding expression. Here, it's just a string. The segment in curly brackets determines the value on the view model to bind to. Later, we'll look at currentPage.text
and see how this gets set, but it will suffice for now to realize that when this value changes; it gets incorporated into the value for title. We'll see something like this:
Note that this will happen without having to wire up any event handlers. It's a little sprinkle of magic that reduces the boilerplate code we have to write.
Next up, we specify a border layout and then fill the items array with the tree and detail panel, referencing them by their xtype
. Thanks to our configuration of the requires
option, Ext JS is already aware of these classes, so we can use the aliases as shorthand.
Other than the binding configuration and a bit of auto-requiring magic, there's nothing special happening here. The key, in terms of application design, is the introduction of the binding concept in association with view models and view controllers. Hopefully, we've shown how these ideas can be introduced with barely any additional code.
Now that we've got our viewport container, we can introduce the views themselves. First, we'll look at the code for the tree that shows the page hierarchy:
// app/view/main/Tree.js Ext.define('ArchitectureCms.view.main.Tree', { extend: 'Ext.tree.Panel', xtype: 'page-tree', rootVisible: false, tbar: [ { xtype: 'textfield', emptyText: 'Search...', width: '100%', bind: { value: '{searchTerm}'}} ], bind: { store: '{pages}', searchFor: '{searchTerm}' }, config: { searchFor: null }, applySearchFor: Ext.emptyFn });
More binding expressions! One important thing to realize is that a view model declared on a high-level component, in this case our ArchitectureCms.view.main.Main
viewport, will cascade down and become available to child components. This means that our binding expressions in the tree will refer to the Page
view model we assigned to the main viewport. What customer requirement are we trying to fulfill by using binding in the tree?
We want to be able to search for a page and have it highlighted in the tree. To do so, when we type in textfield
, the value has to be passed to the tree. A traditional way of doing this would be to listen for a change event or keypress
on textfield
, then trigger a search
method on the tree. Rather than doing this manually, we can use data binding via a view model to achieve the same effect:
The searchTerm
value on the view model can flow back and forth between the searchFor
config on the tree and the value on textfield
. However, in this case, it's only one direction (from textfield
down to the tree).
In addition, we tell the tree to bind to the pages value on the view model; we know we're going to need a list of pages from somewhere.
The missing piece in this puzzle is the part that actually does the searching on the tree. Thanks to the Ext JS configuration system, any config
option that is specified also creates an applyConfigName
method on the class instance and this is called every time the config
option changes. This means that by creating applySearchFor
on the tree, every time searchFor
updates via its binding, we can run a piece of code to do something with the new value.
Note that we put a function placeholder in the last code snippet (the Ext.emptyFn
part). Here's the actual code we're going to use here:
applySearchFor: function(text) { var root = this.getRootNode(); var match = root.findChildBy(function(child) { var txt = child.get('text'), if(txt.match(new RegExp(text, 'i'))) { this.expandNode(child, true, function() { var node = this.getView().getNode(child); Ext.get(node).highlight(); }, this); } }, this, true); }
In brief, use a regular expression to do a case-insensitive match on the search term against the text of each tree node. If a match is found, expand the tree to this point and call its highlight
method to produce a visual cue.
The tree is used to browse the hierarchy of trees in the CMS, so we now need a way to look at the detail of each page. The detail pane is a panel containing a number of form fields:
Ext.define('ArchitectureCms.view.main.Detail', { extend: 'Ext.form.Panel', xtype: 'page-detail', defaultType: 'textfield', bodyPadding: 10, hidden: true, bind: { hidden: '{!currentPage}' }, items: [ { xtype: 'container', cls: 'ct-alert', html: 'This record is unsaved!', bind: { hidden: '{!isUnsavedPage}' } }, { fieldLabel: 'Id', bind: '{currentPage.id}', xtype: 'displayfield'}, { fieldLabel: 'Published', bind: '{currentPage.published}', xtype: 'checkboxfield' }, { fieldLabel: 'Label', bind: '{currentPage.text}' }, { fieldLabel: 'URL Stub', bind: '{currentPage.stub}' }, { fieldLabel: 'Body', bind: { value: '{currentPage.body}' }, xtype: 'htmleditor' } ], bbar: [ { text: 'Save', itemId: 'save' }, { text: 'Add Child Page', itemId: 'addChild' }, { text: 'Delete', itemId: 'delete' } ] });
Each of the form fields has a binding expression, which ties the field value to a value on the currentPage
object of the view model. When the user changes the field, the view model will automatically get updated. Note that we don't have to specifically state the property to bind to because form fields have their defaultBindProperty
set to value
.
The whole form panel has its hidden value bound to currentPage
, so if this value is not set, the panel will be hidden. This allows you to hide the form when no page is selected. We've also got a warning message as the first item in the panel, which will be hidden when the view model's isUnsavedPage
value changes to false
.
We've only written a little bit of code outside the UI configuration, yet with the addition of the view model, we'll already have a populated tree panel with search tied to a detail panel. Next, we'll look at the view model code itself.
This view model uses a simple formula to provide a calculated value to the view:
// app/view/main/PageModel.js Ext.define('ArchitectureCms.view.main.PageModel', { extend: 'Ext.app.ViewModel', alias: 'viewmodel.page', requires: ['Architecture.store.Pages'], stores: { pages: { type: 'pages', session: true } }, formulas: { isUnsavedPage: function(get) { return get('page.id').toString().indexOf('Unsaved-') > -1; } } });
Considering the functionality that this class enables, that's very little code. The store definition is fairly self-explanatory, just using the ArchitectureCms.store.Pages
alias to specify that the view model has a page value powered by this store.
The formulas definition is a little more interesting. It's a way of declaring that a value will be returned based on other values in the view model. In this case, as we specified on our model that newly created records would use a prefix of Unsaved-
, we can look for this to determine whether the record's been saved to the server or not. So, isUnsavedPage
returns true
or false
depending on whether the record's ID contains this prefix or not.
The only missing thing here is the currentPage
value. We can set arbitrary values on the view model. So, this gets set elsewhere in the controller. Before we talk about this, let's jump back to discuss a new concept in Ext JS 5: Ext.data.Session
.
An Ext.data.Session
is a way of centralizing data in an application, ensuring that stores are working with the same set of data without having redundant reloading. It also allows much easier batched updates and deletions.
In our application, we set session
, set to true
on our top-level viewport, which tells Ext JS to automatically create a session and make it available to any other code that requests it. This is the simplest way of constructing a session, although there's a lot more customization that we can do if need be.
The reason we use a session in this application is to allow us to link the data that the tree and the detail panel uses. This helps with data binding too; we can use exactly the same model instance in the tree and the detail panel, which means that updates made in the detail panel will flow through the view model and into the correct page instance in the tree. In a moment, when we look at our view controller, we'll use the session a little more and get a glimpse of how it can help manage your data.
In previous chapters, we've looked at how the controller can use event domains to hook into anything interesting happening elsewhere in the application. Here, we use the same approach, which we discussed previously, and get the controller to hook up a bunch of event handlers to deal with user actions in the user interface:
// app/view/main/MainController.js Ext.define('ArchitectureCms.view.main.MainController', { extend: 'Ext.app.ViewController', alias: 'controller.main', requires: ['ArchitectureCms.model.Page'], init: function() { this.listen({ component: { 'treepanel': { 'select': 'onPageSelect' }, 'page-detail #save': { click: 'onSaveClick' }, 'page-detail #addChild': { click: 'onAddClick' }, 'page-detail #delete': { click: 'onDeleteClick' } } }); }, onPageSelect: function(tree, model) { this.getViewModel().setLinks({ currentPage: { type: 'Page', id: model.getId() } }); }, onAddClick: function() { var me = this; Ext.Msg.prompt('Add Page', 'Page Label', function (action, value) { if (action === 'ok') { var session = me.getSession(), selectedPage = viewModel.get('currentPage'), tree = me.lookupReference('tree'), var newPage = session.createRecord('Page', { label: value, text: value, leaf: true }); selectedPage.insertChild(0, newPage); tree.setSelection(newPage); tree.expandNode(selectedPage); } }); }, onDeleteClick: function() { var me = this; Ext.Msg.confirm('Warning', 'Are you sure you'd like to delete this record?', function(btn) { if(btn === 'yes') { me.getViewModel().get('currentPage').erase(); me.getViewModel().set('currentPage', null); Ext.toast('Page deleted'), } }, this) }, onSaveClick: function() { this.getViewModel().get('currentPage').save(); Ext.toast('Page saved'), } });
This view controller handles the following four events:
select
event on the tree handled by the onPageSelect
methodclick
event on the detail panel's save button handled by onSaveClick
click
event on the detail panel's add child button handled by onAddChildClick
click
event on the detail panel's delete button handled by onDeleteClick
Some of this will be self-explanatory, some relates to data binding, and some relates to the session. Let's break down the important parts.
When the tree fires a select
event, the view controller's onPageSelect
method gets passed the model for the selected tree node. We mentioned earlier that we can set arbitrary values on the view model, specifically the currentPage
value, and so this is what we do here, but with a twist.
Rather than just setting the data, we give Ext JS a hint that we want to set a model instance by using the links configuration. By supplying the name of the model class and its ID, Ext JS will use the matching instance if it's already available in the current Ext.data.Session
or it'll automatically load it from the server. It's a handy shortcut for reducing the number of requests to the backend API and another example of how to use a session.
The view controller listens for events on a button with an item ID of #addChild
. When it fires, we ask the user for the name of the new page, and the next step is to actually create a page record. Rather than using Ext.create
, we call createRecord
on the current Ext.data.Session
, which allows you to continue to make Ext JS aware of the records we're managing. It also allows you to maintain a global understanding of the saved and unsaved records. This would be even more useful in an application where we need to do batch updates of records.
After creating a model instance, we follow the pseudocode we wrote earlier in the chapter, but tie it to actual Ext JS methods and add the page to the tree data structure before selecting it in the tree UI itself.
This is fairly straightforward (handle the click
event from the #delete
button and then grab the currentPage
from the view model). We also remove the leftover page from the view model so that the detail panel automatically clears itself, rather than leaving a dead record available to edit. We display a notification to the user with Ext.toast
.
This is even simpler (handling a click on the #save button, grabbing the currentPage
from the view model, and then calling its save
method). There's nothing special happening here. The only thing to note is that if this is a new record, the server will respond with a new ID and replace the one that Ext JS automatically allocated. Thanks to the binding to isUnsavedPage
on the view model, this will cause the "unsaved" message to disappear from the detail panel.