@nocobase/client-v2 2.2.0-alpha.1 → 2.2.0-alpha.3

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 (220) hide show
  1. package/es/PluginSettingsManager.d.ts +33 -0
  2. package/es/RouteRepository.d.ts +21 -6
  3. package/es/components/category-tabs/SortableCategoryTabs.d.ts +55 -0
  4. package/es/components/category-tabs/index.d.ts +9 -0
  5. package/es/components/form/JsonTextArea.d.ts +2 -1
  6. package/es/components/form/TypedVariableInput.d.ts +22 -4
  7. package/es/components/form/VariableJsonTextArea.d.ts +19 -0
  8. package/es/components/form/filter/CollectionFilterItem.d.ts +11 -1
  9. package/es/components/form/filter/index.d.ts +2 -0
  10. package/es/components/form/index.d.ts +1 -0
  11. package/es/components/index.d.ts +2 -0
  12. package/es/flow/actions/afterSuccess.d.ts +8 -1
  13. package/es/flow/actions/index.d.ts +1 -1
  14. package/es/flow/admin-shell/admin-layout/AdminLayoutEntryGuard.d.ts +2 -0
  15. package/es/flow/admin-shell/admin-layout/AdminLayoutMenuFlowUtils.d.ts +2 -1
  16. package/es/flow/admin-shell/admin-layout/AdminLayoutMenuModels.d.ts +2 -0
  17. package/es/flow/admin-shell/admin-layout/AdminLayoutMenuUtils.d.ts +3 -2
  18. package/es/flow/admin-shell/admin-layout/AdminLayoutSlotModels.d.ts +5 -0
  19. package/es/flow/admin-shell/admin-layout/resolveAdminRouteRuntimeTarget.d.ts +6 -0
  20. package/es/flow/components/FieldAssignRulesEditor.d.ts +3 -1
  21. package/es/flow/components/FieldAssignValueInput.d.ts +2 -0
  22. package/es/flow/index.d.ts +1 -0
  23. package/es/flow/models/base/GridModel.d.ts +1 -1
  24. package/es/flow/models/blocks/filter-form/FilterFormBlockModel.d.ts +5 -0
  25. package/es/flow/models/blocks/form/FormBlockModel.d.ts +4 -0
  26. package/es/flow/models/blocks/form/submitHandler.d.ts +1 -1
  27. package/es/flow/models/blocks/form/value-runtime/rules.d.ts +21 -0
  28. package/es/flow/models/blocks/form/value-runtime/runtime.d.ts +30 -0
  29. package/es/flow/models/blocks/form/value-runtime/types.d.ts +2 -2
  30. package/es/flow/models/blocks/js-block/JSBlock.d.ts +1 -0
  31. package/es/flow/models/blocks/table/TableActionsColumnModel.d.ts +1 -0
  32. package/es/flow/models/blocks/table/TableBlockModel.d.ts +1 -0
  33. package/es/flow/models/blocks/table/dragSort/dragSortHooks.d.ts +1 -1
  34. package/es/flow/models/blocks/table/dragSort/dragSortSettings.d.ts +2 -1
  35. package/es/flow/models/blocks/table/dragSort/dragSortUtils.d.ts +6 -4
  36. package/es/flow/models/fields/AssociationFieldModel/RecordSelectFieldModel.d.ts +2 -0
  37. package/es/flow/models/fields/AssociationFieldModel/SubTableFieldModel/SubTableColumnModel.d.ts +6 -0
  38. package/es/flow/resolveViewParamsToViewList.d.ts +1 -1
  39. package/es/flow/utils/dataScopeRowSnapshot.d.ts +59 -0
  40. package/es/flow/utils/dateTimeDisplayProps.d.ts +49 -0
  41. package/es/flow/utils/formValueDeps.d.ts +32 -0
  42. package/es/flow-compat/FieldValidation.d.ts +2 -1
  43. package/es/index.d.ts +2 -1
  44. package/es/index.mjs +233 -137
  45. package/lib/index.js +234 -138
  46. package/package.json +7 -7
  47. package/src/PluginSettingsManager.ts +53 -0
  48. package/src/RouteRepository.ts +126 -24
  49. package/src/__tests__/PluginSettingsManager.test.ts +13 -0
  50. package/src/__tests__/RouteRepository.test.ts +216 -0
  51. package/src/__tests__/browserChecker.test.ts +61 -0
  52. package/src/__tests__/exports.test.ts +16 -0
  53. package/src/__tests__/nocobase-buildin-plugin-auth.test.tsx +199 -0
  54. package/src/__tests__/plugin-manager.test.tsx +44 -2
  55. package/src/__tests__/settings-center.test.tsx +40 -1
  56. package/src/collection-manager/field-configure.ts +1 -1
  57. package/src/collection-manager/interfaces/id.ts +1 -1
  58. package/src/collection-manager/interfaces/m2m.tsx +2 -2
  59. package/src/collection-manager/interfaces/m2o.tsx +2 -2
  60. package/src/collection-manager/interfaces/nanoid.ts +1 -1
  61. package/src/collection-manager/interfaces/o2m.tsx +2 -2
  62. package/src/collection-manager/interfaces/obo.tsx +2 -2
  63. package/src/collection-manager/interfaces/oho.tsx +2 -2
  64. package/src/collection-manager/interfaces/properties/index.ts +2 -2
  65. package/src/collection-manager/interfaces/uuid.ts +1 -1
  66. package/src/components/AppComponents.tsx +19 -3
  67. package/src/components/README.md +7 -1
  68. package/src/components/README.zh-CN.md +6 -1
  69. package/src/components/category-tabs/SortableCategoryTabs.tsx +210 -0
  70. package/src/components/category-tabs/index.ts +10 -0
  71. package/src/components/form/JsonTextArea.tsx +21 -11
  72. package/src/components/form/TypedVariableInput.tsx +416 -93
  73. package/src/components/form/VariableJsonTextArea.tsx +175 -0
  74. package/src/components/form/__tests__/JsonTextArea.test.tsx +43 -0
  75. package/src/components/form/__tests__/TypedVariableInput.test.tsx +268 -9
  76. package/src/components/form/__tests__/VariableJsonTextArea.test.tsx +86 -0
  77. package/src/components/form/filter/CollectionFilterItem.tsx +32 -7
  78. package/src/components/form/filter/__tests__/CollectionFilterItem.test.tsx +38 -0
  79. package/src/components/form/filter/index.ts +2 -0
  80. package/src/components/form/index.tsx +1 -0
  81. package/src/components/index.ts +2 -0
  82. package/src/flow/__tests__/FlowRoute.test.tsx +443 -5
  83. package/src/flow/__tests__/getKey.test.ts +7 -0
  84. package/src/flow/__tests__/resolveViewParamsToViewList.test.ts +34 -0
  85. package/src/flow/actions/__tests__/afterSuccess.test.ts +73 -0
  86. package/src/flow/actions/__tests__/dataScopeFilter.test.ts +58 -0
  87. package/src/flow/actions/__tests__/dataScopeFormValueClear.test.ts +1022 -0
  88. package/src/flow/actions/__tests__/fieldLinkageRules.scopeDepth.test.ts +150 -1
  89. package/src/flow/actions/__tests__/linkageAssignField.legacy.test.ts +135 -0
  90. package/src/flow/actions/__tests__/linkageRules.hiddenSync.restore.test.ts +19 -5
  91. package/src/flow/actions/__tests__/linkageRules.subFormSetFieldProps.test.ts +92 -0
  92. package/src/flow/actions/__tests__/openView.defineProps.route.test.tsx +104 -0
  93. package/src/flow/actions/__tests__/subFormFieldLinkageRules.inputArgs.test.ts +2 -2
  94. package/src/flow/actions/afterSuccess.tsx +142 -3
  95. package/src/flow/actions/customVariable.tsx +1 -2
  96. package/src/flow/actions/dataScopeFilter.ts +10 -0
  97. package/src/flow/actions/dateTimeFormat.tsx +42 -28
  98. package/src/flow/actions/index.ts +1 -1
  99. package/src/flow/actions/linkageRules.tsx +123 -35
  100. package/src/flow/actions/linkageRulesFormValueRefresh.ts +22 -130
  101. package/src/flow/actions/openView.tsx +38 -4
  102. package/src/flow/actions/runjs.tsx +2 -14
  103. package/src/flow/actions/validation.tsx +62 -30
  104. package/src/flow/admin-shell/BaseLayoutModel.tsx +25 -3
  105. package/src/flow/admin-shell/BaseLayoutRouteCoordinator.ts +5 -2
  106. package/src/flow/admin-shell/__tests__/AdminLayoutRouteCoordinator.test.ts +13 -0
  107. package/src/flow/admin-shell/admin-layout/AdminLayoutComponent.tsx +34 -10
  108. package/src/flow/admin-shell/admin-layout/AdminLayoutEntryGuard.test.tsx +310 -0
  109. package/src/flow/admin-shell/admin-layout/AdminLayoutEntryGuard.tsx +44 -8
  110. package/src/flow/admin-shell/admin-layout/AdminLayoutMenuFlowUtils.ts +5 -3
  111. package/src/flow/admin-shell/admin-layout/AdminLayoutMenuModels.tsx +61 -9
  112. package/src/flow/admin-shell/admin-layout/AdminLayoutMenuUtils.tsx +71 -8
  113. package/src/flow/admin-shell/admin-layout/AdminLayoutModel.tsx +1 -1
  114. package/src/flow/admin-shell/admin-layout/AdminLayoutSlotModels.tsx +23 -9
  115. package/src/flow/admin-shell/admin-layout/HelpLite.tsx +1 -3
  116. package/src/flow/admin-shell/admin-layout/__tests__/AdminLayoutComponent.test.tsx +188 -0
  117. package/src/flow/admin-shell/admin-layout/__tests__/AdminLayoutMenuModels.test.ts +99 -0
  118. package/src/flow/admin-shell/admin-layout/__tests__/AdminLayoutModel.test.tsx +82 -0
  119. package/src/flow/admin-shell/admin-layout/__tests__/TopbarActionsBar.test.tsx +166 -2
  120. package/src/flow/admin-shell/admin-layout/resolveAdminRouteRuntimeTarget.test.ts +20 -0
  121. package/src/flow/admin-shell/admin-layout/resolveAdminRouteRuntimeTarget.ts +33 -5
  122. package/src/flow/components/DefaultValue.tsx +1 -2
  123. package/src/flow/components/DynamicFlowsIcon.tsx +447 -126
  124. package/src/flow/components/FieldAssignRulesEditor.tsx +128 -21
  125. package/src/flow/components/FieldAssignValueInput.tsx +10 -5
  126. package/src/flow/components/FlowRoute.tsx +56 -11
  127. package/src/flow/components/__tests__/DynamicFlowsIcon.test.tsx +148 -2
  128. package/src/flow/components/__tests__/FieldAssignRulesEditor.test.tsx +508 -4
  129. package/src/flow/components/__tests__/fieldAssignOptions.test.ts +151 -3
  130. package/src/flow/components/code-editor/__tests__/useCodeRunner.test.tsx +2 -17
  131. package/src/flow/components/code-editor/hooks/useCodeRunner.ts +2 -14
  132. package/src/flow/components/code-editor/runjsDiagnostics.ts +8 -45
  133. package/src/flow/components/fieldAssignOptions.ts +155 -27
  134. package/src/flow/flows/editMarkdownFlow.tsx +1 -1
  135. package/src/flow/getViewDiffAndUpdateHidden.tsx +1 -0
  136. package/src/flow/index.ts +2 -1
  137. package/src/flow/internal/utils/operatorSchemaHelper.ts +15 -1
  138. package/src/flow/models/actions/JSActionModel.tsx +2 -7
  139. package/src/flow/models/actions/JSCollectionActionModel.tsx +2 -7
  140. package/src/flow/models/actions/JSItemActionModel.tsx +2 -7
  141. package/src/flow/models/actions/JSRecordActionModel.tsx +2 -7
  142. package/src/flow/models/base/ActionModel.tsx +12 -1
  143. package/src/flow/models/base/ActionModelCore.tsx +6 -4
  144. package/src/flow/models/base/GridModel.tsx +38 -7
  145. package/src/flow/models/base/PageModel/PageModel.tsx +4 -1
  146. package/src/flow/models/base/PageModel/__tests__/PageModel.test.ts +28 -4
  147. package/src/flow/models/base/__tests__/ActionModel.test.ts +83 -0
  148. package/src/flow/models/base/__tests__/ActionModelCore.render.test.tsx +37 -0
  149. package/src/flow/models/base/__tests__/GridModel.dragSnapshotContainer.test.ts +239 -1
  150. package/src/flow/models/base/__tests__/transformRowsToSingleColumn.test.ts +48 -0
  151. package/src/flow/models/blocks/filter-form/FilterFormBlockModel.tsx +31 -3
  152. package/src/flow/models/blocks/filter-form/FilterFormJSActionModel.tsx +2 -7
  153. package/src/flow/models/blocks/filter-form/__tests__/defaultValues.wiring.test.ts +77 -0
  154. package/src/flow/models/blocks/filter-form/fields/FilterFormCustomFieldModel.tsx +1 -1
  155. package/src/flow/models/blocks/filter-manager/flow-actions/__tests__/customizeFilterRender.test.tsx +56 -1
  156. package/src/flow/models/blocks/form/EditFormModel.tsx +1 -0
  157. package/src/flow/models/blocks/form/FormActionModel.tsx +1 -1
  158. package/src/flow/models/blocks/form/FormBlockModel.tsx +7 -0
  159. package/src/flow/models/blocks/form/JSFormActionModel.tsx +2 -7
  160. package/src/flow/models/blocks/form/__tests__/FormBlockModel.test.tsx +17 -0
  161. package/src/flow/models/blocks/form/__tests__/submitHandler.test.ts +54 -1
  162. package/src/flow/models/blocks/form/submitHandler.ts +17 -3
  163. package/src/flow/models/blocks/form/value-runtime/__tests__/runtime.test.ts +940 -75
  164. package/src/flow/models/blocks/form/value-runtime/rules.ts +445 -13
  165. package/src/flow/models/blocks/form/value-runtime/runtime.ts +348 -13
  166. package/src/flow/models/blocks/form/value-runtime/types.ts +2 -2
  167. package/src/flow/models/blocks/js-block/JSBlock.tsx +225 -9
  168. package/src/flow/models/blocks/js-block/__tests__/JSBlockModel.test.tsx +150 -0
  169. package/src/flow/models/blocks/table/JSColumnModel.tsx +1 -9
  170. package/src/flow/models/blocks/table/TableActionsColumnModel.tsx +36 -13
  171. package/src/flow/models/blocks/table/TableBlockModel.tsx +38 -17
  172. package/src/flow/models/blocks/table/TableColumnModel.tsx +36 -3
  173. package/src/flow/models/blocks/table/__tests__/TableActionsColumnModel.test.tsx +111 -1
  174. package/src/flow/models/blocks/table/__tests__/TableBlockModel.dragSort.test.ts +127 -0
  175. package/src/flow/models/blocks/table/__tests__/TableBlockModel.mobileSettingsButtons.test.tsx +78 -0
  176. package/src/flow/models/blocks/table/__tests__/TableBlockModel.rowSelection.test.tsx +1 -0
  177. package/src/flow/models/blocks/table/__tests__/TableColumnModel.test.tsx +159 -1
  178. package/src/flow/models/blocks/table/dragSort/dragSortHooks.tsx +28 -17
  179. package/src/flow/models/blocks/table/dragSort/dragSortSettings.ts +13 -6
  180. package/src/flow/models/blocks/table/dragSort/dragSortUtils.ts +15 -2
  181. package/src/flow/models/fields/AssociationFieldModel/AssociationFieldModel.tsx +1 -1
  182. package/src/flow/models/fields/AssociationFieldModel/PopupSubTableFieldModel/PopupSubTableFieldModel.tsx +29 -6
  183. package/src/flow/models/fields/AssociationFieldModel/RecordSelectFieldModel.tsx +16 -4
  184. package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/SubTableColumnModel.tsx +141 -19
  185. package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/SubTableField.tsx +13 -1
  186. package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/__tests__/SubTableColumnModel.rowRecord.test.ts +197 -0
  187. package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/__tests__/SubTableFieldModel.reset.test.ts +180 -0
  188. package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/index.tsx +6 -4
  189. package/src/flow/models/fields/AssociationFieldModel/__tests__/AssociationFieldModel.updateAssociation.test.ts +72 -0
  190. package/src/flow/models/fields/AssociationFieldModel/__tests__/RecordPickerFieldModel.itemContext.test.ts +23 -0
  191. package/src/flow/models/fields/ClickableFieldModel.tsx +5 -0
  192. package/src/flow/models/fields/DisplayDateTimeFieldModel.tsx +29 -1
  193. package/src/flow/models/fields/InputFieldModel.tsx +40 -2
  194. package/src/flow/models/fields/JSEditableFieldModel.tsx +2 -11
  195. package/src/flow/models/fields/JSFieldModel.tsx +2 -7
  196. package/src/flow/models/fields/JSItemModel.tsx +2 -14
  197. package/src/flow/models/fields/SelectFieldModel.tsx +6 -4
  198. package/src/flow/models/fields/TextareaFieldModel.tsx +49 -3
  199. package/src/flow/models/fields/__tests__/DisplayDateTimeFieldModel.test.tsx +96 -0
  200. package/src/flow/models/fields/__tests__/InputFieldModel.test.tsx +117 -1
  201. package/src/flow/models/fields/__tests__/SelectFieldModel.test.tsx +43 -0
  202. package/src/flow/models/fields/__tests__/TextareaFieldModel.test.tsx +100 -0
  203. package/src/flow/models/topbar/TopbarActionModel.tsx +93 -10
  204. package/src/flow/resolveViewParamsToViewList.tsx +11 -3
  205. package/src/flow/utils/__tests__/dateTimeFormat.test.ts +258 -0
  206. package/src/flow/utils/dataScopeFormValueClear.ts +218 -81
  207. package/src/flow/utils/dataScopeRowSnapshot.ts +616 -0
  208. package/src/flow/utils/dateTimeDisplayProps.ts +135 -0
  209. package/src/flow/utils/formValueDeps.ts +170 -0
  210. package/src/flow-compat/FieldValidation.tsx +122 -60
  211. package/src/flow-compat/Popover.tsx +43 -4
  212. package/src/flow-compat/__tests__/Popover.test.tsx +34 -0
  213. package/src/index.ts +8 -1
  214. package/src/layout-manager/__tests__/LayoutRoute.test.tsx +41 -1
  215. package/src/nocobase-buildin-plugin/index.tsx +44 -14
  216. package/src/settings-center/AdminSettingsLayout.tsx +10 -2
  217. package/src/settings-center/SystemSettingsPage.tsx +1 -2
  218. package/src/settings-center/plugin-manager/PluginCard.tsx +2 -2
  219. package/src/settings-center/plugin-manager/index.tsx +3 -0
  220. package/src/settings-center/utils.tsx +0 -6
