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

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 (121) hide show
  1. package/es/PluginSettingsManager.d.ts +33 -0
  2. package/es/RouteRepository.d.ts +21 -6
  3. package/es/components/form/JsonTextArea.d.ts +2 -1
  4. package/es/components/form/VariableJsonTextArea.d.ts +19 -0
  5. package/es/components/form/index.d.ts +1 -0
  6. package/es/components/index.d.ts +1 -0
  7. package/es/flow/admin-shell/admin-layout/AdminLayoutEntryGuard.d.ts +2 -0
  8. package/es/flow/admin-shell/admin-layout/AdminLayoutMenuFlowUtils.d.ts +2 -1
  9. package/es/flow/admin-shell/admin-layout/AdminLayoutMenuModels.d.ts +2 -0
  10. package/es/flow/admin-shell/admin-layout/AdminLayoutMenuUtils.d.ts +3 -2
  11. package/es/flow/admin-shell/admin-layout/AdminLayoutSlotModels.d.ts +4 -0
  12. package/es/flow/admin-shell/admin-layout/resolveAdminRouteRuntimeTarget.d.ts +6 -0
  13. package/es/flow/components/FieldAssignRulesEditor.d.ts +3 -1
  14. package/es/flow/models/base/GridModel.d.ts +1 -1
  15. package/es/flow/models/blocks/filter-form/FilterFormBlockModel.d.ts +5 -0
  16. package/es/flow/models/blocks/form/FormBlockModel.d.ts +4 -0
  17. package/es/flow/models/blocks/form/value-runtime/rules.d.ts +21 -0
  18. package/es/flow/models/blocks/form/value-runtime/runtime.d.ts +21 -0
  19. package/es/flow/models/blocks/form/value-runtime/types.d.ts +2 -2
  20. package/es/flow-compat/FieldValidation.d.ts +2 -1
  21. package/es/index.d.ts +2 -1
  22. package/es/index.mjs +145 -119
  23. package/lib/index.js +148 -122
  24. package/package.json +7 -7
  25. package/src/PluginSettingsManager.ts +53 -0
  26. package/src/RouteRepository.ts +126 -24
  27. package/src/__tests__/PluginSettingsManager.test.ts +13 -0
  28. package/src/__tests__/RouteRepository.test.ts +216 -0
  29. package/src/__tests__/exports.test.ts +16 -0
  30. package/src/__tests__/plugin-manager.test.tsx +44 -2
  31. package/src/__tests__/settings-center.test.tsx +40 -1
  32. package/src/components/form/JsonTextArea.tsx +21 -11
  33. package/src/components/form/VariableJsonTextArea.tsx +175 -0
  34. package/src/components/form/__tests__/JsonTextArea.test.tsx +43 -0
  35. package/src/components/form/__tests__/VariableJsonTextArea.test.tsx +86 -0
  36. package/src/components/form/index.tsx +1 -0
  37. package/src/components/index.ts +1 -0
  38. package/src/flow/__tests__/FlowRoute.test.tsx +443 -5
  39. package/src/flow/actions/__tests__/dataScopeFilter.test.ts +58 -0
  40. package/src/flow/actions/__tests__/fieldLinkageRules.scopeDepth.test.ts +150 -1
  41. package/src/flow/actions/__tests__/linkageAssignField.legacy.test.ts +135 -0
  42. package/src/flow/actions/__tests__/linkageRules.hiddenSync.restore.test.ts +19 -5
  43. package/src/flow/actions/__tests__/linkageRules.subFormSetFieldProps.test.ts +92 -0
  44. package/src/flow/actions/customVariable.tsx +1 -2
  45. package/src/flow/actions/dataScopeFilter.ts +10 -0
  46. package/src/flow/actions/linkageRules.tsx +122 -34
  47. package/src/flow/actions/runjs.tsx +2 -14
  48. package/src/flow/actions/validation.tsx +62 -30
  49. package/src/flow/admin-shell/admin-layout/AdminLayoutComponent.tsx +33 -10
  50. package/src/flow/admin-shell/admin-layout/AdminLayoutEntryGuard.test.tsx +310 -0
  51. package/src/flow/admin-shell/admin-layout/AdminLayoutEntryGuard.tsx +44 -8
  52. package/src/flow/admin-shell/admin-layout/AdminLayoutMenuFlowUtils.ts +5 -3
  53. package/src/flow/admin-shell/admin-layout/AdminLayoutMenuModels.tsx +61 -9
  54. package/src/flow/admin-shell/admin-layout/AdminLayoutMenuUtils.tsx +71 -8
  55. package/src/flow/admin-shell/admin-layout/AdminLayoutModel.tsx +1 -1
  56. package/src/flow/admin-shell/admin-layout/AdminLayoutSlotModels.tsx +21 -8
  57. package/src/flow/admin-shell/admin-layout/__tests__/AdminLayoutComponent.test.tsx +188 -0
  58. package/src/flow/admin-shell/admin-layout/__tests__/AdminLayoutMenuModels.test.ts +99 -0
  59. package/src/flow/admin-shell/admin-layout/__tests__/TopbarActionsBar.test.tsx +166 -2
  60. package/src/flow/admin-shell/admin-layout/resolveAdminRouteRuntimeTarget.test.ts +20 -0
  61. package/src/flow/admin-shell/admin-layout/resolveAdminRouteRuntimeTarget.ts +33 -5
  62. package/src/flow/components/DefaultValue.tsx +1 -2
  63. package/src/flow/components/FieldAssignRulesEditor.tsx +128 -21
  64. package/src/flow/components/FlowRoute.tsx +56 -11
  65. package/src/flow/components/__tests__/FieldAssignRulesEditor.test.tsx +508 -4
  66. package/src/flow/components/__tests__/fieldAssignOptions.test.ts +151 -3
  67. package/src/flow/components/code-editor/__tests__/useCodeRunner.test.tsx +2 -17
  68. package/src/flow/components/code-editor/hooks/useCodeRunner.ts +2 -14
  69. package/src/flow/components/code-editor/runjsDiagnostics.ts +8 -45
  70. package/src/flow/components/fieldAssignOptions.ts +155 -27
  71. package/src/flow/models/actions/JSActionModel.tsx +2 -7
  72. package/src/flow/models/actions/JSCollectionActionModel.tsx +2 -7
  73. package/src/flow/models/actions/JSItemActionModel.tsx +2 -7
  74. package/src/flow/models/actions/JSRecordActionModel.tsx +2 -7
  75. package/src/flow/models/base/ActionModel.tsx +12 -1
  76. package/src/flow/models/base/GridModel.tsx +38 -7
  77. package/src/flow/models/base/PageModel/PageModel.tsx +4 -1
  78. package/src/flow/models/base/PageModel/__tests__/PageModel.test.ts +28 -4
  79. package/src/flow/models/base/__tests__/ActionModel.test.ts +83 -0
  80. package/src/flow/models/base/__tests__/GridModel.dragSnapshotContainer.test.ts +239 -1
  81. package/src/flow/models/base/__tests__/transformRowsToSingleColumn.test.ts +48 -0
  82. package/src/flow/models/blocks/filter-form/FilterFormBlockModel.tsx +31 -3
  83. package/src/flow/models/blocks/filter-form/FilterFormJSActionModel.tsx +2 -7
  84. package/src/flow/models/blocks/filter-form/__tests__/defaultValues.wiring.test.ts +77 -0
  85. package/src/flow/models/blocks/form/EditFormModel.tsx +1 -0
  86. package/src/flow/models/blocks/form/FormBlockModel.tsx +7 -0
  87. package/src/flow/models/blocks/form/JSFormActionModel.tsx +2 -7
  88. package/src/flow/models/blocks/form/__tests__/FormBlockModel.test.tsx +17 -0
  89. package/src/flow/models/blocks/form/value-runtime/__tests__/runtime.test.ts +880 -102
  90. package/src/flow/models/blocks/form/value-runtime/rules.ts +445 -13
  91. package/src/flow/models/blocks/form/value-runtime/runtime.ts +257 -13
  92. package/src/flow/models/blocks/form/value-runtime/types.ts +2 -2
  93. package/src/flow/models/blocks/js-block/JSBlock.tsx +2 -7
  94. package/src/flow/models/blocks/table/JSColumnModel.tsx +1 -9
  95. package/src/flow/models/blocks/table/TableActionsColumnModel.tsx +1 -1
  96. package/src/flow/models/blocks/table/TableBlockModel.tsx +13 -3
  97. package/src/flow/models/blocks/table/__tests__/TableActionsColumnModel.test.tsx +54 -0
  98. package/src/flow/models/blocks/table/__tests__/TableBlockModel.mobileSettingsButtons.test.tsx +78 -0
  99. package/src/flow/models/fields/AssociationFieldModel/AssociationFieldModel.tsx +1 -1
  100. package/src/flow/models/fields/AssociationFieldModel/PopupSubTableFieldModel/PopupSubTableFieldModel.tsx +29 -6
  101. package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/SubTableField.tsx +13 -1
  102. package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/__tests__/SubTableFieldModel.reset.test.ts +180 -0
  103. package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/index.tsx +6 -4
  104. package/src/flow/models/fields/AssociationFieldModel/__tests__/AssociationFieldModel.updateAssociation.test.ts +72 -0
  105. package/src/flow/models/fields/InputFieldModel.tsx +14 -22
  106. package/src/flow/models/fields/JSEditableFieldModel.tsx +2 -11
  107. package/src/flow/models/fields/JSFieldModel.tsx +2 -7
  108. package/src/flow/models/fields/JSItemModel.tsx +2 -14
  109. package/src/flow/models/fields/__tests__/InputFieldModel.test.tsx +17 -0
  110. package/src/flow/models/topbar/TopbarActionModel.tsx +93 -10
  111. package/src/flow-compat/FieldValidation.tsx +122 -60
  112. package/src/flow-compat/Popover.tsx +43 -4
  113. package/src/flow-compat/__tests__/Popover.test.tsx +34 -0
  114. package/src/index.ts +8 -1
  115. package/src/layout-manager/__tests__/LayoutRoute.test.tsx +41 -1
  116. package/src/nocobase-buildin-plugin/index.tsx +0 -2
  117. package/src/settings-center/AdminSettingsLayout.tsx +3 -0
  118. package/src/settings-center/SystemSettingsPage.tsx +0 -1
  119. package/src/settings-center/plugin-manager/PluginCard.tsx +2 -2
  120. package/src/settings-center/plugin-manager/index.tsx +3 -0
  121. package/src/settings-center/utils.tsx +0 -6
