@nocobase/flow-engine 2.0.0-alpha.27 → 2.0.0-alpha.29
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/lib/components/settings/wrappers/contextual/DefaultSettingsIcon.js +14 -2
- package/lib/locale/en-US.json +2 -1
- package/lib/locale/index.d.ts +2 -0
- package/lib/locale/zh-CN.json +2 -1
- package/package.json +4 -4
- package/src/components/settings/wrappers/contextual/DefaultSettingsIcon.tsx +15 -2
- package/src/components/settings/wrappers/contextual/__tests__/DefaultSettingsIcon.test.tsx +424 -0
- package/src/components/subModel/__tests__/AddSubModelButton.test.tsx +4 -7
- package/src/locale/en-US.json +2 -1
- package/src/locale/zh-CN.json +2 -1
|
@@ -103,12 +103,24 @@ const DefaultSettingsIcon = /* @__PURE__ */ __name(({
|
|
|
103
103
|
const dropdownMaxHeight = (0, import_hooks.useNiceDropdownMaxHeight)([visible]);
|
|
104
104
|
const copyUidToClipboard = (0, import_react.useCallback)(
|
|
105
105
|
async (uid) => {
|
|
106
|
+
var _a;
|
|
106
107
|
try {
|
|
107
108
|
await navigator.clipboard.writeText(uid);
|
|
108
109
|
message.success(t("UID copied to clipboard"));
|
|
109
110
|
} catch (error) {
|
|
110
111
|
console.error(t("Copy failed"), ":", error);
|
|
111
|
-
|
|
112
|
+
const isHttps = typeof window !== "undefined" && ((_a = window.location) == null ? void 0 : _a.protocol) === "https:";
|
|
113
|
+
if (!isHttps) {
|
|
114
|
+
message.error(
|
|
115
|
+
t(
|
|
116
|
+
"Copy failed under HTTP. Clipboard API is unavailable on non-HTTPS pages. Please copy [{{uid}}] manually.",
|
|
117
|
+
{ uid }
|
|
118
|
+
)
|
|
119
|
+
);
|
|
120
|
+
return;
|
|
121
|
+
} else {
|
|
122
|
+
message.error(t("Copy failed, please copy [{{uid}}] manually.", { uid }));
|
|
123
|
+
}
|
|
112
124
|
}
|
|
113
125
|
},
|
|
114
126
|
[message, t]
|
|
@@ -216,7 +228,7 @@ const DefaultSettingsIcon = /* @__PURE__ */ __name(({
|
|
|
216
228
|
const getModelConfigurableFlowsAndSteps = (0, import_react.useCallback)(
|
|
217
229
|
async (targetModel, modelKey) => {
|
|
218
230
|
try {
|
|
219
|
-
const flows = targetModel.getFlows();
|
|
231
|
+
const flows = targetModel.constructor.globalFlowRegistry.getFlows();
|
|
220
232
|
const flowsArray = Array.from(flows.values());
|
|
221
233
|
const flowsWithSteps = await Promise.all(
|
|
222
234
|
flowsArray.map(async (flow) => {
|
package/lib/locale/en-US.json
CHANGED
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
"UID copied to clipboard": "UID copied to clipboard",
|
|
24
24
|
"Copy failed": "Copy failed",
|
|
25
25
|
"Copy failed, please copy [{{uid}}] manually.": "Copy failed, please copy [{{uid}}] manually.",
|
|
26
|
+
"Copy failed under HTTP. Clipboard API is unavailable on non-HTTPS pages. Please copy [{{uid}}] manually.": "Copy failed under HTTP. Clipboard API is unavailable on non-HTTPS pages. Please copy [{{uid}}] manually.",
|
|
26
27
|
"Confirm delete": "Confirm delete",
|
|
27
28
|
"Are you sure you want to delete this item? This action cannot be undone.": "Are you sure you want to delete this item? This action cannot be undone.",
|
|
28
29
|
"Delete operation failed": "Delete operation failed",
|
|
@@ -59,4 +60,4 @@
|
|
|
59
60
|
"Common actions": "Common actions",
|
|
60
61
|
"This variable is not available": "This variable is not available",
|
|
61
62
|
"Copy popup UID": "Copy popup UID"
|
|
62
|
-
}
|
|
63
|
+
}
|
package/lib/locale/index.d.ts
CHANGED
|
@@ -32,6 +32,7 @@ export declare const locales: {
|
|
|
32
32
|
"UID copied to clipboard": string;
|
|
33
33
|
"Copy failed": string;
|
|
34
34
|
"Copy failed, please copy [{{uid}}] manually.": string;
|
|
35
|
+
"Copy failed under HTTP. Clipboard API is unavailable on non-HTTPS pages. Please copy [{{uid}}] manually.": string;
|
|
35
36
|
"Confirm delete": string;
|
|
36
37
|
"Are you sure you want to delete this item? This action cannot be undone.": string;
|
|
37
38
|
"Delete operation failed": string;
|
|
@@ -94,6 +95,7 @@ export declare const locales: {
|
|
|
94
95
|
"UID copied to clipboard": string;
|
|
95
96
|
"Copy failed": string;
|
|
96
97
|
"Copy failed, please copy [{{uid}}] manually.": string;
|
|
98
|
+
"Copy failed under HTTP. Clipboard API is unavailable on non-HTTPS pages. Please copy [{{uid}}] manually.": string;
|
|
97
99
|
"Confirm delete": string;
|
|
98
100
|
"Are you sure you want to delete this item? This action cannot be undone.": string;
|
|
99
101
|
"Delete operation failed": string;
|
package/lib/locale/zh-CN.json
CHANGED
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
"UID copied to clipboard": "UID 已复制到剪贴板",
|
|
24
24
|
"Copy failed": "复制失败",
|
|
25
25
|
"Copy failed, please copy [{{uid}}] manually.": "复制失败,请手动复制 [{{uid}}]。",
|
|
26
|
+
"Copy failed under HTTP. Clipboard API is unavailable on non-HTTPS pages. Please copy [{{uid}}] manually.": "HTTP 环境下复制失败。非 HTTPS 页面不支持剪贴板 API,请手动复制 [{{uid}}]。",
|
|
26
27
|
"Confirm delete": "确认删除",
|
|
27
28
|
"Are you sure you want to delete this item? This action cannot be undone.": "确定要删除此项吗?此操作不可撤销。",
|
|
28
29
|
"Delete operation failed": "删除操作失败",
|
|
@@ -59,4 +60,4 @@
|
|
|
59
60
|
"Common actions": "通用操作",
|
|
60
61
|
"This variable is not available": "此变量不可用",
|
|
61
62
|
"Copy popup UID": "复制弹窗 UID"
|
|
62
|
-
}
|
|
63
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nocobase/flow-engine",
|
|
3
|
-
"version": "2.0.0-alpha.
|
|
3
|
+
"version": "2.0.0-alpha.29",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "A standalone flow engine for NocoBase, managing workflows, models, and actions.",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -8,8 +8,8 @@
|
|
|
8
8
|
"dependencies": {
|
|
9
9
|
"@formily/antd-v5": "1.x",
|
|
10
10
|
"@formily/reactive": "2.x",
|
|
11
|
-
"@nocobase/sdk": "2.0.0-alpha.
|
|
12
|
-
"@nocobase/shared": "2.0.0-alpha.
|
|
11
|
+
"@nocobase/sdk": "2.0.0-alpha.29",
|
|
12
|
+
"@nocobase/shared": "2.0.0-alpha.29",
|
|
13
13
|
"ahooks": "^3.7.2",
|
|
14
14
|
"dompurify": "^3.0.2",
|
|
15
15
|
"lodash": "^4.x",
|
|
@@ -35,5 +35,5 @@
|
|
|
35
35
|
],
|
|
36
36
|
"author": "NocoBase Team",
|
|
37
37
|
"license": "AGPL-3.0",
|
|
38
|
-
"gitHead": "
|
|
38
|
+
"gitHead": "58961a6fbb9fe07572d863cf7119a1636011deae"
|
|
39
39
|
}
|
|
@@ -126,7 +126,19 @@ export const DefaultSettingsIcon: React.FC<DefaultSettingsIconProps> = ({
|
|
|
126
126
|
message.success(t('UID copied to clipboard'));
|
|
127
127
|
} catch (error) {
|
|
128
128
|
console.error(t('Copy failed'), ':', error);
|
|
129
|
-
|
|
129
|
+
// 如果不是 HTTPS 协议,给出更具体的提示:HTTP 下剪贴板 API 不可用
|
|
130
|
+
const isHttps = typeof window !== 'undefined' && window.location?.protocol === 'https:';
|
|
131
|
+
if (!isHttps) {
|
|
132
|
+
message.error(
|
|
133
|
+
t(
|
|
134
|
+
'Copy failed under HTTP. Clipboard API is unavailable on non-HTTPS pages. Please copy [{{uid}}] manually.',
|
|
135
|
+
{ uid },
|
|
136
|
+
),
|
|
137
|
+
);
|
|
138
|
+
return;
|
|
139
|
+
} else {
|
|
140
|
+
message.error(t('Copy failed, please copy [{{uid}}] manually.', { uid }));
|
|
141
|
+
}
|
|
130
142
|
}
|
|
131
143
|
},
|
|
132
144
|
[message, t],
|
|
@@ -255,7 +267,8 @@ export const DefaultSettingsIcon: React.FC<DefaultSettingsIconProps> = ({
|
|
|
255
267
|
const getModelConfigurableFlowsAndSteps = useCallback(
|
|
256
268
|
async (targetModel: FlowModel, modelKey?: string): Promise<FlowInfo[]> => {
|
|
257
269
|
try {
|
|
258
|
-
|
|
270
|
+
// 仅使用静态流(类级全局注册的 flows),排除实例动态流
|
|
271
|
+
const flows = (targetModel.constructor as typeof FlowModel).globalFlowRegistry.getFlows();
|
|
259
272
|
|
|
260
273
|
const flowsArray = Array.from(flows.values());
|
|
261
274
|
|
|
@@ -0,0 +1,424 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This file is part of the NocoBase (R) project.
|
|
3
|
+
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
|
4
|
+
* Authors: NocoBase Team.
|
|
5
|
+
*
|
|
6
|
+
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
|
7
|
+
* For more information, please refer to: https://www.nocobase.com/agreement.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import React from 'react';
|
|
11
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
12
|
+
import { render, cleanup, waitFor } from '@testing-library/react';
|
|
13
|
+
import { App, ConfigProvider } from 'antd';
|
|
14
|
+
|
|
15
|
+
import { FlowEngine } from '../../../../../flowEngine';
|
|
16
|
+
import { FlowModel } from '../../../../../models/flowModel';
|
|
17
|
+
import { DefaultSettingsIcon } from '../DefaultSettingsIcon';
|
|
18
|
+
|
|
19
|
+
// ---- Mock antd to capture Dropdown menu props ----
|
|
20
|
+
const dropdownMenus: any[] = [];
|
|
21
|
+
vi.mock('antd', async (importOriginal) => {
|
|
22
|
+
const Dropdown = (props: any) => {
|
|
23
|
+
(globalThis as any).__lastDropdownMenu = props.menu;
|
|
24
|
+
dropdownMenus.push(props.menu);
|
|
25
|
+
return React.createElement('span', { 'data-testid': 'dropdown' }, props.children);
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const App = Object.assign(({ children }: any) => React.createElement(React.Fragment, null, children), {
|
|
29
|
+
useApp: () => ({ message: { success: () => {}, error: () => {}, info: () => {} } }),
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
const ConfigProvider = ({ children }: any) => React.createElement(React.Fragment, null, children);
|
|
33
|
+
const Modal = {
|
|
34
|
+
confirm: (opts: any) => {
|
|
35
|
+
if (opts && typeof opts.onOk === 'function') return opts.onOk();
|
|
36
|
+
},
|
|
37
|
+
error: vi.fn(),
|
|
38
|
+
};
|
|
39
|
+
const Typography = {
|
|
40
|
+
Paragraph: ({ children }: any) => React.createElement('p', null, children ?? 'Paragraph'),
|
|
41
|
+
Text: ({ children }: any) => React.createElement('span', null, children ?? 'Text'),
|
|
42
|
+
};
|
|
43
|
+
const Collapse = Object.assign((props: any) => React.createElement('div', null, props.children ?? 'Collapse'), {
|
|
44
|
+
Panel: (props: any) => React.createElement('div', null, props.children ?? 'Panel'),
|
|
45
|
+
});
|
|
46
|
+
const Space = ({ children }: any) => React.createElement('div', null, children);
|
|
47
|
+
const FormItem = (props: any) => React.createElement('div', null, props.children ?? 'FormItem');
|
|
48
|
+
const Form = Object.assign((props: any) => React.createElement('form', null, props.children ?? 'Form'), {
|
|
49
|
+
Item: FormItem,
|
|
50
|
+
useForm: () => [{ setFieldsValue: (_: any) => {} }],
|
|
51
|
+
});
|
|
52
|
+
const Input: any = (props: any) => React.createElement('input', props);
|
|
53
|
+
Input.TextArea = (props: any) => React.createElement('textarea', props);
|
|
54
|
+
const InputNumber = (props: any) => React.createElement('input', { ...props, type: 'number' });
|
|
55
|
+
const Select = (props: any) => React.createElement('select', props);
|
|
56
|
+
const Switch = (props: any) => React.createElement('input', { ...props, type: 'checkbox' });
|
|
57
|
+
const Alert = (props: any) => React.createElement('div', { role: 'alert' }, props.message ?? 'Alert');
|
|
58
|
+
const Button = (props: any) => React.createElement('button', props, props.children ?? 'Button');
|
|
59
|
+
const Result = (props: any) => React.createElement('div', null, props.children ?? 'Result');
|
|
60
|
+
|
|
61
|
+
// Keep other components from original mock/default
|
|
62
|
+
return {
|
|
63
|
+
Dropdown,
|
|
64
|
+
App,
|
|
65
|
+
ConfigProvider,
|
|
66
|
+
Modal,
|
|
67
|
+
Typography,
|
|
68
|
+
Collapse,
|
|
69
|
+
Space,
|
|
70
|
+
Form,
|
|
71
|
+
Input,
|
|
72
|
+
InputNumber,
|
|
73
|
+
Select,
|
|
74
|
+
Switch,
|
|
75
|
+
Alert,
|
|
76
|
+
Button,
|
|
77
|
+
Result,
|
|
78
|
+
};
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
describe('DefaultSettingsIcon - only static flows are shown', () => {
|
|
82
|
+
beforeEach(() => {
|
|
83
|
+
dropdownMenus.length = 0;
|
|
84
|
+
(globalThis as any).__lastDropdownMenu = undefined;
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
afterEach(() => {
|
|
88
|
+
cleanup();
|
|
89
|
+
vi.clearAllMocks();
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('excludes instance (dynamic) flows from the settings menu', async () => {
|
|
93
|
+
class TestFlowModel extends FlowModel {}
|
|
94
|
+
|
|
95
|
+
const engine = new FlowEngine();
|
|
96
|
+
const model = new TestFlowModel({ uid: 'model-static-only', flowEngine: engine });
|
|
97
|
+
|
|
98
|
+
// register one static flow with a visible step
|
|
99
|
+
TestFlowModel.registerFlow({
|
|
100
|
+
key: 'static1',
|
|
101
|
+
title: 'Static Flow',
|
|
102
|
+
steps: {
|
|
103
|
+
general: {
|
|
104
|
+
title: 'General',
|
|
105
|
+
uiSchema: {
|
|
106
|
+
field: { type: 'string', 'x-component': 'Input' },
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
// add a dynamic (instance) flow which should NOT appear in menu
|
|
113
|
+
model.flowRegistry.addFlow('dyn1', {
|
|
114
|
+
title: 'Dynamic Flow',
|
|
115
|
+
steps: {
|
|
116
|
+
general: {
|
|
117
|
+
title: 'General (Dyn)',
|
|
118
|
+
uiSchema: {
|
|
119
|
+
field: { type: 'string', 'x-component': 'Input' },
|
|
120
|
+
},
|
|
121
|
+
},
|
|
122
|
+
},
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
render(
|
|
126
|
+
React.createElement(
|
|
127
|
+
ConfigProvider as any,
|
|
128
|
+
null,
|
|
129
|
+
React.createElement(
|
|
130
|
+
App as any,
|
|
131
|
+
null,
|
|
132
|
+
React.createElement(DefaultSettingsIcon as any, {
|
|
133
|
+
model,
|
|
134
|
+
// 关闭常用操作,避免干扰断言
|
|
135
|
+
showDeleteButton: false,
|
|
136
|
+
showCopyUidButton: false,
|
|
137
|
+
}),
|
|
138
|
+
),
|
|
139
|
+
),
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
// 等待菜单内出现静态流分组,确保异步加载完成
|
|
143
|
+
await waitFor(() => {
|
|
144
|
+
const menu = (globalThis as any).__lastDropdownMenu;
|
|
145
|
+
expect(menu).toBeTruthy();
|
|
146
|
+
const items = (menu?.items || []) as any[];
|
|
147
|
+
const groupLabels = items.filter((it) => it.type === 'group').map((it) => String(it.label));
|
|
148
|
+
expect(groupLabels).toContain('Static Flow');
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
const menu = (globalThis as any).__lastDropdownMenu;
|
|
152
|
+
const items = (menu?.items || []) as any[];
|
|
153
|
+
|
|
154
|
+
// groups for flows are labeled with flow.title; ensure static group exists, dynamic group不存在
|
|
155
|
+
const groupLabels = items.filter((it) => it.type === 'group').map((it) => String(it.label));
|
|
156
|
+
expect(groupLabels).toContain('Static Flow');
|
|
157
|
+
expect(groupLabels).not.toContain('Dynamic Flow');
|
|
158
|
+
|
|
159
|
+
// 静态流的 step 存在(key: `${flowKey}:${stepKey}`),动态流 step 不存在
|
|
160
|
+
expect(items.some((it) => String(it.key || '').startsWith('static1:'))).toBe(true);
|
|
161
|
+
expect(items.some((it) => String(it.key || '').startsWith('dyn1:'))).toBe(false);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('filters out steps with hideInSettings and keeps visible ones', async () => {
|
|
165
|
+
class TestFlowModel extends FlowModel {}
|
|
166
|
+
const engine = new FlowEngine();
|
|
167
|
+
const model = new TestFlowModel({ uid: 'm-hide', flowEngine: engine });
|
|
168
|
+
|
|
169
|
+
TestFlowModel.registerFlow({
|
|
170
|
+
key: 'flowA',
|
|
171
|
+
title: 'Flow A',
|
|
172
|
+
steps: {
|
|
173
|
+
hidden: { title: 'Hidden', hideInSettings: true, uiSchema: { a: { type: 'string', 'x-component': 'Input' } } },
|
|
174
|
+
visible: { title: 'Visible', uiSchema: { b: { type: 'string', 'x-component': 'Input' } } },
|
|
175
|
+
},
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
render(
|
|
179
|
+
React.createElement(
|
|
180
|
+
ConfigProvider as any,
|
|
181
|
+
null,
|
|
182
|
+
React.createElement(App as any, null, React.createElement(DefaultSettingsIcon as any, { model })),
|
|
183
|
+
),
|
|
184
|
+
);
|
|
185
|
+
|
|
186
|
+
await waitFor(() => {
|
|
187
|
+
const menu = (globalThis as any).__lastDropdownMenu;
|
|
188
|
+
const items = (menu?.items || []) as any[];
|
|
189
|
+
expect(items.some((it) => String(it.key || '') === 'flowA:visible')).toBe(true);
|
|
190
|
+
expect(items.some((it) => String(it.key || '') === 'flowA:hidden')).toBe(false);
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it('includes step when uiSchema provided by action (step.use)', async () => {
|
|
195
|
+
class TestFlowModel extends FlowModel {}
|
|
196
|
+
const engine = new FlowEngine();
|
|
197
|
+
const model = new TestFlowModel({ uid: 'm-action', flowEngine: engine });
|
|
198
|
+
|
|
199
|
+
// Step has no uiSchema but uses an action that provides uiSchema
|
|
200
|
+
TestFlowModel.registerFlow({
|
|
201
|
+
key: 'flowB',
|
|
202
|
+
title: 'Flow B',
|
|
203
|
+
steps: {
|
|
204
|
+
useAction: { title: 'Use Action', use: 'act' },
|
|
205
|
+
},
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
// Stub getAction to provide uiSchema
|
|
209
|
+
(model as any).getAction = vi.fn().mockReturnValue({
|
|
210
|
+
name: 'act',
|
|
211
|
+
title: 'Action Title',
|
|
212
|
+
uiSchema: { x: { type: 'string', 'x-component': 'Input' } },
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
render(
|
|
216
|
+
React.createElement(
|
|
217
|
+
ConfigProvider as any,
|
|
218
|
+
null,
|
|
219
|
+
React.createElement(App as any, null, React.createElement(DefaultSettingsIcon as any, { model })),
|
|
220
|
+
),
|
|
221
|
+
);
|
|
222
|
+
|
|
223
|
+
await waitFor(() => {
|
|
224
|
+
const menu = (globalThis as any).__lastDropdownMenu;
|
|
225
|
+
const items = (menu?.items || []) as any[];
|
|
226
|
+
expect(items.some((it) => String(it.key || '') === 'flowB:useAction')).toBe(true);
|
|
227
|
+
});
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it('clicking a step item opens flow settings with correct args', async () => {
|
|
231
|
+
class TestFlowModel extends FlowModel {}
|
|
232
|
+
const engine = new FlowEngine();
|
|
233
|
+
const model = new TestFlowModel({ uid: 'm-open', flowEngine: engine });
|
|
234
|
+
const openSpy = vi.spyOn(model, 'openFlowSettings').mockResolvedValue(undefined as any);
|
|
235
|
+
|
|
236
|
+
TestFlowModel.registerFlow({
|
|
237
|
+
key: 'flowC',
|
|
238
|
+
title: 'Flow C',
|
|
239
|
+
steps: {
|
|
240
|
+
general: { title: 'General', uiSchema: { f: { type: 'string', 'x-component': 'Input' } } },
|
|
241
|
+
},
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
render(
|
|
245
|
+
React.createElement(
|
|
246
|
+
ConfigProvider as any,
|
|
247
|
+
null,
|
|
248
|
+
React.createElement(App as any, null, React.createElement(DefaultSettingsIcon as any, { model })),
|
|
249
|
+
),
|
|
250
|
+
);
|
|
251
|
+
|
|
252
|
+
await waitFor(() => {
|
|
253
|
+
expect((globalThis as any).__lastDropdownMenu).toBeTruthy();
|
|
254
|
+
});
|
|
255
|
+
const menu = (globalThis as any).__lastDropdownMenu;
|
|
256
|
+
menu.onClick?.({ key: 'flowC:general' });
|
|
257
|
+
expect(openSpy).toHaveBeenCalledWith({ flowKey: 'flowC', stepKey: 'general' });
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it('copy UID action writes model uid to clipboard', async () => {
|
|
261
|
+
class TestFlowModel extends FlowModel {}
|
|
262
|
+
const engine = new FlowEngine();
|
|
263
|
+
const model = new TestFlowModel({ uid: 'm-copy', flowEngine: engine });
|
|
264
|
+
|
|
265
|
+
TestFlowModel.registerFlow({
|
|
266
|
+
key: 'flowD',
|
|
267
|
+
title: 'Flow D',
|
|
268
|
+
steps: { s: { title: 'S', uiSchema: { f: { type: 'string', 'x-component': 'Input' } } } },
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
// mock clipboard
|
|
272
|
+
Object.defineProperty(window.navigator, 'clipboard', {
|
|
273
|
+
value: { writeText: vi.fn().mockResolvedValue(undefined) },
|
|
274
|
+
configurable: true,
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
render(
|
|
278
|
+
React.createElement(
|
|
279
|
+
ConfigProvider as any,
|
|
280
|
+
null,
|
|
281
|
+
React.createElement(App as any, null, React.createElement(DefaultSettingsIcon as any, { model })),
|
|
282
|
+
),
|
|
283
|
+
);
|
|
284
|
+
|
|
285
|
+
await waitFor(() => {
|
|
286
|
+
expect((globalThis as any).__lastDropdownMenu).toBeTruthy();
|
|
287
|
+
});
|
|
288
|
+
const menu = (globalThis as any).__lastDropdownMenu;
|
|
289
|
+
menu.onClick?.({ key: 'copy-uid' });
|
|
290
|
+
expect((navigator as any).clipboard.writeText).toHaveBeenCalledWith('m-copy');
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
it('delete action calls model.destroy()', async () => {
|
|
294
|
+
class TestFlowModel extends FlowModel {}
|
|
295
|
+
const engine = new FlowEngine();
|
|
296
|
+
const model = new TestFlowModel({ uid: 'm-del', flowEngine: engine });
|
|
297
|
+
const destroySpy = vi.spyOn(model, 'destroy').mockResolvedValue(undefined as any);
|
|
298
|
+
|
|
299
|
+
TestFlowModel.registerFlow({
|
|
300
|
+
key: 'flowE',
|
|
301
|
+
title: 'Flow E',
|
|
302
|
+
steps: { s: { title: 'S', uiSchema: { f: { type: 'string', 'x-component': 'Input' } } } },
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
render(
|
|
306
|
+
React.createElement(
|
|
307
|
+
ConfigProvider as any,
|
|
308
|
+
null,
|
|
309
|
+
React.createElement(App as any, null, React.createElement(DefaultSettingsIcon as any, { model })),
|
|
310
|
+
),
|
|
311
|
+
);
|
|
312
|
+
|
|
313
|
+
await waitFor(() => {
|
|
314
|
+
expect((globalThis as any).__lastDropdownMenu).toBeTruthy();
|
|
315
|
+
});
|
|
316
|
+
const menu = (globalThis as any).__lastDropdownMenu;
|
|
317
|
+
menu.onClick?.({ key: 'delete' });
|
|
318
|
+
expect(destroySpy).toHaveBeenCalled();
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
it('shows sub-model steps with modelKey when flattenSubMenus=false and menuLevels=2', async () => {
|
|
322
|
+
class Parent extends FlowModel {}
|
|
323
|
+
class Child extends FlowModel {}
|
|
324
|
+
const engine = new FlowEngine();
|
|
325
|
+
const parent = new Parent({ uid: 'parent-1', flowEngine: engine });
|
|
326
|
+
const child = new Child({ uid: 'child-1', flowEngine: engine });
|
|
327
|
+
|
|
328
|
+
// child static flow
|
|
329
|
+
Child.registerFlow({
|
|
330
|
+
key: 'childFlow',
|
|
331
|
+
title: 'Child Flow',
|
|
332
|
+
steps: { cstep: { title: 'C', uiSchema: { x: { type: 'string', 'x-component': 'Input' } } } },
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
parent.addSubModel('items', child);
|
|
336
|
+
|
|
337
|
+
render(
|
|
338
|
+
React.createElement(
|
|
339
|
+
ConfigProvider as any,
|
|
340
|
+
null,
|
|
341
|
+
React.createElement(
|
|
342
|
+
App as any,
|
|
343
|
+
null,
|
|
344
|
+
React.createElement(DefaultSettingsIcon as any, {
|
|
345
|
+
model: parent,
|
|
346
|
+
menuLevels: 2,
|
|
347
|
+
flattenSubMenus: false,
|
|
348
|
+
}),
|
|
349
|
+
),
|
|
350
|
+
),
|
|
351
|
+
);
|
|
352
|
+
|
|
353
|
+
await waitFor(() => {
|
|
354
|
+
const menu = (globalThis as any).__lastDropdownMenu;
|
|
355
|
+
expect(menu).toBeTruthy();
|
|
356
|
+
const items = (menu?.items || []) as any[];
|
|
357
|
+
const subMenu = items.find((it) => Array.isArray(it?.children));
|
|
358
|
+
expect(subMenu).toBeTruthy();
|
|
359
|
+
expect(subMenu!.children.some((it: any) => String(it.key).startsWith('items[0]:childFlow:cstep'))).toBe(true);
|
|
360
|
+
});
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
it('adds "Copy popup UID" for popupSettings flow (current model and sub-model)', async () => {
|
|
364
|
+
class Parent extends FlowModel {}
|
|
365
|
+
class Child extends FlowModel {}
|
|
366
|
+
const engine = new FlowEngine();
|
|
367
|
+
const parent = new Parent({ uid: 'parent-2', flowEngine: engine });
|
|
368
|
+
const child = new Child({ uid: 'child-2', flowEngine: engine });
|
|
369
|
+
|
|
370
|
+
// current model popupSettings
|
|
371
|
+
Parent.registerFlow({
|
|
372
|
+
key: 'popupSettings',
|
|
373
|
+
title: 'Popup',
|
|
374
|
+
steps: { stage: { title: 'Stage', uiSchema: { a: { type: 'string', 'x-component': 'Input' } } } },
|
|
375
|
+
});
|
|
376
|
+
// sub model popupSettings
|
|
377
|
+
Child.registerFlow({
|
|
378
|
+
key: 'popupSettings',
|
|
379
|
+
title: 'Popup Child',
|
|
380
|
+
steps: { stage: { title: 'Stage', uiSchema: { a: { type: 'string', 'x-component': 'Input' } } } },
|
|
381
|
+
});
|
|
382
|
+
parent.addSubModel('items', child);
|
|
383
|
+
|
|
384
|
+
// mock clipboard
|
|
385
|
+
Object.defineProperty(window.navigator, 'clipboard', {
|
|
386
|
+
value: { writeText: vi.fn().mockResolvedValue(undefined) },
|
|
387
|
+
configurable: true,
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
render(
|
|
391
|
+
React.createElement(
|
|
392
|
+
ConfigProvider as any,
|
|
393
|
+
null,
|
|
394
|
+
React.createElement(
|
|
395
|
+
App as any,
|
|
396
|
+
null,
|
|
397
|
+
React.createElement(DefaultSettingsIcon as any, {
|
|
398
|
+
model: parent,
|
|
399
|
+
menuLevels: 2,
|
|
400
|
+
flattenSubMenus: true,
|
|
401
|
+
}),
|
|
402
|
+
),
|
|
403
|
+
),
|
|
404
|
+
);
|
|
405
|
+
|
|
406
|
+
// 等待“Copy popup UID”对应的菜单项出现,避免异步时序导致的偶发失败
|
|
407
|
+
await waitFor(() => {
|
|
408
|
+
const m = (globalThis as any).__lastDropdownMenu;
|
|
409
|
+
const is = (m?.items || []) as any[];
|
|
410
|
+
const current = is.find((it) => String(it.key) === 'copy-pop-uid:popupSettings:stage');
|
|
411
|
+
const sub = is.find((it) => String(it.key).startsWith('copy-pop-uid:items[0]:popupSettings:stage'));
|
|
412
|
+
expect(current).toBeTruthy();
|
|
413
|
+
expect(sub).toBeTruthy();
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
// click and verify clipboard(直接使用最新的 menu)
|
|
417
|
+
const menu = (globalThis as any).__lastDropdownMenu;
|
|
418
|
+
menu.onClick?.({ key: 'copy-pop-uid:popupSettings:stage' });
|
|
419
|
+
expect((navigator as any).clipboard.writeText).toHaveBeenCalledWith('parent-2');
|
|
420
|
+
|
|
421
|
+
menu.onClick?.({ key: 'copy-pop-uid:items[0]:popupSettings:stage' });
|
|
422
|
+
expect((navigator as any).clipboard.writeText).toHaveBeenCalledWith('child-2');
|
|
423
|
+
});
|
|
424
|
+
});
|
|
@@ -943,13 +943,10 @@ describe('AddSubModelButton toggleable behavior', () => {
|
|
|
943
943
|
expect(screen.getByText('Child A')).toBeInTheDocument();
|
|
944
944
|
expect(screen.getByText('Child B')).toBeInTheDocument();
|
|
945
945
|
|
|
946
|
-
// ensure destroy
|
|
947
|
-
await waitFor(
|
|
948
|
-
()
|
|
949
|
-
|
|
950
|
-
},
|
|
951
|
-
{ timeout: 5000 },
|
|
952
|
-
);
|
|
946
|
+
// ensure destroy has been called (avoid flakiness on exact call counts)
|
|
947
|
+
await waitFor(() => {
|
|
948
|
+
expect(repo.destroy).toHaveBeenCalled();
|
|
949
|
+
});
|
|
953
950
|
});
|
|
954
951
|
|
|
955
952
|
test('toggle state updates without menu closing', async () => {
|
package/src/locale/en-US.json
CHANGED
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
"UID copied to clipboard": "UID copied to clipboard",
|
|
24
24
|
"Copy failed": "Copy failed",
|
|
25
25
|
"Copy failed, please copy [{{uid}}] manually.": "Copy failed, please copy [{{uid}}] manually.",
|
|
26
|
+
"Copy failed under HTTP. Clipboard API is unavailable on non-HTTPS pages. Please copy [{{uid}}] manually.": "Copy failed under HTTP. Clipboard API is unavailable on non-HTTPS pages. Please copy [{{uid}}] manually.",
|
|
26
27
|
"Confirm delete": "Confirm delete",
|
|
27
28
|
"Are you sure you want to delete this item? This action cannot be undone.": "Are you sure you want to delete this item? This action cannot be undone.",
|
|
28
29
|
"Delete operation failed": "Delete operation failed",
|
|
@@ -59,4 +60,4 @@
|
|
|
59
60
|
"Common actions": "Common actions",
|
|
60
61
|
"This variable is not available": "This variable is not available",
|
|
61
62
|
"Copy popup UID": "Copy popup UID"
|
|
62
|
-
}
|
|
63
|
+
}
|
package/src/locale/zh-CN.json
CHANGED
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
"UID copied to clipboard": "UID 已复制到剪贴板",
|
|
24
24
|
"Copy failed": "复制失败",
|
|
25
25
|
"Copy failed, please copy [{{uid}}] manually.": "复制失败,请手动复制 [{{uid}}]。",
|
|
26
|
+
"Copy failed under HTTP. Clipboard API is unavailable on non-HTTPS pages. Please copy [{{uid}}] manually.": "HTTP 环境下复制失败。非 HTTPS 页面不支持剪贴板 API,请手动复制 [{{uid}}]。",
|
|
26
27
|
"Confirm delete": "确认删除",
|
|
27
28
|
"Are you sure you want to delete this item? This action cannot be undone.": "确定要删除此项吗?此操作不可撤销。",
|
|
28
29
|
"Delete operation failed": "删除操作失败",
|
|
@@ -59,4 +60,4 @@
|
|
|
59
60
|
"Common actions": "通用操作",
|
|
60
61
|
"This variable is not available": "此变量不可用",
|
|
61
62
|
"Copy popup UID": "复制弹窗 UID"
|
|
62
|
-
}
|
|
63
|
+
}
|