diff --git a/src/main/java/com/example/components/Fa.java b/src/main/java/com/example/components/Fa.java index 33c9ed3..7e5c701 100644 --- a/src/main/java/com/example/components/Fa.java +++ b/src/main/java/com/example/components/Fa.java @@ -20,7 +20,11 @@ public enum Fa { FORM("fa-solid", "fa-rectangle-list"), TABLE("fa-solid", "fa-table"), GLOBE("fa-solid", "fa-globe"), - SEARCH("fa-solid", "fa-magnifying-glass"); + SEARCH("fa-solid", "fa-magnifying-glass"), + GRID("fa-solid", "fa-table-cells-large"), + ADD("fa-solid", "fa-plus"), + REMOVE("fa-solid", "fa-trash"), + RESET("fa-solid", "fa-arrow-rotate-left"); private final String[] classes; diff --git a/src/main/java/com/example/components/GridStackLayout.java b/src/main/java/com/example/components/GridStackLayout.java index e3c9984..190c42a 100644 --- a/src/main/java/com/example/components/GridStackLayout.java +++ b/src/main/java/com/example/components/GridStackLayout.java @@ -106,10 +106,17 @@ public class GridStackLayout extends Component implements HasSize, HasStyle { } } - /** Convenience overload: wraps a plain component in a default-sized {@link GridStackItem}. */ + /** Convenience overload: wraps a plain component in a default-sized + * {@link GridStackItem} (an item passed in directly is added as is). + *

+ * Appends through the element API rather than calling {@code add(item)}: + * a single-argument call resolves to this overload, not the varargs one, + * so delegating would recurse into itself. */ public GridStackItem add(Component content) { - GridStackItem item = new GridStackItem(content); - add(item); + GridStackItem item = content instanceof GridStackItem existing + ? existing + : new GridStackItem(content); + getElement().appendChild(item.getElement()); return item; } diff --git a/src/main/java/com/example/views/GridStackView.java b/src/main/java/com/example/views/GridStackView.java new file mode 100644 index 0000000..86193b6 --- /dev/null +++ b/src/main/java/com/example/views/GridStackView.java @@ -0,0 +1,145 @@ +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.ArrayDeque; +import java.util.Deque; +import java.util.List; + +/** + * Showcase for {@link GridStackLayout}: a dashboard of draggable/resizable + * cards whose layout is persisted per browser, plus controls to add, remove + * and reset widgets at runtime. + */ +@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 Deque extraWidgets = new ArrayDeque<>(); + 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 remove = new Button(getTranslation("gridstack.removeWidget"), Fa.REMOVE.create(), + e -> removeWidget()); + + Button reset = new Button(getTranslation("gridstack.reset"), Fa.RESET.create(), + e -> grid.resetLayout()); + + HorizontalLayout toolbar = new HorizontalLayout(add, remove, 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. + extraWidgetCount++; + String title = getTranslation("gridstack.widget", extraWidgetCount); + GridStackItem item = new GridStackItem("extra-" + extraWidgetCount, 0, 0, 4, 2, + new Card(title, new Paragraph(getTranslation("gridstack.widgetText")))); + extraWidgets.push(item); + grid.add(item); + } + + private void removeWidget() { + GridStackItem item = extraWidgets.poll(); + if (item == null) { + return; + } + grid.remove(item); + extraWidgetCount--; + } + + 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 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"); + } +} diff --git a/src/main/java/com/example/views/MainLayout.java b/src/main/java/com/example/views/MainLayout.java index 0ea2e4f..c9ccc7d 100644 --- a/src/main/java/com/example/views/MainLayout.java +++ b/src/main/java/com/example/views/MainLayout.java @@ -49,6 +49,8 @@ public class MainLayout extends AppLayout { Fa.FORM.create())); nav.addItem(new SideNavItem(getTranslation("nav.table"), TableView.class, Fa.TABLE.create())); + nav.addItem(new SideNavItem(getTranslation("nav.gridstack"), GridStackView.class, + Fa.GRID.create())); addToDrawer(nav); } diff --git a/src/main/resources/META-INF/resources/styles.css b/src/main/resources/META-INF/resources/styles.css index 2feed68..eb1a3bb 100644 --- a/src/main/resources/META-INF/resources/styles.css +++ b/src/main/resources/META-INF/resources/styles.css @@ -123,6 +123,13 @@ vaadin-app-layout::part(content) { margin: 0 0 12px 0; } +/* Secondary text (e.g. the layout status line in GridStackView). */ +.dialect-muted { + color: var(--dialect-ink); + opacity: 0.65; + font-size: 0.9rem; +} + /* GridStackLayout (components/GridStackLayout.java): extend the same alias layer used by .dialect-card rather than hardcoding new colors. */ .grid-stack-item-content { diff --git a/src/main/resources/vaadin-i18n/translations.properties b/src/main/resources/vaadin-i18n/translations.properties index 89bb3dd..9d04a42 100644 --- a/src/main/resources/vaadin-i18n/translations.properties +++ b/src/main/resources/vaadin-i18n/translations.properties @@ -10,16 +10,28 @@ lang.es=Español nav.dashboard=Dashboard nav.form=Formular nav.table=Tabelle +nav.gridstack=Raster page.dashboard=Dashboard page.form=Formular page.table=Tabelle +page.gridstack=Raster-Layout card.revenueTrend=Umsatz-Entwicklung card.revenueByMonth=Umsatz nach Monat card.revenueByRegion=Umsatz nach Region card.registration=Registrierung card.employees=Mitarbeiter +card.gridstackHint=Bedienung + +gridstack.hint=Karten am Titel greifen und verschieben, an der unteren rechten Ecke die Größe ändern. Das Layout wird im Browser gespeichert und beim nächsten Besuch wiederhergestellt. +gridstack.addWidget=Widget hinzufügen +gridstack.removeWidget=Letztes entfernen +gridstack.reset=Layout zurücksetzen +gridstack.status=Layout geändert – {0} Widgets +gridstack.statusInitial=Layout unverändert +gridstack.widget=Widget {0} +gridstack.widgetText=Frei platzierbare Karte. chart.revenueSeries=Umsatz 2026 chart.pointClick=Serie {0}, Punkt {1} diff --git a/src/main/resources/vaadin-i18n/translations_en.properties b/src/main/resources/vaadin-i18n/translations_en.properties index 5142573..1439663 100644 --- a/src/main/resources/vaadin-i18n/translations_en.properties +++ b/src/main/resources/vaadin-i18n/translations_en.properties @@ -10,16 +10,28 @@ lang.es=Español nav.dashboard=Dashboard nav.form=Form nav.table=Table +nav.gridstack=Grid page.dashboard=Dashboard page.form=Form page.table=Table +page.gridstack=Grid layout card.revenueTrend=Revenue Trend card.revenueByMonth=Revenue by Month card.revenueByRegion=Revenue by Region card.registration=Registration card.employees=Employees +card.gridstackHint=How it works + +gridstack.hint=Drag cards by their header to move them, resize them from the bottom right corner. The layout is stored in your browser and restored on your next visit. +gridstack.addWidget=Add widget +gridstack.removeWidget=Remove last +gridstack.reset=Reset layout +gridstack.status=Layout changed – {0} widgets +gridstack.statusInitial=Layout unchanged +gridstack.widget=Widget {0} +gridstack.widgetText=Freely placeable card. chart.revenueSeries=Revenue 2026 chart.pointClick=Series {0}, point {1} diff --git a/src/main/resources/vaadin-i18n/translations_es.properties b/src/main/resources/vaadin-i18n/translations_es.properties index 5f034cb..2071ab7 100644 --- a/src/main/resources/vaadin-i18n/translations_es.properties +++ b/src/main/resources/vaadin-i18n/translations_es.properties @@ -10,16 +10,28 @@ lang.es=Español nav.dashboard=Panel nav.form=Formulario nav.table=Tabla +nav.gridstack=Cuadrícula page.dashboard=Panel page.form=Formulario page.table=Tabla +page.gridstack=Diseño de cuadrícula card.revenueTrend=Evolución de ingresos card.revenueByMonth=Ingresos por mes card.revenueByRegion=Ingresos por región card.registration=Registro card.employees=Empleados +card.gridstackHint=Cómo funciona + +gridstack.hint=Arrastra las tarjetas por su título para moverlas y cambia su tamaño desde la esquina inferior derecha. El diseño se guarda en el navegador y se restaura en la próxima visita. +gridstack.addWidget=Añadir widget +gridstack.removeWidget=Quitar el último +gridstack.reset=Restablecer diseño +gridstack.status=Diseño modificado – {0} widgets +gridstack.statusInitial=Diseño sin cambios +gridstack.widget=Widget {0} +gridstack.widgetText=Tarjeta de colocación libre. chart.revenueSeries=Ingresos 2026 chart.pointClick=Serie {0}, punto {1} diff --git a/src/test/java/com/example/views/GridStackViewTest.java b/src/test/java/com/example/views/GridStackViewTest.java new file mode 100644 index 0000000..8041b7e --- /dev/null +++ b/src/test/java/com/example/views/GridStackViewTest.java @@ -0,0 +1,63 @@ +package com.example.views; + +import com.example.Application; +import com.example.components.GridStackItem; +import com.example.components.GridStackLayout; +import com.vaadin.browserless.SpringBrowserlessTest; +import com.vaadin.browserless.ViewPackages; +import com.vaadin.flow.component.button.Button; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@SpringBootTest(classes = Application.class) +@ViewPackages(classes = GridStackView.class) +class GridStackViewTest extends SpringBrowserlessTest { + + @Test + void view_rendersGridWithDefaultWidgets() { + navigate(GridStackView.class); + + GridStackLayout grid = $view(GridStackLayout.class).first(); + assertNotNull(grid); + + List layout = grid.getLayout(); + assertEquals(4, layout.size(), "expected the four default widgets"); + assertTrue(layout.stream().allMatch(p -> p.w() > 0 && p.h() > 0)); + } + + @Test + void addAndRemoveWidget_changesItemCount() { + navigate(GridStackView.class); + + GridStackLayout grid = $view(GridStackLayout.class).first(); + int initial = grid.getLayout().size(); + + button("gridstack.addWidget").click(); + assertEquals(initial + 1, grid.getLayout().size()); + + button("gridstack.removeWidget").click(); + assertEquals(initial, grid.getLayout().size()); + } + + @Test + void removeWidget_onDefaultLayout_keepsStaticWidgets() { + navigate(GridStackView.class); + + GridStackLayout grid = $view(GridStackLayout.class).first(); + button("gridstack.removeWidget").click(); + + assertEquals(4, grid.getLayout().size(), "static widgets must not be removable"); + } + + private Button button(String translationKey) { + String caption = getCurrentView().getElement().getComponent() + .orElseThrow().getTranslation(translationKey); + return $view(Button.class).withText(caption).first(); + } +}