Editing Models and Validation

TornadoFX doesn't force any particular architectural pattern on you as a developer, and it works equally well with both MVC, MVP, and their derivatives.

To help with implementing these patterns TornadoFX provides a tool called ViewModel that helps cleanly separate your UI and business logic, giving you features like rollback/commit and dirty state checking. These patterns are hard or cumbersome to implement manually, so it is advised to leverage the ViewModel and ItemViewModel when it is needed.

Typically you will use the ItemViewModel when you are creating a facade in front of a single object, and a ViewModelfor more complex situations.

A Typical Use Case

Say you have a given domain type Person. We allow its two properties to be nullable so they can be inputted later by the user.

import tornadofx.*

class Person(name: String? = null, title: String? = null) {
    val nameProperty = SimpleStringProperty(this, "name", name)
    var name by nameProperty

    val titleProperty = SimpleStringProperty(this, "title", title)
    var title by titleProperty 
}

(Notice the import, you need to import at least tornadofx.getValue and tornadofx.setValue for the by delegate to work)

Consider a Master/Detail view where you have a TableView displaying a list of people, and a Form where the currently selected person's information can be edited. Before we get into the ViewModel, we will create a version of this View without using the ViewModel.

Figure 11.1

Below is code for our first attempt in building this, and it has a number of problems we will address.

import javafx.scene.control.TableView
import javafx.scene.control.TextField
import javafx.scene.layout.BorderPane
import tornadofx.*

class Person(name: String? = null, title: String? = null) {
    val nameProperty = SimpleStringProperty(this, "name", name)
    var name by nameProperty

    val titleProperty = SimpleStringProperty(this, "title", title)
    var title by titleProperty 
}

class PersonEditor : View("Person Editor") {
    override val root = BorderPane()
    var nameField : TextField by singleAssign()
    var titleField : TextField by singleAssign()
    var personTable : TableView<Person> by singleAssign()
    // Some fake data for our table
    val persons = listOf(Person("John", "Manager"), Person("Jay", "Worker bee")).observable()

    var prevSelection: Person? = null

    init {
        with(root) {
            // TableView showing a list of people
            center {
                tableview(persons) {
                    personTable = this
                    column("Name", Person::nameProperty)
                    column("Title", Person::titleProperty)

                    // Edit the currently selected person
                    selectionModel.selectedItemProperty().onChange {
                        editPerson(it)
                        prevSelection = it
                    }
                }
            }

            right {
                form {
                    fieldset("Edit person") {
                        field("Name") {
                            textfield() {
                                nameField = this
                            }
                        }
                        field("Title") {
                            textfield() {
                                titleField = this
                            }
                        }
                        button("Save").action {
                            save()
                        }
                    }
                }
            }
        }
    }

    private fun editPerson(person: Person?) {
        if (person != null) {
            prevSelection?.apply {
                nameProperty.unbindBidirectional(nameField.textProperty())
                titleProperty.unbindBidirectional(titleField.textProperty())
            }
            nameField.bind(person.nameProperty)
            titleField.bind(person.titleProperty)
            prevSelection = person
        }
    }

    private fun save() {
        // Extract the selected person from the tableView
        val person = personTable.selectedItem!!

        // A real application would persist the person here
        println("Saving ${person.name} / ${person.title}")
    }
}

We define a View consisting of a TableView in the center of a BorderPane and a Form on the right side. We define some properties for the form fields and the table itself so we can reference them later.

While we build the table, we attach a listener to the selected item so we can call the editPerson function when the table selection changes. The editPerson function binds the properties of the selected person to the text fields in the form.

Problems with our initial attempt

At first glance it might look OK, but when we dig deeper there are several issues.

Manual binding

Every time the selection in the table changes, we have to unbind/rebind the data for the form fields manually. Apart from the added code and logic, there is another huge problem with this: the data is updated for every change in the text fields, and the changes will even be reflected in the table. While this might look cool and is technically correct, it presents one big problem: what if the user does not want to save the changes? We have no way of rolling back. So to prevent this, we would have to skip the binding altogether and manually extract the values from the text fields, then create a new Person object on save. In fact, this is a pattern found in many applications and expected by most users. Implementing a "Reset" button for this form would mean managing variables with the initial values and again assigning those values manually to the text fields.

