mirror of
https://github.com/ecomfe/vue-echarts.git
synced 2025-10-28 11:33:57 +08:00
fix: refactor slot implementation and improve types
- Improved types. - Refactored slot implementation to make it more robust. - Modified the `setOption` behavior to ensure it respects the `manual-update` flag. - Renamed the `smart-update` files to `update`. - Improved warnings. - Added more tests.
This commit is contained in:
@ -155,7 +155,7 @@ See more examples [here](https://github.com/ecomfe/vue-echarts/tree/main/demo).
|
|||||||
|
|
||||||
#### Smart Update
|
#### Smart Update
|
||||||
- If you supply `update-options` (via prop or injection), Vue ECharts forwards it directly to `setOption` and skips the planner.
|
- If you supply `update-options` (via prop or injection), Vue ECharts forwards it directly to `setOption` and skips the planner.
|
||||||
- Manual `setOption` calls (only available when `manual-update` is `true`) behave like native ECharts, honouring only the per-call override you pass in.
|
- Manual `setOption` calls (only available when `manual-update` is `true`) behave like native ECharts, honouring only the per-call override you pass in and are not carried across re-initializations.
|
||||||
- Otherwise, Vue ECharts analyses the change: removed objects become `null`, removed arrays become `[]` with `replaceMerge`, ID/anonymous deletions trigger `replaceMerge`, and risky changes fall back to `notMerge: true`.
|
- Otherwise, Vue ECharts analyses the change: removed objects become `null`, removed arrays become `[]` with `replaceMerge`, ID/anonymous deletions trigger `replaceMerge`, and risky changes fall back to `notMerge: true`.
|
||||||
|
|
||||||
- `update-options: object`
|
- `update-options: object`
|
||||||
@ -184,7 +184,7 @@ See more examples [here](https://github.com/ecomfe/vue-echarts/tree/main/demo).
|
|||||||
|
|
||||||
- `manual-update: boolean` (default: `false`)
|
- `manual-update: boolean` (default: `false`)
|
||||||
|
|
||||||
For performance critical scenarios (having a large dataset) we'd better bypass Vue's reactivity system for `option` prop. By specifying `manual-update` prop with `true` and not providing `option` prop, the dataset won't be watched any more. After doing so, you need to retrieve the component instance with `ref` and manually call `setOption` method to update the chart (manual `setOption` calls are ignored when `manual-update` is `false`).
|
Handy for performance-sensitive charts (large or high-frequency updates). When set to `true`, Vue only uses the `option` prop for the initial render; later prop changes do nothing and you must drive updates via `setOption` on a template ref. If the chart re-initializes (for example due to `init-options` changes, flipping `manual-update`, or a remount), the manual state is discarded and the chart is rendered again from the current `option` value.
|
||||||
|
|
||||||
### Events
|
### Events
|
||||||
|
|
||||||
@ -245,7 +245,7 @@ Vue ECharts support the following events:
|
|||||||
- `zr:dblclick`
|
- `zr:dblclick`
|
||||||
- `zr:contextmenu`
|
- `zr:contextmenu`
|
||||||
|
|
||||||
See supported events [here →](https://echarts.apache.org/en/api.html#events)
|
See supported events in the [ECharts API reference →](https://echarts.apache.org/en/api.html#events)
|
||||||
|
|
||||||
#### Native DOM Events
|
#### Native DOM Events
|
||||||
|
|
||||||
@ -339,7 +339,7 @@ export default {
|
|||||||
|
|
||||||
Vue ECharts allows you to define ECharts option's [`tooltip.formatter`](https://echarts.apache.org/en/option.html#tooltip.formatter) and [`toolbox.feature.dataView.optionToContent`](https://echarts.apache.org/en/option.html#toolbox.feature.dataView.optionToContent) callbacks via Vue slots instead of defining them in your `option` object. This simplifies custom HTMLElement rendering using familiar Vue templating.
|
Vue ECharts allows you to define ECharts option's [`tooltip.formatter`](https://echarts.apache.org/en/option.html#tooltip.formatter) and [`toolbox.feature.dataView.optionToContent`](https://echarts.apache.org/en/option.html#toolbox.feature.dataView.optionToContent) callbacks via Vue slots instead of defining them in your `option` object. This simplifies custom HTMLElement rendering using familiar Vue templating.
|
||||||
|
|
||||||
**Slot Naming Convention**
|
#### Slot Naming Convention
|
||||||
|
|
||||||
- Slot names begin with `tooltip`/`dataView`, followed by hyphen-separated path segments to the target.
|
- Slot names begin with `tooltip`/`dataView`, followed by hyphen-separated path segments to the target.
|
||||||
- Each segment corresponds to an `option` property name or an array index (for arrays, use the numeric index).
|
- Each segment corresponds to an `option` property name or an array index (for arrays, use the numeric index).
|
||||||
|
|||||||
@ -155,7 +155,7 @@ app.component('VChart', VueECharts)
|
|||||||
|
|
||||||
#### 智能更新
|
#### 智能更新
|
||||||
- 如果提供了 `update-options`(或通过 inject 注入),Vue ECharts 会直接把它传给 `setOption`,不会执行智能计划。
|
- 如果提供了 `update-options`(或通过 inject 注入),Vue ECharts 会直接把它传给 `setOption`,不会执行智能计划。
|
||||||
- 手动调用 `setOption`(仅当 `manual-update` 为 `true` 时可用)与原生 ECharts 保持一致,只使用本次调用传入的参数。
|
- 手动调用 `setOption`(仅当 `manual-update` 为 `true` 时可用)与原生 ECharts 保持一致,只使用本次调用传入的参数,重新初始化后不会保留这些调用的效果。
|
||||||
- 其他情况下,Vue ECharts 会分析差异:删除的对象写入 `null`,删除的数组写入 `[]` 并加入 `replaceMerge`,ID/匿名项减少时追加 `replaceMerge`,风险较高的变更会退回 `notMerge: true`。
|
- 其他情况下,Vue ECharts 会分析差异:删除的对象写入 `null`,删除的数组写入 `[]` 并加入 `replaceMerge`,ID/匿名项减少时追加 `replaceMerge`,风险较高的变更会退回 `notMerge: true`。
|
||||||
|
|
||||||
- `update-options: object`
|
- `update-options: object`
|
||||||
@ -184,7 +184,7 @@ app.component('VChart', VueECharts)
|
|||||||
|
|
||||||
- `manual-update: boolean`(默认值 `false`)
|
- `manual-update: boolean`(默认值 `false`)
|
||||||
|
|
||||||
在性能敏感(数据量很大)的场景下,我们最好对于 `option` prop 绕过 Vue 的响应式系统。当将 `manual-update` 指定为 `true` 且不传入 `option` prop 时,数据将不会被监听。此时需要用 `ref` 获取组件实例并手动调用 `setOption` 来更新图表(当 `manual-update` 为 `false` 时,手动调用 `setOption` 会被忽略)。
|
适用于性能敏感的场景(例如 `option` 很大或更新频繁)。设为 `true` 时,`option` 只参与首次渲染,后续的 prop 变更不会触发图表更新,需要你通过模板 `ref` 手动调用 `setOption`。如果图表因为修改 `init-options`、切换 `manual-update` 或重新挂载而被重新初始化,之前通过 `setOption` 写入的状态会丢失,并重新使用当前的 `option` 值渲染。
|
||||||
|
|
||||||
### 事件
|
### 事件
|
||||||
|
|
||||||
@ -245,7 +245,7 @@ Vue ECharts 支持如下事件:
|
|||||||
- `zr:dblclick`
|
- `zr:dblclick`
|
||||||
- `zr:contextmenu`
|
- `zr:contextmenu`
|
||||||
|
|
||||||
请参考支持的事件列表。[前往 →](https://echarts.apache.org/zh/api.html#events)
|
更多事件说明可参考 [ECharts 官方事件文档 →](https://echarts.apache.org/zh/api.html#events)
|
||||||
|
|
||||||
#### 原生 DOM 事件
|
#### 原生 DOM 事件
|
||||||
|
|
||||||
@ -330,7 +330,7 @@ export default {
|
|||||||
- `dispose` [→](https://echarts.apache.org/zh/api.html#echartsInstance.dispose)
|
- `dispose` [→](https://echarts.apache.org/zh/api.html#echartsInstance.dispose)
|
||||||
|
|
||||||
> [!NOTE]
|
> [!NOTE]
|
||||||
> 如下 ECharts 实例方法没有被暴露,因为它们的功能已经通过组件 [props](#props) 提供了:
|
> 如下 ECharts 实例方法没有被暴露,因为它们的功能已经通过组件 [prop](#props) 提供了:
|
||||||
>
|
>
|
||||||
> - [`showLoading`](https://echarts.apache.org/zh/api.html#echartsInstance.showLoading) / [`hideLoading`](https://echarts.apache.org/zh/api.html#echartsInstance.hideLoading):请使用 `loading` 和 `loading-options` prop。
|
> - [`showLoading`](https://echarts.apache.org/zh/api.html#echartsInstance.showLoading) / [`hideLoading`](https://echarts.apache.org/zh/api.html#echartsInstance.hideLoading):请使用 `loading` 和 `loading-options` prop。
|
||||||
> - [`setTheme`](https://echarts.apache.org/zh/api.html#echartsInstance.setTheme):请使用 `theme` prop。
|
> - [`setTheme`](https://echarts.apache.org/zh/api.html#echartsInstance.setTheme):请使用 `theme` prop。
|
||||||
@ -339,7 +339,7 @@ export default {
|
|||||||
|
|
||||||
Vue ECharts 允许你通过 Vue 插槽来定义 ECharts 配置中的 [`tooltip.formatter`](https://echarts.apache.org/zh/option.html#tooltip.formatter) 和 [`toolbox.feature.dataView.optionToContent`](https://echarts.apache.org/zh/option.html#toolbox.feature.dataView.optionToContent) 回调,而无需在 `option` 对象中定义它们。你可以使用熟悉的 Vue 模板语法来编写自定义提示框或数据视图中的内容。
|
Vue ECharts 允许你通过 Vue 插槽来定义 ECharts 配置中的 [`tooltip.formatter`](https://echarts.apache.org/zh/option.html#tooltip.formatter) 和 [`toolbox.feature.dataView.optionToContent`](https://echarts.apache.org/zh/option.html#toolbox.feature.dataView.optionToContent) 回调,而无需在 `option` 对象中定义它们。你可以使用熟悉的 Vue 模板语法来编写自定义提示框或数据视图中的内容。
|
||||||
|
|
||||||
**插槽命名约定**
|
#### 插槽命名约定
|
||||||
|
|
||||||
- 插槽名称以 `tooltip`/`dataView` 开头,后面跟随用连字符分隔的路径片段,用于定位目标。
|
- 插槽名称以 `tooltip`/`dataView` 开头,后面跟随用连字符分隔的路径片段,用于定位目标。
|
||||||
- 每个路径片段对应 `option` 对象的属性名或数组索引(数组索引使用数字形式)。
|
- 每个路径片段对应 `option` 对象的属性名或数组索引(数组索引使用数字形式)。
|
||||||
|
|||||||
@ -7,7 +7,7 @@ import {
|
|||||||
TooltipComponent,
|
TooltipComponent,
|
||||||
} from "echarts/components";
|
} from "echarts/components";
|
||||||
import { shallowRef } from "vue";
|
import { shallowRef } from "vue";
|
||||||
import type { InitOptions, LoadingOptions, Option } from "../../src/types";
|
import type { LoadingOptions, Option } from "../../src/types";
|
||||||
import VChart from "../../src/ECharts";
|
import VChart from "../../src/ECharts";
|
||||||
import VExample from "./Example.vue";
|
import VExample from "./Example.vue";
|
||||||
import worldMap from "../data/world.json";
|
import worldMap from "../data/world.json";
|
||||||
@ -40,7 +40,7 @@ function isFlightDataset(value: unknown): value is FlightDataset {
|
|||||||
|
|
||||||
const chart = shallowRef<ChartInstance | null>(null);
|
const chart = shallowRef<ChartInstance | null>(null);
|
||||||
const loading = shallowRef(false);
|
const loading = shallowRef(false);
|
||||||
const loaded = shallowRef(false);
|
const flightData = shallowRef<FlightDataset | null>(null);
|
||||||
|
|
||||||
const loadingOptions: LoadingOptions = {
|
const loadingOptions: LoadingOptions = {
|
||||||
text: "",
|
text: "",
|
||||||
@ -50,29 +50,39 @@ const loadingOptions: LoadingOptions = {
|
|||||||
zlevel: 0,
|
zlevel: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
const initOptions: InitOptions = {
|
async function load(): Promise<FlightDataset> {
|
||||||
renderer: "canvas",
|
if (flightData.value) {
|
||||||
};
|
return flightData.value;
|
||||||
|
|
||||||
function load(): void {
|
|
||||||
loaded.value = true;
|
|
||||||
loading.value = true;
|
|
||||||
|
|
||||||
import("../data/flight.json").then(({ default: rawData }) => {
|
|
||||||
if (!isFlightDataset(rawData)) {
|
|
||||||
loading.value = false;
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
loading.value = true;
|
||||||
|
|
||||||
|
const { default: data } = await import("../data/flight.json");
|
||||||
|
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
|
|
||||||
|
if (!isFlightDataset(data)) {
|
||||||
|
throw new Error("Invalid flight dataset");
|
||||||
|
}
|
||||||
|
|
||||||
|
flightData.value = data;
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function render(): Promise<void> {
|
||||||
|
let data = flightData.value;
|
||||||
|
if (!data) {
|
||||||
|
data = await load();
|
||||||
|
}
|
||||||
|
|
||||||
const getAirportCoord = (index: number): [number, number] => [
|
const getAirportCoord = (index: number): [number, number] => [
|
||||||
rawData.airports[index][3],
|
data.airports[index][3],
|
||||||
rawData.airports[index][4],
|
data.airports[index][4],
|
||||||
];
|
];
|
||||||
|
|
||||||
type Route = [[number, number], [number, number]];
|
type Route = [[number, number], [number, number]];
|
||||||
const routes = rawData.routes.map<Route>(([, from, to]) => {
|
const routes = data.routes.map<Route>(([, from, to]) => {
|
||||||
const fromCoord = getAirportCoord(from);
|
const fromCoord = getAirportCoord(from);
|
||||||
const toCoord = getAirportCoord(to);
|
const toCoord = getAirportCoord(to);
|
||||||
return [fromCoord, toCoord];
|
return [fromCoord, toCoord];
|
||||||
@ -91,9 +101,9 @@ function load(): void {
|
|||||||
backgroundColor: "#003",
|
backgroundColor: "#003",
|
||||||
tooltip: {
|
tooltip: {
|
||||||
formatter({ dataIndex }: { dataIndex: number }) {
|
formatter({ dataIndex }: { dataIndex: number }) {
|
||||||
const route = rawData.routes[dataIndex];
|
const route = data.routes[dataIndex];
|
||||||
const fromName = rawData.airports[route[1]][1];
|
const fromName = data.airports[route[1]][1];
|
||||||
const toName = rawData.airports[route[2]][1];
|
const toName = data.airports[route[2]][1];
|
||||||
return `${fromName} > ${toName}`;
|
return `${fromName} > ${toName}`;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@ -123,7 +133,6 @@ function load(): void {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
} satisfies Option);
|
} satisfies Option);
|
||||||
});
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@ -134,7 +143,6 @@ function load(): void {
|
|||||||
autoresize
|
autoresize
|
||||||
:loading="loading"
|
:loading="loading"
|
||||||
:loading-options="loadingOptions"
|
:loading-options="loadingOptions"
|
||||||
:init-options="initOptions"
|
|
||||||
style="background-color: #003"
|
style="background-color: #003"
|
||||||
manual-update
|
manual-update
|
||||||
/>
|
/>
|
||||||
@ -144,7 +152,7 @@ function load(): void {
|
|||||||
use cases.
|
use cases.
|
||||||
</p>
|
</p>
|
||||||
<p class="actions">
|
<p class="actions">
|
||||||
<button :disabled="loaded" @click="load">Load</button>
|
<button :disabled="loading" @click="render">Load</button>
|
||||||
</p>
|
</p>
|
||||||
</template>
|
</template>
|
||||||
</VExample>
|
</VExample>
|
||||||
|
|||||||
@ -11,7 +11,6 @@ import {
|
|||||||
nextTick,
|
nextTick,
|
||||||
watchEffect,
|
watchEffect,
|
||||||
toValue,
|
toValue,
|
||||||
warn,
|
|
||||||
} from "vue";
|
} from "vue";
|
||||||
import { init as initChart } from "echarts/core";
|
import { init as initChart } from "echarts/core";
|
||||||
import type { EChartsOption } from "echarts";
|
import type { EChartsOption } from "echarts";
|
||||||
@ -25,10 +24,10 @@ import {
|
|||||||
useSlotOption,
|
useSlotOption,
|
||||||
} from "./composables";
|
} from "./composables";
|
||||||
import type { PublicMethods, SlotsTypes } from "./composables";
|
import type { PublicMethods, SlotsTypes } from "./composables";
|
||||||
import { isOn, omitOn } from "./utils";
|
import { isOn, omitOn, warn } from "./utils";
|
||||||
import { register, TAG_NAME } from "./wc";
|
import { register, TAG_NAME } from "./wc";
|
||||||
import { planUpdate } from "./smart-update";
|
import { planUpdate } from "./update";
|
||||||
import type { Signature, UpdatePlan } from "./smart-update";
|
import type { Signature, UpdatePlan } from "./update";
|
||||||
|
|
||||||
import type { PropType, InjectionKey } from "vue";
|
import type { PropType, InjectionKey } from "vue";
|
||||||
import type {
|
import type {
|
||||||
@ -81,7 +80,6 @@ export default defineComponent({
|
|||||||
|
|
||||||
const { autoresize, manualUpdate, loading, loadingOptions } = toRefs(props);
|
const { autoresize, manualUpdate, loading, loadingOptions } = toRefs(props);
|
||||||
|
|
||||||
const realOption = computed(() => props.option || {});
|
|
||||||
const realTheme = computed(() => props.theme || toValue(defaultTheme));
|
const realTheme = computed(() => props.theme || toValue(defaultTheme));
|
||||||
const realInitOptions = computed(
|
const realInitOptions = computed(
|
||||||
() => props.initOptions || toValue(defaultInitOptions) || undefined,
|
() => props.initOptions || toValue(defaultInitOptions) || undefined,
|
||||||
@ -186,7 +184,7 @@ export default defineComponent({
|
|||||||
listeners.set({ event, zr, once }, attrs[key]);
|
listeners.set({ event, zr, once }, attrs[key]);
|
||||||
});
|
});
|
||||||
|
|
||||||
function init(option?: Option, manual = false, override?: UpdateOptions) {
|
function init() {
|
||||||
if (!root.value) {
|
if (!root.value) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -235,10 +233,17 @@ export default defineComponent({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function commit() {
|
function commit() {
|
||||||
const opt = option || realOption.value;
|
const { option } = props;
|
||||||
if (opt) {
|
|
||||||
applyOption(instance, opt, override, manual);
|
if (manualUpdate.value) {
|
||||||
override = undefined;
|
if (option) {
|
||||||
|
applyOption(instance, option, undefined, true);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (option) {
|
||||||
|
applyOption(instance, option);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -259,9 +264,7 @@ export default defineComponent({
|
|||||||
lazyUpdate?: boolean,
|
lazyUpdate?: boolean,
|
||||||
) => {
|
) => {
|
||||||
if (!props.manualUpdate) {
|
if (!props.manualUpdate) {
|
||||||
warn(
|
warn("`setOption` is only available when `manual-update` is `true`.");
|
||||||
"[vue-echarts] setOption is only available when manual-update is true.",
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -283,23 +286,21 @@ export default defineComponent({
|
|||||||
lastSignature = undefined;
|
lastSignature = undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
let unwatchOption: (() => void) | null = null;
|
|
||||||
watch(
|
watch(
|
||||||
manualUpdate,
|
|
||||||
(manualUpdate) => {
|
|
||||||
if (typeof unwatchOption === "function") {
|
|
||||||
unwatchOption();
|
|
||||||
unwatchOption = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!manualUpdate) {
|
|
||||||
unwatchOption = watch(
|
|
||||||
() => props.option,
|
() => props.option,
|
||||||
(option) => {
|
(option) => {
|
||||||
if (!option) {
|
if (!option) {
|
||||||
lastSignature = undefined;
|
lastSignature = undefined;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (manualUpdate.value) {
|
||||||
|
warn(
|
||||||
|
"`option` prop changes are ignored when `manual-update` is `true`.",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!chart.value) {
|
if (!chart.value) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -308,15 +309,9 @@ export default defineComponent({
|
|||||||
},
|
},
|
||||||
{ deep: true },
|
{ deep: true },
|
||||||
);
|
);
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
immediate: true,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
realInitOptions,
|
[manualUpdate, realInitOptions],
|
||||||
() => {
|
() => {
|
||||||
cleanup();
|
cleanup();
|
||||||
init();
|
init();
|
||||||
|
|||||||
@ -26,25 +26,26 @@ export type PublicMethods = Pick<EChartsType, MethodName>;
|
|||||||
export function usePublicAPI(
|
export function usePublicAPI(
|
||||||
chart: Ref<EChartsType | undefined>,
|
chart: Ref<EChartsType | undefined>,
|
||||||
): PublicMethods {
|
): PublicMethods {
|
||||||
function makePublicMethod<T extends MethodName>(
|
function makePublicMethod<T extends MethodName>(name: T): EChartsType[T] {
|
||||||
name: T,
|
// Return a function that matches the signature of EChartsType[T]
|
||||||
): (...args: Parameters<EChartsType[T]>) => ReturnType<EChartsType[T]> {
|
const fn = function (this: unknown, ...args: unknown[]): unknown {
|
||||||
return (...args) => {
|
|
||||||
if (!chart.value) {
|
if (!chart.value) {
|
||||||
throw new Error("ECharts is not initialized yet.");
|
throw new Error("ECharts is not initialized yet.");
|
||||||
}
|
}
|
||||||
return (chart.value[name] as any).apply(chart.value, args);
|
// Use Reflect.apply to call the method with proper context
|
||||||
|
return Reflect.apply(chart.value[name], chart.value, args);
|
||||||
};
|
};
|
||||||
|
return fn as EChartsType[T];
|
||||||
}
|
}
|
||||||
|
|
||||||
function makePublicMethods(): PublicMethods {
|
// Build the methods object with proper typing
|
||||||
const methods = Object.create(null);
|
const methods = METHOD_NAMES.reduce(
|
||||||
METHOD_NAMES.forEach((name) => {
|
(acc, name) => {
|
||||||
methods[name] = makePublicMethod(name);
|
acc[name] = makePublicMethod(name);
|
||||||
});
|
return acc;
|
||||||
|
},
|
||||||
|
{} as Record<MethodName, unknown>,
|
||||||
|
) as PublicMethods;
|
||||||
|
|
||||||
return methods as PublicMethods;
|
return methods;
|
||||||
}
|
|
||||||
|
|
||||||
return makePublicMethods();
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,11 +6,10 @@ import {
|
|||||||
onMounted,
|
onMounted,
|
||||||
shallowRef,
|
shallowRef,
|
||||||
shallowReactive,
|
shallowReactive,
|
||||||
warn,
|
|
||||||
} from "vue";
|
} from "vue";
|
||||||
import type { Slots, SlotsType } from "vue";
|
import type { Slots, SlotsType } from "vue";
|
||||||
import type { Option } from "../types";
|
import type { Option } from "../types";
|
||||||
import { isBrowser, isValidArrayIndex, isSameSet } from "../utils";
|
import { isBrowser, isValidArrayIndex, isSameSet, warn } from "../utils";
|
||||||
import type { TooltipComponentFormatterCallbackParams } from "echarts";
|
import type { TooltipComponentFormatterCallbackParams } from "echarts";
|
||||||
|
|
||||||
const SLOT_OPTION_PATHS = {
|
const SLOT_OPTION_PATHS = {
|
||||||
@ -52,7 +51,11 @@ export function useSlotOption(slots: Slots, onSlotsChange: () => void) {
|
|||||||
return h(
|
return h(
|
||||||
"div",
|
"div",
|
||||||
{
|
{
|
||||||
ref: (el) => (containers[slotName] = el as HTMLElement),
|
ref: (el) => {
|
||||||
|
if (el instanceof HTMLElement) {
|
||||||
|
containers[slotName] = el;
|
||||||
|
}
|
||||||
|
},
|
||||||
style: { display: "contents" },
|
style: { display: "contents" },
|
||||||
},
|
},
|
||||||
slotContent,
|
slotContent,
|
||||||
@ -62,38 +65,68 @@ export function useSlotOption(slots: Slots, onSlotsChange: () => void) {
|
|||||||
: undefined;
|
: undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Shallow-clone the option along the path and override the target callback
|
// Helper to check if a value is a plain object
|
||||||
|
function isObject(val: unknown): val is Record<string, unknown> {
|
||||||
|
return val !== null && typeof val === "object" && !Array.isArray(val);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shallow-clone the option along each path and override the target callback
|
||||||
function patchOption(src: Option): Option {
|
function patchOption(src: Option): Option {
|
||||||
const root = { ...src };
|
const root = { ...src } as Record<string, unknown>;
|
||||||
|
|
||||||
|
// Ensure the child at `seg` is a writable container (cloned or newly created).
|
||||||
|
// Returns the child container, or undefined if traversal is blocked by a primitive.
|
||||||
|
const ensureChild = (
|
||||||
|
parent: Record<string, unknown>,
|
||||||
|
seg: string,
|
||||||
|
): Record<string, unknown> | undefined => {
|
||||||
|
const next = parent[seg];
|
||||||
|
|
||||||
|
if (Array.isArray(next)) {
|
||||||
|
parent[seg] = [...next];
|
||||||
|
return parent[seg] as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
if (isObject(next)) {
|
||||||
|
parent[seg] = { ...next };
|
||||||
|
return parent[seg] as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
if (next === undefined) {
|
||||||
|
parent[seg] = isValidArrayIndex(seg) ? [] : {};
|
||||||
|
return parent[seg] as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
// Blocked by a non-container value
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
|
||||||
Object.keys(slots)
|
Object.keys(slots)
|
||||||
.filter((key) => {
|
.filter((key) => {
|
||||||
const isValidSlot = isValidSlotName(key);
|
const valid = isValidSlotName(key);
|
||||||
if (!isValidSlot) {
|
if (!valid) {
|
||||||
warn(`Invalid vue-echarts slot name: ${key}`);
|
warn(`Invalid slot name: ${key}`);
|
||||||
}
|
}
|
||||||
return isValidSlot;
|
return valid;
|
||||||
})
|
})
|
||||||
.forEach((key) => {
|
.forEach((key) => {
|
||||||
const path = key.split("-");
|
const [prefix, ...rest] = key.split("-") as [SlotPrefix, ...string[]];
|
||||||
const prefix = path.shift() as SlotPrefix;
|
const tail = SLOT_OPTION_PATHS[prefix];
|
||||||
path.push(...SLOT_OPTION_PATHS[prefix]);
|
if (!tail) {
|
||||||
|
return;
|
||||||
let cur: any = root;
|
|
||||||
for (let i = 0; i < path.length - 1; i++) {
|
|
||||||
const seg = path[i];
|
|
||||||
const next = cur[seg];
|
|
||||||
|
|
||||||
// Shallow-clone the link; create empty shell if missing
|
|
||||||
cur[seg] = next
|
|
||||||
? Array.isArray(next)
|
|
||||||
? [...next]
|
|
||||||
: { ...next }
|
|
||||||
: isValidArrayIndex(seg)
|
|
||||||
? []
|
|
||||||
: {};
|
|
||||||
cur = cur[seg];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const path = [...rest, ...tail];
|
||||||
|
if (path.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Traverse to the parent of the leaf, cloning or creating along the way
|
||||||
|
let cur: Record<string, unknown> | undefined = root;
|
||||||
|
for (let i = 0; i < path.length - 1; i++) {
|
||||||
|
cur = cur && ensureChild(cur, path[i]);
|
||||||
|
if (!cur) {
|
||||||
|
return; // Blocked by a primitive — skip this key
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
cur[path[path.length - 1]] = (p: unknown) => {
|
cur[path[path.length - 1]] = (p: unknown) => {
|
||||||
initialized[key] = true;
|
initialized[key] = true;
|
||||||
params[key] = p;
|
params[key] = p;
|
||||||
@ -101,7 +134,7 @@ export function useSlotOption(slots: Slots, onSlotsChange: () => void) {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
return root;
|
return root as Option;
|
||||||
}
|
}
|
||||||
|
|
||||||
// `slots` is not reactive, so we need to watch it manually
|
// `slots` is not reactive, so we need to watch it manually
|
||||||
|
|||||||
16
src/utils.ts
16
src/utils.ts
@ -1,3 +1,5 @@
|
|||||||
|
import { warn as vueWarn } from "vue";
|
||||||
|
|
||||||
type Attrs = Record<string, any>;
|
type Attrs = Record<string, any>;
|
||||||
|
|
||||||
export function isBrowser(): boolean {
|
export function isBrowser(): boolean {
|
||||||
@ -34,10 +36,14 @@ export function isSameSet<T>(a: T[], b: T[]): boolean {
|
|||||||
const setA = new Set(a);
|
const setA = new Set(a);
|
||||||
const setB = new Set(b);
|
const setB = new Set(b);
|
||||||
|
|
||||||
if (setA.size !== setB.size) return false;
|
if (setA.size !== setB.size) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
for (const val of setA) {
|
for (const val of setA) {
|
||||||
if (!setB.has(val)) return false;
|
if (!setB.has(val)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
@ -46,3 +52,9 @@ export function isSameSet<T>(a: T[], b: T[]): boolean {
|
|||||||
export function isPlainObject(v: unknown): v is Record<string, unknown> {
|
export function isPlainObject(v: unknown): v is Record<string, unknown> {
|
||||||
return v != null && typeof v === "object" && !Array.isArray(v);
|
return v != null && typeof v === "object" && !Array.isArray(v);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const LOG_PREFIX = "[vue-echarts]";
|
||||||
|
|
||||||
|
export function warn(message: string): void {
|
||||||
|
vueWarn(`${LOG_PREFIX} ${message}`);
|
||||||
|
}
|
||||||
|
|||||||
@ -7,7 +7,7 @@ import {
|
|||||||
resetECharts,
|
resetECharts,
|
||||||
type ChartStub,
|
type ChartStub,
|
||||||
} from "./helpers/mock";
|
} from "./helpers/mock";
|
||||||
import type { UpdateOptions } from "../src/types";
|
import type { InitOptions, Option, UpdateOptions } from "../src/types";
|
||||||
import { withConsoleWarn } from "./helpers/dom";
|
import { withConsoleWarn } from "./helpers/dom";
|
||||||
import ECharts, { UPDATE_OPTIONS_KEY } from "../src/ECharts";
|
import ECharts, { UPDATE_OPTIONS_KEY } from "../src/ECharts";
|
||||||
import { renderChart } from "./helpers/renderChart";
|
import { renderChart } from "./helpers/renderChart";
|
||||||
@ -74,9 +74,9 @@ describe("ECharts component", () => {
|
|||||||
const manualOption = { series: [{ type: "bar", data: [1, 2, 3] }] };
|
const manualOption = { series: [{ type: "bar", data: [1, 2, 3] }] };
|
||||||
exposed.value.setOption(manualOption);
|
exposed.value.setOption(manualOption);
|
||||||
|
|
||||||
expect(chartStub.setOption).toHaveBeenCalledTimes(2);
|
expect(chartStub.setOption).toHaveBeenCalledTimes(1);
|
||||||
expect(chartStub.setOption.mock.calls[1][0]).toMatchObject(manualOption);
|
expect(chartStub.setOption.mock.calls[0][0]).toMatchObject(manualOption);
|
||||||
expect(chartStub.setOption.mock.calls[1][1]).toEqual({});
|
expect(chartStub.setOption.mock.calls[0][1]).toEqual({});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("ignores setOption when manual-update is false", async () => {
|
it("ignores setOption when manual-update is false", async () => {
|
||||||
@ -91,11 +91,113 @@ describe("ECharts component", () => {
|
|||||||
exposed.value.setOption({ title: { text: "ignored" } }, true);
|
exposed.value.setOption({ title: { text: "ignored" } }, true);
|
||||||
expect(chartStub.setOption).toHaveBeenCalledTimes(initialCalls);
|
expect(chartStub.setOption).toHaveBeenCalledTimes(initialCalls);
|
||||||
expect(warnSpy).toHaveBeenCalledWith(
|
expect(warnSpy).toHaveBeenCalledWith(
|
||||||
expect.stringContaining("[vue-echarts] setOption is only available"),
|
expect.stringContaining(
|
||||||
|
"[vue-echarts] `setOption` is only available when `manual-update` is `true`.",
|
||||||
|
),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("warns when option prop changes in manual-update mode", async () => {
|
||||||
|
const option = ref({ title: { text: "initial" } });
|
||||||
|
const exposed = shallowRef<any>();
|
||||||
|
|
||||||
|
renderChart(() => ({ option: option.value, manualUpdate: true }), exposed);
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {
|
||||||
|
// noop
|
||||||
|
});
|
||||||
|
|
||||||
|
option.value = { title: { text: "next" } };
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(warnSpy).toHaveBeenCalled();
|
||||||
|
expect(warnSpy.mock.calls[0][0]).toContain(
|
||||||
|
"[vue-echarts] `option` prop changes are ignored when `manual-update` is `true`.",
|
||||||
|
);
|
||||||
|
|
||||||
|
warnSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not replay manual option after initOptions-triggered reinit", async () => {
|
||||||
|
const initOptions = ref<InitOptions>({ renderer: "canvas" });
|
||||||
|
const exposed = shallowRef<any>();
|
||||||
|
|
||||||
|
renderChart(
|
||||||
|
() => ({ manualUpdate: true, initOptions: initOptions.value }),
|
||||||
|
exposed,
|
||||||
|
);
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
const manualOption: Option = {
|
||||||
|
title: { text: "manual" },
|
||||||
|
series: [{ type: "bar", data: [1, 2, 3] }],
|
||||||
|
};
|
||||||
|
|
||||||
|
exposed.value.setOption(manualOption);
|
||||||
|
expect(chartStub.setOption).toHaveBeenCalledTimes(1);
|
||||||
|
expect(chartStub.setOption.mock.calls[0][0]).toMatchObject(manualOption);
|
||||||
|
|
||||||
|
const firstStub = chartStub;
|
||||||
|
const replacementStub = enqueueChart();
|
||||||
|
chartStub = replacementStub;
|
||||||
|
|
||||||
|
initOptions.value = { renderer: "svg" as const };
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(firstStub.dispose).toHaveBeenCalledTimes(1);
|
||||||
|
expect(replacementStub.setOption).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("re-initializes manual chart from option prop after reinit", async () => {
|
||||||
|
const option = ref<Required<Option>>({
|
||||||
|
title: { text: "base" },
|
||||||
|
series: [{ type: "bar", data: [1] }],
|
||||||
|
});
|
||||||
|
const initOptions = ref<InitOptions>({ renderer: "canvas" });
|
||||||
|
const exposed = shallowRef<any>();
|
||||||
|
|
||||||
|
renderChart(
|
||||||
|
() => ({
|
||||||
|
option: option.value,
|
||||||
|
manualUpdate: true,
|
||||||
|
initOptions: initOptions.value,
|
||||||
|
}),
|
||||||
|
exposed,
|
||||||
|
);
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(chartStub.setOption).toHaveBeenCalledTimes(1);
|
||||||
|
expect(chartStub.setOption.mock.calls[0][0]).toMatchObject({
|
||||||
|
title: { text: "base" },
|
||||||
|
});
|
||||||
|
|
||||||
|
chartStub.setOption.mockClear();
|
||||||
|
|
||||||
|
const manualOption: Option = {
|
||||||
|
title: { text: "manual" },
|
||||||
|
series: [{ type: "bar", data: [2] }],
|
||||||
|
};
|
||||||
|
|
||||||
|
exposed.value.setOption(manualOption);
|
||||||
|
expect(chartStub.setOption).toHaveBeenCalledTimes(1);
|
||||||
|
expect(chartStub.setOption.mock.calls[0][0]).toMatchObject(manualOption);
|
||||||
|
|
||||||
|
const firstStub = chartStub;
|
||||||
|
const replacementStub = enqueueChart();
|
||||||
|
chartStub = replacementStub;
|
||||||
|
|
||||||
|
initOptions.value = { renderer: "svg" as const };
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(firstStub.dispose).toHaveBeenCalledTimes(1);
|
||||||
|
expect(replacementStub.setOption).toHaveBeenCalledTimes(1);
|
||||||
|
expect(replacementStub.setOption.mock.calls[0][0]).toMatchObject({
|
||||||
|
title: { text: "base" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("passes theme and initOptions props and reacts to theme changes", async () => {
|
it("passes theme and initOptions props and reacts to theme changes", async () => {
|
||||||
const option = ref({ title: { text: "brew" } });
|
const option = ref({ title: { text: "brew" } });
|
||||||
const theme = ref("dark");
|
const theme = ref("dark");
|
||||||
@ -173,6 +275,7 @@ describe("ECharts component", () => {
|
|||||||
const option = ref({ title: { text: "initial" } });
|
const option = ref({ title: { text: "initial" } });
|
||||||
const manualUpdate = ref(true);
|
const manualUpdate = ref(true);
|
||||||
const exposed = shallowRef<any>();
|
const exposed = shallowRef<any>();
|
||||||
|
const firstStub = chartStub;
|
||||||
|
|
||||||
renderChart(
|
renderChart(
|
||||||
() => ({
|
() => ({
|
||||||
@ -183,14 +286,33 @@ describe("ECharts component", () => {
|
|||||||
);
|
);
|
||||||
await nextTick();
|
await nextTick();
|
||||||
|
|
||||||
expect(chartStub.setOption).toHaveBeenCalledTimes(1);
|
expect(firstStub.setOption).toHaveBeenCalledTimes(1);
|
||||||
|
expect(firstStub.setOption.mock.calls[0][0]).toMatchObject({
|
||||||
|
title: { text: "initial" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {
|
||||||
|
// noop
|
||||||
|
});
|
||||||
|
|
||||||
option.value = { title: { text: "manual" } };
|
option.value = { title: { text: "manual" } };
|
||||||
await nextTick();
|
await nextTick();
|
||||||
expect(chartStub.setOption).toHaveBeenCalledTimes(1);
|
expect(firstStub.setOption).toHaveBeenCalledTimes(1);
|
||||||
|
expect(warnSpy).toHaveBeenCalled();
|
||||||
|
expect(warnSpy.mock.calls[0][0]).toContain(
|
||||||
|
"[vue-echarts] `option` prop changes are ignored when `manual-update` is `true`.",
|
||||||
|
);
|
||||||
|
warnSpy.mockClear();
|
||||||
|
|
||||||
|
const replacementStub = enqueueChart();
|
||||||
manualUpdate.value = false;
|
manualUpdate.value = false;
|
||||||
|
chartStub = replacementStub;
|
||||||
await nextTick();
|
await nextTick();
|
||||||
|
expect(firstStub.dispose).toHaveBeenCalledTimes(1);
|
||||||
|
expect(replacementStub.setOption).toHaveBeenCalledTimes(1);
|
||||||
|
expect(replacementStub.setOption.mock.calls[0][0]).toMatchObject({
|
||||||
|
title: { text: "manual" },
|
||||||
|
});
|
||||||
|
|
||||||
option.value = { title: { text: "reactive" } };
|
option.value = { title: { text: "reactive" } };
|
||||||
await nextTick();
|
await nextTick();
|
||||||
@ -199,6 +321,8 @@ describe("ECharts component", () => {
|
|||||||
expect(chartStub.setOption.mock.calls[1][0]).toMatchObject({
|
expect(chartStub.setOption.mock.calls[1][0]).toMatchObject({
|
||||||
title: { text: "reactive" },
|
title: { text: "reactive" },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
warnSpy.mockRestore();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uses injected updateOptions defaults when not provided via props", async () => {
|
it("uses injected updateOptions defaults when not provided via props", async () => {
|
||||||
@ -511,6 +635,26 @@ describe("ECharts component", () => {
|
|||||||
expect(chartStub.setOption).toHaveBeenCalledTimes(1);
|
expect(chartStub.setOption).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("applies option when nested data mutates", async () => {
|
||||||
|
const option = ref<Option>({
|
||||||
|
series: [{ type: "bar", data: [1, 2, 3] }],
|
||||||
|
});
|
||||||
|
const exposed = shallowRef<any>();
|
||||||
|
|
||||||
|
renderChart(() => ({ option: option.value }), exposed);
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
chartStub.setOption.mockClear();
|
||||||
|
|
||||||
|
(option.value!.series as any)[0].data.push(4);
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(chartStub.setOption).toHaveBeenCalledTimes(1);
|
||||||
|
expect(chartStub.setOption.mock.calls[0][0]).toMatchObject({
|
||||||
|
series: [{ data: [1, 2, 3, 4] }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("honors override.replaceMerge in update options", async () => {
|
it("honors override.replaceMerge in update options", async () => {
|
||||||
const option = ref({ series: [{ type: "bar", data: [1] }] });
|
const option = ref({ series: [{ type: "bar", data: [1] }] });
|
||||||
const exposed = shallowRef<any>();
|
const exposed = shallowRef<any>();
|
||||||
@ -674,12 +818,28 @@ describe("ECharts component", () => {
|
|||||||
expect(chartStub.setOption).toHaveBeenCalledTimes(1);
|
expect(chartStub.setOption).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
// Toggle to manual mode; watcher should be cleaned up (unwatchOption branch)
|
// Toggle to manual mode; watcher should be cleaned up (unwatchOption branch)
|
||||||
|
const firstStub = chartStub;
|
||||||
|
const replacementStub = enqueueChart();
|
||||||
manual.value = true;
|
manual.value = true;
|
||||||
|
chartStub = replacementStub;
|
||||||
await nextTick();
|
await nextTick();
|
||||||
|
expect(firstStub.dispose).toHaveBeenCalledTimes(1);
|
||||||
|
expect(replacementStub.setOption).toHaveBeenCalledTimes(1);
|
||||||
|
chartStub.setOption.mockClear();
|
||||||
|
|
||||||
|
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {
|
||||||
|
// noop
|
||||||
|
});
|
||||||
|
|
||||||
chartStub.setOption.mockClear();
|
chartStub.setOption.mockClear();
|
||||||
option.value = { title: { text: "reactive-2" } } as any;
|
option.value = { title: { text: "reactive-2" } } as any;
|
||||||
await nextTick();
|
await nextTick();
|
||||||
expect(chartStub.setOption).not.toHaveBeenCalled();
|
expect(chartStub.setOption).not.toHaveBeenCalled();
|
||||||
|
expect(warnSpy).toHaveBeenCalled();
|
||||||
|
expect(warnSpy.mock.calls[0][0]).toContain(
|
||||||
|
"[vue-echarts] `option` prop changes are ignored when `manual-update` is `true`.",
|
||||||
|
);
|
||||||
|
|
||||||
|
warnSpy.mockRestore();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -16,6 +16,7 @@ export function createEChartsModule() {
|
|||||||
|
|
||||||
export interface ChartStub {
|
export interface ChartStub {
|
||||||
setOption: ReturnType<typeof vi.fn>;
|
setOption: ReturnType<typeof vi.fn>;
|
||||||
|
getOption: ReturnType<typeof vi.fn>;
|
||||||
resize: ReturnType<typeof vi.fn>;
|
resize: ReturnType<typeof vi.fn>;
|
||||||
dispose: ReturnType<typeof vi.fn>;
|
dispose: ReturnType<typeof vi.fn>;
|
||||||
isDisposed: ReturnType<typeof vi.fn>;
|
isDisposed: ReturnType<typeof vi.fn>;
|
||||||
@ -36,9 +37,15 @@ export function createChartStub(): ChartStub {
|
|||||||
on: vi.fn(),
|
on: vi.fn(),
|
||||||
off: vi.fn(),
|
off: vi.fn(),
|
||||||
};
|
};
|
||||||
|
let lastOption: unknown;
|
||||||
|
|
||||||
|
const setOption = vi.fn((option: unknown) => {
|
||||||
|
lastOption = option;
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
setOption: vi.fn(),
|
setOption,
|
||||||
|
getOption: vi.fn(() => lastOption),
|
||||||
resize: vi.fn(),
|
resize: vi.fn(),
|
||||||
dispose: vi.fn(),
|
dispose: vi.fn(),
|
||||||
isDisposed: vi.fn(() => false),
|
isDisposed: vi.fn(() => false),
|
||||||
|
|||||||
@ -188,7 +188,7 @@ describe("useSlotOption", () => {
|
|||||||
const patched: any = exposed.value!.patchOption({});
|
const patched: any = exposed.value!.patchOption({});
|
||||||
const flattened = warnSpy.mock.calls.flat().join(" ");
|
const flattened = warnSpy.mock.calls.flat().join(" ");
|
||||||
|
|
||||||
expect(flattened).toContain("Invalid vue-echarts slot name: legend");
|
expect(flattened).toContain("[vue-echarts] Invalid slot name: legend");
|
||||||
expect(patched.legend).toBeUndefined();
|
expect(patched.legend).toBeUndefined();
|
||||||
expect(changeSpy).not.toHaveBeenCalled();
|
expect(changeSpy).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
@ -224,6 +224,20 @@ describe("useSlotOption", () => {
|
|||||||
expect(container?.textContent).toBe("series-0");
|
expect(container?.textContent).toBe("series-0");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("skips slot patch when path is blocked by non-object", async () => {
|
||||||
|
const { exposed } = renderSlotComponent(() => ({
|
||||||
|
"tooltip-series-0": () => [h("span", "series-0")],
|
||||||
|
}));
|
||||||
|
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
const option = { series: 1 as any };
|
||||||
|
const patched = exposed.value!.patchOption(option);
|
||||||
|
|
||||||
|
expect(patched.series).toBe(1);
|
||||||
|
expect(typeof patched.series).toBe("number");
|
||||||
|
});
|
||||||
|
|
||||||
it("creates array shells when target slot path is missing", async () => {
|
it("creates array shells when target slot path is missing", async () => {
|
||||||
const { exposed } = renderSlotComponent(() => ({
|
const { exposed } = renderSlotComponent(() => ({
|
||||||
"tooltip-series-1": () => [h("span", "series-1")],
|
"tooltip-series-1": () => [h("span", "series-1")],
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
import { buildSignature, planUpdate } from "../src/smart-update";
|
import { buildSignature, planUpdate } from "../src/update";
|
||||||
import type { EChartsOption } from "echarts";
|
import type { EChartsOption } from "echarts";
|
||||||
|
|
||||||
describe("smart-update", () => {
|
describe("smart-update", () => {
|
||||||
Reference in New Issue
Block a user