@nocobase/flow-engine 2.2.0-alpha.1 → 2.2.0-alpha.11
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/JSRunner.d.ts +1 -0
- package/lib/JSRunner.js +110 -20
- package/lib/acl/Acl.d.ts +2 -1
- package/lib/acl/Acl.js +28 -0
- package/lib/components/FieldModelRenderer.js +44 -31
- package/lib/components/FlowContextSelector.js +55 -12
- package/lib/components/FormItem.js +11 -7
- package/lib/components/MobilePopup.js +39 -10
- package/lib/components/MobilePopup.style.js +27 -6
- package/lib/components/dnd/index.js +9 -2
- package/lib/components/settings/wrappers/contextual/FlowsFloatContextMenu.js +86 -32
- package/lib/components/settings/wrappers/contextual/useFloatToolbarVisibility.js +20 -0
- package/lib/components/subModel/LazyDropdown.js +41 -26
- 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 +1 -1
- package/lib/flowContext.js +97 -42
- package/lib/flowEngine.js +6 -0
- package/lib/flowI18n.js +3 -3
- package/lib/flowSettings.d.ts +5 -1
- package/lib/flowSettings.js +70 -0
- package/lib/locale/en-US.json +3 -0
- package/lib/locale/index.d.ts +6 -0
- package/lib/locale/zh-CN.json +3 -0
- package/lib/resources/apiResource.js +2 -1
- package/lib/resources/baseRecordResource.js +6 -17
- package/lib/resources/multiRecordResource.js +13 -3
- package/lib/resources/singleRecordResource.js +7 -2
- package/lib/runjs-context/helpers.js +12 -5
- package/lib/types.d.ts +15 -1
- package/lib/types.js +1 -0
- package/lib/utils/dataSourceDirty.d.ts +20 -0
- package/lib/utils/dataSourceDirty.js +139 -0
- package/lib/utils/dirtyAwareApiClient.d.ts +11 -0
- package/lib/utils/dirtyAwareApiClient.js +632 -0
- package/lib/utils/index.d.ts +1 -1
- package/lib/utils/index.js +11 -11
- package/lib/utils/loadedPageCache.d.ts +1 -0
- package/lib/utils/loadedPageCache.js +6 -0
- package/lib/utils/openViewRouteState.d.ts +28 -0
- package/lib/utils/openViewRouteState.js +125 -0
- package/lib/utils/parsePathnameToViewParams.d.ts +3 -0
- package/lib/utils/parsePathnameToViewParams.js +18 -1
- package/lib/utils/resolveRunJSObjectValues.js +3 -2
- package/lib/utils/runjsModuleLoader.js +0 -30
- package/lib/views/ViewNavigation.js +5 -0
- package/package.json +4 -4
- package/src/JSRunner.ts +112 -25
- package/src/__tests__/JSRunner.test.ts +4 -5
- package/src/__tests__/flowContext.test.ts +154 -0
- package/src/__tests__/flowEngine.dataSourceDirty.test.ts +51 -0
- package/src/__tests__/flowI18n.test.ts +11 -0
- package/src/__tests__/flowModel.openView.navigation.test.ts +28 -0
- package/src/__tests__/flowSettings.test.ts +72 -0
- package/src/__tests__/runjsRuntimeFeatures.test.ts +15 -2
- 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/FieldModelRenderer.tsx +50 -36
- package/src/components/FlowContextSelector.tsx +66 -11
- package/src/components/FormItem.tsx +12 -7
- package/src/components/MobilePopup.style.ts +34 -7
- package/src/components/MobilePopup.tsx +42 -10
- package/src/components/__tests__/FieldModelRenderer.test.tsx +165 -0
- package/src/components/__tests__/FormItem.test.tsx +17 -2
- package/src/components/__tests__/MobilePopup.style.test.tsx +103 -0
- package/src/components/__tests__/MobilePopup.test.tsx +150 -0
- package/src/components/dnd/index.tsx +11 -2
- package/src/components/settings/wrappers/contextual/FlowsFloatContextMenu.tsx +105 -35
- package/src/components/settings/wrappers/contextual/__tests__/FlowsFloatContextMenu.test.tsx +381 -12
- package/src/components/settings/wrappers/contextual/useFloatToolbarVisibility.ts +28 -0
- package/src/components/subModel/LazyDropdown.tsx +44 -26
- package/src/components/subModel/__tests__/AddSubModelButton.test.tsx +85 -2
- 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 +60 -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 +104 -36
- package/src/flowEngine.ts +6 -0
- package/src/flowI18n.ts +8 -3
- package/src/flowSettings.ts +85 -1
- package/src/locale/__tests__/index.test.ts +21 -0
- package/src/locale/en-US.json +3 -0
- package/src/locale/zh-CN.json +3 -0
- package/src/resources/apiResource.ts +2 -1
- package/src/resources/baseRecordResource.ts +6 -23
- package/src/resources/multiRecordResource.ts +13 -3
- package/src/resources/singleRecordResource.ts +6 -1
- package/src/runjs-context/helpers.ts +12 -6
- package/src/types.ts +16 -0
- package/src/utils/__tests__/dirtyAwareApiClient.test.ts +713 -0
- package/src/utils/__tests__/openViewRouteState.test.ts +40 -0
- package/src/utils/__tests__/parsePathnameToViewParams.test.ts +36 -0
- package/src/utils/dataSourceDirty.ts +126 -0
- package/src/utils/dirtyAwareApiClient.ts +742 -0
- package/src/utils/index.ts +10 -9
- package/src/utils/loadedPageCache.ts +7 -0
- package/src/utils/openViewRouteState.ts +107 -0
- package/src/utils/parsePathnameToViewParams.ts +23 -1
- package/src/utils/resolveRunJSObjectValues.ts +5 -2
- package/src/utils/runjsModuleLoader.ts +0 -32
- package/src/views/ViewNavigation.ts +6 -1
- package/src/views/__tests__/ViewNavigation.test.ts +15 -0
- package/lib/utils/safeGlobals.d.ts +0 -28
- package/lib/utils/safeGlobals.js +0 -367
- package/src/utils/__tests__/runjsRequireAsyncAutoWhitelist.test.ts +0 -38
- package/src/utils/__tests__/safeGlobals.test.ts +0 -106
- package/src/utils/safeGlobals.ts +0 -406
|
@@ -14,6 +14,8 @@ import { FlowEngine } from '../flowEngine';
|
|
|
14
14
|
import { FlowModel } from '../models/flowModel';
|
|
15
15
|
import { RunJSContextRegistry } from '../runjs-context/registry';
|
|
16
16
|
import { setupRunJSContexts } from '../runjs-context/setup';
|
|
17
|
+
import { createViewScopedEngine } from '../ViewScopedFlowEngine';
|
|
18
|
+
import { DATA_SOURCE_DIRTY_EVENT } from '../views/viewEvents';
|
|
17
19
|
|
|
18
20
|
describe('FlowContext properties and methods', () => {
|
|
19
21
|
it('should return static property value', () => {
|
|
@@ -177,6 +179,49 @@ describe('FlowContext properties and methods', () => {
|
|
|
177
179
|
});
|
|
178
180
|
});
|
|
179
181
|
|
|
182
|
+
it('should expose current role as a top-level variable', async () => {
|
|
183
|
+
const engine = new FlowEngine();
|
|
184
|
+
const ctx = engine.context;
|
|
185
|
+
ctx.defineProperty('api', { value: { auth: { role: 'admin' } } });
|
|
186
|
+
|
|
187
|
+
expect(ctx.role).toBe('admin');
|
|
188
|
+
await expect(ctx.resolveJsonTemplate('{{ ctx.role }}')).resolves.toBe('admin');
|
|
189
|
+
|
|
190
|
+
const roleNode = ctx.getPropertyMetaTree().find((node) => node.name === 'role');
|
|
191
|
+
expect(roleNode).toMatchObject({
|
|
192
|
+
name: 'role',
|
|
193
|
+
title: '{{t("Current role")}}',
|
|
194
|
+
paths: ['role'],
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it('should expose actual role names for union role mode', async () => {
|
|
199
|
+
const engine = new FlowEngine();
|
|
200
|
+
const ctx = engine.context;
|
|
201
|
+
ctx.defineProperty('api', { value: { auth: { role: '__union__' } } });
|
|
202
|
+
ctx.defineProperty('user', {
|
|
203
|
+
value: {
|
|
204
|
+
roles: [
|
|
205
|
+
{ name: 'admin', title: 'Admin' },
|
|
206
|
+
{ name: 'member', title: 'Member' },
|
|
207
|
+
],
|
|
208
|
+
},
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
expect(ctx.role).toEqual(['admin', 'member']);
|
|
212
|
+
await expect(ctx.resolveJsonTemplate('{{ ctx.role }}')).resolves.toEqual(['admin', 'member']);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it('should expose an empty role list for union role mode without user roles', async () => {
|
|
216
|
+
const engine = new FlowEngine();
|
|
217
|
+
const ctx = engine.context;
|
|
218
|
+
ctx.defineProperty('api', { value: { auth: { role: '__union__' } } });
|
|
219
|
+
ctx.defineProperty('user', { value: {} });
|
|
220
|
+
|
|
221
|
+
expect(ctx.role).toEqual([]);
|
|
222
|
+
await expect(ctx.resolveJsonTemplate('{{ ctx.role }}')).resolves.toEqual([]);
|
|
223
|
+
});
|
|
224
|
+
|
|
180
225
|
it('should throw sync error in get', () => {
|
|
181
226
|
const ctx = new FlowContext();
|
|
182
227
|
ctx.defineProperty('error', {
|
|
@@ -1386,6 +1431,92 @@ describe('FlowEngine context', () => {
|
|
|
1386
1431
|
expect(engine.context.appName).toBe('NocoBase');
|
|
1387
1432
|
});
|
|
1388
1433
|
|
|
1434
|
+
it('ctx.api should return a dirty-aware wrapper for static api properties', async () => {
|
|
1435
|
+
const engine = new FlowEngine();
|
|
1436
|
+
const update = vi.fn(async () => ({ data: { data: { id: 1 } } }));
|
|
1437
|
+
const api = {
|
|
1438
|
+
auth: { locale: 'zh-CN' },
|
|
1439
|
+
request: vi.fn(async () => ({ data: { ok: true } })),
|
|
1440
|
+
resource: vi.fn(() => ({ update })),
|
|
1441
|
+
};
|
|
1442
|
+
engine.context.defineProperty('api', { value: api });
|
|
1443
|
+
|
|
1444
|
+
await engine.context.api.resource('posts').update({ filterByTk: 1, values: { title: 't' } });
|
|
1445
|
+
|
|
1446
|
+
expect(update).toHaveBeenCalledTimes(1);
|
|
1447
|
+
expect(engine.getDataSourceDirtyVersion('main', 'posts')).toBe(1);
|
|
1448
|
+
expect(engine.context.api).toBe(engine.context.api);
|
|
1449
|
+
});
|
|
1450
|
+
|
|
1451
|
+
it('ctx.api should stay dirty-aware when resolved from a scoped context delegate', async () => {
|
|
1452
|
+
const root = new FlowEngine();
|
|
1453
|
+
const scoped = createViewScopedEngine(root);
|
|
1454
|
+
const update = vi.fn(async () => ({ data: { data: { id: 1 } } }));
|
|
1455
|
+
root.context.defineProperty('api', {
|
|
1456
|
+
value: {
|
|
1457
|
+
auth: { locale: 'zh-CN' },
|
|
1458
|
+
request: vi.fn(async () => ({ data: { ok: true } })),
|
|
1459
|
+
resource: vi.fn(() => ({ update })),
|
|
1460
|
+
},
|
|
1461
|
+
});
|
|
1462
|
+
|
|
1463
|
+
await scoped.context.api.resource('posts').update({ filterByTk: 1, values: { title: 't' } });
|
|
1464
|
+
|
|
1465
|
+
expect(update).toHaveBeenCalledTimes(1);
|
|
1466
|
+
expect(root.getDataSourceDirtyVersion('main', 'posts')).toBe(1);
|
|
1467
|
+
expect(scoped.context.engine.getDataSourceDirtyVersion('main', 'posts')).toBe(1);
|
|
1468
|
+
});
|
|
1469
|
+
|
|
1470
|
+
it('ctx.request should use the dirty-aware api wrapper', async () => {
|
|
1471
|
+
const engine = new FlowEngine();
|
|
1472
|
+
const request = vi.fn(async () => ({ data: { ok: true } }));
|
|
1473
|
+
engine.context.defineProperty('api', {
|
|
1474
|
+
value: {
|
|
1475
|
+
auth: { locale: 'zh-CN' },
|
|
1476
|
+
request,
|
|
1477
|
+
resource: vi.fn(),
|
|
1478
|
+
},
|
|
1479
|
+
});
|
|
1480
|
+
|
|
1481
|
+
await engine.context.request({
|
|
1482
|
+
resource: 'posts',
|
|
1483
|
+
action: 'update',
|
|
1484
|
+
headers: { 'X-Data-Source': 'analytics' },
|
|
1485
|
+
params: { filterByTk: 1 },
|
|
1486
|
+
} as any);
|
|
1487
|
+
|
|
1488
|
+
expect(request).toHaveBeenCalledTimes(1);
|
|
1489
|
+
expect(engine.getDataSourceDirtyVersion('analytics', 'posts')).toBe(1);
|
|
1490
|
+
});
|
|
1491
|
+
|
|
1492
|
+
it('ctx.request should use the caller context when resolved through a scoped delegate', async () => {
|
|
1493
|
+
const root = new FlowEngine();
|
|
1494
|
+
const scoped = createViewScopedEngine(root);
|
|
1495
|
+
const callerCtx = new FlowContext();
|
|
1496
|
+
const dirtyEvents: Array<{ dataSourceKey: string; resourceNames: string[] }> = [];
|
|
1497
|
+
const request = vi.fn(async () => ({ data: { ok: true } }));
|
|
1498
|
+
root.context.defineProperty('api', {
|
|
1499
|
+
value: {
|
|
1500
|
+
auth: { locale: 'zh-CN' },
|
|
1501
|
+
request,
|
|
1502
|
+
resource: vi.fn(),
|
|
1503
|
+
},
|
|
1504
|
+
});
|
|
1505
|
+
callerCtx.addDelegate(scoped.context);
|
|
1506
|
+
scoped.context.engine.emitter.on(DATA_SOURCE_DIRTY_EVENT, (event) => dirtyEvents.push(event));
|
|
1507
|
+
|
|
1508
|
+
await callerCtx.request({
|
|
1509
|
+
resource: 'posts',
|
|
1510
|
+
action: 'update',
|
|
1511
|
+
params: { filterByTk: 1 },
|
|
1512
|
+
} as any);
|
|
1513
|
+
|
|
1514
|
+
expect(request).toHaveBeenCalledTimes(1);
|
|
1515
|
+
expect(root.getDataSourceDirtyVersion('main', 'posts')).toBe(1);
|
|
1516
|
+
expect(scoped.context.engine.getDataSourceDirtyVersion('main', 'posts')).toBe(1);
|
|
1517
|
+
expect(dirtyEvents).toEqual([{ dataSourceKey: 'main', resourceNames: ['posts'] }]);
|
|
1518
|
+
});
|
|
1519
|
+
|
|
1389
1520
|
it('ctx.sql should resolve template variables from caller context in delegate chain', async () => {
|
|
1390
1521
|
const engine = new FlowEngine();
|
|
1391
1522
|
const request = vi.fn(async () => ({ data: { data: [] } }));
|
|
@@ -1476,6 +1607,29 @@ describe('FlowEngine context', () => {
|
|
|
1476
1607
|
await expect((engine.context as any).getVar('foo.bar')).rejects.toThrow();
|
|
1477
1608
|
});
|
|
1478
1609
|
|
|
1610
|
+
it('model.context.getVar should resolve the latest ctx.auth.user after user changes', async () => {
|
|
1611
|
+
const engine = new FlowEngine();
|
|
1612
|
+
engine.context.defineProperty('api', {
|
|
1613
|
+
value: { auth: { role: 'admin', locale: 'en-US', token: 'token-1' } },
|
|
1614
|
+
});
|
|
1615
|
+
engine.context.defineProperty('user', { value: { id: 1, nickname: 'Alice' } });
|
|
1616
|
+
|
|
1617
|
+
class AuthUserModel extends FlowModel {}
|
|
1618
|
+
engine.registerModels({ AuthUserModel });
|
|
1619
|
+
const model = engine.createModel({ use: 'AuthUserModel' });
|
|
1620
|
+
|
|
1621
|
+
expect(await model.context.getVar('ctx.auth.user.id')).toBe(1);
|
|
1622
|
+
expect(await model.context.getVar('ctx.auth.token')).toBe('token-1');
|
|
1623
|
+
|
|
1624
|
+
engine.context.api.auth.role = 'member';
|
|
1625
|
+
engine.context.api.auth.token = 'token-2';
|
|
1626
|
+
engine.context.defineProperty('user', { value: { id: 2, nickname: 'Bob' } });
|
|
1627
|
+
|
|
1628
|
+
expect(await model.context.getVar('ctx.auth.user.id')).toBe(2);
|
|
1629
|
+
expect(await model.context.getVar('ctx.auth.roleName')).toBe('member');
|
|
1630
|
+
expect(await model.context.getVar('ctx.auth.token')).toBe('token-2');
|
|
1631
|
+
});
|
|
1632
|
+
|
|
1479
1633
|
it('engine.context.runAction should resolve action from engine.getAction', async () => {
|
|
1480
1634
|
const engine = new FlowEngine();
|
|
1481
1635
|
|
|
@@ -11,6 +11,7 @@ import { describe, expect, it, vi } from 'vitest';
|
|
|
11
11
|
import { FlowEngine } from '../flowEngine';
|
|
12
12
|
import { MultiRecordResource } from '../resources/multiRecordResource';
|
|
13
13
|
import { SingleRecordResource } from '../resources/singleRecordResource';
|
|
14
|
+
import { DATA_SOURCE_DIRTY_EVENT } from '../views/viewEvents';
|
|
14
15
|
|
|
15
16
|
describe('FlowEngine dataSource dirty registry', () => {
|
|
16
17
|
it('tracks versions per dataSourceKey + resourceName', () => {
|
|
@@ -60,4 +61,54 @@ describe('FlowEngine dataSource dirty registry', () => {
|
|
|
60
61
|
// plus root collection (safety)
|
|
61
62
|
expect(markSpy).toHaveBeenCalledWith('main', 'users');
|
|
62
63
|
});
|
|
64
|
+
|
|
65
|
+
it('marks dirty once for record write helpers when using the dirty-aware context api', async () => {
|
|
66
|
+
const engine = new FlowEngine();
|
|
67
|
+
const request = vi.fn(async () => ({ data: { data: { id: 1 }, meta: {} } }));
|
|
68
|
+
const dirtyEvents: Array<{ dataSourceKey: string; resourceNames: string[] }> = [];
|
|
69
|
+
engine.context.defineProperty('api', {
|
|
70
|
+
value: {
|
|
71
|
+
auth: { locale: 'zh-CN' },
|
|
72
|
+
request,
|
|
73
|
+
resource: vi.fn(),
|
|
74
|
+
},
|
|
75
|
+
});
|
|
76
|
+
engine.emitter.on(DATA_SOURCE_DIRTY_EVENT, (event) => dirtyEvents.push(event));
|
|
77
|
+
|
|
78
|
+
const multi = engine.createResource(MultiRecordResource);
|
|
79
|
+
multi.setDataSourceKey('main').setResourceName('posts');
|
|
80
|
+
await multi.create({ title: 't' } as any, { refresh: false });
|
|
81
|
+
|
|
82
|
+
expect(request).toHaveBeenCalledTimes(1);
|
|
83
|
+
expect(engine.getDataSourceDirtyVersion('main', 'posts')).toBe(1);
|
|
84
|
+
expect(dirtyEvents).toEqual([{ dataSourceKey: 'main', resourceNames: ['posts'] }]);
|
|
85
|
+
|
|
86
|
+
const single = engine.createResource(SingleRecordResource);
|
|
87
|
+
single.setDataSourceKey('main').setResourceName('posts').setFilterByTk(1);
|
|
88
|
+
await single.save({ title: 'u' } as any, { refresh: false });
|
|
89
|
+
|
|
90
|
+
expect(request).toHaveBeenCalledTimes(2);
|
|
91
|
+
expect(engine.getDataSourceDirtyVersion('main', 'posts')).toBe(2);
|
|
92
|
+
expect(dirtyEvents).toEqual([
|
|
93
|
+
{ dataSourceKey: 'main', resourceNames: ['posts'] },
|
|
94
|
+
{ dataSourceKey: 'main', resourceNames: ['posts'] },
|
|
95
|
+
]);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('still marks dirty for direct runAction writes', async () => {
|
|
99
|
+
const engine = new FlowEngine();
|
|
100
|
+
engine.context.defineProperty('api', {
|
|
101
|
+
value: {
|
|
102
|
+
auth: { locale: 'zh-CN' },
|
|
103
|
+
request: vi.fn(async () => ({ data: { data: { id: 1 }, meta: {} } })),
|
|
104
|
+
resource: vi.fn(),
|
|
105
|
+
},
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
const multi = engine.createResource(MultiRecordResource);
|
|
109
|
+
multi.setDataSourceKey('main').setResourceName('posts');
|
|
110
|
+
await multi.runAction('create', { data: { title: 't' } });
|
|
111
|
+
|
|
112
|
+
expect(engine.getDataSourceDirtyVersion('main', 'posts')).toBe(1);
|
|
113
|
+
});
|
|
63
114
|
});
|
|
@@ -17,6 +17,17 @@ describe('FlowI18n', () => {
|
|
|
17
17
|
expect(i18n.translate("{{ t('Hello') }}")).toBe('你好');
|
|
18
18
|
});
|
|
19
19
|
|
|
20
|
+
it('keeps embedded quotes of a different type inside the key', () => {
|
|
21
|
+
// A single-quoted key whose text contains double quotes (and vice versa) must not be truncated at the first inner
|
|
22
|
+
// quote.
|
|
23
|
+
const key = 'Unlike "Post-action event", it listens for data changes.';
|
|
24
|
+
const table: Record<string, string> = { [key]: '与“操作后事件”不同,它监听数据变动。' };
|
|
25
|
+
const i18n = new FlowI18n({ i18n: { t: (k: string) => table[k] ?? k } });
|
|
26
|
+
|
|
27
|
+
expect(i18n.translate(`{{t('${key}', { ns: "workflow" })}}`)).toBe(table[key]);
|
|
28
|
+
expect(i18n.translate(`{{t("It's here", { ns: "workflow" })}}`)).toBe("It's here");
|
|
29
|
+
});
|
|
30
|
+
|
|
20
31
|
it('template compile ignores malformed options', () => {
|
|
21
32
|
const spy = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
|
|
22
33
|
const i18n = new FlowI18n({ i18n: { t: (k: string) => k } });
|
|
@@ -104,4 +104,32 @@ describe('FlowModelContext.openView - navigation enforcement', () => {
|
|
|
104
104
|
expect(child.dispatchEvent).toHaveBeenCalledTimes(1);
|
|
105
105
|
expect(child.dispatchEvent.mock.calls[0][0]).toBe('click');
|
|
106
106
|
});
|
|
107
|
+
|
|
108
|
+
it('inherits current model input args when opening an external popup', async () => {
|
|
109
|
+
const { parent, child } = setup();
|
|
110
|
+
parent['getInputArgs'] = vi.fn(() => ({
|
|
111
|
+
filterByTk: 2,
|
|
112
|
+
sourceId: 10,
|
|
113
|
+
defaultInputKeys: ['filterByTk', 'sourceId'],
|
|
114
|
+
}));
|
|
115
|
+
|
|
116
|
+
await (parent.context as any).openView('child-uid', { mode: 'dialog' });
|
|
117
|
+
|
|
118
|
+
expect(child.dispatchEvent).toHaveBeenCalledTimes(1);
|
|
119
|
+
expect(child.dispatchEvent.mock.calls[0][1]).toMatchObject({
|
|
120
|
+
mode: 'dialog',
|
|
121
|
+
filterByTk: 2,
|
|
122
|
+
sourceId: 10,
|
|
123
|
+
});
|
|
124
|
+
expect(child.dispatchEvent.mock.calls[0][1]).not.toHaveProperty('defaultInputKeys');
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('does not debounce external popup dispatches', async () => {
|
|
128
|
+
const { parent, child } = setup();
|
|
129
|
+
|
|
130
|
+
await (parent.context as any).openView('child-uid', { mode: 'dialog', filterByTk: 1 });
|
|
131
|
+
|
|
132
|
+
expect(child.dispatchEvent).toHaveBeenCalledTimes(1);
|
|
133
|
+
expect(child.dispatchEvent.mock.calls[0][2]).toBeUndefined();
|
|
134
|
+
});
|
|
107
135
|
});
|
|
@@ -557,6 +557,78 @@ describe('FlowSettings', () => {
|
|
|
557
557
|
});
|
|
558
558
|
});
|
|
559
559
|
|
|
560
|
+
describe('Dynamic Flow Source Providers', () => {
|
|
561
|
+
test('should return current model as the default dynamic flow source', async () => {
|
|
562
|
+
const model = new FlowModel({ uid: 'source-model', flowEngine: engine });
|
|
563
|
+
|
|
564
|
+
const sources = await flowSettings.getDynamicFlowSources(model);
|
|
565
|
+
|
|
566
|
+
expect(sources).toHaveLength(1);
|
|
567
|
+
expect(sources[0].key).toBe('self');
|
|
568
|
+
expect(sources[0].label).toBe('Current block');
|
|
569
|
+
expect(sources[0].model).toBe(model);
|
|
570
|
+
});
|
|
571
|
+
|
|
572
|
+
test('should register, resolve, and dispose dynamic flow source providers', async () => {
|
|
573
|
+
const model = new FlowModel({ uid: 'source-model', flowEngine: engine });
|
|
574
|
+
const target = new FlowModel({ uid: 'target-model', flowEngine: engine });
|
|
575
|
+
|
|
576
|
+
const dispose = flowSettings.registerDynamicFlowSourceProvider({
|
|
577
|
+
key: 'test-provider',
|
|
578
|
+
visible: (m) => m.uid === model.uid,
|
|
579
|
+
getSources: () => [{ key: 'target', label: 'Target model', model: target, sort: 10 }],
|
|
580
|
+
});
|
|
581
|
+
|
|
582
|
+
expect(flowSettings.hasDynamicFlowSourceProvider(model)).toBe(true);
|
|
583
|
+
expect(flowSettings.hasDynamicFlowSourceProvider(target)).toBe(false);
|
|
584
|
+
|
|
585
|
+
const sources = await flowSettings.getDynamicFlowSources(model);
|
|
586
|
+
expect(sources.map((source) => source.key)).toEqual(['self', 'target']);
|
|
587
|
+
|
|
588
|
+
dispose();
|
|
589
|
+
|
|
590
|
+
expect(flowSettings.hasDynamicFlowSourceProvider(model)).toBe(false);
|
|
591
|
+
await expect(flowSettings.getDynamicFlowSources(model)).resolves.toHaveLength(1);
|
|
592
|
+
});
|
|
593
|
+
|
|
594
|
+
test('should skip duplicate dynamic flow source keys and model uids', async () => {
|
|
595
|
+
const model = new FlowModel({ uid: 'source-model', flowEngine: engine });
|
|
596
|
+
const target = new FlowModel({ uid: 'target-model', flowEngine: engine });
|
|
597
|
+
|
|
598
|
+
flowSettings.registerDynamicFlowSourceProvider({
|
|
599
|
+
key: 'test-provider',
|
|
600
|
+
getSources: () => [
|
|
601
|
+
{ key: 'target', label: 'Target model', model: target },
|
|
602
|
+
{ key: 'target', label: 'Duplicate key', model: new FlowModel({ uid: 'other-model', flowEngine: engine }) },
|
|
603
|
+
{ key: 'duplicate-uid', label: 'Duplicate uid', model: target },
|
|
604
|
+
],
|
|
605
|
+
});
|
|
606
|
+
|
|
607
|
+
const sources = await flowSettings.getDynamicFlowSources(model);
|
|
608
|
+
|
|
609
|
+
expect(sources.map((source) => source.key)).toEqual(['self', 'target']);
|
|
610
|
+
});
|
|
611
|
+
|
|
612
|
+
test('should ignore failing dynamic flow source providers', async () => {
|
|
613
|
+
const model = new FlowModel({ uid: 'source-model', flowEngine: engine });
|
|
614
|
+
|
|
615
|
+
flowSettings.registerDynamicFlowSourceProvider({
|
|
616
|
+
key: 'failing-provider',
|
|
617
|
+
getSources: () => {
|
|
618
|
+
throw new Error('provider failed');
|
|
619
|
+
},
|
|
620
|
+
});
|
|
621
|
+
|
|
622
|
+
const sources = await flowSettings.getDynamicFlowSources(model);
|
|
623
|
+
|
|
624
|
+
expect(sources.map((source) => source.key)).toEqual(['self']);
|
|
625
|
+
expect(consoleSpy.warn).toHaveBeenCalledWith(
|
|
626
|
+
"FlowSettings: Dynamic flow source provider 'failing-provider' failed.",
|
|
627
|
+
expect.any(Error),
|
|
628
|
+
);
|
|
629
|
+
});
|
|
630
|
+
});
|
|
631
|
+
|
|
560
632
|
describe('Step Settings Dialog', () => {
|
|
561
633
|
test('should call openStepSettingsDialog with correct parameters', async () => {
|
|
562
634
|
const { openStepSettingsDialog } = await import('../components/settings/wrappers/contextual/StepSettingsDialog');
|
|
@@ -8,9 +8,9 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { describe, it, expect, beforeAll, vi } from 'vitest';
|
|
11
|
-
import { FlowEngineContext, FlowRunJSContext } from '../flowContext';
|
|
11
|
+
import { FlowContext, FlowEngineContext, FlowRunJSContext } from '../flowContext';
|
|
12
|
+
import { RUNJS_OPEN_VIEW_ROUTE_STATE } from '../utils/openViewRouteState';
|
|
12
13
|
import { FlowEngine } from '../flowEngine';
|
|
13
|
-
import { FlowContext } from '../flowContext';
|
|
14
14
|
import { setupRunJSContexts } from '../runjs-context/setup';
|
|
15
15
|
import { createJSRunnerWithVersion } from '..';
|
|
16
16
|
import { RunJSContextRegistry } from '../runjs-context/registry';
|
|
@@ -267,6 +267,19 @@ describe('RunJS Runtime Features', () => {
|
|
|
267
267
|
expect(runCtx.libs.dayjs).toBeDefined();
|
|
268
268
|
expect(runCtx.libs.antdIcons).toBeDefined();
|
|
269
269
|
});
|
|
270
|
+
|
|
271
|
+
it('should mark ctx.openView calls with route state only when RunJS passes display overrides', async () => {
|
|
272
|
+
const parentCtx = new FlowContext();
|
|
273
|
+
const openView = vi.fn(async () => undefined);
|
|
274
|
+
parentCtx.defineMethod('openView', openView);
|
|
275
|
+
|
|
276
|
+
const runCtx = new FlowRunJSContext(parentCtx);
|
|
277
|
+
await (runCtx as any).openView('popup', { mode: 'dialog', size: 'large' });
|
|
278
|
+
await (runCtx as any).openView('popup', { filterByTk: 1 });
|
|
279
|
+
|
|
280
|
+
expect(openView.mock.calls[0][1][RUNJS_OPEN_VIEW_ROUTE_STATE]).toEqual({ mode: 'dialog', size: 'large' });
|
|
281
|
+
expect(Object.prototype.hasOwnProperty.call(openView.mock.calls[1][1], RUNJS_OPEN_VIEW_ROUTE_STATE)).toBe(false);
|
|
282
|
+
});
|
|
270
283
|
});
|
|
271
284
|
|
|
272
285
|
describe('Actual code execution', () => {
|
|
@@ -340,9 +340,9 @@ describe('ViewScopedFlowEngine', () => {
|
|
|
340
340
|
const repository = new DirtyPageRepository();
|
|
341
341
|
root.setModelRepository(repository);
|
|
342
342
|
|
|
343
|
-
class ParentModel extends FlowModel {}
|
|
344
|
-
class PageModel extends FlowModel {}
|
|
345
343
|
class BlockModel extends FlowModel {}
|
|
344
|
+
class PageModel extends FlowModel<{ parent?: FlowModel; subModels: { items: BlockModel[] } }> {}
|
|
345
|
+
class ParentModel extends FlowModel<{ parent?: FlowModel; subModels: { page?: PageModel } }> {}
|
|
346
346
|
root.registerModels({ ParentModel, PageModel, BlockModel });
|
|
347
347
|
|
|
348
348
|
const parent = root.createModel<ParentModel>({ use: 'ParentModel', uid: 'popup-action' });
|
|
@@ -357,7 +357,10 @@ describe('ViewScopedFlowEngine', () => {
|
|
|
357
357
|
items: [{ use: 'BlockModel', uid: 'stale-block' }],
|
|
358
358
|
},
|
|
359
359
|
});
|
|
360
|
-
const staleBlock = stalePage.findSubModel('items'
|
|
360
|
+
const staleBlock = stalePage.findSubModel('items', (item) => item.uid === 'stale-block');
|
|
361
|
+
if (!staleBlock) {
|
|
362
|
+
throw new Error('Expected stale block to be loaded');
|
|
363
|
+
}
|
|
361
364
|
parent.setSubModel('page', stalePage);
|
|
362
365
|
oldScoped.unlinkFromStack();
|
|
363
366
|
|
|
@@ -372,7 +375,7 @@ describe('ViewScopedFlowEngine', () => {
|
|
|
372
375
|
},
|
|
373
376
|
};
|
|
374
377
|
|
|
375
|
-
root.flowSettings.enable();
|
|
378
|
+
await root.flowSettings.enable();
|
|
376
379
|
await staleBlock.saveStepParams();
|
|
377
380
|
root.flowSettings.disable();
|
|
378
381
|
repository.findOneCalls = 0;
|
|
@@ -388,8 +391,8 @@ describe('ViewScopedFlowEngine', () => {
|
|
|
388
391
|
|
|
389
392
|
expect(repository.findOneCalls).toBe(1);
|
|
390
393
|
expect(loaded).not.toBe(stalePage);
|
|
391
|
-
expect(
|
|
392
|
-
expect(loaded?.mapSubModels('items'
|
|
394
|
+
expect(parent.subModels.page).toBe(loaded);
|
|
395
|
+
expect(loaded?.mapSubModels('items', (item) => item.uid)).toEqual(['fresh-block']);
|
|
393
396
|
|
|
394
397
|
repository.findOneCalls = 0;
|
|
395
398
|
const nextRuntimeScoped = createViewScopedEngine(root);
|
|
@@ -405,6 +408,69 @@ describe('ViewScopedFlowEngine', () => {
|
|
|
405
408
|
expect(loadedAgain?.uid).toBe('popup-page');
|
|
406
409
|
});
|
|
407
410
|
|
|
411
|
+
it('reloads a page after it was loaded in flow settings mode', async () => {
|
|
412
|
+
const root = new FlowEngine();
|
|
413
|
+
const repository = new DirtyPageRepository();
|
|
414
|
+
root.setModelRepository(repository);
|
|
415
|
+
|
|
416
|
+
class ParentModel extends FlowModel {}
|
|
417
|
+
class PageModel extends FlowModel {}
|
|
418
|
+
class BlockModel extends FlowModel {}
|
|
419
|
+
root.registerModels({ ParentModel, PageModel, BlockModel });
|
|
420
|
+
|
|
421
|
+
const parent = root.createModel<ParentModel>({ use: 'ParentModel', uid: 'settings-popup-action' });
|
|
422
|
+
repository.data = {
|
|
423
|
+
use: 'PageModel',
|
|
424
|
+
uid: 'settings-popup-page',
|
|
425
|
+
parentId: parent.uid,
|
|
426
|
+
subKey: 'page',
|
|
427
|
+
subType: 'object',
|
|
428
|
+
subModels: {
|
|
429
|
+
items: [{ use: 'BlockModel', uid: 'stale-settings-block' }],
|
|
430
|
+
},
|
|
431
|
+
};
|
|
432
|
+
|
|
433
|
+
await root.flowSettings.enable();
|
|
434
|
+
const designScoped = createViewScopedEngine(root);
|
|
435
|
+
const designLoaded = await designScoped.loadOrCreateModel<PageModel>({
|
|
436
|
+
async: true,
|
|
437
|
+
parentId: parent.uid,
|
|
438
|
+
subKey: 'page',
|
|
439
|
+
subType: 'object',
|
|
440
|
+
use: 'PageModel',
|
|
441
|
+
});
|
|
442
|
+
expect(repository.findOneCalls).toBe(1);
|
|
443
|
+
expect(designLoaded?.mapSubModels('items', (item) => item.uid)).toEqual(['stale-settings-block']);
|
|
444
|
+
designScoped.unlinkFromStack();
|
|
445
|
+
|
|
446
|
+
repository.data = {
|
|
447
|
+
use: 'PageModel',
|
|
448
|
+
uid: 'settings-popup-page',
|
|
449
|
+
parentId: parent.uid,
|
|
450
|
+
subKey: 'page',
|
|
451
|
+
subType: 'object',
|
|
452
|
+
subModels: {
|
|
453
|
+
items: [{ use: 'BlockModel', uid: 'fresh-settings-block' }],
|
|
454
|
+
},
|
|
455
|
+
};
|
|
456
|
+
root.flowSettings.disable();
|
|
457
|
+
repository.findOneCalls = 0;
|
|
458
|
+
|
|
459
|
+
const runtimeScoped = createViewScopedEngine(root);
|
|
460
|
+
const runtimeLoaded = await runtimeScoped.loadOrCreateModel<PageModel>({
|
|
461
|
+
async: true,
|
|
462
|
+
parentId: parent.uid,
|
|
463
|
+
subKey: 'page',
|
|
464
|
+
subType: 'object',
|
|
465
|
+
use: 'PageModel',
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
expect(repository.findOneCalls).toBe(1);
|
|
469
|
+
expect(runtimeLoaded).not.toBe(designLoaded);
|
|
470
|
+
expect(parent.subModels.page).toBe(runtimeLoaded);
|
|
471
|
+
expect(runtimeLoaded?.mapSubModels('items', (item) => item.uid)).toEqual(['fresh-settings-block']);
|
|
472
|
+
});
|
|
473
|
+
|
|
408
474
|
it('does not bypass loaded page cache after a non-config save', async () => {
|
|
409
475
|
const root = new FlowEngine();
|
|
410
476
|
const repository = new DirtyPageRepository();
|
package/src/acl/Acl.tsx
CHANGED
|
@@ -16,7 +16,7 @@ interface CheckOptions {
|
|
|
16
16
|
actionName: string;
|
|
17
17
|
fields?: string[];
|
|
18
18
|
recordPkValue?: string | number;
|
|
19
|
-
allowedActions
|
|
19
|
+
allowedActions?: Record<string, Array<string | number>>;
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
export class ACL {
|
|
@@ -149,6 +149,41 @@ export class ACL {
|
|
|
149
149
|
return allowed;
|
|
150
150
|
}
|
|
151
151
|
|
|
152
|
+
can(options: CheckOptions): boolean {
|
|
153
|
+
const { allowAll } = this.data;
|
|
154
|
+
if (allowAll) {
|
|
155
|
+
return true;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const { actionName, allowedActions, recordPkValue } = options;
|
|
159
|
+
const hasRecordPkValue = recordPkValue !== undefined && recordPkValue !== null;
|
|
160
|
+
const recordPermission =
|
|
161
|
+
hasRecordPkValue && allowedActions ? this.verifyScope(actionName, recordPkValue, allowedActions) : null;
|
|
162
|
+
if (hasRecordPkValue && allowedActions && recordPermission !== true) {
|
|
163
|
+
return false;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const params = this.parseAction(options);
|
|
167
|
+
if (!params) {
|
|
168
|
+
return false;
|
|
169
|
+
}
|
|
170
|
+
if (!_.isEmpty(params.filter) && recordPermission !== true) {
|
|
171
|
+
return false;
|
|
172
|
+
}
|
|
173
|
+
if (!options.fields?.length) {
|
|
174
|
+
return true;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const allowedFields: string[] = []
|
|
178
|
+
.concat(params.whitelist || [])
|
|
179
|
+
.concat(params.fields || [])
|
|
180
|
+
.concat(params.appends || []);
|
|
181
|
+
if (!allowedFields.length) {
|
|
182
|
+
return true;
|
|
183
|
+
}
|
|
184
|
+
return options.fields.every((field) => allowedFields.includes(field));
|
|
185
|
+
}
|
|
186
|
+
|
|
152
187
|
async aclCheck(options: CheckOptions): Promise<boolean> {
|
|
153
188
|
// await this.load();
|
|
154
189
|
const { allowAll } = this.data;
|
|
@@ -71,6 +71,76 @@ describe('ACL', () => {
|
|
|
71
71
|
expect(notOk).toBe(false);
|
|
72
72
|
});
|
|
73
73
|
|
|
74
|
+
it('checks record update scope before field permission', () => {
|
|
75
|
+
const payload = {
|
|
76
|
+
data: {
|
|
77
|
+
allowAll: false,
|
|
78
|
+
actionAlias: {},
|
|
79
|
+
resources: ['posts'],
|
|
80
|
+
actions: { 'posts:update': { whitelist: ['title'] } },
|
|
81
|
+
strategy: { actions: [] },
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
const engine = makeEngine(payload);
|
|
85
|
+
const acl = new ACL(engine);
|
|
86
|
+
acl.setData(payload.data);
|
|
87
|
+
|
|
88
|
+
const options = {
|
|
89
|
+
dataSourceKey: 'main',
|
|
90
|
+
resourceName: 'posts',
|
|
91
|
+
actionName: 'update',
|
|
92
|
+
allowedActions: {
|
|
93
|
+
update: [1],
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
expect(acl.can({ ...options, recordPkValue: 1, fields: ['title'] })).toBe(true);
|
|
98
|
+
expect(acl.can({ ...options, recordPkValue: 2, fields: ['title'] })).toBe(false);
|
|
99
|
+
expect(acl.can({ ...options, recordPkValue: 1, fields: ['body'] })).toBe(false);
|
|
100
|
+
expect(acl.can({ ...options, recordPkValue: 0, fields: ['title'] })).toBe(false);
|
|
101
|
+
expect(
|
|
102
|
+
acl.can({
|
|
103
|
+
dataSourceKey: 'main',
|
|
104
|
+
resourceName: 'posts',
|
|
105
|
+
actionName: 'update',
|
|
106
|
+
fields: ['title'],
|
|
107
|
+
}),
|
|
108
|
+
).toBe(true);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('allows every field when a scoped action has no field restriction', () => {
|
|
112
|
+
const payload = {
|
|
113
|
+
data: {
|
|
114
|
+
allowAll: false,
|
|
115
|
+
actionAlias: {},
|
|
116
|
+
resources: ['posts'],
|
|
117
|
+
actions: {
|
|
118
|
+
'posts:update': {
|
|
119
|
+
filter: { createdById: '{{ ctx.state.currentUser.id }}' },
|
|
120
|
+
},
|
|
121
|
+
},
|
|
122
|
+
strategy: { actions: [] },
|
|
123
|
+
},
|
|
124
|
+
};
|
|
125
|
+
const engine = makeEngine(payload);
|
|
126
|
+
const acl = new ACL(engine);
|
|
127
|
+
acl.setData(payload.data);
|
|
128
|
+
|
|
129
|
+
const options = {
|
|
130
|
+
dataSourceKey: 'main',
|
|
131
|
+
resourceName: 'posts',
|
|
132
|
+
actionName: 'update',
|
|
133
|
+
allowedActions: {
|
|
134
|
+
update: [1],
|
|
135
|
+
},
|
|
136
|
+
fields: ['title'],
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
expect(acl.can({ ...options, recordPkValue: 1 })).toBe(true);
|
|
140
|
+
expect(acl.can({ ...options, recordPkValue: 2 })).toBe(false);
|
|
141
|
+
expect(acl.can({ ...options, allowedActions: undefined, recordPkValue: undefined })).toBe(false);
|
|
142
|
+
});
|
|
143
|
+
|
|
74
144
|
it('reloads permissions when auth token changes', async () => {
|
|
75
145
|
const payload1 = {
|
|
76
146
|
data: {
|