diff --git a/src/main/bundles/dev.bundle b/src/main/bundles/dev.bundle index c2249a8..db04b3b 100644 Binary files a/src/main/bundles/dev.bundle and b/src/main/bundles/dev.bundle differ diff --git a/src/main/frontend/components/apex-chart.ts b/src/main/frontend/components/apex-chart.ts index d093f8f..04568b5 100644 --- a/src/main/frontend/components/apex-chart.ts +++ b/src/main/frontend/components/apex-chart.ts @@ -20,6 +20,10 @@ function resolveColor(varName: string): string { return resolved; } +function sameJson(a: unknown, b: unknown): boolean { + return JSON.stringify(a) === JSON.stringify(b); +} + function isDarkScheme(): boolean { return getComputedStyle(document.documentElement).colorScheme.includes('dark'); } @@ -113,6 +117,49 @@ export class ApexChart extends LitElement { } } + /** + * Data-only update: patches the live chart via ApexCharts' own + * updateSeries/updateOptions instead of rebuilding it from a full option + * set, so the SVG animates from its previous values and zoom/selection + * state survives. The patch carries `series` plus, depending on the chart + * type, `categories` (axis charts) or `labels` (pie). + */ + async updateData(patchJson: string) { + await this.updateComplete; + const patch = JSON.parse(patchJson); + // Nothing rendered yet — there is no option set to patch onto. The + // server only calls this after a full render, so this is a no-op guard. + if (!this.lastOptions) return; + + const options = this.lastOptions; + // Categories/labels are structural: they only reach the chart through + // updateOptions, which redraws. Skip it when they are unchanged, which + // is the common case for a pure data refresh. + const structural: any = {}; + if (patch.categories && !sameJson(patch.categories, options.xaxis?.categories)) { + structural.xaxis = { ...options.xaxis, categories: patch.categories }; + } + if (patch.labels && !sameJson(patch.labels, options.labels)) { + structural.labels = patch.labels; + } + + options.series = patch.series; + if (patch.categories) options.xaxis = { ...options.xaxis, categories: patch.categories }; + if (patch.labels) options.labels = patch.labels; + + // Re-resolve the theme-varying keys on the merged options, so a rebuild + // after a detach (see connectedCallback) starts from the patched data. + const themed = applyThemeOverlay(options); + this.lastOptionsJson = JSON.stringify(themed); + + if (!this.chart) return; + + if (Object.keys(structural).length > 0) { + await this.chart.updateOptions(structural, false, true); + } + await this.chart.updateSeries(themed.series, true); + } + disconnectedCallback() { super.disconnectedCallback(); window.removeEventListener('dialect-theme-change', this.onThemeChange); diff --git a/src/main/java/com/example/components/ApexChart.java b/src/main/java/com/example/components/ApexChart.java index 55b827b..74b63b4 100644 --- a/src/main/java/com/example/components/ApexChart.java +++ b/src/main/java/com/example/components/ApexChart.java @@ -17,6 +17,7 @@ public abstract class ApexChart extends Component implements HasSize { protected static final JsonMapper MAPPER = JsonMapper.builder().build(); private final String chartType; + private boolean optionsSent; protected ApexChart(String chartType) { this.chartType = chartType; @@ -28,6 +29,23 @@ public abstract class ApexChart extends Component implements HasSize { protected void sendOptions(Map options) { getElement().callJsFunction("renderChart", MAPPER.writeValueAsString(options)); + optionsSent = true; + } + + /** + * Sends a data-only patch ({@code series} plus {@code categories} or + * {@code labels}) that the client applies to the running chart, instead of + * rebuilding it from a full option set. Only valid once + * {@link #sendOptions} has run — see {@link #hasSentOptions()}. + */ + protected void sendDataPatch(Map patch) { + getElement().callJsFunction("updateData", MAPPER.writeValueAsString(patch)); + } + + /** Whether a full option set has been sent, i.e. whether there is a chart + * on the client a data patch could be applied to. */ + protected boolean hasSentOptions() { + return optionsSent; } @ClientCallable diff --git a/src/main/java/com/example/components/AxisChart.java b/src/main/java/com/example/components/AxisChart.java index 1efd490..e2f24a1 100644 --- a/src/main/java/com/example/components/AxisChart.java +++ b/src/main/java/com/example/components/AxisChart.java @@ -20,4 +20,22 @@ public abstract class AxisChart extends ApexChart { sendOptions(options); } + + /** + * Replaces the data of an already rendered chart without re-rendering it: + * the client patches series (and categories) into the live chart, so the + * update animates from the previous values instead of flashing. Falls back + * to {@link #setData} as long as nothing has been rendered yet. + */ + public void updateData(String seriesName, List values, List categories) { + if (!hasSentOptions()) { + setData(seriesName, values, categories); + return; + } + + sendDataPatch(Map.of( + "series", List.of(Map.of("name", seriesName, "data", values)), + "categories", categories + )); + } } diff --git a/src/main/java/com/example/components/PieChart.java b/src/main/java/com/example/components/PieChart.java index d47f836..1670837 100644 --- a/src/main/java/com/example/components/PieChart.java +++ b/src/main/java/com/example/components/PieChart.java @@ -16,4 +16,18 @@ public class PieChart extends ApexChart { sendOptions(options); } + + /** + * Replaces the data of an already rendered chart without re-rendering it — + * see {@link AxisChart#updateData}. Falls back to {@link #setData} as long + * as nothing has been rendered yet. + */ + public void updateData(List values, List labels) { + if (!hasSentOptions()) { + setData(values, labels); + return; + } + + sendDataPatch(Map.of("series", values, "labels", labels)); + } } diff --git a/src/test/java/com/example/components/ApexChartUpdateTest.java b/src/test/java/com/example/components/ApexChartUpdateTest.java new file mode 100644 index 0000000..e922f86 --- /dev/null +++ b/src/test/java/com/example/components/ApexChartUpdateTest.java @@ -0,0 +1,102 @@ +package com.example.components; + +import com.example.Application; +import com.example.views.DashboardView; +import com.vaadin.browserless.SpringBrowserlessTest; +import com.vaadin.browserless.ViewPackages; +import com.vaadin.flow.component.UI; +import com.vaadin.flow.component.internal.PendingJavaScriptInvocation; +import com.vaadin.flow.component.internal.UIInternals.JavaScriptInvocation; +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.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Covers the incremental update path of {@link ApexChart} by inspecting the JS + * calls queued for the client: a chart that has been rendered gets a data-only + * {@code updateData} patch, one that has not gets a full {@code renderChart}. + */ +@SpringBootTest(classes = Application.class) +@ViewPackages(classes = DashboardView.class) +class ApexChartUpdateTest extends SpringBrowserlessTest { + + private static final List VALUES = List.of(11.0, 22.0); + private static final List CATEGORIES = List.of("Jan", "Feb"); + + @Test + void updateData_afterRender_sendsDataPatch() { + navigate(DashboardView.class); + LineChart chart = $view(LineChart.class).first(); + drainInvocations(); + + chart.updateData("Umsatz", VALUES, CATEGORIES); + + JavaScriptInvocation call = lastInvocation(); + assertTrue(call.getExpression().contains("updateData"), call.getExpression()); + String json = json(call); + assertTrue(json.contains("\"series\""), json); + assertTrue(json.contains("\"categories\""), json); + assertFalse(json.contains("\"colors\""), "a patch carries data only, no theme options: " + json); + } + + @Test + void updateData_beforeFirstRender_fallsBackToFullRender() { + navigate(DashboardView.class); + LineChart chart = new LineChart(); + getCurrentView().getElement().appendChild(chart.getElement()); + drainInvocations(); + + chart.updateData("Umsatz", VALUES, CATEGORIES); + + JavaScriptInvocation call = lastInvocation(); + assertTrue(call.getExpression().contains("renderChart"), call.getExpression()); + assertTrue(json(call).contains("\"colors\""), "a full render carries the theme options"); + } + + @Test + void pieChart_updateData_sendsSeriesAndLabels() { + navigate(DashboardView.class); + PieChart chart = $view(PieChart.class).first(); + drainInvocations(); + + chart.updateData(VALUES, List.of("Nord", "Sued")); + + JavaScriptInvocation call = lastInvocation(); + assertTrue(call.getExpression().contains("updateData"), call.getExpression()); + String json = json(call); + assertTrue(json.contains("\"series\""), json); + assertTrue(json.contains("\"labels\""), json); + } + + /** The JSON payload of a {@code callJsFunction} invocation — parameter 0 is + * the element the function is called on. */ + private String json(JavaScriptInvocation call) { + return (String) call.getParameters().get(1); + } + + /** Clears everything the view queued while rendering, so the assertions + * only see what the call under test produced. */ + private void drainInvocations() { + dump(); + } + + private JavaScriptInvocation lastInvocation() { + List pending = dump(); + assertEquals(1, pending.size(), "expected exactly one queued client call"); + return pending.getFirst().getInvocation(); + } + + /** {@code callJsFunction} only materialises its invocation in the + * before-client-response phase, which no real client response triggers + * here — so run that phase explicitly before collecting. */ + private List dump() { + UI ui = UI.getCurrent(); + ui.getInternals().getStateTree().runExecutionsBeforeClientResponse(); + return ui.getInternals().dumpPendingJavaScriptInvocations(); + } +} diff --git a/src/test/java/com/example/e2e/DashboardChartPlaywrightTest.java b/src/test/java/com/example/e2e/DashboardChartPlaywrightTest.java new file mode 100644 index 0000000..dc1af3a --- /dev/null +++ b/src/test/java/com/example/e2e/DashboardChartPlaywrightTest.java @@ -0,0 +1,58 @@ +package com.example.e2e; + +import com.microsoft.playwright.Locator; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static com.microsoft.playwright.assertions.PlaywrightAssertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * End-to-end coverage for the incremental update path of {@code apex-chart.ts}: + * a data update must patch the running ApexCharts instance instead of tearing + * the chart down and building it again. + */ +class DashboardChartPlaywrightTest extends PlaywrightTestBase { + + private static final String PATCH = """ + {"series":[{"name":"Revenue","data":[1,2,3,4,5,6]}], + "categories":["Jan","Feb","Mar","Apr","May","Jun"]}"""; + + @BeforeEach + void openDashboard() { + navigate(""); + } + + @Test + void dataUpdate_keepsTheRenderedSvgInPlace() { + Locator chart = page.locator("apex-chart").first(); + assertThat(chart.locator("svg").first()).isVisible(); + + // Tag the live SVG node: a full rebuild would replace it and drop the tag. + chart.evaluate("el => el.querySelector('svg').dataset.marker = 'first-render'"); + + chart.evaluate("(el, patch) => el.updateData(patch)", PATCH); + + assertEquals(1, chart.locator("svg[data-marker='first-render']").count(), + "the chart must be patched in place, not recreated"); + assertEquals("1,2,3,4,5,6", + chart.evaluate("el => JSON.parse(el.lastOptionsJson).series[0].data.join(',')"), + "the patched data must have reached the chart"); + } + + @Test + void dataUpdate_keepsTheThemeOverlay() { + Locator chart = page.locator("apex-chart").first(); + assertThat(chart.locator("svg").first()).isVisible(); + + chart.evaluate("(el, patch) => el.updateData(patch)", PATCH); + + // grid.borderColor is only ever set by applyThemeOverlay(), from the + // resolved --dialect-border token — so a resolved rgb() value here means + // the overlay survived the incremental update. + Object borderColor = chart.evaluate("el => JSON.parse(el.lastOptionsJson).grid.borderColor"); + assertTrue(String.valueOf(borderColor).startsWith("rgb"), + "expected a resolved theme color, got: " + borderColor); + } +}