Workspaces

Java Business applications have traditionally been based on one of the Rich Client Frameworks,
namely NetBeans Platform or Eclipse RCP. An important reason for choosing an RCP platform has been the
workspace like functionality they provide. Some important features of a workspace are:

  • Common action buttons that tie to the state of the docked view (Save, Refresh etc)
  • Context based UI nodes added to the common workspace interface
  • Navigation stack for traversing visited views, controlled through back and forward buttons like a web browser
  • Menu system with dynamic contributions and modifications

TornadoFX has begun to bridge the gap between the RCP platforms by providing Workspaces. While still in it's infancy,
the default functionality is a solid foundation for business applications in need of the features discussed above.

The simplest possible Workspace app

To kick off a Workspace app, all you need to do is to subclass App and set the primary View to Workspace::class.
The result can be seen below (Figure 16.1).

class MyApp : App(Workspace::class)

Figure 16.1

The resulting Workspace consists of a button bar with four default buttons and an empty content area below it.
The content area can house any UIComponent. You add a component to the content area by calling workspace.dock() on it. If you
show the Workspace without a docked View, it will by default only take up the space needed for the buttons. The window in Figure 16.1
was resized after it was opened.

Let's pretend we have a CustomerList component that we would like to dock in the Workspace as the application starts.
We do this by overriding the onBeforeShow callback:

class MyApp : App(Workspace::class) {
    override fun onBeforeShow(view: UIComponent) {
        workspace.dock<CustomerList>()
    }
}

Figure 16.2

The complete code of the Customer List is not important for us, suffice it to say that it displays a TableView and
lists some Customers. What is interesting however, is that the Refresh button in the Workspace was enabled
when the CustomerList was docked, while the Save button remained disabled.

Leveraging the Workspace buttons

Whenever a UIComponent is docked in the Workspace, the Refresh, Save and Delete buttons will be enabled by default. This happensbecause the Workspace looks at the refreshable, savable and deletable properties in the docked component. Every UIComponentreturns a boolean property with the default value of true, which the Workspace then connects to the enabled state of these buttons. In the CustomerList example, we made sure the Save button was always disabled by overriding this property:

override val savable = SimpleBooleanProperty(false)

We can achieve the same result by calling disableSave() in the init block, same goes for disableRefresh() and disableDelete().

We didn't touch the other buttons, so they remain true as per the default. Whenever the Refresh button
is called, it will fire the onRefresh function in the View. You can override this to provide your refresh action:

override val onRefresh() {
    customerTable.asyncItems { customerController.listCustomers() }
}

Same goes for the Delete button. We will revisit the Save button and introduce a neat trick to only activate it when there are dirty changes later in this chapter.

Tabbed Views

You may at one point dock a View containing a TabPane inside of a Workspace, and then add tabs which represents further UIComponents. You can quite easily proxy the savable, refreshable and deletable state and actions from the Workspace onto the View represented by the currently active Tab. Consider a Customer Editor which has tabs for editing customer data, and one for editing contacts for that customer. Whenever the user selects one of the tabs, the buttons in the Workspace should interact with the state and actions from the selected tab view.

class CustomerEditor : View("Customer Editor") {
    override val root = tabpane {
        tab(CustomerBasicDataEditor::class)
        tab(ContactListEditor::class)
        connectWorkspaceActions()
    }
}

That single call to connectWorkspaceActions() takes care of everything for us. The actual implementation of the two sub views are omitted for brevity, but you can imagine that they share a CustomerViewModel injected into the scope they share for example.

The actual implementation of connectWorkspaceActions is quite simple, and reveals what's going on under the cover:

fun TabPane.connectWorkspaceActions() {
    savableWhen { savable }
    whenSaved { onSave() }

    deletableWhen { deletable }
    whenDeleted { onDelete() }

    refreshableWhen { refreshable }
    whenRefreshed { onRefresh() }
}

