From 75b1199ea1500ab24fb4a75a3afa61e598d68bb7 Mon Sep 17 00:00:00 2001 From: fangsmile <892739385@qq.com> Date: Mon, 21 Sep 2026 10:39:43 +0800 Subject: [PATCH 1/3] fix(vue-vtable): preserve immediate editor cell updates Keep validation synchronous unless user validation is asynchronous. This prevents completed editors from overwriting immediate same-cell updates. Co-Authored-By: Claude Sonnet 4.6 noreply@anthropic.com --- ...25-datasource-editor_2026-09-21-00-00.json | 11 +++ .../__tests__/dynamic-render-editor.test.ts | 86 +++++++++++++++++++ packages/vue-vtable/demo/src/App.vue | 4 +- .../composition/Issue4925DataSourceEditor.vue | 72 ++++++++++++++++ packages/vue-vtable/jest.config.js | 5 +- packages/vue-vtable/src/edit/editor.ts | 39 +++++---- 6 files changed, 195 insertions(+), 22 deletions(-) create mode 100644 common/changes/@visactor/vue-vtable/fix-issue-4925-datasource-editor_2026-09-21-00-00.json create mode 100644 packages/vue-vtable/__tests__/dynamic-render-editor.test.ts create mode 100644 packages/vue-vtable/demo/src/table/gramatical/composition/Issue4925DataSourceEditor.vue 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 0000000000..861f33ccf2 --- /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 0000000000..de87b630fb --- /dev/null +++ b/packages/vue-vtable/__tests__/dynamic-render-editor.test.ts @@ -0,0 +1,86 @@ +/* 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); + }); +}); diff --git a/packages/vue-vtable/demo/src/App.vue b/packages/vue-vtable/demo/src/App.vue index 3722650d69..99f7209759 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 0000000000..8e72ac70fb --- /dev/null +++ b/packages/vue-vtable/demo/src/table/gramatical/composition/Issue4925DataSourceEditor.vue @@ -0,0 +1,72 @@ + + + + + diff --git a/packages/vue-vtable/jest.config.js b/packages/vue-vtable/jest.config.js index bfdfee91b2..d5d5718d00 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 256ec9805e..b9f44a57fd 100644 --- a/packages/vue-vtable/src/edit/editor.ts +++ b/packages/vue-vtable/src/edit/editor.ts @@ -236,12 +236,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 +249,25 @@ 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; - } - return validate; + 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; + }; + const validate = editConfig.validateValue({ col, row, value, oldValue, table }); + return validate instanceof Promise ? validate.then(handleValidationResult) : handleValidationResult(validate); } return true; } From 76ac69fd6e8f4cf7d2421f5b5cdbf0dc7827f3f1 Mon Sep 17 00:00:00 2001 From: fangsmile <892739385@qq.com> Date: Mon, 21 Sep 2026 16:33:39 +0800 Subject: [PATCH 2/3] fix(vue-vtable): harden editor validation handling Preserve cleanup for synchronous validation errors and support cross-realm promise-like results. Co-Authored-By: Claude Sonnet 4.6 noreply@anthropic.com --- .../__tests__/dynamic-render-editor.test.ts | 48 +++++++++++++++++++ packages/vue-vtable/src/edit/editor.ts | 13 ++++- 2 files changed, 59 insertions(+), 2 deletions(-) diff --git a/packages/vue-vtable/__tests__/dynamic-render-editor.test.ts b/packages/vue-vtable/__tests__/dynamic-render-editor.test.ts index de87b630fb..4692c64e1a 100644 --- a/packages/vue-vtable/__tests__/dynamic-render-editor.test.ts +++ b/packages/vue-vtable/__tests__/dynamic-render-editor.test.ts @@ -83,4 +83,52 @@ describe('DynamicRenderEditor', () => { 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' + }) + ); + }); }); diff --git a/packages/vue-vtable/src/edit/editor.ts b/packages/vue-vtable/src/edit/editor.ts index b9f44a57fd..0cb9225ff3 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 { /** 行索引 */ @@ -266,8 +270,13 @@ export class DynamicRenderEditor { } return validate; }; - const validate = editConfig.validateValue({ col, row, value, oldValue, table }); - return validate instanceof Promise ? validate.then(handleValidationResult) : handleValidationResult(validate); + let validate: boolean | Promise; + try { + validate = editConfig.validateValue({ col, row, value, oldValue, table }); + } catch (error) { + return Promise.reject(error); + } + return isPromiseLike(validate) ? validate.then(handleValidationResult) : handleValidationResult(validate); } return true; } From 984825c597bb7c5cfebd90c8fc48b8573882cc50 Mon Sep 17 00:00:00 2001 From: fangsmile <892739385@qq.com> Date: Mon, 21 Sep 2026 17:59:16 +0800 Subject: [PATCH 3/3] fix(vue-vtable): normalize validator thenables Convert thenable errors into rejected promises so editor validation state can recover. Co-Authored-By: Claude Sonnet 4.6 noreply@anthropic.com --- .../__tests__/dynamic-render-editor.test.ts | 44 +++++++++++++++++++ packages/vue-vtable/src/edit/editor.ts | 7 +-- 2 files changed, 48 insertions(+), 3 deletions(-) diff --git a/packages/vue-vtable/__tests__/dynamic-render-editor.test.ts b/packages/vue-vtable/__tests__/dynamic-render-editor.test.ts index 4692c64e1a..f9042db5ab 100644 --- a/packages/vue-vtable/__tests__/dynamic-render-editor.test.ts +++ b/packages/vue-vtable/__tests__/dynamic-render-editor.test.ts @@ -131,4 +131,48 @@ describe('DynamicRenderEditor', () => { }) ); }); + + 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/src/edit/editor.ts b/packages/vue-vtable/src/edit/editor.ts index 0cb9225ff3..d74c8728f0 100644 --- a/packages/vue-vtable/src/edit/editor.ts +++ b/packages/vue-vtable/src/edit/editor.ts @@ -270,13 +270,14 @@ export class DynamicRenderEditor { } return validate; }; - let validate: boolean | Promise; try { - validate = editConfig.validateValue({ col, row, value, oldValue, table }); + 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 isPromiseLike(validate) ? validate.then(handleValidationResult) : handleValidationResult(validate); } return true; }