Tight Coupling

Another issue is when it is time to save the edited person, the save function has to extract the selected item from the table again. For that to happen the save function has to know about the TableView. Alternatively it would have to know about the text fields like the editPerson function does, and manually extract the values to reconstruct a Person object.

Introducing ViewModel

The ViewModel is a mediator between the TableView and the Form. It acts as a middleman between the data in the text fields and the data in the actual Person object. As you will see, the code is much shorter and easier to reason about. The implementation code of the PersonModel will be shown shortly. For now just focus on its usage. Please note that this is not the recommended syntax, this merely serves as an explanation of the concepts.

class PersonEditor : View("Person Editor") {
    override val root = BorderPane()
    val persons = listOf(Person("John", "Manager"), Person("Jay", "Worker bee")).observable()
    val model = PersonModel(Person())

    init {
        with(root) {
            center {
                tableview(persons) {
                    column("Name", Person::nameProperty)
                    column("Title", Person::titleProperty)

                    // Update the person inside the view model on selection change
                    model.rebindOnChange(this) { selectedPerson ->
                        item = selectedPerson ?: Person()
                    }
                }
            }

            right {
                form {
                    fieldset("Edit person") {
                        field("Name") {
                            textfield(model.name)
                        }
                        field("Title") {
                            textfield(model.title)
                        }
                        button("Save") {
                            enableWhen(model.dirty)
                            action {
                                save()
                            }
                        }
                        button("Reset").action {
                            model.rollback()
                        }
                    }
                }
            }
        }
    }

    private fun save() {
        // Flush changes from the text fields into the model
        model.commit()

        // The edited person is contained in the model
        val person = model.item

        // A real application would persist the person here
        println("Saving ${person.name} / ${person.title}")
    }

}
class PersonModel(person: Person) : ItemViewModel<Person>(person) {
    val name = bind(Person::nameProperty)
    val title = bind(Person::titleProperty)
}

This looks a lot better, but what exactly is going on here? We have introduced a subclass of ViewModel called PersonModel. The model holds a Person object and has properties for the name and title fields. We will discuss the model further after we have looked at the rest of the client code.

Note that we hold no reference to the TableView or the text fields. Apart from a lot less code, the first big change is the way we update the Person inside the model:

model.rebindOnChange(this) { selectedPerson ->
    person = selectedPerson ?: Person()
}

The rebindOnChange() function takes the TableView as an argument and a function that will be called when the selection changes. This works with ListView,TreeView, TreeTableView, and any other ObservableValue as well. This function is called on the model and has the selectedPerson as its single argument. We assign the selected person to the person property of the model, or a new Person if the selection was empty/null. That way we ensure that there is always data for the model to present.

When we create the TextFields, we bind the model properties directly to it since most Node builders accept an ObservableValue to bind to.

field("Name") {
    textfield(model.name)
}

Even when the selection changes, the model properties persist but the values for the properties are updated. We totally avoid the manual binding from our previous attempt.

Another big change in this version is that the data in the table does not update when we type into the text fields. This is because the model has exposed a copy of the properties from the person object and does not write back into the actual person object before we call model.commit(). This is exactly what we do in the save function. Once commit has been called, the data in the facade is flushed back into our person object and the table will now reflect our changes.

Rollback

Since the model holds a reference to the actual Person object, we can reset the text fields to reflect the actual data in our Person object. We could add a reset button like this:

button("Reset").action {
    model.rollback()
}

When the button is pressed, any changes are discarded and the text fields show the actual Person object values again.

The PersonModel

We never explained how the PersonModel works yet, and you probably have been wondering about how the PersonModel is implemented. Here it is:

class PersonModel(person: Person) : ItemViewModel<Person>(person) {
    val name = bind(Person::nameProperty)
    val title = bind(Person::titleProperty)
}

