@nocobase/client-v2 2.1.10 → 2.1.12

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.
Files changed (59) hide show
  1. package/es/flow/actions/afterSuccess.d.ts +8 -1
  2. package/es/flow/actions/index.d.ts +1 -1
  3. package/es/flow/models/blocks/form/submitHandler.d.ts +1 -1
  4. package/es/flow/models/blocks/js-block/JSBlock.d.ts +1 -0
  5. package/es/flow/models/blocks/table/TableActionsColumnModel.d.ts +1 -0
  6. package/es/flow/models/blocks/table/TableBlockModel.d.ts +1 -0
  7. package/es/flow/models/blocks/table/dragSort/dragSortHooks.d.ts +1 -1
  8. package/es/flow/models/blocks/table/dragSort/dragSortSettings.d.ts +2 -1
  9. package/es/flow/models/blocks/table/dragSort/dragSortUtils.d.ts +6 -4
  10. package/es/flow/resolveViewParamsToViewList.d.ts +1 -1
  11. package/es/index.mjs +100 -84
  12. package/lib/index.js +126 -110
  13. package/package.json +7 -7
  14. package/src/collection-manager/field-configure.ts +1 -1
  15. package/src/collection-manager/interfaces/id.ts +1 -1
  16. package/src/collection-manager/interfaces/m2m.tsx +2 -2
  17. package/src/collection-manager/interfaces/m2o.tsx +2 -2
  18. package/src/collection-manager/interfaces/nanoid.ts +1 -1
  19. package/src/collection-manager/interfaces/o2m.tsx +2 -2
  20. package/src/collection-manager/interfaces/obo.tsx +2 -2
  21. package/src/collection-manager/interfaces/oho.tsx +2 -2
  22. package/src/collection-manager/interfaces/properties/index.ts +2 -2
  23. package/src/collection-manager/interfaces/uuid.ts +1 -1
  24. package/src/flow/__tests__/getKey.test.ts +7 -0
  25. package/src/flow/__tests__/resolveViewParamsToViewList.test.ts +34 -0
  26. package/src/flow/actions/__tests__/afterSuccess.test.ts +73 -0
  27. package/src/flow/actions/__tests__/dataScopeFilter.test.ts +58 -0
  28. package/src/flow/actions/__tests__/openView.defineProps.route.test.tsx +104 -0
  29. package/src/flow/actions/afterSuccess.tsx +142 -3
  30. package/src/flow/actions/dataScopeFilter.ts +10 -0
  31. package/src/flow/actions/index.ts +1 -1
  32. package/src/flow/actions/openView.tsx +38 -4
  33. package/src/flow/admin-shell/BaseLayoutModel.tsx +25 -3
  34. package/src/flow/admin-shell/BaseLayoutRouteCoordinator.ts +5 -2
  35. package/src/flow/admin-shell/__tests__/AdminLayoutRouteCoordinator.test.ts +13 -0
  36. package/src/flow/admin-shell/admin-layout/__tests__/AdminLayoutModel.test.tsx +82 -0
  37. package/src/flow/components/FieldAssignRulesEditor.tsx +66 -9
  38. package/src/flow/components/__tests__/FieldAssignRulesEditor.test.tsx +427 -0
  39. package/src/flow/components/__tests__/fieldAssignOptions.test.ts +151 -3
  40. package/src/flow/components/fieldAssignOptions.ts +155 -27
  41. package/src/flow/getViewDiffAndUpdateHidden.tsx +1 -0
  42. package/src/flow/models/base/ActionModelCore.tsx +6 -4
  43. package/src/flow/models/base/__tests__/ActionModelCore.render.test.tsx +37 -0
  44. package/src/flow/models/blocks/filter-form/fields/FilterFormCustomFieldModel.tsx +1 -1
  45. package/src/flow/models/blocks/form/FormActionModel.tsx +1 -1
  46. package/src/flow/models/blocks/form/__tests__/submitHandler.test.ts +54 -1
  47. package/src/flow/models/blocks/form/submitHandler.ts +17 -3
  48. package/src/flow/models/blocks/js-block/JSBlock.tsx +223 -2
  49. package/src/flow/models/blocks/js-block/__tests__/JSBlockModel.test.tsx +150 -0
  50. package/src/flow/models/blocks/table/TableActionsColumnModel.tsx +35 -12
  51. package/src/flow/models/blocks/table/TableBlockModel.tsx +25 -14
  52. package/src/flow/models/blocks/table/__tests__/TableActionsColumnModel.test.tsx +57 -1
  53. package/src/flow/models/blocks/table/__tests__/TableBlockModel.dragSort.test.ts +127 -0
  54. package/src/flow/models/blocks/table/__tests__/TableBlockModel.rowSelection.test.tsx +1 -0
  55. package/src/flow/models/blocks/table/dragSort/dragSortHooks.tsx +28 -17
  56. package/src/flow/models/blocks/table/dragSort/dragSortSettings.ts +13 -6
  57. package/src/flow/models/blocks/table/dragSort/dragSortUtils.ts +15 -2
  58. package/src/flow/resolveViewParamsToViewList.tsx +11 -3
  59. package/src/settings-center/SystemSettingsPage.tsx +1 -1
