diff --git a/src/main/java/com/example/components/Fa.java b/src/main/java/com/example/components/Fa.java index 8c52505..cd8662e 100644 --- a/src/main/java/com/example/components/Fa.java +++ b/src/main/java/com/example/components/Fa.java @@ -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"); diff --git a/src/main/java/com/example/components/GridStackItem.java b/src/main/java/com/example/components/GridStackItem.java index d4130cb..e267351 100644 --- a/src/main/java/com/example/components/GridStackItem.java +++ b/src/main/java/com/example/components/GridStackItem.java @@ -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 actionItems = new EnumMap<>(Action.class); private final Map actionCaptions = new EnumMap<>(Action.class); + private final Map 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). + *

+ * 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) { diff --git a/src/main/java/com/example/export/CsvExport.java b/src/main/java/com/example/export/CsvExport.java new file mode 100644 index 0000000..64eb338 --- /dev/null +++ b/src/main/java/com/example/export/CsvExport.java @@ -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. + *

+ * 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 categories, List 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("^-|-$", ""); + } +} diff --git a/src/main/java/com/example/views/DashboardView.java b/src/main/java/com/example/views/DashboardView.java index 2a1eb9e..ebe6a09 100644 --- a/src/main/java/com/example/views/DashboardView.java +++ b/src/main/java/com/example/views/DashboardView.java @@ -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; @@ -22,10 +23,17 @@ 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 @@ -172,7 +180,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 +193,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. + *

+ * 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 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) { diff --git a/src/main/java/com/example/widgets/WidgetRegistry.java b/src/main/java/com/example/widgets/WidgetRegistry.java index 29e1db5..fafb84f 100644 --- a/src/main/java/com/example/widgets/WidgetRegistry.java +++ b/src/main/java/com/example/widgets/WidgetRegistry.java @@ -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. + *

+ * 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 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); - chart.updateData(chart.getTranslation(series.nameKey()), series.values(), - translate(chart, series.categoryKeys())); - }); + return bind(chart, context, dataService::revenueByMonth, series -> + chart.updateData(chart.getTranslation(series.nameKey()), series.values(), + translate(chart, series.categoryKeys()))); } private Component pieChart(DashboardContext context) { PieChart chart = new PieChart(); chart.addPointClickListener(e -> Notification.show( chart.getTranslation("chart.sliceClick", e.getDataPointIndex()))); - 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())); - }); + // A pie has no series name — its categories are the slice labels. + return bind(chart, context, dataService::revenueByRegion, series -> + chart.updateData(series.values(), translate(chart, series.categoryKeys()))); } /** @@ -123,17 +145,36 @@ public class WidgetRegistry { *

* 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. + *

+ * {@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 feed) { - feed.accept(context.getFilter()); - Registration registration = context.addFilterChangeListener(feed); + Function query, + Consumer feed) { + Consumer 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). */ diff --git a/src/main/resources/META-INF/resources/styles.css b/src/main/resources/META-INF/resources/styles.css index bf0c3e8..745b9fe 100644 --- a/src/main/resources/META-INF/resources/styles.css +++ b/src/main/resources/META-INF/resources/styles.css @@ -336,6 +336,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; diff --git a/src/main/resources/vaadin-i18n/translations.properties b/src/main/resources/vaadin-i18n/translations.properties index d4cbff6..31c698e 100644 --- a/src/main/resources/vaadin-i18n/translations.properties +++ b/src/main/resources/vaadin-i18n/translations.properties @@ -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,10 @@ gridstack.maximize=Maximieren gridstack.restore=Wiederherstellen gridstack.duplicate=Duplizieren gridstack.remove=Entfernen +gridstack.export=Als CSV exportieren + +# Spaltenüberschrift der CSV-Exporte; die Wertspalte trägt den Serien-Namen. +export.category=Kategorie filter.period=Zeitraum filter.period.month=Monat diff --git a/src/main/resources/vaadin-i18n/translations_en.properties b/src/main/resources/vaadin-i18n/translations_en.properties index 7b233ee..0ccc49c 100644 --- a/src/main/resources/vaadin-i18n/translations_en.properties +++ b/src/main/resources/vaadin-i18n/translations_en.properties @@ -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,10 @@ gridstack.maximize=Maximize gridstack.restore=Restore gridstack.duplicate=Duplicate gridstack.remove=Remove +gridstack.export=Export as CSV + +# Column header of the CSV exports; the value column carries the series name. +export.category=Category filter.period=Period filter.period.month=Month diff --git a/src/main/resources/vaadin-i18n/translations_es.properties b/src/main/resources/vaadin-i18n/translations_es.properties index 9665412..ba3e5df 100644 --- a/src/main/resources/vaadin-i18n/translations_es.properties +++ b/src/main/resources/vaadin-i18n/translations_es.properties @@ -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,11 @@ gridstack.maximize=Maximizar gridstack.restore=Restaurar gridstack.duplicate=Duplicar gridstack.remove=Eliminar +gridstack.export=Exportar como CSV + +# 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 diff --git a/src/test/java/com/example/e2e/WidgetActionMenuPlaywrightTest.java b/src/test/java/com/example/e2e/WidgetActionMenuPlaywrightTest.java index c80c31d..5f39f1b 100644 --- a/src/test/java/com/example/e2e/WidgetActionMenuPlaywrightTest.java +++ b/src/test/java/com/example/e2e/WidgetActionMenuPlaywrightTest.java @@ -1,10 +1,13 @@ package com.example.e2e; +import com.microsoft.playwright.Download; import com.microsoft.playwright.Locator; 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; @@ -44,11 +47,32 @@ class WidgetActionMenuPlaywrightTest extends PlaywrightTestBase { 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(); diff --git a/src/test/java/com/example/export/CsvExportTest.java b/src/test/java/com/example/export/CsvExportTest.java new file mode 100644 index 0000000..f2f926b --- /dev/null +++ b/src/test/java/com/example/export/CsvExportTest.java @@ -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))); + } +} diff --git a/src/test/java/com/example/views/DashboardViewTest.java b/src/test/java/com/example/views/DashboardViewTest.java index 4539890..dc52cd2 100644 --- a/src/test/java/com/example/views/DashboardViewTest.java +++ b/src/test/java/com/example/views/DashboardViewTest.java @@ -12,14 +12,18 @@ 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.dom.DomEvent; import com.vaadin.flow.internal.JacksonUtils; @@ -336,6 +340,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