Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ed9e6f81ea | |||
| 640616c60a |
Binary file not shown.
@@ -20,6 +20,10 @@ function resolveColor(varName: string): string {
|
|||||||
return resolved;
|
return resolved;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sameJson(a: unknown, b: unknown): boolean {
|
||||||
|
return JSON.stringify(a) === JSON.stringify(b);
|
||||||
|
}
|
||||||
|
|
||||||
function isDarkScheme(): boolean {
|
function isDarkScheme(): boolean {
|
||||||
return getComputedStyle(document.documentElement).colorScheme.includes('dark');
|
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() {
|
disconnectedCallback() {
|
||||||
super.disconnectedCallback();
|
super.disconnectedCallback();
|
||||||
window.removeEventListener('dialect-theme-change', this.onThemeChange);
|
window.removeEventListener('dialect-theme-change', this.onThemeChange);
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ public abstract class ApexChart extends Component implements HasSize {
|
|||||||
protected static final JsonMapper MAPPER = JsonMapper.builder().build();
|
protected static final JsonMapper MAPPER = JsonMapper.builder().build();
|
||||||
|
|
||||||
private final String chartType;
|
private final String chartType;
|
||||||
|
private boolean optionsSent;
|
||||||
|
|
||||||
protected ApexChart(String chartType) {
|
protected ApexChart(String chartType) {
|
||||||
this.chartType = chartType;
|
this.chartType = chartType;
|
||||||
@@ -28,6 +29,23 @@ public abstract class ApexChart extends Component implements HasSize {
|
|||||||
|
|
||||||
protected void sendOptions(Map<String, Object> options) {
|
protected void sendOptions(Map<String, Object> options) {
|
||||||
getElement().callJsFunction("renderChart", MAPPER.writeValueAsString(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<String, Object> 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
|
@ClientCallable
|
||||||
|
|||||||
@@ -20,4 +20,22 @@ public abstract class AxisChart extends ApexChart {
|
|||||||
|
|
||||||
sendOptions(options);
|
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<Double> values, List<String> categories) {
|
||||||
|
if (!hasSentOptions()) {
|
||||||
|
setData(seriesName, values, categories);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
sendDataPatch(Map.of(
|
||||||
|
"series", List.of(Map.of("name", seriesName, "data", values)),
|
||||||
|
"categories", categories
|
||||||
|
));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,4 +16,18 @@ public class PieChart extends ApexChart {
|
|||||||
|
|
||||||
sendOptions(options);
|
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<Double> values, List<String> labels) {
|
||||||
|
if (!hasSentOptions()) {
|
||||||
|
setData(values, labels);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
sendDataPatch(Map.of("series", values, "labels", labels));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<Double> VALUES = List.of(11.0, 22.0);
|
||||||
|
private static final List<String> 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<PendingJavaScriptInvocation> 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<PendingJavaScriptInvocation> dump() {
|
||||||
|
UI ui = UI.getCurrent();
|
||||||
|
ui.getInternals().getStateTree().runExecutionsBeforeClientResponse();
|
||||||
|
return ui.getInternals().dumpPendingJavaScriptInvocations();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user