From 187840146444358b0b3b5d6d6135794d3a4178c0 Mon Sep 17 00:00:00 2001 From: Pit Friedrich Date: Sat, 8 Aug 2026 16:44:15 +0200 Subject: [PATCH] Added editable Grid feature --- .../example/base/ui/InlineEditSupport.java | 217 ++++++++++++++++++ .../java/com/example/customer/Customer.java | 82 ++++++- .../com/example/customer/CustomerService.java | 65 ++++-- .../example/customer/ui/CustomerListView.java | 71 +++++- .../META-INF/resources/inline-edit-grid.css | 17 ++ .../base/ui/InlineEditSupportTest.java | 124 ++++++++++ .../base/ui/InlineEditSupportTestView.java | 86 +++++++ .../customer/ui/CustomerListViewTest.java | 45 ++++ 8 files changed, 674 insertions(+), 33 deletions(-) create mode 100644 src/main/java/com/example/base/ui/InlineEditSupport.java create mode 100644 src/main/resources/META-INF/resources/inline-edit-grid.css create mode 100644 src/test/java/com/example/base/ui/InlineEditSupportTest.java create mode 100644 src/test/java/com/example/base/ui/InlineEditSupportTestView.java diff --git a/src/main/java/com/example/base/ui/InlineEditSupport.java b/src/main/java/com/example/base/ui/InlineEditSupport.java new file mode 100644 index 0000000..d1d29ac --- /dev/null +++ b/src/main/java/com/example/base/ui/InlineEditSupport.java @@ -0,0 +1,217 @@ +package com.example.base.ui; + +import com.vaadin.flow.component.Component; +import com.vaadin.flow.component.Focusable; +import com.vaadin.flow.component.Key; +import com.vaadin.flow.component.KeyModifier; +import com.vaadin.flow.component.Shortcuts; +import com.vaadin.flow.component.grid.Grid; +import com.vaadin.flow.component.grid.dataview.GridListDataView; +import com.vaadin.flow.component.grid.editor.Editor; +import com.vaadin.flow.data.binder.Binder; +import com.vaadin.flow.function.SerializableConsumer; +import org.jspecify.annotations.Nullable; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +/** + * Adds Grid Pro-style inline editing — full keyboard control included — to a plain {@link Grid}. + *

+ * This project depends on {@code vaadin-core}, and Grid Pro ({@code vaadin-grid-pro-flow}) is a + * commercial add-on only shipped in the full {@code vaadin} artifact, so it isn't available here. + * This class rebuilds the parts of its behavior that matter — double-click/Enter/F2/Space to + * start editing, Esc to discard, Enter to save and close, and Tab/Shift+Tab to save and hop to + * the next/previous editable cell, wrapping across row boundaries — on top of {@link Grid}'s own + * free {@code Editor} API ({@link Grid#getEditor()}, {@link Binder}, and + * {@link Grid.Column#setEditorComponent(Component)}). + *

+ * Usage: build a {@link Binder} for the row type, bind each editable field to it as usual, then + * register the column/field pairs here: + *

{@code
+ * var binder = new Binder<>(Customer.class);
+ * binder.forField(nameField).asRequired().bind(Customer::getName, Customer::setName);
+ *
+ * InlineEditSupport.of(grid, binder)
+ *         .editable(nameColumn, nameField)
+ *         .onSave(customerService::update);
+ * }
+ * Columns that are never passed to {@link #editable(Grid.Column, Component)} stay read-only and + * are skipped when tabbing between cells. + *

+ * Limitation: because the keyboard handling here runs server-side rather than in Grid Pro's + * client-side code, an editor field whose own overlay also reacts to Enter (e.g. {@code Select}, + * {@code DatePicker}) will both commit its own value and close the row editor on the + * same keypress. + *

+ * Row-to-row Tab hopping needs an in-memory {@code GridListDataView}; on a lazy-loaded grid, + * Tab/Shift+Tab from the first/last editable cell of a row simply close the editor instead of + * moving to the next/previous row. + * + * @param + * the row item type + */ +public class InlineEditSupport { + + private final Grid grid; + private final Editor editor; + private final List> editableColumns = new ArrayList<>(); + + private @Nullable T focusedItem; + private Grid.@Nullable Column focusedColumn; + private Grid.@Nullable Column pendingFocusColumn; + private Grid.@Nullable Column editingColumn; + + private SerializableConsumer onSave = item -> { + }; + + private InlineEditSupport(Grid grid, Binder binder) { + this.grid = grid; + this.editor = grid.getEditor(); + editor.setBinder(binder); + editor.setBuffered(true); + + grid.addClassName("inline-edit-grid"); + + grid.addCellFocusListener(event -> { + if (event.isBodyCell()) { + focusedItem = event.getItem().orElse(null); + focusedColumn = event.getColumn().orElse(null); + } + }); + + // event.getColumn() is null when the multi-selection column (or, in tests, a + // synthetic double-click with no column) is clicked; editCell falls back to the + // first editable column in that case. + grid.addItemDoubleClickListener(event -> editCell(event.getItem(), event.getColumn())); + + editor.addOpenListener(event -> { + var column = pendingFocusColumn; + pendingFocusColumn = null; + if (column != null && column.getEditorComponent() instanceof Focusable focusable) { + focusable.focus(); + } + }); + + Shortcuts.addShortcutListener(grid, this::editFocusedCell, Key.ENTER).listenOn(grid); + Shortcuts.addShortcutListener(grid, this::editFocusedCell, Key.F2).listenOn(grid); + Shortcuts.addShortcutListener(grid, this::editFocusedCell, Key.SPACE).listenOn(grid) + .allowBrowserDefault(); + } + + public static InlineEditSupport of(Grid grid, Binder binder) { + return new InlineEditSupport<>(grid, binder); + } + + /** Registers {@code editorField} as the editor for {@code column} and enables editing on it. */ + public InlineEditSupport editable(Grid.Column column, Component editorField) { + column.setEditorComponent(editorField); + editableColumns.add(column); + + Shortcuts.addShortcutListener(editorField, this::saveAndClose, Key.ENTER).listenOn(editorField); + Shortcuts.addShortcutListener(editorField, this::cancelEdit, Key.ESCAPE).listenOn(editorField); + Shortcuts.addShortcutListener(editorField, this::editNextCell, Key.TAB).listenOn(editorField); + Shortcuts.addShortcutListener(editorField, this::editPreviousCell, Key.TAB, KeyModifier.SHIFT) + .listenOn(editorField); + + return this; + } + + /** Called with the edited item once a save (from any of the actions below) succeeds. */ + public InlineEditSupport onSave(SerializableConsumer onSave) { + this.onSave = onSave; + return this; + } + + /** Opens the editor on {@code item}/{@code column}; {@code column} defaults to the first editable one. */ + public void editCell(T item, Grid.@Nullable Column column) { + if (editor.isOpen()) { + editor.save(); + } + var target = column != null && editableColumns.contains(column) ? column + : editableColumns.isEmpty() ? null : editableColumns.getFirst(); + if (target == null) { + return; + } + pendingFocusColumn = target; + editingColumn = target; + grid.scrollToItem(item); + editor.editItem(item); + } + + /** Saves and closes the editor. Returns {@code false} (and leaves it open) if validation fails. */ + public boolean saveAndClose() { + var item = editor.getItem(); + if (editor.save()) { + onSave.accept(item); + grid.getDataProvider().refreshItem(item); + grid.focus(); + return true; + } + return false; + } + + /** Discards any changes and closes the editor without saving. */ + public void cancelEdit() { + if (editor.isOpen()) { + editor.cancel(); + grid.focus(); + } + } + + /** Saves the current cell and opens the next editable cell, hopping to the next row at the end. */ + public void editNextCell() { + moveTo(1); + } + + /** Saves the current cell and opens the previous editable cell, hopping to the previous row at the start. */ + public void editPreviousCell() { + moveTo(-1); + } + + private void editFocusedCell() { + if (editor.isOpen() || focusedItem == null) { + return; + } + editCell(focusedItem, focusedColumn); + } + + private void moveTo(int direction) { + if (!editor.isOpen() || editableColumns.isEmpty()) { + return; + } + var item = editor.getItem(); + var column = editingColumn != null ? editingColumn : editableColumns.getFirst(); + if (!editor.save()) { + return; + } + onSave.accept(item); + grid.getDataProvider().refreshItem(item); + + var index = editableColumns.indexOf(column) + direction; + if (index >= 0 && index < editableColumns.size()) { + editCell(item, editableColumns.get(index)); + return; + } + + var nextItem = direction > 0 ? listDataView().flatMap(dv -> dv.getNextItem(item)) + : listDataView().flatMap(dv -> dv.getPreviousItem(item)); + if (nextItem.isPresent()) { + var edge = direction > 0 ? editableColumns.getFirst() : editableColumns.getLast(); + editCell(nextItem.get(), edge); + } else { + editor.closeEditor(); + grid.focus(); + } + } + + private Optional> listDataView() { + try { + return Optional.of(grid.getListDataView()); + } catch (IllegalStateException e) { + // The grid uses a lazy-loading data provider; row-to-row hopping isn't supported. + return Optional.empty(); + } + } +} diff --git a/src/main/java/com/example/customer/Customer.java b/src/main/java/com/example/customer/Customer.java index 9e6e041..2cf7833 100644 --- a/src/main/java/com/example/customer/Customer.java +++ b/src/main/java/com/example/customer/Customer.java @@ -3,8 +3,84 @@ package com.example.customer; import java.time.LocalDate; /** - * A customer record. This is a plain, immutable value type backed by - * {@link CustomerService}'s in-memory dummy data, not a JPA entity. + * A customer record. This is a mutable value type backed by {@link CustomerService}'s in-memory + * dummy data, not a JPA entity. Validation lives in the setters (throwing + * {@link IllegalArgumentException}), following the same convention as + * {@code com.example.examplefeature.Task}. + *

+ * Unlike {@code Task}, {@code equals}/{@code hashCode} are intentionally left at their default, + * identity-based implementation: there is no id to key on, so identity equality is exactly what + * makes {@link java.util.List#contains} and the grid's {@code DataProvider#refreshItem} work + * correctly against these in-memory instances. */ -public record Customer(String name, String email, String company, String status, LocalDate customerSince) { +public class Customer { + + private String name; + private String email; + private String company; + private String status; + private LocalDate customerSince; + + public Customer(String name, String email, String company, String status, LocalDate customerSince) { + setName(name); + setEmail(email); + setCompany(company); + setStatus(status); + setCustomerSince(customerSince); + } + + public String getName() { + return name; + } + + public void setName(String name) { + if (name == null || name.isBlank()) { + throw new IllegalArgumentException("Name must not be blank"); + } + this.name = name; + } + + public String getEmail() { + return email; + } + + public void setEmail(String email) { + if (email == null || email.isBlank()) { + throw new IllegalArgumentException("Email must not be blank"); + } + this.email = email; + } + + public String getCompany() { + return company; + } + + public void setCompany(String company) { + if (company == null || company.isBlank()) { + throw new IllegalArgumentException("Company must not be blank"); + } + this.company = company; + } + + public String getStatus() { + return status; + } + + public void setStatus(String status) { + if (status == null || status.isBlank()) { + throw new IllegalArgumentException("Status must not be blank"); + } + this.status = status; + } + + public LocalDate getCustomerSince() { + return customerSince; + } + + public void setCustomerSince(LocalDate customerSince) { + if (customerSince == null) { + throw new IllegalArgumentException("Customer since must not be null"); + } + this.customerSince = customerSince; + } } diff --git a/src/main/java/com/example/customer/CustomerService.java b/src/main/java/com/example/customer/CustomerService.java index dd57706..a06b426 100644 --- a/src/main/java/com/example/customer/CustomerService.java +++ b/src/main/java/com/example/customer/CustomerService.java @@ -3,6 +3,8 @@ package com.example.customer; import org.springframework.stereotype.Service; import java.time.LocalDate; +import java.util.ArrayList; +import java.util.Collections; import java.util.List; @Service @@ -10,30 +12,47 @@ public class CustomerService { // TODO Replace with a real data source (e.g. a JPA-backed CustomerRepository) once // customers are no longer dummy data. - private static final List DUMMY_CUSTOMERS = List.of( - new Customer("Alice Johnson", "alice.johnson@example.com", "Acme Corp", "Active", - LocalDate.of(2019, 3, 12)), - new Customer("Bob Smith", "bob.smith@example.com", "Globex Inc", "Active", LocalDate.of(2020, 7, 1)), - new Customer("Carla Diaz", "carla.diaz@example.com", "Initech", "Inactive", LocalDate.of(2018, 11, 23)), - new Customer("David Chen", "david.chen@example.com", "Umbrella Corp", "Prospect", - LocalDate.of(2024, 1, 15)), - new Customer("Elena Petrova", "elena.petrova@example.com", "Hooli", "Active", LocalDate.of(2021, 5, 30)), - new Customer("Frank Müller", "frank.mueller@example.com", "Soylent Corp", "Inactive", - LocalDate.of(2017, 9, 4)), - new Customer("Grace Kim", "grace.kim@example.com", "Stark Industries", "Active", - LocalDate.of(2022, 2, 18)), - new Customer("Hassan Ali", "hassan.ali@example.com", "Wayne Enterprises", "Prospect", - LocalDate.of(2025, 4, 9)), - new Customer("Ingrid Nilsson", "ingrid.nilsson@example.com", "Wonka Industries", "Active", - LocalDate.of(2016, 6, 21)), - new Customer("Julien Moreau", "julien.moreau@example.com", "Cyberdyne Systems", "Inactive", - LocalDate.of(2023, 10, 8)), - new Customer("Katrin Bauer", "katrin.bauer@example.com", "Massive Dynamic", "Active", - LocalDate.of(2020, 12, 3)), - new Customer("Liam O'Connor", "liam.oconnor@example.com", "Aperture Science", "Prospect", - LocalDate.of(2026, 1, 27))); + private final List customers = new ArrayList<>(seed()); + + private static List seed() { + return List.of( + new Customer("Alice Johnson", "alice.johnson@example.com", "Acme Corp", "Active", + LocalDate.of(2019, 3, 12)), + new Customer("Bob Smith", "bob.smith@example.com", "Globex Inc", "Active", LocalDate.of(2020, 7, 1)), + new Customer("Carla Diaz", "carla.diaz@example.com", "Initech", "Inactive", + LocalDate.of(2018, 11, 23)), + new Customer("David Chen", "david.chen@example.com", "Umbrella Corp", "Prospect", + LocalDate.of(2024, 1, 15)), + new Customer("Elena Petrova", "elena.petrova@example.com", "Hooli", "Active", + LocalDate.of(2021, 5, 30)), + new Customer("Frank Müller", "frank.mueller@example.com", "Soylent Corp", "Inactive", + LocalDate.of(2017, 9, 4)), + new Customer("Grace Kim", "grace.kim@example.com", "Stark Industries", "Active", + LocalDate.of(2022, 2, 18)), + new Customer("Hassan Ali", "hassan.ali@example.com", "Wayne Enterprises", "Prospect", + LocalDate.of(2025, 4, 9)), + new Customer("Ingrid Nilsson", "ingrid.nilsson@example.com", "Wonka Industries", "Active", + LocalDate.of(2016, 6, 21)), + new Customer("Julien Moreau", "julien.moreau@example.com", "Cyberdyne Systems", "Inactive", + LocalDate.of(2023, 10, 8)), + new Customer("Katrin Bauer", "katrin.bauer@example.com", "Massive Dynamic", "Active", + LocalDate.of(2020, 12, 3)), + new Customer("Liam O'Connor", "liam.oconnor@example.com", "Aperture Science", "Prospect", + LocalDate.of(2026, 1, 27))); + } public List list() { - return DUMMY_CUSTOMERS; + return Collections.unmodifiableList(customers); + } + + /** + * Persists changes to an already-tracked customer. While the store is in-memory the grid + * edits the very instance held here, so there is nothing to write — this is the seam a + * future {@code CustomerRepository} slots into (see the TODO above). + */ + public void update(Customer customer) { + if (!customers.contains(customer)) { + throw new IllegalArgumentException("Unknown customer"); + } } } diff --git a/src/main/java/com/example/customer/ui/CustomerListView.java b/src/main/java/com/example/customer/ui/CustomerListView.java index a5e07cd..159a6c9 100644 --- a/src/main/java/com/example/customer/ui/CustomerListView.java +++ b/src/main/java/com/example/customer/ui/CustomerListView.java @@ -1,10 +1,19 @@ package com.example.customer.ui; +import com.example.base.ui.InlineEditSupport; import com.example.base.ui.ViewTitle; import com.example.customer.Customer; import com.example.customer.CustomerService; +import com.vaadin.flow.component.dependency.StyleSheet; +import com.vaadin.flow.component.datepicker.DatePicker; import com.vaadin.flow.component.grid.Grid; +import com.vaadin.flow.component.html.Span; import com.vaadin.flow.component.orderedlayout.VerticalLayout; +import com.vaadin.flow.component.select.Select; +import com.vaadin.flow.component.textfield.EmailField; +import com.vaadin.flow.component.textfield.TextField; +import com.vaadin.flow.data.binder.Binder; +import com.vaadin.flow.data.validator.EmailValidator; import com.vaadin.flow.router.Menu; import com.vaadin.flow.router.PageTitle; import com.vaadin.flow.router.Route; @@ -12,31 +21,79 @@ import jakarta.annotation.security.PermitAll; import java.time.format.DateTimeFormatter; import java.time.format.FormatStyle; +import java.util.List; @Route(value = "customers") @PageTitle("Customers") @Menu(order = 1, icon = "vaadin:users", title = "Customers") @PermitAll +@StyleSheet("inline-edit-grid.css") class CustomerListView extends VerticalLayout { + private static final List STATUS_OPTIONS = List.of("Active", "Inactive", "Prospect"); + final Grid customerGrid; + // Package-private (rather than private) so CustomerListViewTest can drive the editors directly. + final TextField nameEditor; + final EmailField emailEditor; + final Select statusEditor; + final DatePicker sinceEditor; + + final Grid.Column companyColumn; + CustomerListView(CustomerService customerService) { var dateFormatter = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(getLocale()); customerGrid = new Grid<>(); customerGrid.setItems(customerService.list()); - customerGrid.addColumn(Customer::name).setHeader("Name").setSortable(true); - customerGrid.addColumn(Customer::email).setHeader("Email").setSortable(true); - customerGrid.addColumn(Customer::company).setHeader("Company").setSortable(true); - customerGrid.addColumn(Customer::status).setHeader("Status").setSortable(true); - customerGrid.addColumn(customer -> dateFormatter.format(customer.customerSince())) + var nameColumn = customerGrid.addColumn(Customer::getName).setHeader("Name").setSortable(true) + .setKey("name"); + var emailColumn = customerGrid.addColumn(Customer::getEmail).setHeader("Email").setSortable(true) + .setKey("email"); + companyColumn = customerGrid.addColumn(Customer::getCompany).setHeader("Company").setSortable(true) + .setKey("company"); + var statusColumn = customerGrid.addColumn(Customer::getStatus).setHeader("Status").setSortable(true) + .setKey("status"); + var sinceColumn = customerGrid + .addColumn(customer -> dateFormatter.format(customer.getCustomerSince())) .setHeader("Customer Since") - .setSortable(true); + .setSortable(true) + .setKey("customerSince"); customerGrid.setEmptyStateText("No customers found"); customerGrid.setSizeFull(); + nameEditor = new TextField(); + nameEditor.setWidthFull(); + emailEditor = new EmailField(); + emailEditor.setWidthFull(); + statusEditor = new Select<>(); + statusEditor.setItems(STATUS_OPTIONS); + statusEditor.setWidthFull(); + sinceEditor = new DatePicker(); + sinceEditor.setWidthFull(); + + var binder = new Binder<>(Customer.class); + binder.forField(nameEditor).asRequired("Name must not be blank").bind(Customer::getName, Customer::setName); + binder.forField(emailEditor).asRequired("Email must not be blank") + .withValidator(new EmailValidator("Enter a valid email address")) + .bind(Customer::getEmail, Customer::setEmail); + binder.forField(statusEditor).asRequired("Status is required").bind(Customer::getStatus, + Customer::setStatus); + binder.forField(sinceEditor).asRequired("Customer since is required").bind(Customer::getCustomerSince, + Customer::setCustomerSince); + + InlineEditSupport.of(customerGrid, binder) + .editable(nameColumn, nameEditor) + .editable(emailColumn, emailEditor) + .editable(statusColumn, statusEditor) + .editable(sinceColumn, sinceEditor) + .onSave(customerService::update); + + var hint = new Span("Double-click a cell or press Enter to edit. Tab moves to the next field, Esc discards."); + hint.addClassName("inline-edit-hint"); + setSizeFull(); - add(new ViewTitle("Customers"), customerGrid); + add(new ViewTitle("Customers"), hint, customerGrid); } } diff --git a/src/main/resources/META-INF/resources/inline-edit-grid.css b/src/main/resources/META-INF/resources/inline-edit-grid.css new file mode 100644 index 0000000..b12354f --- /dev/null +++ b/src/main/resources/META-INF/resources/inline-edit-grid.css @@ -0,0 +1,17 @@ +/* Styling for com.example.base.ui.InlineEditSupport-powered grids: makes it visually obvious + * which cell is under keyboard focus (essential once editing is keyboard-driven) and which cells + * are editable in the first place. Only unsuffixed Aura/Vaadin tokens are used, so this follows + * whichever Solstice color scheme is active - see solstice.css. */ + +.inline-edit-grid::part(focused-cell) { + box-shadow: inset 0 0 0 2px var(--aura-accent-color); +} + +.inline-edit-grid vaadin-grid-cell-content:hover { + background: color-mix(in oklch, var(--aura-accent-color) 8%, transparent); +} + +.inline-edit-hint { + color: var(--vaadin-text-color-secondary); + font-size: var(--aura-font-size-s); +} diff --git a/src/test/java/com/example/base/ui/InlineEditSupportTest.java b/src/test/java/com/example/base/ui/InlineEditSupportTest.java new file mode 100644 index 0000000..8914e51 --- /dev/null +++ b/src/test/java/com/example/base/ui/InlineEditSupportTest.java @@ -0,0 +1,124 @@ +package com.example.base.ui; + +import com.vaadin.browserless.SpringBrowserlessTest; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.transaction.annotation.Transactional; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.MOCK) +@Transactional +@WithMockUser(roles = "USER") +class InlineEditSupportTest extends SpringBrowserlessTest { + + @Test + void double_click_opens_the_editor_on_the_clicked_row() { + var view = navigate(InlineEditSupportTestView.class); + + // Grid.kt's _doubleClickItem fires the event with a null column, exercising the + // "fall back to the first editable column" branch of editCell. + test(view.grid).doubleClickRow(0); + roundTrip(); + + assertThat(view.grid.getEditor().isOpen()).isTrue(); + assertThat(view.nameEditor.getValue()).isEqualTo("Row0 Name"); + } + + @Test + void enter_saves_and_closes_and_invokes_the_on_save_callback() { + var view = navigate(InlineEditSupportTestView.class); + view.support.editCell(view.items.get(0), null); + roundTrip(); + + test(view.nameEditor).setValue("Updated Name"); + var saved = view.support.saveAndClose(); + + assertThat(saved).isTrue(); + assertThat(view.grid.getEditor().isOpen()).isFalse(); + assertThat(view.items.get(0).name()).isEqualTo("Updated Name"); + assertThat(view.saveCount).isEqualTo(1); + } + + @Test + void escape_discards_the_change() { + var view = navigate(InlineEditSupportTestView.class); + view.support.editCell(view.items.get(0), null); + roundTrip(); + + test(view.nameEditor).setValue("Should not stick"); + view.support.cancelEdit(); + + assertThat(view.grid.getEditor().isOpen()).isFalse(); + assertThat(view.items.get(0).name()).isEqualTo("Row0 Name"); + assertThat(view.saveCount).isZero(); + } + + @Test + void invalid_input_keeps_the_editor_open() { + var view = navigate(InlineEditSupportTestView.class); + view.support.editCell(view.items.get(0), null); + roundTrip(); + + test(view.nameEditor).setValue(""); + var saved = view.support.saveAndClose(); + + assertThat(saved).isFalse(); + assertThat(view.grid.getEditor().isOpen()).isTrue(); + assertThat(view.items.get(0).name()).isEqualTo("Row0 Name"); + } + + @Test + void tab_skips_the_non_editable_column() { + var view = navigate(InlineEditSupportTestView.class); + view.support.editCell(view.items.get(0), view.grid.getColumnByKey("name")); + roundTrip(); + + view.support.editNextCell(); + roundTrip(); + + assertThat(view.grid.getEditor().isOpen()).isTrue(); + assertThat(view.grid.getEditor().getItem()).isEqualTo(view.items.get(0)); + assertThat(view.valueEditor.getValue()).isEqualTo("Row0 Value"); + } + + @Test + void tab_from_the_last_editable_cell_opens_the_next_row() { + var view = navigate(InlineEditSupportTestView.class); + view.support.editCell(view.items.get(0), view.grid.getColumnByKey("value")); + roundTrip(); + + view.support.editNextCell(); + roundTrip(); + + assertThat(view.grid.getEditor().isOpen()).isTrue(); + assertThat(view.grid.getEditor().getItem()).isEqualTo(view.items.get(1)); + assertThat(view.nameEditor.getValue()).isEqualTo("Row1 Name"); + } + + @Test + void shift_tab_from_the_first_editable_cell_opens_the_previous_row() { + var view = navigate(InlineEditSupportTestView.class); + view.support.editCell(view.items.get(1), view.grid.getColumnByKey("name")); + roundTrip(); + + view.support.editPreviousCell(); + roundTrip(); + + assertThat(view.grid.getEditor().isOpen()).isTrue(); + assertThat(view.grid.getEditor().getItem()).isEqualTo(view.items.get(0)); + assertThat(view.valueEditor.getValue()).isEqualTo("Row0 Value"); + } + + @Test + void tab_on_the_last_row_closes_the_editor() { + var view = navigate(InlineEditSupportTestView.class); + view.support.editCell(view.items.get(1), view.grid.getColumnByKey("value")); + roundTrip(); + + view.support.editNextCell(); + + assertThat(view.grid.getEditor().isOpen()).isFalse(); + } +} diff --git a/src/test/java/com/example/base/ui/InlineEditSupportTestView.java b/src/test/java/com/example/base/ui/InlineEditSupportTestView.java new file mode 100644 index 0000000..a7a8dc8 --- /dev/null +++ b/src/test/java/com/example/base/ui/InlineEditSupportTestView.java @@ -0,0 +1,86 @@ +package com.example.base.ui; + +import com.vaadin.flow.component.grid.Grid; +import com.vaadin.flow.component.html.Div; +import com.vaadin.flow.component.textfield.TextField; +import com.vaadin.flow.data.binder.Binder; +import com.vaadin.flow.router.PageTitle; +import com.vaadin.flow.router.Route; +import jakarta.annotation.security.PermitAll; + +import java.util.ArrayList; +import java.util.List; + +/** Test-only host route so InlineEditSupportTest can exercise the component inside an attached UI. */ +@Route("test/inline-edit-grid") +@PageTitle("InlineEditSupport test host") +@PermitAll +class InlineEditSupportTestView extends Div { + + final List items; + final Grid grid; + final TextField nameEditor; + final TextField valueEditor; + final InlineEditSupport support; + + int saveCount = 0; + + InlineEditSupportTestView() { + items = new ArrayList<>( + List.of(new Item("Row0 Name", "mid0", "Row0 Value"), new Item("Row1 Name", "mid1", "Row1 Value"))); + + grid = new Grid<>(); + grid.setItems(items); + var nameColumn = grid.addColumn(Item::name).setHeader("Name").setKey("name"); + // Deliberately not made editable, so the tests can assert Tab skips it. + grid.addColumn(Item::middle).setHeader("Middle").setKey("middle"); + var valueColumn = grid.addColumn(Item::value).setHeader("Value").setKey("value"); + + nameEditor = new TextField(); + valueEditor = new TextField(); + + var binder = new Binder<>(Item.class); + binder.forField(nameEditor).asRequired("Name must not be blank").bind(Item::name, Item::setName); + binder.forField(valueEditor).asRequired("Value must not be blank").bind(Item::value, Item::setValue); + + support = InlineEditSupport.of(grid, binder) + .editable(nameColumn, nameEditor) + .editable(valueColumn, valueEditor) + .onSave(item -> saveCount++); + + add(grid); + } + + /** Small mutable row bean, deliberately separate from {@code com.example.customer.Customer}. */ + static class Item { + private String name; + private final String middle; + private String value; + + Item(String name, String middle, String value) { + this.name = name; + this.middle = middle; + this.value = value; + } + + String name() { + return name; + } + + void setName(String name) { + this.name = name; + } + + String middle() { + return middle; + } + + String value() { + return value; + } + + void setValue(String value) { + this.value = value; + } + } +} diff --git a/src/test/java/com/example/customer/ui/CustomerListViewTest.java b/src/test/java/com/example/customer/ui/CustomerListViewTest.java index 9dd1ec7..65e3f6a 100644 --- a/src/test/java/com/example/customer/ui/CustomerListViewTest.java +++ b/src/test/java/com/example/customer/ui/CustomerListViewTest.java @@ -4,10 +4,15 @@ import com.vaadin.browserless.SpringBrowserlessTest; import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.annotation.DirtiesContext; import static org.assertj.core.api.Assertions.assertThat; +// CustomerService now holds a mutable in-memory list (a singleton Spring bean), so edits made by +// one test would otherwise leak into the next; @DirtiesContext resets the whole context between +// test methods so each test sees the pristine dummy data again. @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.MOCK) +@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD) @WithMockUser(roles = "USER") class CustomerListViewTest extends SpringBrowserlessTest { @@ -28,4 +33,44 @@ class CustomerListViewTest extends SpringBrowserlessTest { assertThat(test(view.customerGrid).getCellText(0, 3)).isEqualTo("Active"); assertThat(test(view.customerGrid).getCellText(0, 4)).isNotEmpty(); } + + @Test + void company_column_is_not_editable() { + var view = navigate(CustomerListView.class); + + assertThat(view.companyColumn.getEditorComponent()).isNull(); + } + + @Test + void editing_a_name_updates_the_grid_cell() { + var view = navigate(CustomerListView.class); + + test(view.customerGrid).doubleClickRow(0); + roundTrip(); + test(view.nameEditor).setValue("Alice Doe"); + view.customerGrid.getEditor().save(); + + assertThat(test(view.customerGrid).getCellText(0, 0)).isEqualTo("Alice Doe"); + } + + @Test + void an_invalid_email_is_rejected() { + var view = navigate(CustomerListView.class); + + view.customerGrid.getEditor().editItem(view.customerGrid.getListDataView().getItem(0)); + roundTrip(); + test(view.emailEditor).setValue("not-an-email"); + var saved = view.customerGrid.getEditor().save(); + + assertThat(saved).isFalse(); + assertThat(view.customerGrid.getEditor().isOpen()).isTrue(); + assertThat(test(view.customerGrid).getCellText(0, 1)).isEqualTo("alice.johnson@example.com"); + } + + @Test + void status_editor_offers_the_three_known_values() { + var view = navigate(CustomerListView.class); + + assertThat(test(view.statusEditor).getSuggestions()).containsExactly("Active", "Inactive", "Prospect"); + } }