diff --git a/.github/ISSUE_TEMPLATE/bug-report.yml b/.github/ISSUE_TEMPLATE/bug-report.yml new file mode 100644 index 0000000..7ff0972 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug-report.yml @@ -0,0 +1,47 @@ +name: Bug Report +description: Please report issues related to Easy Form Add-on here. +body: + - type: textarea + id: problem-description + attributes: + label: Describe the bug + description: A clear description of the issue you're experiencing. + validations: + required: true + - type: textarea + id: expected-behavior + attributes: + label: Expected behavior + description: A clear and concise description of the expected behavior. + validations: + required: false + - type: textarea + id: minimal-reproduction + attributes: + label: Minimal reproducible example + description: If possible, add a concise code snippet that reproduces the issue and describe the steps needed to follow to reproduce it. + validations: + required: false + - type: input + id: addon-version + attributes: + label: Add-on Version + description: The version of the add-on on which you're experiencing the issue. + validations: + required: true + - type: input + id: vaadin-version + attributes: + label: Vaadin Version + description: The complete Vaadin version (X.Y.Z) on which the issue is reproducible. + validations: + required: true + - type: textarea + id: additional-information + attributes: + label: Additional information + description: "Any other context/information about the issue can be added here (browser, OS, etc.)." + validations: + required: false + + diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..3ba13e0 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1 @@ +blank_issues_enabled: false diff --git a/.github/ISSUE_TEMPLATE/feature-request.yml b/.github/ISSUE_TEMPLATE/feature-request.yml new file mode 100644 index 0000000..0ab0287 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature-request.yml @@ -0,0 +1,24 @@ +name: Feature Request +description: Please add feature suggestions related to Easy Form Add-on here. +body: + - type: textarea + id: feature-proposal + attributes: + label: Feature proposal + description: A concise but detailed description of the feature that you would like to see in the add-on. + validations: + required: true + - type: textarea + id: feature-implementation + attributes: + label: Describe solution expectations + description: Do you have an idea/expectations of how it could be implemented? Did you try a possible solution that you want to share? + validations: + required: false + - type: textarea + id: additional-information + attributes: + label: Additional information + description: Add any extra information you think it might be relevant to the request. + validations: + required: false diff --git a/.github/workflows/maven.yml b/.github/workflows/maven.yml index 95c3fe2..d25a5b9 100644 --- a/.github/workflows/maven.yml +++ b/.github/workflows/maven.yml @@ -19,7 +19,7 @@ jobs: build-vaadin24: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set up JDK uses: actions/setup-java@v3 with: @@ -27,4 +27,17 @@ jobs: distribution: 'temurin' cache: maven - name: Build (Vaadin 24) - run: mvn -B package --file pom.xml \ No newline at end of file + run: mvn -B package --file pom.xml + + build-vaadin25: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Set up JDK + uses: actions/setup-java@v3 + with: + java-version: '21' + distribution: 'temurin' + cache: maven + - name: Build (Vaadin 25) + run: mvn -B package --file pom.xml -Pv25 \ No newline at end of file diff --git a/.gitignore b/.gitignore index 8a30ffa..16f259c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ node_modules target .vscode +.vaadin .settings .project .classpath @@ -10,11 +11,14 @@ package.json webpack.config.js /error-screenshots drivers -tsconfig.json .idea -types.d.ts -/frontend/generated -/frontend/index.html +.eclipse +/tsconfig.json* +/types.d.ts* vite.generated.ts vite.config.ts -/src/main/dev-bundle \ No newline at end of file +/src/main/dev-bundle +/src/main/bundles +/src/main/frontend/generated +/src/main/frontend/index.html +/.claude/settings.local.json diff --git a/.vaadin/copilot/vaadin-copilot.properties b/.vaadin/copilot/vaadin-copilot.properties index 0e8b963..9cd78a6 100644 --- a/.vaadin/copilot/vaadin-copilot.properties +++ b/.vaadin/copilot/vaadin-copilot.properties @@ -1,6 +1,6 @@ #Vaadin Copilot Integration Runtime Properties -#Tue Jan 06 09:29:34 ART 2026 -endpoint=http\://127.0.0.1\:50537/vaadin/copilot-3789fb28-3a13-4614-8e14-1bb9afeea771 +#Tue Jul 28 12:03:17 ART 2026 +endpoint=http\://127.0.0.1\:56632/vaadin/copilot-e2a26822-4451-492a-b12a-cc2b7dbc2666 ide=eclipse supportedActions=write,writeBase64,delete,undo,redo,refresh,showInIde,getModulePaths,compileFiles,restartApplication,getVaadinRoutes,getVaadinVersion,getVaadinComponents,getVaadinEntities,getVaadinSecurity,reloadMavenModule,heartbeat version=1.0.0 diff --git a/README.md b/README.md index 44391d5..f46a885 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,21 @@ -[![Published on Vaadin Directory](https://img.shields.io/badge/Vaadin%20Directory-published-00b4f0.svg)](https://vaadin.com/directory/component/easy-form-addon) -[![Stars on vaadin.com/directory](https://img.shields.io/vaadin-directory/star/app-layout-addon.svg)](https://vaadin.com/directory/component/easy-form-addon) +[![Published on Vaadin Directory](https://img.shields.io/badge/Vaadin%20Directory-published-00b4f0.svg)](https://vaadin.com/directory/component/easy-form-add-on) +[![Stars on vaadin.com/directory](https://img.shields.io/vaadin-directory/star/easy-form-add-on.svg)](https://vaadin.com/directory/component/easy-form-add-on) [![Build Status](https://jenkins.flowingcode.com/job/easy-form-addon/badge/icon)](https://jenkins.flowingcode.com/job/easy-form-addon) [![Maven Central](https://img.shields.io/maven-central/v/com.flowingcode.vaadin.addons/easy-form-addon)](https://mvnrepository.com/artifact/com.flowingcode.vaadin.addons/easy-form-addon) # Easy Form Add-on -This is a template project for building new Vaadin 24 add-ons +Vaadin Flow component that automatically generates a fully functional form from a Java POJO definition, using reflection and Bean Validation (JSR-380) annotations. All customization is programmatic through a fluent Java API — the POJO stays clean. ## Features -* List the features of your add-on in here +* Automatic field discovery from POJO properties (getter/setter conventions) +* Type-to-component mapping with sensible Vaadin defaults, overridable globally, per form, or per property +* Automatic data binding and Bean Validation (`@NotNull`, `@Size`, `@Email`, `@Min`, `@Max`, ...) +* Fluent per-field API: labels, placeholders, helper texts, validators, converters, custom components +* Field states: visible, read-only, or hidden +* Configurable save/cancel button bar with support for extra buttons +* Responsive multi-column layout with per-field column spanning ## Online demo @@ -17,7 +23,7 @@ This is a template project for building new Vaadin 24 add-ons ## Download release -[Available in Vaadin Directory](https://vaadin.com/directory/component/easy-form-addon) +[Available in Vaadin Directory](https://vaadin.com/directory/component/easy-form-add-on) ### Maven install @@ -75,13 +81,51 @@ Then, follow these steps for creating a contribution: This add-on is distributed under Apache License 2.0. For license terms, see LICENSE.txt. -EASY_FORM_ADDON is written by Flowing Code S.A. +Easy Form Add-on is written by Flowing Code S.A. # Developer Guide ## Getting started -Add your code samples in this section +```java +// Minimal usage — three lines to a working form +EasyForm form = new EasyForm<>(Person.class); +form.setSaveAction(person -> personService.save(person)); +add(form); +``` + +Customization is done through a fluent API: + +```java +EasyForm form = new EasyForm<>(Person.class); + +// Field order (unlisted fields follow, in declaration order) +form.setFieldOrder("firstName", "lastName", "email", "birthDate"); + +// Field selection (everything not listed is excluded from the layout and the binding) +form.setVisibleFields("firstName", "lastName", "email", "birthDate"); + +// Or per field +form.configureField("internalCode").excluded(); + +// Field-level customization (configureField returns the wrapper, field the component) +form.configureField("email").withLabel("Email Address").asRequired("Email is required"); +form.configureField("notes").withComponent(new TextArea()).withColSpan(2); + +// Events — enable a save button only while the form is valid and dirty +form.addStatusChangeListener(event -> + form.getSaveButton().setEnabled(!event.hasValidationErrors() && event.getBinder().hasChanges())); + +// Layout +form.setResponsiveSteps(new ResponsiveStep("0", 1), new ResponsiveStep("600px", 2)); + +// Button bar +form.setSaveAction(person -> personService.save(person)); +form.setCancelAction(() -> navigateBack()); + +// Pre-populate for editing +form.setBean(existingPerson); +``` ## Special configuration when using Spring @@ -91,4 +135,4 @@ To do so, just add ```com.flowingcode``` to the ```vaadin.whitelisted-packages`` ```vaadin.whitelisted-packages = com.vaadin,org.vaadin,dev.hilla,com.flowingcode``` -More information on Spring whitelisted configuration [here](https://vaadin.com/docs/latest/integrations/spring/configuration/#configure-the-scanning-of-packages). +More information on Spring whitelisted configuration [here](https://vaadin.com/docs/latest/integrations/spring/configuration/#configure-packages-scanning). diff --git a/SPECIFICATIONS.md b/SPECIFICATIONS.md index 03b064c..fafb553 100644 --- a/SPECIFICATIONS.md +++ b/SPECIFICATIONS.md @@ -2,15 +2,19 @@ ## 1. Overview -The Easy Form Add-on is a Vaadin Flow component that automatically generates a fully functional form from a Java POJO (Plain Old Java Object) definition. It uses reflection and Bean Validation (JSR-380) annotations to create form fields, configure validations, and manage data binding — all with minimal boilerplate code. +The Easy Form Add-on is a Vaadin Flow component that automatically generates a fully functional form from a Java POJO definition. It uses reflection and Bean Validation (JSR-380) annotations to create form fields, configure validations, and manage data binding — all with minimal boilerplate code. -The add-on provides a clean, fluent Java API for controlling field visibility, editability, layout, button configuration, and action handling. +All customization is programmatic through a fluent Java API. The POJO itself stays clean — no add-on-specific annotations are required. ## 2. Core Concepts ### 2.1 Automatic Field Discovery -Given a POJO class `T`, `EasyForm` introspects its properties (via getter/setter conventions) and creates appropriate Vaadin form fields for each one. +Given a POJO class `T`, `EasyForm` introspects its properties (via getter/setter conventions) and creates appropriate Vaadin form fields for each one. Properties without both a getter and setter are ignored. Properties whose type has no registered component mapping are also ignored (with a logged warning). + +Discovery is **flat**. Nested property paths such as `"address.street"`, which Vaadin's `Binder` does support, are not discovered; `configureField("address.street")` fails like any unknown property, and `findField` returns an empty optional. Reach a nested value through a custom component plus a converter, or flatten the property onto the bean. + +A subclass can narrow discovery by overriding `includeProperty(PropertyDescriptor)` — see §3.9. ### 2.2 Type-to-Component Mapping @@ -19,7 +23,6 @@ Each Java type maps to a default Vaadin component: | Java Type | Vaadin Component | Notes | |-----------|-----------------|-------| | `String` | `TextField` | | -| `String` (multiline) | `TextArea` | Activated via annotation or API | | `Integer`, `int` | `IntegerField` | | | `Long`, `long` | `NumberField` | | | `Double`, `double`, `Float`, `float` | `NumberField` | | @@ -29,19 +32,29 @@ Each Java type maps to a default Vaadin component: | `LocalDateTime` | `DateTimePicker` | | | `LocalTime` | `TimePicker` | | | `Enum` | `ComboBox` | Populated with enum constants | -| `Set>` | `CheckboxGroup` | Multi-select for enum sets | -Custom type mappings can be registered globally or per-form instance. +### 2.3 Customization Levels + +Type-to-component mappings can be overridden at three levels, from broadest to most specific: + +1. **Global defaults** — Static registry that applies to all `EasyForm` instances. Changed via `EasyForm.setDefaultComponentFactory(Class, SerializableSupplier)`, where `C extends Component & HasValue`. +2. **Per-form instance** — Overrides for a specific form. Changed via `form.setComponentFactory(Class, SerializableSupplier)`, with the same bound. +3. **Per-property** — Override for a single property. Changed via `form.configureField("notes").withComponent(new TextArea())`. + +The most specific level wins: per-property > per-form > global > built-in defaults. + +The `C extends Component & HasValue` bound is what makes "the factory must produce a Vaadin `Component`" a compile-time requirement rather than a runtime check. -### 2.3 Data Binding +### 2.4 Data Binding `EasyForm` internally manages a Vaadin `Binder` that binds each generated field to the corresponding POJO property. The binder supports: - Automatic two-way binding (read from / write to the POJO) - Bean Validation integration (`@NotNull`, `@Size`, `@Email`, `@Min`, `@Max`, `@Pattern`, etc.) -- Manual validation rules added via the API +- Per-field validators added via the programmatic API +- Per-field converters for type adaptation (e.g. `String` field bound to an `Integer` property) -### 2.4 Field States +### 2.5 Field States Each field can be in one of three states: @@ -49,7 +62,9 @@ Each field can be in one of three states: |-------|---------|----------|---------------------| | **Visible** (default) | Yes | Yes | Yes | | **Read-only** | Yes | No | Yes (read only) | -| **Hidden** | No | No | No | +| **Excluded** | No | No | No | + +The state is per field, set through `configureField(name).visible()` / `.readOnly()` / `.excluded()`. `setVisibleFields(...)` is a bulk operation over that same state — it excludes everything not listed and re-includes everything listed — so there is one source of truth for whether a field is part of the form, and an individual field can always be brought back with `.visible()`. ## 3. API Design @@ -65,123 +80,191 @@ EasyForm form = new EasyForm<>(Person.class); form.setBean(person); ``` +Every instance-level configuration method on `EasyForm` returns the form, so form-level and +field-level configuration read the same way: + +```java +EasyForm form = new EasyForm<>(Person.class) + .setFieldOrder("firstName", "lastName", "email") + .setLabelGenerator(name -> translate(name)) + .setSaveAction(person -> personService.save(person)) + .setBean(person); +``` + +`addButton` returns the created `Button` (needed for `removeButton`), the listener methods return a `Registration`, and the inherited `HasSize`/`HasStyle`/`HasEnabled` setters are void as Vaadin declares them. + ### 3.2 Field Configuration — Fluent API ```java EasyForm form = new EasyForm<>(Person.class); // Configure individual fields -form.getField("firstName") +form.configureField("firstName") .withLabel("First Name") .withPlaceholder("Enter first name") .asRequired("First name is required"); -form.getField("email") +// the value type is only needed for the methods that take it as a parameter +form.configureField("email", String.class) .withLabel("Email Address") .withValidator(new EmailValidator("Invalid email")); // Make a field read-only -form.getField("id").readOnly(); - -// Hide a field -form.getField("internalCode").hidden(); +form.configureField("id").readOnly(); -// Set field order (only listed fields are shown, in this order) -form.setFieldOrder("firstName", "lastName", "email", "birthDate", "address"); +// Exclude a field from the layout and the binding +form.configureField("internalCode").excluded(); -// Hide multiple fields at once -form.hideFields("id", "createdAt", "updatedAt"); +// Reorder only — unlisted fields follow, in declaration order, and stay bound +form.setFieldOrder("firstName", "lastName", "email", "birthDate"); -// Make multiple fields read-only -form.readOnlyFields("createdBy", "version"); +// Restrict the form to a subset of properties: everything not listed is excluded, +// and anything listed that was excluded comes back +form.setVisibleFields("firstName", "lastName", "email"); // Use a custom component for a specific field -form.getField("notes").withComponent(new TextArea()); +form.configureField("notes").withComponent(new TextArea()); + +// Override the component factory for a type in this form instance +form.setComponentFactory(String.class, TextArea::new); +``` + +### 3.3 Field Wrapper — `EasyForm.Field` -// Use a custom component factory for a type -form.setComponentFactory(Address.class, () -> new AddressFormField()); +`configureField` returns an `EasyForm.Field` wrapper for configuring a single field; `field` returns the underlying component directly, for when the component is what's wanted: + +```java +Field configureField(String propertyName); + Field configureField(String propertyName, Class valueType); + +HasValue field(String propertyName); + HasValue field(String propertyName, Class valueType); ``` -### 3.3 Field Wrapper — `EasyFormField` +`field(name)` is exactly `configureField(name).getComponent()`, and the two-argument overload applies the same presentation-type check. Both return `null` for a property whose type has no component factory. + +`V` is the **presentation** value type — the value type of the component, which is not necessarily the property type: a `Long` property presented by the built-in `NumberField` factory has a presentation type of `Double`. + +A presentation type cannot be inferred from a property name, so the single-argument overload returns a **wildcard**. The state, presentation and layout methods chain as usual, and so does `withComponent`, which takes its type from its argument; only `withValidator` and `withConverter` need a concrete `V`, and on a wildcard they are simply not callable. This is deliberate — it makes an unsound `Field f = form.configureField("age")` a compile error instead of a `ClassCastException` inside the binder later. -The `getField(String propertyName)` method returns an `EasyFormField` wrapper that provides a fluent interface for configuring individual fields: +The two-argument overload infers `V` from the class literal, and checks it against the value type of the component currently generated for the property: ```java -public class EasyFormField { +// V = String, inferred from the class literal +form.configureField("email", String.class).withValidator(new EmailValidator("Invalid email")); + +// throws IllegalArgumentException: the component of 'email' presents String, not Integer +form.configureField("email", Integer.class); +``` + +The check is skipped for properties without a component, and for components whose value type cannot be resolved reflectively (such as `ComboBox`, which is generic in its value type). + +```java +public static final class Field { // Visibility and state - EasyFormField visible(); - EasyFormField readOnly(); - EasyFormField hidden(); + Field visible(); + Field readOnly(); + Field excluded(); - // Labels and placeholders - EasyFormField withLabel(String label); - EasyFormField withPlaceholder(String placeholder); - EasyFormField withHelperText(String helperText); + // Labels and presentation + Field withLabel(String label); + Field withPlaceholder(String placeholder); + Field withHelperText(String helperText); // Validation - EasyFormField asRequired(String errorMessage); - EasyFormField withValidator(Validator validator); + Field asRequired(String errorMessage); + Field withValidator(Validator validator); + + // Converter (presentation type V to model/property type P) +