It can hold a Person object, and it has defined two strange-looking properties called name and title via the bind delegate. Yeah it looks weird, but there is a very good reason for it. The { person.nameProperty } parameter for the bind function is a lambda that returns a property. This returned property is examined by the ViewModel, and a new property of the same type is created. It is then put into the name property of the ViewModel.

When we bind a text field to the name property of the model, only the copy is updated when you type into the text field. The ViewModel keeps track of which actual property belongs to which facade, and when you call commit the values from the facade are flushed into the actual backing property. On the flip side, when you call rollback the exact opposite happens: The actual property value is flushed into the facade.

The reason the actual property is wrapped in a function is that this makes it possible to change the person variable and then extract the property from that new person. You can read more about this below (rebinding).

Dirty Checking

The model has a Property called dirty. This is a BooleanBinding which you can observe to enable or disable certain features. For example, we could easily disable the save button until there are actual changes. The updated save button would look like this:

button("Save") {
    enableWhen(model.dirty)
    action {
        save()
    }
}

There is also a plain val called isDirty which returns a Boolean representing the dirty state for the entire model.

One thing to note is that if the backing object is being modified while the ViewModel is also modified via the UI, all uncommitted changes in the ViewModel are being overridden by the changes in the backing object. That means the data in the ViewModel might get lost if external modification of the backing object takes place.

val person = Person("John", "Manager")
val model = PersonModel(person)

model.name.value = "Johnny"   //modify the ViewModel
person.name = "Johan"         //modify the underlying object

println("  Person = ${person.name}, ${person.title}")             //output:   Person = Johan, Manager
println("Is dirty = ${model.isDirty}")                            //output: Is dirty = false
println("   Model = ${model.name.value}, ${model.title.value}")   //output:    Model = Johan, Manager

As can be seen above the changes in the ViewModel got overridden when the underlying object was modified. And the ViewModel was not flagged as dirty.

Dirty Properties

You can check if a specific property is dirty, meaning that it has been changed compared to the backing source object value.

val nameWasChanged = model.isDirty(model.name)

There is also an extension property version that accomplishes the same task:

val nameWasChanged = model.name.isDirty

The shorthand version is an extension val on Property<T> but it will only work for properties that are bound inside a ViewModel. You will find model.isNotDirty properties as well.

If you need to dynamically react based on the dirty state of a specific property in the ViewModel, you can get a hold of a BooleanBinding representing the dirty state of that field like this:

val nameDirtyProperty = model.dirtyStateFor(PersonModel::name)

Extracting the Source Object Value

To retrieve the backing object value for a property you can call model.backingValue(property).

val person = model.backingValue(property)

Specific Property Subtypes (IntegerProperty, BooleanProperty)

If you bind, for example, an IntegerProperty, the type of the facade property will look like Property<Int> but it is infact an IntegerProperty under the hood. If you need to access the special functions provided by IntegerProperty, you will have to cast the bind result:

val age = bind(Person::ageProperty) as IntegerProperty

Similarily, you can expose a read only property by specifying a read only type:

val age = bind(Person::ageProperty) as ReadOnlyIntegerProperty

The reason for this is an unfortunate shortcoming on the type system that prevents the compiler from differentiating between overloaded bind functions for these specific types, so the single bind function inside ViewModel inspects the property type and returns the best match, but unfortunately the return type signature has to be Property<T> for now.

Rebinding

As you saw in the TableView example above, it is possible to change the domain object that is wrapped by the ViewModel. This test case sheds some more light on that:

@Test fun swap_source_object() {
    val person1 = Person("Person 1")
    val person2 = Person("Person 2")

    val model = PersonModel(person1)
    assertEquals(model.name, "Person 1")

    model.rebind { person = person2 }
    assertEquals(model.name, "Person 2")
}

The test creates two Person objects and a ViewModel. The model is initialised with the first person object. It then checks that model.name corresponds to the name in person1. Now something weird happens:

model.rebind { person = person2 }

The code inside the rebind() block above will be executed and all the properties of the model are updated with values from the new source object. This is actually analogous to writing:

