Workspaces
Java Business applications have traditionally been based on one of the Rich Client Frameworks, namely the 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 its infancy, the default functionality is a solid foundation for business applications in need of the features discussed above.
A Simple Workspace Example
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
To keep things focused, we will leave out the CustomerList
implementation code which simply displays a TableView
with 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 happens because the Workspace
looks at the refreshable
, savable
and deletable
properties in the docked component. Every UIComponent
returns 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, the TornadoFX maintainers 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, and the same goes for disableRefresh()
and disableDelete()
.
We did not 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:
class MyApp: App(MyWorkspace::class) {
override fun onBeforeShow(view: UIComponent) {
workspace.dock<MyView>()
}
}
class MyWorkspace: Workspace() {
override fun 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
does not have those functions?! Yes, in TornadoFX it does :) You can probably guess that the implementation is again another proxy into the currently selected Tab
in the TabPane
, and a lookup of 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
, onDelete
or 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.hide()
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.
Important: Modifications to the workspace must be made from onDock
. Trying to modify the Workspace from init
will likely fail, as
the workspace is not guaranteed to be available while init
is running. You can not change the enable/disable state of native
workspace buttons, as the workspace manages these internally based on the properties of the currently docked view.
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
Navigating between docked views
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.