@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.
- package/es/flow/components/code-editor/index.d.ts +1 -0
- package/es/flow/models/blocks/shared/legacyDefaultValueMigrationBase.d.ts +1 -0
- package/es/flow/models/fields/AssociationFieldModel/SubTableFieldModel/SubTableColumnModel.d.ts +4 -0
- package/es/flow/models/fields/ClickableFieldModel.d.ts +3 -0
- package/es/flow/models/fields/DisplayEnumFieldModel.d.ts +6 -0
- package/es/flow/models/fields/DisplayTitleFieldModel.d.ts +2 -2
- package/es/index.mjs +76 -76
- package/lib/index.js +67 -67
- package/package.json +8 -5
- package/src/__tests__/globalDeps.test.ts +1 -0
- package/src/flow/actions/__tests__/formAssignRules.legacyMigration.test.tsx +173 -0
- package/src/flow/actions/__tests__/pattern.test.ts +134 -0
- package/src/flow/actions/__tests__/titleField.test.ts +45 -0
- package/src/flow/actions/filterFormDefaultValues.tsx +30 -9
- package/src/flow/actions/formAssignRules.tsx +24 -9
- package/src/flow/actions/pattern.tsx +41 -6
- package/src/flow/actions/titleField.tsx +4 -2
- package/src/flow/actions/validation.tsx +1 -1
- package/src/flow/admin-shell/admin-layout/AdminLayoutMenuModels.tsx +0 -4
- package/src/flow/admin-shell/admin-layout/__tests__/AdminLayoutMenuModels.test.ts +13 -5
- package/src/flow/components/DynamicFlowsIcon.tsx +87 -13
- package/src/flow/components/__tests__/DynamicFlowsIcon.test.tsx +195 -8
- package/src/flow/components/code-editor/index.tsx +12 -8
- package/src/flow/models/base/PageModel/RootPageModel.tsx +1 -1
- package/src/flow/models/blocks/filter-form/__tests__/legacyDefaultValueMigration.test.ts +3 -0
- package/src/flow/models/blocks/form/FormActionModel.tsx +2 -8
- package/src/flow/models/blocks/form/FormItemModel.tsx +1 -1
- package/src/flow/models/blocks/form/__tests__/legacyDefaultValueMigration.test.ts +3 -0
- package/src/flow/models/blocks/shared/legacyDefaultValueMigrationBase.ts +21 -5
- package/src/flow/models/blocks/table/TableColumnModel.tsx +5 -2
- package/src/flow/models/blocks/table/__tests__/TableColumnModel.test.tsx +36 -0
- package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/SubTableColumnModel.tsx +144 -3
- package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/__tests__/SubTableColumnModel.rowRecord.test.ts +170 -1
- package/src/flow/models/fields/ClickableFieldModel.tsx +46 -2
- package/src/flow/models/fields/DisplayEnumFieldModel.tsx +44 -0
- package/src/flow/models/fields/DisplayTitleFieldModel.tsx +40 -15
- package/src/flow/models/fields/__tests__/ClickableFieldModel.test.ts +180 -2
- package/src/flow/models/fields/__tests__/DisplayEnumFieldModel.test.tsx +39 -0
- package/src/utils/globalDeps.ts +9 -0
|
@@ -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(
|
|
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[
|
|
381
|
-
expect(route.children[
|
|
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[
|
|
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
|
-
({
|
|
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:
|
|
289
|
+
disabled: flowRegistry.hasFlow(f.key),
|
|
274
290
|
}));
|
|
275
|
-
}, [
|
|
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
|
-
|
|
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 =
|
|
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.
|
|
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 =
|
|
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
|
-
|
|
699
|
-
|
|
760
|
+
const previousFlows = serializeFlowRegistry(model.flowRegistry);
|
|
761
|
+
const afterSaves: Array<() => Promise<void>> = [];
|
|
700
762
|
|
|
701
|
-
|
|
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">
|
|
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
|
-
|
|
134
|
-
|
|
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
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
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
|
-
|
|
111
|
-
|
|
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
|
|
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
|
|
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');
|