diff --git a/common/changes/@visactor/vue-vtable/fix-issue-4925-datasource-editor_2026-09-21-00-00.json b/common/changes/@visactor/vue-vtable/fix-issue-4925-datasource-editor_2026-09-21-00-00.json new file mode 100644 index 000000000..861f33ccf --- /dev/null +++ b/common/changes/@visactor/vue-vtable/fix-issue-4925-datasource-editor_2026-09-21-00-00.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@visactor/vue-vtable", + "comment": "fix: prevent dynamic editor completion from overwriting immediate cell updates", + "type": "patch" + } + ], + "packageName": "@visactor/vue-vtable", + "email": "github@visactor.io" +} diff --git a/packages/vue-vtable/__tests__/dynamic-render-editor.test.ts b/packages/vue-vtable/__tests__/dynamic-render-editor.test.ts new file mode 100644 index 000000000..f9042db5a --- /dev/null +++ b/packages/vue-vtable/__tests__/dynamic-render-editor.test.ts @@ -0,0 +1,178 @@ +/* eslint-env jest */ +/* eslint-disable no-undef */ +// @ts-nocheck +import { DynamicRenderEditor } from '../src/edit/editor'; + +jest.mock('@visactor/vtable', () => ({ + TYPES: { + Placement: { + top: 'top' + } + } +})); + +describe('DynamicRenderEditor', () => { + test('finishes synchronously without validation before a button updates the edited cell', async () => { + let cellValue = 'original'; + const editor = new DynamicRenderEditor(); + editor.setValue('editor-value'); + + const table = { + getBodyColumnDefine: jest.fn().mockReturnValue({ field: 'name' }) + } as any; + + const finishEditing = () => { + const validation = editor.validateValue(editor.getValue(), cellValue, { col: 0, row: 1 }, table); + if (validation instanceof Promise) { + return validation.then(valid => { + if (valid) { + cellValue = editor.getValue(); + } + }); + } + if (validation) { + cellValue = editor.getValue(); + } + return validation; + }; + + const completion = finishEditing(); + cellValue = 'button-value'; + await completion; + + expect(cellValue).toBe('button-value'); + }); + + test('keeps configured synchronous validation synchronous', () => { + const editor = new DynamicRenderEditor(); + const table = { + getBodyColumnDefine: jest.fn().mockReturnValue({ + editConfig: { + validateValue: () => false, + invalidPrompt: 'invalid value' + } + }), + getVisibleCellRangeRelativeRect: jest.fn().mockReturnValue({ left: 0, top: 0, width: 100, height: 40 }), + showTooltip: jest.fn() + } as any; + + const validation = editor.validateValue('next', 'previous', { col: 0, row: 1 }, table); + + expect(validation).toBe(false); + expect(table.showTooltip).toHaveBeenCalledWith( + 0, + 1, + expect.objectContaining({ + content: 'invalid value' + }) + ); + }); + + test('keeps configured asynchronous validation asynchronous', async () => { + const editor = new DynamicRenderEditor(); + const table = { + getBodyColumnDefine: jest.fn().mockReturnValue({ + editConfig: { + validateValue: () => Promise.resolve(true) + } + }) + } as any; + + const validation = editor.validateValue('next', 'previous', { col: 0, row: 1 }, table); + + expect(validation).toBeInstanceOf(Promise); + await expect(validation).resolves.toBe(true); + }); + + test('converts synchronous validation errors to rejected promises', async () => { + const editor = new DynamicRenderEditor(); + const error = new Error('validation failed'); + const table = { + getBodyColumnDefine: jest.fn().mockReturnValue({ + editConfig: { + validateValue: () => { + throw error; + } + } + }) + } as any; + + const validation = editor.validateValue('next', 'previous', { col: 0, row: 1 }, table); + + expect(validation).toBeInstanceOf(Promise); + await expect(validation).rejects.toBe(error); + }); + + test('handles promise-like validation results from another realm', async () => { + const editor = new DynamicRenderEditor(); + const promiseLike = { + then(resolve: (value: boolean) => boolean) { + return Promise.resolve(resolve(false)); + } + }; + const table = { + getBodyColumnDefine: jest.fn().mockReturnValue({ + editConfig: { + validateValue: () => promiseLike + } + }), + getVisibleCellRangeRelativeRect: jest.fn().mockReturnValue({ left: 0, top: 0, width: 100, height: 40 }), + showTooltip: jest.fn() + } as any; + + const validation = editor.validateValue('next', 'previous', { col: 0, row: 1 }, table); + + await expect(validation).resolves.toBe(false); + expect(table.showTooltip).toHaveBeenCalledWith( + 0, + 1, + expect.objectContaining({ + content: 'invalid' + }) + ); + }); + + test('converts errors from a promise-like then getter to rejected promises', async () => { + const editor = new DynamicRenderEditor(); + const error = new Error('then getter failed'); + const promiseLike = Object.defineProperty({}, 'then', { + get() { + throw error; + } + }); + const table = { + getBodyColumnDefine: jest.fn().mockReturnValue({ + editConfig: { + validateValue: () => promiseLike + } + }) + } as any; + + const validation = editor.validateValue('next', 'previous', { col: 0, row: 1 }, table); + + expect(validation).toBeInstanceOf(Promise); + await expect(validation).rejects.toBe(error); + }); + + test('converts errors from invoking a promise-like then to rejected promises', async () => { + const editor = new DynamicRenderEditor(); + const error = new Error('then invocation failed'); + const promiseLike = { + then() { + throw error; + } + }; + const table = { + getBodyColumnDefine: jest.fn().mockReturnValue({ + editConfig: { + validateValue: () => promiseLike + } + }) + } as any; + + const validation = editor.validateValue('next', 'previous', { col: 0, row: 1 }, table); + + expect(validation).toBeInstanceOf(Promise); + await expect(validation).rejects.toBe(error); + }); +}); diff --git a/packages/vue-vtable/demo/src/App.vue b/packages/vue-vtable/demo/src/App.vue index 3722650d6..99f720975 100644 --- a/packages/vue-vtable/demo/src/App.vue +++ b/packages/vue-vtable/demo/src/App.vue @@ -22,6 +22,7 @@ import ListTableCustomHover from './table/gramatical/composition/ListTable-custo import Issue5150CustomLayoutSort from './table/gramatical/composition/Issue5150CustomLayoutSort.vue'; import Issue5157CustomLayoutScrollbar from './table/gramatical/composition/Issue5157CustomLayoutScrollbar.vue'; import Issue4884SlotEditorBackground from './table/gramatical/composition/Issue4884SlotEditorBackground.vue'; +import Issue4925DataSourceEditor from './table/gramatical/composition/Issue4925DataSourceEditor.vue'; import ListTableVFor from './table/gramatical/options/ListTable-v-for.vue'; import PivotTable from './table/gramatical/options/PivotTable.vue'; @@ -55,7 +56,8 @@ import singleRadio from './table/single/single-radio.vue'; - + + diff --git a/packages/vue-vtable/demo/src/table/gramatical/composition/Issue4925DataSourceEditor.vue b/packages/vue-vtable/demo/src/table/gramatical/composition/Issue4925DataSourceEditor.vue new file mode 100644 index 000000000..8e72ac70f --- /dev/null +++ b/packages/vue-vtable/demo/src/table/gramatical/composition/Issue4925DataSourceEditor.vue @@ -0,0 +1,72 @@ + + + Current value: {{ currentValue }} + + + + + Set approved + + + + + + + + + diff --git a/packages/vue-vtable/jest.config.js b/packages/vue-vtable/jest.config.js index bfdfee91b..d5d5718d0 100644 --- a/packages/vue-vtable/jest.config.js +++ b/packages/vue-vtable/jest.config.js @@ -9,9 +9,8 @@ module.exports = { verbose: true, globals: { 'ts-jest': { - diagnostics: { - exclude: ['**'] - }, + diagnostics: false, + isolatedModules: true, tsconfig: { resolveJsonModule: true, esModuleInterop: true diff --git a/packages/vue-vtable/src/edit/editor.ts b/packages/vue-vtable/src/edit/editor.ts index 256ec9805..d74c8728f 100644 --- a/packages/vue-vtable/src/edit/editor.ts +++ b/packages/vue-vtable/src/edit/editor.ts @@ -4,6 +4,10 @@ import { h, isVNode, customRef, render } from 'vue'; import { TYPES } from '@visactor/vtable'; import type { RectProps } from '@visactor/vtable/es/ts-types/common'; +function isPromiseLike(value: T | Promise): value is Promise { + return Boolean(value && typeof (value as Promise).then === 'function'); +} + /** 渲染式编辑器参数 */ export interface DynamicRenderEditorParams { /** 行索引 */ @@ -236,12 +240,12 @@ export class DynamicRenderEditor { } } - async validateValue( + validateValue( value?: any, oldValue?: any, editCell?: { col: number; row: number }, table?: any - ): Promise { + ): boolean | Promise { const { col, row } = editCell || {}; if (!isValid(col) || !isValid(row)) { return true; @@ -249,22 +253,31 @@ export class DynamicRenderEditor { const define = table.getBodyColumnDefine(col, row) as ColumnDefine; const { editConfig } = define || {}; if (typeof editConfig?.validateValue === 'function') { - const validate = await editConfig.validateValue({ col, row, value, oldValue, table }); - if (validate === false) { - const rect = table.getVisibleCellRangeRelativeRect({ col, row }); - table.showTooltip(col, row, { - content: editConfig.invalidPrompt || 'invalid', - referencePosition: { rect, placement: TYPES.Placement.top }, - style: { - bgColor: 'red', - color: 'white', - arrowMark: true - }, - disappearDelay: 1000 - }); - return false; + const handleValidationResult = (validate: boolean) => { + if (validate === false) { + const rect = table.getVisibleCellRangeRelativeRect({ col, row }); + table.showTooltip(col, row, { + content: editConfig.invalidPrompt || 'invalid', + referencePosition: { rect, placement: TYPES.Placement.top }, + style: { + bgColor: 'red', + color: 'white', + arrowMark: true + }, + disappearDelay: 1000 + }); + return false; + } + return validate; + }; + try { + const validate = editConfig.validateValue({ col, row, value, oldValue, table }); + return isPromiseLike(validate) + ? Promise.resolve(validate).then(handleValidationResult) + : handleValidationResult(validate); + } catch (error) { + return Promise.reject(error); } - return validate; } return true; }