Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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"
}
178 changes: 178 additions & 0 deletions packages/vue-vtable/__tests__/dynamic-render-editor.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
4 changes: 3 additions & 1 deletion packages/vue-vtable/demo/src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -55,7 +56,8 @@ import singleRadio from './table/single/single-radio.vue';

<!-- <ListTable /> -->
<!-- <Issue5150CustomLayoutSort /> -->
<Issue4884SlotEditorBackground />
<!-- <Issue4884SlotEditorBackground /> -->
<Issue4925DataSourceEditor />
<!-- <ListTableEditor /> -->
<!-- <ListTableEditorArco /> -->
<!-- <ListTableEditorRender /> -->
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
<template>
<div class="issue-4925-demo">
<div class="result" data-testid="issue-4925-result">Current value: {{ currentValue }}</div>
<vue-list-table ref="tableRef" class="table" :options="option">
<ListColumn field="id" title="ID" :width="100" />
<ListColumn field="status" title="Status" :width="240" :editor="DYNAMIC_RENDER_EDITOR">
<template #edit="{ table, col, row }">
<button class="update-button" @click.stop="updateCurrentCell(table, col, row)">Set approved</button>
</template>
</ListColumn>
</vue-list-table>
</div>
</template>

<script setup lang="ts">
import * as VTable from '@visactor/vtable';
import { ref } from 'vue';
import { DYNAMIC_RENDER_EDITOR, ListColumn } from '../../../../../src';

const records = [
{ id: 1, status: 'pending' },
{ id: 2, status: 'pending' }
];
const dataSource = new VTable.data.CachedDataSource({
get(index: number) {
return records[index];
},
length: records.length
});
const option = {
dataSource,
editCellTrigger: 'click'
};
const tableRef = ref();
const currentValue = ref(records[0].status);

function updateCurrentCell(table: VTable.ListTable, col: number, row: number) {
table.completeEditCell();
table.changeCellValue(col, row, 'approved');
setTimeout(() => {
currentValue.value = String(table.getCellOriginValue(col, row));
}, 0);
}
</script>

<style scoped>
.issue-4925-demo {
display: flex;
height: 100vh;
min-height: 0;
flex-direction: column;
gap: 12px;
}

.table {
flex: 1;
min-height: 0;
}

.result {
font-size: 14px;
line-height: 32px;
}

.update-button {
height: 100%;
border: 1px solid #1664ff;
background: #1664ff;
color: #fff;
cursor: pointer;
}
</style>
5 changes: 2 additions & 3 deletions packages/vue-vtable/jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,8 @@ module.exports = {
verbose: true,
globals: {
'ts-jest': {
diagnostics: {
exclude: ['**']
},
diagnostics: false,
isolatedModules: true,
tsconfig: {
resolveJsonModule: true,
esModuleInterop: true
Expand Down
47 changes: 30 additions & 17 deletions packages/vue-vtable/src/edit/editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>(value: T | Promise<T>): value is Promise<T> {
return Boolean(value && typeof (value as Promise<T>).then === 'function');
}

/** 渲染式编辑器参数 */
export interface DynamicRenderEditorParams {
/** 行索引 */
Expand Down Expand Up @@ -236,35 +240,44 @@ export class DynamicRenderEditor {
}
}

async validateValue(
validateValue(
value?: any,
oldValue?: any,
editCell?: { col: number; row: number },
table?: any
): Promise<boolean> {
): boolean | Promise<boolean> {
const { col, row } = editCell || {};
if (!isValid(col) || !isValid(row)) {
return true;
}
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;
}
Expand Down
Loading