model.person = person2
model.rebind()

The form you choose is up to you, but the first form makes sure you do not forget to call rebind. After rebind is called, the model is not dirty and all values will reflect the ones form the new source object or source objects. It's important to note that you can pass multiple source objects to a view model and update all or some of them as you see fit.

Rebind Listener

Our TableView example called the rebindOnChange() function and passed in a TableView as the first argument. This made sure that rebind was called whenever the selection of the TableView changed. This is actually just a shortcut to another function with the same name that takes an observable and calls rebind whenever that observable changes. If you call this function, you do not need to call rebind manually as long as you have an observable that represent the state change that should cause the model to rebind.

As you saw, TableView has a shorthand support for the selectionModel.selectedItemProperty. If not for this shorthand function call, you would have to write it like this:

model.rebindOnChange(table.selectionModel.selectedItemProperty()) {
    item = it ?: Person()
}

The above example is included to clarify how the rebindOnChange() function works under the hood. For real use cases involving a TableView, you should opt for the bindSelected(model) function that is available when you combine TableView and ItemViewModel.

ItemViewModel

When working with the ViewModel you will notice some repetitive and somewhat verbose tasks. They include calling rebind or configuring rebindOnChange to change the source object. The ItemViewModel is an extension to the ViewModel and in almost all use cases you would want to inherit from this instead of the ViewModel class.

The ItemViewModel has a property called itemProperty of the specified type, so our PersonModel would now look like:

class PersonModel : ItemViewModel<Person>() {
    val name = bind(Person::nameProperty) 
    val title = bind(Person::titleProperty)
}

You will notice we no longer need to pass in the var person: Person in the constructor. The ItemViewModel now has an observable property called
itemProperty and getters/setters via the item property. Whenever you assign something to item or via itemProperty.value, the model is automatically rebound for you. There is also an observable empty boolean value you can use to check if the ItemViewModel is currently holding a Person.

The binding expressions need to take into account that it might not represent any item at the time of binding. That is why the binding expressions above now use the null safe operator.

We just got rid of some boiler plate, but the ItemViewModel gives us a lot more functionality. Remember how we bound the selected person from the TableView to our model earlier?

// Update the person inside the view model on selection change
model.rebindOnChange(this) { selectedPerson ->
    person = selectedPerson ?: Person()
}

Using the ItemViewModel this can be rewritten:

// Update the person inside the view model on selection change
bindSelected(model)

This will effectively attach the listener we had to write manually before and make sure that the TableView selection is visible in the model.

The save() function will now also be slightly different, since there is no person property in our model:

private fun save() {
    model.commit()
    val person = model.item
    println("Saving ${person.name} / ${person.title}")
}

The person is extracted from the itemProperty using the item getter.

OnCommit callback

Sometimes it's desirable to do a specific action after the model was successfully committed.
The ViewModel offers two callbacks, onCommit and onCommit(commits: List<Commit>), for that.

The first functiononCommit, has no parameters and will be called after a successful commit,
right before the optional successFn is invoked (see: commit).
The second function will be called in the same order and with the addition of passing a list of committed properties along.
Each Commit in the list, consists of the original ObservableValue, the oldValue and the newValue
and a property changed, to signal if the oldValue is different then the newValue.

Let's look at an example how we can retrieve only the changed objects and print them to stdout.

To find out which object changed we defined a little extension function, which will find the given property and if it was changed will return the old and new value or null if there was no change.

class PersonModel : ItemViewModel<Person>() {

    val firstname = bind(Person::firstName)
    val lastName = bind(Person::lastName)

    override fun onCommit(commits: List<Commit>) {
       // The println will only be called if findChanged is not null 
       commits.findChanged(firstName)?.let { println("First-Name changed from ${it.second} to ${it.first}")}
       commits.findChanged(lastName)?.let { println("Last-Name changed from ${it.second} to ${it.first}")}
    }

    private fun <T> List<Commit>.findChanged(ref: Property<T>): Pair<T, T>? {
        val commit = find { it.property == ref && it.changed}
        return commit?.let { (it.newValue as T) to (it.oldValue as T) }
    }
}

