Compare commits
29 Commits
4de757df99
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 0a8f7408f1 | |||
| 7a4bc72799 | |||
| 505ad32d4a | |||
| dbe304e61e | |||
| 114f00523b | |||
| 4878a7fc06 | |||
| cd1d8f494d | |||
| c71774bc5f | |||
| ed399cec6c | |||
| a7f79e64df | |||
| 4e48e31011 | |||
| 71892ea7ad | |||
| cfa644c0e1 | |||
| bb9b89a8d8 | |||
| 8545b06f41 | |||
| cff232070d | |||
| df5d1a9b9b | |||
| cdbd61aefb | |||
| 5fe159a604 | |||
| 03872a9d0c | |||
| b341b2ed5d | |||
| e62292b96c | |||
| 21834ec9a0 | |||
| ed9e6f81ea | |||
| 640616c60a | |||
| 1b846751dd | |||
| 487c206411 | |||
| 7aff9fee4f | |||
| a996269aff |
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 0–10 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`.
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
|
||||||
@@ -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`.
|
||||||
|
|||||||
@@ -1,61 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
source .env
|
|
||||||
|
|
||||||
REPO_DIR="$HOME/codebase/chart-app" # Haupt-Klon
|
|
||||||
BASE_BRANCH="main"
|
|
||||||
TEST_CMD="./mvnw test" # z.B. ./gradlew test
|
|
||||||
MAX_ISSUES=5 # pro Durchlauf, damit du nicht davonläufst
|
|
||||||
POLL_INTERVAL=120 # Sekunden zwischen Polls (0 = einmal laufen)
|
|
||||||
|
|
||||||
cd "$REPO_DIR"
|
|
||||||
|
|
||||||
process_next() {
|
|
||||||
git -C "$REPO_DIR" fetch origin "$BASE_BRANCH" --quiet
|
|
||||||
git -C "$REPO_DIR" checkout "$BASE_BRANCH" --quiet
|
|
||||||
git -C "$REPO_DIR" pull --quiet
|
|
||||||
|
|
||||||
# 1) Nächstes Issue holen (Sub-Agent, minimaler Output)
|
|
||||||
local out
|
|
||||||
out=$(claude -p "Nutze den Sub-Agent 'next-issue'. Gib NUR dessen Rueckgabezeile aus (ISSUE=<nr> oder ISSUE=NONE), keinen weiteren Text." \
|
|
||||||
--allowedTools "Agent,mcp__gitea__list_issues,mcp__gitea__issue_read" \
|
|
||||||
--output-format text)
|
|
||||||
local nr
|
|
||||||
nr=$(echo "$out" | grep -oE 'ISSUE=[0-9NONE]+' | cut -d= -f2)
|
|
||||||
|
|
||||||
if [[ "$nr" == "NONE" || -z "$nr" ]]; then
|
|
||||||
echo "Keine offenen ai-ready Issues."
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo ">>> Bearbeite Issue #$nr"
|
|
||||||
local wt="../wt-issue-$nr"
|
|
||||||
local branch="ai/issue-$nr"
|
|
||||||
|
|
||||||
# 2) Isolierter Worktree + Branch
|
|
||||||
git worktree add -b "$branch" "$wt" "$BASE_BRANCH" --quiet
|
|
||||||
|
|
||||||
# 3) Worker im Worktree ausführen (autonom)
|
|
||||||
(
|
|
||||||
cd "$wt"
|
|
||||||
claude -p "/fix-issue $nr" \
|
|
||||||
--allowedTools "Bash($TEST_CMD),Bash(git *),Edit,Read,Write,Grep,Glob,mcp__gitea__issue_read,mcp__gitea__issue_write,mcp__gitea__label_read,mcp__gitea__pull_request_write" \
|
|
||||||
--output-format text
|
|
||||||
) || echo "!!! Worker für #$nr mit Fehler beendet"
|
|
||||||
|
|
||||||
# 4) Aufräumen
|
|
||||||
git worktree remove "$wt" --force || true
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
# --- Hauptschleife ---
|
|
||||||
while true; do
|
|
||||||
count=0
|
|
||||||
while (( count < MAX_ISSUES )); do
|
|
||||||
if process_next; then ((count++)); else break; fi
|
|
||||||
done
|
|
||||||
echo "Durchlauf fertig ($count Issues)."
|
|
||||||
(( POLL_INTERVAL == 0 )) && break
|
|
||||||
sleep "$POLL_INTERVAL"
|
|
||||||
done
|
|
||||||
Binary file not shown.
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,7 +26,14 @@ public enum Fa {
|
|||||||
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");
|
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;
|
||||||
|
|
||||||
|
|||||||
@@ -3,9 +3,21 @@ package com.example.components;
|
|||||||
import com.vaadin.flow.component.Component;
|
import com.vaadin.flow.component.Component;
|
||||||
import com.vaadin.flow.component.ComponentEvent;
|
import com.vaadin.flow.component.ComponentEvent;
|
||||||
import com.vaadin.flow.component.ComponentEventListener;
|
import com.vaadin.flow.component.ComponentEventListener;
|
||||||
|
import com.vaadin.flow.component.contextmenu.MenuItem;
|
||||||
|
import com.vaadin.flow.component.html.Anchor;
|
||||||
|
import com.vaadin.flow.component.html.AttachmentType;
|
||||||
import com.vaadin.flow.component.html.Div;
|
import com.vaadin.flow.component.html.Div;
|
||||||
|
import com.vaadin.flow.component.html.Span;
|
||||||
|
import com.vaadin.flow.component.menubar.MenuBar;
|
||||||
|
import com.vaadin.flow.component.menubar.MenuBarVariant;
|
||||||
|
import com.vaadin.flow.server.streams.DownloadHandler;
|
||||||
import com.vaadin.flow.shared.Registration;
|
import com.vaadin.flow.shared.Registration;
|
||||||
|
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.EnumMap;
|
||||||
|
import java.util.EnumSet;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -19,6 +31,15 @@ import java.util.UUID;
|
|||||||
* the grip (see {@link #setClosable(boolean)}); removing the item server-side
|
* the grip (see {@link #setClosable(boolean)}); removing the item server-side
|
||||||
* is enough to unregister the widget client-side, since grid-stack.ts observes
|
* is enough to unregister the widget client-side, since grid-stack.ts observes
|
||||||
* its own childList.
|
* its own childList.
|
||||||
|
* <p>
|
||||||
|
* Next to those two sits the action menu ({@link Action}): the slot for
|
||||||
|
* everything a widget can do beyond move/resize/close. {@link Action#MAXIMIZE}
|
||||||
|
* and {@link Action#REMOVE} are handled here, the rest is only reported to
|
||||||
|
* {@link #addActionListener(ComponentEventListener) action listeners} — what
|
||||||
|
* "refresh" or "duplicate" means depends on the widget, which this component
|
||||||
|
* knows nothing about. An entry that hands out a file rather than changing the
|
||||||
|
* widget is wired with
|
||||||
|
* {@link #setActionDownload(Action, DownloadHandler) setActionDownload}.
|
||||||
*/
|
*/
|
||||||
public class GridStackItem extends Div {
|
public class GridStackItem extends Div {
|
||||||
|
|
||||||
@@ -30,10 +51,53 @@ public class GridStackItem extends Div {
|
|||||||
* styling and the grip's offset off it. */
|
* styling and the grip's offset off it. */
|
||||||
public static final String CLOSE_BUTTON_CLASS = "dialect-close-button";
|
public static final String CLOSE_BUTTON_CLASS = "dialect-close-button";
|
||||||
|
|
||||||
|
/** Marker class for the action menu; the corner controls' offsets in
|
||||||
|
* {@code styles.css} are keyed off its presence, same as the close
|
||||||
|
* button's. */
|
||||||
|
public static final String ACTION_MENU_CLASS = "dialect-action-menu";
|
||||||
|
|
||||||
|
/** Marker class for the download link of a
|
||||||
|
* {@link #setActionDownload(Action, DownloadHandler) download entry};
|
||||||
|
* {@code styles.css} stretches it over the whole entry, so the whole row
|
||||||
|
* is clickable and not just its caption. */
|
||||||
|
public static final String ACTION_LINK_CLASS = "dialect-action-link";
|
||||||
|
|
||||||
|
/** Class set on the item itself while it is
|
||||||
|
* {@link #setMaximized(boolean) maximized} — {@code styles.css} is what
|
||||||
|
* actually lifts the item out of the grid. */
|
||||||
|
public static final String MAXIMIZED_CLASS = "dialect-maximized";
|
||||||
|
|
||||||
|
/** What the action menu can offer. Which entries a given widget shows is up
|
||||||
|
* to the caller ({@link #setActionEnabled(Action, boolean)}) — not every
|
||||||
|
* widget type supports every action. */
|
||||||
|
public enum Action {
|
||||||
|
/** Re-request this widget's data. Reported only. */
|
||||||
|
REFRESH,
|
||||||
|
/** Expand the widget over the grid and back. Handled here. */
|
||||||
|
MAXIMIZE,
|
||||||
|
/** Add another widget of the same kind. Reported only. */
|
||||||
|
DUPLICATE,
|
||||||
|
/** Download the data behind the widget. Reported only — the file itself
|
||||||
|
* comes from the {@link #setActionDownload(Action, DownloadHandler)
|
||||||
|
* download handler} the caller wired to this entry. */
|
||||||
|
EXPORT,
|
||||||
|
/** Same as the close button, from the menu. Handled here. */
|
||||||
|
REMOVE
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Shown by default: both are widget-type independent, since this component
|
||||||
|
* can carry them out on its own. */
|
||||||
|
private static final Action[] DEFAULT_ACTIONS = {Action.MAXIMIZE, Action.REMOVE};
|
||||||
|
|
||||||
private final Div content = new Div();
|
private final Div content = new Div();
|
||||||
private final Div dragHandle = new Div();
|
private final Div dragHandle = new Div();
|
||||||
private final Div closeButton = new Div();
|
private final Div closeButton = new Div();
|
||||||
|
private final MenuBar actionMenu = new MenuBar();
|
||||||
|
private final Map<Action, MenuItem> actionItems = new EnumMap<>(Action.class);
|
||||||
|
private final Map<Action, Span> actionCaptions = new EnumMap<>(Action.class);
|
||||||
|
private final Map<Action, Anchor> actionLinks = new EnumMap<>(Action.class);
|
||||||
private boolean closable = true;
|
private boolean closable = true;
|
||||||
|
private boolean maximized;
|
||||||
|
|
||||||
public GridStackItem(Component... content) {
|
public GridStackItem(Component... content) {
|
||||||
this(UUID.randomUUID().toString(), 0, 0, 4, 3, content);
|
this(UUID.randomUUID().toString(), 0, 0, 4, 3, content);
|
||||||
@@ -77,6 +141,164 @@ public class GridStackItem extends Div {
|
|||||||
closeButton.getElement().addEventListener("keydown", e -> close(true))
|
closeButton.getElement().addEventListener("keydown", e -> close(true))
|
||||||
.setFilter("event.key === 'Enter' || event.key === ' '");
|
.setFilter("event.key === 'Enter' || event.key === ' '");
|
||||||
getElement().appendChild(closeButton.getElement());
|
getElement().appendChild(closeButton.getElement());
|
||||||
|
|
||||||
|
buildActionMenu();
|
||||||
|
getElement().appendChild(actionMenu.getElement());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The overflow menu, a third sibling of the content wrapper: outside it for
|
||||||
|
* the same reason as the grip, and outside the {@code DRAG_HANDLE_CLASS}
|
||||||
|
* selector so opening it never starts a drag.
|
||||||
|
* <p>
|
||||||
|
* A {@link MenuBar} rather than a hand-rolled popup: its button is a real
|
||||||
|
* button, so the menu is reachable and operable from the keyboard without
|
||||||
|
* this component re-implementing any of it.
|
||||||
|
*/
|
||||||
|
private void buildActionMenu() {
|
||||||
|
actionMenu.addClassName(ACTION_MENU_CLASS);
|
||||||
|
actionMenu.addThemeVariants(MenuBarVariant.LUMO_TERTIARY_INLINE, MenuBarVariant.LUMO_ICON);
|
||||||
|
|
||||||
|
String label = getTranslation("gridstack.actions");
|
||||||
|
MenuItem root = actionMenu.addItem(Fa.MENU.create());
|
||||||
|
root.getElement().setAttribute("title", label);
|
||||||
|
root.getElement().setAttribute("aria-label", label);
|
||||||
|
|
||||||
|
addActionItem(root, Action.REFRESH, Fa.REFRESH, "gridstack.refresh");
|
||||||
|
addActionItem(root, Action.MAXIMIZE, Fa.MAXIMIZE, "gridstack.maximize");
|
||||||
|
addActionItem(root, Action.DUPLICATE, Fa.DUPLICATE, "gridstack.duplicate");
|
||||||
|
addActionItem(root, Action.EXPORT, Fa.EXPORT, "gridstack.export");
|
||||||
|
addActionItem(root, Action.REMOVE, Fa.REMOVE, "gridstack.remove");
|
||||||
|
|
||||||
|
setActions(DEFAULT_ACTIONS);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The caption is a {@link Span} of its own rather than the item's text:
|
||||||
|
* {@code setText} would drop the icon along with the old text, and the
|
||||||
|
* maximize entry relabels itself. */
|
||||||
|
private void addActionItem(MenuItem root, Action action, Fa icon, String captionKey) {
|
||||||
|
Span caption = new Span(getTranslation(captionKey));
|
||||||
|
MenuItem item = root.getSubMenu().addItem(icon.create(),
|
||||||
|
e -> triggerAction(action, e.isFromClient()));
|
||||||
|
item.add(caption);
|
||||||
|
actionItems.put(action, item);
|
||||||
|
actionCaptions.put(action, caption);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Carries out what this component can do itself, then reports the action.
|
||||||
|
* The event is fired last, so a listener sees the item in its new state —
|
||||||
|
* and, for {@link Action#REMOVE}, already detached, as with
|
||||||
|
* {@link CloseEvent}. */
|
||||||
|
private void triggerAction(Action action, boolean fromClient) {
|
||||||
|
switch (action) {
|
||||||
|
case MAXIMIZE -> setMaximized(!maximized);
|
||||||
|
case REMOVE -> close(fromClient);
|
||||||
|
default -> { }
|
||||||
|
}
|
||||||
|
fireEvent(new ActionEvent(this, fromClient, action));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Shows exactly the given actions in the menu and hides every other one. */
|
||||||
|
public GridStackItem setActions(Action... actions) {
|
||||||
|
Set<Action> wanted = EnumSet.noneOf(Action.class);
|
||||||
|
Collections.addAll(wanted, actions);
|
||||||
|
actionItems.forEach((action, item) -> item.setVisible(wanted.contains(action)));
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Makes an entry hand out a file: its caption becomes a download link over
|
||||||
|
* {@code handler}, so picking it downloads instead of only firing an
|
||||||
|
* {@link ActionEvent} (which it still does — a listener can react to the
|
||||||
|
* export as well).
|
||||||
|
* <p>
|
||||||
|
* The handler is asked for its content when the entry is clicked, not here,
|
||||||
|
* so a widget that has been re-fed in the meantime exports what it is
|
||||||
|
* showing at that moment. Calling this again re-points the same link.
|
||||||
|
*/
|
||||||
|
public GridStackItem setActionDownload(Action action, DownloadHandler handler) {
|
||||||
|
Anchor link = actionLinks.computeIfAbsent(action, key -> {
|
||||||
|
Anchor anchor = new Anchor();
|
||||||
|
anchor.addClassName(ACTION_LINK_CLASS);
|
||||||
|
// Reparents the caption into the link: the entry keeps its icon and
|
||||||
|
// its text, only the text is now what the browser downloads from.
|
||||||
|
anchor.add(actionCaptions.get(key));
|
||||||
|
actionItems.get(key).add(anchor);
|
||||||
|
return anchor;
|
||||||
|
});
|
||||||
|
link.setHref(handler, AttachmentType.DOWNLOAD);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The download link of an entry wired with
|
||||||
|
* {@link #setActionDownload(Action, DownloadHandler)}, if it has one. */
|
||||||
|
public Anchor getActionLink(Action action) {
|
||||||
|
return actionLinks.get(action);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Shows or hides a single menu entry — the widget types that support an
|
||||||
|
* action differ, the menu does not. */
|
||||||
|
public GridStackItem setActionEnabled(Action action, boolean enabled) {
|
||||||
|
actionItems.get(action).setVisible(enabled);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isActionEnabled(Action action) {
|
||||||
|
return actionItems.get(action).isVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The menu entry of an action. Exposed for restyling and for tests, which
|
||||||
|
* click it rather than calling the action directly. */
|
||||||
|
public MenuItem getActionItem(Action action) {
|
||||||
|
return actionItems.get(action);
|
||||||
|
}
|
||||||
|
|
||||||
|
public MenuBar getActionMenu() {
|
||||||
|
return actionMenu;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Expands the item over the grid, or puts it back. Only a class on the item
|
||||||
|
* changes — its {@code gs-*} attributes and its gridstack node are left
|
||||||
|
* exactly as they are, so restoring is by definition the position it had.
|
||||||
|
*/
|
||||||
|
public GridStackItem setMaximized(boolean maximized) {
|
||||||
|
if (maximized == this.maximized) {
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
this.maximized = maximized;
|
||||||
|
getElement().getClassList().set(MAXIMIZED_CLASS, maximized);
|
||||||
|
actionCaptions.get(Action.MAXIMIZE).setText(
|
||||||
|
getTranslation(maximized ? "gridstack.restore" : "gridstack.maximize"));
|
||||||
|
// The item's box changes without gridstack resizing anything, so nothing
|
||||||
|
// fires the resize the charts inside reflow on (see grid-stack.ts,
|
||||||
|
// which does the same on resizestop). After the next frame, so the new
|
||||||
|
// geometry is the one the chart measures.
|
||||||
|
getElement().executeJs("requestAnimationFrame("
|
||||||
|
+ "() => window.dispatchEvent(new Event('resize')))");
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isMaximized() {
|
||||||
|
return maximized;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Registration addActionListener(ComponentEventListener<ActionEvent> listener) {
|
||||||
|
return addListener(ActionEvent.class, listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fired after an action menu entry was picked. */
|
||||||
|
public static class ActionEvent extends ComponentEvent<GridStackItem> {
|
||||||
|
private final Action action;
|
||||||
|
|
||||||
|
ActionEvent(GridStackItem source, boolean fromClient, Action action) {
|
||||||
|
super(source, fromClient);
|
||||||
|
this.action = action;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Action getAction() {
|
||||||
|
return action;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -90,10 +312,26 @@ public class GridStackItem extends Div {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void close(boolean fromClient) {
|
private void close(boolean fromClient) {
|
||||||
|
// Captured before detaching: once removeFromParent() runs, the grid no
|
||||||
|
// longer reports this item, so its position could not be read back from
|
||||||
|
// there afterwards. The gs-* attributes themselves are untouched by the
|
||||||
|
// detach, so a caller that keeps this item around and re-adds it later
|
||||||
|
// (e.g. to undo the close) lands it back at exactly this spot.
|
||||||
|
Position position = capturePosition();
|
||||||
// Detach first so listeners observe the layout they are about to see —
|
// Detach first so listeners observe the layout they are about to see —
|
||||||
// getLayout() on the grid no longer counts this item.
|
// getLayout() on the grid no longer counts this item.
|
||||||
getElement().removeFromParent();
|
getElement().removeFromParent();
|
||||||
fireEvent(new CloseEvent(this, fromClient));
|
fireEvent(new CloseEvent(this, fromClient, position));
|
||||||
|
}
|
||||||
|
|
||||||
|
private Position capturePosition() {
|
||||||
|
return new Position(getItemId(),
|
||||||
|
attrInt("gs-x"), attrInt("gs-y"), attrInt("gs-w"), attrInt("gs-h"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private int attrInt(String name) {
|
||||||
|
String value = getElement().getAttribute(name);
|
||||||
|
return value == null ? 0 : Integer.parseInt(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Shows or hides the close button (shown by default). */
|
/** Shows or hides the close button (shown by default). */
|
||||||
@@ -124,10 +362,23 @@ public class GridStackItem extends Div {
|
|||||||
return addListener(CloseEvent.class, listener);
|
return addListener(CloseEvent.class, listener);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Fired after the item has been removed from its {@link GridStackLayout}. */
|
/** Fired after the item has been removed from its {@link GridStackLayout}.
|
||||||
|
* Carries the {@link Position} it held right before detaching, so a
|
||||||
|
* listener can offer to undo the close (re-adding the item at that exact
|
||||||
|
* spot) without having to track positions itself. {@link #isFromClient()}
|
||||||
|
* tells apart a user-initiated close (button/menu) from a programmatic
|
||||||
|
* {@link GridStackItem#close()} — an undo affordance only makes sense for
|
||||||
|
* the former. */
|
||||||
public static class CloseEvent extends ComponentEvent<GridStackItem> {
|
public static class CloseEvent extends ComponentEvent<GridStackItem> {
|
||||||
CloseEvent(GridStackItem source, boolean fromClient) {
|
private final Position position;
|
||||||
|
|
||||||
|
CloseEvent(GridStackItem source, boolean fromClient, Position position) {
|
||||||
super(source, fromClient);
|
super(source, fromClient);
|
||||||
|
this.position = position;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Position getPosition() {
|
||||||
|
return position;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -102,6 +102,30 @@ public class GridStackLayout extends Component implements HasSize, HasStyle {
|
|||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Below {@code maxWidthPx} (measured against the browser window, not just
|
||||||
|
* this element's own width), gridstack collapses to {@code columns}
|
||||||
|
* columns — pass 1 to stack every item full-width, which is what makes a
|
||||||
|
* chart legible on a phone. Drag/resize is disabled below the same
|
||||||
|
* threshold (see {@code grid-stack.ts}): rearranging a stacked mobile
|
||||||
|
* layout by touch is mostly misfires, and gridstack still restores the
|
||||||
|
* pre-collapse positions once the window widens back past the threshold —
|
||||||
|
* that restore is the engine's own column-change cache, not something this
|
||||||
|
* class drives. Persisting to {@code localStorage} (see
|
||||||
|
* {@link #setStorageKey(String)}) is suppressed while collapsed, so a
|
||||||
|
* narrowed window can never overwrite the saved desktop layout.
|
||||||
|
*/
|
||||||
|
public GridStackLayout setResponsiveBreakpoint(int maxWidthPx, int columns) {
|
||||||
|
Map<String, Object> breakpoint = new LinkedHashMap<>();
|
||||||
|
breakpoint.put("w", maxWidthPx);
|
||||||
|
breakpoint.put("c", columns);
|
||||||
|
Map<String, Object> columnOpts = new LinkedHashMap<>();
|
||||||
|
columnOpts.put("breakpointForWindow", true);
|
||||||
|
columnOpts.put("breakpoints", List.of(breakpoint));
|
||||||
|
options.put("columnOpts", columnOpts);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Enables browser-localStorage persistence of the layout under the given
|
* Enables browser-localStorage persistence of the layout under the given
|
||||||
* key (shared across sessions/tabs on the same origin — pick something
|
* key (shared across sessions/tabs on the same origin — pick something
|
||||||
|
|||||||
@@ -0,0 +1,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,132 +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.List;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Showcase for {@link GridStackLayout}: a dashboard 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.
|
|
||||||
*/
|
|
||||||
@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 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 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
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.
|
|
||||||
// The counter only ever grows: 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++;
|
|
||||||
String title = getTranslation("gridstack.widget", extraWidgetCount);
|
|
||||||
grid.add(new GridStackItem("extra-" + extraWidgetCount, 0, 0, 4, 2,
|
|
||||||
new Card(title, new Paragraph(getTranslation("gridstack.widgetText")))));
|
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
||||||
@@ -196,10 +302,63 @@ vaadin-app-layout::part(content) {
|
|||||||
transition: opacity 120ms ease;
|
transition: opacity 120ms ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.grid-stack-item:has(> .dialect-close-button) > .dialect-drag-handle {
|
/* Action menu (GridStackItem.Action): the third corner control, between the
|
||||||
|
close button and the grip. Each control that is present pushes the ones to
|
||||||
|
its left one slot (32px) further in. */
|
||||||
|
.dialect-action-menu {
|
||||||
|
position: absolute;
|
||||||
|
top: 14px;
|
||||||
|
right: 16px;
|
||||||
|
z-index: 1;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 120ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Its button is a corner control like the grip and the close button, so it is
|
||||||
|
sized like one. A menu-bar button at its own size is 64px wide, i.e. twice
|
||||||
|
its slot: painted after the grip, it swallowed the grip's pointer events and
|
||||||
|
left the widget undraggable. The button is a light-DOM child of the menu bar,
|
||||||
|
so plain selectors reach it. */
|
||||||
|
.dialect-action-menu vaadin-menu-bar-button {
|
||||||
|
width: 28px;
|
||||||
|
min-width: 0;
|
||||||
|
height: 28px;
|
||||||
|
padding: 0;
|
||||||
|
color: var(--dialect-ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-stack-item:has(> .dialect-close-button) > .dialect-action-menu {
|
||||||
right: 48px;
|
right: 48px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.grid-stack-item:has(> .dialect-close-button) > .dialect-drag-handle,
|
||||||
|
.grid-stack-item:has(> .dialect-action-menu) > .dialect-drag-handle {
|
||||||
|
right: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-stack-item:has(> .dialect-close-button):has(> .dialect-action-menu)
|
||||||
|
> .dialect-drag-handle {
|
||||||
|
right: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Stays put while its menu is open: the overlay is anchored to the button, and
|
||||||
|
the pointer moving onto the overlay leaves the item, ending the :hover. */
|
||||||
|
.grid-stack-item:hover > .dialect-action-menu,
|
||||||
|
.dialect-action-menu:focus-within,
|
||||||
|
.dialect-action-menu:has(vaadin-menu-bar-button[expanded]) {
|
||||||
|
opacity: 0.75;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Download entry (GridStackItem#setActionDownload): its caption is an anchor,
|
||||||
|
which the menu overlay knows nothing about — stretch it over the whole entry
|
||||||
|
so the entire row downloads, and drop the link look, since the entry already
|
||||||
|
reads as a menu item. */
|
||||||
|
.dialect-action-link {
|
||||||
|
flex: 1;
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
.grid-stack-item:hover > .dialect-close-button,
|
.grid-stack-item:hover > .dialect-close-button,
|
||||||
.dialect-close-button:focus-visible {
|
.dialect-close-button:focus-visible {
|
||||||
opacity: 0.65;
|
opacity: 0.65;
|
||||||
@@ -214,11 +373,38 @@ vaadin-app-layout::part(content) {
|
|||||||
/* Touch devices never hover — keep the corner controls permanently visible. */
|
/* Touch devices never hover — keep the corner controls permanently visible. */
|
||||||
@media (pointer: coarse) {
|
@media (pointer: coarse) {
|
||||||
.dialect-drag-handle,
|
.dialect-drag-handle,
|
||||||
.dialect-close-button {
|
.dialect-close-button,
|
||||||
|
.dialect-action-menu {
|
||||||
opacity: 0.65;
|
opacity: 0.65;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Maximized item (GridStackItem#setMaximized): lifted out of the grid purely
|
||||||
|
visually — its gs-* attributes and its gridstack node are untouched, so
|
||||||
|
dropping this class puts it back exactly where it was. !important because
|
||||||
|
gridstack writes the item's box as inline style. */
|
||||||
|
.grid-stack-item.dialect-maximized {
|
||||||
|
position: fixed !important;
|
||||||
|
inset: 16px !important;
|
||||||
|
width: auto !important;
|
||||||
|
height: auto !important;
|
||||||
|
min-height: 0 !important;
|
||||||
|
transform: none !important;
|
||||||
|
z-index: 20;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-stack-item.dialect-maximized > .grid-stack-item-content {
|
||||||
|
height: 100%;
|
||||||
|
inset: 0 !important;
|
||||||
|
width: auto !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Nothing to drag or resize while maximized: the item is not in grid flow. */
|
||||||
|
.grid-stack-item.dialect-maximized > .dialect-drag-handle,
|
||||||
|
.grid-stack-item.dialect-maximized .ui-resizable-handle {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
.grid-stack-item[gs-no-move] > .dialect-drag-handle {
|
.grid-stack-item[gs-no-move] > .dialect-drag-handle {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
@@ -234,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, mit dem X oben rechts schließen. Das Layout wird im Browser gespeichert und beim nächsten Besuch wiederhergestellt.
|
gridstack.hint=Karten am Griff oben rechts verschieben, an der unteren rechten Ecke die Größe ändern, mit dem X oben rechts schließen. Weitere Aktionen – aktualisieren, maximieren, duplizieren, als CSV exportieren – liegen im Menü daneben. Das Layout wird im Browser gespeichert und beim nächsten Besuch wiederhergestellt.
|
||||||
gridstack.addWidget=Widget hinzufügen
|
gridstack.addWidget=Widget hinzufügen
|
||||||
gridstack.reset=Layout zurücksetzen
|
gridstack.reset=Layout zurücksetzen
|
||||||
gridstack.status=Layout geändert – {0} Widgets
|
gridstack.status=Layout geändert – {0} Widgets
|
||||||
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.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, close them with the X in the top right corner. The layout is stored in your browser and restored on your next visit.
|
gridstack.hint=Move cards with the grip in their top right corner, resize them from the bottom right corner, close them with the X in the top right corner. More actions – refresh, maximize, duplicate, export as CSV – live in the menu next to it. The layout is stored in your browser and restored on your next visit.
|
||||||
gridstack.addWidget=Add widget
|
gridstack.addWidget=Add widget
|
||||||
gridstack.reset=Reset layout
|
gridstack.reset=Reset layout
|
||||||
gridstack.status=Layout changed – {0} widgets
|
gridstack.status=Layout changed – {0} widgets
|
||||||
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.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, cambia su tamaño desde la esquina inferior derecha y ciérralas con la X de la esquina superior derecha. El diseño se guarda en el navegador y se restaura en la próxima visita.
|
gridstack.hint=Mueve las tarjetas con el asa de la esquina superior derecha, cambia su tamaño desde la esquina inferior derecha y ciérralas con la X de la esquina superior derecha. Más acciones – actualizar, maximizar, duplicar, exportar como CSV – están en el menú contiguo. El diseño se guarda en el navegador y se restaura en la próxima visita.
|
||||||
gridstack.addWidget=Añadir widget
|
gridstack.addWidget=Añadir widget
|
||||||
gridstack.reset=Restablecer diseño
|
gridstack.reset=Restablecer diseño
|
||||||
gridstack.status=Diseño modificado – {0} widgets
|
gridstack.status=Diseño modificado – {0} widgets
|
||||||
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.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,109 +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.browserless.internal.ElementUtilsKt;
|
|
||||||
import com.vaadin.flow.component.button.Button;
|
|
||||||
import com.vaadin.flow.component.html.Div;
|
|
||||||
import com.vaadin.flow.dom.DomEvent;
|
|
||||||
import com.vaadin.flow.internal.JacksonUtils;
|
|
||||||
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.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 addAndCloseWidget_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());
|
|
||||||
|
|
||||||
clickCloseButton($view(GridStackItem.class).last());
|
|
||||||
assertEquals(initial, grid.getLayout().size());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void closeButton_removesTheClickedWidgetOnly() {
|
|
||||||
navigate(GridStackView.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(3, 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(GridStackView.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(3, grid.getLayout().size());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void nonClosableItem_hasNoCloseButton() {
|
|
||||||
navigate(GridStackView.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()));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 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()));
|
|
||||||
}
|
|
||||||
|
|
||||||
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user