@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.
- package/es/flow/models/base/BlockGridModel.d.ts +2 -1
- package/es/flow/models/blocks/form/value-runtime/rules.d.ts +5 -0
- package/es/flow/models/blocks/form/value-runtime/runtime.d.ts +12 -0
- package/es/flow/models/fields/AssociationFieldModel/SubTableFieldModel/rowIdentity.d.ts +18 -0
- package/es/index.mjs +70 -70
- package/lib/index.js +71 -71
- package/package.json +5 -5
- package/src/flow/models/base/BlockGridModel.tsx +48 -2
- package/src/flow/models/base/__tests__/BlockGridModel.selectSceneAddBlock.test.ts +83 -0
- package/src/flow/models/blocks/form/value-runtime/__tests__/runtime.test.ts +293 -0
- package/src/flow/models/blocks/form/value-runtime/__tests__/subtable-nested.test.ts +3 -2
- package/src/flow/models/blocks/form/value-runtime/rules.ts +66 -14
- package/src/flow/models/blocks/form/value-runtime/runtime.ts +285 -12
- package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/SubTableColumnModel.tsx +10 -2
- package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/SubTableField.tsx +46 -22
- package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/rowIdentity.ts +70 -0
- package/src/flow/models/fields/AssociationFieldModel/__tests__/SubTableRowIdentity.test.ts +45 -0
|
@@ -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
|
-
|
|
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 =
|
|
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
|
-
:
|
|
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
|
|
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 =
|
|
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)
|
|
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 =
|
|
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 {
|
|
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
|
-
|
|
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,
|
|
955
|
+
this.observableBindings.set(pathKey, binding);
|
|
683
956
|
}
|
|
684
957
|
|
|
685
958
|
if (triggerEvent) {
|
package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/SubTableColumnModel.tsx
CHANGED
|
@@ -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 &&
|
|
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
|
|
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 {
|
|
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:
|
|
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,
|
|
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 = [...
|
|
93
|
+
const newValue = [...getLatestValue(), newRow];
|
|
83
94
|
setCurrentPage(Math.ceil(newValue.length / currentPageSize));
|
|
84
|
-
|
|
95
|
+
applyValue(newValue);
|
|
85
96
|
};
|
|
86
97
|
|
|
87
98
|
// 删除行
|
|
88
99
|
const handleDelete = (index: number) => {
|
|
89
|
-
const newValue = [...
|
|
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
|
-
|
|
104
|
+
applyValue(newValue);
|
|
94
105
|
};
|
|
95
106
|
|
|
96
107
|
// 编辑单元格
|
|
97
108
|
const handleCellChange = (rowIdx, dataIndex, cellValue) => {
|
|
98
|
-
const
|
|
99
|
-
|
|
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
|
|
127
|
+
return col.render({
|
|
113
128
|
record,
|
|
114
129
|
rowIdx: pageRowIdx,
|
|
115
|
-
id: `field-${
|
|
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
|
|
135
|
+
handleCellChange(pageRowIdx, col.dataIndex, value?.target?.value ?? value);
|
|
121
136
|
},
|
|
122
|
-
['aria-describedby']: `field-${
|
|
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
|
-
|
|
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 (!
|
|
177
|
+
if (!currentValue.length) return [];
|
|
154
178
|
|
|
155
179
|
const start = (currentPage - 1) * currentPageSize;
|
|
156
|
-
return
|
|
157
|
-
}, [
|
|
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) =>
|
|
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
|
+
}
|