Files
chart-app/src/main/java/com/example/views/DashboardView.java
T
Pit Friedrich 7a4bc72799
CI / build-and-test (pull_request) Successful in 2m47s
fix: responsive grid breakpoints for small screens (#41)
Add a columnOpts breakpoint (768px -> 1 column) so widgets stack
full-width on mobile instead of staying at 12 columns. Persisting to
localStorage is skipped while gridstack's own responsive collapse is
active, so narrowing the window can no longer overwrite the saved
desktop layout; drag/resize is disabled below the same breakpoint to
avoid touch misfires.
2026-07-29 07:52:36 +02:00

382 lines
17 KiB
Java

package com.example.views;
import com.example.components.Card;
import com.example.components.Fa;
import com.example.components.GridStackItem;
import com.example.components.GridStackItem.Action;
import com.example.components.GridStackLayout;
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;
import com.vaadin.flow.component.Component;
import com.vaadin.flow.component.button.Button;
import com.vaadin.flow.component.button.ButtonVariant;
import com.vaadin.flow.component.dialog.Dialog;
import com.vaadin.flow.component.html.Div;
import com.vaadin.flow.component.html.Paragraph;
import com.vaadin.flow.component.html.Span;
import com.vaadin.flow.component.notification.Notification;
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
* layout is persisted per browser, plus controls to add widgets and reset the
* layout at runtime. Widgets are removed by their own close button (see
* {@link GridStackItem#setClosable(boolean)}), not from the toolbar, and are
* added back from the picker over {@link WidgetRegistry} — or undone straight
* from the toast every close spawns (see {@link #offerUndo}).
* <p>
* All widget numbers come from {@link ChartDataService} and every chart widget
* is built by the registry; this view only decides where a widget sits and
* resolves the translation keys it is handed against the bundle.
* <p>
* A {@link DashboardFilterBar} above the grid drives every widget through one
* {@link DashboardContext}: the charts subscribe to it themselves (in the
* registry), the KPI tiles are re-fed here, since this view is what built them.
* The filter is view state and is not persisted — see {@link DashboardContext}.
*/
@Route("")
public class DashboardView extends VerticalLayout implements HasDynamicTitle {
private static final String STORAGE_KEY = "dashboard";
/** Matches gridstack's {@code columnOpts} breakpoint below which the grid
* stacks to a single column (see {@link GridStackLayout#setResponsiveBreakpoint}). */
private static final int MOBILE_BREAKPOINT_PX = 768;
/** A KPI tile is a quarter row wide, so the n-th one starts at 3n. */
private static final int KPI_WIDTH = 3;
private final ChartDataService dataService;
private final WidgetRegistry widgets;
private final DashboardContext context = new DashboardContext();
private final GridStackLayout grid = new GridStackLayout();
private final Span status = new Span();
/** Shown instead of {@link #grid} once every widget has been closed; not a
* {@link GridStackItem} itself, so it never becomes draggable and never
* shows up in {@link GridStackLayout#getLayout()}. */
private final Div emptyState = new Div();
/** The KPI tiles by KPI id, so a filter change re-feeds each tile with the
* data of the same KPI rather than by position. */
private final Map<String, KpiTile> kpiTiles = new LinkedHashMap<>();
private int extraWidgetCount;
public DashboardView(ChartDataService dataService, WidgetRegistry widgets) {
this.dataService = dataService;
this.widgets = widgets;
addClassName("dialect-content");
grid.setWidthFull();
grid.setStorageKey(STORAGE_KEY);
// Below phone/small-tablet width, stack every widget full-width.
grid.setResponsiveBreakpoint(MOBILE_BREAKPOINT_PX, 1);
grid.addLayoutChangeListener(e -> status.setText(
getTranslation("gridstack.status", e.getPositions().size())));
configureEmptyState();
buildDefaultWidgets();
context.addFilterChangeListener(this::updateKpiTiles);
status.setText(getTranslation("gridstack.statusInitial"));
status.addClassName("dialect-muted");
add(toolbar(), new DashboardFilterBar(context), grid, emptyState);
}
/** The dashboard's initial widget set: the KPI tiles first — the numbers a
* dashboard is read for, above the charts that explain them — then the
* three default charts and the usage hint. Also used to rebuild the
* dashboard from scratch via {@link #restoreDefaultWidgets()}. */
private void buildDefaultWidgets() {
// They are 3x1 — a quarter row each, one cell high, laid out left to
// right in the order the service returns them. The grid id is the
// KPI's own id, so it survives reordering.
List<KpiData> kpis = dataService.kpis(context.getFilter());
for (int i = 0; i < kpis.size(); i++) {
KpiData kpi = kpis.get(i);
KpiTile tile = feed(new KpiTile(getTranslation(kpi.labelKey()), ""), kpi);
kpiTiles.put(kpi.id(), tile);
GridStackItem item = new GridStackItem(kpi.id(), i * KPI_WIDTH, 0, KPI_WIDTH, 1, tile);
// A tile can be refreshed (this view feeds it) but not duplicated:
// a second copy of the same KPI would be the same number twice.
item.setActionEnabled(Action.REFRESH, true);
item.addActionListener(e -> {
if (e.getAction() == Action.REFRESH) {
refreshKpiTile(kpi.id());
}
});
addWidgetToGrid(item);
}
List.of(
defaultWidget(WidgetRegistry.REVENUE_TREND, 0, 1, 6, 3),
defaultWidget(WidgetRegistry.REVENUE_MONTH, 6, 1, 6, 3),
defaultWidget(WidgetRegistry.REVENUE_REGION, 0, 4, 5, 3),
new GridStackItem("hint", 5, 4, 7, 3,
new Card(getTranslation("card.gridstackHint"),
new Paragraph(getTranslation("gridstack.hint")))))
.forEach(this::addWidgetToGrid);
}
/** Builds the placeholder shown once every widget has been closed: a short
* explanation, a CTA that opens the same {@link #openWidgetPicker() widget
* picker} as the toolbar, and a secondary action that rebuilds the default
* layout — {@link GridStackLayout#resetLayout()} alone cannot do that here,
* since it only repositions widgets still present, and none are left. */
private void configureEmptyState() {
emptyState.addClassName("dialect-empty-state");
var icon = Fa.GRID.create();
icon.addClassName("dialect-empty-state__icon");
Span title = new Span(getTranslation("gridstack.emptyTitle"));
title.addClassName("dialect-empty-state__title");
Span hint = new Span(getTranslation("gridstack.emptyHint"));
hint.addClassName("dialect-muted");
Button add = new Button(getTranslation("gridstack.addWidget"), Fa.ADD.create(),
e -> openWidgetPicker());
add.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
Button restore = new Button(getTranslation("gridstack.restoreDefaults"), Fa.RESET.create(),
e -> restoreDefaultWidgets());
HorizontalLayout actions = new HorizontalLayout(add, restore);
actions.addClassName("dialect-empty-state__actions");
emptyState.add(icon, title, hint, actions);
}
/** Clears the stale KPI tile references and rebuilds the initial widget set
* — the empty state's secondary action. */
private void restoreDefaultWidgets() {
kpiTiles.clear();
buildDefaultWidgets();
grid.resetLayout();
}
/** Toggles {@link #grid} and {@link #emptyState} based on whether any
* widget is left — called from every path that adds or removes one. */
private void updateEmptyState() {
boolean empty = grid.getLayout().isEmpty();
emptyState.setVisible(empty);
grid.setVisible(!empty);
}
private HorizontalLayout toolbar() {
Button add = new Button(getTranslation("gridstack.addWidget"), Fa.ADD.create(),
e -> openWidgetPicker());
add.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
Button reset = new Button(getTranslation("gridstack.reset"), Fa.RESET.create(),
e -> grid.resetLayout());
HorizontalLayout toolbar = new HorizontalLayout(add, reset, status);
toolbar.setPadding(false);
toolbar.setWidthFull();
toolbar.setAlignItems(Alignment.CENTER);
return toolbar;
}
/** Lists the registered widget types; picking one adds a widget of that
* type, which is also how a closed widget is brought back. */
private void openWidgetPicker() {
Dialog picker = new Dialog(getTranslation("gridstack.pickerTitle"));
VerticalLayout choices = new VerticalLayout();
choices.setPadding(false);
for (WidgetDefinition definition : widgets.definitions()) {
Button choice = new Button(getTranslation(definition.titleKey()), e -> {
addWidget(definition);
picker.close();
});
choice.setWidthFull();
choices.add(choice);
}
picker.add(choices);
picker.getFooter().add(new Button(getTranslation("form.cancel"), e -> picker.close()));
picker.open();
}
/** Adds a widget at the top left in its type's default size; gridstack
* floats it down into the first free slot. */
private void addWidget(WidgetDefinition definition) {
// The id must stay stable across reloads for the saved layout to match
// it again, so it is derived from a counter rather than a random UUID.
// The counter only ever grows, and it is shared across types: closing a
// widget must not hand its id to the next one, or the new widget would
// inherit the closed one's saved position.
extraWidgetCount++;
addWidgetToGrid(widget(definition.type() + "-" + extraWidgetCount, definition,
0, 0, definition.width(), definition.height()));
}
/** Adds an item to the grid and wires the undo toast onto its close — every
* widget goes through here exactly once, so the close listener (and thus
* the undo offer) is registered once per item, not once per re-add: an
* undo hands the very same instance back to {@link #grid} directly. */
private void addWidgetToGrid(GridStackItem item) {
item.addCloseListener(this::offerUndo);
grid.add(item);
updateEmptyState();
}
/**
* Closing a widget is one misclick away from losing its position, and a
* confirmation dialog on every close would be worse than the problem — so
* this shows a dismissible undo toast instead. Skipped for programmatic
* closes ({@link GridStackItem#close()}, e.g. from duplicate cleanup),
* which is exactly what {@link GridStackItem.CloseEvent#isFromClient()}
* tells apart.
* <p>
* Undo re-adds the very item that was closed — not a rebuilt copy — at the
* {@link GridStackItem.Position} the event captured before detaching, so it
* keeps its {@code gs-id} and its exact size/position rather than landing
* in the grid's next free slot.
*/
private void offerUndo(GridStackItem.CloseEvent event) {
updateEmptyState();
if (!event.isFromClient()) {
return;
}
GridStackItem item = event.getSource();
GridStackItem.Position position = event.getPosition();
Notification toast = new Notification();
toast.setDuration(8000);
toast.setPosition(Notification.Position.BOTTOM_START);
Button undo = new Button(getTranslation("gridstack.undo"), e -> {
item.setPosition(position.x(), position.y(), position.w(), position.h());
grid.add(item);
updateEmptyState();
toast.close();
});
undo.addThemeVariants(ButtonVariant.LUMO_TERTIARY_INLINE);
HorizontalLayout content = new HorizontalLayout(
new Span(getTranslation("gridstack.closed")), undo);
content.setAlignItems(Alignment.CENTER);
toast.add(content);
toast.open();
}
/** A widget of the initial set. Its id is the plain type — never handed out
* again by {@link #addWidget(WidgetDefinition)}, which always suffixes a
* counter — and it keeps the dashboard's own placement rather than the
* type's default size. */
private GridStackItem defaultWidget(String type, int x, int y, int w, int h) {
return widget(type, widgets.require(type), x, y, w, h);
}
/** Registry widgets support the whole action menu: they know how to
* re-request their data, and the definition they were built from is what
* duplicating one needs. */
private GridStackItem widget(String id, WidgetDefinition definition,
int x, int y, int w, int h) {
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.EXPORT, Action.REMOVE);
item.setActionDownload(Action.EXPORT, csvDownload(definition, content));
item.addActionListener(e -> {
switch (e.getAction()) {
case REFRESH -> WidgetRegistry.refresh(content);
case DUPLICATE -> addWidget(definition);
default -> { }
}
});
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
* refresh, which asks for one widget, not for the dashboard. */
private void refreshKpiTile(String kpiId) {
dataService.kpis(context.getFilter()).stream()
.filter(kpi -> kpiId.equals(kpi.id()))
.findFirst()
.ifPresent(kpi -> feed(kpiTiles.get(kpi.id()), kpi));
}
/** Re-feeds the tiles still on the dashboard. A closed tile keeps its entry
* in the map — the same KPI can be added back — but is detached, so
* feeding it would queue a client call for a chart that is not there. */
private void updateKpiTiles(DashboardFilter filter) {
for (KpiData kpi : dataService.kpis(filter)) {
KpiTile tile = kpiTiles.get(kpi.id());
if (tile != null && tile.isAttached()) {
feed(tile, kpi);
}
}
}
/** The value's unit and number pattern come from the bundle, since they are
* locale-specific (decimal separator, currency, "Mio."); the service
* supplies only the number, which depends on the filter. */
private KpiTile feed(KpiTile tile, KpiData kpi) {
return tile.setValue(getTranslation(kpi.valueKey(), kpi.value()))
.setDelta(kpi.deltaPercent())
.setSparkline(tile.getLabel(), kpi.trend());
}
@Override
public String getPageTitle() {
return getTranslation("page.dashboard");
}
}