This function is declared inside UIComponent, so the savableWhen, deletableWhen and refreshableWhen are performed on the UIComponent. Those state are then bound to the savable, deletable and refreshable state of the TabPane. But wait - a TabPane doesn't have those functions?! Yes, in TornadoFX it has :) You can probably guess that the implementation is again another proxy into the currently selected Tab in the TabPane, and a lookup the UIComponent represented by the content property of that Tab. Whenever the Tab changes (or when the content of the tab changes), the underlying UIComponent is looked up, and the pertinent states are bound to the Workspace.

It would also be possible to bind these states and connect the actions more explicitly. You will never or seldom need to do that, but the following
example might help your understanding of the proxy mechanism.

class TooExplicitCustomerEditor : View() {
    override val root = tab {
        ...
    }
    override val savable = root.savable
    override val refreshable = root.refreshable
    override val deletable = root.deletable

    override fun onSave() {
        root.onSave()
    }

    override fun onDelete() {
        root.onDelete()
    }

    override fun onRefresh() {
        root.onRefresh()
    }
}

As mentioned, you never need to do this and should always use the connectWorkspaceActions call, but you might want to override one of onSave, onDeleteor onRefresh to perform some action in the main editor before calling the same action inside the active tab by calling root.onXXX. Let's say that the refresh call in the main editor reloads the customer, but you also want to have the contact list refresh if that view is currently active. This could be done
like this:

class CustomerEditor : View() {
    val customerController : CustomerController by inject()
    val customer: CustomerModel by inject()

    override val root = tabpane {
        tab(CustomerBasicDataEditor::class)
        tab(ContactListEditor::class)
        connectWorkspaceActions()
    }

    override val onRefresh() {
        runAsync {
            customerController.getCustomer(customer.id.value)
        } ui {
            customer.item = it
            root.onRefresh()
        }
    }
}

This little trick enables you to handle the actual reload of the customer in the main view instead of reimplementing it in every tab.

Forwarding button state and actions

As we have seen, the currently docked View controls the Workspace buttons. Some times you dock nested Views inside the main View, and you would like that nested View to control the buttons and actions instead. This can easily be done with the forwardWorkspaceActions function. You can change the forwarding however you see fit, for example on focus or on click on some component inside the nested View.

class CustomerEditor : View() {
    override val root = hbox {
        val basicDataEditor = find<CustomerBasicDataEditor>()
        add(basicDataEditor)
        forwardWorkspaceActions(basicDataEditor)
        add(ContactListEditor::class)
    }
}

Modifying the default workspace

The default workspace only gives you basic functionality. As your application grows you will want to suplement the
toolbar with more buttons and controls, and maybe a MenuBar above it. For small modifications you can augment
it in the onBeforeFunction as we did above, but you will most probably want to subclass as the customizations
become more advanced. The following code and image is taken from a real world CRM application:

class CRMWorkspace : Workspace() {
    init {
        add(MainMenu::class)
        add(RestProgressBar::class)
        add(SearchView::class)
    }
}

The CRMWorkspace loads three other views into it. One providing a MenuBar, then the default RestProgressBar is
added, and lastly a SearchView providing a search input field is added.

The Workspace has a pretty good idea about where to place whatever you add to it. For example, buttons will by default
be added after the four default buttons, while other components are added to the far right of the ToolBar. The MenuBar
is automatically added above the ToolBar, at the top of the screen.

Figure 16.3 shows how it looks in production, with a little bit of custom styling and a CustomerEditor docked into it.
This application happens to be in Norwegian, and some of the information in the Customer card has been removed.

Figure 16.3

You will notice that the Save button is enabled in this View. This is because the savable property is bound to
the dirty state property of the view model:

val model: CustomerModel by inject()
override val savable = model.dirty

When a customer is loaded, the Save button will stay disabled until an edit has been made. To save, we override the onSave function:

override fun onSave() {
    runAsync {
        customerController.save(customer.item)
    } ui { saved ->
        customer.update(saved)    
    }
}

