@nocobase/client-v2 2.1.0-beta.33 → 2.1.0-beta.35
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/es/APIClient.d.ts +16 -0
- package/es/Application.d.ts +2 -1
- package/es/BaseApplication.d.ts +6 -0
- package/es/PluginManager.d.ts +2 -0
- package/es/authRedirect.d.ts +9 -16
- package/es/components/form/EnvVariableInput.d.ts +8 -6
- package/es/components/form/VariableInput.d.ts +73 -0
- package/es/components/form/index.d.ts +1 -0
- package/es/components/form/table/RowOverlayPreview.d.ts +27 -0
- package/es/components/form/table/SelectionCell.d.ts +36 -0
- package/es/components/form/table/Table.d.ts +82 -0
- package/es/components/form/table/constants.d.ts +15 -0
- package/es/components/form/table/dnd/SortableRow.d.ts +40 -0
- package/es/components/form/table/dnd/index.d.ts +9 -0
- package/es/components/form/table/index.d.ts +9 -0
- package/es/components/form/table/styles.d.ts +41 -0
- package/es/components/form/table/utils.d.ts +44 -0
- package/es/components/index.d.ts +2 -0
- package/es/flow/components/TextAreaWithContextSelector.d.ts +15 -0
- package/es/flow/models/blocks/filter-form/FilterFormBlockModel.d.ts +9 -1
- package/es/flow/models/blocks/table/dragSort/dragSortComponents.d.ts +1 -6
- package/es/flow/models/blocks/table/dragSort/dragSortHooks.d.ts +5 -1
- package/es/flow-compat/passwordUtils.d.ts +1 -1
- package/es/index.d.ts +1 -0
- package/es/index.mjs +166 -99
- package/es/json-logic/globalOperators.d.ts +11 -0
- package/es/theme/globalStyles.d.ts +9 -0
- package/es/theme/index.d.ts +1 -0
- package/es/utils/globalDeps.d.ts +7 -0
- package/lib/index.js +173 -106
- package/package.json +9 -6
- package/src/APIClient.ts +68 -0
- package/src/Application.tsx +6 -2
- package/src/BaseApplication.tsx +8 -0
- package/src/PluginManager.ts +2 -0
- package/src/__tests__/app.test.tsx +8 -0
- package/src/__tests__/authRedirect.test.ts +170 -64
- package/src/__tests__/globalDeps.test.ts +2 -0
- package/src/__tests__/nocobase-buildin-plugin-auth.test.tsx +6 -6
- package/src/__tests__/remotePlugins.test.ts +148 -0
- package/src/authRedirect.ts +23 -84
- package/src/components/form/EnvVariableInput.tsx +11 -46
- package/src/components/form/VariableInput.tsx +177 -0
- package/src/components/form/__tests__/EnvVariableInput.test.tsx +175 -0
- package/src/components/form/index.tsx +1 -0
- package/src/components/form/table/RowOverlayPreview.tsx +51 -0
- package/src/components/form/table/SelectionCell.tsx +72 -0
- package/src/components/form/table/Table.tsx +279 -0
- package/src/components/form/table/__tests__/Table.pagination.test.tsx +80 -0
- package/src/components/form/table/constants.ts +16 -0
- package/src/components/form/table/dnd/SortableRow.tsx +106 -0
- package/src/components/form/table/dnd/index.ts +10 -0
- package/src/components/form/table/index.tsx +13 -0
- package/src/components/form/table/styles.ts +110 -0
- package/src/components/form/table/utils.ts +75 -0
- package/src/components/index.ts +2 -0
- package/src/css-variable/CSSVariableProvider.tsx +1 -1
- package/src/flow/actions/filterFormDefaultValues.tsx +1 -2
- package/src/flow/admin-shell/admin-layout/AdminLayoutMenuModels.tsx +2 -0
- package/src/flow/admin-shell/admin-layout/resolveAdminRouteRuntimeTarget.test.ts +111 -0
- package/src/flow/admin-shell/admin-layout/resolveAdminRouteRuntimeTarget.ts +2 -1
- package/src/flow/components/TextAreaWithContextSelector.tsx +30 -6
- package/src/flow/components/code-editor/__tests__/useCodeRunner.test.tsx +81 -0
- package/src/flow/components/code-editor/hooks/useCodeRunner.ts +34 -2
- package/src/flow/models/blocks/filter-form/FilterFormBlockModel.tsx +329 -5
- package/src/flow/models/blocks/filter-form/__tests__/defaultValues.wiring.test.ts +337 -0
- package/src/flow/models/blocks/table/dragSort/dragSortComponents.tsx +1 -81
- package/src/flow/models/fields/JSEditableFieldModel.tsx +107 -7
- package/src/flow/models/fields/__tests__/JSEditableFieldModel.test.tsx +97 -0
- package/src/index.ts +1 -0
- package/src/json-logic/globalOperators.js +731 -0
- package/src/nocobase-buildin-plugin/index.tsx +4 -4
- package/src/theme/globalStyles.ts +21 -0
- package/src/theme/index.tsx +1 -0
- package/src/utils/globalDeps.ts +50 -30
- package/src/utils/remotePlugins.ts +107 -6
|
@@ -14,8 +14,11 @@ import { configRequirejs, defineDevPlugins, getPlugins } from '../utils/remotePl
|
|
|
14
14
|
describe('client-v2 remotePlugins', () => {
|
|
15
15
|
afterEach(() => {
|
|
16
16
|
window.define = undefined;
|
|
17
|
+
window.__nocobase_app_dev_plugins__ = undefined;
|
|
17
18
|
});
|
|
18
19
|
|
|
20
|
+
const createModuleUrl = (code: string) => `data:text/javascript;charset=utf-8,${encodeURIComponent(code)}`;
|
|
21
|
+
|
|
19
22
|
it('should define dev plugins with /client-v2 module ids', () => {
|
|
20
23
|
class DemoPlugin extends Plugin {}
|
|
21
24
|
|
|
@@ -58,6 +61,151 @@ describe('client-v2 remotePlugins', () => {
|
|
|
58
61
|
expect(mockDefine).not.toHaveBeenCalledWith('@nocobase/demo/client', expect.any(Function));
|
|
59
62
|
});
|
|
60
63
|
|
|
64
|
+
it('should request remote plugins when devDynamicImport only resolves some plugins', async () => {
|
|
65
|
+
class DemoPlugin extends Plugin {}
|
|
66
|
+
|
|
67
|
+
const remoteFn = vi.fn();
|
|
68
|
+
const requirejs: any = {
|
|
69
|
+
requirejs: (pluginData, resolve) => {
|
|
70
|
+
remoteFn();
|
|
71
|
+
resolve({ default: DemoPlugin });
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
requirejs.requirejs.config = vi.fn();
|
|
75
|
+
|
|
76
|
+
const mockDefine: any = vi.fn();
|
|
77
|
+
window.define = mockDefine;
|
|
78
|
+
|
|
79
|
+
const plugins = await getPlugins({
|
|
80
|
+
requirejs,
|
|
81
|
+
pluginData: [
|
|
82
|
+
{
|
|
83
|
+
name: '@nocobase/demo',
|
|
84
|
+
packageName: '@nocobase/demo',
|
|
85
|
+
url: 'https://demo.com/dist/client-v2/index.js',
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
name: '@nocobase/remote',
|
|
89
|
+
packageName: '@nocobase/remote',
|
|
90
|
+
url: 'https://remote.com/dist/client-v2/index.js',
|
|
91
|
+
},
|
|
92
|
+
] as any,
|
|
93
|
+
devDynamicImport: ((packageName) => {
|
|
94
|
+
if (packageName === '@nocobase/demo') {
|
|
95
|
+
return Promise.resolve({ default: DemoPlugin });
|
|
96
|
+
}
|
|
97
|
+
return Promise.resolve(null);
|
|
98
|
+
}) as any,
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
expect(plugins).toEqual([
|
|
102
|
+
['@nocobase/demo', DemoPlugin],
|
|
103
|
+
['@nocobase/remote', DemoPlugin],
|
|
104
|
+
]);
|
|
105
|
+
expect(remoteFn).toHaveBeenCalledTimes(1);
|
|
106
|
+
expect(mockDefine).toHaveBeenCalledTimes(1);
|
|
107
|
+
expect(requirejs.requirejs.config).toHaveBeenCalledWith({
|
|
108
|
+
waitSeconds: 120,
|
|
109
|
+
paths: {
|
|
110
|
+
'@nocobase/remote/client-v2': 'https://remote.com/dist/client-v2/index.js',
|
|
111
|
+
},
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('should register ESM dev plugins before importing dependent plugins', async () => {
|
|
116
|
+
const requirejs: any = {
|
|
117
|
+
requirejs: vi.fn(),
|
|
118
|
+
};
|
|
119
|
+
requirejs.requirejs.config = vi.fn();
|
|
120
|
+
|
|
121
|
+
const mockDefine: any = vi.fn();
|
|
122
|
+
window.define = mockDefine;
|
|
123
|
+
|
|
124
|
+
const plugins = await getPlugins({
|
|
125
|
+
requirejs,
|
|
126
|
+
pluginData: [
|
|
127
|
+
{
|
|
128
|
+
name: '@nocobase/dependent',
|
|
129
|
+
packageName: '@nocobase/dependent',
|
|
130
|
+
url: createModuleUrl(`
|
|
131
|
+
const dependency = window.__nocobase_app_dev_plugins__ && window.__nocobase_app_dev_plugins__['@nocobase/dependency/client-v2'];
|
|
132
|
+
export default class DependentPlugin extends dependency.default {
|
|
133
|
+
static SharedBase = dependency.SharedBase;
|
|
134
|
+
}
|
|
135
|
+
`),
|
|
136
|
+
devMode: 'esm',
|
|
137
|
+
appDevDependencies: ['@nocobase/dependency'],
|
|
138
|
+
},
|
|
139
|
+
{
|
|
140
|
+
name: '@nocobase/dependency',
|
|
141
|
+
packageName: '@nocobase/dependency',
|
|
142
|
+
url: createModuleUrl('export class SharedBase {}; export default class DependencyPlugin {}'),
|
|
143
|
+
devMode: 'esm',
|
|
144
|
+
},
|
|
145
|
+
] as any,
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
const dependencyModule = window.__nocobase_app_dev_plugins__['@nocobase/dependency/client-v2'] as {
|
|
149
|
+
default?: unknown;
|
|
150
|
+
SharedBase?: unknown;
|
|
151
|
+
};
|
|
152
|
+
const dependentPlugin = plugins[1][1] as (typeof plugins)[number][1] & { SharedBase?: unknown };
|
|
153
|
+
|
|
154
|
+
expect(plugins.map(([name]) => name)).toEqual(['@nocobase/dependency', '@nocobase/dependent']);
|
|
155
|
+
expect(dependencyModule.SharedBase).toBeDefined();
|
|
156
|
+
expect(plugins[0][1]).toBe(dependencyModule.default);
|
|
157
|
+
expect(dependentPlugin.SharedBase).toBe(dependencyModule.SharedBase);
|
|
158
|
+
expect(mockDefine).toHaveBeenCalledWith('@nocobase/dependency/client-v2', expect.any(Function));
|
|
159
|
+
expect(requirejs.requirejs.config).not.toHaveBeenCalled();
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('should load RequireJS dependencies before ESM dev plugins', async () => {
|
|
163
|
+
class DependencyPlugin extends Plugin {}
|
|
164
|
+
const SharedBase = { source: 'remote' };
|
|
165
|
+
const dependencyModule = {
|
|
166
|
+
SharedBase,
|
|
167
|
+
default: DependencyPlugin,
|
|
168
|
+
};
|
|
169
|
+
const requirejs: any = {
|
|
170
|
+
requirejs: vi.fn((packageNames, resolve) => {
|
|
171
|
+
expect(packageNames).toEqual(['@nocobase/remote-dependency/client-v2']);
|
|
172
|
+
resolve(dependencyModule);
|
|
173
|
+
}),
|
|
174
|
+
};
|
|
175
|
+
requirejs.requirejs.config = vi.fn();
|
|
176
|
+
|
|
177
|
+
window.define = vi.fn();
|
|
178
|
+
|
|
179
|
+
const plugins = await getPlugins({
|
|
180
|
+
requirejs,
|
|
181
|
+
pluginData: [
|
|
182
|
+
{
|
|
183
|
+
name: '@nocobase/dependent',
|
|
184
|
+
packageName: '@nocobase/dependent',
|
|
185
|
+
url: createModuleUrl(`
|
|
186
|
+
const dependency = window.__nocobase_app_dev_plugins__ && window.__nocobase_app_dev_plugins__['@nocobase/remote-dependency/client-v2'];
|
|
187
|
+
export default class DependentPlugin extends dependency.default {
|
|
188
|
+
static SharedBase = dependency.SharedBase;
|
|
189
|
+
}
|
|
190
|
+
`),
|
|
191
|
+
devMode: 'esm',
|
|
192
|
+
appDevDependencies: ['@nocobase/remote-dependency'],
|
|
193
|
+
},
|
|
194
|
+
{
|
|
195
|
+
name: '@nocobase/remote-dependency',
|
|
196
|
+
packageName: '@nocobase/remote-dependency',
|
|
197
|
+
url: 'https://dependency.com',
|
|
198
|
+
},
|
|
199
|
+
] as any,
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
const dependentPlugin = plugins[1][1] as (typeof plugins)[number][1] & { SharedBase?: unknown };
|
|
203
|
+
|
|
204
|
+
expect(plugins.map(([name]) => name)).toEqual(['@nocobase/remote-dependency', '@nocobase/dependent']);
|
|
205
|
+
expect(window.__nocobase_app_dev_plugins__['@nocobase/remote-dependency/client-v2']).toBe(dependencyModule);
|
|
206
|
+
expect(dependentPlugin.SharedBase).toBe(SharedBase);
|
|
207
|
+
});
|
|
208
|
+
|
|
61
209
|
it('should configure remote plugin paths with /client-v2 module ids', () => {
|
|
62
210
|
const requirejs: any = {
|
|
63
211
|
requirejs: {
|
package/src/authRedirect.ts
CHANGED
|
@@ -21,8 +21,6 @@ type LocationLike = {
|
|
|
21
21
|
hash?: string;
|
|
22
22
|
};
|
|
23
23
|
|
|
24
|
-
const V2_PUBLIC_PATH_SUFFIX = '/v2/';
|
|
25
|
-
|
|
26
24
|
function ensureLeadingSlash(value = '') {
|
|
27
25
|
if (!value) {
|
|
28
26
|
return '/';
|
|
@@ -111,18 +109,18 @@ function getV2PublicPath(app: AppLike) {
|
|
|
111
109
|
return normalizePublicPath(app.getPublicPath());
|
|
112
110
|
}
|
|
113
111
|
|
|
114
|
-
function getRootPublicPath(app: AppLike) {
|
|
115
|
-
const publicPath = getV2PublicPath(app);
|
|
116
|
-
if (!publicPath.endsWith(V2_PUBLIC_PATH_SUFFIX)) {
|
|
117
|
-
return normalizePublicPath(publicPath.replace(/\/v2\/?$/, '/') || '/');
|
|
118
|
-
}
|
|
119
|
-
return normalizePublicPath(publicPath.slice(0, -V2_PUBLIC_PATH_SUFFIX.length) || '/');
|
|
120
|
-
}
|
|
121
|
-
|
|
122
112
|
function getV2BasePath(app: AppLike) {
|
|
123
113
|
return trimTrailingSlashes(getV2PublicPath(app)) || '/';
|
|
124
114
|
}
|
|
125
115
|
|
|
116
|
+
export function getV2EffectiveBasePath(app: AppLike): string {
|
|
117
|
+
const basename = app.router?.getBasename?.();
|
|
118
|
+
if (basename) {
|
|
119
|
+
return normalizePublicPath(basename);
|
|
120
|
+
}
|
|
121
|
+
return getV2PublicPath(app);
|
|
122
|
+
}
|
|
123
|
+
|
|
126
124
|
function joinRootRelativePath(basePath: string, pathname: string) {
|
|
127
125
|
const normalizedBasePath = normalizePublicPath(basePath);
|
|
128
126
|
const normalizedPathname = normalizePathname(pathname);
|
|
@@ -138,46 +136,8 @@ function joinRootRelativePath(basePath: string, pathname: string) {
|
|
|
138
136
|
return normalizePathname(`/${trimLeadingSlashes(normalizedBasePath)}/${trimLeadingSlashes(normalizedPathname)}`);
|
|
139
137
|
}
|
|
140
138
|
|
|
141
|
-
function
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
if (normalizedPathname === '/admin') {
|
|
145
|
-
return '/admin';
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
const segments = normalizedPathname.split('/').filter(Boolean);
|
|
149
|
-
if (segments[0] !== 'admin' || segments.length < 2) {
|
|
150
|
-
return null;
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
const [, name, ...restSegments] = segments;
|
|
154
|
-
const legacySegments = ['admin', name];
|
|
155
|
-
let index = 0;
|
|
156
|
-
|
|
157
|
-
while (index < restSegments.length) {
|
|
158
|
-
const segment = restSegments[index];
|
|
159
|
-
|
|
160
|
-
if (segment === 'tab' && restSegments[index + 1]) {
|
|
161
|
-
legacySegments.push('tabs', restSegments[index + 1]);
|
|
162
|
-
index += 2;
|
|
163
|
-
continue;
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
if (segment === 'view') {
|
|
167
|
-
legacySegments.push('popups', ...restSegments.slice(index + 1));
|
|
168
|
-
index = restSegments.length;
|
|
169
|
-
continue;
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
return null;
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
return normalizePathname(`/${legacySegments.join('/')}`);
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
function getLegacySigninPath(app: AppLike) {
|
|
179
|
-
const rootPublicPath = trimTrailingSlashes(getRootPublicPath(app));
|
|
180
|
-
return `${rootPublicPath}/signin`;
|
|
139
|
+
function getV2SigninPath(app: AppLike) {
|
|
140
|
+
return joinRootRelativePath(getV2EffectiveBasePath(app), '/signin');
|
|
181
141
|
}
|
|
182
142
|
|
|
183
143
|
function stripCurrentV2Basename(app: AppLike, pathname: string) {
|
|
@@ -198,28 +158,7 @@ function stripCurrentV2Basename(app: AppLike, pathname: string) {
|
|
|
198
158
|
}
|
|
199
159
|
|
|
200
160
|
function getDefaultV2AdminRedirectPath(app: AppLike) {
|
|
201
|
-
return joinRootRelativePath(
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
/**
|
|
205
|
-
* 将 v2 admin 路径转换为对应的 v1 admin 根相对路径。
|
|
206
|
-
*
|
|
207
|
-
* @param app 当前 v2 应用实例
|
|
208
|
-
* @param value 当前 v2 admin 路径或 location 对象
|
|
209
|
-
* @returns 对应的 v1 admin 根相对路径;若无法安全转换则返回 null
|
|
210
|
-
*/
|
|
211
|
-
export function convertV2AdminPathToLegacy(app: AppLike, value: string | LocationLike) {
|
|
212
|
-
const locationLike = splitPathLike(value);
|
|
213
|
-
const pathname = stripCurrentV2Basename(app, locationLike.pathname || '/');
|
|
214
|
-
const legacyPathname = mapV2AdminPathnameToLegacyPathname(pathname);
|
|
215
|
-
|
|
216
|
-
if (!legacyPathname) {
|
|
217
|
-
return null;
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
return `${joinRootRelativePath(getRootPublicPath(app), legacyPathname)}${normalizeSearch(
|
|
221
|
-
locationLike.search,
|
|
222
|
-
)}${normalizeHash(locationLike.hash)}`;
|
|
161
|
+
return joinRootRelativePath(getV2EffectiveBasePath(app), '/admin');
|
|
223
162
|
}
|
|
224
163
|
|
|
225
164
|
/**
|
|
@@ -233,29 +172,29 @@ export function getCurrentV2RedirectPath(app: AppLike, locationLike: LocationLik
|
|
|
233
172
|
const pathname = stripCurrentV2Basename(app, locationLike?.pathname || '/');
|
|
234
173
|
const search = normalizeSearch(locationLike?.search || '');
|
|
235
174
|
const hash = normalizeHash(locationLike?.hash || '');
|
|
236
|
-
return `${joinRootRelativePath(
|
|
175
|
+
return `${joinRootRelativePath(getV2EffectiveBasePath(app), pathname)}${search}${hash}`;
|
|
237
176
|
}
|
|
238
177
|
|
|
239
178
|
/**
|
|
240
|
-
* 构造跳转到
|
|
179
|
+
* 构造跳转到 v2 登录页的完整 href。
|
|
241
180
|
*
|
|
242
181
|
* @param app 当前 v2 应用实例
|
|
243
182
|
* @param targetPath 登录后回跳地址
|
|
244
|
-
* @returns 指向
|
|
183
|
+
* @returns 指向 v2 登录页的 href
|
|
245
184
|
*/
|
|
246
|
-
export function
|
|
247
|
-
return `${
|
|
185
|
+
export function buildV2SigninHref(app: AppLike, targetPath: string) {
|
|
186
|
+
return `${getV2SigninPath(app)}?redirect=${encodeURIComponent(targetPath)}`;
|
|
248
187
|
}
|
|
249
188
|
|
|
250
189
|
/**
|
|
251
|
-
* 通过整页跳转进入
|
|
190
|
+
* 通过整页跳转进入 v2 登录页。
|
|
252
191
|
*
|
|
253
192
|
* @param app 当前 v2 应用实例
|
|
254
193
|
* @param targetPath 登录后回跳地址
|
|
255
194
|
* @param options 跳转选项
|
|
256
195
|
*/
|
|
257
|
-
export function
|
|
258
|
-
const href =
|
|
196
|
+
export function redirectToV2Signin(app: AppLike, targetPath: string, options?: { replace?: boolean }) {
|
|
197
|
+
const href = buildV2SigninHref(app, targetPath);
|
|
259
198
|
if (options?.replace === false) {
|
|
260
199
|
window.location.href = href;
|
|
261
200
|
return;
|
|
@@ -264,13 +203,13 @@ export function redirectToLegacySignin(app: AppLike, targetPath: string, options
|
|
|
264
203
|
}
|
|
265
204
|
|
|
266
205
|
/**
|
|
267
|
-
* 解析并校验服务端返回的
|
|
206
|
+
* 解析并校验服务端返回的 v2 登录页地址。
|
|
268
207
|
*
|
|
269
208
|
* @param value 服务端返回的 redirect
|
|
270
209
|
* @param app 当前 v2 应用实例
|
|
271
|
-
* @returns 合法时返回完整同源 URL
|
|
210
|
+
* @returns 合法时返回完整同源 URL,否则返回 null
|
|
272
211
|
*/
|
|
273
|
-
export function
|
|
212
|
+
export function resolveV2SigninRedirect(value: string | undefined | null, app: AppLike) {
|
|
274
213
|
if (!value) {
|
|
275
214
|
return null;
|
|
276
215
|
}
|
|
@@ -286,7 +225,7 @@ export function resolveLegacySigninRedirect(value: string | undefined | null, ap
|
|
|
286
225
|
return null;
|
|
287
226
|
}
|
|
288
227
|
|
|
289
|
-
const validPathnames = new Set(['/signin',
|
|
228
|
+
const validPathnames = new Set(['/signin', getV2SigninPath(app)]);
|
|
290
229
|
if (!validPathnames.has(url.pathname)) {
|
|
291
230
|
return null;
|
|
292
231
|
}
|
|
@@ -7,19 +7,14 @@
|
|
|
7
7
|
* For more information, please refer to: https://www.nocobase.com/agreement.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import {
|
|
11
|
-
useFlowContext,
|
|
12
|
-
VariableHybridInput,
|
|
13
|
-
type MetaTreeNode,
|
|
14
|
-
type VariableHybridInputConverters,
|
|
15
|
-
} from '@nocobase/flow-engine';
|
|
16
|
-
import { useRequest } from 'ahooks';
|
|
10
|
+
import type { MetaTreeNode, VariableHybridInputConverters } from '@nocobase/flow-engine';
|
|
17
11
|
import { Input } from 'antd';
|
|
18
12
|
import React, { useMemo } from 'react';
|
|
13
|
+
import { VariableInput } from './VariableInput';
|
|
19
14
|
|
|
20
15
|
const ENV_EXPR_REGEXP = /\{\{\s*(\$env\.[^{}]+?)\s*\}\}/g;
|
|
21
16
|
const ENV_SINGLE_EXPR_REGEXP = /^\{\{\s*(\$env\.[^{}]+?)\s*\}\}$/;
|
|
22
|
-
const
|
|
17
|
+
const ENV_NAMESPACES = ['$env'];
|
|
23
18
|
|
|
24
19
|
/**
|
|
25
20
|
* Convert a stored value like `"{{ $env.foo.bar }}"` back into the
|
|
@@ -43,37 +38,6 @@ export function formatEnvPath(meta?: MetaTreeNode) {
|
|
|
43
38
|
return `{{ ${paths.join('.')} }}`;
|
|
44
39
|
}
|
|
45
40
|
|
|
46
|
-
/**
|
|
47
|
-
* Pull the `$env` sub-tree off the FlowContext meta registry and eagerly
|
|
48
|
-
* resolve lazy `children` thunks so the picker can render labels on first
|
|
49
|
-
* paint. Empty tree (no env-variables plugin or no defined vars) yields `[]`.
|
|
50
|
-
*/
|
|
51
|
-
function useEnvMetaTree(): MetaTreeNode[] {
|
|
52
|
-
const ctx = useFlowContext();
|
|
53
|
-
const { data } = useRequest<MetaTreeNode[], []>(
|
|
54
|
-
async () => {
|
|
55
|
-
const tree = ctx.getPropertyMetaTree().filter((node) => node.name === '$env');
|
|
56
|
-
for (const node of tree) {
|
|
57
|
-
if (typeof node.children === 'function') {
|
|
58
|
-
try {
|
|
59
|
-
const resolved = await (node.children as () => Promise<MetaTreeNode[]>)();
|
|
60
|
-
node.children = Array.isArray(resolved) ? resolved : [];
|
|
61
|
-
} catch {
|
|
62
|
-
node.children = [];
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
return tree.filter((node) => Array.isArray(node.children) && node.children.length > 0);
|
|
67
|
-
},
|
|
68
|
-
{
|
|
69
|
-
cacheKey: META_TREE_CACHE_KEY,
|
|
70
|
-
refreshOnWindowFocus: true,
|
|
71
|
-
},
|
|
72
|
-
);
|
|
73
|
-
|
|
74
|
-
return data ?? [];
|
|
75
|
-
}
|
|
76
|
-
|
|
77
41
|
export interface EnvVariableInputProps {
|
|
78
42
|
value?: string;
|
|
79
43
|
onChange?: (value: string) => void;
|
|
@@ -91,15 +55,16 @@ export interface EnvVariableInputProps {
|
|
|
91
55
|
const isVariableExpr = (value?: string) => typeof value === 'string' && /\{\{\s*[^{}]+?\s*\}\}/.test(value);
|
|
92
56
|
|
|
93
57
|
/**
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
58
|
+
* Convenience wrapper around `VariableInput` constrained to the `$env`
|
|
59
|
+
* namespace, with optional password-input masking for plain values. Use for
|
|
60
|
+
* fields that accept either a literal credential or a `{{ $env.X }}`
|
|
61
|
+
* reference (S3 access keys, OAuth secrets, etc.). The `$env` tree is
|
|
62
|
+
* provided by the environment-variables plugin's
|
|
63
|
+
* `flowEngine.context.defineProperty('$env', ...)`; this component degrades
|
|
64
|
+
* gracefully to an empty picker when no env variables are defined.
|
|
99
65
|
*/
|
|
100
66
|
export function EnvVariableInput(props: EnvVariableInputProps) {
|
|
101
67
|
const { password, ...rest } = props;
|
|
102
|
-
const metaTree = useEnvMetaTree();
|
|
103
68
|
|
|
104
69
|
const converters = useMemo<VariableHybridInputConverters>(
|
|
105
70
|
() => ({
|
|
@@ -122,5 +87,5 @@ export function EnvVariableInput(props: EnvVariableInputProps) {
|
|
|
122
87
|
);
|
|
123
88
|
}
|
|
124
89
|
|
|
125
|
-
return <
|
|
90
|
+
return <VariableInput {...rest} namespaces={ENV_NAMESPACES} converters={converters} />;
|
|
126
91
|
}
|
|
@@ -0,0 +1,177 @@
|
|
|
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 {
|
|
11
|
+
useFlowContext,
|
|
12
|
+
VariableHybridInput,
|
|
13
|
+
type MetaTreeNode,
|
|
14
|
+
type VariableHybridInputConverters,
|
|
15
|
+
} from '@nocobase/flow-engine';
|
|
16
|
+
import { useRequest } from 'ahooks';
|
|
17
|
+
import React, { useMemo } from 'react';
|
|
18
|
+
import { TextAreaWithContextSelector } from '../../flow/components/TextAreaWithContextSelector';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The flow-engine defaults emit `{{ ctx.$X.Y }}` and only parse the same
|
|
22
|
+
* shape back into a path — but NocoBase server templates (and v1 stored
|
|
23
|
+
* values) use the bare `{{$X.Y}}` form without the `ctx.` prefix. These
|
|
24
|
+
* converters keep the picker's output stable against v1 and let already-
|
|
25
|
+
* stored values round-trip to a labelled pill instead of falling back to a
|
|
26
|
+
* raw `{{…}}` literal.
|
|
27
|
+
*/
|
|
28
|
+
const VARIABLE_EXPR_RE = /^\{\{\s*(.+?)\s*\}\}$/;
|
|
29
|
+
|
|
30
|
+
export function parseVariablePath(value?: string): string[] | undefined {
|
|
31
|
+
if (typeof value !== 'string') return undefined;
|
|
32
|
+
const match = value.trim().match(VARIABLE_EXPR_RE);
|
|
33
|
+
if (!match) return undefined;
|
|
34
|
+
let pathString = match[1];
|
|
35
|
+
// Backwards-compat: accept the legacy `ctx.` prefix so values produced by
|
|
36
|
+
// pre-fix versions of the picker still resolve to a labelled pill.
|
|
37
|
+
if (pathString === 'ctx') return [];
|
|
38
|
+
if (pathString.startsWith('ctx.')) pathString = pathString.slice(4);
|
|
39
|
+
return pathString.split('.');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function formatVariablePath(meta?: MetaTreeNode): string | undefined {
|
|
43
|
+
const paths = meta?.paths || [];
|
|
44
|
+
if (paths.length === 0) return undefined;
|
|
45
|
+
// No inner spaces — matches the v1 storage shape exactly so round-trips
|
|
46
|
+
// through the API stay byte-stable.
|
|
47
|
+
return `{{${paths.join('.')}}}`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const META_TREE_CACHE_PREFIX = '@nocobase/client-v2:VariableInput:metaTree';
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Resolve the meta tree the variable picker should expose. Filters the global
|
|
54
|
+
* meta tree by `namespaces` (top-level property names like `'$env'`,
|
|
55
|
+
* `'$user'`), appends `extraNodes`, and eagerly resolves any lazy `children`
|
|
56
|
+
* thunks so labels render on first paint.
|
|
57
|
+
*
|
|
58
|
+
* Returns `[]` while loading or when no nodes survive the filter, mirroring
|
|
59
|
+
* the existing EnvVariableInput behavior so the picker still opens but offers
|
|
60
|
+
* nothing.
|
|
61
|
+
*/
|
|
62
|
+
export function useFilteredMetaTree(options: { namespaces?: string[]; extraNodes?: MetaTreeNode[] }): MetaTreeNode[] {
|
|
63
|
+
const { namespaces, extraNodes } = options;
|
|
64
|
+
const ctx = useFlowContext();
|
|
65
|
+
const cacheKey = useMemo(() => {
|
|
66
|
+
const ns = namespaces ? [...namespaces].sort().join(',') : '*';
|
|
67
|
+
return `${META_TREE_CACHE_PREFIX}:${ns}`;
|
|
68
|
+
}, [namespaces]);
|
|
69
|
+
|
|
70
|
+
const { data } = useRequest<MetaTreeNode[], []>(
|
|
71
|
+
async () => {
|
|
72
|
+
const all = ctx.getPropertyMetaTree?.() ?? [];
|
|
73
|
+
const filtered = namespaces ? all.filter((node) => namespaces.includes(node.name)) : all;
|
|
74
|
+
for (const node of filtered) {
|
|
75
|
+
if (typeof node.children === 'function') {
|
|
76
|
+
try {
|
|
77
|
+
const resolved = await (node.children as () => Promise<MetaTreeNode[]>)();
|
|
78
|
+
node.children = Array.isArray(resolved) ? resolved : [];
|
|
79
|
+
} catch {
|
|
80
|
+
node.children = [];
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
const withChildren = filtered.filter(
|
|
85
|
+
(node) => !Array.isArray(node.children) || node.children.length > 0 || node.type !== 'object',
|
|
86
|
+
);
|
|
87
|
+
return withChildren;
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
cacheKey,
|
|
91
|
+
refreshOnWindowFocus: true,
|
|
92
|
+
},
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
return useMemo(() => {
|
|
96
|
+
const base = data ?? [];
|
|
97
|
+
if (!extraNodes?.length) return base;
|
|
98
|
+
return [...base, ...extraNodes];
|
|
99
|
+
}, [data, extraNodes]);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export interface VariableInputProps {
|
|
103
|
+
value?: string;
|
|
104
|
+
onChange?: (value: string) => void;
|
|
105
|
+
disabled?: boolean;
|
|
106
|
+
placeholder?: string;
|
|
107
|
+
addonBefore?: React.ReactNode;
|
|
108
|
+
/**
|
|
109
|
+
* Restrict the picker to specific top-level meta tree namespaces (e.g.
|
|
110
|
+
* `['$env', '$user']`). When omitted, every registered top-level property is
|
|
111
|
+
* exposed. Filter happens at the picker level — the underlying regex used
|
|
112
|
+
* for pill rendering still matches any `{{ ... }}` expression so pre-existing
|
|
113
|
+
* out-of-scope values stay legible.
|
|
114
|
+
*/
|
|
115
|
+
namespaces?: string[];
|
|
116
|
+
/**
|
|
117
|
+
* Static leaves appended to the picker, after the namespace-filtered nodes.
|
|
118
|
+
* Use for ad-hoc local-only variables (e.g. `$resetLink`) that are not part
|
|
119
|
+
* of the global FlowContext registry.
|
|
120
|
+
*/
|
|
121
|
+
extraNodes?: MetaTreeNode[];
|
|
122
|
+
/**
|
|
123
|
+
* Override the converters used by the underlying `VariableHybridInput`.
|
|
124
|
+
* Mostly useful when the caller wants to constrain `formatPathToValue` to a
|
|
125
|
+
* specific namespace (see `EnvVariableInput` for that pattern).
|
|
126
|
+
*/
|
|
127
|
+
converters?: VariableHybridInputConverters;
|
|
128
|
+
className?: string;
|
|
129
|
+
style?: React.CSSProperties;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Inline (single-line) variable input. Renders the literal text and any
|
|
134
|
+
* `{{ ... }}` references as styled pills via `VariableHybridInput`. Use for
|
|
135
|
+
* fields like form input titles, email subject lines, or any place a single
|
|
136
|
+
* line of mixed literal+variable content is appropriate.
|
|
137
|
+
*/
|
|
138
|
+
export function VariableInput(props: VariableInputProps) {
|
|
139
|
+
const { namespaces, extraNodes, converters, ...rest } = props;
|
|
140
|
+
const metaTree = useFilteredMetaTree({ namespaces, extraNodes });
|
|
141
|
+
const mergedConverters = useMemo<VariableHybridInputConverters>(
|
|
142
|
+
() => ({
|
|
143
|
+
formatPathToValue: formatVariablePath,
|
|
144
|
+
parseValueToPath: parseVariablePath,
|
|
145
|
+
...converters,
|
|
146
|
+
}),
|
|
147
|
+
[converters],
|
|
148
|
+
);
|
|
149
|
+
return <VariableHybridInput {...rest} converters={mergedConverters} metaTree={metaTree} />;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export interface VariableTextAreaProps extends Omit<VariableInputProps, 'converters' | 'addonBefore'> {
|
|
153
|
+
rows?: number;
|
|
154
|
+
maxRows?: number;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Multi-line variable input. Variables are inserted as raw `{{ ... }}` text at
|
|
159
|
+
* the caret rather than rendered as pills — use for email body templates and
|
|
160
|
+
* other free-form long-form text where literal display of variable expressions
|
|
161
|
+
* is desirable (the server expands them at render time).
|
|
162
|
+
*/
|
|
163
|
+
export function VariableTextArea(props: VariableTextAreaProps) {
|
|
164
|
+
const { namespaces, extraNodes, rows, maxRows, style, ...rest } = props;
|
|
165
|
+
const metaTree = useFilteredMetaTree({ namespaces, extraNodes });
|
|
166
|
+
const metaTreeGetter = useMemo(() => () => metaTree, [metaTree]);
|
|
167
|
+
return (
|
|
168
|
+
<TextAreaWithContextSelector
|
|
169
|
+
{...rest}
|
|
170
|
+
rows={rows}
|
|
171
|
+
maxRows={maxRows}
|
|
172
|
+
style={style}
|
|
173
|
+
metaTree={metaTreeGetter}
|
|
174
|
+
formatPathToValue={(meta) => formatVariablePath(meta) ?? ''}
|
|
175
|
+
/>
|
|
176
|
+
);
|
|
177
|
+
}
|