@nocobase/flow-engine 2.2.0-alpha.1 → 2.2.0-alpha.11
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/JSRunner.d.ts +1 -0
- package/lib/JSRunner.js +110 -20
- package/lib/acl/Acl.d.ts +2 -1
- package/lib/acl/Acl.js +28 -0
- package/lib/components/FieldModelRenderer.js +44 -31
- package/lib/components/FlowContextSelector.js +55 -12
- package/lib/components/FormItem.js +11 -7
- package/lib/components/MobilePopup.js +39 -10
- package/lib/components/MobilePopup.style.js +27 -6
- package/lib/components/dnd/index.js +9 -2
- package/lib/components/settings/wrappers/contextual/FlowsFloatContextMenu.js +86 -32
- package/lib/components/settings/wrappers/contextual/useFloatToolbarVisibility.js +20 -0
- package/lib/components/subModel/LazyDropdown.js +41 -26
- package/lib/components/variables/VariableHybridInput.d.ts +9 -0
- package/lib/components/variables/VariableHybridInput.js +146 -17
- package/lib/components/variables/VariableInput.js +19 -7
- package/lib/components/variables/VariableTag.js +48 -36
- package/lib/components/variables/types.d.ts +21 -0
- package/lib/flowContext.d.ts +1 -1
- package/lib/flowContext.js +97 -42
- package/lib/flowEngine.js +6 -0
- package/lib/flowI18n.js +3 -3
- package/lib/flowSettings.d.ts +5 -1
- package/lib/flowSettings.js +70 -0
- package/lib/locale/en-US.json +3 -0
- package/lib/locale/index.d.ts +6 -0
- package/lib/locale/zh-CN.json +3 -0
- package/lib/resources/apiResource.js +2 -1
- package/lib/resources/baseRecordResource.js +6 -17
- package/lib/resources/multiRecordResource.js +13 -3
- package/lib/resources/singleRecordResource.js +7 -2
- package/lib/runjs-context/helpers.js +12 -5
- package/lib/types.d.ts +15 -1
- package/lib/types.js +1 -0
- package/lib/utils/dataSourceDirty.d.ts +20 -0
- package/lib/utils/dataSourceDirty.js +139 -0
- package/lib/utils/dirtyAwareApiClient.d.ts +11 -0
- package/lib/utils/dirtyAwareApiClient.js +632 -0
- package/lib/utils/index.d.ts +1 -1
- package/lib/utils/index.js +11 -11
- package/lib/utils/loadedPageCache.d.ts +1 -0
- package/lib/utils/loadedPageCache.js +6 -0
- package/lib/utils/openViewRouteState.d.ts +28 -0
- package/lib/utils/openViewRouteState.js +125 -0
- package/lib/utils/parsePathnameToViewParams.d.ts +3 -0
- package/lib/utils/parsePathnameToViewParams.js +18 -1
- package/lib/utils/resolveRunJSObjectValues.js +3 -2
- package/lib/utils/runjsModuleLoader.js +0 -30
- package/lib/views/ViewNavigation.js +5 -0
- package/package.json +4 -4
- package/src/JSRunner.ts +112 -25
- package/src/__tests__/JSRunner.test.ts +4 -5
- package/src/__tests__/flowContext.test.ts +154 -0
- package/src/__tests__/flowEngine.dataSourceDirty.test.ts +51 -0
- package/src/__tests__/flowI18n.test.ts +11 -0
- package/src/__tests__/flowModel.openView.navigation.test.ts +28 -0
- package/src/__tests__/flowSettings.test.ts +72 -0
- package/src/__tests__/runjsRuntimeFeatures.test.ts +15 -2
- package/src/__tests__/viewScopedFlowEngine.test.ts +72 -6
- package/src/acl/Acl.tsx +36 -1
- package/src/acl/__tests__/Acl.test.tsx +70 -0
- package/src/components/FieldModelRenderer.tsx +50 -36
- package/src/components/FlowContextSelector.tsx +66 -11
- package/src/components/FormItem.tsx +12 -7
- package/src/components/MobilePopup.style.ts +34 -7
- package/src/components/MobilePopup.tsx +42 -10
- package/src/components/__tests__/FieldModelRenderer.test.tsx +165 -0
- package/src/components/__tests__/FormItem.test.tsx +17 -2
- package/src/components/__tests__/MobilePopup.style.test.tsx +103 -0
- package/src/components/__tests__/MobilePopup.test.tsx +150 -0
- package/src/components/dnd/index.tsx +11 -2
- package/src/components/settings/wrappers/contextual/FlowsFloatContextMenu.tsx +105 -35
- package/src/components/settings/wrappers/contextual/__tests__/FlowsFloatContextMenu.test.tsx +381 -12
- package/src/components/settings/wrappers/contextual/useFloatToolbarVisibility.ts +28 -0
- package/src/components/subModel/LazyDropdown.tsx +44 -26
- package/src/components/subModel/__tests__/AddSubModelButton.test.tsx +85 -2
- package/src/components/variables/VariableHybridInput.tsx +185 -14
- package/src/components/variables/VariableInput.tsx +32 -7
- package/src/components/variables/VariableTag.tsx +51 -37
- package/src/components/variables/__tests__/FlowContextSelector.test.tsx +60 -3
- package/src/components/variables/__tests__/VariableHybridInput.test.tsx +212 -0
- package/src/components/variables/__tests__/VariableInput.test.tsx +202 -6
- package/src/components/variables/__tests__/VariableTag.test.tsx +80 -0
- package/src/components/variables/types.ts +21 -0
- package/src/flowContext.ts +104 -36
- package/src/flowEngine.ts +6 -0
- package/src/flowI18n.ts +8 -3
- package/src/flowSettings.ts +85 -1
- package/src/locale/__tests__/index.test.ts +21 -0
- package/src/locale/en-US.json +3 -0
- package/src/locale/zh-CN.json +3 -0
- package/src/resources/apiResource.ts +2 -1
- package/src/resources/baseRecordResource.ts +6 -23
- package/src/resources/multiRecordResource.ts +13 -3
- package/src/resources/singleRecordResource.ts +6 -1
- package/src/runjs-context/helpers.ts +12 -6
- package/src/types.ts +16 -0
- package/src/utils/__tests__/dirtyAwareApiClient.test.ts +713 -0
- package/src/utils/__tests__/openViewRouteState.test.ts +40 -0
- package/src/utils/__tests__/parsePathnameToViewParams.test.ts +36 -0
- package/src/utils/dataSourceDirty.ts +126 -0
- package/src/utils/dirtyAwareApiClient.ts +742 -0
- package/src/utils/index.ts +10 -9
- package/src/utils/loadedPageCache.ts +7 -0
- package/src/utils/openViewRouteState.ts +107 -0
- package/src/utils/parsePathnameToViewParams.ts +23 -1
- package/src/utils/resolveRunJSObjectValues.ts +5 -2
- package/src/utils/runjsModuleLoader.ts +0 -32
- package/src/views/ViewNavigation.ts +6 -1
- package/src/views/__tests__/ViewNavigation.test.ts +15 -0
- package/lib/utils/safeGlobals.d.ts +0 -28
- package/lib/utils/safeGlobals.js +0 -367
- package/src/utils/__tests__/runjsRequireAsyncAutoWhitelist.test.ts +0 -38
- package/src/utils/__tests__/safeGlobals.test.ts +0 -106
- package/src/utils/safeGlobals.ts +0 -406
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
|
11
|
+
import { Input } from 'antd';
|
|
11
12
|
import React from 'react';
|
|
12
13
|
import { describe, expect, it, vi } from 'vitest';
|
|
13
14
|
import type { ContextSelectorItem } from '../types';
|
|
@@ -100,6 +101,127 @@ describe('VariableInput', () => {
|
|
|
100
101
|
);
|
|
101
102
|
});
|
|
102
103
|
|
|
104
|
+
it('renders a variable tag when custom converters resolve a non-ctx workflow variable format', async () => {
|
|
105
|
+
const flowContext = createTestFlowContext();
|
|
106
|
+
const workflowMetaTree = [
|
|
107
|
+
{
|
|
108
|
+
name: '$context',
|
|
109
|
+
title: 'Trigger variables',
|
|
110
|
+
type: 'object',
|
|
111
|
+
paths: ['$context'],
|
|
112
|
+
children: [
|
|
113
|
+
{
|
|
114
|
+
name: 'data',
|
|
115
|
+
title: 'Trigger data',
|
|
116
|
+
type: 'object',
|
|
117
|
+
paths: ['$context', 'data'],
|
|
118
|
+
children: [
|
|
119
|
+
{
|
|
120
|
+
name: 'updatedAt',
|
|
121
|
+
title: 'Last updated at',
|
|
122
|
+
type: 'string',
|
|
123
|
+
paths: ['$context', 'data', 'updatedAt'],
|
|
124
|
+
},
|
|
125
|
+
],
|
|
126
|
+
},
|
|
127
|
+
],
|
|
128
|
+
},
|
|
129
|
+
];
|
|
130
|
+
|
|
131
|
+
render(
|
|
132
|
+
<TestFlowContextWrapper context={flowContext}>
|
|
133
|
+
<VariableInput
|
|
134
|
+
value="{{$context.data.updatedAt}}"
|
|
135
|
+
metaTree={workflowMetaTree}
|
|
136
|
+
converters={{
|
|
137
|
+
resolvePathFromValue: (currentValue) =>
|
|
138
|
+
currentValue === '{{$context.data.updatedAt}}' ? ['$context', 'data', 'updatedAt'] : undefined,
|
|
139
|
+
resolveValueFromPath: () => undefined,
|
|
140
|
+
}}
|
|
141
|
+
/>
|
|
142
|
+
</TestFlowContextWrapper>,
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
await waitFor(
|
|
146
|
+
() => {
|
|
147
|
+
const variableTag = screen.getByText('Trigger variables/Trigger data/Last updated at');
|
|
148
|
+
expect(variableTag).toBeInTheDocument();
|
|
149
|
+
expect(variableTag.closest('.ant-tag')).toBeInTheDocument();
|
|
150
|
+
},
|
|
151
|
+
{ timeout: 3000 },
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
const selectorButton = screen.getByRole('button');
|
|
155
|
+
expect(selectorButton.className).toContain('ant-btn-primary');
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('renders a parsing failure tag when a custom variable path is missing from the meta tree', async () => {
|
|
159
|
+
const flowContext = createTestFlowContext();
|
|
160
|
+
const workflowMetaTree = [
|
|
161
|
+
{
|
|
162
|
+
name: '$context',
|
|
163
|
+
title: 'Trigger variables',
|
|
164
|
+
type: 'object',
|
|
165
|
+
paths: ['$context'],
|
|
166
|
+
children: [
|
|
167
|
+
{
|
|
168
|
+
name: 'data',
|
|
169
|
+
title: 'Trigger data',
|
|
170
|
+
type: 'object',
|
|
171
|
+
paths: ['$context', 'data'],
|
|
172
|
+
},
|
|
173
|
+
],
|
|
174
|
+
},
|
|
175
|
+
];
|
|
176
|
+
|
|
177
|
+
render(
|
|
178
|
+
<TestFlowContextWrapper context={flowContext}>
|
|
179
|
+
<VariableInput
|
|
180
|
+
value="{{$context.data.id}}"
|
|
181
|
+
metaTree={workflowMetaTree}
|
|
182
|
+
converters={{
|
|
183
|
+
resolvePathFromValue: (currentValue) =>
|
|
184
|
+
currentValue === '{{$context.data.id}}' ? ['$context', 'data', 'id'] : undefined,
|
|
185
|
+
resolveValueFromPath: () => undefined,
|
|
186
|
+
}}
|
|
187
|
+
/>
|
|
188
|
+
</TestFlowContextWrapper>,
|
|
189
|
+
);
|
|
190
|
+
|
|
191
|
+
await waitFor(
|
|
192
|
+
() => {
|
|
193
|
+
const variableTag = screen.getByText('Variable parsing failed');
|
|
194
|
+
expect(variableTag).toBeInTheDocument();
|
|
195
|
+
expect(variableTag.closest('.ant-tag')).toHaveClass('ant-tag-error');
|
|
196
|
+
},
|
|
197
|
+
{ timeout: 3000 },
|
|
198
|
+
);
|
|
199
|
+
|
|
200
|
+
expect(screen.queryByDisplayValue('{{$context.data.id}}')).not.toBeInTheDocument();
|
|
201
|
+
expect(screen.getByRole('button').className).toContain('ant-btn-primary');
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it('disables custom tag input when rendering a selected variable', async () => {
|
|
205
|
+
const flowContext = createTestFlowContext();
|
|
206
|
+
const { container } = render(
|
|
207
|
+
<TestFlowContextWrapper context={flowContext}>
|
|
208
|
+
<VariableInput value="{{ ctx.user.name }}" metaTree={() => flowContext.getPropertyMetaTree()} />
|
|
209
|
+
</TestFlowContextWrapper>,
|
|
210
|
+
);
|
|
211
|
+
|
|
212
|
+
await waitFor(
|
|
213
|
+
() => {
|
|
214
|
+
expect(screen.getByText('User/Name')).toBeInTheDocument();
|
|
215
|
+
},
|
|
216
|
+
{ timeout: 3000 },
|
|
217
|
+
);
|
|
218
|
+
|
|
219
|
+
const selectElement = container.querySelector('.ant-select.variable');
|
|
220
|
+
expect(selectElement).toBeInTheDocument();
|
|
221
|
+
expect(selectElement).not.toHaveClass('ant-select-show-search');
|
|
222
|
+
expect(container.querySelector('.ant-select-selection-search-input')).toHaveAttribute('readonly');
|
|
223
|
+
});
|
|
224
|
+
|
|
103
225
|
it('should render FlowContextSelector button', async () => {
|
|
104
226
|
const flowContext = createTestFlowContext();
|
|
105
227
|
render(
|
|
@@ -112,6 +234,62 @@ describe('VariableInput', () => {
|
|
|
112
234
|
expect(selectorButton).toBeInTheDocument();
|
|
113
235
|
});
|
|
114
236
|
|
|
237
|
+
it('disables the FlowContextSelector button when disabled', async () => {
|
|
238
|
+
const flowContext = createTestFlowContext();
|
|
239
|
+
render(
|
|
240
|
+
<TestFlowContextWrapper context={flowContext}>
|
|
241
|
+
<VariableInput value="test" metaTree={() => flowContext.getPropertyMetaTree()} disabled />
|
|
242
|
+
</TestFlowContextWrapper>,
|
|
243
|
+
);
|
|
244
|
+
|
|
245
|
+
const selectorButton = await screen.findByRole('button');
|
|
246
|
+
expect(selectorButton).toBeDisabled();
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it('should not highlight the selector button for synthetic constant/null paths', async () => {
|
|
250
|
+
const flowContext = createTestFlowContext();
|
|
251
|
+
|
|
252
|
+
render(
|
|
253
|
+
<TestFlowContextWrapper context={flowContext}>
|
|
254
|
+
<VariableInput
|
|
255
|
+
value=""
|
|
256
|
+
metaTree={[
|
|
257
|
+
{ name: 'constant', title: 'Constant', type: 'string', paths: ['constant'] },
|
|
258
|
+
{ name: 'null', title: 'Null', type: 'object', paths: ['null'] },
|
|
259
|
+
...flowContext.getPropertyMetaTree(),
|
|
260
|
+
]}
|
|
261
|
+
converters={{
|
|
262
|
+
renderInputComponent: (meta) => {
|
|
263
|
+
const first = meta?.paths?.[0];
|
|
264
|
+
if (first === 'constant') {
|
|
265
|
+
return (props: any) => <input aria-label="constant-value" {...props} />;
|
|
266
|
+
}
|
|
267
|
+
if (first === 'null') {
|
|
268
|
+
return () => <Input placeholder="<Null>" readOnly />;
|
|
269
|
+
}
|
|
270
|
+
return null;
|
|
271
|
+
},
|
|
272
|
+
resolveValueFromPath: (meta) => {
|
|
273
|
+
const first = meta?.paths?.[0];
|
|
274
|
+
if (first === 'constant') return '';
|
|
275
|
+
if (first === 'null') return null;
|
|
276
|
+
return undefined;
|
|
277
|
+
},
|
|
278
|
+
resolvePathFromValue: (currentValue) => {
|
|
279
|
+
if (currentValue === null) return ['null'];
|
|
280
|
+
const trimmed = typeof currentValue === 'string' ? currentValue.trim() : currentValue;
|
|
281
|
+
if (trimmed === '') return ['constant'];
|
|
282
|
+
return undefined;
|
|
283
|
+
},
|
|
284
|
+
}}
|
|
285
|
+
/>
|
|
286
|
+
</TestFlowContextWrapper>,
|
|
287
|
+
);
|
|
288
|
+
|
|
289
|
+
const selectorButton = await screen.findByRole('button');
|
|
290
|
+
expect(selectorButton.className).not.toContain('ant-btn-primary');
|
|
291
|
+
});
|
|
292
|
+
|
|
115
293
|
it('should handle onChange from Input', async () => {
|
|
116
294
|
const onChange = vi.fn();
|
|
117
295
|
const flowContext = createTestFlowContext();
|
|
@@ -195,20 +373,21 @@ describe('VariableInput', () => {
|
|
|
195
373
|
|
|
196
374
|
const selectElement = container.querySelector('.ant-select');
|
|
197
375
|
expect(selectElement).toBeInTheDocument();
|
|
376
|
+
if (!selectElement) {
|
|
377
|
+
throw new Error('Expected variable tag select wrapper to be present');
|
|
378
|
+
}
|
|
198
379
|
|
|
199
380
|
// 触发鼠标悬停以显示清除按钮
|
|
200
|
-
fireEvent.mouseEnter(selectElement
|
|
381
|
+
fireEvent.mouseEnter(selectElement);
|
|
201
382
|
|
|
202
383
|
// 尝试触发清除功能
|
|
203
384
|
// 方法1: 直接触发 Select 组件的 onClear 事件
|
|
204
|
-
const selectInstance = selectElement as any;
|
|
205
|
-
|
|
206
385
|
// 尝试通过键盘事件触发清除
|
|
207
|
-
fireEvent.keyDown(selectElement
|
|
386
|
+
fireEvent.keyDown(selectElement, { key: 'Backspace', code: 'Backspace' });
|
|
208
387
|
|
|
209
388
|
// 或者尝试触发自定义的清除逻辑
|
|
210
389
|
const clearEvents = new CustomEvent('clear');
|
|
211
|
-
selectElement
|
|
390
|
+
selectElement.dispatchEvent(clearEvents);
|
|
212
391
|
|
|
213
392
|
// 检查是否调用了清除功能(可能需要调整期望)
|
|
214
393
|
// 如果清除按钮不能直接测试,我们验证组件支持清除功能
|
|
@@ -276,7 +455,24 @@ describe('VariableInput', () => {
|
|
|
276
455
|
expect(input).toHaveClass('custom-class');
|
|
277
456
|
|
|
278
457
|
// Note: The disabled prop might not be correctly passed through in the current implementation
|
|
279
|
-
|
|
458
|
+
expect(input).toBeDisabled();
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
it('disables the rendered variable tag when the input is disabled', async () => {
|
|
462
|
+
const flowContext = createTestFlowContext();
|
|
463
|
+
const { container } = render(
|
|
464
|
+
<TestFlowContextWrapper context={flowContext}>
|
|
465
|
+
<VariableInput value="{{ ctx.user.name }}" metaTree={() => flowContext.getPropertyMetaTree()} disabled />
|
|
466
|
+
</TestFlowContextWrapper>,
|
|
467
|
+
);
|
|
468
|
+
|
|
469
|
+
await waitFor(() => {
|
|
470
|
+
expect(screen.getByText('User/Name')).toBeInTheDocument();
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
const selectElement = container.querySelector('.ant-select.variable');
|
|
474
|
+
expect(selectElement).toHaveClass('ant-select-disabled');
|
|
475
|
+
expect(container.querySelector('.ant-select-clear')).not.toBeInTheDocument();
|
|
280
476
|
});
|
|
281
477
|
|
|
282
478
|
it('should handle empty metaTree', async () => {
|
|
@@ -54,6 +54,44 @@ describe('VariableTag', () => {
|
|
|
54
54
|
expect(onClear).toBeInstanceOf(Function);
|
|
55
55
|
});
|
|
56
56
|
|
|
57
|
+
it('keeps custom tag input enabled by default', async () => {
|
|
58
|
+
const onClear = vi.fn();
|
|
59
|
+
const { container } = renderWithCtx(<VariableTag value="{{ ctx.User.Email }}" onClear={onClear} />);
|
|
60
|
+
|
|
61
|
+
await waitFor(
|
|
62
|
+
() => {
|
|
63
|
+
expect(screen.getByText('User/Email')).toBeInTheDocument();
|
|
64
|
+
},
|
|
65
|
+
{ timeout: 3000 },
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
const selectElement = container.querySelector('.ant-select.variable');
|
|
69
|
+
expect(selectElement).toBeInTheDocument();
|
|
70
|
+
expect(selectElement).toHaveClass('ant-select-show-search');
|
|
71
|
+
expect(container.querySelector('.ant-select-selection-search-input')).not.toHaveAttribute('readonly');
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('can disable custom tag input while keeping clear enabled', async () => {
|
|
75
|
+
const onClear = vi.fn();
|
|
76
|
+
const { container } = renderWithCtx(
|
|
77
|
+
<VariableTag value="{{ ctx.User.Email }}" onClear={onClear} allowCustomTagInput={false} />,
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
await waitFor(
|
|
81
|
+
() => {
|
|
82
|
+
expect(screen.getByText('User/Email')).toBeInTheDocument();
|
|
83
|
+
},
|
|
84
|
+
{ timeout: 3000 },
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
const selectElement = container.querySelector('.ant-select.variable');
|
|
88
|
+
expect(selectElement).toBeInTheDocument();
|
|
89
|
+
expect(selectElement).not.toHaveClass('ant-select-disabled');
|
|
90
|
+
expect(selectElement).not.toHaveClass('ant-select-show-search');
|
|
91
|
+
expect(container.querySelector('.ant-select-clear')).toBeInTheDocument();
|
|
92
|
+
expect(container.querySelector('.ant-select-selection-search-input')).toHaveAttribute('readonly');
|
|
93
|
+
});
|
|
94
|
+
|
|
57
95
|
it('should not show close button when onClear is not provided', async () => {
|
|
58
96
|
const { container } = renderWithCtx(<VariableTag value="{{ ctx.User.Name }}" />);
|
|
59
97
|
|
|
@@ -166,6 +204,32 @@ describe('VariableTag', () => {
|
|
|
166
204
|
}
|
|
167
205
|
});
|
|
168
206
|
|
|
207
|
+
it('renders a red failure pill when the variable path cannot be resolved', async () => {
|
|
208
|
+
renderWithCtx(
|
|
209
|
+
<VariableTag
|
|
210
|
+
value="{{ ctx.missing.field }}"
|
|
211
|
+
metaTree={[
|
|
212
|
+
{
|
|
213
|
+
name: 'user',
|
|
214
|
+
title: 'User',
|
|
215
|
+
type: 'object',
|
|
216
|
+
paths: ['user'],
|
|
217
|
+
children: [{ name: 'name', title: 'Name', type: 'string', paths: ['user', 'name'] }],
|
|
218
|
+
},
|
|
219
|
+
]}
|
|
220
|
+
/>,
|
|
221
|
+
);
|
|
222
|
+
|
|
223
|
+
await waitFor(
|
|
224
|
+
() => {
|
|
225
|
+
const tag = screen.getByText('Variable parsing failed');
|
|
226
|
+
expect(tag).toBeInTheDocument();
|
|
227
|
+
expect(tag.closest('.ant-tag')).toHaveClass('ant-tag-error');
|
|
228
|
+
},
|
|
229
|
+
{ timeout: 3000 },
|
|
230
|
+
);
|
|
231
|
+
});
|
|
232
|
+
|
|
169
233
|
it('should render Select component with proper structure', async () => {
|
|
170
234
|
const { container } = render(<VariableTag value="{{ ctx.Test }}" />);
|
|
171
235
|
|
|
@@ -233,6 +297,22 @@ describe('VariableTag', () => {
|
|
|
233
297
|
expect(selectElement).not.toHaveClass('ant-select-disabled');
|
|
234
298
|
});
|
|
235
299
|
|
|
300
|
+
it('does not show clear affordance when disabled even if onClear is provided', async () => {
|
|
301
|
+
const onClear = vi.fn();
|
|
302
|
+
const { container } = renderWithCtx(<VariableTag value="{{ ctx.Test }}" onClear={onClear} disabled />);
|
|
303
|
+
|
|
304
|
+
await waitFor(
|
|
305
|
+
() => {
|
|
306
|
+
expect(screen.getByText('Test')).toBeInTheDocument();
|
|
307
|
+
},
|
|
308
|
+
{ timeout: 3000 },
|
|
309
|
+
);
|
|
310
|
+
|
|
311
|
+
const selectElement = container.querySelector('.ant-select.variable');
|
|
312
|
+
expect(selectElement).toHaveClass('ant-select-disabled');
|
|
313
|
+
expect(container.querySelector('.ant-select-clear')).not.toBeInTheDocument();
|
|
314
|
+
});
|
|
315
|
+
|
|
236
316
|
it('should have proper accessibility attributes for Select component', async () => {
|
|
237
317
|
const { container } = renderWithCtx(<VariableTag value="{{ ctx.Test }}" />);
|
|
238
318
|
|
|
@@ -16,12 +16,30 @@ export interface FlowContextSelectorProps
|
|
|
16
16
|
value?: string;
|
|
17
17
|
onChange?: (value: string, metaTreeNode?: MetaTreeNode) => void;
|
|
18
18
|
children?: CascaderProps<ContextSelectorItem>['children'];
|
|
19
|
+
/**
|
|
20
|
+
* Controls whether the default `x` trigger button is rendered as active
|
|
21
|
+
* (`type="primary"`). When omitted, the selector falls back to its parsed
|
|
22
|
+
* `value` path (`true` iff a valid variable path is currently selected).
|
|
23
|
+
*
|
|
24
|
+
* Use this when callers intentionally feed synthetic paths such as
|
|
25
|
+
* `['constant']` / `['null']` into the cascader to keep menu state aligned,
|
|
26
|
+
* but only want real variable references to show the blue active button.
|
|
27
|
+
*/
|
|
28
|
+
active?: boolean;
|
|
19
29
|
metaTree?: MetaTreeNode[] | (() => MetaTreeNode[] | Promise<MetaTreeNode[]>);
|
|
20
30
|
parseValueToPath?: (value: string) => string[] | undefined;
|
|
21
31
|
formatPathToValue?: (item: MetaTreeNode) => string;
|
|
22
32
|
open?: boolean;
|
|
23
33
|
onlyLeafSelectable?: boolean;
|
|
24
34
|
ignoreFieldNames?: string[];
|
|
35
|
+
/**
|
|
36
|
+
* Footer rendered at the bottom of the dropdown. Defaults to a muted
|
|
37
|
+
* "Double click to choose entire object" hint when non-leaf selection is
|
|
38
|
+
* allowed (`onlyLeafSelectable` is false) — since double-clicking a non-leaf
|
|
39
|
+
* node selects the whole object. Pass an explicit node to override, or `null`
|
|
40
|
+
* to hide it.
|
|
41
|
+
*/
|
|
42
|
+
dropdownFooter?: React.ReactNode;
|
|
25
43
|
}
|
|
26
44
|
|
|
27
45
|
export interface ContextSelectorItem {
|
|
@@ -76,7 +94,10 @@ export interface VariableInputProps {
|
|
|
76
94
|
|
|
77
95
|
export interface VariableTagProps {
|
|
78
96
|
value?: string;
|
|
97
|
+
resolvedPath?: Array<string | number>;
|
|
79
98
|
onClear?: () => void;
|
|
99
|
+
disabled?: boolean;
|
|
100
|
+
allowCustomTagInput?: boolean;
|
|
80
101
|
className?: string;
|
|
81
102
|
style?: React.CSSProperties;
|
|
82
103
|
metaTreeNode?: MetaTreeNode | null;
|
package/src/flowContext.ts
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
import { ISchema } from '@formily/json-schema';
|
|
11
11
|
import { observable } from '@formily/reactive';
|
|
12
|
-
import { APIClient, RequestOptions } from '@nocobase/sdk';
|
|
12
|
+
import type { APIClient, RequestOptions } from '@nocobase/sdk';
|
|
13
13
|
import type { Router } from '@remix-run/router';
|
|
14
14
|
import axios from 'axios';
|
|
15
15
|
import { MessageInstance } from 'antd/es/message/interface';
|
|
@@ -39,9 +39,11 @@ import {
|
|
|
39
39
|
extractUsedVariablePaths,
|
|
40
40
|
FlowExitException,
|
|
41
41
|
FLOW_ENGINE_NAMESPACE,
|
|
42
|
+
createOpenViewRouteState,
|
|
42
43
|
isCtxDatePathPrefix,
|
|
43
44
|
isCssFile,
|
|
44
45
|
prepareRunJsCode,
|
|
46
|
+
RUNJS_OPEN_VIEW_ROUTE_STATE,
|
|
45
47
|
resolveCtxDatePath,
|
|
46
48
|
resolveDefaultParams,
|
|
47
49
|
resolveExpressions,
|
|
@@ -51,6 +53,7 @@ import { FlowExitAllException } from './utils/exceptions';
|
|
|
51
53
|
import { enqueueVariablesResolve, JSONValue } from './utils/params-resolvers';
|
|
52
54
|
import type { RecordRef } from './utils/serverContextParams';
|
|
53
55
|
import { buildServerContextParams as _buildServerContextParams } from './utils/serverContextParams';
|
|
56
|
+
import { getDirtyAwareApiClient } from './utils/dirtyAwareApiClient';
|
|
54
57
|
import { inferRecordRef } from './utils/variablesParams';
|
|
55
58
|
import { FlowView, FlowViewer } from './views/FlowView';
|
|
56
59
|
import { RunJSContextRegistry, getModelClassName, type RunJSVersion } from './runjs-context/registry';
|
|
@@ -2909,19 +2912,20 @@ export class FlowContext {
|
|
|
2909
2912
|
|
|
2910
2913
|
// 静态值
|
|
2911
2914
|
if ('value' in options) {
|
|
2912
|
-
return options.value;
|
|
2915
|
+
return key === 'api' ? getDirtyAwareApiClient(options.value, currentContext) : options.value;
|
|
2913
2916
|
}
|
|
2914
2917
|
|
|
2915
2918
|
// get 方法
|
|
2916
2919
|
if (options.get) {
|
|
2917
2920
|
if (options.cache === false) {
|
|
2918
|
-
|
|
2921
|
+
const value = options.get(currentContext);
|
|
2922
|
+
return key === 'api' ? getDirtyAwareApiClient(value, currentContext) : value;
|
|
2919
2923
|
}
|
|
2920
2924
|
|
|
2921
2925
|
const cacheKey = options.observable ? '_observableCache' : '_cache';
|
|
2922
2926
|
|
|
2923
2927
|
if (key in this[cacheKey]) {
|
|
2924
|
-
return this[cacheKey][key];
|
|
2928
|
+
return key === 'api' ? getDirtyAwareApiClient(this[cacheKey][key], currentContext) : this[cacheKey][key];
|
|
2925
2929
|
}
|
|
2926
2930
|
|
|
2927
2931
|
if (this._pending[key]) return this._pending[key];
|
|
@@ -2939,7 +2943,7 @@ export class FlowContext {
|
|
|
2939
2943
|
(v) => {
|
|
2940
2944
|
this[cacheKey][key] = v;
|
|
2941
2945
|
delete this._pending[key];
|
|
2942
|
-
return v;
|
|
2946
|
+
return key === 'api' ? getDirtyAwareApiClient(v, currentContext) : v;
|
|
2943
2947
|
},
|
|
2944
2948
|
(err) => {
|
|
2945
2949
|
delete this._pending[key];
|
|
@@ -2951,7 +2955,7 @@ export class FlowContext {
|
|
|
2951
2955
|
|
|
2952
2956
|
// sync 直接缓存
|
|
2953
2957
|
this[cacheKey][key] = result;
|
|
2954
|
-
return result;
|
|
2958
|
+
return key === 'api' ? getDirtyAwareApiClient(result, currentContext) : result;
|
|
2955
2959
|
}
|
|
2956
2960
|
|
|
2957
2961
|
return undefined;
|
|
@@ -3074,7 +3078,7 @@ class BaseFlowEngineContext extends FlowContext {
|
|
|
3074
3078
|
this.defineMethod('getModel', (modelName: string, searchInPreviousEngines?: boolean) => {
|
|
3075
3079
|
return this.engine.getModel(modelName, searchInPreviousEngines);
|
|
3076
3080
|
});
|
|
3077
|
-
this.defineMethod('request', (options: RequestOptions)
|
|
3081
|
+
this.defineMethod('request', function (this: FlowContext, options: RequestOptions) {
|
|
3078
3082
|
const app = this.app as { getApiUrl?: (pathname?: string) => string } | undefined;
|
|
3079
3083
|
if (typeof options?.url === 'string' && shouldBypassApiClient(options.url, app)) {
|
|
3080
3084
|
return axios.request(options);
|
|
@@ -3139,6 +3143,37 @@ class BaseFlowModelContext extends BaseFlowEngineContext {
|
|
|
3139
3143
|
declare makeResource: <T extends FlowResource = FlowResource>(resourceType: ResourceType<T>) => T;
|
|
3140
3144
|
}
|
|
3141
3145
|
|
|
3146
|
+
const OPEN_VIEW_INHERITED_INPUT_ARG_KEYS = [
|
|
3147
|
+
'dataSourceKey',
|
|
3148
|
+
'collectionName',
|
|
3149
|
+
'associationName',
|
|
3150
|
+
'filterByTk',
|
|
3151
|
+
'sourceId',
|
|
3152
|
+
'tabUid',
|
|
3153
|
+
];
|
|
3154
|
+
|
|
3155
|
+
function pickDefinedKeys(source: Record<string, unknown> | null | undefined, keys: string[]) {
|
|
3156
|
+
const res: Record<string, unknown> = {};
|
|
3157
|
+
for (const key of keys) {
|
|
3158
|
+
if (typeof source?.[key] !== 'undefined') {
|
|
3159
|
+
res[key] = source[key];
|
|
3160
|
+
}
|
|
3161
|
+
}
|
|
3162
|
+
return res;
|
|
3163
|
+
}
|
|
3164
|
+
|
|
3165
|
+
function pickDefinedOpenViewInputArgs(source?: Record<string, unknown> | null) {
|
|
3166
|
+
return pickDefinedKeys(source, OPEN_VIEW_INHERITED_INPUT_ARG_KEYS);
|
|
3167
|
+
}
|
|
3168
|
+
|
|
3169
|
+
function applyDefinedDefaults(target: Record<string, unknown>, defaults: Record<string, unknown>) {
|
|
3170
|
+
for (const [key, value] of Object.entries(defaults)) {
|
|
3171
|
+
if (typeof target[key] === 'undefined') {
|
|
3172
|
+
target[key] = value;
|
|
3173
|
+
}
|
|
3174
|
+
}
|
|
3175
|
+
}
|
|
3176
|
+
|
|
3142
3177
|
export class FlowEngineContext extends BaseFlowEngineContext {
|
|
3143
3178
|
// public dataSourceManager: DataSourceManager;
|
|
3144
3179
|
constructor(public engine: FlowEngine) {
|
|
@@ -3398,7 +3433,19 @@ export class FlowEngineContext extends BaseFlowEngineContext {
|
|
|
3398
3433
|
}),
|
|
3399
3434
|
});
|
|
3400
3435
|
this.defineProperty('role', {
|
|
3401
|
-
get: () =>
|
|
3436
|
+
get: () => {
|
|
3437
|
+
const currentRole = this.api?.auth?.role;
|
|
3438
|
+
if (currentRole !== '__union__') {
|
|
3439
|
+
return currentRole;
|
|
3440
|
+
}
|
|
3441
|
+
const roles = this.user?.roles;
|
|
3442
|
+
if (!Array.isArray(roles)) {
|
|
3443
|
+
return [];
|
|
3444
|
+
}
|
|
3445
|
+
return roles
|
|
3446
|
+
.map((role: { name?: string }) => role?.name)
|
|
3447
|
+
.filter((name: string | undefined): name is string => !!name);
|
|
3448
|
+
},
|
|
3402
3449
|
cache: false,
|
|
3403
3450
|
// 注意:使用惰性 meta 工厂,避免在 i18n 尚未注入时提前求值导致无法翻译
|
|
3404
3451
|
meta: Object.assign(() => ({ type: 'string', title: this.t('Current role'), sort: 990 }), {
|
|
@@ -3457,11 +3504,12 @@ export class FlowEngineContext extends BaseFlowEngineContext {
|
|
|
3457
3504
|
});
|
|
3458
3505
|
this.defineProperty('auth', {
|
|
3459
3506
|
get: () => ({
|
|
3460
|
-
roleName: this.api
|
|
3461
|
-
locale: this.api
|
|
3462
|
-
token: this.api
|
|
3507
|
+
roleName: this.api?.auth?.role,
|
|
3508
|
+
locale: this.api?.auth?.locale,
|
|
3509
|
+
token: this.api?.auth?.token,
|
|
3463
3510
|
user: this.user,
|
|
3464
3511
|
}),
|
|
3512
|
+
cache: false,
|
|
3465
3513
|
});
|
|
3466
3514
|
this.defineProperty('date', {
|
|
3467
3515
|
get: () => {
|
|
@@ -3561,7 +3609,17 @@ export class FlowEngineContext extends BaseFlowEngineContext {
|
|
|
3561
3609
|
doc = {};
|
|
3562
3610
|
}
|
|
3563
3611
|
const deprecatedCtx = createRunJSDeprecationProxy(runCtx, { doc });
|
|
3564
|
-
const
|
|
3612
|
+
const browserGlobals: Record<string, any> = {};
|
|
3613
|
+
if (typeof window !== 'undefined') {
|
|
3614
|
+
browserGlobals.window = window;
|
|
3615
|
+
if (typeof navigator !== 'undefined') {
|
|
3616
|
+
browserGlobals.navigator = navigator;
|
|
3617
|
+
}
|
|
3618
|
+
}
|
|
3619
|
+
if (typeof document !== 'undefined') {
|
|
3620
|
+
browserGlobals.document = document;
|
|
3621
|
+
}
|
|
3622
|
+
const globals: Record<string, any> = { ctx: deprecatedCtx, ...browserGlobals, ...(options?.globals || {}) };
|
|
3565
3623
|
const { timeoutMs } = options || {};
|
|
3566
3624
|
return new JSRunner({ globals, timeoutMs });
|
|
3567
3625
|
});
|
|
@@ -3687,7 +3745,14 @@ export class FlowModelContext extends BaseFlowModelContext {
|
|
|
3687
3745
|
},
|
|
3688
3746
|
});
|
|
3689
3747
|
this.defineMethod('openView', async function (uid: string, options) {
|
|
3690
|
-
const
|
|
3748
|
+
const inheritedInputArgs = {
|
|
3749
|
+
...(typeof this.model?.['getInputArgs'] === 'function'
|
|
3750
|
+
? pickDefinedOpenViewInputArgs(this.model['getInputArgs']())
|
|
3751
|
+
: {}),
|
|
3752
|
+
...pickDefinedOpenViewInputArgs(this.inputArgs),
|
|
3753
|
+
};
|
|
3754
|
+
const opts = { ...(options || {}) };
|
|
3755
|
+
applyDefinedDefaults(opts, inheritedInputArgs);
|
|
3691
3756
|
// NOTE: when custom context is passed, route navigation must be disabled to avoid losing it after refresh.
|
|
3692
3757
|
if (opts.defineProperties || opts.defineMethods) {
|
|
3693
3758
|
opts.navigation = false; // 强制不使用路由导航, 避免刷新页面时丢失上下文
|
|
@@ -3695,15 +3760,6 @@ export class FlowModelContext extends BaseFlowModelContext {
|
|
|
3695
3760
|
let model: FlowModel | null = null;
|
|
3696
3761
|
model = await this.engine.loadModel({ uid });
|
|
3697
3762
|
if (!model) {
|
|
3698
|
-
const pickDefined = (src: Record<string, any>, keys: string[]) => {
|
|
3699
|
-
const res: Record<string, any> = {};
|
|
3700
|
-
for (const k of keys) {
|
|
3701
|
-
if (typeof src?.[k] !== 'undefined') {
|
|
3702
|
-
res[k] = src[k];
|
|
3703
|
-
}
|
|
3704
|
-
}
|
|
3705
|
-
return res;
|
|
3706
|
-
};
|
|
3707
3763
|
model = this.engine.createModel({
|
|
3708
3764
|
uid, // 注意: 新建的 model 应该使用 ${parentModel.uid}-xxx 形式的 uid
|
|
3709
3765
|
use: 'PopupActionModel',
|
|
@@ -3714,7 +3770,7 @@ export class FlowModelContext extends BaseFlowModelContext {
|
|
|
3714
3770
|
popupSettings: {
|
|
3715
3771
|
openView: {
|
|
3716
3772
|
// 仅在创建时持久化一份默认配置;运行时以本次 opts 为准,避免多个 opener 互相覆盖。
|
|
3717
|
-
...
|
|
3773
|
+
...pickDefinedKeys(opts, ['dataSourceKey', 'collectionName', 'associationName', 'mode', 'size']),
|
|
3718
3774
|
},
|
|
3719
3775
|
},
|
|
3720
3776
|
},
|
|
@@ -3734,8 +3790,6 @@ export class FlowModelContext extends BaseFlowModelContext {
|
|
|
3734
3790
|
// 统一语义:为即将打开的外部视图定义一个 PendingView(占位视图)
|
|
3735
3791
|
const pendingType = (opts?.isMobileLayout ? 'embed' : opts?.mode || 'drawer') as any;
|
|
3736
3792
|
const pendingInputArgs = { ...opts, viewUid, navigation: opts.navigation };
|
|
3737
|
-
pendingInputArgs.filterByTk = pendingInputArgs.filterByTk || this.inputArgs?.filterByTk;
|
|
3738
|
-
pendingInputArgs.sourceId = pendingInputArgs.sourceId || this.inputArgs?.sourceId;
|
|
3739
3793
|
|
|
3740
3794
|
const pendingView = {
|
|
3741
3795
|
type: pendingType,
|
|
@@ -3754,17 +3808,10 @@ export class FlowModelContext extends BaseFlowModelContext {
|
|
|
3754
3808
|
} else if (on && typeof on === 'object' && typeof (on as any).eventName === 'string' && (on as any).eventName) {
|
|
3755
3809
|
openEventName = (on as any).eventName;
|
|
3756
3810
|
}
|
|
3757
|
-
await model.dispatchEvent(
|
|
3758
|
-
|
|
3759
|
-
|
|
3760
|
-
|
|
3761
|
-
// ...this.model?.['getInputArgs']?.(), // 避免部分关系字段信息丢失, 仿照 ClickableCollectionField 做法
|
|
3762
|
-
...opts,
|
|
3763
|
-
},
|
|
3764
|
-
{
|
|
3765
|
-
debounce: true,
|
|
3766
|
-
},
|
|
3767
|
-
);
|
|
3811
|
+
await model.dispatchEvent(openEventName, {
|
|
3812
|
+
// navigation: false, // TODO: 路由模式有bug,不支持多层同样viewId的弹窗,因此这里默认先用false
|
|
3813
|
+
...opts,
|
|
3814
|
+
});
|
|
3768
3815
|
});
|
|
3769
3816
|
this.defineMethod('getEvents', function (this: BaseFlowModelContext) {
|
|
3770
3817
|
return this.model.getEvents();
|
|
@@ -4558,6 +4605,27 @@ export class FlowRunJSContext extends FlowContext {
|
|
|
4558
4605
|
this.defineProperty('ReactDOM', { value: ReactDOMShim });
|
|
4559
4606
|
|
|
4560
4607
|
setupRunJSLibs(this);
|
|
4608
|
+
this.defineMethod('openView', async function (uid: string, options?: Record<PropertyKey, unknown>) {
|
|
4609
|
+
const delegateOpenView = (
|
|
4610
|
+
delegate as FlowContext & {
|
|
4611
|
+
openView?: (uid: string, options?: Record<PropertyKey, unknown>) => Promise<unknown>;
|
|
4612
|
+
}
|
|
4613
|
+
).openView;
|
|
4614
|
+
|
|
4615
|
+
if (typeof delegateOpenView !== 'function') {
|
|
4616
|
+
throw new Error('ctx.openView is not available in current context.');
|
|
4617
|
+
}
|
|
4618
|
+
|
|
4619
|
+
const routeState = createOpenViewRouteState(options);
|
|
4620
|
+
if (!routeState) {
|
|
4621
|
+
return delegateOpenView(uid, options);
|
|
4622
|
+
}
|
|
4623
|
+
|
|
4624
|
+
return delegateOpenView(uid, {
|
|
4625
|
+
...(options || {}),
|
|
4626
|
+
[RUNJS_OPEN_VIEW_ROUTE_STATE]: routeState,
|
|
4627
|
+
});
|
|
4628
|
+
});
|
|
4561
4629
|
|
|
4562
4630
|
// Convenience: ctx.render(<App />[, container])
|
|
4563
4631
|
// - container defaults to ctx.element if available
|
package/src/flowEngine.ts
CHANGED
|
@@ -1343,6 +1343,9 @@ export class FlowEngine {
|
|
|
1343
1343
|
if (!this.ensureModelRepository()) return;
|
|
1344
1344
|
const refresh = !!options?.refresh;
|
|
1345
1345
|
const bypassLoadedPageCache = this._loadedPageCache.shouldBypass(options, () => this.context.flowSettingsEnabled);
|
|
1346
|
+
if (this.context.flowSettingsEnabled) {
|
|
1347
|
+
this._loadedPageCache.markDirtyForOptions(options);
|
|
1348
|
+
}
|
|
1346
1349
|
if (!refresh && !bypassLoadedPageCache) {
|
|
1347
1350
|
const model = this.findModelByParentId(options.parentId, options.subKey);
|
|
1348
1351
|
if (model) {
|
|
@@ -1412,6 +1415,9 @@ export class FlowEngine {
|
|
|
1412
1415
|
if (!this.ensureModelRepository()) return;
|
|
1413
1416
|
const { uid, parentId, subKey } = options;
|
|
1414
1417
|
const bypassLoadedPageCache = this._loadedPageCache.shouldBypass(options, () => this.context.flowSettingsEnabled);
|
|
1418
|
+
if (this.context.flowSettingsEnabled) {
|
|
1419
|
+
this._loadedPageCache.markDirtyForOptions(options);
|
|
1420
|
+
}
|
|
1415
1421
|
if (uid && !bypassLoadedPageCache && this._modelInstances.has(uid)) {
|
|
1416
1422
|
return this._modelInstances.get(uid) as T;
|
|
1417
1423
|
}
|