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}).
*
* 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.
*
* 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 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 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.
*
* 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.
*
* A download is served on a request of its own, outside the session lock
* and without a current {@code UI} (see
* {@code StreamRequestHandler#callElementResourceHandler}), so the lock is
* taken for the read and the locale is passed explicitly.
*/
private DownloadHandler csvDownload(WidgetDefinition definition, Component content) {
return DownloadHandler.fromInputStream(event -> {
VaadinSession session = event.getSession();
session.lock();
Locale locale;
String fileName;
Optional table;
try {
locale = event.getUI().getLocale();
table = WidgetRegistry.export(content, locale);
fileName = CsvExport.fileName(getTranslation(locale, definition.titleKey()),
getTranslation(locale, context.getFilter().period().labelKey()));
} finally {
session.unlock();
}
if (table.isEmpty()) {
return DownloadResponse.error(HttpStatusCode.NOT_FOUND);
}
byte[] csv = table.get().toBytes(locale);
return new DownloadResponse(new ByteArrayInputStream(csv), fileName,
"text/csv;charset=utf-8", csv.length);
});
}
/** Re-feeds a single tile from the current filter — the action menu's
* refresh, which asks for one widget, not for the dashboard. */
private void refreshKpiTile(String kpiId) {
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");
}
}