Vaadin Tree verwenden

Darstellung hierarchischer Strukturen

uml mit orm notesHierarchisch strukturierte Informationen lassen sich gut mit Tree Views darstellen. Das gilt speziell dann, wenn die Tiefe der Hierarchie nicht festgelegt ist. Für das Beispielprojekt ergibt sich eine fachliche Hierarchie, die sich in das Klassenmodell abbildet:

  • Projekte sind auf der obersten Ebene der Hierarchie. Es kann mehrere Projekte geben.
  • Ein Projekt hat einen Backlog, der eine Liste von Tasks umfasst, und eine Liste von Sprints, die wiederum jeweils ihren Backlog mit einer (kleineren) Liste von Tasks besitzen.
  • Eine Task kann im Projektverlauf in Subtasks aufgespalten werden.
  • Hierarchie kann noch erweitert werden, z.B. durch Zuordnung von Entwicklern zu Tasks usw.

Anforderungen an die Darstellung mit einem TreeView

Treeview mit drei EbenenEs gibt funktionale und technische Anforderungen an einen TreeView für die Scrum-Applikation:

  1. Der Tree View soll mehrere Wurzelknoten zulassen.
  2. Assoziationen sollen als Zwischenebene angezeigt werden.
  3. Wenn eine Assoziation ausgewählt wird, soll das zugehörige Projekt ausgewählt werden.
  4. Wenn neue Objekte angelegt werden, sollen diese sinnvoll in den Tree eingefügt werden. Dabei sollen aufgeklappte Knoten weiter aufgeklappt sein. Die Selektion soll sich nicht ändern.
  5. Wenn ein Objekt in einer Tab-Seite editiert wird, soll eine Änderung der Selektion im Tree nicht möglich sein.
  6. Wenn die im Tree angezeigte Eigenschaft geändert wird, soll sich der Anzeigetext im Tree auch ändern. Andere Änderungen der Ansicht sollen dabei nicht auftreten.
  7. Wenn übergeordnete Knoten geschlossen und wieder geöffnet werden, sollen die untergeordneten Knoten wie vorher geschlossen oder offen angezeigt werden.
  8. Zu jedem angezeigten Eintrag soll eine ID zugreifbar sein, mit der das entsprechende Objekt identifiziert werden kann (gilt nicht für Assoziationsknoten).
  9. Zu jedem Knoten soll die ID des zugehörigen Wurzelknoten bestimmt werden können.

Implementierung mit der Vaadin Tree Komponente

Die Tree Komponente von Vaadin unterscheidet sich im Datenmodell deutlich von anderen TreeView Implementierungen, z.B. in Java Spring. Während das Datenmodell üblicherweise selbst eine Baumstruktur aufweist, ist es in Vaadin eher eine Liste von Items, die mit verschiedenen addItem(...) Methoden, z.B.
public Item addItem(java.lang.Object itemId) throws java.lang.UnsupportedOperationException
hinzugefügt werden können. Die Baumstruktur wird nachträglich mit der Methode
public boolean setParent(java.lang.Object itemId,java.lang.Object newParentId)
für zwei bereits existierende Items aufgeprägt. Dadurch können Vaadin Trees mehrere Wurzeln haben (Anforderung 1).

Items werden über ihre Id identifiziert, die ein Java Object ist. Daher kann die Id auch in einer Datenstruktur komplexere Referenzinformation enthalten. Im Beispiel wird die Groovy Kurzschreibweise für eine einzeilige Map (z.B. [type: PROJECT_TYPE, id: projId]) verwendet, um den Datentyp und die Domain-ID des referenzierten Objekts anzugeben (Anforderung 8). Der angezeigte Text (die Caption) ist im einfachsten Fall die toString() Repräsentation der Item-Id, jedoch kann mit der Methode public void setItemCaption(java.lang.Object itemId, java.lang.String caption) ein anderer Text gesetzt werden.

Für das im Tree aktuell selektierte Item kann die Id mit der Methode public java.lang.Object getValue() gelesen werden. Bei einer Änderung der Selektion werden alle registrierten ValueChangeListener benachrichtigt.

Auf dieser Grundlage kann eine den Anforderungen entsprechende Implementierung realisiert werden. Um eine hohe Kohäsion und geringe Kopplung zu erreichen, werden dazu drei Klassen und zwei Interfaces implementiert.

