Compare commits

..

32 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
pitfriedrich cff232070d Merge pull request 'feat: global dashboard filter bar (date range, region) (#24)' (#36) from ai/issue-24-dashboard-filter-bar into main
Reviewed-on: #36
2026-07-28 18:57:15 +00:00
Pit Friedrich df5d1a9b9b fix: add a global dashboard filter bar (#24)
CI / build-and-test (pull_request) Successful in 2m46s
The reporting period was baked into the data service, so nothing could
change what the dashboard shows without editing code.

- DashboardFilter (period + optional region) parameterises every
  ChartDataService query; the no-arg overloads are the default filter.
- DashboardContext is the bus between the new DashboardFilterBar and the
  widgets: charts subscribe in WidgetRegistry, KPI tiles in DashboardView.
- Updates go through updateData, so widgets patch in place instead of
  being rebuilt; SparklineChart gained the same path.
- KpiData carries the number, the bundle the unit and number pattern, so
  a KPI value can follow the filter and stay locale-formatted.
- Filter state is deliberately not persisted, unlike the grid layout.

Closes #24

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0124BiJikbhbiEsNfJxdWM69
2026-07-28 20:52:33 +02:00
pitfriedrich cdbd61aefb Merge pull request 'feat: widget registry and "add widget" picker (#21)' (#35) from ai/issue-21-widget-registry-picker into main
Reviewed-on: #35
2026-07-28 18:36:01 +00:00
Pit Friedrich 5fe159a604 fix: add a widget registry and "add widget" picker (#21)
CI / build-and-test (pull_request) Successful in 2m25s
"Widget hinzufügen" appended an empty placeholder card, so a widget closed
via its X was gone until a page reload. Widget types now live in a
WidgetRegistry (WidgetDefinition: type id, title key, default size,
content factory); the toolbar button opens a picker dialog over the
registry, and the dashboard builds its initial widgets from it too.

Chart construction moves from DashboardView into the registry factories,
which resolve translation keys through the chart component so they follow
the UI locale. Added widgets get an id of "<type>-<n>" from a
monotonically growing counter, so a closed widget's id is never handed to
a new one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0124BiJikbhbiEsNfJxdWM69
2026-07-28 20:32:45 +02:00
pitfriedrich 03872a9d0c Merge pull request 'feat: data service layer for chart widgets (#20)' (#34) from ai/issue-20-chart-data-service into main
Reviewed-on: #34
2026-07-28 18:22:25 +00:00
Pit Friedrich b341b2ed5d fix: pull dashboard widget data from a ChartDataService (#20)
CI / build-and-test (pull_request) Successful in 2m32s
Chart and KPI numbers were literals in DashboardView, duplicated between
the line and bar chart, and only existed at construction time. Add a thin
data layer as the seam for later refresh/filter work:

- ChartSeries / KpiData records, immutable, carrying translation keys
  rather than display text so the data layer stays free of a UI locale.
- ChartDataService interface plus an in-memory implementation returning
  the previous hardcoded numbers, so nothing changes visually.
- DashboardView injects the service, lays out the KPI tiles from its
  list (grid ids come from the data, so saved layouts still match), and
  resolves the keys against the bundle.

Also fixes DashboardChartPlaywrightTest, red on main since the KPI tiles
landed: it patched the first apex-chart, which is now a sparkline with no
xaxis.categories, so the axis-chart patch was a structural change and
legitimately redrew the SVG. It now targets an axis chart.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0124BiJikbhbiEsNfJxdWM69
2026-07-28 20:19:17 +02:00
pitfriedrich e62292b96c Merge pull request 'feat: KPI / stat tile widget type (#26)' (#33) from ai/issue-26-kpi-tile into main
Reviewed-on: #33
2026-07-28 18:04:47 +00:00
Pit Friedrich 21834ec9a0 fix: add KPI tile widget type to the dashboard (#26)
CI / build-and-test (pull_request) Successful in 2m24s
Adds a non-chart dashboard widget: a large value with its label, an
optional signed delta versus the previous period, and an optional
sparkline. Four tiles now sit above the charts, 3x1 each.

- KpiTile draws no surface of its own and fills the grid item's, styled
  from the --dialect-* token layer; the delta's up/down colors are new
  tokens rather than literals, so they follow the light/dark toggle.
- Value and delta share one row: stacking them costs a line the tile does
  not have at its default height of one grid cell.
- SparklineChart is a chrome-free line chart built on DialectTheme, so it
  shares the palette and font with the real charts.
- ApexCharts' sparkline.enabled drops the axes on the initial render but
  updateOptions (how the theme toggle recolors a chart) brings the y-axis
  labels back; they are hidden in CSS, which reaches the chart SVG since
  it renders into light DOM.

The registry registration the issue asks for is left out: #21 is blocked,
so there is no widget picker to register with yet. The tiles are wired
into DashboardView the same way the chart widgets are, and their values
come from the translation bundle until the data service (#20) lands.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0124BiJikbhbiEsNfJxdWM69
2026-07-28 19:57:55 +02:00
pitfriedrich ed9e6f81ea Merge pull request 'fix: incremental chart updates in ApexChart (updateSeries instead of full re-render) (#23)' (#32) from ai/issue-23-incremental-chart-updates into main
Reviewed-on: #32
2026-07-28 17:33:20 +00:00
Pit Friedrich 640616c60a fix: update ApexCharts data in place instead of re-rendering (#23)
CI / build-and-test (pull_request) Successful in 2m21s
Every data change went through renderChart, which rebuilt the chart from a
full option set: animations restarted and zoom/selection state was lost.

Add an updateData path that patches the live chart via ApexCharts'
updateSeries (and updateOptions only when categories/labels actually
change), exposed as AxisChart.updateData / PieChart.updateData. Before the
first render there is nothing to patch, so those fall back to setData.
The theme overlay is re-applied to the merged options, so a rebuild after
a detach starts from the patched data.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0124BiJikbhbiEsNfJxdWM69
2026-07-28 19:29:09 +02:00
pitfriedrich 1b846751dd Merge pull request 'refactor: merge DashboardView and GridStackView into a single dashboard (#19)' (#31) from ai/issue-19-merge-dashboard into main
Reviewed-on: #31
2026-07-28 17:15:44 +00:00
Pit Friedrich 487c206411 fix: merge GridStackView into DashboardView (#19)
CI / build-and-test (pull_request) Successful in 2m22s
DashboardView and GridStackView rendered the same three charts from the
same hardcoded data, so every chart factory method existed twice. The
dashboard is now the GridStackLayout variant at @Route(""):

- GridStackView deleted, its grid/toolbar/chart factories moved into
  DashboardView, which keeps the point-click notifications the old
  dashboard had.
- Charts size to their widget (100%/100%) instead of a fixed 400px.
- MainLayout has one dashboard entry; nav.gridstack/page.gridstack keys
  dropped from all three bundles.
- Storage key is "dashboard" (was "gridstack-demo"), so a saved layout
  from the old route is not reused.
- GridStackViewTest renamed to DashboardViewTest, plus a test asserting
  the three charts render.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0124BiJikbhbiEsNfJxdWM69
2026-07-28 19:12:52 +02:00
pitfriedrich 7aff9fee4f Merge pull request 'test: add Playwright end-to-end example for FormView (#16)' (#18) from ai/issue-16-playwright-formview into main
Reviewed-on: #18
2026-07-26 18:50:21 +00:00
Pit Friedrich a996269aff test: add Playwright end-to-end example for FormView (#16)
CI / build-and-test (pull_request) Successful in 2m16s
Adds the first browser-driven test to the project: PlaywrightTestBase starts
the app on a random port and drives a headless Chromium against it, and
FormViewPlaywrightTest covers the form's rendering, binder validation and the
save/reset buttons.

The tests skip themselves via a JUnit assumption when no browser can be
launched, so runners without Playwright browsers stay green.

Closes #16

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ue9ZtWUBQF4SuSHpzZ3zwq
2026-07-26 20:38:34 +02:00
pitfriedrich 4de757df99 Merge pull request 'fix: make dashboard widgets closable (#14)' (#17) from ai/issue-14-closable-widgets into main
Reviewed-on: #17
2026-07-26 18:20:26 +00:00
Pit Friedrich 7d23eef81b fix: make dashboard widgets closable (#14)
CI / build-and-test (pull_request) Successful in 1m27s
Every GridStackItem now renders a close button next to its drag grip,
mirroring the grip's hover affordance. Closing detaches the item
server-side; grid-stack.ts already unregisters widgets via its
MutationObserver, so the layout is persisted without an extra protocol.

Closable is on by default and can be turned off per item with
setClosable(false); GridStackItem.CloseEvent lets views react.

The GridStackView "remove last" toolbar button is dropped — per-widget
close replaces it, so the view no longer tracks a widget stack. Its
counter now only grows, keeping a closed widget's id from being handed
to a new widget (which would inherit the saved position).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ue9ZtWUBQF4SuSHpzZ3zwq
2026-07-26 20:17:05 +02:00
Pit Friedrich 2e2c9aec3b added playwright to dev dependencies 2026-07-26 20:10:08 +02:00
50 changed files with 3930 additions and 386 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.
+10 -4
View File
@@ -9,9 +9,14 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
./mvnw compile # compile only ./mvnw compile # compile only
./mvnw package # production build → target/*.jar ./mvnw package # production build → target/*.jar
java -jar target/*.jar # run production jar java -jar target/*.jar # run production jar
./mvnw test # run all tests
``` ```
No test sources exist yet (`src/test` is empty) — `spring-boot-starter-test` and `browserless-test-spring` are on the classpath but unused. **Tests** live in two flavours, both run by surefire (`*Test`):
- `views/*Test` — browserless (`browserless-test-spring`): the Vaadin UI is built server-side and asserted on the component tree, no browser. Fast (seconds).
- `e2e/*PlaywrightTest` — real headless Chromium (`com.microsoft.playwright:playwright`) against the app on a random port, via `e2e/PlaywrightTestBase`. Slow (~1 min). Playwright downloads its browsers into `~/.cache/ms-playwright` on first run; if that fails (offline runner, missing system libs) the tests are **skipped** via a JUnit assumption instead of failing the build.
E2E assertions match the **English** captions: German is the unsuffixed fallback bundle (`translations.properties`), so `de` is not a provided locale and Vaadin serves `translations_en.properties` for a German browser. `PlaywrightTestBase` pins the context locale to `en-US` to make that explicit.
**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.: **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 ```bash
@@ -34,7 +39,7 @@ Since `apex-chart.ts` renders into light DOM (`createRenderRoot()` returns `this
**`GridStackLayout`/`GridStackItem`** (`components/`) wrap [gridstack.js](https://gridstack.js.org) 13.1.0 for draggable/resizable grids. Unlike `ApexChart`, the bridge (`frontend/components/grid-stack.ts`) is a plain `HTMLElement`, not a Lit component — its children are server-rendered `GridStackItem`s living in light DOM, and a Lit render root would fight gridstack for ownership of them. A `MutationObserver` calls `makeWidget`/`removeWidget` as Flow adds/removes children, so there's no explicit add/remove protocol to the client. Every `GridStackItem` needs a stable `gs-id` (auto-generated if not given) — `GridStackLayout.setStorageKey(...)` persists drag/resize state to browser `localStorage` keyed on it and restores by matching ids, so items lose their saved position if their id changes between reloads. Item styling (`.grid-stack-item-content`) extends the `--dialect-*` alias layer in `styles.css`, same as `.dialect-card`. **`GridStackLayout`/`GridStackItem`** (`components/`) wrap [gridstack.js](https://gridstack.js.org) 13.1.0 for draggable/resizable grids. Unlike `ApexChart`, the bridge (`frontend/components/grid-stack.ts`) is a plain `HTMLElement`, not a Lit component — its children are server-rendered `GridStackItem`s living in light DOM, and a Lit render root would fight gridstack for ownership of them. A `MutationObserver` calls `makeWidget`/`removeWidget` as Flow adds/removes children, so there's no explicit add/remove protocol to the client. Every `GridStackItem` needs a stable `gs-id` (auto-generated if not given) — `GridStackLayout.setStorageKey(...)` persists drag/resize state to browser `localStorage` keyed on it and restores by matching ids, so items lose their saved position if their id changes between reloads. Item styling (`.grid-stack-item-content`) extends the `--dialect-*` alias layer in `styles.css`, same as `.dialect-card`.
**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`. **Views** (`views/`): `MainLayout` (`@Layout`, applies to all routes) is the `AppLayout` shell — navbar + `SideNav` drawer, with one `SideNavItem` per route. Routes: `DashboardView` (`@Route("")`) puts each chart in a `Card` (`components/Card.java`) inside a `GridStackItem` of a `GridStackLayout`, so widgets are draggable/resizable and the layout is persisted to `localStorage`; charts use `width: 100%` / `height: 100%` to fill their widget rather than a fixed pixel height; `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. **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.
@@ -43,8 +48,9 @@ UI copy/data (chart labels, notifications) is in German.
## AI-Autofix Regeln ## AI-Autofix Regeln
- Nur Issues mit Label `ai-ready` bearbeiten. - Nur Issues mit Label `ai-ready` bearbeiten.
- Branch-Namensschema: `ai/issue-<nr>-<kurz-slug>`. - Branch-Namensschema: `ai/issue-<nr>-<kurz-slug>`.
- Kein Fix ohne grüne Tests. Test-Befehl: `<dein Testkommando>`. - Kein Fix ohne grüne Tests. Test-Befehl: `./mvnw test`.
- 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
+7
View File
@@ -56,6 +56,13 @@
<artifactId>browserless-test-spring</artifactId> <artifactId>browserless-test-spring</artifactId>
<scope>test</scope> <scope>test</scope>
</dependency> </dependency>
<!-- Source: https://mvnrepository.com/artifact/com.microsoft.playwright/playwright -->
<dependency>
<groupId>com.microsoft.playwright</groupId>
<artifactId>playwright</artifactId>
<version>1.61.0</version>
<scope>test</scope>
</dependency>
</dependencies> </dependencies>
<build> <build>
Binary file not shown.
@@ -20,6 +20,10 @@ function resolveColor(varName: string): string {
return resolved; return resolved;
} }
function sameJson(a: unknown, b: unknown): boolean {
return JSON.stringify(a) === JSON.stringify(b);
}
function isDarkScheme(): boolean { function isDarkScheme(): boolean {
return getComputedStyle(document.documentElement).colorScheme.includes('dark'); return getComputedStyle(document.documentElement).colorScheme.includes('dark');
} }
@@ -113,6 +117,49 @@ export class ApexChart extends LitElement {
} }
} }
/**
* Data-only update: patches the live chart via ApexCharts' own
* updateSeries/updateOptions instead of rebuilding it from a full option
* set, so the SVG animates from its previous values and zoom/selection
* state survives. The patch carries `series` plus, depending on the chart
* type, `categories` (axis charts) or `labels` (pie).
*/
async updateData(patchJson: string) {
await this.updateComplete;
const patch = JSON.parse(patchJson);
// Nothing rendered yet — there is no option set to patch onto. The
// server only calls this after a full render, so this is a no-op guard.
if (!this.lastOptions) return;
const options = this.lastOptions;
// Categories/labels are structural: they only reach the chart through
// updateOptions, which redraws. Skip it when they are unchanged, which
// is the common case for a pure data refresh.
const structural: any = {};
if (patch.categories && !sameJson(patch.categories, options.xaxis?.categories)) {
structural.xaxis = { ...options.xaxis, categories: patch.categories };
}
if (patch.labels && !sameJson(patch.labels, options.labels)) {
structural.labels = patch.labels;
}
options.series = patch.series;
if (patch.categories) options.xaxis = { ...options.xaxis, categories: patch.categories };
if (patch.labels) options.labels = patch.labels;
// Re-resolve the theme-varying keys on the merged options, so a rebuild
// after a detach (see connectedCallback) starts from the patched data.
const themed = applyThemeOverlay(options);
this.lastOptionsJson = JSON.stringify(themed);
if (!this.chart) return;
if (Object.keys(structural).length > 0) {
await this.chart.updateOptions(structural, false, true);
}
await this.chart.updateSeries(themed.series, true);
}
disconnectedCallback() { disconnectedCallback() {
super.disconnectedCallback(); super.disconnectedCallback();
window.removeEventListener('dialect-theme-change', this.onThemeChange); window.removeEventListener('dialect-theme-change', this.onThemeChange);
@@ -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));
@@ -17,6 +17,7 @@ public abstract class ApexChart extends Component implements HasSize {
protected static final JsonMapper MAPPER = JsonMapper.builder().build(); protected static final JsonMapper MAPPER = JsonMapper.builder().build();
private final String chartType; private final String chartType;
private boolean optionsSent;
protected ApexChart(String chartType) { protected ApexChart(String chartType) {
this.chartType = chartType; this.chartType = chartType;
@@ -28,6 +29,23 @@ public abstract class ApexChart extends Component implements HasSize {
protected void sendOptions(Map<String, Object> options) { protected void sendOptions(Map<String, Object> options) {
getElement().callJsFunction("renderChart", MAPPER.writeValueAsString(options)); getElement().callJsFunction("renderChart", MAPPER.writeValueAsString(options));
optionsSent = true;
}
/**
* Sends a data-only patch ({@code series} plus {@code categories} or
* {@code labels}) that the client applies to the running chart, instead of
* rebuilding it from a full option set. Only valid once
* {@link #sendOptions} has run — see {@link #hasSentOptions()}.
*/
protected void sendDataPatch(Map<String, Object> patch) {
getElement().callJsFunction("updateData", MAPPER.writeValueAsString(patch));
}
/** Whether a full option set has been sent, i.e. whether there is a chart
* on the client a data patch could be applied to. */
protected boolean hasSentOptions() {
return optionsSent;
} }
@ClientCallable @ClientCallable
@@ -20,4 +20,22 @@ public abstract class AxisChart extends ApexChart {
sendOptions(options); sendOptions(options);
} }
/**
* Replaces the data of an already rendered chart without re-rendering it:
* the client patches series (and categories) into the live chart, so the
* update animates from the previous values instead of flashing. Falls back
* to {@link #setData} as long as nothing has been rendered yet.
*/
public void updateData(String seriesName, List<Double> values, List<String> categories) {
if (!hasSentOptions()) {
setData(seriesName, values, categories);
return;
}
sendDataPatch(Map.of(
"series", List.of(Map.of("name", seriesName, "data", values)),
"categories", categories
));
}
} }
@@ -62,4 +62,25 @@ public final class DialectTheme {
return options; return options;
} }
/**
* Options for a {@link SparklineChart}: ApexCharts' own
* {@code sparkline.enabled} strips axes, grid and legend, so the line fills
* the whole (small) container. Built on top of {@link #baseOptions} rather
* than from scratch, so palette and font stay shared with the real charts.
*/
public static Map<String, Object> sparklineOptions() {
Map<String, Object> options = baseOptions("line");
@SuppressWarnings("unchecked")
Map<String, Object> chart = (Map<String, Object>) options.get("chart");
chart.put("sparkline", Map.of("enabled", true));
// Thinner than a full-size line, and no tooltip: a KPI tile is meant to
// be read at a glance, not hovered.
options.put("stroke", Map.of("curve", "smooth", "width", 2));
options.put("tooltip", Map.of("enabled", false));
return options;
}
} }
+9 -1
View File
@@ -25,7 +25,15 @@ public enum Fa {
ADD("fa-solid", "fa-plus"), ADD("fa-solid", "fa-plus"),
REMOVE("fa-solid", "fa-trash"), REMOVE("fa-solid", "fa-trash"),
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"),
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_DOWN("fa-solid", "fa-arrow-trend-down");
private final String[] classes; private final String[] classes;
@@ -1,8 +1,23 @@
package com.example.components; 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.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 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;
/** /**
@@ -12,7 +27,19 @@ import java.util.UUID;
* <p> * <p>
* Dragging starts from the grip handle only, never from the item body, so the * Dragging starts from the grip handle only, never from the item body, so the
* components inside an item stay interactive (see * components inside an item stay interactive (see
* {@link GridStackLayout#setDragHandle(String)}). * {@link GridStackLayout#setDragHandle(String)}). A close button sits next to
* the grip (see {@link #setClosable(boolean)}); removing the item server-side
* is enough to unregister the widget client-side, since grid-stack.ts observes
* its own childList.
* <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 {
@@ -20,8 +47,57 @@ public class GridStackItem extends Div {
* {@link GridStackLayout}'s {@code handle} option. */ * {@link GridStackLayout}'s {@code handle} option. */
public static final String DRAG_HANDLE_CLASS = "dialect-drag-handle"; public static final String DRAG_HANDLE_CLASS = "dialect-drag-handle";
/** Marker class for the close button; {@code styles.css} keys both its own
* styling and the grip's offset off it. */
public static final String CLOSE_BUTTON_CLASS = "dialect-close-button";
/** Marker class for the action menu; the corner controls' offsets in
* {@code styles.css} are keyed off its presence, same as the close
* button's. */
public static final String ACTION_MENU_CLASS = "dialect-action-menu";
/** 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 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 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);
@@ -51,6 +127,259 @@ public class GridStackItem extends Div {
dragHandle.getElement().setAttribute("title", label); dragHandle.getElement().setAttribute("title", label);
dragHandle.getElement().setAttribute("aria-label", label); dragHandle.getElement().setAttribute("aria-label", label);
getElement().appendChild(dragHandle.getElement()); getElement().appendChild(dragHandle.getElement());
// Sibling of the content wrapper for the same reason as the grip, and
// outside the drag-handle selector so clicking it never starts a drag.
closeButton.addClassName(CLOSE_BUTTON_CLASS);
closeButton.add(Fa.CLOSE.create());
String closeLabel = getTranslation("gridstack.close");
closeButton.getElement().setAttribute("title", closeLabel);
closeButton.getElement().setAttribute("aria-label", closeLabel);
closeButton.getElement().setAttribute("role", "button");
closeButton.getElement().setAttribute("tabindex", "0");
closeButton.getElement().addEventListener("click", e -> close(true));
closeButton.getElement().addEventListener("keydown", e -> close(true))
.setFilter("event.key === 'Enter' || event.key === ' '");
getElement().appendChild(closeButton.getElement());
buildActionMenu();
getElement().appendChild(actionMenu.getElement());
}
/**
* The overflow menu, a third sibling of the content wrapper: outside it for
* the same reason as the grip, and outside the {@code DRAG_HANDLE_CLASS}
* selector so opening it never starts a drag.
* <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;
}
}
/**
* Detaches this item from its layout and notifies
* {@link #addCloseListener(ComponentEventListener) close listeners}. Called
* by the close button, and usable server-side to close an item
* programmatically.
*/
public void close() {
close(false);
}
private void close(boolean fromClient) {
// Captured before detaching: once removeFromParent() runs, the grid no
// longer reports this item, so its position could not be read back from
// there afterwards. The gs-* attributes themselves are untouched by the
// detach, so a caller that keeps this item around and re-adds it later
// (e.g. to undo the close) lands it back at exactly this spot.
Position position = capturePosition();
// Detach first so listeners observe the layout they are about to see —
// getLayout() on the grid no longer counts this item.
getElement().removeFromParent();
fireEvent(new CloseEvent(this, fromClient, 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). */
public GridStackItem setClosable(boolean closable) {
if (closable == this.closable) {
return this;
}
this.closable = closable;
if (closable) {
getElement().appendChild(closeButton.getElement());
} else {
closeButton.getElement().removeFromParent();
}
return this;
}
public boolean isClosable() {
return closable;
}
/** The close button element. Exposed for restyling, the same way
* {@link #getDragHandle()} is. */
public Div getCloseButton() {
return closeButton;
}
public Registration addCloseListener(ComponentEventListener<CloseEvent> listener) {
return addListener(CloseEvent.class, listener);
}
/** 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> {
private final Position position;
CloseEvent(GridStackItem source, boolean fromClient, Position position) {
super(source, fromClient);
this.position = position;
}
public Position getPosition() {
return position;
}
} }
public void add(Component... components) { public void add(Component... components) {
@@ -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,138 @@
package com.example.components;
import com.vaadin.flow.component.html.Div;
import com.vaadin.flow.component.html.Span;
import java.text.DecimalFormat;
import java.text.DecimalFormatSymbols;
import java.util.List;
import java.util.Locale;
/**
* A non-chart dashboard widget: one large value with its label, an optional
* signed delta versus the previous period, and an optional
* {@link SparklineChart}.
* <p>
* Deliberately not a {@link Card}: a card contributes its own surface, heading
* and 20px padding, which is more chrome than fits a one-row grid item. The
* tile draws no surface of its own and fills the grid item's instead, styled
* from the same {@code --dialect-*} token layer (see {@code styles.css}) —
* including the delta's up/down colors, which are tokens rather than literals
* so they follow the light/dark toggle.
* <p>
* Label and value are passed in already translated; the only text this
* component formats itself is the delta, which is locale-formatted rather than
* translated (a signed number and a percent sign).
*/
public class KpiTile extends Div {
/** Positive/negative/zero delta modifiers; {@code styles.css} keys the
* delta color off these. */
private static final String DELTA_CLASS = "dialect-kpi__delta";
private final Span value = new Span();
private final Span label = new Span();
private final Div delta = new Div();
/** Value and delta share one row — stacking them costs a line the tile does
* not have at its default height of one grid cell. */
private final Div figure = new Div();
private String deltaText;
private SparklineChart sparkline;
public KpiTile(String label, String value) {
addClassName("dialect-kpi");
this.value.addClassName("dialect-kpi__value");
this.value.setText(value);
this.label.addClassName("dialect-kpi__label");
this.label.setText(label);
figure.addClassName("dialect-kpi__figure");
figure.add(this.value);
add(this.label, figure);
}
public KpiTile setValue(String value) {
this.value.setText(value);
return this;
}
public String getValue() {
return value.getText();
}
public String getLabel() {
return label.getText();
}
/**
* Shows the change versus the previous period, as a percentage: an arrow
* plus the signed number, colored by direction. A delta of exactly zero
* gets the neutral treatment rather than an upward arrow.
*/
public KpiTile setDelta(double percent) {
delta.removeAll();
delta.getClassNames().clear();
delta.addClassName(DELTA_CLASS);
delta.addClassName(DELTA_CLASS + "--" + direction(percent));
if (percent > 0) {
delta.add(Fa.TREND_UP.create());
} else if (percent < 0) {
delta.add(Fa.TREND_DOWN.create());
}
deltaText = formatDelta(percent);
delta.add(new Span(deltaText));
if (delta.getParent().isEmpty()) {
figure.add(delta);
}
return this;
}
/** The rendered delta text (without the arrow icon), or {@code null} while
* no delta has been set. Kept as a field rather than read back off the
* element: the text lives in a child span, so the wrapper's own text
* content is empty. */
public String getDeltaText() {
return deltaText;
}
/** Adds a trend line below the value, or re-feeds the existing one — in
* place, so a re-fed tile (a filter change) animates instead of flashing. */
public KpiTile setSparkline(String seriesName, List<Double> values) {
if (sparkline == null) {
sparkline = new SparklineChart();
sparkline.setWidthFull();
sparkline.setHeight("100%");
Div wrapper = new Div(sparkline);
wrapper.addClassName("dialect-kpi__sparkline");
add(wrapper);
}
sparkline.updateData(seriesName, values);
return this;
}
public SparklineChart getSparkline() {
return sparkline;
}
private static String direction(double percent) {
if (percent > 0) {
return "up";
}
return percent < 0 ? "down" : "flat";
}
/** {@code +12,4 %} / {@code -3,1 %} — the sign is always explicit, since a
* delta without one reads as an absolute value. */
private String formatDelta(double percent) {
Locale locale = getLocale();
DecimalFormat format = new DecimalFormat("+0.0;-0.0",
DecimalFormatSymbols.getInstance(locale));
return format.format(percent) + " %";
}
}
@@ -16,4 +16,18 @@ public class PieChart extends ApexChart {
sendOptions(options); sendOptions(options);
} }
/**
* Replaces the data of an already rendered chart without re-rendering it —
* see {@link AxisChart#updateData}. Falls back to {@link #setData} as long
* as nothing has been rendered yet.
*/
public void updateData(List<Double> values, List<String> labels) {
if (!hasSentOptions()) {
setData(values, labels);
return;
}
sendDataPatch(Map.of("series", values, "labels", labels));
}
} }
@@ -0,0 +1,44 @@
package com.example.components;
import java.util.List;
import java.util.Map;
/**
* A chrome-free line chart — no axes, grid, legend or tooltip, just the trend
* shape — meant to sit inside a {@link KpiTile} next to the value it belongs
* to. Not an {@link AxisChart}: there are no categories to label, so the
* axis-based {@code setData} signature would only carry dead arguments.
*/
public class SparklineChart extends ApexChart {
/** Marker class the axis-suppressing rule in {@code styles.css} keys off. */
private static final String CLASS_NAME = "dialect-sparkline";
public SparklineChart() {
super("line");
// Element API rather than addClassName: ApexChart is HasSize, not
// HasStyle.
getElement().getClassList().add(CLASS_NAME);
}
public void setData(String seriesName, List<Double> values) {
Map<String, Object> options = DialectTheme.sparklineOptions();
options.put("series", List.of(Map.of("name", seriesName, "data", values)));
sendOptions(options);
}
/**
* Replaces the trend of an already rendered sparkline without re-rendering
* it — see {@link AxisChart#updateData}. There are no categories to patch
* along, so this is a series-only update.
*/
public void updateData(String seriesName, List<Double> values) {
if (!hasSentOptions()) {
setData(seriesName, values);
return;
}
sendDataPatch(Map.of("series", List.of(Map.of("name", seriesName, "data", values))));
}
}
@@ -0,0 +1,43 @@
package com.example.data;
import java.util.List;
/**
* The seam between the dashboard widgets and wherever their numbers come from.
* Views ask this service for data instead of holding literals, so a widget can
* be re-fed later (live refresh, a global filter, a per-widget refresh action)
* and so the data logic is testable without a Vaadin component tree.
* <p>
* Every query is parameterised by a {@link DashboardFilter}: that is the path
* the filter bar drives all widgets through. The no-argument overloads are the
* same queries under {@link DashboardFilter#defaults()}.
* <p>
* Deliberately thin: the current implementation
* ({@link InMemoryChartDataService}) is in-memory dummy data. A real backend
* would replace the implementation, not this interface.
*/
public interface ChartDataService {
/** Revenue per month (per week for {@code Period.MONTH}), for the line and
* bar charts. Honours both the period and a region selection. */
ChartSeries revenueByMonth(DashboardFilter filter);
/** Revenue per sales region, for the pie chart. Honours the period; a
* region selection is ignored, since the breakdown is the chart itself. */
ChartSeries revenueByRegion(DashboardFilter filter);
/** The KPI tiles, in the order the dashboard lays them out. */
List<KpiData> kpis(DashboardFilter filter);
default ChartSeries revenueByMonth() {
return revenueByMonth(DashboardFilter.defaults());
}
default ChartSeries revenueByRegion() {
return revenueByRegion(DashboardFilter.defaults());
}
default List<KpiData> kpis() {
return kpis(DashboardFilter.defaults());
}
}
@@ -0,0 +1,27 @@
package com.example.data;
import java.util.List;
/**
* One named series of numbers plus the categories they are indexed by — the
* shape every chart on the dashboard is fed with, axis-based or not (a pie
* chart reads the categories as slice labels and ignores the series name).
* <p>
* {@code nameKey} and {@code categoryKeys} are <em>translation keys</em>, not
* display text: the data layer must stay free of a {@code UI} and its locale,
* so resolving them against the bundle is the view's job. Values and keys are
* defensively copied, so a data set handed out by a
* {@link ChartDataService} cannot be modified by its consumer.
*/
public record ChartSeries(String nameKey, List<Double> values, List<String> categoryKeys) {
public ChartSeries {
values = List.copyOf(values);
categoryKeys = List.copyOf(categoryKeys);
if (values.size() != categoryKeys.size()) {
throw new IllegalArgumentException(
"each value needs a category: %d values, %d categories"
.formatted(values.size(), categoryKeys.size()));
}
}
}
@@ -0,0 +1,72 @@
package com.example.data;
import java.util.Objects;
/**
* What the dashboard is currently showing: a reporting period and, optionally, a
* single sales region. Immutable — a filter change produces a new instance
* (see {@link #withPeriod} / {@link #withRegion}), which is what makes it safe
* to hand around to widgets.
* <p>
* Every {@link ChartDataService} query takes one of these; widgets a given
* dimension does not apply to simply ignore it (the region pie chart <em>is</em>
* the region breakdown, so it ignores a region selection but honours the
* period).
*/
public record DashboardFilter(Period period, String regionKey) {
/**
* Sentinel {@link #regionKey} meaning "do not restrict by region". It is a
* translation key of its own, so the filter bar can label it like any real
* region instead of special-casing a null.
*/
public static final String ALL_REGIONS = "filter.allRegions";
public DashboardFilter {
Objects.requireNonNull(period, "period");
Objects.requireNonNull(regionKey, "regionKey");
}
/** The unfiltered dashboard: half a year, all regions. */
public static DashboardFilter defaults() {
return new DashboardFilter(Period.HALF_YEAR, ALL_REGIONS);
}
public boolean allRegions() {
return ALL_REGIONS.equals(regionKey);
}
public DashboardFilter withPeriod(Period period) {
return new DashboardFilter(period, regionKey);
}
public DashboardFilter withRegion(String regionKey) {
return new DashboardFilter(period, regionKey);
}
/**
* The reporting period, which decides both the range covered and the
* granularity of a data point: a month is read as four weeks, everything
* longer as months.
*/
public enum Period {
MONTH("filter.period.month"),
QUARTER("filter.period.quarter"),
HALF_YEAR("filter.period.halfYear"),
YEAR("filter.period.year");
private final String labelKey;
Period(String labelKey) {
this.labelKey = labelKey;
}
/** Translation key of the caption the filter bar shows — the data layer
* stays free of a {@code UI} and its locale, same as
* {@link ChartSeries}. */
public String labelKey() {
return labelKey;
}
}
}
@@ -0,0 +1,142 @@
package com.example.data;
import com.example.data.DashboardFilter.Period;
import org.springframework.stereotype.Service;
import java.util.List;
/**
* Dummy implementation over a hardcoded year of numbers. There is no query
* engine behind this: a {@link DashboardFilter} selects a slice of the base
* year and scales it, deterministically, so the same filter always yields the
* same data.
* <p>
* The scaling is relative to {@link DashboardFilter#defaults()}, so the
* unfiltered dashboard shows exactly the figures it always did.
*/
@Service
public class InMemoryChartDataService implements ChartDataService {
private static final List<String> MONTHS = List.of(
"month.jan", "month.feb", "month.mar", "month.apr", "month.may", "month.jun",
"month.jul", "month.aug", "month.sep", "month.oct", "month.nov", "month.dec");
/** The four weeks a {@code Period.MONTH} is split into. */
private static final List<String> WEEKS = List.of(
"week.1", "week.2", "week.3", "week.4");
/** How a month's figure is distributed over its weeks. Sums to 1, so a
* month's four weeks add up to the month again. */
private static final List<Double> WEEK_SHARES = List.of(0.22, 0.25, 0.23, 0.30);
/** Index of the month a {@code Period.MONTH} reports on — June, the last
* month of the default half year. */
private static final int CURRENT_MONTH = 5;
private static final List<String> REGIONS = List.of(
"region.north", "region.south", "region.east", "region.west");
/** Revenue per region over the default half year; also the weights a region
* selection scales the other widgets by. */
private static final List<Double> REVENUE_PER_REGION = List.of(30.0, 40.0, 35.0, 50.0);
private static final List<Double> REVENUE_PER_MONTH = List.of(
30.0, 40.0, 35.0, 50.0, 49.0, 60.0, 58.0, 64.0, 55.0, 70.0, 66.0, 80.0);
private static final List<Double> OPEN_ORDERS_TREND = List.of(
52.0, 47.0, 49.0, 44.0, 40.0, 38.0, 41.0, 39.0, 36.0, 34.0, 33.0, 30.0);
private static final List<Double> NEW_CUSTOMERS_TREND = List.of(
74.0, 81.0, 79.0, 95.0, 104.0, 112.0, 108.0, 118.0, 121.0, 130.0, 127.0, 140.0);
private static final List<Double> ORDER_VALUE_TREND = List.of(
480.0, 492.0, 478.0, 489.0, 483.0, 486.0, 491.0, 488.0, 494.0, 490.0, 497.0, 499.0);
@Override
public ChartSeries revenueByMonth(DashboardFilter filter) {
return new ChartSeries("chart.revenueSeries",
scale(slice(REVENUE_PER_MONTH, filter.period()), regionShare(filter), 1),
categoryKeys(filter.period()));
}
@Override
public ChartSeries revenueByRegion(DashboardFilter filter) {
// The region breakdown is what this chart is, so a region selection
// would leave it with a single slice — it honours the period only.
return new ChartSeries("chart.revenueSeries",
scale(REVENUE_PER_REGION, periodFactor(filter.period()), 1), REGIONS);
}
@Override
public List<KpiData> kpis(DashboardFilter filter) {
double region = regionShare(filter);
// A total covers the whole period, so it grows with it; a snapshot
// (open orders) and an average (order value) do not.
double total = periodFactor(filter.period()) * region;
return List.of(
new KpiData("kpi-revenue", "kpi.revenueTotal", round(1.24 * total, 2), 12.4,
scale(slice(REVENUE_PER_MONTH, filter.period()), region, 1)),
new KpiData("kpi-orders", "kpi.openOrders", round(38 * region, 0), -3.1,
scale(slice(OPEN_ORDERS_TREND, filter.period()), region, 0)),
new KpiData("kpi-customers", "kpi.newCustomers", round(112 * total, 0), 8.0,
scale(slice(NEW_CUSTOMERS_TREND, filter.period()), region, 0)),
new KpiData("kpi-order-value", "kpi.averageOrderValue", 486.0, 0.0,
slice(ORDER_VALUE_TREND, filter.period())));
}
/** The stretch of the base year a period covers, at the period's own
* granularity: four weeks of the current month, or whole months. */
private static List<Double> slice(List<Double> year, Period period) {
return switch (period) {
case MONTH -> WEEK_SHARES.stream()
.map(share -> round(year.get(CURRENT_MONTH) * share, 1)).toList();
case QUARTER -> List.copyOf(year.subList(3, 6));
case HALF_YEAR -> List.copyOf(year.subList(0, 6));
case YEAR -> year;
};
}
private static List<String> categoryKeys(Period period) {
return switch (period) {
case MONTH -> WEEKS;
case QUARTER -> MONTHS.subList(3, 6);
case HALF_YEAR -> MONTHS.subList(0, 6);
case YEAR -> MONTHS;
};
}
/** How much larger the selected period is than the default half year — what
* cumulative figures (pie slices, KPI totals) are scaled by. */
private static double periodFactor(Period period) {
return sum(slice(REVENUE_PER_MONTH, period)) / sum(slice(REVENUE_PER_MONTH, Period.HALF_YEAR));
}
/** A selected region's share of total revenue, or 1 for all regions. */
private static double regionShare(DashboardFilter filter) {
if (filter.allRegions()) {
return 1;
}
int index = REGIONS.indexOf(filter.regionKey());
if (index < 0) {
throw new IllegalArgumentException("unknown region: " + filter.regionKey());
}
return REVENUE_PER_REGION.get(index) / sum(REVENUE_PER_REGION);
}
private static List<Double> scale(List<Double> values, double factor, int decimals) {
if (factor == 1) {
return List.copyOf(values);
}
return values.stream().map(value -> round(value * factor, decimals)).toList();
}
private static double round(double value, int decimals) {
double unit = Math.pow(10, decimals);
return Math.round(value * unit) / unit;
}
private static double sum(List<Double> values) {
return values.stream().mapToDouble(Double::doubleValue).sum();
}
}
@@ -0,0 +1,29 @@
package com.example.data;
import java.util.List;
/**
* The numbers behind one KPI tile: the figure itself, the change versus the
* previous period and the trend the sparkline draws.
* <p>
* {@code id} identifies the KPI itself (the dashboard reuses it as the grid
* item's {@code gs-id}, which must stay stable across reloads for a saved
* layout to match it again), while {@code labelKey} is a translation key. The
* displayed value is {@code labelKey + ".value"} <em>formatted with</em>
* {@code value}: the bundle carries the unit and the locale's number pattern
* ("1,24 Mio. €" against "€1.24M"), the service carries only the number — which
* it has to, because the number depends on the {@link DashboardFilter}.
*/
public record KpiData(String id, String labelKey, double value, double deltaPercent,
List<Double> trend) {
public KpiData {
trend = List.copyOf(trend);
}
/** The bundle key of the display value; a {@code MessageFormat} pattern
* taking {@link #value()} as its single argument. */
public String valueKey() {
return labelKey + ".value";
}
}
@@ -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("^-|-$", "");
}
}
@@ -0,0 +1,63 @@
package com.example.views;
import com.example.data.DashboardFilter;
import com.example.data.DashboardFilter.Period;
import com.example.widgets.DashboardContext;
import com.vaadin.flow.component.orderedlayout.HorizontalLayout;
import com.vaadin.flow.component.select.Select;
import java.util.List;
/**
* The dashboard's global filter: reporting period and sales region. Picking a
* value pushes a new {@link DashboardFilter} into the {@link DashboardContext},
* which is what re-feeds the widgets — the bar itself knows no widget and holds
* no data.
* <p>
* Both selects are always populated, so there is no empty state to handle: the
* "all regions" entry is a region key like any other (see
* {@link DashboardFilter#ALL_REGIONS}).
*/
public class DashboardFilterBar extends HorizontalLayout {
private static final List<String> REGION_KEYS = List.of(DashboardFilter.ALL_REGIONS,
"region.north", "region.south", "region.east", "region.west");
private final Select<Period> period = new Select<>();
private final Select<String> region = new Select<>();
public DashboardFilterBar(DashboardContext context) {
setPadding(false);
setAlignItems(Alignment.END);
period.setLabel(getTranslation("filter.period"));
period.setItems(Period.values());
period.setItemLabelGenerator(value -> getTranslation(value.labelKey()));
period.setValue(context.getFilter().period());
period.addValueChangeListener(e -> {
if (e.getValue() != null) {
context.setFilter(context.getFilter().withPeriod(e.getValue()));
}
});
region.setLabel(getTranslation("filter.region"));
region.setItems(REGION_KEYS);
region.setItemLabelGenerator(this::getTranslation);
region.setValue(context.getFilter().regionKey());
region.addValueChangeListener(e -> {
if (e.getValue() != null) {
context.setFilter(context.getFilter().withRegion(e.getValue()));
}
});
add(period, region);
}
public Select<Period> getPeriodSelect() {
return period;
}
public Select<String> getRegionSelect() {
return region;
}
}
@@ -1,63 +1,377 @@
package com.example.views; package com.example.views;
import com.example.components.BarChart;
import com.example.components.Card; import com.example.components.Card;
import com.example.components.LineChart; import com.example.components.Fa;
import com.example.components.PieChart; import com.example.components.GridStackItem;
import com.example.components.GridStackItem.Action;
import com.example.components.GridStackLayout;
import com.example.components.KpiTile;
import com.example.data.ChartDataService;
import com.example.data.DashboardFilter;
import com.example.data.KpiData;
import com.example.export.CsvExport;
import com.example.widgets.DashboardContext;
import com.example.widgets.WidgetDefinition;
import com.example.widgets.WidgetRegistry;
import com.vaadin.flow.component.Component;
import com.vaadin.flow.component.button.Button;
import com.vaadin.flow.component.button.ButtonVariant;
import com.vaadin.flow.component.dialog.Dialog;
import com.vaadin.flow.component.html.Div;
import com.vaadin.flow.component.html.Paragraph;
import com.vaadin.flow.component.html.Span;
import com.vaadin.flow.component.notification.Notification; import com.vaadin.flow.component.notification.Notification;
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.List; import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
/**
* The dashboard: a {@link GridStackLayout} of draggable/resizable cards whose
* layout is persisted per browser, plus controls to add widgets and reset the
* layout at runtime. Widgets are removed by their own close button (see
* {@link GridStackItem#setClosable(boolean)}), not from the toolbar, and are
* added back from the picker over {@link WidgetRegistry} — or undone straight
* from the toast every close spawns (see {@link #offerUndo}).
* <p>
* All widget numbers come from {@link ChartDataService} and every chart widget
* is built by the registry; this view only decides where a widget sits and
* resolves the translation keys it is handed against the bundle.
* <p>
* A {@link DashboardFilterBar} above the grid drives every widget through one
* {@link DashboardContext}: the charts subscribe to it themselves (in the
* registry), the KPI tiles are re-fed here, since this view is what built them.
* The filter is view state and is not persisted — see {@link DashboardContext}.
*/
@Route("") @Route("")
public class DashboardView extends VerticalLayout implements HasDynamicTitle { public class DashboardView extends VerticalLayout implements HasDynamicTitle {
public DashboardView() { private static final String STORAGE_KEY = "dashboard";
/** Matches gridstack's {@code columnOpts} breakpoint below which the grid
* stacks to a single column (see {@link GridStackLayout#setResponsiveBreakpoint}). */
private static final int MOBILE_BREAKPOINT_PX = 768;
/** A KPI tile is a quarter row wide, so the n-th one starts at 3n. */
private static final int KPI_WIDTH = 3;
private final ChartDataService dataService;
private final WidgetRegistry widgets;
private final DashboardContext context = new DashboardContext();
private final GridStackLayout grid = new GridStackLayout();
private final Span status = new Span();
/** Shown instead of {@link #grid} once every widget has been closed; not a
* {@link GridStackItem} itself, so it never becomes draggable and never
* shows up in {@link GridStackLayout#getLayout()}. */
private final Div emptyState = new Div();
/** The KPI tiles by KPI id, so a filter change re-feeds each tile with the
* data of the same KPI rather than by position. */
private final Map<String, KpiTile> kpiTiles = new LinkedHashMap<>();
private int extraWidgetCount;
public DashboardView(ChartDataService dataService, WidgetRegistry widgets) {
this.dataService = dataService;
this.widgets = widgets;
addClassName("dialect-content"); addClassName("dialect-content");
List<String> months = List.of( grid.setWidthFull();
getTranslation("month.jan"), getTranslation("month.feb"), grid.setStorageKey(STORAGE_KEY);
getTranslation("month.mar"), getTranslation("month.apr"), // Below phone/small-tablet width, stack every widget full-width.
getTranslation("month.may"), getTranslation("month.jun")); grid.setResponsiveBreakpoint(MOBILE_BREAKPOINT_PX, 1);
grid.addLayoutChangeListener(e -> status.setText(
getTranslation("gridstack.status", e.getPositions().size())));
LineChart lineChart = new LineChart(); configureEmptyState();
lineChart.setWidthFull(); buildDefaultWidgets();
lineChart.setHeight("400px"); context.addFilterChangeListener(this::updateKpiTiles);
lineChart.setData(getTranslation("chart.revenueSeries"),
List.of(30.0, 40.0, 35.0, 50.0, 49.0, 60.0), status.setText(getTranslation("gridstack.statusInitial"));
months); status.addClassName("dialect-muted");
lineChart.addPointClickListener(e -> {
Notification.show(getTranslation("chart.pointClick", add(toolbar(), new DashboardFilterBar(context), grid, emptyState);
e.getSeriesIndex(), e.getDataPointIndex())); }
/** The dashboard's initial widget set: the KPI tiles first — the numbers a
* dashboard is read for, above the charts that explain them — then the
* three default charts and the usage hint. Also used to rebuild the
* dashboard from scratch via {@link #restoreDefaultWidgets()}. */
private void buildDefaultWidgets() {
// They are 3x1 — a quarter row each, one cell high, laid out left to
// right in the order the service returns them. The grid id is the
// KPI's own id, so it survives reordering.
List<KpiData> kpis = dataService.kpis(context.getFilter());
for (int i = 0; i < kpis.size(); i++) {
KpiData kpi = kpis.get(i);
KpiTile tile = feed(new KpiTile(getTranslation(kpi.labelKey()), ""), kpi);
kpiTiles.put(kpi.id(), 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);
}
List.of(
defaultWidget(WidgetRegistry.REVENUE_TREND, 0, 1, 6, 3),
defaultWidget(WidgetRegistry.REVENUE_MONTH, 6, 1, 6, 3),
defaultWidget(WidgetRegistry.REVENUE_REGION, 0, 4, 5, 3),
new GridStackItem("hint", 5, 4, 7, 3,
new Card(getTranslation("card.gridstackHint"),
new Paragraph(getTranslation("gridstack.hint")))))
.forEach(this::addWidgetToGrid);
}
/** Builds the placeholder shown once every widget has been closed: a short
* explanation, a CTA that opens the same {@link #openWidgetPicker() widget
* picker} as the toolbar, and a secondary action that rebuilds the default
* layout — {@link GridStackLayout#resetLayout()} alone cannot do that here,
* since it only repositions widgets still present, and none are left. */
private void configureEmptyState() {
emptyState.addClassName("dialect-empty-state");
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() {
Button add = new Button(getTranslation("gridstack.addWidget"), Fa.ADD.create(),
e -> openWidgetPicker());
add.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
Button reset = new Button(getTranslation("gridstack.reset"), Fa.RESET.create(),
e -> grid.resetLayout());
HorizontalLayout toolbar = new HorizontalLayout(add, reset, status);
toolbar.setPadding(false);
toolbar.setWidthFull();
toolbar.setAlignItems(Alignment.CENTER);
return toolbar;
}
/** Lists the registered widget types; picking one adds a widget of that
* type, which is also how a closed widget is brought back. */
private void openWidgetPicker() {
Dialog picker = new Dialog(getTranslation("gridstack.pickerTitle"));
VerticalLayout choices = new VerticalLayout();
choices.setPadding(false);
for (WidgetDefinition definition : widgets.definitions()) {
Button choice = new Button(getTranslation(definition.titleKey()), e -> {
addWidget(definition);
picker.close();
});
choice.setWidthFull();
choices.add(choice);
}
picker.add(choices);
picker.getFooter().add(new Button(getTranslation("form.cancel"), e -> picker.close()));
picker.open();
}
/** Adds a widget at the top left in its type's default size; gridstack
* floats it down into the first free slot. */
private void addWidget(WidgetDefinition definition) {
// The id must stay stable across reloads for the saved layout to match
// it again, so it is derived from a counter rather than a random UUID.
// The counter only ever grows, and it is shared across types: closing a
// widget must not hand its id to the next one, or the new widget would
// inherit the closed one's saved position.
extraWidgetCount++;
addWidgetToGrid(widget(definition.type() + "-" + extraWidgetCount, definition,
0, 0, definition.width(), definition.height()));
}
/** Adds an item to the grid and wires the undo toast onto its close — every
* widget goes through here exactly once, so the close listener (and thus
* the undo offer) is registered once per item, not once per re-add: an
* undo hands the very same instance back to {@link #grid} directly. */
private void addWidgetToGrid(GridStackItem item) {
item.addCloseListener(this::offerUndo);
grid.add(item);
updateEmptyState();
}
/**
* Closing a widget is one misclick away from losing its position, and a
* confirmation dialog on every close would be worse than the problem — so
* this shows a dismissible undo toast instead. Skipped for programmatic
* closes ({@link GridStackItem#close()}, e.g. from duplicate cleanup),
* which is exactly what {@link GridStackItem.CloseEvent#isFromClient()}
* tells apart.
* <p>
* Undo re-adds the very item that was closed — not a rebuilt copy — at the
* {@link GridStackItem.Position} the event captured before detaching, so it
* keeps its {@code gs-id} and its exact size/position rather than landing
* in the grid's next free slot.
*/
private void offerUndo(GridStackItem.CloseEvent event) {
updateEmptyState();
if (!event.isFromClient()) {
return;
}
GridStackItem item = event.getSource();
GridStackItem.Position position = event.getPosition();
Notification toast = new Notification();
toast.setDuration(8000);
toast.setPosition(Notification.Position.BOTTOM_START);
Button undo = new Button(getTranslation("gridstack.undo"), e -> {
item.setPosition(position.x(), position.y(), position.w(), position.h());
grid.add(item);
updateEmptyState();
toast.close();
}); });
undo.addThemeVariants(ButtonVariant.LUMO_TERTIARY_INLINE);
BarChart barChart = new BarChart(); HorizontalLayout content = new HorizontalLayout(
barChart.setWidthFull(); new Span(getTranslation("gridstack.closed")), undo);
barChart.setHeight("400px"); content.setAlignItems(Alignment.CENTER);
barChart.setData(getTranslation("chart.revenueSeries"), toast.add(content);
List.of(30.0, 40.0, 35.0, 50.0, 49.0, 60.0), toast.open();
months); }
barChart.addPointClickListener(e -> {
Notification.show(getTranslation("chart.pointClick", /** A widget of the initial set. Its id is the plain type — never handed out
e.getSeriesIndex(), e.getDataPointIndex())); * again by {@link #addWidget(WidgetDefinition)}, which always suffixes a
* counter — and it keeps the dashboard's own placement rather than the
* type's default size. */
private GridStackItem defaultWidget(String type, int x, int y, int w, int 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,
int x, int y, int w, int h) {
Component content = definition.factory().apply(context);
GridStackItem item = new GridStackItem(id, x, y, w, h,
new Card(getTranslation(definition.titleKey()), content));
item.setActions(Action.REFRESH, Action.MAXIMIZE, Action.DUPLICATE,
Action.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;
}
PieChart pieChart = new PieChart(); /**
pieChart.setWidthFull(); * The widget's data as a CSV attachment. Nothing is computed here: the
pieChart.setHeight("400px"); * callback runs when the user picks the entry, so data, labels and file
pieChart.setData( * name are all of the moment — including whatever the filter bar is set to
List.of(30.0, 40.0, 35.0, 50.0), * then.
List.of(getTranslation("region.north"), getTranslation("region.south"), * <p>
getTranslation("region.east"), getTranslation("region.west"))); * A download is served on a request of its own, outside the session lock
pieChart.addPointClickListener(e -> { * and without a current {@code UI} (see
Notification.show(getTranslation("chart.sliceClick", e.getDataPointIndex())); * {@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);
}); });
}
add(new Card(getTranslation("card.revenueTrend"), lineChart), /** Re-feeds a single tile from the current filter — the action menu's
new Card(getTranslation("card.revenueByMonth"), barChart), * refresh, which asks for one widget, not for the dashboard. */
new Card(getTranslation("card.revenueByRegion"), pieChart)); 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
* in the map — the same KPI can be added back — but is detached, so
* feeding it would queue a client call for a chart that is not there. */
private void updateKpiTiles(DashboardFilter filter) {
for (KpiData kpi : dataService.kpis(filter)) {
KpiTile tile = kpiTiles.get(kpi.id());
if (tile != null && tile.isAttached()) {
feed(tile, kpi);
}
}
}
/** The value's unit and number pattern come from the bundle, since they are
* locale-specific (decimal separator, currency, "Mio."); the service
* supplies only the number, which depends on the filter. */
private KpiTile feed(KpiTile tile, KpiData kpi) {
return tile.setValue(getTranslation(kpi.valueKey(), kpi.value()))
.setDelta(kpi.deltaPercent())
.setSparkline(tile.getLabel(), kpi.trend());
} }
@Override @Override
@@ -1,145 +0,0 @@
package com.example.views;
import com.example.components.BarChart;
import com.example.components.Card;
import com.example.components.Fa;
import com.example.components.GridStackItem;
import com.example.components.GridStackLayout;
import com.example.components.LineChart;
import com.example.components.PieChart;
import com.example.components.ApexChart;
import com.vaadin.flow.component.Component;
import com.vaadin.flow.component.button.Button;
import com.vaadin.flow.component.button.ButtonVariant;
import com.vaadin.flow.component.html.Paragraph;
import com.vaadin.flow.component.html.Span;
import com.vaadin.flow.component.orderedlayout.HorizontalLayout;
import com.vaadin.flow.component.orderedlayout.VerticalLayout;
import com.vaadin.flow.router.HasDynamicTitle;
import com.vaadin.flow.router.Route;
import java.util.ArrayDeque;
import java.util.Deque;
import java.util.List;
/**
* Showcase for {@link GridStackLayout}: a dashboard of draggable/resizable
* cards whose layout is persisted per browser, plus controls to add, remove
* and reset widgets at runtime.
*/
@Route("gridstack")
public class GridStackView extends VerticalLayout implements HasDynamicTitle {
private static final String STORAGE_KEY = "gridstack-demo";
private final GridStackLayout grid = new GridStackLayout();
private final Deque<GridStackItem> extraWidgets = new ArrayDeque<>();
private final Span status = new Span();
private int extraWidgetCount;
public GridStackView() {
addClassName("dialect-content");
grid.setWidthFull();
grid.setStorageKey(STORAGE_KEY);
grid.addLayoutChangeListener(e -> status.setText(
getTranslation("gridstack.status", e.getPositions().size())));
grid.add(
new GridStackItem("revenue-trend", 0, 0, 6, 3,
new Card(getTranslation("card.revenueTrend"), lineChart())),
new GridStackItem("revenue-month", 6, 0, 6, 3,
new Card(getTranslation("card.revenueByMonth"), barChart())),
new GridStackItem("revenue-region", 0, 3, 5, 3,
new Card(getTranslation("card.revenueByRegion"), pieChart())),
new GridStackItem("hint", 5, 3, 7, 3,
new Card(getTranslation("card.gridstackHint"),
new Paragraph(getTranslation("gridstack.hint")))));
status.setText(getTranslation("gridstack.statusInitial"));
status.addClassName("dialect-muted");
add(toolbar(), grid);
}
private HorizontalLayout toolbar() {
Button add = new Button(getTranslation("gridstack.addWidget"), Fa.ADD.create(),
e -> addWidget());
add.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
Button remove = new Button(getTranslation("gridstack.removeWidget"), Fa.REMOVE.create(),
e -> removeWidget());
Button reset = new Button(getTranslation("gridstack.reset"), Fa.RESET.create(),
e -> grid.resetLayout());
HorizontalLayout toolbar = new HorizontalLayout(add, remove, reset, status);
toolbar.setPadding(false);
toolbar.setWidthFull();
toolbar.setAlignItems(Alignment.CENTER);
return toolbar;
}
private void addWidget() {
// The id must stay stable across reloads for the saved layout to match
// it again, so it is derived from a counter rather than a random UUID.
extraWidgetCount++;
String title = getTranslation("gridstack.widget", extraWidgetCount);
GridStackItem item = new GridStackItem("extra-" + extraWidgetCount, 0, 0, 4, 2,
new Card(title, new Paragraph(getTranslation("gridstack.widgetText"))));
extraWidgets.push(item);
grid.add(item);
}
private void removeWidget() {
GridStackItem item = extraWidgets.poll();
if (item == null) {
return;
}
grid.remove(item);
extraWidgetCount--;
}
private Component lineChart() {
LineChart chart = new LineChart();
chart.setData(getTranslation("chart.revenueSeries"),
List.of(30.0, 40.0, 35.0, 50.0, 49.0, 60.0), months());
return sizeFull(chart);
}
private Component barChart() {
BarChart chart = new BarChart();
chart.setData(getTranslation("chart.revenueSeries"),
List.of(30.0, 40.0, 35.0, 50.0, 49.0, 60.0), months());
return sizeFull(chart);
}
private Component pieChart() {
PieChart chart = new PieChart();
chart.setData(List.of(30.0, 40.0, 35.0, 50.0),
List.of(getTranslation("region.north"), getTranslation("region.south"),
getTranslation("region.east"), getTranslation("region.west")));
return sizeFull(chart);
}
/** 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
* resize on resizestop, which ApexCharts reflows on). */
private Component sizeFull(ApexChart chart) {
chart.setWidthFull();
chart.setHeight("100%");
return chart;
}
private List<String> months() {
return List.of(
getTranslation("month.jan"), getTranslation("month.feb"),
getTranslation("month.mar"), getTranslation("month.apr"),
getTranslation("month.may"), getTranslation("month.jun"));
}
@Override
public String getPageTitle() {
return getTranslation("page.gridstack");
}
}
@@ -49,8 +49,6 @@ public class MainLayout extends AppLayout {
Fa.FORM.create())); Fa.FORM.create()));
nav.addItem(new SideNavItem(getTranslation("nav.table"), TableView.class, nav.addItem(new SideNavItem(getTranslation("nav.table"), TableView.class,
Fa.TABLE.create())); Fa.TABLE.create()));
nav.addItem(new SideNavItem(getTranslation("nav.gridstack"), GridStackView.class,
Fa.GRID.create()));
addToDrawer(nav); addToDrawer(nav);
} }
@@ -0,0 +1,63 @@
package com.example.widgets;
import com.example.data.ChartDataService;
import com.example.data.DashboardFilter;
import com.vaadin.flow.shared.Registration;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.function.Consumer;
/**
* The dashboard's current {@link DashboardFilter} plus the widgets listening to
* it — the small event bus between the filter bar (which sets) and the widgets
* (which subscribe and re-request their data from
* {@link ChartDataService}). Neither side knows the other.
* <p>
* One context per dashboard instance, deliberately not a Spring bean: it is
* view state, so nothing leaks between users, tabs or visits. For the same
* reason the filter is <em>not persisted</em> — a reload starts from
* {@link DashboardFilter#defaults()}, unlike the grid layout, which
* {@code GridStackLayout} keeps in {@code localStorage}.
*/
public class DashboardContext {
private final List<Consumer<DashboardFilter>> listeners = new ArrayList<>();
private DashboardFilter filter = DashboardFilter.defaults();
public DashboardFilter getFilter() {
return filter;
}
/**
* Applies a filter and hands it to every listener. A filter equal to the
* current one changes nothing and notifies nobody, so re-picking the same
* value in the filter bar does not make the widgets re-fetch.
*/
public void setFilter(DashboardFilter filter) {
Objects.requireNonNull(filter, "filter");
if (this.filter.equals(filter)) {
return;
}
this.filter = filter;
// Over a copy: a listener may unsubscribe while being notified (a
// widget closing in reaction to the update).
List.copyOf(listeners).forEach(listener -> listener.accept(filter));
}
/**
* Subscribes to later filter changes. The listener is not called with the
* current filter — a widget is built from it already — so a subscriber
* feeds itself once and then only reacts.
*/
public Registration addFilterChangeListener(Consumer<DashboardFilter> listener) {
listeners.add(listener);
return () -> listeners.remove(listener);
}
/** How many widgets are currently listening; for tests and diagnostics. */
public int getListenerCount() {
return listeners.size();
}
}
@@ -0,0 +1,26 @@
package com.example.widgets;
import com.vaadin.flow.component.Component;
import java.util.function.Function;
/**
* One kind of dashboard widget: what it is called, how large it starts out, and
* how to build its content. Definitions live in {@link WidgetRegistry}; the
* dashboard turns one into a grid item, so a definition knows nothing about
* gridstack or about where its widget ends up sitting.
*
* @param type stable id of the widget kind — grid item ids are derived from
* it, so it must not change once a layout has been persisted
* @param titleKey translation key for the card title
* @param width default width in grid columns
* @param height default height in grid rows
* @param factory builds the card content from the dashboard's
* {@link DashboardContext}: it reads the current filter for the
* first render and subscribes for later ones. Called once per
* added widget, since a Vaadin component cannot be attached in
* two places
*/
public record WidgetDefinition(String type, String titleKey, int width, int height,
Function<DashboardContext, Component> factory) {
}
@@ -0,0 +1,192 @@
package com.example.widgets;
import com.example.components.ApexChart;
import com.example.components.AxisChart;
import com.example.components.BarChart;
import com.example.components.LineChart;
import com.example.components.PieChart;
import com.example.data.ChartDataService;
import com.example.data.ChartSeries;
import com.example.data.DashboardFilter;
import com.example.export.CsvExport;
import com.vaadin.flow.component.Component;
import com.vaadin.flow.component.ComponentUtil;
import com.vaadin.flow.component.notification.Notification;
import com.vaadin.flow.shared.Registration;
import org.springframework.stereotype.Service;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
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 view is what lets the "add widget" picker offer real content and lets a
* closed widget be brought back: the view only decides <em>where</em> a widget
* sits, the registry decides what one <em>is</em>.
* <p>
* Numbers come from {@link ChartDataService}, queried with the filter the
* widget's {@link DashboardContext} currently holds; the factories resolve the
* data's translation keys against the bundle through the chart component
* itself, so they follow the current UI locale without the registry being a
* component. Every widget also subscribes to the context, so a filter change
* re-feeds it — in place, through {@code updateData}, rather than by rebuilding
* the chart.
*/
@Service
public class WidgetRegistry {
public static final String REVENUE_TREND = "revenue-trend";
public static final String REVENUE_MONTH = "revenue-month";
public static final String REVENUE_REGION = "revenue-region";
private final ChartDataService dataService;
/** Insertion-ordered: the picker lists definitions in registration order. */
private final Map<String, WidgetDefinition> definitions = new LinkedHashMap<>();
public WidgetRegistry(ChartDataService dataService) {
this.dataService = dataService;
register(new WidgetDefinition(REVENUE_TREND, "card.revenueTrend", 6, 3,
context -> axisChart(new LineChart(), context)));
register(new WidgetDefinition(REVENUE_MONTH, "card.revenueByMonth", 6, 3,
context -> axisChart(new BarChart(), context)));
register(new WidgetDefinition(REVENUE_REGION, "card.revenueByRegion", 5, 3,
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. */
public final void register(WidgetDefinition definition) {
definitions.put(definition.type(), definition);
}
public List<WidgetDefinition> definitions() {
return List.copyOf(definitions.values());
}
/** The definition for the given type. Unknown types are a programming
* error, not user input — the picker only ever offers registered ones. */
public WidgetDefinition require(String type) {
WidgetDefinition definition = definitions.get(type);
if (definition == null) {
throw new IllegalArgumentException("unknown widget type: " + type);
}
return definition;
}
private Component axisChart(AxisChart chart, DashboardContext context) {
chart.addPointClickListener(e -> Notification.show(chart.getTranslation(
"chart.pointClick", e.getSeriesIndex(), e.getDataPointIndex())));
return bind(chart, context, dataService::revenueByMonth, series ->
chart.updateData(chart.getTranslation(series.nameKey()), series.values(),
translate(chart, series.categoryKeys())));
}
private Component pieChart(DashboardContext context) {
PieChart chart = new PieChart();
chart.addPointClickListener(e -> Notification.show(
chart.getTranslation("chart.sliceClick", e.getDataPointIndex())));
// A pie has no series name — its categories are the slice labels.
return bind(chart, context, dataService::revenueByRegion, series ->
chart.updateData(series.values(), translate(chart, series.categoryKeys())));
}
/**
* Feeds the chart from the current filter and keeps it fed: the
* subscription is dropped when the widget is detached (closed, or the
* dashboard left), so a chart that is gone is not updated any more.
* <p>
* 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.
* <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,
Function<DashboardFilter, ChartSeries> query,
Consumer<ChartSeries> feed) {
Consumer<DashboardFilter> render = filter -> feed.accept(query.apply(filter));
render.accept(context.getFilter());
Registration registration = context.addFilterChangeListener(render);
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);
}
/** 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
* resizing a widget resizes the chart (grid-stack.ts fires a window
* resize on resizestop, which ApexCharts reflows on). */
private Component sizeFull(ApexChart chart) {
chart.setWidthFull();
chart.setHeight("100%");
return chart;
}
/** Data sets carry translation keys, not display text — see
* {@link ChartSeries}. */
private List<String> translate(Component component, List<String> keys) {
return keys.stream().map(component::getTranslation).toList();
}
}
@@ -11,6 +11,10 @@
--dialect-ink: light-dark(#14171f, #e6eaf0); --dialect-ink: light-dark(#14171f, #e6eaf0);
--dialect-border: light-dark(#e6eaf0, #2b303b); --dialect-border: light-dark(#e6eaf0, #2b303b);
--dialect-radius: 14px; --dialect-radius: 14px;
/* Delta direction (KpiTile). Lightened in dark mode so they keep contrast
against the dark surface instead of going muddy. */
--dialect-positive: light-dark(#1f9d63, #45c98a);
--dialect-negative: light-dark(#d1453b, #f0736a);
/* light-dark() only takes 2 args, so each shadow layer gets its own light-dark() /* light-dark() only takes 2 args, so each shadow layer gets its own light-dark()
call and the layers are joined below (var() is a text substitution, so the call and the layers are joined below (var() is a text substitution, so the
comma-separated shadow list still parses correctly). */ comma-separated shadow list still parses correctly). */
@@ -123,6 +127,108 @@ vaadin-app-layout::part(content) {
margin: 0 0 12px 0; margin: 0 0 12px 0;
} }
/* KpiTile (components/KpiTile.java): a one-row widget, so everything is sized
to survive a 120px grid cell — the sparkline is the only flexible part and
collapses first. */
.dialect-kpi {
display: flex;
flex-direction: column;
/* border-box, or the padding is added on top of the 100% and the tile ends
up taller than the grid item it is supposed to fill. */
box-sizing: border-box;
height: 100%;
min-height: 0;
gap: 2px;
padding: 12px 16px;
overflow: hidden;
}
/* Value and delta on one line, baseline-aligned, so a one-cell-high tile still
has room for the sparkline underneath. */
.dialect-kpi__figure {
display: flex;
align-items: baseline;
gap: 8px;
min-width: 0;
}
.dialect-kpi__label {
font-size: 0.8rem;
font-weight: 500;
letter-spacing: 0.02em;
text-transform: uppercase;
color: var(--dialect-ink);
opacity: 0.6;
/* Long labels shorten rather than wrapping into a second line, which at
h=1 would push the value out of the tile. */
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.dialect-kpi__value {
font-size: clamp(1.4rem, 4cqw, 2rem);
font-weight: 700;
line-height: 1.15;
letter-spacing: -0.02em;
color: var(--dialect-ink);
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Never shrinks: a truncated delta would be unreadable, so the value gives way
first (it ellipsises). */
.dialect-kpi__delta {
display: flex;
align-items: center;
flex: none;
gap: 6px;
font-size: 0.85rem;
font-weight: 600;
}
.dialect-kpi__delta--up {
color: var(--dialect-positive);
}
.dialect-kpi__delta--down {
color: var(--dialect-negative);
}
.dialect-kpi__delta--flat {
color: var(--dialect-ink);
opacity: 0.6;
}
/* ApexCharts' `sparkline.enabled` drops the axes on the initial render, but a
later updateOptions — which is how the theme toggle recolors a chart — brings
the y-axis labels back, clipped to a smudge at the tile's left edge. Hide
them for good; the chart renders into light DOM, so this rule reaches its SVG
(see components/apex-chart.ts). */
apex-chart.dialect-sparkline .apexcharts-yaxis,
apex-chart.dialect-sparkline .apexcharts-xaxis {
display: none;
}
/* The only part allowed to shrink: at h=1 there is no room left for it and it
collapses to nothing, at h≥2 it takes the slack. */
.dialect-kpi__sparkline {
flex: 1 1 0;
min-height: 0;
margin-top: 4px;
overflow: hidden;
}
/* The tile draws no surface of its own — inside a grid item it fills
grid-stack-item-content, which already provides background, radius and
shadow. This also gives the value's `cqw` font size a container to resolve
against, so a narrow tile scales its number down instead of clipping it. */
.grid-stack-item-content > .dialect-kpi {
container-type: inline-size;
}
/* Secondary text (e.g. the layout status line in GridStackView). */ /* Secondary text (e.g. the layout status line in GridStackView). */
.dialect-muted { .dialect-muted {
color: var(--dialect-ink); color: var(--dialect-ink);
@@ -176,13 +282,129 @@ vaadin-app-layout::part(content) {
opacity: 1; opacity: 1;
} }
/* Touch devices never hover — keep the grip permanently visible there. */ /* Close button (GridStackItem#setClosable): same affordance as the grip, in
the outermost corner slot. The grip steps aside only when it is there, so a
non-closable item keeps its grip flush with the corner. */
.dialect-close-button {
position: absolute;
top: 14px;
right: 16px;
z-index: 1;
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 6px;
color: var(--dialect-ink);
opacity: 0;
cursor: pointer;
transition: opacity 120ms ease;
}
/* 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;
}
.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,
.dialect-close-button:focus-visible {
opacity: 0.65;
}
.dialect-close-button:hover,
.dialect-close-button:active {
opacity: 1;
color: var(--dialect-primary);
}
/* 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-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;
} }
@@ -198,3 +420,32 @@ vaadin-app-layout::part(content) {
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;
}
@@ -10,12 +10,10 @@ lang.es=Español
nav.dashboard=Dashboard nav.dashboard=Dashboard
nav.form=Formular nav.form=Formular
nav.table=Tabelle nav.table=Tabelle
nav.gridstack=Raster
page.dashboard=Dashboard page.dashboard=Dashboard
page.form=Formular page.form=Formular
page.table=Tabelle page.table=Tabelle
page.gridstack=Raster-Layout
card.revenueTrend=Umsatz-Entwicklung card.revenueTrend=Umsatz-Entwicklung
card.revenueByMonth=Umsatz nach Monat card.revenueByMonth=Umsatz nach Monat
@@ -24,15 +22,48 @@ 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. 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.removeWidget=Letztes entfernen
gridstack.reset=Layout zurücksetzen gridstack.reset=Layout zurücksetzen
gridstack.status=Layout geändert {0} Widgets gridstack.status=Layout geändert {0} Widgets
gridstack.statusInitial=Layout unverändert gridstack.statusInitial=Layout unverändert
gridstack.widget=Widget {0} gridstack.pickerTitle=Widget auswählen
gridstack.widgetText=Frei platzierbare Karte.
gridstack.dragHandle=Verschieben gridstack.dragHandle=Verschieben
gridstack.close=Schließen
gridstack.actions=Aktionen
gridstack.refresh=Aktualisieren
gridstack.maximize=Maximieren
gridstack.restore=Wiederherstellen
gridstack.duplicate=Duplizieren
gridstack.remove=Entfernen
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.month=Monat
filter.period.quarter=Quartal
filter.period.halfYear=Halbjahr
filter.period.year=Jahr
filter.region=Region
filter.allRegions=Alle Regionen
# Die KPI-Werte sind MessageFormat-Muster: die Zahl kommt aus dem Datenservice
# (sie hängt vom Filter ab), Einheit und Zahlenformat aus dem Bundle.
kpi.revenueTotal=Gesamtumsatz
kpi.revenueTotal.value={0,number,#,##0.00} Mio. €
kpi.openOrders=Offene Vorgänge
kpi.openOrders.value={0,number,#,##0}
kpi.newCustomers=Neue Kunden
kpi.newCustomers.value={0,number,#,##0}
kpi.averageOrderValue=Ø Bestellwert
kpi.averageOrderValue.value={0,number,#,##0} €
chart.revenueSeries=Umsatz 2026 chart.revenueSeries=Umsatz 2026
chart.pointClick=Serie {0}, Punkt {1} chart.pointClick=Serie {0}, Punkt {1}
@@ -44,6 +75,17 @@ month.mar=Mär
month.apr=Apr month.apr=Apr
month.may=Mai month.may=Mai
month.jun=Jun month.jun=Jun
month.jul=Jul
month.aug=Aug
month.sep=Sep
month.oct=Okt
month.nov=Nov
month.dec=Dez
week.1=Woche 1
week.2=Woche 2
week.3=Woche 3
week.4=Woche 4
region.north=Nord region.north=Nord
region.south=Süd region.south=Süd
@@ -10,12 +10,10 @@ lang.es=Español
nav.dashboard=Dashboard nav.dashboard=Dashboard
nav.form=Form nav.form=Form
nav.table=Table nav.table=Table
nav.gridstack=Grid
page.dashboard=Dashboard page.dashboard=Dashboard
page.form=Form page.form=Form
page.table=Table page.table=Table
page.gridstack=Grid layout
card.revenueTrend=Revenue Trend card.revenueTrend=Revenue Trend
card.revenueByMonth=Revenue by Month card.revenueByMonth=Revenue by Month
@@ -24,15 +22,48 @@ 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. 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.removeWidget=Remove last
gridstack.reset=Reset layout gridstack.reset=Reset layout
gridstack.status=Layout changed {0} widgets gridstack.status=Layout changed {0} widgets
gridstack.statusInitial=Layout unchanged gridstack.statusInitial=Layout unchanged
gridstack.widget=Widget {0} gridstack.pickerTitle=Choose a widget
gridstack.widgetText=Freely placeable card.
gridstack.dragHandle=Move gridstack.dragHandle=Move
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.month=Month
filter.period.quarter=Quarter
filter.period.halfYear=Half year
filter.period.year=Year
filter.region=Region
filter.allRegions=All regions
# The KPI values are MessageFormat patterns: the number comes from the data
# service (it depends on the filter), unit and number format from the bundle.
kpi.revenueTotal=Total revenue
kpi.revenueTotal.value=€{0,number,#,##0.00}M
kpi.openOrders=Open orders
kpi.openOrders.value={0,number,#,##0}
kpi.newCustomers=New customers
kpi.newCustomers.value={0,number,#,##0}
kpi.averageOrderValue=Avg. order value
kpi.averageOrderValue.value=€{0,number,#,##0}
chart.revenueSeries=Revenue 2026 chart.revenueSeries=Revenue 2026
chart.pointClick=Series {0}, point {1} chart.pointClick=Series {0}, point {1}
@@ -44,6 +75,17 @@ month.mar=Mar
month.apr=Apr month.apr=Apr
month.may=May month.may=May
month.jun=Jun month.jun=Jun
month.jul=Jul
month.aug=Aug
month.sep=Sep
month.oct=Oct
month.nov=Nov
month.dec=Dec
week.1=Week 1
week.2=Week 2
week.3=Week 3
week.4=Week 4
region.north=North region.north=North
region.south=South region.south=South
@@ -10,12 +10,10 @@ lang.es=Español
nav.dashboard=Panel nav.dashboard=Panel
nav.form=Formulario nav.form=Formulario
nav.table=Tabla nav.table=Tabla
nav.gridstack=Cuadrícula
page.dashboard=Panel page.dashboard=Panel
page.form=Formulario page.form=Formulario
page.table=Tabla page.table=Tabla
page.gridstack=Diseño de cuadrícula
card.revenueTrend=Evolución de ingresos card.revenueTrend=Evolución de ingresos
card.revenueByMonth=Ingresos por mes card.revenueByMonth=Ingresos por mes
@@ -24,15 +22,49 @@ 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 y cambia su tamaño desde la esquina inferior 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.removeWidget=Quitar el último
gridstack.reset=Restablecer diseño gridstack.reset=Restablecer diseño
gridstack.status=Diseño modificado {0} widgets gridstack.status=Diseño modificado {0} widgets
gridstack.statusInitial=Diseño sin cambios gridstack.statusInitial=Diseño sin cambios
gridstack.widget=Widget {0} gridstack.pickerTitle=Elegir un widget
gridstack.widgetText=Tarjeta de colocación libre.
gridstack.dragHandle=Mover gridstack.dragHandle=Mover
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.month=Mes
filter.period.quarter=Trimestre
filter.period.halfYear=Semestre
filter.period.year=Año
filter.region=Región
filter.allRegions=Todas las regiones
# Los valores KPI son patrones MessageFormat: el número viene del servicio de
# datos (depende del filtro), la unidad y el formato numérico del bundle.
kpi.revenueTotal=Ingresos totales
kpi.revenueTotal.value={0,number,#,##0.00} M€
kpi.openOrders=Pedidos abiertos
kpi.openOrders.value={0,number,#,##0}
kpi.newCustomers=Nuevos clientes
kpi.newCustomers.value={0,number,#,##0}
kpi.averageOrderValue=Valor medio del pedido
kpi.averageOrderValue.value={0,number,#,##0} €
chart.revenueSeries=Ingresos 2026 chart.revenueSeries=Ingresos 2026
chart.pointClick=Serie {0}, punto {1} chart.pointClick=Serie {0}, punto {1}
@@ -44,6 +76,17 @@ month.mar=Mar
month.apr=Abr month.apr=Abr
month.may=May month.may=May
month.jun=Jun month.jun=Jun
month.jul=Jul
month.aug=Ago
month.sep=Sep
month.oct=Oct
month.nov=Nov
month.dec=Dic
week.1=Semana 1
week.2=Semana 2
week.3=Semana 3
week.4=Semana 4
region.north=Norte region.north=Norte
region.south=Sur region.south=Sur
@@ -0,0 +1,127 @@
package com.example.components;
import com.example.Application;
import com.example.data.DashboardFilter.Period;
import com.example.views.DashboardFilterBar;
import com.example.views.DashboardView;
import com.vaadin.browserless.SpringBrowserlessTest;
import com.vaadin.browserless.ViewPackages;
import com.vaadin.flow.component.UI;
import com.vaadin.flow.component.internal.PendingJavaScriptInvocation;
import com.vaadin.flow.component.internal.UIInternals.JavaScriptInvocation;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Covers the incremental update path of {@link ApexChart} by inspecting the JS
* calls queued for the client: a chart that has been rendered gets a data-only
* {@code updateData} patch, one that has not gets a full {@code renderChart}.
*/
@SpringBootTest(classes = Application.class)
@ViewPackages(classes = DashboardView.class)
class ApexChartUpdateTest extends SpringBrowserlessTest {
private static final List<Double> VALUES = List.of(11.0, 22.0);
private static final List<String> CATEGORIES = List.of("Jan", "Feb");
@Test
void updateData_afterRender_sendsDataPatch() {
navigate(DashboardView.class);
LineChart chart = $view(LineChart.class).first();
drainInvocations();
chart.updateData("Umsatz", VALUES, CATEGORIES);
JavaScriptInvocation call = lastInvocation();
assertTrue(call.getExpression().contains("updateData"), call.getExpression());
String json = json(call);
assertTrue(json.contains("\"series\""), json);
assertTrue(json.contains("\"categories\""), json);
assertFalse(json.contains("\"colors\""), "a patch carries data only, no theme options: " + json);
}
@Test
void updateData_beforeFirstRender_fallsBackToFullRender() {
navigate(DashboardView.class);
LineChart chart = new LineChart();
getCurrentView().getElement().appendChild(chart.getElement());
drainInvocations();
chart.updateData("Umsatz", VALUES, CATEGORIES);
JavaScriptInvocation call = lastInvocation();
assertTrue(call.getExpression().contains("renderChart"), call.getExpression());
assertTrue(json(call).contains("\"colors\""), "a full render carries the theme options");
}
@Test
void pieChart_updateData_sendsSeriesAndLabels() {
navigate(DashboardView.class);
PieChart chart = $view(PieChart.class).first();
drainInvocations();
chart.updateData(VALUES, List.of("Nord", "Sued"));
JavaScriptInvocation call = lastInvocation();
assertTrue(call.getExpression().contains("updateData"), call.getExpression());
String json = json(call);
assertTrue(json.contains("\"series\""), json);
assertTrue(json.contains("\"labels\""), json);
}
/**
* The dashboard's own path into this: a filter change must reach every
* widget as a data patch, so the whole dashboard updates without a single
* chart being rebuilt.
*/
@Test
void filterChange_patchesEveryChartInPlace() {
navigate(DashboardView.class);
drainInvocations();
$view(DashboardFilterBar.class).first().getPeriodSelect().setValue(Period.QUARTER);
// Everything the filter change queued — including the select's own
// client-side bookkeeping, which is not a chart call.
List<String> calls = dump().stream()
.map(pending -> pending.getInvocation().getExpression()).toList();
// Three charts plus one sparkline per KPI tile.
assertEquals(7, calls.stream().filter(call -> call.contains("updateData")).count(),
"expected every widget to be re-fed: " + calls);
assertTrue(calls.stream().noneMatch(call -> call.contains("renderChart")),
"a filter change must not rebuild a chart: " + calls);
}
/** The JSON payload of a {@code callJsFunction} invocation — parameter 0 is
* the element the function is called on. */
private String json(JavaScriptInvocation call) {
return (String) call.getParameters().get(1);
}
/** Clears everything the view queued while rendering, so the assertions
* only see what the call under test produced. */
private void drainInvocations() {
dump();
}
private JavaScriptInvocation lastInvocation() {
List<PendingJavaScriptInvocation> pending = dump();
assertEquals(1, pending.size(), "expected exactly one queued client call");
return pending.getFirst().getInvocation();
}
/** {@code callJsFunction} only materialises its invocation in the
* before-client-response phase, which no real client response triggers
* here — so run that phase explicitly before collecting. */
private List<PendingJavaScriptInvocation> dump() {
UI ui = UI.getCurrent();
ui.getInternals().getStateTree().runExecutionsBeforeClientResponse();
return ui.getInternals().dumpPendingJavaScriptInvocations();
}
}
@@ -0,0 +1,140 @@
package com.example.data;
import com.example.data.DashboardFilter.Period;
import org.junit.jupiter.api.Test;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
/** The data layer is plain Java: no Spring context, no Vaadin UI. */
class InMemoryChartDataServiceTest {
private final ChartDataService service = new InMemoryChartDataService();
@Test
void revenueByMonth_hasOneValuePerMonth() {
ChartSeries series = service.revenueByMonth();
assertEquals(List.of(30.0, 40.0, 35.0, 50.0, 49.0, 60.0), series.values());
assertEquals(List.of("month.jan", "month.feb", "month.mar",
"month.apr", "month.may", "month.jun"), series.categoryKeys());
assertEquals("chart.revenueSeries", series.nameKey());
}
@Test
void revenueByRegion_hasOneValuePerRegion() {
ChartSeries series = service.revenueByRegion();
assertEquals(4, series.values().size());
assertEquals(List.of("region.north", "region.south",
"region.east", "region.west"), series.categoryKeys());
}
@Test
void kpis_haveStableUniqueIdsAndATrend() {
List<KpiData> kpis = service.kpis();
assertEquals(4, kpis.size());
Set<String> ids = kpis.stream().map(KpiData::id).collect(Collectors.toSet());
assertEquals(kpis.size(), ids.size(), "grid ids must be unique");
assertTrue(ids.stream().allMatch(id -> id.startsWith("kpi-")));
assertTrue(kpis.stream().allMatch(kpi -> !kpi.trend().isEmpty()),
"each tile draws a sparkline");
}
@Test
void kpi_derivesTheValueKeyFromTheLabelKey() {
KpiData revenue = service.kpis().getFirst();
assertEquals("kpi.revenueTotal", revenue.labelKey());
assertEquals("kpi.revenueTotal.value", revenue.valueKey());
assertEquals(12.4, revenue.deltaPercent());
assertEquals(1.24, revenue.value());
}
@Test
void period_decidesTheGranularityAndTheRange() {
assertEquals(4, monthly(Period.MONTH).values().size(), "a month is read as weeks");
assertEquals(List.of("week.1", "week.2", "week.3", "week.4"),
monthly(Period.MONTH).categoryKeys());
assertEquals(List.of("month.apr", "month.may", "month.jun"),
monthly(Period.QUARTER).categoryKeys());
assertEquals(12, monthly(Period.YEAR).values().size());
assertEquals("month.dec", monthly(Period.YEAR).categoryKeys().getLast());
}
/** The four weeks of a month add up to the month again. */
@Test
void month_splitsTheCurrentMonthOverItsWeeks() {
double weeks = monthly(Period.MONTH).values().stream()
.mapToDouble(Double::doubleValue).sum();
assertEquals(monthly(Period.HALF_YEAR).values().getLast(), weeks, 0.001);
}
@Test
void aLongerPeriod_growsTheCumulativeFigures() {
double half = revenueKpi(new DashboardFilter(Period.HALF_YEAR, DashboardFilter.ALL_REGIONS));
double year = revenueKpi(new DashboardFilter(Period.YEAR, DashboardFilter.ALL_REGIONS));
assertTrue(year > half, "a year covers more revenue than half of one: " + year);
// The pie is the same figure sliced by region, so it grows along.
assertTrue(total(service.revenueByRegion(new DashboardFilter(Period.YEAR,
DashboardFilter.ALL_REGIONS))) > total(service.revenueByRegion()),
"the region breakdown follows the period");
}
@Test
void aRegion_isAShareOfTheTotal() {
DashboardFilter north = DashboardFilter.defaults().withRegion("region.north");
ChartSeries series = service.revenueByMonth(north);
assertEquals(6, series.values().size(), "the region does not change the range");
assertTrue(total(series) < total(service.revenueByMonth()),
"one region is less than all of them: " + series.values());
// The pie *is* the region breakdown, so it ignores a region selection.
assertEquals(service.revenueByRegion(), service.revenueByRegion(north));
}
@Test
void unknownRegion_isRejected() {
DashboardFilter unknown = DashboardFilter.defaults().withRegion("region.moon");
assertThrows(IllegalArgumentException.class, () -> service.revenueByMonth(unknown));
}
private ChartSeries monthly(Period period) {
return service.revenueByMonth(new DashboardFilter(period, DashboardFilter.ALL_REGIONS));
}
private double revenueKpi(DashboardFilter filter) {
return service.kpis(filter).getFirst().value();
}
private double total(ChartSeries series) {
return series.values().stream().mapToDouble(Double::doubleValue).sum();
}
@Test
void series_isImmutableAndBalanced() {
ChartSeries series = service.revenueByMonth();
assertThrows(UnsupportedOperationException.class, () -> series.values().add(1.0));
assertThrows(IllegalArgumentException.class,
() -> new ChartSeries("x", List.of(1.0, 2.0), List.of("only.one")));
}
@Test
void callsAreRepeatable() {
assertEquals(service.revenueByMonth(), service.revenueByMonth());
assertFalse(service.kpis().isEmpty());
}
}
@@ -0,0 +1,66 @@
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.assertTrue;
/**
* End-to-end coverage for the incremental update path of {@code apex-chart.ts}:
* a data update must patch the running ApexCharts instance instead of tearing
* the chart down and building it again.
*/
class DashboardChartPlaywrightTest extends PlaywrightTestBase {
private static final String PATCH = """
{"series":[{"name":"Revenue","data":[1,2,3,4,5,6]}],
"categories":["Jan","Feb","Mar","Apr","May","Jun"]}""";
@BeforeEach
void openDashboard() {
navigate("");
}
@Test
void dataUpdate_keepsTheRenderedSvgInPlace() {
Locator chart = axisChart();
assertThat(chart.locator("svg").first()).isVisible();
// Tag the live SVG node: a full rebuild would replace it and drop the tag.
chart.evaluate("el => el.querySelector('svg').dataset.marker = 'first-render'");
chart.evaluate("(el, patch) => el.updateData(patch)", PATCH);
assertEquals(1, chart.locator("svg[data-marker='first-render']").count(),
"the chart must be patched in place, not recreated");
assertEquals("1,2,3,4,5,6",
chart.evaluate("el => JSON.parse(el.lastOptionsJson).series[0].data.join(',')"),
"the patched data must have reached the chart");
}
@Test
void dataUpdate_keepsTheThemeOverlay() {
Locator chart = axisChart();
assertThat(chart.locator("svg").first()).isVisible();
chart.evaluate("(el, patch) => el.updateData(patch)", PATCH);
// grid.borderColor is only ever set by applyThemeOverlay(), from the
// resolved --dialect-border token — so a resolved rgb() value here means
// the overlay survived the incremental update.
Object borderColor = chart.evaluate("el => JSON.parse(el.lastOptionsJson).grid.borderColor");
assertTrue(String.valueOf(borderColor).startsWith("rgb"),
"expected a resolved theme color, got: " + borderColor);
}
/** The first chart in the DOM is a KPI sparkline, which has no
* {@code xaxis.categories} — patching one with categories is a structural
* change and legitimately redraws. The patch above is an axis-chart patch,
* so it has to be applied to an axis chart. */
private Locator axisChart() {
return page.locator("apex-chart:not(.dialect-sparkline)").first();
}
}
@@ -0,0 +1,86 @@
package com.example.e2e;
import com.microsoft.playwright.Locator;
import com.microsoft.playwright.Page;
import com.microsoft.playwright.options.AriaRole;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static com.microsoft.playwright.assertions.PlaywrightAssertions.assertThat;
/**
* End-to-end example covering {@link com.example.views.FormView}: rendering,
* binder validation and the two form buttons. Captions are English — see
* {@link PlaywrightTestBase#openPage()} for why.
*/
class FormViewPlaywrightTest extends PlaywrightTestBase {
@BeforeEach
void openFormView() {
navigate("formular");
}
@Test
void form_rendersItsFieldsAndTheReadOnlyAge() {
assertThat(page.getByText("Registration")).isVisible();
assertThat(field("First name")).isVisible();
assertThat(field("Last name")).isVisible();
assertThat(field("Email")).isVisible();
// preset on the bean and bound read-only
assertThat(field("Age")).hasValue("37");
assertThat(field("Age")).not().isEditable();
}
@Test
void save_withoutRequiredFields_showsErrorNotification() {
button("Save").click();
assertThat(notification()).containsText("Please check your entries");
}
@Test
void save_withInvalidEmail_showsErrorNotification() {
field("First name").fill("Max");
field("Last name").fill("Mustermann");
field("Email").fill("not-an-email");
button("Save").click();
assertThat(notification()).containsText("Please check your entries");
}
@Test
void save_withValidInput_showsSuccessNotification() {
field("First name").fill("Max");
field("Last name").fill("Mustermann");
field("Email").fill("max@example.com");
button("Save").click();
assertThat(notification()).containsText("Saved: Max Mustermann");
}
@Test
void reset_clearsTheEnteredValues() {
field("First name").fill("Max");
field("Last name").fill("Mustermann");
button("Reset").click();
assertThat(field("First name")).hasValue("");
assertThat(field("Last name")).hasValue("");
}
/** The input inside the Vaadin field carrying {@code label}. */
private Locator field(String label) {
return page.getByLabel(label, new Page.GetByLabelOptions().setExact(true));
}
private Locator button(String caption) {
return page.getByRole(AriaRole.BUTTON, new Page.GetByRoleOptions().setName(caption).setExact(true));
}
private Locator notification() {
return page.locator("vaadin-notification-card");
}
}
@@ -0,0 +1,93 @@
package com.example.e2e;
import com.example.Application;
import com.microsoft.playwright.Browser;
import com.microsoft.playwright.BrowserContext;
import com.microsoft.playwright.BrowserType;
import com.microsoft.playwright.Page;
import com.microsoft.playwright.Playwright;
import com.microsoft.playwright.assertions.PlaywrightAssertions;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Assumptions;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.server.LocalServerPort;
import java.util.Locale;
/**
* Base class for end-to-end tests that drive the running application in a real
* browser. The Spring Boot app is started on a random port, Playwright opens a
* headless Chromium against it, and every test gets a fresh browser context —
* so each test also gets a fresh Vaadin session.
*
* <p>Playwright downloads its browsers into {@code ~/.cache/ms-playwright} on
* first use. If that is not possible (offline runner, missing system libraries)
* the tests are <em>skipped</em> rather than failed, so the build stays green
* on runners that cannot host a browser.
*/
@SpringBootTest(classes = Application.class, webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public abstract class PlaywrightTestBase {
/** Generous: the first request in dev mode has to bootstrap the frontend. */
private static final double TIMEOUT_MS = 60_000;
private static Playwright playwright;
private static Browser browser;
@LocalServerPort
private int port;
private BrowserContext context;
protected Page page;
@BeforeAll
static void launchBrowser() {
try {
playwright = Playwright.create();
browser = playwright.chromium().launch(new BrowserType.LaunchOptions().setHeadless(true));
} catch (Exception | UnsatisfiedLinkError e) {
closeBrowser();
Assumptions.abort("no Playwright browser available: " + e.getMessage());
}
PlaywrightAssertions.setDefaultAssertionTimeout(TIMEOUT_MS);
}
@AfterAll
static void closeBrowser() {
if (browser != null) {
browser.close();
browser = null;
}
if (playwright != null) {
playwright.close();
playwright = null;
}
}
@BeforeEach
void openPage() {
// Pin the locale so the assertions have one fixed set of captions to
// match. English, not German: the German bundle is the unsuffixed
// fallback (`translations.properties`), so `de` is not a provided
// locale and Vaadin serves `translations_en.properties` for it anyway.
context = browser.newContext(new Browser.NewContextOptions().setLocale(Locale.US.toLanguageTag()));
context.setDefaultTimeout(TIMEOUT_MS);
page = context.newPage();
}
@AfterEach
void closePage() {
if (context != null) {
context.close();
context = null;
}
}
/** Opens a Vaadin route, e.g. {@code navigate("formular")}. */
protected void navigate(String route) {
page.navigate("http://localhost:" + port + "/" + route);
}
}
@@ -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)));
}
}
@@ -0,0 +1,580 @@
package com.example.views;
import com.example.Application;
import com.example.components.BarChart;
import com.example.components.GridStackItem;
import com.example.components.GridStackItem.Action;
import com.example.components.GridStackLayout;
import com.example.components.KpiTile;
import com.example.components.LineChart;
import com.example.components.PieChart;
import com.example.data.ChartDataService;
import com.example.data.DashboardFilter;
import com.example.data.DashboardFilter.Period;
import com.example.data.KpiData;
import com.example.export.CsvExport;
import com.example.widgets.WidgetDefinition;
import com.example.widgets.WidgetRegistry;
import com.vaadin.browserless.SpringBrowserlessTest;
import com.vaadin.browserless.ViewPackages;
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.contextmenu.MenuItem;
import com.vaadin.flow.component.dialog.Dialog;
import com.vaadin.flow.component.html.Anchor;
import com.vaadin.flow.component.html.Div;
import com.vaadin.flow.component.notification.Notification;
import com.vaadin.flow.dom.DomEvent;
import com.vaadin.flow.internal.JacksonUtils;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
@SpringBootTest(classes = Application.class)
@ViewPackages(classes = DashboardView.class)
class DashboardViewTest extends SpringBrowserlessTest {
/** Four KPI tiles plus three charts and the hint card. */
private static final int DEFAULT_WIDGETS = 8;
@Autowired
private WidgetRegistry registry;
@Autowired
private ChartDataService dataService;
@Test
void view_rendersGridWithDefaultWidgets() {
navigate(DashboardView.class);
GridStackLayout grid = $view(GridStackLayout.class).first();
assertNotNull(grid);
List<GridStackItem.Position> layout = grid.getLayout();
assertEquals(DEFAULT_WIDGETS, layout.size(), "expected the default widgets");
assertTrue(layout.stream().allMatch(p -> p.w() > 0 && p.h() > 0));
}
@Test
void kpiTiles_renderValueAndDelta() {
navigate(DashboardView.class);
List<KpiTile> tiles = $view(KpiTile.class).all();
assertEquals(4, tiles.size(), "expected the four KPI tiles");
KpiTile revenue = tiles.getFirst();
assertEquals(translate("kpi.revenueTotal"), revenue.getLabel());
assertEquals(translate("kpi.revenueTotal.value", 1.24), revenue.getValue());
// +12.4 — sign is explicit, decimal separator is the locale's.
assertTrue(revenue.getDeltaText().matches("\\+12[.,]4 %"),
"unexpected delta text: " + revenue.getDeltaText());
KpiTile orders = tiles.get(1);
assertTrue(orders.getDeltaText().startsWith("-"),
"a negative delta keeps its minus sign: " + orders.getDeltaText());
assertNotNull(orders.getSparkline(), "each tile carries a sparkline");
}
@Test
void kpiTiles_areOneRowHigh() {
navigate(DashboardView.class);
List<GridStackItem.Position> kpis = $view(GridStackLayout.class).first().getLayout()
.stream().filter(p -> p.id().startsWith("kpi-")).toList();
assertEquals(4, kpis.size());
assertTrue(kpis.stream().allMatch(p -> p.w() == 3 && p.h() == 1),
"KPI tiles default to a quarter row, one cell high");
}
@Test
void view_rendersTheThreeCharts() {
navigate(DashboardView.class);
assertNotNull($view(LineChart.class).first());
assertNotNull($view(BarChart.class).first());
assertNotNull($view(PieChart.class).first());
}
@Test
void filterBar_startsOnTheDefaultFilter() {
navigate(DashboardView.class);
DashboardFilterBar bar = $view(DashboardFilterBar.class).first();
assertEquals(DashboardFilter.defaults().period(), bar.getPeriodSelect().getValue());
assertEquals(DashboardFilter.ALL_REGIONS, bar.getRegionSelect().getValue());
}
@Test
void changingThePeriod_refeedsTheKpiTiles() {
navigate(DashboardView.class);
KpiTile revenue = $view(KpiTile.class).all().getFirst();
String before = revenue.getValue();
$view(DashboardFilterBar.class).first().getPeriodSelect().setValue(Period.YEAR);
assertNotEquals(before, revenue.getValue(), "a longer period means a larger total");
assertEquals(expectedValue("kpi-revenue",
DashboardFilter.defaults().withPeriod(Period.YEAR)),
revenue.getValue());
}
@Test
void changingTheRegion_refeedsTheKpiTiles() {
navigate(DashboardView.class);
KpiTile revenue = $view(KpiTile.class).all().getFirst();
String before = revenue.getValue();
$view(DashboardFilterBar.class).first().getRegionSelect().setValue("region.north");
assertNotEquals(before, revenue.getValue(), "one region is a share of the total");
assertEquals(expectedValue("kpi-revenue",
DashboardFilter.defaults().withRegion("region.north")),
revenue.getValue());
}
/** A closed tile must not be re-fed: it is detached, so an update would
* queue a client call for a chart that is no longer there. */
@Test
void filterChange_skipsClosedKpiTiles() {
navigate(DashboardView.class);
KpiTile revenue = $view(KpiTile.class).all().getFirst();
String before = revenue.getValue();
clickCloseButton(itemById("kpi-revenue"));
$view(DashboardFilterBar.class).first().getPeriodSelect().setValue(Period.YEAR);
assertEquals(before, revenue.getValue());
}
@Test
void addAndCloseWidget_changesItemCount() {
navigate(DashboardView.class);
GridStackLayout grid = $view(GridStackLayout.class).first();
int initial = grid.getLayout().size();
button("gridstack.addWidget").click();
assertEquals(initial, grid.getLayout().size(),
"opening the picker adds nothing on its own");
pickWidget("card.revenueByRegion");
assertEquals(initial + 1, grid.getLayout().size());
clickCloseButton($view(GridStackItem.class).last());
assertEquals(initial, grid.getLayout().size());
}
@Test
void addWidget_ofAChosenType_rendersRealContent() {
navigate(DashboardView.class);
button("gridstack.addWidget").click();
pickWidget("card.revenueByRegion");
assertEquals(2, $view(PieChart.class).all().size(),
"the added widget renders a chart, not a placeholder");
GridStackItem added = $view(GridStackItem.class).last();
assertTrue(added.getItemId().startsWith("revenue-region-"),
"unexpected id: " + added.getItemId());
}
@Test
void closedWidget_canBeAddedAgainWithAFreshId() {
navigate(DashboardView.class);
GridStackLayout grid = $view(GridStackLayout.class).first();
clickCloseButton(itemById("revenue-trend"));
assertEquals(0, $view(LineChart.class).all().size(), "the line chart is gone");
button("gridstack.addWidget").click();
pickWidget("card.revenueTrend");
List<GridStackItem.Position> layout = grid.getLayout();
assertEquals(DEFAULT_WIDGETS, layout.size());
assertEquals(1, $view(LineChart.class).all().size(), "the line chart is back");
// The closed widget's id must never be handed out again, or the new
// widget would inherit its persisted position.
assertFalse(layout.stream().anyMatch(p -> "revenue-trend".equals(p.id())));
assertTrue(layout.stream().anyMatch(p -> p.id().startsWith("revenue-trend-")));
}
@Test
void addingTheSameTypeTwice_yieldsDistinctIds() {
navigate(DashboardView.class);
button("gridstack.addWidget").click();
pickWidget("card.revenueByMonth");
button("gridstack.addWidget").click();
pickWidget("card.revenueByMonth");
List<String> ids = $view(GridStackLayout.class).first().getLayout().stream()
.map(GridStackItem.Position::id).toList();
assertEquals(ids.size(), ids.stream().distinct().count(), "duplicate ids: " + ids);
}
@Test
void picker_listsEveryRegisteredWidgetType() {
navigate(DashboardView.class);
button("gridstack.addWidget").click();
Dialog picker = $(Dialog.class).first();
for (WidgetDefinition definition : registry.definitions()) {
assertNotNull($(Button.class).from(picker)
.withText(translate(definition.titleKey())).first(),
"picker is missing " + definition.type());
}
}
@Test
void closeButton_removesTheClickedWidgetOnly() {
navigate(DashboardView.class);
GridStackLayout grid = $view(GridStackLayout.class).first();
GridStackItem first = $view(GridStackItem.class).first();
String closedId = first.getItemId();
clickCloseButton(first);
List<GridStackItem.Position> layout = grid.getLayout();
assertEquals(DEFAULT_WIDGETS - 1, layout.size());
assertFalse(layout.stream().anyMatch(p -> closedId.equals(p.id())),
"the closed widget must be gone from the layout");
}
@Test
void closeListener_firesOnceItemIsDetached() {
navigate(DashboardView.class);
GridStackLayout grid = $view(GridStackLayout.class).first();
GridStackItem item = $view(GridStackItem.class).first();
int[] fired = {0};
item.addCloseListener(e -> fired[0]++);
item.close();
assertEquals(1, fired[0]);
assertEquals(DEFAULT_WIDGETS - 1, grid.getLayout().size());
}
@Test
void closingAWidget_showsAnUndoToast() {
navigate(DashboardView.class);
clickCloseButton(itemById("revenue-trend"));
Notification toast = $(Notification.class).first();
assertNotNull(toast, "closing a widget must offer a way back");
assertNotNull($(Button.class).from(toast).withText(translate("gridstack.undo")).first());
}
@Test
void undo_restoresTheWidgetAtItsOriginalPositionAndId() {
navigate(DashboardView.class);
GridStackLayout grid = $view(GridStackLayout.class).first();
GridStackItem.Position before = positionOf("revenue-trend");
clickCloseButton(itemById("revenue-trend"));
assertEquals(DEFAULT_WIDGETS - 1, grid.getLayout().size());
clickUndo();
assertEquals(DEFAULT_WIDGETS, grid.getLayout().size());
assertEquals(before, positionOf("revenue-trend"),
"undo must restore the exact position and size, not the next free slot");
assertEquals(1, $view(LineChart.class).all().size(), "the chart itself is back too");
}
/** Closing the same widget twice in a row — once, undoing, then again —
* must still offer an undo the second time: the close listener is
* registered once per item, not once per (re-)add. */
@Test
void undoneWidget_offersAnUndoAgainWhenClosedAnotherTime() {
navigate(DashboardView.class);
clickCloseButton(itemById("revenue-trend"));
clickUndo();
clickCloseButton(itemById("revenue-trend"));
assertNotNull($(Button.class).from($(Notification.class).first())
.withText(translate("gridstack.undo")).first());
}
/** A close triggered from code (not the button or the menu) is not a
* misclick, so it must not spawn a toast the user never asked for. */
@Test
void programmaticClose_doesNotShowAnUndoToast() {
navigate(DashboardView.class);
itemById("revenue-trend").close();
assertTrue($(Notification.class).all().isEmpty(),
"a programmatic close must not offer an undo");
}
private void clickUndo() {
$(Button.class).from($(Notification.class).first())
.withText(translate("gridstack.undo")).first().click();
}
@Test
void closingEveryWidget_showsEmptyStateAndHidesTheGrid() {
navigate(DashboardView.class);
GridStackLayout grid = $view(GridStackLayout.class).first();
closeAllWidgets();
assertTrue(grid.getLayout().isEmpty(), "no widget should be left");
assertFalse(grid.isVisible(), "the empty grid must not show as an empty box");
Div emptyState = $view(Div.class).withClassName("dialect-empty-state").first();
assertTrue(emptyState.isVisible(), "the empty state must appear");
}
@Test
void addingAWidget_hidesTheEmptyStateAgain() {
navigate(DashboardView.class);
closeAllWidgets();
button("gridstack.addWidget").click();
pickWidget("card.revenueByRegion");
assertFalse($view(Div.class).withClassName("dialect-empty-state").exists(),
"the empty state must be gone once a widget is back — invisible components drop out of the query");
assertTrue($view(GridStackLayout.class).first().isVisible());
}
@Test
void restoreDefaultLayout_rebuildsTheDefaultWidgetsFromTheEmptyState() {
navigate(DashboardView.class);
closeAllWidgets();
button("gridstack.restoreDefaults").click();
GridStackLayout grid = $view(GridStackLayout.class).first();
assertEquals(DEFAULT_WIDGETS, grid.getLayout().size());
assertFalse($view(Div.class).withClassName("dialect-empty-state").exists(),
"the empty state must be gone once the default layout is back");
assertTrue(grid.isVisible());
}
private void closeAllWidgets() {
List<GridStackItem> items = $view(GridStackItem.class).all();
while (!items.isEmpty()) {
clickCloseButton(items.getFirst());
items = $view(GridStackItem.class).all();
}
}
@Test
void nonClosableItem_hasNoCloseButton() {
navigate(DashboardView.class);
GridStackItem item = $view(GridStackItem.class).first();
assertTrue(item.isClosable(), "items are closable by default");
item.setClosable(false);
assertFalse(item.isClosable());
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
* {@code close()} directly, so the button's own wiring is covered too. */
private void clickCloseButton(GridStackItem item) {
Div closeButton = item.getCloseButton();
ElementUtilsKt._fireDomEvent(closeButton.getElement(),
new DomEvent(closeButton.getElement(), "click", JacksonUtils.createObjectNode()));
}
/** Clicks a widget type in the picker dialog "add widget" opened. */
private void pickWidget(String titleKey) {
Dialog picker = $(Dialog.class).first();
$(Button.class).from(picker).withText(translate(titleKey)).first().click();
}
/** What the tile of the given KPI must read under that filter, formatted the
* way the view formats it — the number comes from the service, the pattern
* from the bundle. */
private String expectedValue(String kpiId, DashboardFilter filter) {
KpiData kpi = dataService.kpis(filter).stream()
.filter(data -> kpiId.equals(data.id())).findFirst().orElseThrow();
return translate(kpi.valueKey(), kpi.value());
}
private GridStackItem itemById(String id) {
return $view(GridStackItem.class)
.withCondition(item -> id.equals(item.getItemId())).single();
}
private Button button(String translationKey) {
return $view(Button.class).withText(translate(translationKey)).first();
}
private String translate(String translationKey, Object... params) {
return getCurrentView().getElement().getComponent()
.orElseThrow().getTranslation(translationKey, params);
}
}
@@ -1,63 +0,0 @@
package com.example.views;
import com.example.Application;
import com.example.components.GridStackItem;
import com.example.components.GridStackLayout;
import com.vaadin.browserless.SpringBrowserlessTest;
import com.vaadin.browserless.ViewPackages;
import com.vaadin.flow.component.button.Button;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
@SpringBootTest(classes = Application.class)
@ViewPackages(classes = GridStackView.class)
class GridStackViewTest extends SpringBrowserlessTest {
@Test
void view_rendersGridWithDefaultWidgets() {
navigate(GridStackView.class);
GridStackLayout grid = $view(GridStackLayout.class).first();
assertNotNull(grid);
List<GridStackItem.Position> layout = grid.getLayout();
assertEquals(4, layout.size(), "expected the four default widgets");
assertTrue(layout.stream().allMatch(p -> p.w() > 0 && p.h() > 0));
}
@Test
void addAndRemoveWidget_changesItemCount() {
navigate(GridStackView.class);
GridStackLayout grid = $view(GridStackLayout.class).first();
int initial = grid.getLayout().size();
button("gridstack.addWidget").click();
assertEquals(initial + 1, grid.getLayout().size());
button("gridstack.removeWidget").click();
assertEquals(initial, grid.getLayout().size());
}
@Test
void removeWidget_onDefaultLayout_keepsStaticWidgets() {
navigate(GridStackView.class);
GridStackLayout grid = $view(GridStackLayout.class).first();
button("gridstack.removeWidget").click();
assertEquals(4, grid.getLayout().size(), "static widgets must not be removable");
}
private Button button(String translationKey) {
String caption = getCurrentView().getElement().getComponent()
.orElseThrow().getTranslation(translationKey);
return $view(Button.class).withText(caption).first();
}
}
@@ -0,0 +1,73 @@
package com.example.widgets;
import com.example.data.DashboardFilter;
import com.example.data.DashboardFilter.Period;
import com.vaadin.flow.shared.Registration;
import org.junit.jupiter.api.Test;
import java.util.ArrayList;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
/** The filter bus is plain Java: no Spring context, no Vaadin UI. */
class DashboardContextTest {
private final DashboardContext context = new DashboardContext();
private final List<DashboardFilter> seen = new ArrayList<>();
@Test
void startsOnTheDefaultFilter() {
assertEquals(DashboardFilter.defaults(), context.getFilter());
}
@Test
void setFilter_notifiesEverySubscriber() {
context.addFilterChangeListener(seen::add);
context.addFilterChangeListener(seen::add);
context.setFilter(context.getFilter().withPeriod(Period.YEAR));
assertEquals(2, seen.size());
assertTrue(seen.stream().allMatch(filter -> filter.period() == Period.YEAR));
assertEquals(Period.YEAR, context.getFilter().period());
}
@Test
void setFilter_withAnUnchangedFilter_notifiesNobody() {
context.addFilterChangeListener(seen::add);
context.setFilter(DashboardFilter.defaults());
assertTrue(seen.isEmpty(), "an unchanged filter must not make widgets re-fetch");
}
@Test
void removedListener_isNotNotifiedAgain() {
Registration registration = context.addFilterChangeListener(seen::add);
context.setFilter(context.getFilter().withRegion("region.north"));
registration.remove();
context.setFilter(context.getFilter().withPeriod(Period.MONTH));
assertEquals(1, seen.size(), "a closed widget must stop being fed");
assertEquals(0, context.getListenerCount());
}
/** A widget may close itself while reacting, which unsubscribes it from
* inside the notification loop. */
@Test
void listener_mayUnsubscribeWhileBeingNotified() {
Registration[] registration = new Registration[1];
registration[0] = context.addFilterChangeListener(filter -> {
seen.add(filter);
registration[0].remove();
});
context.setFilter(context.getFilter().withPeriod(Period.QUARTER));
context.setFilter(context.getFilter().withPeriod(Period.YEAR));
assertEquals(1, seen.size());
}
}
@@ -0,0 +1,58 @@
package com.example.widgets;
import com.example.data.InMemoryChartDataService;
import com.vaadin.flow.component.html.Div;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* The definitions themselves — the factories need a Vaadin UI to resolve
* translations, so they are exercised from {@code DashboardViewTest} instead.
*/
class WidgetRegistryTest {
private final WidgetRegistry registry = new WidgetRegistry(new InMemoryChartDataService());
@Test
void definitions_areListedInRegistrationOrder() {
List<String> types = registry.definitions().stream()
.map(WidgetDefinition::type).toList();
assertEquals(List.of(WidgetRegistry.REVENUE_TREND, WidgetRegistry.REVENUE_MONTH,
WidgetRegistry.REVENUE_REGION), types);
assertTrue(registry.definitions().stream()
.allMatch(d -> d.width() > 0 && d.height() > 0),
"every definition carries a usable default size");
}
/** A widget with no refresher must not blow up when one is asked for: the
* dashboard offers the action per widget, the registry only carries it
* out. */
@Test
void refresh_ignoresAWidgetThatCarriesNoRefresher() {
assertDoesNotThrow(() -> WidgetRegistry.refresh(new Div()));
}
@Test
void require_rejectsAnUnknownType() {
assertThrows(IllegalArgumentException.class, () -> registry.require("nope"));
}
@Test
void register_replacesTheDefinitionOfTheSameType() {
int before = registry.definitions().size();
WidgetDefinition replacement = new WidgetDefinition(WidgetRegistry.REVENUE_TREND,
"card.revenueTrend", 2, 2, context -> null);
registry.register(replacement);
assertEquals(before, registry.definitions().size());
assertEquals(replacement, registry.require(WidgetRegistry.REVENUE_TREND));
}
}