feat: export widget data as CSV (#28) #38
@@ -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 -> {
|
// A pie has no series name — its categories are the slice labels.
|
||||||
ChartSeries series = dataService.revenueByRegion(filter);
|
return bind(chart, context, dataService::revenueByRegion, series ->
|
||||||
// A pie has no series name — its categories are the slice labels.
|
chart.updateData(series.values(), translate(chart, series.categoryKeys())));
|
||||||
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
|
||||||
|
|||||||
Reference in New Issue
Block a user