Übersicht
class ProjectTree
In dieser Klasse werden die Vaadin Komponenten angelegt und konfiguriert, der Baum für die Scrum Projektansicht aufgebaut und projektspezifische Methoden für die Baumansicht implementiert.
class VaadinTreeHelper (groovydoc)
Hilfsmethoden für die Arbeit mit dem Tree:
addNode: neuen Knoten mit Caption in die Hierarchie einfügen
descend: eine ganze Baumstruktur, wie sie typischerweise durch ein DTO bereitgestellt wird, hinzufügen
getAllExpanded, reexpand: expandierte Knoten finden bzw. wieder expandieren
findMatchingId: Item anhand seiner Id finden
topParentForId: passenden Wurzelknoten zu einem Item finden (Anforderung 9)
class VaadinSelectionModel (groovydoc)
Verwaltet VaadinSelectionListener und VaadinTreeRootChangeListener. Dabei können sich VaadinSelectionListener für alle Änderungen der Selektion registrieren, oder nur für Selektionen, die eine bestimmte Klasse betreffen.
interface VaadinSelectionListener
Listener wird benachrichtigt, wenn sich die Selektion des Items im Tree geändert hat.
interface VaadinTreeRootChangeListener
Listener wird benachrichtigt, wenn sich durch einen Wechsel der Selektion im Tree auch der Wurzelknoten geändert hat.

Hier ist die Dokumentation aller Klassen.

