@nocobase/flow-engine 2.2.0-beta.1 → 2.2.0-beta.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.
Files changed (85) hide show
  1. package/lib/JSRunner.d.ts +1 -0
  2. package/lib/JSRunner.js +110 -20
  3. package/lib/components/FieldModelRenderer.js +44 -31
  4. package/lib/components/MobilePopup.js +28 -10
  5. package/lib/components/MobilePopup.style.js +27 -6
  6. package/lib/components/dnd/index.js +9 -2
  7. package/lib/components/settings/wrappers/contextual/FlowsFloatContextMenu.js +86 -32
  8. package/lib/components/settings/wrappers/contextual/useFloatToolbarVisibility.js +20 -0
  9. package/lib/flowContext.d.ts +1 -1
  10. package/lib/flowContext.js +97 -42
  11. package/lib/flowEngine.js +6 -0
  12. package/lib/flowSettings.d.ts +5 -1
  13. package/lib/flowSettings.js +70 -0
  14. package/lib/locale/en-US.json +1 -0
  15. package/lib/locale/index.d.ts +2 -0
  16. package/lib/locale/zh-CN.json +1 -0
  17. package/lib/resources/apiResource.js +2 -1
  18. package/lib/resources/baseRecordResource.js +6 -17
  19. package/lib/resources/multiRecordResource.js +13 -3
  20. package/lib/resources/singleRecordResource.js +7 -2
  21. package/lib/runjs-context/helpers.js +12 -5
  22. package/lib/types.d.ts +12 -0
  23. package/lib/utils/dataSourceDirty.d.ts +20 -0
  24. package/lib/utils/dataSourceDirty.js +139 -0
  25. package/lib/utils/dirtyAwareApiClient.d.ts +11 -0
  26. package/lib/utils/dirtyAwareApiClient.js +632 -0
  27. package/lib/utils/index.d.ts +1 -1
  28. package/lib/utils/index.js +11 -11
  29. package/lib/utils/loadedPageCache.d.ts +1 -0
  30. package/lib/utils/loadedPageCache.js +6 -0
  31. package/lib/utils/openViewRouteState.d.ts +28 -0
  32. package/lib/utils/openViewRouteState.js +125 -0
  33. package/lib/utils/parsePathnameToViewParams.d.ts +3 -0
  34. package/lib/utils/parsePathnameToViewParams.js +18 -1
  35. package/lib/utils/resolveRunJSObjectValues.js +3 -2
  36. package/lib/utils/runjsModuleLoader.js +0 -30
  37. package/lib/views/ViewNavigation.js +5 -0
  38. package/package.json +4 -4
  39. package/src/JSRunner.ts +112 -25
  40. package/src/__tests__/JSRunner.test.ts +4 -5
  41. package/src/__tests__/flowContext.test.ts +154 -0
  42. package/src/__tests__/flowEngine.dataSourceDirty.test.ts +51 -0
  43. package/src/__tests__/flowModel.openView.navigation.test.ts +28 -0
  44. package/src/__tests__/flowSettings.test.ts +72 -0
  45. package/src/__tests__/runjsRuntimeFeatures.test.ts +15 -2
  46. package/src/__tests__/viewScopedFlowEngine.test.ts +72 -6
  47. package/src/components/FieldModelRenderer.tsx +50 -36
  48. package/src/components/MobilePopup.style.ts +34 -7
  49. package/src/components/MobilePopup.tsx +30 -10
  50. package/src/components/__tests__/FieldModelRenderer.test.tsx +165 -0
  51. package/src/components/__tests__/MobilePopup.style.test.tsx +103 -0
  52. package/src/components/__tests__/MobilePopup.test.tsx +109 -0
  53. package/src/components/dnd/index.tsx +11 -2
  54. package/src/components/settings/wrappers/contextual/FlowsFloatContextMenu.tsx +105 -35
  55. package/src/components/settings/wrappers/contextual/__tests__/FlowsFloatContextMenu.test.tsx +381 -12
  56. package/src/components/settings/wrappers/contextual/useFloatToolbarVisibility.ts +28 -0
  57. package/src/flowContext.ts +104 -36
  58. package/src/flowEngine.ts +6 -0
  59. package/src/flowSettings.ts +85 -1
  60. package/src/locale/en-US.json +1 -0
  61. package/src/locale/zh-CN.json +1 -0
  62. package/src/resources/apiResource.ts +2 -1
  63. package/src/resources/baseRecordResource.ts +6 -23
  64. package/src/resources/multiRecordResource.ts +13 -3
  65. package/src/resources/singleRecordResource.ts +6 -1
  66. package/src/runjs-context/helpers.ts +12 -6
  67. package/src/types.ts +14 -0
  68. package/src/utils/__tests__/dirtyAwareApiClient.test.ts +713 -0
  69. package/src/utils/__tests__/openViewRouteState.test.ts +40 -0
  70. package/src/utils/__tests__/parsePathnameToViewParams.test.ts +36 -0
  71. package/src/utils/dataSourceDirty.ts +126 -0
  72. package/src/utils/dirtyAwareApiClient.ts +742 -0
  73. package/src/utils/index.ts +10 -9
  74. package/src/utils/loadedPageCache.ts +7 -0
  75. package/src/utils/openViewRouteState.ts +107 -0
  76. package/src/utils/parsePathnameToViewParams.ts +23 -1
  77. package/src/utils/resolveRunJSObjectValues.ts +5 -2
  78. package/src/utils/runjsModuleLoader.ts +0 -32
  79. package/src/views/ViewNavigation.ts +6 -1
  80. package/src/views/__tests__/ViewNavigation.test.ts +15 -0
  81. package/lib/utils/safeGlobals.d.ts +0 -28
  82. package/lib/utils/safeGlobals.js +0 -367
  83. package/src/utils/__tests__/runjsRequireAsyncAutoWhitelist.test.ts +0 -38
  84. package/src/utils/__tests__/safeGlobals.test.ts +0 -106
  85. 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
  });
