Compare commits
13 Commits
bb9b89a8d8
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 0a8f7408f1 | |||
| 7a4bc72799 | |||
| 505ad32d4a | |||
| dbe304e61e | |||
| 114f00523b | |||
| 4878a7fc06 | |||
| cd1d8f494d | |||
| c71774bc5f | |||
| ed399cec6c | |||
| a7f79e64df | |||
| 4e48e31011 | |||
| 71892ea7ad | |||
| cfa644c0e1 |
@@ -4,7 +4,7 @@ description: Wählt das nächste zu bearbeitende Gitea-Issue und gibt nur die Ke
|
||||
tools: mcp__gitea__list_issues, mcp__gitea__issue_read
|
||||
model: haiku
|
||||
---
|
||||
Finde das nächste offene Issue im Repo <owner>/<repo> mit Label `ai-ready`
|
||||
Finde das nächste offene Issue im Repo pitfriedrich/chart-app mit Label `ai-ready`
|
||||
und NICHT mit Label `ai-wip` oder `ai-blocked`.
|
||||
Priorisiere nach `prio/high` > `prio/medium` > `prio/low`, dann nach ältestem.
|
||||
|
||||
|
||||
@@ -5,7 +5,8 @@ allowed-tools: >
|
||||
Bash, Edit, Read, Write, Grep, Glob,
|
||||
mcp__gitea__issue_read, mcp__gitea__issue_write,
|
||||
mcp__gitea__label_read,
|
||||
mcp__gitea__pull_request_write
|
||||
mcp__gitea__pull_request_write, mcp__gitea__pull_request_read,
|
||||
mcp__gitea__actions_run_read
|
||||
model: opus
|
||||
---
|
||||
Bearbeite Gitea-Issue #$1 im aktuellen Repo. Schritt für Schritt:
|
||||
@@ -18,22 +19,36 @@ Name → ID für die gesamte Bearbeitung. Verwende ausschließlich `add_labels`
|
||||
Niemals `replace_labels` oder `clear_labels` benutzen — das löscht alle
|
||||
übrigen Labels des Issues (z.B. `prio/*`).
|
||||
|
||||
0. Vorbedingungen: Wenn Issue #$1 bereits `ai-wip` oder `ai-blocked` trägt,
|
||||
abbrechen (kein Doppelstart). Prüfe `git status --porcelain` — ist der
|
||||
Baum nicht sauber, abbrechen statt fremde Änderungen mitzucommitten.
|
||||
1. Lies Issue #$1 (Titel, Beschreibung, Kommentare) über `issue_read`.
|
||||
Notiere die aktuell gesetzten Labels, damit du sie nicht verlierst.
|
||||
2. Hole die Label-Liste via `label_read`. Setze Label `ai-wip`
|
||||
(`add_labels`), entferne `ai-ready` (`remove_label`).
|
||||
3. Analysiere das Repo und finde die Ursache. Wenn der Scope zu groß/unklar
|
||||
ist (siehe CLAUDE.md), brich ab: kommentiere das Issue mit einer kurzen
|
||||
Begründung, setze `ai-blocked`, und beende ohne Codeänderung.
|
||||
4. Implementiere den Fix. Halte den Diff minimal und fokussiert.
|
||||
5. Führe die Tests aus (`<dein Testkommando>`). Bei Rot: bis zu 2x nachbessern.
|
||||
Wenn danach immer noch rot → `ai-blocked`, Issue kommentieren, abbrechen.
|
||||
6. Commit mit Message: `fix: <kurze Beschreibung> (#$1)`.
|
||||
7. Push den aktuellen Branch zum Remote.
|
||||
8. Öffne via `pull_request_write` einen PR gegen `main` (bzw. `develop`).
|
||||
Die PR-Beschreibung MUSS `Closes #$1` enthalten.
|
||||
9. Kommentiere am Issue mit dem PR-Link, setze Label `ai-review`
|
||||
(`add_labels`) und entferne `ai-wip` (`remove_label`).
|
||||
Begründung, setze `ai-blocked`, entferne `ai-wip`, und beende ohne
|
||||
Codeänderung.
|
||||
4. Lege den Branch an: `git checkout main && git pull --ff-only`, dann
|
||||
`git checkout -b ai/issue-$1-<kurz-slug>` (Slug aus dem Issue-Titel,
|
||||
kleingeschrieben, Bindestriche statt Leerzeichen).
|
||||
5. Implementiere den Fix. Halte den Diff minimal und fokussiert.
|
||||
6. Führe die Tests aus (`./mvnw test`). Bei Rot: bis zu 2x nachbessern.
|
||||
Wenn danach immer noch rot → `ai-blocked`, `ai-wip` entfernen, Issue
|
||||
kommentieren, abbrechen. Hinweis: `e2e/*PlaywrightTest` überspringt sich
|
||||
selbst per JUnit-Assumption, wenn der Browser-Download fehlschlägt —
|
||||
übersprungen zählt nicht als rot und ist kein Abbruchgrund.
|
||||
7. Commit mit Message: `fix: <kurze Beschreibung> (#$1)`.
|
||||
8. Push den Branch zum Remote.
|
||||
9. Öffne via `pull_request_write` einen PR gegen `main`. Die
|
||||
PR-Beschreibung MUSS `Closes #$1` enthalten.
|
||||
10. Warte den CI-Lauf des PR-Branches ab (`actions_run_read`, PR läuft
|
||||
automatisch gegen `.gitea/workflows/ci.yml`).
|
||||
- Grün: kommentiere am Issue mit dem PR-Link, setze Label `ai-review`
|
||||
(`add_labels`), entferne `ai-wip` (`remove_label`).
|
||||
- Rot: setze `ai-blocked`, entferne `ai-wip`, kommentiere das Issue mit
|
||||
einem kurzen Auszug aus dem CI-Log. Kein `ai-review`.
|
||||
|
||||
Wenn du in einem Schritt `ai-blocked` setzt, entferne ebenfalls `ai-wip`.
|
||||
Existiert ein benötigtes Label nicht im Repo, kommentiere das am Issue statt
|
||||
|
||||
@@ -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/
|
||||
vite.generated.ts
|
||||
/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.
|
||||
@@ -52,4 +52,5 @@ UI copy/data (chart labels, notifications) is in German.
|
||||
- Bei Unklarheit / Scope > ~200 Zeilen Diff: NICHT fixen,
|
||||
stattdessen Issue kommentieren ("needs human") und Label `ai-blocked` setzen.
|
||||
- PR-Beschreibung MUSS `Closes #<nr>` enthalten.
|
||||
- Keine Änderungen an <kritische Pfade, z.B. build config, Migrations> ohne Freigabe.
|
||||
- Keine Änderungen an kritischen Pfaden ohne Freigabe: `pom.xml`, `mvnw`/`mvnw.cmd`,
|
||||
`.mvn/`, `.gitea/workflows/`, `.gitignore`.
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
source .env
|
||||
|
||||
REPO_DIR="$HOME/codebase/chart-app" # Haupt-Klon
|
||||
BASE_BRANCH="main"
|
||||
TEST_CMD="./mvnw test" # z.B. ./gradlew test
|
||||
MAX_ISSUES=5 # pro Durchlauf, damit du nicht davonläufst
|
||||
POLL_INTERVAL=120 # Sekunden zwischen Polls (0 = einmal laufen)
|
||||
|
||||
cd "$REPO_DIR"
|
||||
|
||||
process_next() {
|
||||
git -C "$REPO_DIR" fetch origin "$BASE_BRANCH" --quiet
|
||||
git -C "$REPO_DIR" checkout "$BASE_BRANCH" --quiet
|
||||
git -C "$REPO_DIR" pull --quiet
|
||||
|
||||
# 1) Nächstes Issue holen (Sub-Agent, minimaler Output)
|
||||
local out
|
||||
out=$(claude -p "Nutze den Sub-Agent 'next-issue'. Gib NUR dessen Rueckgabezeile aus (ISSUE=<nr> oder ISSUE=NONE), keinen weiteren Text." \
|
||||
--allowedTools "Agent,mcp__gitea__list_issues,mcp__gitea__issue_read" \
|
||||
--output-format text)
|
||||
local nr
|
||||
nr=$(echo "$out" | grep -oE 'ISSUE=[0-9NONE]+' | cut -d= -f2)
|
||||
|
||||
if [[ "$nr" == "NONE" || -z "$nr" ]]; then
|
||||
echo "Keine offenen ai-ready Issues."
|
||||
return 1
|
||||
fi
|
||||
|
||||
echo ">>> Bearbeite Issue #$nr"
|
||||
local wt="../wt-issue-$nr"
|
||||
local branch="ai/issue-$nr"
|
||||
|
||||
# 2) Isolierter Worktree + Branch
|
||||
git worktree add -b "$branch" "$wt" "$BASE_BRANCH" --quiet
|
||||
|
||||
# 3) Worker im Worktree ausführen (autonom)
|
||||
(
|
||||
cd "$wt"
|
||||
claude -p "/fix-issue $nr" \
|
||||
--allowedTools "Bash($TEST_CMD),Bash(git *),Edit,Read,Write,Grep,Glob,mcp__gitea__issue_read,mcp__gitea__issue_write,mcp__gitea__label_read,mcp__gitea__pull_request_write" \
|
||||
--output-format text
|
||||
) || echo "!!! Worker für #$nr mit Fehler beendet"
|
||||
|
||||
# 4) Aufräumen
|
||||
git worktree remove "$wt" --force || true
|
||||
return 0
|
||||
}
|
||||
|
||||
# --- Hauptschleife ---
|
||||
while true; do
|
||||
count=0
|
||||
while (( count < MAX_ISSUES )); do
|
||||
if process_next; then ((count++)); else break; fi
|
||||
done
|
||||
echo "Durchlauf fertig ($count Issues)."
|
||||
(( POLL_INTERVAL == 0 )) && break
|
||||
sleep "$POLL_INTERVAL"
|
||||
done
|
||||
Binary file not shown.
@@ -11,6 +11,13 @@ class GridStackLayout extends HTMLElement {
|
||||
private observer?: MutationObserver;
|
||||
private storageKey: string | null = null;
|
||||
private persistTimer?: ReturnType<typeof setTimeout>;
|
||||
/** The column count configured server-side (before any responsive
|
||||
* collapse) — {@link persist} compares the live column count against
|
||||
* this to tell a real layout change from gridstack's own breakpoint
|
||||
* collapse. */
|
||||
private fullColumn = 12;
|
||||
private mobileQuery?: MediaQueryList;
|
||||
private mobileQueryHandler?: (e: MediaQueryListEvent) => void;
|
||||
|
||||
connectedCallback() {
|
||||
this.observer = new MutationObserver((mutations) => this.onMutation(mutations));
|
||||
@@ -20,6 +27,11 @@ class GridStackLayout extends HTMLElement {
|
||||
disconnectedCallback() {
|
||||
this.observer?.disconnect();
|
||||
this.observer = undefined;
|
||||
if (this.mobileQuery && this.mobileQueryHandler) {
|
||||
this.mobileQuery.removeEventListener('change', this.mobileQueryHandler);
|
||||
}
|
||||
this.mobileQuery = undefined;
|
||||
this.mobileQueryHandler = undefined;
|
||||
// Write out a still-pending debounced save instead of dropping it:
|
||||
// a drag/resize followed straight away by navigating to another route
|
||||
// detaches this element inside the debounce window, which used to lose
|
||||
@@ -39,6 +51,7 @@ class GridStackLayout extends HTMLElement {
|
||||
|
||||
this.classList.add('grid-stack');
|
||||
const options = JSON.parse(optionsJson);
|
||||
this.fullColumn = options.column ?? this.fullColumn;
|
||||
const grid = GridStack.init(options, this);
|
||||
if (!grid) return;
|
||||
this.grid = grid;
|
||||
@@ -48,6 +61,19 @@ class GridStackLayout extends HTMLElement {
|
||||
|
||||
grid.on('change added removed', () => this.schedulePersist());
|
||||
grid.on('resizestop', () => window.dispatchEvent(new Event('resize')));
|
||||
|
||||
// Below the responsive breakpoint, disable drag/resize: rearranging a
|
||||
// stacked mobile layout by touch is mostly misfires, and it's the
|
||||
// desktop layout — not the stacked one — that's worth protecting from
|
||||
// an accidental drag. Mirrors the same width gridstack's own
|
||||
// columnOpts.breakpoints collapses at, so both switch together.
|
||||
const breakpointWidth: number | undefined = options.columnOpts?.breakpoints?.[0]?.w;
|
||||
if (breakpointWidth) {
|
||||
this.mobileQuery = window.matchMedia(`(max-width: ${breakpointWidth}px)`);
|
||||
this.mobileQueryHandler = (e) => this.grid?.setStatic(e.matches);
|
||||
this.mobileQuery.addEventListener('change', this.mobileQueryHandler);
|
||||
this.grid.setStatic(this.mobileQuery.matches);
|
||||
}
|
||||
}
|
||||
|
||||
/** Drops any saved layout for this grid and re-applies the positions
|
||||
@@ -119,6 +145,12 @@ class GridStackLayout extends HTMLElement {
|
||||
|
||||
private persist(notifyServer = true) {
|
||||
if (!this.grid) return;
|
||||
// gridstack's responsive columnOpts collapse fires the same 'change'
|
||||
// event a real drag/resize would, with save() reporting whatever
|
||||
// shape the engine forced items into at 1 column. That shape is not
|
||||
// something the user chose — persisting it would silently overwrite
|
||||
// the desktop layout the next time the window narrows.
|
||||
if (this.grid.getColumn() !== this.fullColumn) return;
|
||||
const nodes = this.grid.save(false) as GridStackNode[];
|
||||
if (this.storageKey) {
|
||||
localStorage.setItem(STORAGE_PREFIX + this.storageKey, JSON.stringify(nodes));
|
||||
|
||||
@@ -31,6 +31,7 @@ public enum Fa {
|
||||
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");
|
||||
|
||||
|
||||
@@ -4,10 +4,13 @@ import com.vaadin.flow.component.Component;
|
||||
import com.vaadin.flow.component.ComponentEvent;
|
||||
import com.vaadin.flow.component.ComponentEventListener;
|
||||
import com.vaadin.flow.component.contextmenu.MenuItem;
|
||||
import com.vaadin.flow.component.html.Anchor;
|
||||
import com.vaadin.flow.component.html.AttachmentType;
|
||||
import com.vaadin.flow.component.html.Div;
|
||||
import com.vaadin.flow.component.html.Span;
|
||||
import com.vaadin.flow.component.menubar.MenuBar;
|
||||
import com.vaadin.flow.component.menubar.MenuBarVariant;
|
||||
import com.vaadin.flow.server.streams.DownloadHandler;
|
||||
import com.vaadin.flow.shared.Registration;
|
||||
|
||||
import java.util.Collections;
|
||||
@@ -34,7 +37,9 @@ import java.util.UUID;
|
||||
* 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.
|
||||
* 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 {
|
||||
|
||||
@@ -51,6 +56,12 @@ public class GridStackItem extends Div {
|
||||
* 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. */
|
||||
@@ -66,6 +77,10 @@ public class GridStackItem extends Div {
|
||||
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
|
||||
}
|
||||
@@ -80,6 +95,7 @@ public class GridStackItem extends Div {
|
||||
private final MenuBar actionMenu = new MenuBar();
|
||||
private final Map<Action, MenuItem> actionItems = new EnumMap<>(Action.class);
|
||||
private final Map<Action, Span> actionCaptions = new EnumMap<>(Action.class);
|
||||
private final Map<Action, Anchor> actionLinks = new EnumMap<>(Action.class);
|
||||
private boolean closable = true;
|
||||
private boolean maximized;
|
||||
|
||||
@@ -151,6 +167,7 @@ public class GridStackItem extends Div {
|
||||
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);
|
||||
@@ -189,6 +206,36 @@ public class GridStackItem extends Div {
|
||||
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) {
|
||||
@@ -265,10 +312,26 @@ public class GridStackItem extends Div {
|
||||
}
|
||||
|
||||
private void close(boolean fromClient) {
|
||||
// Captured before detaching: once removeFromParent() runs, the grid no
|
||||
// longer reports this item, so its position could not be read back from
|
||||
// there afterwards. The gs-* attributes themselves are untouched by the
|
||||
// detach, so a caller that keeps this item around and re-adds it later
|
||||
// (e.g. to undo the close) lands it back at exactly this spot.
|
||||
Position position = capturePosition();
|
||||
// Detach first so listeners observe the layout they are about to see —
|
||||
// getLayout() on the grid no longer counts this item.
|
||||
getElement().removeFromParent();
|
||||
fireEvent(new CloseEvent(this, fromClient));
|
||||
fireEvent(new CloseEvent(this, fromClient, position));
|
||||
}
|
||||
|
||||
private Position capturePosition() {
|
||||
return new Position(getItemId(),
|
||||
attrInt("gs-x"), attrInt("gs-y"), attrInt("gs-w"), attrInt("gs-h"));
|
||||
}
|
||||
|
||||
private int attrInt(String name) {
|
||||
String value = getElement().getAttribute(name);
|
||||
return value == null ? 0 : Integer.parseInt(value);
|
||||
}
|
||||
|
||||
/** Shows or hides the close button (shown by default). */
|
||||
@@ -299,10 +362,23 @@ public class GridStackItem extends Div {
|
||||
return addListener(CloseEvent.class, listener);
|
||||
}
|
||||
|
||||
/** Fired after the item has been removed from its {@link GridStackLayout}. */
|
||||
/** Fired after the item has been removed from its {@link GridStackLayout}.
|
||||
* Carries the {@link Position} it held right before detaching, so a
|
||||
* listener can offer to undo the close (re-adding the item at that exact
|
||||
* spot) without having to track positions itself. {@link #isFromClient()}
|
||||
* tells apart a user-initiated close (button/menu) from a programmatic
|
||||
* {@link GridStackItem#close()} — an undo affordance only makes sense for
|
||||
* the former. */
|
||||
public static class CloseEvent extends ComponentEvent<GridStackItem> {
|
||||
CloseEvent(GridStackItem source, boolean fromClient) {
|
||||
private final Position position;
|
||||
|
||||
CloseEvent(GridStackItem source, boolean fromClient, Position position) {
|
||||
super(source, fromClient);
|
||||
this.position = position;
|
||||
}
|
||||
|
||||
public Position getPosition() {
|
||||
return position;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -102,6 +102,30 @@ public class GridStackLayout extends Component implements HasSize, HasStyle {
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Below {@code maxWidthPx} (measured against the browser window, not just
|
||||
* this element's own width), gridstack collapses to {@code columns}
|
||||
* columns — pass 1 to stack every item full-width, which is what makes a
|
||||
* chart legible on a phone. Drag/resize is disabled below the same
|
||||
* threshold (see {@code grid-stack.ts}): rearranging a stacked mobile
|
||||
* layout by touch is mostly misfires, and gridstack still restores the
|
||||
* pre-collapse positions once the window widens back past the threshold —
|
||||
* that restore is the engine's own column-change cache, not something this
|
||||
* class drives. Persisting to {@code localStorage} (see
|
||||
* {@link #setStorageKey(String)}) is suppressed while collapsed, so a
|
||||
* narrowed window can never overwrite the saved desktop layout.
|
||||
*/
|
||||
public GridStackLayout setResponsiveBreakpoint(int maxWidthPx, int columns) {
|
||||
Map<String, Object> breakpoint = new LinkedHashMap<>();
|
||||
breakpoint.put("w", maxWidthPx);
|
||||
breakpoint.put("c", columns);
|
||||
Map<String, Object> columnOpts = new LinkedHashMap<>();
|
||||
columnOpts.put("breakpointForWindow", true);
|
||||
columnOpts.put("breakpoints", List.of(breakpoint));
|
||||
options.put("columnOpts", columnOpts);
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enables browser-localStorage persistence of the layout under the given
|
||||
* key (shared across sessions/tabs on the same origin — pick something
|
||||
|
||||
@@ -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("^-|-$", "");
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ 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;
|
||||
@@ -16,23 +17,33 @@ import com.vaadin.flow.component.Component;
|
||||
import com.vaadin.flow.component.button.Button;
|
||||
import com.vaadin.flow.component.button.ButtonVariant;
|
||||
import com.vaadin.flow.component.dialog.Dialog;
|
||||
import com.vaadin.flow.component.html.Div;
|
||||
import com.vaadin.flow.component.html.Paragraph;
|
||||
import com.vaadin.flow.component.html.Span;
|
||||
import com.vaadin.flow.component.notification.Notification;
|
||||
import com.vaadin.flow.component.orderedlayout.HorizontalLayout;
|
||||
import com.vaadin.flow.component.orderedlayout.VerticalLayout;
|
||||
import com.vaadin.flow.router.HasDynamicTitle;
|
||||
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.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}.
|
||||
* 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
|
||||
@@ -48,6 +59,10 @@ public class DashboardView extends VerticalLayout implements HasDynamicTitle {
|
||||
|
||||
private static final String STORAGE_KEY = "dashboard";
|
||||
|
||||
/** Matches gridstack's {@code columnOpts} breakpoint below which the grid
|
||||
* stacks to a single column (see {@link GridStackLayout#setResponsiveBreakpoint}). */
|
||||
private static final int MOBILE_BREAKPOINT_PX = 768;
|
||||
|
||||
/** A KPI tile is a quarter row wide, so the n-th one starts at 3n. */
|
||||
private static final int KPI_WIDTH = 3;
|
||||
|
||||
@@ -56,6 +71,10 @@ public class DashboardView extends VerticalLayout implements HasDynamicTitle {
|
||||
private final DashboardContext context = new DashboardContext();
|
||||
private final GridStackLayout grid = new GridStackLayout();
|
||||
private final Span status = new Span();
|
||||
/** Shown instead of {@link #grid} once every widget has been closed; not a
|
||||
* {@link GridStackItem} itself, so it never becomes draggable and never
|
||||
* shows up in {@link GridStackLayout#getLayout()}. */
|
||||
private final Div emptyState = new Div();
|
||||
/** The KPI tiles by KPI id, so a filter change re-feeds each tile with the
|
||||
* data of the same KPI rather than by position. */
|
||||
private final Map<String, KpiTile> kpiTiles = new LinkedHashMap<>();
|
||||
@@ -68,13 +87,29 @@ public class DashboardView extends VerticalLayout implements HasDynamicTitle {
|
||||
|
||||
grid.setWidthFull();
|
||||
grid.setStorageKey(STORAGE_KEY);
|
||||
// Below phone/small-tablet width, stack every widget full-width.
|
||||
grid.setResponsiveBreakpoint(MOBILE_BREAKPOINT_PX, 1);
|
||||
grid.addLayoutChangeListener(e -> status.setText(
|
||||
getTranslation("gridstack.status", e.getPositions().size())));
|
||||
|
||||
// KPI tiles first: the numbers a dashboard is read for, above the charts
|
||||
// that explain them. They are 3x1 — a quarter row each, one cell high,
|
||||
// laid out left to right in the order the service returns them. The
|
||||
// grid id is the KPI's own id, so it survives reordering.
|
||||
configureEmptyState();
|
||||
buildDefaultWidgets();
|
||||
context.addFilterChangeListener(this::updateKpiTiles);
|
||||
|
||||
status.setText(getTranslation("gridstack.statusInitial"));
|
||||
status.addClassName("dialect-muted");
|
||||
|
||||
add(toolbar(), new DashboardFilterBar(context), grid, emptyState);
|
||||
}
|
||||
|
||||
/** The dashboard's initial widget set: the KPI tiles first — the numbers a
|
||||
* dashboard is read for, above the charts that explain them — then the
|
||||
* three default charts and the usage hint. Also used to rebuild the
|
||||
* dashboard from scratch via {@link #restoreDefaultWidgets()}. */
|
||||
private void buildDefaultWidgets() {
|
||||
// They are 3x1 — a quarter row each, one cell high, laid out left to
|
||||
// right in the order the service returns them. The grid id is the
|
||||
// KPI's own id, so it survives reordering.
|
||||
List<KpiData> kpis = dataService.kpis(context.getFilter());
|
||||
for (int i = 0; i < kpis.size(); i++) {
|
||||
KpiData kpi = kpis.get(i);
|
||||
@@ -89,22 +124,61 @@ public class DashboardView extends VerticalLayout implements HasDynamicTitle {
|
||||
refreshKpiTile(kpi.id());
|
||||
}
|
||||
});
|
||||
grid.add(item);
|
||||
addWidgetToGrid(item);
|
||||
}
|
||||
context.addFilterChangeListener(this::updateKpiTiles);
|
||||
|
||||
grid.add(
|
||||
List.of(
|
||||
defaultWidget(WidgetRegistry.REVENUE_TREND, 0, 1, 6, 3),
|
||||
defaultWidget(WidgetRegistry.REVENUE_MONTH, 6, 1, 6, 3),
|
||||
defaultWidget(WidgetRegistry.REVENUE_REGION, 0, 4, 5, 3),
|
||||
new GridStackItem("hint", 5, 4, 7, 3,
|
||||
new Card(getTranslation("card.gridstackHint"),
|
||||
new Paragraph(getTranslation("gridstack.hint")))));
|
||||
new Paragraph(getTranslation("gridstack.hint")))))
|
||||
.forEach(this::addWidgetToGrid);
|
||||
}
|
||||
|
||||
status.setText(getTranslation("gridstack.statusInitial"));
|
||||
status.addClassName("dialect-muted");
|
||||
/** Builds the placeholder shown once every widget has been closed: a short
|
||||
* explanation, a CTA that opens the same {@link #openWidgetPicker() widget
|
||||
* picker} as the toolbar, and a secondary action that rebuilds the default
|
||||
* layout — {@link GridStackLayout#resetLayout()} alone cannot do that here,
|
||||
* since it only repositions widgets still present, and none are left. */
|
||||
private void configureEmptyState() {
|
||||
emptyState.addClassName("dialect-empty-state");
|
||||
|
||||
add(toolbar(), new DashboardFilterBar(context), grid);
|
||||
var icon = Fa.GRID.create();
|
||||
icon.addClassName("dialect-empty-state__icon");
|
||||
|
||||
Span title = new Span(getTranslation("gridstack.emptyTitle"));
|
||||
title.addClassName("dialect-empty-state__title");
|
||||
Span hint = new Span(getTranslation("gridstack.emptyHint"));
|
||||
hint.addClassName("dialect-muted");
|
||||
|
||||
Button add = new Button(getTranslation("gridstack.addWidget"), Fa.ADD.create(),
|
||||
e -> openWidgetPicker());
|
||||
add.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
|
||||
Button restore = new Button(getTranslation("gridstack.restoreDefaults"), Fa.RESET.create(),
|
||||
e -> restoreDefaultWidgets());
|
||||
|
||||
HorizontalLayout actions = new HorizontalLayout(add, restore);
|
||||
actions.addClassName("dialect-empty-state__actions");
|
||||
|
||||
emptyState.add(icon, title, hint, actions);
|
||||
}
|
||||
|
||||
/** Clears the stale KPI tile references and rebuilds the initial widget set
|
||||
* — the empty state's secondary action. */
|
||||
private void restoreDefaultWidgets() {
|
||||
kpiTiles.clear();
|
||||
buildDefaultWidgets();
|
||||
grid.resetLayout();
|
||||
}
|
||||
|
||||
/** Toggles {@link #grid} and {@link #emptyState} based on whether any
|
||||
* widget is left — called from every path that adds or removes one. */
|
||||
private void updateEmptyState() {
|
||||
boolean empty = grid.getLayout().isEmpty();
|
||||
emptyState.setVisible(empty);
|
||||
grid.setVisible(!empty);
|
||||
}
|
||||
|
||||
private HorizontalLayout toolbar() {
|
||||
@@ -152,10 +226,60 @@ public class DashboardView extends VerticalLayout implements HasDynamicTitle {
|
||||
// widget must not hand its id to the next one, or the new widget would
|
||||
// inherit the closed one's saved position.
|
||||
extraWidgetCount++;
|
||||
grid.add(widget(definition.type() + "-" + extraWidgetCount, definition,
|
||||
addWidgetToGrid(widget(definition.type() + "-" + extraWidgetCount, definition,
|
||||
0, 0, definition.width(), definition.height()));
|
||||
}
|
||||
|
||||
/** Adds an item to the grid and wires the undo toast onto its close — every
|
||||
* widget goes through here exactly once, so the close listener (and thus
|
||||
* the undo offer) is registered once per item, not once per re-add: an
|
||||
* undo hands the very same instance back to {@link #grid} directly. */
|
||||
private void addWidgetToGrid(GridStackItem item) {
|
||||
item.addCloseListener(this::offerUndo);
|
||||
grid.add(item);
|
||||
updateEmptyState();
|
||||
}
|
||||
|
||||
/**
|
||||
* Closing a widget is one misclick away from losing its position, and a
|
||||
* confirmation dialog on every close would be worse than the problem — so
|
||||
* this shows a dismissible undo toast instead. Skipped for programmatic
|
||||
* closes ({@link GridStackItem#close()}, e.g. from duplicate cleanup),
|
||||
* which is exactly what {@link GridStackItem.CloseEvent#isFromClient()}
|
||||
* tells apart.
|
||||
* <p>
|
||||
* Undo re-adds the very item that was closed — not a rebuilt copy — at the
|
||||
* {@link GridStackItem.Position} the event captured before detaching, so it
|
||||
* keeps its {@code gs-id} and its exact size/position rather than landing
|
||||
* in the grid's next free slot.
|
||||
*/
|
||||
private void offerUndo(GridStackItem.CloseEvent event) {
|
||||
updateEmptyState();
|
||||
if (!event.isFromClient()) {
|
||||
return;
|
||||
}
|
||||
GridStackItem item = event.getSource();
|
||||
GridStackItem.Position position = event.getPosition();
|
||||
|
||||
Notification toast = new Notification();
|
||||
toast.setDuration(8000);
|
||||
toast.setPosition(Notification.Position.BOTTOM_START);
|
||||
|
||||
Button undo = new Button(getTranslation("gridstack.undo"), e -> {
|
||||
item.setPosition(position.x(), position.y(), position.w(), position.h());
|
||||
grid.add(item);
|
||||
updateEmptyState();
|
||||
toast.close();
|
||||
});
|
||||
undo.addThemeVariants(ButtonVariant.LUMO_TERTIARY_INLINE);
|
||||
|
||||
HorizontalLayout content = new HorizontalLayout(
|
||||
new Span(getTranslation("gridstack.closed")), undo);
|
||||
content.setAlignItems(Alignment.CENTER);
|
||||
toast.add(content);
|
||||
toast.open();
|
||||
}
|
||||
|
||||
/** A widget of the initial set. Its id is the plain type — never handed out
|
||||
* again by {@link #addWidget(WidgetDefinition)}, which always suffixes a
|
||||
* counter — and it keeps the dashboard's own placement rather than the
|
||||
@@ -172,7 +296,9 @@ public class DashboardView extends VerticalLayout implements HasDynamicTitle {
|
||||
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.REMOVE);
|
||||
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);
|
||||
@@ -183,6 +309,41 @@ public class DashboardView extends VerticalLayout implements HasDynamicTitle {
|
||||
return item;
|
||||
}
|
||||
|
||||
/**
|
||||
* The widget's data as a CSV attachment. Nothing is computed here: the
|
||||
* callback runs when the user picks the entry, so data, labels and file
|
||||
* name are all of the moment — including whatever the filter bar is set to
|
||||
* then.
|
||||
* <p>
|
||||
* A download is served on a request of its own, outside the session lock
|
||||
* and without a current {@code UI} (see
|
||||
* {@code StreamRequestHandler#callElementResourceHandler}), so the lock is
|
||||
* taken for the read and the locale is passed explicitly.
|
||||
*/
|
||||
private DownloadHandler csvDownload(WidgetDefinition definition, Component content) {
|
||||
return DownloadHandler.fromInputStream(event -> {
|
||||
VaadinSession session = event.getSession();
|
||||
session.lock();
|
||||
Locale locale;
|
||||
String fileName;
|
||||
Optional<CsvExport> table;
|
||||
try {
|
||||
locale = event.getUI().getLocale();
|
||||
table = WidgetRegistry.export(content, locale);
|
||||
fileName = CsvExport.fileName(getTranslation(locale, definition.titleKey()),
|
||||
getTranslation(locale, context.getFilter().period().labelKey()));
|
||||
} finally {
|
||||
session.unlock();
|
||||
}
|
||||
if (table.isEmpty()) {
|
||||
return DownloadResponse.error(HttpStatusCode.NOT_FOUND);
|
||||
}
|
||||
byte[] csv = table.get().toBytes(locale);
|
||||
return new DownloadResponse(new ByteArrayInputStream(csv), fileName,
|
||||
"text/csv;charset=utf-8", csv.length);
|
||||
});
|
||||
}
|
||||
|
||||
/** Re-feeds a single tile from the current filter — the action menu's
|
||||
* refresh, which asks for one widget, not for the dashboard. */
|
||||
private void refreshKpiTile(String kpiId) {
|
||||
|
||||
@@ -8,6 +8,7 @@ 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;
|
||||
@@ -16,8 +17,11 @@ 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
|
||||
@@ -76,6 +80,28 @@ public class WidgetRegistry {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
@@ -98,22 +124,18 @@ public class WidgetRegistry {
|
||||
private Component axisChart(AxisChart chart, DashboardContext context) {
|
||||
chart.addPointClickListener(e -> Notification.show(chart.getTranslation(
|
||||
"chart.pointClick", e.getSeriesIndex(), e.getDataPointIndex())));
|
||||
return bind(chart, context, filter -> {
|
||||
ChartSeries series = dataService.revenueByMonth(filter);
|
||||
return bind(chart, context, dataService::revenueByMonth, series ->
|
||||
chart.updateData(chart.getTranslation(series.nameKey()), series.values(),
|
||||
translate(chart, series.categoryKeys()));
|
||||
});
|
||||
translate(chart, series.categoryKeys())));
|
||||
}
|
||||
|
||||
private Component pieChart(DashboardContext context) {
|
||||
PieChart chart = new PieChart();
|
||||
chart.addPointClickListener(e -> Notification.show(
|
||||
chart.getTranslation("chart.sliceClick", e.getDataPointIndex())));
|
||||
return bind(chart, context, filter -> {
|
||||
ChartSeries series = dataService.revenueByRegion(filter);
|
||||
// A pie has no series name — its categories are the slice labels.
|
||||
chart.updateData(series.values(), translate(chart, series.categoryKeys()));
|
||||
});
|
||||
return bind(chart, context, dataService::revenueByRegion, series ->
|
||||
chart.updateData(series.values(), translate(chart, series.categoryKeys())));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -123,17 +145,36 @@ public class WidgetRegistry {
|
||||
* <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,
|
||||
Consumer<DashboardFilter> feed) {
|
||||
feed.accept(context.getFilter());
|
||||
Registration registration = context.addFilterChangeListener(feed);
|
||||
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) () -> feed.accept(context.getFilter()));
|
||||
(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). */
|
||||
|
||||
@@ -307,13 +307,26 @@ apex-chart.dialect-sparkline .apexcharts-xaxis {
|
||||
its left one slot (32px) further in. */
|
||||
.dialect-action-menu {
|
||||
position: absolute;
|
||||
top: 11px;
|
||||
top: 14px;
|
||||
right: 16px;
|
||||
z-index: 1;
|
||||
opacity: 0;
|
||||
transition: opacity 120ms ease;
|
||||
}
|
||||
|
||||
/* Its button is a corner control like the grip and the close button, so it is
|
||||
sized like one. A menu-bar button at its own size is 64px wide, i.e. twice
|
||||
its slot: painted after the grip, it swallowed the grip's pointer events and
|
||||
left the widget undraggable. The button is a light-DOM child of the menu bar,
|
||||
so plain selectors reach it. */
|
||||
.dialect-action-menu vaadin-menu-bar-button {
|
||||
width: 28px;
|
||||
min-width: 0;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
color: var(--dialect-ink);
|
||||
}
|
||||
|
||||
.grid-stack-item:has(> .dialect-close-button) > .dialect-action-menu {
|
||||
right: 48px;
|
||||
}
|
||||
@@ -336,6 +349,16 @@ apex-chart.dialect-sparkline .apexcharts-xaxis {
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
/* Download entry (GridStackItem#setActionDownload): its caption is an anchor,
|
||||
which the menu overlay knows nothing about — stretch it over the whole entry
|
||||
so the entire row downloads, and drop the link look, since the entry already
|
||||
reads as a menu item. */
|
||||
.dialect-action-link {
|
||||
flex: 1;
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.grid-stack-item:hover > .dialect-close-button,
|
||||
.dialect-close-button:focus-visible {
|
||||
opacity: 0.65;
|
||||
@@ -397,3 +420,32 @@ apex-chart.dialect-sparkline .apexcharts-xaxis {
|
||||
border: 2px dashed var(--dialect-border);
|
||||
border-radius: var(--dialect-radius);
|
||||
}
|
||||
|
||||
/* DashboardView's placeholder for a widget-less dashboard (not a
|
||||
GridStackItem, see DashboardView.emptyState) — dashed like gridstack's own
|
||||
drop placeholder above, to read as "nothing here yet" rather than a card. */
|
||||
.dialect-empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 48px 24px;
|
||||
text-align: center;
|
||||
border: 2px dashed var(--dialect-border);
|
||||
border-radius: var(--dialect-radius);
|
||||
color: var(--dialect-ink);
|
||||
}
|
||||
|
||||
.dialect-empty-state__icon {
|
||||
font-size: 2rem;
|
||||
color: var(--dialect-primary);
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.dialect-empty-state__title {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.dialect-empty-state__actions {
|
||||
gap: 8px;
|
||||
}
|
||||
@@ -22,7 +22,7 @@ card.registration=Registrierung
|
||||
card.employees=Mitarbeiter
|
||||
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. Weitere Aktionen – aktualisieren, maximieren, duplizieren – liegen im Menü daneben. 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.reset=Layout zurücksetzen
|
||||
gridstack.status=Layout geändert – {0} Widgets
|
||||
@@ -36,6 +36,15 @@ 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
|
||||
|
||||
@@ -22,7 +22,7 @@ card.registration=Registration
|
||||
card.employees=Employees
|
||||
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. More actions – refresh, maximize, duplicate – live in the menu next to it. 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.reset=Reset layout
|
||||
gridstack.status=Layout changed – {0} widgets
|
||||
@@ -36,6 +36,15 @@ 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
|
||||
|
||||
@@ -22,7 +22,7 @@ card.registration=Registro
|
||||
card.employees=Empleados
|
||||
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. Más acciones – actualizar, maximizar, duplicar – están en el menú contiguo. 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.reset=Restablecer diseño
|
||||
gridstack.status=Diseño modificado – {0} widgets
|
||||
@@ -36,6 +36,16 @@ 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
|
||||
|
||||
@@ -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(',');
|
||||
}""");
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,19 @@
|
||||
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;
|
||||
|
||||
/**
|
||||
@@ -40,15 +45,75 @@ class WidgetActionMenuPlaywrightTest extends PlaywrightTestBase {
|
||||
assertEquals(before, slot(item), "the widget must not have moved");
|
||||
}
|
||||
|
||||
/** The other half of it: the grip has to stay reachable next to the menu.
|
||||
* Both sit in the item's top right corner and the menu is painted after
|
||||
* the grip, so a menu wider than its corner slot takes the grip's pointer
|
||||
* events and the widget cannot be dragged at all any more. */
|
||||
@Test
|
||||
void draggingTheGrip_movesTheWidget() {
|
||||
Locator item = widget();
|
||||
String before = slot(item);
|
||||
|
||||
BoundingBox grip = grip(item).boundingBox();
|
||||
double x = grip.x + grip.width / 2;
|
||||
double y = grip.y + grip.height / 2;
|
||||
assertEquals("dialect-drag-handle", topmostClassAt(x, y),
|
||||
"another corner control covers the grip");
|
||||
|
||||
page.mouse().move(x, y);
|
||||
page.mouse().down();
|
||||
// In steps, not one jump: gridstack starts the drag on the first move
|
||||
// past its threshold and places the item off the moves after that, so a
|
||||
// single move would only ever arm the drag.
|
||||
page.mouse().move(x + 240, y + 200, new Mouse.MoveOptions().setSteps(20));
|
||||
page.mouse().up();
|
||||
|
||||
page.waitForCondition(() -> !before.equals(slot(item)));
|
||||
assertNotEquals(before, slot(item), "the widget must have moved");
|
||||
}
|
||||
|
||||
/** The class of the corner control the browser actually hits at that point,
|
||||
* or the topmost element's own class list if it is no corner control. */
|
||||
private String topmostClassAt(double x, double y) {
|
||||
return (String) page.evaluate("""
|
||||
([x, y]) => {
|
||||
const el = document.elementFromPoint(x, y);
|
||||
const control = el?.closest(
|
||||
'.dialect-drag-handle, .dialect-close-button, .dialect-action-menu');
|
||||
return control ? control.className : String(el?.className ?? el);
|
||||
}""", java.util.List.of(x, y));
|
||||
}
|
||||
|
||||
@Test
|
||||
void menuButton_opensTheActions() {
|
||||
menuButton(widget()).click();
|
||||
|
||||
for (String caption : new String[] {"Refresh", "Maximize", "Duplicate", "Remove"}) {
|
||||
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();
|
||||
@@ -87,6 +152,10 @@ class WidgetActionMenuPlaywrightTest extends PlaywrightTestBase {
|
||||
}""");
|
||||
}
|
||||
|
||||
private Locator grip(Locator item) {
|
||||
return item.locator("> .dialect-drag-handle");
|
||||
}
|
||||
|
||||
private Locator menuButton(Locator item) {
|
||||
return item.locator(".dialect-action-menu vaadin-menu-bar-button").first();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
package com.example.e2e;
|
||||
|
||||
import com.microsoft.playwright.Locator;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static com.microsoft.playwright.assertions.PlaywrightAssertions.assertThat;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
/**
|
||||
* End-to-end coverage for the undo-on-close toast (see
|
||||
* {@code DashboardView#offerUndo}): a real browser is what actually confirms
|
||||
* the item lands back in its exact grid cell, gridstack animation included.
|
||||
*/
|
||||
class WidgetUndoClosePlaywrightTest extends PlaywrightTestBase {
|
||||
|
||||
@BeforeEach
|
||||
void openDashboard() {
|
||||
navigate("");
|
||||
}
|
||||
|
||||
@Test
|
||||
void close_thenUndo_restoresTheExactSlot() {
|
||||
Locator item = widget();
|
||||
String before = slot(item);
|
||||
|
||||
closeButton(item).click();
|
||||
assertThat(widget()).not().isAttached();
|
||||
|
||||
undoButton().click();
|
||||
|
||||
Locator restored = widget();
|
||||
assertThat(restored).isAttached();
|
||||
page.waitForCondition(() -> before.equals(slot(restored)));
|
||||
assertEquals(before, slot(restored), "undo must restore the exact position and size");
|
||||
}
|
||||
|
||||
/** Targeted by its stable {@code gs-id} rather than by position: closing it
|
||||
* changes which item a positional selector would resolve to first. */
|
||||
private Locator widget() {
|
||||
return page.locator(".grid-stack-item[gs-id='revenue-trend']");
|
||||
}
|
||||
|
||||
private Locator closeButton(Locator item) {
|
||||
return item.locator("> .dialect-close-button");
|
||||
}
|
||||
|
||||
private Locator undoButton() {
|
||||
return page.locator("vaadin-notification-card").getByRole(
|
||||
com.microsoft.playwright.options.AriaRole.BUTTON,
|
||||
new Locator.GetByRoleOptions().setName("Undo"));
|
||||
}
|
||||
|
||||
/** The item's box measured against the grid, not the viewport — same as
|
||||
* {@code WidgetActionMenuPlaywrightTest#slot}. */
|
||||
private String slot(Locator item) {
|
||||
return (String) item.evaluate("""
|
||||
el => {
|
||||
const grid = el.closest('.grid-stack').getBoundingClientRect();
|
||||
const box = el.getBoundingClientRect();
|
||||
return [box.left - grid.left, box.top - grid.top, box.width, box.height]
|
||||
.map(Math.round).join(',');
|
||||
}""");
|
||||
}
|
||||
}
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
@@ -12,15 +12,20 @@ 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;
|
||||
@@ -263,6 +268,114 @@ class DashboardViewTest extends SpringBrowserlessTest {
|
||||
assertEquals(DEFAULT_WIDGETS - 1, grid.getLayout().size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void closingAWidget_showsAnUndoToast() {
|
||||
navigate(DashboardView.class);
|
||||
|
||||
clickCloseButton(itemById("revenue-trend"));
|
||||
|
||||
Notification toast = $(Notification.class).first();
|
||||
assertNotNull(toast, "closing a widget must offer a way back");
|
||||
assertNotNull($(Button.class).from(toast).withText(translate("gridstack.undo")).first());
|
||||
}
|
||||
|
||||
@Test
|
||||
void undo_restoresTheWidgetAtItsOriginalPositionAndId() {
|
||||
navigate(DashboardView.class);
|
||||
GridStackLayout grid = $view(GridStackLayout.class).first();
|
||||
GridStackItem.Position before = positionOf("revenue-trend");
|
||||
|
||||
clickCloseButton(itemById("revenue-trend"));
|
||||
assertEquals(DEFAULT_WIDGETS - 1, grid.getLayout().size());
|
||||
|
||||
clickUndo();
|
||||
|
||||
assertEquals(DEFAULT_WIDGETS, grid.getLayout().size());
|
||||
assertEquals(before, positionOf("revenue-trend"),
|
||||
"undo must restore the exact position and size, not the next free slot");
|
||||
assertEquals(1, $view(LineChart.class).all().size(), "the chart itself is back too");
|
||||
}
|
||||
|
||||
/** Closing the same widget twice in a row — once, undoing, then again —
|
||||
* must still offer an undo the second time: the close listener is
|
||||
* registered once per item, not once per (re-)add. */
|
||||
@Test
|
||||
void undoneWidget_offersAnUndoAgainWhenClosedAnotherTime() {
|
||||
navigate(DashboardView.class);
|
||||
|
||||
clickCloseButton(itemById("revenue-trend"));
|
||||
clickUndo();
|
||||
clickCloseButton(itemById("revenue-trend"));
|
||||
|
||||
assertNotNull($(Button.class).from($(Notification.class).first())
|
||||
.withText(translate("gridstack.undo")).first());
|
||||
}
|
||||
|
||||
/** A close triggered from code (not the button or the menu) is not a
|
||||
* misclick, so it must not spawn a toast the user never asked for. */
|
||||
@Test
|
||||
void programmaticClose_doesNotShowAnUndoToast() {
|
||||
navigate(DashboardView.class);
|
||||
|
||||
itemById("revenue-trend").close();
|
||||
|
||||
assertTrue($(Notification.class).all().isEmpty(),
|
||||
"a programmatic close must not offer an undo");
|
||||
}
|
||||
|
||||
private void clickUndo() {
|
||||
$(Button.class).from($(Notification.class).first())
|
||||
.withText(translate("gridstack.undo")).first().click();
|
||||
}
|
||||
|
||||
@Test
|
||||
void closingEveryWidget_showsEmptyStateAndHidesTheGrid() {
|
||||
navigate(DashboardView.class);
|
||||
GridStackLayout grid = $view(GridStackLayout.class).first();
|
||||
|
||||
closeAllWidgets();
|
||||
|
||||
assertTrue(grid.getLayout().isEmpty(), "no widget should be left");
|
||||
assertFalse(grid.isVisible(), "the empty grid must not show as an empty box");
|
||||
Div emptyState = $view(Div.class).withClassName("dialect-empty-state").first();
|
||||
assertTrue(emptyState.isVisible(), "the empty state must appear");
|
||||
}
|
||||
|
||||
@Test
|
||||
void addingAWidget_hidesTheEmptyStateAgain() {
|
||||
navigate(DashboardView.class);
|
||||
closeAllWidgets();
|
||||
|
||||
button("gridstack.addWidget").click();
|
||||
pickWidget("card.revenueByRegion");
|
||||
|
||||
assertFalse($view(Div.class).withClassName("dialect-empty-state").exists(),
|
||||
"the empty state must be gone once a widget is back — invisible components drop out of the query");
|
||||
assertTrue($view(GridStackLayout.class).first().isVisible());
|
||||
}
|
||||
|
||||
@Test
|
||||
void restoreDefaultLayout_rebuildsTheDefaultWidgetsFromTheEmptyState() {
|
||||
navigate(DashboardView.class);
|
||||
closeAllWidgets();
|
||||
|
||||
button("gridstack.restoreDefaults").click();
|
||||
|
||||
GridStackLayout grid = $view(GridStackLayout.class).first();
|
||||
assertEquals(DEFAULT_WIDGETS, grid.getLayout().size());
|
||||
assertFalse($view(Div.class).withClassName("dialect-empty-state").exists(),
|
||||
"the empty state must be gone once the default layout is back");
|
||||
assertTrue(grid.isVisible());
|
||||
}
|
||||
|
||||
private void closeAllWidgets() {
|
||||
List<GridStackItem> items = $view(GridStackItem.class).all();
|
||||
while (!items.isEmpty()) {
|
||||
clickCloseButton(items.getFirst());
|
||||
items = $view(GridStackItem.class).all();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void nonClosableItem_hasNoCloseButton() {
|
||||
navigate(DashboardView.class);
|
||||
@@ -336,6 +449,70 @@ class DashboardViewTest extends SpringBrowserlessTest {
|
||||
"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
|
||||
|
||||
Reference in New Issue
Block a user