Tab-Seite mit State Machine

Struktur der Tab-Seite

Screenshot vom Tab Backlog der Beispielapplikation
Tabseite zur Darstellung und Bearbeitung von Tasks

Die Benutzeroberfläche der Beispielanwendung ist mit Tab-Seiten aufgebaut, die jeweils eine Domain-Klasse darstellen und das Editieren erlauben. Die Tab-Seiten demonstrieren beispielhaft den Aufbau von größeren Bestandteilen der GUI. Dabei lassen sich konzeptionell mehrere Bereiche unterscheiden:

  • Aufbau und Layoutgestaltung der Steuerelemente mit dem VaadinBuilder
  • Interaktion mit anderen Klassen der GUI
  • Steuerung der Benutzerinteraktion mit einer State Machine

Aufbau und Interaktion

Die Tab-Seiten sind recht umfangreich und werden daher jeweils in einer eigenen Klasse implementiert. Dadurch wird eine gute Kohäsion erreicht. Eine UI-Klasse ist jeweils für Darstellung und Editieren von Objekten einer Domainklasse bzw. Klassenhierarchie zuständig. Sie verwalten dabei einen Teilbaum in der Komponentenhierarchie der Vaadin GUI. Um dies mit dem VaadinBuilder zu realisieren, erben sie von der Klasse SubTree (s. Beschreibung beim VaadinBuilder). Der statische Teil der Klassen besteht aus der Definition von Variablen für die Vaadin-Komponenten, dem Aufbau der UI mit dem VaadinBuilder und der Initialisierung der Variablen. Die Interaktion mit Objekten anderer UI-Klassen erfolgt einerseits über Listener, andererseits über Spring Dependency Injection (@Autowired). Dazu werden die Klassen als Spring Beans annotiert.

Beispiel

Aufbau der UI und Implementierung der Interaktionsfähigkeit bei der Klasse TaskTab.groovy

