@nocobase/flow-engine 2.1.0-alpha.4 → 2.1.0-alpha.40

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 (194) hide show
  1. package/LICENSE +201 -661
  2. package/README.md +79 -10
  3. package/lib/JSRunner.d.ts +10 -1
  4. package/lib/JSRunner.js +50 -5
  5. package/lib/ViewScopedFlowEngine.js +5 -1
  6. package/lib/components/FieldModelRenderer.js +2 -2
  7. package/lib/components/FlowModelRenderer.d.ts +3 -1
  8. package/lib/components/FlowModelRenderer.js +12 -6
  9. package/lib/components/FormItem.d.ts +6 -0
  10. package/lib/components/FormItem.js +11 -3
  11. package/lib/components/MobilePopup.js +6 -5
  12. package/lib/components/dnd/gridDragPlanner.d.ts +59 -2
  13. package/lib/components/dnd/gridDragPlanner.js +613 -21
  14. package/lib/components/dnd/index.d.ts +31 -2
  15. package/lib/components/dnd/index.js +244 -23
  16. package/lib/components/settings/wrappers/component/SelectWithTitle.d.ts +2 -1
  17. package/lib/components/settings/wrappers/component/SelectWithTitle.js +14 -12
  18. package/lib/components/settings/wrappers/contextual/DefaultSettingsIcon.d.ts +3 -0
  19. package/lib/components/settings/wrappers/contextual/DefaultSettingsIcon.js +68 -10
  20. package/lib/components/settings/wrappers/contextual/FlowsFloatContextMenu.d.ts +23 -43
  21. package/lib/components/settings/wrappers/contextual/FlowsFloatContextMenu.js +352 -295
  22. package/lib/components/settings/wrappers/contextual/StepSettingsDialog.js +16 -2
  23. package/lib/components/settings/wrappers/contextual/useFloatToolbarPortal.d.ts +36 -0
  24. package/lib/components/settings/wrappers/contextual/useFloatToolbarPortal.js +274 -0
  25. package/lib/components/settings/wrappers/contextual/useFloatToolbarVisibility.d.ts +30 -0
  26. package/lib/components/settings/wrappers/contextual/useFloatToolbarVisibility.js +315 -0
  27. package/lib/components/subModel/AddSubModelButton.js +27 -1
  28. package/lib/components/subModel/LazyDropdown.js +96 -39
  29. package/lib/components/subModel/index.d.ts +1 -0
  30. package/lib/components/subModel/index.js +19 -0
  31. package/lib/components/subModel/utils.d.ts +1 -1
  32. package/lib/components/subModel/utils.js +9 -3
  33. package/lib/components/variables/VariableHybridInput.d.ts +27 -0
  34. package/lib/components/variables/VariableHybridInput.js +499 -0
  35. package/lib/components/variables/index.d.ts +2 -0
  36. package/lib/components/variables/index.js +3 -0
  37. package/lib/data-source/index.d.ts +75 -0
  38. package/lib/data-source/index.js +247 -5
  39. package/lib/executor/FlowExecutor.js +32 -9
  40. package/lib/flow-registry/DetachedFlowRegistry.d.ts +21 -0
  41. package/lib/flow-registry/DetachedFlowRegistry.js +80 -0
  42. package/lib/flow-registry/index.d.ts +1 -0
  43. package/lib/flow-registry/index.js +3 -1
  44. package/lib/flowContext.d.ts +3 -0
  45. package/lib/flowContext.js +43 -1
  46. package/lib/flowEngine.d.ts +151 -1
  47. package/lib/flowEngine.js +389 -15
  48. package/lib/flowI18n.js +2 -1
  49. package/lib/flowSettings.d.ts +14 -6
  50. package/lib/flowSettings.js +34 -6
  51. package/lib/index.d.ts +2 -0
  52. package/lib/index.js +7 -0
  53. package/lib/lazy-helper.d.ts +14 -0
  54. package/lib/lazy-helper.js +71 -0
  55. package/lib/locale/en-US.json +1 -0
  56. package/lib/locale/index.d.ts +2 -0
  57. package/lib/locale/zh-CN.json +1 -0
  58. package/lib/models/DisplayItemModel.d.ts +1 -1
  59. package/lib/models/EditableItemModel.d.ts +1 -1
  60. package/lib/models/FilterableItemModel.d.ts +1 -1
  61. package/lib/models/flowModel.d.ts +13 -10
  62. package/lib/models/flowModel.js +78 -18
  63. package/lib/provider.js +38 -23
  64. package/lib/reactive/observer.js +46 -16
  65. package/lib/runjs-context/registry.d.ts +1 -1
  66. package/lib/runjs-context/setup.js +20 -12
  67. package/lib/runjs-context/snippets/index.js +13 -2
  68. package/lib/runjs-context/snippets/scene/detail/set-field-style.snippet.d.ts +11 -0
  69. package/lib/runjs-context/snippets/scene/detail/set-field-style.snippet.js +50 -0
  70. package/lib/runjs-context/snippets/scene/table/set-cell-style.snippet.d.ts +11 -0
  71. package/lib/runjs-context/snippets/scene/table/set-cell-style.snippet.js +54 -0
  72. package/lib/scheduler/ModelOperationScheduler.d.ts +5 -1
  73. package/lib/scheduler/ModelOperationScheduler.js +3 -2
  74. package/lib/types.d.ts +50 -2
  75. package/lib/types.js +1 -0
  76. package/lib/utils/createCollectionContextMeta.js +6 -2
  77. package/lib/utils/index.d.ts +3 -2
  78. package/lib/utils/index.js +7 -0
  79. package/lib/utils/parsePathnameToViewParams.js +1 -1
  80. package/lib/utils/randomId.d.ts +39 -0
  81. package/lib/utils/randomId.js +45 -0
  82. package/lib/utils/runjsTemplateCompat.js +1 -1
  83. package/lib/utils/runjsValue.js +41 -11
  84. package/lib/utils/schema-utils.d.ts +7 -1
  85. package/lib/utils/schema-utils.js +19 -0
  86. package/lib/views/FlowView.d.ts +7 -1
  87. package/lib/views/FlowView.js +11 -1
  88. package/lib/views/PageComponent.js +8 -6
  89. package/lib/views/ViewNavigation.js +6 -2
  90. package/lib/views/runViewBeforeClose.d.ts +10 -0
  91. package/lib/views/runViewBeforeClose.js +45 -0
  92. package/lib/views/useDialog.d.ts +2 -1
  93. package/lib/views/useDialog.js +20 -3
  94. package/lib/views/useDrawer.d.ts +2 -1
  95. package/lib/views/useDrawer.js +20 -3
  96. package/lib/views/usePage.d.ts +5 -11
  97. package/lib/views/usePage.js +302 -144
  98. package/package.json +6 -5
  99. package/src/JSRunner.ts +68 -4
  100. package/src/ViewScopedFlowEngine.ts +4 -0
  101. package/src/__tests__/JSRunner.test.ts +27 -1
  102. package/src/__tests__/flow-engine.test.ts +166 -0
  103. package/src/__tests__/flowContext.test.ts +82 -1
  104. package/src/__tests__/flowEngine.modelLoaders.test.ts +245 -0
  105. package/src/__tests__/flowSettings.test.ts +94 -15
  106. package/src/__tests__/objectVariable.test.ts +24 -0
  107. package/src/__tests__/provider.test.tsx +24 -2
  108. package/src/__tests__/renderHiddenInConfig.test.tsx +6 -6
  109. package/src/__tests__/runjsContext.test.ts +16 -0
  110. package/src/__tests__/runjsContextRuntime.test.ts +2 -0
  111. package/src/__tests__/runjsPreprocessDefault.test.ts +23 -0
  112. package/src/__tests__/runjsSnippets.test.ts +21 -0
  113. package/src/__tests__/viewScopedFlowEngine.test.ts +3 -3
  114. package/src/components/FieldModelRenderer.tsx +2 -1
  115. package/src/components/FlowModelRenderer.tsx +18 -6
  116. package/src/components/FormItem.tsx +7 -1
  117. package/src/components/MobilePopup.tsx +4 -2
  118. package/src/components/__tests__/FlowModelRenderer.test.tsx +65 -2
  119. package/src/components/__tests__/FormItem.test.tsx +25 -0
  120. package/src/components/__tests__/dnd.test.ts +44 -0
  121. package/src/components/__tests__/flow-model-render-error-fallback.test.tsx +20 -10
  122. package/src/components/__tests__/gridDragPlanner.test.ts +558 -3
  123. package/src/components/dnd/__tests__/DndProvider.test.tsx +98 -0
  124. package/src/components/dnd/gridDragPlanner.ts +758 -19
  125. package/src/components/dnd/index.tsx +305 -28
  126. package/src/components/settings/wrappers/component/SelectWithTitle.tsx +21 -9
  127. package/src/components/settings/wrappers/contextual/DefaultSettingsIcon.tsx +88 -10
  128. package/src/components/settings/wrappers/contextual/FlowsFloatContextMenu.tsx +487 -440
  129. package/src/components/settings/wrappers/contextual/StepSettingsDialog.tsx +18 -2
  130. package/src/components/settings/wrappers/contextual/__tests__/DefaultSettingsIcon.test.tsx +189 -3
  131. package/src/components/settings/wrappers/contextual/__tests__/FlowsFloatContextMenu.test.tsx +778 -0
  132. package/src/components/settings/wrappers/contextual/useFloatToolbarPortal.ts +360 -0
  133. package/src/components/settings/wrappers/contextual/useFloatToolbarVisibility.ts +361 -0
  134. package/src/components/subModel/AddSubModelButton.tsx +32 -2
  135. package/src/components/subModel/LazyDropdown.tsx +107 -43
  136. package/src/components/subModel/__tests__/AddSubModelButton.test.tsx +319 -36
  137. package/src/components/subModel/__tests__/utils.test.ts +24 -0
  138. package/src/components/subModel/index.ts +1 -0
  139. package/src/components/subModel/utils.ts +7 -1
  140. package/src/components/variables/VariableHybridInput.tsx +531 -0
  141. package/src/components/variables/index.ts +2 -0
  142. package/src/data-source/__tests__/collection.test.ts +41 -2
  143. package/src/data-source/__tests__/index.test.ts +68 -1
  144. package/src/data-source/index.ts +304 -6
  145. package/src/executor/FlowExecutor.ts +35 -10
  146. package/src/executor/__tests__/flowExecutor.test.ts +57 -0
  147. package/src/flow-registry/DetachedFlowRegistry.ts +46 -0
  148. package/src/flow-registry/__tests__/detachedFlowRegistry.test.ts +47 -0
  149. package/src/flow-registry/index.ts +1 -0
  150. package/src/flowContext.ts +47 -3
  151. package/src/flowEngine.ts +445 -11
  152. package/src/flowI18n.ts +2 -1
  153. package/src/flowSettings.ts +40 -6
  154. package/src/index.ts +2 -0
  155. package/src/lazy-helper.tsx +57 -0
  156. package/src/locale/en-US.json +1 -0
  157. package/src/locale/zh-CN.json +1 -0
  158. package/src/models/DisplayItemModel.tsx +1 -1
  159. package/src/models/EditableItemModel.tsx +1 -1
  160. package/src/models/FilterableItemModel.tsx +1 -1
  161. package/src/models/__tests__/dispatchEvent.when.test.ts +214 -0
  162. package/src/models/__tests__/flowModel.test.ts +47 -3
  163. package/src/models/flowModel.tsx +119 -33
  164. package/src/provider.tsx +41 -25
  165. package/src/reactive/__tests__/observer.test.tsx +82 -0
  166. package/src/reactive/observer.tsx +87 -25
  167. package/src/runjs-context/registry.ts +1 -1
  168. package/src/runjs-context/setup.ts +22 -12
  169. package/src/runjs-context/snippets/index.ts +12 -1
  170. package/src/runjs-context/snippets/scene/detail/set-field-style.snippet.ts +30 -0
  171. package/src/runjs-context/snippets/scene/table/set-cell-style.snippet.ts +34 -0
  172. package/src/scheduler/ModelOperationScheduler.ts +14 -3
  173. package/src/types.ts +62 -0
  174. package/src/utils/__tests__/createCollectionContextMeta.test.ts +48 -0
  175. package/src/utils/__tests__/parsePathnameToViewParams.test.ts +7 -0
  176. package/src/utils/__tests__/runjsValue.test.ts +11 -0
  177. package/src/utils/__tests__/utils.test.ts +62 -0
  178. package/src/utils/createCollectionContextMeta.ts +6 -2
  179. package/src/utils/index.ts +5 -1
  180. package/src/utils/parsePathnameToViewParams.ts +2 -2
  181. package/src/utils/randomId.ts +48 -0
  182. package/src/utils/runjsTemplateCompat.ts +1 -1
  183. package/src/utils/runjsValue.ts +50 -11
  184. package/src/utils/schema-utils.ts +30 -1
  185. package/src/views/FlowView.tsx +22 -2
  186. package/src/views/PageComponent.tsx +7 -4
  187. package/src/views/ViewNavigation.ts +6 -2
  188. package/src/views/__tests__/FlowView.usePage.test.tsx +243 -3
  189. package/src/views/__tests__/runViewBeforeClose.test.ts +30 -0
  190. package/src/views/__tests__/useDialog.closeDestroy.test.tsx +13 -12
  191. package/src/views/runViewBeforeClose.ts +19 -0
  192. package/src/views/useDialog.tsx +25 -3
  193. package/src/views/useDrawer.tsx +25 -3
  194. package/src/views/usePage.tsx +365 -179
