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"); } }