fix: add a per-widget action menu (#27)
CI / build-and-test (pull_request) Successful in 2m25s
CI / build-and-test (pull_request) Successful in 2m25s
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0124BiJikbhbiEsNfJxdWM69
This commit is contained in:
@@ -27,6 +27,10 @@ public enum Fa {
|
|||||||
RESET("fa-solid", "fa-arrow-rotate-left"),
|
RESET("fa-solid", "fa-arrow-rotate-left"),
|
||||||
DRAG("fa-solid", "fa-grip-vertical"),
|
DRAG("fa-solid", "fa-grip-vertical"),
|
||||||
CLOSE("fa-solid", "fa-xmark"),
|
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_UP("fa-solid", "fa-arrow-trend-up"),
|
||||||
TREND_DOWN("fa-solid", "fa-arrow-trend-down");
|
TREND_DOWN("fa-solid", "fa-arrow-trend-down");
|
||||||
|
|
||||||
|
|||||||
@@ -3,9 +3,18 @@ package com.example.components;
|
|||||||
import com.vaadin.flow.component.Component;
|
import com.vaadin.flow.component.Component;
|
||||||
import com.vaadin.flow.component.ComponentEvent;
|
import com.vaadin.flow.component.ComponentEvent;
|
||||||
import com.vaadin.flow.component.ComponentEventListener;
|
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.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 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;
|
import java.util.UUID;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -19,6 +28,13 @@ import java.util.UUID;
|
|||||||
* the grip (see {@link #setClosable(boolean)}); removing the item server-side
|
* the grip (see {@link #setClosable(boolean)}); removing the item server-side
|
||||||
* is enough to unregister the widget client-side, since grid-stack.ts observes
|
* is enough to unregister the widget client-side, since grid-stack.ts observes
|
||||||
* its own childList.
|
* its own childList.
|
||||||
|
* <p>
|
||||||
|
* 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 {
|
public class GridStackItem extends Div {
|
||||||
|
|
||||||
@@ -30,10 +46,42 @@ public class GridStackItem extends Div {
|
|||||||
* styling and the grip's offset off it. */
|
* styling and the grip's offset off it. */
|
||||||
public static final String CLOSE_BUTTON_CLASS = "dialect-close-button";
|
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 content = new Div();
|
||||||
private final Div dragHandle = new Div();
|
private final Div dragHandle = new Div();
|
||||||
private final Div closeButton = new Div();
|
private final Div closeButton = new Div();
|
||||||
|
private final MenuBar actionMenu = new MenuBar();
|
||||||
|
private final Map<Action, MenuItem> actionItems = new EnumMap<>(Action.class);
|
||||||
|
private final Map<Action, Span> actionCaptions = new EnumMap<>(Action.class);
|
||||||
private boolean closable = true;
|
private boolean closable = true;
|
||||||
|
private boolean maximized;
|
||||||
|
|
||||||
public GridStackItem(Component... content) {
|
public GridStackItem(Component... content) {
|
||||||
this(UUID.randomUUID().toString(), 0, 0, 4, 3, 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))
|
closeButton.getElement().addEventListener("keydown", e -> close(true))
|
||||||
.setFilter("event.key === 'Enter' || event.key === ' '");
|
.setFilter("event.key === 'Enter' || event.key === ' '");
|
||||||
getElement().appendChild(closeButton.getElement());
|
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.
|
||||||
|
* <p>
|
||||||
|
* 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<Action> 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<ActionEvent> listener) {
|
||||||
|
return addListener(ActionEvent.class, listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fired after an action menu entry was picked. */
|
||||||
|
public static class ActionEvent extends ComponentEvent<GridStackItem> {
|
||||||
|
private final Action action;
|
||||||
|
|
||||||
|
ActionEvent(GridStackItem source, boolean fromClient, Action action) {
|
||||||
|
super(source, fromClient);
|
||||||
|
this.action = action;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Action getAction() {
|
||||||
|
return action;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package com.example.views;
|
|||||||
import com.example.components.Card;
|
import com.example.components.Card;
|
||||||
import com.example.components.Fa;
|
import com.example.components.Fa;
|
||||||
import com.example.components.GridStackItem;
|
import com.example.components.GridStackItem;
|
||||||
|
import com.example.components.GridStackItem.Action;
|
||||||
import com.example.components.GridStackLayout;
|
import com.example.components.GridStackLayout;
|
||||||
import com.example.components.KpiTile;
|
import com.example.components.KpiTile;
|
||||||
import com.example.data.ChartDataService;
|
import com.example.data.ChartDataService;
|
||||||
@@ -11,6 +12,7 @@ import com.example.data.KpiData;
|
|||||||
import com.example.widgets.DashboardContext;
|
import com.example.widgets.DashboardContext;
|
||||||
import com.example.widgets.WidgetDefinition;
|
import com.example.widgets.WidgetDefinition;
|
||||||
import com.example.widgets.WidgetRegistry;
|
import com.example.widgets.WidgetRegistry;
|
||||||
|
import com.vaadin.flow.component.Component;
|
||||||
import com.vaadin.flow.component.button.Button;
|
import com.vaadin.flow.component.button.Button;
|
||||||
import com.vaadin.flow.component.button.ButtonVariant;
|
import com.vaadin.flow.component.button.ButtonVariant;
|
||||||
import com.vaadin.flow.component.dialog.Dialog;
|
import com.vaadin.flow.component.dialog.Dialog;
|
||||||
@@ -78,7 +80,16 @@ public class DashboardView extends VerticalLayout implements HasDynamicTitle {
|
|||||||
KpiData kpi = kpis.get(i);
|
KpiData kpi = kpis.get(i);
|
||||||
KpiTile tile = feed(new KpiTile(getTranslation(kpi.labelKey()), ""), kpi);
|
KpiTile tile = feed(new KpiTile(getTranslation(kpi.labelKey()), ""), kpi);
|
||||||
kpiTiles.put(kpi.id(), tile);
|
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);
|
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);
|
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,
|
private GridStackItem widget(String id, WidgetDefinition definition,
|
||||||
int x, int y, int w, int h) {
|
int x, int y, int w, int h) {
|
||||||
return new GridStackItem(id, x, y, w, h, new Card(getTranslation(definition.titleKey()),
|
Component content = definition.factory().apply(context);
|
||||||
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
|
/** Re-feeds the tiles still on the dashboard. A closed tile keeps its entry
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import com.example.data.ChartDataService;
|
|||||||
import com.example.data.ChartSeries;
|
import com.example.data.ChartSeries;
|
||||||
import com.example.data.DashboardFilter;
|
import com.example.data.DashboardFilter;
|
||||||
import com.vaadin.flow.component.Component;
|
import com.vaadin.flow.component.Component;
|
||||||
|
import com.vaadin.flow.component.ComponentUtil;
|
||||||
import com.vaadin.flow.component.notification.Notification;
|
import com.vaadin.flow.component.notification.Notification;
|
||||||
import com.vaadin.flow.shared.Registration;
|
import com.vaadin.flow.shared.Registration;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
@@ -54,6 +55,27 @@ public class WidgetRegistry {
|
|||||||
this::pieChart));
|
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. */
|
/** Adds a definition, replacing any earlier one of the same type. */
|
||||||
public final void register(WidgetDefinition definition) {
|
public final void register(WidgetDefinition definition) {
|
||||||
definitions.put(definition.type(), definition);
|
definitions.put(definition.type(), definition);
|
||||||
@@ -107,6 +129,8 @@ public class WidgetRegistry {
|
|||||||
feed.accept(context.getFilter());
|
feed.accept(context.getFilter());
|
||||||
Registration registration = context.addFilterChangeListener(feed);
|
Registration registration = context.addFilterChangeListener(feed);
|
||||||
chart.addDetachListener(e -> registration.remove());
|
chart.addDetachListener(e -> registration.remove());
|
||||||
|
ComponentUtil.setData(chart, Refresh.class,
|
||||||
|
(Refresh) () -> feed.accept(context.getFilter()));
|
||||||
return sizeFull(chart);
|
return sizeFull(chart);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -302,10 +302,40 @@ apex-chart.dialect-sparkline .apexcharts-xaxis {
|
|||||||
transition: opacity 120ms ease;
|
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;
|
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,
|
.grid-stack-item:hover > .dialect-close-button,
|
||||||
.dialect-close-button:focus-visible {
|
.dialect-close-button:focus-visible {
|
||||||
opacity: 0.65;
|
opacity: 0.65;
|
||||||
@@ -320,11 +350,38 @@ apex-chart.dialect-sparkline .apexcharts-xaxis {
|
|||||||
/* Touch devices never hover — keep the corner controls permanently visible. */
|
/* Touch devices never hover — keep the corner controls permanently visible. */
|
||||||
@media (pointer: coarse) {
|
@media (pointer: coarse) {
|
||||||
.dialect-drag-handle,
|
.dialect-drag-handle,
|
||||||
.dialect-close-button {
|
.dialect-close-button,
|
||||||
|
.dialect-action-menu {
|
||||||
opacity: 0.65;
|
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 {
|
.grid-stack-item[gs-no-move] > .dialect-drag-handle {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ card.registration=Registrierung
|
|||||||
card.employees=Mitarbeiter
|
card.employees=Mitarbeiter
|
||||||
card.gridstackHint=Bedienung
|
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.addWidget=Widget hinzufügen
|
||||||
gridstack.reset=Layout zurücksetzen
|
gridstack.reset=Layout zurücksetzen
|
||||||
gridstack.status=Layout geändert – {0} Widgets
|
gridstack.status=Layout geändert – {0} Widgets
|
||||||
@@ -30,6 +30,12 @@ gridstack.statusInitial=Layout unverändert
|
|||||||
gridstack.pickerTitle=Widget auswählen
|
gridstack.pickerTitle=Widget auswählen
|
||||||
gridstack.dragHandle=Verschieben
|
gridstack.dragHandle=Verschieben
|
||||||
gridstack.close=Schließen
|
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=Zeitraum
|
||||||
filter.period.month=Monat
|
filter.period.month=Monat
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ card.registration=Registration
|
|||||||
card.employees=Employees
|
card.employees=Employees
|
||||||
card.gridstackHint=How it works
|
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.addWidget=Add widget
|
||||||
gridstack.reset=Reset layout
|
gridstack.reset=Reset layout
|
||||||
gridstack.status=Layout changed – {0} widgets
|
gridstack.status=Layout changed – {0} widgets
|
||||||
@@ -30,6 +30,12 @@ gridstack.statusInitial=Layout unchanged
|
|||||||
gridstack.pickerTitle=Choose a widget
|
gridstack.pickerTitle=Choose a widget
|
||||||
gridstack.dragHandle=Move
|
gridstack.dragHandle=Move
|
||||||
gridstack.close=Close
|
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=Period
|
||||||
filter.period.month=Month
|
filter.period.month=Month
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ card.registration=Registro
|
|||||||
card.employees=Empleados
|
card.employees=Empleados
|
||||||
card.gridstackHint=Cómo funciona
|
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.addWidget=Añadir widget
|
||||||
gridstack.reset=Restablecer diseño
|
gridstack.reset=Restablecer diseño
|
||||||
gridstack.status=Diseño modificado – {0} widgets
|
gridstack.status=Diseño modificado – {0} widgets
|
||||||
@@ -30,6 +30,12 @@ gridstack.statusInitial=Diseño sin cambios
|
|||||||
gridstack.pickerTitle=Elegir un widget
|
gridstack.pickerTitle=Elegir un widget
|
||||||
gridstack.dragHandle=Mover
|
gridstack.dragHandle=Mover
|
||||||
gridstack.close=Cerrar
|
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=Periodo
|
||||||
filter.period.month=Mes
|
filter.period.month=Mes
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ package com.example.views;
|
|||||||
import com.example.Application;
|
import com.example.Application;
|
||||||
import com.example.components.BarChart;
|
import com.example.components.BarChart;
|
||||||
import com.example.components.GridStackItem;
|
import com.example.components.GridStackItem;
|
||||||
|
import com.example.components.GridStackItem.Action;
|
||||||
import com.example.components.GridStackLayout;
|
import com.example.components.GridStackLayout;
|
||||||
import com.example.components.KpiTile;
|
import com.example.components.KpiTile;
|
||||||
import com.example.components.LineChart;
|
import com.example.components.LineChart;
|
||||||
@@ -17,6 +18,7 @@ import com.vaadin.browserless.SpringBrowserlessTest;
|
|||||||
import com.vaadin.browserless.ViewPackages;
|
import com.vaadin.browserless.ViewPackages;
|
||||||
import com.vaadin.browserless.internal.ElementUtilsKt;
|
import com.vaadin.browserless.internal.ElementUtilsKt;
|
||||||
import com.vaadin.flow.component.button.Button;
|
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.dialog.Dialog;
|
||||||
import com.vaadin.flow.component.html.Div;
|
import com.vaadin.flow.component.html.Div;
|
||||||
import com.vaadin.flow.dom.DomEvent;
|
import com.vaadin.flow.dom.DomEvent;
|
||||||
@@ -273,6 +275,95 @@ class DashboardViewTest extends SpringBrowserlessTest {
|
|||||||
assertFalse(item.getChildren().anyMatch(child -> child == item.getCloseButton()));
|
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<GridStackItem.Position> 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
|
/** Fires the DOM click the close button listens for, rather than calling
|
||||||
* {@code close()} directly, so the button's own wiring is covered too. */
|
* {@code close()} directly, so the button's own wiring is covered too. */
|
||||||
private void clickCloseButton(GridStackItem item) {
|
private void clickCloseButton(GridStackItem item) {
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
package com.example.widgets;
|
package com.example.widgets;
|
||||||
|
|
||||||
import com.example.data.InMemoryChartDataService;
|
import com.example.data.InMemoryChartDataService;
|
||||||
|
import com.vaadin.flow.component.html.Div;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
import java.util.List;
|
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.assertEquals;
|
||||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
@@ -29,6 +31,14 @@ class WidgetRegistryTest {
|
|||||||
"every definition carries a usable default size");
|
"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
|
@Test
|
||||||
void require_rejectsAnUnknownType() {
|
void require_rejectsAnUnknownType() {
|
||||||
assertThrows(IllegalArgumentException.class, () -> registry.require("nope"));
|
assertThrows(IllegalArgumentException.class, () -> registry.require("nope"));
|
||||||
|
|||||||
Reference in New Issue
Block a user