A State Machine for UI Control

Reusable Detail View Control

A common situation in web UIs is that you can select some item to view and edit its details. Selection can be done using treeviews, list, search masks and more.

Examples

Example using a TreeView for selection
Example using a TreeView for selection with detail view in edit mode and TreeView hence disabled
A web UI with a search mask on top and a detail mask below
Using a search mask for item selection

Detail View Behavior Model

When the displayed item is edited, the selection should be disabled. So there is a lot of stateful behavior:

  • User actions in the selection component change the content of the detail component.
  • User actions in the detail component
    • may disable the selection component
    • may enable or disable buttons

It turns out that the behavior model for many simple detail view components is similar. So it makes sense to provide a base class that captures this behavior. A simple state chart for the behavior model looks like this:

DetailView Control State Chart
State chart controlling a DetailView

Implementing Standard Detail View Behavior

The abstract Groovy class DetailViewBehavior.groovy serves as a control template for detail view implementations. It can be embedded as an (anonymous) inner class that controls view behavior. Abstract methods have to be overwritten to control specific view elements and communicate with the environment.

abstract class DetailViewBehavior {
    protected StateMachine<DVState, DVEvent> sm

    /**
     * initialize the state machine with
     * @param initState
     */
    void initSm(DVState initState) {

        sm = new StateMachine<DVState, DVEvent>(initState)

        // define state activities
        sm.addEntryAction(DVState.INIT, { clearFields(); initmode() })
        sm.addEntryAction(DVState.EMPTY, { emptymode() })
        sm.addEntryAction(DVState.SHOW, { showmode() })
        sm.addEntryAction(DVState.CREATEEMPTY, { createemptymode() })
        sm.addEntryAction(DVState.CREATE, { clearFields(); createmode() })
        sm.addEntryAction(DVState.EDIT, { editmode() })

        // define transition [optionally with activities]
        sm.addTransition(DVState.SUBVIEW, DVState.INIT, DVEvent.Init)
        sm.addTransition(DVState.INIT, DVState.SHOW, DVEvent.Select)
                {Object... params -> onItemSelected(params[0])}
        sm.addTransition(DVState.INIT, DVState.EMPTY, DVEvent.Root)
        sm.addTransition(DVState.TOPVIEW, DVState.EMPTY, DVEvent.Init)
        sm.addTransition(DVState.EMPTY, DVState.EMPTY, DVEvent.Root)
        sm.addTransition(DVState.EMPTY, DVState.CREATEEMPTY, DVEvent.Create)
        sm.addTransition(DVState.EMPTY, DVState.SHOW, DVEvent.Select)
                {Object... params -> onItemSelected(params[0])}
        sm.addTransition(DVState.CREATEEMPTY, DVState.SHOW, DVEvent.Save) {
            onCreateSave()
        }
        sm.addTransition(DVState.CREATEEMPTY, DVState.EMPTY, DVEvent.Cancel) {
            onCreateCancel()
        }
        sm.addTransition(DVState.SHOW, DVState.EDIT, DVEvent.Edit)
        sm.addTransition(DVState.SHOW, DVState.CREATE, DVEvent.Create)
        sm.addTransition(DVState.SHOW, DVState.SHOW, DVEvent.Select)
                {Object... params -> onItemSelected(params[0])}
        sm.addTransition(DVState.SHOW, DVState.EMPTY, DVEvent.Root)
        sm.addTransition(DVState.EDIT, DVState.SHOW, DVEvent.Save) {
            onEditSave(); onEditDone()
        }
        sm.addTransition(DVState.EDIT, DVState.SHOW, DVEvent.Cancel) {
            onEditCancel(); onEditDone()
        }
        sm.addTransition(DVState.CREATE, DVState.SHOW, DVEvent.Save) {
            onCreateSave()
        }
        sm.addTransition(DVState.CREATE, DVState.SHOW, DVEvent.Cancel) {
            onCreateCancel()
        }
    }

    void execute(DVEvent event, Object... params) {
        sm.execute(event, params)
    }
    /**
     * Initialize all fields for a new item to display or edit. Given is its id,
     * so its full dto can be addressed and requested by an appropriate service.
     * This method is usually called by the selector component.
     * @param itemId unique identifying key
     */
    @Deprecated
    protected void initItem(Long itemId) {}

