@nocobase/flow-engine 2.2.0-alpha.7 → 2.2.0-alpha.8
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/components/variables/VariableInput.js +15 -7
- package/lib/components/variables/VariableTag.js +43 -32
- package/lib/components/variables/types.d.ts +2 -0
- package/lib/utils/dirtyAwareApiClient.js +267 -13
- package/package.json +4 -4
- package/src/components/variables/VariableInput.tsx +34 -7
- package/src/components/variables/VariableTag.tsx +47 -34
- package/src/components/variables/__tests__/VariableInput.test.tsx +93 -1
- package/src/components/variables/__tests__/VariableTag.test.tsx +80 -0
- package/src/components/variables/types.ts +2 -0
- package/src/utils/__tests__/dirtyAwareApiClient.test.ts +321 -0
- package/src/utils/dirtyAwareApiClient.ts +325 -13
|
@@ -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',
|