mirror of
https://github.com/nocobase/nocobase.git
synced 2026-08-30 16:56:07 +08:00
feat(data-visualization): support chart event cleanup (#10034)
* feat(data-visualization): support chart event cleanup * fix(data-visualization): ignore stale chart event runs * fix(data-visualization): skip stale chart events after cleanup
This commit is contained in:
+63
-10
@@ -37,6 +37,8 @@ import {
|
||||
|
||||
const NO_PREVIEW_SNAPSHOT = Symbol('NO_PREVIEW_SNAPSHOT');
|
||||
|
||||
type ChartEventCleanup = () => void | Promise<void>;
|
||||
|
||||
type ChartBlockModelStructure = {
|
||||
subModels: {
|
||||
page: ChildPageModel;
|
||||
@@ -94,6 +96,8 @@ export class ChartBlockModel extends DataBlockModel<ChartBlockModelStructure> {
|
||||
private __onResourceRefresh = () => this.renderChart();
|
||||
private __eventsBoundChart?: EChartsType;
|
||||
private __eventsBoundRaw?: string;
|
||||
private chartEventsCleanup?: ChartEventCleanup;
|
||||
private __eventsApplyToken = 0;
|
||||
private lastRefreshSnapshot: ChartDirtyRefreshSnapshot | null = null;
|
||||
private dirtyRefreshing = false;
|
||||
|
||||
@@ -213,6 +217,31 @@ export class ChartBlockModel extends DataBlockModel<ChartBlockModelStructure> {
|
||||
}
|
||||
}
|
||||
|
||||
private resetEventsBound() {
|
||||
this.__eventsBoundChart = undefined;
|
||||
this.__eventsBoundRaw = undefined;
|
||||
}
|
||||
|
||||
private async runChartEventCleanups(cleanups: ChartEventCleanup[]) {
|
||||
for (const cleanup of cleanups.slice().reverse()) {
|
||||
try {
|
||||
await cleanup();
|
||||
} catch (error) {
|
||||
console.error('Chart events cleanup error:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async cleanupChartEvents() {
|
||||
const cleanup = this.chartEventsCleanup;
|
||||
this.chartEventsCleanup = undefined;
|
||||
this.resetEventsBound();
|
||||
|
||||
if (cleanup) {
|
||||
await this.runChartEventCleanups([cleanup]);
|
||||
}
|
||||
}
|
||||
|
||||
async buildQueryRequest(query: any) {
|
||||
if (!query || query?.mode === 'sql') {
|
||||
return query;
|
||||
@@ -513,48 +542,74 @@ export class ChartBlockModel extends DataBlockModel<ChartBlockModelStructure> {
|
||||
|
||||
// 应用事件配置(仅设置,不负责渲染)
|
||||
async applyEvents(raw?: string, chartInstance?: EChartsType) {
|
||||
if (!raw) return;
|
||||
const applyToken = ++this.__eventsApplyToken;
|
||||
|
||||
if (!raw) {
|
||||
await this.cleanupChartEvents();
|
||||
return;
|
||||
}
|
||||
|
||||
if (chartInstance) {
|
||||
await this.runChartEvents(raw, chartInstance);
|
||||
await this.runChartEvents(raw, chartInstance, applyToken);
|
||||
return;
|
||||
}
|
||||
|
||||
const chart = (this.context.chartRef as any).current as EChartsType | null;
|
||||
if (chart) {
|
||||
await this.runChartEvents(raw, chart);
|
||||
await this.runChartEvents(raw, chart, applyToken);
|
||||
return;
|
||||
}
|
||||
|
||||
this.context.onRefReady(this.context.chartRef, async () => {
|
||||
if (applyToken !== this.__eventsApplyToken) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentChart = (this.context.chartRef as any).current as EChartsType | null;
|
||||
if (currentChart) {
|
||||
await this.runChartEvents(raw, currentChart);
|
||||
if (currentChart && applyToken === this.__eventsApplyToken) {
|
||||
await this.runChartEvents(raw, currentChart, applyToken);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private async runChartEvents(raw: string, chart: EChartsType) {
|
||||
private async runChartEvents(raw: string, chart: EChartsType, applyToken: number) {
|
||||
if (this.shouldSkipApplyEvents(raw, chart)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.cleanupChartEvents();
|
||||
if (applyToken !== this.__eventsApplyToken) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.markEventsBound(raw, chart);
|
||||
const cleanups: ChartEventCleanup[] = [];
|
||||
|
||||
try {
|
||||
const { success, error, timeout } = await this.context.runjs(raw, {
|
||||
const { success, value, error, timeout } = await this.context.runjs(raw, {
|
||||
chart,
|
||||
});
|
||||
if (success) {
|
||||
if (typeof value === 'function') {
|
||||
cleanups.push(value);
|
||||
}
|
||||
if (applyToken !== this.__eventsApplyToken) {
|
||||
this.clearEventsBound(raw, chart);
|
||||
await this.runChartEventCleanups(cleanups);
|
||||
return;
|
||||
}
|
||||
this.chartEventsCleanup = cleanups.length ? () => this.runChartEventCleanups(cleanups) : undefined;
|
||||
return;
|
||||
}
|
||||
|
||||
this.clearEventsBound(raw, chart);
|
||||
await this.runChartEventCleanups(cleanups);
|
||||
if (error || timeout) {
|
||||
console.error('applyEvents runjs error:', error || 'timeout');
|
||||
}
|
||||
} catch (error) {
|
||||
this.clearEventsBound(raw, chart);
|
||||
await this.runChartEventCleanups(cleanups);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -739,9 +794,7 @@ ChartBlockModel.registerFlow({
|
||||
});
|
||||
|
||||
// 事件部分
|
||||
if (chart.events?.raw) {
|
||||
await ctx.model.applyEvents(chart.events?.raw);
|
||||
}
|
||||
await ctx.model.applyEvents(chart.events?.raw);
|
||||
} catch (error) {
|
||||
console.error('ChartBlockModel chartSettings configure flow handler() error:', error);
|
||||
}
|
||||
|
||||
+4
-3
@@ -14,14 +14,15 @@ import { useT } from '../../locale';
|
||||
import { FunctionOutlined } from '@ant-design/icons';
|
||||
import { observer, useFlowSettingsContext } from '@nocobase/flow-engine';
|
||||
|
||||
const DEFAULT_EVENTS_RAW = `// chart.off('click');
|
||||
// chart.on('click', 'series', function() {
|
||||
const DEFAULT_EVENTS_RAW = `// const handler = function() {
|
||||
// ctx.openView(ctx.model.uid + '-1', {
|
||||
// mode: 'dialog',
|
||||
// size: 'large',
|
||||
// defineProperties: {}, // inject context into the new view
|
||||
// });
|
||||
// });
|
||||
// };
|
||||
// chart.on('click', 'series', handler);
|
||||
// return () => chart.off('click', handler);
|
||||
`;
|
||||
|
||||
const getFormValues = (ctx: any) => ctx.getStepFormValues('chartSettings', 'configure') || {};
|
||||
|
||||
+118
-2
@@ -244,8 +244,124 @@ describe('ChartBlockModel chart events binding', () => {
|
||||
await model.applyEvents(raw, chartB);
|
||||
|
||||
expect(runjs).toHaveBeenCalledTimes(2);
|
||||
expect(runjs).toHaveBeenNthCalledWith(1, raw, { chart: chartA });
|
||||
expect(runjs).toHaveBeenNthCalledWith(2, raw, { chart: chartB });
|
||||
expect(runjs).toHaveBeenNthCalledWith(1, raw, expect.objectContaining({ chart: chartA }));
|
||||
expect(runjs).toHaveBeenNthCalledWith(2, raw, expect.objectContaining({ chart: chartB }));
|
||||
});
|
||||
|
||||
it('runs the previous cleanup function before applying different raw events', async () => {
|
||||
const { model } = setupModel({
|
||||
mode: 'builder',
|
||||
collectionPath: ['main', 'orders'],
|
||||
});
|
||||
const firstCleanup = vi.fn();
|
||||
const secondCleanup = vi.fn();
|
||||
const chart = {} as any;
|
||||
|
||||
vi.spyOn(model.context, 'runjs').mockImplementation(async (raw: string) => {
|
||||
return { success: true, value: raw === 'first' ? firstCleanup : secondCleanup };
|
||||
});
|
||||
|
||||
await model.applyEvents('first', chart);
|
||||
await model.applyEvents('second', chart);
|
||||
|
||||
expect(firstCleanup).toHaveBeenCalledTimes(1);
|
||||
expect(secondCleanup).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('runs returned cleanup functions when events are cleared', async () => {
|
||||
const { model } = setupModel({
|
||||
mode: 'builder',
|
||||
collectionPath: ['main', 'orders'],
|
||||
});
|
||||
const cleanup = vi.fn();
|
||||
const chart = {} as any;
|
||||
|
||||
vi.spyOn(model.context, 'runjs').mockResolvedValue({ success: true, value: cleanup });
|
||||
|
||||
await model.applyEvents('return cleanup;', chart);
|
||||
await model.applyEvents(undefined, chart);
|
||||
|
||||
expect(cleanup).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('ignores stale onRefReady callbacks after chart events are cleared', async () => {
|
||||
const { model } = setupModel({
|
||||
mode: 'builder',
|
||||
collectionPath: ['main', 'orders'],
|
||||
});
|
||||
const chartRef = { current: null as any };
|
||||
const chart = {} as any;
|
||||
let readyCallback: (() => Promise<void>) | undefined;
|
||||
const runjs = vi.spyOn(model.context, 'runjs').mockResolvedValue({ success: true, value: undefined });
|
||||
|
||||
model.context.defineProperty('chartRef', { value: chartRef });
|
||||
vi.spyOn(model.context, 'onRefReady').mockImplementation((_ref: any, callback: any) => {
|
||||
readyCallback = callback;
|
||||
});
|
||||
|
||||
await model.applyEvents('chart.on("click", () => {})');
|
||||
await model.applyEvents(undefined);
|
||||
|
||||
chartRef.current = chart;
|
||||
await readyCallback?.();
|
||||
|
||||
expect(runjs).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ignores stale onRefReady callbacks after chart events are replaced', async () => {
|
||||
const { model } = setupModel({
|
||||
mode: 'builder',
|
||||
collectionPath: ['main', 'orders'],
|
||||
});
|
||||
const chartRef = { current: null as any };
|
||||
const chart = {} as any;
|
||||
const readyCallbacks: (() => Promise<void>)[] = [];
|
||||
const runjs = vi.spyOn(model.context, 'runjs').mockResolvedValue({ success: true, value: undefined });
|
||||
|
||||
model.context.defineProperty('chartRef', { value: chartRef });
|
||||
vi.spyOn(model.context, 'onRefReady').mockImplementation((_ref: any, callback: any) => {
|
||||
readyCallbacks.push(callback);
|
||||
});
|
||||
|
||||
await model.applyEvents('first');
|
||||
await model.applyEvents('second');
|
||||
|
||||
chartRef.current = chart;
|
||||
await readyCallbacks[0]?.();
|
||||
await readyCallbacks[1]?.();
|
||||
|
||||
expect(runjs).toHaveBeenCalledTimes(1);
|
||||
expect(runjs).toHaveBeenCalledWith('second', expect.objectContaining({ chart }));
|
||||
});
|
||||
|
||||
it('does not run stale chart events after asynchronous cleanup finishes', async () => {
|
||||
const { model } = setupModel({
|
||||
mode: 'builder',
|
||||
collectionPath: ['main', 'orders'],
|
||||
});
|
||||
const chart = {} as any;
|
||||
let resolveCleanup: (() => void) | undefined;
|
||||
const cleanup = vi.fn(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
resolveCleanup = resolve;
|
||||
}),
|
||||
);
|
||||
const runjs = vi
|
||||
.spyOn(model.context, 'runjs')
|
||||
.mockResolvedValueOnce({ success: true, value: cleanup })
|
||||
.mockResolvedValueOnce({ success: true, value: undefined });
|
||||
|
||||
await model.applyEvents('active', chart);
|
||||
const staleApply = model.applyEvents('stale', chart);
|
||||
await Promise.resolve();
|
||||
expect(cleanup).toHaveBeenCalledTimes(1);
|
||||
const clearApply = model.applyEvents(undefined, chart);
|
||||
resolveCleanup?.();
|
||||
await Promise.all([staleApply, clearApply]);
|
||||
|
||||
expect(runjs).toHaveBeenCalledTimes(1);
|
||||
expect(runjs).toHaveBeenCalledWith('active', expect.objectContaining({ chart }));
|
||||
});
|
||||
|
||||
it('clears the bound marker when chart events throw so the same chart can retry', async () => {
|
||||
|
||||
Reference in New Issue
Block a user