    /** prepare for editing in CREATEEMPTY state */
    protected abstract void createemptymode()
    /** prepare for editing in CREATE state */
    protected abstract void createmode()

    /**
     * Any transition triggered by select event (i.e. a new item was selected in
     * the selector component) has to load the new item from the service layer
     * and load its attributes into the field components.
     * Deprecates initItem method
     * @param itemId unique identifying key
     */
    protected abstract void onItemSelected(Long itemId)
    /**
     * leaving CREATE or CREATEEMPTY state with save
     * saving created item to persistent storage,
     * typically by calling an appropriate service.
     */
    protected abstract void onCreateSave()
    /** leaving CREATE or CREATEEMPTY  state with cancel */
    protected abstract void onCreateCancel()
    /** prepare for editing in EDIT state */
    protected abstract void editmode()
    /** prepare INIT state */
    protected void initmode() {}
    /** prepare EMPTY state */
    protected abstract void emptymode()
    /** prepare SHOW state */
    protected abstract void showmode()
    /** clear all editable fields */
    protected abstract void clearFields()
    /** leaving EDIT state with cancel,
     * so reset all fields from the current full dto object */
    protected abstract void onEditCancel()
    /**
     * leaving EDIT state with save,
     * saving current item after editing to persistent storage,
     * typically by calling an appropriate service.
     */
    protected abstract void onEditSave()
    /**
     * When editing an item was finished [Save] or cancelled [Cancel], notify the selector
     * component to enable it and eventually update the items changed caption
     * @param itemId identifies edited item
     * @param caption eventually updated caption of the edited item
     * @param mustReload Component must reload after new item was created
     *        or (tree-) structure changed
     */
    protected abstract void onEditDone(boolean mustReload = true)
}

/**
 * <p>Defined states for DetailView state chart.</p>
 * <p>It is a choice of implementing classes, if editing existing items or creating new items
 * uses a dialog window (States DIALOG and CREATEDIALOG) or the same fields
 * as for show and edit (States EDIT, CREATE, CREATEEMPTY).<br>
 * States CREATEEMPTY ans DIALOGEMPTY were introduced, because the state model should
 * completely represent the state of the UI. So it can be avoided to have an additional
 * implicit state represented by a field that holds the current item or is empty.</p>
 * <p>To allow extensions to the state chart, three "spare" states ST1, ST2, ST3
 * are introduced. These states are not used by the default implementation.</p>
 */
enum DVState {
    /** creation state for detail views of sublevel objects*/
    SUBVIEW,
    /** creation state for detail views of toplevel objects*/
    TOPVIEW,
    /** nothing is selected in the controlling tree*/
    INIT,
    /** no object is selected for this view, but a root node is selected*/
    EMPTY,
    /** an object is selected and shown on the tab*/
    SHOW,
    /** starting from EMPTY (important for Cancel events!), create a new Object*/
    CREATEEMPTY,
    /** starting from SHOW (important for Cancel events!), create a new Object*/
    CREATE,
    /** selected object is being edited*/
    EDIT,
    /** spare unused state to allow state chart extensions*/
    XST1,
    /** spare unused state to allow state chart extensions*/
    XST2,
    /** spare unused state to allow state chart extensions*/
    XST3
}

/**
 * <p>Defined events for the DetailView state chart.</p>
 * <p>To allow extensions to the state chart, three "spare" events EV1, EV2, EV3
 * are introduced. These events are not used by the default implementation.</p>
 */