This particular customerController.save call will return the Customer from the server once it is saved. If the server made any changes
to our customer object, they would have been reflected in the saved customer we got back. For that reason, we call
customer.update(saved) which is function you get for free if you implement JsonModel. This makes sure that changes
from the server is pushed back into the model. This is completely optional, and you might just want to do customerController.save(customer.item).

Title and heading

When a view is docked, the title of the Workspace will match the title of that view. There is also a heading
text in the workspace that by default shows the same text as the title. The heading can be overriden by assigning to
the heading variable or binding to the headingProperty property. If you want to completely remove the heading, augment
the workspace with workspace.headingContainer.removeFromParent() or just hide it. You can also put whatever
nodes you want inside the heading container. You saw this trick in the CRM screenshot, where a Gravator icon was placed
to the left of the customer name.

Dynamic elements in the ToolBar

Some views might need more buttons or functionality added to the ToolBar, but once you navigate away from the view it
wouldn't make sense to keep them around. The Workspace will actually track whatever elements you add to it while a view is
docked and remove those changes when the view is undocked. The perfect place to add these extra buttons would be the onDock
call of the view.

Every UIComponent has a property called workspace which will point to the current Workspace for the current Scope. Let's
add an "Add Customer" button to the Workspace whenever the CustomerList is docked:

override fun onDock() {
    with (workspace) {
        button("Add Customer").action { 
            addCustomer() 
        }
    }
}

The Workspace will now look like in Figure 16.4

It looks like a default button. You can remove the border around the button by adding the icon-only css class to it. Optionally
you can configure an icon for the graphic node if you like. The built in icons are svg shapes added in the built in workspace.css
but feel free to add your icon in any way you see fit. Let's add an icon from the FontAwesomeFX library and make it look like
the other buttons:

button("Add Customer") {
    addClass("icon-only")
    graphic = FontAwesomeIconView(PLUS_CIRCLE).apply {
        style {
            fill = c("#818181")
        }
        glyphSize = 18
    }
    action { addCustomer() }
}

In a real application you would use a css class so you don't need to configure the fill for every button you add. The result can be seen in Figure 16.5:

Figure 16.5

Our Customer List is configured so that whenever you double click a customer you will be taken to an editor for that customer.
The TableView binds the selected user to a CustomerModel view model object, and the action is performed like this:

tableview(customers) {
    column("First Name", Customer::firstNameProperty)
    column("Last Name", Customer::lastNameProperty)
    bindSelected(model)
    onUserSelect { workspace.dock<CustomerEditor>() }
}

The only thing we need to do is actually dock the CustomerEditor when the user selects a row. Since the CustomerEditor
will be looked up in the same scope we are currently in, it will have access to the selected customer as well:

class CustomerEditor : Fragment("Customer Editor") {
    val customer: CustomerModel by inject()
    override val savable = customer.dirty
    override val headingProperty = customer.fullName

    override val root = form {
        fieldset("Customer Details") {
            field("First Name") {
                textfield(customer.firstName)
            }
            field("Last Name") {
                textfield(customer.lastName)
            }
        }
    }

    override fun onSave() {
        customer.commit()
    }

    override fun onRefresh() {
        customer.rollback()
    }
}

The customer model is injected, and will contain the selected customer from the list. The savable property is bound
to the dirty property of the model and the headingProperty is bound to a StringBinding called fullName, which
concatinates the first and last names and updates whenever they are changed. The form fields bind to the name properties
and lastly the onSave and onRefresh functions are implemented to react to the corresponding Workspace buttons.

Figure 16.6

We can see that the title and heading are indeed displaying separate information. Since we haven't made any edits
yet, the Save button is disabled, while the Refresh button is available, and would roll back any changes made
since the last commit.

The back button is enabled as well, and clicking it would navigate back to the Customer list. This is a very powerful
feature which enables browser like navigation in your application with very little effort on your part. The Workspace
keeps a navigation stack of configurable depth. By default it will contain 10 previously docked views. You can configure the
maxViewStackDepth to change the number of views held in the navigation stack.

