diff --git a/packages/effects/plugins/src/vxe-table/__tests__/use-vxe-grid.reactivity.test.ts b/packages/effects/plugins/src/vxe-table/__tests__/use-vxe-grid.reactivity.test.ts index d2e542cef..b0cf3b6c1 100644 --- a/packages/effects/plugins/src/vxe-table/__tests__/use-vxe-grid.reactivity.test.ts +++ b/packages/effects/plugins/src/vxe-table/__tests__/use-vxe-grid.reactivity.test.ts @@ -2,14 +2,30 @@ import type { App } from 'vue'; import { createApp, defineComponent, h, nextTick, ref } from 'vue'; -import { describe, expect, it, vi } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import VbenVxeGrid from '../use-vxe-grid.vue'; +const mockRefs = vi.hoisted(() => ({ + gridOptions: undefined as any, + isMobile: undefined as any, +})); +const observedColumns = vi.hoisted(() => [] as unknown[]); const observedToolbarConfigs = vi.hoisted(() => [] as unknown[]); +const extendProxyOptionsMock = vi.hoisted(() => vi.fn()); vi.mock('@vben/hooks', async () => { const { ref } = await import('vue'); + const gridOptions = ref({ + columns: [ + { + field: 'operation', + fixed: 'right', + width: 'auto', + }, + ], + }); + mockRefs.gridOptions = gridOptions; return { usePriorityValues: () => ({ @@ -17,15 +33,7 @@ vi.mock('@vben/hooks', async () => { formOptions: ref(undefined), gridClass: ref(undefined), gridEvents: ref(undefined), - gridOptions: ref({ - columns: [ - { - field: 'operation', - fixed: 'right', - width: 'auto', - }, - ], - }), + gridOptions, separator: ref(undefined), showSearchForm: ref(true), tableData: ref(undefined), @@ -38,7 +46,9 @@ vi.mock('@vben/hooks', async () => { vi.mock('@vben/preferences', async () => { const { ref } = await import('vue'); - return { usePreferences: () => ({ isMobile: ref(false) }) }; + const isMobile = ref(false); + mockRefs.isMobile = isMobile; + return { usePreferences: () => ({ isMobile }) }; }); vi.mock('@vben/icons', async () => { @@ -73,6 +83,7 @@ vi.mock('vxe-table', async () => { inheritAttrs: false, setup(_, { attrs, slots }) { return () => { + observedColumns.push(attrs.columns); observedToolbarConfigs.push(attrs.toolbarConfig); return h('div', [ slots['toolbar-actions']?.({}), @@ -85,7 +96,9 @@ vi.mock('vxe-table', async () => { }; }); -vi.mock('../extends', () => ({ extendProxyOptions: vi.fn() })); +vi.mock('../extends', () => ({ + extendProxyOptions: extendProxyOptionsMock, +})); vi.mock('../init', async () => { const { defineComponent, h } = await import('vue'); @@ -110,21 +123,46 @@ vi.mock('../viewed-row', () => ({ useViewedRow: vi.fn(), })); -describe('vben vxe grid toolbar slots', () => { +function createGridApi(commitProxy = vi.fn()) { + return { + grid: { commitProxy }, + mount: vi.fn(), + reload: vi.fn(), + setState: vi.fn(), + toggleSearchForm: vi.fn(), + unmount: vi.fn(), + useStore: vi.fn(() => ({})), + }; +} + +async function flushGridInit() { + await nextTick(); + await nextTick(); + await nextTick(); +} + +describe('vben vxe grid reactivity', () => { + beforeEach(() => { + mockRefs.gridOptions.value = { + columns: [ + { + field: 'operation', + fixed: 'right', + width: 'auto', + }, + ], + }; + mockRefs.isMobile.value = false; + observedColumns.length = 0; + observedToolbarConfigs.length = 0; + extendProxyOptionsMock.mockClear(); + }); + it.each(['table-title', 'toolbar-actions', 'toolbar-tools'])( 'keeps toolbar options stable when the %s slot updates', async (slotName) => { - observedToolbarConfigs.length = 0; const loading = ref(false); - const api = { - grid: { commitProxy: vi.fn() }, - mount: vi.fn(), - reload: vi.fn(), - setState: vi.fn(), - toggleSearchForm: vi.fn(), - unmount: vi.fn(), - useStore: vi.fn(() => ({})), - }; + const api = createGridApi(); const Consumer = defineComponent( () => () => h(VbenVxeGrid, { api } as any, { @@ -164,4 +202,80 @@ describe('vben vxe grid toolbar slots', () => { } }, ); + + it('keeps columns stable when unrelated grid options update', async () => { + mockRefs.gridOptions.value = { + columns: [{ field: 'name' }], + pagerConfig: {}, + }; + const api = createGridApi(); + const Consumer = defineComponent( + () => () => h(VbenVxeGrid, { api } as any), + ); + const host = document.createElement('div'); + document.body.append(host); + const app: App = createApp(Consumer); + + try { + app.mount(host); + await flushGridInit(); + const initialColumns = observedColumns.at(-1); + + mockRefs.isMobile.value = true; + await nextTick(); + await nextTick(); + + expect(observedColumns.at(-1)).toBe(initialColumns); + } finally { + app.unmount(); + host.remove(); + } + }); + + it('uses the initial action for the first proxy load', async () => { + mockRefs.gridOptions.value = { + columns: [{ field: 'name', sortable: true }], + proxyConfig: { + ajax: { query: vi.fn() }, + autoLoad: true, + }, + sortConfig: { + defaultSort: { field: 'name', order: 'asc' }, + }, + }; + const commitProxy = vi.fn(); + const api = createGridApi(commitProxy); + const Consumer = defineComponent( + () => () => h(VbenVxeGrid, { api } as any), + ); + const host = document.createElement('div'); + document.body.append(host); + const app: App = createApp(Consumer); + + try { + app.mount(host); + await flushGridInit(); + + expect(api.setState).toHaveBeenCalledOnce(); + expect(extendProxyOptionsMock).toHaveBeenCalledOnce(); + expect(commitProxy).toHaveBeenCalledOnce(); + expect(commitProxy).toHaveBeenCalledWith('initial', {}); + const setStateCallOrder = api.setState.mock.invocationCallOrder[0]; + const extendProxyCallOrder = + extendProxyOptionsMock.mock.invocationCallOrder[0]; + const commitProxyCallOrder = commitProxy.mock.invocationCallOrder[0]; + if ( + setStateCallOrder === undefined || + extendProxyCallOrder === undefined || + commitProxyCallOrder === undefined + ) { + throw new Error('Expected all initialization calls to be recorded'); + } + expect(setStateCallOrder).toBeLessThan(commitProxyCallOrder); + expect(extendProxyCallOrder).toBeLessThan(commitProxyCallOrder); + } finally { + app.unmount(); + host.remove(); + } + }); }); diff --git a/packages/effects/plugins/src/vxe-table/use-vxe-grid.vue b/packages/effects/plugins/src/vxe-table/use-vxe-grid.vue index e1e7c69a9..ae1025d5b 100644 --- a/packages/effects/plugins/src/vxe-table/use-vxe-grid.vue +++ b/packages/effects/plugins/src/vxe-table/use-vxe-grid.vue @@ -203,14 +203,17 @@ const toolbarOptions = computed(() => { const options = computed(() => { const globalGridConfig = VxeUI?.getConfig()?.grid ?? {}; - const mergedOptions: VxeTableGridProps = cloneDeep( - mergeWithArrayOverride( - {}, - toRaw(toolbarOptions.value), - toRaw(gridOptions.value), - globalGridConfig, - ), + const rawMergedOptions: VxeTableGridProps = mergeWithArrayOverride( + {}, + toRaw(toolbarOptions.value), + toRaw(gridOptions.value), + globalGridConfig, ); + const mergedOptions: VxeTableGridProps = cloneDeep(rawMergedOptions); + + // Keep the columns reference stable when unrelated reactive options change. + // Otherwise VXE reloads the columns and loses its initialized sort state. + mergedOptions.columns = rawMergedOptions.columns; if (mergedOptions.proxyConfig) { const { ajax } = mergedOptions.proxyConfig; @@ -332,12 +335,6 @@ async function init() { // 内部主动加载数据,防止form的默认值影响 const autoLoad = defaultGridOptions.proxyConfig?.autoLoad; const enableProxyConfig = options.value.proxyConfig?.enabled; - if (enableProxyConfig && autoLoad) { - props.api.grid.commitProxy?.( - 'query', - formOptions.value ? ((await formApi.getValues()) ?? {}) : {}, - ); - } // form 由 vben-form代替,所以不适配formConfig,这里给出警告 const formConfig = gridOptions.value?.formConfig; @@ -353,6 +350,15 @@ async function init() { extendProxyOptions(props.api, defaultGridOptions, () => formApi.getLatestSubmissionValues(), ); + + if (enableProxyConfig && autoLoad) { + // Wait for VXE to receive the latest options before applying default sort. + await nextTick(); + props.api.grid.commitProxy?.( + 'initial', + formOptions.value ? ((await formApi.getValues()) ?? {}) : {}, + ); + } } // formOptions支持响应式