@nocobase/client-v2 2.2.0-beta.3 → 2.2.0-beta.6

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 (133) hide show
  1. package/es/RouteRepository.d.ts +21 -6
  2. package/es/components/form/JsonTextArea.d.ts +2 -1
  3. package/es/components/form/VariableJsonTextArea.d.ts +19 -0
  4. package/es/components/form/index.d.ts +1 -0
  5. package/es/components/index.d.ts +1 -0
  6. package/es/flow/admin-shell/admin-layout/AdminLayoutEntryGuard.d.ts +2 -0
  7. package/es/flow/admin-shell/admin-layout/AdminLayoutMenuFlowUtils.d.ts +2 -1
  8. package/es/flow/admin-shell/admin-layout/AdminLayoutMenuModels.d.ts +2 -0
  9. package/es/flow/admin-shell/admin-layout/AdminLayoutMenuUtils.d.ts +3 -2
  10. package/es/flow/admin-shell/admin-layout/AdminLayoutSlotModels.d.ts +4 -0
  11. package/es/flow/admin-shell/admin-layout/resolveAdminRouteRuntimeTarget.d.ts +6 -0
  12. package/es/flow/components/FieldAssignRulesEditor.d.ts +3 -1
  13. package/es/flow/models/base/GridModel.d.ts +1 -1
  14. package/es/flow/models/blocks/filter-form/FilterFormBlockModel.d.ts +5 -0
  15. package/es/flow/models/blocks/form/FormBlockModel.d.ts +4 -0
  16. package/es/flow/models/blocks/form/value-runtime/rules.d.ts +21 -0
  17. package/es/flow/models/blocks/form/value-runtime/runtime.d.ts +21 -0
  18. package/es/flow/models/blocks/form/value-runtime/types.d.ts +2 -2
  19. package/es/flow/models/fields/AssociationFieldModel/RecordSelectFieldModel.d.ts +2 -0
  20. package/es/flow/models/fields/AssociationFieldModel/SubTableFieldModel/SubTableColumnModel.d.ts +1 -0
  21. package/es/flow/utils/dateTimeDisplayProps.d.ts +49 -0
  22. package/es/flow-compat/FieldValidation.d.ts +2 -1
  23. package/es/index.d.ts +2 -1
  24. package/es/index.mjs +167 -121
  25. package/lib/index.js +167 -121
  26. package/package.json +7 -7
  27. package/src/RouteRepository.ts +126 -24
  28. package/src/__tests__/RouteRepository.test.ts +216 -0
  29. package/src/__tests__/exports.test.ts +16 -0
  30. package/src/__tests__/settings-center.test.tsx +33 -0
  31. package/src/components/form/JsonTextArea.tsx +21 -11
  32. package/src/components/form/VariableJsonTextArea.tsx +175 -0
  33. package/src/components/form/__tests__/JsonTextArea.test.tsx +43 -0
  34. package/src/components/form/__tests__/VariableJsonTextArea.test.tsx +86 -0
  35. package/src/components/form/index.tsx +1 -0
  36. package/src/components/index.ts +1 -0
  37. package/src/flow/__tests__/FlowRoute.test.tsx +443 -5
  38. package/src/flow/actions/__tests__/fieldLinkageRules.scopeDepth.test.ts +150 -1
  39. package/src/flow/actions/__tests__/linkageAssignField.legacy.test.ts +135 -0
  40. package/src/flow/actions/__tests__/linkageRules.hiddenSync.restore.test.ts +19 -5
  41. package/src/flow/actions/__tests__/linkageRules.subFormSetFieldProps.test.ts +92 -0
  42. package/src/flow/actions/customVariable.tsx +1 -2
  43. package/src/flow/actions/dateTimeFormat.tsx +42 -28
  44. package/src/flow/actions/linkageRules.tsx +122 -34
  45. package/src/flow/actions/runjs.tsx +2 -14
  46. package/src/flow/actions/validation.tsx +62 -30
  47. package/src/flow/admin-shell/admin-layout/AdminLayoutComponent.tsx +33 -10
  48. package/src/flow/admin-shell/admin-layout/AdminLayoutEntryGuard.test.tsx +310 -0
  49. package/src/flow/admin-shell/admin-layout/AdminLayoutEntryGuard.tsx +44 -8
  50. package/src/flow/admin-shell/admin-layout/AdminLayoutMenuFlowUtils.ts +5 -3
  51. package/src/flow/admin-shell/admin-layout/AdminLayoutMenuModels.tsx +61 -9
  52. package/src/flow/admin-shell/admin-layout/AdminLayoutMenuUtils.tsx +71 -8
  53. package/src/flow/admin-shell/admin-layout/AdminLayoutModel.tsx +1 -1
  54. package/src/flow/admin-shell/admin-layout/AdminLayoutSlotModels.tsx +21 -8
  55. package/src/flow/admin-shell/admin-layout/HelpLite.tsx +1 -3
  56. package/src/flow/admin-shell/admin-layout/__tests__/AdminLayoutComponent.test.tsx +188 -0
  57. package/src/flow/admin-shell/admin-layout/__tests__/AdminLayoutMenuModels.test.ts +99 -0
  58. package/src/flow/admin-shell/admin-layout/__tests__/TopbarActionsBar.test.tsx +166 -2
  59. package/src/flow/admin-shell/admin-layout/resolveAdminRouteRuntimeTarget.test.ts +20 -0
  60. package/src/flow/admin-shell/admin-layout/resolveAdminRouteRuntimeTarget.ts +33 -5
  61. package/src/flow/components/DefaultValue.tsx +1 -2
  62. package/src/flow/components/DynamicFlowsIcon.tsx +447 -126
  63. package/src/flow/components/FieldAssignRulesEditor.tsx +62 -12
  64. package/src/flow/components/FlowRoute.tsx +56 -11
  65. package/src/flow/components/__tests__/DynamicFlowsIcon.test.tsx +148 -2
  66. package/src/flow/components/__tests__/FieldAssignRulesEditor.test.tsx +81 -4
  67. package/src/flow/components/code-editor/__tests__/useCodeRunner.test.tsx +2 -17
  68. package/src/flow/components/code-editor/hooks/useCodeRunner.ts +2 -14
  69. package/src/flow/components/code-editor/runjsDiagnostics.ts +8 -45
  70. package/src/flow/flows/editMarkdownFlow.tsx +1 -1
  71. package/src/flow/index.ts +1 -1
  72. package/src/flow/internal/utils/operatorSchemaHelper.ts +15 -1
  73. package/src/flow/models/actions/JSActionModel.tsx +2 -7
  74. package/src/flow/models/actions/JSCollectionActionModel.tsx +2 -7
  75. package/src/flow/models/actions/JSItemActionModel.tsx +2 -7
  76. package/src/flow/models/actions/JSRecordActionModel.tsx +2 -7
  77. package/src/flow/models/base/ActionModel.tsx +12 -1
  78. package/src/flow/models/base/GridModel.tsx +38 -7
  79. package/src/flow/models/base/PageModel/PageModel.tsx +4 -1
  80. package/src/flow/models/base/PageModel/__tests__/PageModel.test.ts +28 -4
  81. package/src/flow/models/base/__tests__/ActionModel.test.ts +83 -0
  82. package/src/flow/models/base/__tests__/GridModel.dragSnapshotContainer.test.ts +239 -1
  83. package/src/flow/models/base/__tests__/transformRowsToSingleColumn.test.ts +48 -0
  84. package/src/flow/models/blocks/filter-form/FilterFormBlockModel.tsx +31 -3
  85. package/src/flow/models/blocks/filter-form/FilterFormJSActionModel.tsx +2 -7
  86. package/src/flow/models/blocks/filter-form/__tests__/defaultValues.wiring.test.ts +77 -0
  87. package/src/flow/models/blocks/filter-manager/flow-actions/__tests__/customizeFilterRender.test.tsx +56 -1
  88. package/src/flow/models/blocks/form/EditFormModel.tsx +1 -0
  89. package/src/flow/models/blocks/form/FormBlockModel.tsx +7 -0
  90. package/src/flow/models/blocks/form/JSFormActionModel.tsx +2 -7
  91. package/src/flow/models/blocks/form/__tests__/FormBlockModel.test.tsx +17 -0
  92. package/src/flow/models/blocks/form/value-runtime/__tests__/runtime.test.ts +880 -102
  93. package/src/flow/models/blocks/form/value-runtime/rules.ts +445 -13
  94. package/src/flow/models/blocks/form/value-runtime/runtime.ts +257 -13
  95. package/src/flow/models/blocks/form/value-runtime/types.ts +2 -2
  96. package/src/flow/models/blocks/js-block/JSBlock.tsx +2 -7
  97. package/src/flow/models/blocks/table/JSColumnModel.tsx +1 -9
  98. package/src/flow/models/blocks/table/TableActionsColumnModel.tsx +1 -1
  99. package/src/flow/models/blocks/table/TableBlockModel.tsx +13 -3
  100. package/src/flow/models/blocks/table/TableColumnModel.tsx +36 -3
  101. package/src/flow/models/blocks/table/__tests__/TableActionsColumnModel.test.tsx +54 -0
  102. package/src/flow/models/blocks/table/__tests__/TableBlockModel.mobileSettingsButtons.test.tsx +78 -0
  103. package/src/flow/models/blocks/table/__tests__/TableColumnModel.test.tsx +159 -1
  104. package/src/flow/models/fields/AssociationFieldModel/AssociationFieldModel.tsx +1 -1
  105. package/src/flow/models/fields/AssociationFieldModel/PopupSubTableFieldModel/PopupSubTableFieldModel.tsx +29 -6
  106. package/src/flow/models/fields/AssociationFieldModel/RecordSelectFieldModel.tsx +16 -4
  107. package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/SubTableColumnModel.tsx +24 -1
  108. package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/SubTableField.tsx +13 -1
  109. package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/__tests__/SubTableColumnModel.rowRecord.test.ts +12 -0
  110. package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/__tests__/SubTableFieldModel.reset.test.ts +180 -0
  111. package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/index.tsx +6 -4
  112. package/src/flow/models/fields/AssociationFieldModel/__tests__/AssociationFieldModel.updateAssociation.test.ts +72 -0
  113. package/src/flow/models/fields/AssociationFieldModel/__tests__/RecordPickerFieldModel.itemContext.test.ts +23 -0
  114. package/src/flow/models/fields/ClickableFieldModel.tsx +5 -0
  115. package/src/flow/models/fields/DisplayDateTimeFieldModel.tsx +29 -1
  116. package/src/flow/models/fields/InputFieldModel.tsx +14 -22
  117. package/src/flow/models/fields/JSEditableFieldModel.tsx +2 -11
  118. package/src/flow/models/fields/JSFieldModel.tsx +2 -7
  119. package/src/flow/models/fields/JSItemModel.tsx +2 -14
  120. package/src/flow/models/fields/SelectFieldModel.tsx +6 -4
  121. package/src/flow/models/fields/__tests__/DisplayDateTimeFieldModel.test.tsx +96 -0
  122. package/src/flow/models/fields/__tests__/InputFieldModel.test.tsx +17 -0
  123. package/src/flow/models/fields/__tests__/SelectFieldModel.test.tsx +43 -0
  124. package/src/flow/models/topbar/TopbarActionModel.tsx +93 -10
  125. package/src/flow/utils/__tests__/dateTimeFormat.test.ts +258 -0
  126. package/src/flow/utils/dateTimeDisplayProps.ts +135 -0
  127. package/src/flow-compat/FieldValidation.tsx +122 -60
  128. package/src/flow-compat/Popover.tsx +43 -4
  129. package/src/flow-compat/__tests__/Popover.test.tsx +34 -0
  130. package/src/index.ts +8 -1
  131. package/src/layout-manager/__tests__/LayoutRoute.test.tsx +41 -1
  132. package/src/settings-center/AdminSettingsLayout.tsx +3 -0
  133. package/src/settings-center/plugin-manager/index.tsx +3 -0