Field withConverter(Converter converter); - // Custom component - EasyFormField withComponent(HasValue component); + // Custom component (replaces the auto-generated one for this property, + // and re-types the wrapper after the value type of the new component) + > Field withComponent(C component); - // Column span in form layout - EasyFormField withColSpan(int colSpan); + // Layout hint — column span in the form layout + Field withColSpan(int colSpan); // Access the underlying Vaadin component HasValue getComponent(); } ``` -### 3.4 Button Configuration +### 3.4 Validators and Converters -By default, the form displays **Save** and **Cancel** buttons in a horizontal toolbar below the fields. +Validators and converters are set on the field binding and follow standard Vaadin `Binder` semantics. ```java -// Default buttons with actions +// Add a validator to a field +form.configureField("age", Integer.class) + .withValidator(new IntegerRangeValidator("Must be 0-150", 0, 150)); + +// Replace the component and add a converter (String field bound to an Integer property). +// withComponent re-types the wrapper to String, so no type argument is needed here. +form.configureField("zipCode") + .withComponent(new TextField()) + .withConverter(new StringToIntegerConverter("Must be a number")); + +// Bean Validation annotations on the POJO are picked up automatically. +// Programmatic validators are applied in addition to annotation-based ones. +``` + +A component whose value type cannot be written to its property needs a converter. Since the two are necessarily set one after the other, the combination is not validated while the field is being configured, but when the form first interacts with a bean — `setBean`, `readBean` or `getValidBean` — which throws `IllegalStateException` naming the property and both types. Fields with a converter are not checked, because converter types are erased at runtime. + +### 3.5 Button Bar + +When a save action or cancel action is configured, `EasyForm` renders a horizontal button bar below the fields. The bar is only rendered if at least one action is set. + +**Defaults:** +- Save button: label "Save", primary theme variant. Shown when `setSaveAction` is called. +- Cancel button: label "Cancel", tertiary theme variant. Shown when `setCancelAction` is called. + +```java +// Configure save/cancel actions (this makes the buttons appear) form.setSaveAction(person -> personService.save(person)); form.setCancelAction(() -> navigateBack()); -// Add a Remove button -form.addButton("Remove", VaadinIcon.TRASH, ButtonVariant.LUMO_ERROR, () -> { - personService.delete(form.getBean()); -}); +// Customize button text through the exposed buttons +form.getSaveButton().setText("Submit"); +form.getCancelButton().setText("Discard"); -// Add a custom button -form.addButton("Send Email", VaadinIcon.ENVELOPE, () -> { - emailService.send(form.getBean()); -}); +// Or through the i18n object, which is the translation seam for both texts +form.setI18n(new EasyForm.EasyFormI18n().setSave("Guardar").setCancel("Cancelar")); -// Remove the default Cancel button -form.removeCancelButton(); +// Customize button visibility +form.setSaveButtonVisible(false); +form.setCancelButtonVisible(false); -// Customize the Save button label -form.setSaveButtonText("Submit"); +// Extra buttons: theme variants are varargs, and can be removed again +Button reset = form.addButton("Reset", event -> form.reset(), ButtonVariant.LUMO_TERTIARY); +form.removeButton(reset); -// Configure button visibility -form.setSaveButtonVisible(boolean visible); -form.setCancelButtonVisible(boolean visible); +// Access the button to further customize style/icon +form.getSaveButton().setIcon(VaadinIcon.CHECK.create()); +form.getCancelButton().addThemeVariants(ButtonVariant.LUMO_ERROR); -// Completely replace the button bar -form.setButtonBarFactory(binder -> { - HorizontalLayout bar = new HorizontalLayout(); - // ... custom button layout - return bar; +// Add extra buttons to the bar +form.addButton("Delete", event -> { + personService.delete(form.getBean()); +}); + +// Add extra button with icon and variant +form.addButton("Send Email", VaadinIcon.ENVELOPE.create(), ButtonVariant.LUMO_TERTIARY, event -> { + emailService.send(form.getBean()); }); ``` -### 3.5 Save Action and Validation Flow +### 3.6 Save Action and Validation Flow When the Save button is clicked: -1. All field validators are executed. +1. All field-level validators (annotation-based and programmatic) are executed. 2. Bean-level validators (cross-field) are executed. 3. If all validations pass, the binder writes the values to the bean. 4. The registered save action is invoked with the populated bean. 5. If validation fails, error messages are displayed on the relevant fields. ```java -// Save with result handling form.setSaveAction(person -> { try { personService.save(person); @@ -191,7 +274,7 @@ form.setSaveAction(person -> { } }); -// Cross-field validation +// Cross-field (bean-level) validation form.addBeanValidator((person, context) -> { if (person.getEndDate() != null && person.getEndDate().isBefore(person.getStartDate())) { return ValidationResult.error("End date must be after start date"); @@ -200,10 +283,12 @@ form.addBeanValidator((person, context) -> { }); ``` -### 3.6 Form Layout Configuration +### 3.7 Layout + +`EasyForm` uses a `FormLayout` internally by default, which provides responsive multi-column arrangement and per-field column spanning. ```java -// Set the number of columns in the FormLayout +// Configure responsive breakpoints form.setResponsiveSteps( new ResponsiveStep("0", 1), new ResponsiveStep("600px", 2), @@ -211,55 +296,77 @@ form.setResponsiveSteps( ); // Make a field span multiple columns -form.getField("address").withColSpan(2); +form.configureField("address").withColSpan(2); ``` -### 3.7 Events - -```java -// Listen for value changes on a specific field -form.getField("country").addValueChangeListener(event -> { - // Update available cities based on selected country - form.getField("city").withItems(getCities(event.getValue())); -}); - -// Listen for form-level changes -form.addValueChangeListener(event -> { - // Any field changed -}); - -// Before-save hook (can cancel save) -form.addBeforeSaveListener(event -> { - if (!confirmAction()) { - event.cancel(); - } -}); - -// After-save hook -form.addAfterSaveListener(event -> { - Notification.show("Saved: " + event.getBean()); -}); -``` +The defaults are sensible for most forms (single column on small screens, two columns on wider screens). Column span defaults to 1 for all fields. ### 3.8 Read and Write ```java -// Set a bean to populate the form (edit mode) +// Set a bean to populate the form (edit mode — changes written through to the bean) form.setBean(existingPerson); -// Read values without binding (display mode) +// Read values without live binding (display/copy mode) form.readBean(existingPerson); -// Manually get the current bean with form values written to it -Optional result = form.getValidBean(); // validates first +// Validate and get the bean with current field values written to it +Optional result = form.getValidBean(); + +// Validate without writing, and find out what failed +BinderValidationStatus status = form.validate(); -// Reset form to initial state +// Reset form to the last-set bean values, re-attaching the bean if it was cleared form.reset(); -// Clear all fields +// Clear all fields. This also detaches the bean, so the cleared values can never be +// written to it: getBean() returns null afterwards and getValidBean() targets a new +// instance. reset() brings the bean back. form.clear(); ``` +### 3.9 Extension Points + +`EasyForm` is not final, and a subclass can influence generation by overriding: + +| Method | Called for | Default | +|---|---|---| +| `boolean includeProperty(PropertyDescriptor)` | every property with a getter and a setter | accepts all | +| `HasValue createComponent(String, Class)` | property types with **no** registered factory | `ComboBox` for enums, else `null` | +| `String createLabel(String)` | fields with no explicit label | the `setLabelGenerator` function, else camelCase-to-title-case | +| `void configureComponent(String, HasValue)` | every generated component, after its label is applied | does nothing | + +Component precedence is `withComponent` > registered factory > `createComponent`. To replace the component for a type that *does* have a factory, register one with `setComponentFactory` (which also carries the converter) rather than overriding `createComponent`. + +These are called while the constructor discovers the properties, so an override must not depend on state initialized in the subclass constructor body. + +### 3.10 Events and Introspection + +```java +// Keep a save button in step with validity and dirty state +form.addStatusChangeListener(event -> + saveButton.setEnabled(!event.hasValidationErrors() && event.getBinder().hasChanges())); + +// Every value change, valid or not +form.addValueChangeListener(event -> markDirty()); + +// What fields exist, in display order +List names = form.getFieldNames(); +List> fields = form.getFields(); + +// Non-throwing lookup +form.findField("email").ifPresent(field -> field.withLabel("E-mail")); +``` + +Both listener methods return a `Registration`. + +`EasyForm` implements `HasSize`, `HasStyle` and `HasEnabled`, so the whole form can be sized, styled and disabled as one component. `setEnabled(false)` does two things: + +- the generated components and the button bar are disabled, which the inherited `HasEnabled` behaviour already covers — they are all in the form's element tree, so `isEnabled()` returns `false` for each of them; +- every binding is made read-only, so a disabled form cannot be written to the bean at all, not even programmatically. Values are still *read* into the fields. + +Re-enabling restores the read-only state each field was configured with, so a field made read-only through `readOnly()` stays read-only. A binding's read-only state is always derived from the field state and the form's enabled flag together — there is no separate stored copy. + ## 4. Default Label Generation When no explicit label is provided, labels are auto-generated from property names using camelCase-to-title-case conversion: @@ -272,6 +379,8 @@ When no explicit label is provided, labels are auto-generated from property name | `email` | `Email` | | `isActive` | `Is Active` | +Override this in bulk with `setLabelGenerator(name -> ...)`, per field with `withLabel(...)`, or in a subclass by overriding `createLabel(String)`. An explicit `withLabel` always wins. + ## 5. Bean Validation Support The form automatically picks up JSR-380 (Bean Validation) annotations from the POJO: @@ -295,40 +404,23 @@ public class Person { } ``` -These annotations are automatically applied as validators on the corresponding form fields. - -## 6. Nested POJO Support - -For properties that are themselves POJOs (e.g., `Address`), the form can: +These annotations are automatically applied as validators on the corresponding form fields. Programmatic validators added via `withValidator()` run in addition to annotation-based ones. -- **Flatten** nested properties into the form (default for known types) -- **Use a custom component** registered for that type -- **Ignore** them (hidden by default if no mapping exists) - -```java -// Register a component factory for Address type -form.setComponentFactory(Address.class, () -> new AddressFormField()); - -// Or flatten Address properties into the main form with a prefix -form.flattenNestedBean("address", Address.class); -// This creates fields: address.street, address.city, address.postalCode, etc. -``` - -## 7. Serialization +## 6. Serialization `EasyForm` must be fully serializable for Vaadin session persistence. All internal state, including field configurations, validators, and component references, must be serializable. -## 8. Accessibility +## 7. Accessibility -- All generated fields must have proper labels (either explicit or auto-generated). -- Required fields must be marked with the appropriate ARIA attributes. -- Error messages must be associated with their corresponding fields. -- The form must be navigable via keyboard (tab order follows field order). +- All generated fields have proper labels (explicit or auto-generated). +- Required fields are marked with appropriate ARIA attributes (handled by Vaadin components when `asRequired` is set). +- Error messages are associated with their corresponding fields. +- Tab order follows field order. -## 9. Usage Example — Complete +## 8. Usage Example — Complete ```java -// Minimal usage +// Minimal usage — three lines to a working form EasyForm simpleForm = new EasyForm<>(Person.class); simpleForm.setSaveAction(person -> personService.save(person)); add(simpleForm); @@ -336,14 +428,23 @@ add(simpleForm); // Customized usage EasyForm form = new EasyForm<>(Person.class); -// Configure fields +// Field selection and order form.setFieldOrder("firstName", "lastName", "email", "birthDate", "age", "subscriber"); -form.hideFields("id", "createdAt", "updatedAt"); -form.getField("email").withLabel("Email Address").asRequired("Email is required"); -form.getField("birthDate").withLabel("Date of Birth"); +form.setVisibleFields("firstName", "lastName", "email", "birthDate", "age", "subscriber"); + +// Field-level customization +form.configureField("email").withLabel("Email Address").asRequired("Email is required"); +form.configureField("birthDate").withLabel("Date of Birth"); +form.configureField("notes").withComponent(new TextArea()).withColSpan(2); + +// Layout +form.setResponsiveSteps( + new ResponsiveStep("0", 1), + new ResponsiveStep("600px", 2) +); -// Configure buttons -form.setSaveButtonText("Create Person"); +// Button bar +form.getSaveButton().setText("Create Person"); form.setSaveAction(person -> { personService.save(person); Notification.show("Person created"); @@ -356,15 +457,17 @@ form.setBean(existingPerson); add(form); ``` -## 10. Dependencies +## 9. Dependencies -- Vaadin Flow (24.x) +- Vaadin Flow (24.x / 25.x) - Bean Validation API (Jakarta Validation) - Lombok (per Flowing Code convention for new add-ons) -## 11. Non-Goals (Out of Scope) +## 10. Non-Goals (v1) -- Server-side data persistence (that is the consumer's responsibility) +- Nested POJO support (flatten or component-factory based — planned for a future version) +- Annotation-based field configuration on the POJO +- Server-side data persistence (consumer's responsibility) - Built-in navigation or routing - Complex multi-page wizard forms (single form per instance) - File upload fields (can be added via custom component factory) diff --git a/pom.xml b/pom.xml index d5aa5f2..4c7c8d3 100644 --- a/pom.xml +++ b/pom.xml @@ -12,16 +12,16 @@ https://www.flowingcode.com/en/open-source/ - 24.1.2 - 4.10.0 + 24.10.8 + 4.44.0 17 17 UTF-8 UTF-8 ${project.basedir}/drivers - 11.0.12 - 3.8.0 - true + 11.0.20 + 5.3.1 + 1.18.46 @@ -45,13 +45,6 @@ master - - - ossrh - https://s01.oss.sonatype.org/service/local/staging/deploy/maven2/ - - - flowingcode @@ -69,64 +62,38 @@ import ${vaadin.version} - - com.flowingcode.vaadin.addons.demo - commons-demo - ${flowingcode.commons.demo.version} - - - - central - https://repo.maven.apache.org/maven2 - - false - - - - Vaadin Directory - https://maven.vaadin.com/vaadin-addons - - - - Vaadin prereleases - https://maven.vaadin.com/vaadin-prereleases - - - - vaadin-snapshots - https://oss.sonatype.org/content/repositories/vaadin-snapshots/ - - - - - - central - https://repo.maven.apache.org/maven2 - - false - - - - - Vaadin prereleases - https://maven.vaadin.com/vaadin-prereleases - - - vaadin-snapshots - https://oss.sonatype.org/content/repositories/vaadin-snapshots/ - false - - - com.vaadin vaadin-core true + + jakarta.validation + jakarta.validation-api + 3.0.2 + + + org.projectlombok + lombok + ${lombok.version} + provided + + + jakarta.servlet + jakarta.servlet-api + 6.0.0 + provided + + + com.flowingcode.vaadin.addons.demo + commons-demo + ${flowingcode.commons.demo.version} + test + org.slf4j slf4j-simple @@ -137,11 +104,6 @@ vaadin-testbench test - - com.flowingcode.vaadin.addons.demo - commons-demo - test - org.hamcrest hamcrest-library @@ -158,7 +120,19 @@ io.github.bonigarcia webdrivermanager - 5.1.1 + 5.9.1 + test + + + org.hibernate.validator + hibernate-validator + 8.0.2.Final + test + + + org.glassfish.expressly + expressly + 5.0.0 test @@ -167,12 +141,27 @@ jetty:run + + org.apache.maven.plugins + maven-compiler-plugin + 3.13.0 + + + + org.projectlombok + lombok + ${lombok.version} + + + + org.apache.maven.plugins maven-release-plugin 3.0.1 @{project.version} + release @@ -209,13 +198,35 @@ + + maven-clean-plugin + 3.3.2 + + + + ${basedir} + + tsconfig.json + + + + + org.codehaus.mojo license-maven-plugin - 1.14 + 2.3.0 apache_v2 false + + **/main/dev-bundle/** + **/main/bundles/** + **/main/frontend/** + + + java + @@ -242,7 +253,7 @@ jetty-maven-plugin ${jetty.version} - 3 + 3 true @@ -254,6 +265,9 @@ jar + + true + @@ -281,7 +295,7 @@ org.apache.maven.plugins maven-javadoc-plugin - 3.0.1 + 3.11.1 attach-javadocs @@ -294,7 +308,10 @@ true none - -Xdoclint:none + true + + https://javadoc.flowingcode.com/artifact/com.vaadin/vaadin-platform-javadoc/${vaadin.version} + @@ -429,7 +446,7 @@ gpg - env.gpg.passphrase + env.MAVEN_GPG_PASSPHRASE @@ -437,7 +454,7 @@ org.apache.maven.plugins maven-gpg-plugin - 3.1.0 + 3.2.7 sign-artifacts @@ -445,9 +462,6 @@ sign - - ${env.gpg.passphrase} - @@ -457,8 +471,21 @@ demo-jar - + + + org.apache.maven.plugins + maven-compiler-plugin + + + + com.flowingcode.vaadin.addons.demo + commons-demo-processor + ${flowingcode.commons.demo.version} + + + + org.apache.maven.plugins maven-jar-plugin @@ -474,6 +501,7 @@ **/it/* **/DemoView.class **/DemoLayout.class + **/AppShellConfiguratorImpl.class @@ -481,8 +509,199 @@ + + + com.flowingcode.vaadin.addons.demo + commons-demo-processor + ${flowingcode.commons.demo.version} + provided + + + + + + demo-war + + true + + + + com.vaadin + vaadin-core + + + com.vaadin + vaadin-dev + + + + + com.flowingcode.vaadin.addons.demo + commons-demo + ${flowingcode.commons.demo.version} + + + org.slf4j + slf4j-simple + + + + + + org.apache.maven.plugins + maven-antrun-plugin + 1.7 + + + copy-demo + process-sources + + run + + + + + + + + + + + + + + + + + + + + + + + + com.vaadin + vaadin-maven-plugin + ${vaadin.version} + + + + prepare-frontend + build-frontend + + compile + + + + + + org.apache.maven.plugins + maven-war-plugin + + + + war + + + false + + + + + + + + + + release + + + + org.sonatype.central + central-publishing-maven-plugin + 0.8.0 + true + + + + + + + dance + + + + org.apache.maven.plugins + maven-clean-plugin + + + + ${project.basedir} + + package.json + package-lock.json + tsconfig.json + tsconfig.json.* + types.d.ts + types.d.ts.* + vite.config.ts + vite.generated.ts + webpack.config.js + webpack.generated.js + + + + ${project.basedir}/src/main/frontend + + index.html + + + + ${project.basedir}/src/main/frontend/generated + + + ${project.basedir}/node_modules + + + ${project.basedir}/src/main/bundles + + + ${project.basedir}/src/main/dev-bundle + + + + + + + + + + v25 + + 21 + 21 + 25.2.4 + + + + com.vaadin + vaadin-dev + true + + + org.ow2.asm + asm + 9.8 + true + + + jakarta.servlet + jakarta.servlet-api + 6.1.0 + provided + + - diff --git a/src/main/java/com/flowingcode/vaadin/addons/easyform/EasyForm.java b/src/main/java/com/flowingcode/vaadin/addons/easyform/EasyForm.java new file mode 100644 index 0000000..2638e24 --- /dev/null +++ b/src/main/java/com/flowingcode/vaadin/addons/easyform/EasyForm.java @@ -0,0 +1,1664 @@ +/*- + * #%L + * Easy Form Add-on + * %% + * Copyright (C) 2026 Flowing Code + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ + +package com.flowingcode.vaadin.addons.easyform; + +import com.vaadin.flow.component.AttachEvent; +import com.vaadin.flow.component.ClickEvent; +import com.vaadin.flow.component.Component; +import com.vaadin.flow.component.ComponentEventListener; +import com.vaadin.flow.component.Composite; +import com.vaadin.flow.component.HasEnabled; +import com.vaadin.flow.component.HasHelper; +import com.vaadin.flow.component.HasLabel; +import com.vaadin.flow.component.HasPlaceholder; +import com.vaadin.flow.component.HasSize; +import com.vaadin.flow.component.HasStyle; +import com.vaadin.flow.component.HasValue; +import com.vaadin.flow.component.button.Button; +import com.vaadin.flow.component.button.ButtonVariant; +import com.vaadin.flow.component.checkbox.Checkbox; +import com.vaadin.flow.component.combobox.ComboBox; +import com.vaadin.flow.component.datepicker.DatePicker; +import com.vaadin.flow.component.datetimepicker.DateTimePicker; +import com.vaadin.flow.component.formlayout.FormLayout; +import com.vaadin.flow.component.formlayout.FormLayout.ResponsiveStep; +import com.vaadin.flow.component.orderedlayout.HorizontalLayout; +import com.vaadin.flow.component.orderedlayout.VerticalLayout; +import com.vaadin.flow.component.textfield.BigDecimalField; +import com.vaadin.flow.component.textfield.IntegerField; +import com.vaadin.flow.component.textfield.NumberField; +import com.vaadin.flow.component.textfield.TextField; +import com.vaadin.flow.component.timepicker.TimePicker; +import com.vaadin.flow.data.binder.BeanValidationBinder; +import com.vaadin.flow.data.binder.Binder; +import com.vaadin.flow.data.binder.Binder.Binding; +import com.vaadin.flow.data.binder.Binder.BindingBuilder; +import com.vaadin.flow.data.binder.BinderValidationStatus; +import com.vaadin.flow.data.binder.Result; +import com.vaadin.flow.data.binder.StatusChangeListener; +import com.vaadin.flow.data.binder.Validator; +import com.vaadin.flow.data.binder.ValueContext; +import com.vaadin.flow.data.converter.Converter; +import com.vaadin.flow.data.converter.StringToLongConverter; +import com.vaadin.flow.function.SerializableConsumer; +import com.vaadin.flow.function.SerializableFunction; +import com.vaadin.flow.function.SerializableRunnable; +import com.vaadin.flow.function.SerializableSupplier; +import com.vaadin.flow.internal.ReflectTools; +import com.vaadin.flow.shared.Registration; +import com.vaadin.flow.shared.util.SharedUtil; +import java.beans.IntrospectionException; +import java.beans.Introspector; +import java.beans.PropertyDescriptor; +import java.io.Serializable; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.stream.Collectors; +import lombok.Getter; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * A form component that is automatically generated from a POJO definition. + * + *

+ * {@code EasyForm} introspects the properties of the given bean type (via getter/setter + * conventions) and creates an appropriate Vaadin form field for each one, configures validations + * based on JSR-380 (Bean Validation) annotations, and manages data binding through an internal + * {@link Binder}. Properties without both a getter and a setter, or whose type has no registered + * component factory, are ignored. + * + *

+ * All customization is programmatic through a fluent API: + * + *

{@code
+ * EasyForm form = new EasyForm<>(Person.class);
+ * form.configureField("email").withLabel("Email Address").asRequired("Email is required");
+ * form.setSaveAction(person -> personService.save(person));
+ * add(form);
+ * }
+ * + *

