@nocobase/client-v2 2.2.0-beta.5 → 2.2.0-beta.6

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 (109) hide show
  1. package/es/RouteRepository.d.ts +21 -6
  2. package/es/components/form/JsonTextArea.d.ts +2 -1
  3. package/es/components/form/VariableJsonTextArea.d.ts +19 -0
  4. package/es/components/form/index.d.ts +1 -0
  5. package/es/components/index.d.ts +1 -0
  6. package/es/flow/admin-shell/admin-layout/AdminLayoutEntryGuard.d.ts +2 -0
  7. package/es/flow/admin-shell/admin-layout/AdminLayoutMenuFlowUtils.d.ts +2 -1
  8. package/es/flow/admin-shell/admin-layout/AdminLayoutMenuModels.d.ts +2 -0
  9. package/es/flow/admin-shell/admin-layout/AdminLayoutMenuUtils.d.ts +3 -2
  10. package/es/flow/admin-shell/admin-layout/AdminLayoutSlotModels.d.ts +4 -0
  11. package/es/flow/admin-shell/admin-layout/resolveAdminRouteRuntimeTarget.d.ts +6 -0
  12. package/es/flow/components/FieldAssignRulesEditor.d.ts +3 -1
  13. package/es/flow/models/base/GridModel.d.ts +1 -1
  14. package/es/flow/models/blocks/filter-form/FilterFormBlockModel.d.ts +5 -0
  15. package/es/flow/models/blocks/form/FormBlockModel.d.ts +4 -0
  16. package/es/flow/models/blocks/form/value-runtime/rules.d.ts +21 -0
  17. package/es/flow/models/blocks/form/value-runtime/runtime.d.ts +21 -0
  18. package/es/flow/models/blocks/form/value-runtime/types.d.ts +2 -2
  19. package/es/flow-compat/FieldValidation.d.ts +2 -1
  20. package/es/index.d.ts +2 -1
  21. package/es/index.mjs +143 -117
  22. package/lib/index.js +148 -122
  23. package/package.json +7 -7
  24. package/src/RouteRepository.ts +126 -24
  25. package/src/__tests__/RouteRepository.test.ts +216 -0
  26. package/src/__tests__/exports.test.ts +16 -0
  27. package/src/__tests__/settings-center.test.tsx +33 -0
  28. package/src/components/form/JsonTextArea.tsx +21 -11
  29. package/src/components/form/VariableJsonTextArea.tsx +175 -0
  30. package/src/components/form/__tests__/JsonTextArea.test.tsx +43 -0
  31. package/src/components/form/__tests__/VariableJsonTextArea.test.tsx +86 -0
  32. package/src/components/form/index.tsx +1 -0
  33. package/src/components/index.ts +1 -0
  34. package/src/flow/__tests__/FlowRoute.test.tsx +443 -5
  35. package/src/flow/actions/__tests__/fieldLinkageRules.scopeDepth.test.ts +150 -1
  36. package/src/flow/actions/__tests__/linkageAssignField.legacy.test.ts +135 -0
  37. package/src/flow/actions/__tests__/linkageRules.hiddenSync.restore.test.ts +19 -5
  38. package/src/flow/actions/__tests__/linkageRules.subFormSetFieldProps.test.ts +92 -0
  39. package/src/flow/actions/customVariable.tsx +1 -2
  40. package/src/flow/actions/linkageRules.tsx +122 -34
  41. package/src/flow/actions/runjs.tsx +2 -14
  42. package/src/flow/actions/validation.tsx +62 -30
  43. package/src/flow/admin-shell/admin-layout/AdminLayoutComponent.tsx +33 -10
  44. package/src/flow/admin-shell/admin-layout/AdminLayoutEntryGuard.test.tsx +310 -0
  45. package/src/flow/admin-shell/admin-layout/AdminLayoutEntryGuard.tsx +44 -8
  46. package/src/flow/admin-shell/admin-layout/AdminLayoutMenuFlowUtils.ts +5 -3
  47. package/src/flow/admin-shell/admin-layout/AdminLayoutMenuModels.tsx +61 -9
  48. package/src/flow/admin-shell/admin-layout/AdminLayoutMenuUtils.tsx +71 -8
  49. package/src/flow/admin-shell/admin-layout/AdminLayoutModel.tsx +1 -1
  50. package/src/flow/admin-shell/admin-layout/AdminLayoutSlotModels.tsx +21 -8
  51. package/src/flow/admin-shell/admin-layout/__tests__/AdminLayoutComponent.test.tsx +188 -0
  52. package/src/flow/admin-shell/admin-layout/__tests__/AdminLayoutMenuModels.test.ts +99 -0
  53. package/src/flow/admin-shell/admin-layout/__tests__/TopbarActionsBar.test.tsx +166 -2
  54. package/src/flow/admin-shell/admin-layout/resolveAdminRouteRuntimeTarget.test.ts +20 -0
  55. package/src/flow/admin-shell/admin-layout/resolveAdminRouteRuntimeTarget.ts +33 -5
  56. package/src/flow/components/DefaultValue.tsx +1 -2
  57. package/src/flow/components/FieldAssignRulesEditor.tsx +62 -12
  58. package/src/flow/components/FlowRoute.tsx +56 -11
  59. package/src/flow/components/__tests__/FieldAssignRulesEditor.test.tsx +81 -4
  60. package/src/flow/components/code-editor/__tests__/useCodeRunner.test.tsx +2 -17
  61. package/src/flow/components/code-editor/hooks/useCodeRunner.ts +2 -14
  62. package/src/flow/components/code-editor/runjsDiagnostics.ts +8 -45
  63. package/src/flow/models/actions/JSActionModel.tsx +2 -7
  64. package/src/flow/models/actions/JSCollectionActionModel.tsx +2 -7
  65. package/src/flow/models/actions/JSItemActionModel.tsx +2 -7
  66. package/src/flow/models/actions/JSRecordActionModel.tsx +2 -7
  67. package/src/flow/models/base/ActionModel.tsx +12 -1
  68. package/src/flow/models/base/GridModel.tsx +38 -7
  69. package/src/flow/models/base/PageModel/PageModel.tsx +4 -1
  70. package/src/flow/models/base/PageModel/__tests__/PageModel.test.ts +28 -4
  71. package/src/flow/models/base/__tests__/ActionModel.test.ts +83 -0
  72. package/src/flow/models/base/__tests__/GridModel.dragSnapshotContainer.test.ts +239 -1
  73. package/src/flow/models/base/__tests__/transformRowsToSingleColumn.test.ts +48 -0
  74. package/src/flow/models/blocks/filter-form/FilterFormBlockModel.tsx +31 -3
  75. package/src/flow/models/blocks/filter-form/FilterFormJSActionModel.tsx +2 -7
  76. package/src/flow/models/blocks/filter-form/__tests__/defaultValues.wiring.test.ts +77 -0
  77. package/src/flow/models/blocks/form/EditFormModel.tsx +1 -0
  78. package/src/flow/models/blocks/form/FormBlockModel.tsx +7 -0
  79. package/src/flow/models/blocks/form/JSFormActionModel.tsx +2 -7
  80. package/src/flow/models/blocks/form/__tests__/FormBlockModel.test.tsx +17 -0
  81. package/src/flow/models/blocks/form/value-runtime/__tests__/runtime.test.ts +880 -102
  82. package/src/flow/models/blocks/form/value-runtime/rules.ts +445 -13
  83. package/src/flow/models/blocks/form/value-runtime/runtime.ts +257 -13
  84. package/src/flow/models/blocks/form/value-runtime/types.ts +2 -2
  85. package/src/flow/models/blocks/js-block/JSBlock.tsx +2 -7
  86. package/src/flow/models/blocks/table/JSColumnModel.tsx +1 -9
  87. package/src/flow/models/blocks/table/TableActionsColumnModel.tsx +1 -1
  88. package/src/flow/models/blocks/table/TableBlockModel.tsx +13 -3
  89. package/src/flow/models/blocks/table/__tests__/TableActionsColumnModel.test.tsx +54 -0
  90. package/src/flow/models/blocks/table/__tests__/TableBlockModel.mobileSettingsButtons.test.tsx +78 -0
  91. package/src/flow/models/fields/AssociationFieldModel/AssociationFieldModel.tsx +1 -1
  92. package/src/flow/models/fields/AssociationFieldModel/PopupSubTableFieldModel/PopupSubTableFieldModel.tsx +29 -6
  93. package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/SubTableField.tsx +13 -1
  94. package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/__tests__/SubTableFieldModel.reset.test.ts +180 -0
  95. package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/index.tsx +6 -4
  96. package/src/flow/models/fields/AssociationFieldModel/__tests__/AssociationFieldModel.updateAssociation.test.ts +72 -0
  97. package/src/flow/models/fields/InputFieldModel.tsx +14 -22
  98. package/src/flow/models/fields/JSEditableFieldModel.tsx +2 -11
  99. package/src/flow/models/fields/JSFieldModel.tsx +2 -7
  100. package/src/flow/models/fields/JSItemModel.tsx +2 -14
  101. package/src/flow/models/fields/__tests__/InputFieldModel.test.tsx +17 -0
  102. package/src/flow/models/topbar/TopbarActionModel.tsx +93 -10
  103. package/src/flow-compat/FieldValidation.tsx +122 -60
  104. package/src/flow-compat/Popover.tsx +43 -4
  105. package/src/flow-compat/__tests__/Popover.test.tsx +34 -0
  106. package/src/index.ts +8 -1
  107. package/src/layout-manager/__tests__/LayoutRoute.test.tsx +41 -1
  108. package/src/settings-center/AdminSettingsLayout.tsx +3 -0
  109. package/src/settings-center/plugin-manager/index.tsx +3 -0
