From 8545b06f41fb807cb00116275ae15de9e2ac69b5 Mon Sep 17 00:00:00 2001 From: Pit Friedrich Date: Tue, 28 Jul 2026 21:26:05 +0200 Subject: [PATCH] fix: add a per-widget action menu (#27) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A GridStackItem now carries an overflow menu next to the grip and the close button: refresh, maximize, duplicate, remove. Maximize and remove are handled by the item itself; refresh and duplicate are only reported, since what they mean depends on the widget. Maximizing only sets a class — the item's gs-* attributes and its gridstack node are untouched, so restoring is by definition the position it had. The menu sits outside the drag-handle selector, like the close button, so opening it never starts a drag; a Playwright test drags it to prove it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_0124BiJikbhbiEsNfJxdWM69 --- src/main/java/com/example/components/Fa.java | 4 + .../com/example/components/GridStackItem.java | 175 ++++++++++++++++++ .../java/com/example/views/DashboardView.java | 39 +++- .../com/example/widgets/WidgetRegistry.java | 24 +++ .../resources/META-INF/resources/styles.css | 61 +++++- .../vaadin-i18n/translations.properties | 8 +- .../vaadin-i18n/translations_en.properties | 8 +- .../vaadin-i18n/translations_es.properties | 8 +- .../e2e/WidgetActionMenuPlaywrightTest.java | 117 ++++++++++++ .../com/example/views/DashboardViewTest.java | 91 +++++++++ .../example/widgets/WidgetRegistryTest.java | 10 + 11 files changed, 537 insertions(+), 8 deletions(-) create mode 100644 src/test/java/com/example/e2e/WidgetActionMenuPlaywrightTest.java diff --git a/src/main/java/com/example/components/Fa.java b/src/main/java/com/example/components/Fa.java index 8ada6e1..8c52505 100644 --- a/src/main/java/com/example/components/Fa.java +++ b/src/main/java/com/example/components/Fa.java @@ -27,6 +27,10 @@ public enum Fa { RESET("fa-solid", "fa-arrow-rotate-left"), DRAG("fa-solid", "fa-grip-vertical"), CLOSE("fa-solid", "fa-xmark"), + MENU("fa-solid", "fa-ellipsis-vertical"), + REFRESH("fa-solid", "fa-arrows-rotate"), + MAXIMIZE("fa-solid", "fa-expand"), + DUPLICATE("fa-solid", "fa-clone"), TREND_UP("fa-solid", "fa-arrow-trend-up"), TREND_DOWN("fa-solid", "fa-arrow-trend-down"); diff --git a/src/main/java/com/example/components/GridStackItem.java b/src/main/java/com/example/components/GridStackItem.java index 794b4a5..d4130cb 100644 --- a/src/main/java/com/example/components/GridStackItem.java +++ b/src/main/java/com/example/components/GridStackItem.java @@ -3,9 +3,18 @@ package com.example.components; import com.vaadin.flow.component.Component; import com.vaadin.flow.component.ComponentEvent; import com.vaadin.flow.component.ComponentEventListener; +import com.vaadin.flow.component.contextmenu.MenuItem; import com.vaadin.flow.component.html.Div; +import com.vaadin.flow.component.html.Span; +import com.vaadin.flow.component.menubar.MenuBar; +import com.vaadin.flow.component.menubar.MenuBarVariant; import com.vaadin.flow.shared.Registration; +import java.util.Collections; +import java.util.EnumMap; +import java.util.EnumSet; +import java.util.Map; +import java.util.Set; import java.util.UUID; /** @@ -19,6 +28,13 @@ import java.util.UUID; * the grip (see {@link #setClosable(boolean)}); removing the item server-side * is enough to unregister the widget client-side, since grid-stack.ts observes * its own childList. + *

+ * Next to those two sits the action menu ({@link Action}): the slot for + * everything a widget can do beyond move/resize/close. {@link Action#MAXIMIZE} + * and {@link Action#REMOVE} are handled here, the rest is only reported to + * {@link #addActionListener(ComponentEventListener) action listeners} — what + * "refresh" or "duplicate" means depends on the widget, which this component + * knows nothing about. */ public class GridStackItem extends Div { @@ -30,10 +46,42 @@ public class GridStackItem extends Div { * styling and the grip's offset off it. */ public static final String CLOSE_BUTTON_CLASS = "dialect-close-button"; + /** Marker class for the action menu; the corner controls' offsets in + * {@code styles.css} are keyed off its presence, same as the close + * button's. */ + public static final String ACTION_MENU_CLASS = "dialect-action-menu"; + + /** Class set on the item itself while it is + * {@link #setMaximized(boolean) maximized} — {@code styles.css} is what + * actually lifts the item out of the grid. */ + public static final String MAXIMIZED_CLASS = "dialect-maximized"; + + /** What the action menu can offer. Which entries a given widget shows is up + * to the caller ({@link #setActionEnabled(Action, boolean)}) — not every + * widget type supports every action. */ + public enum Action { + /** Re-request this widget's data. Reported only. */ + REFRESH, + /** Expand the widget over the grid and back. Handled here. */ + MAXIMIZE, + /** Add another widget of the same kind. Reported only. */ + DUPLICATE, + /** Same as the close button, from the menu. Handled here. */ + REMOVE + } + + /** Shown by default: both are widget-type independent, since this component + * can carry them out on its own. */ + private static final Action[] DEFAULT_ACTIONS = {Action.MAXIMIZE, Action.REMOVE}; + private final Div content = new Div(); private final Div dragHandle = new Div(); private final Div closeButton = new Div(); + private final MenuBar actionMenu = new MenuBar(); + private final Map actionItems = new EnumMap<>(Action.class); + private final Map actionCaptions = new EnumMap<>(Action.class); private boolean closable = true; + private boolean maximized; public GridStackItem(Component... content) { this(UUID.randomUUID().toString(), 0, 0, 4, 3, content); @@ -77,6 +125,133 @@ public class GridStackItem extends Div { closeButton.getElement().addEventListener("keydown", e -> close(true)) .setFilter("event.key === 'Enter' || event.key === ' '"); getElement().appendChild(closeButton.getElement()); + + buildActionMenu(); + getElement().appendChild(actionMenu.getElement()); + } + + /** + * The overflow menu, a third sibling of the content wrapper: outside it for + * the same reason as the grip, and outside the {@code DRAG_HANDLE_CLASS} + * selector so opening it never starts a drag. + *

+ * A {@link MenuBar} rather than a hand-rolled popup: its button is a real + * button, so the menu is reachable and operable from the keyboard without + * this component re-implementing any of it. + */ + private void buildActionMenu() { + actionMenu.addClassName(ACTION_MENU_CLASS); + actionMenu.addThemeVariants(MenuBarVariant.LUMO_TERTIARY_INLINE, MenuBarVariant.LUMO_ICON); + + String label = getTranslation("gridstack.actions"); + MenuItem root = actionMenu.addItem(Fa.MENU.create()); + root.getElement().setAttribute("title", label); + root.getElement().setAttribute("aria-label", label); + + addActionItem(root, Action.REFRESH, Fa.REFRESH, "gridstack.refresh"); + addActionItem(root, Action.MAXIMIZE, Fa.MAXIMIZE, "gridstack.maximize"); + addActionItem(root, Action.DUPLICATE, Fa.DUPLICATE, "gridstack.duplicate"); + addActionItem(root, Action.REMOVE, Fa.REMOVE, "gridstack.remove"); + + setActions(DEFAULT_ACTIONS); + } + + /** The caption is a {@link Span} of its own rather than the item's text: + * {@code setText} would drop the icon along with the old text, and the + * maximize entry relabels itself. */ + private void addActionItem(MenuItem root, Action action, Fa icon, String captionKey) { + Span caption = new Span(getTranslation(captionKey)); + MenuItem item = root.getSubMenu().addItem(icon.create(), + e -> triggerAction(action, e.isFromClient())); + item.add(caption); + actionItems.put(action, item); + actionCaptions.put(action, caption); + } + + /** Carries out what this component can do itself, then reports the action. + * The event is fired last, so a listener sees the item in its new state — + * and, for {@link Action#REMOVE}, already detached, as with + * {@link CloseEvent}. */ + private void triggerAction(Action action, boolean fromClient) { + switch (action) { + case MAXIMIZE -> setMaximized(!maximized); + case REMOVE -> close(fromClient); + default -> { } + } + fireEvent(new ActionEvent(this, fromClient, action)); + } + + /** Shows exactly the given actions in the menu and hides every other one. */ + public GridStackItem setActions(Action... actions) { + Set wanted = EnumSet.noneOf(Action.class); + Collections.addAll(wanted, actions); + actionItems.forEach((action, item) -> item.setVisible(wanted.contains(action))); + return this; + } + + /** Shows or hides a single menu entry — the widget types that support an + * action differ, the menu does not. */ + public GridStackItem setActionEnabled(Action action, boolean enabled) { + actionItems.get(action).setVisible(enabled); + return this; + } + + public boolean isActionEnabled(Action action) { + return actionItems.get(action).isVisible(); + } + + /** The menu entry of an action. Exposed for restyling and for tests, which + * click it rather than calling the action directly. */ + public MenuItem getActionItem(Action action) { + return actionItems.get(action); + } + + public MenuBar getActionMenu() { + return actionMenu; + } + + /** + * Expands the item over the grid, or puts it back. Only a class on the item + * changes — its {@code gs-*} attributes and its gridstack node are left + * exactly as they are, so restoring is by definition the position it had. + */ + public GridStackItem setMaximized(boolean maximized) { + if (maximized == this.maximized) { + return this; + } + this.maximized = maximized; + getElement().getClassList().set(MAXIMIZED_CLASS, maximized); + actionCaptions.get(Action.MAXIMIZE).setText( + getTranslation(maximized ? "gridstack.restore" : "gridstack.maximize")); + // The item's box changes without gridstack resizing anything, so nothing + // fires the resize the charts inside reflow on (see grid-stack.ts, + // which does the same on resizestop). After the next frame, so the new + // geometry is the one the chart measures. + getElement().executeJs("requestAnimationFrame(" + + "() => window.dispatchEvent(new Event('resize')))"); + return this; + } + + public boolean isMaximized() { + return maximized; + } + + public Registration addActionListener(ComponentEventListener listener) { + return addListener(ActionEvent.class, listener); + } + + /** Fired after an action menu entry was picked. */ + public static class ActionEvent extends ComponentEvent { + private final Action action; + + ActionEvent(GridStackItem source, boolean fromClient, Action action) { + super(source, fromClient); + this.action = action; + } + + public Action getAction() { + return action; + } } /** diff --git a/src/main/java/com/example/views/DashboardView.java b/src/main/java/com/example/views/DashboardView.java index 2a9935b..2a1eb9e 100644 --- a/src/main/java/com/example/views/DashboardView.java +++ b/src/main/java/com/example/views/DashboardView.java @@ -3,6 +3,7 @@ package com.example.views; import com.example.components.Card; import com.example.components.Fa; import com.example.components.GridStackItem; +import com.example.components.GridStackItem.Action; import com.example.components.GridStackLayout; import com.example.components.KpiTile; import com.example.data.ChartDataService; @@ -11,6 +12,7 @@ import com.example.data.KpiData; import com.example.widgets.DashboardContext; import com.example.widgets.WidgetDefinition; import com.example.widgets.WidgetRegistry; +import com.vaadin.flow.component.Component; import com.vaadin.flow.component.button.Button; import com.vaadin.flow.component.button.ButtonVariant; import com.vaadin.flow.component.dialog.Dialog; @@ -78,7 +80,16 @@ public class DashboardView extends VerticalLayout implements HasDynamicTitle { KpiData kpi = kpis.get(i); KpiTile tile = feed(new KpiTile(getTranslation(kpi.labelKey()), ""), kpi); kpiTiles.put(kpi.id(), tile); - grid.add(new GridStackItem(kpi.id(), i * KPI_WIDTH, 0, KPI_WIDTH, 1, tile)); + GridStackItem item = new GridStackItem(kpi.id(), i * KPI_WIDTH, 0, KPI_WIDTH, 1, tile); + // A tile can be refreshed (this view feeds it) but not duplicated: + // a second copy of the same KPI would be the same number twice. + item.setActionEnabled(Action.REFRESH, true); + item.addActionListener(e -> { + if (e.getAction() == Action.REFRESH) { + refreshKpiTile(kpi.id()); + } + }); + grid.add(item); } context.addFilterChangeListener(this::updateKpiTiles); @@ -153,10 +164,32 @@ public class DashboardView extends VerticalLayout implements HasDynamicTitle { return widget(type, widgets.require(type), x, y, w, h); } + /** Registry widgets support the whole action menu: they know how to + * re-request their data, and the definition they were built from is what + * duplicating one needs. */ private GridStackItem widget(String id, WidgetDefinition definition, int x, int y, int w, int h) { - return new GridStackItem(id, x, y, w, h, new Card(getTranslation(definition.titleKey()), - definition.factory().apply(context))); + Component content = definition.factory().apply(context); + GridStackItem item = new GridStackItem(id, x, y, w, h, + new Card(getTranslation(definition.titleKey()), content)); + item.setActions(Action.REFRESH, Action.MAXIMIZE, Action.DUPLICATE, Action.REMOVE); + item.addActionListener(e -> { + switch (e.getAction()) { + case REFRESH -> WidgetRegistry.refresh(content); + case DUPLICATE -> addWidget(definition); + default -> { } + } + }); + return item; + } + + /** Re-feeds a single tile from the current filter — the action menu's + * refresh, which asks for one widget, not for the dashboard. */ + private void refreshKpiTile(String kpiId) { + dataService.kpis(context.getFilter()).stream() + .filter(kpi -> kpiId.equals(kpi.id())) + .findFirst() + .ifPresent(kpi -> feed(kpiTiles.get(kpi.id()), kpi)); } /** Re-feeds the tiles still on the dashboard. A closed tile keeps its entry diff --git a/src/main/java/com/example/widgets/WidgetRegistry.java b/src/main/java/com/example/widgets/WidgetRegistry.java index ce95051..29e1db5 100644 --- a/src/main/java/com/example/widgets/WidgetRegistry.java +++ b/src/main/java/com/example/widgets/WidgetRegistry.java @@ -9,6 +9,7 @@ import com.example.data.ChartDataService; import com.example.data.ChartSeries; import com.example.data.DashboardFilter; import com.vaadin.flow.component.Component; +import com.vaadin.flow.component.ComponentUtil; import com.vaadin.flow.component.notification.Notification; import com.vaadin.flow.shared.Registration; import org.springframework.stereotype.Service; @@ -54,6 +55,27 @@ public class WidgetRegistry { this::pieChart)); } + /** + * How a built widget re-requests its data — the same feed the context + * drives on a filter change, only triggered by hand (the action menu's + * refresh). It is carried on the widget component itself, so a caller can + * refresh a widget without knowing which type it is or holding the feed. + */ + @FunctionalInterface + public interface Refresh { + void run(); + } + + /** Re-requests the widget's data, if it is one that can. A widget without a + * refresher (a static card, say) is silently left alone — the dashboard + * offers the action per widget, this only carries it out. */ + public static void refresh(Component widget) { + Refresh refresh = ComponentUtil.getData(widget, Refresh.class); + if (refresh != null) { + refresh.run(); + } + } + /** Adds a definition, replacing any earlier one of the same type. */ public final void register(WidgetDefinition definition) { definitions.put(definition.type(), definition); @@ -107,6 +129,8 @@ public class WidgetRegistry { feed.accept(context.getFilter()); Registration registration = context.addFilterChangeListener(feed); chart.addDetachListener(e -> registration.remove()); + ComponentUtil.setData(chart, Refresh.class, + (Refresh) () -> feed.accept(context.getFilter())); return sizeFull(chart); } diff --git a/src/main/resources/META-INF/resources/styles.css b/src/main/resources/META-INF/resources/styles.css index 6446ead..bf0c3e8 100644 --- a/src/main/resources/META-INF/resources/styles.css +++ b/src/main/resources/META-INF/resources/styles.css @@ -302,10 +302,40 @@ apex-chart.dialect-sparkline .apexcharts-xaxis { transition: opacity 120ms ease; } -.grid-stack-item:has(> .dialect-close-button) > .dialect-drag-handle { +/* Action menu (GridStackItem.Action): the third corner control, between the + close button and the grip. Each control that is present pushes the ones to + its left one slot (32px) further in. */ +.dialect-action-menu { + position: absolute; + top: 11px; + right: 16px; + z-index: 1; + opacity: 0; + transition: opacity 120ms ease; +} + +.grid-stack-item:has(> .dialect-close-button) > .dialect-action-menu { right: 48px; } +.grid-stack-item:has(> .dialect-close-button) > .dialect-drag-handle, +.grid-stack-item:has(> .dialect-action-menu) > .dialect-drag-handle { + right: 48px; +} + +.grid-stack-item:has(> .dialect-close-button):has(> .dialect-action-menu) + > .dialect-drag-handle { + right: 80px; +} + +/* Stays put while its menu is open: the overlay is anchored to the button, and + the pointer moving onto the overlay leaves the item, ending the :hover. */ +.grid-stack-item:hover > .dialect-action-menu, +.dialect-action-menu:focus-within, +.dialect-action-menu:has(vaadin-menu-bar-button[expanded]) { + opacity: 0.75; +} + .grid-stack-item:hover > .dialect-close-button, .dialect-close-button:focus-visible { opacity: 0.65; @@ -320,11 +350,38 @@ apex-chart.dialect-sparkline .apexcharts-xaxis { /* Touch devices never hover — keep the corner controls permanently visible. */ @media (pointer: coarse) { .dialect-drag-handle, - .dialect-close-button { + .dialect-close-button, + .dialect-action-menu { opacity: 0.65; } } +/* Maximized item (GridStackItem#setMaximized): lifted out of the grid purely + visually — its gs-* attributes and its gridstack node are untouched, so + dropping this class puts it back exactly where it was. !important because + gridstack writes the item's box as inline style. */ +.grid-stack-item.dialect-maximized { + position: fixed !important; + inset: 16px !important; + width: auto !important; + height: auto !important; + min-height: 0 !important; + transform: none !important; + z-index: 20; +} + +.grid-stack-item.dialect-maximized > .grid-stack-item-content { + height: 100%; + inset: 0 !important; + width: auto !important; +} + +/* Nothing to drag or resize while maximized: the item is not in grid flow. */ +.grid-stack-item.dialect-maximized > .dialect-drag-handle, +.grid-stack-item.dialect-maximized .ui-resizable-handle { + display: none; +} + .grid-stack-item[gs-no-move] > .dialect-drag-handle { display: none; } diff --git a/src/main/resources/vaadin-i18n/translations.properties b/src/main/resources/vaadin-i18n/translations.properties index 29e0757..d4cbff6 100644 --- a/src/main/resources/vaadin-i18n/translations.properties +++ b/src/main/resources/vaadin-i18n/translations.properties @@ -22,7 +22,7 @@ card.registration=Registrierung card.employees=Mitarbeiter card.gridstackHint=Bedienung -gridstack.hint=Karten am Griff oben rechts verschieben, an der unteren rechten Ecke die Größe ändern, mit dem X oben rechts schließen. Das Layout wird im Browser gespeichert und beim nächsten Besuch wiederhergestellt. +gridstack.hint=Karten am Griff oben rechts verschieben, an der unteren rechten Ecke die Größe ändern, mit dem X oben rechts schließen. Weitere Aktionen – aktualisieren, maximieren, duplizieren – liegen im Menü daneben. Das Layout wird im Browser gespeichert und beim nächsten Besuch wiederhergestellt. gridstack.addWidget=Widget hinzufügen gridstack.reset=Layout zurücksetzen gridstack.status=Layout geändert – {0} Widgets @@ -30,6 +30,12 @@ gridstack.statusInitial=Layout unverändert gridstack.pickerTitle=Widget auswählen gridstack.dragHandle=Verschieben gridstack.close=Schließen +gridstack.actions=Aktionen +gridstack.refresh=Aktualisieren +gridstack.maximize=Maximieren +gridstack.restore=Wiederherstellen +gridstack.duplicate=Duplizieren +gridstack.remove=Entfernen filter.period=Zeitraum filter.period.month=Monat diff --git a/src/main/resources/vaadin-i18n/translations_en.properties b/src/main/resources/vaadin-i18n/translations_en.properties index f0ee188..7b233ee 100644 --- a/src/main/resources/vaadin-i18n/translations_en.properties +++ b/src/main/resources/vaadin-i18n/translations_en.properties @@ -22,7 +22,7 @@ card.registration=Registration card.employees=Employees card.gridstackHint=How it works -gridstack.hint=Move cards with the grip in their top right corner, resize them from the bottom right corner, close them with the X in the top right corner. The layout is stored in your browser and restored on your next visit. +gridstack.hint=Move cards with the grip in their top right corner, resize them from the bottom right corner, close them with the X in the top right corner. More actions – refresh, maximize, duplicate – live in the menu next to it. The layout is stored in your browser and restored on your next visit. gridstack.addWidget=Add widget gridstack.reset=Reset layout gridstack.status=Layout changed – {0} widgets @@ -30,6 +30,12 @@ gridstack.statusInitial=Layout unchanged gridstack.pickerTitle=Choose a widget gridstack.dragHandle=Move gridstack.close=Close +gridstack.actions=Actions +gridstack.refresh=Refresh +gridstack.maximize=Maximize +gridstack.restore=Restore +gridstack.duplicate=Duplicate +gridstack.remove=Remove filter.period=Period filter.period.month=Month diff --git a/src/main/resources/vaadin-i18n/translations_es.properties b/src/main/resources/vaadin-i18n/translations_es.properties index d6ca742..9665412 100644 --- a/src/main/resources/vaadin-i18n/translations_es.properties +++ b/src/main/resources/vaadin-i18n/translations_es.properties @@ -22,7 +22,7 @@ card.registration=Registro card.employees=Empleados card.gridstackHint=Cómo funciona -gridstack.hint=Mueve las tarjetas con el asa de la esquina superior derecha, cambia su tamaño desde la esquina inferior derecha y ciérralas con la X de la esquina superior derecha. El diseño se guarda en el navegador y se restaura en la próxima visita. +gridstack.hint=Mueve las tarjetas con el asa de la esquina superior derecha, cambia su tamaño desde la esquina inferior derecha y ciérralas con la X de la esquina superior derecha. Más acciones – actualizar, maximizar, duplicar – están en el menú contiguo. El diseño se guarda en el navegador y se restaura en la próxima visita. gridstack.addWidget=Añadir widget gridstack.reset=Restablecer diseño gridstack.status=Diseño modificado – {0} widgets @@ -30,6 +30,12 @@ gridstack.statusInitial=Diseño sin cambios gridstack.pickerTitle=Elegir un widget gridstack.dragHandle=Mover gridstack.close=Cerrar +gridstack.actions=Acciones +gridstack.refresh=Actualizar +gridstack.maximize=Maximizar +gridstack.restore=Restaurar +gridstack.duplicate=Duplicar +gridstack.remove=Eliminar filter.period=Periodo filter.period.month=Mes diff --git a/src/test/java/com/example/e2e/WidgetActionMenuPlaywrightTest.java b/src/test/java/com/example/e2e/WidgetActionMenuPlaywrightTest.java new file mode 100644 index 0000000..c80c31d --- /dev/null +++ b/src/test/java/com/example/e2e/WidgetActionMenuPlaywrightTest.java @@ -0,0 +1,117 @@ +package com.example.e2e; + +import com.microsoft.playwright.Locator; +import com.microsoft.playwright.options.BoundingBox; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.regex.Pattern; + +import static com.microsoft.playwright.assertions.PlaywrightAssertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * End-to-end coverage for the per-widget action menu, above all for the thing + * only a real browser can show: the menu sits outside the drag-handle selector, + * so operating it never moves the widget. + */ +class WidgetActionMenuPlaywrightTest extends PlaywrightTestBase { + + @BeforeEach + void openDashboard() { + navigate(""); + } + + @Test + void draggingTheMenuButton_doesNotMoveTheWidget() { + Locator item = widget(); + assertThat(menuButton(item)).isVisible(); + String before = slot(item); + + BoundingBox button = menuButton(item).boundingBox(); + page.mouse().move(button.x + button.width / 2, button.y + button.height / 2); + page.mouse().down(); + // Well past gridstack's drag threshold, and diagonally, so a drag that + // did start would land the item in a different cell. + page.mouse().move(button.x + 240, button.y + 200); + page.mouse().up(); + + assertEquals(before, slot(item), "the widget must not have moved"); + } + + @Test + void menuButton_opensTheActions() { + menuButton(widget()).click(); + + for (String caption : new String[] {"Refresh", "Maximize", "Duplicate", "Remove"}) { + assertThat(menuEntry(caption)).isVisible(); + } + } + + @Test + void maximize_expandsTheWidgetAndRestoresItsExactPosition() { + Locator item = widget(); + double widthBefore = item.boundingBox().width; + String before = slot(item); + + pickAction(item, "Maximize"); + assertThat(item).hasClass(Pattern.compile("dialect-maximized")); + page.waitForCondition(() -> item.boundingBox().width > widthBefore); + assertTrue(item.boundingBox().width > widthBefore, + "the maximized widget covers the grid"); + + pickAction(item, "Restore"); + // gridstack animates items, so the widget slides back rather than + // jumping: wait the animation out before measuring the final slot. + page.waitForCondition(() -> before.equals(slot(item))); + assertEquals(before, slot(item), "restored to the very same slot"); + } + + /** The first chart widget: the KPI tiles come first in the DOM and offer a + * smaller set of actions. */ + private Locator widget() { + return page.locator(".grid-stack-item:has(apex-chart:not(.dialect-sparkline))").first(); + } + + /** The item's box measured against the grid, not the viewport: the page + * around the grid may shift (dev tools coming and going), the widget's slot + * in the grid is what these tests are about. */ + private String slot(Locator item) { + return (String) item.evaluate(""" + el => { + const grid = el.closest('.grid-stack').getBoundingClientRect(); + const box = el.getBoundingClientRect(); + return [box.left - grid.left, box.top - grid.top, box.width, box.height] + .map(Math.round).join(','); + }"""); + } + + private Locator menuButton(Locator item) { + return item.locator(".dialect-action-menu vaadin-menu-bar-button").first(); + } + + private void pickAction(Locator item, String caption) { + dismissDevToolsOverlay(); + menuButton(item).click(); + menuEntry(caption).click(); + } + + /** A maximized widget puts its menu button in the top right corner of the + * viewport — in dev mode, that is where Vaadin's copilot renders a popover. + * It lives in the browser's top layer, so nothing on the page can be above + * it and it swallows the click; the app itself never sees it in + * production, so the test takes it out of the way. */ + private void dismissDevToolsOverlay() { + page.evaluate("() => document.querySelectorAll('copilot-main, vaadin-dev-tools')" + + ".forEach(el => el.remove())"); + } + + /** Only the entries of the overlay that is open right now: a menu-bar keeps + * the items of an already closed overlay around, hidden, and they carry the + * same captions. */ + private Locator menuEntry(String caption) { + return page.locator("vaadin-menu-bar-item:visible") + .filter(new Locator.FilterOptions().setHasText(caption)).first(); + } +} diff --git a/src/test/java/com/example/views/DashboardViewTest.java b/src/test/java/com/example/views/DashboardViewTest.java index c06cabd..4539890 100644 --- a/src/test/java/com/example/views/DashboardViewTest.java +++ b/src/test/java/com/example/views/DashboardViewTest.java @@ -3,6 +3,7 @@ package com.example.views; import com.example.Application; import com.example.components.BarChart; import com.example.components.GridStackItem; +import com.example.components.GridStackItem.Action; import com.example.components.GridStackLayout; import com.example.components.KpiTile; import com.example.components.LineChart; @@ -17,6 +18,7 @@ import com.vaadin.browserless.SpringBrowserlessTest; import com.vaadin.browserless.ViewPackages; import com.vaadin.browserless.internal.ElementUtilsKt; import com.vaadin.flow.component.button.Button; +import com.vaadin.flow.component.contextmenu.MenuItem; import com.vaadin.flow.component.dialog.Dialog; import com.vaadin.flow.component.html.Div; import com.vaadin.flow.dom.DomEvent; @@ -273,6 +275,95 @@ class DashboardViewTest extends SpringBrowserlessTest { assertFalse(item.getChildren().anyMatch(child -> child == item.getCloseButton())); } + @Test + void actionMenu_offersWhatTheWidgetTypeSupports() { + navigate(DashboardView.class); + + GridStackItem chart = itemById("revenue-trend"); + for (Action action : Action.values()) { + assertTrue(chart.isActionEnabled(action), + "a registry widget supports every action, missing: " + action); + } + + GridStackItem kpi = itemById("kpi-revenue"); + assertTrue(kpi.isActionEnabled(Action.REFRESH)); + assertFalse(kpi.isActionEnabled(Action.DUPLICATE), + "duplicating a KPI tile would show the same number twice"); + } + + @Test + void actionMenu_remove_removesThatWidgetOnly() { + navigate(DashboardView.class); + + GridStackLayout grid = $view(GridStackLayout.class).first(); + clickAction(itemById("revenue-trend"), Action.REMOVE); + + List layout = grid.getLayout(); + assertEquals(DEFAULT_WIDGETS - 1, layout.size()); + assertFalse(layout.stream().anyMatch(p -> "revenue-trend".equals(p.id()))); + } + + @Test + void actionMenu_maximize_togglesAndKeepsTheGridPosition() { + navigate(DashboardView.class); + + GridStackItem item = itemById("revenue-month"); + GridStackItem.Position before = positionOf("revenue-month"); + assertFalse(item.isMaximized()); + + clickAction(item, Action.MAXIMIZE); + assertTrue(item.isMaximized()); + assertTrue(item.getElement().getClassList().contains(GridStackItem.MAXIMIZED_CLASS)); + assertEquals(before, positionOf("revenue-month"), + "maximizing must not touch the item's grid placement"); + + clickAction(item, Action.MAXIMIZE); + assertFalse(item.isMaximized()); + assertFalse(item.getElement().getClassList().contains(GridStackItem.MAXIMIZED_CLASS)); + assertEquals(before, positionOf("revenue-month"), "restored to the very same slot"); + } + + @Test + void actionMenu_duplicate_addsAnotherWidgetOfTheSameType() { + navigate(DashboardView.class); + + GridStackLayout grid = $view(GridStackLayout.class).first(); + clickAction(itemById("revenue-region"), Action.DUPLICATE); + + assertEquals(DEFAULT_WIDGETS + 1, grid.getLayout().size()); + assertEquals(2, $view(PieChart.class).all().size(), "the copy renders the same chart"); + assertTrue($view(GridStackItem.class).last().getItemId().startsWith("revenue-region-"), + "the copy gets a fresh id, so it keeps its own saved position"); + } + + /** The tile is fed from the service, so overwriting its value and asking for + * a refresh must put the real number back. */ + @Test + void actionMenu_refresh_refeedsTheWidget() { + navigate(DashboardView.class); + + KpiTile revenue = $view(KpiTile.class).all().getFirst(); + String expected = revenue.getValue(); + revenue.setValue("stale"); + + clickAction(itemById("kpi-revenue"), Action.REFRESH); + + assertEquals(expected, revenue.getValue()); + } + + /** Fires the DOM click on the menu entry rather than calling the action + * directly, so the menu's own wiring is covered too. */ + private void clickAction(GridStackItem item, Action action) { + MenuItem entry = item.getActionItem(action); + ElementUtilsKt._fireDomEvent(entry.getElement(), + new DomEvent(entry.getElement(), "click", JacksonUtils.createObjectNode())); + } + + private GridStackItem.Position positionOf(String id) { + return $view(GridStackLayout.class).first().getLayout().stream() + .filter(p -> id.equals(p.id())).findFirst().orElseThrow(); + } + /** Fires the DOM click the close button listens for, rather than calling * {@code close()} directly, so the button's own wiring is covered too. */ private void clickCloseButton(GridStackItem item) { diff --git a/src/test/java/com/example/widgets/WidgetRegistryTest.java b/src/test/java/com/example/widgets/WidgetRegistryTest.java index 5fc3da9..7acc465 100644 --- a/src/test/java/com/example/widgets/WidgetRegistryTest.java +++ b/src/test/java/com/example/widgets/WidgetRegistryTest.java @@ -1,10 +1,12 @@ package com.example.widgets; import com.example.data.InMemoryChartDataService; +import com.vaadin.flow.component.html.Div; import org.junit.jupiter.api.Test; import java.util.List; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -29,6 +31,14 @@ class WidgetRegistryTest { "every definition carries a usable default size"); } + /** A widget with no refresher must not blow up when one is asked for: the + * dashboard offers the action per widget, the registry only carries it + * out. */ + @Test + void refresh_ignoresAWidgetThatCarriesNoRefresher() { + assertDoesNotThrow(() -> WidgetRegistry.refresh(new Div())); + } + @Test void require_rejectsAnUnknownType() { assertThrows(IllegalArgumentException.class, () -> registry.require("nope"));