@nocobase/client-v2 2.1.0-beta.32 → 2.1.0-beta.33

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 (39) hide show
  1. package/es/flow/components/code-editor/index.d.ts +1 -0
  2. package/es/flow/models/blocks/shared/legacyDefaultValueMigrationBase.d.ts +1 -0
  3. package/es/flow/models/fields/AssociationFieldModel/SubTableFieldModel/SubTableColumnModel.d.ts +4 -0
  4. package/es/flow/models/fields/ClickableFieldModel.d.ts +3 -0
  5. package/es/flow/models/fields/DisplayEnumFieldModel.d.ts +6 -0
  6. package/es/flow/models/fields/DisplayTitleFieldModel.d.ts +2 -2
  7. package/es/index.mjs +76 -76
  8. package/lib/index.js +67 -67
  9. package/package.json +8 -5
  10. package/src/__tests__/globalDeps.test.ts +1 -0
  11. package/src/flow/actions/__tests__/formAssignRules.legacyMigration.test.tsx +173 -0
  12. package/src/flow/actions/__tests__/pattern.test.ts +134 -0
  13. package/src/flow/actions/__tests__/titleField.test.ts +45 -0
  14. package/src/flow/actions/filterFormDefaultValues.tsx +30 -9
  15. package/src/flow/actions/formAssignRules.tsx +24 -9
  16. package/src/flow/actions/pattern.tsx +41 -6
  17. package/src/flow/actions/titleField.tsx +4 -2
  18. package/src/flow/actions/validation.tsx +1 -1
  19. package/src/flow/admin-shell/admin-layout/AdminLayoutMenuModels.tsx +0 -4
  20. package/src/flow/admin-shell/admin-layout/__tests__/AdminLayoutMenuModels.test.ts +13 -5
  21. package/src/flow/components/DynamicFlowsIcon.tsx +87 -13
  22. package/src/flow/components/__tests__/DynamicFlowsIcon.test.tsx +195 -8
  23. package/src/flow/components/code-editor/index.tsx +12 -8
  24. package/src/flow/models/base/PageModel/RootPageModel.tsx +1 -1
  25. package/src/flow/models/blocks/filter-form/__tests__/legacyDefaultValueMigration.test.ts +3 -0
  26. package/src/flow/models/blocks/form/FormActionModel.tsx +2 -8
  27. package/src/flow/models/blocks/form/FormItemModel.tsx +1 -1
  28. package/src/flow/models/blocks/form/__tests__/legacyDefaultValueMigration.test.ts +3 -0
  29. package/src/flow/models/blocks/shared/legacyDefaultValueMigrationBase.ts +21 -5
  30. package/src/flow/models/blocks/table/TableColumnModel.tsx +5 -2
  31. package/src/flow/models/blocks/table/__tests__/TableColumnModel.test.tsx +36 -0
  32. package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/SubTableColumnModel.tsx +144 -3
  33. package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/__tests__/SubTableColumnModel.rowRecord.test.ts +170 -1
  34. package/src/flow/models/fields/ClickableFieldModel.tsx +46 -2
  35. package/src/flow/models/fields/DisplayEnumFieldModel.tsx +44 -0
  36. package/src/flow/models/fields/DisplayTitleFieldModel.tsx +40 -15
  37. package/src/flow/models/fields/__tests__/ClickableFieldModel.test.ts +180 -2
  38. package/src/flow/models/fields/__tests__/DisplayEnumFieldModel.test.tsx +39 -0
  39. package/src/utils/globalDeps.ts +9 -0