...
@SpringComponent
@UIScope
class TaskTab extends TabBase
        implements VaadinSelectionListener, VaadinTreeRootChangeListener,
                Serializable {

    public static final String TAG = 'tag'
    public static final String IS_SUPERTASK = 'isSupertask'
    public static final String IS_COMPLETED = 'isCompleted'
    public static final String ESTIMATE = 'estimate'
    public static final String SPENT = 'spent'
    public static final String DESCRIPTION = 'description'

    private TextField tag, estimate, spent
    private TextArea description
    private CheckBox supertask, completed
    private Button newButton, editButton, saveButton, cancelButton, subtaskButton

    private Map<String, Serializable> currentItemId
    private Map<String, Serializable> currentTopItemId
    private TaskDto.QFull currentDto
    private UI ui
    private Component topComponent

    private SubtaskDialog dialog = new SubtaskDialog()

    @Autowired
    private TaskService taskService
    @Autowired
    private ProjectTree projectTree


    @Override
    Component build() {
        topComponent = vaadin."$C.vlayout"('Backlog',
                [spacing: true, margin: true]) {
            "$F.text"('Aufgabe', [uikey: TAG])
            "$C.hlayout"('Status', [spacing: true, margin: false]) {
                "$F.checkbox"('übergeordnet', [uikey: IS_SUPERTASK])
                "$F.checkbox"('abgeschlossen', [uikey: IS_COMPLETED])
            }
            "$F.text"('Schätzung', [uikey: ESTIMATE])
            "$F.text"('aktueller Verbrauch', [uikey: SPENT])
            "$F.textarea"('Beschreibung', [uikey: DESCRIPTION])
            "$C.hlayout"([uikey       : 'buttonfield', spacing: true,
                          gridPosition: [0, 3, 1, 3]]) {
                "$F.button"('New',
                        [uikey         : 'newbutton',
                         disableOnClick: true,
                         clickListener : { sm.execute(Event.Create) }])
                "$F.button"('Edit',
                        [uikey         : 'editbutton',
                         disableOnClick: true,
                         clickListener : { sm.execute(Event.Edit) }])
                "$F.button"('Subtask',
                        [uikey         : 'subtaskbutton',
                         disableOnClick: true, enabled: false,
                         clickListener : { sm.execute(Event.Dialog) }])
                "$F.button"('Cancel',
                        [uikey         : 'cancelbutton',
                         disableOnClick: true,
                         enabled       : false,
                         clickListener : { sm.execute(Event.Cancel) }])
                "$F.button"('Save',
                        [uikey         : 'savebutton',
                         disableOnClick: true, enabled: false,
                         clickShortcut : ShortcutAction.KeyCode.ENTER,
                         styleName     : Reindeer.BUTTON_DEFAULT,
                         clickListener : { sm.execute(Event.Save) }])
            }
        }
        topComponent
    }

    @Override
    void init(Object... value) {
        uiComponents = vaadin.uiComponents
        tag = uiComponents."$subkeyPrefix$TAG"
        estimate = uiComponents."$subkeyPrefix$ESTIMATE"
        spent = uiComponents."$subkeyPrefix$SPENT"
        description = uiComponents."$subkeyPrefix$DESCRIPTION"
        completed = uiComponents."$subkeyPrefix$IS_COMPLETED"
        supertask = uiComponents."$subkeyPrefix$IS_SUPERTASK"
        newButton = uiComponents."${subkeyPrefix}newbutton"
        editButton = uiComponents."${subkeyPrefix}editbutton"
        saveButton = uiComponents."${subkeyPrefix}savebutton"
        cancelButton = uiComponents."${subkeyPrefix}cancelbutton"
        subtaskButton = uiComponents."${subkeyPrefix}subtaskbutton"
        projectTree.selectionModel.addListenerForKey(this, 'Task')
        projectTree.selectionModel.addRootChangeListener(this)
        // find the top level Vaadin Window
        ui = getVaadinUi(topComponent)
        // build dialog window
        dialog.build()
        // build state machine
        sm = new TabViewStateMachine(TabViewStateMachine.State.SUBTAB, 'TskTab')
        configureSm()
        sm.execute(Event.Init)
    }

    @Override
    void onItemSelected(Map<String, Serializable> taskItemId) {
        currentItemId = taskItemId
        initItem((Long) currentItemId['id'])
        sm.execute(Event.Select)
    }

    @Override
    void onRootChanged(Map<String, Serializable> projectItemId) {
        currentTopItemId = projectItemId
        sm.execute(Event.Root)
    }
Erläuterungen zum Code
Zeilen 26-27: Interaktion
Die Klasse wird als Spring Bean annotiert. Die Annotation @SpringComponent wird durch die Spring-Vaadin Integration eingeführt und ist gleichbedeutend mit Spring @Component. Da auch Vaadin eine @Component Annotation definiert, wird eine Doppeldeutigkeit vermieden.
@UIScope legt fest, dass Spring Beans von dieser Klasse jeweils im Zugriffsbereich und mit der Lebensdauer einer Vaadin UI Session anlegt.
Zeilen 32-37: Aufbau
Symbolische Konstanten zum Zugriff auf die Map der Vaadin Komponenten
Zeilen 39-42: Aufbau
Variablen zum vereinfachten lokalen Zugriff auf die Vaadin-Komponenten
Zeilen 44-48: Aufbau
Einige Variablen, in denen Informationen über das aktuell dargestellte Objekt abgelegt wird
Zeilen 52-53: Architektur
Zugriff auf die Service Bean, die entsprechend dem Architekturmodell für die Kommunikation mit dem Applikationskern zuständig ist
Zeilen 54-55: Interaktion
Zugriff auf eine andere UI-Bean
Zeilen 58-59: Aufbau
In der build() Methode wird Teilbaum von Vaadin Komponenten für diese Tab-Seite mit dem VaadinBuilder aufgebaut
Zeilen 75, 79, 83, 88, 94: Dialogsteuerung
Die hier angelegten Listener erzeugen Ereignisse für die State Machine und verbinden damit die Vaadin Komponenten mit der Dialogsteuerung
Zeilen 100-101: Aufbau
Die init() Methode verbindet die lokalen Variablen mit den in build() angelegten Vaadin Komponenten und führt weitere Initialisierungen durch, die im VaadinBuilder nicht möglich sind
Zeilen 114, 115: Interaktion
Das Objekt wird als Listener bei dem ProjectTree Objekt registriert, damit auf Änderungen der Selektion im Tree reagiert werden kann
Zeilen 126-127 und 133-134: Interaktion, Dialogsteuerung
Hier werden die Listener callback Methoden implementiert, die wieder Events für die State Machine erzeugen und Variablen aktualisieren

Dialogsteuerung mit der DialogStateMachine

Das Zustandsmodell aller Seiten ist (fast) identisch, daher ist es in einer gemeinsamen Basisklasse implementiert.

Events kommen aus zwei möglichen Quellen:

  • von den verschiedenen Widgets innerhalb der Tab-Seite:
    • create und edit, wenn Objekte angelegt oder bearbeitet werden sollen
    • save oder cancel, wenn die Veränderungen gespeichert oder die Bearbeitung abgebrochen werden sollen
    • dialog, wenn zu einem Task ein neuer Subtask angelegt werden soll
  • vom Treeview neben den Tabs, in dem die Domain-Objekte ausgewählt werden können:
    • root, wenn ein Projekt ausgewählt wurde, ohne ein bestimmtes Domain-Objekt der angezeigten Tab-Seite auszuwählen
    • select, wenn ein Domain-Objekt im Treeview ausgewählt wurde

Es gibt zwei unterschiedliche Startzustände:

  • TOPTAB für die Projekt-Seite. Da man Projekte neu angelegen können soll, ist der New-Button immer aktiviert. Die GUI beginnt also im State EMPTY, in dem kein Objekt angezeigt wird, aber neue Objekte angelegt werden können (Event create -> CREATEEMPTY)
  • SUBTAB für die anderen Seiten. Tasks im Backlog und Sprints sollen immer einem Projekt zugeordnet sein, daher muss erst ein Projekt feststehen, bevor ein neues Objekt angelegt werden kann.

Bemerkung: Die Zustände CREATE und CREATEEMPTY unterscheiden sich nur durch das Ziel des cancel-Events. Wenn die DialogStateMachine wie in UML State Charts einen History-State implementieren würde, könnte man beide States zusammenfassen.

Implementierung

Klassenmodell der State Machines und Views
State Machines und Views

Die Klasse TabViewStateMachine implementiert das Verhaltensmodell für alle Tab-Seiten. Die Aktivitäten sind in einer Basisklasse der Tab-Seiten und in den Tab-Seiten-Klassen selbst implementiert.

 

Die Implementierung der State Machine wird in Richtung einer UML State Chart spezialisiert, indem nicht beliebige Closures zugelassen, sondern für jeden State entry()- und exit()-Actions sowie Actions, die bei der Transition ausgeführt werden, in drei Maps abgelegt und in die Transitions-Closure eingefügt werden.

@Slf4j
class TabViewStateMachine {

    public static enum State {
        SUBTAB,         // creation state for tab views of sublevel objects
        TOPTAB,         // creation state for tab views of toplevel objects
        INIT,           // nothing is selected in the controlling tree
        EMPTY,          // no object is selected for this tab, but a root node is selected
        SHOW,           // an object is selected and shown on the tab
        CREATEEMPTY,    // starting from EMPTY (important for Cancel events!), a new Object is created
        CREATE,         // starting from SHOW (important for Cancel events!), a new Object is created
        EDIT,           // selected object is being edited
        DIALOG,         // we are in a modal dialog
    }

    public static enum Event {
        Init,     // initialise state machine
        Select,   // an item of the displayed class was selected
        Root,     // a new branch was selected, either by selecting another top level object or some subobject
        Edit,     // start editing the selected object
        Create,   // start creating a new object
        Cancel,   // cancel edit or create
        Save,     // save newly edited or created object
        Dialog,   // enter a modal dialog
    }

    private DialogStateMachine sm

    private Map&amp;amp;lt;State, Closure&amp;amp;gt; onEntry = new HashMap&amp;amp;lt;&amp;amp;gt;()
    private Map&amp;amp;lt;State, Closure&amp;amp;gt; onExit = new HashMap&amp;amp;lt;&amp;amp;gt;()
    private Map&amp;amp;lt;Integer, Closure&amp;amp;gt; onTransition = new HashMap&amp;amp;lt;&amp;amp;gt;()

    public Map&amp;amp;lt;State, Closure&amp;amp;gt; getOnEntry() { onEntry }

    public Map&amp;amp;lt;State, Closure&amp;amp;gt; getOnExit() { onExit }

    public Map&amp;amp;lt;Integer, Closure&amp;amp;gt; getOnTransition() { onTransition }

    State getCurrentState() { (State) sm?.currentState }


    TabViewStateMachine(State initial, String id = 'tvsm') {
        switch (initial) {
            case State.TOPTAB:
                sm = new DialogStateMachine(State.TOPTAB, id)
                break
            case State.SUBTAB:
                sm = new DialogStateMachine(State.SUBTAB, id)
                break
            default:
                log.warn("unexpected initial state $initial")
                sm = new DialogStateMachine(State.TOPTAB, id)
        }
        buildDialogSM()
    }

    public execute(Event event, Serializable... params) {
        sm.execute(event, params)
    }

    /**
     * delegate calculating a unique transition index from current state
     * and triggering event to DialogStateMachine
     * @param st current state
     * @param ev event triggering transition
     * @return a unique Integer computed from state and event
     */
    public Integer trix(State st, Event ev) {
        sm.trix(st, ev)
    }

    void addTransition(State from, State to, Event ev) {
        sm.addTransition(from, to, ev) {
            onExit[(from)]?.call()
            onTransition[trix(from, ev)]?.call()
            onEntry[(to)]?.call()
        }
    }

    /**
     * Build a state machine to control dialog behaviour
     */
    private void buildDialogSM() {
        addTransition(State.SUBTAB, State.INIT, Event.Init)
        addTransition(State.INIT, State.SHOW, Event.Select)
        addTransition(State.INIT, State.EMPTY, Event.Root)
        addTransition(State.TOPTAB, State.EMPTY, Event.Init)
        addTransition(State.EMPTY, State.EMPTY, Event.Root)
        addTransition(State.EMPTY, State.CREATEEMPTY, Event.Create)
        addTransition(State.EMPTY, State.SHOW, Event.Select)
        addTransition(State.CREATEEMPTY, State.EMPTY, Event.Cancel)
        addTransition(State.CREATEEMPTY, State.SHOW, Event.Save)
        addTransition(State.SHOW, State.EDIT, Event.Edit)
        addTransition(State.SHOW, State.CREATE, Event.Create)
        addTransition(State.SHOW, State.SHOW, Event.Select)
        addTransition(State.SHOW, State.EMPTY, Event.Root)
        addTransition(State.EDIT, State.SHOW, Event.Save)
        addTransition(State.EDIT, State.SHOW, Event.Cancel)
        addTransition(State.CREATE, State.SHOW, Event.Save)
        addTransition(State.CREATE, State.SHOW, Event.Cancel)
        addTransition(State.SHOW, State.DIALOG, Event.Dialog)
        addTransition(State.DIALOG, State.SHOW, Event.Save)
        addTransition(State.DIALOG, State.SHOW, Event.Cancel)
    }
}
Erläuterungen zum Code
Zeilen 51-61
Definiert die Zustände aus der State Chart als Enum-Werte
Zeilen 63-72
Definiert die möglichen Events als Enum-Werte
Zeile 72
TabViewStateMachine verwendet eine DialogStateMachine
Zeilen 76-78
Alle AKtionen (onEntry, onExit, onTransition) werden als Closures in einer Map gespeichert. Die Schlüssel sind der zugeordnete State (onEntry, onExit) bzw. der Transitionsindex
Zeilen 91, 94
Der Startzustand wird im Konstruktor festgelegt
Zeilen 119-123
Die Transitions-Closure der DialogStateMachine ruft die Closures onExit(from), onTransition(Transitionsindex), onEntry(to) aus den Maps auf, wenn sie definiert sind.
Zeilen 131-150
Hier werden alle 20 Transitionen aus dem Zustandsmodell angelegt. Damit ist die Grundstruktur des Verhaltens definiert. Andere Klassen, die diese StateMachine nutzen, können weitere Transitionen hinzufügen.

Verwendung der StateMachine

Die TabViewStateMachine wird in der Klasse TabBase verwendet. Diese abstrakte Klasse implementiert alle Methoden, die für die TabView Klassen identisch sind, und deklariert abstrakte Methoden, die von allen TabView Klassen individuell implementiert werden müssen.

abstract class TabBase extends SubTree {
    protected TabViewStateMachine sm
    @Autowired
    protected ProjectTree projectTree

    protected configureSm() {
        sm.onEntry[TabViewStateMachine.State.INIT] = {
            clearFields()
            initmode()
        }
        sm.onEntry[TabViewStateMachine.State.EMPTY] = {
            emptymode()
        }
        sm.onEntry[TabViewStateMachine.State.SHOW] = {
            showmode()
        }
        sm.onEntry[TabViewStateMachine.State.CREATEEMPTY] = {
            createemptymode()
        }
        sm.onEntry[TabViewStateMachine.State.CREATE] = {
            clearFields()
            createmode()
        }
        sm.onEntry[TabViewStateMachine.State.EDIT] = {
            editmode()
        }
        sm.onEntry[TabViewStateMachine.State.DIALOG] = {
            dialogmode()
        }

        sm.onTransition[sm.trix(TabViewStateMachine.State.CREATEEMPTY, TabViewStateMachine.Event.Save)] = {
            saveItem(0)
            setFieldValues()
            projectTree.onEditItemDone(matchForNewItem, currentCaption, true)
        }
        sm.onTransition[sm.trix(TabViewStateMachine.State.CREATEEMPTY, TabViewStateMachine.Event.Cancel)] = {
            projectTree.onEditItemDone('', '')
        }
        sm.onTransition[sm.trix(TabViewStateMachine.State.EDIT, TabViewStateMachine.Event.Save)] = {
            saveItem(currentDomainId)
            projectTree.onEditItemDone(currentItemId,  currentCaption)
        }
        sm.onTransition[sm.trix(TabViewStateMachine.State.EDIT, TabViewStateMachine.Event.Cancel)] = {
            setFieldValues()
            projectTree.onEditItemDone(currentItemId,  currentCaption)
        }
        sm.onTransition[sm.trix(TabViewStateMachine.State.CREATE, TabViewStateMachine.Event.Save)] = {
            saveItem(0)
            projectTree.onEditItemDone(matchForNewItem,  currentCaption, true)
        }
        sm.onTransition[sm.trix(TabViewStateMachine.State.CREATE, TabViewStateMachine.Event.Cancel)] = {
            setFieldValues()
            projectTree.onEditItemDone(currentItemId,  currentCaption)
        }
        sm.onTransition[sm.trix(TabViewStateMachine.State.DIALOG, TabViewStateMachine.Event.Save)] = {
            saveDialog()
        }
        sm.onTransition[sm.trix(TabViewStateMachine.State.DIALOG, TabViewStateMachine.Event.Cancel)] = {
            cancelDialog()
        }
    }

    /** item id of currently selected object from vaadin selection component */
    protected abstract getCurrentItemId()
    /** value for the domain object id of currently displayed object */
    protected abstract Long getCurrentDomainId()
    /** get caption of current object for display in selection component */
    protected abstract String getCurrentCaption()
    /** item match mimics id for searching item in vaadin selection */
    protected abstract getMatchForNewItem()

    /** prepare for editing in CREATEEMPTY state */
    protected createemptymode() { editmode() }
    /** prepare for editing in CREATE state */
    protected createmode() { editmode() }
    /** prepare for working in DIALOG state */
    protected dialogmode() {}
    /** leaving DIALOG state with save */
    protected saveDialog() {}
    /** leaving DIALOG state with cancel */
    protected cancelDialog() {}
    /** prepare for editing in EDIT state */
    protected abstract editmode()
    /** prepare INIT state */
    protected initmode() {}
    /** prepare EMPTY state */
    protected abstract emptymode()
    /** prepare SHOW state */
    protected abstract showmode()
    /** clear all editable fields */
    protected abstract clearFields()
    /**
     * for the given persistent object id, fetch the full dto and save it in field currentDto
     * @param itemId object id
     */
    protected abstract void initItem(Long itemId)
    /**
     * set all fields from the current full dto object
     */
    protected abstract void setFieldValues()

    protected abstract saveItem(Long id)
}
Erläuterungen zum Code
Zeilen 16, 21-23, 41-45, 74ff.
Die Konfiguration der State Machine findet in dieser Methode statt. Hier werden die onEntry Closures und die onTransition Closures konfiguriert (z.B. Zeile 21-23 und 41-45). In den Closures werden Methoden aufgerufen, die teilweise abstrakt sind(z.B. Zeilen 74 ff.) und in den abgeleiteten Klassen für die dort repräsentierte Domain-Klasse implementiert werden.

Damit wird eine einheitliche Struktur für alle TabView Klassen vorgegeben und redundanter Code vermieden.

Konkrete Anwendungen in den TabView Klassen

In den einzelnen TabViews wird die State Machine initialisiert und die abstrakten Methoden implementiert.

        // build state machine
        sm = new TabViewStateMachine(TabViewStateMachine.State.SUBTAB, 'TskTab')
        configureSm()
        sm.execute(Event.Init)

Dies ist ein Beispiel für eine typische onEntry Methode, in der die Widgets der Seite disabled oder enabled werden.

    /** prepare EMPTY state */
    @Override
    protected emptymode() {
        clearFields()
        currentDto = null
        [tag, estimate, spent, description, completed, supertask,
         saveButton, cancelButton, editButton, subtaskButton].each {
            it.enabled = false
        }
        [newButton].each { it.enabled = true }
    }

Dies ist eine onTransition Methode. Die meisten onTransition Closures in der Klasse TabBase bauen ihre Funktionalität aus mehreren Methodenaufrufen zusammen. In diesem Fall übernimmt das die Methode saveDialog.

    /** leaving DIALOG state with save */
    protected saveDialog() {
        createSubtask()
        dialog.window.close()
        projectTree.onEditItemDone(currentItemId, currentCaption, true)
    }

Abschließend ein Beispiel für eine Transition mit Choice. Es wird überprüft, ob der angemeldete User ein neues Objekt anlegen kann. Im Erfolgsfall wird in den State CREATEEMPTY gewechselt und die dafür vorgesehene Aktivität ausgeführt. Im Fehlerfall wird nur der currentState der State Machine zurückgegeben. Dadurch bleibt die State Machine im currentState. Der newButton muss zusätzlich wieder enabled werden, da für ihn die Eigenschaft disableOnClick auf true gesetzt wurde und er sienen Status daher unabhängig von der State Machine ändert. Das widerspricht zwar der reinen Lehre, sorgt aber dafür, dass auf den Button keine Doppelklicks im Browser ausgeführt werden können.

    @Override
    protected createemptymode() {
        if (authorizationService.hasRole('ROLE_ADMIN')) {
            super.createemptymode()
        } else {
            Notification.show("Sorry, you don't have the rights to do that.");
            newButton.enabled = true
            sm.currentState
        }
    }

Schreibe einen Kommentar

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