@nocobase/flow-engine 2.0.0-beta.2 → 2.0.0-beta.20
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/BlockScopedFlowEngine.js +0 -1
- package/lib/JSRunner.d.ts +6 -0
- package/lib/JSRunner.js +2 -1
- package/lib/ViewScopedFlowEngine.js +3 -0
- package/lib/acl/Acl.js +13 -3
- package/lib/components/dnd/gridDragPlanner.d.ts +1 -0
- package/lib/components/dnd/gridDragPlanner.js +53 -1
- package/lib/components/settings/wrappers/component/SwitchWithTitle.js +2 -1
- package/lib/components/settings/wrappers/contextual/DefaultSettingsIcon.js +11 -3
- package/lib/components/variables/VariableInput.js +8 -2
- package/lib/data-source/index.js +6 -0
- package/lib/executor/FlowExecutor.d.ts +2 -1
- package/lib/executor/FlowExecutor.js +156 -22
- package/lib/flowContext.d.ts +4 -1
- package/lib/flowContext.js +176 -107
- package/lib/flowEngine.d.ts +21 -0
- package/lib/flowEngine.js +38 -0
- package/lib/flowSettings.js +12 -10
- package/lib/index.d.ts +3 -0
- package/lib/index.js +16 -0
- package/lib/models/CollectionFieldModel.d.ts +1 -0
- package/lib/models/CollectionFieldModel.js +3 -2
- package/lib/models/flowModel.d.ts +7 -0
- package/lib/models/flowModel.js +66 -1
- package/lib/provider.js +7 -6
- package/lib/resources/baseRecordResource.d.ts +5 -0
- package/lib/resources/baseRecordResource.js +24 -0
- package/lib/resources/multiRecordResource.d.ts +1 -0
- package/lib/resources/multiRecordResource.js +11 -4
- package/lib/resources/singleRecordResource.js +2 -0
- package/lib/resources/sqlResource.d.ts +1 -0
- package/lib/resources/sqlResource.js +8 -3
- package/lib/runjs-context/contexts/base.js +10 -4
- package/lib/runjsLibs.d.ts +28 -0
- package/lib/runjsLibs.js +532 -0
- package/lib/scheduler/ModelOperationScheduler.d.ts +2 -0
- package/lib/scheduler/ModelOperationScheduler.js +21 -21
- package/lib/types.d.ts +15 -0
- package/lib/utils/createCollectionContextMeta.js +1 -0
- package/lib/utils/index.d.ts +2 -0
- package/lib/utils/index.js +10 -0
- package/lib/utils/params-resolvers.js +16 -9
- package/lib/utils/resolveModuleUrl.d.ts +58 -0
- package/lib/utils/resolveModuleUrl.js +65 -0
- package/lib/utils/runjsModuleLoader.d.ts +58 -0
- package/lib/utils/runjsModuleLoader.js +422 -0
- package/lib/utils/runjsTemplateCompat.d.ts +35 -0
- package/lib/utils/runjsTemplateCompat.js +743 -0
- package/lib/utils/safeGlobals.d.ts +5 -9
- package/lib/utils/safeGlobals.js +129 -17
- package/lib/views/createViewMeta.d.ts +0 -7
- package/lib/views/createViewMeta.js +19 -70
- package/lib/views/index.d.ts +1 -2
- package/lib/views/index.js +4 -3
- package/lib/views/useDialog.js +8 -3
- package/lib/views/useDrawer.js +7 -2
- package/lib/views/usePage.d.ts +4 -0
- package/lib/views/usePage.js +43 -6
- package/lib/views/usePopover.js +4 -1
- package/lib/views/viewEvents.d.ts +17 -0
- package/lib/views/viewEvents.js +90 -0
- package/package.json +4 -4
- package/src/BlockScopedFlowEngine.ts +2 -5
- package/src/JSRunner.ts +8 -1
- package/src/ViewScopedFlowEngine.ts +4 -0
- package/src/__tests__/createViewMeta.popup.test.ts +62 -1
- package/src/__tests__/flowEngine.dataSourceDirty.test.ts +63 -0
- package/src/__tests__/flowSettings.open.test.tsx +69 -15
- package/src/__tests__/provider.test.tsx +0 -5
- package/src/__tests__/runjsExternalLibs.test.ts +242 -0
- package/src/__tests__/runjsLibsLazyLoading.test.ts +44 -0
- package/src/__tests__/runjsPreprocessDefault.test.ts +49 -0
- package/src/acl/Acl.tsx +3 -3
- package/src/components/__tests__/gridDragPlanner.test.ts +141 -1
- package/src/components/dnd/gridDragPlanner.ts +60 -0
- package/src/components/settings/wrappers/component/SwitchWithTitle.tsx +2 -1
- package/src/components/settings/wrappers/component/__tests__/InlineControls.test.tsx +74 -0
- package/src/components/settings/wrappers/contextual/DefaultSettingsIcon.tsx +11 -3
- package/src/components/settings/wrappers/contextual/__tests__/DefaultSettingsIcon.test.tsx +63 -4
- package/src/components/variables/VariableInput.tsx +8 -2
- package/src/data-source/index.ts +6 -0
- package/src/executor/FlowExecutor.ts +193 -23
- package/src/executor/__tests__/flowExecutor.test.ts +66 -0
- package/src/flowContext.ts +234 -118
- package/src/flowEngine.ts +41 -0
- package/src/flowSettings.ts +12 -11
- package/src/index.ts +10 -0
- package/src/models/CollectionFieldModel.tsx +3 -1
- package/src/models/__tests__/dispatchEvent.when.test.ts +356 -0
- package/src/models/__tests__/flowModel.clone.test.ts +416 -0
- package/src/models/__tests__/flowModel.test.ts +16 -0
- package/src/models/flowModel.tsx +94 -1
- package/src/provider.tsx +9 -7
- package/src/resources/__tests__/multiRecordResource.test.ts +44 -0
- package/src/resources/__tests__/sqlResource.test.ts +60 -0
- package/src/resources/baseRecordResource.ts +31 -0
- package/src/resources/multiRecordResource.ts +11 -4
- package/src/resources/singleRecordResource.ts +3 -0
- package/src/resources/sqlResource.ts +8 -3
- package/src/runjs-context/contexts/base.ts +9 -2
- package/src/runjsLibs.ts +622 -0
- package/src/scheduler/ModelOperationScheduler.ts +23 -21
- package/src/types.ts +26 -1
- package/src/utils/__tests__/params-resolvers.test.ts +40 -0
- package/src/utils/__tests__/runjsRequireAsyncAutoWhitelist.test.ts +38 -0
- package/src/utils/__tests__/runjsTemplateCompat.test.ts +159 -0
- package/src/utils/__tests__/safeGlobals.test.ts +49 -2
- package/src/utils/createCollectionContextMeta.ts +1 -0
- package/src/utils/index.ts +6 -0
- package/src/utils/params-resolvers.ts +23 -9
- package/src/utils/resolveModuleUrl.ts +91 -0
- package/src/utils/runjsModuleLoader.ts +553 -0
- package/src/utils/runjsTemplateCompat.ts +828 -0
- package/src/utils/safeGlobals.ts +133 -16
- package/src/views/__tests__/FlowView.usePage.test.tsx +54 -1
- package/src/views/__tests__/useDialog.closeDestroy.test.tsx +35 -8
- package/src/views/__tests__/viewEvents.resolveOpenerEngine.test.ts +28 -0
- package/src/views/createViewMeta.ts +22 -75
- package/src/views/index.tsx +1 -2
- package/src/views/useDialog.tsx +9 -2
- package/src/views/useDrawer.tsx +8 -1
- package/src/views/usePage.tsx +51 -5
- package/src/views/usePopover.tsx +4 -1
- package/src/views/viewEvents.ts +55 -0
package/src/utils/safeGlobals.ts
CHANGED
|
@@ -15,6 +15,66 @@
|
|
|
15
15
|
* - 不允许随意访问未声明的属性,最小权限原则
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
|
+
type RunJSSafeGlobalsRegistry = {
|
|
19
|
+
windowAllow: Set<string>;
|
|
20
|
+
documentAllow: Set<string>;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
function getRunJSSafeGlobalsRegistry(): RunJSSafeGlobalsRegistry {
|
|
24
|
+
const g: any = globalThis as any;
|
|
25
|
+
if (g.__nocobaseRunJSSafeGlobalsRegistry?.windowAllow && g.__nocobaseRunJSSafeGlobalsRegistry?.documentAllow) {
|
|
26
|
+
return g.__nocobaseRunJSSafeGlobalsRegistry as RunJSSafeGlobalsRegistry;
|
|
27
|
+
}
|
|
28
|
+
const reg: RunJSSafeGlobalsRegistry = {
|
|
29
|
+
windowAllow: new Set<string>(),
|
|
30
|
+
documentAllow: new Set<string>(),
|
|
31
|
+
};
|
|
32
|
+
g.__nocobaseRunJSSafeGlobalsRegistry = reg;
|
|
33
|
+
return reg;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function registerRunJSSafeWindowGlobals(keys: Iterable<string> | null | undefined): void {
|
|
37
|
+
if (!keys) return;
|
|
38
|
+
const reg = getRunJSSafeGlobalsRegistry();
|
|
39
|
+
for (const k of keys) {
|
|
40
|
+
if (typeof k !== 'string') continue;
|
|
41
|
+
const key = k.trim();
|
|
42
|
+
if (!key) continue;
|
|
43
|
+
reg.windowAllow.add(key);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function registerRunJSSafeDocumentGlobals(keys: Iterable<string> | null | undefined): void {
|
|
48
|
+
if (!keys) return;
|
|
49
|
+
const reg = getRunJSSafeGlobalsRegistry();
|
|
50
|
+
for (const k of keys) {
|
|
51
|
+
if (typeof k !== 'string') continue;
|
|
52
|
+
const key = k.trim();
|
|
53
|
+
if (!key) continue;
|
|
54
|
+
reg.documentAllow.add(key);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function __resetRunJSSafeGlobalsRegistryForTests(): void {
|
|
59
|
+
const g: any = globalThis as any;
|
|
60
|
+
if (g.__nocobaseRunJSSafeGlobalsRegistry) {
|
|
61
|
+
try {
|
|
62
|
+
g.__nocobaseRunJSSafeGlobalsRegistry.windowAllow?.clear?.();
|
|
63
|
+
g.__nocobaseRunJSSafeGlobalsRegistry.documentAllow?.clear?.();
|
|
64
|
+
} catch {
|
|
65
|
+
// ignore
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function isAllowedDynamicWindowKey(key: string): boolean {
|
|
71
|
+
return getRunJSSafeGlobalsRegistry().windowAllow.has(key);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function isAllowedDynamicDocumentKey(key: string): boolean {
|
|
75
|
+
return getRunJSSafeGlobalsRegistry().documentAllow.has(key);
|
|
76
|
+
}
|
|
77
|
+
|
|
18
78
|
export function createSafeWindow(extra?: Record<string, any>) {
|
|
19
79
|
// 解析相对 URL 使用脱敏 base(不含 query/hash),避免在解析时泄露敏感信息
|
|
20
80
|
const getSafeBaseHref = () => `${window.location.origin}${window.location.pathname}`;
|
|
@@ -160,15 +220,44 @@ export function createSafeWindow(extra?: Record<string, any>) {
|
|
|
160
220
|
...(extra || {}),
|
|
161
221
|
};
|
|
162
222
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
}
|
|
223
|
+
const target: Record<string, any> = Object.create(null);
|
|
224
|
+
|
|
225
|
+
return new Proxy(target, {
|
|
226
|
+
get(t, prop: string | symbol) {
|
|
227
|
+
if (typeof prop !== 'string') {
|
|
228
|
+
return Reflect.get(t, prop);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if (prop in allowedGlobals) return allowedGlobals[prop];
|
|
232
|
+
if (Object.prototype.hasOwnProperty.call(t, prop)) return (t as any)[prop];
|
|
233
|
+
if (isAllowedDynamicWindowKey(prop)) {
|
|
234
|
+
const v = (window as any)[prop];
|
|
235
|
+
// Bind functions to the real window to avoid Illegal invocation
|
|
236
|
+
if (typeof v === 'function') return v.bind(window);
|
|
237
|
+
return v;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
throw new Error(`Access to global property "${prop}" is not allowed.`);
|
|
170
241
|
},
|
|
171
|
-
|
|
242
|
+
set(t, prop: string | symbol, value: any) {
|
|
243
|
+
if (typeof prop !== 'string') {
|
|
244
|
+
Reflect.set(t, prop, value);
|
|
245
|
+
return true;
|
|
246
|
+
}
|
|
247
|
+
if (prop in allowedGlobals) {
|
|
248
|
+
throw new Error(`Mutation of global property "${prop}" is not allowed.`);
|
|
249
|
+
}
|
|
250
|
+
(t as any)[prop] = value;
|
|
251
|
+
return true;
|
|
252
|
+
},
|
|
253
|
+
has(t, prop: string | symbol) {
|
|
254
|
+
if (typeof prop !== 'string') return Reflect.has(t, prop);
|
|
255
|
+
if (prop in allowedGlobals) return true;
|
|
256
|
+
if (Object.prototype.hasOwnProperty.call(t, prop)) return true;
|
|
257
|
+
if (isAllowedDynamicWindowKey(prop)) return true;
|
|
258
|
+
return false;
|
|
259
|
+
},
|
|
260
|
+
});
|
|
172
261
|
}
|
|
173
262
|
|
|
174
263
|
export function createSafeDocument(extra?: Record<string, any>) {
|
|
@@ -178,15 +267,43 @@ export function createSafeDocument(extra?: Record<string, any>) {
|
|
|
178
267
|
querySelectorAll: document.querySelectorAll.bind(document),
|
|
179
268
|
...(extra || {}),
|
|
180
269
|
};
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
{
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
270
|
+
const target: Record<string, any> = Object.create(null);
|
|
271
|
+
return new Proxy(target, {
|
|
272
|
+
get(t, prop: string | symbol) {
|
|
273
|
+
if (typeof prop !== 'string') {
|
|
274
|
+
return Reflect.get(t, prop);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
if (prop in allowed) return allowed[prop];
|
|
278
|
+
if (Object.prototype.hasOwnProperty.call(t, prop)) return (t as any)[prop];
|
|
279
|
+
if (isAllowedDynamicDocumentKey(prop)) {
|
|
280
|
+
const v = (document as any)[prop];
|
|
281
|
+
// Bind functions to the real document to avoid Illegal invocation
|
|
282
|
+
if (typeof v === 'function') return v.bind(document);
|
|
283
|
+
return v;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
throw new Error(`Access to document property "${prop}" is not allowed.`);
|
|
188
287
|
},
|
|
189
|
-
|
|
288
|
+
set(t, prop: string | symbol, value: any) {
|
|
289
|
+
if (typeof prop !== 'string') {
|
|
290
|
+
Reflect.set(t, prop, value);
|
|
291
|
+
return true;
|
|
292
|
+
}
|
|
293
|
+
if (prop in allowed) {
|
|
294
|
+
throw new Error(`Mutation of document property "${prop}" is not allowed.`);
|
|
295
|
+
}
|
|
296
|
+
(t as any)[prop] = value;
|
|
297
|
+
return true;
|
|
298
|
+
},
|
|
299
|
+
has(t, prop: string | symbol) {
|
|
300
|
+
if (typeof prop !== 'string') return Reflect.has(t, prop);
|
|
301
|
+
if (prop in allowed) return true;
|
|
302
|
+
if (Object.prototype.hasOwnProperty.call(t, prop)) return true;
|
|
303
|
+
if (isAllowedDynamicDocumentKey(prop)) return true;
|
|
304
|
+
return false;
|
|
305
|
+
},
|
|
306
|
+
});
|
|
190
307
|
}
|
|
191
308
|
|
|
192
309
|
export function createSafeNavigator(extra?: Record<string, any>) {
|
|
@@ -13,7 +13,7 @@ import { render, act, waitFor, screen } from '@testing-library/react';
|
|
|
13
13
|
import { FlowEngine } from '../../flowEngine';
|
|
14
14
|
import { FlowEngineProvider } from '../../provider';
|
|
15
15
|
import { FlowViewer } from '../FlowView';
|
|
16
|
-
import { usePage } from '../usePage';
|
|
16
|
+
import { usePage, GLOBAL_EMBED_CONTAINER_ID } from '../usePage';
|
|
17
17
|
import { App, ConfigProvider } from 'antd';
|
|
18
18
|
|
|
19
19
|
describe('FlowViewer zIndex with usePage', () => {
|
|
@@ -130,4 +130,57 @@ describe('FlowViewer zIndex with usePage', () => {
|
|
|
130
130
|
|
|
131
131
|
unmount();
|
|
132
132
|
});
|
|
133
|
+
|
|
134
|
+
it('replaces previous embed view when using global #nocobase-embed-container target', async () => {
|
|
135
|
+
let api: { open: (config: any, flowContext: any) => any } | undefined;
|
|
136
|
+
|
|
137
|
+
function TestApp({ onReady }: { onReady: (page: any) => void }) {
|
|
138
|
+
const [page, pageHolder] = usePage() as [{ open: (config: any, flowContext: any) => any }, React.ReactNode];
|
|
139
|
+
|
|
140
|
+
React.useEffect(() => {
|
|
141
|
+
onReady(page);
|
|
142
|
+
}, [page, onReady]);
|
|
143
|
+
|
|
144
|
+
return <>{pageHolder}</>;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const Wrapper: React.FC<{ onReady: (page: any) => void }> = ({ onReady }) => (
|
|
148
|
+
<ConfigProvider>
|
|
149
|
+
<App>
|
|
150
|
+
<FlowEngineProvider engine={engine}>
|
|
151
|
+
<TestApp onReady={onReady} />
|
|
152
|
+
</FlowEngineProvider>
|
|
153
|
+
</App>
|
|
154
|
+
</ConfigProvider>
|
|
155
|
+
);
|
|
156
|
+
|
|
157
|
+
const target = document.createElement('div');
|
|
158
|
+
target.id = GLOBAL_EMBED_CONTAINER_ID;
|
|
159
|
+
document.body.appendChild(target);
|
|
160
|
+
|
|
161
|
+
const { unmount } = render(
|
|
162
|
+
<Wrapper
|
|
163
|
+
onReady={(page) => {
|
|
164
|
+
api = page;
|
|
165
|
+
}}
|
|
166
|
+
/>,
|
|
167
|
+
);
|
|
168
|
+
|
|
169
|
+
await waitFor(() => expect(api).toBeDefined());
|
|
170
|
+
|
|
171
|
+
await act(async () => {
|
|
172
|
+
api!.open({ target, content: <div data-testid="page1">Page 1</div> }, engine.context);
|
|
173
|
+
});
|
|
174
|
+
await waitFor(() => expect(screen.getByTestId('page1')).toBeInTheDocument());
|
|
175
|
+
|
|
176
|
+
// Opening page2 into the global embed container should destroy page1 (replace behavior).
|
|
177
|
+
await act(async () => {
|
|
178
|
+
api!.open({ target, content: <div data-testid="page2">Page 2</div> }, engine.context);
|
|
179
|
+
});
|
|
180
|
+
await waitFor(() => expect(screen.getByTestId('page2')).toBeInTheDocument());
|
|
181
|
+
expect(screen.queryByTestId('page1')).not.toBeInTheDocument();
|
|
182
|
+
|
|
183
|
+
unmount();
|
|
184
|
+
document.body.removeChild(target);
|
|
185
|
+
});
|
|
133
186
|
});
|
|
@@ -14,30 +14,28 @@ import { useDialog } from '../useDialog';
|
|
|
14
14
|
import { FlowContext } from '../../flowContext';
|
|
15
15
|
|
|
16
16
|
// Mock dependencies
|
|
17
|
-
vi.mock('
|
|
17
|
+
vi.mock('../../provider', () => ({
|
|
18
18
|
FlowEngineProvider: ({ children }) => children,
|
|
19
19
|
}));
|
|
20
20
|
|
|
21
|
-
vi.mock('
|
|
21
|
+
vi.mock('../../FlowContextProvider', () => ({
|
|
22
22
|
FlowViewContextProvider: ({ children }) => children,
|
|
23
23
|
}));
|
|
24
24
|
|
|
25
|
-
vi.mock('
|
|
25
|
+
vi.mock('../../ViewScopedFlowEngine', () => ({
|
|
26
26
|
createViewScopedEngine: (engine) => ({
|
|
27
27
|
context: new FlowContext(),
|
|
28
28
|
unlinkFromStack: vi.fn(),
|
|
29
|
+
// mimic real view stack linkage: previousEngine points to the last engine in chain
|
|
30
|
+
previousEngine: (engine as any)?.nextEngine || engine,
|
|
29
31
|
}),
|
|
30
32
|
}));
|
|
31
33
|
|
|
32
|
-
vi.mock('
|
|
34
|
+
vi.mock('../../utils/variablesParams', () => ({
|
|
33
35
|
createViewRecordResolveOnServer: vi.fn(),
|
|
34
36
|
getViewRecordFromParent: vi.fn(),
|
|
35
37
|
}));
|
|
36
38
|
|
|
37
|
-
vi.mock('../createViewMeta', () => ({
|
|
38
|
-
registerPopupVariable: vi.fn(),
|
|
39
|
-
}));
|
|
40
|
-
|
|
41
39
|
vi.mock('../DialogComponent', () => ({
|
|
42
40
|
default: ({ children }) => <div>{children}</div>,
|
|
43
41
|
}));
|
|
@@ -52,8 +50,12 @@ vi.mock('../usePatchElement', () => ({
|
|
|
52
50
|
describe('useDialog - close/destroy logic', () => {
|
|
53
51
|
const createMockFlowContext = () => {
|
|
54
52
|
const ctx = new FlowContext();
|
|
53
|
+
ctx.defineMethod('t', (key: string) => key);
|
|
55
54
|
ctx.engine = {
|
|
56
55
|
context: new FlowContext(),
|
|
56
|
+
emitter: {
|
|
57
|
+
emit: vi.fn(),
|
|
58
|
+
},
|
|
57
59
|
};
|
|
58
60
|
return ctx;
|
|
59
61
|
};
|
|
@@ -129,4 +131,29 @@ describe('useDialog - close/destroy logic', () => {
|
|
|
129
131
|
// Should not call destroy directly, let router handle it
|
|
130
132
|
expect(mockCloseFunc).not.toHaveBeenCalled();
|
|
131
133
|
});
|
|
134
|
+
|
|
135
|
+
it('should emit view activated event on opener engine', () => {
|
|
136
|
+
const api = renderUseDialog();
|
|
137
|
+
const flowContext = createMockFlowContext();
|
|
138
|
+
const emitSpy = flowContext.engine.emitter.emit;
|
|
139
|
+
|
|
140
|
+
const dialog = api.open({ inputArgs: { viewUid: 'child-view' } }, flowContext);
|
|
141
|
+
|
|
142
|
+
dialog.close();
|
|
143
|
+
expect(emitSpy).toHaveBeenCalledWith('view:activated', expect.anything());
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('should emit view events on immediate opener engine (previousEngine) when present', () => {
|
|
147
|
+
const api = renderUseDialog();
|
|
148
|
+
const flowContext = createMockFlowContext();
|
|
149
|
+
const rootEmitSpy = flowContext.engine.emitter.emit;
|
|
150
|
+
const openerEmitSpy = vi.fn();
|
|
151
|
+
(flowContext.engine as any).nextEngine = { emitter: { emit: openerEmitSpy }, __NOCOBASE_ENGINE_SCOPE__: 'view' };
|
|
152
|
+
|
|
153
|
+
const dialog = api.open({ inputArgs: { viewUid: 'child-view' } }, flowContext);
|
|
154
|
+
|
|
155
|
+
dialog.close();
|
|
156
|
+
expect(openerEmitSpy).toHaveBeenCalledWith('view:activated', expect.anything());
|
|
157
|
+
expect(rootEmitSpy).not.toHaveBeenCalledWith('view:activated', expect.anything());
|
|
158
|
+
});
|
|
132
159
|
});
|
|
@@ -0,0 +1,28 @@
|
|
|
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 { describe, expect, it } from 'vitest';
|
|
11
|
+
import { FlowEngine } from '../../flowEngine';
|
|
12
|
+
import { createViewScopedEngine } from '../../ViewScopedFlowEngine';
|
|
13
|
+
import { resolveOpenerEngine } from '../viewEvents';
|
|
14
|
+
|
|
15
|
+
describe('viewEvents.resolveOpenerEngine', () => {
|
|
16
|
+
it('prefers the parent view engine even when it is not the stack tail (cached page scenario)', () => {
|
|
17
|
+
const root = new FlowEngine();
|
|
18
|
+
const pageA = createViewScopedEngine(root);
|
|
19
|
+
createViewScopedEngine(root); // pageB appended after pageA
|
|
20
|
+
|
|
21
|
+
// Open a dialog from pageA while another kept-alive view exists after it.
|
|
22
|
+
// view scoped engines always link to the tail, so the dialog's previousEngine will be pageB.
|
|
23
|
+
const dialog = createViewScopedEngine(pageA);
|
|
24
|
+
|
|
25
|
+
const opener = resolveOpenerEngine(pageA, dialog);
|
|
26
|
+
expect(opener).toBe(pageA);
|
|
27
|
+
});
|
|
28
|
+
});
|
|
@@ -70,66 +70,6 @@ function makeMetaFromValue(value: any, title?: string, seen?: WeakSet<any>): any
|
|
|
70
70
|
return { type: 'any', title };
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
-
/**
|
|
74
|
-
* Create a meta factory for ctx.view that includes:
|
|
75
|
-
* - buildVariablesParams: { record } via inferRecordRef
|
|
76
|
-
* - properties.record: full collection meta via buildRecordMeta
|
|
77
|
-
* - type/preventClose/inputArgs/navigation fields for better variable selection UX
|
|
78
|
-
*/
|
|
79
|
-
export function createViewMeta(ctx: FlowContext): PropertyMetaFactory {
|
|
80
|
-
const viewTitle = ctx.t('当前视图');
|
|
81
|
-
const factory: PropertyMetaFactory = async () => {
|
|
82
|
-
const view = ctx.view;
|
|
83
|
-
return {
|
|
84
|
-
type: 'object',
|
|
85
|
-
title: ctx.t('当前视图'),
|
|
86
|
-
buildVariablesParams: (c) => {
|
|
87
|
-
const params = inferViewRecordRef(c);
|
|
88
|
-
if (params) {
|
|
89
|
-
return {
|
|
90
|
-
record: params,
|
|
91
|
-
};
|
|
92
|
-
}
|
|
93
|
-
return undefined;
|
|
94
|
-
},
|
|
95
|
-
properties: async () => {
|
|
96
|
-
const props: Record<string, any> = {};
|
|
97
|
-
// 仅当能推断到当前记录引用时,才暴露“当前视图记录”,避免出现空子菜单
|
|
98
|
-
const refNow = inferViewRecordRef(ctx);
|
|
99
|
-
if (refNow && refNow.collection) {
|
|
100
|
-
const recordFactory: PropertyMetaFactory = async () => {
|
|
101
|
-
try {
|
|
102
|
-
const ref = inferViewRecordRef(ctx);
|
|
103
|
-
if (!ref?.collection) return null;
|
|
104
|
-
const dsKey = ref.dataSourceKey || 'main';
|
|
105
|
-
const ds = ctx.dataSourceManager?.getDataSource?.(dsKey);
|
|
106
|
-
const col = ds?.collectionManager?.getCollection?.(ref.collection);
|
|
107
|
-
if (!col) return null;
|
|
108
|
-
return (await buildRecordMeta(
|
|
109
|
-
() => col,
|
|
110
|
-
ctx.t('当前视图记录'),
|
|
111
|
-
(c) => inferViewRecordRef(c),
|
|
112
|
-
)) as PropertyMeta;
|
|
113
|
-
} catch (e) {
|
|
114
|
-
return null;
|
|
115
|
-
}
|
|
116
|
-
};
|
|
117
|
-
recordFactory.title = ctx.t('当前视图记录');
|
|
118
|
-
recordFactory.hasChildren = true;
|
|
119
|
-
props.record = recordFactory;
|
|
120
|
-
}
|
|
121
|
-
props.type = { type: 'string', title: ctx.t?.('类型') || '类型' };
|
|
122
|
-
props.preventClose = { type: 'boolean', title: ctx.t?.('是否允许关闭') || '是否允许关闭' };
|
|
123
|
-
props.inputArgs = makeMetaFromValue(view?.inputArgs, ctx.t?.('输入参数') || '输入参数');
|
|
124
|
-
return props;
|
|
125
|
-
},
|
|
126
|
-
} as PropertyMeta;
|
|
127
|
-
};
|
|
128
|
-
// 设置工厂函数的 title,让未加载前的占位标题就是“当前视图”
|
|
129
|
-
factory.title = viewTitle;
|
|
130
|
-
return factory;
|
|
131
|
-
}
|
|
132
|
-
|
|
133
73
|
/**
|
|
134
74
|
* 为 ctx.popup 构建元信息:
|
|
135
75
|
* - popup.record:当前弹窗记录(服务端解析)
|
|
@@ -142,14 +82,16 @@ export function createPopupMeta(ctx: FlowContext, anchorView?: FlowView): Proper
|
|
|
142
82
|
const isPopupView = (view?: FlowView): boolean => {
|
|
143
83
|
if (!view) return false;
|
|
144
84
|
const stack = Array.isArray(view.navigation?.viewStack) ? view.navigation.viewStack : [];
|
|
145
|
-
|
|
85
|
+
const openerUids = view?.inputArgs?.openerUids;
|
|
86
|
+
const hasOpener = Array.isArray(openerUids) && openerUids.length > 0;
|
|
87
|
+
return stack.length >= 2 || hasOpener;
|
|
146
88
|
};
|
|
147
89
|
|
|
148
90
|
const hasPopupNow = (): boolean => isPopupView(anchorView ?? ctx.view);
|
|
149
91
|
|
|
150
92
|
// 统一解析锚定视图下的 RecordRef,避免在设置弹窗等二级视图中被误导
|
|
151
93
|
const resolveRecordRef = async (flowCtx: FlowContext): Promise<RecordRef | undefined> => {
|
|
152
|
-
const view = anchorView ??
|
|
94
|
+
const view = anchorView ?? flowCtx.view;
|
|
153
95
|
if (!view || !isPopupView(view)) return undefined;
|
|
154
96
|
|
|
155
97
|
const base = await buildPopupRuntime(flowCtx, view);
|
|
@@ -353,19 +295,24 @@ export function createPopupMeta(ctx: FlowContext, anchorView?: FlowView): Proper
|
|
|
353
295
|
const props: Record<string, any> = {};
|
|
354
296
|
// 当前弹窗 UID(纯前端变量)
|
|
355
297
|
props.uid = { type: 'string', title: t('Popup uid') };
|
|
356
|
-
//
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
(
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
298
|
+
// 仅当存在 filterByTk(可推断具体记录)时才提供“当前弹窗记录”变量;
|
|
299
|
+
// 对于新增/选择类弹窗(无 filterByTk),不应展示该变量以避免误导。
|
|
300
|
+
const recordRef = await resolveRecordRef(ctx);
|
|
301
|
+
if (recordRef) {
|
|
302
|
+
// 基于锚定视图计算“当前弹窗记录”的集合与 RecordRef
|
|
303
|
+
const recordFactory: PropertyMetaFactory = async () => {
|
|
304
|
+
const col = await getCurrentCollection();
|
|
305
|
+
if (!col) return null;
|
|
306
|
+
return await buildRecordMeta(
|
|
307
|
+
() => col,
|
|
308
|
+
t('Current popup record'),
|
|
309
|
+
(c) => resolveRecordRef(c),
|
|
310
|
+
);
|
|
311
|
+
};
|
|
312
|
+
recordFactory.title = t('Current popup record');
|
|
313
|
+
recordFactory.hasChildren = true;
|
|
314
|
+
props.record = recordFactory;
|
|
315
|
+
}
|
|
369
316
|
// 当 view.inputArgs 带有 sourceId + associationName 时,提供“上级记录”变量(基于 sourceId 推断)
|
|
370
317
|
try {
|
|
371
318
|
const inputArgs = ctx.view?.inputArgs;
|
package/src/views/index.tsx
CHANGED
|
@@ -9,7 +9,6 @@
|
|
|
9
9
|
|
|
10
10
|
export { useDialog } from './useDialog';
|
|
11
11
|
export { useDrawer } from './useDrawer';
|
|
12
|
-
export { usePage } from './usePage';
|
|
12
|
+
export { usePage, GLOBAL_EMBED_CONTAINER_ID, EMBED_REPLACING_DATA_KEY } from './usePage';
|
|
13
13
|
export { usePopover } from './usePopover';
|
|
14
14
|
export { ViewNavigation } from './ViewNavigation';
|
|
15
|
-
export { createViewMeta } from './createViewMeta';
|
package/src/views/useDialog.tsx
CHANGED
|
@@ -15,6 +15,7 @@ import { FlowViewContextProvider } from '../FlowContextProvider';
|
|
|
15
15
|
import { registerPopupVariable } from './createViewMeta';
|
|
16
16
|
import DialogComponent from './DialogComponent';
|
|
17
17
|
import usePatchElement from './usePatchElement';
|
|
18
|
+
import { VIEW_ACTIVATED_EVENT, bumpViewActivatedVersion, resolveOpenerEngine } from './viewEvents';
|
|
18
19
|
import { FlowEngineProvider } from '../provider';
|
|
19
20
|
import { createViewScopedEngine } from '../ViewScopedFlowEngine';
|
|
20
21
|
import { createViewRecordResolveOnServer, getViewRecordFromParent } from '../utils/variablesParams';
|
|
@@ -25,6 +26,7 @@ export function useDialog() {
|
|
|
25
26
|
const holderRef = React.useRef(null);
|
|
26
27
|
|
|
27
28
|
const open = (config, flowContext) => {
|
|
29
|
+
const parentEngine = flowContext?.engine;
|
|
28
30
|
uuid += 1;
|
|
29
31
|
const dialogRef = React.createRef<{
|
|
30
32
|
destroy: () => void;
|
|
@@ -77,6 +79,8 @@ export function useDialog() {
|
|
|
77
79
|
const ctx = new FlowContext();
|
|
78
80
|
// 为当前视图创建作用域引擎(隔离实例与缓存)
|
|
79
81
|
const scopedEngine = createViewScopedEngine(flowContext.engine);
|
|
82
|
+
const openerEngine = resolveOpenerEngine(parentEngine, scopedEngine);
|
|
83
|
+
|
|
80
84
|
ctx.defineProperty('engine', { value: scopedEngine });
|
|
81
85
|
ctx.addDelegate(scopedEngine.context);
|
|
82
86
|
if (config.inheritContext !== false) {
|
|
@@ -95,6 +99,10 @@ export function useDialog() {
|
|
|
95
99
|
dialogRef.current?.destroy();
|
|
96
100
|
closeFunc?.();
|
|
97
101
|
resolvePromise?.(result);
|
|
102
|
+
// Notify opener view that it becomes active again.
|
|
103
|
+
const openerEmitter = openerEngine?.emitter;
|
|
104
|
+
bumpViewActivatedVersion(openerEmitter);
|
|
105
|
+
openerEmitter?.emit?.(VIEW_ACTIVATED_EVENT, { type: 'dialog', viewUid: currentDialog?.inputArgs?.viewUid });
|
|
98
106
|
// 关闭时修正 previous/next 指针
|
|
99
107
|
scopedEngine.unlinkFromStack();
|
|
100
108
|
},
|
|
@@ -130,7 +138,6 @@ export function useDialog() {
|
|
|
130
138
|
|
|
131
139
|
ctx.defineProperty('view', {
|
|
132
140
|
get: () => currentDialog,
|
|
133
|
-
// meta: createViewMeta(ctx),
|
|
134
141
|
resolveOnServer: createViewRecordResolveOnServer(ctx, () => getViewRecordFromParent(flowContext, ctx)),
|
|
135
142
|
});
|
|
136
143
|
// 顶层 popup 变量:弹窗记录/数据源/上级弹窗链(去重封装)
|
|
@@ -164,9 +171,9 @@ export function useDialog() {
|
|
|
164
171
|
className="nb-dialog-overflow-hidden"
|
|
165
172
|
ref={dialogRef}
|
|
166
173
|
hidden={config.inputArgs?.hidden?.value}
|
|
167
|
-
{...config}
|
|
168
174
|
footer={currentFooter}
|
|
169
175
|
header={currentHeader}
|
|
176
|
+
{...config}
|
|
170
177
|
onCancel={() => {
|
|
171
178
|
currentDialog.close(config.result);
|
|
172
179
|
}}
|
package/src/views/useDrawer.tsx
CHANGED
|
@@ -15,6 +15,7 @@ import { FlowViewContextProvider } from '../FlowContextProvider';
|
|
|
15
15
|
import { registerPopupVariable } from './createViewMeta';
|
|
16
16
|
import DrawerComponent from './DrawerComponent';
|
|
17
17
|
import usePatchElement from './usePatchElement';
|
|
18
|
+
import { VIEW_ACTIVATED_EVENT, bumpViewActivatedVersion, resolveOpenerEngine } from './viewEvents';
|
|
18
19
|
import { FlowEngineProvider } from '../provider';
|
|
19
20
|
import { createViewScopedEngine } from '../ViewScopedFlowEngine';
|
|
20
21
|
import { createViewRecordResolveOnServer, getViewRecordFromParent } from '../utils/variablesParams';
|
|
@@ -54,6 +55,7 @@ export function useDrawer() {
|
|
|
54
55
|
RenderNestedDrawer.displayName = 'RenderNestedDrawer';
|
|
55
56
|
|
|
56
57
|
const open = (config, flowContext: FlowEngineContext) => {
|
|
58
|
+
const parentEngine = flowContext.engine;
|
|
57
59
|
const drawerRef = React.createRef<{
|
|
58
60
|
destroy: () => void;
|
|
59
61
|
update: (config: any) => void;
|
|
@@ -105,6 +107,8 @@ export function useDrawer() {
|
|
|
105
107
|
const ctx = new FlowContext();
|
|
106
108
|
// 为当前视图创建作用域引擎(隔离实例与缓存)
|
|
107
109
|
const scopedEngine = createViewScopedEngine(flowContext.engine);
|
|
110
|
+
const openerEngine = resolveOpenerEngine(parentEngine, scopedEngine);
|
|
111
|
+
|
|
108
112
|
// 先将引擎暴露给视图上下文,再按需继承父上下文
|
|
109
113
|
ctx.defineProperty('engine', { value: scopedEngine });
|
|
110
114
|
ctx.addDelegate(scopedEngine.context);
|
|
@@ -124,6 +128,10 @@ export function useDrawer() {
|
|
|
124
128
|
drawerRef.current?.destroy();
|
|
125
129
|
closeFunc?.();
|
|
126
130
|
resolvePromise?.(result);
|
|
131
|
+
// Notify opener view that it becomes active again.
|
|
132
|
+
const openerEmitter = openerEngine?.emitter;
|
|
133
|
+
bumpViewActivatedVersion(openerEmitter);
|
|
134
|
+
openerEmitter?.emit?.(VIEW_ACTIVATED_EVENT, { type: 'drawer', viewUid: currentDrawer?.inputArgs?.viewUid });
|
|
127
135
|
// 关闭时修正 previous/next 指针
|
|
128
136
|
scopedEngine.unlinkFromStack();
|
|
129
137
|
},
|
|
@@ -159,7 +167,6 @@ export function useDrawer() {
|
|
|
159
167
|
|
|
160
168
|
ctx.defineProperty('view', {
|
|
161
169
|
get: () => currentDrawer,
|
|
162
|
-
// meta: createViewMeta(ctx),
|
|
163
170
|
resolveOnServer: createViewRecordResolveOnServer(ctx, () => getViewRecordFromParent(flowContext, ctx)),
|
|
164
171
|
});
|
|
165
172
|
// 顶层 popup 变量:弹窗记录/数据源/上级弹窗链(去重封装)
|