Alternative to overriding onSave and onRefresh

Some times you want to access an object in one of the workbench button actions but you want to avoid creating a variable
for that object. Instead you can use the whenSaved and whenRefreshed callbacks, which can be configured from anywhere.
Important: They are alternatives to onSave and onRefresh so you should only do one or the other. Let's say we want to
refresh a TableView when the Refresh button is clicked. We can configure this inside the builder for the TableView:

tableview {
    whenRefreshed { 
        asyncItems { controller.loadItems() } 
    }    
}

This is a handy alternative in some situations, but make sure you only choose one of the strategies.

Advanced scope navigation

When you leverage injected view models together with a navigation stack, some interesting challenges appear that need
to be addressed. If you removed the Back button (workspace.backButton.removeFromParent()) or set the maxViewStackDepth to
0 you can disregard this particular challenge, but to leverage this powerful navigation paradigm, there are some things
you need to think about.

Consider our prevous example with an injected CustomerModel that represents the currently selected customer in the CustomerList
while also being used by the CustomerEditor to edit that same customer. Then let's assume that there is a way to search for
a customer and edit it, perhaps using a TextField in the ToolBar of the Workspace as a search entry point. If you search for
a new customer and go on to edit it, then navigate back to the previous customer editor, it would suddenly operate on the
last customer you set in the CustomerModel. You can probably imagine the ensuing havoc.

Fortunately, the scoping support stretches far into the Workspace feature and provides some handy tools for this particular situation.

We need to find a way to contain the scope for the pair of CustomerList and CustomerEditor so they can work together while allowing
other views to use the CustomerModel, but in a different scope. It's actually quite easy. Whenever you create a new CustomerList,
also create a new Scope. If you were to do this manually, it would look something like this:

// Create a new scope, but keep the current workspace
val newScope = Scope(workspace)

// Find the CustomerList in the new scope
val customerList = find<CustomerList>(newScope)

// Dock the customerList in the workspace
workspace.dock(customerList)

Those three distinct operations can be performed in a single call:

workspace.dockInNewScope<CustomerList>()

When the CustomerList docks the CustomerEditor later on, it happens in this new scope. But what about the search field?

We would need to provide a separate scope for the CustomerEditor that should show the result of the search, but
were we would also also need to inject the customer model containing the selected customer into the new scope. This
following code is imagined inside the action that selects a customer from the search result:

fun editCustomer(customer: Customer) {
// Create a view model for the customer
val model = CustomerModel(customer)

// Create a new scope, but keep the current workspace
val newScope = Scope(workspace)

// Insert the customer model into the new scope
newScope.set(model)

// Find the CustomerEditor in the new scope
val editor = find<CustomerEditor>(newScope)

// Dock the editor
workspace.dock(editor)
}

That's a lot of steps. Fortunately, we can do that as well in a single call:

fun editCustomer(customer: Customer) {
    workspace.dockInNewScope<CustomerEditor>(CustomerModel(customer))
}

The dockInNewScope function takes a vararg list of injectable objects to insert into the new scope before looking
up our CustomerEditor and docking it.

Separating scopes this way makes sure we can utilize injected view models without being afraid of other views stepping
on our data. It is a pragmatic approach to an intricate problem. It also gives you a way of bleeding injectables
into new scopes, should your use case require it.

Custom ViewStack optimizations

Some use cases might require you to make sure that the user cannot go back to a certain view after he has navigated
to the prior view. You can remove your self from the View Stack on unDock like this:

override fun onUndock() {
    workspace.viewStack.remove(this)
}

Docking multiple views in the editor area

The Workspace provides an alternative way to navigate between views. Instead of back and forward buttons, you can choose
to dock multiple views inside a TabPane in the editor area. The Workspace has a navigationMode property that lets
you change how the views are represented in the editor area. The default is Workspace.NavigationMode.Stack. The following example
creates a tabbed Workspace that automatically docks two views inside it when it's created:

