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");
+ }
}