Injectable Models

Most commonly you will not have both the TableView and the editor in the same View. We would then need to access the ViewModel from at least two different views, one for the TableView and one for the form. Luckily, the ViewModel is injectable, so we can rewrite our editor example and split the two views:

class PersonList : View("Person List") {
    val persons = listOf(Person("John", "Manager"), Person("Jay", "Worker bee")).observable()
    val model : PersonModel by inject()

    override val root = tableview(persons) {
        title = "Person"
        column("Name", Person::nameProperty)
        column("Title", Person::titleProperty)
        bindSelected(model)
    }
}

The person TableView now becomes a lot cleaner and easier to reason with. In a real application the list of persons would probably come from a controller or a remoting call though. The model is simply injected into the View, and we will do the same for the editor:

class PersonEditor : View("Person Editor") {
    val model : PersonModel by inject()

    override val root = form {
        fieldset("Edit person") {
            field("Name") {
                textfield(model.name)
            }
            field("Title") {
                textfield(model.title)
            }
           button("Save") {
                enableWhen(model.dirty)
                action {
                    save()
                }
            }
            button("Reset").action {
                model.rollback()
            }
        }
    }

    private fun save() {
        model.commit()
        println("Saving ${model.item.name} / ${model.item.title}")
    }
}

The injected instance of the model will be the exact same one in both views. Again, in a real application the save call would probably be offloaded
to a controller asynchronously.

When to Use ViewModel vs ItemViewModel

This chapter has progressed from the low-level implementation ViewModel into a streamlined ItemViewModel. You might wonder if there are any use cases for inheriting from
ViewModel instead of ItemViewModel at all. The answer is that while you would typically extend ItemViewModel more than 90% of the time, there are some use cases where it does not make sense. Since ViewModels can be injected and used to keep navigational state and overall UI state, you might use it for situations where you do not have a single domain object - you could have multiple domain objects or just a collection of loose properties. In this use case the ItemViewModel does not make any sense, and you might implement the ViewModel directly. For common cases though, ItemViewModel is your best friend.

There is one potential issue with this approach. If we want to display multiple "pairs" of lists and forms, perhaps in different windows, we need a way to separate and bind the model belonging to a spesific pair of list and form. There are many ways to deal with that, but one tool very well suited for this is the scopes. Check out the scope documentation for more information about this approach.

Validation

Almost every application needs to check that the input supplied by the user conforms to a set of rules or are otherwise acceptable. TornadoFX sports an extensible validation and decoration framework.

We will first look at validation as a standalone feature before we integrate it with the ViewModel.

Under the Hood

The following explanation is a bit verbose and does not reflect the way you would write validation code in your application. This section will provide you with a solid understanding of how validation works and how the individual pieces fit together.

Validator

A Validator knows how to inspect user input of a specified type and will return a ValidationMessage with a ValidationSeverity describing how the input compares to the expected input for a specific control. If a Validator deems that there is nothing to report for an input value, it returns null. A text message can optionally accompany the ValidationMessage, and would normally be displayed by the Decorator configured in the ValidationContext. We will cover more on decorators later.

The following severity levels are supported:

  • Error - Input was not accepted
  • Warning - Input is not ideal, but accepted
  • Success - Input is accepted
  • Info - Input is accepted

There are multiple severity levels representing successful input to easier provide the contextually correct feedback in most cases. For example, you might want to give an informational message for a field no matter the input value, or specifically mark fields with a green checkbox when they are entered. The only severity that will result in an invalid status is the Error level.

ValidationTrigger

By default validation will happen when the input value changes. The input value is always an ObservableValue<T>, and the default trigger simply listens for changes. You can however choose
to validate when the input field looses focus, or when a save button is clicked for instance. The following ValidationTriggers can be configured for each validator:

  • OnChange - Validate when input value changes, optionally after a given delay in milliseconds
  • OnBlur - Validate when the input field looses focus
  • Never - Only validate when ValidationContext.validate() is called

ValidationContext