@@ -28,7 +28,7 @@ import { buildFieldAssignCascaderOptionsFromCollection, type FieldAssignCascader
28
28
  import { isRunJSValue, isVariableExpression, type MetaTreeNode } from '@nocobase/flow-engine';
29
29
  import { isToManyAssociationField } from '../internal/utils/modelUtils';
30
30
 
31
- export type AssignMode = 'default' | 'assign';
31
+ export type AssignMode = 'default' | 'assign' | 'override';
32
32
 
33
33
  type CollectionFieldLike = {
34
34
  name?: unknown;
@@ -89,6 +89,8 @@ export interface FieldAssignRulesEditorProps {
89
89
  defaultMode?: AssignMode;
90
90
  /** 固定 mode:用于“仅默认值/仅赋值”的场景 */
91
91
  fixedMode?: AssignMode;
92
+ /** 可选 mode 列表;fixedMode 优先级更高 */
93
+ allowedModes?: AssignMode[];
92
94
  /** 是否显示 condition */
93
95
  showCondition?: boolean;
94
96
  /** 是否显示 enable 开关 */
@@ -109,6 +111,23 @@ export interface FieldAssignRulesEditorProps {
109
111
  maxAssociationFieldDepth?: number;
110
112
  }
111
113
 
114
+ const ALL_ASSIGN_MODES: AssignMode[] = ['default', 'override', 'assign'];
115
+
116
+ function isAssignMode(mode: unknown): mode is AssignMode {
117
+ return mode === 'default' || mode === 'override' || mode === 'assign';
118
+ }
119
+
120
+ function normalizeAssignModes(modes?: AssignMode[]): AssignMode[] {
121
+ const source = Array.isArray(modes) && modes.length ? modes : ALL_ASSIGN_MODES;
122
+ const out: AssignMode[] = [];
123
+ for (const mode of source) {
124
+ if (!isAssignMode(mode)) continue;
125
+ if (out.includes(mode)) continue;
126
+ out.push(mode);
127
+ }
128
+ return out.length ? out : ALL_ASSIGN_MODES;
129
+ }
130
+
112
131
  export const FieldAssignRulesEditor: React.FC<FieldAssignRulesEditorProps> = (props) => {
113
132
  const {
114
133
  t,
@@ -118,6 +137,7 @@ export const FieldAssignRulesEditor: React.FC<FieldAssignRulesEditorProps> = (pr
118
137
  onChange,
119
138
  defaultMode = 'assign',
120
139
  fixedMode,
140
+ allowedModes: rawAllowedModes,
121
141
  showCondition = true,
122
142
  showEnable = true,
123
143
  showValueEditorWhenNoField = false,
@@ -129,6 +149,28 @@ export const FieldAssignRulesEditor: React.FC<FieldAssignRulesEditorProps> = (pr
129
149
  } = props;
130
150
 
131
151
  const value = React.useMemo(() => (Array.isArray(rawValue) ? rawValue : []), [rawValue]);
152
+ const allowedModes = React.useMemo(
153
+ () => normalizeAssignModes(fixedMode ? [fixedMode] : rawAllowedModes),
154
+ [fixedMode, rawAllowedModes],
155
+ );
156
+ const getDefaultMode = React.useCallback((): AssignMode => {
157
+ if (fixedMode) return fixedMode;
158
+ if (isAssignMode(defaultMode) && allowedModes.includes(defaultMode)) return defaultMode;
159
+ return allowedModes[0] || 'assign';
160
+ }, [allowedModes, defaultMode, fixedMode]);
161
+ const normalizeItemMode = React.useCallback(
162
+ (item: FieldAssignRuleItem): FieldAssignRuleItem => {
163
+ const mode = isAssignMode(item?.mode) && allowedModes.includes(item.mode) ? item.mode : getDefaultMode();
164
+ return item?.mode === mode ? item : { ...item, mode };
165
+ },
166
+ [allowedModes, getDefaultMode],
167
+ );
168
+ const emitChange = React.useCallback(
169
+ (next: FieldAssignRuleItem[]) => {
170
+ onChange?.(next.map((item) => normalizeItemMode(item)));
171
+ },
172
+ [normalizeItemMode, onChange],
173
+ );
132
174
  const [cascaderOptions, setCascaderOptions] = React.useState<FieldAssignCascaderOption[]>(() =>
133
175
  Array.isArray(fieldOptions) ? (fieldOptions as FieldAssignCascaderOption[]) : [],
134
176
  );
@@ -312,7 +354,7 @@ export const FieldAssignRulesEditor: React.FC<FieldAssignRulesEditorProps> = (pr
312
354
 
313
355
  const patchItem = (index: number, patch: Partial<FieldAssignRuleItem>) => {
314
356
  const next = value.map((it, i) => (i === index ? { ...it, ...patch } : it));
315
- onChange?.(next);
357
+ emitChange(next);
316
358
  };
317
359
 
318
360
  const removeItem = (index: number) => {
@@ -334,7 +376,7 @@ export const FieldAssignRulesEditor: React.FC<FieldAssignRulesEditorProps> = (pr
334
376
  setSyncingRuleKey(null);
335
377
  }
336
378
  const next = value.filter((_, i) => i !== index);
337
- onChange?.(next);
379
+ emitChange(next);
338
380
  };
339
381
 
340
382
  const moveItem = (index: number, direction: 'up' | 'down') => {
@@ -343,7 +385,7 @@ export const FieldAssignRulesEditor: React.FC<FieldAssignRulesEditorProps> = (pr
343
385
  if (targetIndex < 0 || targetIndex >= next.length) return;
344
386
  const [item] = next.splice(index, 1);
345
387
  next.splice(targetIndex, 0, item);
346
- onChange?.(next);
388
+ emitChange(next);
347
389
  };
348
390
 
349
391
  const addItem = () => {
@@ -352,29 +394,33 @@ export const FieldAssignRulesEditor: React.FC<FieldAssignRulesEditorProps> = (pr
352
394
  {
353
395
  key: uid(),
354
396
  enable: true,
355
- mode: fixedMode || defaultMode,
397
+ mode: getDefaultMode(),
356
398
  condition: { logic: '$and', items: [] },
357
399
  targetPath: undefined,
358
400
  value: undefined,
359
401
  },
360
402
  ];
361
- onChange?.(next);
403
+ emitChange(next);
362
404
  };
363
405
 
364
406
  const getEffectiveMode = (item: FieldAssignRuleItem): AssignMode => {
365
407
  if (fixedMode) return fixedMode;
366
- return item?.mode === 'default' ? 'default' : 'assign';
408
+ if (isAssignMode(item?.mode) && allowedModes.includes(item.mode)) return item.mode;
409
+ return getDefaultMode();
367
410
  };
368
411
 
369
412
  const renderAssignModeLabel = React.useCallback(
370
413
  (mode: AssignMode) => {
371
- const modeText = mode === 'default' ? t('Default value') : t('Fixed value');
414
+ const modeText =
415
+ mode === 'default' ? t('Default value') : mode === 'override' ? t('Override value') : t('Fixed value');
372
416
  const modeHelpText =
373
417
  mode === 'default'
374
418
  ? t(
375
419
  'A pre-filled value. Editable, for new entries only, and won’t affect existing data (including empty values).',
376
420
  )
377
- : t('A system-set value. Read-only.');
421
+ : mode === 'override'
422
+ ? t('Overrides existing values until the user edits it.')
423
+ : t('A system-set value. Read-only.');
378
424
 
379
425
  const preventModeToggle = (e: React.MouseEvent<HTMLElement>) => {
380
426
  e.preventDefault();
@@ -627,7 +673,8 @@ export const FieldAssignRulesEditor: React.FC<FieldAssignRulesEditorProps> = (pr
627
673
 
628
674
  const renderPanelHeader = (item: FieldAssignRuleItem, index: number) => {
629
675
  const mode = getEffectiveMode(item);
630
- const modeText = mode === 'default' ? t('Default value') : t('Fixed value');
676
+ const modeText =
677
+ mode === 'default' ? t('Default value') : mode === 'override' ? t('Override value') : t('Fixed value');
631
678
  const fieldLabel = getFieldLabel(item.targetPath);
632
679
  const title = fieldLabel ? String(fieldLabel) : t('Please select field');
633
680
 
@@ -935,8 +982,11 @@ export const FieldAssignRulesEditor: React.FC<FieldAssignRulesEditorProps> = (pr
935
982
  onChange={(event) => patchItem(index, { mode: event.target.value as AssignMode })}
936
983
  >
937
984
  <Space size={16}>
938
- <Radio value="default">{renderAssignModeLabel('default')}</Radio>
939
- <Radio value="assign">{renderAssignModeLabel('assign')}</Radio>
985
+ {allowedModes.map((allowedMode) => (
986
+ <Radio key={allowedMode} value={allowedMode}>
987
+ {renderAssignModeLabel(allowedMode)}
988
+ </Radio>
989
+ ))}
940
990
  </Space>
941
991
  </Radio.Group>
942
992
  </div>
@@ -28,6 +28,13 @@ type FlowRouteGuardState = {
28
28
 
29
29
  export type LegacyPageBehavior = 'redirect' | 'notFound' | 'bridge';
30
30
 
31
+ type FlowRouteLayoutContext = {
32
+ authCheck?: boolean;
33
+ routePath?: string;
34
+ routeName?: string;
35
+ uid?: string;
36
+ };
37
+
31
38
  export type FlowRouteProps = {
32
39
  pageUid?: string;
33
40
  active?: boolean;
@@ -38,7 +45,7 @@ export type FlowRouteProps = {
38
45
  const getDefaultAdminLayoutModel = (flowEngine: FlowEngine) =>
39
46
  getAdminLayoutModel<AdminLayoutModel>(flowEngine, { required: true });
40
47
 
41
- const getDefaultLayoutModel = (flowEngine: FlowEngine, contextLayout?: any) => {
48
+ const getDefaultLayoutModel = (flowEngine: FlowEngine, contextLayout?: FlowRouteLayoutContext) => {
42
49
  const layout = contextLayout || flowEngine.context.layout;
43
50
 
44
51
  if (layout?.uid) {
@@ -48,7 +55,10 @@ const getDefaultLayoutModel = (flowEngine: FlowEngine, contextLayout?: any) => {
48
55
  return getDefaultAdminLayoutModel(flowEngine);
49
56
  };
50
57
 
51
- const getDefaultLegacyPageBehavior = (flowEngine: FlowEngine, contextLayout?: any): LegacyPageBehavior => {
58
+ const getDefaultLegacyPageBehavior = (
59
+ flowEngine: FlowEngine,
60
+ contextLayout?: FlowRouteLayoutContext,
61
+ ): LegacyPageBehavior => {
52
62
  const layout = contextLayout || flowEngine.context.layout;
53
63
 
54
64
  if (layout?.routeName && layout.routeName !== 'admin') {
@@ -58,6 +68,8 @@ const getDefaultLegacyPageBehavior = (flowEngine: FlowEngine, contextLayout?: an
58
68
  return 'redirect';
59
69
  };
60
70
 
71
+ const shouldRequireAccessibleRoute = (contextLayout?: FlowRouteLayoutContext) => contextLayout?.authCheck !== false;
72
+
61
73
  const hasFlowModel = async (flowEngine: FlowEngine, pageUid: string) => {
62
74
  if (flowEngine.getModel(pageUid)) {
63
75
  return true;
@@ -148,18 +160,46 @@ const BridgeFlowRoute = ({
148
160
  * @throws {Error} 当缺少 `route.params.name` 时抛出异常
149
161
  */
150
162
  const FlowRoute = (props: FlowRouteProps = {}) => {
163
+ const {
164
+ active,
165
+ getLayoutModel: getLayoutModelProp,
166
+ legacyPageBehavior: legacyPageBehaviorProp,
167
+ pageUid: pageUidProp,
168
+ } = props;
151
169
  const flowEngine = useFlowEngine();
152
- const flowContext = useFlowContext<any>();
170
+ const flowContext = useFlowContext<{ layout?: FlowRouteLayoutContext }>();
153
171
  const contextLayout = flowContext?.layout;
172
+ const propsLayoutModel = useMemo(() => getLayoutModelProp?.(flowEngine), [flowEngine, getLayoutModelProp]);
173
+ const rawRouteLayout = contextLayout || propsLayoutModel?.layout;
174
+ const routeLayoutUid = rawRouteLayout?.uid;
175
+ const routeLayoutRouteName = rawRouteLayout?.routeName;
176
+ const routeLayoutRoutePath = rawRouteLayout?.routePath;
177
+ const routeLayoutAuthCheck = rawRouteLayout?.authCheck;
178
+ const routeLayout = useMemo(() => {
179
+ if (
180
+ !routeLayoutUid &&
181
+ !routeLayoutRouteName &&
182
+ !routeLayoutRoutePath &&
183
+ typeof routeLayoutAuthCheck === 'undefined'
184
+ ) {
185
+ return undefined;
186
+ }
187
+ return {
188
+ authCheck: routeLayoutAuthCheck,
189
+ routeName: routeLayoutRouteName,
190
+ routePath: routeLayoutRoutePath,
191
+ uid: routeLayoutUid,
192
+ };
193
+ }, [routeLayoutAuthCheck, routeLayoutRouteName, routeLayoutRoutePath, routeLayoutUid]);
154
194
  const getLayoutModel = useMemo(
155
- () => props.getLayoutModel || ((engine: FlowEngine) => getDefaultLayoutModel(engine, contextLayout)),
156
- [contextLayout, props.getLayoutModel],
195
+ () => getLayoutModelProp || ((engine: FlowEngine) => getDefaultLayoutModel(engine, routeLayout)),
196
+ [getLayoutModelProp, routeLayout],
157
197
  );
158
- const legacyPageBehavior = props.legacyPageBehavior || getDefaultLegacyPageBehavior(flowEngine, contextLayout);
198
+ const legacyPageBehavior = legacyPageBehaviorProp || getDefaultLegacyPageBehavior(flowEngine, routeLayout);
159
199
  const app = useApp();
160
200
  const routeRepository = flowEngine.context.routeRepository;
161
201
  const params = useParams();
162
- const pageUid = props.pageUid || params?.name;
202
+ const pageUid = pageUidProp || params?.name;
163
203
  const skipRouteRepositoryCheck = !routeRepository;
164
204
  const [guardState, setGuardState] = useState<FlowRouteGuardState>({
165
205
  pending: true,
@@ -196,6 +236,11 @@ const FlowRoute = (props: FlowRouteProps = {}) => {
196
236
  }
197
237
 
198
238
  const route = skipRouteRepositoryCheck ? undefined : routeRepository?.getRouteBySchemaUid?.(pageUid);
239
+ if (!route && !skipRouteRepositoryCheck && shouldRequireAccessibleRoute(routeLayout)) {
240
+ setGuardState({ pending: false, allowBridge: false, notFound: true });
241
+ return;
242
+ }
243
+
199
244
  if (!route && legacyPageBehavior === 'notFound') {
200
245
  const flowModelExists = await hasFlowModel(flowEngine, pageUid);
201
246
  if (active && requestId === requestIdRef.current) {
@@ -245,12 +290,12 @@ const FlowRoute = (props: FlowRouteProps = {}) => {
245
290
  }
246
291
  };
247
292
 
248
- void run();
293
+ run();
249
294
 
250
295
  return () => {
251
296
  active = false;
252
297
  };
253
- }, [app, flowEngine, legacyPageBehavior, pageUid, routeRepository, skipRouteRepositoryCheck]);
298
+ }, [app, flowEngine, legacyPageBehavior, pageUid, routeLayout, routeRepository, skipRouteRepositoryCheck]);
254
299
 
255
300
  const content = useMemo(() => {
256
301
  if (guardState.pending) {
@@ -265,8 +310,8 @@ const FlowRoute = (props: FlowRouteProps = {}) => {
265
310
  return null;
266
311
  }
267
312
 
268
- return <BridgeFlowRoute pageUid={pageUid} active={props.active} getLayoutModel={getLayoutModel} />;
269
- }, [getLayoutModel, guardState.allowBridge, guardState.notFound, guardState.pending, pageUid, props.active]);
313
+ return <BridgeFlowRoute pageUid={pageUid} active={active} getLayoutModel={getLayoutModel} />;
314
+ }, [active, getLayoutModel, guardState.allowBridge, guardState.notFound, guardState.pending, pageUid]);
270
315
 
271
316
  return content;
272
317
  };
@@ -10,19 +10,24 @@
10
10
  import React from 'react';
11
11
  import { beforeEach, describe, expect, it, vi } from 'vitest';
12
12
  import { render, screen, userEvent, waitFor } from '@nocobase/test/client';
13
- import { ActionScene, FlowEngine, FlowModel, GLOBAL_EMBED_CONTAINER_ID } from '@nocobase/flow-engine';
13
+ import { ActionScene, FlowEngine, FlowModel, GLOBAL_EMBED_CONTAINER_ID, useFlowContext } from '@nocobase/flow-engine';
14
14
  import { DynamicFlowsIcon } from '../DynamicFlowsIcon';
15
15
 
16
16
  const mockState = vi.hoisted(() => ({
17
17
  capturedSelectProps: [] as any[],
18
+ capturedTabsProps: [] as any[],
18
19
  flowContextValue: undefined as any,
19
20
  }));
20
21
 
21
22
  vi.mock('@nocobase/flow-engine', async (importOriginal) => {
22
23
  const actual = await importOriginal<typeof import('@nocobase/flow-engine')>();
24
+ const React = await import('react');
23
25
  return {
24
26
  ...actual,
25
- useFlowContext: () => mockState.flowContextValue,
27
+ useFlowContext: () => {
28
+ const context = React.useContext(actual.FlowReactContext);
29
+ return context?.view ? context : mockState.flowContextValue;
30
+ },
26
31
  };
27
32
  });
28
33
 
@@ -62,6 +67,33 @@ vi.mock('antd', async (importOriginal) => {
62
67
  return <div data-testid={String(props.placeholder || 'select')} />;
63
68
  },
64
69
  Space: ({ children }: any) => <div>{children}</div>,
70
+ Tabs: (props: any) => {
71
+ mockState.capturedTabsProps.push(props);
72
+ const { items, activeKey, onChange, tabBarStyle } = props;
73
+ const currentKey = activeKey || items?.[0]?.key;
74
+ return (
75
+ <div data-testid="dynamic-flow-source-tabs">
76
+ <div role="tablist" style={tabBarStyle}>
77
+ {items?.map((item: any) => (
78
+ <button
79
+ key={item.key}
80
+ role="tab"
81
+ aria-selected={item.key === currentKey}
82
+ type="button"
83
+ onClick={() => onChange?.(item.key)}
84
+ >
85
+ {item.label}
86
+ </button>
87
+ ))}
88
+ </div>
89
+ {items?.map((item: any) => (
90
+ <div key={item.key} hidden={item.key !== currentKey}>
91
+ {item.children}
92
+ </div>
93
+ ))}
94
+ </div>
95
+ );
96
+ },
65
97
  Tooltip: ({ children }: any) => <>{children}</>,
66
98
  };
67
99
  });
@@ -83,6 +115,8 @@ const openDynamicFlowsEditor = async (model: FlowModel) => {
83
115
  expect(trigger).not.toBeNull();
84
116
  await userEvent.click(trigger as Element);
85
117
 
118
+ await waitFor(() => expect(model.context.viewer.embed).toHaveBeenCalled());
119
+
86
120
  const embedCall = (model.context.viewer.embed as any).mock.calls.at(-1)?.[0];
87
121
  expect(embedCall?.content).toBeTruthy();
88
122
 
@@ -163,6 +197,38 @@ const createModel = (options: { preventClose?: boolean; hiddenClose?: boolean; s
163
197
  return model;
164
198
  };
165
199
 
200
+ const createPeerModel = (model: FlowModel, uid: string, saveStepParams = vi.fn(async () => undefined)) => {
201
+ const PeerModel = model.constructor as typeof FlowModel;
202
+ const peer = new PeerModel({
203
+ uid,
204
+ use: PeerModel,
205
+ flowEngine: model.flowEngine,
206
+ stepParams: {},
207
+ subModels: {},
208
+ flowRegistry: {
209
+ flow1: {
210
+ title: 'Template event flow',
211
+ on: 'click',
212
+ steps: {
213
+ notifyStep: {
214
+ use: 'notify',
215
+ },
216
+ },
217
+ },
218
+ },
219
+ } as any);
220
+
221
+ peer.context.defineProperty('message', {
222
+ value: {
223
+ error: vi.fn(),
224
+ success: vi.fn(),
225
+ },
226
+ });
227
+ vi.spyOn(peer, 'saveStepParams').mockImplementation(saveStepParams);
228
+
229
+ return peer;
230
+ };
231
+
166
232
  const createView = () => {
167
233
  let destroyed = false;
168
234
  let closingPromise: Promise<boolean | void> | undefined;
@@ -189,6 +255,9 @@ const createView = () => {
189
255
  destroyed = true;
190
256
  }),
191
257
  beforeClose: undefined as any,
258
+ inputArgs: {
259
+ pageActive: true,
260
+ },
192
261
  };
193
262
  return view;
194
263
  };
@@ -196,6 +265,7 @@ const createView = () => {
196
265
  describe('DynamicFlowsIcon', () => {
197
266
  beforeEach(() => {
198
267
  mockState.capturedSelectProps.length = 0;
268
+ mockState.capturedTabsProps.length = 0;
199
269
  mockState.flowContextValue = {
200
270
  modal: {
201
271
  confirm: vi.fn(),
@@ -377,4 +447,80 @@ describe('DynamicFlowsIcon', () => {
377
447
  expect(view.destroy).toHaveBeenCalledTimes(1);
378
448
  expect(model.context.message.success).toHaveBeenCalledWith('Configuration saved');
379
449
  });
450
+
451
+ it('renders source tabs and closes the editor when saving one source', async () => {
452
+ const model = createModel();
453
+ const targetSaveStepParams = vi.fn(async () => undefined);
454
+ const target = createPeerModel(model, 'target-model', targetSaveStepParams);
455
+
456
+ model.flowEngine.flowSettings.registerDynamicFlowSourceProvider({
457
+ key: 'test-source-provider',
458
+ getSources: () => [{ key: 'referenced-template', label: 'Referenced template', model: target }],
459
+ });
460
+
461
+ const { view } = await openDynamicFlowsEditor(model);
462
+
463
+ expect(screen.getByRole('tab', { name: 'Current block' })).toHaveAttribute('aria-selected', 'true');
464
+ expect(mockState.capturedTabsProps.at(-1)?.tabBarStyle?.paddingLeft).toBeGreaterThan(0);
465
+ await userEvent.click(screen.getByRole('tab', { name: 'Referenced template' }));
466
+
467
+ await userEvent.click(screen.getByRole('button', { name: 'Add event flow' }));
468
+ await userEvent.click(screen.getByRole('button', { name: 'Save' }));
469
+
470
+ expect(targetSaveStepParams).toHaveBeenCalledTimes(1);
471
+ expect(target.flowRegistry.getFlows().size).toBe(2);
472
+ expect(view.destroy).toHaveBeenCalledTimes(1);
473
+ expect(target.context.message.success).toHaveBeenCalledWith('Configuration saved');
474
+ });
475
+
476
+ it('saves all dirty sources without discard confirmation', async () => {
477
+ const saveStepParams = vi.fn(async () => undefined);
478
+ const model = createModel({ saveStepParams });
479
+ const targetSaveStepParams = vi.fn(async () => undefined);
480
+ const target = createPeerModel(model, 'target-model', targetSaveStepParams);
481
+ mockState.flowContextValue.modal.confirm.mockResolvedValue(false);
482
+
483
+ model.flowEngine.flowSettings.registerDynamicFlowSourceProvider({
484
+ key: 'test-source-unsaved-provider',
485
+ getSources: () => [{ key: 'referenced-template', label: 'Referenced template', model: target }],
486
+ });
487
+
488
+ const { view } = await openDynamicFlowsEditor(model);
489
+
490
+ await userEvent.click(screen.getByRole('tab', { name: 'Referenced template' }));
491
+ await userEvent.click(screen.getByRole('button', { name: 'Add event flow' }));
492
+ await userEvent.click(screen.getByRole('tab', { name: 'Current block' }));
493
+ await userEvent.click(screen.getByRole('button', { name: 'Add event flow' }));
494
+ await userEvent.click(screen.getByRole('button', { name: 'Save' }));
495
+
496
+ expect(saveStepParams).toHaveBeenCalledTimes(1);
497
+ expect(targetSaveStepParams).toHaveBeenCalledTimes(1);
498
+ expect(mockState.flowContextValue.modal.confirm).not.toHaveBeenCalled();
499
+ expect(view.close).toHaveBeenCalledTimes(1);
500
+ expect(view.destroy).toHaveBeenCalledTimes(1);
501
+ expect(model.flowRegistry.getFlows().size).toBe(2);
502
+ expect(target.flowRegistry.getFlows().size).toBe(2);
503
+ });
504
+
505
+ it('provides the active source model context inside source tabs', async () => {
506
+ const model = createModel();
507
+ const target = createPeerModel(model, 'target-model');
508
+ const seenModelUids: string[] = [];
509
+ const ContextProbe = () => {
510
+ const ctx = useFlowContext<any>();
511
+ seenModelUids.push(ctx?.model?.uid);
512
+ return null;
513
+ };
514
+ model.flowEngine.flowSettings.renderStepForm = vi.fn(() => <ContextProbe />) as any;
515
+
516
+ model.flowEngine.flowSettings.registerDynamicFlowSourceProvider({
517
+ key: 'test-source-context-provider',
518
+ getSources: () => [{ key: 'referenced-template', label: 'Referenced template', model: target }],
519
+ });
520
+
521
+ await openDynamicFlowsEditor(model);
522
+ await userEvent.click(screen.getByRole('tab', { name: 'Referenced template' }));
523
+
524
+ await waitFor(() => expect(seenModelUids).toContain('target-model'));
525
+ });
380
526
  });
@@ -795,15 +795,23 @@ describe('FieldAssignRulesEditor', () => {
795
795
  );
796
796
 
797
797
  expect(screen.getByLabelText('Default value')).toBeInTheDocument();
798
+ expect(screen.getByLabelText('Override value')).toBeInTheDocument();
798
799
  expect(screen.getByLabelText('Fixed value')).toBeInTheDocument();
799
800
  expect(screen.getByLabelText('Fixed value')).toBeChecked();
800
801
  await userEvent.click(screen.getByLabelText('Default value'));
801
802
  await waitFor(() => {
802
803
  expect(onChange).toHaveBeenCalled();
803
804
  });
804
-
805
- const lastCall = onChange.mock.calls[onChange.mock.calls.length - 1]?.[0] as FieldAssignRuleItem[];
805
+ let lastCall = onChange.mock.calls[onChange.mock.calls.length - 1]?.[0] as FieldAssignRuleItem[];
806
806
  expect(lastCall?.[0]?.mode).toBe('default');
807
+
808
+ onChange.mockClear();
809
+ await userEvent.click(screen.getByLabelText('Override value'));
810
+ await waitFor(() => {
811
+ expect(onChange).toHaveBeenCalled();
812
+ });
813
+ lastCall = onChange.mock.calls[onChange.mock.calls.length - 1]?.[0] as FieldAssignRuleItem[];
814
+ expect(lastCall?.[0]?.mode).toBe('override');
807
815
  });
808
816
 
809
817
  it('shows assignment mode tooltips for each option', async () => {
@@ -821,7 +829,7 @@ describe('FieldAssignRulesEditor', () => {
821
829
  );
822
830
 
823
831
  const questionIcons = Array.from(container.querySelectorAll('.anticon-question-circle'));
824
- expect(questionIcons).toHaveLength(2);
832
+ expect(questionIcons).toHaveLength(3);
825
833
 
826
834
  await userEvent.hover(questionIcons[0] as Element);
827
835
  await waitFor(() => {
@@ -835,11 +843,80 @@ describe('FieldAssignRulesEditor', () => {
835
843
  await userEvent.unhover(questionIcons[0] as Element);
836
844
 
837
845
  await userEvent.hover(questionIcons[1] as Element);
846
+ await waitFor(() => {
847
+ expect(screen.getByText('Overrides existing values until the user edits it.')).toBeInTheDocument();
848
+ });
849
+
850
+ await userEvent.unhover(questionIcons[1] as Element);
851
+
852
+ await userEvent.hover(questionIcons[2] as Element);
838
853
  await waitFor(() => {
839
854
  expect(screen.getByText('A system-set value. Read-only.')).toBeInTheDocument();
840
855
  });
841
856
  });
842
857
 
858
+ it('honors allowed assignment modes', () => {
859
+ const value: FieldAssignRuleItem[] = [
860
+ {
861
+ key: '1',
862
+ enable: true,
863
+ targetPath: 'title',
864
+ mode: 'override',
865
+ },
866
+ ];
867
+
868
+ render(
869
+ wrap(
870
+ <FieldAssignRulesEditor
871
+ t={t}
872
+ fieldOptions={[]}
873
+ value={value}
874
+ allowedModes={['default', 'assign']}
875
+ showCondition={false}
876
+ showEnable={false}
877
+ />,
878
+ ),
879
+ );
880
+
881
+ expect(screen.getByLabelText('Default value')).toBeInTheDocument();
882
+ expect(screen.getByLabelText('Fixed value')).toBeInTheDocument();
883
+ expect(screen.queryByLabelText('Override value')).toBeNull();
884
+ expect(screen.getByLabelText('Fixed value')).toBeChecked();
885
+ });
886
+
887
+ it('normalizes disallowed assignment mode before emitting changes', async () => {
888
+ const onChange = vi.fn();
889
+ const value: FieldAssignRuleItem[] = [
890
+ {
891
+ key: '1',
892
+ enable: true,
893
+ targetPath: 'title',
894
+ mode: 'override',
895
+ },
896
+ ];
897
+
898
+ render(
899
+ wrap(
900
+ <FieldAssignRulesEditor
901
+ t={t}
902
+ fieldOptions={[]}
903
+ value={value}
904
+ onChange={onChange}
905
+ allowedModes={['default', 'assign']}
906
+ showCondition={false}
907
+ />,
908
+ ),
909
+ );
910
+
911
+ await userEvent.click(screen.getByRole('switch'));
912
+
913
+ await waitFor(() => {
914
+ expect(onChange).toHaveBeenCalled();
915
+ });
916
+ const lastCall = onChange.mock.calls[onChange.mock.calls.length - 1]?.[0] as FieldAssignRuleItem[];
917
+ expect(lastCall?.[0]?.mode).toBe('assign');
918
+ });
919
+
843
920
  it('merges extra item tree into base item while keeping order', () => {
844
921
  const base: MetaTreeNode[] = [
845
922
  { name: 'formValues', title: 'Current form', type: 'object', paths: ['formValues'] },
@@ -897,7 +974,7 @@ describe('FieldAssignRulesEditor', () => {
897
974
 
898
975
  const itemNode = merged.find((node) => node.name === 'item');
899
976
  expect(itemNode).toBeTruthy();
900
- const parent = (itemNode.children as MetaTreeNode[]).find((node) => node.name === 'parentItem');
977
+ const parent = ((itemNode?.children as MetaTreeNode[]) || []).find((node) => node.name === 'parentItem');
901
978
  expect(parent).toBeTruthy();
902
979
 
903
980
  const nestedParent = ((parent?.children as MetaTreeNode[]) || []).find((node) => node.name === 'parentItem');
@@ -20,8 +20,6 @@ import {
20
20
  FlowEngineProvider,
21
21
  FlowModelRenderer,
22
22
  ElementProxy,
23
- createSafeWindow,
24
- createSafeDocument,
25
23
  createViewScopedEngine,
26
24
  } from '@nocobase/flow-engine';
27
25
  import { JSEditableFieldModel } from '../../../models/fields/JSEditableFieldModel';
@@ -167,11 +165,7 @@ describe('useCodeRunner (beforeRender)', () => {
167
165
  const code = ctx?.inputArgs?.preview?.code || '';
168
166
  ctx.onRefReady(ctx.ref, async (el) => {
169
167
  ctx.defineProperty('element', { get: () => new ElementProxy(el as any) });
170
- await ctx.runjs(
171
- code,
172
- { window: createSafeWindow(), document: createSafeDocument() },
173
- { preprocessTemplates: true },
174
- );
168
+ await ctx.runjs(code, undefined, { preprocessTemplates: true });
175
169
  });
176
170
  },
177
171
  },
@@ -286,16 +280,7 @@ return currentUsername;
286
280
  const code = ctx?.inputArgs?.preview?.code || '';
287
281
  ctx.onRefReady(ctx.ref, async (el) => {
288
282
  ctx.defineProperty('element', { get: () => new ElementProxy(el as any) });
289
- const navigator = { userAgent: 'test' } as any;
290
- await ctx.runjs(
291
- code,
292
- {
293
- window: createSafeWindow({ navigator }),
294
- document: createSafeDocument(),
295
- navigator,
296
- },
297
- { preprocessTemplates: true },
298
- );
283
+ await ctx.runjs(code, undefined, { preprocessTemplates: true });
299
284
  });
300
285
  },
301
286
  },