@nocobase/client-v2 2.1.0-alpha.23 → 2.1.0-alpha.25

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -16,6 +16,7 @@ import { createFormValuesProxy } from './deps';
16
16
  import { createFormPatcher } from './form-patch';
17
17
  import { namePathToPathKey, pathKeyToNamePath, resolveDynamicNamePath } from './path';
18
18
  import { RuleEngine } from './rules';
19
+ import { getSubTableRowIdentity } from '../../../fields/AssociationFieldModel/SubTableFieldModel/rowIdentity';
19
20
  import type {
20
21
  FormAssignRuleItem,
21
22
  FormValueWriteMeta,
@@ -25,10 +26,11 @@ import type {
25
26
  SetOptions,
26
27
  ValueSource,
27
28
  } from './types';
28
- import { createTxId, MAX_WRITES_PER_PATH_PER_TX } from './utils';
29
+ import { createTxId, isEmptyValue, MAX_WRITES_PER_PATH_PER_TX } from './utils';
29
30
 
30
31
  type ObservableBinding = {
31
32
  source: ValueSource;
33
+ pathKey: string;
32
34
  dispose: () => void;
33
35
  };
34
36
 
@@ -241,6 +243,7 @@ export class FormValueRuntime {
241
243
 
242
244
  const changedPaths: NamePath[] = [];
243
245
  const touchedChangedPathKeys = new Set<string>();
246
+ let hasMeaningfulTouchedChange = false;
244
247
  let bumpedWriteSeq = false;
245
248
  for (const field of changedFields || []) {
246
249
  const name = field?.name;
@@ -259,8 +262,11 @@ export class FormValueRuntime {
259
262
  }
260
263
  _.set(this.valuesMirror, namePath, nextValue);
261
264
  changedPaths.push(namePath);
262
- if (field?.touched === true) {
265
+ const isMeaningfulTouched =
266
+ field?.touched === true && !this.shouldIgnoreSyntheticTouchedInit(namePath, prevValue, nextValue);
267
+ if (isMeaningfulTouched) {
263
268
  touchedChangedPathKeys.add(namePathToPathKey(namePath));
269
+ hasMeaningfulTouchedChange = true;
264
270
  }
265
271
  }
266
272
 
@@ -279,7 +285,7 @@ export class FormValueRuntime {
279
285
  return;
280
286
  }
281
287
 
282
- const isUser = changedFields.some((f) => f?.touched === true);
288
+ const isUser = hasMeaningfulTouchedChange;
283
289
  const source: ValueSource = isUser ? 'user' : 'system';
284
290
 
285
291
  const writeSeq = this.writeSeq;
@@ -299,16 +305,267 @@ export class FormValueRuntime {
299
305
  });
300
306
  }
301
307
 
308
+ private shouldIgnoreSyntheticTouchedInit(namePath: NamePath, prevValue: any, nextValue: any) {
309
+ if (!namePath?.length) return false;
310
+ if (typeof prevValue !== 'undefined') return false;
311
+ if (!isEmptyValue(nextValue)) return false;
312
+
313
+ for (let i = namePath.length - 1; i >= 0; i--) {
314
+ if (typeof namePath[i] !== 'number') continue;
315
+ const rowPath = namePath.slice(0, i + 1);
316
+ const rowValue = this.getFormValueAtPath(rowPath);
317
+ return !!rowValue?.__is_new__;
318
+ }
319
+
320
+ return false;
321
+ }
322
+
323
+ private isDeletedArrayItemPath(path: NamePath | string, snapshot: any) {
324
+ const namePath = Array.isArray(path) ? path : pathKeyToNamePath(path);
325
+ if (!namePath?.length) return false;
326
+
327
+ for (let i = namePath.length - 1; i >= 0; i--) {
328
+ if (typeof namePath[i] !== 'number') continue;
329
+ const rowPath = namePath.slice(0, i + 1);
330
+ return typeof _.get(snapshot, rowPath as any) === 'undefined';
331
+ }
332
+
333
+ return false;
334
+ }
335
+
336
+ private pruneDeletedArrayItemState(snapshot: any) {
337
+ for (const key of Array.from(this.explicitSet)) {
338
+ if (!this.isDeletedArrayItemPath(key, snapshot)) continue;
339
+ this.explicitSet.delete(key);
340
+ }
341
+
342
+ for (const key of Array.from(this.lastDefaultValueByPathKey.keys())) {
343
+ if (!this.isDeletedArrayItemPath(key, snapshot)) continue;
344
+ this.lastDefaultValueByPathKey.delete(key);
345
+ }
346
+
347
+ for (const key of Array.from(this.lastWriteMetaByPathKey.keys())) {
348
+ if (!this.isDeletedArrayItemPath(key, snapshot)) continue;
349
+ this.lastWriteMetaByPathKey.delete(key);
350
+ }
351
+
352
+ for (const [key, binding] of Array.from(this.observableBindings.entries())) {
353
+ if (!this.isDeletedArrayItemPath(key, snapshot)) continue;
354
+ binding.dispose();
355
+ this.observableBindings.delete(key);
356
+ }
357
+ }
358
+
359
+ private getArrayItemTargetKey(arrayPath?: NamePath): string | string[] {
360
+ let collection = this.model?.context?.collection;
361
+ let field: any;
362
+ for (const seg of arrayPath || []) {
363
+ if (typeof seg === 'number') continue;
364
+ if (typeof seg !== 'string' || !collection?.getField) break;
365
+
366
+ field = collection?.getField?.(seg);
367
+ if (!field?.isAssociationField?.()) break;
368
+ collection = field?.targetCollection;
369
+ }
370
+
371
+ const raw = field?.targetCollection?.filterTargetKey ?? field?.targetCollection?.filterByTk ?? field?.targetKey;
372
+ if (Array.isArray(raw)) {
373
+ const keys = raw.filter((key): key is string => typeof key === 'string' && !!key);
374
+ return keys.length ? keys : 'id';
375
+ }
376
+ return typeof raw === 'string' && raw ? raw : 'id';
377
+ }
378
+
379
+ private getArrayItemIdentity(item: any, arrayPath?: NamePath) {
380
+ return getSubTableRowIdentity(item, this.getArrayItemTargetKey(arrayPath));
381
+ }
382
+
383
+ private reconcileArrayItemState(rawChangedPaths: NamePath[], changedValues: any, snapshot: any) {
384
+ const seenPathKeys = new Set<string>();
385
+
386
+ for (const path of rawChangedPaths || []) {
387
+ if (!path?.length) continue;
388
+ const pathKey = namePathToPathKey(path);
389
+ if (seenPathKeys.has(pathKey)) continue;
390
+ seenPathKeys.add(pathKey);
391
+
392
+ const prevValue = _.get(this.valuesMirror, path as any);
393
+ const nextValue = this.getObservedChangedValue(path, changedValues, snapshot);
394
+ if (!Array.isArray(prevValue) || !Array.isArray(nextValue)) continue;
395
+
396
+ const nextIndexByIdentity = new Map<string, number>();
397
+ nextValue.forEach((item, index) => {
398
+ const identity = this.getArrayItemIdentity(item, path);
399
+ if (identity) {
400
+ nextIndexByIdentity.set(identity, index);
401
+ }
402
+ });
403
+
404
+ if (!nextIndexByIdentity.size) continue;
405
+
406
+ this.reconcileArrayItemSet(this.explicitSet, path, prevValue, nextIndexByIdentity);
407
+ this.reconcileArrayItemMap(this.lastDefaultValueByPathKey, path, prevValue, nextIndexByIdentity);
408
+ this.reconcileArrayItemMap(this.lastWriteMetaByPathKey, path, prevValue, nextIndexByIdentity);
409
+ this.reconcileObservableBindings(path, prevValue, nextIndexByIdentity);
410
+ }
411
+ }
412
+
413
+ private getReconciledArrayItemPath(
414
+ pathKey: string,
415
+ arrayPath: NamePath,
416
+ prevItems: any[],
417
+ nextIndexByIdentity: Map<string, number>,
418
+ ) {
419
+ const namePath = pathKeyToNamePath(pathKey);
420
+ if (namePath.length <= arrayPath.length) return { action: 'keep' as const };
421
+
422
+ for (let i = 0; i < arrayPath.length; i++) {
423
+ if (namePath[i] !== arrayPath[i]) return { action: 'keep' as const };
424
+ }
425
+
426
+ const oldIndex = namePath[arrayPath.length];
427
+ if (typeof oldIndex !== 'number') return { action: 'keep' as const };
428
+
429
+ const identity = this.getArrayItemIdentity(prevItems[oldIndex], arrayPath);
430
+ if (!identity) return { action: 'keep' as const };
431
+
432
+ const nextIndex = nextIndexByIdentity.get(identity);
433
+ if (nextIndex == null) return { action: 'delete' as const };
434
+ if (nextIndex === oldIndex) return { action: 'keep' as const };
435
+
436
+ const nextPath = [...namePath];
437
+ nextPath[arrayPath.length] = nextIndex;
438
+ return { action: 'move' as const, key: namePathToPathKey(nextPath) };
439
+ }
440
+
441
+ private reconcileArrayItemSet(
442
+ target: Set<string>,
443
+ arrayPath: NamePath,
444
+ prevItems: any[],
445
+ nextIndexByIdentity: Map<string, number>,
446
+ ) {
447
+ const nextEntries = new Set<string>();
448
+ let changed = false;
449
+
450
+ for (const key of target) {
451
+ const result = this.getReconciledArrayItemPath(key, arrayPath, prevItems, nextIndexByIdentity);
452
+ if (result.action === 'delete') {
453
+ changed = true;
454
+ continue;
455
+ }
456
+ if (result.action === 'move') {
457
+ changed = true;
458
+ nextEntries.add(result.key);
459
+ continue;
460
+ }
461
+ nextEntries.add(key);
462
+ }
463
+
464
+ if (!changed) return;
465
+ target.clear();
466
+ for (const key of nextEntries) {
467
+ target.add(key);
468
+ }
469
+ }
470
+
471
+ private reconcileArrayItemMap<T>(
472
+ target: Map<string, T>,
473
+ arrayPath: NamePath,
474
+ prevItems: any[],
475
+ nextIndexByIdentity: Map<string, number>,
476
+ ) {
477
+ const nextEntries = new Map<string, T>();
478
+ let changed = false;
479
+
480
+ for (const [key, value] of target) {
481
+ const result = this.getReconciledArrayItemPath(key, arrayPath, prevItems, nextIndexByIdentity);
482
+ if (result.action === 'delete') {
483
+ changed = true;
484
+ continue;
485
+ }
486
+ if (result.action === 'move') {
487
+ changed = true;
488
+ nextEntries.set(result.key, value);
489
+ continue;
490
+ }
491
+ nextEntries.set(key, value);
492
+ }
493
+
494
+ if (!changed) return;
495
+ target.clear();
496
+ for (const [key, value] of nextEntries) {
497
+ target.set(key, value);
498
+ }
499
+ }
500
+
501
+ private reconcileObservableBindings(arrayPath: NamePath, prevItems: any[], nextIndexByIdentity: Map<string, number>) {
502
+ const nextEntries = new Map<string, ObservableBinding>();
503
+ let changed = false;
504
+
505
+ for (const [key, binding] of this.observableBindings) {
506
+ const result = this.getReconciledArrayItemPath(key, arrayPath, prevItems, nextIndexByIdentity);
507
+ if (result.action === 'delete') {
508
+ changed = true;
509
+ binding.dispose();
510
+ continue;
511
+ }
512
+ if (result.action === 'move') {
513
+ changed = true;
514
+ binding.pathKey = result.key;
515
+ nextEntries.set(result.key, binding);
516
+ continue;
517
+ }
518
+ nextEntries.set(key, binding);
519
+ }
520
+
521
+ if (!changed) return;
522
+ this.observableBindings.clear();
523
+ for (const [key, binding] of nextEntries) {
524
+ this.observableBindings.set(key, binding);
525
+ }
526
+ }
527
+
528
+ private getObservedChangedValue(path: NamePath, changedValues: any, snapshot: any) {
529
+ if (path?.length === 1) {
530
+ const key = path[0];
531
+ if (
532
+ (typeof key === 'string' || typeof key === 'number') &&
533
+ changedValues &&
534
+ Object.prototype.hasOwnProperty.call(changedValues, key)
535
+ ) {
536
+ return changedValues[key];
537
+ }
538
+ }
539
+
540
+ return _.get(snapshot, path as any);
541
+ }
542
+
543
+ private getObservedSnapshot(changedValues: any, snapshot: any) {
544
+ if (!changedValues || typeof changedValues !== 'object') return snapshot;
545
+ if (!snapshot || typeof snapshot !== 'object') return changedValues;
546
+
547
+ let observed = snapshot;
548
+ for (const key of Object.keys(changedValues)) {
549
+ if (observed === snapshot) {
550
+ observed = Array.isArray(snapshot) ? [...snapshot] : { ...snapshot };
551
+ }
552
+ _.set(observed, [key], changedValues[key]);
553
+ }
554
+
555
+ return observed;
556
+ }
557
+
302
558
  handleFormValuesChange(changedValues: any, allValues: any) {
303
559
  if (this.disposed) return;
304
560
  if (this.isSuppressed()) {
305
561
  return;
306
562
  }
307
563
 
564
+ const changedValuePaths: NamePath[] = Object.keys(changedValues || {}).map((k) => [k]);
308
565
  const rawChangedPaths: NamePath[] =
309
566
  this.lastObservedChangedPaths && this.lastObservedChangedPaths.length
310
567
  ? this.lastObservedChangedPaths
311
- : Object.keys(changedValues || {}).map((k) => [k]);
568
+ : changedValuePaths;
312
569
 
313
570
  const source: ValueSource = this.lastObservedSource ?? 'user';
314
571
 
@@ -316,16 +573,21 @@ export class FormValueRuntime {
316
573
  this.lastObservedChangedPaths = null;
317
574
  this.lastObservedSource = null;
318
575
 
319
- const snapshot = allValues && typeof allValues === 'object' ? allValues : this.getFormValuesSnapshot();
576
+ const rawSnapshot = allValues && typeof allValues === 'object' ? allValues : this.getFormValuesSnapshot();
577
+ const snapshot = this.getObservedSnapshot(changedValues, rawSnapshot);
578
+ this.reconcileArrayItemState([...rawChangedPaths, ...changedValuePaths], changedValues, snapshot);
579
+ this.pruneDeletedArrayItemState(snapshot);
320
580
 
321
- const explicitPaths = this.deriveExplicitPaths(rawChangedPaths, snapshot, this.valuesMirror);
581
+ const explicitPaths = this.deriveExplicitPaths(rawChangedPaths, changedValues, snapshot, this.valuesMirror).filter(
582
+ (path) => !this.isDeletedArrayItemPath(path, snapshot),
583
+ );
322
584
 
323
585
  let hasMirrorChange = false;
324
586
  let bumpedWriteSeq = false;
325
587
  const actuallyChangedPaths: NamePath[] = [];
326
588
  for (const p of rawChangedPaths) {
327
589
  if (!p?.length) continue;
328
- const nextValue = _.get(snapshot, p);
590
+ const nextValue = this.getObservedChangedValue(p, changedValues, snapshot);
329
591
  const prevValue = _.get(this.valuesMirror, p);
330
592
  if (_.isEqual(prevValue, nextValue)) continue;
331
593
  if (!bumpedWriteSeq) {
@@ -364,14 +626,14 @@ export class FormValueRuntime {
364
626
  });
365
627
  }
366
628
 
367
- private deriveExplicitPaths(rawChangedPaths: NamePath[], snapshot: any, valuesMirrorBefore: any): NamePath[] {
629
+ private deriveExplicitPaths(rawChangedPaths: NamePath[], changedValues: any, snapshot: any, valuesMirrorBefore: any) {
368
630
  const explicitPaths: NamePath[] = [];
369
631
  const seen = new Set<string>();
370
632
 
371
633
  for (const path of rawChangedPaths || []) {
372
634
  if (!path?.length) continue;
373
635
  const prevValue = _.get(valuesMirrorBefore, path as any);
374
- const nextValue = _.get(snapshot, path as any);
636
+ const nextValue = this.getObservedChangedValue(path, changedValues, snapshot);
375
637
  this.collectExplicitDiffPaths(prevValue, nextValue, path, explicitPaths, seen);
376
638
  }
377
639
 
@@ -667,19 +929,30 @@ export class FormValueRuntime {
667
929
  }
668
930
  }
669
931
 
670
- for (const { namePath, rawValue, pathKey } of filteredToWrite) {
932
+ for (const { rawValue, pathKey } of filteredToWrite) {
671
933
  if (!isObservable(rawValue)) continue;
672
934
  const obs = rawValue;
673
935
 
936
+ const binding: ObservableBinding = { source, pathKey, dispose: () => {} };
674
937
  const disposer = observe(obs, () => {
675
- this.applyBoundValue(callerCtx, namePath, pathKey, toJS(obs), source, linkageScopeDepth, linkageTxId);
938
+ const currentPathKey = binding.pathKey;
939
+ this.applyBoundValue(
940
+ callerCtx,
941
+ pathKeyToNamePath(currentPathKey),
942
+ currentPathKey,
943
+ toJS(obs),
944
+ source,
945
+ linkageScopeDepth,
946
+ linkageTxId,
947
+ );
676
948
  });
949
+ binding.dispose = disposer;
677
950
 
678
951
  const existing = this.observableBindings.get(pathKey);
679
952
  if (existing) {
680
953
  existing.dispose();
681
954
  }
682
- this.observableBindings.set(pathKey, { source, dispose: disposer });
955
+ this.observableBindings.set(pathKey, binding);
683
956
  }
684
957
 
685
958
  if (triggerEvent) {
@@ -34,6 +34,7 @@ import { SubTableFieldModel } from '.';
34
34
  import { FieldModel } from '../../../base/FieldModel';
35
35
  import { FieldDeletePlaceholder, CustomWidth } from '../../../blocks/table/TableColumnModel';
36
36
  import { buildDynamicNamePath } from '../../../blocks/form/dynamicNamePath';
37
+ import { getSubTableRowIdentity } from './rowIdentity';
37
38
 
38
39
  const SubTableRowRuleBinder: React.FC<{ model: any }> = ({ model }) => {
39
40
  React.useEffect(() => {
@@ -355,7 +356,11 @@ const MemoCell: React.FC<CellProps> = React.memo(
355
356
  },
356
357
  (prev, next) => {
357
358
  return (
358
- prev.value === next.value && prev.id === next.id && prev.memoKey === next.memoKey && prev.width === next.width
359
+ prev.value === next.value &&
360
+ prev.id === next.id &&
361
+ prev.memoKey === next.memoKey &&
362
+ prev.width === next.width &&
363
+ prev.rowIdx === next.rowIdx
359
364
  );
360
365
  },
361
366
  );
@@ -548,7 +553,10 @@ export class SubTableColumnModel<
548
553
  const baseFieldIndex = parentFieldIndex ?? (this.parent as any)?.context?.fieldIndex ?? this.context?.fieldIndex;
549
554
  const baseArr = Array.isArray(baseFieldIndex) ? baseFieldIndex : [];
550
555
  const baseIndexKey = baseArr.length ? baseArr.join('|') : 'root';
551
- const rowForkKey = `row:${baseIndexKey}:${String(rowIdx)}`;
556
+ const filterTargetKey =
557
+ (this.parent as any)?.collection?.filterTargetKey ?? (this.parent as any)?.context?.collection?.filterTargetKey;
558
+ const rowIdentity = getSubTableRowIdentity(record, filterTargetKey) ?? `row:${String(rowIdx)}`;
559
+ const rowForkKey = `row:${baseIndexKey}:${rowIdentity}:${String(rowIdx)}`;
552
560
  const rowFork: any = (() => {
553
561
  const fork = this.createFork({}, rowForkKey);
554
562
  const associationFieldPath =
@@ -14,12 +14,11 @@ import { useTranslation } from 'react-i18next';
14
14
  import { PlusOutlined } from '@ant-design/icons';
15
15
  import React, { useEffect, useMemo, useState } from 'react';
16
16
  import { ActionWithoutPermission } from '../../../base/ActionModel';
17
- import { getRowKey } from '../../../blocks/table/utils';
17
+ import { getSubTableRowIdentity, normalizeSubTableRows } from './rowIdentity';
18
18
 
19
19
  export function SubTableField(props) {
20
20
  const { t } = useTranslation();
21
21
  const {
22
- value = [],
23
22
  onChange,
24
23
  columns,
25
24
  disabled,
@@ -39,12 +38,25 @@ export function SubTableField(props) {
39
38
  } = props;
40
39
  const [currentPage, setCurrentPage] = useState(1);
41
40
  const [currentPageSize, setCurrentPageSize] = useState(pageSize);
41
+ const rawCurrentValue = getCurrentValue();
42
+ const currentValue = useMemo(() => normalizeSubTableRows(rawCurrentValue), [rawCurrentValue]);
43
+ const getRecordIdentity = React.useCallback(
44
+ (record: any) => getSubTableRowIdentity(record, filterTargetKey),
45
+ [filterTargetKey],
46
+ );
42
47
  useEffect(() => {
43
48
  setCurrentPageSize(pageSize);
44
49
  }, [pageSize]);
45
50
  useEffect(() => {
46
51
  resetPage && setCurrentPage(1);
47
52
  }, [resetPage]);
53
+ const applyValue = React.useCallback((nextValue: any) => onChange?.(normalizeSubTableRows(nextValue)), [onChange]);
54
+ const getLatestValue = React.useCallback(() => normalizeSubTableRows(getCurrentValue()), [getCurrentValue]);
55
+ useEffect(() => {
56
+ if (currentValue !== rawCurrentValue) {
57
+ applyValue(currentValue);
58
+ }
59
+ }, [applyValue, currentValue, rawCurrentValue]);
48
60
 
49
61
  // 前端分页
50
62
  const pagination = useMemo(() => {
@@ -56,7 +68,7 @@ export function SubTableField(props) {
56
68
  },
57
69
  current: currentPage,
58
70
  pageSize: currentPageSize,
59
- total: value?.length,
71
+ total: currentValue.length,
60
72
  onChange: (page, size) => {
61
73
  setCurrentPage(page);
62
74
  setCurrentPageSize(size);
@@ -66,38 +78,36 @@ export function SubTableField(props) {
66
78
  return t('Total {{count}} items', { count: total });
67
79
  },
68
80
  } as any;
69
- }, [currentPage, currentPageSize, value]);
81
+ }, [currentPage, currentPageSize, currentValue.length]);
70
82
 
71
83
  // 新增一行
72
84
  const handleAdd = () => {
73
85
  if (allowCreate === false) return;
74
86
 
75
- const currentValue = getCurrentValue();
76
87
  const newRow = {
77
88
  __is_new__: true,
78
89
  };
79
90
  columns.forEach((col) => {
80
91
  newRow[col.dataIndex] = undefined;
81
92
  });
82
- const newValue = [...currentValue, newRow];
93
+ const newValue = [...getLatestValue(), newRow];
83
94
  setCurrentPage(Math.ceil(newValue.length / currentPageSize));
84
- onChange?.(newValue);
95
+ applyValue(newValue);
85
96
  };
86
97
 
87
98
  // 删除行
88
99
  const handleDelete = (index: number) => {
89
- const newValue = [...getCurrentValue()];
100
+ const newValue = [...getLatestValue()];
90
101
  newValue.splice(index, 1);
91
102
  const lastPage = Math.ceil(newValue.length / currentPageSize);
92
103
  setCurrentPage(currentPage > lastPage ? lastPage : currentPage);
93
- onChange?.(newValue);
104
+ applyValue(newValue);
94
105
  };
95
106
 
96
107
  // 编辑单元格
97
108
  const handleCellChange = (rowIdx, dataIndex, cellValue) => {
98
- const currentValue = getCurrentValue();
99
- const newData = currentValue.map((row, idx) => (idx === rowIdx ? { ...row, [dataIndex]: cellValue } : row));
100
- onChange?.(newData);
109
+ const newData = getLatestValue().map((row, idx) => (idx === rowIdx ? { ...row, [dataIndex]: cellValue } : row));
110
+ applyValue(newData);
101
111
  };
102
112
 
103
113
  // 渲染可编辑单元格
@@ -106,20 +116,25 @@ export function SubTableField(props) {
106
116
  ...col,
107
117
  render: (text, record, rowIdx) => {
108
118
  const pageRowIdx = (currentPage - 1) * currentPageSize + rowIdx;
119
+ const rowIdentity = getRecordIdentity(record) ?? `row:${pageRowIdx}`;
120
+ // row identity keeps logical rows stable, while binding key still follows
121
+ // the current array index so Form.Item can rebind after reordering/removal.
122
+ const rowBindingKey = `${rowIdentity}:${pageRowIdx}`;
123
+ const columnKey = col.dataIndex ?? col.key ?? 'cell';
109
124
  if (!col.render) {
110
125
  return;
111
126
  }
112
- return col?.render({
127
+ return col.render({
113
128
  record,
114
129
  rowIdx: pageRowIdx,
115
- id: `field-${col.dataIndex}-${rowIdx}`,
130
+ id: `field-${String(columnKey)}-${rowBindingKey}`,
116
131
  value: text,
117
132
  parentFieldIndex,
118
133
  parentItem,
119
134
  onChange: (value) => {
120
- handleCellChange(pageRowIdx, col.dataIndex, value?.target?.value || value);
135
+ handleCellChange(pageRowIdx, col.dataIndex, value?.target?.value ?? value);
121
136
  },
122
- ['aria-describedby']: `field-${col.dataIndex}-${rowIdx}`,
137
+ ['aria-describedby']: `field-${String(columnKey)}-${rowBindingKey}`,
123
138
  });
124
139
  },
125
140
  }))
@@ -137,8 +152,17 @@ export function SubTableField(props) {
137
152
  }
138
153
  return (
139
154
  <div
155
+ onMouseDown={(event) => {
156
+ const activeElement = document.activeElement as HTMLElement | null;
157
+ if (!activeElement || event.currentTarget.contains(activeElement)) {
158
+ return;
159
+ }
160
+ activeElement.blur?.();
161
+ }}
140
162
  onClick={() => {
141
- handleDelete(pageRowIdx);
163
+ setTimeout(() => {
164
+ handleDelete(pageRowIdx);
165
+ });
142
166
  }}
143
167
  >
144
168
  <CloseOutlined style={{ cursor: 'pointer', color: 'gray' }} />
@@ -150,17 +174,17 @@ export function SubTableField(props) {
150
174
  .filter(Boolean);
151
175
 
152
176
  const pagedDataSource = useMemo(() => {
153
- if (!value?.length) return [];
177
+ if (!currentValue.length) return [];
154
178
 
155
179
  const start = (currentPage - 1) * currentPageSize;
156
- return value.slice(start, start + currentPageSize);
157
- }, [value, currentPage, currentPageSize]);
180
+ return currentValue.slice(start, start + currentPageSize);
181
+ }, [currentValue, currentPage, currentPageSize]);
158
182
  return (
159
183
  <Form.Item>
160
184
  <Table
161
185
  dataSource={pagedDataSource}
162
186
  columns={editableColumns}
163
- rowKey={(record) => getRowKey(record, filterTargetKey)}
187
+ rowKey={(record) => getRecordIdentity(record) ?? ''}
164
188
  tableLayout="fixed"
165
189
  scroll={{ x: 'max-content' }}
166
190
  pagination={pagination}
@@ -179,7 +203,7 @@ export function SubTableField(props) {
179
203
  </span>
180
204
  ),
181
205
  }}
182
- components={components || {}}
206
+ components={components ?? {}}
183
207
  className={css`
184
208
  .ant-table-cell-ellipsis.ant-table-cell-fix-right-first .ant-table-cell-content {
185
209
  display: inline;
@@ -0,0 +1,70 @@
1
+ /**
2
+ * This file is part of the NocoBase (R) project.
3
+ * Copyright (c) 2020-2024 NocoBase Co., Ltd.
4
+ * Authors: NocoBase Team.
5
+ *
6
+ * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
7
+ * For more information, please refer to: https://www.nocobase.com/agreement.
8
+ */
9
+
10
+ import { uid } from '@formily/shared';
11
+
12
+ type FilterTargetKey = string | string[] | null | undefined;
13
+ type SubTableRow = {
14
+ __is_new__?: boolean;
15
+ id?: any;
16
+ [key: string]: any;
17
+ };
18
+
19
+ export const SUB_TABLE_TEMP_ROW_KEY = '__index__';
20
+
21
+ function getPersistedRowKey(record: SubTableRow, filterTargetKey: FilterTargetKey) {
22
+ if (!filterTargetKey) return null;
23
+
24
+ if (Array.isArray(filterTargetKey)) {
25
+ const values = filterTargetKey.map((k) => record?.[k]);
26
+ if (values.some((v) => v == null)) return null;
27
+ return values.map((v) => String(v)).join('__');
28
+ }
29
+
30
+ const value = record?.[filterTargetKey];
31
+ return value == null ? null : String(value);
32
+ }
33
+
34
+ export function getSubTableRowIdentity(record: SubTableRow, filterTargetKey: FilterTargetKey) {
35
+ const tempKey = record?.[SUB_TABLE_TEMP_ROW_KEY];
36
+ if (record?.__is_new__ && tempKey != null && tempKey !== '') {
37
+ return `tmp:${String(tempKey)}`;
38
+ }
39
+
40
+ const persistedKey = getPersistedRowKey(record, filterTargetKey);
41
+ if (persistedKey != null) {
42
+ return `pk:${persistedKey}`;
43
+ }
44
+
45
+ if (tempKey != null && tempKey !== '') {
46
+ return `tmp:${String(tempKey)}`;
47
+ }
48
+
49
+ return null;
50
+ }
51
+
52
+ export function normalizeSubTableRows(rows: SubTableRow[]) {
53
+ if (!rows.length) return rows;
54
+
55
+ let changed = false;
56
+ const normalized = rows.map((row) => {
57
+ const tempKey = row?.[SUB_TABLE_TEMP_ROW_KEY];
58
+ if (!row.__is_new__ || (tempKey != null && tempKey !== '')) {
59
+ return row;
60
+ }
61
+
62
+ changed = true;
63
+ return {
64
+ ...row,
65
+ [SUB_TABLE_TEMP_ROW_KEY]: uid(),
66
+ };
67
+ });
68
+
69
+ return changed ? normalized : rows;
70
+ }