Compare commits

..

15 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
pitfriedrich 71892ea7ad Merge pull request 'feat: export widget data as CSV (#28)' (#38) from ai/issue-28-widget-csv-export into main
Reviewed-on: #38
2026-07-28 19:49:20 +00:00
Pit Friedrich cfa644c0e1 fix: export widget data as CSV from the action menu (#28)
CI / build-and-test (pull_request) Successful in 2m26s
The ApexCharts toolbar stays hidden (DialectTheme), so the dashboard had no
export at all. Adds an "Als CSV exportieren" entry to the per-widget action
menu, served server-side from the same data the widget renders.

- CsvExport: the export table plus its CSV dialect (';' separator, CRLF,
  UTF-8 BOM, locale-formatted numbers) and file-name slugging.
- GridStackItem.setActionDownload: turns a menu entry's caption into an
  anchor over a DownloadHandler, so an entry can hand out a file.
- WidgetRegistry: keeps each widget's query, not only its result, so the
  Export hook re-runs it — an export always matches the current filter.
- DashboardView: builds the download on click; file name is widget title +
  period (revenue-trend-half-year.csv).

Chart image export is left as the follow-up the issue calls optional.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0124BiJikbhbiEsNfJxdWM69
2026-07-28 21:45:40 +02:00
pitfriedrich bb9b89a8d8 Merge pull request 'feat: per-widget action menu (refresh, maximize, duplicate, remove) (#27)' (#37) from ai/issue-27-widget-action-menu into main
Reviewed-on: #37
2026-07-28 19:29:06 +00:00
Pit Friedrich 8545b06f41 fix: add a per-widget action menu (#27)
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
2026-07-28 21:26:05 +02:00
25 changed files with 1630 additions and 149 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 tools: mcp__gitea__list_issues, mcp__gitea__issue_read
model: haiku 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`. und NICHT mit Label `ai-wip` oder `ai-blocked`.
Priorisiere nach `prio/high` > `prio/medium` > `prio/low`, dann nach ältestem. 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, Bash, Edit, Read, Write, Grep, Glob,
mcp__gitea__issue_read, mcp__gitea__issue_write, mcp__gitea__issue_read, mcp__gitea__issue_write,
mcp__gitea__label_read, 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 model: opus
--- ---
Bearbeite Gitea-Issue #$1 im aktuellen Repo. Schritt für Schritt: 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 Niemals `replace_labels` oder `clear_labels` benutzen — das löscht alle
übrigen Labels des Issues (z.B. `prio/*`). ü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`. 1. Lies Issue #$1 (Titel, Beschreibung, Kommentare) über `issue_read`.
Notiere die aktuell gesetzten Labels, damit du sie nicht verlierst. Notiere die aktuell gesetzten Labels, damit du sie nicht verlierst.
2. Hole die Label-Liste via `label_read`. Setze Label `ai-wip` 2. Hole die Label-Liste via `label_read`. Setze Label `ai-wip`
(`add_labels`), entferne `ai-ready` (`remove_label`). (`add_labels`), entferne `ai-ready` (`remove_label`).
3. Analysiere das Repo und finde die Ursache. Wenn der Scope zu groß/unklar 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 ist (siehe CLAUDE.md), brich ab: kommentiere das Issue mit einer kurzen
Begründung, setze `ai-blocked`, und beende ohne Codeänderung. Begründung, setze `ai-blocked`, entferne `ai-wip`, und beende ohne
4. Implementiere den Fix. Halte den Diff minimal und fokussiert. Codeänderung.
5. Führe die Tests aus (`<dein Testkommando>`). Bei Rot: bis zu 2x nachbessern. 4. Lege den Branch an: `git checkout main && git pull --ff-only`, dann
Wenn danach immer noch rot → `ai-blocked`, Issue kommentieren, abbrechen. `git checkout -b ai/issue-$1-<kurz-slug>` (Slug aus dem Issue-Titel,
6. Commit mit Message: `fix: <kurze Beschreibung> (#$1)`. kleingeschrieben, Bindestriche statt Leerzeichen).
7. Push den aktuellen Branch zum Remote. 5. Implementiere den Fix. Halte den Diff minimal und fokussiert.
8. Öffne via `pull_request_write` einen PR gegen `main` (bzw. `develop`). 6. Führe die Tests aus (`./mvnw test`). Bei Rot: bis zu 2x nachbessern.
Die PR-Beschreibung MUSS `Closes #$1` enthalten. Wenn danach immer noch rot → `ai-blocked`, `ai-wip` entfernen, Issue
9. Kommentiere am Issue mit dem PR-Link, setze Label `ai-review` kommentieren, abbrechen. Hinweis: `e2e/*PlaywrightTest` überspringt sich
(`add_labels`) und entferne `ai-wip` (`remove_label`). 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`. 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 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/ src/main/frontend/generated/
vite.generated.ts vite.generated.ts
/src/main/bundles/prod.bundle /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, - Bei Unklarheit / Scope > ~200 Zeilen Diff: NICHT fixen,
stattdessen Issue kommentieren ("needs human") und Label `ai-blocked` setzen. stattdessen Issue kommentieren ("needs human") und Label `ai-blocked` setzen.
- PR-Beschreibung MUSS `Closes #<nr>` enthalten. - 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 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));
@@ -27,6 +27,11 @@ 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"),
EXPORT("fa-solid", "fa-file-csv"),
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,21 @@ 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.Anchor;
import com.vaadin.flow.component.html.AttachmentType;
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.server.streams.DownloadHandler;
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 +31,15 @@ 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. An entry that hands out a file rather than changing the
* widget is wired with
* {@link #setActionDownload(Action, DownloadHandler) setActionDownload}.
*/ */
public class GridStackItem extends Div { public class GridStackItem extends Div {
@@ -30,10 +51,53 @@ 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";
/** Marker class for the download link of a
* {@link #setActionDownload(Action, DownloadHandler) download entry};
* {@code styles.css} stretches it over the whole entry, so the whole row
* is clickable and not just its caption. */
public static final String ACTION_LINK_CLASS = "dialect-action-link";
/** 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,
/** Download the data behind the widget. Reported only — the file itself
* comes from the {@link #setActionDownload(Action, DownloadHandler)
* download handler} the caller wired to this entry. */
EXPORT,
/** 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 final Map<Action, Anchor> actionLinks = 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 +141,164 @@ 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.EXPORT, Fa.EXPORT, "gridstack.export");
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;
}
/**
* Makes an entry hand out a file: its caption becomes a download link over
* {@code handler}, so picking it downloads instead of only firing an
* {@link ActionEvent} (which it still does — a listener can react to the
* export as well).
* <p>
* The handler is asked for its content when the entry is clicked, not here,
* so a widget that has been re-fed in the meantime exports what it is
* showing at that moment. Calling this again re-points the same link.
*/
public GridStackItem setActionDownload(Action action, DownloadHandler handler) {
Anchor link = actionLinks.computeIfAbsent(action, key -> {
Anchor anchor = new Anchor();
anchor.addClassName(ACTION_LINK_CLASS);
// Reparents the caption into the link: the entry keeps its icon and
// its text, only the text is now what the browser downloads from.
anchor.add(actionCaptions.get(key));
actionItems.get(key).add(anchor);
return anchor;
});
link.setHref(handler, AttachmentType.DOWNLOAD);
return this;
}
/** The download link of an entry wired with
* {@link #setActionDownload(Action, DownloadHandler)}, if it has one. */
public Anchor getActionLink(Action action) {
return actionLinks.get(action);
}
/** 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;
}
} }
/** /**
@@ -90,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). */
@@ -124,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
@@ -0,0 +1,96 @@
package com.example.export;
import java.nio.charset.StandardCharsets;
import java.text.Normalizer;
import java.text.NumberFormat;
import java.util.Arrays;
import java.util.List;
import java.util.Locale;
/**
* One widget's data as a downloadable CSV table: the labels a widget is
* currently showing plus the numbers behind them, already resolved against the
* bundle — this class never sees a translation key, only display text.
* <p>
* The dialect is the one German spreadsheet software expects: {@code ;} as the
* separator (a decimal comma makes {@code ,} unusable), CRLF line breaks, and a
* UTF-8 BOM in {@link #toBytes(Locale)} so Excel reads the umlauts. Numbers are
* formatted for the same {@link Locale} the widget renders in, so the file
* carries the values the user sees, not their machine representation.
*/
public record CsvExport(String categoryHeader, String valueHeader,
List<String> categories, List<Double> values) {
private static final String SEPARATOR = ";";
private static final String LINE_BREAK = "\r\n";
/** UTF-8 byte order mark — without it Excel reads the file as ANSI. */
private static final String BOM = "";
public CsvExport {
categories = List.copyOf(categories);
values = List.copyOf(values);
if (categories.size() != values.size()) {
throw new IllegalArgumentException(
"each value needs a category: %d values, %d categories"
.formatted(values.size(), categories.size()));
}
}
/** A header row plus one row per data point, in the widget's own order. */
public String toCsv(Locale locale) {
NumberFormat numbers = NumberFormat.getNumberInstance(locale);
numbers.setGroupingUsed(false);
numbers.setMaximumFractionDigits(2);
StringBuilder csv = new StringBuilder();
row(csv, quote(categoryHeader), quote(valueHeader));
for (int i = 0; i < categories.size(); i++) {
row(csv, quote(categories.get(i)), numbers.format(values.get(i)));
}
return csv.toString();
}
public byte[] toBytes(Locale locale) {
return (BOM + toCsv(locale)).getBytes(StandardCharsets.UTF_8);
}
private static void row(StringBuilder csv, String... fields) {
csv.append(String.join(SEPARATOR, fields)).append(LINE_BREAK);
}
/** Quotes a field the way RFC 4180 does, doubling embedded quotes. Only
* where it is needed, so a plain label stays readable in a text editor. */
private static String quote(String field) {
if (field.contains(SEPARATOR) || field.contains("\"")
|| field.contains("\n") || field.contains("\r")) {
return '"' + field.replace("\"", "\"\"") + '"';
}
return field;
}
/**
* A file name built from what the user picked the export from — widget
* title and reporting period, say — so a downloads folder full of exports
* is still readable. Parts are slugified and joined with {@code -};
* {@code "Umsatz-Entwicklung"} and {@code "Halbjahr"} become
* {@code umsatz-entwicklung-halbjahr.csv}.
*/
public static String fileName(String... parts) {
String name = Arrays.stream(parts).map(CsvExport::slug)
.filter(part -> !part.isEmpty())
.reduce((a, b) -> a + "-" + b)
.orElse("export");
return name + ".csv";
}
/** Lowercase ASCII, dashes for everything else: umlauts are decomposed and
* their accents dropped, {@code ß} spelled out — {@link Normalizer} has no
* decomposition for it. */
private static String slug(String text) {
String ascii = Normalizer.normalize(text.toLowerCase(Locale.ROOT).replace("ß", "ss"),
Normalizer.Form.NFD)
.replaceAll("\\p{M}", "");
return ascii.replaceAll("[^a-z0-9]+", "-").replaceAll("^-|-$", "");
}
}
@@ -3,34 +3,47 @@ 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;
import com.example.data.DashboardFilter; import com.example.data.DashboardFilter;
import com.example.data.KpiData; import com.example.data.KpiData;
import com.example.export.CsvExport;
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;
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;
import com.vaadin.flow.router.Route; import com.vaadin.flow.router.Route;
import com.vaadin.flow.server.HttpStatusCode;
import com.vaadin.flow.server.VaadinSession;
import com.vaadin.flow.server.streams.DownloadHandler;
import com.vaadin.flow.server.streams.DownloadResponse;
import java.io.ByteArrayInputStream;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Locale;
import java.util.Map; import java.util.Map;
import java.util.Optional;
/** /**
* The dashboard: a {@link GridStackLayout} of draggable/resizable cards whose * The dashboard: a {@link GridStackLayout} of draggable/resizable cards whose
* 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
@@ -46,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;
@@ -54,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<>();
@@ -66,34 +87,98 @@ 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);
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());
}
});
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() {
@@ -141,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
@@ -153,10 +288,69 @@ 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.EXPORT, Action.REMOVE);
item.setActionDownload(Action.EXPORT, csvDownload(definition, content));
item.addActionListener(e -> {
switch (e.getAction()) {
case REFRESH -> WidgetRegistry.refresh(content);
case DUPLICATE -> addWidget(definition);
default -> { }
}
});
return item;
}
/**
* The widget's data as a CSV attachment. Nothing is computed here: the
* callback runs when the user picks the entry, so data, labels and file
* name are all of the moment — including whatever the filter bar is set to
* then.
* <p>
* A download is served on a request of its own, outside the session lock
* and without a current {@code UI} (see
* {@code StreamRequestHandler#callElementResourceHandler}), so the lock is
* taken for the read and the locale is passed explicitly.
*/
private DownloadHandler csvDownload(WidgetDefinition definition, Component content) {
return DownloadHandler.fromInputStream(event -> {
VaadinSession session = event.getSession();
session.lock();
Locale locale;
String fileName;
Optional<CsvExport> table;
try {
locale = event.getUI().getLocale();
table = WidgetRegistry.export(content, locale);
fileName = CsvExport.fileName(getTranslation(locale, definition.titleKey()),
getTranslation(locale, context.getFilter().period().labelKey()));
} finally {
session.unlock();
}
if (table.isEmpty()) {
return DownloadResponse.error(HttpStatusCode.NOT_FOUND);
}
byte[] csv = table.get().toBytes(locale);
return new DownloadResponse(new ByteArrayInputStream(csv), fileName,
"text/csv;charset=utf-8", csv.length);
});
}
/** 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
@@ -8,15 +8,20 @@ import com.example.components.PieChart;
import com.example.data.ChartDataService; 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.example.export.CsvExport;
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;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Locale;
import java.util.Map; import java.util.Map;
import java.util.Optional;
import java.util.function.Consumer; import java.util.function.Consumer;
import java.util.function.Function;
/** /**
* The widget types a dashboard can show. Holding them here rather than inline in * The widget types a dashboard can show. Holding them here rather than inline in
@@ -54,6 +59,49 @@ 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();
}
}
/**
* How a built widget hands out the data it is showing right now, as an
* export table with its labels already resolved against the bundle. Carried
* on the widget component like {@link Refresh}, and read at the moment the
* user asks for the file — so an export always matches the current filter.
* <p>
* The locale is passed in rather than taken from the component: a download
* is served on its own request, where there is no current {@code UI} to
* read a locale from (see {@code DashboardView}).
*/
@FunctionalInterface
public interface Export {
CsvExport table(Locale locale);
}
/** The widget's current data as an export table, or empty for a widget that
* has none (the hint card). */
public static Optional<CsvExport> export(Component widget, Locale locale) {
return Optional.ofNullable(ComponentUtil.getData(widget, Export.class))
.map(export -> export.table(locale));
}
/** 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);
@@ -76,22 +124,18 @@ public class WidgetRegistry {
private Component axisChart(AxisChart chart, DashboardContext context) { private Component axisChart(AxisChart chart, DashboardContext context) {
chart.addPointClickListener(e -> Notification.show(chart.getTranslation( chart.addPointClickListener(e -> Notification.show(chart.getTranslation(
"chart.pointClick", e.getSeriesIndex(), e.getDataPointIndex()))); "chart.pointClick", e.getSeriesIndex(), e.getDataPointIndex())));
return bind(chart, context, filter -> { return bind(chart, context, dataService::revenueByMonth, series ->
ChartSeries series = dataService.revenueByMonth(filter); chart.updateData(chart.getTranslation(series.nameKey()), series.values(),
chart.updateData(chart.getTranslation(series.nameKey()), series.values(), translate(chart, series.categoryKeys())));
translate(chart, series.categoryKeys()));
});
} }
private Component pieChart(DashboardContext context) { private Component pieChart(DashboardContext context) {
PieChart chart = new PieChart(); PieChart chart = new PieChart();
chart.addPointClickListener(e -> Notification.show( chart.addPointClickListener(e -> Notification.show(
chart.getTranslation("chart.sliceClick", e.getDataPointIndex()))); chart.getTranslation("chart.sliceClick", e.getDataPointIndex())));
return bind(chart, context, filter -> { // A pie has no series name — its categories are the slice labels.
ChartSeries series = dataService.revenueByRegion(filter); return bind(chart, context, dataService::revenueByRegion, series ->
// A pie has no series name — its categories are the slice labels. chart.updateData(series.values(), translate(chart, series.categoryKeys())));
chart.updateData(series.values(), translate(chart, series.categoryKeys()));
});
} }
/** /**
@@ -101,15 +145,36 @@ public class WidgetRegistry {
* <p> * <p>
* The first feed goes through the same {@code updateData} path as later * The first feed goes through the same {@code updateData} path as later
* ones, which falls back to a full render while nothing has been drawn yet. * ones, which falls back to a full render while nothing has been drawn yet.
* <p>
* {@code query} is kept rather than only its result, so the widget's
* {@link Refresh} and {@link Export} hooks re-run it against whatever the
* filter is when they are called.
*/ */
private Component bind(ApexChart chart, DashboardContext context, private Component bind(ApexChart chart, DashboardContext context,
Consumer<DashboardFilter> feed) { Function<DashboardFilter, ChartSeries> query,
feed.accept(context.getFilter()); Consumer<ChartSeries> feed) {
Registration registration = context.addFilterChangeListener(feed); Consumer<DashboardFilter> render = filter -> feed.accept(query.apply(filter));
render.accept(context.getFilter());
Registration registration = context.addFilterChangeListener(render);
chart.addDetachListener(e -> registration.remove()); chart.addDetachListener(e -> registration.remove());
ComponentUtil.setData(chart, Refresh.class,
(Refresh) () -> render.accept(context.getFilter()));
ComponentUtil.setData(chart, Export.class,
(Export) locale -> table(chart, query.apply(context.getFilter()), locale));
return sizeFull(chart); return sizeFull(chart);
} }
/** The exported table of a chart series: the same labels the chart draws,
* resolved through the chart component, so the file reads like the widget
* it came from. */
private CsvExport table(Component chart, ChartSeries series, Locale locale) {
return new CsvExport(chart.getTranslation(locale, "export.category"),
chart.getTranslation(locale, series.nameKey()),
series.categoryKeys().stream()
.map(key -> chart.getTranslation(locale, key)).toList(),
series.values());
}
/** Charts fill their grid item instead of using a fixed pixel height, so /** Charts fill their grid item instead of using a fixed pixel height, so
* resizing a widget resizes the chart (grid-stack.ts fires a window * resizing a widget resizes the chart (grid-stack.ts fires a window
* resize on resizestop, which ApexCharts reflows on). */ * resize on resizestop, which ApexCharts reflows on). */
@@ -302,10 +302,63 @@ 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: 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; 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;
}
/* Download entry (GridStackItem#setActionDownload): its caption is an anchor,
which the menu overlay knows nothing about — stretch it over the whole entry
so the entire row downloads, and drop the link look, since the entry already
reads as a menu item. */
.dialect-action-link {
flex: 1;
color: inherit;
text-decoration: none;
}
.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 +373,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;
} }
@@ -340,3 +420,32 @@ apex-chart.dialect-sparkline .apexcharts-xaxis {
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;
}
@@ -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, als CSV exportieren 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,21 @@ 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
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
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, export as CSV 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,21 @@ 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
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
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, exportar como CSV 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,22 @@ 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
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.
export.category=Categoría
filter.period=Periodo filter.period=Periodo
filter.period.month=Mes filter.period.month=Mes
@@ -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,186 @@
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;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
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;
/**
* 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");
}
/** 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();
for (String caption : new String[] {
"Refresh", "Maximize", "Duplicate", "Export as CSV", "Remove"}) {
assertThat(menuEntry(caption)).isVisible();
}
}
/** The whole download path, which only a browser exercises: the menu entry
* is an anchor over a {@code DownloadHandler}, so the file is served on a
* request of its own — outside the session lock and without a current UI. */
@Test
void export_downloadsTheWidgetDataAsCsv() throws IOException {
Locator item = widget();
dismissDevToolsOverlay();
menuButton(item).click();
Download download = page.waitForDownload(() -> menuEntry("Export as CSV").click());
assertEquals("revenue-trend-half-year.csv", download.suggestedFilename());
String csv = new String(download.createReadStream().readAllBytes(),
StandardCharsets.UTF_8);
assertTrue(csv.startsWith("Category;Revenue 2026\r\n"), csv);
// Six months of the default half year, plus the header row.
assertEquals(7, csv.strip().split("\r\n").length, csv);
assertTrue(csv.contains("Jan;30\r\n"), csv);
}
@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 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();
}
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();
}
}
@@ -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(',');
}""");
}
}
@@ -0,0 +1,74 @@
package com.example.export;
import org.junit.jupiter.api.Test;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Locale;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
class CsvExportTest {
private static final CsvExport REVENUE = new CsvExport("Kategorie", "Umsatz 2026",
List.of("Jan", "Feb"), List.of(30.0, 40.5));
@Test
void csv_isAHeaderRowPlusOneRowPerDataPoint() {
assertEquals("""
Kategorie;Umsatz 2026\r
Jan;30\r
Feb;40,5\r
""", REVENUE.toCsv(Locale.GERMANY));
}
/** The separator is what makes a decimal comma safe, so the two must not be
* picked apart: a locale with a decimal point keeps the same separator. */
@Test
void numbers_followTheLocale_separatorDoesNot() {
assertTrue(REVENUE.toCsv(Locale.US).contains("Feb;40.5"),
REVENUE.toCsv(Locale.US));
}
@Test
void fieldsWithSeparatorOrQuote_areQuotedRfc4180Style() {
CsvExport csv = new CsvExport("Kategorie", "Umsatz; \"netto\"",
List.of("Nord; Süd"), List.of(1.0));
assertEquals("Kategorie;\"Umsatz; \"\"netto\"\"\"\r\n"
+ "\"Nord; Süd\";1\r\n", csv.toCsv(Locale.GERMANY));
}
/** Without the BOM Excel reads the file as ANSI and mangles the umlauts. */
@Test
void bytes_startWithTheUtf8Bom() {
byte[] bytes = REVENUE.toBytes(Locale.GERMANY);
assertEquals("", new String(bytes, 0, 3, StandardCharsets.UTF_8));
assertTrue(new String(bytes, StandardCharsets.UTF_8).endsWith("Feb;40,5\r\n"));
}
@Test
void fileName_slugifiesEveryPart() {
assertEquals("umsatz-entwicklung-halbjahr.csv",
CsvExport.fileName("Umsatz-Entwicklung", "Halbjahr"));
assertEquals("umsatz-nach-region-monat.csv",
CsvExport.fileName("Umsatz nach Region", "Monat"));
// Umlauts are decomposed and stripped, ß spelled out — Normalizer has
// no decomposition for it.
assertEquals("grosse-umsatze.csv", CsvExport.fileName("Große Umsätze"));
}
@Test
void fileName_neverEndsUpEmpty() {
assertEquals("export.csv", CsvExport.fileName("", ""));
}
@Test
void aValueWithoutACategory_isRejected() {
assertThrows(IllegalArgumentException.class,
() -> new CsvExport("Kategorie", "Umsatz", List.of("Jan"), List.of(1.0, 2.0)));
}
}
@@ -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;
@@ -11,14 +12,20 @@ import com.example.data.ChartDataService;
import com.example.data.DashboardFilter; import com.example.data.DashboardFilter;
import com.example.data.DashboardFilter.Period; import com.example.data.DashboardFilter.Period;
import com.example.data.KpiData; import com.example.data.KpiData;
import com.example.export.CsvExport;
import com.example.widgets.WidgetDefinition; import com.example.widgets.WidgetDefinition;
import com.example.widgets.WidgetRegistry; import com.example.widgets.WidgetRegistry;
import com.vaadin.browserless.SpringBrowserlessTest; 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.Component;
import com.vaadin.flow.component.UI;
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.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;
@@ -261,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);
@@ -273,6 +388,159 @@ 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 entry is a download link, not a server round trip: it must carry an
* href and the download attribute, or picking it would open the CSV in the
* browser instead of saving it. */
@Test
void actionMenu_export_isADownloadLink() {
navigate(DashboardView.class);
Anchor link = itemById("revenue-trend").getActionLink(Action.EXPORT);
assertNotNull(link, "the export entry carries a download link");
assertFalse(link.getHref().isBlank());
assertTrue(link.isDownload(), "the CSV must be saved, not opened");
assertFalse(itemById("kpi-revenue").isActionEnabled(Action.EXPORT),
"a KPI tile is a single number — nothing to export as a table");
}
/** What the export hands out is the chart's own data, read when it is asked
* for: the same values, in the same order, as the widget is showing. */
@Test
void export_carriesTheValuesTheWidgetShows() {
navigate(DashboardView.class);
CsvExport csv = exportOf($view(LineChart.class).first());
assertEquals(translate("export.category"), csv.categoryHeader());
assertEquals(translate("chart.revenueSeries"), csv.valueHeader());
assertEquals(dataService.revenueByMonth(DashboardFilter.defaults()).values(),
csv.values());
assertEquals(List.of(translate("month.jan"), translate("month.feb"),
translate("month.mar"), translate("month.apr"),
translate("month.may"), translate("month.jun")), csv.categories());
}
@Test
void export_followsTheGlobalFilter() {
navigate(DashboardView.class);
$view(DashboardFilterBar.class).first().getPeriodSelect().setValue(Period.YEAR);
CsvExport csv = exportOf($view(LineChart.class).first());
assertEquals(12, csv.values().size(), "a year is twelve data points");
assertEquals(dataService.revenueByMonth(
DashboardFilter.defaults().withPeriod(Period.YEAR)).values(),
csv.values());
}
/** The pie chart's categories are its slice labels, so its export is the
* region breakdown rather than a time series. */
@Test
void export_ofThePieChart_isTheRegionBreakdown() {
navigate(DashboardView.class);
CsvExport csv = exportOf($view(PieChart.class).first());
assertEquals(List.of(translate("region.north"), translate("region.south"),
translate("region.east"), translate("region.west")), csv.categories());
assertEquals(dataService.revenueByRegion(DashboardFilter.defaults()).values(),
csv.values());
}
private CsvExport exportOf(Component widget) {
return WidgetRegistry.export(widget, UI.getCurrent().getLocale())
.orElseThrow(() -> new AssertionError("widget has no export"));
}
/** 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"));