Compare commits

..

2 Commits

Author SHA1 Message Date
pitfriedrich 1b846751dd Merge pull request 'refactor: merge DashboardView and GridStackView into a single dashboard (#19)' (#31) from ai/issue-19-merge-dashboard into main
Reviewed-on: #31
2026-07-28 17:15:44 +00:00
Pit Friedrich 487c206411 fix: merge GridStackView into DashboardView (#19)
CI / build-and-test (pull_request) Successful in 2m22s
DashboardView and GridStackView rendered the same three charts from the
same hardcoded data, so every chart factory method existed twice. The
dashboard is now the GridStackLayout variant at @Route(""):

- GridStackView deleted, its grid/toolbar/chart factories moved into
  DashboardView, which keeps the point-click notifications the old
  dashboard had.
- Charts size to their widget (100%/100%) instead of a fixed 400px.
- MainLayout has one dashboard entry; nav.gridstack/page.gridstack keys
  dropped from all three bundles.
- Storage key is "dashboard" (was "gridstack-demo"), so a saved layout
  from the old route is not reused.
- GridStackViewTest renamed to DashboardViewTest, plus a test asserting
  the three charts render.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0124BiJikbhbiEsNfJxdWM69
2026-07-28 19:12:52 +02:00
8 changed files with 130 additions and 186 deletions
+1 -1
View File
@@ -39,7 +39,7 @@ Since `apex-chart.ts` renders into light DOM (`createRenderRoot()` returns `this
**`GridStackLayout`/`GridStackItem`** (`components/`) wrap [gridstack.js](https://gridstack.js.org) 13.1.0 for draggable/resizable grids. Unlike `ApexChart`, the bridge (`frontend/components/grid-stack.ts`) is a plain `HTMLElement`, not a Lit component — its children are server-rendered `GridStackItem`s living in light DOM, and a Lit render root would fight gridstack for ownership of them. A `MutationObserver` calls `makeWidget`/`removeWidget` as Flow adds/removes children, so there's no explicit add/remove protocol to the client. Every `GridStackItem` needs a stable `gs-id` (auto-generated if not given) — `GridStackLayout.setStorageKey(...)` persists drag/resize state to browser `localStorage` keyed on it and restores by matching ids, so items lose their saved position if their id changes between reloads. Item styling (`.grid-stack-item-content`) extends the `--dialect-*` alias layer in `styles.css`, same as `.dialect-card`. **`GridStackLayout`/`GridStackItem`** (`components/`) wrap [gridstack.js](https://gridstack.js.org) 13.1.0 for draggable/resizable grids. Unlike `ApexChart`, the bridge (`frontend/components/grid-stack.ts`) is a plain `HTMLElement`, not a Lit component — its children are server-rendered `GridStackItem`s living in light DOM, and a Lit render root would fight gridstack for ownership of them. A `MutationObserver` calls `makeWidget`/`removeWidget` as Flow adds/removes children, so there's no explicit add/remove protocol to the client. Every `GridStackItem` needs a stable `gs-id` (auto-generated if not given) — `GridStackLayout.setStorageKey(...)` persists drag/resize state to browser `localStorage` keyed on it and restores by matching ids, so items lose their saved position if their id changes between reloads. Item styling (`.grid-stack-item-content`) extends the `--dialect-*` alias layer in `styles.css`, same as `.dialect-card`.
**Views** (`views/`): `MainLayout` (`@Layout`, applies to all routes) is the `AppLayout` shell — navbar + `SideNav` drawer, with one `SideNavItem` per route. Routes: `DashboardView` (`@Route("")`) wraps each chart in a `Card` (`components/Card.java`); `FormView` (`@Route("formular")`) demonstrates form controls bound via `Binder`; `TableView` (`@Route("tabelle")`) demonstrates a `Grid` over dummy data with a live text filter (`GridListDataView.addFilter`, `TextField` in `ValueChangeMode.EAGER`). New views should reuse `Card` to wrap their content rather than adding components directly, and get a matching `SideNavItem` in `MainLayout`. **Views** (`views/`): `MainLayout` (`@Layout`, applies to all routes) is the `AppLayout` shell — navbar + `SideNav` drawer, with one `SideNavItem` per route. Routes: `DashboardView` (`@Route("")`) puts each chart in a `Card` (`components/Card.java`) inside a `GridStackItem` of a `GridStackLayout`, so widgets are draggable/resizable and the layout is persisted to `localStorage`; charts use `width: 100%` / `height: 100%` to fill their widget rather than a fixed pixel height; `FormView` (`@Route("formular")`) demonstrates form controls bound via `Binder`; `TableView` (`@Route("tabelle")`) demonstrates a `Grid` over dummy data with a live text filter (`GridListDataView.addFilter`, `TextField` in `ValueChangeMode.EAGER`). New views should reuse `Card` to wrap their content rather than adding components directly, and get a matching `SideNavItem` in `MainLayout`.
**Styling**: `src/main/resources/META-INF/resources/styles.css` is the one project-level stylesheet (loaded via `@StyleSheet("styles.css")` in `Application.java`). It defines `--dialect-*` design tokens (Dialect design system: primary orange `#E86C00`, cool-gray background, card radius/shadow) and aliases them onto Aura's own CSS custom properties (`--aura-accent-color-*`, `--aura-background-color-*`, `--aura-orange`, `--aura-yellow`) rather than fighting the theme. Aura tokens use OKLCH + relative-color syntax and accept plain hex overrides. When restyling, prefer extending this alias layer over hardcoding new colors in components. **Styling**: `src/main/resources/META-INF/resources/styles.css` is the one project-level stylesheet (loaded via `@StyleSheet("styles.css")` in `Application.java`). It defines `--dialect-*` design tokens (Dialect design system: primary orange `#E86C00`, cool-gray background, card radius/shadow) and aliases them onto Aura's own CSS custom properties (`--aura-accent-color-*`, `--aura-background-color-*`, `--aura-orange`, `--aura-yellow`) rather than fighting the theme. Aura tokens use OKLCH + relative-color syntax and accept plain hex overrides. When restyling, prefer extending this alias layer over hardcoding new colors in components.
@@ -1,63 +1,135 @@
package com.example.views; package com.example.views;
import com.example.components.ApexChart;
import com.example.components.BarChart; import com.example.components.BarChart;
import com.example.components.Card; import com.example.components.Card;
import com.example.components.Fa;
import com.example.components.GridStackItem;
import com.example.components.GridStackLayout;
import com.example.components.LineChart; import com.example.components.LineChart;
import com.example.components.PieChart; import com.example.components.PieChart;
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.html.Paragraph;
import com.vaadin.flow.component.html.Span;
import com.vaadin.flow.component.notification.Notification; 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.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 java.util.List; import java.util.List;
/**
* 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.
*/
@Route("") @Route("")
public class DashboardView extends VerticalLayout implements HasDynamicTitle { public class DashboardView extends VerticalLayout implements HasDynamicTitle {
private static final String STORAGE_KEY = "dashboard";
private final GridStackLayout grid = new GridStackLayout();
private final Span status = new Span();
private int extraWidgetCount;
public DashboardView() { public DashboardView() {
addClassName("dialect-content"); addClassName("dialect-content");
List<String> months = List.of( grid.setWidthFull();
grid.setStorageKey(STORAGE_KEY);
grid.addLayoutChangeListener(e -> status.setText(
getTranslation("gridstack.status", e.getPositions().size())));
grid.add(
new GridStackItem("revenue-trend", 0, 0, 6, 3,
new Card(getTranslation("card.revenueTrend"), lineChart())),
new GridStackItem("revenue-month", 6, 0, 6, 3,
new Card(getTranslation("card.revenueByMonth"), barChart())),
new GridStackItem("revenue-region", 0, 3, 5, 3,
new Card(getTranslation("card.revenueByRegion"), pieChart())),
new GridStackItem("hint", 5, 3, 7, 3,
new Card(getTranslation("card.gridstackHint"),
new Paragraph(getTranslation("gridstack.hint")))));
status.setText(getTranslation("gridstack.statusInitial"));
status.addClassName("dialect-muted");
add(toolbar(), grid);
}
private HorizontalLayout toolbar() {
Button add = new Button(getTranslation("gridstack.addWidget"), Fa.ADD.create(),
e -> addWidget());
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;
}
private void addWidget() {
// 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: 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++;
String title = getTranslation("gridstack.widget", extraWidgetCount);
grid.add(new GridStackItem("extra-" + extraWidgetCount, 0, 0, 4, 2,
new Card(title, new Paragraph(getTranslation("gridstack.widgetText")))));
}
private Component lineChart() {
LineChart chart = new LineChart();
chart.setData(getTranslation("chart.revenueSeries"),
List.of(30.0, 40.0, 35.0, 50.0, 49.0, 60.0), months());
chart.addPointClickListener(e -> Notification.show(getTranslation(
"chart.pointClick", e.getSeriesIndex(), e.getDataPointIndex())));
return sizeFull(chart);
}
private Component barChart() {
BarChart chart = new BarChart();
chart.setData(getTranslation("chart.revenueSeries"),
List.of(30.0, 40.0, 35.0, 50.0, 49.0, 60.0), months());
chart.addPointClickListener(e -> Notification.show(getTranslation(
"chart.pointClick", e.getSeriesIndex(), e.getDataPointIndex())));
return sizeFull(chart);
}
private Component pieChart() {
PieChart chart = new PieChart();
chart.setData(List.of(30.0, 40.0, 35.0, 50.0),
List.of(getTranslation("region.north"), getTranslation("region.south"),
getTranslation("region.east"), getTranslation("region.west")));
chart.addPointClickListener(e -> Notification.show(
getTranslation("chart.sliceClick", e.getDataPointIndex())));
return sizeFull(chart);
}
/** 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
* resize on resizestop, which ApexCharts reflows on). */
private Component sizeFull(ApexChart chart) {
chart.setWidthFull();
chart.setHeight("100%");
return chart;
}
private List<String> months() {
return List.of(
getTranslation("month.jan"), getTranslation("month.feb"), getTranslation("month.jan"), getTranslation("month.feb"),
getTranslation("month.mar"), getTranslation("month.apr"), getTranslation("month.mar"), getTranslation("month.apr"),
getTranslation("month.may"), getTranslation("month.jun")); getTranslation("month.may"), getTranslation("month.jun"));
LineChart lineChart = new LineChart();
lineChart.setWidthFull();
lineChart.setHeight("400px");
lineChart.setData(getTranslation("chart.revenueSeries"),
List.of(30.0, 40.0, 35.0, 50.0, 49.0, 60.0),
months);
lineChart.addPointClickListener(e -> {
Notification.show(getTranslation("chart.pointClick",
e.getSeriesIndex(), e.getDataPointIndex()));
});
BarChart barChart = new BarChart();
barChart.setWidthFull();
barChart.setHeight("400px");
barChart.setData(getTranslation("chart.revenueSeries"),
List.of(30.0, 40.0, 35.0, 50.0, 49.0, 60.0),
months);
barChart.addPointClickListener(e -> {
Notification.show(getTranslation("chart.pointClick",
e.getSeriesIndex(), e.getDataPointIndex()));
});
PieChart pieChart = new PieChart();
pieChart.setWidthFull();
pieChart.setHeight("400px");
pieChart.setData(
List.of(30.0, 40.0, 35.0, 50.0),
List.of(getTranslation("region.north"), getTranslation("region.south"),
getTranslation("region.east"), getTranslation("region.west")));
pieChart.addPointClickListener(e -> {
Notification.show(getTranslation("chart.sliceClick", e.getDataPointIndex()));
});
add(new Card(getTranslation("card.revenueTrend"), lineChart),
new Card(getTranslation("card.revenueByMonth"), barChart),
new Card(getTranslation("card.revenueByRegion"), pieChart));
} }
@Override @Override
@@ -1,132 +0,0 @@
package com.example.views;
import com.example.components.BarChart;
import com.example.components.Card;
import com.example.components.Fa;
import com.example.components.GridStackItem;
import com.example.components.GridStackLayout;
import com.example.components.LineChart;
import com.example.components.PieChart;
import com.example.components.ApexChart;
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.html.Paragraph;
import com.vaadin.flow.component.html.Span;
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 java.util.List;
/**
* Showcase for {@link GridStackLayout}: a dashboard 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.
*/
@Route("gridstack")
public class GridStackView extends VerticalLayout implements HasDynamicTitle {
private static final String STORAGE_KEY = "gridstack-demo";
private final GridStackLayout grid = new GridStackLayout();
private final Span status = new Span();
private int extraWidgetCount;
public GridStackView() {
addClassName("dialect-content");
grid.setWidthFull();
grid.setStorageKey(STORAGE_KEY);
grid.addLayoutChangeListener(e -> status.setText(
getTranslation("gridstack.status", e.getPositions().size())));
grid.add(
new GridStackItem("revenue-trend", 0, 0, 6, 3,
new Card(getTranslation("card.revenueTrend"), lineChart())),
new GridStackItem("revenue-month", 6, 0, 6, 3,
new Card(getTranslation("card.revenueByMonth"), barChart())),
new GridStackItem("revenue-region", 0, 3, 5, 3,
new Card(getTranslation("card.revenueByRegion"), pieChart())),
new GridStackItem("hint", 5, 3, 7, 3,
new Card(getTranslation("card.gridstackHint"),
new Paragraph(getTranslation("gridstack.hint")))));
status.setText(getTranslation("gridstack.statusInitial"));
status.addClassName("dialect-muted");
add(toolbar(), grid);
}
private HorizontalLayout toolbar() {
Button add = new Button(getTranslation("gridstack.addWidget"), Fa.ADD.create(),
e -> addWidget());
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;
}
private void addWidget() {
// 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: 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++;
String title = getTranslation("gridstack.widget", extraWidgetCount);
grid.add(new GridStackItem("extra-" + extraWidgetCount, 0, 0, 4, 2,
new Card(title, new Paragraph(getTranslation("gridstack.widgetText")))));
}
private Component lineChart() {
LineChart chart = new LineChart();
chart.setData(getTranslation("chart.revenueSeries"),
List.of(30.0, 40.0, 35.0, 50.0, 49.0, 60.0), months());
return sizeFull(chart);
}
private Component barChart() {
BarChart chart = new BarChart();
chart.setData(getTranslation("chart.revenueSeries"),
List.of(30.0, 40.0, 35.0, 50.0, 49.0, 60.0), months());
return sizeFull(chart);
}
private Component pieChart() {
PieChart chart = new PieChart();
chart.setData(List.of(30.0, 40.0, 35.0, 50.0),
List.of(getTranslation("region.north"), getTranslation("region.south"),
getTranslation("region.east"), getTranslation("region.west")));
return sizeFull(chart);
}
/** 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
* resize on resizestop, which ApexCharts reflows on). */
private Component sizeFull(ApexChart chart) {
chart.setWidthFull();
chart.setHeight("100%");
return chart;
}
private List<String> months() {
return List.of(
getTranslation("month.jan"), getTranslation("month.feb"),
getTranslation("month.mar"), getTranslation("month.apr"),
getTranslation("month.may"), getTranslation("month.jun"));
}
@Override
public String getPageTitle() {
return getTranslation("page.gridstack");
}
}
@@ -49,8 +49,6 @@ public class MainLayout extends AppLayout {
Fa.FORM.create())); Fa.FORM.create()));
nav.addItem(new SideNavItem(getTranslation("nav.table"), TableView.class, nav.addItem(new SideNavItem(getTranslation("nav.table"), TableView.class,
Fa.TABLE.create())); Fa.TABLE.create()));
nav.addItem(new SideNavItem(getTranslation("nav.gridstack"), GridStackView.class,
Fa.GRID.create()));
addToDrawer(nav); addToDrawer(nav);
} }
@@ -10,12 +10,10 @@ lang.es=Español
nav.dashboard=Dashboard nav.dashboard=Dashboard
nav.form=Formular nav.form=Formular
nav.table=Tabelle nav.table=Tabelle
nav.gridstack=Raster
page.dashboard=Dashboard page.dashboard=Dashboard
page.form=Formular page.form=Formular
page.table=Tabelle page.table=Tabelle
page.gridstack=Raster-Layout
card.revenueTrend=Umsatz-Entwicklung card.revenueTrend=Umsatz-Entwicklung
card.revenueByMonth=Umsatz nach Monat card.revenueByMonth=Umsatz nach Monat
@@ -10,12 +10,10 @@ lang.es=Español
nav.dashboard=Dashboard nav.dashboard=Dashboard
nav.form=Form nav.form=Form
nav.table=Table nav.table=Table
nav.gridstack=Grid
page.dashboard=Dashboard page.dashboard=Dashboard
page.form=Form page.form=Form
page.table=Table page.table=Table
page.gridstack=Grid layout
card.revenueTrend=Revenue Trend card.revenueTrend=Revenue Trend
card.revenueByMonth=Revenue by Month card.revenueByMonth=Revenue by Month
@@ -10,12 +10,10 @@ lang.es=Español
nav.dashboard=Panel nav.dashboard=Panel
nav.form=Formulario nav.form=Formulario
nav.table=Tabla nav.table=Tabla
nav.gridstack=Cuadrícula
page.dashboard=Panel page.dashboard=Panel
page.form=Formulario page.form=Formulario
page.table=Tabla page.table=Tabla
page.gridstack=Diseño de cuadrícula
card.revenueTrend=Evolución de ingresos card.revenueTrend=Evolución de ingresos
card.revenueByMonth=Ingresos por mes card.revenueByMonth=Ingresos por mes
@@ -1,8 +1,11 @@
package com.example.views; package com.example.views;
import com.example.Application; import com.example.Application;
import com.example.components.BarChart;
import com.example.components.GridStackItem; import com.example.components.GridStackItem;
import com.example.components.GridStackLayout; import com.example.components.GridStackLayout;
import com.example.components.LineChart;
import com.example.components.PieChart;
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;
@@ -21,12 +24,12 @@ import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.assertTrue;
@SpringBootTest(classes = Application.class) @SpringBootTest(classes = Application.class)
@ViewPackages(classes = GridStackView.class) @ViewPackages(classes = DashboardView.class)
class GridStackViewTest extends SpringBrowserlessTest { class DashboardViewTest extends SpringBrowserlessTest {
@Test @Test
void view_rendersGridWithDefaultWidgets() { void view_rendersGridWithDefaultWidgets() {
navigate(GridStackView.class); navigate(DashboardView.class);
GridStackLayout grid = $view(GridStackLayout.class).first(); GridStackLayout grid = $view(GridStackLayout.class).first();
assertNotNull(grid); assertNotNull(grid);
@@ -36,9 +39,18 @@ class GridStackViewTest extends SpringBrowserlessTest {
assertTrue(layout.stream().allMatch(p -> p.w() > 0 && p.h() > 0)); assertTrue(layout.stream().allMatch(p -> p.w() > 0 && p.h() > 0));
} }
@Test
void view_rendersTheThreeCharts() {
navigate(DashboardView.class);
assertNotNull($view(LineChart.class).first());
assertNotNull($view(BarChart.class).first());
assertNotNull($view(PieChart.class).first());
}
@Test @Test
void addAndCloseWidget_changesItemCount() { void addAndCloseWidget_changesItemCount() {
navigate(GridStackView.class); navigate(DashboardView.class);
GridStackLayout grid = $view(GridStackLayout.class).first(); GridStackLayout grid = $view(GridStackLayout.class).first();
int initial = grid.getLayout().size(); int initial = grid.getLayout().size();
@@ -52,7 +64,7 @@ class GridStackViewTest extends SpringBrowserlessTest {
@Test @Test
void closeButton_removesTheClickedWidgetOnly() { void closeButton_removesTheClickedWidgetOnly() {
navigate(GridStackView.class); navigate(DashboardView.class);
GridStackLayout grid = $view(GridStackLayout.class).first(); GridStackLayout grid = $view(GridStackLayout.class).first();
GridStackItem first = $view(GridStackItem.class).first(); GridStackItem first = $view(GridStackItem.class).first();
@@ -68,7 +80,7 @@ class GridStackViewTest extends SpringBrowserlessTest {
@Test @Test
void closeListener_firesOnceItemIsDetached() { void closeListener_firesOnceItemIsDetached() {
navigate(GridStackView.class); navigate(DashboardView.class);
GridStackLayout grid = $view(GridStackLayout.class).first(); GridStackLayout grid = $view(GridStackLayout.class).first();
GridStackItem item = $view(GridStackItem.class).first(); GridStackItem item = $view(GridStackItem.class).first();
@@ -83,7 +95,7 @@ class GridStackViewTest extends SpringBrowserlessTest {
@Test @Test
void nonClosableItem_hasNoCloseButton() { void nonClosableItem_hasNoCloseButton() {
navigate(GridStackView.class); navigate(DashboardView.class);
GridStackItem item = $view(GridStackItem.class).first(); GridStackItem item = $view(GridStackItem.class).first();
assertTrue(item.isClosable(), "items are closable by default"); assertTrue(item.isClosable(), "items are closable by default");