@@ -18,7 +18,7 @@ export const validation = defineAction({
18
18
  return;
19
19
  }
20
20
  const targetInterface = ctx.model.collectionField.getInterfaceOptions();
21
- if (!targetInterface.validationType) {
21
+ if (!targetInterface?.validationType) {
22
22
  return null;
23
23
  }
24
24
  return {
@@ -628,10 +628,6 @@ export class AdminLayoutMenuItemModel extends FlowModel<AdminLayoutMenuItemStruc
628
628
  )
629
629
  .filter(Boolean) || [];
630
630
 
631
- if (isV2AdminRuntime(this.context.app) && children.length === 0) {
632
- return null;
633
- }
634
-
635
631
  if (options.designable && depth === 0) {
636
632
  children.push(getAdminLayoutMenuInitializerButton('schema-initializer-Menu-side', this, route));
637
633
  }
@@ -311,7 +311,7 @@ describe('AdminLayoutModel menu items', () => {
311
311
  expect(route.children[1]._model).toBe(adminLayoutModel.subModels.menuItems?.[1]);
312
312
  });
313
313
 
314
- it('should filter legacy page menu routes in v2 admin layout', () => {
314
+ it('should filter legacy page menu routes but keep empty groups in v2 admin layout', () => {
315
315
  const adminLayoutModel = engine.createModel<AdminLayoutModel>({
316
316
  uid: 'admin-layout-model',
317
317
  use: AdminLayoutModel,
@@ -369,22 +369,30 @@ describe('AdminLayoutModel menu items', () => {
369
369
  t: (title) => title,
370
370
  });
371
371
 
372
- expect(route.children).toHaveLength(2);
372
+ expect(route.children).toHaveLength(3);
373
373
  expect(route.children[0]).toMatchObject({
374
+ path: '/admin/2',
375
+ redirect: '/admin/2',
376
+ _runtimePath: null,
377
+ _navigationMode: 'spa',
378
+ _isLegacy: false,
379
+ });
380
+ expect(route.children[0].routes).toBeUndefined();
381
+ expect(route.children[1]).toMatchObject({
374
382
  path: '/admin/3',
375
383
  redirect: '/admin/nested-flow-page',
376
384
  _runtimePath: '/apps/demo/v2/admin/nested-flow-page',
377
385
  _navigationMode: 'spa',
378
386
  _isLegacy: false,
379
387
  });
380
- expect(route.children[0].routes).toHaveLength(1);
381
- expect(route.children[0].routes?.[0]).toMatchObject({
388
+ expect(route.children[1].routes).toHaveLength(1);
389
+ expect(route.children[1].routes?.[0]).toMatchObject({
382
390
  path: '/admin/nested-flow-page',
383
391
  _runtimePath: '/apps/demo/v2/admin/nested-flow-page',
384
392
  _navigationMode: 'spa',
385
393
  _isLegacy: false,
386
394
  });
387
- expect(route.children[1]).toMatchObject({
395
+ expect(route.children[2]).toMatchObject({
388
396
  path: '/admin/__admin_layout__/link/4',
389
397
  });
390
398
  });
@@ -28,6 +28,9 @@ import {
28
28
  GLOBAL_EMBED_CONTAINER_ID,
29
29
  EMBED_REPLACING_DATA_KEY,
30
30
  shouldHideEventInSettings,
31
+ DetachedFlowRegistry,
32
+ replaceFlowRegistry,
33
+ serializeFlowRegistry,
31
34
  } from '@nocobase/flow-engine';
32
35
  import { Collapse, Input, Button, Space, Tooltip, Empty, Dropdown, Select } from 'antd';
33
36
  import { uid } from '@formily/shared';
@@ -35,6 +38,9 @@ import { useUpdate } from 'ahooks';
35
38
  import _ from 'lodash';
36
39
 
37
40
  type FlowOnObject = Exclude<FlowDefinition['on'], string | undefined>;
41
+ type FlowRegistryAvailability = {
42
+ hasFlow(flowKey: string): boolean;
43
+ };
38
44
 
39
45
  function isFlowOnObject(on: FlowDefinition['on']): on is FlowOnObject {
40
46
  return !!on && typeof on === 'object';
@@ -93,7 +99,7 @@ function validateFlowOnPhase(onObj: FlowOnObject): 'flowKey' | 'stepKey' | undef
93
99
 
94
100
  export const DynamicFlowsIcon: React.FC<{ model: FlowModel }> = (props) => {
95
101
  const { model } = props;
96
- const t = model.translate.bind(model);
102
+ const t = React.useMemo(() => model.translate.bind(model), [model]);
97
103
 
98
104
  const handleClick = () => {
99
105
  const target = document.querySelector<HTMLDivElement>(`#${GLOBAL_EMBED_CONTAINER_ID}`);
@@ -190,7 +196,17 @@ const FieldLabel = ({
190
196
 
191
197
  // 事件配置组件 - 独立的 observer 组件确保响应式更新
192
198
  const EventConfigSection = observer(
193
- ({ flow, model, flowEngine }: { flow: FlowDefinition; model: FlowModel; flowEngine: any }) => {
199
+ ({
200
+ flow,
201
+ model,
202
+ flowEngine,
203
+ flowRegistry,
204
+ }: {
205
+ flow: FlowDefinition;
206
+ model: FlowModel;
207
+ flowEngine: any;
208
+ flowRegistry: FlowRegistryAvailability;
209
+ }) => {
194
210
  const ctx = useFlowContext<FlowEngineContext>();
195
211
  const t = model.translate.bind(model);
196
212
  const refresh = useUpdate();
@@ -270,9 +286,9 @@ const EventConfigSection = observer(
270
286
  return staticFlows.map((f) => ({
271
287
  value: f.key,
272
288
  label: formatKeyWithTitle(String(f.key), f.title),
273
- disabled: model.flowRegistry.hasFlow(f.key),
289
+ disabled: flowRegistry.hasFlow(f.key),
274
290
  }));
275
- }, [formatKeyWithTitle, model.flowRegistry, staticFlows]);
291
+ }, [flowRegistry, formatKeyWithTitle, staticFlows]);
276
292
 
277
293
  const stepOptions = React.useMemo(() => {
278
294
  if (!flowKeyValue) return [];
@@ -446,12 +462,58 @@ const DynamicFlowsEditor = observer((props: { model: FlowModel }) => {
446
462
  const { model } = props;
447
463
  const ctx = useFlowContext<FlowEngineContext>();
448
464
  const flowEngine = model.flowEngine;
465
+ const latestFlows = serializeFlowRegistry(model.flowRegistry);
466
+ const initialFlowsRef = React.useRef(latestFlows);
467
+ const [draftFlowRegistry] = React.useState(() => new DetachedFlowRegistry(latestFlows));
449
468
  const [submitLoading, setSubmitLoading] = React.useState(false);
450
- const t = model.translate.bind(model);
469
+ const t = React.useMemo(() => model.translate.bind(model), [model]);
470
+ const hasUnsavedChanges = React.useCallback(() => {
471
+ return !_.isEqual(initialFlowsRef.current, serializeFlowRegistry(draftFlowRegistry));
472
+ }, [draftFlowRegistry]);
473
+
474
+ React.useEffect(() => {
475
+ if (_.isEqual(initialFlowsRef.current, latestFlows) || hasUnsavedChanges()) {
476
+ return;
477
+ }
478
+
479
+ replaceFlowRegistry(draftFlowRegistry, latestFlows);
480
+ initialFlowsRef.current = latestFlows;
481
+ }, [draftFlowRegistry, hasUnsavedChanges, latestFlows]);
482
+
483
+ React.useEffect(() => {
484
+ const view = ctx.view;
485
+ const previousBeforeClose = view.beforeClose;
486
+ const beforeClose = async (payload) => {
487
+ 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;
495
+
496
+ if (!confirmed) {
497
+ return false;
498
+ }
499
+ }
500
+
501
+ const result = await previousBeforeClose?.(payload);
502
+ return result !== false;
503
+ };
504
+
505
+ view.beforeClose = beforeClose;
506
+
507
+ return () => {
508
+ if (view.beforeClose === beforeClose) {
509
+ view.beforeClose = previousBeforeClose;
510
+ }
511
+ };
512
+ }, [ctx, hasUnsavedChanges, t]);
451
513
 
452
514
  // 添加新流
453
515
  const handleAddFlow = () => {
454
- model.flowRegistry.addFlow(uid(), {
516
+ draftFlowRegistry.addFlow(uid(), {
455
517
  title: t('Event flow'),
456
518
  steps: {},
457
519
  });
@@ -516,7 +578,7 @@ const DynamicFlowsEditor = observer((props: { model: FlowModel }) => {
516
578
  );
517
579
 
518
580
  // 生成折叠面板项
519
- const collapseItems = model.flowRegistry.mapFlows((flow) => {
581
+ const collapseItems = draftFlowRegistry.mapFlows((flow) => {
520
582
  return {
521
583
  key: flow.key,
522
584
  label: renderPanelHeader(flow),
@@ -529,7 +591,7 @@ const DynamicFlowsEditor = observer((props: { model: FlowModel }) => {
529
591
  children: (
530
592
  <div>
531
593
  {/* 事件部分 */}
532
- <EventConfigSection flow={flow} model={model} flowEngine={flowEngine} />
594
+ <EventConfigSection flow={flow} model={model} flowEngine={flowEngine} flowRegistry={draftFlowRegistry} />
533
595
 
534
596
  {/* 步骤部分 */}
535
597
  <div>
@@ -670,13 +732,13 @@ const DynamicFlowsEditor = observer((props: { model: FlowModel }) => {
670
732
  flexShrink: 0,
671
733
  }}
672
734
  >
673
- <Button onClick={() => ctx.view.destroy()}>{t('Cancel')}</Button>
735
+ <Button onClick={() => ctx.view.close()}>{t('Cancel')}</Button>
674
736
  <Button
675
737
  type="primary"
676
738
  loading={submitLoading}
677
739
  onClick={async () => {
678
740
  setSubmitLoading(true);
679
- const invalid = model.flowRegistry
741
+ const invalid = draftFlowRegistry
680
742
  .mapFlows((flow) => {
681
743
  if (!isFlowOnObject(flow.on)) return;
682
744
  normalizeFlowOnPhase(flow.on);
@@ -695,10 +757,11 @@ const DynamicFlowsEditor = observer((props: { model: FlowModel }) => {
695
757
  setSubmitLoading(false);
696
758
  return;
697
759
  }
698
- try {
699
- const afterSaves: Array<() => Promise<void>> = [];
760
+ const previousFlows = serializeFlowRegistry(model.flowRegistry);
761
+ const afterSaves: Array<() => Promise<void>> = [];
700
762
 
701
- for (const flow of model.flowRegistry.mapFlows((it) => it)) {
763
+ try {
764
+ for (const flow of draftFlowRegistry.mapFlows((it) => it)) {
702
765
  for (const step of flow.mapSteps((it) => it)) {
703
766
  const serialized = step.serialize();
704
767
  const actionDef = step.use ? model.getAction(step.use) : undefined;
@@ -731,8 +794,19 @@ const DynamicFlowsEditor = observer((props: { model: FlowModel }) => {
731
794
  }
732
795
  }
733
796
 
797
+ replaceFlowRegistry(model.flowRegistry, serializeFlowRegistry(draftFlowRegistry));
734
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
+ }
735
806
 
807
+ initialFlowsRef.current = serializeFlowRegistry(model.flowRegistry);
808
+
809
+ try {
736
810
  for (const runAfterSave of afterSaves) {
737
811
  await runAfterSave();
738
812
  }
@@ -10,7 +10,7 @@
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 { FlowEngine, FlowModel, GLOBAL_EMBED_CONTAINER_ID } from '@nocobase/flow-engine';
13
+ import { ActionScene, FlowEngine, FlowModel, GLOBAL_EMBED_CONTAINER_ID } from '@nocobase/flow-engine';
14
14
  import { DynamicFlowsIcon } from '../DynamicFlowsIcon';
15
15
 
16
16
  const mockState = vi.hoisted(() => ({
@@ -30,15 +30,31 @@ vi.mock('antd', async (importOriginal) => {
30
30
  const actual = await importOriginal<typeof import('antd')>();
31
31
  return {
32
32
  ...actual,
33
- Button: ({ children, onClick, ...props }: any) => (
33
+ Button: ({ children, onClick, icon: _icon, loading: _loading, ...props }: any) => (
34
34
  <button type="button" onClick={onClick} {...props}>
35
35
  {children}
36
36
  </button>
37
37
  ),
38
38
  Collapse: ({ items }: any) => (
39
- <div data-testid="collapse">{items?.map((item: any) => <div key={item.key}>{item.children}</div>)}</div>
39
+ <div data-testid="collapse">
40
+ {items?.map((item: any) => (
41
+ <div key={item.key}>
42
+ {item.label}
43
+ {item.children}
44
+ </div>
45
+ ))}
46
+ </div>
47
+ ),
48
+ Dropdown: ({ children, menu }: any) => (
49
+ <div>
50
+ {children}
51
+ {menu?.items?.map((item: any) => (
52
+ <button key={item.key} type="button" onClick={item.onClick}>
53
+ {item.label}
54
+ </button>
55
+ ))}
56
+ </div>
40
57
  ),
41
- Dropdown: ({ children }: any) => <div>{children}</div>,
42
58
  Empty: ({ description }: any) => <div>{description}</div>,
43
59
  Input: (props: any) => <input {...props} />,
44
60
  Select: (props: any) => {
@@ -70,10 +86,16 @@ const openDynamicFlowsEditor = async (model: FlowModel) => {
70
86
  const embedCall = (model.context.viewer.embed as any).mock.calls.at(-1)?.[0];
71
87
  expect(embedCall?.content).toBeTruthy();
72
88
 
73
- render(embedCall.content);
89
+ const editor = render(embedCall.content);
90
+
91
+ return {
92
+ embedCall,
93
+ view: mockState.flowContextValue.view,
94
+ editor,
95
+ };
74
96
  };
75
97
 
76
- const createModel = (options: { preventClose?: boolean; hiddenClose?: boolean } = {}) => {
98
+ const createModel = (options: { preventClose?: boolean; hiddenClose?: boolean; saveStepParams?: any } = {}) => {
77
99
  const engine = new FlowEngine();
78
100
  engine.translate = vi.fn((key: string) => key) as any;
79
101
  engine.flowSettings.renderStepForm = vi.fn(() => null) as any;
@@ -95,6 +117,14 @@ const createModel = (options: { preventClose?: boolean; hiddenClose?: boolean }
95
117
  handler: vi.fn(),
96
118
  },
97
119
  });
120
+ LocalTestModel.registerActions({
121
+ notify: {
122
+ name: 'notify',
123
+ title: 'Notify',
124
+ scene: ActionScene.DYNAMIC_EVENT_FLOW,
125
+ handler: vi.fn(),
126
+ },
127
+ });
98
128
 
99
129
  const model = new LocalTestModel({
100
130
  uid: `test-model-${Math.random().toString(36).slice(2, 8)}`,
@@ -122,17 +152,55 @@ const createModel = (options: { preventClose?: boolean; hiddenClose?: boolean }
122
152
  destroy: vi.fn(),
123
153
  },
124
154
  });
155
+ model.context.defineProperty('message', {
156
+ value: {
157
+ error: vi.fn(),
158
+ success: vi.fn(),
159
+ },
160
+ });
161
+ vi.spyOn(model, 'saveStepParams').mockImplementation(options.saveStepParams || vi.fn(async () => undefined));
125
162
 
126
163
  return model;
127
164
  };
128
165
 
166
+ const createView = () => {
167
+ let destroyed = false;
168
+ let closingPromise: Promise<boolean | void> | undefined;
169
+ const view = {
170
+ close: vi.fn(function () {
171
+ if (destroyed) {
172
+ return Promise.resolve(true);
173
+ }
174
+ if (closingPromise) {
175
+ return closingPromise;
176
+ }
177
+ closingPromise = (async () => {
178
+ const allowed = await view.beforeClose?.({});
179
+ if (allowed === false) {
180
+ closingPromise = undefined;
181
+ return false;
182
+ }
183
+ view.destroy();
184
+ return true;
185
+ })();
186
+ return closingPromise;
187
+ }),
188
+ destroy: vi.fn(() => {
189
+ destroyed = true;
190
+ }),
191
+ beforeClose: undefined as any,
192
+ };
193
+ return view;
194
+ };
195
+
129
196
  describe('DynamicFlowsIcon', () => {
130
197
  beforeEach(() => {
131
198
  mockState.capturedSelectProps.length = 0;
132
199
  mockState.flowContextValue = {
133
- view: {
134
- destroy: vi.fn(),
200
+ modal: {
201
+ confirm: vi.fn(),
135
202
  },
203
+ view: createView(),
136
204
  };
137
205
  document.body.innerHTML = `<div id="${GLOBAL_EMBED_CONTAINER_ID}"></div>`;
138
206
  });
@@ -190,4 +258,123 @@ describe('DynamicFlowsIcon', () => {
190
258
  expect(screen.getByTestId('collapse')).toBeInTheDocument();
191
259
  });
192
260
  });
261
+
262
+ it('keeps added event flows in draft until saved', async () => {
263
+ const model = createModel();
264
+
265
+ await openDynamicFlowsEditor(model);
266
+ await userEvent.click(screen.getByRole('button', { name: 'Add event flow' }));
267
+
268
+ expect(model.flowRegistry.getFlows().size).toBe(1);
269
+ expect(model.flowRegistry.hasFlow('flow1')).toBe(true);
270
+ });
271
+
272
+ it('blocks cancel when draft has unsaved changes and confirmation is rejected', async () => {
273
+ const model = createModel();
274
+ mockState.flowContextValue.modal.confirm.mockResolvedValue(false);
275
+ const { view } = await openDynamicFlowsEditor(model);
276
+
277
+ await userEvent.click(screen.getByRole('button', { name: 'Add event flow' }));
278
+ await userEvent.click(screen.getByRole('button', { name: 'Cancel' }));
279
+
280
+ expect(mockState.flowContextValue.modal.confirm).toHaveBeenCalledWith({
281
+ title: 'Unsaved changes',
282
+ content: "Are you sure you don't want to save?",
283
+ okText: 'Confirm',
284
+ cancelText: 'Cancel',
285
+ });
286
+ expect(view.destroy).not.toHaveBeenCalled();
287
+ expect(model.flowRegistry.getFlows().size).toBe(1);
288
+ });
289
+
290
+ it('runs only one unsaved confirmation while cancel is pending', async () => {
291
+ const model = createModel();
292
+ let resolveConfirm: (value: boolean) => void;
293
+ mockState.flowContextValue.modal.confirm.mockImplementation(
294
+ () => new Promise<boolean>((resolve) => (resolveConfirm = resolve)),
295
+ );
296
+ const { view } = await openDynamicFlowsEditor(model);
297
+
298
+ await userEvent.click(screen.getByRole('button', { name: 'Add event flow' }));
299
+ const cancelButton = screen.getByRole('button', { name: 'Cancel' });
300
+ await userEvent.click(cancelButton);
301
+ await userEvent.click(cancelButton);
302
+
303
+ expect(mockState.flowContextValue.modal.confirm).toHaveBeenCalledTimes(1);
304
+
305
+ resolveConfirm(true);
306
+ await waitFor(() => expect(view.destroy).toHaveBeenCalledTimes(1));
307
+ });
308
+
309
+ it('discards draft changes after confirmed cancel', async () => {
310
+ const model = createModel();
311
+ mockState.flowContextValue.modal.confirm.mockResolvedValue(true);
312
+ const { view } = await openDynamicFlowsEditor(model);
313
+
314
+ await userEvent.click(screen.getByRole('button', { name: 'Add event flow' }));
315
+ await userEvent.click(screen.getByRole('button', { name: 'Cancel' }));
316
+
317
+ expect(view.destroy).toHaveBeenCalledTimes(1);
318
+ expect(model.flowRegistry.getFlows().size).toBe(1);
319
+ expect(model.flowRegistry.hasFlow('flow1')).toBe(true);
320
+ });
321
+
322
+ it('discards existing event flow edits after confirmed cancel', async () => {
323
+ const model = createModel();
324
+ mockState.flowContextValue.modal.confirm.mockResolvedValue(true);
325
+ const { view } = await openDynamicFlowsEditor(model);
326
+
327
+ const titleInput = screen.getByPlaceholderText('Enter flow title');
328
+ await userEvent.clear(titleInput);
329
+ await userEvent.type(titleInput, 'Changed flow');
330
+ await userEvent.click(screen.getByRole('button', { name: 'Cancel' }));
331
+
332
+ expect(view.destroy).toHaveBeenCalledTimes(1);
333
+ expect(model.flowRegistry.getFlow('flow1')?.title).toBe('Event flow');
334
+ });
335
+
336
+ it('discards added draft steps after confirmed cancel', async () => {
337
+ const model = createModel();
338
+ mockState.flowContextValue.modal.confirm.mockResolvedValue(true);
339
+ const { view } = await openDynamicFlowsEditor(model);
340
+
341
+ expect(model.flowRegistry.getFlow('flow1')?.getSteps().size).toBe(0);
342
+
343
+ await userEvent.click(screen.getByRole('button', { name: 'Notify' }));
344
+ await userEvent.click(screen.getByRole('button', { name: 'Cancel' }));
345
+
346
+ expect(view.destroy).toHaveBeenCalledTimes(1);
347
+ expect(model.flowRegistry.getFlow('flow1')?.getSteps().size).toBe(0);
348
+ });
349
+
350
+ it('does not keep discarded draft changes after reopening the editor', async () => {
351
+ const model = createModel();
352
+ mockState.flowContextValue.modal.confirm.mockResolvedValue(true);
353
+
354
+ const firstEditor = await openDynamicFlowsEditor(model);
355
+ await userEvent.click(screen.getByRole('button', { name: 'Add event flow' }));
356
+ await userEvent.click(screen.getByRole('button', { name: 'Cancel' }));
357
+ firstEditor.editor.unmount();
358
+
359
+ mockState.capturedSelectProps.length = 0;
360
+ mockState.flowContextValue.view = createView();
361
+ const secondEditor = await openDynamicFlowsEditor(model);
362
+
363
+ expect(secondEditor.editor.container.querySelectorAll('input[placeholder="Enter flow title"]')).toHaveLength(1);
364
+ expect(model.flowRegistry.getFlows().size).toBe(1);
365
+ });
366
+
367
+ it('saves draft event flows into the real registry', async () => {
368
+ const saveStepParams = vi.fn(async () => undefined);
369
+ const model = createModel({ saveStepParams });
370
+ const { view } = await openDynamicFlowsEditor(model);
371
+
372
+ await userEvent.click(screen.getByRole('button', { name: 'Add event flow' }));
373
+ await userEvent.click(screen.getByRole('button', { name: 'Save' }));
374
+
375
+ expect(saveStepParams).toHaveBeenCalledTimes(1);
376
+ expect(model.flowRegistry.getFlows().size).toBe(2);
377
+ expect(view.destroy).toHaveBeenCalledTimes(1);
378
+ expect(model.context.message.success).toHaveBeenCalledWith('Configuration saved');
379
+ });
193
380
  });
@@ -42,6 +42,7 @@ interface CodeEditorProps {
42
42
  language?: string;
43
43
  scene?: string | string[];
44
44
  RightExtra?: React.FC<any>;
45
+ showLogs?: boolean;
45
46
  }
46
47
 
47
48
  export * from './types';
@@ -64,6 +65,7 @@ export const CodeEditor: React.FC<CodeEditorProps> = ({
64
65
  language,
65
66
  scene,
66
67
  RightExtra,
68
+ showLogs = true,
67
69
  }) => {
68
70
  const wrapperRef = useRef<HTMLDivElement>(null);
69
71
  const viewRef = useRef<EditorView | null>(null);
@@ -249,14 +251,16 @@ export const CodeEditor: React.FC<CodeEditorProps> = ({
249
251
  completionSource={completionSource}
250
252
  viewRef={viewRef}
251
253
  />
252
- <LogsPanel
253
- logs={logs}
254
- onJumpTo={(line, column) => {
255
- const view = viewRef.current;
256
- if (view) jumpTo(view, line, column);
257
- }}
258
- tr={tr}
259
- />
254
+ {showLogs ? (
255
+ <LogsPanel
256
+ logs={logs}
257
+ onJumpTo={(line, column) => {
258
+ const view = viewRef.current;
259
+ if (view) jumpTo(view, line, column);
260
+ }}
261
+ tr={tr}
262
+ />
263
+ ) : null}
260
264
  <SnippetsDrawer
261
265
  open={snippetOpen}
262
266
  onClose={() => setSnippetOpen(false)}
@@ -162,7 +162,7 @@ RootPageModel.registerFlow({
162
162
  const route = ctx.routeRepository.getRouteBySchemaUid(ctx.model.parentId);
163
163
  ctx.model.setProps('routeId', route?.id);
164
164
  const routes: NocoBaseDesktopRoute[] = _.castArray(route?.children);
165
- for (const route of routes.sort((a, b) => a.sort - b.sort)) {
165
+ for (const route of routes.filter(Boolean).sort((a, b) => (a.sort || 0) - (b.sort || 0))) {
166
166
  // 过滤掉隐藏的路由
167
167
  if (route.hideInMenu) {
168
168
  continue;
@@ -18,6 +18,7 @@ function createMockFieldModel(options: { uid: string; props?: Record<string, any
18
18
  const model: any = {
19
19
  uid: options.uid,
20
20
  props: { ...(options.props || {}) },
21
+ _options: { props: { ...(options.props || {}) } },
21
22
  stepParams: { ...(options.stepParams || {}) },
22
23
  emitter: { emit: vi.fn() },
23
24
  setProps(patch: any) {
@@ -97,7 +98,9 @@ describe('filter-form legacyDefaultValueMigration', () => {
97
98
  clearLegacyDefaultValuesFromFilterFormModel(filterFormModel);
98
99
 
99
100
  expect(field1.props.initialValue).toBeUndefined();
101
+ expect(field1._options.props.initialValue).toBeUndefined();
100
102
  expect(field1.props.keep).toBe(true);
103
+ expect(field1._options.props.keep).toBe(true);
101
104
  expect(field1.stepParams.filterFormItemSettings?.initialValue).toBeUndefined();
102
105
  expect(field1.stepParams.otherFlow?.s?.x).toBe(1);
103
106
 
@@ -94,8 +94,6 @@ FormSubmitActionModel.registerFlow({
94
94
  ctx.model.setProps('loading', true);
95
95
  const { submitHandler } = await import('./submitHandler');
96
96
  await submitHandler(ctx, params);
97
- ctx.message.success(ctx.t('Saved successfully'));
98
- ctx.model.setProps('loading', false);
99
97
  } catch (error) {
100
98
  ctx.model.setProps('loading', false);
101
99
  // 显示保存失败提示
@@ -107,12 +105,8 @@ FormSubmitActionModel.registerFlow({
107
105
  }
108
106
  },
109
107
  },
110
- refreshAndClose: {
111
- async handler(ctx) {
112
- if (ctx.view) {
113
- ctx.view.close();
114
- }
115
- },
108
+ afterSuccess: {
109
+ use: 'afterSuccess',
116
110
  },
117
111
  },
118
112
  });
@@ -431,7 +431,7 @@ FormItemModel.registerFlow({
431
431
  },
432
432
  defaultParams: (ctx: any) => {
433
433
  const titleField =
434
- ctx.model.props.titleField || ctx.model.context.collectionField.targetCollectionTitleFieldName;
434
+ ctx.model.props.titleField || ctx.model?.context?.collectionField?.targetCollectionTitleFieldName;
435
435
  return {
436
436
  titleField: titleField,
437
437
  };
@@ -18,6 +18,7 @@ function createMockFieldModel(options: { uid: string; props?: Record<string, any
18
18
  const model: any = {
19
19
  uid: options.uid,
20
20
  props: { ...(options.props || {}) },
21
+ _options: { props: { ...(options.props || {}) } },
21
22
  stepParams: { ...(options.stepParams || {}) },
22
23
  emitter: { emit: vi.fn() },
23
24
  setProps(patch: any) {
@@ -124,7 +125,9 @@ describe('legacyDefaultValueMigration', () => {
124
125
 
125
126
  // field1: props.initialValue removed, stepParams cleared for initialValue
126
127
  expect(field1.props.initialValue).toBeUndefined();
128
+ expect(field1._options.props.initialValue).toBeUndefined();
127
129
  expect(field1.props.keep).toBe(true);
130
+ expect(field1._options.props.keep).toBe(true);
128
131
  expect(field1.stepParams.editItemSettings?.initialValue).toBeUndefined();
129
132
  expect(field1.stepParams.otherFlow?.s?.x).toBe(1);
130
133
  // field2: legacy flow cleared
@@ -23,6 +23,11 @@ export interface LegacyClearer {
23
23
  (model: any): void;
24
24
  }
25
25
 
26
+ export function hasPersistedAssignRulesValue(model: any, flowKey: string, stepKey: string): boolean {
27
+ const params = model?.getStepParams?.(flowKey, stepKey);
28
+ return !!params && Object.prototype.hasOwnProperty.call(params, 'value');
29
+ }
30
+
26
31
  function getPropsInitialValue(model: any): any | undefined {
27
32
  if (!model) return undefined;
28
33
  const props = typeof model.getProps === 'function' ? model.getProps() : model.props;
@@ -70,15 +75,26 @@ function deleteStepParams(model: any, flowKey: string, stepKey: string) {
70
75
  model.emitter?.emit?.('onStepParamsChanged');
71
76
  }
72
77
 
78
+ function deletePropsInitialValue(model: any) {
79
+ if (!model) return;
80
+
81
+ model.setProps?.({ initialValue: undefined });
82
+
83
+ if (model.props && Object.prototype.hasOwnProperty.call(model.props, 'initialValue')) {
84
+ delete model.props.initialValue;
85
+ }
86
+
87
+ const optionsProps = model._options?.props;
88
+ if (optionsProps && Object.prototype.hasOwnProperty.call(optionsProps, 'initialValue')) {
89
+ delete optionsProps.initialValue;
90
+ }
91
+ }
92
+
73
93
  export function createLegacyClearer(flowKeys: string[]): LegacyClearer {
74
94
  return (model: any): void => {
75
95
  if (!model) return;
76
96
 
77
- model.setProps?.({ initialValue: undefined });
78
-
79
- if (model.props && Object.prototype.hasOwnProperty.call(model.props, 'initialValue')) {
80
- delete model.props.initialValue;
81
- }
97
+ deletePropsInitialValue(model);
82
98
 
83
99
  for (const flowKey of flowKeys) {
84
100
  deleteStepParams(model, flowKey, 'initialValue');