@nocobase/flow-engine 2.2.0-beta.8 → 2.2.0
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/acl/Acl.d.ts +2 -1
- package/lib/acl/Acl.js +28 -0
- package/lib/components/FlowContextSelector.js +62 -13
- package/lib/components/FormItem.js +11 -7
- package/lib/components/MobilePopup.js +39 -10
- package/lib/components/MobilePopup.style.js +11 -1
- package/lib/components/subModel/LazyDropdown.js +62 -33
- 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 +12 -1
- package/lib/flowContext.js +57 -12
- package/lib/flowEngine.js +6 -0
- package/lib/flowI18n.js +3 -3
- package/lib/locale/en-US.json +2 -0
- package/lib/locale/index.d.ts +4 -0
- package/lib/locale/zh-CN.json +2 -0
- package/lib/resources/flowResource.js +1 -0
- package/lib/types.d.ts +3 -1
- package/lib/types.js +1 -0
- package/lib/utils/associationObjectVariable.d.ts +10 -0
- package/lib/utils/associationObjectVariable.js +10 -7
- package/lib/utils/dateVariable.d.ts +22 -0
- package/lib/utils/dateVariable.js +123 -16
- package/lib/utils/dirtyAwareApiClient.d.ts +1 -0
- package/lib/utils/dirtyAwareApiClient.js +280 -13
- package/lib/utils/index.d.ts +3 -3
- package/lib/utils/index.js +8 -0
- package/lib/utils/loadedPageCache.d.ts +1 -0
- package/lib/utils/loadedPageCache.js +6 -0
- package/lib/utils/params-resolvers.d.ts +3 -0
- package/lib/utils/params-resolvers.js +10 -0
- package/lib/utils/variablesParams.js +5 -0
- package/lib/views/createViewMeta.d.ts +1 -0
- package/lib/views/createViewMeta.js +53 -22
- package/package.json +4 -4
- package/src/__tests__/createViewMeta.popup.test.ts +84 -1
- package/src/__tests__/flowContext.test.ts +31 -0
- package/src/__tests__/flowI18n.test.ts +11 -0
- package/src/__tests__/objectVariable.test.ts +6 -1
- package/src/__tests__/runjsFormSubmit.test.ts +138 -0
- 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/FlowContextSelector.tsx +73 -12
- package/src/components/FormItem.tsx +12 -7
- package/src/components/MobilePopup.style.ts +12 -1
- package/src/components/MobilePopup.tsx +42 -10
- package/src/components/__tests__/FormItem.test.tsx +17 -2
- package/src/components/__tests__/MobilePopup.test.tsx +150 -0
- package/src/components/subModel/LazyDropdown.tsx +71 -38
- package/src/components/subModel/__tests__/AddSubModelButton.test.tsx +85 -2
- package/src/components/subModel/__tests__/LazyDropdown.test.tsx +202 -0
- 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 +95 -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 +83 -9
- package/src/flowEngine.ts +6 -0
- package/src/flowI18n.ts +8 -3
- package/src/locale/__tests__/index.test.ts +21 -0
- package/src/locale/en-US.json +2 -0
- package/src/locale/zh-CN.json +2 -0
- package/src/resources/__tests__/flowResource.test.ts +3 -0
- package/src/resources/flowResource.ts +1 -0
- package/src/types.ts +2 -0
- package/src/utils/__tests__/dateVariable.test.ts +57 -4
- package/src/utils/__tests__/dirtyAwareApiClient.test.ts +321 -0
- package/src/utils/__tests__/variablesParams.test.ts +28 -1
- package/src/utils/associationObjectVariable.ts +9 -6
- package/src/utils/dateVariable.ts +145 -18
- package/src/utils/dirtyAwareApiClient.ts +348 -13
- package/src/utils/index.ts +17 -2
- package/src/utils/loadedPageCache.ts +7 -0
- package/src/utils/params-resolvers.ts +12 -0
- package/src/utils/variablesParams.ts +10 -0
- package/src/views/createViewMeta.ts +52 -18
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { describe, expect, it, vi } from 'vitest';
|
|
11
|
+
import { APIClient, type IResource, type RequestOptions } from '@nocobase/sdk';
|
|
11
12
|
import { FlowContext } from '../../flowContext';
|
|
12
13
|
import { FlowEngine } from '../../flowEngine';
|
|
13
14
|
import { createViewScopedEngine } from '../../ViewScopedFlowEngine';
|
|
@@ -80,6 +81,326 @@ describe('dirtyAwareApiClient', () => {
|
|
|
80
81
|
expect(engine.getDataSourceDirtyVersion('main', 'posts')).toBe(1);
|
|
81
82
|
});
|
|
82
83
|
|
|
84
|
+
it('should keep request overrides local to the dirty-aware proxy', async () => {
|
|
85
|
+
const engine = new FlowEngine();
|
|
86
|
+
const request = vi.fn(async () => ({ data: { ok: true } }));
|
|
87
|
+
const api: TestApi = {
|
|
88
|
+
auth: { locale: 'zh-CN' },
|
|
89
|
+
request,
|
|
90
|
+
resource: vi.fn(),
|
|
91
|
+
};
|
|
92
|
+
const originalRequest = api.request;
|
|
93
|
+
const wrappedApi = getWrappedApi(engine, api);
|
|
94
|
+
const delegatedRequest = wrappedApi.request.bind(wrappedApi);
|
|
95
|
+
const requestOverride = vi.fn((config: TestRequestOptions) => delegatedRequest(config));
|
|
96
|
+
|
|
97
|
+
wrappedApi.request = requestOverride;
|
|
98
|
+
|
|
99
|
+
await wrappedApi.request({ url: 'posts:list' });
|
|
100
|
+
|
|
101
|
+
expect(requestOverride).toHaveBeenCalledTimes(1);
|
|
102
|
+
expect(request).toHaveBeenCalledTimes(1);
|
|
103
|
+
expect(api.request).toBe(originalRequest);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('should keep resource overrides local to the dirty-aware proxy', async () => {
|
|
107
|
+
const engine = new FlowEngine();
|
|
108
|
+
const update = vi.fn(async () => ({ data: { ok: true } }));
|
|
109
|
+
const api: TestApi = {
|
|
110
|
+
auth: { locale: 'zh-CN' },
|
|
111
|
+
request: vi.fn(async () => ({ data: { ok: true } })),
|
|
112
|
+
resource: vi.fn(() => ({ update })),
|
|
113
|
+
};
|
|
114
|
+
const originalResource = api.resource;
|
|
115
|
+
const wrappedApi = getWrappedApi(engine, api);
|
|
116
|
+
const delegatedResource = wrappedApi.resource.bind(wrappedApi);
|
|
117
|
+
const resourceOverride = vi.fn((...args: Parameters<TestApi['resource']>) => delegatedResource(...args));
|
|
118
|
+
|
|
119
|
+
wrappedApi.resource = resourceOverride;
|
|
120
|
+
|
|
121
|
+
await wrappedApi.resource('posts').update({ filterByTk: 1 });
|
|
122
|
+
|
|
123
|
+
expect(resourceOverride).toHaveBeenCalledTimes(1);
|
|
124
|
+
expect(update).toHaveBeenCalledTimes(1);
|
|
125
|
+
expect(api.resource).toBe(originalResource);
|
|
126
|
+
expect(engine.getDataSourceDirtyVersion('main', 'posts')).toBe(1);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('should route resource actions through the local request override without double-marking', async () => {
|
|
130
|
+
const engine = new FlowEngine();
|
|
131
|
+
const api = new APIClient();
|
|
132
|
+
const transport = vi.spyOn(api.axios, 'request').mockResolvedValue({ data: { ok: true } });
|
|
133
|
+
const wrappedApi = getDirtyAwareApiClient(api, engine.context) as APIClient;
|
|
134
|
+
const delegatedRequest = wrappedApi.request.bind(wrappedApi);
|
|
135
|
+
const requestOverride = vi.fn((config: Parameters<APIClient['request']>[0]) => delegatedRequest(config));
|
|
136
|
+
|
|
137
|
+
wrappedApi.request = requestOverride as APIClient['request'];
|
|
138
|
+
|
|
139
|
+
await wrappedApi.resource('posts').update({ filterByTk: 1, values: { title: 'updated' } });
|
|
140
|
+
|
|
141
|
+
expect(requestOverride).toHaveBeenCalledTimes(1);
|
|
142
|
+
expect(transport).toHaveBeenCalledTimes(1);
|
|
143
|
+
expect(engine.getDataSourceDirtyVersion('main', 'posts')).toBe(1);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('should invoke custom request methods on the original api instance', async () => {
|
|
147
|
+
class StatefulRequestAPIClient extends APIClient {
|
|
148
|
+
#privateRequestCalls = 0;
|
|
149
|
+
publicRequestCalls = 0;
|
|
150
|
+
|
|
151
|
+
get privateRequestCalls() {
|
|
152
|
+
return this.#privateRequestCalls;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
override request<T, R, D>(config: Parameters<APIClient['request']>[0]): Promise<R> {
|
|
156
|
+
this.#privateRequestCalls += 1;
|
|
157
|
+
this.publicRequestCalls += 1;
|
|
158
|
+
return super.request<T, R, D>(config);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const engine = new FlowEngine();
|
|
163
|
+
const api = new StatefulRequestAPIClient();
|
|
164
|
+
const transport = vi.spyOn(api.axios, 'request').mockResolvedValue({ data: { ok: true } });
|
|
165
|
+
const wrappedApi = getDirtyAwareApiClient(api, engine.context) as APIClient;
|
|
166
|
+
|
|
167
|
+
await wrappedApi.resource('posts').update({ filterByTk: 1, values: { title: 'updated' } });
|
|
168
|
+
|
|
169
|
+
expect(transport).toHaveBeenCalledTimes(1);
|
|
170
|
+
expect(api.privateRequestCalls).toBe(1);
|
|
171
|
+
expect(api.publicRequestCalls).toBe(1);
|
|
172
|
+
expect(engine.getDataSourceDirtyVersion('main', 'posts')).toBe(1);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it('should invoke custom resource methods on the original api instance', async () => {
|
|
176
|
+
class StatefulResourceAPIClient extends APIClient {
|
|
177
|
+
#privateResourceCalls = 0;
|
|
178
|
+
publicResourceCalls = 0;
|
|
179
|
+
|
|
180
|
+
get privateResourceCalls() {
|
|
181
|
+
return this.#privateResourceCalls;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
override resource(...args: Parameters<APIClient['resource']>): IResource {
|
|
185
|
+
this.#privateResourceCalls += 1;
|
|
186
|
+
this.publicResourceCalls += 1;
|
|
187
|
+
return super.resource(...args);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const engine = new FlowEngine();
|
|
192
|
+
const api = new StatefulResourceAPIClient();
|
|
193
|
+
const transport = vi.spyOn(api.axios, 'request').mockResolvedValue({ data: { ok: true } });
|
|
194
|
+
const wrappedApi = getDirtyAwareApiClient(api, engine.context) as APIClient;
|
|
195
|
+
|
|
196
|
+
await wrappedApi.request({
|
|
197
|
+
resource: 'posts',
|
|
198
|
+
action: 'update',
|
|
199
|
+
params: { filterByTk: 1, values: { title: 'updated' } },
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
expect(transport).toHaveBeenCalledTimes(1);
|
|
203
|
+
expect(api.privateResourceCalls).toBe(1);
|
|
204
|
+
expect(api.publicResourceCalls).toBe(1);
|
|
205
|
+
expect(engine.getDataSourceDirtyVersion('main', 'posts')).toBe(1);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it('should keep one dirty mark when the request override rebuilds the config', async () => {
|
|
209
|
+
const engine = new FlowEngine();
|
|
210
|
+
const api = new APIClient();
|
|
211
|
+
const transport = vi.spyOn(api.axios, 'request').mockResolvedValue({ data: { ok: true } });
|
|
212
|
+
const wrappedApi = getDirtyAwareApiClient(api, engine.context) as APIClient;
|
|
213
|
+
const delegatedRequest = wrappedApi.request.bind(wrappedApi);
|
|
214
|
+
const requestOverride = vi.fn((config: Parameters<APIClient['request']>[0]) => {
|
|
215
|
+
const { url, method, headers, params, data } = config as RequestOptions;
|
|
216
|
+
return delegatedRequest({ url, method, headers, params, data });
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
wrappedApi.request = requestOverride as APIClient['request'];
|
|
220
|
+
|
|
221
|
+
await wrappedApi.resource('posts').update({ filterByTk: 1, values: { title: 'updated' } });
|
|
222
|
+
|
|
223
|
+
expect(requestOverride).toHaveBeenCalledTimes(1);
|
|
224
|
+
expect(transport).toHaveBeenCalledTimes(1);
|
|
225
|
+
expect(engine.getDataSourceDirtyVersion('main', 'posts')).toBe(1);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it('should keep nested mutations from a request override independent', async () => {
|
|
229
|
+
const engine = new FlowEngine();
|
|
230
|
+
const api = new APIClient();
|
|
231
|
+
const transport = vi.spyOn(api.axios, 'request').mockResolvedValue({ data: { ok: true } });
|
|
232
|
+
const wrappedApi = getDirtyAwareApiClient(api, engine.context) as APIClient;
|
|
233
|
+
const delegatedRequest = wrappedApi.request.bind(wrappedApi);
|
|
234
|
+
const requestOverride = vi.fn(async (config: Parameters<APIClient['request']>[0]) => {
|
|
235
|
+
await delegatedRequest({ url: 'comments:create', method: 'post' });
|
|
236
|
+
return delegatedRequest(config);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
wrappedApi.request = requestOverride as APIClient['request'];
|
|
240
|
+
|
|
241
|
+
await wrappedApi.resource('posts').update({ filterByTk: 1, values: { title: 'updated' } });
|
|
242
|
+
|
|
243
|
+
expect(requestOverride).toHaveBeenCalledTimes(1);
|
|
244
|
+
expect(transport).toHaveBeenCalledTimes(2);
|
|
245
|
+
expect(engine.getDataSourceDirtyVersion('main', 'comments')).toBe(1);
|
|
246
|
+
expect(engine.getDataSourceDirtyVersion('main', 'posts')).toBe(1);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it('should route structured requests through the local resource override without double-marking', async () => {
|
|
250
|
+
const engine = new FlowEngine();
|
|
251
|
+
const api = new APIClient();
|
|
252
|
+
const transport = vi.spyOn(api.axios, 'request').mockResolvedValue({ data: { ok: true } });
|
|
253
|
+
const wrappedApi = getDirtyAwareApiClient(api, engine.context) as APIClient;
|
|
254
|
+
const delegatedResource = wrappedApi.resource.bind(wrappedApi);
|
|
255
|
+
const resourceOverride = vi.fn((...args: Parameters<APIClient['resource']>) => delegatedResource(...args));
|
|
256
|
+
|
|
257
|
+
wrappedApi.resource = resourceOverride;
|
|
258
|
+
|
|
259
|
+
await wrappedApi.request({
|
|
260
|
+
resource: 'posts',
|
|
261
|
+
action: 'update',
|
|
262
|
+
params: { filterByTk: 1, values: { title: 'updated' } },
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
expect(resourceOverride).toHaveBeenCalledTimes(1);
|
|
266
|
+
expect(transport).toHaveBeenCalledTimes(1);
|
|
267
|
+
expect(engine.getDataSourceDirtyVersion('main', 'posts')).toBe(1);
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it('should keep one dirty mark when the resource override delegates lazily', async () => {
|
|
271
|
+
const engine = new FlowEngine();
|
|
272
|
+
const api = new APIClient();
|
|
273
|
+
const transport = vi.spyOn(api.axios, 'request').mockResolvedValue({ data: { ok: true } });
|
|
274
|
+
const wrappedApi = getDirtyAwareApiClient(api, engine.context) as APIClient;
|
|
275
|
+
const delegatedResource = wrappedApi.resource.bind(wrappedApi);
|
|
276
|
+
const resourceOverride = vi.fn(
|
|
277
|
+
(...args: Parameters<APIClient['resource']>): IResource => ({
|
|
278
|
+
update: async (...actionArgs: Parameters<IResource['update']>) => {
|
|
279
|
+
await Promise.resolve();
|
|
280
|
+
return delegatedResource(...args).update(...actionArgs);
|
|
281
|
+
},
|
|
282
|
+
}),
|
|
283
|
+
);
|
|
284
|
+
|
|
285
|
+
wrappedApi.resource = resourceOverride;
|
|
286
|
+
|
|
287
|
+
await wrappedApi.request({
|
|
288
|
+
resource: 'posts',
|
|
289
|
+
action: 'update',
|
|
290
|
+
params: { filterByTk: 1, values: { title: 'updated' } },
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
expect(resourceOverride).toHaveBeenCalledTimes(1);
|
|
294
|
+
expect(transport).toHaveBeenCalledTimes(1);
|
|
295
|
+
expect(engine.getDataSourceDirtyVersion('main', 'posts')).toBe(1);
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
it('should keep nested mutations from a resource override independent', async () => {
|
|
299
|
+
const engine = new FlowEngine();
|
|
300
|
+
const api = new APIClient();
|
|
301
|
+
const transport = vi.spyOn(api.axios, 'request').mockResolvedValue({ data: { ok: true } });
|
|
302
|
+
const wrappedApi = getDirtyAwareApiClient(api, engine.context) as APIClient;
|
|
303
|
+
const delegatedResource = wrappedApi.resource.bind(wrappedApi);
|
|
304
|
+
let nestedMutation: Promise<unknown> | undefined;
|
|
305
|
+
const resourceOverride = vi.fn((...args: Parameters<APIClient['resource']>) => {
|
|
306
|
+
nestedMutation = delegatedResource('comments').create({ values: { body: 'nested' } });
|
|
307
|
+
return delegatedResource(...args);
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
wrappedApi.resource = resourceOverride;
|
|
311
|
+
|
|
312
|
+
await wrappedApi.request({
|
|
313
|
+
resource: 'posts',
|
|
314
|
+
action: 'update',
|
|
315
|
+
params: { filterByTk: 1, values: { title: 'updated' } },
|
|
316
|
+
});
|
|
317
|
+
await nestedMutation;
|
|
318
|
+
|
|
319
|
+
expect(resourceOverride).toHaveBeenCalledTimes(1);
|
|
320
|
+
expect(transport).toHaveBeenCalledTimes(2);
|
|
321
|
+
expect(engine.getDataSourceDirtyVersion('main', 'comments')).toBe(1);
|
|
322
|
+
expect(engine.getDataSourceDirtyVersion('main', 'posts')).toBe(1);
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
it('should clear local request and resource overrides when deleted', () => {
|
|
326
|
+
const engine = new FlowEngine();
|
|
327
|
+
const api: TestApi = {
|
|
328
|
+
auth: { locale: 'zh-CN' },
|
|
329
|
+
request: vi.fn(async () => ({ data: { ok: true } })),
|
|
330
|
+
resource: vi.fn(() => ({})),
|
|
331
|
+
};
|
|
332
|
+
const wrappedApi = getWrappedApi(engine, api);
|
|
333
|
+
const defaultRequest = wrappedApi.request;
|
|
334
|
+
const defaultResource = wrappedApi.resource;
|
|
335
|
+
|
|
336
|
+
wrappedApi.request = vi.fn();
|
|
337
|
+
wrappedApi.resource = vi.fn();
|
|
338
|
+
|
|
339
|
+
expect(Reflect.deleteProperty(wrappedApi, 'request')).toBe(true);
|
|
340
|
+
expect(Reflect.deleteProperty(wrappedApi, 'resource')).toBe(true);
|
|
341
|
+
expect(wrappedApi.request).toBe(defaultRequest);
|
|
342
|
+
expect(wrappedApi.resource).toBe(defaultResource);
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
it('should reject request and resource descriptor overrides without mutating the raw api', () => {
|
|
346
|
+
const engine = new FlowEngine();
|
|
347
|
+
const api: TestApi = {
|
|
348
|
+
auth: { locale: 'zh-CN' },
|
|
349
|
+
request: vi.fn(async () => ({ data: { ok: true } })),
|
|
350
|
+
resource: vi.fn(() => ({})),
|
|
351
|
+
};
|
|
352
|
+
const wrappedApi = getWrappedApi(engine, api);
|
|
353
|
+
const originalRequest = api.request;
|
|
354
|
+
const originalResource = api.resource;
|
|
355
|
+
|
|
356
|
+
expect(Reflect.defineProperty(wrappedApi, 'request', { value: vi.fn() })).toBe(false);
|
|
357
|
+
expect(Reflect.defineProperty(wrappedApi, 'resource', { value: vi.fn() })).toBe(false);
|
|
358
|
+
expect(() => Object.defineProperty(wrappedApi, 'request', { value: vi.fn() })).toThrow(TypeError);
|
|
359
|
+
expect(() => Object.defineProperty(wrappedApi, 'resource', { value: vi.fn() })).toThrow(TypeError);
|
|
360
|
+
expect(api.request).toBe(originalRequest);
|
|
361
|
+
expect(api.resource).toBe(originalResource);
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
it('should reject local overrides for own methods on a non-extensible raw api', () => {
|
|
365
|
+
const engine = new FlowEngine();
|
|
366
|
+
const api: TestApi = {
|
|
367
|
+
auth: { locale: 'zh-CN' },
|
|
368
|
+
request: vi.fn(async () => ({ data: { ok: true } })),
|
|
369
|
+
resource: vi.fn(() => ({})),
|
|
370
|
+
};
|
|
371
|
+
const wrappedApi = getWrappedApi(engine, api);
|
|
372
|
+
const defaultMethods = {
|
|
373
|
+
request: wrappedApi.request,
|
|
374
|
+
resource: wrappedApi.resource,
|
|
375
|
+
};
|
|
376
|
+
|
|
377
|
+
Object.preventExtensions(api);
|
|
378
|
+
|
|
379
|
+
for (const methodName of ['request', 'resource'] as const) {
|
|
380
|
+
expect(Reflect.set(wrappedApi, methodName, vi.fn())).toBe(false);
|
|
381
|
+
expect(wrappedApi[methodName]).toBe(defaultMethods[methodName]);
|
|
382
|
+
expect(Reflect.deleteProperty(wrappedApi, methodName)).toBe(false);
|
|
383
|
+
}
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
it('should keep overrides isolated between flow contexts', () => {
|
|
387
|
+
const api: TestApi = {
|
|
388
|
+
auth: { locale: 'zh-CN' },
|
|
389
|
+
request: vi.fn(async () => ({ data: { ok: true } })),
|
|
390
|
+
resource: vi.fn(() => ({})),
|
|
391
|
+
};
|
|
392
|
+
const firstContextApi = getDirtyAwareApiClient(api, new FlowContext()) as TestApi;
|
|
393
|
+
const secondContextApi = getDirtyAwareApiClient(api, new FlowContext()) as TestApi;
|
|
394
|
+
const secondRequest = secondContextApi.request;
|
|
395
|
+
const requestOverride = vi.fn();
|
|
396
|
+
|
|
397
|
+
firstContextApi.request = requestOverride;
|
|
398
|
+
|
|
399
|
+
expect(firstContextApi.request).toBe(requestOverride);
|
|
400
|
+
expect(secondContextApi.request).toBe(secondRequest);
|
|
401
|
+
expect(api.request).not.toBe(requestOverride);
|
|
402
|
+
});
|
|
403
|
+
|
|
83
404
|
it('should not mark dirty for read actions', async () => {
|
|
84
405
|
const nonMutatingActions = [
|
|
85
406
|
'get',
|
|
@@ -34,7 +34,8 @@ describe('variablesParams helpers', () => {
|
|
|
34
34
|
|
|
35
35
|
it('inferRecordRef fallback to collection.getFilterByTK when resource has no filterByTk', () => {
|
|
36
36
|
const engine = new FlowEngine();
|
|
37
|
-
const ds = engine.context.dataSourceManager.getDataSource('main')
|
|
37
|
+
const ds = engine.context.dataSourceManager.getDataSource('main');
|
|
38
|
+
if (!ds) throw new Error('main data source is required');
|
|
38
39
|
ds.addCollection({
|
|
39
40
|
name: 'users',
|
|
40
41
|
filterTargetKey: 'id',
|
|
@@ -108,6 +109,32 @@ describe('variablesParams helpers', () => {
|
|
|
108
109
|
});
|
|
109
110
|
});
|
|
110
111
|
|
|
112
|
+
it('collectContextParamsForTemplate infers view.record when its meta has no descriptor', async () => {
|
|
113
|
+
const ctx: any = {
|
|
114
|
+
getPropertyOptions: () => undefined,
|
|
115
|
+
view: {
|
|
116
|
+
inputArgs: {
|
|
117
|
+
collectionName: 'posts',
|
|
118
|
+
dataSourceKey: 'main',
|
|
119
|
+
filterByTk: 3,
|
|
120
|
+
},
|
|
121
|
+
},
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
const res = await collectContextParamsForTemplate(ctx, {
|
|
125
|
+
recordId: '{{ ctx.view.record.id }}',
|
|
126
|
+
viewType: '{{ ctx.view.type }}',
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
expect(res).toEqual({
|
|
130
|
+
'view.record': {
|
|
131
|
+
collection: 'posts',
|
|
132
|
+
dataSourceKey: 'main',
|
|
133
|
+
filterByTk: 3,
|
|
134
|
+
},
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
111
138
|
it('createRecordResolveOnServerWithLocal: no local record => always use server', () => {
|
|
112
139
|
const resolver = createRecordResolveOnServerWithLocal(
|
|
113
140
|
() => ({ name: 'posts', dataSourceKey: 'main' }) as any,
|
|
@@ -51,13 +51,14 @@ function findFieldByName(collection: Collection | null | undefined, name?: strin
|
|
|
51
51
|
* @param primaryKey 主键字段名
|
|
52
52
|
* @returns 解析出的主键值,无法解析时返回 undefined
|
|
53
53
|
*/
|
|
54
|
-
function
|
|
54
|
+
export function getAssociationFilterByTk(value: unknown, primaryKey: string | string[]) {
|
|
55
55
|
if (value == null) return undefined;
|
|
56
56
|
if (Array.isArray(primaryKey)) {
|
|
57
57
|
if (typeof value !== 'object' || !value) return undefined;
|
|
58
|
-
const
|
|
58
|
+
const record = value as Record<string, unknown>;
|
|
59
|
+
const out: Record<string, unknown> = {};
|
|
59
60
|
for (const k of primaryKey) {
|
|
60
|
-
const v =
|
|
61
|
+
const v = record[k];
|
|
61
62
|
if (typeof v === 'undefined' || v === null) return undefined;
|
|
62
63
|
out[k] = v;
|
|
63
64
|
}
|
|
@@ -65,7 +66,7 @@ function toFilterByTk(value: unknown, primaryKey: string | string[]) {
|
|
|
65
66
|
}
|
|
66
67
|
if (typeof value === 'string' || typeof value === 'number') return value;
|
|
67
68
|
if (typeof value === 'object') {
|
|
68
|
-
return (value as
|
|
69
|
+
return (value as Record<string, unknown>)[primaryKey];
|
|
69
70
|
}
|
|
70
71
|
return undefined;
|
|
71
72
|
}
|
|
@@ -149,7 +150,9 @@ export function createAssociationAwareObjectMetaFactory(
|
|
|
149
150
|
if (associationValue == null) continue;
|
|
150
151
|
|
|
151
152
|
if (Array.isArray(associationValue)) {
|
|
152
|
-
const ids = associationValue
|
|
153
|
+
const ids = associationValue
|
|
154
|
+
.map((item) => getAssociationFilterByTk(item, primaryKey))
|
|
155
|
+
.filter((v) => v != null);
|
|
153
156
|
if (ids.length) {
|
|
154
157
|
params[name] = {
|
|
155
158
|
collection: target,
|
|
@@ -158,7 +161,7 @@ export function createAssociationAwareObjectMetaFactory(
|
|
|
158
161
|
};
|
|
159
162
|
}
|
|
160
163
|
} else {
|
|
161
|
-
const id =
|
|
164
|
+
const id = getAssociationFilterByTk(associationValue, primaryKey);
|
|
162
165
|
if (id != null) {
|
|
163
166
|
params[name] = {
|
|
164
167
|
collection: target,
|
|
@@ -11,7 +11,7 @@ import dayjs from 'dayjs';
|
|
|
11
11
|
|
|
12
12
|
const CTX_DATE_REGEX = /^\{\{\s*ctx\.date(?:\.(.+?))?\s*\}\}$/;
|
|
13
13
|
|
|
14
|
-
const
|
|
14
|
+
const PRESET_KEY_LIST = [
|
|
15
15
|
'today',
|
|
16
16
|
'now',
|
|
17
17
|
'yesterday',
|
|
@@ -28,10 +28,28 @@ const PRESET_KEYS = new Set([
|
|
|
28
28
|
'thisYear',
|
|
29
29
|
'lastYear',
|
|
30
30
|
'nextYear',
|
|
31
|
-
]
|
|
31
|
+
] as const;
|
|
32
|
+
|
|
33
|
+
export type CtxDatePreset = (typeof PRESET_KEY_LIST)[number];
|
|
34
|
+
export type CtxDateRelativeDirection = 'next' | 'past';
|
|
35
|
+
export type CtxDateRelativeUnit = 'day' | 'week' | 'month' | 'year';
|
|
36
|
+
|
|
37
|
+
export type CtxDateExpressionConfig =
|
|
38
|
+
| { kind: 'exact'; value: string | [string, string]; format?: string }
|
|
39
|
+
| {
|
|
40
|
+
kind: 'relative';
|
|
41
|
+
direction: CtxDateRelativeDirection;
|
|
42
|
+
amount: number;
|
|
43
|
+
unit: CtxDateRelativeUnit;
|
|
44
|
+
format?: string;
|
|
45
|
+
}
|
|
46
|
+
| { kind: 'preset'; preset: CtxDatePreset; format?: string };
|
|
47
|
+
|
|
48
|
+
const PRESET_KEYS = new Set<string>(PRESET_KEY_LIST);
|
|
32
49
|
|
|
33
50
|
const RELATIVE_DIRECTIONS = new Set(['next', 'past']);
|
|
34
51
|
const RELATIVE_UNITS = new Set(['day', 'week', 'month', 'year']);
|
|
52
|
+
const MAX_DATE_FORMAT_LENGTH = 128;
|
|
35
53
|
|
|
36
54
|
function parseCtxDateSegments(value: string): string[] | null {
|
|
37
55
|
if (typeof value !== 'string') return null;
|
|
@@ -46,8 +64,7 @@ function parseCtxDateSegments(value: string): string[] | null {
|
|
|
46
64
|
.filter(Boolean);
|
|
47
65
|
}
|
|
48
66
|
|
|
49
|
-
|
|
50
|
-
const segments = withDatePrefix((pathSegments || []).map((seg) => String(seg)));
|
|
67
|
+
function isBaseCtxDatePathPrefix(segments: string[]): boolean {
|
|
51
68
|
if (segments[0] !== 'date') return false;
|
|
52
69
|
if (segments.length === 1) return true;
|
|
53
70
|
|
|
@@ -96,6 +113,36 @@ export function isCtxDatePathPrefix(pathSegments: string[]): boolean {
|
|
|
96
113
|
return false;
|
|
97
114
|
}
|
|
98
115
|
|
|
116
|
+
function decodeFormatToken(token: string): string | undefined {
|
|
117
|
+
const raw = String(token || '');
|
|
118
|
+
if (!raw.startsWith('v')) return undefined;
|
|
119
|
+
const decoded = decodeBase64Url(raw.slice(1));
|
|
120
|
+
if (!decoded || decoded.length > MAX_DATE_FORMAT_LENGTH) return undefined;
|
|
121
|
+
return decoded;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function splitFormattedDateSegments(segments: string[]): { baseSegments: string[]; format?: string } | null {
|
|
125
|
+
if (segments[0] !== 'date') return null;
|
|
126
|
+
if (segments[1] !== 'format') return { baseSegments: segments };
|
|
127
|
+
if (segments.length < 4) return null;
|
|
128
|
+
|
|
129
|
+
const format = decodeFormatToken(segments[2]);
|
|
130
|
+
if (!format) return null;
|
|
131
|
+
return { baseSegments: ['date', ...segments.slice(3)], format };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function isCtxDatePathPrefix(pathSegments: string[]): boolean {
|
|
135
|
+
const segments = withDatePrefix((pathSegments || []).map((seg) => String(seg)));
|
|
136
|
+
if (segments[0] !== 'date') return false;
|
|
137
|
+
if (segments.length === 1) return true;
|
|
138
|
+
if (segments[1] !== 'format') return isBaseCtxDatePathPrefix(segments);
|
|
139
|
+
if (segments.length === 2) return true;
|
|
140
|
+
if (segments.length === 3) return typeof decodeFormatToken(segments[2]) === 'string';
|
|
141
|
+
|
|
142
|
+
const formatted = splitFormattedDateSegments(segments);
|
|
143
|
+
return formatted ? isBaseCtxDatePathPrefix(formatted.baseSegments) : false;
|
|
144
|
+
}
|
|
145
|
+
|
|
99
146
|
function withDatePrefix(pathSegments: string[]): string[] {
|
|
100
147
|
if (pathSegments[0] === 'date') {
|
|
101
148
|
return pathSegments;
|
|
@@ -210,27 +257,29 @@ export function isCtxDateExpression(value: unknown): value is string {
|
|
|
210
257
|
export function isCompleteCtxDatePath(pathSegments: string[]): boolean {
|
|
211
258
|
if (!isCtxDatePathPrefix(pathSegments)) return false;
|
|
212
259
|
const segments = withDatePrefix((pathSegments || []).map((seg) => String(seg)));
|
|
213
|
-
|
|
260
|
+
const formatted = splitFormattedDateSegments(segments);
|
|
261
|
+
if (!formatted) return false;
|
|
262
|
+
const baseSegments = formatted.baseSegments;
|
|
214
263
|
|
|
215
|
-
if (
|
|
216
|
-
return
|
|
264
|
+
if (baseSegments[1] === 'preset') {
|
|
265
|
+
return baseSegments.length === 3 && PRESET_KEYS.has(baseSegments[2]);
|
|
217
266
|
}
|
|
218
267
|
|
|
219
|
-
if (
|
|
220
|
-
if (
|
|
268
|
+
if (baseSegments[1] === 'relative') {
|
|
269
|
+
if (baseSegments.length !== 5) return false;
|
|
221
270
|
return (
|
|
222
|
-
RELATIVE_DIRECTIONS.has(
|
|
223
|
-
RELATIVE_UNITS.has(
|
|
224
|
-
typeof parseNumberToken(
|
|
271
|
+
RELATIVE_DIRECTIONS.has(baseSegments[2]) &&
|
|
272
|
+
RELATIVE_UNITS.has(baseSegments[3]) &&
|
|
273
|
+
typeof parseNumberToken(baseSegments[4]) === 'number'
|
|
225
274
|
);
|
|
226
275
|
}
|
|
227
276
|
|
|
228
|
-
if (
|
|
229
|
-
return
|
|
277
|
+
if (baseSegments[1] === 'exact' && baseSegments[2] === 'single' && baseSegments[3] === 'date') {
|
|
278
|
+
return baseSegments.length === 5 && /^v.+/.test(baseSegments[4]);
|
|
230
279
|
}
|
|
231
280
|
|
|
232
|
-
if (
|
|
233
|
-
return
|
|
281
|
+
if (baseSegments[1] === 'exact' && baseSegments[2] === 'range' && baseSegments[3] === 'date') {
|
|
282
|
+
return baseSegments.length === 6 && /^v.+/.test(baseSegments[4]) && /^v.+/.test(baseSegments[5]);
|
|
234
283
|
}
|
|
235
284
|
|
|
236
285
|
return false;
|
|
@@ -238,7 +287,10 @@ export function isCompleteCtxDatePath(pathSegments: string[]): boolean {
|
|
|
238
287
|
|
|
239
288
|
export function parseCtxDateExpression(value: unknown): any {
|
|
240
289
|
if (!isCtxDateExpression(value)) return undefined;
|
|
241
|
-
const
|
|
290
|
+
const rawSegments = withDatePrefix(parseCtxDateSegments(value as string) || []);
|
|
291
|
+
const formatted = splitFormattedDateSegments(rawSegments);
|
|
292
|
+
if (!formatted) return undefined;
|
|
293
|
+
const segments = formatted.baseSegments;
|
|
242
294
|
|
|
243
295
|
if (segments[1] === 'preset' && segments.length === 3 && PRESET_KEYS.has(segments[2])) {
|
|
244
296
|
return { type: segments[2] };
|
|
@@ -276,6 +328,66 @@ export function parseCtxDateExpression(value: unknown): any {
|
|
|
276
328
|
return undefined;
|
|
277
329
|
}
|
|
278
330
|
|
|
331
|
+
export function parseCtxDateExpressionConfig(value: unknown): CtxDateExpressionConfig | undefined {
|
|
332
|
+
if (!isCtxDateExpression(value)) return undefined;
|
|
333
|
+
const segments = withDatePrefix(parseCtxDateSegments(value) || []);
|
|
334
|
+
const formatted = splitFormattedDateSegments(segments);
|
|
335
|
+
if (!formatted) return undefined;
|
|
336
|
+
|
|
337
|
+
const parsed = parseCtxDateExpression(value);
|
|
338
|
+
const formatConfig = formatted.format ? { format: formatted.format } : {};
|
|
339
|
+
if (typeof parsed === 'string') {
|
|
340
|
+
return { kind: 'exact', value: parsed, ...formatConfig };
|
|
341
|
+
}
|
|
342
|
+
if (Array.isArray(parsed) && parsed.length === 2 && typeof parsed[0] === 'string' && typeof parsed[1] === 'string') {
|
|
343
|
+
return { kind: 'exact', value: [parsed[0], parsed[1]], ...formatConfig };
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
if (!parsed || typeof parsed !== 'object') return undefined;
|
|
347
|
+
const typed = parsed as { type?: unknown; unit?: unknown; number?: unknown };
|
|
348
|
+
if (typed.type === 'past' || typed.type === 'next') {
|
|
349
|
+
if (typeof typed.unit !== 'string' || !RELATIVE_UNITS.has(typed.unit) || typeof typed.number !== 'number') {
|
|
350
|
+
return undefined;
|
|
351
|
+
}
|
|
352
|
+
return {
|
|
353
|
+
kind: 'relative',
|
|
354
|
+
direction: typed.type,
|
|
355
|
+
amount: typed.number,
|
|
356
|
+
unit: typed.unit as CtxDateRelativeUnit,
|
|
357
|
+
...formatConfig,
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
if (typeof typed.type === 'string' && PRESET_KEYS.has(typed.type)) {
|
|
362
|
+
return { kind: 'preset', preset: typed.type as CtxDatePreset, ...formatConfig };
|
|
363
|
+
}
|
|
364
|
+
return undefined;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
export function serializeCtxDateExpressionConfig(config: CtxDateExpressionConfig): string | undefined {
|
|
368
|
+
let legacyValue: unknown;
|
|
369
|
+
|
|
370
|
+
if (config.kind === 'preset') {
|
|
371
|
+
if (!PRESET_KEYS.has(config.preset)) return undefined;
|
|
372
|
+
legacyValue = { type: config.preset };
|
|
373
|
+
} else if (config.kind === 'relative') {
|
|
374
|
+
if (!RELATIVE_DIRECTIONS.has(config.direction) || !RELATIVE_UNITS.has(config.unit)) return undefined;
|
|
375
|
+
const amount = Math.floor(Number(config.amount));
|
|
376
|
+
if (!Number.isFinite(amount) || amount <= 0) return undefined;
|
|
377
|
+
legacyValue = { type: config.direction, unit: config.unit, number: amount };
|
|
378
|
+
} else {
|
|
379
|
+
legacyValue = config.value;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
const expression = serializeCtxDateValue(legacyValue);
|
|
383
|
+
if (!expression || !config.format) return expression;
|
|
384
|
+
|
|
385
|
+
const format = String(config.format);
|
|
386
|
+
if (!format.trim() || format.length > MAX_DATE_FORMAT_LENGTH) return undefined;
|
|
387
|
+
const segments = withDatePrefix(parseCtxDateSegments(expression) || []);
|
|
388
|
+
return toCtxDateExpression(['date', 'format', `v${encodeBase64Url(format)}`, ...segments.slice(1)]);
|
|
389
|
+
}
|
|
390
|
+
|
|
279
391
|
export function serializeCtxDateValue(value: unknown): string | undefined {
|
|
280
392
|
if (isCtxDateExpression(value)) {
|
|
281
393
|
return String(value).trim();
|
|
@@ -327,8 +439,23 @@ export function serializeCtxDateValue(value: unknown): string | undefined {
|
|
|
327
439
|
return undefined;
|
|
328
440
|
}
|
|
329
441
|
|
|
442
|
+
function formatResolvedDateValue(value: unknown, format: string): unknown {
|
|
443
|
+
const formatValue = (item: unknown) => {
|
|
444
|
+
if (typeof item !== 'string') return item;
|
|
445
|
+
const parsed = dayjs(item);
|
|
446
|
+
return parsed.isValid() ? parsed.format(format) : item;
|
|
447
|
+
};
|
|
448
|
+
return Array.isArray(value) ? value.map(formatValue) : formatValue(value);
|
|
449
|
+
}
|
|
450
|
+
|
|
330
451
|
export function resolveCtxDatePath(pathSegments: string[]): any {
|
|
331
|
-
const
|
|
452
|
+
const rawSegments = withDatePrefix((pathSegments || []).map((seg) => String(seg)));
|
|
453
|
+
const formatted = splitFormattedDateSegments(rawSegments);
|
|
454
|
+
if (!formatted) return undefined;
|
|
455
|
+
if (formatted.format) {
|
|
456
|
+
return formatResolvedDateValue(resolveCtxDatePath(formatted.baseSegments), formatted.format);
|
|
457
|
+
}
|
|
458
|
+
const segments = formatted.baseSegments;
|
|
332
459
|
if (segments[0] !== 'date') return undefined;
|
|
333
460
|
|
|
334
461
|
if (segments[1] === 'preset' && segments.length === 3) {
|