Compare commits
6 Commits
cd1d8f494d
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 0a8f7408f1 | |||
| 7a4bc72799 | |||
| 505ad32d4a | |||
| dbe304e61e | |||
| 114f00523b | |||
| 4878a7fc06 |
@@ -11,6 +11,13 @@ class GridStackLayout extends HTMLElement {
|
|||||||
private observer?: MutationObserver;
|
private observer?: MutationObserver;
|
||||||
private storageKey: string | null = null;
|
private storageKey: string | null = null;
|
||||||
private persistTimer?: ReturnType<typeof setTimeout>;
|
private persistTimer?: ReturnType<typeof setTimeout>;
|
||||||
|
/** The column count configured server-side (before any responsive
|
||||||
|
* collapse) — {@link persist} compares the live column count against
|
||||||
|
* this to tell a real layout change from gridstack's own breakpoint
|
||||||
|
* collapse. */
|
||||||
|
private fullColumn = 12;
|
||||||
|
private mobileQuery?: MediaQueryList;
|
||||||
|
private mobileQueryHandler?: (e: MediaQueryListEvent) => void;
|
||||||
|
|
||||||
connectedCallback() {
|
connectedCallback() {
|
||||||
this.observer = new MutationObserver((mutations) => this.onMutation(mutations));
|
this.observer = new MutationObserver((mutations) => this.onMutation(mutations));
|
||||||
@@ -20,6 +27,11 @@ class GridStackLayout extends HTMLElement {
|
|||||||
disconnectedCallback() {
|
disconnectedCallback() {
|
||||||
this.observer?.disconnect();
|
this.observer?.disconnect();
|
||||||
this.observer = undefined;
|
this.observer = undefined;
|
||||||
|
if (this.mobileQuery && this.mobileQueryHandler) {
|
||||||
|
this.mobileQuery.removeEventListener('change', this.mobileQueryHandler);
|
||||||
|
}
|
||||||
|
this.mobileQuery = undefined;
|
||||||
|
this.mobileQueryHandler = undefined;
|
||||||
// Write out a still-pending debounced save instead of dropping it:
|
// Write out a still-pending debounced save instead of dropping it:
|
||||||
// a drag/resize followed straight away by navigating to another route
|
// a drag/resize followed straight away by navigating to another route
|
||||||
// detaches this element inside the debounce window, which used to lose
|
// detaches this element inside the debounce window, which used to lose
|
||||||
@@ -39,6 +51,7 @@ class GridStackLayout extends HTMLElement {
|
|||||||
|
|
||||||
this.classList.add('grid-stack');
|
this.classList.add('grid-stack');
|
||||||
const options = JSON.parse(optionsJson);
|
const options = JSON.parse(optionsJson);
|
||||||
|
this.fullColumn = options.column ?? this.fullColumn;
|
||||||
const grid = GridStack.init(options, this);
|
const grid = GridStack.init(options, this);
|
||||||
if (!grid) return;
|
if (!grid) return;
|
||||||
this.grid = grid;
|
this.grid = grid;
|
||||||
@@ -48,6 +61,19 @@ class GridStackLayout extends HTMLElement {
|
|||||||
|
|
||||||
grid.on('change added removed', () => this.schedulePersist());
|
grid.on('change added removed', () => this.schedulePersist());
|
||||||
grid.on('resizestop', () => window.dispatchEvent(new Event('resize')));
|
grid.on('resizestop', () => window.dispatchEvent(new Event('resize')));
|
||||||
|
|
||||||
|
// Below the responsive breakpoint, disable drag/resize: rearranging a
|
||||||
|
// stacked mobile layout by touch is mostly misfires, and it's the
|
||||||
|
// desktop layout — not the stacked one — that's worth protecting from
|
||||||
|
// an accidental drag. Mirrors the same width gridstack's own
|
||||||
|
// columnOpts.breakpoints collapses at, so both switch together.
|
||||||
|
const breakpointWidth: number | undefined = options.columnOpts?.breakpoints?.[0]?.w;
|
||||||
|
if (breakpointWidth) {
|
||||||
|
this.mobileQuery = window.matchMedia(`(max-width: ${breakpointWidth}px)`);
|
||||||
|
this.mobileQueryHandler = (e) => this.grid?.setStatic(e.matches);
|
||||||
|
this.mobileQuery.addEventListener('change', this.mobileQueryHandler);
|
||||||
|
this.grid.setStatic(this.mobileQuery.matches);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Drops any saved layout for this grid and re-applies the positions
|
/** Drops any saved layout for this grid and re-applies the positions
|
||||||
@@ -119,6 +145,12 @@ class GridStackLayout extends HTMLElement {
|
|||||||
|
|
||||||
private persist(notifyServer = true) {
|
private persist(notifyServer = true) {
|
||||||
if (!this.grid) return;
|
if (!this.grid) return;
|
||||||
|
// gridstack's responsive columnOpts collapse fires the same 'change'
|
||||||
|
// event a real drag/resize would, with save() reporting whatever
|
||||||
|
// shape the engine forced items into at 1 column. That shape is not
|
||||||
|
// something the user chose — persisting it would silently overwrite
|
||||||
|
// the desktop layout the next time the window narrows.
|
||||||
|
if (this.grid.getColumn() !== this.fullColumn) return;
|
||||||
const nodes = this.grid.save(false) as GridStackNode[];
|
const nodes = this.grid.save(false) as GridStackNode[];
|
||||||
if (this.storageKey) {
|
if (this.storageKey) {
|
||||||
localStorage.setItem(STORAGE_PREFIX + this.storageKey, JSON.stringify(nodes));
|
localStorage.setItem(STORAGE_PREFIX + this.storageKey, JSON.stringify(nodes));
|
||||||
|
|||||||
@@ -312,10 +312,26 @@ public class GridStackItem extends Div {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void close(boolean fromClient) {
|
private void close(boolean fromClient) {
|
||||||
|
// Captured before detaching: once removeFromParent() runs, the grid no
|
||||||
|
// longer reports this item, so its position could not be read back from
|
||||||
|
// there afterwards. The gs-* attributes themselves are untouched by the
|
||||||
|
// detach, so a caller that keeps this item around and re-adds it later
|
||||||
|
// (e.g. to undo the close) lands it back at exactly this spot.
|
||||||
|
Position position = capturePosition();
|
||||||
// Detach first so listeners observe the layout they are about to see —
|
// Detach first so listeners observe the layout they are about to see —
|
||||||
// getLayout() on the grid no longer counts this item.
|
// getLayout() on the grid no longer counts this item.
|
||||||
getElement().removeFromParent();
|
getElement().removeFromParent();
|
||||||
fireEvent(new CloseEvent(this, fromClient));
|
fireEvent(new CloseEvent(this, fromClient, position));
|
||||||
|
}
|
||||||
|
|
||||||
|
private Position capturePosition() {
|
||||||
|
return new Position(getItemId(),
|
||||||
|
attrInt("gs-x"), attrInt("gs-y"), attrInt("gs-w"), attrInt("gs-h"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private int attrInt(String name) {
|
||||||
|
String value = getElement().getAttribute(name);
|
||||||
|
return value == null ? 0 : Integer.parseInt(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Shows or hides the close button (shown by default). */
|
/** Shows or hides the close button (shown by default). */
|
||||||
@@ -346,10 +362,23 @@ public class GridStackItem extends Div {
|
|||||||
return addListener(CloseEvent.class, listener);
|
return addListener(CloseEvent.class, listener);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Fired after the item has been removed from its {@link GridStackLayout}. */
|
/** Fired after the item has been removed from its {@link GridStackLayout}.
|
||||||
|
* Carries the {@link Position} it held right before detaching, so a
|
||||||
|
* listener can offer to undo the close (re-adding the item at that exact
|
||||||
|
* spot) without having to track positions itself. {@link #isFromClient()}
|
||||||
|
* tells apart a user-initiated close (button/menu) from a programmatic
|
||||||
|
* {@link GridStackItem#close()} — an undo affordance only makes sense for
|
||||||
|
* the former. */
|
||||||
public static class CloseEvent extends ComponentEvent<GridStackItem> {
|
public static class CloseEvent extends ComponentEvent<GridStackItem> {
|
||||||
CloseEvent(GridStackItem source, boolean fromClient) {
|
private final Position position;
|
||||||
|
|
||||||
|
CloseEvent(GridStackItem source, boolean fromClient, Position position) {
|
||||||
super(source, fromClient);
|
super(source, fromClient);
|
||||||
|
this.position = position;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Position getPosition() {
|
||||||
|
return position;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -102,6 +102,30 @@ public class GridStackLayout extends Component implements HasSize, HasStyle {
|
|||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Below {@code maxWidthPx} (measured against the browser window, not just
|
||||||
|
* this element's own width), gridstack collapses to {@code columns}
|
||||||
|
* columns — pass 1 to stack every item full-width, which is what makes a
|
||||||
|
* chart legible on a phone. Drag/resize is disabled below the same
|
||||||
|
* threshold (see {@code grid-stack.ts}): rearranging a stacked mobile
|
||||||
|
* layout by touch is mostly misfires, and gridstack still restores the
|
||||||
|
* pre-collapse positions once the window widens back past the threshold —
|
||||||
|
* that restore is the engine's own column-change cache, not something this
|
||||||
|
* class drives. Persisting to {@code localStorage} (see
|
||||||
|
* {@link #setStorageKey(String)}) is suppressed while collapsed, so a
|
||||||
|
* narrowed window can never overwrite the saved desktop layout.
|
||||||
|
*/
|
||||||
|
public GridStackLayout setResponsiveBreakpoint(int maxWidthPx, int columns) {
|
||||||
|
Map<String, Object> breakpoint = new LinkedHashMap<>();
|
||||||
|
breakpoint.put("w", maxWidthPx);
|
||||||
|
breakpoint.put("c", columns);
|
||||||
|
Map<String, Object> columnOpts = new LinkedHashMap<>();
|
||||||
|
columnOpts.put("breakpointForWindow", true);
|
||||||
|
columnOpts.put("breakpoints", List.of(breakpoint));
|
||||||
|
options.put("columnOpts", columnOpts);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Enables browser-localStorage persistence of the layout under the given
|
* Enables browser-localStorage persistence of the layout under the given
|
||||||
* key (shared across sessions/tabs on the same origin — pick something
|
* key (shared across sessions/tabs on the same origin — pick something
|
||||||
|
|||||||
@@ -17,8 +17,10 @@ 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;
|
||||||
|
import com.vaadin.flow.component.html.Div;
|
||||||
import com.vaadin.flow.component.html.Paragraph;
|
import com.vaadin.flow.component.html.Paragraph;
|
||||||
import com.vaadin.flow.component.html.Span;
|
import com.vaadin.flow.component.html.Span;
|
||||||
|
import com.vaadin.flow.component.notification.Notification;
|
||||||
import com.vaadin.flow.component.orderedlayout.HorizontalLayout;
|
import com.vaadin.flow.component.orderedlayout.HorizontalLayout;
|
||||||
import com.vaadin.flow.component.orderedlayout.VerticalLayout;
|
import com.vaadin.flow.component.orderedlayout.VerticalLayout;
|
||||||
import com.vaadin.flow.router.HasDynamicTitle;
|
import com.vaadin.flow.router.HasDynamicTitle;
|
||||||
@@ -40,7 +42,8 @@ import java.util.Optional;
|
|||||||
* layout is persisted per browser, plus controls to add widgets and reset the
|
* layout is persisted per browser, plus controls to add widgets and reset the
|
||||||
* layout at runtime. Widgets are removed by their own close button (see
|
* layout at runtime. Widgets are removed by their own close button (see
|
||||||
* {@link GridStackItem#setClosable(boolean)}), not from the toolbar, and are
|
* {@link GridStackItem#setClosable(boolean)}), not from the toolbar, and are
|
||||||
* added back from the picker over {@link WidgetRegistry}.
|
* added back from the picker over {@link WidgetRegistry} — or undone straight
|
||||||
|
* from the toast every close spawns (see {@link #offerUndo}).
|
||||||
* <p>
|
* <p>
|
||||||
* All widget numbers come from {@link ChartDataService} and every chart widget
|
* All widget numbers come from {@link ChartDataService} and every chart widget
|
||||||
* is built by the registry; this view only decides where a widget sits and
|
* is built by the registry; this view only decides where a widget sits and
|
||||||
@@ -56,6 +59,10 @@ public class DashboardView extends VerticalLayout implements HasDynamicTitle {
|
|||||||
|
|
||||||
private static final String STORAGE_KEY = "dashboard";
|
private static final String STORAGE_KEY = "dashboard";
|
||||||
|
|
||||||
|
/** Matches gridstack's {@code columnOpts} breakpoint below which the grid
|
||||||
|
* stacks to a single column (see {@link GridStackLayout#setResponsiveBreakpoint}). */
|
||||||
|
private static final int MOBILE_BREAKPOINT_PX = 768;
|
||||||
|
|
||||||
/** A KPI tile is a quarter row wide, so the n-th one starts at 3n. */
|
/** A KPI tile is a quarter row wide, so the n-th one starts at 3n. */
|
||||||
private static final int KPI_WIDTH = 3;
|
private static final int KPI_WIDTH = 3;
|
||||||
|
|
||||||
@@ -64,6 +71,10 @@ public class DashboardView extends VerticalLayout implements HasDynamicTitle {
|
|||||||
private final DashboardContext context = new DashboardContext();
|
private final DashboardContext context = new DashboardContext();
|
||||||
private final GridStackLayout grid = new GridStackLayout();
|
private final GridStackLayout grid = new GridStackLayout();
|
||||||
private final Span status = new Span();
|
private final Span status = new Span();
|
||||||
|
/** Shown instead of {@link #grid} once every widget has been closed; not a
|
||||||
|
* {@link GridStackItem} itself, so it never becomes draggable and never
|
||||||
|
* shows up in {@link GridStackLayout#getLayout()}. */
|
||||||
|
private final Div emptyState = new Div();
|
||||||
/** The KPI tiles by KPI id, so a filter change re-feeds each tile with the
|
/** The KPI tiles by KPI id, so a filter change re-feeds each tile with the
|
||||||
* data of the same KPI rather than by position. */
|
* data of the same KPI rather than by position. */
|
||||||
private final Map<String, KpiTile> kpiTiles = new LinkedHashMap<>();
|
private final Map<String, KpiTile> kpiTiles = new LinkedHashMap<>();
|
||||||
@@ -76,13 +87,29 @@ public class DashboardView extends VerticalLayout implements HasDynamicTitle {
|
|||||||
|
|
||||||
grid.setWidthFull();
|
grid.setWidthFull();
|
||||||
grid.setStorageKey(STORAGE_KEY);
|
grid.setStorageKey(STORAGE_KEY);
|
||||||
|
// Below phone/small-tablet width, stack every widget full-width.
|
||||||
|
grid.setResponsiveBreakpoint(MOBILE_BREAKPOINT_PX, 1);
|
||||||
grid.addLayoutChangeListener(e -> status.setText(
|
grid.addLayoutChangeListener(e -> status.setText(
|
||||||
getTranslation("gridstack.status", e.getPositions().size())));
|
getTranslation("gridstack.status", e.getPositions().size())));
|
||||||
|
|
||||||
// KPI tiles first: the numbers a dashboard is read for, above the charts
|
configureEmptyState();
|
||||||
// that explain them. They are 3x1 — a quarter row each, one cell high,
|
buildDefaultWidgets();
|
||||||
// laid out left to right in the order the service returns them. The
|
context.addFilterChangeListener(this::updateKpiTiles);
|
||||||
// grid id is the KPI's own id, so it survives reordering.
|
|
||||||
|
status.setText(getTranslation("gridstack.statusInitial"));
|
||||||
|
status.addClassName("dialect-muted");
|
||||||
|
|
||||||
|
add(toolbar(), new DashboardFilterBar(context), grid, emptyState);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The dashboard's initial widget set: the KPI tiles first — the numbers a
|
||||||
|
* dashboard is read for, above the charts that explain them — then the
|
||||||
|
* three default charts and the usage hint. Also used to rebuild the
|
||||||
|
* dashboard from scratch via {@link #restoreDefaultWidgets()}. */
|
||||||
|
private void buildDefaultWidgets() {
|
||||||
|
// They are 3x1 — a quarter row each, one cell high, laid out left to
|
||||||
|
// right in the order the service returns them. The grid id is the
|
||||||
|
// KPI's own id, so it survives reordering.
|
||||||
List<KpiData> kpis = dataService.kpis(context.getFilter());
|
List<KpiData> kpis = dataService.kpis(context.getFilter());
|
||||||
for (int i = 0; i < kpis.size(); i++) {
|
for (int i = 0; i < kpis.size(); i++) {
|
||||||
KpiData kpi = kpis.get(i);
|
KpiData kpi = kpis.get(i);
|
||||||
@@ -97,22 +124,61 @@ public class DashboardView extends VerticalLayout implements HasDynamicTitle {
|
|||||||
refreshKpiTile(kpi.id());
|
refreshKpiTile(kpi.id());
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
grid.add(item);
|
addWidgetToGrid(item);
|
||||||
}
|
}
|
||||||
context.addFilterChangeListener(this::updateKpiTiles);
|
|
||||||
|
|
||||||
grid.add(
|
List.of(
|
||||||
defaultWidget(WidgetRegistry.REVENUE_TREND, 0, 1, 6, 3),
|
defaultWidget(WidgetRegistry.REVENUE_TREND, 0, 1, 6, 3),
|
||||||
defaultWidget(WidgetRegistry.REVENUE_MONTH, 6, 1, 6, 3),
|
defaultWidget(WidgetRegistry.REVENUE_MONTH, 6, 1, 6, 3),
|
||||||
defaultWidget(WidgetRegistry.REVENUE_REGION, 0, 4, 5, 3),
|
defaultWidget(WidgetRegistry.REVENUE_REGION, 0, 4, 5, 3),
|
||||||
new GridStackItem("hint", 5, 4, 7, 3,
|
new GridStackItem("hint", 5, 4, 7, 3,
|
||||||
new Card(getTranslation("card.gridstackHint"),
|
new Card(getTranslation("card.gridstackHint"),
|
||||||
new Paragraph(getTranslation("gridstack.hint")))));
|
new Paragraph(getTranslation("gridstack.hint")))))
|
||||||
|
.forEach(this::addWidgetToGrid);
|
||||||
|
}
|
||||||
|
|
||||||
status.setText(getTranslation("gridstack.statusInitial"));
|
/** Builds the placeholder shown once every widget has been closed: a short
|
||||||
status.addClassName("dialect-muted");
|
* explanation, a CTA that opens the same {@link #openWidgetPicker() widget
|
||||||
|
* picker} as the toolbar, and a secondary action that rebuilds the default
|
||||||
|
* layout — {@link GridStackLayout#resetLayout()} alone cannot do that here,
|
||||||
|
* since it only repositions widgets still present, and none are left. */
|
||||||
|
private void configureEmptyState() {
|
||||||
|
emptyState.addClassName("dialect-empty-state");
|
||||||
|
|
||||||
add(toolbar(), new DashboardFilterBar(context), grid);
|
var icon = Fa.GRID.create();
|
||||||
|
icon.addClassName("dialect-empty-state__icon");
|
||||||
|
|
||||||
|
Span title = new Span(getTranslation("gridstack.emptyTitle"));
|
||||||
|
title.addClassName("dialect-empty-state__title");
|
||||||
|
Span hint = new Span(getTranslation("gridstack.emptyHint"));
|
||||||
|
hint.addClassName("dialect-muted");
|
||||||
|
|
||||||
|
Button add = new Button(getTranslation("gridstack.addWidget"), Fa.ADD.create(),
|
||||||
|
e -> openWidgetPicker());
|
||||||
|
add.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
|
||||||
|
Button restore = new Button(getTranslation("gridstack.restoreDefaults"), Fa.RESET.create(),
|
||||||
|
e -> restoreDefaultWidgets());
|
||||||
|
|
||||||
|
HorizontalLayout actions = new HorizontalLayout(add, restore);
|
||||||
|
actions.addClassName("dialect-empty-state__actions");
|
||||||
|
|
||||||
|
emptyState.add(icon, title, hint, actions);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clears the stale KPI tile references and rebuilds the initial widget set
|
||||||
|
* — the empty state's secondary action. */
|
||||||
|
private void restoreDefaultWidgets() {
|
||||||
|
kpiTiles.clear();
|
||||||
|
buildDefaultWidgets();
|
||||||
|
grid.resetLayout();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Toggles {@link #grid} and {@link #emptyState} based on whether any
|
||||||
|
* widget is left — called from every path that adds or removes one. */
|
||||||
|
private void updateEmptyState() {
|
||||||
|
boolean empty = grid.getLayout().isEmpty();
|
||||||
|
emptyState.setVisible(empty);
|
||||||
|
grid.setVisible(!empty);
|
||||||
}
|
}
|
||||||
|
|
||||||
private HorizontalLayout toolbar() {
|
private HorizontalLayout toolbar() {
|
||||||
@@ -160,10 +226,60 @@ public class DashboardView extends VerticalLayout implements HasDynamicTitle {
|
|||||||
// widget must not hand its id to the next one, or the new widget would
|
// widget must not hand its id to the next one, or the new widget would
|
||||||
// inherit the closed one's saved position.
|
// inherit the closed one's saved position.
|
||||||
extraWidgetCount++;
|
extraWidgetCount++;
|
||||||
grid.add(widget(definition.type() + "-" + extraWidgetCount, definition,
|
addWidgetToGrid(widget(definition.type() + "-" + extraWidgetCount, definition,
|
||||||
0, 0, definition.width(), definition.height()));
|
0, 0, definition.width(), definition.height()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Adds an item to the grid and wires the undo toast onto its close — every
|
||||||
|
* widget goes through here exactly once, so the close listener (and thus
|
||||||
|
* the undo offer) is registered once per item, not once per re-add: an
|
||||||
|
* undo hands the very same instance back to {@link #grid} directly. */
|
||||||
|
private void addWidgetToGrid(GridStackItem item) {
|
||||||
|
item.addCloseListener(this::offerUndo);
|
||||||
|
grid.add(item);
|
||||||
|
updateEmptyState();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Closing a widget is one misclick away from losing its position, and a
|
||||||
|
* confirmation dialog on every close would be worse than the problem — so
|
||||||
|
* this shows a dismissible undo toast instead. Skipped for programmatic
|
||||||
|
* closes ({@link GridStackItem#close()}, e.g. from duplicate cleanup),
|
||||||
|
* which is exactly what {@link GridStackItem.CloseEvent#isFromClient()}
|
||||||
|
* tells apart.
|
||||||
|
* <p>
|
||||||
|
* Undo re-adds the very item that was closed — not a rebuilt copy — at the
|
||||||
|
* {@link GridStackItem.Position} the event captured before detaching, so it
|
||||||
|
* keeps its {@code gs-id} and its exact size/position rather than landing
|
||||||
|
* in the grid's next free slot.
|
||||||
|
*/
|
||||||
|
private void offerUndo(GridStackItem.CloseEvent event) {
|
||||||
|
updateEmptyState();
|
||||||
|
if (!event.isFromClient()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
GridStackItem item = event.getSource();
|
||||||
|
GridStackItem.Position position = event.getPosition();
|
||||||
|
|
||||||
|
Notification toast = new Notification();
|
||||||
|
toast.setDuration(8000);
|
||||||
|
toast.setPosition(Notification.Position.BOTTOM_START);
|
||||||
|
|
||||||
|
Button undo = new Button(getTranslation("gridstack.undo"), e -> {
|
||||||
|
item.setPosition(position.x(), position.y(), position.w(), position.h());
|
||||||
|
grid.add(item);
|
||||||
|
updateEmptyState();
|
||||||
|
toast.close();
|
||||||
|
});
|
||||||
|
undo.addThemeVariants(ButtonVariant.LUMO_TERTIARY_INLINE);
|
||||||
|
|
||||||
|
HorizontalLayout content = new HorizontalLayout(
|
||||||
|
new Span(getTranslation("gridstack.closed")), undo);
|
||||||
|
content.setAlignItems(Alignment.CENTER);
|
||||||
|
toast.add(content);
|
||||||
|
toast.open();
|
||||||
|
}
|
||||||
|
|
||||||
/** A widget of the initial set. Its id is the plain type — never handed out
|
/** A widget of the initial set. Its id is the plain type — never handed out
|
||||||
* again by {@link #addWidget(WidgetDefinition)}, which always suffixes a
|
* again by {@link #addWidget(WidgetDefinition)}, which always suffixes a
|
||||||
* counter — and it keeps the dashboard's own placement rather than the
|
* counter — and it keeps the dashboard's own placement rather than the
|
||||||
|
|||||||
@@ -419,4 +419,33 @@ apex-chart.dialect-sparkline .apexcharts-xaxis {
|
|||||||
background: var(--dialect-bg);
|
background: var(--dialect-bg);
|
||||||
border: 2px dashed var(--dialect-border);
|
border: 2px dashed var(--dialect-border);
|
||||||
border-radius: var(--dialect-radius);
|
border-radius: var(--dialect-radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* DashboardView's placeholder for a widget-less dashboard (not a
|
||||||
|
GridStackItem, see DashboardView.emptyState) — dashed like gridstack's own
|
||||||
|
drop placeholder above, to read as "nothing here yet" rather than a card. */
|
||||||
|
.dialect-empty-state {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 48px 24px;
|
||||||
|
text-align: center;
|
||||||
|
border: 2px dashed var(--dialect-border);
|
||||||
|
border-radius: var(--dialect-radius);
|
||||||
|
color: var(--dialect-ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialect-empty-state__icon {
|
||||||
|
font-size: 2rem;
|
||||||
|
color: var(--dialect-primary);
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialect-empty-state__title {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialect-empty-state__actions {
|
||||||
|
gap: 8px;
|
||||||
}
|
}
|
||||||
@@ -37,6 +37,11 @@ gridstack.restore=Wiederherstellen
|
|||||||
gridstack.duplicate=Duplizieren
|
gridstack.duplicate=Duplizieren
|
||||||
gridstack.remove=Entfernen
|
gridstack.remove=Entfernen
|
||||||
gridstack.export=Als CSV exportieren
|
gridstack.export=Als CSV exportieren
|
||||||
|
gridstack.closed=Widget entfernt
|
||||||
|
gridstack.undo=Rückgängig
|
||||||
|
gridstack.emptyTitle=Keine Widgets auf dem Dashboard
|
||||||
|
gridstack.emptyHint=Alle Widgets wurden geschlossen. Füge eines hinzu oder stelle das Standardlayout wieder her.
|
||||||
|
gridstack.restoreDefaults=Standardlayout wiederherstellen
|
||||||
|
|
||||||
# Spaltenüberschrift der CSV-Exporte; die Wertspalte trägt den Serien-Namen.
|
# Spaltenüberschrift der CSV-Exporte; die Wertspalte trägt den Serien-Namen.
|
||||||
export.category=Kategorie
|
export.category=Kategorie
|
||||||
|
|||||||
@@ -37,6 +37,11 @@ gridstack.restore=Restore
|
|||||||
gridstack.duplicate=Duplicate
|
gridstack.duplicate=Duplicate
|
||||||
gridstack.remove=Remove
|
gridstack.remove=Remove
|
||||||
gridstack.export=Export as CSV
|
gridstack.export=Export as CSV
|
||||||
|
gridstack.closed=Widget removed
|
||||||
|
gridstack.undo=Undo
|
||||||
|
gridstack.emptyTitle=No widgets on the dashboard
|
||||||
|
gridstack.emptyHint=All widgets have been closed. Add one or restore the default layout.
|
||||||
|
gridstack.restoreDefaults=Restore default layout
|
||||||
|
|
||||||
# Column header of the CSV exports; the value column carries the series name.
|
# Column header of the CSV exports; the value column carries the series name.
|
||||||
export.category=Category
|
export.category=Category
|
||||||
|
|||||||
@@ -37,6 +37,11 @@ gridstack.restore=Restaurar
|
|||||||
gridstack.duplicate=Duplicar
|
gridstack.duplicate=Duplicar
|
||||||
gridstack.remove=Eliminar
|
gridstack.remove=Eliminar
|
||||||
gridstack.export=Exportar como CSV
|
gridstack.export=Exportar como CSV
|
||||||
|
gridstack.closed=Widget eliminado
|
||||||
|
gridstack.undo=Deshacer
|
||||||
|
gridstack.emptyTitle=No hay widgets en el panel
|
||||||
|
gridstack.emptyHint=Se han cerrado todos los widgets. Añade uno o restablece el diseño predeterminado.
|
||||||
|
gridstack.restoreDefaults=Restablecer diseño predeterminado
|
||||||
|
|
||||||
# Encabezado de columna de las exportaciones CSV; la columna de valores lleva
|
# Encabezado de columna de las exportaciones CSV; la columna de valores lleva
|
||||||
# el nombre de la serie.
|
# el nombre de la serie.
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
package com.example.e2e;
|
||||||
|
|
||||||
|
import com.microsoft.playwright.Locator;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import static com.microsoft.playwright.assertions.PlaywrightAssertions.assertThat;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* End-to-end coverage for the {@code columnOpts} breakpoint added to
|
||||||
|
* {@code GridStackLayout} (see {@code DashboardView#MOBILE_BREAKPOINT_PX}):
|
||||||
|
* gridstack's own column-change caching restores the desktop layout, but only
|
||||||
|
* a real browser proves the resize actually happens and that the collapsed,
|
||||||
|
* single-column shape never gets written into the persisted layout.
|
||||||
|
*/
|
||||||
|
class ResponsiveGridPlaywrightTest extends PlaywrightTestBase {
|
||||||
|
|
||||||
|
private static final int DESKTOP_WIDTH = 1280;
|
||||||
|
private static final int DESKTOP_HEIGHT = 900;
|
||||||
|
private static final int MOBILE_WIDTH = 375;
|
||||||
|
private static final int MOBILE_HEIGHT = 720;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void openDashboardAtDesktopWidth() {
|
||||||
|
page.setViewportSize(DESKTOP_WIDTH, DESKTOP_HEIGHT);
|
||||||
|
navigate("");
|
||||||
|
assertThat(widget()).isVisible();
|
||||||
|
page.waitForCondition(() -> columns() == 12);
|
||||||
|
// Let any debounced persist from the initial makeWidget() calls settle
|
||||||
|
// before a test starts measuring, so it isn't racing that write.
|
||||||
|
page.waitForTimeout(300);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void mobileViewport_stacksWidgetsFullWidthAndDisablesDragging() {
|
||||||
|
double gridWidthDesktop = gridWidth();
|
||||||
|
double itemWidthDesktop = itemWidth();
|
||||||
|
assertTrue(itemWidthDesktop < gridWidthDesktop - 1,
|
||||||
|
"at desktop width the widget must not already span the whole grid");
|
||||||
|
|
||||||
|
page.setViewportSize(MOBILE_WIDTH, MOBILE_HEIGHT);
|
||||||
|
page.waitForCondition(() -> columns() == 1);
|
||||||
|
|
||||||
|
double gridWidthMobile = gridWidth();
|
||||||
|
double itemWidthMobile = itemWidth();
|
||||||
|
assertEquals(gridWidthMobile, itemWidthMobile, 1.0,
|
||||||
|
"below the breakpoint every widget must span the full grid width");
|
||||||
|
assertTrue(isStatic(), "dragging/resizing must be disabled below the breakpoint");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void collapsingToMobile_doesNotOverwriteThePersistedDesktopLayout() {
|
||||||
|
String before = slot(widget());
|
||||||
|
String storedBefore = storedLayout();
|
||||||
|
|
||||||
|
page.setViewportSize(MOBILE_WIDTH, MOBILE_HEIGHT);
|
||||||
|
page.waitForCondition(() -> columns() == 1);
|
||||||
|
// give the (debounced) persist path a chance to run, if it were going to
|
||||||
|
page.waitForTimeout(300);
|
||||||
|
assertEquals(storedBefore, storedLayout(),
|
||||||
|
"collapsing to one column must not touch the persisted desktop layout");
|
||||||
|
|
||||||
|
page.setViewportSize(DESKTOP_WIDTH, DESKTOP_HEIGHT);
|
||||||
|
page.waitForCondition(() -> columns() == 12);
|
||||||
|
|
||||||
|
assertEquals(before, slot(widget()), "the original desktop layout must be restored");
|
||||||
|
assertFalse(isStatic(), "dragging/resizing must be re-enabled back above the breakpoint");
|
||||||
|
}
|
||||||
|
|
||||||
|
private Locator widget() {
|
||||||
|
return page.locator(".grid-stack-item[gs-id='revenue-trend']");
|
||||||
|
}
|
||||||
|
|
||||||
|
private int columns() {
|
||||||
|
Object value = page.locator(".grid-stack").evaluate(
|
||||||
|
"el => el.style.getPropertyValue('--gs-columns')");
|
||||||
|
return Integer.parseInt(String.valueOf(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isStatic() {
|
||||||
|
return (boolean) page.locator(".grid-stack").evaluate(
|
||||||
|
"el => el.classList.contains('grid-stack-static')");
|
||||||
|
}
|
||||||
|
|
||||||
|
private double gridWidth() {
|
||||||
|
return ((Number) page.locator(".grid-stack").evaluate(
|
||||||
|
"el => el.getBoundingClientRect().width")).doubleValue();
|
||||||
|
}
|
||||||
|
|
||||||
|
private double itemWidth() {
|
||||||
|
return ((Number) widget().evaluate(
|
||||||
|
"el => el.getBoundingClientRect().width")).doubleValue();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The value {@code GridStackLayout} keeps in {@code localStorage} for this
|
||||||
|
* view, unaffected by the responsive collapse. */
|
||||||
|
private String storedLayout() {
|
||||||
|
Object value = page.evaluate("() => localStorage.getItem('gridstack:dashboard')");
|
||||||
|
return value == null ? null : String.valueOf(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The item's box measured against the grid, not the viewport — same as
|
||||||
|
* {@code WidgetActionMenuPlaywrightTest#slot}. */
|
||||||
|
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(',');
|
||||||
|
}""");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
package com.example.e2e;
|
||||||
|
|
||||||
|
import com.microsoft.playwright.Locator;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import static com.microsoft.playwright.assertions.PlaywrightAssertions.assertThat;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* End-to-end coverage for the undo-on-close toast (see
|
||||||
|
* {@code DashboardView#offerUndo}): a real browser is what actually confirms
|
||||||
|
* the item lands back in its exact grid cell, gridstack animation included.
|
||||||
|
*/
|
||||||
|
class WidgetUndoClosePlaywrightTest extends PlaywrightTestBase {
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void openDashboard() {
|
||||||
|
navigate("");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void close_thenUndo_restoresTheExactSlot() {
|
||||||
|
Locator item = widget();
|
||||||
|
String before = slot(item);
|
||||||
|
|
||||||
|
closeButton(item).click();
|
||||||
|
assertThat(widget()).not().isAttached();
|
||||||
|
|
||||||
|
undoButton().click();
|
||||||
|
|
||||||
|
Locator restored = widget();
|
||||||
|
assertThat(restored).isAttached();
|
||||||
|
page.waitForCondition(() -> before.equals(slot(restored)));
|
||||||
|
assertEquals(before, slot(restored), "undo must restore the exact position and size");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Targeted by its stable {@code gs-id} rather than by position: closing it
|
||||||
|
* changes which item a positional selector would resolve to first. */
|
||||||
|
private Locator widget() {
|
||||||
|
return page.locator(".grid-stack-item[gs-id='revenue-trend']");
|
||||||
|
}
|
||||||
|
|
||||||
|
private Locator closeButton(Locator item) {
|
||||||
|
return item.locator("> .dialect-close-button");
|
||||||
|
}
|
||||||
|
|
||||||
|
private Locator undoButton() {
|
||||||
|
return page.locator("vaadin-notification-card").getByRole(
|
||||||
|
com.microsoft.playwright.options.AriaRole.BUTTON,
|
||||||
|
new Locator.GetByRoleOptions().setName("Undo"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The item's box measured against the grid, not the viewport — same as
|
||||||
|
* {@code WidgetActionMenuPlaywrightTest#slot}. */
|
||||||
|
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(',');
|
||||||
|
}""");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,6 +25,7 @@ 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.Anchor;
|
import com.vaadin.flow.component.html.Anchor;
|
||||||
import com.vaadin.flow.component.html.Div;
|
import com.vaadin.flow.component.html.Div;
|
||||||
|
import com.vaadin.flow.component.notification.Notification;
|
||||||
import com.vaadin.flow.dom.DomEvent;
|
import com.vaadin.flow.dom.DomEvent;
|
||||||
import com.vaadin.flow.internal.JacksonUtils;
|
import com.vaadin.flow.internal.JacksonUtils;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
@@ -267,6 +268,114 @@ class DashboardViewTest extends SpringBrowserlessTest {
|
|||||||
assertEquals(DEFAULT_WIDGETS - 1, grid.getLayout().size());
|
assertEquals(DEFAULT_WIDGETS - 1, grid.getLayout().size());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void closingAWidget_showsAnUndoToast() {
|
||||||
|
navigate(DashboardView.class);
|
||||||
|
|
||||||
|
clickCloseButton(itemById("revenue-trend"));
|
||||||
|
|
||||||
|
Notification toast = $(Notification.class).first();
|
||||||
|
assertNotNull(toast, "closing a widget must offer a way back");
|
||||||
|
assertNotNull($(Button.class).from(toast).withText(translate("gridstack.undo")).first());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void undo_restoresTheWidgetAtItsOriginalPositionAndId() {
|
||||||
|
navigate(DashboardView.class);
|
||||||
|
GridStackLayout grid = $view(GridStackLayout.class).first();
|
||||||
|
GridStackItem.Position before = positionOf("revenue-trend");
|
||||||
|
|
||||||
|
clickCloseButton(itemById("revenue-trend"));
|
||||||
|
assertEquals(DEFAULT_WIDGETS - 1, grid.getLayout().size());
|
||||||
|
|
||||||
|
clickUndo();
|
||||||
|
|
||||||
|
assertEquals(DEFAULT_WIDGETS, grid.getLayout().size());
|
||||||
|
assertEquals(before, positionOf("revenue-trend"),
|
||||||
|
"undo must restore the exact position and size, not the next free slot");
|
||||||
|
assertEquals(1, $view(LineChart.class).all().size(), "the chart itself is back too");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Closing the same widget twice in a row — once, undoing, then again —
|
||||||
|
* must still offer an undo the second time: the close listener is
|
||||||
|
* registered once per item, not once per (re-)add. */
|
||||||
|
@Test
|
||||||
|
void undoneWidget_offersAnUndoAgainWhenClosedAnotherTime() {
|
||||||
|
navigate(DashboardView.class);
|
||||||
|
|
||||||
|
clickCloseButton(itemById("revenue-trend"));
|
||||||
|
clickUndo();
|
||||||
|
clickCloseButton(itemById("revenue-trend"));
|
||||||
|
|
||||||
|
assertNotNull($(Button.class).from($(Notification.class).first())
|
||||||
|
.withText(translate("gridstack.undo")).first());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A close triggered from code (not the button or the menu) is not a
|
||||||
|
* misclick, so it must not spawn a toast the user never asked for. */
|
||||||
|
@Test
|
||||||
|
void programmaticClose_doesNotShowAnUndoToast() {
|
||||||
|
navigate(DashboardView.class);
|
||||||
|
|
||||||
|
itemById("revenue-trend").close();
|
||||||
|
|
||||||
|
assertTrue($(Notification.class).all().isEmpty(),
|
||||||
|
"a programmatic close must not offer an undo");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void clickUndo() {
|
||||||
|
$(Button.class).from($(Notification.class).first())
|
||||||
|
.withText(translate("gridstack.undo")).first().click();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void closingEveryWidget_showsEmptyStateAndHidesTheGrid() {
|
||||||
|
navigate(DashboardView.class);
|
||||||
|
GridStackLayout grid = $view(GridStackLayout.class).first();
|
||||||
|
|
||||||
|
closeAllWidgets();
|
||||||
|
|
||||||
|
assertTrue(grid.getLayout().isEmpty(), "no widget should be left");
|
||||||
|
assertFalse(grid.isVisible(), "the empty grid must not show as an empty box");
|
||||||
|
Div emptyState = $view(Div.class).withClassName("dialect-empty-state").first();
|
||||||
|
assertTrue(emptyState.isVisible(), "the empty state must appear");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void addingAWidget_hidesTheEmptyStateAgain() {
|
||||||
|
navigate(DashboardView.class);
|
||||||
|
closeAllWidgets();
|
||||||
|
|
||||||
|
button("gridstack.addWidget").click();
|
||||||
|
pickWidget("card.revenueByRegion");
|
||||||
|
|
||||||
|
assertFalse($view(Div.class).withClassName("dialect-empty-state").exists(),
|
||||||
|
"the empty state must be gone once a widget is back — invisible components drop out of the query");
|
||||||
|
assertTrue($view(GridStackLayout.class).first().isVisible());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void restoreDefaultLayout_rebuildsTheDefaultWidgetsFromTheEmptyState() {
|
||||||
|
navigate(DashboardView.class);
|
||||||
|
closeAllWidgets();
|
||||||
|
|
||||||
|
button("gridstack.restoreDefaults").click();
|
||||||
|
|
||||||
|
GridStackLayout grid = $view(GridStackLayout.class).first();
|
||||||
|
assertEquals(DEFAULT_WIDGETS, grid.getLayout().size());
|
||||||
|
assertFalse($view(Div.class).withClassName("dialect-empty-state").exists(),
|
||||||
|
"the empty state must be gone once the default layout is back");
|
||||||
|
assertTrue(grid.isVisible());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void closeAllWidgets() {
|
||||||
|
List<GridStackItem> items = $view(GridStackItem.class).all();
|
||||||
|
while (!items.isEmpty()) {
|
||||||
|
clickCloseButton(items.getFirst());
|
||||||
|
items = $view(GridStackItem.class).all();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void nonClosableItem_hasNoCloseButton() {
|
void nonClosableItem_hasNoCloseButton() {
|
||||||
navigate(DashboardView.class);
|
navigate(DashboardView.class);
|
||||||
|
|||||||
Reference in New Issue
Block a user