Creating Master-Detail Forms with Vaadin and Grails
Join the DZone community and get the full member experience.
Join For FreeIn the article, Groovy, Grails and Vaadin, Petter Holmström outlined an example of using Vaadin with Grails, to build a simple data bound UI for a single database table. This was a great article that showed how one can quickly build a web-based solution that behaves very much like a typical client-server application. However, there are many instances where we need to be able to build an entry screen which allows the user to update both the master record and its detail records at the same time, such as in an Invoice entry screen. In this article I looked at extending the example in the above article, leveraging the simplicity of the Vaadin framework and the GORM capabilities, to build a master-detail example.
After years of doing development using Oracle Forms, I liked the way it provides generic functions on each form (or data block) to allow users to add new records, delete them, query them and navigate from one record to the next. Furthermore, in Oracle Forms, you could provide a data grid to allow the user to edit the related detail table (such as the invoice lines in an Invoice document) and keeps these records in sync with the Invoice header (master) record.
This is a first attempt at reproducing some of these capabilities using Vaadin and Grails.
The master-detail data model
The example here is an extension of the Petter Holmström model of the Trip Planner. In the original example, there is a table holding information on trips taken by the user. We will extend that example by having a one-to-many relation with a table of the places that are visited in each trip.
This is the code for the Trip domain class. Notice that we are using Grails 1.3.1 which, by default, puts the domain class into the tripplanner package.
package tripplanner
class Trip {
static constraints = {
name(nullable: true)
city(nullable: true)
startDate(nullable: true)
endDate(nullable: true)
purpose(nullable: true)
notes(nullable: true)
}
String name
String city
Date startDate
Date endDate
String purpose
String notes
static hasMany = [ places : Place ]
static mapping = {
places lazy:false, cascade:"all,delete-orphan"
}
}
We have made the fields nullable for ease of inserting the record into the table. We also created the one-to-many relation with the Place domain class, and put in the mapping to cascade the deletion and updates to the sub-table class. We also need to change the relation from lazy to eager to ensure that the all detail records are loaded at the same time as the master (header) record.
As for the Place domain class, we have 2 fields; name of the place we visited and a description field. In addition, we also put in a transient field, "_deleted", hich we will use to mark a detail record for deletion.
package tripplanner
class Place {
static constraints = {
name(nullable: true)
description(nullable: true)
}
String name
String description
boolean _deleted
static transients = [ '_deleted' ]
static belongsTo = [trip:Trip]
}
The master-detail form
Now, we need to change the VaadinApp class. Instead of listing the master record in a table to be selected for editing, I have opted to follow the Oracle Forms style for letting the user query for master records using Query By Example (QBE). The standard GORM and Hibernate infrastructure allows the use of a "example" object as a query pattern to extract a subset of records. This unfortunately is not exactly how Oracle
Forms QBE works as Oracle Forms allows the user to put in "A%" patterns to query for values that starts with "A" and ">999" to query for values that is bigger than 999. The QBE in GORM only allows exact matches. We will accept this limitation for this example.
So now we need a set of buttons to allow the user to:
- New - Add a new record data entry
- Save - Saves the current record to the database, including the changes done to the detail table
- Del - Deletes the current master record and the associated detail records
- EnterQ - Enters into Query mode which allows the user to provide a pattern for a QBE search
- ExecQ - Executes the Query and shows the first matching record from the query
- ExitQ - Exits out of Query mode and goes back to New record mode
- Prev - Go to previous record if there any queried records
- Next - Go to next record if there are any queried records
Error messages which arise from the save / update button are listed at the top of the master record form fields (in the "description field" of the Vaadin Form).
There is also a status indicator label located at the footer of the master record form ("footer section" of the Vaadin Form). The status label will show the current mode of the master record entry screen and also the current record number if this is part of a query set.
Below the master record form is the editable, selectable detail records table which is located in its own panel. Users are allowed to edit the detail record table only if the master record is in Edit mode. This means that users need to save the newly created master (header) record before that they can add any detail records.
The detail record table has a column for all the editable, visible records excluding the "_deleted" field which is for internal use only. To manipulate the detail records, we provide 2 buttons to add a new detail record (the "+" button) or delete the selected detail record (the "-" button). All additions, deletions or even updates are only saved to the database after the user clicks on the "Save" button in the master (header) record form.
As the user moves from one master record to the next using the "Prev" and "Next" buttons in the master table form, the detail table will be synchronized accordingly.
package tripplanner
import com.vaadin.ui.*
import com.vaadin.data.*
import com.vaadin.data.util.*
class VaadinApp extends com.vaadin.Application {
def query
def rec_pos
def container = new BeanItemContainer<Place>(Place.class)
def master_fields = ["name", "purpose", "startDate", "endDate", "city", "notes"]
def saveButton
def newButton
def delButton
def entQButton
def exeQButton
def extQButton
def prevButton
def nextButton
def statusLabel
def pnlDetail
void new_mode(editor) {
def tripInstance = new Trip()
editor.itemDataSource = new BeanItem(tripInstance)
editor.visibleItemProperties = master_fields
editor.description = ""
container.removeAllItems()
saveButton.setEnabled(true)
delButton.setEnabled(false)
entQButton.setEnabled(true)
extQButton.setEnabled(false)
statusLabel.setValue "New Mode"
pnlDetail.setEnabled(false)
}
void init() {
//def window = new Window("Trip Maintenance", new SplitPanel(SplitPanel.ORIENTATION_HORIZONTAL))
def window = new Window("Trip Maintenance")
setMainWindow window
// Form Panel
def panel = new Panel("Trip Maintenance")
panel.setSizeFull()
panel.setLayout(new VerticalLayout());
// Trip editor
def tripEditor = new Form()
tripEditor.setSizeFull()
tripEditor.layout.setMargin true
tripEditor.immediate = true
tripEditor.visible = true
// status bar - shows the mode of the form
statusLabel = new Label("New Mode")
// define master form buttons
saveButton = new Button("Save")
newButton = new Button("New")
delButton = new Button("Del")
entQButton = new Button("EnterQ")
exeQButton = new Button("ExecQ")
extQButton = new Button("ExitQ")
prevButton = new Button("Prev")
nextButton = new Button("Next")
// set initial state of buttons
delButton.setEnabled(false)
extQButton.setEnabled(false)
// default is New Mode
def v_tripInstance = new Trip()
tripEditor.itemDataSource = new BeanItem(v_tripInstance)
tripEditor.visibleItemProperties = master_fields
//new_mode(tripEditor)
// panel for detail form
pnlDetail = new Panel()
pnlDetail.setEnabled(false)
// table to hold the detail form rows
def table = new Table()
table.containerDataSource = container
table.selectable = true
table.editable = true
table.setSizeFull()
table.visibleColumns = ["name", "description"]
table.immediate = true
// toolbar for the detail form manipulation
def toolbar = new HorizontalLayout()
// buttons for detail form
// button to add new row to detail form
def addRowButton = new Button("+", new Button.ClickListener() {
void buttonClick(Button.ClickEvent event) {
def placeInstance = new Place()
def tripInstance = tripEditor.itemDataSource.bean
tripInstance.addToPlaces(placeInstance)
container.addBean placeInstance
}
})
toolbar.addComponent addRowButton
// button to delete current row from detail form
def delRowButton = new Button("-", new Button.ClickListener() {
void buttonClick(Button.ClickEvent event) {
if (table.value) {
def placeInstance = table.value
placeInstance._deleted = true
container.removeItem(placeInstance)
table.value = null
}
}
})
toolbar.addComponent delRowButton
// detail panel has the table rows and the toolbar
pnlDetail.addComponent toolbar
pnlDetail.addComponent table
// master form buttons
// save record button
saveButton.addListener(new Button.ClickListener() {
void buttonClick(Button.ClickEvent event) {
Trip.withTransaction { status ->
table.commit()
def tripInstance = tripEditor.itemDataSource.bean
def _toBeDeleted = tripInstance.places.findAll {it._deleted}
if (_toBeDeleted) {
tripInstance.places.removeAll(_toBeDeleted)
}
if (!tripInstance.save(flush:true)) {
tripEditor.description = "Error:<ul>"
tripInstance.errors.allErrors.each { error ->
tripEditor.description = tripEditor.description + "<li>" + "Field [${error.getField()}] with value [${error.getRejectedValue()}] is invalid</li>"
}
tripEditor.description = tripEditor.description + "</ul>"
window.showNotification "Could not save changes"
} else {
tripEditor.description = ""
window.showNotification "Changes saved"
delButton.setEnabled(true)
statusLabel.setValue("Edit Mode");
pnlDetail.setEnabled(true)
populate_container(container,tripInstance.places)
}
}
}
})
// new record button
newButton.addListener(new Button.ClickListener() {
void buttonClick(Button.ClickEvent event) {
new_mode(tripEditor)
}
})
// delete record button
delButton.addListener(new Button.ClickListener() {
void buttonClick(Button.ClickEvent event) {
if (tripEditor.itemDataSource.bean) {
Trip.withTransaction { status ->
def tripInstance = tripEditor.itemDataSource.bean
tripInstance.delete(flush:true)
}
}
new_mode(tripEditor)
}
})
// enter query mode
entQButton.addListener(new Button.ClickListener() {
void buttonClick(Button.ClickEvent event) {
def tripInstance = new Trip()
def newBean = new BeanItem(tripInstance)
tripEditor.itemDataSource = newBean
tripEditor.visibleItemProperties = master_fields
tripEditor.description = ""
saveButton.setEnabled(false)
delButton.setEnabled(false)
entQButton.setEnabled(false)
extQButton.setEnabled(true)
statusLabel.setValue("Query Mode");
pnlDetail.setEnabled(false)
}
})
// execute query
exeQButton.addListener(new Button.ClickListener() {
void buttonClick(Button.ClickEvent event) {
tripEditor.commit()
def example = (Trip) tripEditor.itemDataSource.bean
query = Trip.findAll(example)
rec_pos = 0
tripEditor.description = ""
entQButton.setEnabled(true)
extQButton.setEnabled(false)
if (query.size > 0) {
def next_rec = query[rec_pos]
tripEditor.itemDataSource = new BeanItem(next_rec)
tripEditor.visibleItemProperties = master_fields
saveButton.setEnabled(true)
delButton.setEnabled(true)
statusLabel.setValue("Edit Mode. Record " + (rec_pos+1) + "/" + query.size);
pnlDetail.setEnabled(true)
populate_container(container,next_rec.places)
} else {
window.showNotification "No record found"
new_mode(tripEditor)
}
}
})
// exit query mode. Back to New mode
extQButton.addListener(new Button.ClickListener() {
void buttonClick(Button.ClickEvent event) {
new_mode(tripEditor)
}
})
// edit mode, previous record
prevButton.addListener(new Button.ClickListener() {
void buttonClick(Button.ClickEvent event) {
if (!query) {
window.showNotification "No query result"
return
}
if (rec_pos > 0) {
rec_pos--
def next_rec = query[rec_pos]
tripEditor.itemDataSource = new BeanItem(next_rec)
tripEditor.visibleItemProperties = master_fields
saveButton.setEnabled(true)
statusLabel.setValue("Edit Mode. Record " + (rec_pos+1) + "/" + query.size)
pnlDetail.setEnabled(true)
populate_container(container,next_rec.places)
}
}
})
// edit mode, next record
nextButton.addListener(new Button.ClickListener() {
void buttonClick(Button.ClickEvent event) {
if (!query) {
window.showNotification "No query result"
return
}
if (rec_pos < query.size - 1) {
rec_pos++
def next_rec = query[rec_pos]
tripEditor.itemDataSource = new BeanItem(next_rec)
tripEditor.visibleItemProperties = master_fields
saveButton.setEnabled(true)
statusLabel.setValue("Edit Mode. Record " + (rec_pos+1) + "/" + query.size)
pnlDetail.setEnabled(true)
populate_container(container,next_rec.places)
}
}
})
// put the status bar on the footer of the master form
tripEditor.footer = new HorizontalLayout()
tripEditor.footer.addComponent statusLabel
// put all master form buttons into a horizontal panel
def btnPanel = new HorizontalLayout()
btnPanel.addComponent saveButton
btnPanel.addComponent newButton
btnPanel.addComponent delButton
btnPanel.addComponent entQButton
btnPanel.addComponent exeQButton
btnPanel.addComponent extQButton
btnPanel.addComponent prevButton
btnPanel.addComponent nextButton
// construct master-detail form panel
panel.addComponent btnPanel
panel.addComponent tripEditor
panel.addComponent pnlDetail
// set panel to main window
mainWindow.addComponent panel
}
// routine to populate the container with the detail records
void populate_container(container, details) {
container.removeAllItems()
details.each {
if (!it._deleted) container.addBean it
}
}
}
A walkthrough of the above VaadinApp code is as follows.
- The query object stores the list of "Trip" instances that is returned from a QBE query. The rec_pos is an index into the above list for the currently displayed record.
- The master_fields stores the list of master record fields that is visible on the form
- We put some of the buttons, status label and detail panel object as the object variables so that they can be access by helper routines
- The new_mode routine is to put the form into the New mode, which occurs multiple times in the whole code
- The init routine is where the whole window and form is defined
- The form is laid out with the master record buttons at the top, followed by the master record
form, and then the detail table sub-panel below that - In the detail sub-panel, there are 2 buttons, "+" and "-" which adds a detail to the Table and the master record instance and removes a detail record, respectively. These are not put into a GORM Transaction as we only want to save (flush) the record when the "Save" button is saved.
- The really tricky part of the whole form is in the master record buttons. For the "Save" button, we first force the detail table to update the source records in the master collection. Then we delete all the detail records which are marked for deletion, where the "_deleted" field is set to true. Finally the master record is saved by calling the "save" method in the tripInstance bean object. Any error messages are put into the "description field" of the master record form. If everything is fine, then the form is put into "Edit" mode for further editing including adding detail records into the Table below.
- The new button just puts a new Trip instance into the master record form (and disables the detail table panel)
- The delete button just deletes the master record. There is no need to "commit" the transaction as it is done automatically, and this differs from Oracle Forms
- The enter-query button just clears the fields to collect the parameters to search. If all the fields are null, then the QBE will match all records in the database
- Executing the query will run the "findAll(object)" method of the GORM domain instance. The return result is a List of matching instances. This is not quite scalable as the List needs to be stored in memory for the duration of the session or until the next query is run. This will need to be improved further. If there are any returned records, then the master form will display the first record and put it in "Edit" mode
- Exiting the Query mode will return the form to the New mode
- The previous and next buttons will update the master record form and the container of the detail table panel, thus keeping both screens in synchrony.
The picture on the left shows the data entry screen with the detail records for the current master record.
Future Direction
To improve on this, we will need to componentize the whole form so that developers do not need to copy and paste the above template code and modify it for each form. Furthermore the "container" object above should be changeable to allow for other types of containers to be used but they should not be data-bound and leave the data-binding to the GORM objects and layer.
Another suggestion is to create a Container component which is manipulated by the master table form buttons (add, save, delete, enter query, execute query, previous record, next record) and it then controls the master table entry Form.
Finally, as mentioned in the Petter Holmström article, we are not really using any feature of Grails other than the GORM, and so, this entire example can be build using just Groovy, GORM and Vaadin.
Opinions expressed by DZone contributors are their own.
Comments