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 ViewModel
for 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 calleditemProperty
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 fromViewModel
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 acceptedWarning
- Input is not ideal, but acceptedSuccess
- Input is acceptedInfo
- 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 millisecondsOnBlur
- Validate when the input field looses focusNever
- Only validate whenValidationContext.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 SimpleMessageDecorator
which 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
.