@@ -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 === '')
@@ -7,7 +7,7 @@
7
7
  * For more information, please refer to: https://www.nocobase.com/agreement.
8
8
  */
9
9
 
10
- export * from './afterSuccess';
10
+ export { afterSuccess } from './afterSuccess';
11
11
  export * from './confirm';
12
12
  export * from './dataScope';
13
13
  export * from './openView';
@@ -7,7 +7,15 @@
7
7
  * For more information, please refer to: https://www.nocobase.com/agreement.
8
8
  */
9
9
 
10
- import { defineAction, tExpr, FlowModelContext, FlowModel, FlowExitAllException } from '@nocobase/flow-engine';
10
+ import {
11
+ defineAction,
12
+ tExpr,
13
+ FlowModelContext,
14
+ FlowModel,
15
+ FlowExitAllException,
16
+ createOpenViewRouteState,
17
+ RUNJS_OPEN_VIEW_ROUTE_STATE,
18
+ } from '@nocobase/flow-engine';
11
19
  import React from 'react';
12
20
  import { FlowPage } from '../FlowPage';
13
21
  import { PageModel, RootPageModel } from '../models';
@@ -291,8 +299,32 @@ export const openView = defineAction({
291
299
  ? (inputArgs as any).associationName
292
300
  : (params as any)?.associationName;
293
301
  const mergedTabUid = typeof inputArgs.tabUid !== 'undefined' ? inputArgs.tabUid : params.tabUid;
294
- // 移动端中只需要显示子页面
295
- const openMode = ctx.inputArgs?.isMobileLayout ? 'embed' : ctx.inputArgs?.mode || params.mode || 'drawer';
302
+ const hasRunJSOpenViewRouteState = Object.prototype.hasOwnProperty.call(inputArgs, RUNJS_OPEN_VIEW_ROUTE_STATE);
303
+ const runJSOpenViewRouteStateInput = (inputArgs as Record<PropertyKey, unknown>)[RUNJS_OPEN_VIEW_ROUTE_STATE];
304
+ const runJSOpenViewRouteState = hasRunJSOpenViewRouteState
305
+ ? createOpenViewRouteState(
306
+ runJSOpenViewRouteStateInput && typeof runJSOpenViewRouteStateInput === 'object'
307
+ ? (runJSOpenViewRouteStateInput as { mode?: unknown; size?: unknown })
308
+ : {
309
+ mode: inputArgs.mode,
310
+ size: inputArgs.size,
311
+ },
312
+ )
313
+ : undefined;
314
+ const replayOpenViewRouteStateInput = (inputArgs as { openViewRouteState?: unknown }).openViewRouteState;
315
+ const replayOpenViewRouteState =
316
+ replayOpenViewRouteStateInput && typeof replayOpenViewRouteStateInput === 'object'
317
+ ? createOpenViewRouteState(replayOpenViewRouteStateInput as { mode?: unknown; size?: unknown })
318
+ : undefined;
319
+ const runtimeOpenViewRouteState = runJSOpenViewRouteState || replayOpenViewRouteState;
320
+ // 移动端中只需要显示子页面。本次 dispatch 参数优先于持久化默认值。
321
+ const openMode = ctx.inputArgs?.isMobileLayout
322
+ ? 'embed'
323
+ : runtimeOpenViewRouteState?.mode || ctx.inputArgs?.mode || params.mode || 'drawer';
324
+ const effectiveRunJSOpenViewRouteState =
325
+ runJSOpenViewRouteState && ctx.inputArgs?.isMobileLayout
326
+ ? createOpenViewRouteState({ ...runJSOpenViewRouteState, mode: openMode })
327
+ : runJSOpenViewRouteState;
296
328
  let navigation = typeof inputArgs.navigation !== 'undefined' ? inputArgs.navigation : params.navigation;
297
329
 
298
330
  // 传递了上下文就必须禁用路由,否则下次路由打开会缺少上下文
@@ -313,6 +345,7 @@ export const openView = defineAction({
313
345
  sourceId: mergedSourceId,
314
346
  tabUid: mergedTabUid,
315
347
  viewUid: ctx.model.context?.inputArgs?.viewUid || ctx.model.uid,
348
+ ...(effectiveRunJSOpenViewRouteState ? { openViewRouteState: effectiveRunJSOpenViewRouteState } : {}),
316
349
  } as Record<string, unknown>;
317
350
  const pendingView = {
318
351
  type: pendingType,
@@ -328,6 +361,7 @@ export const openView = defineAction({
328
361
  filterByTk: mergedFilterByTk,
329
362
  sourceId: mergedSourceId,
330
363
  tabUid: mergedTabUid,
364
+ ...(effectiveRunJSOpenViewRouteState ? { openViewRouteState: effectiveRunJSOpenViewRouteState } : {}),
331
365
  };
332
366
  ctx.view.navigation.navigateTo(nextView);
333
367
  return;
@@ -373,7 +407,7 @@ export const openView = defineAction({
373
407
 
374
408
  const pageModelClass =
375
409
  ctx.inputArgs.pageModelClass || params.pageModelClass || ctx.layout?.childPageModelClass || 'ChildPageModel';
376
- const size = ctx.inputArgs.size || params.size || 'medium';
410
+ const size = runtimeOpenViewRouteState?.size || ctx.inputArgs.size || params.size || 'medium';
377
411
  let pageModelUid: string | null = null;
378
412
  let pageModelRef: FlowModel | null = null;
379
413
 
@@ -8,7 +8,13 @@
8
8
  */
9
9
 
10
10
  import { define, observable } from '@formily/reactive';
11
- import { parsePathnameToViewParams, type FlowEngine, FlowModel, type ViewParam } from '@nocobase/flow-engine';
11
+ import {
12
+ decodeOpenViewRouteState,
13
+ parsePathnameToViewParams,
14
+ type FlowEngine,
15
+ FlowModel,
16
+ type ViewParam,
17
+ } from '@nocobase/flow-engine';
12
18
  import {
13
19
  BaseLayoutRouteCoordinator,
14
20
  type BaseLayoutRouteCoordinatorOptions,
@@ -116,20 +122,36 @@ const isStandardLayoutRelativePath = (relativePath: string) => {
116
122
  }
117
123
 
118
124
  let i = 1;
125
+ let currentViewUid = segments[0];
119
126
  while (i < segments.length) {
120
127
  const segment = segments[i];
128
+
121
129
  if (segment === 'view') {
122
130
  if (!segments[i + 1]) {
123
131
  return false;
124
132
  }
133
+ currentViewUid = segments[i + 1];
134
+ i += 2;
135
+ continue;
136
+ }
137
+
138
+ if (segment === 'opts') {
139
+ if (!segments[i + 1] || !decodeOpenViewRouteState(currentViewUid, segments[i + 1])) {
140
+ return false;
141
+ }
142
+ i += 2;
143
+ continue;
144
+ }
145
+
146
+ if (isKnownViewParamName(segment) && segments[i + 1]) {
125
147
  i += 2;
126
148
  continue;
127
149
  }
128
150
 
129
- if (!isKnownViewParamName(segment) || !segments[i + 1]) {
151
+ if (!segments[i + 1]) {
130
152
  return false;
131
153
  }
132
- i += 2;
154
+ return false;
133
155
  }
134
156
 
135
157
  return true;
@@ -272,7 +272,7 @@ export class BaseLayoutRouteCoordinator {
272
272
  runtime.viewState[getKey(viewItem)]?.destroy?.(true);
273
273
  delete runtime.viewState[getKey(viewItem)];
274
274
  });
275
- updateViewListHidden(viewList);
275
+ updateViewListHidden(viewList, !!this.layoutContext?.isMobileLayout);
276
276
  }
277
277
 
278
278
  if (viewsToOpen.length) {
@@ -299,7 +299,7 @@ export class BaseLayoutRouteCoordinator {
299
299
  }
300
300
 
301
301
  if (runtime.meta.active) {
302
- updateViewListHidden(viewList);
302
+ updateViewListHidden(viewList, !!this.layoutContext?.isMobileLayout);
303
303
  return;
304
304
  }
305
305
 
@@ -390,6 +390,7 @@ export class BaseLayoutRouteCoordinator {
390
390
  openViewParams?.associationName && !hasUsableSourceId(viewItem.params.sourceId)
391
391
  ? null
392
392
  : openViewParams?.associationName;
393
+ const openViewRouteState = viewItem.params.openViewRouteState;
393
394
  const openerUids = viewList.slice(0, viewItem.index).map((item) => item.params.viewUid);
394
395
  const navigation = new ViewNavigation(
395
396
  this.flowEngine.context,
@@ -408,6 +409,8 @@ export class BaseLayoutRouteCoordinator {
408
409
  deactivateRef,
409
410
  openerUids,
410
411
  ...viewItem.params,
412
+ ...(openViewRouteState?.mode ? { mode: openViewRouteState.mode } : {}),
413
+ ...(openViewRouteState?.size ? { size: openViewRouteState.size } : {}),
411
414
  pageActive: runtime.meta.active,
412
415
  activationControlledByLayout: true,
413
416
  navigation,
@@ -121,6 +121,19 @@ describe('AdminLayoutRouteCoordinator', () => {
121
121
  });
122
122
  });
123
123
 
124
+ it('passes RunJS openView route state as runtime mode and size during route replay', () => {
125
+ const { dispatchEvent } = setupRouteReplay({
126
+ openViewRouteState: { mode: 'dialog', size: 'large' },
127
+ });
128
+
129
+ expect(dispatchEvent.mock.calls[0][1]).toMatchObject({
130
+ mode: 'dialog',
131
+ size: 'large',
132
+ openViewRouteState: { mode: 'dialog', size: 'large' },
133
+ triggerByRouter: true,
134
+ });
135
+ });
136
+
124
137
  it('uses layout content element before route page placeholder as view target', () => {
125
138
  const engine = new FlowEngine();
126
139
  engine.registerModels({ RouteModel });
@@ -39,6 +39,7 @@ import {
39
39
  FlowEngine,
40
40
  FlowEngineProvider,
41
41
  FlowModelRenderer,
42
+ encodeOpenViewRouteState,
42
43
  useFlowEngine,
43
44
  type FlowModel,
44
45
  } from '@nocobase/flow-engine';
@@ -213,6 +214,87 @@ describe('AdminLayoutModel runtime', () => {
213
214
  });
214
215
  });
215
216
 
217
+ it('should parse RunJS openView route params into layout route view stack state', async () => {
218
+ const token = encodeOpenViewRouteState('popup', { mode: 'dialog', size: 'large' });
219
+ if (!token) {
220
+ throw new Error('Expected openView route state token.');
221
+ }
222
+ const engine = new FlowEngine();
223
+ engine.context.defineProperty('routeRepository', {
224
+ value: {
225
+ getRouteBySchemaUid: (pageUid: string) => ({ title: pageUid }),
226
+ },
227
+ });
228
+
229
+ render(
230
+ <FlowEngineProvider engine={engine}>
231
+ <TestAdminLayoutHost />
232
+ </FlowEngineProvider>,
233
+ );
234
+ const model = engine.getModel<TestAdminLayoutModel>('admin-layout-model');
235
+ expect(model).toBeTruthy();
236
+
237
+ act(() => {
238
+ model.syncLayoutRoute({
239
+ name: getLayoutPageViewRouteName('admin'),
240
+ pathname: `/admin/page-1/view/popup/opts/${token}/filterbytk/1`,
241
+ layoutBasePathname: '/admin',
242
+ });
243
+ });
244
+
245
+ await waitFor(() => {
246
+ expect(model.context.layoutRoute).toMatchObject({
247
+ type: 'page',
248
+ pageUid: 'page-1',
249
+ viewStack: [
250
+ { viewUid: 'page-1' },
251
+ {
252
+ viewUid: 'popup',
253
+ openViewRouteState: { mode: 'dialog', size: 'large' },
254
+ filterByTk: '1',
255
+ },
256
+ ],
257
+ });
258
+ });
259
+ });
260
+
261
+ it('should reject malformed RunJS openView route params', async () => {
262
+ const wrongViewToken = encodeOpenViewRouteState('other-popup', { mode: 'dialog', size: 'large' });
263
+ if (!wrongViewToken) {
264
+ throw new Error('Expected openView route state token.');
265
+ }
266
+ const engine = new FlowEngine();
267
+
268
+ render(
269
+ <FlowEngineProvider engine={engine}>
270
+ <TestAdminLayoutHost />
271
+ </FlowEngineProvider>,
272
+ );
273
+ const model = engine.getModel<TestAdminLayoutModel>('admin-layout-model');
274
+ expect(model).toBeTruthy();
275
+
276
+ [
277
+ '/admin/page-1/sourceid',
278
+ '/admin/page-1/AbCdEfGh/filterbytk/1',
279
+ '/admin/page-1/view/popup/AbCdEfGh/filterbytk/1',
280
+ '/admin/page-1/view/popup/opts/AbCdEfGh/filterbytk/1',
281
+ '/admin/page-1/view/popup/openviewmode/dialog',
282
+ '/admin/page-1/view/popup/openviewsize/large',
283
+ `/admin/page-1/view/popup/opts/${wrongViewToken}/filterbytk/1`,
284
+ ].forEach((pathname) => {
285
+ expect(
286
+ model.resolveLayoutRoute({
287
+ name: getLayoutPageViewRouteName('admin'),
288
+ pathname,
289
+ layoutBasePathname: '/admin',
290
+ }),
291
+ ).toMatchObject({
292
+ type: 'notFound',
293
+ pathname,
294
+ });
295
+ });
296
+ });
297
+
216
298
  it('should not consume global routes that belong to nested layouts', async () => {
217
299
  const engine = new FlowEngine();
218
300
  const routeRef = observable.ref({
@@ -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
- Array.isArray(fieldOptions) ? (fieldOptions as FieldAssignCascaderOption[]) : [],
223
+ normalizeFieldOptions(fieldOptions),
176
224
  );
225
+ const loadedCascaderOptionsRef = React.useRef<WeakSet<FieldAssignCascaderOption>>(new WeakSet());
177
226
 
178
227
  React.useEffect(() => {
179
- setCascaderOptions(Array.isArray(fieldOptions) ? (fieldOptions as FieldAssignCascaderOption[]) : []);
180
- }, [fieldOptions]);
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 = true;
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 = true;
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
- void run();
723
+ run().catch((error) => {
724
+ console.warn('[FieldAssignRulesEditor] Failed to preload cascader path', error);
725
+ });
669
726
  return () => {
670
727
  cancelled = true;
671
728
  };