Normally you would validate user input from multiple controls or input fields at once. You can gather these validators in a ValidationContext so you can check if all validators are valid, or ask the validation context to perform validation for all fields at any given time. The context also controls what kind of decorator will be used to convey the validation message for each field. See the Ad Hoc validation example below.

Decorator

The decorationProvider of a ValidationContext is in charge of providing feedback when a ValidationMessage is associated with an input. By default this is an instance of SimpleMessageDecoratorwhich will mark the input field with a colored triangle in the topper left corner and display a popup with the message while the input has focus.

Figure 11.2 The default decorator showing a required field validation message

If you don't like the default decorator look you can easily create your own by implementing the Decorator interface:

interface Decorator {
    fun decorate(node: Node)
    fun undecorate(node: Node)
}

You can assign your decorator to a given ValidationContext like this:

context.decorationProvider = MyDecorator()

Tip: You can create a decorator that applies CSS style classes to your inputs instead of overlaying other nodes to provide feedback.

Ad Hoc Validation

While you will probably never do this in a real application, it is possible to set up a ValidationContext and apply validators to it manually. The following
example is actually taken from the internal tests of the framework. It illustrates the concept, but is not a practical pattern in an application.

// Create a validation context
val context = ValidationContext()

// Create a TextField we can attach validation to
val input = TextField()

// Define a validator that accepts input longer than 5 chars
val validator = context.addValidator(input, input.textProperty()) {
    if (it!!.length < 5) error("Too short") else null
}

// Simulate user input
input.text = "abc"

// Validation should fail
assertFalse(validator.validate())

// Extract the validation result
val result = validator.result

// The severity should be error
assertTrue(result is ValidationMessage && result.severity == ValidationSeverity.Error)

// Confirm valid input passes validation
input.text = "longvalue"
assertTrue(validator.validate())
assertNull(validator.result)

Take special note of the last parameter to the addValidator call. This is the actual validation logic. The function is passed the
current input for the property it validates and must return null if there are no messages, or an instance of ValidationMessage if something
is noteworthy about the input. A message with severity Error will cause the validation to fail. As you can see, you don't need to instantiate
a ValidationMessage yourself, simply use one of the functions error, warning, success or info instead.

Validation with ViewModel

Every ViewModel contains a ValidationContext, so you don't need to instantiate one yourself. The Validation framework integrates with the type
safe builders as well, and even provides some built in validators, like the required validator. Going back to our person editor, we can
make the input fields required with this simple change:

field("Name") {
    textfield(model.name).required()
}

That's all there is to it. The required validator optionally takes a message that will be presented to the user if the validation fails. The default text
is "This field is required".

Instead of using the built in required validator we can express the same thing manually:

field("Name") {
    textfield(model.name).validator {
        if (it.isNullOrBlank()) error("The name field is required") else null
    }
}

If you want to further customize the textfield, you might want to add another set of curly braces:

field("Name") {
    textfield(model.name) {
        // Manipulate the text field here
        validator {
            if (it.isNullOrBlank()) error("The name field is required") else null
        }
    }
}

Binding buttons to validation state

You might want to only enable certain buttons in your forms when the input is valid. The model.valid property can be used for this purpose. Since
the default validation trigger is OnChange, the valid state would only be accurate when you first try to commit the model. However, if you want
to bind a button to the valid state of the model you can call model.validate(decorateErrors = false) to force all validators to report their results without
actually showing any validation errors to the user.

field("username") {
    textfield(username).required()
}
field("password") {
    passwordfield(password).required()
}
buttonbar {
    button("Login", ButtonBar.ButtonData.OK_DONE) {
        enableWhen(model.valid)
        action {
            model.commit {
                doLogin()
            }
        }
    }
}
// Force validators to update the `model.valid` property
model.validate(decorateErrors = false)

Notice how the login button's enabled state is bound to the enabled state of the model via enableWhen { model.valid } call. After all
the fields and validators are configured, the model.validate(decorateErrors = false) make sure the valid state of the model is updated
without triggering error decorations on the fields that fail validation. The decorators will kick in on value change by default, unless you
override the trigger parameter to validator. The required() build in validator also accepts this parameter. For example, to run the validator
only when the input field looses focus you can call textfield(username).required(ValidationTrigger.OnBlur).