enum DVEvent {
    /** initialise state machine*/
    Init,
    /** an item of the displayed class was selected*/
    Select,
    /** new branch was selected by selecting another top level object or a subobject*/
    Root,
    /** start editing the selected object*/
    Edit,
    /** start creating a new object*/
    Create,
    /** cancel edit or create*/
    Cancel,
    /** save newly edited or created object*/
    Save,
    /** spare unused event to allow state chart extensions*/
    XEV1,
    /** spare unused event to allow state chart extensions*/
    XEV2,
    /** spare unused event to allow state chart extensions*/
    XEV3
}
Comments on Code
Line 80
Class DetailViewBehavior uses a StateMachine object to implement behavior. This object is protected so that it can be modified (e.g. added additional transitions or activities) by a derived class.
Lines 86 and 88
The state machine is created and initialized in public method initSm() with the appropriate start state. In the following lines, all onEntry activities and transitions are added as shown in the above state chart. For every state,  an onEntry Closure is provided. Also all transition have Closures. They all call methods, most of them abstract, that must be implemented in derived classes.
Line 133
Public method execute() directly delegates to the StateMachine instance.
Line 143
@Deprecated  method initItem(Long itemId)  was used to load a new object into the detail view. Its functionality is now refactored to transition method onItemSelected(Long itemId) . Given the itemId, a data transfer object for this object can be retrieved from the service layer. It will usually be stored in some local variable and its fields displayed in the view.
Lines 146, 148, 157, 163, 165, …, 175, 178, 184, 193
Protected methods implementing activities onEntry and on transitions. It was found that some activities are often unneccessary, so these have empty default implementations. The others are made abstract to enforce their implementation. See javadoc comments for more detailled descriptions.
Lines 207 … and 237 …
Enums defining states and events for the state machine. Due to Javas implementation of Enums, it is not simple to make states and events extendable without loosing the benefits of Enums. Therefor three „spare“ state (XST1, XST2, XST3) and event (XEV1, XEV2, XEV3) values are provided to allow some additional states and events for an extended state chart.

Integrating DetailViewBehavior into a Vaadin View

