@nocobase/client-v2 2.1.0-alpha.30 → 2.1.0-alpha.32
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/components/form/JsonTextArea.d.ts +18 -0
- package/es/components/index.d.ts +1 -0
- package/es/flow/actions/dateRangeLimit.d.ts +9 -0
- package/es/flow/actions/index.d.ts +1 -0
- package/es/flow/actions/linkageRulesFormValueRefresh.d.ts +10 -0
- package/es/flow/index.d.ts +1 -0
- package/es/flow/models/actions/AssociateActionModel.d.ts +19 -0
- package/es/flow/models/actions/AssociationActionUtils.d.ts +17 -0
- package/es/flow/models/actions/DisassociateActionModel.d.ts +16 -0
- package/es/flow/models/actions/index.d.ts +3 -0
- package/es/flow/models/base/GridModel.d.ts +3 -1
- package/es/flow/models/base/PageModel/PageModel.d.ts +4 -0
- package/es/flow/models/base/PageModel/RootPageModel.d.ts +9 -0
- package/es/flow/models/blocks/filter-form/FilterFormGridModel.d.ts +15 -6
- package/es/flow/models/blocks/form/value-runtime/runtime.d.ts +7 -0
- package/es/flow/models/blocks/shared/filterOperators.d.ts +9 -0
- package/es/flow/models/fields/AssociationFieldModel/SubTableFieldModel/SubTableColumnModel.d.ts +3 -0
- package/es/flow/models/fields/AssociationFieldModel/recordSelectSettingsUtils.d.ts +9 -0
- package/es/flow/models/fields/DateTimeFieldModel/dateLimit.d.ts +20 -0
- package/es/flow/models/fields/JSEditableFieldModel.d.ts +4 -0
- package/es/flow-compat/data.d.ts +9 -2
- package/es/flow-compat/index.d.ts +1 -1
- package/es/index.d.ts +1 -0
- package/es/index.mjs +100 -93
- package/lib/index.js +101 -94
- package/package.json +6 -5
- package/src/BaseApplication.tsx +1 -1
- package/src/__tests__/app.test.tsx +23 -6
- package/src/__tests__/globalDeps.test.ts +5 -0
- package/src/components/form/JsonTextArea.tsx +129 -0
- package/src/components/index.ts +1 -0
- package/src/flow/actions/__tests__/fieldLinkageRules.scopeDepth.test.ts +478 -0
- package/src/flow/actions/__tests__/linkageRules.formValueDrivenRefresh.test.ts +438 -0
- package/src/flow/actions/__tests__/linkageRulesRefresh.test.ts +42 -0
- package/src/flow/actions/__tests__/pattern.test.ts +190 -0
- package/src/flow/actions/dateRangeLimit.tsx +66 -0
- package/src/flow/actions/index.ts +1 -0
- package/src/flow/actions/linkageRules.tsx +119 -14
- package/src/flow/actions/linkageRulesFormValueRefresh.ts +492 -0
- package/src/flow/actions/linkageRulesRefresh.tsx +4 -2
- package/src/flow/actions/openView.tsx +2 -1
- package/src/flow/actions/pattern.tsx +25 -2
- package/src/flow/actions/titleField.tsx +8 -3
- package/src/flow/admin-shell/AdminLayoutRouteCoordinator.ts +7 -1
- package/src/flow/admin-shell/__tests__/AdminLayoutRouteCoordinator.test.ts +117 -0
- package/src/flow/components/FieldAssignValueInput.tsx +1 -0
- package/src/flow/components/filter/LinkageFilterItem.tsx +6 -5
- package/src/flow/components/filter/VariableFilterItem.tsx +14 -13
- package/src/flow/components/filter/__tests__/LinkageFilterItem.test.tsx +33 -0
- package/src/flow/components/filter/__tests__/VariableFilterItem.test.tsx +48 -5
- package/src/flow/index.ts +1 -0
- package/src/flow/internal/utils/__tests__/titleFieldQuickSync.test.ts +1 -0
- package/src/flow/internal/utils/titleFieldQuickSync.ts +2 -2
- package/src/flow/models/actions/AssociateActionModel.tsx +196 -0
- package/src/flow/models/actions/AssociationActionUtils.ts +90 -0
- package/src/flow/models/actions/DisassociateActionModel.tsx +57 -0
- package/src/flow/models/actions/FilterActionModel.tsx +17 -9
- package/src/flow/models/actions/__tests__/AssociationActionModel.test.ts +250 -0
- package/src/flow/models/actions/index.ts +3 -0
- package/src/flow/models/base/GridModel.tsx +21 -1
- package/src/flow/models/base/PageModel/PageModel.tsx +15 -3
- package/src/flow/models/base/PageModel/RootPageModel.tsx +37 -2
- package/src/flow/models/base/PageModel/__tests__/PageModel.test.ts +73 -0
- package/src/flow/models/base/PageModel/__tests__/RootPageModel.test.ts +116 -0
- package/src/flow/models/base/__tests__/GridModel.dragSnapshotContainer.test.ts +98 -0
- package/src/flow/models/blocks/details/DetailsItemModel.tsx +3 -0
- package/src/flow/models/blocks/filter-form/FilterFormGridModel.tsx +200 -36
- package/src/flow/models/blocks/filter-form/__tests__/FilterFormGridModel.toggleFormFieldsCollapse.test.ts +270 -1
- package/src/flow/models/blocks/filter-form/__tests__/customFieldOperators.test.tsx +23 -0
- package/src/flow/models/blocks/filter-form/customFieldOperators.ts +12 -1
- package/src/flow/models/blocks/filter-form/fields/FieldComponentProps.tsx +22 -8
- package/src/flow/models/blocks/filter-form/fields/__tests__/FilterFormCustomFieldModel.recordSelect.test.tsx +18 -0
- package/src/flow/models/blocks/filter-manager/FilterManager.ts +51 -1
- package/src/flow/models/blocks/filter-manager/__tests__/FilterManager.test.ts +75 -0
- package/src/flow/models/blocks/form/FormItemModel.tsx +48 -28
- package/src/flow/models/blocks/form/value-runtime/__tests__/runtime.test.ts +167 -1
- package/src/flow/models/blocks/form/value-runtime/runtime.ts +103 -11
- package/src/flow/models/blocks/shared/filterOperators.ts +14 -0
- package/src/flow/models/blocks/table/TableBlockModel.tsx +19 -3
- package/src/flow/models/fields/AssociationFieldModel/RecordSelectFieldModel.tsx +5 -1
- package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/SubTableColumnModel.tsx +48 -8
- package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/SubTableField.tsx +47 -0
- package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/__tests__/SubTableColumnModel.rowRecord.test.ts +42 -0
- package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/__tests__/SubTableField.refresh.test.tsx +122 -0
- package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/index.tsx +2 -0
- package/src/flow/models/fields/AssociationFieldModel/recordSelectSettingsUtils.ts +20 -0
- package/src/flow/models/fields/ClickableFieldModel.tsx +21 -9
- package/src/flow/models/fields/DateTimeFieldModel/DateOnlyFieldModel.tsx +9 -0
- package/src/flow/models/fields/DateTimeFieldModel/DateTimeFieldModel.tsx +4 -0
- package/src/flow/models/fields/DateTimeFieldModel/DateTimeNoTzFieldModel.tsx +9 -0
- package/src/flow/models/fields/DateTimeFieldModel/DateTimeTzFieldModel.tsx +9 -0
- package/src/flow/models/fields/DateTimeFieldModel/__tests__/DateTimeNoTzFieldModel.dateLimit.test.tsx +242 -0
- package/src/flow/models/fields/DateTimeFieldModel/dateLimit.ts +152 -0
- package/src/flow/models/fields/DividerItemModel.tsx +30 -15
- package/src/flow/models/fields/JSEditableFieldModel.tsx +110 -14
- package/src/flow/models/fields/__tests__/ClickableFieldModel.test.ts +87 -0
- package/src/flow/models/fields/__tests__/JSEditableFieldModel.test.tsx +210 -0
- package/src/flow/models/fields/mobile-components/MobileSelect.tsx +11 -3
- package/src/flow/models/fields/mobile-components/__tests__/MobileSelect.test.tsx +235 -0
- package/src/flow-compat/data.ts +25 -3
- package/src/flow-compat/index.ts +7 -1
- package/src/index.ts +1 -0
- package/src/utils/globalDeps.ts +6 -0
package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/SubTableColumnModel.tsx
CHANGED
|
@@ -177,6 +177,25 @@ const MemoFieldRenderer = React.memo(FieldModelRenderer, (prev, next) => {
|
|
|
177
177
|
return prev.value === next.value && prev.model === next.model;
|
|
178
178
|
});
|
|
179
179
|
|
|
180
|
+
export function buildRowPathFromFieldIndex(fieldIndex: unknown): Array<string | number> | null {
|
|
181
|
+
if (!Array.isArray(fieldIndex) || !fieldIndex.length) return null;
|
|
182
|
+
const out: Array<string | number> = [];
|
|
183
|
+
for (const entry of fieldIndex) {
|
|
184
|
+
if (typeof entry !== 'string') continue;
|
|
185
|
+
const [fieldName, indexStr] = entry.split(':');
|
|
186
|
+
const index = Number(indexStr);
|
|
187
|
+
if (!fieldName || !Number.isFinite(index)) continue;
|
|
188
|
+
out.push(fieldName, index);
|
|
189
|
+
}
|
|
190
|
+
return out.length ? out : null;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export function getLatestSubTableRowRecord(form: any, fieldIndex: unknown, fallbackRecord: any): any {
|
|
194
|
+
const latestRowPath = buildRowPathFromFieldIndex(fieldIndex);
|
|
195
|
+
const latestRecord = latestRowPath ? form?.getFieldValue?.(latestRowPath) : undefined;
|
|
196
|
+
return typeof latestRecord === 'undefined' ? fallbackRecord : latestRecord;
|
|
197
|
+
}
|
|
198
|
+
|
|
180
199
|
function shouldCommitImmediately(value: any) {
|
|
181
200
|
if (Array.isArray(value)) {
|
|
182
201
|
return true;
|
|
@@ -194,7 +213,7 @@ function shouldCommitImmediately(value: any) {
|
|
|
194
213
|
}
|
|
195
214
|
|
|
196
215
|
const FieldModelRendererOptimize = React.memo((props: any) => {
|
|
197
|
-
const { model, onChange, value, ...rest } = props;
|
|
216
|
+
const { model, onChange, value, commitOnChange, ...rest } = props;
|
|
198
217
|
const pendingValueRef = React.useRef<any>(props?.value);
|
|
199
218
|
|
|
200
219
|
useEffect(() => {
|
|
@@ -204,11 +223,11 @@ const FieldModelRendererOptimize = React.memo((props: any) => {
|
|
|
204
223
|
const handleChange = React.useCallback(
|
|
205
224
|
(value: any) => {
|
|
206
225
|
pendingValueRef.current = value;
|
|
207
|
-
if (shouldCommitImmediately(value)) {
|
|
226
|
+
if (commitOnChange || shouldCommitImmediately(value)) {
|
|
208
227
|
onChange?.(value);
|
|
209
228
|
}
|
|
210
229
|
},
|
|
211
|
-
[onChange],
|
|
230
|
+
[commitOnChange, onChange],
|
|
212
231
|
);
|
|
213
232
|
|
|
214
233
|
const handleCommit = React.useCallback(() => {
|
|
@@ -241,10 +260,11 @@ interface CellProps {
|
|
|
241
260
|
rowFork?: any;
|
|
242
261
|
memoKey?: string;
|
|
243
262
|
width?: number;
|
|
263
|
+
commitOnChange?: boolean;
|
|
244
264
|
}
|
|
245
265
|
|
|
246
266
|
const MemoCell: React.FC<CellProps> = React.memo(
|
|
247
|
-
({ value, record, rowIdx, id, parent, parentFieldIndex, rowFork, width }) => {
|
|
267
|
+
({ value, record, rowIdx, id, parent, parentFieldIndex, rowFork, width, commitOnChange }) => {
|
|
248
268
|
const isNew = record?.__is_new__;
|
|
249
269
|
return (
|
|
250
270
|
<div
|
|
@@ -346,7 +366,11 @@ const MemoCell: React.FC<CellProps> = React.memo(
|
|
|
346
366
|
}
|
|
347
367
|
/>
|
|
348
368
|
) : (
|
|
349
|
-
<FieldModelRendererOptimize
|
|
369
|
+
<FieldModelRendererOptimize
|
|
370
|
+
model={fork}
|
|
371
|
+
id={[(parent as any).context.fieldPath, rowIdx]}
|
|
372
|
+
commitOnChange={commitOnChange}
|
|
373
|
+
/>
|
|
350
374
|
)}
|
|
351
375
|
</FormItem>
|
|
352
376
|
);
|
|
@@ -360,6 +384,7 @@ const MemoCell: React.FC<CellProps> = React.memo(
|
|
|
360
384
|
prev.id === next.id &&
|
|
361
385
|
prev.memoKey === next.memoKey &&
|
|
362
386
|
prev.width === next.width &&
|
|
387
|
+
prev.commitOnChange === next.commitOnChange &&
|
|
363
388
|
prev.rowIdx === next.rowIdx
|
|
364
389
|
);
|
|
365
390
|
},
|
|
@@ -428,6 +453,15 @@ export class SubTableColumnModel<
|
|
|
428
453
|
return this.parent.collection;
|
|
429
454
|
}
|
|
430
455
|
|
|
456
|
+
get hasFormulaColumn() {
|
|
457
|
+
return (
|
|
458
|
+
this.parent?.mapSubModels('columns', (column: SubTableColumnModel) => {
|
|
459
|
+
const field = column.collectionField;
|
|
460
|
+
return field?.interface === 'formula' || field?.type === 'formula';
|
|
461
|
+
}) || []
|
|
462
|
+
).some(Boolean);
|
|
463
|
+
}
|
|
464
|
+
|
|
431
465
|
onInit(options: any): void {
|
|
432
466
|
super.onInit(options);
|
|
433
467
|
this.context.defineProperty('resourceName', {
|
|
@@ -559,6 +593,9 @@ export class SubTableColumnModel<
|
|
|
559
593
|
const rowForkKey = `row:${baseIndexKey}:${rowIdentity}:${String(rowIdx)}`;
|
|
560
594
|
const rowFork: any = (() => {
|
|
561
595
|
const fork = this.createFork({}, rowForkKey);
|
|
596
|
+
fork.context.defineProperty('subTableRowFork', {
|
|
597
|
+
value: true,
|
|
598
|
+
});
|
|
562
599
|
const associationFieldPath =
|
|
563
600
|
(this.parent as any)?.fieldPath ??
|
|
564
601
|
(this.parent as any)?.context?.fieldPath ??
|
|
@@ -577,9 +614,11 @@ export class SubTableColumnModel<
|
|
|
577
614
|
}
|
|
578
615
|
fork.context.defineProperty('item', {
|
|
579
616
|
get: () => {
|
|
617
|
+
const form = (fork.context as any)?.form || (this.context?.blockModel as any)?.context?.form;
|
|
618
|
+
const rowRecord = getLatestSubTableRowRecord(form, fork.context.fieldIndex, record);
|
|
580
619
|
const parentItemCtx = (parentItem ?? this.context?.item) as any;
|
|
581
|
-
const isNew =
|
|
582
|
-
const isStored =
|
|
620
|
+
const isNew = rowRecord?.__is_new__;
|
|
621
|
+
const isStored = rowRecord?.__is_stored__;
|
|
583
622
|
const list = (this.parent as any)?.props?.value;
|
|
584
623
|
const length = Array.isArray(list) ? list.length : undefined;
|
|
585
624
|
return {
|
|
@@ -587,7 +626,7 @@ export class SubTableColumnModel<
|
|
|
587
626
|
length,
|
|
588
627
|
__is_new__: isNew,
|
|
589
628
|
__is_stored__: isStored,
|
|
590
|
-
value:
|
|
629
|
+
value: rowRecord,
|
|
591
630
|
parentItem: parentItemCtx,
|
|
592
631
|
};
|
|
593
632
|
},
|
|
@@ -607,6 +646,7 @@ export class SubTableColumnModel<
|
|
|
607
646
|
rowFork={rowFork}
|
|
608
647
|
memoKey={rowForkKey}
|
|
609
648
|
width={this.props.width}
|
|
649
|
+
commitOnChange={this.hasFormulaColumn}
|
|
610
650
|
/>
|
|
611
651
|
);
|
|
612
652
|
};
|
|
@@ -14,8 +14,41 @@ 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 { parsePathString } from '../../../blocks/form/value-runtime/path';
|
|
17
18
|
import { getSubTableRowIdentity, normalizeSubTableRows } from './rowIdentity';
|
|
18
19
|
|
|
20
|
+
type NamePath = Array<string | number>;
|
|
21
|
+
|
|
22
|
+
function isSamePathPrefix(prefix: NamePath, path: NamePath) {
|
|
23
|
+
if (!prefix.length || prefix.length > path.length) return false;
|
|
24
|
+
return prefix.every((seg, index) => seg === path[index]);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function isRelatedPath(a: NamePath, b: NamePath) {
|
|
28
|
+
return isSamePathPrefix(a, b) || isSamePathPrefix(b, a);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function normalizeChangedPath(path: unknown): NamePath | null {
|
|
32
|
+
const rawPath = Array.isArray(path) ? path : typeof path === 'string' ? [path] : null;
|
|
33
|
+
if (!rawPath) return null;
|
|
34
|
+
const normalized = rawPath.flatMap((seg) => {
|
|
35
|
+
if (typeof seg === 'number') return [seg];
|
|
36
|
+
if (typeof seg !== 'string') return [];
|
|
37
|
+
return parsePathString(seg).filter((parsed): parsed is string | number => typeof parsed !== 'object');
|
|
38
|
+
});
|
|
39
|
+
return normalized.length ? normalized : null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function shouldRefreshForChangedPaths(fieldPath: unknown, changedPaths: unknown) {
|
|
43
|
+
const currentFieldPath = normalizeChangedPath(fieldPath);
|
|
44
|
+
if (!currentFieldPath) return false;
|
|
45
|
+
const paths = Array.isArray(changedPaths) ? changedPaths : [];
|
|
46
|
+
return paths.some((path) => {
|
|
47
|
+
const changedPath = normalizeChangedPath(path);
|
|
48
|
+
return changedPath ? isRelatedPath(currentFieldPath, changedPath) : false;
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
19
52
|
export function SubTableField(props) {
|
|
20
53
|
const { t } = useTranslation();
|
|
21
54
|
const {
|
|
@@ -35,9 +68,12 @@ export function SubTableField(props) {
|
|
|
35
68
|
resetPage,
|
|
36
69
|
filterTargetKey = 'id',
|
|
37
70
|
getCurrentValue,
|
|
71
|
+
fieldPathArray,
|
|
72
|
+
formValuesChangeEmitter,
|
|
38
73
|
} = props;
|
|
39
74
|
const [currentPage, setCurrentPage] = useState(1);
|
|
40
75
|
const [currentPageSize, setCurrentPageSize] = useState(pageSize);
|
|
76
|
+
const [, forceRefresh] = useState(0);
|
|
41
77
|
const rawCurrentValue = getCurrentValue();
|
|
42
78
|
const currentValue = useMemo(() => normalizeSubTableRows(rawCurrentValue), [rawCurrentValue]);
|
|
43
79
|
const getRecordIdentity = React.useCallback(
|
|
@@ -50,6 +86,17 @@ export function SubTableField(props) {
|
|
|
50
86
|
useEffect(() => {
|
|
51
87
|
resetPage && setCurrentPage(1);
|
|
52
88
|
}, [resetPage]);
|
|
89
|
+
useEffect(() => {
|
|
90
|
+
if (!formValuesChangeEmitter?.on || !formValuesChangeEmitter?.off) return;
|
|
91
|
+
const listener = (payload: any) => {
|
|
92
|
+
if (!shouldRefreshForChangedPaths(fieldPathArray, payload?.changedPaths)) return;
|
|
93
|
+
forceRefresh((v) => v + 1);
|
|
94
|
+
};
|
|
95
|
+
formValuesChangeEmitter.on('formValuesChange', listener);
|
|
96
|
+
return () => {
|
|
97
|
+
formValuesChangeEmitter.off('formValuesChange', listener);
|
|
98
|
+
};
|
|
99
|
+
}, [fieldPathArray, formValuesChangeEmitter]);
|
|
53
100
|
const applyValue = React.useCallback((nextValue: any) => onChange?.(normalizeSubTableRows(nextValue)), [onChange]);
|
|
54
101
|
const getLatestValue = React.useCallback(() => normalizeSubTableRows(getCurrentValue()), [getCurrentValue]);
|
|
55
102
|
useEffect(() => {
|
|
@@ -0,0 +1,42 @@
|
|
|
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 { describe, expect, it, vi } from 'vitest';
|
|
11
|
+
import { getLatestSubTableRowRecord, buildRowPathFromFieldIndex } from '../SubTableColumnModel';
|
|
12
|
+
|
|
13
|
+
describe('SubTableColumnModel row record helpers', () => {
|
|
14
|
+
it('builds the row path from fieldIndex entries', () => {
|
|
15
|
+
expect(buildRowPathFromFieldIndex(['roles:0'])).toEqual(['roles', 0]);
|
|
16
|
+
expect(buildRowPathFromFieldIndex(['users:1', 'roles:2'])).toEqual(['users', 1, 'roles', 2]);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('prefers the latest row value from form over the fallback record', () => {
|
|
20
|
+
const form = {
|
|
21
|
+
getFieldValue: vi.fn((path: any) => {
|
|
22
|
+
if (JSON.stringify(path) === JSON.stringify(['roles', 0])) {
|
|
23
|
+
return { uid: 'role-uid-1', __is_new__: true };
|
|
24
|
+
}
|
|
25
|
+
}),
|
|
26
|
+
};
|
|
27
|
+
const fallback = { uid: 'stale-role', __is_new__: false };
|
|
28
|
+
|
|
29
|
+
expect(getLatestSubTableRowRecord(form, ['roles:0'], fallback)).toEqual({
|
|
30
|
+
uid: 'role-uid-1',
|
|
31
|
+
__is_new__: true,
|
|
32
|
+
});
|
|
33
|
+
expect(form.getFieldValue).toHaveBeenCalledWith(['roles', 0]);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('falls back to the record when latest row value is unavailable', () => {
|
|
37
|
+
const form = { getFieldValue: vi.fn(() => undefined) };
|
|
38
|
+
const fallback = { uid: 'stale-role', __is_new__: false };
|
|
39
|
+
|
|
40
|
+
expect(getLatestSubTableRowRecord(form, ['roles:0'], fallback)).toBe(fallback);
|
|
41
|
+
});
|
|
42
|
+
});
|
|
@@ -0,0 +1,122 @@
|
|
|
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 React from 'react';
|
|
11
|
+
import { EventEmitter } from 'events';
|
|
12
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
13
|
+
import { act, render, screen } from '@nocobase/test/client';
|
|
14
|
+
import { SubTableField } from '../SubTableField';
|
|
15
|
+
|
|
16
|
+
vi.mock('react-i18next', async (importOriginal) => ({
|
|
17
|
+
...(await importOriginal<any>()),
|
|
18
|
+
useTranslation: () => ({
|
|
19
|
+
t: (value: string) => value,
|
|
20
|
+
}),
|
|
21
|
+
}));
|
|
22
|
+
|
|
23
|
+
vi.mock('antd', async () => {
|
|
24
|
+
const actual = await vi.importActual<any>('antd');
|
|
25
|
+
return {
|
|
26
|
+
...actual,
|
|
27
|
+
Table: ({ dataSource = [], columns = [] }: any) => (
|
|
28
|
+
<div data-testid="subtable">
|
|
29
|
+
{dataSource.map((record: any, rowIdx: number) => (
|
|
30
|
+
<div data-testid={`row-${rowIdx}`} key={record.__index__ || rowIdx}>
|
|
31
|
+
{columns.map((column: any) => (
|
|
32
|
+
<div data-testid={`cell-${rowIdx}-${String(column.dataIndex || column.key)}`} key={column.key}>
|
|
33
|
+
{column.render?.(record[column.dataIndex], record, rowIdx)}
|
|
34
|
+
</div>
|
|
35
|
+
))}
|
|
36
|
+
</div>
|
|
37
|
+
))}
|
|
38
|
+
</div>
|
|
39
|
+
),
|
|
40
|
+
};
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe('SubTableField refresh', () => {
|
|
44
|
+
it('rerenders from current form value when a nested subtable path changes', () => {
|
|
45
|
+
const emitter = new EventEmitter();
|
|
46
|
+
const store = {
|
|
47
|
+
roles: [{ __is_new__: true, __index__: 'row-1', uid: 'role-uid-1', name: '' }],
|
|
48
|
+
};
|
|
49
|
+
const columns = [
|
|
50
|
+
{
|
|
51
|
+
key: 'name',
|
|
52
|
+
dataIndex: 'name',
|
|
53
|
+
render: ({ value }: any) => <span>{value || 'empty'}</span>,
|
|
54
|
+
},
|
|
55
|
+
];
|
|
56
|
+
|
|
57
|
+
render(
|
|
58
|
+
<SubTableField
|
|
59
|
+
columns={columns}
|
|
60
|
+
pageSize={10}
|
|
61
|
+
filterTargetKey="id"
|
|
62
|
+
fieldPathArray={['roles']}
|
|
63
|
+
formValuesChangeEmitter={emitter}
|
|
64
|
+
getCurrentValue={() => store.roles}
|
|
65
|
+
/>,
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
expect(screen.getByTestId('cell-0-name')).toHaveTextContent('empty');
|
|
69
|
+
|
|
70
|
+
act(() => {
|
|
71
|
+
store.roles = [{ ...store.roles[0], name: 'role-uid-1' }];
|
|
72
|
+
emitter.emit('formValuesChange', {
|
|
73
|
+
source: 'linkage',
|
|
74
|
+
changedPaths: [['roles', 0, 'name']],
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
expect(screen.getByTestId('cell-0-name')).toHaveTextContent('role-uid-1');
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('ignores unrelated form value changes', () => {
|
|
82
|
+
const emitter = new EventEmitter();
|
|
83
|
+
let renderCount = 0;
|
|
84
|
+
const store = {
|
|
85
|
+
roles: [{ __is_new__: true, __index__: 'row-1', uid: 'role-uid-1', name: '' }],
|
|
86
|
+
};
|
|
87
|
+
const columns = [
|
|
88
|
+
{
|
|
89
|
+
key: 'name',
|
|
90
|
+
dataIndex: 'name',
|
|
91
|
+
render: ({ value }: any) => {
|
|
92
|
+
renderCount += 1;
|
|
93
|
+
return <span>{value || 'empty'}</span>;
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
];
|
|
97
|
+
|
|
98
|
+
render(
|
|
99
|
+
<SubTableField
|
|
100
|
+
columns={columns}
|
|
101
|
+
pageSize={10}
|
|
102
|
+
filterTargetKey="id"
|
|
103
|
+
fieldPathArray={['roles']}
|
|
104
|
+
formValuesChangeEmitter={emitter}
|
|
105
|
+
getCurrentValue={() => store.roles}
|
|
106
|
+
/>,
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
expect(renderCount).toBe(1);
|
|
110
|
+
|
|
111
|
+
act(() => {
|
|
112
|
+
store.roles = [{ ...store.roles[0], name: 'role-uid-1' }];
|
|
113
|
+
emitter.emit('formValuesChange', {
|
|
114
|
+
source: 'user',
|
|
115
|
+
changedPaths: [['profile', 'name']],
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
expect(renderCount).toBe(1);
|
|
120
|
+
expect(screen.getByTestId('cell-0-name')).toHaveTextContent('empty');
|
|
121
|
+
});
|
|
122
|
+
});
|
|
@@ -120,6 +120,8 @@ export class SubTableFieldModel extends AssociationFieldModel {
|
|
|
120
120
|
parentFieldIndex={this.context.fieldIndex}
|
|
121
121
|
parentItem={this.context.item}
|
|
122
122
|
filterTargetKey={this.collection.filterTargetKey}
|
|
123
|
+
formValuesChangeEmitter={this.context.blockModel?.emitter}
|
|
124
|
+
fieldPathArray={this.parent?.context?.fieldPathArray}
|
|
123
125
|
getCurrentValue={this.getCurrentValue}
|
|
124
126
|
/>
|
|
125
127
|
);
|
|
@@ -0,0 +1,20 @@
|
|
|
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
|
+
export function hasAncestorModel(model: any, modelNames: string[]) {
|
|
11
|
+
let cursor = model;
|
|
12
|
+
while (cursor) {
|
|
13
|
+
const modelName = cursor?.constructor?.name;
|
|
14
|
+
if (modelName && modelNames.includes(modelName)) {
|
|
15
|
+
return true;
|
|
16
|
+
}
|
|
17
|
+
cursor = cursor?.parent;
|
|
18
|
+
}
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
@@ -11,10 +11,9 @@ import { CollectionField, tExpr } from '@nocobase/flow-engine';
|
|
|
11
11
|
import { Tag } from 'antd';
|
|
12
12
|
import { castArray, get } from 'lodash';
|
|
13
13
|
import React from 'react';
|
|
14
|
-
import { EllipsisWithTooltip } from '../../components';
|
|
14
|
+
import { EllipsisWithTooltip } from '../../components/EllipsisWithTooltip';
|
|
15
15
|
import { openViewFlow } from '../../flows/openViewFlow';
|
|
16
16
|
import { FieldModel } from '../base/FieldModel';
|
|
17
|
-
import { EditFormModel } from '../blocks/form/EditFormModel';
|
|
18
17
|
|
|
19
18
|
export function transformNestedData(inputData) {
|
|
20
19
|
const resultArray = [];
|
|
@@ -36,6 +35,8 @@ export function transformNestedData(inputData) {
|
|
|
36
35
|
const hasAssociationPathName = (parent: unknown): parent is { associationPathName?: string } =>
|
|
37
36
|
!!parent && typeof parent === 'object' && 'associationPathName' in parent;
|
|
38
37
|
|
|
38
|
+
const hasUsableSourceId = (sourceId: unknown) => sourceId !== undefined && sourceId !== null && String(sourceId) !== '';
|
|
39
|
+
|
|
39
40
|
export class ClickableFieldModel extends FieldModel {
|
|
40
41
|
get collectionField(): CollectionField {
|
|
41
42
|
return this.context.collectionField;
|
|
@@ -62,14 +63,18 @@ export class ClickableFieldModel extends FieldModel {
|
|
|
62
63
|
const parentObj = associationPathName
|
|
63
64
|
? get(this.context.blockModel?.form?.getFieldsValue?.(true) || this.context.record, associationPathName)
|
|
64
65
|
: this.context.record;
|
|
66
|
+
const sourceId = parentObj?.[sourceKey];
|
|
67
|
+
const useAssociationResource = hasUsableSourceId(sourceId);
|
|
65
68
|
this.dispatchEvent(
|
|
66
69
|
'click',
|
|
67
70
|
{
|
|
68
71
|
event,
|
|
69
72
|
filterByTk,
|
|
70
|
-
collectionName:
|
|
71
|
-
|
|
72
|
-
|
|
73
|
+
collectionName: useAssociationResource
|
|
74
|
+
? this.collectionField.collection.name
|
|
75
|
+
: targetCollection?.name || this.collectionField.target,
|
|
76
|
+
associationName: useAssociationResource ? `${sourceCollection.name}.${this.collectionField.name}` : null,
|
|
77
|
+
sourceId: useAssociationResource ? sourceId : null,
|
|
73
78
|
},
|
|
74
79
|
{
|
|
75
80
|
debounce: true,
|
|
@@ -95,6 +100,10 @@ export class ClickableFieldModel extends FieldModel {
|
|
|
95
100
|
const parentObj = associationPathName.includes('.')
|
|
96
101
|
? get(this.context.record, associationPathName.split('.')[0])
|
|
97
102
|
: this.context.record;
|
|
103
|
+
const sourceId = hasUsableSourceId(parentObj?.[sourceKey])
|
|
104
|
+
? parentObj?.[sourceKey]
|
|
105
|
+
: this.context.record?.[foreignKey];
|
|
106
|
+
const useAssociationResource = hasUsableSourceId(sourceId);
|
|
98
107
|
let filterByTk = associationRecord?.[targetKey];
|
|
99
108
|
if (associationField.interface === 'm2m') {
|
|
100
109
|
// also incorrect for v1
|
|
@@ -106,10 +115,13 @@ export class ClickableFieldModel extends FieldModel {
|
|
|
106
115
|
{
|
|
107
116
|
event,
|
|
108
117
|
filterByTk,
|
|
109
|
-
collectionName:
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
118
|
+
collectionName: useAssociationResource
|
|
119
|
+
? this.collectionField.collection.name
|
|
120
|
+
: targetCollection?.name || associationField.target || this.collectionField.collection.name,
|
|
121
|
+
associationName: useAssociationResource
|
|
122
|
+
? `${associationField.collection.name}.${this.collectionField.name}`
|
|
123
|
+
: null,
|
|
124
|
+
sourceId: useAssociationResource ? sourceId : null,
|
|
113
125
|
},
|
|
114
126
|
{
|
|
115
127
|
debounce: true,
|
|
@@ -12,17 +12,26 @@ import { EditableItemModel, useFlowModelContext } from '@nocobase/flow-engine';
|
|
|
12
12
|
import React from 'react';
|
|
13
13
|
import { DateTimeFieldModel } from './DateTimeFieldModel';
|
|
14
14
|
import { MobileDatePicker } from '../mobile-components/MobileDatePicker';
|
|
15
|
+
import { useDateLimit } from './dateLimit';
|
|
15
16
|
|
|
16
17
|
export const DateOnlyPicker = (props) => {
|
|
17
18
|
const { value, format = 'YYYY-MM-DD', picker = 'date', showTime, ...rest } = props;
|
|
18
19
|
const parsedValue = value && dayjs(value).isValid() ? dayjs(value) : null;
|
|
19
20
|
const ctx = useFlowModelContext();
|
|
21
|
+
const { disabledDate, disabledTime, minDate, maxDate } = useDateLimit({
|
|
22
|
+
...props,
|
|
23
|
+
currentForm: ctx.model?.context?.form,
|
|
24
|
+
});
|
|
20
25
|
const componentProps = {
|
|
21
26
|
...rest,
|
|
22
27
|
value: parsedValue,
|
|
23
28
|
format,
|
|
24
29
|
picker,
|
|
25
30
|
showTime,
|
|
31
|
+
disabledDate,
|
|
32
|
+
disabledTime,
|
|
33
|
+
minDate,
|
|
34
|
+
maxDate,
|
|
26
35
|
onChange: (val: any) => {
|
|
27
36
|
const outputFormat = 'YYYY-MM-DD';
|
|
28
37
|
if (!val) {
|
|
@@ -12,17 +12,26 @@ import React from 'react';
|
|
|
12
12
|
import { EditableItemModel, useFlowModelContext } from '@nocobase/flow-engine';
|
|
13
13
|
import { DateTimeFieldModel } from './DateTimeFieldModel';
|
|
14
14
|
import { MobileDatePicker } from '../mobile-components/MobileDatePicker';
|
|
15
|
+
import { useDateLimit } from './dateLimit';
|
|
15
16
|
|
|
16
17
|
export const DateTimeNoTzPicker = (props) => {
|
|
17
18
|
const { value, format = 'YYYY-MM-DD HH:mm:ss', showTime, picker = 'date', onChange, ...rest } = props;
|
|
18
19
|
const parsedValue = value ? dayjs(value) : null;
|
|
19
20
|
const ctx = useFlowModelContext();
|
|
21
|
+
const { disabledDate, disabledTime, minDate, maxDate } = useDateLimit({
|
|
22
|
+
...props,
|
|
23
|
+
currentForm: ctx.model?.context?.form,
|
|
24
|
+
});
|
|
20
25
|
const componentProps = {
|
|
21
26
|
...rest,
|
|
22
27
|
value: parsedValue,
|
|
23
28
|
format,
|
|
24
29
|
picker,
|
|
25
30
|
showTime,
|
|
31
|
+
disabledDate,
|
|
32
|
+
disabledTime,
|
|
33
|
+
minDate,
|
|
34
|
+
maxDate,
|
|
26
35
|
onChange: (val: any) => {
|
|
27
36
|
if (!val) {
|
|
28
37
|
return onChange(val);
|
|
@@ -12,6 +12,7 @@ import React from 'react';
|
|
|
12
12
|
import { DateTimeFieldModel } from './DateTimeFieldModel';
|
|
13
13
|
import { MobileDatePicker } from '../mobile-components/MobileDatePicker';
|
|
14
14
|
import { DatePicker } from 'antd';
|
|
15
|
+
import { useDateLimit } from './dateLimit';
|
|
15
16
|
|
|
16
17
|
function parseToDate(value: string | Date | dayjs.Dayjs | undefined, format?: string): Date | undefined {
|
|
17
18
|
if (!value) return undefined;
|
|
@@ -49,12 +50,20 @@ function parseInitialValue(value: string | Date | undefined, format?: string): d
|
|
|
49
50
|
export const DateTimeTzPicker = (props) => {
|
|
50
51
|
const { value, format = 'YYYY-MM-DD HH:mm:ss', picker = 'date', showTime, ...rest } = props;
|
|
51
52
|
const ctx = useFlowModelContext();
|
|
53
|
+
const { disabledDate, disabledTime, minDate, maxDate } = useDateLimit({
|
|
54
|
+
...props,
|
|
55
|
+
currentForm: ctx.model?.context?.form,
|
|
56
|
+
});
|
|
52
57
|
const componentProps = {
|
|
53
58
|
...rest,
|
|
54
59
|
value: parseInitialValue(value, format),
|
|
55
60
|
format,
|
|
56
61
|
picker,
|
|
57
62
|
showTime,
|
|
63
|
+
disabledDate,
|
|
64
|
+
disabledTime,
|
|
65
|
+
minDate,
|
|
66
|
+
maxDate,
|
|
58
67
|
onChange: (val: any) => {
|
|
59
68
|
let result = parseToDate(val, format);
|
|
60
69
|
// Adjust to start of period for month/quarter/year pickers
|