@@ -28,6 +28,13 @@ type FlowRouteGuardState = {
28
28
 
29
29
  export type LegacyPageBehavior = 'redirect' | 'notFound' | 'bridge';
30
30
 
31
+ type FlowRouteLayoutContext = {
32
+ authCheck?: boolean;
33
+ routePath?: string;
34
+ routeName?: string;
35
+ uid?: string;
36
+ };
37
+
31
38
  export type FlowRouteProps = {
32
39
  pageUid?: string;
33
40
  active?: boolean;
@@ -38,7 +45,7 @@ export type FlowRouteProps = {
38
45
  const getDefaultAdminLayoutModel = (flowEngine: FlowEngine) =>
39
46
  getAdminLayoutModel<AdminLayoutModel>(flowEngine, { required: true });
40
47
 
41
- const getDefaultLayoutModel = (flowEngine: FlowEngine, contextLayout?: any) => {
48
+ const getDefaultLayoutModel = (flowEngine: FlowEngine, contextLayout?: FlowRouteLayoutContext) => {
42
49
  const layout = contextLayout || flowEngine.context.layout;
43
50
 
44
51
  if (layout?.uid) {
@@ -48,7 +55,10 @@ const getDefaultLayoutModel = (flowEngine: FlowEngine, contextLayout?: any) => {
48
55
  return getDefaultAdminLayoutModel(flowEngine);
49
56
  };
50
57
 
51
- const getDefaultLegacyPageBehavior = (flowEngine: FlowEngine, contextLayout?: any): LegacyPageBehavior => {
58
+ const getDefaultLegacyPageBehavior = (
59
+ flowEngine: FlowEngine,
60
+ contextLayout?: FlowRouteLayoutContext,
61
+ ): LegacyPageBehavior => {
52
62
  const layout = contextLayout || flowEngine.context.layout;
53
63
 
54
64
  if (layout?.routeName && layout.routeName !== 'admin') {
@@ -58,6 +68,8 @@ const getDefaultLegacyPageBehavior = (flowEngine: FlowEngine, contextLayout?: an
58
68
  return 'redirect';
59
69
  };
60
70
 
71
+ const shouldRequireAccessibleRoute = (contextLayout?: FlowRouteLayoutContext) => contextLayout?.authCheck !== false;
72
+
61
73
  const hasFlowModel = async (flowEngine: FlowEngine, pageUid: string) => {
62
74
  if (flowEngine.getModel(pageUid)) {
63
75
  return true;
@@ -148,18 +160,46 @@ const BridgeFlowRoute = ({
148
160
  * @throws {Error} 当缺少 `route.params.name` 时抛出异常
149
161
  */
150
162
  const FlowRoute = (props: FlowRouteProps = {}) => {
163
+ const {
164
+ active,
165
+ getLayoutModel: getLayoutModelProp,
166
+ legacyPageBehavior: legacyPageBehaviorProp,
167
+ pageUid: pageUidProp,
168
+ } = props;
151
169
  const flowEngine = useFlowEngine();
152
- const flowContext = useFlowContext<any>();
170
+ const flowContext = useFlowContext<{ layout?: FlowRouteLayoutContext }>();
153
171
  const contextLayout = flowContext?.layout;
172
+ const propsLayoutModel = useMemo(() => getLayoutModelProp?.(flowEngine), [flowEngine, getLayoutModelProp]);
173
+ const rawRouteLayout = contextLayout || propsLayoutModel?.layout;
174
+ const routeLayoutUid = rawRouteLayout?.uid;
175
+ const routeLayoutRouteName = rawRouteLayout?.routeName;
176
+ const routeLayoutRoutePath = rawRouteLayout?.routePath;
177
+ const routeLayoutAuthCheck = rawRouteLayout?.authCheck;
178
+ const routeLayout = useMemo(() => {
179
+ if (
180
+ !routeLayoutUid &&
181
+ !routeLayoutRouteName &&
182
+ !routeLayoutRoutePath &&
183
+ typeof routeLayoutAuthCheck === 'undefined'
184
+ ) {
185
+ return undefined;
186
+ }
187
+ return {
188
+ authCheck: routeLayoutAuthCheck,
189
+ routeName: routeLayoutRouteName,
190
+ routePath: routeLayoutRoutePath,
191
+ uid: routeLayoutUid,
192
+ };
193
+ }, [routeLayoutAuthCheck, routeLayoutRouteName, routeLayoutRoutePath, routeLayoutUid]);
154
194
  const getLayoutModel = useMemo(
155
- () => props.getLayoutModel || ((engine: FlowEngine) => getDefaultLayoutModel(engine, contextLayout)),
156
- [contextLayout, props.getLayoutModel],
195
+ () => getLayoutModelProp || ((engine: FlowEngine) => getDefaultLayoutModel(engine, routeLayout)),
196
+ [getLayoutModelProp, routeLayout],
157
197
  );
158
- const legacyPageBehavior = props.legacyPageBehavior || getDefaultLegacyPageBehavior(flowEngine, contextLayout);
198
+ const legacyPageBehavior = legacyPageBehaviorProp || getDefaultLegacyPageBehavior(flowEngine, routeLayout);
159
199
  const app = useApp();
160
200
  const routeRepository = flowEngine.context.routeRepository;
161
201
  const params = useParams();
162
- const pageUid = props.pageUid || params?.name;
202
+ const pageUid = pageUidProp || params?.name;
163
203
  const skipRouteRepositoryCheck = !routeRepository;
164
204
  const [guardState, setGuardState] = useState<FlowRouteGuardState>({
165
205
  pending: true,
@@ -196,6 +236,11 @@ const FlowRoute = (props: FlowRouteProps = {}) => {
196
236
  }
197
237
 
198
238
  const route = skipRouteRepositoryCheck ? undefined : routeRepository?.getRouteBySchemaUid?.(pageUid);
239
+ if (!route && !skipRouteRepositoryCheck && shouldRequireAccessibleRoute(routeLayout)) {
240
+ setGuardState({ pending: false, allowBridge: false, notFound: true });
241
+ return;
242
+ }
243
+
199
244
  if (!route && legacyPageBehavior === 'notFound') {
200
245
  const flowModelExists = await hasFlowModel(flowEngine, pageUid);
201
246
  if (active && requestId === requestIdRef.current) {
@@ -245,12 +290,12 @@ const FlowRoute = (props: FlowRouteProps = {}) => {
245
290
  }
246
291
  };
247
292
 
248
- void run();
293
+ run();
249
294
 
250
295
  return () => {
251
296
  active = false;
252
297
  };
253
- }, [app, flowEngine, legacyPageBehavior, pageUid, routeRepository, skipRouteRepositoryCheck]);
298
+ }, [app, flowEngine, legacyPageBehavior, pageUid, routeLayout, routeRepository, skipRouteRepositoryCheck]);
254
299
 
255
300
  const content = useMemo(() => {
256
301
  if (guardState.pending) {
@@ -265,8 +310,8 @@ const FlowRoute = (props: FlowRouteProps = {}) => {
265
310
  return null;
266
311
  }
267
312
 
268
- return <BridgeFlowRoute pageUid={pageUid} active={props.active} getLayoutModel={getLayoutModel} />;
269
- }, [getLayoutModel, guardState.allowBridge, guardState.notFound, guardState.pending, pageUid, props.active]);
313
+ return <BridgeFlowRoute pageUid={pageUid} active={active} getLayoutModel={getLayoutModel} />;
314
+ }, [active, getLayoutModel, guardState.allowBridge, guardState.notFound, guardState.pending, pageUid]);
270
315
 
271
316
  return content;
272
317
  };
@@ -795,15 +795,23 @@ describe('FieldAssignRulesEditor', () => {
795
795
  );
796
796
 
797
797
  expect(screen.getByLabelText('Default value')).toBeInTheDocument();
798
+ expect(screen.getByLabelText('Override value')).toBeInTheDocument();
798
799
  expect(screen.getByLabelText('Fixed value')).toBeInTheDocument();
799
800
  expect(screen.getByLabelText('Fixed value')).toBeChecked();
800
801
  await userEvent.click(screen.getByLabelText('Default value'));
801
802
  await waitFor(() => {
802
803
  expect(onChange).toHaveBeenCalled();
803
804
  });
804
-
805
- const lastCall = onChange.mock.calls[onChange.mock.calls.length - 1]?.[0] as FieldAssignRuleItem[];
805
+ let lastCall = onChange.mock.calls[onChange.mock.calls.length - 1]?.[0] as FieldAssignRuleItem[];
806
806
  expect(lastCall?.[0]?.mode).toBe('default');
807
+
808
+ onChange.mockClear();
809
+ await userEvent.click(screen.getByLabelText('Override value'));
810
+ await waitFor(() => {
811
+ expect(onChange).toHaveBeenCalled();
812
+ });
813
+ lastCall = onChange.mock.calls[onChange.mock.calls.length - 1]?.[0] as FieldAssignRuleItem[];
814
+ expect(lastCall?.[0]?.mode).toBe('override');
807
815
  });
808
816
 
809
817
  it('shows assignment mode tooltips for each option', async () => {
@@ -821,7 +829,7 @@ describe('FieldAssignRulesEditor', () => {
821
829
  );
822
830
 
823
831
  const questionIcons = Array.from(container.querySelectorAll('.anticon-question-circle'));
824
- expect(questionIcons).toHaveLength(2);
832
+ expect(questionIcons).toHaveLength(3);
825
833
 
826
834
  await userEvent.hover(questionIcons[0] as Element);
827
835
  await waitFor(() => {
@@ -835,11 +843,80 @@ describe('FieldAssignRulesEditor', () => {
835
843
  await userEvent.unhover(questionIcons[0] as Element);
836
844
 
837
845
  await userEvent.hover(questionIcons[1] as Element);
846
+ await waitFor(() => {
847
+ expect(screen.getByText('Overrides existing values until the user edits it.')).toBeInTheDocument();
848
+ });
849
+
850
+ await userEvent.unhover(questionIcons[1] as Element);
851
+
852
+ await userEvent.hover(questionIcons[2] as Element);
838
853
  await waitFor(() => {
839
854
  expect(screen.getByText('A system-set value. Read-only.')).toBeInTheDocument();
840
855
  });
841
856
  });
842
857
 
858
+ it('honors allowed assignment modes', () => {
859
+ const value: FieldAssignRuleItem[] = [
860
+ {
861
+ key: '1',
862
+ enable: true,
863
+ targetPath: 'title',
864
+ mode: 'override',
865
+ },
866
+ ];
867
+
868
+ render(
869
+ wrap(
870
+ <FieldAssignRulesEditor
871
+ t={t}
872
+ fieldOptions={[]}
873
+ value={value}
874
+ allowedModes={['default', 'assign']}
875
+ showCondition={false}
876
+ showEnable={false}
877
+ />,
878
+ ),
879
+ );
880
+
881
+ expect(screen.getByLabelText('Default value')).toBeInTheDocument();
882
+ expect(screen.getByLabelText('Fixed value')).toBeInTheDocument();
883
+ expect(screen.queryByLabelText('Override value')).toBeNull();
884
+ expect(screen.getByLabelText('Fixed value')).toBeChecked();
885
+ });
886
+
887
+ it('normalizes disallowed assignment mode before emitting changes', async () => {
888
+ const onChange = vi.fn();
889
+ const value: FieldAssignRuleItem[] = [
890
+ {
891
+ key: '1',
892
+ enable: true,
893
+ targetPath: 'title',
894
+ mode: 'override',
895
+ },
896
+ ];
897
+
898
+ render(
899
+ wrap(
900
+ <FieldAssignRulesEditor
901
+ t={t}
902
+ fieldOptions={[]}
903
+ value={value}
904
+ onChange={onChange}
905
+ allowedModes={['default', 'assign']}
906
+ showCondition={false}
907
+ />,
908
+ ),
909
+ );
910
+
911
+ await userEvent.click(screen.getByRole('switch'));
912
+
913
+ await waitFor(() => {
914
+ expect(onChange).toHaveBeenCalled();
915
+ });
916
+ const lastCall = onChange.mock.calls[onChange.mock.calls.length - 1]?.[0] as FieldAssignRuleItem[];
917
+ expect(lastCall?.[0]?.mode).toBe('assign');
918
+ });
919
+
843
920
  it('merges extra item tree into base item while keeping order', () => {
844
921
  const base: MetaTreeNode[] = [
845
922
  { name: 'formValues', title: 'Current form', type: 'object', paths: ['formValues'] },
@@ -897,7 +974,7 @@ describe('FieldAssignRulesEditor', () => {
897
974
 
898
975
  const itemNode = merged.find((node) => node.name === 'item');
899
976
  expect(itemNode).toBeTruthy();
900
- const parent = (itemNode.children as MetaTreeNode[]).find((node) => node.name === 'parentItem');
977
+ const parent = ((itemNode?.children as MetaTreeNode[]) || []).find((node) => node.name === 'parentItem');
901
978
  expect(parent).toBeTruthy();
902
979
 
903
980
  const nestedParent = ((parent?.children as MetaTreeNode[]) || []).find((node) => node.name === 'parentItem');
@@ -20,8 +20,6 @@ import {
20
20
  FlowEngineProvider,
21
21
  FlowModelRenderer,
22
22
  ElementProxy,
23
- createSafeWindow,
24
- createSafeDocument,
25
23
  createViewScopedEngine,
26
24
  } from '@nocobase/flow-engine';
27
25
  import { JSEditableFieldModel } from '../../../models/fields/JSEditableFieldModel';
@@ -167,11 +165,7 @@ describe('useCodeRunner (beforeRender)', () => {
167
165
  const code = ctx?.inputArgs?.preview?.code || '';
168
166
  ctx.onRefReady(ctx.ref, async (el) => {
169
167
  ctx.defineProperty('element', { get: () => new ElementProxy(el as any) });
170
- await ctx.runjs(
171
- code,
172
- { window: createSafeWindow(), document: createSafeDocument() },
173
- { preprocessTemplates: true },
174
- );
168
+ await ctx.runjs(code, undefined, { preprocessTemplates: true });
175
169
  });
176
170
  },
177
171
  },
@@ -286,16 +280,7 @@ return currentUsername;
286
280
  const code = ctx?.inputArgs?.preview?.code || '';
287
281
  ctx.onRefReady(ctx.ref, async (el) => {
288
282
  ctx.defineProperty('element', { get: () => new ElementProxy(el as any) });
289
- const navigator = { userAgent: 'test' } as any;
290
- await ctx.runjs(
291
- code,
292
- {
293
- window: createSafeWindow({ navigator }),
294
- document: createSafeDocument(),
295
- navigator,
296
- },
297
- { preprocessTemplates: true },
298
- );
283
+ await ctx.runjs(code, undefined, { preprocessTemplates: true });
299
284
  });
300
285
  },
301
286
  },
@@ -9,14 +9,7 @@
9
9
 
10
10
  import { useCallback, useRef, useState } from 'react';
11
11
  import { parseErrorLineColumn, WRAPPER_PRELUDE_LINES } from '../errorHelpers';
12
- import {
13
- FlowModelContext,
14
- JSRunner,
15
- createSafeWindow,
16
- createSafeDocument,
17
- createSafeNavigator,
18
- prepareRunJsCode,
19
- } from '@nocobase/flow-engine';
12
+ import { FlowModelContext, JSRunner, prepareRunJsCode } from '@nocobase/flow-engine';
20
13
 
21
14
  export type RunLog = { level: 'log' | 'info' | 'warn' | 'error'; msg: string; line?: number; column?: number };
22
15
  type RunResult = Awaited<ReturnType<JSRunner['run']>>;
@@ -286,12 +279,7 @@ export function useCodeRunner(hostCtx: FlowModelContext, version = 'v1') {
286
279
  const eventName = typeof onDef === 'string' ? onDef : onDef?.eventName;
287
280
  if (!flow) {
288
281
  // 无可用流程(典型场景:联动规则里的 RunJS 预览),直接在当前上下文执行代码
289
- const navigator = createSafeNavigator();
290
- await directRunCtx.runjs(
291
- code,
292
- { window: createSafeWindow({ navigator }), document: createSafeDocument(), navigator },
293
- { version },
294
- );
282
+ await directRunCtx.runjs(code, undefined, { version });
295
283
  } else if (typeof eventName === 'string') {
296
284
  await m.dispatchEvent(eventName, { preview: { code } }, { sequential: true, useCache: false });
297
285
  } else if (isManual) {
@@ -20,10 +20,8 @@ import * as acornWalk from 'acorn-walk';
20
20
  import {
21
21
  JSRunner,
22
22
  FlowContext,
23
- createSafeDocument,
24
- createSafeNavigator,
25
- createSafeWindow,
26
23
  prepareRunJsCode,
24
+ RUNJS_ALLOWED_BARE_GLOBAL_NAMES,
27
25
  shouldPreprocessRunJSTemplates,
28
26
  } from '@nocobase/flow-engine';
29
27
 
@@ -604,41 +602,7 @@ function collectHeuristicIssues(code: string): RunJSIssue[] {
604
602
  return issues;
605
603
  }
606
604
 
607
- const declared = new Set<string>([
608
- // Common globals / allowed runtime context roots
609
- 'ctx',
610
- 'console',
611
- 'window',
612
- 'document',
613
- 'navigator',
614
- 'Math',
615
- 'Date',
616
- 'Array',
617
- 'Object',
618
- 'Number',
619
- 'String',
620
- 'Boolean',
621
- 'Promise',
622
- 'RegExp',
623
- 'Set',
624
- 'Map',
625
- 'WeakSet',
626
- 'WeakMap',
627
- 'JSON',
628
- 'Intl',
629
- 'URL',
630
- 'Error',
631
- 'TypeError',
632
- 'encodeURIComponent',
633
- 'decodeURIComponent',
634
- 'parseInt',
635
- 'parseFloat',
636
- 'isNaN',
637
- 'isFinite',
638
- 'undefined',
639
- 'NaN',
640
- 'Infinity',
641
- ]);
605
+ const declared = new Set<string>(RUNJS_ALLOWED_BARE_GLOBAL_NAMES);
642
606
 
643
607
  const addId = (id: any) => {
644
608
  if (id && typeof id.name === 'string') declared.add(id.name);
@@ -1036,15 +1000,14 @@ export async function diagnoseRunJS(
1036
1000
  const { consoleCapture, loggerCapture } = createLogCollectors(logs);
1037
1001
 
1038
1002
  const baseGlobals: Record<string, any> = { console: consoleCapture };
1039
- try {
1040
- if (typeof window !== 'undefined') {
1041
- const navigator = createSafeNavigator();
1003
+ if (typeof window !== 'undefined') {
1004
+ baseGlobals.window = window;
1005
+ if (typeof navigator !== 'undefined') {
1042
1006
  baseGlobals.navigator = navigator;
1043
- baseGlobals.window = createSafeWindow({ navigator });
1044
- baseGlobals.document = createSafeDocument();
1045
1007
  }
1046
- } catch (_) {
1047
- // ignore safe globals failures
1008
+ }
1009
+ if (typeof document !== 'undefined') {
1010
+ baseGlobals.document = document;
1048
1011
  }
1049
1012
 
1050
1013
  let prepared = src;
@@ -7,7 +7,7 @@
7
7
  * For more information, please refer to: https://www.nocobase.com/agreement.
8
8
  */
9
9
 
10
- import { createSafeDocument, createSafeWindow, createSafeNavigator, tExpr } from '@nocobase/flow-engine';
10
+ import { tExpr } from '@nocobase/flow-engine';
11
11
  import type { ButtonProps } from 'antd/es/button';
12
12
  import { CodeEditor } from '../../components/code-editor';
13
13
  import { ActionModel } from '../base';
@@ -72,12 +72,7 @@ ctx.message.info('Hello JS action.');
72
72
  },
73
73
  async handler(ctx, params) {
74
74
  const { code, version } = resolveRunJsParams(ctx, params);
75
- const navigator = createSafeNavigator();
76
- await ctx.runjs(
77
- code,
78
- { window: createSafeWindow({ navigator }), document: createSafeDocument(), navigator },
79
- { version },
80
- );
75
+ await ctx.runjs(code, undefined, { version });
81
76
  },
82
77
  },
83
78
  },
@@ -7,7 +7,7 @@
7
7
  * For more information, please refer to: https://www.nocobase.com/agreement.
8
8
  */
9
9
 
10
- import { createSafeDocument, createSafeWindow, createSafeNavigator, tExpr } from '@nocobase/flow-engine';
10
+ import { tExpr } from '@nocobase/flow-engine';
11
11
  import type { ButtonProps } from 'antd/es/button';
12
12
  import { CodeEditor } from '../../components/code-editor';
13
13
  import { ActionModel, ActionSceneEnum } from '../base';
@@ -78,12 +78,7 @@ if (!rows.length) {
78
78
  },
79
79
  async handler(ctx, params) {
80
80
  const { code, version } = resolveRunJsParams(ctx, params);
81
- const navigator = createSafeNavigator();
82
- await ctx.runjs(
83
- code,
84
- { window: createSafeWindow({ navigator }), document: createSafeDocument(), navigator },
85
- { version },
86
- );
81
+ await ctx.runjs(code, undefined, { version });
87
82
  },
88
83
  },
89
84
  },
@@ -7,7 +7,7 @@
7
7
  * For more information, please refer to: https://www.nocobase.com/agreement.
8
8
  */
9
9
 
10
- import { ElementProxy, createSafeDocument, createSafeNavigator, createSafeWindow, tExpr } from '@nocobase/flow-engine';
10
+ import { ElementProxy, tExpr } from '@nocobase/flow-engine';
11
11
  import React from 'react';
12
12
  import { CodeEditor } from '../../components/code-editor';
13
13
  import { ActionModel, ActionSceneEnum, CollectionActionGroupModel, RecordActionGroupModel } from '../base';
@@ -137,12 +137,7 @@ JSItemActionModel.registerFlow({
137
137
  ctx.defineProperty('element', {
138
138
  get: () => new ElementProxy(element),
139
139
  });
140
- const navigator = createSafeNavigator();
141
- await ctx.runjs(
142
- code,
143
- { window: createSafeWindow({ navigator }), document: createSafeDocument(), navigator },
144
- { version },
145
- );
140
+ await ctx.runjs(code, undefined, { version });
146
141
  });
147
142
  },
148
143
  },
@@ -7,7 +7,7 @@
7
7
  * For more information, please refer to: https://www.nocobase.com/agreement.
8
8
  */
9
9
 
10
- import { createSafeDocument, createSafeWindow, createSafeNavigator, tExpr } from '@nocobase/flow-engine';
10
+ import { tExpr } from '@nocobase/flow-engine';
11
11
  import type { ButtonProps } from 'antd/es/button';
12
12
  import { CodeEditor } from '../../components/code-editor';
13
13
  import { ActionSceneEnum, RecordActionModel } from '../base';
@@ -78,12 +78,7 @@ if (!ctx.record) {
78
78
  },
79
79
  async handler(ctx, params) {
80
80
  const { code, version } = resolveRunJsParams(ctx, params);
81
- const navigator = createSafeNavigator();
82
- await ctx.runjs(
83
- code,
84
- { window: createSafeWindow({ navigator }), document: createSafeDocument(), navigator },
85
- { version },
86
- );
81
+ await ctx.runjs(code, undefined, { version });
87
82
  },
88
83
  },
89
84
  },
@@ -18,6 +18,17 @@ import { commonConditionHandler, ConditionBuilder } from '../../components/Condi
18
18
  export { ActionModel, ActionSceneEnum, ActionWithoutPermission, type ActionSceneType } from './ActionModelCore';
19
19
  import { ActionModel } from './ActionModelCore';
20
20
 
21
+ function getMobileDefaultProps(ctx) {
22
+ const defaultProps = ctx.model.defaultProps || {};
23
+ if (ctx.isMobileLayout && defaultProps.icon) {
24
+ return {
25
+ ...defaultProps,
26
+ iconOnly: true,
27
+ };
28
+ }
29
+ return defaultProps;
30
+ }
31
+
21
32
  ActionModel.registerFlow({
22
33
  key: 'buttonSettings',
23
34
  title: tExpr('Button settings'),
@@ -80,7 +91,7 @@ ActionModel.registerFlow({
80
91
  };
81
92
  },
82
93
  defaultParams(ctx) {
83
- const defaultProps = ctx.model.defaultProps || {};
94
+ const defaultProps = getMobileDefaultProps(ctx);
84
95
  if (!ctx.model.enableEditColor) {
85
96
  return defaultProps;
86
97
  }
@@ -75,6 +75,12 @@ interface DragState {
75
75
  generatedIds: Map<string, string>;
76
76
  }
77
77
 
78
+ const MOBILE_GRID_DISABLED_DROP_SLOT_TYPES = new Set<LayoutSlot['type']>(['column-edge', 'item-edge', 'empty-column']);
79
+
80
+ const filterMobileGridDragSlots = (slots: LayoutSlot[]) => {
81
+ return slots.filter((slot) => !MOBILE_GRID_DISABLED_DROP_SLOT_TYPES.has(slot.type));
82
+ };
83
+
78
84
  const getClientPoint = (event: any): { x: number; y: number } | null => {
79
85
  if (!event) {
80
86
  return null;
@@ -625,7 +631,7 @@ export class GridModel<T extends { subModels: { items: FlowModel[] } } = Default
625
631
  return;
626
632
  }
627
633
  const snapshot = buildLayoutSnapshot({ container: this.getDragContainer() });
628
- this.dragState.slots = snapshot.slots;
634
+ this.dragState.slots = this.context.isMobileLayout ? filterMobileGridDragSlots(snapshot.slots) : snapshot.slots;
629
635
  this.dragState.containerRect = snapshot.containerRect;
630
636
  }
631
637
 
@@ -1135,7 +1141,7 @@ GridModel.registerFlow({
1135
1141
  /**
1136
1142
  * 将多列栅格 rows 转换为移动端单列 rows。
1137
1143
  * 遍历原 rows 的行与列顺序,把每个原列(过滤空白列后)变成一个新行(仅一列)。
1138
- * 行 key 使用 uid() 生成,保持插入顺序即可。
1144
+ * 行 key 使用原 rowId 与 columnIndex 生成,避免每次渲染产生随机 key 导致移动端滚动位置丢失。
1139
1145
  * @param rows 原始多列 rows
1140
1146
  * @returns 单列 rows
1141
1147
  */
@@ -1145,13 +1151,38 @@ export function transformRowsToSingleColumn(
1145
1151
  ): Record<string, string[][]> {
1146
1152
  const emptyColumnUid = options?.emptyColumnUid ?? EMPTY_COLUMN_UID;
1147
1153
  const singleColumnRows: Record<string, string[][]> = {};
1154
+
1155
+ const getAvailableRowId = (baseRowId: string) => {
1156
+ if (!singleColumnRows[baseRowId]) {
1157
+ return baseRowId;
1158
+ }
1159
+
1160
+ let duplicateIndex = 1;
1161
+ let rowId = `${baseRowId}:duplicate:${duplicateIndex}`;
1162
+ while (singleColumnRows[rowId]) {
1163
+ duplicateIndex += 1;
1164
+ rowId = `${baseRowId}:duplicate:${duplicateIndex}`;
1165
+ }
1166
+ return rowId;
1167
+ };
1168
+
1148
1169
  Object.keys(rows).forEach((rowId) => {
1149
1170
  const columns = rows[rowId];
1150
- columns.forEach((column) => {
1151
- const filtered = column.filter((id) => id !== emptyColumnUid);
1152
- if (filtered.length > 0) {
1153
- singleColumnRows[uid()] = [filtered];
1154
- }
1171
+ const effectiveColumns = columns
1172
+ .map((column, columnIndex) => ({
1173
+ columnIndex,
1174
+ items: column.filter((id) => id !== emptyColumnUid),
1175
+ }))
1176
+ .filter((column) => column.items.length > 0);
1177
+
1178
+ if (effectiveColumns.length === 1 && effectiveColumns[0].columnIndex === 0) {
1179
+ singleColumnRows[getAvailableRowId(rowId)] = [effectiveColumns[0].items];
1180
+ return;
1181
+ }
1182
+
1183
+ effectiveColumns.forEach((column) => {
1184
+ const generatedRowId = `mobile:${encodeURIComponent(rowId)}:${column.columnIndex}`;
1185
+ singleColumnRows[getAvailableRowId(generatedRowId)] = [column.items];
1155
1186
  });
1156
1187
  });
1157
1188
  return singleColumnRows;
@@ -375,12 +375,15 @@ export class PageModel extends FlowModel<PageModelStructure> {
375
375
  tabBarStyle={this.props.tabBarStyle}
376
376
  items={this.mapTabs()}
377
377
  onChange={(activeKey) => {
378
+ const previousActiveKey = this.props.tabActiveKey || this.getActiveTabKey();
378
379
  this.context.view.navigation?.changeTo?.({
379
380
  tabUid: activeKey,
380
381
  });
381
382
 
382
383
  this.invokeTabModelLifecycleMethod(activeKey, 'onActive');
383
- this.invokeTabModelLifecycleMethod(this.props.tabActiveKey, 'onInactive');
384
+ if (previousActiveKey && previousActiveKey !== activeKey) {
385
+ this.invokeTabModelLifecycleMethod(previousActiveKey, 'onInactive');
386
+ }
384
387
  this.setProps('tabActiveKey', activeKey);
385
388
  }}
386
389
  // destroyInactiveTabPane
@@ -140,13 +140,9 @@ vi.mock('@nocobase/flow-engine', () => {
140
140
  jioToJoiSchema: () => ({
141
141
  validate: (value: any) => ({ value }),
142
142
  }),
143
- createSafeWindow: () => window,
144
- createSafeDocument: () => document,
145
- createSafeNavigator: () => navigator,
146
143
  isRunJSValue: () => false,
147
144
  isVariableExpression: () => false,
148
145
  normalizeRunJSValue: (value: any) => value,
149
- runjsWithSafeGlobals: async (handler: any, ...args: any[]) => handler?.(...args),
150
146
  parseCtxDateExpression: (value: any) => value,
151
147
  setupRunJSContexts: () => undefined,
152
148
  getRunJSScenesForContext: () => [],
@@ -452,6 +448,34 @@ describe('PageModel', () => {
452
448
  marginInlineEnd: 24,
453
449
  });
454
450
  });
451
+
452
+ it('should deactivate the previous tab when navigation updates active tab synchronously', () => {
453
+ pageModel.props = { tabActiveKey: 'tab-old' } as any;
454
+ const invokeSpy = vi.spyOn(pageModel as any, 'invokeTabModelLifecycleMethod').mockImplementation(() => undefined);
455
+ const changeTo = vi.fn((params: { tabUid: string }) => {
456
+ pageModel.props.tabActiveKey = params.tabUid;
457
+ });
458
+
459
+ pageModel.context.view = {
460
+ navigation: {
461
+ viewParams: {
462
+ tabUid: 'tab-old',
463
+ },
464
+ changeTo,
465
+ },
466
+ } as any;
467
+
468
+ const result = pageModel.renderTabs() as any;
469
+ const tabsElement = result.props.children;
470
+
471
+ tabsElement.props.onChange('tab-new');
472
+
473
+ expect(changeTo).toHaveBeenCalledWith({ tabUid: 'tab-new' });
474
+ expect(invokeSpy).toHaveBeenNthCalledWith(1, 'tab-new', 'onActive');
475
+ expect(invokeSpy).toHaveBeenNthCalledWith(2, 'tab-old', 'onInactive');
476
+ expect(invokeSpy).not.toHaveBeenCalledWith('tab-new', 'onInactive');
477
+ expect(pageModel.props.tabActiveKey).toBe('tab-new');
478
+ });
455
479
  });
456
480
 
457
481
  describe('render header spacing with tabs', () => {