@@ -26,6 +26,7 @@ function setupEngineWithCollections() {
26
26
  fields: [
27
27
  { name: 'id', type: 'integer', interface: 'number' },
28
28
  { name: 'name', type: 'string', interface: 'text' },
29
+ { name: 'rawUserPayload', type: 'json', filterable: true },
29
30
  ],
30
31
  });
31
32
  ds.addCollection({
@@ -41,6 +42,8 @@ function setupEngineWithCollections() {
41
42
  filterTargetKey: 'id',
42
43
  fields: [
43
44
  { name: 'title', type: 'string', interface: 'text' },
45
+ { name: 'internalName', type: 'string', interface: 'text' },
46
+ { name: 'rawPostPayload', type: 'json', filterable: true },
44
47
  { name: 'author', type: 'belongsTo', target: 'users', interface: 'm2o' },
45
48
  { name: 'tags', type: 'belongsToMany', target: 'tags', interface: 'm2m' },
46
49
  ],
@@ -91,6 +94,27 @@ describe('objectVariable utilities', () => {
91
94
  });
92
95
  });
93
96
 
97
+ it('createAssociationAwareObjectMetaFactory should hide fields without interface from object variable meta', async () => {
98
+ const { collection } = setupEngineWithCollections();
99
+ const obj = { title: 'hello', internalName: 'internal', rawPostPayload: { secret: true }, author: 1 };
100
+ const metaFactory = createAssociationAwareObjectMetaFactory(
101
+ () => collection,
102
+ 'Current object',
103
+ () => obj,
104
+ );
105
+
106
+ const meta = await metaFactory();
107
+ const props = await (meta?.properties as any)?.();
108
+ const authorFields = await props?.author?.properties?.();
109
+
110
+ expect(props).toHaveProperty('title');
111
+ expect(props).toHaveProperty('internalName');
112
+ expect(props).toHaveProperty('author');
113
+ expect(props).not.toHaveProperty('rawPostPayload');
114
+ expect(authorFields).toHaveProperty('name');
115
+ expect(authorFields).not.toHaveProperty('rawUserPayload');
116
+ });
117
+
94
118
  it('integrates with FlowContext.resolveJsonTemplate to call variables:resolve with flattened contextParams', async () => {
95
119
  const { engine, collection } = setupEngineWithCollections();
96
120
  const obj = { author: 1 };
@@ -9,9 +9,10 @@
9
9
 
10
10
  import React from 'react';
11
11
  import { describe, expect, it } from 'vitest';
12
- import { renderHook } from '@testing-library/react';
12
+ import { render, renderHook } from '@testing-library/react';
13
+ import { App, ConfigProvider, theme } from 'antd';
13
14
  import { FlowEngine } from '../flowEngine';
14
- import { FlowEngineProvider, useFlowEngine } from '../provider';
15
+ import { FlowEngineGlobalsContextProvider, FlowEngineProvider, useFlowEngine } from '../provider';
15
16
 
16
17
  describe('FlowEngineProvider/useFlowEngine', () => {
17
18
  it('returns engine within provider', () => {
@@ -20,4 +21,25 @@ describe('FlowEngineProvider/useFlowEngine', () => {
20
21
  const { result } = renderHook(() => useFlowEngine(), { wrapper });
21
22
  expect(result.current).toBe(engine);
22
23
  });
24
+
25
+ it('registers isDarkTheme within globals provider before first child render', () => {
26
+ const engine = new FlowEngine();
27
+ const reads: boolean[] = [];
28
+ const Reader = () => {
29
+ reads.push(engine.context.isDarkTheme);
30
+ return null;
31
+ };
32
+ const wrapper = ({ children }: any) => (
33
+ <ConfigProvider theme={{ algorithm: theme.darkAlgorithm }}>
34
+ <App>
35
+ <FlowEngineProvider engine={engine}>
36
+ <FlowEngineGlobalsContextProvider>{children}</FlowEngineGlobalsContextProvider>
37
+ </FlowEngineProvider>
38
+ </App>
39
+ </ConfigProvider>
40
+ );
41
+ render(<Reader />, { wrapper });
42
+ expect(reads[0]).toBe(true);
43
+ expect(engine.context.isDarkTheme).toBe(true);
44
+ });
23
45
  });
@@ -14,7 +14,7 @@ import { FlowEngine } from '../flowEngine';
14
14
  import { FlowModel, ModelRenderMode } from '../models/flowModel';
15
15
 
16
16
  describe('FlowModel.renderHiddenInConfig', () => {
17
- it('renders via renderHiddenInConfig when hidden and config enabled (React element mode, mounted)', () => {
17
+ it('renders via renderHiddenInConfig when hidden and config enabled (React element mode, mounted)', async () => {
18
18
  class ElemModel extends FlowModel {
19
19
  render() {
20
20
  return <div data-testid="content">Content</div>;
@@ -28,14 +28,14 @@ describe('FlowModel.renderHiddenInConfig', () => {
28
28
  const model = new ElemModel({ uid: 'elem-1', flowEngine: engine });
29
29
 
30
30
  // runtime hidden => mounted result should be empty (no content/hidden)
31
- engine.flowSettings.disable();
31
+ await engine.flowSettings.disable();
32
32
  model.setHidden(true);
33
33
  const { container, unmount, rerender } = render(model.render() as React.ReactElement);
34
34
  expect(screen.queryByTestId('content')).toBeNull();
35
35
  expect(screen.queryByTestId('hidden')).toBeNull();
36
36
 
37
37
  // config enabled + hidden => should show renderHiddenInConfig result
38
- engine.flowSettings.enable();
38
+ await engine.flowSettings.enable();
39
39
  rerender(model.render() as React.ReactElement);
40
40
  expect(screen.getByTestId('hidden').textContent).toBe('HiddenViaAPI');
41
41
 
@@ -46,7 +46,7 @@ describe('FlowModel.renderHiddenInConfig', () => {
46
46
  unmount();
47
47
  cleanup();
48
48
  });
49
- it('returns a render function when hidden and config enabled (RenderFunction mode)', () => {
49
+ it('returns a render function when hidden and config enabled (RenderFunction mode)', async () => {
50
50
  class FuncModel extends FlowModel {
51
51
  static override renderMode = ModelRenderMode.RenderFunction;
52
52
  render() {
@@ -63,13 +63,13 @@ describe('FlowModel.renderHiddenInConfig', () => {
63
63
  const model = engine.createModel({ use: 'FuncModel' }) as FuncModel;
64
64
 
65
65
  // runtime hidden => null
66
- engine.flowSettings.disable();
66
+ await engine.flowSettings.disable();
67
67
  model.setHidden(true);
68
68
  const runtimeHidden = model.render();
69
69
  expect(runtimeHidden).toBeNull();
70
70
 
71
71
  // config enabled + hidden => renderHiddenInConfig (function)
72
- engine.flowSettings.enable();
72
+ await engine.flowSettings.enable();
73
73
  const cfgHidden = model.render();
74
74
  expect(typeof cfgHidden).toBe('function');
75
75
  const cellNode = (cfgHidden as any)();
@@ -29,11 +29,16 @@ describe('flowRunJSContext registry and doc', () => {
29
29
  expect(RunJSContextRegistry['resolve']('v1' as any, '*')).toBeTruthy();
30
30
  });
31
31
 
32
+ it('should register v2 mapping', () => {
33
+ expect(RunJSContextRegistry['resolve']('v2' as any, '*')).toBeTruthy();
34
+ });
35
+
32
36
  it('should register all context types', () => {
33
37
  const contextTypes = [
34
38
  'JSBlockModel',
35
39
  'JSFieldModel',
36
40
  'JSItemModel',
41
+ 'JSItemActionModel',
37
42
  'JSColumnModel',
38
43
  'FormJSFieldItemModel',
39
44
  'JSRecordActionModel',
@@ -44,12 +49,22 @@ describe('flowRunJSContext registry and doc', () => {
44
49
  const ctor = RunJSContextRegistry['resolve']('v1' as any, modelClass);
45
50
  expect(ctor).toBeTruthy();
46
51
  });
52
+
53
+ contextTypes.forEach((modelClass) => {
54
+ const ctor = RunJSContextRegistry['resolve']('v2' as any, modelClass);
55
+ expect(ctor).toBeTruthy();
56
+ });
47
57
  });
48
58
 
49
59
  it('should expose scene metadata for contexts', () => {
50
60
  expect(getRunJSScenesForModel('JSBlockModel', 'v1')).toEqual(['block']);
51
61
  expect(getRunJSScenesForModel('JSFieldModel', 'v1')).toEqual(['detail']);
62
+ expect(getRunJSScenesForModel('JSItemActionModel', 'v1')).toEqual(['table']);
63
+ expect(getRunJSScenesForModel('JSBlockModel', 'v2')).toEqual(['block']);
64
+ expect(getRunJSScenesForModel('JSFieldModel', 'v2')).toEqual(['detail']);
65
+ expect(getRunJSScenesForModel('JSItemActionModel', 'v2')).toEqual(['table']);
52
66
  expect(getRunJSScenesForModel('UnknownModel', 'v1')).toEqual([]);
67
+ expect(getRunJSScenesForModel('UnknownModel', 'v2')).toEqual([]);
53
68
  });
54
69
 
55
70
  it('should only execute once (idempotent)', async () => {
@@ -175,6 +190,7 @@ describe('flowRunJSContext registry and doc', () => {
175
190
  const ctx = new FlowContext();
176
191
  ctx.defineProperty('model', { value: { constructor: { name: 'JSColumnModel' } } });
177
192
  expect(getRunJSScenesForContext(ctx as any, { version: 'v1' })).toEqual(['table']);
193
+ expect(getRunJSScenesForContext(ctx as any, { version: 'v2' })).toEqual(['table']);
178
194
  });
179
195
 
180
196
  it('JSBlockModel context should have element property in doc', () => {
@@ -186,6 +186,7 @@ describe('RunJS Context Runtime Behavior', () => {
186
186
  'JSBlockModel',
187
187
  'JSFieldModel',
188
188
  'JSItemModel',
189
+ 'JSItemActionModel',
189
190
  'JSColumnModel',
190
191
  'FormJSFieldItemModel',
191
192
  'JSRecordActionModel',
@@ -237,6 +238,7 @@ describe('RunJS Context Runtime Behavior', () => {
237
238
  'JSBlockModel',
238
239
  'JSFieldModel',
239
240
  'JSItemModel',
241
+ 'JSItemActionModel',
240
242
  'JSColumnModel',
241
243
  'FormJSFieldItemModel',
242
244
  'JSRecordActionModel',
@@ -36,6 +36,29 @@ describe('ctx.runjs preprocessTemplates default', () => {
36
36
  expect(r.value).toBe('{{ctx.user.id}}');
37
37
  });
38
38
 
39
+ it('disables template preprocess by default for version v2', async () => {
40
+ const engine = new FlowEngine();
41
+ const ctx = engine.context as any;
42
+ ctx.defineProperty('user', { value: { id: 123 } });
43
+
44
+ const r = await ctx.runjs('return "{{ctx.user.id}}";', undefined, { version: 'v2' });
45
+ expect(r.success).toBe(true);
46
+ expect(r.value).toBe('{{ctx.user.id}}');
47
+ });
48
+
49
+ it('keeps explicit preprocessTemplates override higher priority than version', async () => {
50
+ const engine = new FlowEngine();
51
+ const ctx = engine.context as any;
52
+ ctx.defineProperty('user', { value: { id: 123 } });
53
+
54
+ const r = await ctx.runjs('return "{{ctx.user.id}}";', undefined, {
55
+ version: 'v2',
56
+ preprocessTemplates: true,
57
+ });
58
+ expect(r.success).toBe(true);
59
+ expect(r.value).toBe('123');
60
+ });
61
+
39
62
  it('does not double-preprocess already prepared code', async () => {
40
63
  const engine = new FlowEngine();
41
64
  const ctx = engine.context as any;
@@ -112,6 +112,27 @@ describe('RunJS Snippets', () => {
112
112
  expect(multiScene?.scenes).toEqual(expect.arrayContaining(['detail', 'table']));
113
113
  expect(multiScene?.groups).toEqual(expect.arrayContaining(['scene/detail', 'scene/table']));
114
114
  });
115
+
116
+ it('should expose new style snippets for matching contexts', async () => {
117
+ const tableSnippets = await listSnippetsForContext('JSColumnRunJSContext', 'v1', 'zh-CN');
118
+ const fieldSnippets = await listSnippetsForContext('FormJSFieldItemRunJSContext', 'v1', 'zh-CN');
119
+ const detailEventSnippets = await listSnippetsForContext('DetailsItemModel', 'v1', 'zh-CN');
120
+ const tableEventSnippets = await listSnippetsForContext('TableColumnModel', 'v1', 'zh-CN');
121
+
122
+ const tableStyle = tableSnippets.find((s) => s.ref === 'scene/table/set-cell-style');
123
+ expect(tableStyle?.name).toBe('表格字段样式设置');
124
+ expect(tableStyle?.body).toContain('ctx.model.props.onCell');
125
+ expect(tableStyle?.scenes).toEqual(['tableFieldEvent']);
126
+
127
+ const fieldStyle = fieldSnippets.find((s) => s.ref === 'scene/detail/set-field-style');
128
+ expect(fieldStyle?.name).toBe('设置表单项/详情项样式');
129
+ expect(fieldStyle?.body).toContain('ctx.model.props.style');
130
+ expect(fieldStyle?.scenes).toEqual(expect.arrayContaining(['detailFieldEvent', 'formFieldEvent']));
131
+ expect(fieldStyle?.groups).toEqual(expect.arrayContaining(['scene/detail', 'scene/form']));
132
+
133
+ expect(detailEventSnippets.some((s) => s.ref === 'scene/detail/set-field-style')).toBe(true);
134
+ expect(tableEventSnippets.some((s) => s.ref === 'scene/table/set-cell-style')).toBe(true);
135
+ });
115
136
  });
116
137
 
117
138
  describe('New snippets', () => {
@@ -256,11 +256,11 @@ describe('ViewScopedFlowEngine', () => {
256
256
 
257
257
  // Both children should return null from hydration because parent has flowSettingsEnabled
258
258
  // This is the bug fix: previously only children with their own flowSettingsEnabled would return null
259
- const result1 = (scoped as any).hydrateModelFromPreviousEngines({
259
+ const result1 = await (scoped as any).hydrateModelFromPreviousEngines({
260
260
  parentId: 'parent-with-settings',
261
261
  subKey: 'popup',
262
262
  });
263
- const result2 = (scoped as any).hydrateModelFromPreviousEngines({
263
+ const result2 = await (scoped as any).hydrateModelFromPreviousEngines({
264
264
  parentId: 'parent-with-settings',
265
265
  subKey: 'items',
266
266
  });
@@ -298,7 +298,7 @@ describe('ViewScopedFlowEngine', () => {
298
298
  const scoped = createViewScopedEngine(root);
299
299
 
300
300
  // Call the private method hydrateModelFromPreviousEngines directly
301
- const result = (scoped as any).hydrateModelFromPreviousEngines({
301
+ const result = await (scoped as any).hydrateModelFromPreviousEngines({
302
302
  parentId: 'parent-normal',
303
303
  subKey: 'content',
304
304
  });
@@ -7,7 +7,8 @@
7
7
  * For more information, please refer to: https://www.nocobase.com/agreement.
8
8
  */
9
9
 
10
- import { FlowModelRenderer, FlowModelRendererProps } from '@nocobase/flow-engine';
10
+ import type { FlowModelRendererProps } from './FlowModelRenderer';
11
+ import { FlowModelRenderer } from './FlowModelRenderer';
11
12
  import _ from 'lodash';
12
13
  import React, { useEffect, useMemo, useRef } from 'react';
13
14
 
@@ -69,7 +69,9 @@ export interface FlowModelRendererProps {
69
69
  showBackground?: boolean;
70
70
  showBorder?: boolean;
71
71
  showDragHandle?: boolean;
72
- /** 自定义工具栏样式 */
72
+ /** 是否显示事件流入口,默认 true */
73
+ showDynamicFlowsEditor?: boolean;
74
+ /** 自定义工具栏样式,`top/left/right/bottom` 会作为 portal overlay 的 inset 使用 */
73
75
  style?: React.CSSProperties;
74
76
  /**
75
77
  * @default 'inside'
@@ -112,6 +114,8 @@ const FlowModelRendererWithAutoFlows: React.FC<{
112
114
  showBackground?: boolean;
113
115
  showBorder?: boolean;
114
116
  showDragHandle?: boolean;
117
+ showDynamicFlowsEditor?: boolean;
118
+ /** `top/left/right/bottom` 会作为 portal overlay 的 inset 使用 */
115
119
  style?: React.CSSProperties;
116
120
  /**
117
121
  * @default 'inside'
@@ -126,6 +130,7 @@ const FlowModelRendererWithAutoFlows: React.FC<{
126
130
  settingsMenuLevel?: number;
127
131
  extraToolbarItems?: ToolbarItemConfig[];
128
132
  fallback?: React.ReactNode;
133
+ useCache?: boolean;
129
134
  }> = observer(
130
135
  ({
131
136
  model,
@@ -138,12 +143,12 @@ const FlowModelRendererWithAutoFlows: React.FC<{
138
143
  settingsMenuLevel,
139
144
  extraToolbarItems,
140
145
  fallback,
146
+ useCache,
141
147
  }) => {
142
148
  // hidden 占位由模型自身处理;无需在此注入
143
-
144
149
  const { loading: pending, error: autoFlowsError } = useApplyAutoFlows(model, inputArgs, {
145
150
  throwOnError: false,
146
- useCache: model.context.useCache,
151
+ useCache,
147
152
  });
148
153
  // 将错误下沉到 model 实例上,供内容层读取(类型安全的 WeakMap 存储)
149
154
  setAutoFlowError(model, autoFlowsError || null);
@@ -182,6 +187,8 @@ const FlowModelRendererCore: React.FC<{
182
187
  showBackground?: boolean;
183
188
  showBorder?: boolean;
184
189
  showDragHandle?: boolean;
190
+ showDynamicFlowsEditor?: boolean;
191
+ /** `top/left/right/bottom` 会作为 portal overlay 的 inset 使用 */
185
192
  style?: React.CSSProperties;
186
193
  /**
187
194
  * @default 'inside'
@@ -251,6 +258,7 @@ const FlowModelRendererCore: React.FC<{
251
258
  showBackground={_.isObject(showFlowSettings) ? showFlowSettings.showBackground : undefined}
252
259
  showBorder={_.isObject(showFlowSettings) ? showFlowSettings.showBorder : undefined}
253
260
  showDragHandle={_.isObject(showFlowSettings) ? showFlowSettings.showDragHandle : undefined}
261
+ showDynamicFlowsEditor={_.isObject(showFlowSettings) ? showFlowSettings.showDynamicFlowsEditor : undefined}
254
262
  settingsMenuLevel={settingsMenuLevel}
255
263
  extraToolbarItems={extraToolbarItems}
256
264
  toolbarStyle={_.isObject(showFlowSettings) ? showFlowSettings.style : undefined}
@@ -297,6 +305,7 @@ const FlowModelRendererCore: React.FC<{
297
305
  showBackground={_.isObject(showFlowSettings) ? showFlowSettings.showBackground : undefined}
298
306
  showBorder={_.isObject(showFlowSettings) ? showFlowSettings.showBorder : undefined}
299
307
  showDragHandle={_.isObject(showFlowSettings) ? showFlowSettings.showDragHandle : undefined}
308
+ showDynamicFlowsEditor={_.isObject(showFlowSettings) ? showFlowSettings.showDynamicFlowsEditor : undefined}
300
309
  settingsMenuLevel={settingsMenuLevel}
301
310
  extraToolbarItems={extraToolbarItems}
302
311
  toolbarStyle={_.isObject(showFlowSettings) ? showFlowSettings.style : undefined}
@@ -346,13 +355,15 @@ export const FlowModelRenderer: React.FC<FlowModelRendererProps> = observer(
346
355
  extraToolbarItems,
347
356
  useCache,
348
357
  }) => {
358
+ const resolvedUseCache = typeof useCache === 'boolean' ? useCache : model?.context?.useCache;
359
+
349
360
  useEffect(() => {
350
- if (model?.context) {
361
+ if (model?.context && typeof resolvedUseCache !== 'undefined') {
351
362
  model.context.defineProperty('useCache', {
352
- value: typeof useCache === 'boolean' ? useCache : model.context.useCache,
363
+ value: resolvedUseCache,
353
364
  });
354
365
  }
355
- }, [model?.context, useCache]);
366
+ }, [model?.context, resolvedUseCache]);
356
367
 
357
368
  if (!model || typeof model.render !== 'function') {
358
369
  // 可以选择渲染 null 或者一个错误/提示信息
@@ -373,6 +384,7 @@ export const FlowModelRenderer: React.FC<FlowModelRendererProps> = observer(
373
384
  settingsMenuLevel={settingsMenuLevel}
374
385
  extraToolbarItems={extraToolbarItems}
375
386
  fallback={fallback}
387
+ useCache={resolvedUseCache}
376
388
  />
377
389
  );
378
390
 
@@ -19,6 +19,9 @@ interface ExtendedFormItemProps extends FormItemProps {
19
19
  showLabel?: boolean;
20
20
  }
21
21
 
22
+ export const verticalFormItemLabelStyle = { paddingBottom: 0 };
23
+ export const formItemStyle = { marginBottom: 12 };
24
+
22
25
  const formItemPropKeys: (keyof ExtendedFormItemProps)[] = [
23
26
  'colon',
24
27
  'dependencies',
@@ -73,6 +76,8 @@ export const FormItem = ({
73
76
  });
74
77
  const { label, labelWrap, colon = true, layout } = rest;
75
78
  const effectiveLabelWrap = !layout || layout === 'vertical' ? true : labelWrap;
79
+ const labelColStyle =
80
+ layout === 'vertical' ? { width: labelWidth, ...verticalFormItemLabelStyle } : { width: labelWidth };
76
81
  const renderLabel = () => {
77
82
  if (!showLabel) return null;
78
83
  if (effectiveLabelWrap) {
@@ -118,7 +123,8 @@ export const FormItem = ({
118
123
  return (
119
124
  <Form.Item
120
125
  {...rest}
121
- labelCol={{ style: { width: labelWidth } }}
126
+ style={{ ...formItemStyle, ...rest.style }}
127
+ labelCol={{ style: labelColStyle }}
122
128
  layout={layout}
123
129
  label={renderLabel()}
124
130
  colon={false}
@@ -8,11 +8,13 @@
8
8
  */
9
9
 
10
10
  import { ConfigProvider } from 'antd';
11
- import { Popup } from 'antd-mobile';
12
11
  import React, { FC, ReactNode, useMemo } from 'react';
13
- import { CloseOutline } from 'antd-mobile-icons';
14
12
  import { useMobileActionDrawerStyle } from './MobilePopup.style';
15
13
  import { useTranslation } from 'react-i18next';
14
+ import { lazy } from '../lazy-helper';
15
+
16
+ const { Popup } = lazy(() => import('antd-mobile'), 'Popup');
17
+ const { CloseOutline } = lazy(() => import('antd-mobile-icons'), 'CloseOutline');
16
18
 
17
19
  interface MobilePopupProps {
18
20
  title?: string;
@@ -38,9 +38,8 @@ describe('FlowModelRenderer', () => {
38
38
  test('should pass useCache to useApplyAutoFlows and set it on context', async () => {
39
39
  const { unmount } = renderWithProvider(<FlowModelRenderer model={model} useCache={true} />);
40
40
 
41
- // Check if dispatchEvent was called with useCache: true
42
- // useApplyAutoFlows calls dispatchEvent('beforeRender', inputArgs, { useCache })
43
41
  await waitFor(() => {
42
+ expect(model.dispatchEvent).toHaveBeenCalledTimes(1);
44
43
  expect(model.dispatchEvent).toHaveBeenCalledWith(
45
44
  'beforeRender',
46
45
  undefined,
@@ -58,6 +57,7 @@ describe('FlowModelRenderer', () => {
58
57
  const { unmount } = renderWithProvider(<FlowModelRenderer model={model} useCache={false} />);
59
58
 
60
59
  await waitFor(() => {
60
+ expect(model.dispatchEvent).toHaveBeenCalledTimes(1);
61
61
  expect(model.dispatchEvent).toHaveBeenCalledWith(
62
62
  'beforeRender',
63
63
  undefined,
@@ -74,6 +74,7 @@ describe('FlowModelRenderer', () => {
74
74
  const { unmount } = renderWithProvider(<FlowModelRenderer model={model} />);
75
75
 
76
76
  await waitFor(() => {
77
+ expect(model.dispatchEvent).toHaveBeenCalledTimes(1);
77
78
  expect(model.dispatchEvent).toHaveBeenCalledWith(
78
79
  'beforeRender',
79
80
  undefined,
@@ -86,4 +87,66 @@ describe('FlowModelRenderer', () => {
86
87
 
87
88
  unmount();
88
89
  });
90
+
91
+ test('should clear stale beforeRender state after unmount when reusing the same model', async () => {
92
+ const statefulEngine = new FlowEngine();
93
+ const onMountSpy = vi.fn();
94
+ const onUnmountSpy = vi.fn();
95
+
96
+ class StatefulModel extends FlowModel {
97
+ render(): any {
98
+ return <div>Stateful Content</div>;
99
+ }
100
+
101
+ protected onMount(): void {
102
+ onMountSpy();
103
+ }
104
+
105
+ protected onUnmount(): void {
106
+ onUnmountSpy();
107
+ }
108
+ }
109
+
110
+ const statefulModel = new StatefulModel({
111
+ uid: 'stateful-model',
112
+ flowEngine: statefulEngine,
113
+ });
114
+ const executorSpy = vi.spyOn((statefulEngine as any).executor, 'dispatchEvent').mockResolvedValue([]);
115
+
116
+ const firstRender = renderWithProvider(<FlowModelRenderer model={statefulModel} />);
117
+ await waitFor(() => {
118
+ expect(executorSpy).toHaveBeenCalledTimes(1);
119
+ });
120
+ await waitFor(() => {
121
+ expect(onMountSpy).toHaveBeenCalledTimes(1);
122
+ });
123
+
124
+ firstRender.unmount();
125
+ await waitFor(() => {
126
+ expect(onUnmountSpy).toHaveBeenCalledTimes(1);
127
+ });
128
+
129
+ executorSpy.mockClear();
130
+ statefulModel.setStepParams('anyFlow', 'anyStep', { x: 1 });
131
+ await new Promise((resolve) => setTimeout(resolve, 150));
132
+ expect(executorSpy.mock.calls.length).toBe(0);
133
+
134
+ const secondRender = renderWithProvider(<FlowModelRenderer model={statefulModel} />);
135
+ await waitFor(() => {
136
+ expect(executorSpy).toHaveBeenCalledTimes(1);
137
+ });
138
+ await waitFor(() => {
139
+ expect(onMountSpy).toHaveBeenCalledTimes(2);
140
+ });
141
+ const [target, eventName, inputArgs, options] = executorSpy.mock.calls[0];
142
+ expect(target).toBe(statefulModel);
143
+ expect(eventName).toBe('beforeRender');
144
+ expect(inputArgs).toBeUndefined();
145
+ expect(options).toMatchObject({ useCache: true });
146
+
147
+ secondRender.unmount();
148
+ await waitFor(() => {
149
+ expect(onUnmountSpy).toHaveBeenCalledTimes(2);
150
+ });
151
+ });
89
152
  });
@@ -0,0 +1,25 @@
1
+ /**
2
+ * This file is part of the NocoBase (R) project.
3
+ * Copyright (c) 2020-2024 NocoBase Co., Ltd.
4
+ * Authors: NocoBase Team.
5
+ *
6
+ * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
7
+ * For more information, please refer to: https://www.nocobase.com/agreement.
8
+ */
9
+
10
+ import { describe, expect, it } from 'vitest';
11
+ import { formItemStyle, verticalFormItemLabelStyle } from '../FormItem';
12
+
13
+ describe('FormItem', () => {
14
+ it('keeps vertical label-to-value spacing consistent with v1', () => {
15
+ expect(verticalFormItemLabelStyle).toEqual({
16
+ paddingBottom: 0,
17
+ });
18
+ });
19
+
20
+ it('keeps spacing between form items consistent with v1', () => {
21
+ expect(formItemStyle).toEqual({
22
+ marginBottom: 12,
23
+ });
24
+ });
25
+ });
@@ -0,0 +1,44 @@
1
+ /**
2
+ * This file is part of the NocoBase (R) project.
3
+ * Copyright (c) 2020-2024 NocoBase Co., Ltd.
4
+ * Authors: NocoBase Team.
5
+ *
6
+ * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
7
+ * For more information, please refer to: https://www.nocobase.com/agreement.
8
+ */
9
+
10
+ import { describe, expect, it } from 'vitest';
11
+ import { resolveOverlayAnchorTransform } from '../dnd';
12
+
13
+ describe('resolveOverlayAnchorTransform', () => {
14
+ it('should keep the original transform when anchor point is missing', () => {
15
+ const transform = { x: 24, y: 36, scaleX: 1, scaleY: 1 };
16
+
17
+ expect(
18
+ resolveOverlayAnchorTransform({
19
+ activeId: 'menu-item-1',
20
+ active: { id: 'menu-item-1' },
21
+ transform,
22
+ activeNodeRect: { top: 80, left: 120 },
23
+ dragAnchorPoint: null,
24
+ }),
25
+ ).toEqual(transform);
26
+ });
27
+
28
+ it('should align the overlay origin to the pointer position when dragging from toolbar handle', () => {
29
+ expect(
30
+ resolveOverlayAnchorTransform({
31
+ activeId: 'menu-item-1',
32
+ active: { id: 'menu-item-1' },
33
+ transform: { x: 20, y: 30, scaleX: 1, scaleY: 1 },
34
+ activeNodeRect: { top: 200, left: 100 },
35
+ dragAnchorPoint: { x: 180, y: 260 },
36
+ }),
37
+ ).toEqual({
38
+ x: 100,
39
+ y: 90,
40
+ scaleX: 1,
41
+ scaleY: 1,
42
+ });
43
+ });
44
+ });