Struktur der Tab-Seite

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.
* SUBTAB TOPTAB * O O * | | * * | | * +---v---+ +--v------+ * | INIT |---root--->| EMPTY | * +-v-----+ +|-^-v--^-+ * | | | | | * | +---select----+ | | | * select | +----root----+ | | * | | | | | * +----+ | | | create cancel * select| | | | | | * | +-v--v----v--^-+ +----v--^-----+ * +-<| SHOW |<-save-| CREATEEMPTY | * +v--^--^-v---v-+ +-------------+ * | | | | +-------------------------+ * | | +-|-------cancel------+---------|------+ * | +--|-|-------save----+---|---------|---+ | * | | | +-------------+ | | | | | * | | | | | | | | | * create | | edit | | dialog | | * | | | | | | | | | * +v--^--^--+ +-v--^--^-+ +-v---^--^-+ * | CREATE | | EDIT | | DIALOG | * +---------+ +---------+ +----------+
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

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;lt;State, Closure&amp;gt; onEntry = new HashMap&amp;lt;&amp;gt;() private Map&amp;lt;State, Closure&amp;gt; onExit = new HashMap&amp;lt;&amp;gt;() private Map&amp;lt;Integer, Closure&amp;gt; onTransition = new HashMap&amp;lt;&amp;gt;() public Map&amp;lt;State, Closure&amp;gt; getOnEntry() { onEntry } public Map&amp;lt;State, Closure&amp;gt; getOnExit() { onExit } public Map&amp;lt;Integer, Closure&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 } }