All Downloads are FREE. Search and download functionalities are using the official Maven repository.

org.dellroad.stuff.vaadin24.field.FieldBuilderCustomField Maven / Gradle / Ivy

The newest version!

/*
 * Copyright (C) 2022 Archie L. Cobbs. All rights reserved.
 */

package org.dellroad.stuff.vaadin24.field;

import com.google.common.base.Preconditions;
import com.vaadin.flow.component.AbstractField;
import com.vaadin.flow.component.Key;
import com.vaadin.flow.component.button.Button;
import com.vaadin.flow.component.button.ButtonVariant;
import com.vaadin.flow.component.customfield.CustomField;
import com.vaadin.flow.component.dialog.Dialog;
import com.vaadin.flow.component.formlayout.FormLayout;
import com.vaadin.flow.component.orderedlayout.HorizontalLayout;

import java.util.Optional;

/**
 * Support superclass that mostly automates the creation of {@link CustomField}s for editing any model type
 * using sub-fields automatically generated from {@link FieldBuilder} annotations to arbitrary recursion depth.
 *
 * 
 * 
 * 
 *
 * 

* This class is just a {@link BinderCustomField} that uses a {@link FieldBuilder} to generate the sub-fields. * *

Example * *

* Suppose model class {@code Contract} has a "term" property of type {@code DateInterval} which has a start and end date. * We want to use {@link FieldBuilder} annotations to define the editor fields for the properties of {@code Contract}, * including {@code "term"}, but for that to work we will need to specify a custom field to handle the {@code "term"} property. * We also want this custom field to contain the logic for laying out the two date picker components as well as for * validating proper ordering. * *

* For example: * *


 * // This is complex type that we wish to edit with a single CustomField
 * public class DateInterval {
 *
 *     @FieldBuilder.DatePicker(label = "Start date")
 *     public LocalDate getStartDate() { ... }
 *     public void setStartDate(LocalDate date) { ... }
 *
 *     @FieldBuilder.DatePicker(label = "End date")
 *     public LocalDate getEndDate() { ... }
 *     public void setEndDate(LocalDate date) { ... }
 * }
 *
 * // This is the corresponding custom field
 * public class DateIntervalField extends FieldBuilderCustomField<DateInterval> {
 *
 *     public DateIntervalField() {
 *         super(DateInterval.class);
 *     }
 *
 *     // Customize how we want to layout the subfields
 *     @Override
 *     protected void layoutComponents() {
 *        this.add(new HorizontalLayout(
 *          new Text("From"), this.getField("startDate")), new Text("to"), this.getField("endDate"));
 *     }
 *
 *     // Bean level validation: ensure end date is after start date
 *     @Override
 *     public ValidationResult validate(DateInterval dates, ValueContext ctx) {
 *         if (dates.getStartDate().isAfter(dates.getEndDate())
 *             return ValidationResult.error("Dates out-of-order"));
 *         return super.validate(dates, ctx);           // always ensure superclass contraints apply also
 *     }
 * }
 * 
* *

* Once that's done, using {@link FieldBuilder} works recursively and automatically for this multi-level class: * *


 * public class Contract {
 *
 *     @FieldBuilder.CheckBox(label = "Approved?")
 *     public boolean isApproved() { ... }
 *     public void setApproved(boolean approved) { ... }
 *
 *     @FieldBuilder.CustomField(label = "Term", implementation = DateIntervalField.class)
 *     public DateInterval getTerm() { ... }
 *     public void setTerm(DateInterval term) { ... }
 * }
 * 
* *

* If you don't need to do any customization, you can use {@link FieldBuilderCustomField} directly; the field's * value type will be inferred by the {@link #FieldBuilderCustomField(FieldBuilderContext)} constructor: * *


 * public class Contract {
 *
 *     @FieldBuilder.CheckBox(label = "Approved?")
 *     public boolean isApproved() { ... }
 *     public void setApproved(boolean approved) { ... }
 *
 *     // "DateInterval" value type is inferred from the method's return type
 *     @FieldBuilder.CustomField(label = "Term", implementation = FieldBuilderCustomField.class)
 *     public DateInterval getTerm() { ... }
 *     public void setTerm(DateInterval term) { ... }
 * }
 * 
* *

Edit in Dialog Window * *

* For more complex value types, this class support an alternative display model in which the value's sub-fields * are edited in a {@link Dialog} window instead of inline in the form. In lieu of the inline sub-fields, the form * displays an "Edit" button which, when clicked, opens a new {@link Dialog} window into which the sub-fields * are laid out, plus "OK" and "Cancel" buttons. * *

* To configure an edit dialog, add a {@link FieldBuilderCustomField.DialogForm @FieldBuilderCustomField.DialogForm} * annotation to the method: * *


 * public class Contract {
 *
 *     @FieldBuilder.CheckBox(label = "Approved?")
 *     public boolean isApproved() { ... }
 *     public void setApproved(boolean approved) { ... }
 *
 *     @FieldBuilder.CustomField(label = "Term", implementation = FieldBuilderCustomField.class)
 *     @FieldBuilderCustomField.DialogForm(windowTitle = "Contract Term")
 *     public DateInterval getTerm() { ... }
 *     public void setTerm(DateInterval term) { ... }
 * }
 * 
* *

Nullable Values * *

* When the field's value can be null, consider adding a * {@link AbstractFieldBuilder.NullifyCheckbox @FieldBuilder.NullifyCheckbox} annotation. * * @param field value type */ @SuppressWarnings("serial") public class FieldBuilderCustomField extends BinderCustomField { protected final DialogForm dialogForm; /** * The field builder that builds this instance's sub-fields. */ protected AbstractFieldBuilder fieldBuilder; /** * Auto-configure Constructor. * *

* This constructor will infer the target type and find any {@link DialogForm @FieldBuilderCustomField.DialogForm} * annotation by inspecting the annotated method. * * @param ctx field builder context * @throws NullPointerException if {@code ctx} is null */ @SuppressWarnings("unchecked") public FieldBuilderCustomField(FieldBuilderContext ctx) { this((Class)ctx.inferDataModelType(), ctx.getMethod().getAnnotation(DialogForm.class)); } /** * Constructor. * * @param modelType model type to introspect for {@link AbstractFieldBuilder} annotations * @throws IllegalArgumentException if {@code modelType} is null */ public FieldBuilderCustomField(Class modelType) { this(modelType, null); } /** * Constructor. * * @param modelType model type to introspect for {@link AbstractFieldBuilder} annotations * @param dialogForm configuration for using a {@link Dialog} window to edit the field value, or null for inline editing * @throws IllegalArgumentException if {@code modelType} is null */ public FieldBuilderCustomField(Class modelType, DialogForm dialogForm) { super(modelType); this.dialogForm = dialogForm; } /** * {@inheritDoc} * *

* The implementation in {@link FieldBuilderCustomField} just invokes * {@link FieldBuilder#bindFields FieldBuilder.bindFields}{@code (this.binder)}. */ @Override protected void createAndBindFields() { this.fieldBuilder = this.createFieldBuilder(); this.fieldBuilder.bindFields(this.binder); } /** * Create a new {@link AbstractFieldBuilder} for the given type. * *

* The implementation in {@link FieldBuilderCustomField} returns a new {@link FieldBuilder} each time. * * @return field builder */ protected AbstractFieldBuilder createFieldBuilder() { return new FieldBuilder<>(this.modelType); } /** * Layout the components required for this field. * *

* The implementation in {@link FieldBuilderCustomField} delegates to {@link #layoutInlineComponents()} * if {@code #dialogForm} is null, otherwise {@link #layoutNonInlineComponents()}. */ protected void layoutComponents() { if (this.dialogForm == null) this.layoutInlineComponents(); else this.layoutNonInlineComponents(); } /** * Layout components required for this field when a separate edit dialog window is not being used. * *

* The implementation in {@link FieldBuilderCustomField} iterates the bound fields in {@link #fieldBuilder} * into a new {@link HorizontalLayout} which is then added to this instance. */ protected void layoutInlineComponents() { final HorizontalLayout layout = new HorizontalLayout(); this.add(layout); this.fieldBuilder.getFieldComponents().values().stream() .map(FieldComponent::getComponent) .forEach(layout::add); } /** * Layout components required for this field when a separate edit dialog window is being used. * *

* The implementation in {@link FieldBuilderCustomField} */ protected void layoutNonInlineComponents() { final HorizontalLayout layout = new HorizontalLayout(); this.add(layout); final Button editButton = new Button(this.dialogForm.editButtonLabel(), e -> this.editButtonPressed()); layout.add(editButton); } protected void editButtonPressed() { // Get current value, which must not be null, else resort to a new bean final T bean = Optional.ofNullable(this.getValue()) .orElseGet(this::createNewBean); // Open dialog to edit it this.openEditDialog(bean); } /** * Open a dialog window containing a form for editing the given value. * * @param bean field value to edit * @throws IllegalArgumentException if {@code bean} is null * @throws IllegalStateException if {@code this.dialogForm} is null */ protected void openEditDialog(T bean) { // Sanity check Preconditions.checkArgument(bean != null, "null bean"); Preconditions.checkState(this.dialogForm != null, "no @DialogForm"); // Create dialog window final Dialog dialog = new Dialog(this.dialogForm.windowTitle()); dialog.setModal(false); // workaround for https://github.com/vaadin/flow-components/issues/6052 dialog.setHeaderTitle(this.dialogForm.windowTitle()); // Create form final FormLayout formLayout = new FormLayout(); // Add fields to form this.layoutEditDialogFields(formLayout); // Add buttons final Button submitButton = new Button(this.dialogForm.submitButtonLabel(), e -> { if (this.submitEditDialog(bean)) dialog.close(); }); submitButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY); submitButton.addClickShortcut(Key.ENTER); final Button cancelButton = new Button(this.dialogForm.cancelButtonLabel(), e -> dialog.close()); cancelButton.addThemeVariants(ButtonVariant.LUMO_TERTIARY); cancelButton.addClickShortcut(Key.ESCAPE); formLayout.addFormItem(new HorizontalLayout(submitButton, cancelButton), ""); // Add form and open dialog window dialog.add(formLayout); dialog.open(); } protected boolean submitEditDialog(T bean) { Preconditions.checkArgument(bean != null, "null bean"); return this.binder.writeBeanIfValid(bean); } /** * Layout components required for this field in a separate edit dialog window. * *

* The implementation in {@link FieldBuilderCustomField} delegtes to {@link FieldBuilder#addFieldComponents}. * * @param formLayout form */ protected void layoutEditDialogFields(FormLayout formLayout) { this.fieldBuilder.addFieldComponents(formLayout); } /** * Get the {@link FieldComponent} sub-field corresponding to the given field name. * * @param name field name * @return corresponding {@link FieldComponent} * @throws IllegalArgumentException if {@code name} is not found * @throws IllegalArgumentException if {@code name} is null */ protected AbstractField getField(String name) { final FieldComponent fieldComponent = this.getFieldComponent(name); try { return (AbstractField)fieldComponent.getField(); } catch (ClassCastException e) { throw new IllegalArgumentException(String.format("not an AbstractField: %s", name)); } } /** * Get the {@link AbstractField} sub-field corresponding to the given field name. * * @param name field name * @return corresponding {@link AbstractField} * @throws IllegalArgumentException if {@code name} is not found * @throws IllegalArgumentException if {@code name}'s {@linkplain FieldComponent#getField field} is not an {@link AbstractField} * @throws IllegalArgumentException if {@code name} is null */ protected FieldComponent getFieldComponent(String name) { if (name == null) throw new IllegalArgumentException("null name"); final FieldComponent fieldComponent = this.fieldBuilder.getFieldComponents().get(name); if (fieldComponent == null) throw new IllegalArgumentException(String.format("no such field: %s", name)); return fieldComponent; } // DialogForm public @interface DialogForm { /** * Title for the dialog window. */ String windowTitle() default ""; /** * Edit button label. */ String editButtonLabel() default "Edit"; /** * Submit button label. */ String submitButtonLabel() default "OK"; /** * Cancel button label. */ String cancelButtonLabel() default "Cancel"; } }





© 2015 - 2025 Weber Informatics LLC | Privacy Policy