fix: export widget data as CSV from the action menu (#28)
CI / build-and-test (pull_request) Successful in 2m26s
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:
@@ -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("^-|-$", "");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user