@@ -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', () => {
@@ -0,0 +1,83 @@
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 { FlowEngine, FlowRuntimeContext } from '@nocobase/flow-engine';
11
+ import { describe, expect, it } from 'vitest';
12
+ import { ActionModel } from '../ActionModel';
13
+
14
+ class IconActionModel extends ActionModel {
15
+ defaultProps = {
16
+ type: 'default' as const,
17
+ title: 'Icon action',
18
+ icon: 'PlusOutlined',
19
+ };
20
+ }
21
+
22
+ class PlainActionModel extends ActionModel {
23
+ defaultProps = {
24
+ type: 'default' as const,
25
+ title: 'Plain action',
26
+ };
27
+ }
28
+
29
+ function getButtonSettingsDefaultParams(model: ActionModel) {
30
+ const defaultParams = model.getFlow('buttonSettings')?.getStep('general')?.defaultParams;
31
+ if (typeof defaultParams !== 'function') {
32
+ throw new Error('buttonSettings.general.defaultParams is unavailable.');
33
+ }
34
+ return defaultParams(new FlowRuntimeContext(model, 'buttonSettings', 'settings'));
35
+ }
36
+
37
+ describe('ActionModel button settings', () => {
38
+ it('uses icon-only buttons by default for mobile icon actions', () => {
39
+ const engine = new FlowEngine();
40
+ engine.registerModels({ IconActionModel });
41
+ const model = engine.createModel<IconActionModel>({
42
+ use: 'IconActionModel',
43
+ uid: 'mobile-icon-action',
44
+ });
45
+
46
+ model.context.defineProperty('isMobileLayout', { value: true });
47
+
48
+ expect(getButtonSettingsDefaultParams(model)).toMatchObject({
49
+ icon: 'PlusOutlined',
50
+ iconOnly: true,
51
+ });
52
+ });
53
+
54
+ it('keeps button titles visible by default outside mobile layouts', () => {
55
+ const engine = new FlowEngine();
56
+ engine.registerModels({ IconActionModel });
57
+ const model = engine.createModel<IconActionModel>({
58
+ use: 'IconActionModel',
59
+ uid: 'desktop-icon-action',
60
+ });
61
+
62
+ expect(getButtonSettingsDefaultParams(model)).toMatchObject({
63
+ icon: 'PlusOutlined',
64
+ });
65
+ expect(getButtonSettingsDefaultParams(model)).not.toHaveProperty('iconOnly', true);
66
+ });
67
+
68
+ it('does not force icon-only buttons for mobile actions without icons', () => {
69
+ const engine = new FlowEngine();
70
+ engine.registerModels({ PlainActionModel });
71
+ const model = engine.createModel<PlainActionModel>({
72
+ use: 'PlainActionModel',
73
+ uid: 'mobile-plain-action',
74
+ });
75
+
76
+ model.context.defineProperty('isMobileLayout', { value: true });
77
+
78
+ expect(getButtonSettingsDefaultParams(model)).toMatchObject({
79
+ title: 'Plain action',
80
+ });
81
+ expect(getButtonSettingsDefaultParams(model)).not.toHaveProperty('iconOnly', true);
82
+ });
83
+ });
@@ -10,7 +10,7 @@
10
10
  import { FlowEngine } from '@nocobase/flow-engine';
