11. Editing Models and Validation
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.Let's say we have a certain domain type
Person
. We will allow its two properties to be nullable so that they can be entered 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
The code below is our first attempt to build this, and it has some problems that 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.At first glance it might look OK, but when we dig deeper there are several issues.
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.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.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. 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
ItemViewModel
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 ->
item = 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 item
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. Since we're calling this function from inside the TreeView
builder, there is a function to simplify this even further: the 3 lines above can be replaced with simply bindSelected(model)
.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.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.class PersonModel(person: Person) : ItemViewModel<Person>(person) {
val name = bind(Person::nameProperty)
val title = bind(Person::titleProperty)
}
We never explained how the
PersonModel
works yet. 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 KProperty (in other words, it is used to get the actual Person.nameProperty
from any Person
instance). This way, whenever the Person
object is changed (using rebindOnChange()
or model.item = ...
, the name
and title
properties can be changed accordingly to reflect the properties of the new Person
object.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).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
.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)
To retrieve the backing object value for a property you can call
model.backingValue(property)
.val value = model.backingValue(property)
If you bind, for example, an
IntegerProperty
, the type of the facade property will look like Property<Int>
but it is in fact 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
Similarly, 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.As you saw in the
TableView
example above, it is possible to change the domain object that is wrapped by the ItemViewModel
. 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.item = person2
assertEquals(model.name, "Person 2")
}
The test creates two
Person
objects and an ItemViewModel
. 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.item = person2
The ItemViewProperty has an
itemProperty
which holds the current item (accessible via the item
Kotlin property, just like our Person
's nameProperty
can be accessed using name
). Whenever this property changes, the ItemViewModel
updates all of its properties (such as name
and age
in our case) to reflect the new Person
object.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
.The
ItemViewModel
is an extension to ViewModel
, where all properties correspond to a given object's properties (a Person
object in our case). It also provides a simple way to rebind all of its properties at the same time by changing the item
property to a new value.A ViewModel allows you to bind to any property you would like (not necessarily encapsulated in a special class such as Person) and apply the same functionality to it: obtain a new property that can be edited by the user, then commited or rolled back to the original as needed. Instead of using
bind(Person::name)
, you can use a lambda: bind { obtainProperty() }
, where the lambda would return different properties depending on the situation, in a similar manner our Person object changes in the ItemViewModel. It should be noted, though, that the lambda will not be re-evaluated until the rollback()
function is called (Note: in the case of the ItemViewModel, this function is called automatically after changing the value of the item property). Specifically, should the properties be changed, the function to do so is rebind { }
. Under the hood, all this function does is to execute the lambda and then call rollback()
.A very simple implementation of a ViewModel:
class MyViewModel(initialProperty : Property<String>) : ViewModel() {
var myProperty : Property<String> = initialProperty
val propertyFacade = bind { myProperty }
}
If we would like to update the property, we can do it like so:
var model = MyViewModel(someInitialProperty)
// more code...
model.rebind {
myProperty = someOtherProperty
}
Basically, instead of having to rely on a
Person
class to bind to its properties, we can bind to any property. Now you may wonder if there are any cases would be for a low-level implementation ViewModel
when you could simply use the streamlined ItemViewModel
. 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 specific 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.
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 function
onCommit
, 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 of 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) }
}
}
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. However, in a real application, the list of persons would likely come from a controller or a remote call. 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 exactly the same one in both views. Again, in a real application the save call would probably be offloaded to a controller asynchronously.
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 specific 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.
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
.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.
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.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
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.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 look of the default decorator 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.
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)
Pay special attention to the last parameter of the
addValidator
call. It is the actual validation logic. The function receives the current input for the property that 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.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
}
}
}
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()
built in validator also accepts this parameter. For example, to only run the validator when the input field looses focus you can call textfield(username).required(ValidationTrigger.OnBlur)
.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.It's also possible to do a partial commit by supplying a list of fields you want to commit in order 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.
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 that 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
.Last modified 1yr ago