Compare commits

..

11 Commits

Author SHA1 Message Date
pitfriedrich 0a8f7408f1 Merge pull request 'fix: responsive grid breakpoints for small screens (#41)' (#50) from ai/issue-41-responsive-grid-breakpoints into main
Reviewed-on: #50
2026-07-29 05:56:55 +00:00
Pit Friedrich 7a4bc72799 fix: responsive grid breakpoints for small screens (#41)
CI / build-and-test (pull_request) Successful in 2m47s
Add a columnOpts breakpoint (768px -> 1 column) so widgets stack
full-width on mobile instead of staying at 12 columns. Persisting to
localStorage is skipped while gridstack's own responsive collapse is
active, so narrowing the window can no longer overwrite the saved
desktop layout; drag/resize is disabled below the same breakpoint to
avoid touch misfires.
2026-07-29 07:52:36 +02:00
pitfriedrich 505ad32d4a Merge pull request 'fix: empty state when all dashboard widgets are closed' (#49) from ai/issue-39-empty-state-dashboard into main
Reviewed-on: #49
2026-07-29 05:40:11 +00:00
Pit Friedrich dbe304e61e fix: empty state when all dashboard widgets are closed (#39)
CI / build-and-test (pull_request) Successful in 2m22s
Show a placeholder (CTA to add a widget, secondary action to restore the
default layout) instead of a blank grid once every widget has been closed.
2026-07-29 07:32:11 +02:00
pitfriedrich 114f00523b Merge pull request 'fix: undo closing a widget (#40)' (#48) from ai/issue-40-undo-closing-a-widget into main
Reviewed-on: #48
2026-07-28 21:30:53 +00:00
Pit Friedrich 4878a7fc06 fix: undo closing a widget (#40)
CI / build-and-test (pull_request) Successful in 2m19s
Closing a widget was instant and destructive - one misclick on the X
lost its size and position. Close now shows a toast with an undo
button that re-adds the very same widget at its captured gs-x/y/w/h,
keeping its gs-id. Programmatic close() (fromClient=false) is skipped.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HGRChzremwYCUctf2qMrQz
2026-07-28 23:25:13 +02:00
pitfriedrich cd1d8f494d Merge pull request 'Issue/46' (#47) from issue/46 into main
Reviewed-on: #47
2026-07-28 20:53:42 +00:00
Pit Friedrich c71774bc5f chore: harden gitea autofix workflow, ignore dev.bundle
CI / build-and-test (pull_request) Successful in 2m25s
fix-issue/next-issue had unresolved placeholders (<dein Testkommando>,
<owner>/<repo>) and no explicit branch-creation step, so a run that
skipped the CLAUDE.md convention could commit straight to main. Adds a
CI-gate before setting ai-review (actions_run_read against the
existing .gitea/workflows/ci.yml) instead of trusting self-reported
test results, plus a /work-queue command to chain next-issue -> fix-issue.

dev.bundle is Vaadin-regenerated and was tracked+dirty, so every
autofix commit would have swept in unrelated binary diffs.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0114SVTvBYjLBt3TTxhzJttd
2026-07-28 22:51:22 +02:00
Pit Friedrich ed399cec6c chore: harden gitea autofix workflow, ignore dev.bundle
fix-issue/next-issue had unresolved placeholders (<dein Testkommando>,
<owner>/<repo>) and no explicit branch-creation step, so a run that
skipped the CLAUDE.md convention could commit straight to main. Adds a
CI-gate before setting ai-review (actions_run_read against the
existing .gitea/workflows/ci.yml) instead of trusting self-reported
test results, plus a /work-queue command to chain next-issue -> fix-issue.

dev.bundle is Vaadin-regenerated and was tracked+dirty, so every
autofix commit would have swept in unrelated binary diffs.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0114SVTvBYjLBt3TTxhzJttd
2026-07-28 22:50:49 +02:00
pitfriedrich a7f79e64df Merge pull request 'fix: keep the widget action menu off the drag handle (#44)' (#45) from ai/issue-44-menu-overlays-drag-handle into main
Reviewed-on: #45
2026-07-28 20:13:42 +00:00
Pit Friedrich 4e48e31011 fix: keep the widget action menu off the drag handle (#44)
CI / build-and-test (pull_request) Successful in 2m24s
The menu's button is one of the item's corner controls, but it was left at
its own size: a menu-bar button measures 64px, twice the 32px slot the
corner offsets in styles.css assume. Painted after the grip and overlapping
it completely, it took every pointer event meant for the grip — widgets
could not be dragged at all.

Sized to 28px like the grip and the close button, so the slot math holds.
The top offset goes back to 14px with it, the 11px having compensated for
the taller button.

Covered by a Playwright test that checks the grip is the element hit at its
own centre and then drags the widget by it; the existing test only proved
the opposite direction, that dragging the menu button moves nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0124BiJikbhbiEsNfJxdWM69
2026-07-28 22:08:46 +02:00
20 changed files with 663 additions and 130 deletions
+1 -1
View File
@@ -4,7 +4,7 @@ description: Wählt das nächste zu bearbeitende Gitea-Issue und gibt nur die Ke
tools: mcp__gitea__list_issues, mcp__gitea__issue_read
model: haiku
---
Finde das nächste offene Issue im Repo <owner>/<repo> mit Label `ai-ready`
Finde das nächste offene Issue im Repo pitfriedrich/chart-app mit Label `ai-ready`
und NICHT mit Label `ai-wip` oder `ai-blocked`.
Priorisiere nach `prio/high` > `prio/medium` > `prio/low`, dann nach ältestem.
+26 -11
View File
@@ -5,7 +5,8 @@ allowed-tools: >
Bash, Edit, Read, Write, Grep, Glob,
mcp__gitea__issue_read, mcp__gitea__issue_write,
mcp__gitea__label_read,
mcp__gitea__pull_request_write
mcp__gitea__pull_request_write, mcp__gitea__pull_request_read,
mcp__gitea__actions_run_read
model: opus
---
Bearbeite Gitea-Issue #$1 im aktuellen Repo. Schritt für Schritt:
@@ -18,22 +19,36 @@ Name → ID für die gesamte Bearbeitung. Verwende ausschließlich `add_labels`
Niemals `replace_labels` oder `clear_labels` benutzen — das löscht alle
übrigen Labels des Issues (z.B. `prio/*`).
0. Vorbedingungen: Wenn Issue #$1 bereits `ai-wip` oder `ai-blocked` trägt,
abbrechen (kein Doppelstart). Prüfe `git status --porcelain` — ist der
Baum nicht sauber, abbrechen statt fremde Änderungen mitzucommitten.
1. Lies Issue #$1 (Titel, Beschreibung, Kommentare) über `issue_read`.
Notiere die aktuell gesetzten Labels, damit du sie nicht verlierst.
2. Hole die Label-Liste via `label_read`. Setze Label `ai-wip`
(`add_labels`), entferne `ai-ready` (`remove_label`).
3. Analysiere das Repo und finde die Ursache. Wenn der Scope zu groß/unklar
ist (siehe CLAUDE.md), brich ab: kommentiere das Issue mit einer kurzen
Begründung, setze `ai-blocked`, und beende ohne Codeänderung.
4. Implementiere den Fix. Halte den Diff minimal und fokussiert.
5. Führe die Tests aus (`<dein Testkommando>`). Bei Rot: bis zu 2x nachbessern.
Wenn danach immer noch rot → `ai-blocked`, Issue kommentieren, abbrechen.
6. Commit mit Message: `fix: <kurze Beschreibung> (#$1)`.
7. Push den aktuellen Branch zum Remote.
8. Öffne via `pull_request_write` einen PR gegen `main` (bzw. `develop`).
Die PR-Beschreibung MUSS `Closes #$1` enthalten.
9. Kommentiere am Issue mit dem PR-Link, setze Label `ai-review`
(`add_labels`) und entferne `ai-wip` (`remove_label`).
Begründung, setze `ai-blocked`, entferne `ai-wip`, und beende ohne
Codeänderung.
4. Lege den Branch an: `git checkout main && git pull --ff-only`, dann
`git checkout -b ai/issue-$1-<kurz-slug>` (Slug aus dem Issue-Titel,
kleingeschrieben, Bindestriche statt Leerzeichen).
5. Implementiere den Fix. Halte den Diff minimal und fokussiert.
6. Führe die Tests aus (`./mvnw test`). Bei Rot: bis zu 2x nachbessern.
Wenn danach immer noch rot → `ai-blocked`, `ai-wip` entfernen, Issue
kommentieren, abbrechen. Hinweis: `e2e/*PlaywrightTest` überspringt sich
selbst per JUnit-Assumption, wenn der Browser-Download fehlschlägt —
übersprungen zählt nicht als rot und ist kein Abbruchgrund.
7. Commit mit Message: `fix: <kurze Beschreibung> (#$1)`.
8. Push den Branch zum Remote.
9. Öffne via `pull_request_write` einen PR gegen `main`. Die
PR-Beschreibung MUSS `Closes #$1` enthalten.
10. Warte den CI-Lauf des PR-Branches ab (`actions_run_read`, PR läuft
automatisch gegen `.gitea/workflows/ci.yml`).
- Grün: kommentiere am Issue mit dem PR-Link, setze Label `ai-review`
(`add_labels`), entferne `ai-wip` (`remove_label`).
- Rot: setze `ai-blocked`, entferne `ai-wip`, kommentiere das Issue mit
einem kurzen Auszug aus dem CI-Log. Kein `ai-review`.
Wenn du in einem Schritt `ai-blocked` setzt, entferne ebenfalls `ai-wip`.
Existiert ein benötigtes Label nicht im Repo, kommentiere das am Issue statt
+23
View File
@@ -0,0 +1,23 @@
---
description: Wählt das nächste ai-ready Issue und arbeitet es via /fix-issue ab.
allowed-tools: >
Bash, Edit, Read, Write, Grep, Glob,
mcp__gitea__list_issues, mcp__gitea__issue_read, mcp__gitea__issue_write,
mcp__gitea__label_read,
mcp__gitea__pull_request_write, mcp__gitea__pull_request_read,
mcp__gitea__actions_run_read
model: opus
---
Arbeitet genau EIN Issue aus der Autofix-Warteschlange ab. Kein Loop
innerhalb dieses Commands — für wiederholte Läufe die `/loop`-Skill nutzen
(z.B. `/loop 30m /work-queue`).
1. Rufe den Agenten `next-issue` auf. Er liefert entweder `ISSUE=<nr>`
oder `ISSUE=NONE`.
2. `ISSUE=NONE` → melde „Queue leer, kein `ai-ready`-Issue offen" und
beende ohne weitere Aktion.
3. `ISSUE=<nr>` → führe exakt den Ablauf aus `.claude/commands/fix-issue.md`
für Issue `<nr>` aus (Schritte 010 dort, unverändert, inklusive
Label-Regeln, Branch-Schema, Testkommando und CI-Gate). Diese Datei
beschreibt den Ablauf nicht erneut — Quelle der Wahrheit ist
`fix-issue.md`.
+1
View File
@@ -13,3 +13,4 @@ node_modules/
src/main/frontend/generated/
vite.generated.ts
/src/main/bundles/prod.bundle
/src/main/bundles/dev.bundle
-39
View File
@@ -1,39 +0,0 @@
# AGENTS.md
This file provides guidance to Codex (Codex.ai/code) when working with code in this repository.
## Commands
```bash
./mvnw spring-boot:run # run app (default goal) — http://localhost:8080, ~30s first start
./mvnw compile # compile only
./mvnw package # production build → target/*.jar
java -jar target/*.jar # run production jar
```
No test sources exist yet (`src/test` is empty) — `spring-boot-starter-test` and `browserless-test-spring` are on the classpath but unused.
**Java 25 toolchain required** (`pom.xml` sets `java.version=25`). If the default JDK on PATH is older (check `java -version`), point `JAVA_HOME` at a Java 25 install for the Maven build, e.g.:
```bash
JAVA_HOME="/path/to/jdk-25" ./mvnw compile
```
Port 8080 conflicts: a prior `spring-boot:run` left running in the background is the usual cause (Vaadin dev mode keeps a second process/thread alive under a different PID than the launching Maven process — killing the Maven process alone may not free the port). Find and stop the actual listener before restarting.
## Architecture
Vaadin Flow (server-side Java UI, no hand-written HTML/JS for views) on Spring Boot 4.1, using the **Aura** theme (not Lumo — no `@Theme` annotation; Aura is wired via `@StyleSheet(Aura.STYLESHEET)` in `Application.java`).
**Charts are ApexCharts, not Vaadin Charts.** The bridge lives in `components/`:
- `ApexChart` (abstract, `@Tag("apex-chart")`) — owns the JS module (`frontend/components/apex-chart.ts`, a Lit element rendering into light DOM), serializes an options `Map` to JSON via Jackson and calls `renderChart` client-side, and exposes point-click events back to the server via `@ClientCallable`.
- `AxisChart` (abstract) — builds `series`/`xaxis.categories` options for line/bar.
- `LineChart`, `BarChart` extend `AxisChart`; `PieChart` extends `ApexChart` directly (`series`/`labels` instead of axis-based).
- `DialectTheme` — single source of chart styling (categorical color palette, grid/legend/stroke option fragments). Every chart's `setData(...)` starts from `DialectTheme.baseOptions(chartType)` and merges in its data. Add new chart types here, not by duplicating option maps.
Since `apex-chart.ts` renders into light DOM (`createRenderRoot()` returns `this`), global CSS can reach into the chart markup — but series/legend/grid colors are driven entirely by the options JSON, not CSS, because ApexCharts renders its own SVG/canvas.
**Views** (`views/`): `MainLayout` (`@Layout`, applies to all routes) is the `AppLayout` shell — navbar + `SideNav` drawer, with one `SideNavItem` per route. Routes: `DashboardView` (`@Route("")`) wraps each chart in a `Card` (`components/Card.java`); `FormView` (`@Route("formular")`) demonstrates form controls bound via `Binder`; `TableView` (`@Route("tabelle")`) demonstrates a `Grid` over dummy data with a live text filter (`GridListDataView.addFilter`, `TextField` in `ValueChangeMode.EAGER`). New views should reuse `Card` to wrap their content rather than adding components directly, and get a matching `SideNavItem` in `MainLayout`.
**Styling**: `src/main/resources/META-INF/resources/styles.css` is the one project-level stylesheet (loaded via `@StyleSheet("styles.css")` in `Application.java`). It defines `--dialect-*` design tokens (Dialect design system: primary orange `#E86C00`, cool-gray background, card radius/shadow) and aliases them onto Aura's own CSS custom properties (`--aura-accent-color-*`, `--aura-background-color-*`, `--aura-orange`, `--aura-yellow`) rather than fighting the theme. Aura tokens use OKLCH + relative-color syntax and accept plain hex overrides. When restyling, prefer extending this alias layer over hardcoding new colors in components.
UI copy/data (chart labels, notifications) is in German.
+2 -1
View File
@@ -52,4 +52,5 @@ UI copy/data (chart labels, notifications) is in German.
- Bei Unklarheit / Scope > ~200 Zeilen Diff: NICHT fixen,
stattdessen Issue kommentieren ("needs human") und Label `ai-blocked` setzen.
- PR-Beschreibung MUSS `Closes #<nr>` enthalten.
- Keine Änderungen an <kritische Pfade, z.B. build config, Migrations> ohne Freigabe.
- Keine Änderungen an kritischen Pfaden ohne Freigabe: `pom.xml`, `mvnw`/`mvnw.cmd`,
`.mvn/`, `.gitea/workflows/`, `.gitignore`.
-61
View File
@@ -1,61 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
source .env
REPO_DIR="$HOME/codebase/chart-app" # Haupt-Klon
BASE_BRANCH="main"
TEST_CMD="./mvnw test" # z.B. ./gradlew test
MAX_ISSUES=5 # pro Durchlauf, damit du nicht davonläufst
POLL_INTERVAL=120 # Sekunden zwischen Polls (0 = einmal laufen)
cd "$REPO_DIR"
process_next() {
git -C "$REPO_DIR" fetch origin "$BASE_BRANCH" --quiet
git -C "$REPO_DIR" checkout "$BASE_BRANCH" --quiet
git -C "$REPO_DIR" pull --quiet
# 1) Nächstes Issue holen (Sub-Agent, minimaler Output)
local out
out=$(claude -p "Nutze den Sub-Agent 'next-issue'. Gib NUR dessen Rueckgabezeile aus (ISSUE=<nr> oder ISSUE=NONE), keinen weiteren Text." \
--allowedTools "Agent,mcp__gitea__list_issues,mcp__gitea__issue_read" \
--output-format text)
local nr
nr=$(echo "$out" | grep -oE 'ISSUE=[0-9NONE]+' | cut -d= -f2)
if [[ "$nr" == "NONE" || -z "$nr" ]]; then
echo "Keine offenen ai-ready Issues."
return 1
fi
echo ">>> Bearbeite Issue #$nr"
local wt="../wt-issue-$nr"
local branch="ai/issue-$nr"
# 2) Isolierter Worktree + Branch
git worktree add -b "$branch" "$wt" "$BASE_BRANCH" --quiet
# 3) Worker im Worktree ausführen (autonom)
(
cd "$wt"
claude -p "/fix-issue $nr" \
--allowedTools "Bash($TEST_CMD),Bash(git *),Edit,Read,Write,Grep,Glob,mcp__gitea__issue_read,mcp__gitea__issue_write,mcp__gitea__label_read,mcp__gitea__pull_request_write" \
--output-format text
) || echo "!!! Worker für #$nr mit Fehler beendet"
# 4) Aufräumen
git worktree remove "$wt" --force || true
return 0
}
# --- Hauptschleife ---
while true; do
count=0
while (( count < MAX_ISSUES )); do
if process_next; then ((count++)); else break; fi
done
echo "Durchlauf fertig ($count Issues)."
(( POLL_INTERVAL == 0 )) && break
sleep "$POLL_INTERVAL"
done
Binary file not shown.
@@ -11,6 +11,13 @@ class GridStackLayout extends HTMLElement {
private observer?: MutationObserver;
private storageKey: string | null = null;
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() {
this.observer = new MutationObserver((mutations) => this.onMutation(mutations));
@@ -20,6 +27,11 @@ class GridStackLayout extends HTMLElement {
disconnectedCallback() {
this.observer?.disconnect();
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:
// a drag/resize followed straight away by navigating to another route
// detaches this element inside the debounce window, which used to lose
@@ -39,6 +51,7 @@ class GridStackLayout extends HTMLElement {
this.classList.add('grid-stack');
const options = JSON.parse(optionsJson);
this.fullColumn = options.column ?? this.fullColumn;
const grid = GridStack.init(options, this);
if (!grid) return;
this.grid = grid;
@@ -48,6 +61,19 @@ class GridStackLayout extends HTMLElement {
grid.on('change added removed', () => this.schedulePersist());
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
@@ -119,6 +145,12 @@ class GridStackLayout extends HTMLElement {
private persist(notifyServer = true) {
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[];
if (this.storageKey) {
localStorage.setItem(STORAGE_PREFIX + this.storageKey, JSON.stringify(nodes));
@@ -312,10 +312,26 @@ public class GridStackItem extends Div {
}
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 —
// getLayout() on the grid no longer counts this item.
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). */
@@ -346,10 +362,23 @@ public class GridStackItem extends Div {
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> {
CloseEvent(GridStackItem source, boolean fromClient) {
private final Position position;
CloseEvent(GridStackItem source, boolean fromClient, Position position) {
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;
}
/**
* 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
* 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.ButtonVariant;
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.Span;
import com.vaadin.flow.component.notification.Notification;
import com.vaadin.flow.component.orderedlayout.HorizontalLayout;
import com.vaadin.flow.component.orderedlayout.VerticalLayout;
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 at runtime. Widgets are removed by their own close button (see
* {@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>
* 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
@@ -56,6 +59,10 @@ public class DashboardView extends VerticalLayout implements HasDynamicTitle {
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. */
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 GridStackLayout grid = new GridStackLayout();
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
* data of the same KPI rather than by position. */
private final Map<String, KpiTile> kpiTiles = new LinkedHashMap<>();
@@ -76,13 +87,29 @@ public class DashboardView extends VerticalLayout implements HasDynamicTitle {
grid.setWidthFull();
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(
getTranslation("gridstack.status", e.getPositions().size())));
// KPI tiles first: the numbers a dashboard is read for, above the charts
// that explain them. 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.
configureEmptyState();
buildDefaultWidgets();
context.addFilterChangeListener(this::updateKpiTiles);
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());
for (int i = 0; i < kpis.size(); i++) {
KpiData kpi = kpis.get(i);
@@ -97,22 +124,61 @@ public class DashboardView extends VerticalLayout implements HasDynamicTitle {
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_MONTH, 6, 1, 6, 3),
defaultWidget(WidgetRegistry.REVENUE_REGION, 0, 4, 5, 3),
new GridStackItem("hint", 5, 4, 7, 3,
new Card(getTranslation("card.gridstackHint"),
new Paragraph(getTranslation("gridstack.hint")))));
new Paragraph(getTranslation("gridstack.hint")))))
.forEach(this::addWidgetToGrid);
}
status.setText(getTranslation("gridstack.statusInitial"));
status.addClassName("dialect-muted");
/** Builds the placeholder shown once every widget has been closed: a short
* 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() {
@@ -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
// inherit the closed one's saved position.
extraWidgetCount++;
grid.add(widget(definition.type() + "-" + extraWidgetCount, definition,
addWidgetToGrid(widget(definition.type() + "-" + extraWidgetCount, definition,
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
* again by {@link #addWidget(WidgetDefinition)}, which always suffixes a
* counter — and it keeps the dashboard's own placement rather than the
@@ -307,13 +307,26 @@ apex-chart.dialect-sparkline .apexcharts-xaxis {
its left one slot (32px) further in. */
.dialect-action-menu {
position: absolute;
top: 11px;
top: 14px;
right: 16px;
z-index: 1;
opacity: 0;
transition: opacity 120ms ease;
}
/* Its button is a corner control like the grip and the close button, so it is
sized like one. A menu-bar button at its own size is 64px wide, i.e. twice
its slot: painted after the grip, it swallowed the grip's pointer events and
left the widget undraggable. The button is a light-DOM child of the menu bar,
so plain selectors reach it. */
.dialect-action-menu vaadin-menu-bar-button {
width: 28px;
min-width: 0;
height: 28px;
padding: 0;
color: var(--dialect-ink);
}
.grid-stack-item:has(> .dialect-close-button) > .dialect-action-menu {
right: 48px;
}
@@ -406,4 +419,33 @@ apex-chart.dialect-sparkline .apexcharts-xaxis {
background: var(--dialect-bg);
border: 2px dashed var(--dialect-border);
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.remove=Entfernen
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.
export.category=Kategorie
@@ -37,6 +37,11 @@ gridstack.restore=Restore
gridstack.duplicate=Duplicate
gridstack.remove=Remove
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.
export.category=Category
@@ -37,6 +37,11 @@ gridstack.restore=Restaurar
gridstack.duplicate=Duplicar
gridstack.remove=Eliminar
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
# 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(',');
}""");
}
}
@@ -2,6 +2,7 @@ package com.example.e2e;
import com.microsoft.playwright.Download;
import com.microsoft.playwright.Locator;
import com.microsoft.playwright.Mouse;
import com.microsoft.playwright.options.BoundingBox;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -12,6 +13,7 @@ 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.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
@@ -43,6 +45,45 @@ class WidgetActionMenuPlaywrightTest extends PlaywrightTestBase {
assertEquals(before, slot(item), "the widget must not have moved");
}
/** The other half of it: the grip has to stay reachable next to the menu.
* Both sit in the item's top right corner and the menu is painted after
* the grip, so a menu wider than its corner slot takes the grip's pointer
* events and the widget cannot be dragged at all any more. */
@Test
void draggingTheGrip_movesTheWidget() {
Locator item = widget();
String before = slot(item);
BoundingBox grip = grip(item).boundingBox();
double x = grip.x + grip.width / 2;
double y = grip.y + grip.height / 2;
assertEquals("dialect-drag-handle", topmostClassAt(x, y),
"another corner control covers the grip");
page.mouse().move(x, y);
page.mouse().down();
// In steps, not one jump: gridstack starts the drag on the first move
// past its threshold and places the item off the moves after that, so a
// single move would only ever arm the drag.
page.mouse().move(x + 240, y + 200, new Mouse.MoveOptions().setSteps(20));
page.mouse().up();
page.waitForCondition(() -> !before.equals(slot(item)));
assertNotEquals(before, slot(item), "the widget must have moved");
}
/** The class of the corner control the browser actually hits at that point,
* or the topmost element's own class list if it is no corner control. */
private String topmostClassAt(double x, double y) {
return (String) page.evaluate("""
([x, y]) => {
const el = document.elementFromPoint(x, y);
const control = el?.closest(
'.dialect-drag-handle, .dialect-close-button, .dialect-action-menu');
return control ? control.className : String(el?.className ?? el);
}""", java.util.List.of(x, y));
}
@Test
void menuButton_opensTheActions() {
menuButton(widget()).click();
@@ -111,6 +152,10 @@ class WidgetActionMenuPlaywrightTest extends PlaywrightTestBase {
}""");
}
private Locator grip(Locator item) {
return item.locator("> .dialect-drag-handle");
}
private Locator menuButton(Locator item) {
return item.locator(".dialect-action-menu vaadin-menu-bar-button").first();
}
@@ -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.html.Anchor;
import com.vaadin.flow.component.html.Div;
import com.vaadin.flow.component.notification.Notification;
import com.vaadin.flow.dom.DomEvent;
import com.vaadin.flow.internal.JacksonUtils;
import org.junit.jupiter.api.Test;
@@ -267,6 +268,114 @@ class DashboardViewTest extends SpringBrowserlessTest {
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
void nonClosableItem_hasNoCloseButton() {
navigate(DashboardView.class);