fix: export widget data as CSV from the action menu (#28)
CI / build-and-test (pull_request) Successful in 2m26s

The ApexCharts toolbar stays hidden (DialectTheme), so the dashboard had no
export at all. Adds an "Als CSV exportieren" entry to the per-widget action
menu, served server-side from the same data the widget renders.

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0124BiJikbhbiEsNfJxdWM69
This commit is contained in:
Pit Friedrich
2026-07-28 21:45:40 +02:00
parent bb9b89a8d8
commit cfa644c0e1
12 changed files with 439 additions and 20 deletions
@@ -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) {