Class DetailViewBehaviour is intended to be used in a component subtree for displaying and editing details of some domain objects. The example uses SubTree as a base class for implementing the detail view. See full code of example class MilestoneDetailView on GitHub.
@SpringComponent
@UIScope
class MilestoneDetailView extends SubTree
        implements VaadinSelectionListener<ListItemDto>,
                Serializable {

Embedding an object derived from DetailViewBehaviour as an anonymous inner class, all ui elements of the surrounding class are accessible from DetailViewBehaviour methods. External method calls, e.g. selection of a different object in a selection component, are transformed into events sent to the behavior state machine.

    /**
     * is called when an entry of a selection component was selected
     *
     * @param event id of the selected element
     */
    @Override
    void onItemSelected(ListItemDto event) {
        currentItemId = event.id
        viewBehavior.execute(DVEvent.Select, currentItemId.second)
    }

    /**
     * View behavior is implemented using this specialised state machine
     */
    private DetailViewBehavior viewBehavior = new DetailViewBehavior() {

        /**
         * prepare EMPTY state:
         * All inputs disabled, only New button enabled
         */
        @Override
        protected void emptymode() {
            clearFields()
            currentDto = null
            [name, state, dateDue, subtaskSelect, saveButton,
             cancelButton, editButton].each { it.enabled = false }
            [newButton].each { it.enabled = true }
        }

        /** prepare for editing in CREATEEMPTY state */
        @Override
        protected void createemptymode() {
            prepareEdit()
            presetEmpty()
        }

        /** prepare for editing in CREATE state */
        @Override
        protected void createmode() {
            prepareEdit()
            presetEmpty()
        }

        /** prepare for editing in EDIT state */
        @Override
        protected void editmode() {
            prepareEdit()
        }

        /** prepare SHOW state */
        @Override
        protected void showmode() {
            [name, state, dateDue, subtaskSelect, saveButton,
             cancelButton].each { it.enabled = false }
            [newButton, editButton].each { it.enabled = true }
        }

        /**
         * Any transition triggered by select event (i.e. a new item was selected in
         * the selector component) has to load the new item from the service layer
         * and load its attributes into the field components.
         * Deprecates initItem method
         * @param itemId unique identifying key
         */
        @Override
        protected void onItemSelected(Long itemId) {
            currentDto = milestoneService.getMilestoneDetails(itemId)
            def selSub = milestoneService.getSubtaskSelectList(itemId)
            selectableSubtasks = selSub
            setFieldsFromDto()
        }

        /**
         * leaving CREATE or CREATEEMPTY state with save,
         * saving created item to persistent storage by calling
         * appropriate milestoneService method.
         */
        @Override
        protected void onCreateSave() {
            createOrUpdate(new Tuple2<String, Long>('Milestone', 0L))
            onEditDone(true)
        }

        /**
         * leaving CREATE or CREATEEMPTY  state with cancel
         * */
        @Override
        protected void onCreateCancel() {
            onEditDone(false)
        }

        /** leaving EDIT state with cancel,
         * so reset all fields from the current full dto object */
        @Override
        protected void onEditCancel() {
            setFieldsFromDto()
        }

        /**
         * leaving EDIT state with save,
         * saving current item after editing to persistent storage,
         * typically by calling an appropriate service.
         */
        @Override
        protected void onEditSave() {
            def mustReload = name.value != currentDto.args.name
            createOrUpdate(currentItemId)
            onEditDone(mustReload)
        }

        private void createOrUpdate(Tuple2<String, Long> id) {
            FullDto command = new FullDto()
            command.id = id
            def args = command.args
            command.args.name = name.value
            command.args.state = state.value
            command.related.subtask = subtaskSelect?.selectedItems.asList() ?: []

            currentDto = milestoneService.createOrUpdateMilestone(command)
            currentItemId = currentDto.id
            selectableSubtasks =
                    milestoneService.getSubtaskSelectList(currentItemId.second)
        }

        /**
         * When editing an item was finished [Save] or cancelled [Cancel], notify the
         * selector component to enable itself and eventually update the
         * items changed caption
         * @param itemId identifies edited item
         * @param caption eventually updated caption of the edited item
         * @param mustReload Component must reload after new item was created
         *        or (tree-) structure changed
         */
        @Override
        protected void onEditDone(boolean mustReload) {
            milestoneList.onEditItemDone(
                    currentItemId,
                    currentDto.tag ?: '',
                    mustReload)
        }

        /** clear all editable fields */
        @Override
        protected void clearFields() {
            state.deselectAll()
            subtaskSelect.dataProvider = DataProvider.ofCollection([])
            [name, dateDue].each { it.clear() }
        }

        /**
         * set fields and buttons ready for editing and inhibit
         * selection component
         */
        private void prepareEdit() {
            milestoneList.onEditItem()
            [name, state, dateDue, subtaskSelect, saveButton,
             cancelButton].each { it.enabled = true }
            [newButton, editButton].each { it.enabled = false }
        }

        /**
         * load selectable subtasks for new milestone
         */
        private void presetEmpty() {
            def all = milestoneService.getSubtaskSelectList(0L)
            subtaskSelect.dataProvider = DataProvider.ofCollection(all)
        }

        /**
         * set all dialog fields from currentDto values
         * provide default values to avoid vaadin exceptions
         */
        private void setFieldsFromDto() {
            name.value = currentDto.args.name ?: ''
            state.deselectAll()
            state.select currentDto.args.state ?: ''
            def select = currentDto.related.subtask
            def all = selectableSubtasks + select
            subtaskSelect.deselectAll()
            subtaskSelect.dataProvider = DataProvider.ofCollection(all)
            subtaskSelect.deselectAll()
            subtaskSelect.select(select.toArray())
        }

    }
}
Comments on code
Lines 176, 178
When a new object is selected for display, onItemSelected()  is called. The Id of the selected object is sent to the state machine as a parameter to DVEvent.Select by calling the execute method of viewBehavior.
Line 184
The controlling state machine viewBehavior is constructed as an object of the anonymous inner class derived from DetailViewBehavior. All abstract methods have to be implemented here. As methods of an inner class, they have full access to the Vaadin ui components built with VaadinBuilder.
Lines 191, 201, 208, 215, 221
Entry methods of states primarily prepare enabled state of all input components. Common functionality of editing and creation states is put into methods prepareEdit()  and presetEmpty() .
Line 235
Transition method that loads another object from the service layer into the detail view. It is triggered by a changed selection in the selection component.
Lines 235, 248, 274
Transition methods that send edited or created objects to the service layer for saving, triggered by an event from the Save button.
Lines 257, 264
Transition methods that reset UI state when Cancel button is pressed from editing or creating state.
Line 280
Here the actual save or create interaction with the service layer is done. Then the updated object is saved in the local currentDto  variable. Also the list of selectable objects for a toMany association is reloaded from the service layer.
Line 304
The superordinate selection component is notified that editing is finished. So it can be enabled again to allow new selections. Object attributes that are used as a tag in the selection component are passed to allow an update of selection lists or trees. If the structure has changed significantly, e.g. by creating a new object, the selection component is notified so that it can reload from the service layer.
Lines 313, 323, 333, 342
See the Javadoc comments on these helper methods.

Schreibe einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert.