@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
@@ -9,9 +9,12 @@
9
9
 
10
10
  import React from 'react';
11
11
  import { ThunderboltOutlined, DeleteOutlined, PlusOutlined } from '@ant-design/icons';
12
+ import { css } from '@emotion/css';
12
13
  import {
13
14
  ActionDefinition,
14
15
  ActionScene,
16
+ FlowContext,
17
+ FlowContextProvider,
15
18
  FlowEngineContext,
16
19
  FlowEventPhase,
17
20
  FlowModel,
@@ -31,8 +34,9 @@ import {
31
34
  DetachedFlowRegistry,
32
35
  replaceFlowRegistry,
33
36
  serializeFlowRegistry,
37
+ type DynamicFlowSource,
34
38
  } from '@nocobase/flow-engine';
35
- import { Collapse, Input, Button, Space, Tooltip, Empty, Dropdown, Select } from 'antd';
39
+ import { Collapse, Input, Button, Space, Tooltip, Empty, Dropdown, Select, Tabs, theme } from 'antd';
36
40
  import { uid } from '@formily/shared';
37
41
  import { useUpdate } from 'ahooks';
38
42
  import _ from 'lodash';
@@ -41,6 +45,15 @@ type FlowOnObject = Exclude<FlowDefinition['on'], string | undefined>;
41
45
  type FlowRegistryAvailability = {
42
46
  hasFlow(flowKey: string): boolean;
43
47
  };
48
+ type ClosableView = {
49
+ close?: () => Promise<unknown> | unknown;
50
+ beforeClose?: (payload: Record<string, unknown>) => Promise<boolean | void> | boolean | void;
51
+ destroy?: () => void;
52
+ };
53
+ type DynamicFlowsEditorApi = {
54
+ hasUnsavedChanges: () => boolean;
55
+ save: () => Promise<boolean>;
56
+ };
44
57
 
45
58
  function isFlowOnObject(on: FlowDefinition['on']): on is FlowOnObject {
46
59
  return !!on && typeof on === 'object';
@@ -97,17 +110,36 @@ function validateFlowOnPhase(onObj: FlowOnObject): 'flowKey' | 'stepKey' | undef
97
110
  }
98
111
  }
99
112
 
113
+ const confirmDiscardUnsavedChanges = async (ctx: FlowEngineContext, t: (key: string) => string) => {
114
+ return (
115
+ (await ctx.modal?.confirm?.({
116
+ title: t('Unsaved changes'),
117
+ content: t("Are you sure you don't want to save?"),
118
+ okText: t('Confirm'),
119
+ cancelText: t('Cancel'),
120
+ })) ?? true
121
+ );
122
+ };
123
+
100
124
  export const DynamicFlowsIcon: React.FC<{ model: FlowModel }> = (props) => {
101
125
  const { model } = props;
102
126
  const t = React.useMemo(() => model.translate.bind(model), [model]);
103
127
 
104
- const handleClick = () => {
128
+ const handleClick = async () => {
105
129
  const target = document.querySelector<HTMLDivElement>(`#${GLOBAL_EMBED_CONTAINER_ID}`);
106
130
 
107
131
  if (!target) {
108
132
  return;
109
133
  }
110
134
 
135
+ const sources = await model.flowEngine.flowSettings.getDynamicFlowSources(model);
136
+ const content =
137
+ sources.length > 1 ? (
138
+ <DynamicFlowsSourceTabs sources={sources} />
139
+ ) : (
140
+ <DynamicFlowsEditor model={sources[0].model} />
141
+ );
142
+
111
143
  model.context.viewer.embed({
112
144
  type: 'embed',
113
145
  target,
@@ -124,13 +156,231 @@ export const DynamicFlowsIcon: React.FC<{ model: FlowModel }> = (props) => {
124
156
  target.style.minWidth = 'auto';
125
157
  }
126
158
  },
127
- content: <DynamicFlowsEditor model={model} />,
159
+ content,
128
160
  });
129
161
  };
130
162
 
131
163
  return <ThunderboltOutlined style={{ cursor: 'pointer' }} onClick={handleClick} />;
132
164
  };