class TabbedWorkspace: Workspace("Tabbed Workspace", NavigationMode.Tabs) {
    init {
        dock<FirstView>()
        dock<SecondView>()
    }
}

Figure 16.7

A Workspace in Tabs mode automatically hides the navigation buttons as they are no longer needed

You can create a starting point for this Workspace from a normal App class:

class TabbedWorkspaceApp : App(TabbedWorkspace::class)

The views docked inside the Workspace tabs will have their onDock function called whenever they are added and
also when they are subsequently chosen as the active Tab. Correspondingly, the onUndock function is called
whenever it is no longer the active Tab, as well as when it's removed from the TabPane using the close button on the tab.

You can control the closable state of a View docked inside the TabPane via the closeable property in UIComponent.
It returns a BooleanExpression with the default value of true but you can override it to bind against another
property or simply return another SimpleBooleanValue(false) to make it uncloseable. This example makes sure you
cannot close the tab before the CustomerModel inside it is committed or rolled back:

class CustomerEditor : View("Customer Editor") {
    val customer: CustomerModel by inject()
    override val closeable = customer.dirty.not()
}

Drawer navigation

The Workspace has built in support for the Drawer control. You can access workspace.leftDrawer and workspace.rightDrawer to
add items to each drawer. They will show up on either the left or right side whenever you have added one or more items to them.

Items added from a View in onDock will automatically be removed when the View is undocked. Items added directly in the Workspace
subclass, from the onBeforeShow App callback or from any other place will stay until they are manually removed.

The combination of static and dynamic drawer items makes for a very powerful navigation and menu structure. Only your imagination is the limit!

The following example creates a customize Workspace primed with a docked Customer Editor in the editor area and the three
drawer items we created in the Drawer chapter configured statically in the leftDrawer of the Workspace:

// A Form based View we will dock in the workspace editor area
class CustomerEditor : View("Customer Editor") {
    override val root = form {
        fieldset(title) {
            field("Name") { textfield() }
            field("Username") { textfield() }
            button("Save")
        }
    }
}

class DrawerWorkspace : Workspace() {
    init {
        // Dock the Customer Editor by default
        dock<CustomerEditor>()
    }

    init {
        // Add items to the left drawers
        with(leftDrawer) {
            item("Screencasts") {
                webview {
                    prefWidth = 470.0
                    engine.userAgent = iPhoneUserAgent
                    engine.load(TornadoFXScreencastsURI)
                }
            }
            item("Links") {
                listview(links) {
                    cellFormat { link ->
                        graphic = hyperlink(link.name).action {
                            hostServices.showDocument(link.uri)
                        }
                    }
                }
            }
            item("People") {
                tableview(people) {
                    column("Name", Person::name)
                    column("Nick", Person::nick)
                }
            }
        }
    }

    // Sample data and configuration omitted for this example
}

In Figure 16.8 we have expanded the Links drawer item. Notice how it pushes the Customer Editor to the right.

Figure 16.8

By right clicking the drawer and checking the Floating drawers option, the expanded drawer item content will
instead float above the content, like in Figure 16.9:

Figure 16.9

This could be a good idea depending on the available space and the nature of the docked content. You can change the
floating drawer mode in code as well, by setting leftDrawer.floatingDrawers = true.

Remember that Views can contribute drawer items programmatically in their onDock callback. Use this to
provide extra tools for an advanced editor for example. They can easily communicate between each other
using ViewModels. It is recommended to create a new scope to make it easier for these view parts to work in concert
on shared data structures.

Vetoing navigation from the docked View

The currently docked View will receive a callback whenever the Back or Forward buttons of the Workspace is clicked. These functions
are called onNavigateBack and onNavigateForward. The default implementation returns true to signal that the navigation should proceed.
You can however return false to stop the navigation and instead implement your own logic to decide what happens in the UI when one of the
navigation buttons are clicked.

results matching ""

    No results matching ""