Code mit Erläuterungen
...
@SpringComponent
@UIScope
class ProjectTree extends SubTree
        implements Serializable {

    public static final String PROJECT_TYPE = 'Project'
    public static final String SPRINT_TYPE = 'Sprint'
    public static final String TASK_TYPE = 'Task'

    private static final String PTREE = 'ptree'
    private static final String MENU = 'logoutmenu'
    private Tree projectTree
    private VaadinTreeHelper treeHelper

    private uiComponents

    private Map<String, Serializable> selectedProjectId

    def getSelectedProjectId() { selectedProjectId }

    @Autowired
    private ProjectService projectService

    @Autowired
    private VaadinSecurity vaadinSecurity

    VaadinSelectionModel selectionModel = new VaadinSelectionModel()

    @Override
    Component build() {
        vaadin."$C.vlayout"() {
            "$F.menubar"([uikey: MENU]) {
                "$F.menuitem"('Logout', [command: { vaadinSecurity.logout() }])
            }
            "$C.panel"('Projekte', [spacing: true, margin: true]) {
                "$F.tree"('Projekte, Backlogs und Sprints',
                        [uikey: PTREE, caption: 'MenuTree',
                         valueChangeListener: {treeValueChanged(it)}])
            }
        }
    }

    @Override
    void init(Object... value) {
        uiComponents = vaadin.uiComponents
        projectTree = uiComponents."${subkeyPrefix + PTREE}"
        treeHelper = new VaadinTreeHelper(projectTree)
        buildTree(projectTree)
    }

    /**
     * handle changes of tree selection
     * @param event info on the newly selected tree item
     */
    private void treeValueChanged(Property.ValueChangeEvent event) {
        def selectId = event.property.value
        if (selectId) {
            def topItemId = treeHelper.topParentForId(selectId)
            if (topItemId != selectedProjectId) {
                selectionModel.notifyRootChange(topItemId)
                selectedProjectId = topItemId
            }
            if (selectId instanceof Map) {
                selectionModel.notifyChange(selectId)
            }
        }
    }

    /**
     * build a tree representing the domain model
     * @param projectTree
     */
    private void buildTree(Tree projectTree) {
        def projects = projectService.projects
        //loop over all projects
        projects.all.each { projId, projNode ->
            def projectId = treeHelper.addNode([type: PROJECT_TYPE, id: projId], 
                    null, projNode.name, true)
            // an intermediate node 'backlog'
            def backlogTagId = treeHelper.addNode('backlog:' + projId, projectId, 
                    'backlog', !projNode.backlog.isEmpty())
            if (projNode.backlog) {
                // build a subtree for every backlog task
                projNode.backlog.each { taskNode ->
                    treeHelper.descend(taskNode, backlogTagId, TASK_TYPE, 'id', 
                            'tag', 'children')
                }
            }
            def sprintsTagId = treeHelper.addNode('sprints:' + projId, projectId, 
                    'sprints', !projNode.sprint.isEmpty())
            if (projNode.sprint) {
                projNode.sprint.each { sprintNode ->
                    treeHelper.addNode([type: SPRINT_TYPE, id: sprintNode.id], 
                            sprintsTagId, sprintNode.name, false)
                }
            }

        }
    }

    /**
     * disable the tree while a tree item is edited on one of the tab pages
     */
    public void onEditItem() {
        projectTree.enabled = false
    }

    /**
     * enable and update the tree after editing an item
     * @param itemId identifies edited item
     * @param caption eventually updated caption of the edited item
     * @param mustReload tree must reload after new item was created
     *        or structure changed
     */
    public void onEditItemDone(Object itemId, String caption, boolean mustReload = false) {
        if (mustReload) {
            def expandedNodes = treeHelper.allExpanded
            projectTree.removeAllItems()
            buildTree(projectTree)
            def select = treeHelper.findMatchingId(itemId)
            if (select)
                projectTree.select(select)
            treeHelper.reexpand(expandedNodes)
        } else {
            if (projectTree.getItemCaption(itemId) != caption) {
                projectTree.setItemCaption(itemId, caption)
            }
        }
        projectTree.enabled = true
    }
}
Zeilen 23-25
Der ProjectTree ist eine UI Bean
Zeilen 43-44
Die Datenstruktur zum Aufbau des Tree wird als DTO von einer ProjectService Bean geholt
Zeile 49
Das VaadinSelectionModel verwaltet die Listener, die auf Änderungen der Selektion im Tree reagieren
Zeile 51
in der build() Methode wird die UI aus Vaadin Komponenten zusammengebaut,. Dabei wird außer dem Tree noch ein Logout Menü angelegt. Auf Dauer sollte das nicht hier bleiben.
Zeile 66
In der init() Methode werden die Variablen für die Vaadin-Komponenten initialisiert, ein TreeHelper für den neu angelegten Tree angelegt und der Tree mit Daten initialisiert
Zeilen 77, 82, 83, 86
Die ValueChangedEvents des Tree werden semantisch an die Erfordernisse dieser Applikation angepasst und an die betroffenen Listener weitergeleitet. Das ProjectTab Objekt ist als VaadinTreeRootChangeListener registriert und reagiert damit auf Änderungen des Wurzelknoten im Tree als Folge einer neuen Selektion eines beliebigen Knoten (Anforderung 3).
Zeilen 95-115
In der Methode buildTree(...) wird das von der ProjectService Bean geholte DTO in die Baumdarstellung überführt. Dazu werden die Methoden addNode() und descend() von VaadinTreeHelper verwendet. Die Assoziationen werden als Knoten in den Baum eingefügt (102, 111), dabei wird die Id des zugehörigen Projekts in der Item-Id gespeichert (Anforderung 2). Für jeden Knoten, der ein Domain-Objekt repräsentiert, wird die Domain-Id in der Item-Id gespeichert (Anforderung 8).
Zeilen 126-128
Mit dieser Methode können die Tab-Seiten neue Selektionen im Tree unterbinden (Anforderung 5).
Zeilen 137, 139, 142, 145, 148
Nach Abschluss des Editierens wird die Selektion im Tree wieder freigegeben. Falls neue Objekte angelegt wurden, muss der Tree neu aufgebaut werden. Dazu wird gespeichert, welche Knoten expandiert waren (139), um sie anschließend wieder zu expandieren. Dann wird festgestellt, welcher Knoten selektiert war, um diese Selektion wieder herzustellen. Das ist etwas aufwändig, weil der Tree intern die Item-Ids auf Identität (==) und nicht auf Gleichheit (.equals()) untersucht und sich die Identität beim erneuten Laden verändert. Daher wird diese Suche im VaadinTreeHelper realisiert (142) (Anforderung 4). Beim Schließen und wieder Öffnen eines Knotens bleibt die Expansion der untergeordneten Knoten erhalten. Dies wird im Vaadin Tree implementiert und erfordert keinen zusätzlichen Code (Anforderung 7).
Wenn sich nur die Caption eines Knoten geändert hat, wird sie aktualisiert(148)(Anforderung 6).

Die Klasse VaadinTreeHelper nutzt intensiv die Möglichkeiten von Groovy zur Bearbeitung von Collections.

...
class VaadinTreeHelper {
    private Tree tree

    /**
     * A TreeHelper instance is bound to a Vaadin Tree
     * @param aTree the Tree object that is supported by this instance
     */
    public VaadinTreeHelper(Tree aTree) {
        tree = aTree
    }