@@ -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' as any, (item) => item.uid === 'stale-block') as FlowModel;
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((parent.subModels as any).page).toBe(loaded);
392
- expect(loaded?.mapSubModels('items' as any, (item) => item.uid)).toEqual(['fresh-block']);
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();
@@ -10,7 +10,7 @@
10
10
  import type { FlowModelRendererProps } from './FlowModelRenderer';
11
11
  import { FlowModelRenderer } from './FlowModelRenderer';
12
12
  import _ from 'lodash';
13
- import React, { useEffect, useMemo, useRef } from 'react';
13
+ import React, { useCallback, useEffect, useMemo, useRef } from 'react';
14
14
 
15
15
  const flowModelRendererPropKeys: (keyof FlowModelRendererProps)[] = [
16
16
  'model',
@@ -28,49 +28,63 @@ const flowModelRendererPropKeys: (keyof FlowModelRendererProps)[] = [
28
28
  ];
29
29
 
30
30
  export function FieldModelRenderer(props: any) {
31
- const { model, ...rest } = props;
31
+ const { model, onChange, ...rest } = props;
32
32
  const composingRef = useRef(false);
33
33
 
34
- const handleChange = (e: any) => {
35
- let val;
36
- if (e && e.target && typeof e.target.value !== 'undefined') {
37
- val = e.target.value;
38
- } else if (
39
- typeof e === 'string' ||
40
- typeof e === 'number' ||
41
- typeof e === 'boolean' ||
42
- (typeof e === 'object' && !(e instanceof Event))
43
- ) {
44
- val = e;
45
- } else {
46
- val = null;
47
- }
34
+ const handleChange = useCallback(
35
+ (e: any) => {
36
+ let val;
37
+ if (e && e.target && typeof e.target.value !== 'undefined') {
38
+ val = e.target.value;
39
+ } else if (
40
+ typeof e === 'string' ||
41
+ typeof e === 'number' ||
42
+ typeof e === 'boolean' ||
43
+ (typeof e === 'object' && !(e instanceof Event))
44
+ ) {
45
+ val = e;
46
+ } else {
47
+ val = null;
48
+ }
48
49
 
49
- model.setProps({ value: val });
50
- if (!composingRef.current) {
51
- props.onChange?.(val);
52
- }
53
- };
54
- const handleCompositionStart = () => {
50
+ const isComposing = composingRef.current || e?.nativeEvent?.isComposing;
51
+ if (isComposing) {
52
+ return;
53
+ }
54
+
55
+ model.setProps({ value: val });
56
+ if (!composingRef.current) {
57
+ onChange?.(val);
58
+ }
59
+ },
60
+ [model, onChange],
61
+ );
62
+ const handleCompositionStart = useCallback(() => {
55
63
  composingRef.current = true;
56
- };
64
+ }, []);
57
65
 
58
- const handleCompositionEnd = (e: React.CompositionEvent<HTMLInputElement>, flag = true) => {
59
- composingRef.current = false;
60
- if (flag) {
61
- props.onChange(e);
62
- }
63
- };
66
+ const handleCompositionEnd = useCallback(
67
+ (e: React.CompositionEvent<HTMLInputElement>, flag = true) => {
68
+ composingRef.current = false;
69
+ if (flag) {
70
+ handleChange(e);
71
+ }
72
+ },
73
+ [handleChange],
74
+ );
64
75
 
65
- const modelProps = {
66
- onCompositionStart: handleCompositionStart,
67
- onCompositionEnd: handleCompositionEnd,
68
- ..._.omit(rest, flowModelRendererPropKeys),
69
- onChange: handleChange,
70
- };
76
+ const modelProps = useMemo(
77
+ () => ({
78
+ onCompositionStart: handleCompositionStart,
79
+ onCompositionEnd: handleCompositionEnd,
80
+ ..._.omit(rest, flowModelRendererPropKeys),
81
+ onChange: handleChange,
82
+ }),
83
+ [handleChange, handleCompositionEnd, handleCompositionStart, rest],
84
+ );
71
85
  useEffect(() => {
72
86
  model && model.setProps(modelProps);
73
- }, [modelProps]);
87
+ }, [model, modelProps]);
74
88
 
75
89
  return <FlowModelRenderer model={model} {...rest} />;
76
90
  }