feat: export widget data as CSV (#28) #38

Merged
pitfriedrich merged 1 commits from ai/issue-28-widget-csv-export into main 2026-07-28 19:49:21 +00:00
12 changed files with 439 additions and 20 deletions
Showing only changes of commit cfa644c0e1 - Show all commits
@@ -31,6 +31,7 @@ public enum Fa {
REFRESH("fa-solid", "fa-arrows-rotate"), REFRESH("fa-solid", "fa-arrows-rotate"),
MAXIMIZE("fa-solid", "fa-expand"), MAXIMIZE("fa-solid", "fa-expand"),
DUPLICATE("fa-solid", "fa-clone"), DUPLICATE("fa-solid", "fa-clone"),
EXPORT("fa-solid", "fa-file-csv"),
TREND_UP("fa-solid", "fa-arrow-trend-up"), TREND_UP("fa-solid", "fa-arrow-trend-up"),
TREND_DOWN("fa-solid", "fa-arrow-trend-down"); 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.ComponentEvent;
import com.vaadin.flow.component.ComponentEventListener; import com.vaadin.flow.component.ComponentEventListener;
import com.vaadin.flow.component.contextmenu.MenuItem; 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.Div;
import com.vaadin.flow.component.html.Span; import com.vaadin.flow.component.html.Span;
import com.vaadin.flow.component.menubar.MenuBar; import com.vaadin.flow.component.menubar.MenuBar;
import com.vaadin.flow.component.menubar.MenuBarVariant; import com.vaadin.flow.component.menubar.MenuBarVariant;
import com.vaadin.flow.server.streams.DownloadHandler;
import com.vaadin.flow.shared.Registration; import com.vaadin.flow.shared.Registration;
import java.util.Collections; 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 * and {@link Action#REMOVE} are handled here, the rest is only reported to
* {@link #addActionListener(ComponentEventListener) action listeners} — what * {@link #addActionListener(ComponentEventListener) action listeners} — what
* "refresh" or "duplicate" means depends on the widget, which this component * "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 { public class GridStackItem extends Div {
@@ -51,6 +56,12 @@ public class GridStackItem extends Div {
* button's. */ * button's. */
public static final String ACTION_MENU_CLASS = "dialect-action-menu"; 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 /** Class set on the item itself while it is
* {@link #setMaximized(boolean) maximized} — {@code styles.css} is what * {@link #setMaximized(boolean) maximized} — {@code styles.css} is what
* actually lifts the item out of the grid. */ * actually lifts the item out of the grid. */
@@ -66,6 +77,10 @@ public class GridStackItem extends Div {
MAXIMIZE, MAXIMIZE,
/** Add another widget of the same kind. Reported only. */ /** Add another widget of the same kind. Reported only. */
DUPLICATE, 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. */ /** Same as the close button, from the menu. Handled here. */
REMOVE REMOVE
} }
@@ -80,6 +95,7 @@ public class GridStackItem extends Div {
private final MenuBar actionMenu = new MenuBar(); private final MenuBar actionMenu = new MenuBar();
private final Map<Action, MenuItem> actionItems = new EnumMap<>(Action.class); private final Map<Action, MenuItem> actionItems = new EnumMap<>(Action.class);
private final Map<Action, Span> actionCaptions = 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 closable = true;
private boolean maximized; private boolean maximized;
@@ -151,6 +167,7 @@ public class GridStackItem extends Div {
addActionItem(root, Action.REFRESH, Fa.REFRESH, "gridstack.refresh"); addActionItem(root, Action.REFRESH, Fa.REFRESH, "gridstack.refresh");
addActionItem(root, Action.MAXIMIZE, Fa.MAXIMIZE, "gridstack.maximize"); addActionItem(root, Action.MAXIMIZE, Fa.MAXIMIZE, "gridstack.maximize");
addActionItem(root, Action.DUPLICATE, Fa.DUPLICATE, "gridstack.duplicate"); addActionItem(root, Action.DUPLICATE, Fa.DUPLICATE, "gridstack.duplicate");
addActionItem(root, Action.EXPORT, Fa.EXPORT, "gridstack.export");
addActionItem(root, Action.REMOVE, Fa.REMOVE, "gridstack.remove"); addActionItem(root, Action.REMOVE, Fa.REMOVE, "gridstack.remove");
setActions(DEFAULT_ACTIONS); setActions(DEFAULT_ACTIONS);
@@ -189,6 +206,36 @@ public class GridStackItem extends Div {
return this; 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 /** Shows or hides a single menu entry — the widget types that support an
* action differ, the menu does not. */ * action differ, the menu does not. */
public GridStackItem setActionEnabled(Action action, boolean enabled) { public GridStackItem setActionEnabled(Action action, boolean enabled) {
@@ -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.ChartDataService;
import com.example.data.DashboardFilter; import com.example.data.DashboardFilter;
import com.example.data.KpiData; import com.example.data.KpiData;
import com.example.export.CsvExport;
import com.example.widgets.DashboardContext; import com.example.widgets.DashboardContext;
import com.example.widgets.WidgetDefinition; import com.example.widgets.WidgetDefinition;
import com.example.widgets.WidgetRegistry; 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.component.orderedlayout.VerticalLayout;
import com.vaadin.flow.router.HasDynamicTitle; import com.vaadin.flow.router.HasDynamicTitle;
import com.vaadin.flow.router.Route; 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.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Locale;
import java.util.Map; import java.util.Map;
import java.util.Optional;
/** /**
* The dashboard: a {@link GridStackLayout} of draggable/resizable cards whose * 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); Component content = definition.factory().apply(context);
GridStackItem item = new GridStackItem(id, x, y, w, h, GridStackItem item = new GridStackItem(id, x, y, w, h,
new Card(getTranslation(definition.titleKey()), content)); 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 -> { item.addActionListener(e -> {
switch (e.getAction()) { switch (e.getAction()) {
case REFRESH -> WidgetRegistry.refresh(content); case REFRESH -> WidgetRegistry.refresh(content);
@@ -183,6 +193,41 @@ public class DashboardView extends VerticalLayout implements HasDynamicTitle {
return item; 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 /** Re-feeds a single tile from the current filter — the action menu's
* refresh, which asks for one widget, not for the dashboard. */ * refresh, which asks for one widget, not for the dashboard. */
private void refreshKpiTile(String kpiId) { private void refreshKpiTile(String kpiId) {
@@ -8,6 +8,7 @@ import com.example.components.PieChart;
import com.example.data.ChartDataService; import com.example.data.ChartDataService;
import com.example.data.ChartSeries; import com.example.data.ChartSeries;
import com.example.data.DashboardFilter; import com.example.data.DashboardFilter;
import com.example.export.CsvExport;
import com.vaadin.flow.component.Component; import com.vaadin.flow.component.Component;
import com.vaadin.flow.component.ComponentUtil; import com.vaadin.flow.component.ComponentUtil;
import com.vaadin.flow.component.notification.Notification; import com.vaadin.flow.component.notification.Notification;
@@ -16,8 +17,11 @@ import org.springframework.stereotype.Service;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Locale;
import java.util.Map; import java.util.Map;
import java.util.Optional;
import java.util.function.Consumer; import java.util.function.Consumer;
import java.util.function.Function;
/** /**
* The widget types a dashboard can show. Holding them here rather than inline in * 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. */ /** Adds a definition, replacing any earlier one of the same type. */
public final void register(WidgetDefinition definition) { public final void register(WidgetDefinition definition) {
definitions.put(definition.type(), definition); definitions.put(definition.type(), definition);
@@ -98,22 +124,18 @@ public class WidgetRegistry {
private Component axisChart(AxisChart chart, DashboardContext context) { private Component axisChart(AxisChart chart, DashboardContext context) {
chart.addPointClickListener(e -> Notification.show(chart.getTranslation( chart.addPointClickListener(e -> Notification.show(chart.getTranslation(
"chart.pointClick", e.getSeriesIndex(), e.getDataPointIndex()))); "chart.pointClick", e.getSeriesIndex(), e.getDataPointIndex())));
return bind(chart, context, filter -> { return bind(chart, context, dataService::revenueByMonth, series ->
ChartSeries series = dataService.revenueByMonth(filter);
chart.updateData(chart.getTranslation(series.nameKey()), series.values(), chart.updateData(chart.getTranslation(series.nameKey()), series.values(),
translate(chart, series.categoryKeys())); translate(chart, series.categoryKeys())));
});
} }
private Component pieChart(DashboardContext context) { private Component pieChart(DashboardContext context) {
PieChart chart = new PieChart(); PieChart chart = new PieChart();
chart.addPointClickListener(e -> Notification.show( chart.addPointClickListener(e -> Notification.show(
chart.getTranslation("chart.sliceClick", e.getDataPointIndex()))); 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. // 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> * <p>
* The first feed goes through the same {@code updateData} path as later * 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. * 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, private Component bind(ApexChart chart, DashboardContext context,
Consumer<DashboardFilter> feed) { Function<DashboardFilter, ChartSeries> query,
feed.accept(context.getFilter()); Consumer<ChartSeries> feed) {
Registration registration = context.addFilterChangeListener(feed); Consumer<DashboardFilter> render = filter -> feed.accept(query.apply(filter));
render.accept(context.getFilter());
Registration registration = context.addFilterChangeListener(render);
chart.addDetachListener(e -> registration.remove()); chart.addDetachListener(e -> registration.remove());
ComponentUtil.setData(chart, Refresh.class, 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); 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 /** 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 * resizing a widget resizes the chart (grid-stack.ts fires a window
* resize on resizestop, which ApexCharts reflows on). */ * resize on resizestop, which ApexCharts reflows on). */
@@ -336,6 +336,16 @@ apex-chart.dialect-sparkline .apexcharts-xaxis {
opacity: 0.75; 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, .grid-stack-item:hover > .dialect-close-button,
.dialect-close-button:focus-visible { .dialect-close-button:focus-visible {
opacity: 0.65; opacity: 0.65;
@@ -22,7 +22,7 @@ card.registration=Registrierung
card.employees=Mitarbeiter card.employees=Mitarbeiter
card.gridstackHint=Bedienung 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.addWidget=Widget hinzufügen
gridstack.reset=Layout zurücksetzen gridstack.reset=Layout zurücksetzen
gridstack.status=Layout geändert {0} Widgets gridstack.status=Layout geändert {0} Widgets
@@ -36,6 +36,10 @@ gridstack.maximize=Maximieren
gridstack.restore=Wiederherstellen gridstack.restore=Wiederherstellen
gridstack.duplicate=Duplizieren gridstack.duplicate=Duplizieren
gridstack.remove=Entfernen 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=Zeitraum
filter.period.month=Monat filter.period.month=Monat
@@ -22,7 +22,7 @@ card.registration=Registration
card.employees=Employees card.employees=Employees
card.gridstackHint=How it works 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.addWidget=Add widget
gridstack.reset=Reset layout gridstack.reset=Reset layout
gridstack.status=Layout changed {0} widgets gridstack.status=Layout changed {0} widgets
@@ -36,6 +36,10 @@ gridstack.maximize=Maximize
gridstack.restore=Restore gridstack.restore=Restore
gridstack.duplicate=Duplicate gridstack.duplicate=Duplicate
gridstack.remove=Remove 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=Period
filter.period.month=Month filter.period.month=Month
@@ -22,7 +22,7 @@ card.registration=Registro
card.employees=Empleados card.employees=Empleados
card.gridstackHint=Cómo funciona 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.addWidget=Añadir widget
gridstack.reset=Restablecer diseño gridstack.reset=Restablecer diseño
gridstack.status=Diseño modificado {0} widgets gridstack.status=Diseño modificado {0} widgets
@@ -36,6 +36,11 @@ gridstack.maximize=Maximizar
gridstack.restore=Restaurar gridstack.restore=Restaurar
gridstack.duplicate=Duplicar gridstack.duplicate=Duplicar
gridstack.remove=Eliminar 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=Periodo
filter.period.month=Mes filter.period.month=Mes
@@ -1,10 +1,13 @@
package com.example.e2e; package com.example.e2e;
import com.microsoft.playwright.Download;
import com.microsoft.playwright.Locator; import com.microsoft.playwright.Locator;
import com.microsoft.playwright.options.BoundingBox; import com.microsoft.playwright.options.BoundingBox;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.regex.Pattern; import java.util.regex.Pattern;
import static com.microsoft.playwright.assertions.PlaywrightAssertions.assertThat; import static com.microsoft.playwright.assertions.PlaywrightAssertions.assertThat;
@@ -44,11 +47,32 @@ class WidgetActionMenuPlaywrightTest extends PlaywrightTestBase {
void menuButton_opensTheActions() { void menuButton_opensTheActions() {
menuButton(widget()).click(); 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(); 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 @Test
void maximize_expandsTheWidgetAndRestoresItsExactPosition() { void maximize_expandsTheWidgetAndRestoresItsExactPosition() {
Locator item = widget(); Locator item = widget();
@@ -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,14 +12,18 @@ import com.example.data.ChartDataService;
import com.example.data.DashboardFilter; import com.example.data.DashboardFilter;
import com.example.data.DashboardFilter.Period; import com.example.data.DashboardFilter.Period;
import com.example.data.KpiData; import com.example.data.KpiData;
import com.example.export.CsvExport;
import com.example.widgets.WidgetDefinition; import com.example.widgets.WidgetDefinition;
import com.example.widgets.WidgetRegistry; import com.example.widgets.WidgetRegistry;
import com.vaadin.browserless.SpringBrowserlessTest; import com.vaadin.browserless.SpringBrowserlessTest;
import com.vaadin.browserless.ViewPackages; import com.vaadin.browserless.ViewPackages;
import com.vaadin.browserless.internal.ElementUtilsKt; 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.button.Button;
import com.vaadin.flow.component.contextmenu.MenuItem; import com.vaadin.flow.component.contextmenu.MenuItem;
import com.vaadin.flow.component.dialog.Dialog; 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.html.Div;
import com.vaadin.flow.dom.DomEvent; import com.vaadin.flow.dom.DomEvent;
import com.vaadin.flow.internal.JacksonUtils; 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 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 /** The tile is fed from the service, so overwriting its value and asking for
* a refresh must put the real number back. */ * a refresh must put the real number back. */
@Test @Test