@nocobase/client-v2 2.1.10 → 2.1.11
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/index.mjs +65 -65
- package/lib/index.js +65 -65
- package/package.json +7 -7
- package/src/flow/actions/__tests__/dataScopeFilter.test.ts +58 -0
- package/src/flow/actions/dataScopeFilter.ts +10 -0
- package/src/flow/components/FieldAssignRulesEditor.tsx +66 -9
- package/src/flow/components/__tests__/FieldAssignRulesEditor.test.tsx +427 -0
- package/src/flow/components/__tests__/fieldAssignOptions.test.ts +151 -3
- package/src/flow/components/fieldAssignOptions.ts +155 -27
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nocobase/client-v2",
|
|
3
|
-
"version": "2.1.
|
|
3
|
+
"version": "2.1.11",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"main": "lib/index.js",
|
|
6
6
|
"module": "es/index.mjs",
|
|
@@ -27,11 +27,11 @@
|
|
|
27
27
|
"@formily/antd-v5": "1.2.3",
|
|
28
28
|
"@formily/react": "^2.2.27",
|
|
29
29
|
"@formily/shared": "^2.2.27",
|
|
30
|
-
"@nocobase/evaluators": "2.1.
|
|
31
|
-
"@nocobase/flow-engine": "2.1.
|
|
32
|
-
"@nocobase/sdk": "2.1.
|
|
33
|
-
"@nocobase/shared": "2.1.
|
|
34
|
-
"@nocobase/utils": "2.1.
|
|
30
|
+
"@nocobase/evaluators": "2.1.11",
|
|
31
|
+
"@nocobase/flow-engine": "2.1.11",
|
|
32
|
+
"@nocobase/sdk": "2.1.11",
|
|
33
|
+
"@nocobase/shared": "2.1.11",
|
|
34
|
+
"@nocobase/utils": "2.1.11",
|
|
35
35
|
"ahooks": "^3.7.2",
|
|
36
36
|
"antd": "5.24.2",
|
|
37
37
|
"antd-style": "3.7.1",
|
|
@@ -46,5 +46,5 @@
|
|
|
46
46
|
"react-i18next": "^11.15.1",
|
|
47
47
|
"react-router-dom": "^6.30.1"
|
|
48
48
|
},
|
|
49
|
-
"gitHead": "
|
|
49
|
+
"gitHead": "1ccf891d837e21089f65f84b892407b34a0a0cb9"
|
|
50
50
|
}
|
|
@@ -139,6 +139,64 @@ describe('normalizeDataScopeFilter', () => {
|
|
|
139
139
|
expect(resource.removeFilterGroup).not.toHaveBeenCalled();
|
|
140
140
|
});
|
|
141
141
|
|
|
142
|
+
it('dataScope handler preserves current role as server-side variable', async () => {
|
|
143
|
+
const engine = new FlowEngine();
|
|
144
|
+
const resource = {
|
|
145
|
+
addFilterGroup: vi.fn(),
|
|
146
|
+
removeFilterGroup: vi.fn(),
|
|
147
|
+
};
|
|
148
|
+
const ctx = engine.context;
|
|
149
|
+
ctx.defineProperty('api', { value: { auth: { role: '__union__' } } });
|
|
150
|
+
ctx.defineProperty('user', {
|
|
151
|
+
value: {
|
|
152
|
+
roles: [{ name: 'admin' }, { name: 'member' }],
|
|
153
|
+
},
|
|
154
|
+
});
|
|
155
|
+
ctx.defineProperty('model', {
|
|
156
|
+
value: {
|
|
157
|
+
uid: 'table-1',
|
|
158
|
+
resource,
|
|
159
|
+
},
|
|
160
|
+
});
|
|
161
|
+
const params = {
|
|
162
|
+
filter: {
|
|
163
|
+
logic: '$and',
|
|
164
|
+
items: [{ path: 'roles.name', operator: '$includes', value: '{{ ctx.role }}' }],
|
|
165
|
+
},
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
await (dataScope as { handler: (ctx: typeof ctx, params: typeof params) => Promise<void> }).handler(ctx, params);
|
|
169
|
+
|
|
170
|
+
expect(resource.addFilterGroup).toHaveBeenCalledWith('table-1', {
|
|
171
|
+
$and: [{ roles: { name: { $includes: '{{$nRole}}' } } }],
|
|
172
|
+
});
|
|
173
|
+
expect(resource.removeFilterGroup).not.toHaveBeenCalled();
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it('setTargetDataScope handler preserves current role as server-side variable', async () => {
|
|
177
|
+
const resource = {
|
|
178
|
+
addFilterGroup: vi.fn(),
|
|
179
|
+
removeFilterGroup: vi.fn(),
|
|
180
|
+
hasData: vi.fn(() => false),
|
|
181
|
+
refresh: vi.fn(),
|
|
182
|
+
};
|
|
183
|
+
const ctx = createSetTargetDataScopeContext(resource, { resolvedValue: ['admin', 'member'] });
|
|
184
|
+
const params = {
|
|
185
|
+
targetBlockUid: 'target-1',
|
|
186
|
+
filter: {
|
|
187
|
+
logic: '$and',
|
|
188
|
+
items: [{ path: 'roles.name', operator: '$includes', value: '{{ctx.role}}' }],
|
|
189
|
+
},
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
await (setTargetDataScope as any).handler(ctx, params);
|
|
193
|
+
|
|
194
|
+
expect(resource.addFilterGroup).toHaveBeenCalledWith('setTargetDataScope_action-1', {
|
|
195
|
+
$and: [{ roles: { name: { $includes: '{{$nRole}}' } } }],
|
|
196
|
+
});
|
|
197
|
+
expect(resource.removeFilterGroup).not.toHaveBeenCalled();
|
|
198
|
+
});
|
|
199
|
+
|
|
142
200
|
it('setTargetDataScope handler sends null for empty variable dependencies', async () => {
|
|
143
201
|
const resource = {
|
|
144
202
|
addFilterGroup: vi.fn(),
|
|
@@ -12,6 +12,12 @@ import { transformFilter } from '@nocobase/utils/client';
|
|
|
12
12
|
import _ from 'lodash';
|
|
13
13
|
|
|
14
14
|
const PRESERVE_NULL = { __nocobaseDataScopeNull__: true };
|
|
15
|
+
const SERVER_CURRENT_ROLE_VARIABLE = '{{$nRole}}';
|
|
16
|
+
const CURRENT_ROLE_EXPRESSION_RE = /^\s*\{\{\s*ctx\.role\s*\}\}\s*$/;
|
|
17
|
+
|
|
18
|
+
function isCurrentRoleExpression(value: unknown) {
|
|
19
|
+
return typeof value === 'string' && CURRENT_ROLE_EXPRESSION_RE.test(value);
|
|
20
|
+
}
|
|
15
21
|
|
|
16
22
|
function isPreserveNull(value: any) {
|
|
17
23
|
return (
|
|
@@ -45,6 +51,10 @@ function markEmptyVariableValues(rawNode: any, resolvedNode: any) {
|
|
|
45
51
|
}
|
|
46
52
|
|
|
47
53
|
if ('path' in rawNode && 'operator' in rawNode) {
|
|
54
|
+
if (isCurrentRoleExpression(rawNode.value)) {
|
|
55
|
+
resolvedNode.value = SERVER_CURRENT_ROLE_VARIABLE;
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
48
58
|
if (
|
|
49
59
|
isVariableExpression(rawNode.value) &&
|
|
50
60
|
(resolvedNode.value === undefined || resolvedNode.value === null || resolvedNode.value === '')
|
|
@@ -113,6 +113,48 @@ export interface FieldAssignRulesEditorProps {
|
|
|
113
113
|
|
|
114
114
|
const ALL_ASSIGN_MODES: AssignMode[] = ['default', 'override', 'assign'];
|
|
115
115
|
|
|
116
|
+
function cloneCascaderOption(option: FieldAssignCascaderOption): FieldAssignCascaderOption {
|
|
117
|
+
return {
|
|
118
|
+
...option,
|
|
119
|
+
children: option.children?.map((child) => cloneCascaderOption(child)),
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function mergeCascaderOptionInto(options: FieldAssignCascaderOption[], source: FieldAssignCascaderOption) {
|
|
124
|
+
const value = source?.value ? String(source.value) : '';
|
|
125
|
+
if (!value) return;
|
|
126
|
+
|
|
127
|
+
const existing = options.find((item) => String(item?.value || '') === value);
|
|
128
|
+
if (!existing) {
|
|
129
|
+
options.push(cloneCascaderOption(source));
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (!existing.label && source.label) {
|
|
134
|
+
existing.label = source.label;
|
|
135
|
+
}
|
|
136
|
+
if (source.isLeaf === false || existing.children?.length || source.children?.length) {
|
|
137
|
+
existing.isLeaf = false;
|
|
138
|
+
}
|
|
139
|
+
if (source.loading) {
|
|
140
|
+
existing.loading = source.loading;
|
|
141
|
+
}
|
|
142
|
+
if (source.children?.length) {
|
|
143
|
+
existing.children = mergeCascaderOptions(existing.children || [], source.children);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function mergeCascaderOptions(
|
|
148
|
+
configured: FieldAssignCascaderOption[],
|
|
149
|
+
allFields: FieldAssignCascaderOption[],
|
|
150
|
+
): FieldAssignCascaderOption[] {
|
|
151
|
+
const result = (Array.isArray(configured) ? configured : []).map((item) => cloneCascaderOption(item));
|
|
152
|
+
for (const item of Array.isArray(allFields) ? allFields : []) {
|
|
153
|
+
mergeCascaderOptionInto(result, item);
|
|
154
|
+
}
|
|
155
|
+
return result;
|
|
156
|
+
}
|
|
157
|
+
|
|
116
158
|
function isAssignMode(mode: unknown): mode is AssignMode {
|
|
117
159
|
return mode === 'default' || mode === 'override' || mode === 'assign';
|
|
118
160
|
}
|
|
@@ -171,13 +213,21 @@ export const FieldAssignRulesEditor: React.FC<FieldAssignRulesEditorProps> = (pr
|
|
|
171
213
|
},
|
|
172
214
|
[normalizeItemMode, onChange],
|
|
173
215
|
);
|
|
216
|
+
|
|
217
|
+
const normalizeFieldOptions = React.useCallback((options: FieldAssignRulesEditorProps['fieldOptions']) => {
|
|
218
|
+
return Array.isArray(options)
|
|
219
|
+
? (options as FieldAssignCascaderOption[]).map((option) => cloneCascaderOption(option))
|
|
220
|
+
: [];
|
|
221
|
+
}, []);
|
|
174
222
|
const [cascaderOptions, setCascaderOptions] = React.useState<FieldAssignCascaderOption[]>(() =>
|
|
175
|
-
|
|
223
|
+
normalizeFieldOptions(fieldOptions),
|
|
176
224
|
);
|
|
225
|
+
const loadedCascaderOptionsRef = React.useRef<WeakSet<FieldAssignCascaderOption>>(new WeakSet());
|
|
177
226
|
|
|
178
227
|
React.useEffect(() => {
|
|
179
|
-
|
|
180
|
-
|
|
228
|
+
loadedCascaderOptionsRef.current = new WeakSet();
|
|
229
|
+
setCascaderOptions(normalizeFieldOptions(fieldOptions));
|
|
230
|
+
}, [fieldOptions, normalizeFieldOptions, rootCollection, maxAssociationFieldDepth]);
|
|
181
231
|
|
|
182
232
|
const getRuleKey = React.useCallback((item: FieldAssignRuleItem, index: number) => item?.key || String(index), []);
|
|
183
233
|
const [titleFieldDraftMap, setTitleFieldDraftMap] = React.useState<Record<string, string | undefined>>({});
|
|
@@ -597,23 +647,26 @@ export const FieldAssignRulesEditor: React.FC<FieldAssignRulesEditorProps> = (pr
|
|
|
597
647
|
const opts = selectedOptions || [];
|
|
598
648
|
const target = opts[opts.length - 1];
|
|
599
649
|
if (!target) return;
|
|
600
|
-
if (target.children && Array.isArray(target.children) && target.children.length) return;
|
|
601
650
|
if (target.isLeaf) return;
|
|
651
|
+
if (loadedCascaderOptionsRef.current.has(target)) return;
|
|
602
652
|
|
|
603
653
|
const segments = opts.map((o) => String(o?.value)).filter(Boolean);
|
|
604
654
|
const targetCollection = resolveTargetCollectionBySegments(segments);
|
|
605
655
|
if (!targetCollection) {
|
|
606
|
-
target.isLeaf =
|
|
656
|
+
target.isLeaf = !(target.children && target.children.length);
|
|
657
|
+
loadedCascaderOptionsRef.current.add(target);
|
|
607
658
|
setCascaderOptions((prev) => [...prev]);
|
|
608
659
|
return;
|
|
609
660
|
}
|
|
610
661
|
|
|
611
662
|
const children = buildChildrenFromCollection(targetCollection, segments.length);
|
|
612
663
|
if (!children.length) {
|
|
613
|
-
target.isLeaf =
|
|
664
|
+
target.isLeaf = !(target.children && target.children.length);
|
|
614
665
|
} else {
|
|
615
|
-
target.children = children;
|
|
666
|
+
target.children = mergeCascaderOptions(target.children || [], children);
|
|
667
|
+
target.isLeaf = false;
|
|
616
668
|
}
|
|
669
|
+
loadedCascaderOptionsRef.current.add(target);
|
|
617
670
|
setCascaderOptions((prev) => [...prev]);
|
|
618
671
|
},
|
|
619
672
|
[buildChildrenFromCollection, resolveTargetCollectionBySegments],
|
|
@@ -629,12 +682,14 @@ export const FieldAssignRulesEditor: React.FC<FieldAssignRulesEditorProps> = (pr
|
|
|
629
682
|
const hit = options.find((o) => String(o?.value) === seg);
|
|
630
683
|
if (!hit) return;
|
|
631
684
|
selected.push(hit);
|
|
685
|
+
if (!hit.isLeaf && !loadedCascaderOptionsRef.current.has(hit)) {
|
|
686
|
+
await loadCascaderData(selected);
|
|
687
|
+
}
|
|
632
688
|
if (hit.children?.length) {
|
|
633
689
|
options = hit.children;
|
|
634
690
|
continue;
|
|
635
691
|
}
|
|
636
692
|
if (hit.isLeaf) return;
|
|
637
|
-
await loadCascaderData(selected);
|
|
638
693
|
options = hit.children || [];
|
|
639
694
|
}
|
|
640
695
|
},
|
|
@@ -665,7 +720,9 @@ export const FieldAssignRulesEditor: React.FC<FieldAssignRulesEditorProps> = (pr
|
|
|
665
720
|
await preloadCascaderPath(segs);
|
|
666
721
|
}
|
|
667
722
|
};
|
|
668
|
-
|
|
723
|
+
run().catch((error) => {
|
|
724
|
+
console.warn('[FieldAssignRulesEditor] Failed to preload cascader path', error);
|
|
725
|
+
});
|
|
669
726
|
return () => {
|
|
670
727
|
cancelled = true;
|
|
671
728
|
};
|
|
@@ -285,6 +285,433 @@ describe('FieldAssignRulesEditor', () => {
|
|
|
285
285
|
});
|
|
286
286
|
});
|
|
287
287
|
|
|
288
|
+
it('preloads full association children when configured options already contain partial children', async () => {
|
|
289
|
+
const nameField = { name: 'name', title: 'Name', interface: 'input' };
|
|
290
|
+
const nicknameField = { name: 'nickname', title: 'Nickname', interface: 'input' };
|
|
291
|
+
const profileCollection = {
|
|
292
|
+
getField: (name: string) => {
|
|
293
|
+
if (name === 'name') return nameField;
|
|
294
|
+
if (name === 'nickname') return nicknameField;
|
|
295
|
+
return null;
|
|
296
|
+
},
|
|
297
|
+
getFields: vi.fn(() => [nameField, nicknameField]),
|
|
298
|
+
};
|
|
299
|
+
const profileField = {
|
|
300
|
+
name: 'profile',
|
|
301
|
+
title: 'Profile',
|
|
302
|
+
type: 'belongsTo',
|
|
303
|
+
interface: 'm2o',
|
|
304
|
+
target: 'profiles',
|
|
305
|
+
targetCollection: profileCollection,
|
|
306
|
+
};
|
|
307
|
+
const rootCollection = {
|
|
308
|
+
getField: (name: string) => (name === 'profile' ? profileField : null),
|
|
309
|
+
getFields: () => [profileField],
|
|
310
|
+
};
|
|
311
|
+
const fieldOptions = [
|
|
312
|
+
{
|
|
313
|
+
label: 'Profile',
|
|
314
|
+
value: 'profile',
|
|
315
|
+
isLeaf: false,
|
|
316
|
+
children: [{ label: 'Name', value: 'name', isLeaf: true }],
|
|
317
|
+
},
|
|
318
|
+
];
|
|
319
|
+
const value: FieldAssignRuleItem[] = [
|
|
320
|
+
{
|
|
321
|
+
key: 'rule-profile-nickname',
|
|
322
|
+
enable: true,
|
|
323
|
+
targetPath: 'profile.nickname',
|
|
324
|
+
mode: 'assign',
|
|
325
|
+
},
|
|
326
|
+
];
|
|
327
|
+
|
|
328
|
+
render(
|
|
329
|
+
wrap(
|
|
330
|
+
<FieldAssignRulesEditor
|
|
331
|
+
t={t}
|
|
332
|
+
fieldOptions={fieldOptions}
|
|
333
|
+
rootCollection={rootCollection}
|
|
334
|
+
value={value}
|
|
335
|
+
showCondition={false}
|
|
336
|
+
/>,
|
|
337
|
+
),
|
|
338
|
+
);
|
|
339
|
+
|
|
340
|
+
await waitFor(() => {
|
|
341
|
+
expect(profileCollection.getFields).toHaveBeenCalled();
|
|
342
|
+
});
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
it('keeps configured child paths expandable when target collection cannot be resolved', async () => {
|
|
346
|
+
const configuredParent = {
|
|
347
|
+
label: 'Legacy profile',
|
|
348
|
+
value: 'legacyProfile',
|
|
349
|
+
isLeaf: false,
|
|
350
|
+
children: [{ label: 'Nickname', value: 'nickname', isLeaf: true }],
|
|
351
|
+
};
|
|
352
|
+
const rootCollection = {
|
|
353
|
+
getField: vi.fn(() => null),
|
|
354
|
+
getFields: () => [],
|
|
355
|
+
};
|
|
356
|
+
const value: FieldAssignRuleItem[] = [
|
|
357
|
+
{
|
|
358
|
+
key: 'rule-legacy-profile-nickname',
|
|
359
|
+
enable: true,
|
|
360
|
+
targetPath: 'legacyProfile.nickname',
|
|
361
|
+
mode: 'assign',
|
|
362
|
+
},
|
|
363
|
+
];
|
|
364
|
+
|
|
365
|
+
render(
|
|
366
|
+
wrap(
|
|
367
|
+
<FieldAssignRulesEditor
|
|
368
|
+
t={t}
|
|
369
|
+
fieldOptions={[configuredParent]}
|
|
370
|
+
rootCollection={rootCollection}
|
|
371
|
+
value={value}
|
|
372
|
+
showCondition={false}
|
|
373
|
+
/>,
|
|
374
|
+
),
|
|
375
|
+
);
|
|
376
|
+
|
|
377
|
+
await waitFor(() => {
|
|
378
|
+
expect(rootCollection.getField).toHaveBeenCalled();
|
|
379
|
+
expect(configuredParent.isLeaf).toBe(false);
|
|
380
|
+
});
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
it('preloads the latest fieldOptions after props switch', async () => {
|
|
384
|
+
const nameField = { name: 'name', title: 'Name', interface: 'input' };
|
|
385
|
+
const nicknameField = { name: 'nickname', title: 'Nickname', interface: 'input' };
|
|
386
|
+
const profileCollection = {
|
|
387
|
+
getField: (name: string) => {
|
|
388
|
+
if (name === 'name') return nameField;
|
|
389
|
+
if (name === 'nickname') return nicknameField;
|
|
390
|
+
return null;
|
|
391
|
+
},
|
|
392
|
+
getFields: vi.fn(() => [nameField, nicknameField]),
|
|
393
|
+
};
|
|
394
|
+
const profileField = {
|
|
395
|
+
name: 'profile',
|
|
396
|
+
title: 'Profile',
|
|
397
|
+
type: 'belongsTo',
|
|
398
|
+
interface: 'm2o',
|
|
399
|
+
target: 'profiles',
|
|
400
|
+
targetCollection: profileCollection,
|
|
401
|
+
};
|
|
402
|
+
const rootCollection = {
|
|
403
|
+
getField: (name: string) => (name === 'profile' ? profileField : null),
|
|
404
|
+
getFields: () => [profileField],
|
|
405
|
+
};
|
|
406
|
+
const oldConfiguredParent = {
|
|
407
|
+
label: 'Profile',
|
|
408
|
+
value: 'profile',
|
|
409
|
+
isLeaf: false,
|
|
410
|
+
children: [{ label: 'Name', value: 'name', isLeaf: true }],
|
|
411
|
+
};
|
|
412
|
+
const latestConfiguredParent = {
|
|
413
|
+
label: 'Profile',
|
|
414
|
+
value: 'profile',
|
|
415
|
+
isLeaf: false,
|
|
416
|
+
children: [{ label: 'Name', value: 'name', isLeaf: true }],
|
|
417
|
+
};
|
|
418
|
+
const value: FieldAssignRuleItem[] = [
|
|
419
|
+
{
|
|
420
|
+
key: 'rule-profile-nickname',
|
|
421
|
+
enable: true,
|
|
422
|
+
targetPath: 'profile.nickname',
|
|
423
|
+
mode: 'assign',
|
|
424
|
+
},
|
|
425
|
+
];
|
|
426
|
+
|
|
427
|
+
const { container, rerender } = render(
|
|
428
|
+
wrap(
|
|
429
|
+
<FieldAssignRulesEditor
|
|
430
|
+
t={t}
|
|
431
|
+
fieldOptions={[oldConfiguredParent]}
|
|
432
|
+
rootCollection={rootCollection}
|
|
433
|
+
value={[]}
|
|
434
|
+
showCondition={false}
|
|
435
|
+
/>,
|
|
436
|
+
),
|
|
437
|
+
);
|
|
438
|
+
|
|
439
|
+
rerender(
|
|
440
|
+
wrap(
|
|
441
|
+
<FieldAssignRulesEditor
|
|
442
|
+
t={t}
|
|
443
|
+
fieldOptions={[latestConfiguredParent]}
|
|
444
|
+
rootCollection={rootCollection}
|
|
445
|
+
value={value}
|
|
446
|
+
showCondition={false}
|
|
447
|
+
/>,
|
|
448
|
+
),
|
|
449
|
+
);
|
|
450
|
+
|
|
451
|
+
await waitFor(() => {
|
|
452
|
+
expect(profileCollection.getFields).toHaveBeenCalled();
|
|
453
|
+
});
|
|
454
|
+
const selector = container.querySelector('.ant-select-selector') as HTMLElement | null;
|
|
455
|
+
expect(selector).not.toBeNull();
|
|
456
|
+
await userEvent.click(selector as HTMLElement);
|
|
457
|
+
expect(await screen.findByText('Nickname')).toBeInTheDocument();
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
it('does not mutate fieldOptions props while preloading children', async () => {
|
|
461
|
+
const nameField = { name: 'name', title: 'Name', interface: 'input' };
|
|
462
|
+
const nicknameField = { name: 'nickname', title: 'Nickname', interface: 'input' };
|
|
463
|
+
const profileCollection = {
|
|
464
|
+
getField: (name: string) => {
|
|
465
|
+
if (name === 'name') return nameField;
|
|
466
|
+
if (name === 'nickname') return nicknameField;
|
|
467
|
+
return null;
|
|
468
|
+
},
|
|
469
|
+
getFields: vi.fn(() => [nameField, nicknameField]),
|
|
470
|
+
};
|
|
471
|
+
const profileField = {
|
|
472
|
+
name: 'profile',
|
|
473
|
+
title: 'Profile',
|
|
474
|
+
type: 'belongsTo',
|
|
475
|
+
interface: 'm2o',
|
|
476
|
+
target: 'profiles',
|
|
477
|
+
targetCollection: profileCollection,
|
|
478
|
+
};
|
|
479
|
+
const rootCollection = {
|
|
480
|
+
getField: (name: string) => (name === 'profile' ? profileField : null),
|
|
481
|
+
getFields: () => [profileField],
|
|
482
|
+
};
|
|
483
|
+
const configuredParent = {
|
|
484
|
+
label: 'Profile',
|
|
485
|
+
value: 'profile',
|
|
486
|
+
isLeaf: false,
|
|
487
|
+
children: [{ label: 'Name', value: 'name', isLeaf: true }],
|
|
488
|
+
};
|
|
489
|
+
const value: FieldAssignRuleItem[] = [
|
|
490
|
+
{
|
|
491
|
+
key: 'rule-profile-nickname',
|
|
492
|
+
enable: true,
|
|
493
|
+
targetPath: 'profile.nickname',
|
|
494
|
+
mode: 'assign',
|
|
495
|
+
},
|
|
496
|
+
];
|
|
497
|
+
|
|
498
|
+
render(
|
|
499
|
+
wrap(
|
|
500
|
+
<FieldAssignRulesEditor
|
|
501
|
+
t={t}
|
|
502
|
+
fieldOptions={[configuredParent]}
|
|
503
|
+
rootCollection={rootCollection}
|
|
504
|
+
value={value}
|
|
505
|
+
showCondition={false}
|
|
506
|
+
/>,
|
|
507
|
+
),
|
|
508
|
+
);
|
|
509
|
+
|
|
510
|
+
await waitFor(() => {
|
|
511
|
+
expect(profileCollection.getFields).toHaveBeenCalled();
|
|
512
|
+
});
|
|
513
|
+
expect(configuredParent.children.some((child) => child.value === 'nickname')).toBe(false);
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
it('retries preload when rootCollection becomes resolvable with same fieldOptions', async () => {
|
|
517
|
+
const nicknameField = { name: 'nickname', title: 'Nickname', interface: 'input' };
|
|
518
|
+
const profileCollection = {
|
|
519
|
+
getField: (name: string) => (name === 'nickname' ? nicknameField : null),
|
|
520
|
+
getFields: vi.fn(() => [nicknameField]),
|
|
521
|
+
};
|
|
522
|
+
const profileField = {
|
|
523
|
+
name: 'profile',
|
|
524
|
+
title: 'Profile',
|
|
525
|
+
type: 'belongsTo',
|
|
526
|
+
interface: 'm2o',
|
|
527
|
+
target: 'profiles',
|
|
528
|
+
targetCollection: profileCollection,
|
|
529
|
+
};
|
|
530
|
+
const unresolvedCollection = {
|
|
531
|
+
getField: () => null,
|
|
532
|
+
getFields: () => [],
|
|
533
|
+
};
|
|
534
|
+
const resolvedCollection = {
|
|
535
|
+
getField: (name: string) => (name === 'profile' ? profileField : null),
|
|
536
|
+
getFields: () => [profileField],
|
|
537
|
+
};
|
|
538
|
+
const configuredParent = {
|
|
539
|
+
label: 'Profile',
|
|
540
|
+
value: 'profile',
|
|
541
|
+
isLeaf: false,
|
|
542
|
+
children: [{ label: 'Name', value: 'name', isLeaf: true }],
|
|
543
|
+
};
|
|
544
|
+
const value: FieldAssignRuleItem[] = [
|
|
545
|
+
{
|
|
546
|
+
key: 'rule-profile-nickname',
|
|
547
|
+
enable: true,
|
|
548
|
+
targetPath: 'profile.nickname',
|
|
549
|
+
mode: 'assign',
|
|
550
|
+
},
|
|
551
|
+
];
|
|
552
|
+
|
|
553
|
+
const { rerender } = render(
|
|
554
|
+
wrap(
|
|
555
|
+
<FieldAssignRulesEditor
|
|
556
|
+
t={t}
|
|
557
|
+
fieldOptions={[configuredParent]}
|
|
558
|
+
rootCollection={unresolvedCollection}
|
|
559
|
+
value={value}
|
|
560
|
+
showCondition={false}
|
|
561
|
+
/>,
|
|
562
|
+
),
|
|
563
|
+
);
|
|
564
|
+
|
|
565
|
+
rerender(
|
|
566
|
+
wrap(
|
|
567
|
+
<FieldAssignRulesEditor
|
|
568
|
+
t={t}
|
|
569
|
+
fieldOptions={[configuredParent]}
|
|
570
|
+
rootCollection={resolvedCollection}
|
|
571
|
+
value={value}
|
|
572
|
+
showCondition={false}
|
|
573
|
+
/>,
|
|
574
|
+
),
|
|
575
|
+
);
|
|
576
|
+
|
|
577
|
+
await waitFor(() => {
|
|
578
|
+
expect(profileCollection.getFields).toHaveBeenCalled();
|
|
579
|
+
});
|
|
580
|
+
});
|
|
581
|
+
|
|
582
|
+
it('handles preload errors without leaving unhandled rejections', async () => {
|
|
583
|
+
const loadError = new Error('failed to load fields');
|
|
584
|
+
const profileCollection = {
|
|
585
|
+
getFields: vi.fn(() => {
|
|
586
|
+
throw loadError;
|
|
587
|
+
}),
|
|
588
|
+
};
|
|
589
|
+
const profileField = {
|
|
590
|
+
name: 'profile',
|
|
591
|
+
title: 'Profile',
|
|
592
|
+
type: 'belongsTo',
|
|
593
|
+
interface: 'm2o',
|
|
594
|
+
target: 'profiles',
|
|
595
|
+
targetCollection: profileCollection,
|
|
596
|
+
};
|
|
597
|
+
const rootCollection = {
|
|
598
|
+
getField: (name: string) => (name === 'profile' ? profileField : null),
|
|
599
|
+
getFields: () => [profileField],
|
|
600
|
+
};
|
|
601
|
+
const configuredParent = {
|
|
602
|
+
label: 'Profile',
|
|
603
|
+
value: 'profile',
|
|
604
|
+
isLeaf: false,
|
|
605
|
+
};
|
|
606
|
+
const value: FieldAssignRuleItem[] = [
|
|
607
|
+
{
|
|
608
|
+
key: 'rule-profile-nickname',
|
|
609
|
+
enable: true,
|
|
610
|
+
targetPath: 'profile.nickname',
|
|
611
|
+
mode: 'assign',
|
|
612
|
+
},
|
|
613
|
+
];
|
|
614
|
+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
615
|
+
|
|
616
|
+
render(
|
|
617
|
+
wrap(
|
|
618
|
+
<FieldAssignRulesEditor
|
|
619
|
+
t={t}
|
|
620
|
+
fieldOptions={[configuredParent]}
|
|
621
|
+
rootCollection={rootCollection}
|
|
622
|
+
value={value}
|
|
623
|
+
showCondition={false}
|
|
624
|
+
/>,
|
|
625
|
+
),
|
|
626
|
+
);
|
|
627
|
+
|
|
628
|
+
try {
|
|
629
|
+
await waitFor(() => {
|
|
630
|
+
expect(warnSpy).toHaveBeenCalledWith('[FieldAssignRulesEditor] Failed to preload cascader path', loadError);
|
|
631
|
+
});
|
|
632
|
+
} finally {
|
|
633
|
+
warnSpy.mockRestore();
|
|
634
|
+
}
|
|
635
|
+
});
|
|
636
|
+
|
|
637
|
+
it('keeps loaded cascader children when translation function identity changes', async () => {
|
|
638
|
+
const nameField = { name: 'name', title: 'Name', interface: 'input' };
|
|
639
|
+
const nicknameField = { name: 'nickname', title: 'Nickname', interface: 'input' };
|
|
640
|
+
const profileCollection = {
|
|
641
|
+
getField: (name: string) => {
|
|
642
|
+
if (name === 'name') return nameField;
|
|
643
|
+
if (name === 'nickname') return nicknameField;
|
|
644
|
+
return null;
|
|
645
|
+
},
|
|
646
|
+
getFields: vi.fn(() => [nameField, nicknameField]),
|
|
647
|
+
};
|
|
648
|
+
const profileField = {
|
|
649
|
+
name: 'profile',
|
|
650
|
+
title: 'Profile',
|
|
651
|
+
type: 'belongsTo',
|
|
652
|
+
interface: 'm2o',
|
|
653
|
+
target: 'profiles',
|
|
654
|
+
targetCollection: profileCollection,
|
|
655
|
+
};
|
|
656
|
+
const rootCollection = {
|
|
657
|
+
getField: (name: string) => (name === 'profile' ? profileField : null),
|
|
658
|
+
getFields: () => [profileField],
|
|
659
|
+
};
|
|
660
|
+
const fieldOptions = [
|
|
661
|
+
{
|
|
662
|
+
label: 'Profile',
|
|
663
|
+
value: 'profile',
|
|
664
|
+
isLeaf: false,
|
|
665
|
+
},
|
|
666
|
+
];
|
|
667
|
+
const value: FieldAssignRuleItem[] = [
|
|
668
|
+
{
|
|
669
|
+
key: 'rule-profile',
|
|
670
|
+
enable: true,
|
|
671
|
+
targetPath: 'profile',
|
|
672
|
+
mode: 'assign',
|
|
673
|
+
},
|
|
674
|
+
];
|
|
675
|
+
|
|
676
|
+
const { container, rerender } = render(
|
|
677
|
+
wrap(
|
|
678
|
+
<FieldAssignRulesEditor
|
|
679
|
+
t={(key) => key}
|
|
680
|
+
fieldOptions={fieldOptions}
|
|
681
|
+
rootCollection={rootCollection}
|
|
682
|
+
value={value}
|
|
683
|
+
showCondition={false}
|
|
684
|
+
/>,
|
|
685
|
+
),
|
|
686
|
+
);
|
|
687
|
+
|
|
688
|
+
const selector = container.querySelector('.ant-select-selector') as HTMLElement | null;
|
|
689
|
+
expect(selector).not.toBeNull();
|
|
690
|
+
await userEvent.click(selector as HTMLElement);
|
|
691
|
+
const profileOption = (await screen.findAllByText('Profile')).find((item) =>
|
|
692
|
+
item.closest('.ant-cascader-menu-item'),
|
|
693
|
+
);
|
|
694
|
+
expect(profileOption).toBeTruthy();
|
|
695
|
+
await userEvent.click(profileOption as HTMLElement);
|
|
696
|
+
expect(await screen.findByText('Nickname')).toBeInTheDocument();
|
|
697
|
+
await userEvent.keyboard('{Escape}');
|
|
698
|
+
|
|
699
|
+
rerender(
|
|
700
|
+
wrap(
|
|
701
|
+
<FieldAssignRulesEditor
|
|
702
|
+
t={(key) => key}
|
|
703
|
+
fieldOptions={fieldOptions}
|
|
704
|
+
rootCollection={rootCollection}
|
|
705
|
+
value={value}
|
|
706
|
+
showCondition={false}
|
|
707
|
+
/>,
|
|
708
|
+
),
|
|
709
|
+
);
|
|
710
|
+
|
|
711
|
+
await userEvent.click(selector as HTMLElement);
|
|
712
|
+
expect(await screen.findByText('Nickname')).toBeInTheDocument();
|
|
713
|
+
});
|
|
714
|
+
|
|
288
715
|
it('saves selected title field into current assign rule without syncing', async () => {
|
|
289
716
|
const { rootCollection, value } = createAssociationFixture();
|
|
290
717
|
const onChange = vi.fn();
|