@@ -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,65 @@ export interface FieldAssignRulesEditorProps {
109
111
  maxAssociationFieldDepth?: number;
110
112
  }
111
113
 
114
+ const ALL_ASSIGN_MODES: AssignMode[] = ['default', 'override', 'assign'];
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
+
158
+ function isAssignMode(mode: unknown): mode is AssignMode {
159
+ return mode === 'default' || mode === 'override' || mode === 'assign';
160
+ }
161
+
162
+ function normalizeAssignModes(modes?: AssignMode[]): AssignMode[] {
163
+ const source = Array.isArray(modes) && modes.length ? modes : ALL_ASSIGN_MODES;
164
+ const out: AssignMode[] = [];
165
+ for (const mode of source) {
166
+ if (!isAssignMode(mode)) continue;
167
+ if (out.includes(mode)) continue;
168
+ out.push(mode);
169
+ }
170
+ return out.length ? out : ALL_ASSIGN_MODES;
171
+ }
172
+
112
173
  export const FieldAssignRulesEditor: React.FC<FieldAssignRulesEditorProps> = (props) => {
113
174
  const {
114
175
  t,
@@ -118,6 +179,7 @@ export const FieldAssignRulesEditor: React.FC<FieldAssignRulesEditorProps> = (pr
118
179
  onChange,
119
180
  defaultMode = 'assign',
120
181
  fixedMode,
182
+ allowedModes: rawAllowedModes,
121
183
  showCondition = true,
122
184
  showEnable = true,
123
185
  showValueEditorWhenNoField = false,
@@ -129,13 +191,43 @@ export const FieldAssignRulesEditor: React.FC<FieldAssignRulesEditorProps> = (pr
129
191
  } = props;
130
192
 
131
193
  const value = React.useMemo(() => (Array.isArray(rawValue) ? rawValue : []), [rawValue]);
194
+ const allowedModes = React.useMemo(
195
+ () => normalizeAssignModes(fixedMode ? [fixedMode] : rawAllowedModes),
196
+ [fixedMode, rawAllowedModes],
197
+ );
198
+ const getDefaultMode = React.useCallback((): AssignMode => {
199
+ if (fixedMode) return fixedMode;
200
+ if (isAssignMode(defaultMode) && allowedModes.includes(defaultMode)) return defaultMode;
201
+ return allowedModes[0] || 'assign';
202
+ }, [allowedModes, defaultMode, fixedMode]);
203
+ const normalizeItemMode = React.useCallback(
204
+ (item: FieldAssignRuleItem): FieldAssignRuleItem => {
205
+ const mode = isAssignMode(item?.mode) && allowedModes.includes(item.mode) ? item.mode : getDefaultMode();
206
+ return item?.mode === mode ? item : { ...item, mode };
207
+ },
208
+ [allowedModes, getDefaultMode],
209
+ );
210
+ const emitChange = React.useCallback(
211
+ (next: FieldAssignRuleItem[]) => {
212
+ onChange?.(next.map((item) => normalizeItemMode(item)));
213
+ },
214
+ [normalizeItemMode, onChange],
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
+ }, []);
132
222
  const [cascaderOptions, setCascaderOptions] = React.useState<FieldAssignCascaderOption[]>(() =>
133
- Array.isArray(fieldOptions) ? (fieldOptions as FieldAssignCascaderOption[]) : [],
223
+ normalizeFieldOptions(fieldOptions),
134
224
  );
225
+ const loadedCascaderOptionsRef = React.useRef<WeakSet<FieldAssignCascaderOption>>(new WeakSet());
135
226
 
136
227
  React.useEffect(() => {
137
- setCascaderOptions(Array.isArray(fieldOptions) ? (fieldOptions as FieldAssignCascaderOption[]) : []);
138
- }, [fieldOptions]);
228
+ loadedCascaderOptionsRef.current = new WeakSet();
229
+ setCascaderOptions(normalizeFieldOptions(fieldOptions));
230
+ }, [fieldOptions, normalizeFieldOptions, rootCollection, maxAssociationFieldDepth]);
139
231
 
