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
@@ -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("^-|-$", "");
}
}