+ * Configuring a field before the form is used only collects the configuration: the fields are + * bound in one pass the first time the form interacts with a bean or with its {@link Binder}, and + * when the form is attached. A form is therefore configured completely before anything is bound, + * whatever order the configuration is written in. Reconfiguring a field after that rebuilds its + * binding immediately, so the change always takes effect on the next value change. + * + *

+ * Discovery is flat: only the direct properties of the bean type are used. Nested property paths + * such as {@code "address.street"}, which {@link Binder} itself supports, are not discovered, and + * asking for one through {@link #configureField(String)} fails as for any unknown property. + * + *

+ * Subclasses can influence generation by overriding {@link #includeProperty(PropertyDescriptor)}, + * {@link #createComponent(String, Class)}, {@link #createLabel(String)} and + * {@link #configureComponent(String, HasValue)}. These are called while the constructor discovers + * the properties, so an override must not depend on state initialized in the subclass constructor + * body. + * + * @param the bean type + */ +@SuppressWarnings("serial") +public class EasyForm extends Composite + implements HasSize, HasStyle, HasEnabled { + + private static final Logger logger = LoggerFactory.getLogger(EasyForm.class); + + private static final Map, Class> PRIMITIVE_WRAPPERS = Map.of( + boolean.class, Boolean.class, + byte.class, Byte.class, + char.class, Character.class, + double.class, Double.class, + float.class, Float.class, + int.class, Integer.class, + long.class, Long.class, + short.class, Short.class); + + /** + * The default value of every primitive type, written to the property when the component of a + * primitive-backed field is left empty. A primitive cannot hold {@code null}, so without this the + * empty component value would reach the setter and the binding would throw. + */ + private static final Map, Object> PRIMITIVE_DEFAULTS = Map.of( + boolean.class, Boolean.FALSE, + byte.class, (byte) 0, + char.class, (char) 0, + double.class, 0d, + float.class, 0f, + int.class, 0, + long.class, 0L, + short.class, (short) 0); + + /** The conversion error message of the built-in {@code Long} factory. */ + private static final String NOT_A_NUMBER = "Not a valid number"; + + private static final Map, ComponentFactory> globalFactories = new ConcurrentHashMap<>(); + + static { + setDefaultComponentFactory(String.class, TextField::new); + setDefaultComponentFactory(Integer.class, IntegerField::new); + setDefaultComponentFactory(Double.class, NumberField::new); + setDefaultComponentFactory(Long.class, TextField::new, createLongConverter()); + setDefaultComponentFactory(Float.class, NumberField::new, createFloatConverter()); + setDefaultComponentFactory(BigDecimal.class, BigDecimalField::new); + setDefaultComponentFactory(Boolean.class, Checkbox::new); + setDefaultComponentFactory(LocalDate.class, DatePicker::new); + setDefaultComponentFactory(LocalDateTime.class, DateTimePicker::new); + setDefaultComponentFactory(LocalTime.class, TimePicker::new); + } + + /** The bean type this form was created for. */ + @Getter + private final Class beanType; + + /** The internal binder that manages data binding and validation. */ + private final Binder binder; + + /** + * Whether the fields have been bound. Until then, configuring a field only collects the + * configuration; see {@link #bindPendingFields()}. + */ + private boolean bound; + + private final Map> fields = new LinkedHashMap<>(); + + private final Map, ComponentFactory> instanceFactories = new HashMap<>(); + + /** + * The internal layout where the generated fields are placed. + * + *

+ * The layout is owned by the form: it is emptied and repopulated whenever the set of visible + * fields or their order changes — which every field state change, component replacement, column + * span and {@code setFieldOrder} call can cause. Components added to it directly do not survive + * that refresh, so extra content belongs next to the form and not inside its layout. What is + * safe to use here is the configuration of the layout itself, such as + * {@link FormLayout#setResponsiveSteps(ResponsiveStep...)}, which the refresh does not touch. + */ + @Getter + private final FormLayout formLayout = new FormLayout(); + + private final HorizontalLayout buttonBar = new HorizontalLayout(); + + /** The save button. Only displayed after a save action is set. */ + @Getter + private final Button saveButton = new Button("Save"); + + /** The cancel button. Only displayed after a cancel action is set. */ + @Getter + private final Button cancelButton = new Button("Cancel"); + + /** + * The bean the form is currently editing or was populated from, and the instance that + * {@link #getValidBean()} writes to. It is {@code null} when no bean was set, and also after + * {@link #clear()}, which detaches it. + */ + @Getter + private T bean; + + /** The last bean set, remembered so that {@link #reset()} can restore it after a clear. */ + private T lastBean; + + /** The texts of the button bar. */ + @Getter + private EasyFormI18n i18n = new EasyFormI18n(); + + private Set fieldOrder; + private SerializableFunction labelGenerator; + private boolean writeThrough; + private SerializableConsumer saveAction; + private SerializableRunnable cancelAction; + private Boolean saveButtonVisibleOverride; + private Boolean cancelButtonVisibleOverride; + + /** + * Creates a form whose fields are generated from the properties of the given bean type. + * + * @param beanType the bean type to generate the form for, not {@code null} + * @throws NullPointerException if {@code beanType} is {@code null} + * @throws IllegalArgumentException if {@code beanType} cannot be introspected + */ + public EasyForm(final Class beanType) { + this.beanType = Objects.requireNonNull(beanType, "beanType cannot be null"); + binder = createBinder(beanType); + saveButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY); + cancelButton.addThemeVariants(ButtonVariant.LUMO_TERTIARY); + saveButton.addClickListener(event -> save()); + cancelButton.addClickListener(event -> cancel()); + buttonBar.add(saveButton, cancelButton); + getContent().setPadding(false); + getContent().add(formLayout, buttonBar); + discoverFields(); + refreshLayout(); + updateButtonBar(); + } + + // -- component factories -- + + /** + * Registers a global default component factory for the given value type. The factory applies to + * all {@code EasyForm} instances created after this call, unless overridden per form instance or + * per property. + * + *

+ * The built-in type mappings (e.g. {@code String} to {@link TextField}) are registered through + * this same registry and can be replaced by calling this method. + * + * @param the value type + * @param the component type, which must be a {@link Component} and a {@link HasValue} of the + * value type + * @param type the value type to register the factory for, not {@code null} + * @param factory the factory that creates a component for the type, not {@code null} + * @throws NullPointerException if {@code type} or {@code factory} is {@code null} + */ + public static > void setDefaultComponentFactory( + final Class type, final SerializableSupplier factory) { + Objects.requireNonNull(type, "type cannot be null"); + Objects.requireNonNull(factory, "factory cannot be null"); + globalFactories.put(wrap(type), new ComponentFactory(factory, null)); + } + + /** + * Registers a global default component factory for the given property type, together with a + * converter that adapts the component presentation type to the property type (e.g. a + * {@link TextField} whose {@code String} value is converted to a {@code Long} property). The + * factory applies to all {@code EasyForm} instances created after this call, unless overridden + * per form instance or per property. + * + * @param the presentation value type of the created components + * @param

the property type + * @param the component type, which must be a {@link Component} and a {@link HasValue} of the + * presentation value type + * @param propertyType the property type to register the factory for, not {@code null} + * @param factory the factory that creates a component for the type, not {@code null} + * @param converter the converter from the presentation type to the property type, not {@code + * null} + * @throws NullPointerException if {@code propertyType}, {@code factory} or {@code converter} is + * {@code null} + */ + public static > void setDefaultComponentFactory( + final Class

propertyType, final SerializableSupplier factory, + final Converter converter) { + Objects.requireNonNull(propertyType, "propertyType cannot be null"); + Objects.requireNonNull(factory, "factory cannot be null"); + Objects.requireNonNull(converter, "converter cannot be null"); + globalFactories.put(wrap(propertyType), new ComponentFactory(factory, converter)); + } + + /** + * Registers a component factory for the given value type in this form instance, overriding the + * global defaults. Fields already generated for properties of this type are recreated, unless a + * custom component was set for them via {@link Field#withComponent}. + * + * @param the value type + * @param the component type, which must be a {@link Component} and a {@link HasValue} of the + * value type + * @param type the value type to register the factory for, not {@code null} + * @param factory the factory that creates a component for the type, not {@code null} + * @throws NullPointerException if {@code type} or {@code factory} is {@code null} + */ + public > void setComponentFactory(final Class type, + final SerializableSupplier factory) { + Objects.requireNonNull(type, "type cannot be null"); + Objects.requireNonNull(factory, "factory cannot be null"); + setComponentFactory(wrap(type), new ComponentFactory(factory, null)); + } + + /** + * Registers a component factory for the given property type in this form instance, together with + * a converter that adapts the component presentation type to the property type, overriding the + * global defaults. Fields already generated for properties of this type are recreated, unless a + * custom component was set for them via {@link Field#withComponent}. + * + * @param the presentation value type of the created components + * @param

the property type + * @param the component type, which must be a {@link Component} and a {@link HasValue} of the + * presentation value type + * @param propertyType the property type to register the factory for, not {@code null} + * @param factory the factory that creates a component for the type, not {@code null} + * @param converter the converter from the presentation type to the property type, not {@code + * null} + * @throws NullPointerException if {@code propertyType}, {@code factory} or {@code converter} is + * {@code null} + */ + public > void setComponentFactory( + final Class

propertyType, final SerializableSupplier factory, + final Converter converter) { + Objects.requireNonNull(propertyType, "propertyType cannot be null"); + Objects.requireNonNull(factory, "factory cannot be null"); + Objects.requireNonNull(converter, "converter cannot be null"); + setComponentFactory(wrap(propertyType), new ComponentFactory(factory, converter)); + } + + private void setComponentFactory(final Class propertyType, final ComponentFactory factory) { + instanceFactories.put(propertyType, factory); + fields.values().stream() + .filter(field -> !field.componentExplicit && field.propertyType == propertyType) + .forEach(field -> { + resolveComponent(field); + invalidateBinding(field); + }); + refreshLayout(); + } + + // -- field access and bulk configuration -- + + /** + * Returns the configuration wrapper for the field generated for the given property. + * + *

+ * The presentation value type of a field cannot be inferred from a property name, so the wrapper + * is returned with a wildcard type. The state, presentation and layout methods chain as usual, as + * does {@link Field#withComponent}, which takes its type from its argument. The two + * methods that take the presentation value type as a parameter, + * {@link Field#withValidator(Validator)} and + * {@link Field#withConverter(Converter)}, are only callable on a wrapper obtained through + * {@link #configureField(String, Class)}. + * + * @param propertyName the name of the bean property + * @return the field wrapper + * @throws IllegalArgumentException if no property with the given name was discovered + */ + public Field configureField(final String propertyName) { + final Field field = fields.get(propertyName); + if (field == null) { + throw new IllegalArgumentException( + "No property '" + propertyName + "' in " + beanType.getName()); + } + return field; + } + + /** + * Returns the configuration wrapper for the field generated for the given property, typed to the + * given presentation value type. The type is inferred from the argument, so the wrapper can be + * configured in a fluent chain without an explicit type argument: + * + *

{@code
+   * form.configureField("email", String.class).withValidator(new EmailValidator("Invalid email"));
+   * }
+ * + *

+ * The given type is checked against the value type of the component currently generated for the + * property, which is the type the field's validators and converters see. Note that this is the + * presentation type and not necessarily the property type: a {@code Long} property bound through + * the built-in {@link TextField} factory has a presentation type of {@code String}. The check is + * skipped for properties without a component, and for components whose value type cannot be + * resolved (such as {@link ComboBox}). + * + *

+ * Because the check reflects the component in place at the time of the call, replacing the + * component for a property is done through {@link #configureField(String)} and + * {@link Field#withComponent}, which types the returned wrapper after the new + * component. + * + * @param the presentation value type of the field + * @param propertyName the name of the bean property + * @param valueType the expected presentation value type, not {@code null} + * @return the field wrapper + * @throws NullPointerException if {@code valueType} is {@code null} + * @throws IllegalArgumentException if no property with the given name was discovered, or if the + * component of the property does not have the given presentation value type + */ + @SuppressWarnings("unchecked") + public Field configureField(final String propertyName, final Class valueType) { + Objects.requireNonNull(valueType, "valueType cannot be null"); + final Field field = configureField(propertyName); + final Class presentationType = presentationTypeOf(field.component); + if (presentationType != null && !valueType.isAssignableFrom(presentationType)) { + throw new IllegalArgumentException("The component of property '" + propertyName + "' has " + + presentationType.getName() + " as its presentation value type, which is not compatible " + + "with " + valueType.getName()); + } + return (Field) field; + } + + /** + * Returns the component generated for the given property, which is what + * {@link #configureField(String)} wraps. Use this when the component itself is wanted rather than + * its configuration: + * + *

{@code
+   * TextField email = (TextField) form.field("email");
+   * }
+ * + * @param propertyName the name of the bean property + * @return the component, or {@code null} if the property type has no component factory + * @throws IllegalArgumentException if no property with the given name was discovered + */ + public HasValue field(final String propertyName) { + return configureField(propertyName).getComponent(); + } + + /** + * Returns the component generated for the given property, typed to the given presentation value + * type: + * + *
{@code
+   * HasValue email = form.field("email", String.class);
+   * }
+ * + * @param the presentation value type of the component + * @param propertyName the name of the bean property + * @param valueType the expected presentation value type, not {@code null} + * @return the component, or {@code null} if the property type has no component factory + * @throws NullPointerException if {@code valueType} is {@code null} + * @throws IllegalArgumentException if no property with the given name was discovered, or if the + * component of the property does not have the given presentation value type + * @see #configureField(String, Class) + */ + public HasValue field(final String propertyName, final Class valueType) { + return configureField(propertyName, valueType).getComponent(); + } + + /** + * Returns the configuration wrapper for the given property, or an empty optional if the property + * was not discovered. This is the non-throwing counterpart of {@link #configureField(String)}. + * + * @param propertyName the name of the bean property + * @return the field wrapper, or an empty optional + */ + public Optional> findField(final String propertyName) { + return Optional.ofNullable(fields.get(propertyName)); + } + + /** + * Returns the names of the discovered properties, in the order the fields are displayed. + * + * @return an unmodifiable list of property names + */ + public List getFieldNames() { + return orderedFields().stream().map(Field::getPropertyName) + .collect(Collectors.toUnmodifiableList()); + } + + /** + * Returns the configuration wrappers of the discovered properties, in the order the fields are + * displayed. Excluded fields are included in the result. + * + * @return an unmodifiable list of field wrappers + */ + public List> getFields() { + return List.copyOf(orderedFields()); + } + + /** + * Sets the display order of the fields. The listed properties are shown first, in the given + * order, followed by every field that was not listed, in declaration order. Repeated names are + * ignored after their first occurrence. + * + *

+ * This method only reorders: a field left out of the list is still shown and still bound. Use + * {@link #setVisibleFields(String...)} to restrict which fields the form shows. + * + * @param propertyNames the names of the properties to display first, in order + * @return this, for method chaining + * @throws NullPointerException if the array or any of its elements is {@code null} + * @throws IllegalArgumentException if any property name is unknown + */ + public EasyForm setFieldOrder(final String... propertyNames) { + final Set order = new LinkedHashSet<>(List.of(propertyNames)); + requireKnownProperties(order); + fieldOrder = order; + refreshLayout(); + return this; + } + + /** + * Restricts the form to the given properties: every field that is not listed is excluded, and + * every listed field that was excluded is included again. This is the bulk complement of + * {@link Field#excluded()} and operates on the same per-field state, so an individual + * field can still be brought back afterwards with {@link Field#visible()}. A listed field + * that is read-only stays read-only. + * + *

+ * The display order is unaffected — use {@link #setFieldOrder(String...)} for that. + * + * @param propertyNames the names of the properties to show + * @return this, for method chaining + * @throws NullPointerException if the array or any of its elements is {@code null} + * @throws IllegalArgumentException if any property name is unknown + */ + public EasyForm setVisibleFields(final String... propertyNames) { + final Set visible = new LinkedHashSet<>(List.of(propertyNames)); + requireKnownProperties(visible); + for (final Field field : fields.values()) { + if (!visible.contains(field.propertyName)) { + field.state = Field.State.EXCLUDED; + } else if (field.state == Field.State.EXCLUDED) { + field.state = Field.State.VISIBLE; + } + } + fields.values().forEach(this::invalidateBinding); + refreshLayout(); + return this; + } + + // -- binding -- + + /** + * Returns the internal binder, for the parts of {@link Binder} this form does not wrap — + * bean-level validators, {@code hasChanges}, or the {@link Binding} of an individual field. + * + *

+ * Reading the binder builds the bindings of the fields configured so far, if they were not built + * already. Configuration applied here to the binding of a field is therefore kept only until that + * field is reconfigured through this form — {@link Field#withComponent}, {@link Field#excluded()} + * or {@link #setComponentFactory(Class, SerializableSupplier)} rebuild the binding and start it + * over. Configure the fields first and reach for the binder afterwards. + * + * @return the internal binder + */ + public Binder getBinder() { + bindPendingFields(); + return binder; + } + + /** + * Builds the binding of every field that is waiting for one, and marks the form as bound. Called + * the first time the form interacts with the binder or with a bean, and when the form is + * attached, so that a form is configured completely before anything is bound: while the fields + * are unbound, configuring one only collects the configuration, and the whole form is bound in + * one pass afterwards. + */ + private void bindPendingFields() { + bound = true; + for (final Field field : fields.values()) { + if (field.bindingPending) { + field.bindingPending = false; + rebind(field); + } + } + } + + /** + * Marks the binding of the given field as out of date. Before the form is bound this only + * records that the field is waiting for a binding; afterwards the binding is rebuilt right away, + * so that reconfiguring a field always takes effect on the next value change. + */ + private void invalidateBinding(final Field field) { + if (bound) { + field.bindingPending = false; + rebind(field); + } else { + field.bindingPending = true; + // there is no binding to carry these yet, so they go on the component, which keeps the + // deferral invisible to whoever looks at the field before the form is bound + applyReadOnly(field); + if (field.requiredMessage != null && field.component != null) { + field.component.setRequiredIndicatorVisible(true); + } + } + } + + @Override + protected void onAttach(final AttachEvent attachEvent) { + super.onAttach(attachEvent); + bindPendingFields(); + } + + // -- bean handling -- + + /** + * Binds the given bean to the form in edit mode: fields are populated from the bean and valid + * value changes are written through to it. + * + * @param bean the bean to edit, or {@code null} to clear the form + * @return this, for method chaining + * @throws IllegalStateException if a field has a component whose value type cannot be written to + * its property and no converter was set for it + */ + public EasyForm setBean(final T bean) { + checkFieldTypes(); + this.bean = bean; + lastBean = bean; + writeThrough = bean != null; + binder.setBean(bean); + return this; + } + + /** + * Populates the fields with values from the given bean without live binding. Changes are not + * written to the bean until {@link #getValidBean()} or the save action runs. + * + * @param bean the bean to read values from, or {@code null} to clear the form + * @return this, for method chaining + * @throws IllegalStateException if a field has a component whose value type cannot be written to + * its property and no converter was set for it + */ + public EasyForm readBean(final T bean) { + checkFieldTypes(); + this.bean = bean; + lastBean = bean; + writeThrough = false; + if (binder.getBean() != null) { + binder.removeBean(); + } + binder.readBean(bean); + return this; + } + + /** + * Validates the form and returns the bean with the current field values written to it. When no + * bean is attached — because none was set, or because {@link #clear()} detached it — the values + * are written to a new instance, and the bean type must have an accessible no-args constructor. + * + *

+ * Use {@link #validate()} instead when the reason for a validation failure is needed. + * + * @return the populated bean, or an empty optional if validation failed + * @throws IllegalStateException if no bean has been set and the bean type cannot be instantiated, + * or if a field has a component whose value type cannot be written to its property and no + * converter was set for it + */ + public Optional getValidBean() { + checkFieldTypes(); + if (binder.getBean() != null) { + return binder.validate().isOk() ? Optional.of(binder.getBean()) : Optional.empty(); + } + final T target = bean != null ? bean : createBeanInstance(); + return binder.writeBeanIfValid(target) ? Optional.of(target) : Optional.empty(); + } + + /** + * Validates every bound field and returns the resulting status, which carries the individual + * error messages. Nothing is written to a bean, so this can be called to drive the state of the + * surrounding UI. Follows {@link Binder#validate()} semantics. + * + * @return the validation status + * @throws IllegalStateException if a field has a component whose value type cannot be written to + * its property and no converter was set for it + */ + public BinderValidationStatus validate() { + checkFieldTypes(); + return binder.validate(); + } + + /** + * Adds a listener notified whenever the validation status of the form changes, which is the + * supported way to keep the surrounding UI — a save button, a summary — in step with the form. + * The event carries the errors, and its binder answers {@code hasChanges()} for dirty state. + * + *

{@code
+   * form.addStatusChangeListener(
+   *     event -> saveButton.setEnabled(!event.hasValidationErrors() && event.getBinder()
+   *         .hasChanges()));
+   * }
+ * + * @param listener the listener to add, not {@code null} + * @return a registration for removing the listener + */ + public Registration addStatusChangeListener(final StatusChangeListener listener) { + bindPendingFields(); + return binder.addStatusChangeListener(Objects.requireNonNull(listener, + "listener cannot be null")); + } + + /** + * Adds a listener notified whenever the value of any bound field changes, whether or not the new + * value is valid. + * + * @param listener the listener to add, not {@code null} + * @return a registration for removing the listener + */ + public Registration addValueChangeListener( + final HasValue.ValueChangeListener> listener) { + bindPendingFields(); + return binder.addValueChangeListener(Objects.requireNonNull(listener, + "listener cannot be null")); + } + + /** + * Resets the fields to the values of the last bean set through {@link #setBean(Object)} or + * {@link #readBean(Object)}, re-attaching it if it was detached by {@link #clear()}. If no bean + * was ever set, all fields are cleared. + * + * @return this, for method chaining + */ + public EasyForm reset() { + bindPendingFields(); + bean = lastBean; + if (writeThrough) { + binder.setBean(lastBean); + } else { + binder.readBean(lastBean); + } + return this; + } + + /** + * Clears all fields and detaches the current bean, so that the cleared values are never written + * to it: after this call {@code getBean()} returns {@code null} and {@link #getValidBean()} + * writes to a new instance. The last bean set is remembered and can be restored with + * {@link #reset()}. + * + * @return this, for method chaining + */ + public EasyForm clear() { + bindPendingFields(); + if (binder.getBean() != null) { + // detach the bean before clearing, so that cleared values are not written through + binder.setBean(null); + } else { + binder.readBean(null); + } + bean = null; + return this; + } + + /** + * Adds a bean-level (cross-field) validator. Bean validators run after all field-level validators + * have passed. + * + * @param validator the bean validator to add, not {@code null} + * @return this, for method chaining + * @throws NullPointerException if {@code validator} is {@code null} + */ + public EasyForm addBeanValidator(final Validator validator) { + binder.withValidator(Objects.requireNonNull(validator, "validator cannot be null")); + return this; + } + + // -- button bar -- + + /** + * Sets the action invoked with the validated bean when the save button is clicked, and makes the + * save button visible. + * + * @param saveAction the save action, or {@code null} to remove it + * @return this, for method chaining + */ + public EasyForm setSaveAction(final SerializableConsumer saveAction) { + this.saveAction = saveAction; + updateButtonBar(); + return this; + } + + /** + * Sets the action invoked when the cancel button is clicked, and makes the cancel button visible. + * + * @param cancelAction the cancel action, or {@code null} to remove it + * @return this, for method chaining + */ + public EasyForm setCancelAction(final SerializableRunnable cancelAction) { + this.cancelAction = cancelAction; + updateButtonBar(); + return this; + } + + /** + * Sets the texts of the button bar. + * + * @param i18n the texts to use, not {@code null} + * @return this, for method chaining + * @throws NullPointerException if {@code i18n} is {@code null} + */ + public EasyForm setI18n(final EasyFormI18n i18n) { + this.i18n = Objects.requireNonNull(i18n, "i18n cannot be null"); + saveButton.setText(i18n.getSave()); + cancelButton.setText(i18n.getCancel()); + return this; + } + + /** + * Sets the function that generates the label of every field whose label was not set explicitly + * through {@link Field#withLabel(String)}, replacing the default derivation from the + * property name. Pass {@code null} to restore the default. + * + *

+ * This is the bulk counterpart of overriding {@link #createLabel(String)}, and applies + * immediately to the fields already generated. + * + * @param labelGenerator the generator, taking a property name and returning a label, or + * @return this, for method chaining + * {@code null} for the default + */ + public EasyForm setLabelGenerator(final SerializableFunction labelGenerator) { + this.labelGenerator = labelGenerator; + fields.values().forEach(this::applyPresentation); + return this; + } + + /** + * Overrides the visibility of the save button. By default the button is visible if and only if a + * save action has been set. + * + * @param visible whether the save button is visible + * @return this, for method chaining + */ + public EasyForm setSaveButtonVisible(final boolean visible) { + saveButtonVisibleOverride = visible; + updateButtonBar(); + return this; + } + + /** + * Overrides the visibility of the cancel button. By default the button is visible if and only if + * a cancel action has been set. + * + * @param visible whether the cancel button is visible + * @return this, for method chaining + */ + public EasyForm setCancelButtonVisible(final boolean visible) { + cancelButtonVisibleOverride = visible; + updateButtonBar(); + return this; + } + + /** + * Adds an extra button to the button bar. + * + * @param text the button text + * @param clickListener the click listener + * @param variants the theme variants to apply, if any + * @return the added button + */ + public Button addButton(final String text, + final ComponentEventListener> clickListener, + final ButtonVariant... variants) { + final Button button = new Button(text, clickListener); + button.addThemeVariants(variants); + buttonBar.add(button); + updateButtonBar(); + return button; + } + + /** + * Adds an extra button with an icon to the button bar. + * + * @param text the button text + * @param icon the button icon + * @param clickListener the click listener + * @param variants the theme variants to apply, if any + * @return the added button + */ + public Button addButton(final String text, final Component icon, + final ComponentEventListener> clickListener, + final ButtonVariant... variants) { + final Button button = addButton(text, clickListener, variants); + button.setIcon(icon); + return button; + } + + /** + * Removes a button previously added with {@link #addButton}. + * + * @param button the button to remove, not {@code null} + * @return this, for method chaining + * @throws NullPointerException if {@code button} is {@code null} + * @throws IllegalArgumentException if the button is not in the button bar, or is the save or + * cancel button — use {@link #setSaveButtonVisible(boolean)} or + * {@link #setCancelButtonVisible(boolean)} for those + */ + public EasyForm removeButton(final Button button) { + Objects.requireNonNull(button, "button cannot be null"); + if (button == saveButton || button == cancelButton) { + throw new IllegalArgumentException( + "The save and cancel buttons cannot be removed; hide them instead"); + } + if (buttonBar.getChildren().noneMatch(child -> child == button)) { + throw new IllegalArgumentException("The given button is not in the button bar"); + } + buttonBar.remove(button); + updateButtonBar(); + return this; + } + + // -- layout -- + + /** + * Enables or disables the form. In addition to disabling the generated components and the button + * bar — which the inherited {@link HasEnabled} behaviour already does, since they are all in this + * component's element tree — this makes every binding read-only, so that a disabled form cannot + * be written to the bean even programmatically. + * + *

+ * Re-enabling restores the read-only state each field was configured with, so a field made + * read-only through {@link Field#readOnly()} stays read-only. + * + * @param enabled whether the form is enabled + */ + @Override + public void setEnabled(final boolean enabled) { + HasEnabled.super.setEnabled(enabled); + fields.values().forEach(this::applyReadOnly); + } + + /** + * Configures the responsive steps of the internal form layout. + * + * @param steps the responsive steps + * @return this, for method chaining + * @see FormLayout#setResponsiveSteps(ResponsiveStep...) + */ + public EasyForm setResponsiveSteps(final ResponsiveStep... steps) { + formLayout.setResponsiveSteps(steps); + return this; + } + + // -- internals -- + + private Binder createBinder(final Class beanType) { + try { + return new BeanValidationBinder<>(beanType); + } catch (final IllegalStateException e) { + logger.debug("No JSR-380 implementation found; Bean Validation annotations will be ignored", + e); + return new Binder<>(beanType); + } + } + + private void discoverFields() { + PropertyDescriptor[] descriptors; + try { + descriptors = Introspector.getBeanInfo(beanType, Object.class).getPropertyDescriptors(); + } catch (final IntrospectionException e) { + throw new IllegalArgumentException("Cannot introspect " + beanType.getName(), e); + } + final Map declarationOrder = declarationOrder(); + final Comparator comparator = Comparator + .comparingInt( + pd -> declarationOrder.getOrDefault(pd.getName(), Integer.MAX_VALUE)) + .thenComparing(PropertyDescriptor::getName); + final List properties = List.of(descriptors).stream() + .filter(pd -> pd.getReadMethod() != null && pd.getWriteMethod() != null) + .filter(this::includeProperty).sorted(comparator).collect(Collectors.toList()); + for (final PropertyDescriptor property : properties) { + final Class propertyType = property.getPropertyType(); + final Field field = new Field<>(this, property.getName(), wrap(propertyType), + PRIMITIVE_DEFAULTS.get(propertyType)); + resolveComponent(field); + if (field.component == null) { + logger.warn("Property '{}' of type {} has no component factory and will be ignored", + property.getName(), property.getPropertyType().getName()); + } + fields.put(property.getName(), field); + } + } + + private Map declarationOrder() { + final List> hierarchy = new ArrayList<>(); + for (Class clazz = beanType; clazz != null && clazz != Object.class; clazz = + clazz.getSuperclass()) { + hierarchy.add(0, clazz); + } + final Map order = new HashMap<>(); + int index = 0; + for (final Class clazz : hierarchy) { + for (final java.lang.reflect.Field field : clazz.getDeclaredFields()) { + order.putIfAbsent(field.getName(), index++); + } + } + return order; + } + + @SuppressWarnings("unchecked") + private void resolveComponent(final Field field) { + if (field.componentExplicit) { + return; + } + final ComponentFactory factory = factoryFor(field.propertyType); + final HasValue component = factory != null ? factory.supplier.get() + : createComponent(field.propertyName, field.propertyType); + field.component = (HasValue) component; + field.factoryConverter = factory != null ? factory.converter : null; + applyPresentation(field); + if (component != null) { + configureComponent(field.propertyName, component); + } + } + + private ComponentFactory factoryFor(final Class propertyType) { + final ComponentFactory factory = instanceFactories.get(propertyType); + return factory != null ? factory : globalFactories.get(propertyType); + } + + /** + * Decides whether a discovered property becomes a field. Called during construction for every + * property that has both a getter and a setter; returning {@code false} leaves the property out + * of the form and the binding entirely. The default implementation accepts every property. + * + * @param property the property being considered + * @return whether to generate a field for the property + */ + protected boolean includeProperty(final PropertyDescriptor property) { + return true; + } + + /** + * Creates the component for a property that has no registered component factory. The default + * implementation returns a {@link ComboBox} of the constants for an enum property, and + * {@code null} for anything else, which leaves the property out of the form with a logged + * warning. + * + *

+ * Components for property types that do have a factory are not created here — override + * them with {@link #setComponentFactory(Class, SerializableSupplier)}, which also carries the + * converter, or per property with {@link Field#withComponent}. The precedence is + * {@code withComponent} > registered factory > this method. + * + * @param propertyName the name of the property + * @param propertyType the property type, with primitives already wrapped + * @return the component to use, or {@code null} to leave the property out of the form + */ + protected HasValue createComponent(final String propertyName, + final Class propertyType) { + return propertyType.isEnum() ? createEnumComboBox(propertyType) : null; + } + + /** + * Returns the label for a property whose label was not set explicitly through + * {@link Field#withLabel(String)}. The default implementation applies the function given + * to {@link #setLabelGenerator(SerializableFunction)}, or derives the label from the property + * name when there is none. + * + * @param propertyName the name of the property + * @return the label to use + */ + protected String createLabel(final String propertyName) { + return labelGenerator != null ? labelGenerator.apply(propertyName) + : SharedUtil.camelCaseToHumanFriendly(propertyName); + } + + /** + * Called after a generated component has been created and its label, placeholder and helper text + * applied, for decoration that applies to every field — style names, widths, theme variants. The + * default implementation does nothing. Not called for components set through + * {@link Field#withComponent}. + * + * @param propertyName the name of the property the component was generated for + * @param component the generated component + */ + protected void configureComponent(final String propertyName, final HasValue component) { + // for subclasses + } + + /** + * Returns the value type of the given component as declared by its {@link HasValue} + * implementation, or {@code null} if the component is {@code null} or its value type cannot be + * resolved (as is the case for components that are generic in their value type, such as + * {@link ComboBox}). + */ + private static Class presentationTypeOf(final HasValue component) { + if (component == null) { + return null; + } + final List> typeArguments = + ReflectTools.getGenericInterfaceTypes(component.getClass(), HasValue.class); + return typeArguments.size() > 1 ? typeArguments.get(1) : null; + } + + /** + * Verifies that the value of every bound component can be written to its property. Fields with a + * converter are skipped, since the converter types cannot be resolved at runtime. This is checked + * when the form interacts with a bean, and not while a field is being configured, because a + * component and its converter are necessarily set one after the other. + */ + private void checkFieldTypes() { + // an unbound field has nothing to check, so the pending bindings are built first + bindPendingFields(); + for (final Field field : fields.values()) { + if (field.binding == null || field.converter != null || field.factoryConverter != null) { + continue; + } + final Class presentationType = presentationTypeOf(field.component); + if (presentationType != null && !field.propertyType.isAssignableFrom(presentationType)) { + throw new IllegalStateException("The component of property '" + field.propertyName + + "' has " + presentationType.getName() + " as its presentation value type, which " + + "cannot be written to a property of type " + field.propertyType.getName() + + "; set a converter with Field.withConverter"); + } + } + } + + @SuppressWarnings({"unchecked", "rawtypes"}) + private static HasValue createEnumComboBox(final Class type) { + final ComboBox comboBox = new ComboBox<>(); + comboBox.setItems(type.getEnumConstants()); + return comboBox; + } + + /** + * The converter of the built-in {@code Long} factory. {@code Long} is presented as text rather + * than through a {@link NumberField}, because a {@code double} carries only a 53-bit mantissa: + * identifiers above 2^53 would be silently rounded, and fractional input silently truncated. + * {@link StringToLongConverter} is exact over the whole {@code long} range and reports anything + * else as a conversion error. Register another factory for {@code Long.class} to localize the + * message or to present the property differently. + */ + private static Converter createLongConverter() { + return new LongConverter(); + } + + /** + * The converter of the built-in {@code Long} factory: a {@link StringToLongConverter} that + * presents {@code null} as an empty string, which is the null representation a text field needs. + */ + private static final class LongConverter extends StringToLongConverter { + + private LongConverter() { + super(NOT_A_NUMBER); + } + + @Override + public String convertToPresentation(final Long value, final ValueContext context) { + return value == null ? "" : super.convertToPresentation(value, context); + } + } + + /** + * The converter of the built-in {@code Float} factory. The presented value is round-tripped + * through {@link Float#toString(float)}, the shortest decimal that reads back as the same + * {@code float}, rather than widened directly: plain widening exposes the binary representation, + * which would present a stored {@code 0.1f} as {@code 0.10000000149011612}. + */ + private static Converter createFloatConverter() { + return Converter.from(value -> Result.ok(value == null ? null : value.floatValue()), + value -> value == null ? null : Double.valueOf(Float.toString(value))); + } + + /** + * Returns a converter that replaces an empty component value with the given default, leaving + * everything else untouched. Chained last, after any converter of the field, so that it operates + * on the model value that is about to be written to the property. + */ + private static Converter nullToDefault(final V defaultValue) { + return Converter.from(value -> Result.ok(value == null ? defaultValue : value), + value -> value); + } + + @SuppressWarnings({"unchecked", "rawtypes"}) + private void rebind(final Field field) { + final HasValue component = field.component; + // A field that is already bound is being reconfigured, and its component may hold input that + // was not committed to the bean yet; a field that is not is being bound for the first time, or + // is coming back from being excluded, and takes its value from the bean. + final boolean rebuild = field.binding != null; + final V uncommittedValue = rebuild && component != null ? component.getValue() : null; + if (field.binding != null) { + binder.removeBinding((Binding) field.binding); + field.binding = null; + } + if (component == null || !isEffectivelyVisible(field)) { + return; + } + BindingBuilder builder = binder.forField(component); + if (field.requiredMessage != null) { + builder = builder.asRequired(field.requiredMessage); + } + for (final Validator validator : field.validators) { + builder = builder.withValidator((Validator) validator); + } + BindingBuilder finalBuilder = builder; + final Converter converter = field.converter != null ? field.converter : field.factoryConverter; + if (converter != null) { + finalBuilder = builder.withConverter((Converter) converter); + } + if (field.primitiveDefault != null) { + finalBuilder = finalBuilder.withConverter((Converter) nullToDefault(field.primitiveDefault)); + } + final Binding binding = finalBuilder.bind(field.propertyName); + field.binding = binding; + applyReadOnly(field); + if (!writeThrough) { + if (rebuild) { + component.setValue(uncommittedValue != null ? uncommittedValue + : component.getEmptyValue()); + } else if (bean != null) { + binding.read(bean); + } + } + } + + /** + * Applies the read-only state of a field, which is read-only when the field was configured that + * way or when the whole form is disabled. A field that is not bound yet carries the state on its + * component, and takes it through its binding once it has one. + */ + private void applyReadOnly(final Field field) { + final boolean readOnly = !isEnabled() || field.state == Field.State.READ_ONLY; + if (field.binding != null) { + field.binding.setReadOnly(readOnly); + } else if (field.component != null) { + field.component.setReadOnly(readOnly); + } + } + + private void applyPresentation(final Field field) { + final HasValue component = field.component; + if (component == null) { + return; + } + if (component instanceof final HasLabel hasLabel) { + hasLabel.setLabel(field.label != null ? field.label : createLabel(field.propertyName)); + } + if (field.placeholder != null && component instanceof final HasPlaceholder hasPlaceholder) { + hasPlaceholder.setPlaceholder(field.placeholder); + } + if (field.helperText != null && component instanceof final HasHelper hasHelper) { + hasHelper.setHelperText(field.helperText); + } + } + + private void refreshLayout() { + formLayout.removeAll(); + for (final Field field : orderedFields()) { + if (field.component != null && isEffectivelyVisible(field)) { + final Component component = (Component) field.component; + formLayout.add(component); + formLayout.setColspan(component, field.colSpan); + } + } + } + + private void onFieldStateChanged(final Field field) { + invalidateBinding(field); + refreshLayout(); + } + + private void onComponentChanged(final Field field) { + applyPresentation(field); + invalidateBinding(field); + refreshLayout(); + } + + /** + * Rejects unknown property names before any state is mutated, so that a typo cannot leave the + * form half-configured. + * + * @throws IllegalArgumentException if any of the given names is not a discovered property + */ + private void requireKnownProperties(final Set propertyNames) { + propertyNames.forEach(this::configureField); + } + + private List> orderedFields() { + if (fieldOrder == null) { + return new ArrayList<>(fields.values()); + } + final List> ordered = + fieldOrder.stream().map(fields::get).collect(Collectors.toCollection(ArrayList::new)); + fields.values().stream().filter(field -> !fieldOrder.contains(field.propertyName)) + .forEach(ordered::add); + return ordered; + } + + private boolean isEffectivelyVisible(final Field field) { + return field.state != Field.State.EXCLUDED; + } + + private void save() { + if (saveAction != null) { + getValidBean().ifPresent(saveAction); + } + } + + private void cancel() { + if (cancelAction != null) { + cancelAction.run(); + } + } + + private void updateButtonBar() { + saveButton.setVisible( + saveButtonVisibleOverride != null ? saveButtonVisibleOverride : saveAction != null); + cancelButton.setVisible( + cancelButtonVisibleOverride != null ? cancelButtonVisibleOverride : cancelAction != null); + buttonBar.setVisible(buttonBar.getChildren().anyMatch(Component::isVisible)); + } + + private T createBeanInstance() { + try { + return beanType.getDeclaredConstructor().newInstance(); + } catch (final ReflectiveOperationException e) { + throw new IllegalStateException("Cannot instantiate " + beanType.getName(), e); + } + } + + private static Class wrap(final Class type) { + if (!type.isPrimitive()) { + return type; + } + return PRIMITIVE_WRAPPERS.getOrDefault(type, type); + } + + /** A component factory registration: a component supplier and an optional converter. */ + private static final class ComponentFactory implements Serializable { + + private final SerializableSupplier> supplier; + private final Converter converter; + + private ComponentFactory(final SerializableSupplier> supplier, + final Converter converter) { + this.supplier = supplier; + this.converter = converter; + } + } + + /** The texts of the button bar, following the Vaadin i18n convention. */ + public static class EasyFormI18n implements Serializable { + + /** The text of the save button. */ + @Getter + private String save = "Save"; + + /** The text of the cancel button. */ + @Getter + private String cancel = "Cancel"; + + /** + * Sets the text of the save button. + * + * @param save the text to use + * @return this, for method chaining + */ + public EasyFormI18n setSave(final String save) { + this.save = save; + return this; + } + + /** + * Sets the text of the cancel button. + * + * @param cancel the text to use + * @return this, for method chaining + */ + public EasyFormI18n setCancel(final String cancel) { + this.cancel = cancel; + return this; + } + } + + /** + * Fluent configuration wrapper for a single field of an {@code EasyForm}. + * + *

+ * Instances are obtained through {@link EasyForm#configureField(String)} and allow configuring + * the visibility, presentation, validation, and component of the field generated for a bean + * property: + * + *

{@code
+   * form.configureField("email").withLabel("Email Address").withPlaceholder("user@example.com")
+   *     .asRequired("Email is required");
+   * }
+ * + * @param the presentation value type of the field component + */ + public static final class Field implements Serializable { + + /** The states a field can be in. */ + private enum State { + VISIBLE, READ_ONLY, EXCLUDED + } + + private final EasyForm form; + + /** The name of the bean property this field is generated for. */ + @Getter + private final String propertyName; + + private final Class propertyType; + + /** + * The default value of the property type when the property is primitive, and {@code null} + * otherwise. Primitive properties are bound through their wrapper, so the component can be + * empty while the setter cannot take {@code null}. + */ + private final Object primitiveDefault; + + /** + * The underlying Vaadin component of this field, or {@code null} if the property type has no + * component factory. + */ + @Getter + private HasValue component; + + private boolean componentExplicit; + private Converter factoryConverter; + private Converter converter; + private final List> validators = new ArrayList<>(); + private String requiredMessage; + private State state = State.VISIBLE; + private String label; + private String placeholder; + private String helperText; + private int colSpan = 1; + private Binding binding; + + /** Whether the binding of this field has still to be built; see {@code bindPendingFields}. */ + private boolean bindingPending = true; + + private Field(final EasyForm form, final String propertyName, final Class propertyType, + final Object primitiveDefault) { + this.form = form; + this.propertyName = propertyName; + this.propertyType = propertyType; + this.primitiveDefault = primitiveDefault; + } + + // -- visibility and state -- + + /** + * Makes this field visible and editable (the default state), reversing {@link #readOnly()} or + * {@link #excluded()}. + * + * @return this, for method chaining + */ + public Field visible() { + return setState(State.VISIBLE); + } + + /** + * Makes this field read-only: it is displayed and populated from the bean, but cannot be + * edited. + * + * @return this, for method chaining + */ + public Field readOnly() { + return setState(State.READ_ONLY); + } + + /** + * Excludes this field from the layout and from the binding. The property is neither displayed + * nor written when the form writes to a bean, so an excluded property keeps whatever value the + * target instance already had. Reverse it with {@link #visible()}. + * + * @return this, for method chaining + */ + public Field excluded() { + return setState(State.EXCLUDED); + } + + // -- labels and presentation -- + + /** + * Sets the label of this field, replacing the label generated from the property name. + * + * @param label the label to set + * @return this, for method chaining + */ + public Field withLabel(final String label) { + this.label = label; + form.applyPresentation(this); + return this; + } + + /** + * Sets the placeholder of this field. Ignored if the component does not support placeholders. + * + * @param placeholder the placeholder to set + * @return this, for method chaining + */ + public Field withPlaceholder(final String placeholder) { + this.placeholder = placeholder; + form.applyPresentation(this); + return this; + } + + /** + * Sets the helper text of this field. Ignored if the component does not support helper texts. + * + * @param helperText the helper text to set + * @return this, for method chaining + */ + public Field withHelperText(final String helperText) { + this.helperText = helperText; + form.applyPresentation(this); + return this; + } + + // -- validation -- + + /** + * Makes this field required, showing the given message when it is empty. + * + * @param errorMessage the error message to show when the field is empty, not {@code null} + * @return this, for method chaining + * @throws NullPointerException if {@code errorMessage} is {@code null} + */ + public Field asRequired(final String errorMessage) { + requiredMessage = Objects.requireNonNull(errorMessage, "errorMessage cannot be null"); + form.invalidateBinding(this); + return this; + } + + /** + * Adds a validator to this field. Validators run on the presentation value, in the order they + * were added and in addition to any Bean Validation annotations on the property. The validator + * presentation type must match the value type of the field component. + * + * @param validator the validator to add, not {@code null} + * @return this, for method chaining + * @throws NullPointerException if {@code validator} is {@code null} + */ + public Field withValidator(final Validator validator) { + validators.add(Objects.requireNonNull(validator, "validator cannot be null")); + form.invalidateBinding(this); + return this; + } + + /** + * Sets a converter that adapts the presentation value of the component to the model type of the + * bean property (e.g. a {@code String} field bound to an {@code Integer} property). The + * converter presentation type must match the value type of the field component: if the + * auto-generated component does not match, also set one via {@link #withComponent}. + * + * @param

the model (property) type + * @param converter the converter to use, not {@code null} + * @return this, for method chaining + * @throws NullPointerException if {@code converter} is {@code null} + */ + public

Field withConverter(final Converter converter) { + this.converter = Objects.requireNonNull(converter, "converter cannot be null"); + form.invalidateBinding(this); + return this; + } + + // -- component -- + + /** + * Replaces the auto-generated component of this field with the given one, and types this + * wrapper after the value type of the new component. + * + *

+ * A component whose value type differs from the property type needs a converter, which is set + * afterwards on the returned wrapper: + * + *

{@code
+     * form.configureField("age").withComponent(new TextField())
+     *     .withConverter(new StringToIntegerConverter("Must be a number"));
+     * }
+ * + * The two are therefore not validated here, but when the form first interacts with a bean, + * through {@link EasyForm#setBean(Object)}, {@link EasyForm#readBean(Object)} or + * {@link EasyForm#getValidBean()}. + * + *

+ * The converter and the validators of this field are configured against the presentation type + * of its component, so replacing the component discards them: whatever + * {@link #withConverter} and {@link #withValidator} were given no longer applies to the new + * presentation type, and keeping them would fail with a {@code ClassCastException} the next + * time the form interacts with a bean. Set them again on the returned wrapper, which is typed + * after the new component. Everything that does not depend on the presentation type — the + * label, the placeholder, the helper text, the column span, the required message and the state + * of the field — is kept. + * + * @param the presentation value type of the given component + * @param the component type, which must be a {@link Component} and a {@link HasValue} of + * the presentation value type + * @param component the component to use, not {@code null} + * @return this, for method chaining + * @throws NullPointerException if {@code component} is {@code null} + */ + @SuppressWarnings("unchecked") + public > Field withComponent( + final C component) { + Objects.requireNonNull(component, "component cannot be null"); + this.component = (HasValue) component; + componentExplicit = true; + factoryConverter = null; + // Both are typed after the presentation type of the component being replaced. + converter = null; + validators.clear(); + form.onComponentChanged(this); + return (Field) this; + } + + // -- layout -- + + /** + * Sets the number of columns this field spans in the form layout. Defaults to 1. + * + * @param colSpan the column span, at least 1 + * @return this, for method chaining + * @throws IllegalArgumentException if the given span is less than 1 + */ + public Field withColSpan(final int colSpan) { + if (colSpan < 1) { + throw new IllegalArgumentException("colSpan must be at least 1"); + } + this.colSpan = colSpan; + form.refreshLayout(); + return this; + } + + private Field setState(final State state) { + if (this.state != state) { + this.state = state; + form.onFieldStateChanged(this); + } + return this; + } + } +} diff --git a/src/main/resources/META-INF/VAADIN/package.properties b/src/main/resources/META-INF/VAADIN/package.properties new file mode 100644 index 0000000..c66616f --- /dev/null +++ b/src/main/resources/META-INF/VAADIN/package.properties @@ -0,0 +1 @@ +vaadin.allowed-packages=com.flowingcode diff --git a/src/test/java/com/flowingcode/vaadin/addons/DemoLayout.java b/src/test/java/com/flowingcode/vaadin/addons/DemoLayout.java index 8f80b19..63f85e0 100644 --- a/src/test/java/com/flowingcode/vaadin/addons/DemoLayout.java +++ b/src/test/java/com/flowingcode/vaadin/addons/DemoLayout.java @@ -2,7 +2,7 @@ * #%L * Easy Form Add-on * %% - * Copyright (C) 2023 Flowing Code + * Copyright (C) 2026 Flowing Code * %% * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/test/java/com/flowingcode/vaadin/addons/easyform/DemoView.java b/src/test/java/com/flowingcode/vaadin/addons/easyform/DemoView.java index 74702e0..9e560f6 100644 --- a/src/test/java/com/flowingcode/vaadin/addons/easyform/DemoView.java +++ b/src/test/java/com/flowingcode/vaadin/addons/easyform/DemoView.java @@ -2,7 +2,7 @@ * #%L * Easy Form Add-on * %% - * Copyright (C) 2023 Flowing Code + * Copyright (C) 2026 Flowing Code * %% * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -30,7 +30,7 @@ public class DemoView extends VerticalLayout implements BeforeEnterObserver { @Override - public void beforeEnter(BeforeEnterEvent event) { + public void beforeEnter(final BeforeEnterEvent event) { event.forwardTo(EasyFormDemoView.class); } } diff --git a/src/main/java/com/flowingcode/vaadin/addons/easyform/EasyFormAddon.java b/src/test/java/com/flowingcode/vaadin/addons/easyform/EasyFormBasicDemo.java similarity index 50% rename from src/main/java/com/flowingcode/vaadin/addons/easyform/EasyFormAddon.java rename to src/test/java/com/flowingcode/vaadin/addons/easyform/EasyFormBasicDemo.java index 78ebe24..43e1fa7 100644 --- a/src/main/java/com/flowingcode/vaadin/addons/easyform/EasyFormAddon.java +++ b/src/test/java/com/flowingcode/vaadin/addons/easyform/EasyFormBasicDemo.java @@ -2,7 +2,7 @@ * #%L * Easy Form Add-on * %% - * Copyright (C) 2023 Flowing Code + * Copyright (C) 2026 Flowing Code * %% * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -20,13 +20,23 @@ package com.flowingcode.vaadin.addons.easyform; -import com.vaadin.flow.component.Tag; -import com.vaadin.flow.component.dependency.JsModule; -import com.vaadin.flow.component.dependency.NpmPackage; +import com.flowingcode.vaadin.addons.demo.DemoSource; import com.vaadin.flow.component.html.Div; +import com.vaadin.flow.component.notification.Notification; +import com.vaadin.flow.router.PageTitle; +import com.vaadin.flow.router.Route; +@DemoSource +@PageTitle("Basic") @SuppressWarnings("serial") -@NpmPackage(value = "@polymer/paper-input", version = "3.2.1") -@JsModule("@polymer/paper-input/paper-input.js") -@Tag("paper-input") -public class EasyFormAddon extends Div {} +@Route(value = "easy-form/basic", layout = EasyFormDemoView.class) +public class EasyFormBasicDemo extends Div { + + public EasyFormBasicDemo() { + // A fully functional form in three lines: fields, labels, and Bean Validation + // constraints are derived from the Person class. + EasyForm form = new EasyForm<>(Person.class); + form.setSaveAction(person -> Notification.show("Saved " + person.getFirstName())); + add(form); + } +} diff --git a/src/test/java/com/flowingcode/vaadin/addons/easyform/EasyFormCustomizedDemo.java b/src/test/java/com/flowingcode/vaadin/addons/easyform/EasyFormCustomizedDemo.java new file mode 100644 index 0000000..0939348 --- /dev/null +++ b/src/test/java/com/flowingcode/vaadin/addons/easyform/EasyFormCustomizedDemo.java @@ -0,0 +1,97 @@ +/*- + * #%L + * Easy Form Add-on + * %% + * Copyright (C) 2026 Flowing Code + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ + +package com.flowingcode.vaadin.addons.easyform; + +import com.flowingcode.vaadin.addons.demo.DemoSource; +import com.vaadin.flow.component.combobox.ComboBox; +import com.vaadin.flow.component.formlayout.FormLayout.ResponsiveStep; +import com.vaadin.flow.component.html.Div; +import com.vaadin.flow.component.notification.Notification; +import com.vaadin.flow.component.textfield.TextArea; +import com.vaadin.flow.router.PageTitle; +import com.vaadin.flow.router.Route; +import java.time.LocalDate; +import java.util.List; + +@DemoSource +@PageTitle("Customized") +@SuppressWarnings("serial") +@Route(value = "easy-form/customized", layout = EasyFormDemoView.class) +public class EasyFormCustomizedDemo extends Div { + + public EasyFormCustomizedDemo() { + EasyForm form = new EasyForm<>(Person.class); + List candidates = parentCandidates(); + + // Field selection and order: only the listed fields are shown, in this order + form.setFieldOrder("firstName", "lastName", "email", "birthDate", "age", "gender", "parent", + "subscriber", "notes"); + + // Field-level customization + form.configureField("email").withLabel("Email Address").asRequired("Email is required"); + form.configureField("birthDate").withLabel("Date of Birth"); + form.configureField("age").withHelperText("Optional"); + form.configureField("notes").withComponent(new TextArea()).withColSpan(2); + + // A reference to another entity: Person has no built-in component factory, so the field is + // mapped explicitly to a combo box that renders each candidate by first and last name + ComboBox parentComboBox = new ComboBox<>(); + parentComboBox.setItems(candidates); + parentComboBox.setItemLabelGenerator( + candidate -> candidate.getFirstName() + " " + candidate.getLastName()); + form.configureField("parent").withComponent(parentComboBox).withLabel("Parent"); + + // Layout + form.setResponsiveSteps(new ResponsiveStep("0", 1), new ResponsiveStep("600px", 2)); + + // Button bar + form.getSaveButton().setText("Create Person"); + form.setSaveAction(person -> Notification.show("Person created")); + form.setCancelAction(() -> Notification.show("Cancelled")); + form.addButton("Reset", event -> form.reset()); + + // Pre-populate for editing + Person person = new Person(); + person.setFirstName("John"); + person.setLastName("Doe"); + person.setEmail("john.doe@example.com"); + person.setBirthDate(LocalDate.of(1990, 5, 17)); + person.setGender(Person.Gender.MALE); + person.setSubscriber(true); + // the selected value must be one of the combo box items, so reuse the same instances + person.setParent(candidates.get(0)); + form.setBean(person); + + add(form); + } + + private static List parentCandidates() { + return List.of(newPerson("Anna", "Smith"), newPerson("Robert", "Smith"), + newPerson("Maria", "Garcia"), newPerson("James", "Wilson"), newPerson("Laura", "Brown")); + } + + private static Person newPerson(final String firstName, final String lastName) { + Person person = new Person(); + person.setFirstName(firstName); + person.setLastName(lastName); + return person; + } +} diff --git a/src/test/java/com/flowingcode/vaadin/addons/easyform/EasyFormDemo.java b/src/test/java/com/flowingcode/vaadin/addons/easyform/EasyFormDemo.java deleted file mode 100644 index 8508a09..0000000 --- a/src/test/java/com/flowingcode/vaadin/addons/easyform/EasyFormDemo.java +++ /dev/null @@ -1,17 +0,0 @@ -package com.flowingcode.vaadin.addons.easyform; - -import com.flowingcode.vaadin.addons.demo.DemoSource; -import com.vaadin.flow.component.html.Div; -import com.vaadin.flow.router.PageTitle; -import com.vaadin.flow.router.Route; - -@DemoSource -@PageTitle("Easy Form Add-on Demo") -@SuppressWarnings("serial") -@Route(value = "demo", layout = EasyFormDemoView.class) -public class EasyFormDemo extends Div { - - public EasyFormDemo() { - add(new EasyFormAddon()); - } -} diff --git a/src/test/java/com/flowingcode/vaadin/addons/easyform/EasyFormDemoView.java b/src/test/java/com/flowingcode/vaadin/addons/easyform/EasyFormDemoView.java index f6986c7..5365634 100644 --- a/src/test/java/com/flowingcode/vaadin/addons/easyform/EasyFormDemoView.java +++ b/src/test/java/com/flowingcode/vaadin/addons/easyform/EasyFormDemoView.java @@ -2,7 +2,7 @@ * #%L * Easy Form Add-on * %% - * Copyright (C) 2023 Flowing Code + * Copyright (C) 2026 Flowing Code * %% * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -32,7 +32,8 @@ public class EasyFormDemoView extends TabbedDemo { public EasyFormDemoView() { - addDemo(EasyFormDemo.class); + addDemo(EasyFormBasicDemo.class); + addDemo(EasyFormCustomizedDemo.class); setSizeFull(); } } diff --git a/src/test/java/com/flowingcode/vaadin/addons/easyform/Person.java b/src/test/java/com/flowingcode/vaadin/addons/easyform/Person.java new file mode 100644 index 0000000..d9359af --- /dev/null +++ b/src/test/java/com/flowingcode/vaadin/addons/easyform/Person.java @@ -0,0 +1,77 @@ +/*- + * #%L + * Easy Form Add-on + * %% + * Copyright (C) 2026 Flowing Code + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ + +package com.flowingcode.vaadin.addons.easyform; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Past; +import jakarta.validation.constraints.Size; +import java.io.Serializable; +import java.time.LocalDate; +import lombok.Getter; +import lombok.Setter; + +/** Example bean used by the Easy Form demos. */ +@Getter +@Setter +@SuppressWarnings("serial") +public class Person implements Serializable { + + /** Example enum, rendered as a combo box. */ + public enum Gender { + MALE, + FEMALE, + OTHER + } + + private Long id; + + @NotNull(message = "First name is required") + @Size(min = 2, max = 100, message = "First name must be between 2 and 100 characters") + private String firstName; + + @NotNull(message = "Last name is required") + private String lastName; + + @Email(message = "Must be a valid email address") + private String email; + + @Min(value = 0, message = "Age must be positive") + @Max(value = 150, message = "Age must be less than 150") + private Integer age; + + @Past(message = "Birth date must be in the past") + private LocalDate birthDate; + + private Gender gender; + + private Boolean subscriber; + + private String notes; + + /** + * Reference to another person. There is no built-in component factory for this type, so the + * customized demo maps it explicitly to a combo box. + */ + private Person parent; +} diff --git a/src/test/java/com/flowingcode/vaadin/addons/easyform/it/AbstractViewTest.java b/src/test/java/com/flowingcode/vaadin/addons/easyform/it/AbstractViewTest.java deleted file mode 100644 index cd6e8be..0000000 --- a/src/test/java/com/flowingcode/vaadin/addons/easyform/it/AbstractViewTest.java +++ /dev/null @@ -1,106 +0,0 @@ -/*- - * #%L - * Easy Form Add-on - * %% - * Copyright (C) 2023 Flowing Code - * %% - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * #L% - */ - -package com.flowingcode.vaadin.addons.easyform.it; - -import com.vaadin.testbench.ScreenshotOnFailureRule; -import com.vaadin.testbench.TestBench; -import com.vaadin.testbench.parallel.ParallelTest; -import io.github.bonigarcia.wdm.WebDriverManager; -import org.junit.Before; -import org.junit.BeforeClass; -import org.junit.Rule; -import org.openqa.selenium.chrome.ChromeDriver; - -/** - * Base class for ITs - * - *

The tests use Chrome driver (see pom.xml for integration-tests profile) to run integration - * tests on a headless Chrome. If a property {@code test.use .hub} is set to true, {@code - * AbstractViewTest} will assume that the TestBench test is running in a CI environment. In order to - * keep the this class light, it makes certain assumptions about the CI environment (such as - * available environment variables). It is not advisable to use this class as a base class for you - * own TestBench tests. - * - *

To learn more about TestBench, visit Vaadin TestBench. - */ -public abstract class AbstractViewTest extends ParallelTest { - private static final int SERVER_PORT = 8080; - - private final String route; - - @Rule public ScreenshotOnFailureRule rule = new ScreenshotOnFailureRule(this, true); - - public AbstractViewTest() { - this(""); - } - - protected AbstractViewTest(String route) { - this.route = route; - } - - @BeforeClass - public static void setupClass() { - WebDriverManager.chromedriver().setup(); - } - - @Override - @Before - public void setup() throws Exception { - if (isUsingHub()) { - super.setup(); - } else { - setDriver(TestBench.createDriver(new ChromeDriver())); - } - getDriver().get(getURL(route)); - } - - /** - * Returns deployment host name concatenated with route. - * - * @return URL to route - */ - private static String getURL(String route) { - return String.format("http://%s:%d/%s", getDeploymentHostname(), SERVER_PORT, route); - } - - /** Property set to true when running on a test hub. */ - private static final String USE_HUB_PROPERTY = "test.use.hub"; - - /** - * Returns whether we are using a test hub. This means that the starter is running tests in - * Vaadin's CI environment, and uses TestBench to connect to the testing hub. - * - * @return whether we are using a test hub - */ - private static boolean isUsingHub() { - return Boolean.TRUE.toString().equals(System.getProperty(USE_HUB_PROPERTY)); - } - - /** - * If running on CI, get the host name from environment variable HOSTNAME - * - * @return the host name - */ - private static String getDeploymentHostname() { - return isUsingHub() ? System.getenv("HOSTNAME") : "localhost"; - } -} diff --git a/src/test/java/com/flowingcode/vaadin/addons/easyform/it/ViewIT.java b/src/test/java/com/flowingcode/vaadin/addons/easyform/it/ViewIT.java deleted file mode 100644 index d81bd7a..0000000 --- a/src/test/java/com/flowingcode/vaadin/addons/easyform/it/ViewIT.java +++ /dev/null @@ -1,64 +0,0 @@ -/*- - * #%L - * Easy Form Add-on - * %% - * Copyright (C) 2023 Flowing Code - * %% - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * #L% - */ - -package com.flowingcode.vaadin.addons.easyform.it; - -import static org.hamcrest.Matchers.is; -import static org.hamcrest.Matchers.not; -import static org.junit.Assert.assertThat; - -import com.vaadin.testbench.TestBenchElement; -import org.hamcrest.Description; -import org.hamcrest.Matcher; -import org.hamcrest.TypeSafeDiagnosingMatcher; -import org.junit.Test; - -public class ViewIT extends AbstractViewTest { - - private Matcher hasBeenUpgradedToCustomElement = - new TypeSafeDiagnosingMatcher() { - - @Override - public void describeTo(Description description) { - description.appendText("a custom element"); - } - - @Override - protected boolean matchesSafely(TestBenchElement item, Description mismatchDescription) { - String script = "let s=arguments[0].shadowRoot; return !!(s&&s.childElementCount)"; - if (!item.getTagName().contains("-")) { - return true; - } - if ((Boolean) item.getCommandExecutor().executeScript(script, item)) { - return true; - } else { - mismatchDescription.appendText(item.getTagName() + " "); - mismatchDescription.appendDescriptionOf(is(not(this))); - return false; - } - } - }; - - @Test - public void componentWorks() { - TestBenchElement element = $("paper-input").first(); - assertThat(element, hasBeenUpgradedToCustomElement); - } -} diff --git a/src/test/java/com/flowingcode/vaadin/addons/easyform/test/AllTypes.java b/src/test/java/com/flowingcode/vaadin/addons/easyform/test/AllTypes.java new file mode 100644 index 0000000..ba30363 --- /dev/null +++ b/src/test/java/com/flowingcode/vaadin/addons/easyform/test/AllTypes.java @@ -0,0 +1,70 @@ +/*- + * #%L + * Easy Form Add-on + * %% + * Copyright (C) 2026 Flowing Code + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ + +package com.flowingcode.vaadin.addons.easyform.test; + +import java.io.Serializable; +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.util.Locale; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.Setter; + +/** + * Test bean that exercises the built-in type mappings that {@code Person} does not cover, the + * primitive auto-boxing paths, an unsupported property type, and a read-only property. + */ +@Getter +@Setter +@SuppressWarnings("serial") +public class AllTypes implements Serializable { + + private Double doubleValue; + + private Float floatValue; + + private BigDecimal bigDecimalValue; + + private LocalDateTime dateTimeValue; + + private LocalTime timeValue; + + private int intPrimitive; + + private long longPrimitive; + + private double doublePrimitive; + + private float floatPrimitive; + + private boolean booleanPrimitive; + + /** Primitive whose wrapper type has no registered component factory. */ + private short shortPrimitive; + + /** Property type with no registered component factory. */ + private Locale locale; + + /** Read-only property: it has no setter and must therefore be skipped during discovery. */ + @Setter(AccessLevel.NONE) + private String readOnlyValue = "fixed"; +} diff --git a/src/test/java/com/flowingcode/vaadin/addons/easyform/test/EasyFormTest.java b/src/test/java/com/flowingcode/vaadin/addons/easyform/test/EasyFormTest.java new file mode 100644 index 0000000..6f173c6 --- /dev/null +++ b/src/test/java/com/flowingcode/vaadin/addons/easyform/test/EasyFormTest.java @@ -0,0 +1,1220 @@ +/*- + * #%L + * Easy Form Add-on + * %% + * Copyright (C) 2026 Flowing Code + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package com.flowingcode.vaadin.addons.easyform.test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNotSame; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +import com.flowingcode.vaadin.addons.easyform.EasyForm; +import com.flowingcode.vaadin.addons.easyform.Person; +import com.flowingcode.vaadin.addons.easyform.Person.Gender; +import com.vaadin.flow.component.Component; +import com.vaadin.flow.component.HasLabel; +import com.vaadin.flow.component.HasValue; +import com.vaadin.flow.component.UI; +import com.vaadin.flow.component.button.Button; +import com.vaadin.flow.component.button.ButtonVariant; +import com.vaadin.flow.component.checkbox.Checkbox; +import com.vaadin.flow.component.combobox.ComboBox; +import com.vaadin.flow.component.datepicker.DatePicker; +import com.vaadin.flow.component.icon.Icon; +import com.vaadin.flow.component.icon.VaadinIcon; +import com.vaadin.flow.component.textfield.IntegerField; +import com.vaadin.flow.component.textfield.TextArea; +import com.vaadin.flow.component.textfield.TextField; +import com.vaadin.flow.data.binder.BinderValidationStatus; +import com.vaadin.flow.data.binder.ValidationResult; +import com.vaadin.flow.data.binder.ValueContext; +import com.vaadin.flow.data.converter.StringToIntegerConverter; +import com.vaadin.flow.data.validator.StringLengthValidator; +import com.vaadin.flow.data.validator.EmailValidator; +import com.vaadin.flow.shared.Registration; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Optional; +import java.util.stream.Collectors; +import lombok.Getter; +import lombok.Setter; +import org.junit.Test; + +public class EasyFormTest { + + private static List labelsOf(final EasyForm form) { + return form.getFormLayout().getChildren().map(component -> ((HasLabel) component).getLabel()) + .collect(Collectors.toList()); + } + + private static Person validPerson() { + Person person = new Person(); + person.setFirstName("John"); + person.setLastName("Doe"); + person.setEmail("john.doe@example.com"); + person.setAge(30); + person.setBirthDate(LocalDate.of(1990, 5, 17)); + return person; + } + + @Test + public void fieldsAreDiscoveredWithDefaultComponents() { + EasyForm form = new EasyForm<>(Person.class); + assertTrue(form.field("firstName") instanceof TextField); + assertTrue(form.field("age") instanceof IntegerField); + assertTrue(form.field("birthDate") instanceof DatePicker); + assertTrue(form.field("subscriber") instanceof Checkbox); + assertTrue(form.field("gender") instanceof ComboBox); + } + + @Test + public void unknownPropertyThrows() { + EasyForm form = new EasyForm<>(Person.class); + assertThrows(IllegalArgumentException.class, () -> form.configureField("nonexistent")); + assertThrows(IllegalArgumentException.class, + () -> form.configureField("nonexistent", String.class)); + } + + @Test + public void configureFieldWithValueTypeInfersThePresentationType() { + EasyForm form = new EasyForm<>(Person.class); + // compiles without an explicit type argument, unlike form.configureField("email") + assertSame(form.configureField("email"), form.configureField("email", String.class) + .withValidator(new EmailValidator("Invalid email"))); + } + + @Test + public void configureFieldWithValueTypeAcceptsSupertypesOfThePresentationType() { + EasyForm form = new EasyForm<>(Person.class); + assertNotNull(form.configureField("email", CharSequence.class)); + } + + @Test + public void configureFieldWithValueTypeChecksThePresentationTypeAndNotThePropertyType() { + EasyForm form = new EasyForm<>(Person.class); + // a Long property is presented by a TextField, whose value type is String + assertNotNull(form.configureField("id", String.class)); + assertThrows(IllegalArgumentException.class, () -> form.configureField("id", Long.class)); + } + + @Test + public void configureFieldWithValueTypeRejectsIncompatibleTypes() { + EasyForm form = new EasyForm<>(Person.class); + assertThrows(IllegalArgumentException.class, () -> form.configureField("email", Integer.class)); + assertThrows(NullPointerException.class, () -> form.configureField("email", null)); + } + + @Test + public void configureFieldWithValueTypeFollowsTheCurrentComponent() { + EasyForm form = new EasyForm<>(Person.class); + assertNotNull(form.configureField("age", Integer.class)); + form.setComponentFactory( + Integer.class, TextField::new, new StringToIntegerConverter("Must be a number")); + assertNotNull(form.configureField("age", String.class)); + assertThrows(IllegalArgumentException.class, () -> form.configureField("age", Integer.class)); + } + + @Test + public void configureFieldWithValueTypeSkipsComponentsWithAnUnresolvableValueType() { + EasyForm form = new EasyForm<>(Person.class); + assertTrue(form.field("gender") instanceof ComboBox); + assertNotNull(form.configureField("gender", Gender.class)); + } + + @Test + public void configureFieldWithValueTypeSkipsPropertiesWithoutAComponent() { + EasyForm form = new EasyForm<>(Person.class); + assertNull(form.field("parent")); + assertNotNull(form.configureField("parent", Person.class)); + } + + @Test + public void componentThatCannotBeWrittenToItsPropertyIsReported() { + EasyForm form = new EasyForm<>(Person.class); + form.configureField("age").withComponent(new TextField()); + Person person = validPerson(); + assertThrows(IllegalStateException.class, () -> form.setBean(person)); + assertThrows(IllegalStateException.class, () -> form.readBean(person)); + assertThrows(IllegalStateException.class, () -> form.getValidBean()); + } + + @Test + public void componentThatCannotBeWrittenToItsPropertyIsAcceptedWithAConverter() { + EasyForm form = new EasyForm<>(Person.class); + form.configureField("age").withComponent(new TextField()) + .withConverter(new StringToIntegerConverter("Must be a number")); + form.readBean(validPerson()); + TextField age = (TextField) form.field("age", String.class); + assertEquals("30", age.getValue()); + age.setValue("42"); + Optional result = form.getValidBean(); + assertTrue(result.isPresent()); + assertEquals(Integer.valueOf(42), result.get().getAge()); + } + + @Test + public void hiddenFieldWithAnIncompatibleComponentIsNotReported() { + EasyForm form = new EasyForm<>(Person.class); + form.configureField("age").withComponent(new TextField()).excluded(); + form.setBean(validPerson()); + } + + @Test + public void defaultLabelsAreGeneratedFromPropertyNames() { + EasyForm form = new EasyForm<>(Person.class); + TextField firstName = (TextField) form.field("firstName", String.class); + assertEquals("First Name", firstName.getLabel()); + DatePicker birthDate = (DatePicker) form.field("birthDate", LocalDate.class); + assertEquals("Birth Date", birthDate.getLabel()); + } + + @Test + public void explicitLabelOverridesGeneratedOne() { + EasyForm form = new EasyForm<>(Person.class); + form.configureField("email").withLabel("Email Address"); + TextField email = (TextField) form.field("email", String.class); + assertEquals("Email Address", email.getLabel()); + } + + @Test + public void setBeanPopulatesFields() { + EasyForm form = new EasyForm<>(Person.class); + form.setBean(validPerson()); + TextField firstName = (TextField) form.field("firstName", String.class); + assertEquals("John", firstName.getValue()); + } + + @Test + public void setBeanWritesThrough() { + EasyForm form = new EasyForm<>(Person.class); + Person person = validPerson(); + form.setBean(person); + TextField firstName = (TextField) form.field("firstName", String.class); + firstName.setValue("Jane"); + assertEquals("Jane", person.getFirstName()); + } + + @Test + public void readBeanDoesNotWriteThrough() { + EasyForm form = new EasyForm<>(Person.class); + Person person = validPerson(); + form.readBean(person); + TextField firstName = (TextField) form.field("firstName", String.class); + firstName.setValue("Jane"); + assertEquals("John", person.getFirstName()); + } + + @Test + public void getValidBeanReturnsPopulatedBean() { + EasyForm form = new EasyForm<>(Person.class); + form.readBean(validPerson()); + TextField firstName = (TextField) form.field("firstName", String.class); + firstName.setValue("Jane"); + Optional result = form.getValidBean(); + assertTrue(result.isPresent()); + assertEquals("Jane", result.get().getFirstName()); + } + + @Test + public void beanValidationAnnotationsAreApplied() { + EasyForm form = new EasyForm<>(Person.class); + Person person = validPerson(); + person.setEmail("not an email"); + form.readBean(person); + assertFalse(form.getValidBean().isPresent()); + } + + @Test + public void programmaticValidatorsAreApplied() { + EasyForm form = new EasyForm<>(Person.class); + form.configureField("firstName", String.class) + .withValidator( + (String value, ValueContext context) -> + "John".equals(value) + ? ValidationResult.ok() + : ValidationResult.error("Must be John")); + Person person = validPerson(); + person.setFirstName("Jane"); + form.readBean(person); + assertFalse(form.getValidBean().isPresent()); + person.setFirstName("John"); + form.readBean(person); + assertTrue(form.getValidBean().isPresent()); + } + + @Test + public void beanLevelValidatorsAreApplied() { + EasyForm form = new EasyForm<>(Person.class); + form.addBeanValidator( + (person, context) -> + person.getAge() != null && person.getAge() < 18 + ? ValidationResult.error("Must be an adult") + : ValidationResult.ok()); + Person person = validPerson(); + person.setAge(10); + form.readBean(person); + assertFalse(form.getValidBean().isPresent()); + } + + @Test + public void hiddenFieldsAreExcludedFromBinding() { + EasyForm form = new EasyForm<>(Person.class); + Person person = validPerson(); + person.setEmail("not an email"); + form.configureField("email").excluded(); + form.readBean(person); + assertTrue(form.getValidBean().isPresent()); + } + + @Test + public void hiddenFieldCanBeMadeVisibleAgain() { + EasyForm form = new EasyForm<>(Person.class); + Person person = validPerson(); + form.readBean(person); + form.configureField("email").excluded(); + form.configureField("email").visible(); + TextField email = (TextField) form.field("email", String.class); + assertEquals("john.doe@example.com", email.getValue()); + } + + @Test + public void readOnlyFieldIsDisplayedButNotEditable() { + EasyForm form = new EasyForm<>(Person.class); + form.configureField("firstName").readOnly(); + TextField firstName = (TextField) form.field("firstName", String.class); + assertTrue(firstName.isReadOnly()); + } + + @Test + public void setVisibleFieldsShowsOnlyListedFields() { + EasyForm form = new EasyForm<>(Person.class); + form.setVisibleFields("firstName", "lastName"); + long count = form.getFormLayout().getChildren().count(); + assertEquals(2, count); + Person person = validPerson(); + person.setEmail("not an email"); + form.readBean(person); + // email is excluded from the binding, so its invalid value does not fail validation + assertTrue(form.getValidBean().isPresent()); + } + + @Test + public void setFieldOrderDoesNotHideOrUnbindUnlistedFields() { + EasyForm form = new EasyForm<>(Person.class); + long before = form.getFormLayout().getChildren().count(); + form.setFieldOrder("firstName", "lastName"); + assertEquals(before, form.getFormLayout().getChildren().count()); + Person person = validPerson(); + person.setEmail("not an email"); + form.readBean(person); + // email is still bound even though it was not listed, so it still fails validation + assertFalse(form.getValidBean().isPresent()); + } + + @Test + public void setVisibleFieldsRejectsUnknownProperties() { + EasyForm form = new EasyForm<>(Person.class); + assertThrows(IllegalArgumentException.class, () -> form.setVisibleFields("firstName", "bogus")); + } + + @Test + public void setFieldOrderRejectsUnknownProperties() { + EasyForm form = new EasyForm<>(Person.class); + assertThrows(IllegalArgumentException.class, () -> form.setFieldOrder("firstName", "bogus")); + } + + @Test + public void customComponentReplacesGeneratedOne() { + EasyForm form = new EasyForm<>(Person.class); + TextArea notes = new TextArea(); + form.configureField("notes").withComponent(notes); + assertEquals(notes, form.field("notes")); + form.setBean(validPerson()); + notes.setValue("some notes"); + assertEquals("some notes", form.getBean().getNotes()); + } + + @Test + public void perFormComponentFactoryOverridesDefault() { + EasyForm form = new EasyForm<>(Person.class); + form.setComponentFactory(String.class, TextArea::new); + assertTrue(form.field("firstName") instanceof TextArea); + // explicit components are preserved + EasyForm other = new EasyForm<>(Person.class); + TextField custom = new TextField(); + other.configureField("firstName").withComponent(custom); + other.setComponentFactory(String.class, TextArea::new); + assertEquals(custom, other.field("firstName")); + } + + @Test + public void componentFactoryWithConverterAppliesConverter() { + EasyForm form = new EasyForm<>(Person.class); + form.setComponentFactory( + Integer.class, TextField::new, new StringToIntegerConverter("Must be a number")); + assertTrue(form.field("age") instanceof TextField); + form.readBean(validPerson()); + TextField age = (TextField) form.field("age", String.class); + assertEquals("30", age.getValue()); + age.setValue("42"); + Optional result = form.getValidBean(); + assertTrue(result.isPresent()); + assertEquals(Integer.valueOf(42), result.get().getAge()); + } + + @Test + public void longPropertyUsesFactoryProvidedConverter() { + EasyForm form = new EasyForm<>(Person.class); + assertTrue(form.field("id") instanceof TextField); + form.readBean(validPerson()); + TextField id = (TextField) form.field("id", String.class); + id.setValue("7"); + Optional result = form.getValidBean(); + assertTrue(result.isPresent()); + assertEquals(Long.valueOf(7), result.get().getId()); + } + + @Test + public void longPropertyKeepsValuesThatExceedTheDoubleMantissa() { + EasyForm form = new EasyForm<>(Person.class); + form.readBean(validPerson()); + TextField id = (TextField) form.field("id", String.class); + // 2^53 + 1, which cannot be represented as a double + id.setValue("9007199254740993"); + Optional result = form.getValidBean(); + assertTrue(result.isPresent()); + assertEquals(Long.valueOf(9007199254740993L), result.get().getId()); + } + + @Test + public void longPropertyRejectsFractionalInputInsteadOfTruncatingIt() { + // the built-in converter reads the number in the locale of the session, as Vaadin's own + // converters do, so the separator this test types has to be the one of a known locale + Locale defaultLocale = Locale.getDefault(); + Locale.setDefault(Locale.US); + try { + EasyForm form = new EasyForm<>(Person.class); + Person person = validPerson(); + person.setId(3L); + form.readBean(person); + ((TextField) form.field("id", String.class)).setValue("1.5"); + assertFalse(form.getValidBean().isPresent()); + assertEquals(Long.valueOf(3), person.getId()); + } finally { + Locale.setDefault(defaultLocale); + } + } + + @Test + public void withComponentDropsTheConverterOfThePreviousComponent() { + EasyForm form = new EasyForm<>(Person.class); + form.configureField("age").withComponent(new TextField()) + .withConverter(new StringToIntegerConverter("Not a number")); + // the String -> Integer converter cannot apply to the new component and must not survive it + form.configureField("age").withComponent(new IntegerField()); + form.setBean(validPerson()); + assertEquals(Integer.valueOf(30), form.field("age", Integer.class).getValue()); + } + + @Test + public void withComponentDropsTheValidatorsOfThePreviousComponent() { + EasyForm form = new EasyForm<>(Person.class); + form.configureField("age").withComponent(new TextField()) + .withValidator(new StringLengthValidator("Too long", 0, 2)) + .withConverter(new StringToIntegerConverter("Not a number")); + // validators run on the presentation value, so they are as component bound as the converter + form.configureField("age").withComponent(new IntegerField()); + form.readBean(validPerson()); + form.field("age", Integer.class).setValue(100); + Optional result = form.getValidBean(); + assertTrue(result.isPresent()); + assertEquals(Integer.valueOf(100), result.get().getAge()); + } + + @Test + public void reconfiguringAFieldKeepsTheValueTheUserTyped() { + EasyForm form = new EasyForm<>(Person.class); + form.readBean(validPerson()); + TextField firstName = (TextField) form.field("firstName", String.class); + firstName.setValue("TypedByUser"); + form.configureField("firstName").asRequired("First name is required"); + assertEquals("TypedByUser", firstName.getValue()); + } + + @Test + public void changingTheVisibleFieldsKeepsTheValuesTheUserTyped() { + EasyForm form = new EasyForm<>(Person.class); + form.readBean(validPerson()); + TextField firstName = (TextField) form.field("firstName", String.class); + TextField lastName = (TextField) form.field("lastName", String.class); + firstName.setValue("TypedByUser"); + lastName.clear(); + form.setVisibleFields("firstName", "lastName", "age"); + assertEquals("TypedByUser", firstName.getValue()); + assertEquals("", lastName.getValue()); + } + + @Test + public void aFieldThatBecomesVisibleAgainIsPopulatedFromTheBean() { + EasyForm form = new EasyForm<>(Person.class); + form.readBean(validPerson()); + TextField firstName = (TextField) form.field("firstName", String.class); + form.configureField("firstName").excluded(); + firstName.setValue("StaleWhileExcluded"); + form.configureField("firstName").visible(); + assertEquals("John", firstName.getValue()); + } + + // -- binding lifecycle -- + + @Test + public void attachingTheFormBindsTheFields() { + UI ui = new UI(); + EasyForm form = new EasyForm<>(Person.class); + TextField firstName = (TextField) form.field("firstName", String.class); + firstName.setValue("TypedBeforeAttach"); + ui.add(form); + // the value typed while the field was unbound survives being bound + assertEquals("TypedBeforeAttach", firstName.getValue()); + // and what is typed once the form is displayed reaches the binder, which only holds if + // attaching bound the fields: a value changed before its binding exists goes unnoticed + firstName.setValue("TypedAfterAttach"); + assertTrue(form.getBinder().hasChanges()); + } + + @Test + public void configurationCollectedBeforeTheFirstUseIsAppliedInOnePass() { + EasyForm form = new EasyForm<>(Person.class); + form.configureField("age").withComponent(new TextField()) + .withValidator(new StringLengthValidator("At most three digits", 0, 3)) + .withConverter(new StringToIntegerConverter("Not a number")); + form.configureField("email").asRequired("Email is required"); + form.configureField("lastName").excluded(); + form.readBean(validPerson()); + // every piece of the configuration above survived into the single binding of each field + assertEquals("30", form.field("age", String.class).getValue()); + form.field("age", String.class).setValue("1000"); + assertFalse(form.getValidBean().isPresent()); + form.field("age", String.class).setValue("31"); + form.field("email", String.class).clear(); + assertFalse(form.getValidBean().isPresent()); + form.field("email", String.class).setValue("john.doe@example.com"); + Optional result = form.getValidBean(); + assertTrue(result.isPresent()); + assertEquals(Integer.valueOf(31), result.get().getAge()); + // the excluded field was never bound, so it is neither displayed nor written + assertFalse(labelsOf(form).contains("Last Name")); + assertEquals("Doe", result.get().getLastName()); + } + + @Test + public void reconfiguringAFieldAfterTheFormIsBoundTakesEffectImmediately() { + EasyForm form = new EasyForm<>(Person.class); + Person person = validPerson(); + person.setNotes("ok"); + form.setBean(person); + // the form is bound now, so the validator has to reach the binding right away: in write + // through mode nothing else calls the form between here and the value change below. + // 'notes' carries no bean validation annotation, so this validator is the only thing that + // can keep the value out of the bean + form.configureField("notes", String.class) + .withValidator(new StringLengthValidator("At most three characters", 0, 3)); + form.field("notes", String.class).setValue("way too long"); + assertEquals("ok", person.getNotes()); + } + + @Test + public void requiredIndicatorIsVisibleBeforeTheFieldIsBound() { + EasyForm form = new EasyForm<>(Person.class); + form.configureField("email").asRequired("Email is required"); + assertTrue(form.field("email", String.class).isRequiredIndicatorVisible()); + } + + @Test + public void requiredFieldFailsValidationWhenEmpty() { + EasyForm form = new EasyForm<>(Person.class); + form.configureField("email").asRequired("Email is required"); + Person person = validPerson(); + person.setEmail(null); + form.readBean(person); + assertFalse(form.getValidBean().isPresent()); + } + + @Test + public void clearAndReset() { + EasyForm form = new EasyForm<>(Person.class); + form.readBean(validPerson()); + TextField firstName = (TextField) form.field("firstName", String.class); + form.clear(); + assertEquals("", firstName.getValue()); + form.reset(); + assertEquals("John", firstName.getValue()); + } + + @Test + public void clearDoesNotWriteThroughBean() { + EasyForm form = new EasyForm<>(Person.class); + Person person = validPerson(); + form.setBean(person); + form.clear(); + assertEquals("John", person.getFirstName()); + TextField firstName = (TextField) form.field("firstName", String.class); + assertEquals("", firstName.getValue()); + form.reset(); + assertEquals("John", firstName.getValue()); + firstName.setValue("Jane"); + // write-through is restored after reset + assertEquals("Jane", person.getFirstName()); + } + + @Test + public void clearDetachesTheBeanSoItIsNeverWrittenByGetValidBean() { + EasyForm form = new EasyForm<>(Person.class); + Person person = validPerson(); + form.setBean(person); + form.clear(); + assertNull(form.getBean()); + assertNull(form.getBinder().getBean()); + Optional result = form.getValidBean(); + // the cleared form is written to a new instance, never to the bean the caller passed in + assertTrue(result.isEmpty() || result.get() != person); + assertEquals("John", person.getFirstName()); + assertEquals("Doe", person.getLastName()); + } + + @Test + public void clearThenGetValidBeanLeavesAnUnconstrainedBeanUntouched() { + EasyForm form = new EasyForm<>(Unconstrained.class); + Unconstrained bean = new Unconstrained(); + bean.setName("Leonardo"); + form.setBean(bean); + form.clear(); + Optional result = form.getValidBean(); + assertTrue(result.isPresent()); + assertNotSame(bean, result.get()); + assertEquals("Leonardo", bean.getName()); + } + + @Test + public void resetAfterClearReattachesTheBean() { + EasyForm form = new EasyForm<>(Person.class); + Person person = validPerson(); + form.setBean(person); + form.clear(); + form.reset(); + assertSame(person, form.getBean()); + assertSame(person, form.getBinder().getBean()); + TextField firstName = (TextField) form.field("firstName", String.class); + firstName.setValue("Jane"); + assertEquals("Jane", person.getFirstName()); + } + + @Test + public void validateReportsWhichFieldsFailed() { + EasyForm form = new EasyForm<>(Person.class); + Person person = validPerson(); + person.setEmail("not an email"); + form.readBean(person); + BinderValidationStatus status = form.validate(); + assertTrue(status.hasErrors()); + assertEquals(List.of("Must be a valid email address"), + status.getValidationErrors().stream().map(ValidationResult::getErrorMessage) + .collect(Collectors.toList())); + // validate() reports, it does not write + assertEquals("not an email", person.getEmail()); + } + + @Test + public void validateOnAValidFormHasNoErrors() { + EasyForm form = new EasyForm<>(Person.class); + form.readBean(validPerson()); + assertFalse(form.validate().hasErrors()); + } + + // -- events -- + + @Test + public void statusChangeListenerFiresOnValidationChanges() { + EasyForm form = new EasyForm<>(Person.class); + form.setBean(validPerson()); + List errorStates = new ArrayList<>(); + Registration registration = + form.addStatusChangeListener(event -> errorStates.add(event.hasValidationErrors())); + TextField email = (TextField) form.field("email", String.class); + email.setValue("not an email"); + email.setValue("jane.doe@example.com"); + assertEquals(List.of(true, false), errorStates); + registration.remove(); + email.setValue("broken again"); + assertEquals(List.of(true, false), errorStates); + } + + @Test + public void valueChangeListenerFiresForEveryFieldIncludingInvalidValues() { + EasyForm form = new EasyForm<>(Person.class); + form.setBean(validPerson()); + int[] changes = {0}; + form.addValueChangeListener(event -> changes[0]++); + ((TextField) form.field("email", String.class)).setValue("not an email"); + ((TextField) form.field("firstName", String.class)).setValue("Jane"); + assertEquals(2, changes[0]); + } + + @Test + public void listenersRejectNullArguments() { + EasyForm form = new EasyForm<>(Person.class); + assertThrows(NullPointerException.class, () -> form.addStatusChangeListener(null)); + assertThrows(NullPointerException.class, () -> form.addValueChangeListener(null)); + } + + // -- component accessors and fluency -- + + @Test + public void fieldReturnsTheComponentAndConfigureFieldItsWrapper() { + EasyForm form = new EasyForm<>(Person.class); + assertSame(form.configureField("email").getComponent(), form.field("email")); + assertTrue(form.field("email") instanceof TextField); + HasValue typed = form.field("email", String.class); + assertSame(form.field("email"), typed); + // the type check of the two-argument overload applies here too + assertThrows(IllegalArgumentException.class, () -> form.field("email", Integer.class)); + assertThrows(IllegalArgumentException.class, () -> form.field("nonexistent")); + } + + @Test + public void fieldIsNullForPropertiesWithoutAComponent() { + EasyForm form = new EasyForm<>(Person.class); + assertNull(form.field("parent")); + } + + @Test + public void formLevelConfigurationIsChainable() { + Person person = validPerson(); + EasyForm form = new EasyForm<>(Person.class) + .setFieldOrder("firstName", "lastName", "email") + .setVisibleFields("firstName", "lastName", "email") + .setLabelGenerator(name -> name.toUpperCase()) + .setI18n(new EasyForm.EasyFormI18n().setSave("Guardar")) + .setSaveAction(saved -> {}) + .setCancelAction(() -> {}) + .setSaveButtonVisible(true) + .setBean(person); + assertEquals(List.of("FIRSTNAME", "LASTNAME", "EMAIL"), labelsOf(form)); + assertEquals("Guardar", form.getSaveButton().getText()); + assertSame(person, form.getBean()); + // the action methods chain too + assertSame(form, form.clear().reset()); + } + + // -- collection accessors -- + + @Test + public void getFieldNamesAndGetFieldsFollowTheDisplayOrder() { + EasyForm form = new EasyForm<>(Person.class); + form.setFieldOrder("email", "firstName"); + assertEquals(List.of("email", "firstName"), form.getFieldNames().subList(0, 2)); + assertEquals(form.getFieldNames().size(), form.getFields().size()); + assertEquals("email", form.getFields().get(0).getPropertyName()); + // excluded fields are still listed + form.configureField("email").excluded(); + assertTrue(form.getFieldNames().contains("email")); + } + + @Test + public void findFieldDoesNotThrowForUnknownProperties() { + EasyForm form = new EasyForm<>(Person.class); + assertTrue(form.findField("email").isPresent()); + assertSame(form.configureField("email"), form.findField("email").get()); + assertTrue(form.findField("nonexistent").isEmpty()); + // nested property paths are not discovered + assertTrue(form.findField("parent.firstName").isEmpty()); + } + + // -- i18n and label generation -- + + @Test + public void setI18nChangesTheButtonTexts() { + EasyForm form = new EasyForm<>(Person.class); + assertEquals("Save", form.getI18n().getSave()); + form.setI18n(new EasyForm.EasyFormI18n().setSave("Guardar").setCancel("Cancelar")); + assertEquals("Guardar", form.getSaveButton().getText()); + assertEquals("Cancelar", form.getCancelButton().getText()); + assertEquals("Guardar", form.getI18n().getSave()); + assertThrows(NullPointerException.class, () -> form.setI18n(null)); + } + + @Test + public void labelGeneratorReplacesGeneratedLabelsButNotExplicitOnes() { + EasyForm form = new EasyForm<>(Person.class); + form.configureField("email").withLabel("Email Address"); + form.setLabelGenerator(name -> name.toUpperCase()); + assertEquals("FIRSTNAME", + ((TextField) form.field("firstName", String.class)).getLabel()); + assertEquals("Email Address", + ((TextField) form.field("email", String.class)).getLabel()); + // null restores the default derivation + form.setLabelGenerator(null); + assertEquals("First Name", + ((TextField) form.field("firstName", String.class)).getLabel()); + } + + // -- extension points -- + + @Test + public void subclassCanFilterPropertiesAndDecorateComponents() { + EasyForm form = new EasyForm<>(Person.class) { + @Override + protected boolean includeProperty(java.beans.PropertyDescriptor property) { + return !"notes".equals(property.getName()); + } + + @Override + protected String createLabel(String propertyName) { + return "[" + propertyName + "]"; + } + + @Override + protected void configureComponent(String propertyName, HasValue component) { + ((Component) component).getElement().setAttribute("data-property", propertyName); + } + }; + assertTrue(form.findField("notes").isEmpty()); + TextField firstName = (TextField) form.field("firstName", String.class); + assertEquals("[firstName]", firstName.getLabel()); + assertEquals("firstName", firstName.getElement().getAttribute("data-property")); + } + + @Test + public void subclassCanCreateComponentsForOtherwiseUnmappedTypes() { + EasyForm form = new EasyForm<>(Person.class) { + @Override + protected HasValue createComponent(String propertyName, Class propertyType) { + return propertyType == Person.class ? new ComboBox() + : super.createComponent(propertyName, propertyType); + } + }; + // 'parent' has no registered factory, so it would otherwise be left out + assertTrue(form.field("parent") instanceof ComboBox); + // the enum default from super is still in place + assertTrue(form.field("gender") instanceof ComboBox); + } + + // -- button bar -- + + @Test + public void addButtonAppliesEveryVariantAndRemoveButtonTakesItOut() { + EasyForm form = new EasyForm<>(Person.class); + // the button bar is the parent of the built-in buttons + Component bar = form.getSaveButton().getParent().orElseThrow(); + long before = bar.getChildren().count(); + Button button = form.addButton("Reset", event -> {}, ButtonVariant.LUMO_TERTIARY, + ButtonVariant.LUMO_ERROR); + assertEquals(before + 1, bar.getChildren().count()); + assertSame(bar, button.getParent().orElseThrow()); + assertTrue(button.getThemeNames().contains(ButtonVariant.LUMO_TERTIARY.getVariantName())); + assertTrue(button.getThemeNames().contains(ButtonVariant.LUMO_ERROR.getVariantName())); + form.removeButton(button); + assertEquals(before, bar.getChildren().count()); + assertTrue(button.getParent().isEmpty()); + } + + @Test + public void removeButtonRejectsForeignAndBuiltInButtons() { + EasyForm form = new EasyForm<>(Person.class); + assertThrows(NullPointerException.class, () -> form.removeButton(null)); + assertThrows(IllegalArgumentException.class, () -> form.removeButton(form.getSaveButton())); + assertThrows(IllegalArgumentException.class, () -> form.removeButton(form.getCancelButton())); + assertThrows(IllegalArgumentException.class, () -> form.removeButton(new Button("Foreign"))); + } + + // -- form-level component interfaces -- + + @Test + public void formSupportsStyle() { + EasyForm form = new EasyForm<>(Person.class); + form.addClassName("my-form"); + assertTrue(form.getClassNames().contains("my-form")); + } + + @Test + public void disablingTheFormDisablesTheFieldsAndTheButtons() { + EasyForm form = new EasyForm<>(Person.class); + form.setSaveAction(person -> {}); + form.setEnabled(false); + assertFalse(form.isEnabled()); + // the generated components and the button bar are in this component's element tree + assertFalse(((TextField) form.field("firstName")).isEnabled()); + assertFalse(form.getSaveButton().isEnabled()); + assertFalse(form.getCancelButton().isEnabled()); + } + + @Test + public void disablingTheFormStopsValuesBeingWrittenToTheBean() { + EasyForm form = new EasyForm<>(Person.class); + Person person = validPerson(); + form.setBean(person); + form.setEnabled(false); + ((TextField) form.field("firstName")).setValue("Jane"); + assertEquals("John", person.getFirstName()); + // and the values are still read into the fields + assertEquals("Jane", ((TextField) form.field("firstName")).getValue()); + form.setEnabled(true); + ((TextField) form.field("firstName")).setValue("Joanne"); + assertEquals("Joanne", person.getFirstName()); + } + + @Test + public void reEnablingTheFormKeepsPerFieldReadOnlyState() { + EasyForm form = new EasyForm<>(Person.class); + Person person = validPerson(); + form.configureField("lastName").readOnly(); + form.setBean(person); + form.setEnabled(false); + form.setEnabled(true); + ((TextField) form.field("firstName")).setValue("Jane"); + ((TextField) form.field("lastName")).setValue("Smith"); + assertEquals("Jane", person.getFirstName()); + // lastName was configured read-only, so re-enabling must not make it writable + assertEquals("Doe", person.getLastName()); + } + + @Test + public void fieldsBoundWhileDisabledAreAlsoReadOnly() { + EasyForm form = new EasyForm<>(Person.class); + Person person = validPerson(); + form.setEnabled(false); + // rebinding happens here, after the form was disabled + form.configureField("firstName").withLabel("First"); + form.setBean(person); + ((TextField) form.field("firstName")).setValue("Jane"); + assertEquals("John", person.getFirstName()); + } + + /** Bean with no Bean Validation constraints, so an emptied form still validates. */ + public static class Unconstrained { + + @Getter + @Setter + private String name; + } + + @Test + public void readBeanDetachesPreviouslySetBean() { + EasyForm form = new EasyForm<>(Person.class); + Person first = validPerson(); + Person second = validPerson(); + second.setFirstName("Alice"); + form.setBean(first); + form.readBean(second); + TextField firstName = (TextField) form.field("firstName", String.class); + firstName.setValue("Jane"); + assertEquals("John", first.getFirstName()); + assertEquals("Alice", second.getFirstName()); + } + + @Test + public void getValidBeanCreatesInstanceWhenNoBeanSet() { + EasyForm form = new EasyForm<>(Person.class); + TextField firstName = (TextField) form.field("firstName", String.class); + TextField lastName = (TextField) form.field("lastName", String.class); + firstName.setValue("Jane"); + lastName.setValue("Doe"); + Optional result = form.getValidBean(); + assertTrue(result.isPresent()); + assertEquals("Jane", result.get().getFirstName()); + assertNull(result.get().getId()); + } + + @Test + public void buttonBarIsHiddenUntilAnActionIsSet() { + EasyForm form = new EasyForm<>(Person.class); + assertFalse(form.getSaveButton().isVisible()); + assertFalse(form.getCancelButton().isVisible()); + form.setSaveAction(person -> {}); + assertTrue(form.getSaveButton().isVisible()); + assertFalse(form.getCancelButton().isVisible()); + form.setSaveButtonVisible(false); + assertFalse(form.getSaveButton().isVisible()); + } + + // -- accessors -- + + @Test + public void beanTypeAndBinderAreExposed() { + EasyForm form = new EasyForm<>(Person.class); + assertEquals(Person.class, form.getBeanType()); + assertNotNull(form.getBinder()); + assertEquals("email", form.configureField("email").getPropertyName()); + } + + // -- field order -- + + @Test + public void setFieldOrderDeterminesTheLayoutOrder() { + EasyForm form = new EasyForm<>(Person.class); + List unordered = labelsOf(form); + form.setFieldOrder("lastName", "firstName", "email"); + List ordered = labelsOf(form); + assertEquals(List.of("Last Name", "First Name", "Email"), ordered.subList(0, 3)); + // every other field keeps its declaration order after the listed ones + assertEquals(unordered.size(), ordered.size()); + List rest = new ArrayList<>(unordered); + rest.removeAll(List.of("Last Name", "First Name", "Email")); + assertEquals(rest, ordered.subList(3, ordered.size())); + } + + @Test + public void fieldExcludedBySetVisibleFieldsCanBeIncludedAgainIndividually() { + EasyForm form = new EasyForm<>(Person.class); + form.setVisibleFields("firstName"); + assertEquals(List.of("First Name"), labelsOf(form)); + // setVisibleFields drives the same per-field state, so visible() reverses it for one field + form.configureField("email").visible(); + assertEquals(List.of("First Name", "Email"), labelsOf(form)); + Person person = validPerson(); + person.setEmail("not an email"); + form.readBean(person); + // and the field is bound again, not merely displayed + assertFalse(form.getValidBean().isPresent()); + } + + @Test + public void setVisibleFieldsKeepsListedReadOnlyFieldsReadOnly() { + EasyForm form = new EasyForm<>(Person.class); + form.configureField("firstName").readOnly(); + form.setVisibleFields("firstName", "lastName"); + assertEquals(List.of("First Name", "Last Name"), labelsOf(form)); + assertTrue(form.field("firstName", String.class).isReadOnly()); + assertFalse(form.field("lastName", String.class).isReadOnly()); + } + + @Test + public void setVisibleFieldsExcludesEverythingNotListed() { + EasyForm form = new EasyForm<>(Person.class); + form.configureField("email").excluded(); + // listing email again brings it back; lastName, not listed, goes away + form.setVisibleFields("firstName", "email"); + assertEquals(List.of("First Name", "Email"), labelsOf(form)); + } + + @Test + public void setVisibleFieldsCanReIncludeExcludedFields() { + EasyForm form = new EasyForm<>(Person.class); + form.setVisibleFields("firstName"); + form.setVisibleFields("firstName", "email"); + assertEquals(List.of("First Name", "Email"), labelsOf(form)); + Person person = validPerson(); + person.setEmail("not an email"); + form.readBean(person); + // email is bound again, so its invalid value fails validation + assertFalse(form.getValidBean().isPresent()); + } + + @Test + public void excludedFieldsAreRemovedFromTheLayout() { + EasyForm form = new EasyForm<>(Person.class); + long before = form.getFormLayout().getChildren().count(); + form.configureField("email").excluded(); + form.configureField("notes").excluded(); + assertEquals(before - 2, form.getFormLayout().getChildren().count()); + } + + // -- converters -- + + @Test + public void conversionErrorFailsValidationAndLeavesTheBeanUntouched() { + EasyForm form = new EasyForm<>(Person.class); + form.setComponentFactory( + Integer.class, TextField::new, new StringToIntegerConverter("Must be a number")); + Person person = validPerson(); + form.readBean(person); + TextField age = (TextField) form.field("age", String.class); + age.setValue("abc"); + assertFalse(form.getValidBean().isPresent()); + assertEquals(Integer.valueOf(30), person.getAge()); + } + + @Test + public void withConverterAdaptsThePresentationValue() { + EasyForm form = new EasyForm<>(Person.class); + TextField age = new TextField(); + form.configureField("age").withComponent(age) + .withConverter(new StringToIntegerConverter("Must be a number")); + form.readBean(validPerson()); + assertEquals("30", age.getValue()); + age.setValue("42"); + Optional result = form.getValidBean(); + assertTrue(result.isPresent()); + assertEquals(Integer.valueOf(42), result.get().getAge()); + } + + // -- bean instantiation -- + + @Test + public void getValidBeanThrowsWhenTheBeanTypeCannotBeInstantiated() { + EasyForm form = new EasyForm<>(NoDefaultConstructor.class); + TextField name = (TextField) form.field("name", String.class); + name.setValue("value"); + assertThrows(IllegalStateException.class, () -> form.getValidBean()); + } + + // -- button actions -- + + @Test + public void saveActionReceivesTheValidatedBean() { + EasyForm form = new EasyForm<>(Person.class); + List saved = new ArrayList<>(); + form.setSaveAction(saved::add); + form.readBean(validPerson()); + TextField firstName = (TextField) form.field("firstName", String.class); + firstName.setValue("Jane"); + form.getSaveButton().click(); + assertEquals(1, saved.size()); + assertEquals("Jane", saved.get(0).getFirstName()); + } + + @Test + public void saveActionIsNotInvokedWhenValidationFails() { + EasyForm form = new EasyForm<>(Person.class); + List saved = new ArrayList<>(); + form.setSaveAction(saved::add); + Person person = validPerson(); + person.setEmail("not an email"); + form.readBean(person); + form.getSaveButton().click(); + assertTrue(saved.isEmpty()); + } + + @Test + public void cancelActionIsInvokedOnCancel() { + EasyForm form = new EasyForm<>(Person.class); + boolean[] cancelled = {false}; + form.setCancelAction(() -> cancelled[0] = true); + form.getCancelButton().click(); + assertTrue(cancelled[0]); + } + + @Test + public void cancelButtonTextAndVisibilityCanBeOverridden() { + EasyForm form = new EasyForm<>(Person.class); + form.getCancelButton().setText("Discard"); + assertEquals("Discard", form.getCancelButton().getText()); + assertFalse(form.getCancelButton().isVisible()); + form.setCancelButtonVisible(true); + assertTrue(form.getCancelButton().isVisible()); + } + + @Test + public void addButtonWithIconAndVariant() { + EasyForm form = new EasyForm<>(Person.class); + Icon icon = VaadinIcon.PLUS.create(); + boolean[] clicked = {false}; + Button button = + form.addButton("Add", icon, event -> clicked[0] = true, ButtonVariant.LUMO_SUCCESS); + assertEquals("Add", button.getText()); + assertSame(icon, button.getIcon()); + assertTrue(button.getThemeNames().contains(ButtonVariant.LUMO_SUCCESS.getVariantName())); + button.click(); + assertTrue(clicked[0]); + } + + // -- presentation -- + + @Test + public void placeholderAndHelperTextAreApplied() { + EasyForm form = new EasyForm<>(Person.class); + form.configureField("email").withPlaceholder("user@example.com").withHelperText("Work email"); + TextField email = (TextField) form.field("email", String.class); + assertEquals("user@example.com", email.getPlaceholder()); + assertEquals("Work email", email.getHelperText()); + } + + @Test + public void placeholderIsIgnoredWhenTheComponentDoesNotSupportIt() { + EasyForm form = new EasyForm<>(Person.class); + form.configureField("subscriber").withPlaceholder("ignored"); + Checkbox subscriber = (Checkbox) form.field("subscriber", Boolean.class); + assertNull(subscriber.getElement().getProperty("placeholder")); + } + + @Test + public void colSpanIsAppliedToTheLayout() { + EasyForm form = new EasyForm<>(Person.class); + form.configureField("notes").withColSpan(2); + Component notes = (Component) form.field("notes"); + assertEquals("2", notes.getElement().getAttribute("colspan")); + } + + @Test + public void colSpanRejectsValuesBelowOne() { + EasyForm form = new EasyForm<>(Person.class); + assertThrows( + IllegalArgumentException.class, () -> form.configureField("notes").withColSpan(0)); + } + + // -- defensive contracts -- + + @Test + public void constructorRejectsNullBeanType() { + assertThrows(NullPointerException.class, () -> new EasyForm(null)); + } + + @Test + public void componentFactoryRejectsNullArguments() { + EasyForm form = new EasyForm<>(Person.class); + assertThrows( + NullPointerException.class, () -> form.setComponentFactory(null, TextField::new)); + assertThrows( + NullPointerException.class, () -> form.setComponentFactory(String.class, null)); + } + + // A HasValue that is not a Component needs no runtime test any more: the intersection bound on + // setComponentFactory/withComponent turns it into a compile error. + @Test + public void fluentConfigurationRejectsNullArguments() { + EasyForm form = new EasyForm<>(Person.class); + assertThrows(NullPointerException.class, () -> form.addBeanValidator(null)); + assertThrows(NullPointerException.class, () -> form.configureField("email").asRequired(null)); + assertThrows(NullPointerException.class, + () -> form.configureField("email").withValidator(null)); + assertThrows(NullPointerException.class, + () -> form.configureField("email").withConverter(null)); + assertThrows(NullPointerException.class, + () -> form.configureField("email").withComponent(null)); + } + + /** Bean without an accessible no-args constructor. */ + public static class NoDefaultConstructor { + + @Getter + @Setter + private String name; + + public NoDefaultConstructor(final String name) { + this.name = name; + } + } + +} diff --git a/src/test/java/com/flowingcode/vaadin/addons/easyform/test/EasyFormTypesTest.java b/src/test/java/com/flowingcode/vaadin/addons/easyform/test/EasyFormTypesTest.java new file mode 100644 index 0000000..bfc7464 --- /dev/null +++ b/src/test/java/com/flowingcode/vaadin/addons/easyform/test/EasyFormTypesTest.java @@ -0,0 +1,329 @@ +/*- + * #%L + * Easy Form Add-on + * %% + * Copyright (C) 2026 Flowing Code + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ + +package com.flowingcode.vaadin.addons.easyform.test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +import com.flowingcode.vaadin.addons.easyform.EasyForm; +import com.flowingcode.vaadin.addons.easyform.Person; +import com.flowingcode.vaadin.addons.easyform.Person.Gender; +import com.vaadin.flow.component.HasLabel; +import com.vaadin.flow.component.checkbox.Checkbox; +import com.vaadin.flow.component.combobox.ComboBox; +import com.vaadin.flow.component.datetimepicker.DateTimePicker; +import com.vaadin.flow.component.textfield.BigDecimalField; +import com.vaadin.flow.component.textfield.IntegerField; +import com.vaadin.flow.component.textfield.NumberField; +import com.vaadin.flow.component.textfield.TextField; +import com.vaadin.flow.component.timepicker.TimePicker; +import com.vaadin.flow.data.binder.Result; +import com.vaadin.flow.data.converter.Converter; +import com.vaadin.flow.data.converter.StringToIntegerConverter; +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; +import lombok.Getter; +import lombok.Setter; +import org.junit.Test; + +/** + * Tests for the built-in type to component mappings, primitive handling and field discovery rules. + */ +public class EasyFormTypesTest { + + private static List labelsOf(final EasyForm form) { + return form.getFormLayout().getChildren().map(component -> ((HasLabel) component).getLabel()) + .collect(Collectors.toList()); + } + + // -- built-in type mappings not covered by Person -- + + @Test + public void doublePropertyUsesNumberField() { + EasyForm form = new EasyForm<>(AllTypes.class); + assertTrue(form.field("doubleValue") instanceof NumberField); + form.readBean(new AllTypes()); + NumberField field = (NumberField) form.field("doubleValue", Double.class); + field.setValue(1.5); + Optional result = form.getValidBean(); + assertTrue(result.isPresent()); + assertEquals(1.5, result.get().getDoubleValue().doubleValue(), 0d); + } + + @Test + public void floatPropertyUsesNumberFieldWithConverter() { + EasyForm form = new EasyForm<>(AllTypes.class); + assertTrue(form.field("floatValue") instanceof NumberField); + form.readBean(new AllTypes()); + NumberField field = (NumberField) form.field("floatValue", Double.class); + field.setValue(2.5); + Optional result = form.getValidBean(); + assertTrue(result.isPresent()); + assertNotNull(result.get().getFloatValue()); + assertEquals(2.5f, result.get().getFloatValue().floatValue(), 0f); + } + + @Test + public void bigDecimalPropertyUsesBigDecimalField() { + EasyForm form = new EasyForm<>(AllTypes.class); + assertTrue( + form.field("bigDecimalValue") instanceof BigDecimalField); + form.readBean(new AllTypes()); + BigDecimalField field = + (BigDecimalField) form.field("bigDecimalValue", BigDecimal.class); + BigDecimal expected = new BigDecimal("10.50"); + field.setValue(expected); + Optional result = form.getValidBean(); + assertTrue(result.isPresent()); + assertEquals(expected, result.get().getBigDecimalValue()); + } + + @Test + public void localDateTimePropertyUsesDateTimePicker() { + EasyForm form = new EasyForm<>(AllTypes.class); + assertTrue( + form.field("dateTimeValue") instanceof DateTimePicker); + form.readBean(new AllTypes()); + DateTimePicker field = + (DateTimePicker) form.field("dateTimeValue", LocalDateTime.class); + LocalDateTime expected = LocalDateTime.of(2026, 7, 28, 10, 30); + field.setValue(expected); + Optional result = form.getValidBean(); + assertTrue(result.isPresent()); + assertEquals(expected, result.get().getDateTimeValue()); + } + + @Test + public void localTimePropertyUsesTimePicker() { + EasyForm form = new EasyForm<>(AllTypes.class); + assertTrue(form.field("timeValue") instanceof TimePicker); + form.readBean(new AllTypes()); + TimePicker field = (TimePicker) form.field("timeValue", LocalTime.class); + LocalTime expected = LocalTime.of(14, 45); + field.setValue(expected); + Optional result = form.getValidBean(); + assertTrue(result.isPresent()); + assertEquals(expected, result.get().getTimeValue()); + } + + // -- primitive auto-boxing -- + + @Test + public void primitivePropertiesUseTheWrapperTypeComponents() { + EasyForm form = new EasyForm<>(AllTypes.class); + assertTrue(form.field("intPrimitive") instanceof IntegerField); + assertTrue(form.field("longPrimitive") instanceof TextField); + assertTrue(form.field("doublePrimitive") instanceof NumberField); + assertTrue(form.field("floatPrimitive") instanceof NumberField); + assertTrue(form.field("booleanPrimitive") instanceof Checkbox); + } + + @Test + public void primitivePropertiesRoundTrip() { + EasyForm form = new EasyForm<>(AllTypes.class); + form.readBean(new AllTypes()); + ((IntegerField) form.field("intPrimitive", Integer.class)).setValue(7); + ((TextField) form.field("longPrimitive", String.class)).setValue("8"); + ((NumberField) form.field("doublePrimitive", Double.class)).setValue(9.5); + ((Checkbox) form.field("booleanPrimitive", Boolean.class)).setValue(true); + Optional result = form.getValidBean(); + assertTrue(result.isPresent()); + assertEquals(7, result.get().getIntPrimitive()); + assertEquals(8L, result.get().getLongPrimitive()); + assertEquals(9.5, result.get().getDoublePrimitive(), 0d); + assertTrue(result.get().isBooleanPrimitive()); + } + + @Test + public void clearingAPrimitiveFieldWritesTheDefaultInsteadOfFailing() { + EasyForm form = new EasyForm<>(AllTypes.class); + AllTypes bean = new AllTypes(); + bean.setIntPrimitive(7); + bean.setDoublePrimitive(1.5); + bean.setBooleanPrimitive(true); + form.readBean(bean); + form.field("intPrimitive", Integer.class).clear(); + form.field("doublePrimitive", Double.class).clear(); + form.field("booleanPrimitive", Boolean.class).clear(); + Optional result = form.getValidBean(); + assertTrue(result.isPresent()); + assertEquals(0, result.get().getIntPrimitive()); + assertEquals(0d, result.get().getDoublePrimitive(), 0d); + assertFalse(result.get().isBooleanPrimitive()); + } + + @Test + public void clearingAPrimitiveFieldWritesTheDefaultInWriteThroughMode() { + EasyForm form = new EasyForm<>(AllTypes.class); + AllTypes bean = new AllTypes(); + bean.setIntPrimitive(7); + form.setBean(bean); + // in write-through mode the value reaches the property from the value change listener + form.field("intPrimitive", Integer.class).clear(); + assertEquals(0, bean.getIntPrimitive()); + } + + @Test + public void clearingAPrimitiveFieldWithACustomConverterWritesTheDefault() { + EasyForm form = new EasyForm<>(AllTypes.class); + form.configureField("intPrimitive").withComponent(new TextField()) + .withConverter(new StringToIntegerConverter("Not a number")); + AllTypes bean = new AllTypes(); + bean.setIntPrimitive(7); + form.readBean(bean); + form.field("intPrimitive", String.class).clear(); + Optional result = form.getValidBean(); + assertTrue(result.isPresent()); + assertEquals(0, result.get().getIntPrimitive()); + } + + @Test + public void floatValueIsPresentedAsTheShortestDecimalThatReadsBackTheSame() { + EasyForm form = new EasyForm<>(AllTypes.class); + AllTypes bean = new AllTypes(); + bean.setFloatValue(0.1f); + bean.setFloatPrimitive(0.1f); + form.readBean(bean); + // widening the float directly would present the binary value, 0.10000000149011612 + assertEquals(Double.valueOf(0.1), form.field("floatValue", Double.class).getValue()); + assertEquals(Double.valueOf(0.1), form.field("floatPrimitive", Double.class).getValue()); + Optional result = form.getValidBean(); + assertTrue(result.isPresent()); + assertEquals(0.1f, result.get().getFloatValue().floatValue(), 0f); + assertEquals(0.1f, result.get().getFloatPrimitive(), 0f); + } + + @Test + public void unmappedPrimitiveHasNoComponent() { + EasyForm form = new EasyForm<>(AllTypes.class); + assertNull(form.field("shortPrimitive")); + } + + @Test + public void factoryForAWrapperTypeReachesThePrimitiveProperty() { + EasyForm form = new EasyForm<>(AllTypes.class); + assertNull(form.field("shortPrimitive")); + Converter converter = + Converter.from(value -> Result.ok(value == null ? null : value.shortValue()), + value -> value == null ? null : value.intValue()); + form.setComponentFactory(Short.class, IntegerField::new, converter); + assertTrue(form.field("shortPrimitive") instanceof IntegerField); + form.readBean(new AllTypes()); + ((IntegerField) form.field("shortPrimitive", Integer.class)).setValue(5); + Optional result = form.getValidBean(); + assertTrue(result.isPresent()); + assertEquals((short) 5, result.get().getShortPrimitive()); + } + + // -- field discovery rules -- + + @Test + public void unsupportedPropertyTypeIsDiscoveredButHasNoComponent() { + EasyForm form = new EasyForm<>(AllTypes.class); + assertNull(form.field("locale")); + assertEquals("locale", form.configureField("locale").getPropertyName()); + } + + @Test + public void fieldsWithoutComponentAreExcludedFromTheLayout() { + EasyForm form = new EasyForm<>(AllTypes.class); + assertEquals(10, form.getFormLayout().getChildren().count()); + } + + @Test + public void readOnlyPropertyWithoutSetterIsNotDiscovered() { + EasyForm form = new EasyForm<>(AllTypes.class); + assertThrows(IllegalArgumentException.class, () -> form.configureField("readOnlyValue")); + } + + @Test + public void inheritedPropertiesAreOrderedBeforeDeclaredOnes() { + EasyForm form = new EasyForm<>(Child.class); + assertEquals(List.of("Zebra", "Alpha"), labelsOf(form)); + } + + // -- enum handling -- + + @Test + public void enumComboBoxIsPopulatedWithTheEnumConstants() { + EasyForm form = new EasyForm<>(Person.class); + @SuppressWarnings("unchecked") + ComboBox gender = + (ComboBox) form.field("gender", Gender.class); + assertEquals(List.of(Gender.values()), + gender.getListDataView().getItems().collect(Collectors.toList())); + } + + @Test + public void enumValueRoundTrips() { + EasyForm form = new EasyForm<>(Person.class); + Person person = new Person(); + person.setFirstName("John"); + person.setLastName("Doe"); + person.setGender(Gender.FEMALE); + form.readBean(person); + @SuppressWarnings("unchecked") + ComboBox gender = + (ComboBox) form.field("gender", Gender.class); + assertEquals(Gender.FEMALE, gender.getValue()); + gender.setValue(Gender.OTHER); + Optional result = form.getValidBean(); + assertTrue(result.isPresent()); + assertEquals(Gender.OTHER, result.get().getGender()); + } + + @Test + public void componentFactoryForEnumTypeOverridesTheComboBoxFallback() { + EasyForm form = new EasyForm<>(Person.class); + ComboBox custom = new ComboBox<>(); + form.setComponentFactory(Gender.class, () -> custom); + assertSame(custom, form.field("gender")); + } + + /** + * Superclass whose property sorts alphabetically after the subclass property, so that the + * expected order can only be produced by the declaration-order walk and not by the name + * tie-breaker. + */ + @Getter + @Setter + public static class Parent { + + private String zebra; + } + + /** Subclass used to verify that inherited properties keep their declaration order. */ + @Getter + @Setter + public static class Child extends Parent { + + private String alpha; + } +} diff --git a/src/test/java/com/flowingcode/vaadin/addons/easyform/test/SerializationTest.java b/src/test/java/com/flowingcode/vaadin/addons/easyform/test/SerializationTest.java index b342a90..e7c3065 100644 --- a/src/test/java/com/flowingcode/vaadin/addons/easyform/test/SerializationTest.java +++ b/src/test/java/com/flowingcode/vaadin/addons/easyform/test/SerializationTest.java @@ -2,7 +2,7 @@ * #%L * Easy Form Add-on * %% - * Copyright (C) 2023 Flowing Code + * Copyright (C) 2026 Flowing Code * %% * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -19,7 +19,10 @@ */ package com.flowingcode.vaadin.addons.easyform.test; -import com.flowingcode.vaadin.addons.easyform.EasyFormAddon; +import com.flowingcode.vaadin.addons.easyform.EasyForm; +import com.flowingcode.vaadin.addons.easyform.Person; +import com.vaadin.flow.component.textfield.TextArea; +import com.vaadin.flow.data.validator.EmailValidator; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.IOException; @@ -44,7 +47,19 @@ private void testSerializationOf(Object obj) throws IOException, ClassNotFoundEx @Test public void testSerialization() throws ClassNotFoundException, IOException { try { - testSerializationOf(new EasyFormAddon()); + EasyForm form = new EasyForm<>(Person.class); + form.setFieldOrder("firstName", "lastName", "email", "notes"); + form.configureField("email", String.class) + .withLabel("Email Address") + .asRequired("Email is required") + .withValidator(new EmailValidator("Invalid email")); + form.configureField("notes").withComponent(new TextArea()).withColSpan(2); + form.setLabelGenerator(name -> name.toUpperCase()); + form.setI18n(new EasyForm.EasyFormI18n().setSave("Guardar").setCancel("Cancelar")); + form.setSaveAction(person -> {}); + form.setCancelAction(() -> {}); + form.setBean(new Person()); + testSerializationOf(form); } catch (Exception e) { Assert.fail("Problem while testing serialization: " + e.getMessage()); }