В статье Groovy, Grails и Vaadin Петтер Холмстрем обрисовал пример использования Vaadin с Grails для создания простого пользовательского интерфейса с привязкой к данным для одной таблицы базы данных. Это была отличная статья, которая показала, как можно быстро создать веб-решение, которое очень похоже на типичное клиент-серверное приложение. Однако во многих случаях нам нужно иметь возможность создавать экран ввода, который позволяет пользователю одновременно обновлять как основную запись, так и подробные записи, например на экране ввода счета-фактуры. В этой статье я рассмотрел расширение примера в приведенной выше статье, используя простоту фреймворка Vaadin и возможности GORM, чтобы создать пример master-detail.
После многих лет разработки с использованием Oracle Forms мне понравилось, как она предоставляет общие функции для каждой формы (или блока данных), чтобы пользователи могли добавлять новые записи, удалять их, запрашивать их и переходить от одной записи к другой. Кроме того, в Oracle Forms вы можете предоставить сетку данных, позволяющую пользователю редактировать связанную таблицу подробностей (например, строки счета-фактуры в документе счета-фактуры) и синхронизировать эти записи с записью заголовка (основной) счета-фактуры.
Это первая попытка воспроизведения некоторых из этих возможностей с использованием Vaadin и Grails.
Модель данных master-detail
Примером здесь является расширение модели Petter Holmström Планировщика путешествий. В исходном примере есть таблица, содержащая информацию о поездках, совершенных пользователем. Мы расширим этот пример, имея отношение «один ко многим» с таблицей мест, которые посещаются в каждой поездке.
Это код для класса домена Trip. Обратите внимание, что мы используем Grails 1.3.1, который по умолчанию помещает класс домена в пакет tripplanner.
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"
}
}
Мы сделали поля обнуляемыми для простоты вставки записи в таблицу. Мы также создали отношение один-ко-многим с классом домена Place и поместили в отображение для каскадного удаления и обновления класса подстоли. Нам также нужно изменить отношение с ленивого на нетерпеливое, чтобы гарантировать, что все подробные записи загружаются одновременно с основной (заголовочной) записью.
Что касается класса домена Place, у нас есть 2 поля; название места, которое мы посетили, и поле описания. Кроме того, мы также добавили временное поле «_deleted», которое мы будем использовать для пометки записи детали для удаления.
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]
}
Форма мастер-детали
Теперь нам нужно изменить класс VaadinApp. Вместо того, чтобы перечислять основную запись в таблице, которая будет выбрана для редактирования, я предпочел следовать стилю Oracle Forms, чтобы позволить пользователю запрашивать основные записи, используя Query By Example (QBE). Стандартная инфраструктура GORM и Hibernate позволяет использовать «пример» объекта в качестве шаблона запроса для извлечения подмножества записей. К сожалению, это не совсем то, как Oracle
Forms QBE работает, поскольку Oracle Forms позволяет пользователю вводить шаблоны «A» для запроса значений, начинающихся с «A» и «> 999», для запроса значений, превышающих 999. QBE в GORM позволяет только точные совпадения. Мы примем это ограничение для этого примера.
Итак, теперь нам нужен набор кнопок, чтобы позволить пользователю:
- New — добавить новую запись данных записи
- Сохранить — сохраняет текущую запись в базу данных, включая изменения, внесенные в таблицу подробностей
- Del — удаляет текущую основную запись и связанные подробные записи
- EnterQ — входит в режим запроса, который позволяет пользователю предоставить шаблон для поиска QBE
- ExecQ — выполняет запрос и показывает первую соответствующую запись из запроса
- ExitQ — выход из режима запроса и возврат в режим новой записи.
- Prev — перейти к предыдущей записи, если есть какие-либо запрошенные записи
- Далее — перейти к следующей записи, если есть какие-либо запрошенные записи
Эти кнопки расположены в верхней части формы основной записи.
Сообщения об ошибках, возникающие из-за кнопки сохранения / обновления, перечислены в верхней части полей формы основной записи (в «поле описания» формы Vaadin).
Существует также метка индикатора состояния, расположенная в нижнем колонтитуле формы основной записи («нижний колонтитул» формы Vaadin). Метка состояния покажет текущий режим экрана ввода основной записи, а также номер текущей записи, если это является частью набора запросов.
Ниже формы основной записи находится редактируемая таблица записей подробных записей, которая расположена на отдельной панели. Пользователи могут редактировать таблицу подробных записей, только если основная запись находится в режиме редактирования. Это означает, что пользователям необходимо сохранить вновь созданную основную запись (заголовок), прежде чем они смогут добавить какие-либо подробные записи.
Таблица подробных записей содержит столбец для всех редактируемых, видимых записей, за исключением поля «_deleted», предназначенного только для внутреннего использования. Для управления подробными записями мы предоставляем 2 кнопки для добавления новой подробной записи (кнопка «+») или удаления выбранной подробной записи (кнопка «-»). Все добавления, удаления или даже обновления сохраняются в базе данных только после того, как пользователь нажимает кнопку «Сохранить» в форме основной записи (заголовка).
По мере того, как пользователь перемещается от одной основной записи к следующей с помощью кнопок «Prev» и «Next» в форме главной таблицы, таблица сведений будет синхронизироваться соответственно.
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
}
}
}
Пошаговое описание приведенного выше кода VaadinApp заключается в следующем.
- Объект запроса хранит список экземпляров «Trip», который возвращается из запроса QBE. Rec_pos — это указатель в вышеприведенном списке для текущей отображаемой записи.
- В master_fields хранится список полей основной записи, который отображается в форме
- Мы помещаем некоторые кнопки, метку статуса и объект панели сведений в качестве переменных объекта, чтобы к ним можно было обращаться с помощью вспомогательных процедур.
- Подпрограмма new_mode переводит форму в режим New, что происходит несколько раз во всем коде
- В подпрограмме init определены все окно и форма
- Форма размещается с кнопками основной записи в верхней части, затем следует
форма основной записи , а затем подпанель таблицы подробностей под этим - На дополнительной панели подробностей есть 2 кнопки «+» и «-», которые добавляют деталь к таблице и экземпляру основной записи и удаляют запись подробностей соответственно. Они не помещаются в транзакцию GORM, поскольку мы хотим сохранить (очистить) запись только после сохранения кнопки «Сохранить».
- Действительно сложная часть всей формы — кнопки основной записи. Для кнопки «Сохранить» мы сначала принудительно заставляем таблицу сведений обновить исходные записи в основной коллекции. Затем мы удаляем все подробные записи, помеченные для удаления, где для поля «_deleted» установлено значение true. Наконец, основная запись сохраняется путем вызова метода «save» в объекте bean tripInstance. Любые сообщения об ошибках помещаются в «поле описания» формы основной записи. Если все хорошо, то форма переводится в режим «Редактировать» для дальнейшего редактирования, включая добавление подробных записей в таблицу ниже.
- Новая кнопка просто помещает новый экземпляр Trip в форму основной записи (и отключает панель таблицы сведений)
- Кнопка удаления просто удаляет основную запись. Нет необходимости «фиксировать» транзакцию, как это делается автоматически, и это отличается от Oracle Forms
- Кнопка ввода запроса просто очищает поля для сбора параметров для поиска. Если все поля пусты, тогда QBE будет соответствовать всем записям в базе данных.
- При выполнении запроса будет запущен метод «findAll (object)» экземпляра домена GORM. Возвращаемый результат — список совпадающих экземпляров. Это не совсем масштабируемо, так как список необходимо хранить в памяти на время сеанса или до следующего запроса. Это нужно будет улучшить в дальнейшем. Если есть какие-либо возвращенные записи, то основная форма отобразит первую запись и переведет ее в режим «Редактировать».
- Выход из режима запроса вернет форму в новый режим
- Предыдущие и последующие кнопки обновят форму основной записи и контейнер панели подробных таблиц, таким образом поддерживая синхронность обоих экранов.
На рисунке слева показан экран ввода данных с подробными записями для текущей основной записи.
Будущее направление
Чтобы улучшить это, нам нужно будет разбить на части всю форму, чтобы разработчикам не нужно было копировать и вставлять приведенный выше код шаблона и изменять его для каждой формы. Кроме того, вышеприведенный объект «контейнер» должен быть изменяемым, чтобы можно было использовать другие типы контейнеров, но они не должны быть привязаны к данным и оставлять привязку данных к объектам и слою GORM.
Другим предложением является создание компонента Container, который управляется кнопками формы главной таблицы (добавить, сохранить, удалить, ввести запрос, выполнить запрос, предыдущую запись, следующую запись), а затем управлять формой записи основной таблицы.
Наконец, как уже упоминалось в статье Петтера Холмстрема, мы на самом деле не используем никаких функций Grails, кроме GORM, и поэтому весь этот пример может быть собран с использованием только Groovy, GORM и Vaadin.