diff --git a/common/changes/@visactor/vtable/fix-issue-4186-nested-field-update_2026-09-04-10-30.json b/common/changes/@visactor/vtable/fix-issue-4186-nested-field-update_2026-09-04-10-30.json new file mode 100644 index 0000000000..a59754ebe9 --- /dev/null +++ b/common/changes/@visactor/vtable/fix-issue-4186-nested-field-update_2026-09-04-10-30.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "comment": "fix: update nested fields by path", + "type": "patch", + "packageName": "@visactor/vtable" + } + ], + "packageName": "@visactor/vtable", + "email": "biukam.w@gmail.com" +} diff --git a/packages/vtable/__tests__/data-update/listTable-nested-field-update.test.ts b/packages/vtable/__tests__/data-update/listTable-nested-field-update.test.ts new file mode 100644 index 0000000000..b1782f5d96 --- /dev/null +++ b/packages/vtable/__tests__/data-update/listTable-nested-field-update.test.ts @@ -0,0 +1,124 @@ +// @ts-nocheck +import { ListTable } from '../../src'; +import { createDiv } from '../dom'; + +describe('ListTable nested field updates', () => { + test('updates a nested field and reports its old and changed values', () => { + const field = 'facts.2025-02.qty'; + const records = [{ facts: { '2025-02': { qty: 10 } } }]; + const table = new ListTable({ + container: createDiv(), + columns: [{ field, title: 'Quantity' }], + records + }); + const events: any[] = []; + table.on('change_cell_value', event => events.push(event)); + + table.changeCellValueByRecord(0, field, '12', { autoRefresh: false }); + + expect(records[0].facts['2025-02'].qty).toBe(12); + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ + col: 0, + row: 1, + recordIndex: 0, + field, + rawValue: 10, + currentValue: 10, + changedValue: 12 + }); + table.release(); + }); + + test('updates nested fields in a batch change and reports the aggregate event', () => { + const field = 'facts.2025-02.qty'; + const records = [{ facts: { '2025-02': { qty: 10 } } }]; + const table = new ListTable({ + container: createDiv(), + columns: [{ field, title: 'Quantity' }], + records + }); + const cellEvents: any[] = []; + const batchEvents: any[] = []; + table.on('change_cell_value', event => cellEvents.push(event)); + table.on('change_cell_values', event => batchEvents.push(event)); + + table.changeCellValuesByRecords([{ recordIndex: 0, field, value: '12' }], { autoRefresh: false }); + + expect(records[0].facts['2025-02'].qty).toBe(12); + expect(cellEvents).toHaveLength(1); + expect(cellEvents[0].changedValue).toBe(12); + expect(batchEvents).toHaveLength(1); + expect(batchEvents[0].values).toEqual(cellEvents); + table.release(); + }); + + test('updates an array field path and reports its old and changed values', () => { + const field = ['facts', '2025-02', 'qty']; + const records = [{ facts: { '2025-02': { qty: 10 } } }]; + const table = new ListTable({ + container: createDiv(), + columns: [{ field, title: 'Quantity' }], + records + }); + const events: any[] = []; + table.on('change_cell_value', event => events.push(event)); + + table.changeCellValueByRecord(0, [...field], '12', { autoRefresh: false }); + + expect(records[0].facts['2025-02'].qty).toBe(12); + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ + col: 0, + recordIndex: 0, + rawValue: 10, + currentValue: 10, + changedValue: 12 + }); + table.release(); + }); + + test('matches copied array fields when resolving a custom sort function', () => { + const field = ['facts', 'qty']; + const orderFn = jest.fn(); + const table = new ListTable({ + container: createDiv(), + columns: [{ field, title: 'Quantity', sort: orderFn }], + records: [{ facts: { qty: 10 } }] + }); + + expect(table._getSortFuncFromHeaderOption(undefined, [...field])).toBe(orderFn); + table.release(); + }); + + test('reports stored values when updates create records', () => { + const field = ['facts', 'qty']; + const records: any[] = []; + const table = new ListTable({ + container: createDiv(), + columns: [{ field, title: 'Quantity' }], + records + }); + const cellEvents: any[] = []; + const batchEvents: any[] = []; + table.on('change_cell_value', event => cellEvents.push(event)); + table.on('change_cell_values', event => batchEvents.push(event)); + table.dataSource.beforeChangedRecordsMap.set('0', { facts: { qty: 0 } }); + + table.changeCellValueByRecord(0, [...field], '12', { + autoRefresh: false, + noTriggerChangeCellValuesEvent: true + }); + + expect(records[0].facts.qty).toBe(12); + expect(cellEvents[0].changedValue).toBe(12); + + table.dataSource.beforeChangedRecordsMap.set('1', { facts: { qty: 0 } }); + table.changeCellValuesByRecords([{ recordIndex: 1, field: [...field], value: '14' }], { autoRefresh: false }); + + expect(records[1].facts.qty).toBe(14); + expect(cellEvents[1].changedValue).toBe(14); + expect(batchEvents[0].values).toEqual([cellEvents[1]]); + table.release(); + }); +}); diff --git a/packages/vtable/__tests__/data/data-source-nested-field-update.test.ts b/packages/vtable/__tests__/data/data-source-nested-field-update.test.ts new file mode 100644 index 0000000000..beef2f3b89 --- /dev/null +++ b/packages/vtable/__tests__/data/data-source-nested-field-update.test.ts @@ -0,0 +1,164 @@ +// @ts-nocheck +import { DataSource, getField, getRecordFieldValue } from '../../src/data/DataSource'; + +describe('DataSource nested field updates', () => { + test('updates a dotted field path by record index', () => { + const records = [{ facts: { '2025-02': { qty: 10 } } }]; + const dataSource = new DataSource({ records }); + + dataSource.changeFieldValueByRecordIndex('18', 0, 'facts.2025-02.qty'); + + expect(records[0].facts['2025-02'].qty).toBe(18); + expect(records[0]['facts.2025-02.qty']).toBeUndefined(); + dataSource.release(); + }); + + test('updates an array field path by record index', () => { + const records = [{ facts: { '2025-02': { qty: 10 } } }]; + const field = ['facts', '2025-02', 'qty']; + const dataSource = new DataSource({ records }); + + dataSource.changeFieldValueByRecordIndex('18', 0, field); + + expect(records[0].facts['2025-02'].qty).toBe(18); + expect(records[0]['facts,2025-02,qty']).toBeUndefined(); + dataSource.release(); + }); + + test('updates an array field path through the view-index write path', () => { + const records = [{ facts: { '2025-02': { qty: 10 } } }]; + const field = ['facts', '2025-02', 'qty']; + const dataSource = new DataSource({ records }); + + dataSource.changeFieldValue('18', 0, field); + + expect(records[0].facts['2025-02'].qty).toBe(18); + expect(records[0]['facts,2025-02,qty']).toBeUndefined(); + dataSource.release(); + }); + + test('keeps array paths distinct from comma-delimited literal keys', () => { + const records = [{ facts: { '2025-02': { qty: 10 } }, 'facts,2025-02,qty': 11 }]; + const field = ['facts', '2025-02', 'qty']; + const dataSource = new DataSource({ records }); + const table = { leftRowSeriesNumberCount: 0 } as any; + + dataSource.changeFieldValueByRecordIndex(18, 0, field); + + expect(records[0].facts['2025-02'].qty).toBe(18); + expect(records[0]['facts,2025-02,qty']).toBe(11); + expect(getRecordFieldValue(records[0], field)).toBe(18); + expect(getField(records[0], field, 0, 0, table, () => undefined)).toBe(18); + dataSource.release(); + }); + + test('updates a dotted field path through the view-index write path', () => { + const records = [{ facts: { '2025-02': { qty: 10 } } }]; + const dataSource = new DataSource({ records }); + + dataSource.changeFieldValue('18', 0, 'facts.2025-02.qty'); + + expect(records[0].facts['2025-02'].qty).toBe(18); + expect(records[0]['facts.2025-02.qty']).toBeUndefined(); + dataSource.release(); + }); + + test('creates missing objects while updating a dotted field path', () => { + const records = [{}]; + const dataSource = new DataSource({ records }); + + dataSource.changeFieldValueByRecordIndex(18, 0, 'facts.2025-02.qty'); + + expect(records[0]).toEqual({ facts: { '2025-02': { qty: 18 } } }); + dataSource.release(); + }); + + test('prefers an existing literal dotted field over path traversal', () => { + const records = [{ facts: { '2025-02': { qty: 10 } }, 'facts.2025-02.qty': 11 }]; + const dataSource = new DataSource({ records }); + + dataSource.changeFieldValueByRecordIndex(18, 0, 'facts.2025-02.qty'); + + expect(records[0]['facts.2025-02.qty']).toBe(18); + expect(records[0].facts['2025-02'].qty).toBe(10); + dataSource.release(); + }); + + test('does not mutate inherited objects while creating a nested field path', () => { + const inherited = { facts: { '2025-02': { qty: 10 } } }; + const record = Object.create(inherited); + const dataSource = new DataSource({ records: [record] }); + + dataSource.changeFieldValueByRecordIndex(18, 0, 'facts.2025-02.qty'); + + expect(record).toHaveProperty('facts', { '2025-02': { qty: 18 } }); + expect(inherited.facts['2025-02'].qty).toBe(10); + dataSource.release(); + }); + + test('recognizes nested fields in hasField', () => { + const records = [{ facts: { '2025-02': { qty: 10 } } }]; + const dataSource = new DataSource({ records }); + + expect(dataSource.hasField(0, 'facts.2025-02.qty')).toBe(true); + dataSource.release(); + }); + + test('recognizes numeric fields in array records', () => { + const records = [[10, 20]]; + const dataSource = new DataSource({ records }); + + expect(dataSource.hasField(0, 0)).toBe(true); + expect(dataSource.hasField(0, 1)).toBe(true); + expect(dataSource.hasField(0, 2)).toBe(false); + dataSource.release(); + }); + + test('keeps array-path reads aligned when an intermediate value is falsy', () => { + const table = { leftRowSeriesNumberCount: 0 } as any; + const record = { + nested: { + zero: 0, + disabled: false, + empty: '' + } + }; + + for (const key of ['zero', 'disabled', 'empty']) { + const field = ['nested', key, 'missing']; + expect(getRecordFieldValue(record, field)).toBeUndefined(); + expect(getField(record, field, 0, 0, table, () => undefined)).toBeUndefined(); + } + }); + + test('writes a literal __proto__ field without changing the record prototype', () => { + const record = {}; + const dataSource = new DataSource({ records: [record] }); + + dataSource.changeFieldValueByRecordIndex('safe', 0, '__proto__'); + + expect(Object.getPrototypeOf(record)).toBe(Object.prototype); + expect(Object.prototype.hasOwnProperty.call(record, '__proto__')).toBe(true); + expect(Object.getOwnPropertyDescriptor(record, '__proto__')?.value).toBe('safe'); + dataSource.release(); + }); + + test('creates sensitive nested path keys without polluting Object.prototype', () => { + const record = {}; + const dataSource = new DataSource({ records: [record] }); + + dataSource.changeFieldValueByRecordIndex('safe', 0, 'constructor.prototype.polluted'); + + expect(Object.prototype.hasOwnProperty.call(record, 'constructor')).toBe(true); + expect(Object.prototype.hasOwnProperty.call(record.constructor, 'prototype')).toBe(true); + expect(record.constructor.prototype.polluted).toBe('safe'); + expect(Object.prototype.polluted).toBeUndefined(); + + dataSource.changeFieldValueByRecordIndex('nested-safe', 0, ['__proto__', 'polluted']); + + expect(Object.getPrototypeOf(record)).toBe(Object.prototype); + expect(Object.getOwnPropertyDescriptor(record, '__proto__')?.value.polluted).toBe('nested-safe'); + expect(Object.prototype.polluted).toBeUndefined(); + dataSource.release(); + }); +}); diff --git a/packages/vtable/examples/debug/issue-4186-nested-field-edit.ts b/packages/vtable/examples/debug/issue-4186-nested-field-edit.ts new file mode 100644 index 0000000000..d8a7ce5a2c --- /dev/null +++ b/packages/vtable/examples/debug/issue-4186-nested-field-edit.ts @@ -0,0 +1,96 @@ +import * as VTable from '../../src'; +import { InputEditor } from '@visactor/vtable-editors'; + +const CONTAINER_ID = 'vTable'; +const FIELD = 'facts.2025-02.qty'; +const inputEditor = new InputEditor({}); +VTable.register.editor('issue4186-input', inputEditor); + +const records = [ + { + id: 1, + facts: { + '2025-02': { + qty: 10 + } + } + } +]; + +const updateResult = (status: HTMLElement, source: HTMLElement) => { + const record = records[0] as any; + const nestedValue = record.facts?.['2025-02']?.qty; + const flatValue = record[FIELD]; + const pass = nestedValue === 18 && !Object.prototype.hasOwnProperty.call(record, FIELD); + + status.textContent = `${pass ? 'PASS' : 'FAIL'} | nested=${nestedValue}, flat=${String(flatValue)}`; + status.style.color = pass ? '#237804' : '#a8071a'; + source.textContent = JSON.stringify(record, null, 2); + return pass; +}; + +const createControls = () => { + document.getElementById('issue4186Controls')?.remove(); + const container = document.getElementById(CONTAINER_ID)!; + const controls = document.createElement('div'); + controls.id = 'issue4186Controls'; + controls.style.cssText = 'margin-bottom: 12px; font: 13px/1.5 sans-serif;'; + + const runButton = document.createElement('button'); + runButton.textContent = 'Run #4186 check'; + + const resetButton = document.createElement('button'); + resetButton.textContent = 'Reset'; + resetButton.style.marginLeft = '8px'; + + const status = document.createElement('span'); + status.id = 'issue4186Status'; + status.style.marginLeft = '12px'; + status.textContent = 'READY | nested=10'; + + const source = document.createElement('pre'); + source.id = 'issue4186Source'; + source.style.cssText = 'margin: 8px 0 0; padding: 8px; background: #f5f5f5;'; + source.textContent = JSON.stringify(records[0], null, 2); + + controls.append(runButton, resetButton, status, source); + container.parentElement?.insertBefore(controls, container); + + return { runButton, resetButton, status, source }; +}; + +export function createTable() { + const controls = createControls(); + const tableInstance = new VTable.ListTable(document.getElementById(CONTAINER_ID)!, { + records, + columns: [ + { field: 'id', title: 'ID', width: 100 }, + { field: FIELD, title: '2025-02 Quantity', width: 220, editor: 'issue4186-input' } + ], + editCellTrigger: 'doubleclick', + widthMode: 'standard', + defaultRowHeight: 40 + }); + + const runCheck = async () => { + tableInstance.changeCellValue(1, tableInstance.columnHeaderLevelCount, '18'); + await new Promise(resolve => requestAnimationFrame(resolve)); + const pass = updateResult(controls.status, controls.source); + (window as any).BUGSERVER_SCREENSHOT?.(); + return pass; + }; + + controls.runButton.onclick = runCheck; + controls.resetButton.onclick = () => { + records[0].facts['2025-02'].qty = 10; + delete (records[0] as any)[FIELD]; + tableInstance.setRecords(records); + controls.status.textContent = 'READY | nested=10'; + controls.status.style.color = ''; + controls.source.textContent = JSON.stringify(records[0], null, 2); + }; + tableInstance.on('change_cell_value', () => updateResult(controls.status, controls.source)); + + (window as any).tableInstance = tableInstance; + (window as any).issue4186Run = runCheck; +} diff --git a/packages/vtable/examples/menu.ts b/packages/vtable/examples/menu.ts index 6a43a63cee..e8e5c4d953 100644 --- a/packages/vtable/examples/menu.ts +++ b/packages/vtable/examples/menu.ts @@ -74,6 +74,10 @@ export const menus = [ path: 'debug', name: 'issue-3752-large-merge-auto-height' }, + { + path: 'debug', + name: 'issue-4186-nested-field-edit' + }, { path: 'debug', name: 'issue-4761-update-records-edit-render' diff --git a/packages/vtable/src/ListTable.ts b/packages/vtable/src/ListTable.ts index b3bb28e128..7fa7b25716 100644 --- a/packages/vtable/src/ListTable.ts +++ b/packages/vtable/src/ListTable.ts @@ -18,7 +18,7 @@ import type { } from './ts-types'; import { HierarchyState } from './ts-types'; import { SimpleHeaderLayoutMap } from './layout'; -import { isArray, isValid } from '@visactor/vutils'; +import { arrayEqual, isArray, isValid } from '@visactor/vutils'; import { _setDataSource, _setRecords, @@ -49,7 +49,7 @@ import type { IEmptyTipComponent } from './components/empty-tip/empty-tip'; import { Factory } from './core/factory'; import { getGroupByDataConfig } from './core/group-helper'; import { DataSource, type CachedDataSource } from './data'; -import { getValueFromDeepArray } from './data/DataSource'; +import { getRecordFieldValue, getValueFromDeepArray } from './data/DataSource'; import { listTableAddRecord, listTableAddRecords, @@ -100,6 +100,10 @@ function clearLayoutColumnState(columns: ColumnsDefine | undefined) { }); } +function isSameField(left: FieldDef, right: FieldDef): boolean { + return left === right || (Array.isArray(left) && Array.isArray(right) && arrayEqual(left, right)); +} + // registerAxis(); // registerEmptyTip(); // registerLegend(); @@ -703,7 +707,7 @@ export class ListTable extends BaseTable implements ListTableAPI { return this.dataSource.getTableIndex(recordIndex) + this.columnHeaderLevelCount; } getTableIndexByField(field: FieldDef) { - const colObj = this.internalProps.layoutMap.columnObjects.find((col: any) => col.field === field); + const colObj = this.internalProps.layoutMap.columnObjects.find((col: any) => isSameField(col.field, field)); if (!colObj) { return -1; } @@ -1163,7 +1167,7 @@ export class ListTable extends BaseTable implements ListTableAPI { */ getCellRangeByField(field: FieldDef, index: number): CellRange | null { const { layoutMap } = this.internalProps; - const colObj = layoutMap.columnObjects.find((col: any) => col.field === field); + const colObj = layoutMap.columnObjects.find((col: any) => isSameField(col.field, field)); if (colObj) { const layoutRange = layoutMap.getBodyLayoutRangeById(colObj.id); let startRow; @@ -1411,7 +1415,7 @@ export class ListTable extends BaseTable implements ListTableAPI { for (let i = 0; i < columns.length; i++) { const header = columns[i]; if ( - ((fieldKey && fieldKey === header.fieldKey) || (!fieldKey && header.field === field)) && + ((fieldKey && fieldKey === header.fieldKey) || (!fieldKey && isSameField(header.field, field))) && header.sort && typeof header.sort === 'function' ) { @@ -1447,7 +1451,9 @@ export class ListTable extends BaseTable implements ListTableAPI { this.dataSource.sort( normalizedSortState.map((item: any) => { const sortFunc = this._getSortFuncFromHeaderOption(this.internalProps.columns, item.field); - const hd = this.internalProps.layoutMap.headerObjects.find((col: any) => col && col.field === item.field); + const hd = this.internalProps.layoutMap.headerObjects.find( + (col: any) => col && isSameField(col.field, item.field) + ); return { field: item.field, order: item.order, @@ -1652,7 +1658,7 @@ export class ListTable extends BaseTable implements ListTableAPI { const sortFunc = this._getSortFuncFromHeaderOption(undefined, item.field); // 如果sort传入的信息不能生成正确的sortFunc,直接更新表格,避免首次加载无法正常显示内容 const hd = this.internalProps.layoutMap.headerObjectsIncludeHided.find( - (col: any) => col && col.field === item.field + (col: any) => col && isSameField(col.field, item.field) ); return { field: item.field, @@ -1938,19 +1944,24 @@ export class ListTable extends BaseTable implements ListTableAPI { const records = (this.dataSource as DataSource).dataSourceObj?.records as any[] | undefined; let record: any; let oldValue: any; - if (Array.isArray(records) && (typeof field === 'string' || typeof field === 'number')) { + if (Array.isArray(records) && (typeof field === 'string' || typeof field === 'number' || Array.isArray(field))) { record = Array.isArray(recordIndex) ? getValueFromDeepArray(records, recordIndex) : records[recordIndex]; - oldValue = record?.[field as any]; + oldValue = getRecordFieldValue(record, field); } this.dataSource.changeFieldValueByRecordIndex(value, recordIndex, field, this); + if (Array.isArray(records)) { + record = Array.isArray(recordIndex) ? getValueFromDeepArray(records, recordIndex) : records[recordIndex]; + } if (!triggerEvent) { return; } const changedValue = - record && (typeof field === 'string' || typeof field === 'number') ? record?.[field as any] : (value as any); + record && (typeof field === 'string' || typeof field === 'number' || Array.isArray(field)) + ? getRecordFieldValue(record, field) + : (value as any); if (oldValue !== changedValue) { const cellAddr = this.getCellAddrByFieldRecord(field, recordIndex); @@ -2028,16 +2039,21 @@ export class ListTable extends BaseTable implements ListTableAPI { const records = (this.dataSource as DataSource).dataSourceObj?.records as any[] | undefined; let record: any; let oldValue: any; - if (Array.isArray(records) && (typeof field === 'string' || typeof field === 'number')) { + if (Array.isArray(records) && (typeof field === 'string' || typeof field === 'number' || Array.isArray(field))) { record = Array.isArray(recordIndex) ? getValueFromDeepArray(records, recordIndex) : records[recordIndex]; - oldValue = record?.[field as any]; + oldValue = getRecordFieldValue(record, field); } this.dataSource.changeFieldValueByRecordIndex(value, recordIndex, field, this); + if (Array.isArray(records)) { + record = Array.isArray(recordIndex) ? getValueFromDeepArray(records, recordIndex) : records[recordIndex]; + } if (triggerEvent) { const changedValue = - record && (typeof field === 'string' || typeof field === 'number') ? record?.[field as any] : (value as any); + record && (typeof field === 'string' || typeof field === 'number' || Array.isArray(field)) + ? getRecordFieldValue(record, field) + : (value as any); if (oldValue !== changedValue) { const changeValue = { col: (this.getCellAddrByFieldRecord(field, recordIndex)?.col ?? -1) as number, diff --git a/packages/vtable/src/data/DataSource.ts b/packages/vtable/src/data/DataSource.ts index d24368e13f..99e3a183da 100644 --- a/packages/vtable/src/data/DataSource.ts +++ b/packages/vtable/src/data/DataSource.ts @@ -17,9 +17,9 @@ import type { import { AggregationType, HierarchyState } from '../ts-types'; import { applyChainSafe, getOrApply, obj, isPromise, emptyFn } from '../tools/helper'; import { EventTarget } from '../event/EventTarget'; -import { computeChildrenNodeLength, getValueByPath, isAllDigits } from '../tools/util'; +import { computeChildrenNodeLength, isAllDigits } from '../tools/util'; import { calculateArrayDiff } from '../tools/diff-cell'; -import { arrayEqual, cloneDeep, isArray, isNumber, isObject, isValid } from '@visactor/vutils'; +import { arrayEqual, cloneDeep, isArray, isNumber, isValid } from '@visactor/vutils'; import type { BaseTableAPI } from '../ts-types/base-table'; import { RecordAggregator, @@ -100,8 +100,12 @@ export function getField( const colIndex = col - table.leftRowSeriesNumberCount; return record[colIndex]; } - if (isObject(record) && fieldGet in (record as any)) { - const fieldResult = (record as any)[fieldGet]; + if (Array.isArray(fieldGet)) { + const fieldResult = getRecordFieldValue(record, fieldGet); + return getValue(fieldResult, promiseCallBack); + } + if (isRecordContainer(record) && fieldGet in record) { + const fieldResult = record[fieldGet]; return getValue(fieldResult, promiseCallBack); } @@ -109,13 +113,9 @@ export function getField( const fieldResult = fieldGet(record, col, row, table); return getValue(fieldResult, promiseCallBack); } - if (Array.isArray(fieldGet)) { - const fieldResult = getValueByPath(record, [...fieldGet]); - return getValue(fieldResult, promiseCallBack); - } const fieldArray = `${fieldGet}`.split('.'); if (fieldArray.length <= 1) { - const fieldResult = (record as any)[fieldGet]; + const fieldResult = record[fieldGet]; return getValue(fieldResult, promiseCallBack); } const fieldResult = applyChainSafe( @@ -126,6 +126,94 @@ export function getField( return getValue(fieldResult, promiseCallBack); } +function isRecordContainer(value: any): boolean { + return value !== null && typeof value === 'object'; +} + +function getRecordFieldPath(field: FieldDef | number): string[] | undefined { + if (Array.isArray(field)) { + return field; + } + if (typeof field === 'string' && field.includes('.')) { + return field.split('.'); + } + return undefined; +} + +function hasRecordField(record: any, field: FieldDef | number): boolean { + if (!isRecordContainer(record)) { + return false; + } + const path = getRecordFieldPath(field); + if (!Array.isArray(field) && (field as any) in record) { + return true; + } + if (!path) { + return false; + } + let target = record; + for (const key of path) { + if (!isRecordContainer(target) || !(key in target)) { + return false; + } + target = target[key]; + } + return true; +} + +export function getRecordFieldValue(record: any, field: FieldDef | number): any { + if (record === null || record === undefined) { + return undefined; + } + const path = getRecordFieldPath(field); + if (!Array.isArray(field) && isRecordContainer(record) && (field as any) in record) { + return record[field as any]; + } + if (!path) { + return record[field as any]; + } + return path.reduce((current, key) => { + return current === null || current === undefined ? undefined : current[key]; + }, record); +} + +function setRecordProperty(record: any, key: string, value: any): void { + if (key === '__proto__' || key === 'constructor' || key === 'prototype') { + Object.defineProperty(record, key, { + configurable: true, + enumerable: true, + value, + writable: true + }); + return; + } + record[key] = value; +} + +export function setRecordFieldValue(record: any, field: FieldDef | number, value: FieldData): void { + if (record === null || record === undefined) { + return; + } + const path = getRecordFieldPath(field); + if (!path || (!Array.isArray(field) && isRecordContainer(record) && (field as any) in record)) { + setRecordProperty(record, field as any, value); + return; + } + + if (path.length === 0) { + return; + } + let target = record; + for (let i = 0; i < path.length - 1; i++) { + const key = path[i]; + if (!Object.prototype.hasOwnProperty.call(target, key) || target[key] === null || typeof target[key] !== 'object') { + setRecordProperty(target, key, {}); + } + target = target[key]; + } + setRecordProperty(target, path[path.length - 1], value); +} + function _getIndex(sortedIndexMap: null | (number | number[])[], index: number): number | number[] { if (!sortedIndexMap) { return index; @@ -744,8 +832,8 @@ export class DataSource extends EventTarget implements DataSourceAPI { if (field === undefined || field === '') { field = col - table.leftRowSeriesNumberCount; } - if (typeof field === 'string' || typeof field === 'number') { - const beforeChangedValue = this.beforeChangedRecordsMap.get(dataIndex.toString())?.[field as any]; // this.getOriginalField(index, field, col, row, table); + if (typeof field === 'string' || typeof field === 'number' || Array.isArray(field)) { + const beforeChangedValue = getRecordFieldValue(this.beforeChangedRecordsMap.get(dataIndex.toString()), field); const record = this.getOriginalRecord(dataIndex); let formatValue = value; if (typeof beforeChangedValue === 'number' && isAllDigits(value)) { @@ -754,7 +842,7 @@ export class DataSource extends EventTarget implements DataSourceAPI { if (isPromise(record)) { return record .then(record => { - record[field as string | number] = formatValue; + setRecordFieldValue(record, field, formatValue); return formatValue; }) .catch((err: Error) => { @@ -763,10 +851,10 @@ export class DataSource extends EventTarget implements DataSourceAPI { }); } if (record) { - record[field] = formatValue; + setRecordFieldValue(record, field, formatValue); } else { this.records[dataIndex as number] = this.addRecordRule === 'Array' ? [] : {}; - this.records[dataIndex as number][field] = formatValue; + setRecordFieldValue(this.records[dataIndex as number], field, formatValue); } } } @@ -802,8 +890,8 @@ export class DataSource extends EventTarget implements DataSourceAPI { ); } - if (typeof field === 'string' || typeof field === 'number') { - const beforeChangedValue = this.beforeChangedRecordsMap.get(rawKey)?.[field as any]; + if (typeof field === 'string' || typeof field === 'number' || Array.isArray(field)) { + const beforeChangedValue = getRecordFieldValue(this.beforeChangedRecordsMap.get(rawKey), field); const rawRecords = Array.isArray((this.dataSourceObj as any)?.records) ? (this.dataSourceObj as any).records : null; @@ -817,10 +905,10 @@ export class DataSource extends EventTarget implements DataSourceAPI { formatValue = parseFloat(value); } if (record) { - record[field] = formatValue; + setRecordFieldValue(record, field, formatValue); } else if (rawRecords && typeof recordIndex === 'number') { rawRecords[recordIndex] = this.addRecordRule === 'Array' ? [] : {}; - rawRecords[recordIndex][field] = formatValue; + setRecordFieldValue(rawRecords[recordIndex], field, formatValue); } } } @@ -1651,7 +1739,7 @@ export class DataSource extends EventTarget implements DataSourceAPI { return true; } const record = this.getOriginalRecord(index); - return Boolean(record && (field as any) in (record as any)); + return hasRecordField(record, field); } protected fieldPromiseCallBack(