133
165
 
166
+ const DynamicFlowsSourceTabs = observer(({ sources }: { sources: DynamicFlowSource[] }) => {
167
+ const [activeKey, setActiveKey] = React.useState(sources[0]?.key);
168
+ const [submitLoading, setSubmitLoading] = React.useState(false);
169
+ const ctx = useFlowContext<FlowEngineContext>();
170
+ const { token } = theme.useToken();
171
+ const dirtySourceKeysRef = React.useRef(new Set<string>());
172
+ const editorApisRef = React.useRef(new Map<string, DynamicFlowsEditorApi>());
173
+ const t = React.useMemo(
174
+ () => sources[0]?.model.translate.bind(sources[0].model) || ((key: string) => key),
175
+ [sources],
176
+ );
177
+ const tabBarStyle = React.useMemo(
178
+ () => ({
179
+ paddingLeft: token.paddingLG,
180
+ paddingRight: token.paddingLG,
181
+ marginBottom: 0,
182
+ }),
183
+ [token.paddingLG],
184
+ );
185
+ const handleDirtyChange = React.useCallback((sourceKey: string, dirty: boolean) => {
186
+ if (dirty) {
187
+ dirtySourceKeysRef.current.add(sourceKey);
188
+ } else {
189
+ dirtySourceKeysRef.current.delete(sourceKey);
190
+ }
191
+ }, []);
192
+ const handleRegisterEditor = React.useCallback((sourceKey: string, api?: DynamicFlowsEditorApi) => {
193
+ if (api) {
194
+ editorApisRef.current.set(sourceKey, api);
195
+ } else {
196
+ editorApisRef.current.delete(sourceKey);
197
+ }
198
+ }, []);
199
+ const closeView = React.useCallback(async () => {
200
+ const view = ctx.view as ClosableView | undefined;
201
+ if (!view) {
202
+ return false;
203
+ }
204
+
205
+ if (typeof view.close === 'function') {
206
+ return (await view.close()) !== false;
207
+ }
208
+
209
+ const allowed = await view.beforeClose?.({});
210
+ if (allowed === false) {
211
+ return false;
212
+ }
213
+
214
+ view.destroy?.();
215
+ return true;
216
+ }, [ctx.view]);
217
+ const handleSaveAllSources = React.useCallback(async () => {
218
+ setSubmitLoading(true);
219
+ try {
220
+ const orderedSources = [
221
+ ...sources.filter((source) => source.key === activeKey),
222
+ ...sources.filter((source) => source.key !== activeKey),
223
+ ];
224
+
225
+ for (const source of orderedSources) {
226
+ const api = editorApisRef.current.get(source.key);
227
+ if (!api) {
228
+ if (dirtySourceKeysRef.current.has(source.key)) {
229
+ setActiveKey(source.key);
230
+ return;
231
+ }
232
+ continue;
233
+ }
234
+
235
+ const shouldSave =
236
+ source.key === activeKey || dirtySourceKeysRef.current.has(source.key) || api.hasUnsavedChanges();
237
+ if (!shouldSave) {
238
+ continue;
239
+ }
240
+
241
+ const saved = await api.save();
242
+ if (!saved) {
243
+ setActiveKey(source.key);
244
+ return;
245
+ }
246
+ }
247
+
248
+ await closeView();
249
+ } finally {
250
+ setSubmitLoading(false);
251
+ }
252
+ }, [activeKey, closeView, sources]);
253
+
254
+ React.useEffect(() => {
255
+ const view = ctx.view;
256
+ if (!view) {
257
+ return;
258
+ }
259
+
260
+ const previousBeforeClose = view.beforeClose;
261
+ const beforeClose = async (payload) => {
262
+ if (dirtySourceKeysRef.current.size > 0) {
263
+ const confirmed = await confirmDiscardUnsavedChanges(ctx, t);
264
+ if (!confirmed) {
265
+ return false;
266
+ }
267
+ }
268
+
269
+ const result = await previousBeforeClose?.(payload);
270
+ return result !== false;
271
+ };
272
+
273
+ view.beforeClose = beforeClose;
274
+
275
+ return () => {
276
+ if (view.beforeClose === beforeClose) {
277
+ view.beforeClose = previousBeforeClose;
278
+ }
279
+ };
280
+ }, [ctx, t]);
281
+
282
+ return (
283
+ <Tabs
284
+ className={dynamicFlowsSourceTabsClass}
285
+ size="small"
286
+ activeKey={activeKey}
287
+ onChange={setActiveKey}
288
+ tabBarStyle={tabBarStyle}
289
+ items={sources.map((source) => ({
290
+ key: source.key,
291
+ label: source.label,
292
+ forceRender: true,
293
+ children: (
294
+ <DynamicFlowsSourceEditor
295
+ key={source.key}
296
+ source={source}
297
+ active={source.key === activeKey}
298
+ onDirtyChange={(dirty) => handleDirtyChange(source.key, dirty)}
299
+ onRegisterEditor={(api) => handleRegisterEditor(source.key, api)}
300
+ onSaveRequest={handleSaveAllSources}
301
+ submitLoading={submitLoading}
302
+ />
303
+ ),
304
+ }))}
305
+ />
306
+ );
307
+ });
308
+
309
+ type DynamicFlowsSourceEditorProps = {
310
+ source: DynamicFlowSource;
311
+ active: boolean;
312
+ onDirtyChange: (dirty: boolean) => void;
313
+ onRegisterEditor: (api?: DynamicFlowsEditorApi) => void;
314
+ onSaveRequest: () => Promise<void>;
315
+ submitLoading: boolean;
316
+ };
317
+
318
+ const DynamicFlowsSourceEditor = observer(
319
+ ({
320
+ source,
321
+ active,
322
+ onDirtyChange,
323
+ onRegisterEditor,
324
+ onSaveRequest,
325
+ submitLoading,
326
+ }: DynamicFlowsSourceEditorProps) => {
327
+ const hostCtx = useFlowContext<FlowEngineContext>();
328
+ const sourceContext = React.useMemo(() => {
329
+ const context = new FlowContext();
330
+ if (hostCtx instanceof FlowContext) {
331
+ context.addDelegate(hostCtx);
332
+ }
333
+ if (source.model.context instanceof FlowContext) {
334
+ context.addDelegate(source.model.context);
335
+ }
336
+ if (hostCtx?.view) {
337
+ context.defineProperty('view', { value: hostCtx.view });
338
+ }
339
+ if (hostCtx?.modal) {
340
+ context.defineProperty('modal', { value: hostCtx.modal });
341
+ }
342
+ return context;
343
+ }, [hostCtx, source.model]);
344
+
345
+ return (
346
+ <FlowContextProvider context={sourceContext}>
347
+ <DynamicFlowsEditor
348
+ key={source.model.uid}
349
+ model={source.model}
350
+ active={active}
351
+ guardBeforeClose={false}
352
+ onDirtyChange={onDirtyChange}
353
+ onRegisterEditor={onRegisterEditor}
354
+ onSaveRequest={onSaveRequest}
355
+ submitLoading={submitLoading}
356
+ />
357
+ </FlowContextProvider>
358
+ );
359
+ },
360
+ );
361
+
362
+ const dynamicFlowsSourceTabsClass = css`
363
+ height: 100%;
364
+ display: flex;
365
+ flex-direction: column;
366
+
367
+ > .ant-tabs-nav {
368
+ margin-bottom: 0;
369
+ flex-shrink: 0;
370
+ }
371
+
372
+ > .ant-tabs-content-holder {
373
+ flex: 1;
374
+ min-height: 0;
375
+ overflow: hidden;
376
+ }
377
+
378
+ > .ant-tabs-content-holder > .ant-tabs-content,
379
+ > .ant-tabs-content-holder > .ant-tabs-content > .ant-tabs-tabpane {
380
+ height: 100%;
381
+ }
382
+ `;
383
+
134
384
  const styles: Record<string, React.CSSProperties> = {
135
385
  sectionHeader: {
136
386
  display: 'flex',
@@ -165,6 +415,15 @@ const styles: Record<string, React.CSSProperties> = {
165
415
  marginInlineStart: 2,
166
416
  marginInlineEnd: 8,
167
417
  },
418
+ editorFooter: {
419
+ display: 'flex',
420
+ justifyContent: 'flex-end',
421
+ gap: 8,
422
+ padding: '8px 16px',
423
+ borderTop: '1px solid #f0f0f0',
424
+ backgroundColor: '#fff',
425
+ flexShrink: 0,
426
+ },
168
427
  };
169
428
 
170
429
  const SectionHeader = ({ color, children }: { color: string; children: React.ReactNode }) => {
@@ -458,19 +717,39 @@ const EventConfigSection = observer(
458
717
  },
459
718
  );
460
719
 
461
- const DynamicFlowsEditor = observer((props: { model: FlowModel }) => {
720
+ type DynamicFlowsEditorProps = {
721
+ model: FlowModel;
722
+ active?: boolean;
723
+ guardBeforeClose?: boolean;
724
+ onDirtyChange?: (dirty: boolean) => void;
725
+ onRegisterEditor?: (api?: DynamicFlowsEditorApi) => void;
726
+ onSaveRequest?: () => Promise<void>;
727
+ submitLoading?: boolean;
728
+ };
729
+
730
+ const DynamicFlowsEditor = observer((props: DynamicFlowsEditorProps) => {
462
731
  const { model } = props;
732
+ const active = props.active ?? true;
733
+ const guardBeforeClose = props.guardBeforeClose ?? true;
734
+ const onDirtyChange = props.onDirtyChange;
735
+ const onRegisterEditor = props.onRegisterEditor;
736
+ const onSaveRequest = props.onSaveRequest;
463
737
  const ctx = useFlowContext<FlowEngineContext>();
464
738
  const flowEngine = model.flowEngine;
465
739
  const latestFlows = serializeFlowRegistry(model.flowRegistry);
466
740
  const initialFlowsRef = React.useRef(latestFlows);
467
741
  const [draftFlowRegistry] = React.useState(() => new DetachedFlowRegistry(latestFlows));
468
- const [submitLoading, setSubmitLoading] = React.useState(false);
742
+ const [internalSubmitLoading, setInternalSubmitLoading] = React.useState(false);
743
+ const submitLoading = props.submitLoading ?? internalSubmitLoading;
469
744
  const t = React.useMemo(() => model.translate.bind(model), [model]);
470
745
  const hasUnsavedChanges = React.useCallback(() => {
471
746
  return !_.isEqual(initialFlowsRef.current, serializeFlowRegistry(draftFlowRegistry));
472
747
  }, [draftFlowRegistry]);
473
748
 
749
+ React.useEffect(() => {
750
+ onDirtyChange?.(hasUnsavedChanges());
751
+ });
752
+
474
753
  React.useEffect(() => {
475
754
  if (_.isEqual(initialFlowsRef.current, latestFlows) || hasUnsavedChanges()) {
476
755
  return;
@@ -481,17 +760,19 @@ const DynamicFlowsEditor = observer((props: { model: FlowModel }) => {
481
760
  }, [draftFlowRegistry, hasUnsavedChanges, latestFlows]);
482
761
 
483
762
  React.useEffect(() => {
763
+ if (!guardBeforeClose) {
764
+ return;
765
+ }
766
+
484
767
  const view = ctx.view;
768
+ if (!view) {
769
+ return;
770
+ }
771
+
485
772
  const previousBeforeClose = view.beforeClose;
486
773
  const beforeClose = async (payload) => {
487
774
  if (hasUnsavedChanges()) {
488
- const confirmed =
489
- (await ctx.modal?.confirm?.({
490
- title: t('Unsaved changes'),
491
- content: t("Are you sure you don't want to save?"),
492
- okText: t('Confirm'),
493
- cancelText: t('Cancel'),
494
- })) ?? true;
775
+ const confirmed = await confirmDiscardUnsavedChanges(ctx, t);
495
776
 
496
777
  if (!confirmed) {
497
778
  return false;
@@ -509,7 +790,7 @@ const DynamicFlowsEditor = observer((props: { model: FlowModel }) => {
509
790
  view.beforeClose = previousBeforeClose;
510
791
  }
511
792
  };
512
- }, [ctx, hasUnsavedChanges, t]);
793
+ }, [ctx, guardBeforeClose, hasUnsavedChanges, t]);
513
794
 
514
795
  // 添加新流
515
796
  const handleAddFlow = () => {
@@ -677,6 +958,152 @@ const DynamicFlowsEditor = observer((props: { model: FlowModel }) => {
677
958
  ),
678
959
  };
679
960
  });
961
+ const closeView = React.useCallback(async () => {
962
+ const view = ctx.view as ClosableView | undefined;
963
+
964
+ if (!view) {
965
+ return false;
966
+ }
967
+
968
+ if (typeof view.close === 'function') {
969
+ return (await view.close()) !== false;
970
+ }
971
+
972
+ const allowed = await view.beforeClose?.({});
973
+ if (allowed === false) {
974
+ return false;
975
+ }
976
+
977
+ view.destroy?.();
978
+ return true;
979
+ }, [ctx.view]);
980
+ const saveDraft = React.useCallback(async () => {
981
+ setInternalSubmitLoading(true);
982
+ const invalid = draftFlowRegistry
983
+ .mapFlows((flow) => {
984
+ if (!isFlowOnObject(flow.on)) return;
985
+ normalizeFlowOnPhase(flow.on);
986
+ const invalidType = validateFlowOnPhase(flow.on);
987
+ if (!invalidType) return;
988
+ return { type: invalidType };
989
+ })
990
+ .filter(Boolean)[0] as { type: 'flowKey' | 'stepKey' } | undefined;
991
+
992
+ if (invalid) {
993
+ const msg =
994
+ invalid.type === 'flowKey' ? t('Please select a built-in flow') : t('Please select a built-in flow step');
995
+ model.context?.message?.error?.(msg);
996
+ setInternalSubmitLoading(false);
997
+ return false;
998
+ }
999
+ const previousFlows = serializeFlowRegistry(model.flowRegistry);
1000
+ const afterSaves: Array<() => Promise<void>> = [];
1001
+
1002
+ try {
1003
+ for (const flow of draftFlowRegistry.mapFlows((it) => it)) {
1004
+ for (const step of flow.mapSteps((it) => it)) {
1005
+ const serialized = step.serialize();
1006
+ const actionDef = step.use ? model.getAction(step.use) : undefined;
1007
+
1008
+ const beforeParamsSave = serialized.beforeParamsSave || actionDef?.beforeParamsSave;
1009
+ const afterParamsSave = serialized.afterParamsSave || actionDef?.afterParamsSave;
1010
+
1011
+ if (typeof beforeParamsSave !== 'function' && typeof afterParamsSave !== 'function') {
1012
+ continue;
1013
+ }
1014
+
1015
+ const runtimeCtx = new FlowRuntimeContext(model, flow.key, 'settings');
1016
+ setupRuntimeContextSteps(runtimeCtx, flow.steps, model, flow.key);
1017
+ runtimeCtx.defineProperty('currentStep', { value: serialized });
1018
+
1019
+ const currentValues = { ...(step.defaultParams || {}) };
1020
+ const previousParams = { ...(step.defaultParams || {}) };
1021
+
1022
+ if (typeof beforeParamsSave === 'function') {
1023
+ await beforeParamsSave(runtimeCtx as any, currentValues, previousParams);
1024
+ }
1025
+
1026
+ if (typeof afterParamsSave === 'function') {
1027
+ afterSaves.push(async () => {
1028
+ await afterParamsSave(runtimeCtx as any, currentValues, previousParams);
1029
+ });
1030
+ }
1031
+
1032
+ step.defaultParams = currentValues;
1033
+ }
1034
+ }
1035
+
1036
+ replaceFlowRegistry(model.flowRegistry, serializeFlowRegistry(draftFlowRegistry));
1037
+ await model.flowRegistry.save();
1038
+ } catch (error) {
1039
+ replaceFlowRegistry(model.flowRegistry, previousFlows);
1040
+ setInternalSubmitLoading(false);
1041
+ model.context?.message?.error?.('Steps post-save hooks failed to run');
1042
+ console.error(error);
1043
+ return false;
1044
+ }
1045
+
1046
+ initialFlowsRef.current = serializeFlowRegistry(model.flowRegistry);
1047
+
1048
+ try {
1049
+ for (const runAfterSave of afterSaves) {
1050
+ await runAfterSave();
1051
+ }
1052
+ } catch (error) {
1053
+ model.context?.message?.error?.('Steps post-save hooks failed to run');
1054
+ console.error(error);
1055
+ }
1056
+ // 保存事件流定义后,失效 beforeRender 缓存并触发一次重跑,确保改动立刻生效
1057
+ const beforeRenderFlows = model.flowRegistry
1058
+ .mapFlows((flow) => {
1059
+ if (isBeforeRenderFlow(flow)) {
1060
+ return flow;
1061
+ }
1062
+ })
1063
+ .filter(Boolean);
1064
+ if (beforeRenderFlows.length > 0) {
1065
+ model.rerender(); // 不阻塞,后续保存
1066
+ }
1067
+ setInternalSubmitLoading(false);
1068
+ onDirtyChange?.(false);
1069
+ model.context?.message?.success?.(t('Configuration saved'));
1070
+ return true;
1071
+ }, [draftFlowRegistry, model, onDirtyChange, t]);
1072
+ React.useEffect(() => {
1073
+ if (!onRegisterEditor) {
1074
+ return;
1075
+ }
1076
+ onRegisterEditor({
1077
+ hasUnsavedChanges,
1078
+ save: saveDraft,
1079
+ });
1080
+ return () => {
1081
+ onRegisterEditor();
1082
+ };
1083
+ }, [hasUnsavedChanges, onRegisterEditor, saveDraft]);
1084
+
1085
+ const footerButtons = (
1086
+ <Space>
1087
+ <Button onClick={closeView}>{t('Cancel')}</Button>
1088
+ <Button
1089
+ type="primary"
1090
+ loading={submitLoading}
1091
+ onClick={async () => {
1092
+ if (onSaveRequest) {
1093
+ await onSaveRequest();
1094
+ return;
1095
+ }
1096
+ const saved = await saveDraft();
1097
+ if (saved) {
1098
+ await closeView();
1099
+ }
1100
+ }}
1101
+ >
1102
+ {t('Save')}
1103
+ </Button>
1104
+ </Space>
1105
+ );
1106
+ const ViewFooter = ctx.view?.Footer;
680
1107
 
681
1108
  return (
682
1109
  <div
@@ -720,119 +1147,13 @@ const DynamicFlowsEditor = observer((props: { model: FlowModel }) => {
720
1147
  </Button>
721
1148
  </div>
722
1149
 
723
- {/* 底部按钮区域 */}
724
- <div
725
- style={{
726
- display: 'flex',
727
- justifyContent: 'flex-end',
728
- gap: '8px',
729
- padding: '8px 16px',
730
- borderTop: '1px solid #f0f0f0',
731
- backgroundColor: '#fff',
732
- flexShrink: 0,
733
- }}
734
- >
735
- <Button onClick={() => ctx.view.close()}>{t('Cancel')}</Button>
736
- <Button
737
- type="primary"
738
- loading={submitLoading}
739
- onClick={async () => {
740
- setSubmitLoading(true);
741
- const invalid = draftFlowRegistry
742
- .mapFlows((flow) => {
743
- if (!isFlowOnObject(flow.on)) return;
744
- normalizeFlowOnPhase(flow.on);
745
- const invalidType = validateFlowOnPhase(flow.on);
746
- if (!invalidType) return;
747
- return { type: invalidType };
748
- })
749
- .filter(Boolean)[0] as { type: 'flowKey' | 'stepKey' } | undefined;
750
-
751
- if (invalid) {
752
- const msg =
753
- invalid.type === 'flowKey'
754
- ? t('Please select a built-in flow')
755
- : t('Please select a built-in flow step');
756
- model.context?.message?.error?.(msg);
757
- setSubmitLoading(false);
758
- return;
759
- }
760
- const previousFlows = serializeFlowRegistry(model.flowRegistry);
761
- const afterSaves: Array<() => Promise<void>> = [];
762
-
763
- try {
764
- for (const flow of draftFlowRegistry.mapFlows((it) => it)) {
765
- for (const step of flow.mapSteps((it) => it)) {
766
- const serialized = step.serialize();
767
- const actionDef = step.use ? model.getAction(step.use) : undefined;
768
-
769
- const beforeParamsSave = serialized.beforeParamsSave || actionDef?.beforeParamsSave;
770
- const afterParamsSave = serialized.afterParamsSave || actionDef?.afterParamsSave;
771
-
772
- if (typeof beforeParamsSave !== 'function' && typeof afterParamsSave !== 'function') {
773
- continue;
774
- }
775
-
776
- const runtimeCtx = new FlowRuntimeContext(model, flow.key, 'settings');
777
- setupRuntimeContextSteps(runtimeCtx, flow.steps, model, flow.key);
778
- runtimeCtx.defineProperty('currentStep', { value: serialized });
779
-
780
- const currentValues = { ...(step.defaultParams || {}) };
781
- const previousParams = { ...(step.defaultParams || {}) };
782
-
783
- if (typeof beforeParamsSave === 'function') {
784
- await beforeParamsSave(runtimeCtx as any, currentValues, previousParams);
785
- }
786
-
787
- if (typeof afterParamsSave === 'function') {
788
- afterSaves.push(async () => {
789
- await afterParamsSave(runtimeCtx as any, currentValues, previousParams);
790
- });
791
- }
792
-
793
- step.defaultParams = currentValues;
794
- }
795
- }
796
-
797
- replaceFlowRegistry(model.flowRegistry, serializeFlowRegistry(draftFlowRegistry));
798
- await model.flowRegistry.save();
799
- } catch (error) {
800
- replaceFlowRegistry(model.flowRegistry, previousFlows);
801
- setSubmitLoading(false);
802
- model.context?.message?.error?.('Steps post-save hooks failed to run');
803
- console.error(error);
804
- return;
805
- }
806
-
807
- initialFlowsRef.current = serializeFlowRegistry(model.flowRegistry);
808
-
809
- try {
810
- for (const runAfterSave of afterSaves) {
811
- await runAfterSave();
812
- }
813
- } catch (error) {
814
- model.context?.message?.error?.('Steps post-save hooks failed to run');
815
- console.error(error);
816
- }
817
- // 保存事件流定义后,失效 beforeRender 缓存并触发一次重跑,确保改动立刻生效
818
- const beforeRenderFlows = model.flowRegistry
819
- .mapFlows((flow) => {
820
- if (isBeforeRenderFlow(flow)) {
821
- return flow;
822
- }
823
- })
824
- .filter(Boolean);
825
- if (beforeRenderFlows.length > 0) {
826
- model.rerender(); // 不阻塞,后续保存
827
- }
828
- setSubmitLoading(false);
829
- model.context?.message?.success?.(t('Configuration saved'));
830
- ctx.view.destroy();
831
- }}
832
- >
833
- {t('Save')}
834
- </Button>
835
- </div>
1150
+ {active ? (
1151
+ ViewFooter ? (
1152
+ <ViewFooter>{footerButtons}</ViewFooter>
1153
+ ) : (
1154
+ <div style={styles.editorFooter}>{footerButtons}</div>
1155
+ )
1156
+ ) : null}
836
1157
  </div>
837
1158
  );
838
1159
  });