Compare commits
9 Commits
5f8808d5b8
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 9ba37d5738 | |||
| 1878401464 | |||
| de6c1f36e2 | |||
| f6d95b9ff9 | |||
| 9b0fead79f | |||
| 813303cafa | |||
| cb07e287c4 | |||
| 4e98042b35 | |||
| 3e209dfcbc |
@@ -39,7 +39,9 @@ Port defaults to 8080 (override via `server.port` in `src/main/resources/applica
|
||||
|
||||
**Schema management**: `spring.jpa.hibernate.ddl-auto=update` is used for local dev convenience only. This is explicitly not appropriate for production — the app is meant to move to Flyway (or similar) for real schema migrations before shipping.
|
||||
|
||||
**Frontend theming**: `Application` sets the app-shell config (`@Push`, `Aura` theme via `@StyleSheet(Aura.STYLESHEET)`, plus custom `styles.css`/`view-title.css` under `src/main/resources/META-INF/resources/`). No separate frontend build step is needed for typical UI work since Flow generates the client bundle; the `vaadin-maven-plugin` `build-frontend` goal runs as part of the Maven build.
|
||||
**Frontend theming**: `Application` sets the app-shell config (`@Push`, `Aura` theme via `@StyleSheet(Aura.STYLESHEET)`, plus custom `styles.css`/`view-title.css`/`solstice.css` under `src/main/resources/META-INF/resources/`). No separate frontend build step is needed for typical UI work since Flow generates the client bundle; the `vaadin-maven-plugin` `build-frontend` goal runs as part of the Maven build.
|
||||
|
||||
**Custom theme ("Solstice") and dark/light mode**: `solstice.css` overrides Aura's `-light`/`-dark` suffixed design tokens (e.g. `--aura-accent-color-light/-dark`, `--aura-background-color-light/-dark`) rather than the unsuffixed ones — those are read-only, computed via the native CSS `light-dark()` function from the pair. Everything else should keep consuming the unsuffixed tokens (`--aura-accent-color`, `--vaadin-text-color`, ...) so it follows whichever scheme is active automatically. Switching schemes at runtime uses the Vaadin 25 `ColorScheme` API (`Application` declares `@ColorScheme(ColorScheme.Value.SYSTEM)` as the default; `Page#setColorScheme(...)` changes it at runtime) — do not use the older Lumo-only `setAttribute("theme", ...)` pattern. The feature lives in `com.example.base.ui`: `ThemePreference` (session-scoped) holds the current choice and applies it; `ThemeToggle` is the header button; `ThemeInitializer` restores the choice from a cookie (`ThemeCookie`) on every request/UI-init, both for a flash-free reload and to seed a fresh session, since a plain `@ColorScheme` annotation can't read per-visitor state on its own.
|
||||
|
||||
## Testing
|
||||
|
||||
|
||||
@@ -6,11 +6,16 @@ import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
|
||||
import com.vaadin.flow.component.dependency.StyleSheet;
|
||||
import com.vaadin.flow.component.page.AppShellConfigurator;
|
||||
import com.vaadin.flow.component.page.ColorScheme;
|
||||
import com.vaadin.flow.component.page.Push;
|
||||
|
||||
@SpringBootApplication
|
||||
@StyleSheet(Aura.STYLESHEET)
|
||||
@StyleSheet("styles.css") // Your custom styles
|
||||
@StyleSheet("solstice.css") // Custom Solstice theme: amber daylight / aurora midnight
|
||||
// Default for first-ever visits, before a preference cookie exists (see ThemeInitializer):
|
||||
// follow the OS light/dark setting. com.example.base.ui.ThemeToggle lets a user override it.
|
||||
@ColorScheme(ColorScheme.Value.SYSTEM)
|
||||
@Push
|
||||
public class Application implements AppShellConfigurator {
|
||||
|
||||
|
||||
@@ -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}.
|
||||
* <p>
|
||||
* 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)}).
|
||||
* <p>
|
||||
* Usage: build a {@link Binder} for the row type, bind each editable field to it as usual, then
|
||||
* register the column/field pairs here:
|
||||
* <pre>{@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);
|
||||
* }</pre>
|
||||
* Columns that are never passed to {@link #editable(Grid.Column, Component)} stay read-only and
|
||||
* are skipped when tabbing between cells.
|
||||
* <p>
|
||||
* 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 <em>and</em> close the row editor on the
|
||||
* same keypress.
|
||||
* <p>
|
||||
* 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 <T>
|
||||
* the row item type
|
||||
*/
|
||||
public class InlineEditSupport<T> {
|
||||
|
||||
private final Grid<T> grid;
|
||||
private final Editor<T> editor;
|
||||
private final List<Grid.Column<T>> editableColumns = new ArrayList<>();
|
||||
|
||||
private @Nullable T focusedItem;
|
||||
private Grid.@Nullable Column<T> focusedColumn;
|
||||
private Grid.@Nullable Column<T> pendingFocusColumn;
|
||||
private Grid.@Nullable Column<T> editingColumn;
|
||||
|
||||
private SerializableConsumer<T> onSave = item -> {
|
||||
};
|
||||
|
||||
private InlineEditSupport(Grid<T> grid, Binder<T> 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 <T> InlineEditSupport<T> of(Grid<T> grid, Binder<T> binder) {
|
||||
return new InlineEditSupport<>(grid, binder);
|
||||
}
|
||||
|
||||
/** Registers {@code editorField} as the editor for {@code column} and enables editing on it. */
|
||||
public InlineEditSupport<T> editable(Grid.Column<T> 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<T> onSave(SerializableConsumer<T> 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<T> 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<GridListDataView<T>> 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -24,24 +24,31 @@ public final class MainLayout extends AppLayout {
|
||||
|
||||
// AuthenticationContext is not Serializable by design, so this field must stay transient.
|
||||
private final transient AuthenticationContext authenticationContext;
|
||||
private final ThemePreference themePreference;
|
||||
|
||||
MainLayout(AuthenticationContext authenticationContext) {
|
||||
MainLayout(AuthenticationContext authenticationContext, ThemePreference themePreference) {
|
||||
this.authenticationContext = authenticationContext;
|
||||
this.themePreference = themePreference;
|
||||
setPrimarySection(Section.DRAWER);
|
||||
addToDrawer(createApplicationHeader(), createApplicationDrawer(), createApplicationFooter());
|
||||
}
|
||||
|
||||
private Component createApplicationHeader() {
|
||||
// TODO Replace with real application logo and name
|
||||
var appLogo = new Avatar("My Application");
|
||||
var appLogo = new Avatar("Authentication Test");
|
||||
appLogo.addClassName("app-logo");
|
||||
appLogo.addThemeVariants(AvatarVariant.AURA_FILLED, AvatarVariant.XSMALL);
|
||||
|
||||
var appName = new Span("My Application");
|
||||
var appName = new Span("Authentication Test");
|
||||
appName.addClassName("app-name");
|
||||
|
||||
var header = new HorizontalLayout(appLogo, appName);
|
||||
var nameGroup = new HorizontalLayout(appLogo, appName);
|
||||
nameGroup.setAlignItems(FlexComponent.Alignment.CENTER);
|
||||
|
||||
var header = new HorizontalLayout(nameGroup, new ThemeToggle(themePreference));
|
||||
header.setAlignItems(FlexComponent.Alignment.CENTER);
|
||||
header.setJustifyContentMode(FlexComponent.JustifyContentMode.BETWEEN);
|
||||
header.setWidthFull();
|
||||
header.setPadding(true);
|
||||
return header;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
package com.example.base.ui;
|
||||
|
||||
import com.vaadin.flow.component.page.ColorScheme;
|
||||
import com.vaadin.flow.server.VaadinRequest;
|
||||
import com.vaadin.flow.server.VaadinResponse;
|
||||
import jakarta.servlet.http.Cookie;
|
||||
import org.jspecify.annotations.Nullable;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.Arrays;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Reads and writes the visitor's chosen {@link ColorScheme.Value} as a cookie, so it survives
|
||||
* page reloads and future sessions without needing any client-side script. Kept as a plain
|
||||
* utility class (rather than folded into {@link ThemePreference}) so both {@link ThemeInitializer}
|
||||
* (which only ever has a raw request/response, not a session-scoped bean) and
|
||||
* {@link ThemePreference} can share the exact same encoding.
|
||||
*/
|
||||
final class ThemeCookie {
|
||||
|
||||
static final String NAME = "app-theme";
|
||||
|
||||
private static final int MAX_AGE_SECONDS = (int) Duration.ofDays(365).toSeconds();
|
||||
|
||||
private ThemeCookie() {
|
||||
}
|
||||
|
||||
/** Returns the stored preference, if any cookie was sent with the request. */
|
||||
static Optional<ColorScheme.Value> read(@Nullable VaadinRequest request) {
|
||||
if (request == null || request.getCookies() == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
return Arrays.stream(request.getCookies())
|
||||
.filter(cookie -> NAME.equals(cookie.getName()))
|
||||
.map(Cookie::getValue)
|
||||
.map(ColorScheme.Value::fromString)
|
||||
.filter(value -> value != ColorScheme.Value.NORMAL)
|
||||
.findFirst();
|
||||
}
|
||||
|
||||
/** Persists the given preference for a year, scoped to the whole application. */
|
||||
static void write(@Nullable VaadinResponse response, ColorScheme.Value value) {
|
||||
if (response == null) {
|
||||
return;
|
||||
}
|
||||
var cookie = new Cookie(NAME, value.getValue());
|
||||
cookie.setPath("/");
|
||||
cookie.setMaxAge(MAX_AGE_SECONDS);
|
||||
cookie.setHttpOnly(true);
|
||||
response.addCookie(cookie);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package com.example.base.ui;
|
||||
|
||||
import com.vaadin.flow.component.page.ColorScheme;
|
||||
import com.vaadin.flow.server.ServiceInitEvent;
|
||||
import com.vaadin.flow.server.VaadinRequest;
|
||||
import com.vaadin.flow.server.VaadinServiceInitListener;
|
||||
import org.jsoup.nodes.Element;
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* Restores a visitor's stored light/dark preference (see {@link ThemeCookie}) on every visit.
|
||||
* Two things happen, mirroring what {@code @ColorScheme} plus {@code Page#setColorScheme} do
|
||||
* internally (see {@code IndexHtmlRequestHandler#applyColorScheme} / {@code Page#setColorScheme}
|
||||
* in flow-server 25.2.5), so a returning visitor never sees a flash of the wrong theme:
|
||||
* <ul>
|
||||
* <li>Before the page is sent, the {@code <html>} element's {@code theme} attribute and
|
||||
* inline {@code color-scheme} style are overwritten from the cookie, taking precedence over
|
||||
* the {@code @ColorScheme(SYSTEM)} default declared on {@code Application}.</li>
|
||||
* <li>On UI init, the same cookie seeds the session's {@link ThemePreference} bean, so
|
||||
* {@link ThemeToggle} renders the right icon/label immediately, with no client round-trip.</li>
|
||||
* </ul>
|
||||
* First-ever visits (no cookie yet) are left alone and simply follow {@code @ColorScheme(SYSTEM)}.
|
||||
* <p>
|
||||
* Wired the same way as {@code com.example.security.TwoFactorNavigationGuard}: a
|
||||
* {@code VaadinServiceInitListener} bean registering a listener in {@code serviceInit}.
|
||||
* <p>
|
||||
* {@link ThemePreference} is looked up lazily through an {@link ObjectProvider} rather than
|
||||
* injected directly: this class is an application-wide singleton created at startup, well before
|
||||
* any Vaadin session exists, whereas {@code ThemePreference} only resolves inside one - the
|
||||
* lookup has to happen inside the UI-init callback, once a session is actually active.
|
||||
*/
|
||||
@Component
|
||||
class ThemeInitializer implements VaadinServiceInitListener {
|
||||
|
||||
private final ObjectProvider<ThemePreference> themePreference;
|
||||
|
||||
ThemeInitializer(ObjectProvider<ThemePreference> themePreference) {
|
||||
this.themePreference = themePreference;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void serviceInit(ServiceInitEvent event) {
|
||||
event.addIndexHtmlRequestListener(response -> ThemeCookie.read(response.getVaadinRequest())
|
||||
.ifPresent(value -> applyToDocumentRoot(response.getDocument().head().parent(), value)));
|
||||
|
||||
event.getSource().addUIInitListener(uiEvent -> ThemeCookie.read(VaadinRequest.getCurrent())
|
||||
.ifPresent(value -> themePreference.getObject().seed(value)));
|
||||
}
|
||||
|
||||
private void applyToDocumentRoot(Element html, ColorScheme.Value value) {
|
||||
if (html == null) {
|
||||
return;
|
||||
}
|
||||
html.attr("theme", value.getThemeValue());
|
||||
html.attr("style", "color-scheme: " + value.getValue() + ";");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package com.example.base.ui;
|
||||
|
||||
import com.vaadin.flow.component.UI;
|
||||
import com.vaadin.flow.component.page.ColorScheme;
|
||||
import com.vaadin.flow.server.VaadinService;
|
||||
import com.vaadin.flow.spring.annotation.VaadinSessionScope;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* Tracks the current visitor's light/dark preference for the session and applies changes to it.
|
||||
* Same shape as {@code com.example.security.TwoFactorAttemptTracker}: a session-scoped bean
|
||||
* holding small mutable UI state. Public: injected into {@link MainLayout} and {@link ThemeToggle}.
|
||||
* <p>
|
||||
* The preference is also written to a cookie (see {@link ThemeCookie}) so it survives beyond the
|
||||
* session too. {@link ThemeInitializer} reads that cookie back on the next visit, both to render
|
||||
* the correct theme before first paint and to seed this bean for the new session.
|
||||
*/
|
||||
@Component
|
||||
@VaadinSessionScope
|
||||
public class ThemePreference {
|
||||
|
||||
private ColorScheme.Value current = ColorScheme.Value.SYSTEM;
|
||||
|
||||
public ColorScheme.Value get() {
|
||||
return current;
|
||||
}
|
||||
|
||||
/** True once the visitor is explicitly in dark mode (as opposed to SYSTEM/LIGHT). */
|
||||
public boolean isDark() {
|
||||
return current == ColorScheme.Value.DARK;
|
||||
}
|
||||
|
||||
/** Flips between light and dark; an undecided SYSTEM preference is treated as light. */
|
||||
public void toggle() {
|
||||
apply(isDark() ? ColorScheme.Value.LIGHT : ColorScheme.Value.DARK);
|
||||
}
|
||||
|
||||
/** Used by {@link ThemeInitializer} to seed this bean from the stored cookie, without re-applying it. */
|
||||
void seed(ColorScheme.Value value) {
|
||||
current = value;
|
||||
}
|
||||
|
||||
private void apply(ColorScheme.Value value) {
|
||||
current = value;
|
||||
UI.getCurrent().getPage().setColorScheme(value);
|
||||
ThemeCookie.write(VaadinService.getCurrentResponse(), value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package com.example.base.ui;
|
||||
|
||||
import com.vaadin.flow.component.Composite;
|
||||
import com.vaadin.flow.component.button.Button;
|
||||
import com.vaadin.flow.component.button.ButtonVariant;
|
||||
import com.vaadin.flow.component.icon.VaadinIcon;
|
||||
|
||||
/**
|
||||
* An icon-only button in the application header that flips the app between light and dark mode.
|
||||
* Built the same way as {@link OtpField} / {@link ViewTitle}: a small server-side Java composite.
|
||||
* <p>
|
||||
* There's no dedicated toggle/switch component in Vaadin 25 (Aura has no {@code ToggleButton}),
|
||||
* so a tertiary icon {@link Button} is the idiomatic stand-in for a header action like this.
|
||||
*/
|
||||
class ThemeToggle extends Composite<Button> {
|
||||
|
||||
// Package-private (rather than private) so ThemeToggleTest can click it directly.
|
||||
final Button button;
|
||||
|
||||
private final ThemePreference themePreference;
|
||||
|
||||
ThemeToggle(ThemePreference themePreference) {
|
||||
this.themePreference = themePreference;
|
||||
|
||||
button = getContent();
|
||||
button.addClassName("theme-toggle");
|
||||
button.addThemeVariants(ButtonVariant.TERTIARY);
|
||||
button.addClickListener(event -> {
|
||||
themePreference.toggle();
|
||||
render();
|
||||
});
|
||||
render();
|
||||
}
|
||||
|
||||
private void render() {
|
||||
var dark = themePreference.isDark();
|
||||
button.setIcon((dark ? VaadinIcon.SUN_O : VaadinIcon.MOON_O).create());
|
||||
var label = dark ? "Switch to light theme" : "Switch to dark theme";
|
||||
button.setAriaLabel(label);
|
||||
button.setTooltipText(label);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package com.example.customer;
|
||||
|
||||
import java.time.LocalDate;
|
||||
|
||||
/**
|
||||
* 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}.
|
||||
* <p>
|
||||
* 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 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
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
|
||||
public class CustomerService {
|
||||
|
||||
// TODO Replace with a real data source (e.g. a JPA-backed CustomerRepository) once
|
||||
// customers are no longer dummy data.
|
||||
private final List<Customer> customers = new ArrayList<>(seed());
|
||||
|
||||
private static List<Customer> 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<Customer> list() {
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
@NullMarked
|
||||
package com.example.customer;
|
||||
|
||||
import org.jspecify.annotations.NullMarked;
|
||||
@@ -0,0 +1,99 @@
|
||||
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;
|
||||
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<String> STATUS_OPTIONS = List.of("Active", "Inactive", "Prospect");
|
||||
|
||||
final Grid<Customer> customerGrid;
|
||||
|
||||
// Package-private (rather than private) so CustomerListViewTest can drive the editors directly.
|
||||
final TextField nameEditor;
|
||||
final EmailField emailEditor;
|
||||
final Select<String> statusEditor;
|
||||
final DatePicker sinceEditor;
|
||||
|
||||
final Grid.Column<Customer> companyColumn;
|
||||
|
||||
CustomerListView(CustomerService customerService) {
|
||||
var dateFormatter = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(getLocale());
|
||||
|
||||
customerGrid = new Grid<>();
|
||||
customerGrid.setItems(customerService.list());
|
||||
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)
|
||||
.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"), hint, customerGrid);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
@NullMarked
|
||||
package com.example.customer.ui;
|
||||
|
||||
import org.jspecify.annotations.NullMarked;
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
/*
|
||||
* Solstice - a custom Aura theme variant.
|
||||
*
|
||||
* Concept: the two color schemes are the two solstices. Light mode is warm summer daylight
|
||||
* (amber accent, warm paper background); dark mode is cool winter midnight (aurora cyan accent,
|
||||
* deep blue background). Aura resolves each `--aura-*-light`/`--aura-*-dark` pair through the
|
||||
* native CSS `light-dark()` function based on the `color-scheme` set on <html> - see
|
||||
* com.example.base.ui.ThemePreference / ThemeInitializer for how that gets set.
|
||||
*
|
||||
* Only *-light/-dark suffixed custom properties are overridden here; everything else (components,
|
||||
* other CSS in this app) should keep consuming the unsuffixed tokens (--aura-accent-color,
|
||||
* --vaadin-text-color, etc.) so it automatically follows whichever scheme is active.
|
||||
*/
|
||||
|
||||
:root {
|
||||
/* Accent: burnt amber by day, aurora cyan by night. */
|
||||
--aura-accent-color-light: oklch(0.58 0.16 55);
|
||||
--aura-accent-color-dark: oklch(0.80 0.12 195);
|
||||
|
||||
/* App background: warm paper by day, deep midnight blue by night. */
|
||||
--aura-background-color-light: oklch(0.97 0.018 80);
|
||||
--aura-background-color-dark: oklch(0.19 0.03 255);
|
||||
|
||||
/* A touch softer and slightly more contrast than the Aura defaults. */
|
||||
--aura-base-radius: 6;
|
||||
--aura-contrast-level: 1.5;
|
||||
}
|
||||
|
||||
/* Logo and app name pick up the accent so the "identity" of the theme is visible immediately. */
|
||||
.app-logo {
|
||||
--vaadin-avatar-background: var(--aura-accent-color);
|
||||
}
|
||||
|
||||
.app-name {
|
||||
background: linear-gradient(120deg, var(--aura-accent-color), var(--aura-accent-color-dark, var(--aura-accent-color)));
|
||||
background-clip: text;
|
||||
-webkit-background-clip: text;
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
/* The toggle gets a small icon transition so flipping it feels intentional rather than instant. */
|
||||
.theme-toggle vaadin-icon {
|
||||
transition: transform 0.25s ease, color 0.25s ease;
|
||||
}
|
||||
|
||||
.theme-toggle:hover vaadin-icon {
|
||||
transform: rotate(-14deg) scale(1.1);
|
||||
color: var(--aura-accent-color);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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<Item> items;
|
||||
final Grid<Item> grid;
|
||||
final TextField nameEditor;
|
||||
final TextField valueEditor;
|
||||
final InlineEditSupport<Item> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package com.example.base.ui;
|
||||
|
||||
import com.vaadin.browserless.SpringBrowserlessTest;
|
||||
import com.vaadin.flow.component.icon.Icon;
|
||||
import com.vaadin.flow.component.page.ColorScheme;
|
||||
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 ThemeToggleTest extends SpringBrowserlessTest {
|
||||
|
||||
@Test
|
||||
void default_state_offers_to_switch_to_dark_theme() {
|
||||
var view = navigate(ThemeToggleTestView.class);
|
||||
|
||||
assertThat(view.themePreference.get()).isEqualTo(ColorScheme.Value.SYSTEM);
|
||||
assertThat(view.themeToggle.button.getAriaLabel()).contains("Switch to dark theme");
|
||||
}
|
||||
|
||||
@Test
|
||||
void clicking_switches_to_dark_theme() {
|
||||
var view = navigate(ThemeToggleTestView.class);
|
||||
|
||||
test(view.themeToggle.button).click();
|
||||
|
||||
assertThat(view.themePreference.get()).isEqualTo(ColorScheme.Value.DARK);
|
||||
assertThat(view.themePreference.isDark()).isTrue();
|
||||
assertThat(view.themeToggle.button.getAriaLabel()).contains("Switch to light theme");
|
||||
assertThat(((Icon) view.themeToggle.button.getIcon()).getIcon()).isEqualTo("vaadin:sun-o");
|
||||
}
|
||||
|
||||
@Test
|
||||
void clicking_twice_returns_to_light_theme() {
|
||||
var view = navigate(ThemeToggleTestView.class);
|
||||
|
||||
test(view.themeToggle.button).click();
|
||||
test(view.themeToggle.button).click();
|
||||
|
||||
assertThat(view.themePreference.get()).isEqualTo(ColorScheme.Value.LIGHT);
|
||||
assertThat(view.themePreference.isDark()).isFalse();
|
||||
assertThat(view.themeToggle.button.getAriaLabel()).contains("Switch to dark theme");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.example.base.ui;
|
||||
|
||||
import com.vaadin.flow.component.html.Div;
|
||||
import com.vaadin.flow.router.PageTitle;
|
||||
import com.vaadin.flow.router.Route;
|
||||
import jakarta.annotation.security.PermitAll;
|
||||
|
||||
/** Test-only host route so ThemeToggleTest can exercise the component inside an attached UI. */
|
||||
@Route("test/theme-toggle")
|
||||
@PageTitle("ThemeToggle test host")
|
||||
@PermitAll
|
||||
class ThemeToggleTestView extends Div {
|
||||
|
||||
final ThemeToggle themeToggle;
|
||||
final ThemePreference themePreference;
|
||||
|
||||
ThemeToggleTestView(ThemePreference themePreference) {
|
||||
this.themePreference = themePreference;
|
||||
themeToggle = new ThemeToggle(themePreference);
|
||||
add(themeToggle);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package com.example.customer.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.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 {
|
||||
|
||||
@Test
|
||||
void grid_shows_all_dummy_customers() {
|
||||
var view = navigate(CustomerListView.class);
|
||||
|
||||
assertThat(test(view.customerGrid).size()).isEqualTo(12);
|
||||
}
|
||||
|
||||
@Test
|
||||
void grid_shows_customer_columns() {
|
||||
var view = navigate(CustomerListView.class);
|
||||
|
||||
assertThat(test(view.customerGrid).getCellText(0, 0)).isEqualTo("Alice Johnson");
|
||||
assertThat(test(view.customerGrid).getCellText(0, 1)).isEqualTo("alice.johnson@example.com");
|
||||
assertThat(test(view.customerGrid).getCellText(0, 2)).isEqualTo("Acme Corp");
|
||||
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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user