11
11
  import React from 'react';
12
12
  import { beforeEach, describe, expect, it, vi } from 'vitest';
13
- import { GridModel } from '../GridModel';
13
+ import { GridModel, transformRowsToSingleColumn } from '../GridModel';
14
14
 
15
15
  const createMockRect = ({ top, left, width, height }: { top: number; left: number; width: number; height: number }) => {
16
16
  return {
@@ -66,6 +66,83 @@ const createGridContainer = () => {
66
66
  return container;
67
67
  };
68
68
 
69
+ const createGridContainerWithEmptyColumn = () => {
70
+ const container = document.createElement('div');
71
+ container.setAttribute('data-grid-root', '');
72
+ const row = document.createElement('div');
73
+ row.setAttribute('data-grid-row-id', 'row-1');
74
+ container.appendChild(row);
75
+
76
+ const firstColumn = document.createElement('div');
77
+ firstColumn.setAttribute('data-grid-column-row-id', 'row-1');
78
+ firstColumn.setAttribute('data-grid-column-index', '0');
79
+ row.appendChild(firstColumn);
80
+
81
+ const item = document.createElement('div');
82
+ item.setAttribute('data-grid-item-row-id', 'row-1');
83
+ item.setAttribute('data-grid-column-index', '0');
84
+ item.setAttribute('data-grid-item-index', '0');
85
+ item.setAttribute('data-grid-item-uid', 'item-1');
86
+ firstColumn.appendChild(item);
87
+
88
+ const secondColumn = document.createElement('div');
89
+ secondColumn.setAttribute('data-grid-column-row-id', 'row-1');
90
+ secondColumn.setAttribute('data-grid-column-index', '1');
91
+ row.appendChild(secondColumn);
92
+
93
+ mockRect(container, { top: 0, left: 0, width: 480, height: 280 });
94
+ mockRect(row, { top: 20, left: 20, width: 440, height: 120 });
95
+ mockRect(firstColumn, { top: 20, left: 20, width: 220, height: 120 });
96
+ mockRect(item, { top: 30, left: 30, width: 200, height: 100 });
97
+ mockRect(secondColumn, { top: 20, left: 260, width: 200, height: 120 });
98
+ return container;
99
+ };
100
+
101
+ const createProjectedMobileGridContainer = (rows: Record<string, string[][]>) => {
102
+ const container = document.createElement('div');
103
+ container.setAttribute('data-grid-root', '');
104
+ mockRect(container, { top: 0, left: 0, width: 480, height: 320 });
105
+
106
+ let top = 20;
107
+ Object.entries(transformRowsToSingleColumn(rows)).forEach(([rowId, columns]) => {
108
+ const row = document.createElement('div');
109
+ row.setAttribute('data-grid-row-id', rowId);
110
+ container.appendChild(row);
111
+
112
+ const column = document.createElement('div');
113
+ column.setAttribute('data-grid-column-row-id', rowId);
114
+ column.setAttribute('data-grid-column-index', '0');
115
+ row.appendChild(column);
116
+
117
+ const items = columns[0] || [];
118
+ items.forEach((itemUid, itemIndex) => {
119
+ const item = document.createElement('div');
120
+ item.setAttribute('data-grid-item-row-id', rowId);
121
+ item.setAttribute('data-grid-column-index', '0');
122
+ item.setAttribute('data-grid-item-index', String(itemIndex));
123
+ item.setAttribute('data-grid-item-uid', itemUid);
124
+ column.appendChild(item);
125
+ mockRect(item, { top: top + 10 + itemIndex * 36, left: 30, width: 420, height: 32 });
126
+ });
127
+
128
+ mockRect(row, { top, left: 20, width: 440, height: 52 });
129
+ mockRect(column, { top, left: 20, width: 440, height: 52 });
130
+ top += 72;
131
+ });
132
+
133
+ return container;
134
+ };
135
+
136
+ const setMobileGridItems = (engine: FlowEngine, model: GridModel, itemUids: string[]) => {
137
+ (model as any).subModels = {
138
+ items: itemUids.map((itemUid) => engine.createModel({ use: 'FlowModel', uid: itemUid })),
139
+ };
140
+ };
141
+
142
+ const getSavedTopLevelItemOrder = (model: GridModel) => {
143
+ return Object.values(model.props.rows || {}).map((columns: string[][]) => columns[0]?.[0]);
144
+ };
145
+
69
146
  describe('GridModel drag snapshot container', () => {
70
147
  let engine: FlowEngine;
71
148
 
@@ -106,6 +183,167 @@ describe('GridModel drag snapshot container', () => {
106
183
  model.handleDragCancel({} as any);
107
184
  });
108
185
 
186
+ it('filters multi-column drop slots from mobile drag snapshots', () => {
187
+ const model = engine.createModel<GridModel>({
188
+ use: 'GridModel',
189
+ uid: 'grid-mobile-drag-slots',
190
+ props: {
191
+ rows: {
192
+ 'row-1': [['item-1'], []],
193
+ },
194
+ sizes: {
195
+ 'row-1': [12, 12],
196
+ },
197
+ },
198
+ structure: {} as any,
199
+ });
200
+ model.context.defineProperty('isMobileLayout', { value: true });
201
+
202
+ const container = createGridContainerWithEmptyColumn();
203
+ (model.gridContainerRef as any).current = container;
204
+
205
+ model.handleDragStart({
206
+ active: { id: 'item-1' },
207
+ activatorEvent: { clientX: 80, clientY: 80 },
208
+ } as any);
209
+
210
+ const slotTypes = ((model as any).dragState?.slots || []).map((slot) => slot.type);
211
+ expect(slotTypes).toContain('column');
212
+ expect(slotTypes).toContain('row-gap');
213
+ expect(slotTypes).not.toContain('column-edge');
214
+ expect(slotTypes).not.toContain('item-edge');
215
+ expect(slotTypes).not.toContain('empty-column');
216
+ model.handleDragCancel({} as any);
217
+ });
218
+
219
+ it('keeps empty-row slot available for the first mobile grid item', () => {
220
+ const model = engine.createModel<GridModel>({
221
+ use: 'GridModel',
222
+ uid: 'grid-mobile-empty-drag-slots',
223
+ props: {},
224
+ structure: {} as any,
225
+ });
226
+ model.context.defineProperty('isMobileLayout', { value: true });
227
+
228
+ const container = document.createElement('div');
229
+ mockRect(container, { top: 0, left: 0, width: 480, height: 280 });
230
+ (model.gridContainerRef as any).current = container;
231
+
232
+ model.handleDragStart({
233
+ active: { id: 'item-1' },
234
+ activatorEvent: { clientX: 80, clientY: 80 },
235
+ } as any);
236
+
237
+ expect(((model as any).dragState?.slots || []).map((slot) => slot.type)).toEqual(['empty-row']);
238
+ model.handleDragCancel({} as any);
239
+ });
240
+
241
+ it('saves mobile vertical sorting when moving the second item above the first one', () => {
242
+ const rows = {
243
+ 'row-a': [['item-a']],
244
+ 'row-b': [['item-b']],
245
+ };
246
+ const model = engine.createModel<GridModel>({
247
+ use: 'GridModel',
248
+ uid: 'grid-mobile-sort-second-above-first',
249
+ props: {
250
+ rows,
251
+ sizes: {
252
+ 'row-a': [24],
253
+ 'row-b': [24],
254
+ },
255
+ rowOrder: ['row-a', 'row-b'],
256
+ },
257
+ structure: {} as any,
258
+ });
259
+ model.context.defineProperty('isMobileLayout', { value: true });
260
+ setMobileGridItems(engine, model, ['item-a', 'item-b']);
261
+ (model as any).saveStepParams = vi.fn();
262
+
263
+ const container = createProjectedMobileGridContainer(rows);
264
+ (model.gridContainerRef as any).current = container;
265
+
266
+ model.handleDragStart({
267
+ active: { id: 'item-b' },
268
+ activatorEvent: { clientX: 240, clientY: 118 },
269
+ } as any);
270
+ model.handleDragEnd({
271
+ delta: { x: 0, y: -112 },
272
+ } as any);
273
+
274
+ expect(getSavedTopLevelItemOrder(model)).toEqual(['item-b', 'item-a']);
275
+ });
276
+
277
+ it('saves mobile vertical sorting when moving the first item below the last one', () => {
278
+ const rows = {
279
+ 'row-a': [['item-a']],
280
+ 'row-b': [['item-b']],
281
+ 'row-c': [['item-c']],
282
+ };
283
+ const model = engine.createModel<GridModel>({
284
+ use: 'GridModel',
285
+ uid: 'grid-mobile-sort-first-below-last',
286
+ props: {
287
+ rows,
288
+ sizes: {
289
+ 'row-a': [24],
290
+ 'row-b': [24],
291
+ 'row-c': [24],
292
+ },
293
+ rowOrder: ['row-a', 'row-b', 'row-c'],
294
+ },
295
+ structure: {} as any,
296
+ });
297
+ model.context.defineProperty('isMobileLayout', { value: true });
298
+ setMobileGridItems(engine, model, ['item-a', 'item-b', 'item-c']);
299
+ (model as any).saveStepParams = vi.fn();
300
+
301
+ const container = createProjectedMobileGridContainer(rows);
302
+ (model.gridContainerRef as any).current = container;
303
+
304
+ model.handleDragStart({
305
+ active: { id: 'item-a' },
306
+ activatorEvent: { clientX: 240, clientY: 46 },
307
+ } as any);
308
+ model.handleDragEnd({
309
+ delta: { x: 0, y: 184 },
310
+ } as any);
311
+
312
+ expect(getSavedTopLevelItemOrder(model)).toEqual(['item-b', 'item-c', 'item-a']);
313
+ });
314
+
315
+ it('does not filter multi-column drop slots from desktop drag snapshots', () => {
316
+ const model = engine.createModel<GridModel>({
317
+ use: 'GridModel',
318
+ uid: 'grid-desktop-drag-slots',
319
+ props: {
320
+ rows: {
321
+ 'row-1': [['item-1'], []],
322
+ },
323
+ sizes: {
324
+ 'row-1': [12, 12],
325
+ },
326
+ },
327
+ structure: {} as any,
328
+ });
329
+
330
+ const container = createGridContainerWithEmptyColumn();
331
+ (model.gridContainerRef as any).current = container;
332
+
333
+ model.handleDragStart({
334
+ active: { id: 'item-1' },
335
+ activatorEvent: { clientX: 80, clientY: 80 },
336
+ } as any);
337
+
338
+ const slotTypes = ((model as any).dragState?.slots || []).map((slot) => slot.type);
339
+ expect(slotTypes).toContain('column');
340
+ expect(slotTypes).toContain('row-gap');
341
+ expect(slotTypes).toContain('column-edge');
342
+ expect(slotTypes).toContain('item-edge');
343
+ expect(slotTypes).toContain('empty-column');
344
+ model.handleDragCancel({} as any);
345
+ });
346
+
109
347
  it('uses native pointer position instead of scroll-adjusted drag delta', () => {
110
348
  const model = engine.createModel<GridModel>({
111
349
  use: 'GridModel',
@@ -35,6 +35,36 @@ describe('transformRowsToSingleColumn', () => {
35
35
  expect(all).toEqual([['a'], ['b', 'c'], ['d'], ['e']]);
36
36
  });
37
37
 
38
+ it('should preserve row ids for ordinary single-column mobile rows', () => {
39
+ const rows = {
40
+ row_a: [['a']],
41
+ row_b: [['b']],
42
+ };
43
+
44
+ expect(transformRowsToSingleColumn(rows)).toEqual(rows);
45
+ });
46
+
47
+ it('should avoid adding another mobile prefix to ordinary single-column projected rows', () => {
48
+ const rows = {
49
+ 'mobile:row_a:0': [['a']],
50
+ };
51
+
52
+ expect(Object.keys(transformRowsToSingleColumn(rows))).toEqual(['mobile:row_a:0']);
53
+ });
54
+
55
+ it('should keep split multi-column row keys stable and unique', () => {
56
+ const rows = {
57
+ row_a: [['a'], ['b']],
58
+ };
59
+ const result = transformRowsToSingleColumn(rows);
60
+ const keys = Object.keys(result);
61
+
62
+ expect(keys).toHaveLength(2);
63
+ expect(new Set(keys).size).toBe(2);
64
+ expect(keys.every((key) => key.startsWith('mobile:'))).toBe(true);
65
+ expect(Object.values(result).map((row) => row[0])).toEqual([['a'], ['b']]);
66
+ });
67
+
38
68
  it('should skip columns that only contain EMPTY_COLUMN_UID', () => {
39
69
  const rows = buildRows([[[EMPTY_COLUMN_UID], ['a']]]);
40
70
  const result = transformRowsToSingleColumn(rows);
@@ -55,4 +85,22 @@ describe('transformRowsToSingleColumn', () => {
55
85
  const all = Object.values(result).map((r) => r[0]);
56
86
  expect(all).toEqual([['x'], ['y']]);
57
87
  });
88
+
89
+ it('should keep generated row keys stable for the same input', () => {
90
+ const rows = buildRows([
91
+ [['a'], ['b']],
92
+ [['c'], ['d']],
93
+ ]);
94
+
95
+ expect(Object.keys(transformRowsToSingleColumn(rows))).toEqual(Object.keys(transformRowsToSingleColumn(rows)));
96
+ });
97
+
98
+ it('should keep existing row keys stable when appending new mobile rows', () => {
99
+ const before = buildRows([[['a'], ['b']]]);
100
+ const after = buildRows([[['a'], ['b']], [['c']]]);
101
+ const beforeKeys = Object.keys(transformRowsToSingleColumn(before));
102
+ const afterKeys = Object.keys(transformRowsToSingleColumn(after));
103
+
104
+ expect(afterKeys.slice(0, beforeKeys.length)).toEqual(beforeKeys);
105
+ });
58
106
  });
@@ -17,7 +17,6 @@ import {
17
17
  Droppable,
18
18
  isRunJSValue,
19
19
  normalizeRunJSValue,
20
- runjsWithSafeGlobals,
21
20
  tExpr,
22
21
  FlowModelRenderer,
23
22
  FlowSettingsButton,
@@ -231,6 +230,7 @@ export class FilterFormBlockModel extends FilterBlockModel<{
231
230
  private initialDefaultsPromise?: Promise<void>;
232
231
  private initialRefreshHandledTargetIds = new Set<string>();
233
232
  private lastDefaultValueByFieldName = new Map<string, any>();
233
+ private userEditedFieldNames = new Set<string>();
234
234
  private defaultValuesRefreshSeq = 0;
235
235
 
236
236
  get form() {
@@ -436,6 +436,28 @@ export class FilterFormBlockModel extends FilterBlockModel<{
436
436
  return isEqual(current, this.lastDefaultValueByFieldName.get(name));
437
437
  }
438
438
 
439
+ private canApplyFormOverrideValue(name: string, force?: boolean) {
440
+ if (force) return true;
441
+ return !this.userEditedFieldNames.has(name);
442
+ }
443
+
444
+ private normalizeFieldValueMode(mode: unknown): 'default' | 'assign' | 'override' {
445
+ if (mode === 'assign') return 'assign';
446
+ if (mode === 'override') return 'override';
447
+ return 'default';
448
+ }
449
+
450
+ private markFilterFormUserEditedFields(changedValues: any) {
451
+ if (!changedValues || typeof changedValues !== 'object' || Array.isArray(changedValues)) return;
452
+ for (const name of Object.keys(changedValues)) {
453
+ this.userEditedFieldNames.add(String(name));
454
+ }
455
+ }
456
+
457
+ private resetFilterFormUserEditedFields() {
458
+ this.userEditedFieldNames.clear();
459
+ }
460
+
439
461
  private async matchDefaultValueCondition(condition: any) {
440
462
  if (!condition) return true;
441
463
 
@@ -458,6 +480,10 @@ export class FilterFormBlockModel extends FilterBlockModel<{
458
480
  if (!form) return appliedValues;
459
481
 
460
482
  const force = options?.force === true;
483
+ if (force) {
484
+ this.resetFilterFormUserEditedFields();
485
+ }
486
+
461
487
  const params = this.getStepParams?.('formFilterBlockModelSettings', 'defaultValues');
462
488
  const rules = (params?.value || []) as any[];
463
489
  if (!Array.isArray(rules) || rules.length === 0) return appliedValues;
@@ -466,7 +492,7 @@ export class FilterFormBlockModel extends FilterBlockModel<{
466
492
  // RunJS support
467
493
  if (isRunJSValue(raw)) {
468
494
  const { code, version } = normalizeRunJSValue(raw);
469
- const ret = await runjsWithSafeGlobals(this.context, code, { version });
495
+ const ret = await this.context.runjs(code, undefined, { version });
470
496
  return ret?.success ? ret.value : undefined;
471
497
  }
472
498
 
@@ -499,8 +525,9 @@ export class FilterFormBlockModel extends FilterBlockModel<{
499
525
 
500
526
  const operator = getDefaultOperator(itemModel as any);
501
527
  const normalized = normalizeFilterValueByOperator(operator, resolved);
502
- const mode = String(rule.mode || 'default') === 'assign' ? 'assign' : 'default';
528
+ const mode = this.normalizeFieldValueMode(rule.mode);
503
529
  if (mode === 'default' && !this.canApplyFormDefaultValue(String(name), current, force)) continue;
530
+ if (mode === 'override' && !this.canApplyFormOverrideValue(String(name), force)) continue;
504
531
  if (isEqual(current, normalized)) {
505
532
  if (mode === 'default') {
506
533
  this.lastDefaultValueByFieldName.set(String(name), normalized);
@@ -527,6 +554,7 @@ export class FilterFormBlockModel extends FilterBlockModel<{
527
554
  }
528
555
 
529
556
  private handleFilterFormValuesChange(changedValues: any, allValues: any) {
557
+ this.markFilterFormUserEditedFields(changedValues);
530
558
  const refreshSeq = ++this.defaultValuesRefreshSeq;
531
559
  void (async () => {
532
560
  const appliedValues = await this.applyFormDefaultValues({ refreshSeq });
@@ -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 { CodeEditor } from '../../../components/code-editor';
12
12
  import { FilterFormActionModel } from './FilterFormActionModel';
13
13
  import { resolveRunJsParams } from '../../utils/resolveRunJsParams';
@@ -60,12 +60,7 @@ FilterFormJSActionModel.registerFlow({
60
60
  },
61
61
  async handler(ctx, params) {
62
62
  const { code, version } = resolveRunJsParams(ctx, params);
63
- const navigator = createSafeNavigator();
64
- await ctx.runjs(
65
- code,
66
- { window: createSafeWindow({ navigator }), document: createSafeDocument(), navigator },
67
- { version },
68
- );
63
+ await ctx.runjs(code, undefined, { version });
69
64
  },
70
65
  },
71
66
  },