@nocobase/flow-engine 2.1.0-alpha.40 → 2.1.0-alpha.46
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/FlowContextProvider.d.ts +5 -1
- package/lib/FlowContextProvider.js +9 -2
- package/lib/components/settings/wrappers/contextual/DefaultSettingsIcon.js +84 -32
- package/lib/components/subModel/LazyDropdown.js +208 -16
- package/lib/components/subModel/utils.d.ts +1 -0
- package/lib/components/subModel/utils.js +6 -2
- package/lib/data-source/index.d.ts +9 -0
- package/lib/data-source/index.js +12 -0
- package/lib/executor/FlowExecutor.js +0 -3
- package/lib/flowContext.d.ts +6 -1
- package/lib/flowContext.js +38 -6
- package/lib/flowEngine.d.ts +4 -3
- package/lib/flowEngine.js +72 -40
- package/lib/models/flowModel.js +48 -16
- package/lib/runjs-context/contexts/FormJSFieldItemRunJSContext.js +4 -3
- package/lib/runjs-context/contexts/JSBlockRunJSContext.js +4 -15
- package/lib/runjs-context/contexts/JSColumnRunJSContext.js +5 -2
- package/lib/runjs-context/contexts/JSEditableFieldRunJSContext.js +5 -8
- package/lib/runjs-context/contexts/JSFieldRunJSContext.js +4 -3
- package/lib/runjs-context/contexts/JSItemRunJSContext.js +4 -3
- package/lib/runjs-context/contexts/base.js +464 -29
- package/lib/runjs-context/contexts/elementDoc.d.ts +11 -0
- package/lib/runjs-context/contexts/elementDoc.js +152 -0
- package/lib/utils/loadedPageCache.d.ts +24 -0
- package/lib/utils/loadedPageCache.js +139 -0
- package/lib/utils/parsePathnameToViewParams.d.ts +5 -1
- package/lib/utils/parsePathnameToViewParams.js +28 -4
- package/lib/views/ViewNavigation.d.ts +12 -2
- package/lib/views/ViewNavigation.js +22 -7
- package/lib/views/createViewMeta.js +114 -50
- package/lib/views/inheritLayoutContext.d.ts +10 -0
- package/lib/views/inheritLayoutContext.js +50 -0
- package/lib/views/useDialog.js +2 -0
- package/lib/views/useDrawer.js +2 -0
- package/lib/views/usePage.js +2 -0
- package/package.json +4 -4
- package/src/FlowContextProvider.tsx +9 -1
- package/src/__tests__/createViewMeta.popup.test.ts +115 -1
- package/src/__tests__/flowContext.test.ts +23 -0
- package/src/__tests__/flowEngine.moveModel.test.ts +81 -1
- package/src/__tests__/flowEngine.removeModel.test.ts +47 -3
- package/src/__tests__/runjsContext.test.ts +18 -0
- package/src/__tests__/runjsContextImplementations.test.ts +9 -2
- package/src/__tests__/runjsLocales.test.ts +6 -5
- package/src/__tests__/viewScopedFlowEngine.test.ts +133 -0
- package/src/components/settings/wrappers/contextual/DefaultSettingsIcon.tsx +90 -38
- package/src/components/settings/wrappers/contextual/__tests__/DefaultSettingsIcon.test.tsx +155 -5
- package/src/components/subModel/LazyDropdown.tsx +237 -16
- package/src/components/subModel/__tests__/AddSubModelButton.test.tsx +254 -1
- package/src/components/subModel/utils.ts +6 -1
- package/src/data-source/index.ts +18 -0
- package/src/executor/FlowExecutor.ts +0 -3
- package/src/executor/__tests__/flowExecutor.test.ts +26 -0
- package/src/flowContext.ts +43 -6
- package/src/flowEngine.ts +75 -38
- package/src/models/__tests__/flowEngine.resolveUse.test.ts +0 -15
- package/src/models/__tests__/flowModel.test.ts +46 -62
- package/src/models/flowModel.tsx +65 -32
- package/src/runjs-context/contexts/FormJSFieldItemRunJSContext.ts +4 -3
- package/src/runjs-context/contexts/JSBlockRunJSContext.ts +4 -15
- package/src/runjs-context/contexts/JSColumnRunJSContext.ts +4 -2
- package/src/runjs-context/contexts/JSEditableFieldRunJSContext.ts +5 -9
- package/src/runjs-context/contexts/JSFieldRunJSContext.ts +4 -3
- package/src/runjs-context/contexts/JSItemRunJSContext.ts +4 -3
- package/src/runjs-context/contexts/base.ts +467 -31
- package/src/runjs-context/contexts/elementDoc.ts +130 -0
- package/src/utils/__tests__/parsePathnameToViewParams.test.ts +21 -0
- package/src/utils/loadedPageCache.ts +147 -0
- package/src/utils/parsePathnameToViewParams.ts +45 -5
- package/src/views/ViewNavigation.ts +40 -7
- package/src/views/__tests__/ViewNavigation.test.ts +52 -0
- package/src/views/__tests__/inheritLayoutContext.test.ts +53 -0
- package/src/views/createViewMeta.ts +106 -34
- package/src/views/inheritLayoutContext.ts +26 -0
- package/src/views/useDialog.tsx +2 -0
- package/src/views/useDrawer.tsx +2 -0
- package/src/views/usePage.tsx +2 -0
|
@@ -0,0 +1,130 @@
|
|
|
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 type { RunJSDocPropertyDoc } from '../../flowContext';
|
|
11
|
+
|
|
12
|
+
const elementProperties: Record<string, RunJSDocPropertyDoc> = {
|
|
13
|
+
innerHTML: 'Sanitized inner HTML string.',
|
|
14
|
+
outerHTML: 'Sanitized outer HTML string.',
|
|
15
|
+
textContent: 'Text content.',
|
|
16
|
+
style: 'Inline style declaration.',
|
|
17
|
+
classList: 'DOMTokenList for CSS classes.',
|
|
18
|
+
appendChild: {
|
|
19
|
+
type: 'function',
|
|
20
|
+
description: 'Append a DOM node or text.',
|
|
21
|
+
detail: '(child: Node | string) => void',
|
|
22
|
+
completion: { insertText: `ctx.element.appendChild('text')` },
|
|
23
|
+
},
|
|
24
|
+
setAttribute: {
|
|
25
|
+
type: 'function',
|
|
26
|
+
description: 'Set an HTML attribute.',
|
|
27
|
+
detail: '(name: string, value: string) => void',
|
|
28
|
+
completion: { insertText: `ctx.element.setAttribute('data-key', 'value')` },
|
|
29
|
+
},
|
|
30
|
+
getAttribute: {
|
|
31
|
+
type: 'function',
|
|
32
|
+
description: 'Read an HTML attribute.',
|
|
33
|
+
detail: '(name: string) => string | null',
|
|
34
|
+
completion: { insertText: `ctx.element.getAttribute('data-key')` },
|
|
35
|
+
},
|
|
36
|
+
querySelector: {
|
|
37
|
+
type: 'function',
|
|
38
|
+
description: 'Find the first matching descendant.',
|
|
39
|
+
detail: '(selectors: string) => Element | null',
|
|
40
|
+
completion: { insertText: `ctx.element.querySelector('.selector')` },
|
|
41
|
+
},
|
|
42
|
+
querySelectorAll: {
|
|
43
|
+
type: 'function',
|
|
44
|
+
description: 'Find all matching descendants.',
|
|
45
|
+
detail: '(selectors: string) => NodeListOf<Element>',
|
|
46
|
+
completion: { insertText: `ctx.element.querySelectorAll('.selector')` },
|
|
47
|
+
},
|
|
48
|
+
addEventListener: {
|
|
49
|
+
type: 'function',
|
|
50
|
+
description: 'Attach an event listener to the element.',
|
|
51
|
+
detail: '(type: string, listener: EventListener) => void',
|
|
52
|
+
completion: { insertText: `ctx.element.addEventListener('click', (event) => {})` },
|
|
53
|
+
},
|
|
54
|
+
removeEventListener: {
|
|
55
|
+
type: 'function',
|
|
56
|
+
description: 'Remove an event listener from the element.',
|
|
57
|
+
detail: '(type: string, listener: EventListener) => void',
|
|
58
|
+
completion: { insertText: `ctx.element.removeEventListener('click', handler)` },
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const zhCNElementProperties: Record<string, RunJSDocPropertyDoc> = {
|
|
63
|
+
innerHTML: '已消毒的 innerHTML 字符串。',
|
|
64
|
+
outerHTML: '已消毒的 outerHTML 字符串。',
|
|
65
|
+
textContent: '文本内容。',
|
|
66
|
+
style: '内联样式声明。',
|
|
67
|
+
classList: 'CSS class 列表。',
|
|
68
|
+
appendChild: {
|
|
69
|
+
type: 'function',
|
|
70
|
+
description: '追加 DOM 节点或文本。',
|
|
71
|
+
detail: '(child: Node | string) => void',
|
|
72
|
+
completion: { insertText: `ctx.element.appendChild('text')` },
|
|
73
|
+
},
|
|
74
|
+
setAttribute: {
|
|
75
|
+
type: 'function',
|
|
76
|
+
description: '设置 HTML 属性。',
|
|
77
|
+
detail: '(name: string, value: string) => void',
|
|
78
|
+
completion: { insertText: `ctx.element.setAttribute('data-key', 'value')` },
|
|
79
|
+
},
|
|
80
|
+
getAttribute: {
|
|
81
|
+
type: 'function',
|
|
82
|
+
description: '读取 HTML 属性。',
|
|
83
|
+
detail: '(name: string) => string | null',
|
|
84
|
+
completion: { insertText: `ctx.element.getAttribute('data-key')` },
|
|
85
|
+
},
|
|
86
|
+
querySelector: {
|
|
87
|
+
type: 'function',
|
|
88
|
+
description: '查询第一个匹配的后代元素。',
|
|
89
|
+
detail: '(selectors: string) => Element | null',
|
|
90
|
+
completion: { insertText: `ctx.element.querySelector('.selector')` },
|
|
91
|
+
},
|
|
92
|
+
querySelectorAll: {
|
|
93
|
+
type: 'function',
|
|
94
|
+
description: '查询所有匹配的后代元素。',
|
|
95
|
+
detail: '(selectors: string) => NodeListOf<Element>',
|
|
96
|
+
completion: { insertText: `ctx.element.querySelectorAll('.selector')` },
|
|
97
|
+
},
|
|
98
|
+
addEventListener: {
|
|
99
|
+
type: 'function',
|
|
100
|
+
description: '给元素添加事件监听。',
|
|
101
|
+
detail: '(type: string, listener: EventListener) => void',
|
|
102
|
+
completion: { insertText: `ctx.element.addEventListener('click', (event) => {})` },
|
|
103
|
+
},
|
|
104
|
+
removeEventListener: {
|
|
105
|
+
type: 'function',
|
|
106
|
+
description: '移除元素事件监听。',
|
|
107
|
+
detail: '(type: string, listener: EventListener) => void',
|
|
108
|
+
completion: { insertText: `ctx.element.removeEventListener('click', handler)` },
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
export function createElementPropertyDoc(
|
|
113
|
+
description = 'Current DOM container for this RunJS context. Usually an ElementProxy.',
|
|
114
|
+
): RunJSDocPropertyDoc {
|
|
115
|
+
return {
|
|
116
|
+
description,
|
|
117
|
+
detail: 'HTMLElement | ElementProxy',
|
|
118
|
+
properties: elementProperties,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function createZhCNElementPropertyDoc(
|
|
123
|
+
description = '当前 RunJS 上下文的 DOM 容器,通常为 ElementProxy。',
|
|
124
|
+
): RunJSDocPropertyDoc {
|
|
125
|
+
return {
|
|
126
|
+
description,
|
|
127
|
+
detail: 'HTMLElement | ElementProxy',
|
|
128
|
+
properties: zhCNElementProperties,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
@@ -102,6 +102,27 @@ describe('parsePathnameToViewParams', () => {
|
|
|
102
102
|
expect(result).toEqual([{ viewUid: 'xxx', tabUid: 'yyy' }]);
|
|
103
103
|
});
|
|
104
104
|
|
|
105
|
+
test('should parse custom root prefix', () => {
|
|
106
|
+
const result = parsePathnameToViewParams('/embed/xxx/tab/yyy/view/zzz', { rootPrefix: 'embed' });
|
|
107
|
+
expect(result).toEqual([{ viewUid: 'xxx', tabUid: 'yyy' }, { viewUid: 'zzz' }]);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test('should parse pathname by basePath', () => {
|
|
111
|
+
const result = parsePathnameToViewParams('/embed/xxx/tab/yyy/view/zzz', { basePath: '/embed' });
|
|
112
|
+
expect(result).toEqual([{ viewUid: 'xxx', tabUid: 'yyy' }, { viewUid: 'zzz' }]);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test('should parse pathname by nested basePath', () => {
|
|
116
|
+
const result = parsePathnameToViewParams('/admin/settings/public-forms/xxx/view/zzz', {
|
|
117
|
+
basePath: '/admin/settings/public-forms',
|
|
118
|
+
});
|
|
119
|
+
expect(result).toEqual([{ viewUid: 'xxx' }, { viewUid: 'zzz' }]);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test('should keep admin as default root prefix', () => {
|
|
123
|
+
expect(parsePathnameToViewParams('/embed/xxx')).toEqual([]);
|
|
124
|
+
});
|
|
125
|
+
|
|
105
126
|
test('should parse filterByTk from key-value encoded segment into object', () => {
|
|
106
127
|
const kv = encodeURIComponent('id=1&tenant=ac');
|
|
107
128
|
const path = `/admin/xxx/filterbytk/${kv}`;
|
|
@@ -0,0 +1,147 @@
|
|
|
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 type { FlowModel } from '../models';
|
|
11
|
+
|
|
12
|
+
type LoadedPageOptions = {
|
|
13
|
+
parentId?: string;
|
|
14
|
+
subKey?: string;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
type DirtyKeyOptions = {
|
|
18
|
+
force?: boolean;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
type FlowSettingsContextLike = {
|
|
22
|
+
flowSettingsEnabled?: boolean;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
type FlowEngineLike = {
|
|
26
|
+
context?: FlowSettingsContextLike;
|
|
27
|
+
previousEngine?: FlowEngineLike;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const getLoadedPageKey = (options?: LoadedPageOptions): string | undefined => {
|
|
31
|
+
const parentId = options?.parentId;
|
|
32
|
+
const subKey = options?.subKey;
|
|
33
|
+
if (!parentId || subKey !== 'page') {
|
|
34
|
+
return undefined;
|
|
35
|
+
}
|
|
36
|
+
return `${parentId}::${subKey}`;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const getLoadedPageKeyFromModel = (model?: FlowModel | null): string | undefined => {
|
|
40
|
+
let current = model;
|
|
41
|
+
while (current) {
|
|
42
|
+
if (current.subKey === 'page' && current.parent?.uid) {
|
|
43
|
+
return getLoadedPageKey({ parentId: current.parent.uid, subKey: current.subKey });
|
|
44
|
+
}
|
|
45
|
+
current = current.parent as FlowModel | undefined;
|
|
46
|
+
}
|
|
47
|
+
return undefined;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const isFlowSettingsEnabledForContext = (context?: FlowSettingsContextLike): boolean => {
|
|
51
|
+
try {
|
|
52
|
+
return !!context?.flowSettingsEnabled;
|
|
53
|
+
} catch (error) {
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const isFlowSettingsEnabledForModel = (model?: FlowModel | null): boolean => {
|
|
59
|
+
if (isFlowSettingsEnabledForContext(model?.context as FlowSettingsContextLike | undefined)) {
|
|
60
|
+
return true;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const visited = new Set<FlowEngineLike>();
|
|
64
|
+
let engine = model?.flowEngine as FlowEngineLike | undefined;
|
|
65
|
+
while (engine && !visited.has(engine)) {
|
|
66
|
+
visited.add(engine);
|
|
67
|
+
if (isFlowSettingsEnabledForContext(engine.context)) {
|
|
68
|
+
return true;
|
|
69
|
+
}
|
|
70
|
+
engine = engine.previousEngine;
|
|
71
|
+
}
|
|
72
|
+
return false;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const removeLoadedModelTree = (model?: FlowModel | null): void => {
|
|
76
|
+
if (!model?.uid || !model.flowEngine) {
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
if (model.flowEngine.getModel(model.uid) === model) {
|
|
80
|
+
model.flowEngine.removeModelWithSubModels(model.uid);
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const mountLoadedModelToParent = <T extends FlowModel = FlowModel>(model: T | null, forceReplace = false): T | null => {
|
|
85
|
+
if (!model?.parent || !model.subKey) {
|
|
86
|
+
return model;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const mounted = (model.parent.subModels as any)?.[model.subKey];
|
|
90
|
+
const existing =
|
|
91
|
+
forceReplace && model.subType !== 'array' && mounted && !Array.isArray(mounted)
|
|
92
|
+
? (mounted as FlowModel)
|
|
93
|
+
: model.parent.findSubModel(model.subKey, (m) => m.uid === model.uid);
|
|
94
|
+
if (existing) {
|
|
95
|
+
if (!forceReplace || existing === model) {
|
|
96
|
+
return model;
|
|
97
|
+
}
|
|
98
|
+
removeLoadedModelTree(existing);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (model.subType === 'array') {
|
|
102
|
+
model.parent.addSubModel(model.subKey, model);
|
|
103
|
+
} else {
|
|
104
|
+
model.parent.setSubModel(model.subKey, model);
|
|
105
|
+
}
|
|
106
|
+
return model;
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
export const createLoadedPageCache = () => {
|
|
110
|
+
const dirtyKeys = new Set<string>();
|
|
111
|
+
|
|
112
|
+
return {
|
|
113
|
+
getDirtyKeyForModel(model?: FlowModel | null, options?: DirtyKeyOptions): string | undefined {
|
|
114
|
+
if (!options?.force && !isFlowSettingsEnabledForModel(model)) {
|
|
115
|
+
return undefined;
|
|
116
|
+
}
|
|
117
|
+
return getLoadedPageKeyFromModel(model);
|
|
118
|
+
},
|
|
119
|
+
|
|
120
|
+
markDirty(key?: string): void {
|
|
121
|
+
if (key) {
|
|
122
|
+
dirtyKeys.add(key);
|
|
123
|
+
}
|
|
124
|
+
},
|
|
125
|
+
|
|
126
|
+
shouldBypass(options?: LoadedPageOptions, isFlowSettingsEnabled?: () => boolean): boolean {
|
|
127
|
+
const key = getLoadedPageKey(options);
|
|
128
|
+
if (!key || !dirtyKeys.has(key)) {
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
try {
|
|
132
|
+
return !isFlowSettingsEnabled?.();
|
|
133
|
+
} catch (error) {
|
|
134
|
+
return true;
|
|
135
|
+
}
|
|
136
|
+
},
|
|
137
|
+
|
|
138
|
+
clear(options?: LoadedPageOptions): void {
|
|
139
|
+
const key = getLoadedPageKey(options);
|
|
140
|
+
if (key) {
|
|
141
|
+
dirtyKeys.delete(key);
|
|
142
|
+
}
|
|
143
|
+
},
|
|
144
|
+
|
|
145
|
+
mountModelToParent: mountLoadedModelToParent,
|
|
146
|
+
};
|
|
147
|
+
};
|
|
@@ -18,6 +18,35 @@ export interface ViewParam {
|
|
|
18
18
|
sourceId?: string;
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
export interface ParsePathnameToViewParamsOptions {
|
|
22
|
+
rootPrefix?: string;
|
|
23
|
+
basePath?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const normalizePathname = (pathname: string) => {
|
|
27
|
+
if (!pathname || pathname === '/') {
|
|
28
|
+
return '/';
|
|
29
|
+
}
|
|
30
|
+
return `/${pathname.replace(/^\/+/, '').replace(/\/+$/, '')}`;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const normalizeBasePath = (basePath: string) => `/${basePath.replace(/^\/+/, '').replace(/\/+$/, '')}`;
|
|
34
|
+
|
|
35
|
+
const stripBasePath = (pathname: string, basePath: string) => {
|
|
36
|
+
const normalizedPathname = normalizePathname(pathname);
|
|
37
|
+
const normalizedBasePath = normalizeBasePath(basePath);
|
|
38
|
+
|
|
39
|
+
if (normalizedPathname === normalizedBasePath) {
|
|
40
|
+
return '';
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (normalizedPathname.startsWith(`${normalizedBasePath}/`)) {
|
|
44
|
+
return normalizedPathname.slice(normalizedBasePath.length + 1);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return '';
|
|
48
|
+
};
|
|
49
|
+
|
|
21
50
|
/**
|
|
22
51
|
* 解析路径名为视图参数数组
|
|
23
52
|
*
|
|
@@ -33,15 +62,21 @@ export interface ViewParam {
|
|
|
33
62
|
* parsePathnameToViewParams('/admin/xxx/view/yyy') // [{ viewUid: 'xxx' }, { viewUid: 'yyy' }]
|
|
34
63
|
* ```
|
|
35
64
|
*/
|
|
36
|
-
export const parsePathnameToViewParams = (
|
|
65
|
+
export const parsePathnameToViewParams = (
|
|
66
|
+
pathname: string,
|
|
67
|
+
options: ParsePathnameToViewParamsOptions = {},
|
|
68
|
+
): ViewParam[] => {
|
|
37
69
|
if (!pathname || pathname === '/') {
|
|
38
70
|
return [];
|
|
39
71
|
}
|
|
40
72
|
|
|
73
|
+
const rootPrefix = options.rootPrefix || 'admin';
|
|
74
|
+
const relativePath = options.basePath ? stripBasePath(pathname, options.basePath) : '';
|
|
75
|
+
|
|
41
76
|
// 移除开头的斜杠并分割路径
|
|
42
|
-
const segments = pathname.replace(/^\/+/, '').split('/').filter(Boolean);
|
|
77
|
+
const segments = (options.basePath ? relativePath : pathname).replace(/^\/+/, '').split('/').filter(Boolean);
|
|
43
78
|
|
|
44
|
-
if (segments.length < 2) {
|
|
79
|
+
if (segments.length < (options.basePath ? 1 : 2)) {
|
|
45
80
|
return [];
|
|
46
81
|
}
|
|
47
82
|
|
|
@@ -49,11 +84,16 @@ export const parsePathnameToViewParams = (pathname: string): ViewParam[] => {
|
|
|
49
84
|
let currentView: ViewParam | null = null;
|
|
50
85
|
let i = 0;
|
|
51
86
|
|
|
87
|
+
if (options.basePath) {
|
|
88
|
+
currentView = { viewUid: segments[0] };
|
|
89
|
+
i = 1;
|
|
90
|
+
}
|
|
91
|
+
|
|
52
92
|
while (i < segments.length) {
|
|
53
93
|
const segment = segments[i];
|
|
54
94
|
|
|
55
|
-
//
|
|
56
|
-
if (segment ===
|
|
95
|
+
// 处理布局根前缀或 view 关键字
|
|
96
|
+
if (segment === rootPrefix || segment === 'view') {
|
|
57
97
|
// 如果有当前视图,先保存到结果中
|
|
58
98
|
if (currentView) {
|
|
59
99
|
result.push(currentView);
|
|
@@ -13,6 +13,16 @@ import { ViewParam as SharedViewParam } from '../utils';
|
|
|
13
13
|
|
|
14
14
|
type ViewParams = Omit<SharedViewParam, 'viewUid'> & { viewUid?: string };
|
|
15
15
|
|
|
16
|
+
export interface GeneratePathnameFromViewParamsOptions {
|
|
17
|
+
prefix?: string;
|
|
18
|
+
basePath?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface ViewNavigationOptions {
|
|
22
|
+
basePath?: string;
|
|
23
|
+
layoutBasePath?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
16
26
|
function encodeFilterByTk(val: SharedViewParam['filterByTk']): string {
|
|
17
27
|
if (val === undefined || val === null) return '';
|
|
18
28
|
// 1.x 兼容:对象按 key1=v1&key2=v2 拼接后整体 encodeURIComponent
|
|
@@ -30,6 +40,11 @@ function hasUsableSourceId(sourceId: unknown): sourceId is string | number {
|
|
|
30
40
|
return sourceId !== undefined && sourceId !== null && String(sourceId) !== '';
|
|
31
41
|
}
|
|
32
42
|
|
|
43
|
+
function normalizeBasePath(basePath?: string) {
|
|
44
|
+
const value = basePath || '/admin';
|
|
45
|
+
return `/${value.replace(/^\/+/, '').replace(/\/+$/, '')}`;
|
|
46
|
+
}
|
|
47
|
+
|
|
33
48
|
/**
|
|
34
49
|
* 将 ViewParam 数组转换为 pathname
|
|
35
50
|
*
|
|
@@ -43,12 +58,17 @@ function hasUsableSourceId(sourceId: unknown): sourceId is string | number {
|
|
|
43
58
|
* generatePathnameFromViewParams([{ viewUid: 'xxx' }, { viewUid: 'yyy' }]) // '/admin/xxx/view/yyy'
|
|
44
59
|
* ```
|
|
45
60
|
*/
|
|
46
|
-
export function generatePathnameFromViewParams(
|
|
61
|
+
export function generatePathnameFromViewParams(
|
|
62
|
+
viewParams: ViewParams[],
|
|
63
|
+
options: GeneratePathnameFromViewParamsOptions = {},
|
|
64
|
+
): string {
|
|
65
|
+
const basePath = normalizeBasePath(options.basePath || options.prefix);
|
|
66
|
+
|
|
47
67
|
if (!viewParams || viewParams.length === 0) {
|
|
48
|
-
return
|
|
68
|
+
return basePath;
|
|
49
69
|
}
|
|
50
70
|
|
|
51
|
-
const segments =
|
|
71
|
+
const segments = basePath.replace(/^\/+/, '').split('/').filter(Boolean);
|
|
52
72
|
|
|
53
73
|
viewParams.forEach((viewParam, index) => {
|
|
54
74
|
// 如果不是第一个视图,添加 'view' 关键字
|
|
@@ -81,10 +101,12 @@ export class ViewNavigation {
|
|
|
81
101
|
viewStack: ReadonlyArray<ViewParams>; // 只能通过 setViewStack 修改
|
|
82
102
|
ctx: FlowEngineContext;
|
|
83
103
|
viewParams: ViewParams;
|
|
104
|
+
private readonly basePath?: string;
|
|
84
105
|
|
|
85
|
-
constructor(ctx: FlowEngineContext, viewParams: ViewParams[]) {
|
|
106
|
+
constructor(ctx: FlowEngineContext, viewParams: ViewParams[], options: ViewNavigationOptions = {}) {
|
|
86
107
|
this.setViewStack(viewParams);
|
|
87
108
|
this.ctx = ctx;
|
|
109
|
+
this.basePath = options.basePath || options.layoutBasePath;
|
|
88
110
|
|
|
89
111
|
define(this, {
|
|
90
112
|
viewParams: observable,
|
|
@@ -106,7 +128,7 @@ export class ViewNavigation {
|
|
|
106
128
|
});
|
|
107
129
|
|
|
108
130
|
// 2. 根据 viewStack 生成新的 pathname
|
|
109
|
-
const newPathname = generatePathnameFromViewParams(newViewStack);
|
|
131
|
+
const newPathname = generatePathnameFromViewParams(newViewStack, { basePath: this.getLayoutBasePath() });
|
|
110
132
|
|
|
111
133
|
// 3. 触发一次跳转。使用 replace 的方式
|
|
112
134
|
this.ctx.router.navigate(newPathname, { replace: true });
|
|
@@ -115,7 +137,9 @@ export class ViewNavigation {
|
|
|
115
137
|
navigateTo(viewParam: ViewParams, opts?: { replace?: boolean; state?: any }) {
|
|
116
138
|
// 1. 基于当前 viewStack 生成一个 pathname
|
|
117
139
|
// 2. 将当前传入的参数转为 path string
|
|
118
|
-
const newViewPathname = generatePathnameFromViewParams([...this.viewStack, viewParam]
|
|
140
|
+
const newViewPathname = generatePathnameFromViewParams([...this.viewStack, viewParam], {
|
|
141
|
+
basePath: this.getLayoutBasePath(),
|
|
142
|
+
});
|
|
119
143
|
|
|
120
144
|
// 3. 与 pathname 拼接成新的 pathname(这里直接使用新生成的 pathname)
|
|
121
145
|
const newPathname = newViewPathname;
|
|
@@ -126,7 +150,16 @@ export class ViewNavigation {
|
|
|
126
150
|
|
|
127
151
|
back() {
|
|
128
152
|
const prevStack = this.viewStack.slice(0, -1);
|
|
129
|
-
const prevPath = generatePathnameFromViewParams(prevStack);
|
|
153
|
+
const prevPath = generatePathnameFromViewParams(prevStack, { basePath: this.getLayoutBasePath() });
|
|
130
154
|
this.ctx.router.navigate(prevPath, { replace: true });
|
|
131
155
|
}
|
|
156
|
+
|
|
157
|
+
private getLayoutBasePath() {
|
|
158
|
+
const routePath = (this.ctx as any).layout?.routePath;
|
|
159
|
+
return (
|
|
160
|
+
this.basePath ||
|
|
161
|
+
(this.ctx as any).layoutRoute?.basePathname ||
|
|
162
|
+
(routePath?.startsWith('/') ? routePath : '/admin')
|
|
163
|
+
);
|
|
164
|
+
}
|
|
132
165
|
}
|
|
@@ -146,6 +146,47 @@ describe('ViewNavigation', () => {
|
|
|
146
146
|
|
|
147
147
|
expect(mockCtx.router.navigate).toHaveBeenCalledWith('/admin', { replace: true });
|
|
148
148
|
});
|
|
149
|
+
|
|
150
|
+
it('should use explicit basePath when navigating back', () => {
|
|
151
|
+
viewNavigation = new ViewNavigation(mockCtx, [{ viewUid: 'view1' }], { basePath: '/embed' });
|
|
152
|
+
|
|
153
|
+
viewNavigation.back();
|
|
154
|
+
|
|
155
|
+
expect(mockCtx.router.navigate).toHaveBeenCalledWith('/embed', { replace: true });
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('should use layout route basePathname when explicit basePath is absent', () => {
|
|
159
|
+
mockCtx.layoutRoute = {
|
|
160
|
+
basePathname: '/mobile',
|
|
161
|
+
};
|
|
162
|
+
viewNavigation = new ViewNavigation(mockCtx, [{ viewUid: 'view1' }]);
|
|
163
|
+
|
|
164
|
+
viewNavigation.back();
|
|
165
|
+
|
|
166
|
+
expect(mockCtx.router.navigate).toHaveBeenCalledWith('/mobile', { replace: true });
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('should fall back to layout routePath when explicit basePath is absent', () => {
|
|
170
|
+
mockCtx.layout = {
|
|
171
|
+
routePath: '/mobile',
|
|
172
|
+
};
|
|
173
|
+
viewNavigation = new ViewNavigation(mockCtx, [{ viewUid: 'view1' }]);
|
|
174
|
+
|
|
175
|
+
viewNavigation.back();
|
|
176
|
+
|
|
177
|
+
expect(mockCtx.router.navigate).toHaveBeenCalledWith('/mobile', { replace: true });
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('should ignore relative layout routePath when runtime basePathname is absent', () => {
|
|
181
|
+
mockCtx.layout = {
|
|
182
|
+
routePath: 'public-forms',
|
|
183
|
+
};
|
|
184
|
+
viewNavigation = new ViewNavigation(mockCtx, [{ viewUid: 'view1' }]);
|
|
185
|
+
|
|
186
|
+
viewNavigation.back();
|
|
187
|
+
|
|
188
|
+
expect(mockCtx.router.navigate).toHaveBeenCalledWith('/admin', { replace: true });
|
|
189
|
+
});
|
|
149
190
|
});
|
|
150
191
|
});
|
|
151
192
|
|
|
@@ -160,6 +201,17 @@ describe('generatePathnameFromViewParams', () => {
|
|
|
160
201
|
expect(generatePathnameFromViewParams([{ viewUid: 'xxx' }])).toBe('/admin/xxx');
|
|
161
202
|
});
|
|
162
203
|
|
|
204
|
+
it('should generate path with custom prefix', () => {
|
|
205
|
+
expect(generatePathnameFromViewParams([{ viewUid: 'xxx' }], { basePath: '/embed' })).toBe('/embed/xxx');
|
|
206
|
+
expect(generatePathnameFromViewParams([], { basePath: '/embed' })).toBe('/embed');
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it('should generate path with nested basePath', () => {
|
|
210
|
+
expect(generatePathnameFromViewParams([{ viewUid: 'xxx' }], { basePath: '/admin/settings/public-forms' })).toBe(
|
|
211
|
+
'/admin/settings/public-forms/xxx',
|
|
212
|
+
);
|
|
213
|
+
});
|
|
214
|
+
|
|
163
215
|
it('should generate view with tab', () => {
|
|
164
216
|
expect(generatePathnameFromViewParams([{ viewUid: 'xxx', tabUid: 'yyy' }])).toBe('/admin/xxx/tab/yyy');
|
|
165
217
|
});
|
|
@@ -0,0 +1,53 @@
|
|
|
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 { FlowContext } from '../../flowContext';
|
|
12
|
+
import { inheritLayoutContextForDetachedView } from '../inheritLayoutContext';
|
|
13
|
+
|
|
14
|
+
describe('inheritLayoutContextForDetachedView', () => {
|
|
15
|
+
it('inherits layout context for detached view contexts', () => {
|
|
16
|
+
const sourceContext = new FlowContext();
|
|
17
|
+
const engineContext = new FlowContext();
|
|
18
|
+
const layoutContext = new FlowContext();
|
|
19
|
+
const viewContext = new FlowContext();
|
|
20
|
+
|
|
21
|
+
engineContext.defineProperty('skipAclCheck', { value: false });
|
|
22
|
+
layoutContext.defineProperty('skipAclCheck', { value: true });
|
|
23
|
+
sourceContext.defineProperty('engine', {
|
|
24
|
+
value: {
|
|
25
|
+
context: engineContext,
|
|
26
|
+
},
|
|
27
|
+
});
|
|
28
|
+
sourceContext.defineProperty('layoutContext', { value: layoutContext });
|
|
29
|
+
|
|
30
|
+
viewContext.addDelegate(engineContext);
|
|
31
|
+
inheritLayoutContextForDetachedView(viewContext, sourceContext);
|
|
32
|
+
|
|
33
|
+
expect(viewContext.skipAclCheck).toBe(true);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('does nothing when source context has no layout context', () => {
|
|
37
|
+
const sourceContext = new FlowContext();
|
|
38
|
+
const engineContext = new FlowContext();
|
|
39
|
+
const viewContext = new FlowContext();
|
|
40
|
+
|
|
41
|
+
engineContext.defineProperty('skipAclCheck', { value: false });
|
|
42
|
+
sourceContext.defineProperty('engine', {
|
|
43
|
+
value: {
|
|
44
|
+
context: engineContext,
|
|
45
|
+
},
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
viewContext.addDelegate(engineContext);
|
|
49
|
+
inheritLayoutContextForDetachedView(viewContext, sourceContext);
|
|
50
|
+
|
|
51
|
+
expect(viewContext.skipAclCheck).toBe(false);
|
|
52
|
+
});
|
|
53
|
+
});
|