    /**
     *  add a new node to the tree
     * @param id id of a new node. if null, id will begenerated
     * @param parentId id of parent node. if null, there is no parent
     * @param caption caption of new node as displayed
     * @param childrenAllowed can node have children?
     * @return id , either given or generated
     */
    public addNode(Object id, Object parentId, String caption, Boolean childrenAllowed) {
        if (id)
            tree.addItem(id)
        else
            id = tree.addItem()
        tree.setItemCaption(id, caption)
        if (parentId)
            tree.setParent(id, parentId)
        tree.setChildrenAllowed(id, childrenAllowed)
        id
    }

    /**
     * build a vaadin tree for a given tree data structure. To ease the work with tree
     * selections, id's for the vaadin tree node consist of single element maps with a
     * key describing the kind of object represented by the node and a value, typically
     * a database key or other unique value used to lookup the domain instance
     * represented by this tree node
     *
     * @param node an object (e.g. a dto) to be represented by the Vaadin tree node
     * @param parentId id of the Vaadin tree parent node
     * @param idPrefix identifies kind of node, e.g. domain class name of underlying object
     * @param idField name of the field in the node object that holds the key value
     * @param captionField name of the field in the node object that holds the caption
     * @param childrenField name of the field in the node object that holds the list of
     *      child objects
     * @return
     */
    public descend(Object node, Object parentId, String idPrefix, String idField,
                   String captionField, String childrenField) {
        def nodeId = [type: idPrefix, id: node."$idField"]
        addNode(nodeId, parentId, node."$captionField", !node."$childrenField".isEmpty())
        node."$childrenField".each { subnode ->
            descend(subnode, nodeId, idPrefix, idField, captionField, childrenField)
        }
    }

    /**
     * find the node id of the topmost node for a given node
     * @param id th id of the node where we start
     * @return topmost parent node
     */
    public topParentForId(def id) {
        def pid = tree.getParent(id)
        if (pid)
            topParentForId(pid)
        else
            id
    }

    /**
     * get a List that contains ids of all expanded nodes.
     *
     * @return the expanded node list
     */
    public getAllExpanded() {
        tree.itemIds.findAll {
            tree.isExpanded(it)
        }
    }

    /**
     * try to identify previously expanded nodes from List and reexpand them after
     * a tree reload. As node id objects differ after reload, this needs a
     * comparison in groovy
     *
     * @param exp the list generated before tree reload
     */
    public void reexpand(Collection exp) {
        tree.itemIds.findAll { itemId ->
            exp.find { it == itemId }
        }.each {
            tree.expandItem(it)
        }
    }

    /**
     * find matching itemId in the tree by comparing each id of type Map with the
     * match parameter. This is necessary because Tree uses Identity and not Equality
     * to compare ids. So you cannot directly look for an equal but not identical key
     *
     * @param match a map that looks like the id we are looking for
     * @return the found tree id or null, if none
     */
    public findMatchingId(Map match) {
        tree.itemIds.find { id ->
            id instanceof Map && match.keySet().every { key ->
                id[key] == match[key]
            }
        }
    }
}
Zeile 29, addNode()
Diese Methode fasst die drei bis vier Schritte zusammen, um einen neuen Knoten im Baum anzulegen.
Zeile 57, descend()
Diese Methode durchläuft rekursiv die Baumstruktur des DTO und legt Knoten an. Die umfangreiche Parameterliste ermöglicht es, die Methode an die Struktur der DTO Datenstruktur anzupassen.
Zeile 71, topParentForId()
Diese Methodeläuft von einem Tree Item rekursiv die interne Tree Datenstruktur hinauf bis an die Wurzel.
Zeile 84, getAllExpanded()
Diese Methode erzeugt eine Liste der Ids aller Tree Items, die expandiert sind.
Zeile 97, reexpand()
Diese Methode expandiert alle vorher expandierten Items nach einem Neuaufbau des Tree.
Zeilen 115-116 in findMatchingId()
Diese Methode nutzt aus, dass der == Operator in Groovy auf Gleichheit (equals()) und nicht auf Identität (==) überprüft. Dazu muss allerdings die ganze Collection der Tree Items linear durchsucht werden.

Die Klasse VaadinSelectionModel sowie die beiden Listener Interfaces sollten ohne weitere Erläuterungen verständlich sein.

Schreibe einen Kommentar

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