Validation in dialogs

The dialog builder creates a window with a form and a fieldset and let's you start adding fields to it. Some times you don't have a ViewModel for such cases, but you might still want to
use the features it provides. For such situations you can instantiate a ViewModel inline and hook up one or more properties to it. Here is an example dialog that requires the user to enter some input in a textarea:

dialog("Add note") {
    val model = ViewModel()
    val note = model.bind { SimpleStringProperty() }

    field("Note") {
        textarea(note) {
            required()
            whenDocked { requestFocus() }
        }
    }
    buttonbar {
        button("Save note").action {
            model.commit { doSave() }
        }
    }
}

Figure 11.3 A dialog with a inline ViewModel context

Notice how the note property is connected to the context by specifying it's bean parameter. This is crucial for making the field validation available.

Partial commit

It's also possible to do a partial commit by suppling a list of fields you want to commit to avoid committing everything. This can be convenient in situations where you edit the same
ViewModel instance from different Views, for example in a Wizard. See the Wizard chapter for more information about partial commit, and the corresponding partial validation features.

TableViewEditModel

If you are pressed for screen real estate and do not have space for a master/detail setup with a TableView, an effective option is to edit the TableView directly. By enabling a few streamlined features in TornadoFX, you can not only enable easy cell editing but also enable dirty state tracking, committing, and rollback. By calling enableCellEditing() and enableDirtyTracking(), as well as accessing the tableViewEditModel property of a TableView, you can easily enable this functionality.

When you edit a cell, a blue flag will indicate its dirty state. Calling rollback() will revert dirty cells to their original values, whereas commit() will set the current values as the new baseline (and remove all dirty state history).

import tornadofx.*

class MyApp: App(MyView::class)
class MyView : View("My View") {

    val controller: CustomerController by inject()
    var tableViewEditModel: TableViewEditModel<Customer> by singleAssign()

    override val root =  borderpane {
        top = buttonbar {
            button("COMMIT").setOnAction {
                tableViewEditModel.commit()
            }
            button("ROLLBACK").setOnAction {
                tableViewEditModel.rollback()
            }
        }
        center = tableview<Customer> {

            items = controller.customers
            isEditable = true

            column("ID",Customer::idProperty)
            column("FIRST NAME", Customer::firstNameProperty).makeEditable()
            column("LAST NAME", Customer::lastNameProperty).makeEditable()

            enableCellEditing() //enables easier cell navigation/editing
            enableDirtyTracking() //flags cells that are dirty

            tableViewEditModel = editModel
        }
    }
}

class CustomerController : Controller() {
    val customers = listOf(
            Customer(1, "Marley", "John"),
            Customer(2, "Schmidt", "Ally"),
            Customer(3, "Johnson", "Eric")
    ).observable()
}

class Customer(id: Int, lastName: String, firstName: String) {
    val lastNameProperty = SimpleStringProperty(this, "lastName", lastName)
    var lastName by lastNameProperty
    val firstNameProperty = SimpleStringProperty(this, "firstName", firstName) 
    var firstName by firstNameProperty
    val idProperty = SimpleIntegerProperty(this, "id", id) 
    var id by idProperty
}

Figure 11.4 A TableView with dirty state tracking, with rollback() and commit() functionality.

Note also there are many other helpful properties and functions on the TableViewEditModel. The items property is an ObservableMap<S, TableColumnDirtyState<S>> mapping the dirty state of each record item S. If you want to filter out and commit only dirty records so you can persist them somewhere, you can have your "Commit" Button perform this action instead.

button("COMMIT").action {
    tableViewEditModel.items.asSequence()
            .filter { it.value.isDirty }
            .forEach {
                println("Committing ${it.key}")
                it.value.commit()
            }
}

There are also commitSelected() and rollbackSelected() to only commit or rollback the selected records in the TableView.

results matching ""

    No results matching ""