140
232
  const getRuleKey = React.useCallback((item: FieldAssignRuleItem, index: number) => item?.key || String(index), []);
141
233
  const [titleFieldDraftMap, setTitleFieldDraftMap] = React.useState<Record<string, string | undefined>>({});
@@ -312,7 +404,7 @@ export const FieldAssignRulesEditor: React.FC<FieldAssignRulesEditorProps> = (pr
312
404
 
313
405
  const patchItem = (index: number, patch: Partial<FieldAssignRuleItem>) => {
314
406
  const next = value.map((it, i) => (i === index ? { ...it, ...patch } : it));
315
- onChange?.(next);
407
+ emitChange(next);
316
408
  };
317
409
 
318
410
  const removeItem = (index: number) => {
@@ -334,7 +426,7 @@ export const FieldAssignRulesEditor: React.FC<FieldAssignRulesEditorProps> = (pr
334
426
  setSyncingRuleKey(null);
335
427
  }
336
428
  const next = value.filter((_, i) => i !== index);
337
- onChange?.(next);
429
+ emitChange(next);
338
430
  };
339
431
 
340
432
  const moveItem = (index: number, direction: 'up' | 'down') => {
@@ -343,7 +435,7 @@ export const FieldAssignRulesEditor: React.FC<FieldAssignRulesEditorProps> = (pr
343
435
  if (targetIndex < 0 || targetIndex >= next.length) return;
344
436
  const [item] = next.splice(index, 1);
345
437
  next.splice(targetIndex, 0, item);
346
- onChange?.(next);
438
+ emitChange(next);
347
439
  };
348
440
 
349
441
  const addItem = () => {
@@ -352,29 +444,33 @@ export const FieldAssignRulesEditor: React.FC<FieldAssignRulesEditorProps> = (pr
352
444
  {
353
445
  key: uid(),
354
446
  enable: true,
355
- mode: fixedMode || defaultMode,
447
+ mode: getDefaultMode(),
356
448
  condition: { logic: '$and', items: [] },
357
449
  targetPath: undefined,
358
450
  value: undefined,
359
451
  },
360
452
  ];
361
- onChange?.(next);
453
+ emitChange(next);
362
454
  };
363
455
 
364
456
  const getEffectiveMode = (item: FieldAssignRuleItem): AssignMode => {
365
457
  if (fixedMode) return fixedMode;
366
- return item?.mode === 'default' ? 'default' : 'assign';
458
+ if (isAssignMode(item?.mode) && allowedModes.includes(item.mode)) return item.mode;
459
+ return getDefaultMode();
367
460
  };
368
461
 
369
462
  const renderAssignModeLabel = React.useCallback(
370
463
  (mode: AssignMode) => {
371
- const modeText = mode === 'default' ? t('Default value') : t('Fixed value');
464
+ const modeText =
465
+ mode === 'default' ? t('Default value') : mode === 'override' ? t('Override value') : t('Fixed value');
372
466
  const modeHelpText =
373
467
  mode === 'default'
374
468
  ? t(
375
469
  'A pre-filled value. Editable, for new entries only, and won’t affect existing data (including empty values).',
376
470
  )
377
- : t('A system-set value. Read-only.');
471
+ : mode === 'override'
472
+ ? t('Overrides existing values until the user edits it.')
473
+ : t('A system-set value. Read-only.');
378
474
 
379
475
  const preventModeToggle = (e: React.MouseEvent<HTMLElement>) => {
380
476
  e.preventDefault();
@@ -551,23 +647,26 @@ export const FieldAssignRulesEditor: React.FC<FieldAssignRulesEditorProps> = (pr
551
647
  const opts = selectedOptions || [];
552
648
  const target = opts[opts.length - 1];
553
649
  if (!target) return;
554
- if (target.children && Array.isArray(target.children) && target.children.length) return;
555
650
  if (target.isLeaf) return;
651
+ if (loadedCascaderOptionsRef.current.has(target)) return;
556
652
 
557
653
  const segments = opts.map((o) => String(o?.value)).filter(Boolean);
558
654
  const targetCollection = resolveTargetCollectionBySegments(segments);
559
655
  if (!targetCollection) {
560
- target.isLeaf = true;
656
+ target.isLeaf = !(target.children && target.children.length);
657
+ loadedCascaderOptionsRef.current.add(target);
561
658
  setCascaderOptions((prev) => [...prev]);
562
659
  return;
563
660
  }
564
661
 
565
662
  const children = buildChildrenFromCollection(targetCollection, segments.length);
566
663
  if (!children.length) {
567
- target.isLeaf = true;
664
+ target.isLeaf = !(target.children && target.children.length);
568
665
  } else {
569
- target.children = children;
666
+ target.children = mergeCascaderOptions(target.children || [], children);
667
+ target.isLeaf = false;
570
668
  }
669
+ loadedCascaderOptionsRef.current.add(target);
571
670
  setCascaderOptions((prev) => [...prev]);
572
671
  },
573
672
  [buildChildrenFromCollection, resolveTargetCollectionBySegments],
@@ -583,12 +682,14 @@ export const FieldAssignRulesEditor: React.FC<FieldAssignRulesEditorProps> = (pr
583
682
  const hit = options.find((o) => String(o?.value) === seg);
584
683
  if (!hit) return;
585
684
  selected.push(hit);
685
+ if (!hit.isLeaf && !loadedCascaderOptionsRef.current.has(hit)) {
686
+ await loadCascaderData(selected);
687
+ }
586
688
  if (hit.children?.length) {
587
689
  options = hit.children;
588
690
  continue;
589
691
  }
590
692
  if (hit.isLeaf) return;
591
- await loadCascaderData(selected);
592
693
  options = hit.children || [];
593
694
  }
594
695
  },
@@ -619,7 +720,9 @@ export const FieldAssignRulesEditor: React.FC<FieldAssignRulesEditorProps> = (pr
619
720
  await preloadCascaderPath(segs);
620
721
  }
621
722
  };
622
- void run();
723
+ run().catch((error) => {
724
+ console.warn('[FieldAssignRulesEditor] Failed to preload cascader path', error);
725
+ });
623
726
  return () => {
624
727
  cancelled = true;
625
728
  };
@@ -627,7 +730,8 @@ export const FieldAssignRulesEditor: React.FC<FieldAssignRulesEditorProps> = (pr
627
730
 
628
731
  const renderPanelHeader = (item: FieldAssignRuleItem, index: number) => {
629
732
  const mode = getEffectiveMode(item);
630
- const modeText = mode === 'default' ? t('Default value') : t('Fixed value');
733
+ const modeText =
734
+ mode === 'default' ? t('Default value') : mode === 'override' ? t('Override value') : t('Fixed value');
631
735
  const fieldLabel = getFieldLabel(item.targetPath);
632
736
  const title = fieldLabel ? String(fieldLabel) : t('Please select field');
633
737
 
@@ -935,8 +1039,11 @@ export const FieldAssignRulesEditor: React.FC<FieldAssignRulesEditorProps> = (pr
935
1039
  onChange={(event) => patchItem(index, { mode: event.target.value as AssignMode })}
936
1040
  >
937
1041
  <Space size={16}>
938
- <Radio value="default">{renderAssignModeLabel('default')}</Radio>
939
- <Radio value="assign">{renderAssignModeLabel('assign')}</Radio>
1042
+ {allowedModes.map((allowedMode) => (
1043
+ <Radio key={allowedMode} value={allowedMode}>
1044
+ {renderAssignModeLabel(allowedMode)}
1045
+ </Radio>
1046
+ ))}
940
1047
  </Space>
941
1048
  </Radio.Group>
942
1049
  </div>
@@ -341,6 +341,8 @@ interface Props {
341
341
  * 默认 false,保持历史行为。
342
342
  */
343
343
  enableDateVariableAsConstant?: boolean;
344
+ /** 是否允许在变量选择器中使用 RunJS。默认 true,保持历史行为。 */
345
+ allowRunJS?: boolean;
344
346
  maxAssociationFieldDepth?: number;
345
347
  }
346
348
 
@@ -712,6 +714,7 @@ export const FieldAssignValueInput: React.FC<Props> = ({
712
714
  preferFormItemFieldModel,
713
715
  associationFieldNamesOverride,
714
716
  enableDateVariableAsConstant = false,
717
+ allowRunJS = true,
715
718
  maxAssociationFieldDepth = 2,
716
719
  }) => {
717
720
  const flowCtx = useFlowContext<FlowModelContext>();
@@ -1417,11 +1420,13 @@ export const FieldAssignValueInput: React.FC<Props> = ({
1417
1420
  render: ConstantEditor,
1418
1421
  },
1419
1422
  { title: tExpr('Null'), name: 'null', type: 'object', paths: ['null'], render: NullComponent },
1420
- { title: tExpr('RunJS'), name: 'runjs', type: 'object', paths: ['runjs'], render: RunJSComponent },
1423
+ ...(allowRunJS
1424
+ ? [{ title: tExpr('RunJS'), name: 'runjs', type: 'object', paths: ['runjs'], render: RunJSComponent }]
1425
+ : []),
1421
1426
  ...limitedBase,
1422
1427
  ];
1423
1428
  };
1424
- }, [flowCtx, ConstantEditor, NullComponent, RunJSComponent, maxAssociationFieldDepth]);
1429
+ }, [flowCtx, ConstantEditor, NullComponent, RunJSComponent, allowRunJS, maxAssociationFieldDepth]);
1425
1430
 
1426
1431
  const displayValue = React.useMemo(() => {
1427
1432
  if (!useDateVariableConstant) {
@@ -1465,7 +1470,7 @@ export const FieldAssignValueInput: React.FC<Props> = ({
1465
1470
  const firstPath = meta?.paths?.[0];
1466
1471
  if (firstPath === 'constant') return ConstantEditor;
1467
1472
  if (firstPath === 'null') return NullComponent;
1468
- if (firstPath === 'runjs') return RunJSComponent;
1473
+ if (allowRunJS && firstPath === 'runjs') return RunJSComponent;
1469
1474
  return null;
1470
1475
  },
1471
1476
  resolveValueFromPath: (item) => {
@@ -1474,12 +1479,12 @@ export const FieldAssignValueInput: React.FC<Props> = ({
1474
1479
  return useDateVariableConstant ? { type: 'today' } : '';
1475
1480
  }
1476
1481
  if (firstPath === 'null') return null;
1477
- if (firstPath === 'runjs') return { code: '', version: 'v2' };
1482
+ if (allowRunJS && firstPath === 'runjs') return { code: '', version: 'v2' };
1478
1483
  return undefined;
1479
1484
  },
1480
1485
  resolvePathFromValue: (currentValue) => {
1481
1486
  if (currentValue === null) return ['null'];
1482
- if (isRunJSValue(currentValue)) return ['runjs'];
1487
+ if (allowRunJS && isRunJSValue(currentValue)) return ['runjs'];
1483
1488
  if (useDateVariableConstant && isCtxDateExpression(currentValue)) {
1484
1489
  return ['constant'];
1485
1490
  }
@@ -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
  });