fix: add GridStackLayout showcase view (#7)
CI / build-and-test (pull_request) Successful in 1m13s
CI / build-and-test (pull_request) Successful in 1m13s
Adds a /gridstack route demonstrating GridStackLayout: four default widgets (line/bar/pie chart cards plus a usage hint), buttons to add and remove widgets at runtime, a reset-layout button and a layout-change status line. Layout is persisted per browser via the existing localStorage support. The view gets its own SideNavItem in MainLayout and full de/en/es translations. Fixes an infinite recursion in GridStackLayout.add(Component): a single-argument add(item) resolves to that overload rather than the varargs one, so its delegation to add(item) called itself. It now appends through the element API and passes a GridStackItem through unwrapped. Adds the first test sources (browserless UI tests) covering the view's widget count and the add/remove buttons. Closes #7 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PYAcKoXKPN3rJ7nBroKZkc
This commit is contained in:
@@ -20,7 +20,11 @@ public enum Fa {
|
|||||||
FORM("fa-solid", "fa-rectangle-list"),
|
FORM("fa-solid", "fa-rectangle-list"),
|
||||||
TABLE("fa-solid", "fa-table"),
|
TABLE("fa-solid", "fa-table"),
|
||||||
GLOBE("fa-solid", "fa-globe"),
|
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;
|
private final String[] classes;
|
||||||
|
|
||||||
|
|||||||
@@ -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).
|
||||||
|
* <p>
|
||||||
|
* 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) {
|
public GridStackItem add(Component content) {
|
||||||
GridStackItem item = new GridStackItem(content);
|
GridStackItem item = content instanceof GridStackItem existing
|
||||||
add(item);
|
? existing
|
||||||
|
: new GridStackItem(content);
|
||||||
|
getElement().appendChild(item.getElement());
|
||||||
return item;
|
return item;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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<GridStackItem> 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<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,6 +49,8 @@ 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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -123,6 +123,13 @@ vaadin-app-layout::part(content) {
|
|||||||
margin: 0 0 12px 0;
|
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
|
/* GridStackLayout (components/GridStackLayout.java): extend the same alias
|
||||||
layer used by .dialect-card rather than hardcoding new colors. */
|
layer used by .dialect-card rather than hardcoding new colors. */
|
||||||
.grid-stack-item-content {
|
.grid-stack-item-content {
|
||||||
|
|||||||
@@ -10,16 +10,28 @@ 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
|
||||||
card.revenueByRegion=Umsatz nach Region
|
card.revenueByRegion=Umsatz nach Region
|
||||||
card.registration=Registrierung
|
card.registration=Registrierung
|
||||||
card.employees=Mitarbeiter
|
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.revenueSeries=Umsatz 2026
|
||||||
chart.pointClick=Serie {0}, Punkt {1}
|
chart.pointClick=Serie {0}, Punkt {1}
|
||||||
|
|||||||
@@ -10,16 +10,28 @@ 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
|
||||||
card.revenueByRegion=Revenue by Region
|
card.revenueByRegion=Revenue by Region
|
||||||
card.registration=Registration
|
card.registration=Registration
|
||||||
card.employees=Employees
|
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.revenueSeries=Revenue 2026
|
||||||
chart.pointClick=Series {0}, point {1}
|
chart.pointClick=Series {0}, point {1}
|
||||||
|
|||||||
@@ -10,16 +10,28 @@ 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
|
||||||
card.revenueByRegion=Ingresos por región
|
card.revenueByRegion=Ingresos por región
|
||||||
card.registration=Registro
|
card.registration=Registro
|
||||||
card.employees=Empleados
|
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.revenueSeries=Ingresos 2026
|
||||||
chart.pointClick=Serie {0}, punto {1}
|
chart.pointClick=Serie {0}, punto {1}
|
||||||
|
|||||||
@@ -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<GridStackItem.Position> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user