@nocobase/client-v2 2.1.0-beta.23 → 2.1.0-beta.25

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 (42) hide show
  1. package/es/BaseApplication.d.ts +1 -0
  2. package/es/flow/actions/dataScopeFilter.d.ts +9 -0
  3. package/es/flow/components/Grid/index.d.ts +5 -3
  4. package/es/flow/internal/utils/rebuildFieldSubModel.d.ts +2 -1
  5. package/es/flow/models/base/GridModel.d.ts +19 -2
  6. package/es/flow/models/blocks/filter-form/FilterFormGridModel.d.ts +1 -0
  7. package/es/flow/models/fields/JSFieldModel.d.ts +5 -0
  8. package/es/index.mjs +100 -100
  9. package/lib/index.js +100 -100
  10. package/package.json +6 -5
  11. package/src/BaseApplication.tsx +4 -0
  12. package/src/__tests__/globalDeps.test.ts +1 -0
  13. package/src/__tests__/remotePlugins.test.ts +27 -0
  14. package/src/flow/actions/__tests__/dataScopeFilter.test.ts +158 -0
  15. package/src/flow/actions/dataScope.tsx +6 -4
  16. package/src/flow/actions/dataScopeFilter.ts +70 -0
  17. package/src/flow/actions/setTargetDataScope.tsx +6 -5
  18. package/src/flow/components/Grid/index.tsx +66 -20
  19. package/src/flow/internal/utils/__tests__/rebuildFieldSubModel.test.ts +77 -2
  20. package/src/flow/internal/utils/rebuildFieldSubModel.ts +21 -5
  21. package/src/flow/models/base/BlockGridModel.tsx +2 -2
  22. package/src/flow/models/base/GridModel.tsx +428 -195
  23. package/src/flow/models/base/__tests__/BlockGridModel.dragOverlayConfig.test.ts +44 -0
  24. package/src/flow/models/base/__tests__/GridModel.computeOverlayRect.test.ts +29 -0
  25. package/src/flow/models/base/__tests__/GridModel.dragSnapshotContainer.test.ts +181 -2
  26. package/src/flow/models/base/__tests__/GridModel.resizeLayout.test.ts +124 -0
  27. package/src/flow/models/base/__tests__/GridModel.visibleLayout.test.ts +55 -15
  28. package/src/flow/models/blocks/details/DetailsGridModel.tsx +6 -6
  29. package/src/flow/models/blocks/filter-form/FilterFormBlockModel.tsx +9 -5
  30. package/src/flow/models/blocks/filter-form/FilterFormGridModel.tsx +54 -14
  31. package/src/flow/models/blocks/filter-form/__tests__/FilterFormBlockModel.cleanup.test.ts +138 -0
  32. package/src/flow/models/blocks/filter-form/__tests__/FilterFormGridModel.toggleFormFieldsCollapse.test.ts +45 -0
  33. package/src/flow/models/blocks/form/FormGridModel.tsx +6 -6
  34. package/src/flow/models/blocks/form/__tests__/FormBlockModel.test.tsx +22 -0
  35. package/src/flow/models/blocks/table/JSColumnModel.tsx +30 -2
  36. package/src/flow/models/blocks/table/TableBlockModel.tsx +8 -1
  37. package/src/flow/models/blocks/table/TableColumnModel.tsx +1 -0
  38. package/src/flow/models/blocks/table/__tests__/JSColumnModel.test.tsx +51 -0
  39. package/src/flow/models/blocks/table/__tests__/TableBlockModel.quickEditRefresh.test.ts +49 -0
  40. package/src/flow/models/fields/JSFieldModel.tsx +54 -14
  41. package/src/utils/globalDeps.ts +4 -0
  42. package/src/utils/requirejs.ts +1 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nocobase/client-v2",
3
- "version": "2.1.0-beta.23",
3
+ "version": "2.1.0-beta.25",
4
4
  "license": "Apache-2.0",
5
5
  "main": "lib/index.js",
6
6
  "module": "es/index.mjs",
@@ -24,9 +24,10 @@
24
24
  "@formily/antd-v5": "1.2.3",
25
25
  "@formily/react": "^2.2.27",
26
26
  "@formily/shared": "^2.2.27",
27
- "@nocobase/flow-engine": "2.1.0-beta.23",
28
- "@nocobase/sdk": "2.1.0-beta.23",
29
- "@nocobase/shared": "2.1.0-beta.23",
27
+ "@nocobase/flow-engine": "2.1.0-beta.25",
28
+ "@nocobase/sdk": "2.1.0-beta.25",
29
+ "@nocobase/shared": "2.1.0-beta.25",
30
+ "ahooks": "^3.7.2",
30
31
  "antd": "5.24.2",
31
32
  "classnames": "^2.3.1",
32
33
  "dayjs": "^1.11.10",
@@ -35,5 +36,5 @@
35
36
  "react-i18next": "^11.15.1",
36
37
  "react-router-dom": "^6.30.1"
37
38
  },
38
- "gitHead": "bb4c0d3551bf9eff505b63756dd24a0813231f16"
39
+ "gitHead": "824f8b8200e9fe086135768934d3ef427b212446"
39
40
  }
@@ -189,6 +189,7 @@ export abstract class BaseApplication<
189
189
  this.configureContext();
190
190
  this.addBaseProviders();
191
191
  this.addCustomProviders();
192
+ this.addFinalProviders();
192
193
  this.addReactRouterComponents();
193
194
  this.addProviders(options.providers || []);
194
195
  this.ws = this.createWebSocketClient(options);
@@ -338,6 +339,9 @@ export abstract class BaseApplication<
338
339
  this.use(FlowEngineProvider, { engine: this.flowEngine });
339
340
  this.use(GlobalThemeProvider);
340
341
  this.use(AntdAppProvider);
342
+ }
343
+
344
+ protected addFinalProviders() {
341
345
  this.use(FlowEngineGlobalsContextProvider);
342
346
  }
343
347
 
@@ -23,5 +23,6 @@ describe('client-v2 defineGlobalDeps', () => {
23
23
  expect(define).toHaveBeenCalledWith('@nocobase/utils/client', expect.any(Function));
24
24
  expect(define).toHaveBeenCalledWith('@nocobase/client-v2', expect.any(Function));
25
25
  expect(define).toHaveBeenCalledWith('@nocobase/flow-engine', expect.any(Function));
26
+ expect(define).toHaveBeenCalledWith('ahooks', expect.any(Function));
26
27
  });
27
28
  });
@@ -8,6 +8,7 @@
8
8
  */
9
9
 
10
10
  import { Plugin } from '../Plugin';
11
+ import { getRequireJs } from '../utils/requirejs';
11
12
  import { defineDevPlugins, definePluginClient, getPlugins } from '../utils/remotePlugins';
12
13
 
13
14
  describe('client-v2 remotePlugins', () => {
@@ -69,4 +70,30 @@ describe('client-v2 remotePlugins', () => {
69
70
  expect(mockDefine).toHaveBeenCalledWith('@nocobase/demo/client-v2', expect.any(Function));
70
71
  expect(mockDefine).not.toHaveBeenCalledWith('@nocobase/demo/client', expect.any(Function));
71
72
  });
73
+
74
+ it('should not append duplicate .js for plugin URLs without query strings', () => {
75
+ const requirejs = getRequireJs();
76
+
77
+ requirejs.requirejs.config({
78
+ paths: {
79
+ '@nocobase/demo': '/static/plugins/@nocobase/demo/dist/client-v2/index.js',
80
+ },
81
+ });
82
+
83
+ expect(requirejs.requirejs.toUrl('@nocobase/demo')).toBe('/static/plugins/@nocobase/demo/dist/client-v2/index.js');
84
+ });
85
+
86
+ it('should keep hashed plugin URLs unchanged', () => {
87
+ const requirejs = getRequireJs();
88
+
89
+ requirejs.requirejs.config({
90
+ paths: {
91
+ '@nocobase/demo': '/static/plugins/@nocobase/demo/dist/client-v2/index.js?hash=12345678',
92
+ },
93
+ });
94
+
95
+ expect(requirejs.requirejs.toUrl('@nocobase/demo')).toBe(
96
+ '/static/plugins/@nocobase/demo/dist/client-v2/index.js?hash=12345678',
97
+ );
98
+ });
72
99
  });
@@ -0,0 +1,158 @@
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, vi } from 'vitest';
11
+ import { dataScope } from '../dataScope';
12
+ import { normalizeDataScopeFilter } from '../dataScopeFilter';
13
+ import { setTargetDataScope } from '../setTargetDataScope';
14
+
15
+ describe('normalizeDataScopeFilter', () => {
16
+ it('keeps null when a right-side variable resolves to empty', () => {
17
+ const rawFilter = {
18
+ logic: '$and',
19
+ items: [{ path: 'departmentId', operator: '$eq', value: '{{ ctx.formValues.department.id }}' }],
20
+ };
21
+ const resolvedFilter = {
22
+ logic: '$and',
23
+ items: [{ path: 'departmentId', operator: '$eq', value: undefined }],
24
+ };
25
+
26
+ expect(normalizeDataScopeFilter(rawFilter, resolvedFilter)).toEqual({
27
+ $and: [{ departmentId: { $eq: null } }],
28
+ });
29
+ });
30
+
31
+ it('keeps null when a right-side variable resolves to an empty string', () => {
32
+ const rawFilter = {
33
+ logic: '$and',
34
+ items: [{ path: 'departmentId', operator: '$eq', value: '{{ ctx.formValues.department.id }}' }],
35
+ };
36
+ const resolvedFilter = {
37
+ logic: '$and',
38
+ items: [{ path: 'departmentId', operator: '$eq', value: '' }],
39
+ };
40
+
41
+ expect(normalizeDataScopeFilter(rawFilter, resolvedFilter)).toEqual({
42
+ $and: [{ departmentId: { $eq: null } }],
43
+ });
44
+ });
45
+
46
+ it('still prunes empty constant values', () => {
47
+ const filter = {
48
+ logic: '$and',
49
+ items: [{ path: 'departmentId', operator: '$eq', value: undefined }],
50
+ };
51
+
52
+ expect(normalizeDataScopeFilter(filter, filter)).toBeUndefined();
53
+ });
54
+
55
+ it('handles nested groups and keeps non-empty values unchanged', () => {
56
+ const rawFilter = {
57
+ logic: '$and',
58
+ items: [
59
+ { path: 'status', operator: '$eq', value: 'active' },
60
+ {
61
+ logic: '$or',
62
+ items: [{ path: 'departmentId', operator: '$eq', value: '{{ ctx.formValues.department.id }}' }],
63
+ },
64
+ ],
65
+ };
66
+ const resolvedFilter = {
67
+ logic: '$and',
68
+ items: [
69
+ { path: 'status', operator: '$eq', value: 'active' },
70
+ {
71
+ logic: '$or',
72
+ items: [{ path: 'departmentId', operator: '$eq', value: null }],
73
+ },
74
+ ],
75
+ };
76
+
77
+ expect(normalizeDataScopeFilter(rawFilter, resolvedFilter)).toEqual({
78
+ $and: [{ status: { $eq: 'active' } }, { $or: [{ departmentId: { $eq: null } }] }],
79
+ });
80
+ });
81
+
82
+ it('does not preserve explicit null constants', () => {
83
+ const filter = {
84
+ logic: '$and',
85
+ items: [{ path: 'departmentId', operator: '$eq', value: null }],
86
+ };
87
+
88
+ expect(normalizeDataScopeFilter(filter, filter)).toBeUndefined();
89
+ });
90
+
91
+ it('dataScope handler sends null for empty variable dependencies', async () => {
92
+ const resource = {
93
+ addFilterGroup: vi.fn(),
94
+ removeFilterGroup: vi.fn(),
95
+ };
96
+ const ctx = {
97
+ model: {
98
+ uid: 'field-1',
99
+ resource,
100
+ },
101
+ resolveJsonTemplate: vi.fn(async (template) => ({
102
+ ...template,
103
+ items: [{ ...template.items[0], value: undefined }],
104
+ })),
105
+ };
106
+ const params = {
107
+ filter: {
108
+ logic: '$and',
109
+ items: [{ path: 'departmentId', operator: '$eq', value: '{{ ctx.formValues.department.id }}' }],
110
+ },
111
+ };
112
+
113
+ await (dataScope as any).handler(ctx, params);
114
+
115
+ expect(resource.addFilterGroup).toHaveBeenCalledWith('field-1', {
116
+ $and: [{ departmentId: { $eq: null } }],
117
+ });
118
+ expect(resource.removeFilterGroup).not.toHaveBeenCalled();
119
+ });
120
+
121
+ it('setTargetDataScope handler sends null for empty variable dependencies', async () => {
122
+ const resource = {
123
+ addFilterGroup: vi.fn(),
124
+ removeFilterGroup: vi.fn(),
125
+ hasData: vi.fn(() => false),
126
+ refresh: vi.fn(),
127
+ };
128
+ const targetModel = { resource };
129
+ const ctx = {
130
+ model: {
131
+ uid: 'action-1',
132
+ scheduleModelOperation: vi.fn((_uid, callback) => callback(targetModel)),
133
+ },
134
+ resolveJsonTemplate: vi.fn(async (template) => ({
135
+ ...template,
136
+ filter: {
137
+ ...template.filter,
138
+ items: [{ ...template.filter.items[0], value: undefined }],
139
+ },
140
+ })),
141
+ };
142
+ const params = {
143
+ targetBlockUid: 'target-1',
144
+ filter: {
145
+ logic: '$and',
146
+ items: [{ path: 'departmentId', operator: '$eq', value: '{{ ctx.formValues.department.id }}' }],
147
+ },
148
+ };
149
+
150
+ await (setTargetDataScope as any).handler(ctx, params);
151
+
152
+ expect(ctx.model.scheduleModelOperation).toHaveBeenCalledWith('target-1', expect.any(Function));
153
+ expect(resource.addFilterGroup).toHaveBeenCalledWith('setTargetDataScope_action-1', {
154
+ $and: [{ departmentId: { $eq: null } }],
155
+ });
156
+ expect(resource.removeFilterGroup).not.toHaveBeenCalled();
157
+ });
158
+ });
@@ -7,12 +7,12 @@
7
7
  * For more information, please refer to: https://www.nocobase.com/agreement.
8
8
  */
9
9
 
10
- import { defineAction, MultiRecordResource, pruneFilter, tExpr, useFlowSettingsContext } from '@nocobase/flow-engine';
11
- import { isEmptyFilter, transformFilter } from '@nocobase/utils/client';
12
- import _ from 'lodash';
10
+ import { defineAction, MultiRecordResource, tExpr, useFlowSettingsContext } from '@nocobase/flow-engine';
11
+ import { isEmptyFilter } from '@nocobase/utils/client';
13
12
  import React from 'react';
14
13
  import { FilterGroup, VariableFilterItem } from '../components/filter';
15
14
  import { FieldModel } from '../models/base/FieldModel';
15
+ import { normalizeDataScopeFilter } from './dataScopeFilter';
16
16
 
17
17
  export const dataScope = defineAction({
18
18
  name: 'dataScope',
@@ -43,6 +43,7 @@ export const dataScope = defineAction({
43
43
  filter: { logic: '$and', items: [] },
44
44
  };
45
45
  },
46
+ useRawParams: true,
46
47
  async handler(ctx, params) {
47
48
  // @ts-ignore
48
49
  const resource = ctx.model?.resource as MultiRecordResource;
@@ -50,7 +51,8 @@ export const dataScope = defineAction({
50
51
  return;
51
52
  }
52
53
 
53
- const filter = pruneFilter(transformFilter(params.filter));
54
+ const resolvedFilter = await ctx.resolveJsonTemplate(params.filter);
55
+ const filter = normalizeDataScopeFilter(params.filter, resolvedFilter);
54
56
 
55
57
  if (isEmptyFilter(filter)) {
56
58
  resource.removeFilterGroup(ctx.model.uid);
@@ -0,0 +1,70 @@
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 { isVariableExpression, pruneFilter } from '@nocobase/flow-engine';
11
+ import { transformFilter } from '@nocobase/utils/client';
12
+ import _ from 'lodash';
13
+
14
+ const PRESERVE_NULL = { __nocobaseDataScopeNull__: true };
15
+
16
+ function isPreserveNull(value: any) {
17
+ return (
18
+ value &&
19
+ typeof value === 'object' &&
20
+ !Array.isArray(value) &&
21
+ value.__nocobaseDataScopeNull__ === true &&
22
+ Object.keys(value).length === 1
23
+ );
24
+ }
25
+
26
+ function restorePreservedNull(value: any): any {
27
+ if (isPreserveNull(value)) {
28
+ return null;
29
+ }
30
+ if (Array.isArray(value)) {
31
+ return value.map((item) => restorePreservedNull(item));
32
+ }
33
+ if (value && typeof value === 'object') {
34
+ return Object.keys(value).reduce<Record<string, any>>((result, key) => {
35
+ result[key] = restorePreservedNull(value[key]);
36
+ return result;
37
+ }, {});
38
+ }
39
+ return value;
40
+ }
41
+
42
+ function markEmptyVariableValues(rawNode: any, resolvedNode: any) {
43
+ if (!rawNode || !resolvedNode || typeof rawNode !== 'object' || typeof resolvedNode !== 'object') {
44
+ return;
45
+ }
46
+
47
+ if ('path' in rawNode && 'operator' in rawNode) {
48
+ if (
49
+ isVariableExpression(rawNode.value) &&
50
+ (resolvedNode.value === undefined || resolvedNode.value === null || resolvedNode.value === '')
51
+ ) {
52
+ resolvedNode.value = PRESERVE_NULL;
53
+ }
54
+ return;
55
+ }
56
+
57
+ if (Array.isArray(rawNode.items) && Array.isArray(resolvedNode.items)) {
58
+ rawNode.items.forEach((rawItem, index) => {
59
+ markEmptyVariableValues(rawItem, resolvedNode.items[index]);
60
+ });
61
+ }
62
+ }
63
+
64
+ export function normalizeDataScopeFilter(rawFilter: any, resolvedFilter: any) {
65
+ const filterForTransform = _.cloneDeep(resolvedFilter);
66
+ markEmptyVariableValues(rawFilter, filterForTransform);
67
+
68
+ const filter = pruneFilter(transformFilter(filterForTransform));
69
+ return restorePreservedNull(filter);
70
+ }
@@ -12,14 +12,13 @@ import {
12
12
  defineAction,
13
13
  FlowModel,
14
14
  MultiRecordResource,
15
- pruneFilter,
16
15
  useFlowContext,
17
16
  tExpr,
18
17
  } from '@nocobase/flow-engine';
19
- import { isEmptyFilter, transformFilter } from '@nocobase/utils/client';
20
- import _ from 'lodash';
18
+ import { isEmptyFilter } from '@nocobase/utils/client';
21
19
  import React from 'react';
22
20
  import { FilterGroup, VariableFilterItem } from '../components/filter';
21
+ import { normalizeDataScopeFilter } from './dataScopeFilter';
23
22
 
24
23
  export const setTargetDataScope = defineAction({
25
24
  name: 'setTargetDataScope',
@@ -62,8 +61,10 @@ export const setTargetDataScope = defineAction({
62
61
  filter: { logic: '$and', items: [] },
63
62
  };
64
63
  },
64
+ useRawParams: true,
65
65
  async handler(ctx, params) {
66
- const targetBlockUid = params.targetBlockUid;
66
+ const resolvedParams = await ctx.resolveJsonTemplate(params);
67
+ const targetBlockUid = resolvedParams.targetBlockUid;
67
68
  if (!targetBlockUid) {
68
69
  return;
69
70
  }
@@ -74,7 +75,7 @@ export const setTargetDataScope = defineAction({
74
75
  return;
75
76
  }
76
77
 
77
- const filter = pruneFilter(transformFilter(params.filter));
78
+ const filter = normalizeDataScopeFilter(params.filter, resolvedParams.filter);
78
79
 
79
80
  if (isEmptyFilter(filter)) {
80
81
  resource.removeFilterGroup(`setTargetDataScope_${ctx.model.uid}`);
@@ -7,7 +7,7 @@
7
7
  * For more information, please refer to: https://www.nocobase.com/agreement.
8
8
  */
9
9
 
10
- import { EMPTY_COLUMN_UID } from '@nocobase/flow-engine';
10
+ import { EMPTY_COLUMN_UID, GridLayoutPath, GridLayoutV2, normalizeGridLayout } from '@nocobase/flow-engine';
11
11
  import { Col, Row } from 'antd';
12
12
  import React from 'react';
13
13
 
@@ -16,37 +16,63 @@ interface DragOverlayRect {
16
16
  readonly left: number;
17
17
  readonly width: number;
18
18
  readonly height: number;
19
- readonly type: 'column' | 'column-edge' | 'row-gap' | 'empty-row' | 'empty-column';
19
+ readonly type: 'column' | 'column-edge' | 'row-gap' | 'empty-row' | 'empty-column' | 'item-edge';
20
20
  }
21
21
 
22
22
  interface GridProps {
23
- readonly rows: Record<string, string[][]>;
23
+ readonly rows?: Record<string, string[][]>;
24
24
  readonly sizes?: Record<string, number[]>;
25
+ readonly layout?: GridLayoutV2;
25
26
  readonly renderItem: (uid: string) => React.ReactNode;
26
27
  readonly rowGap?: number;
27
28
  readonly colGap?: number;
28
29
  readonly dragOverlayRect?: DragOverlayRect | null;
29
30
  }
30
31
 
31
- export function Grid({ rows, sizes = {}, renderItem, rowGap = 16, colGap = 16, dragOverlayRect }: GridProps) {
32
- if (Object.keys(rows || {}).length === 0) {
32
+ export function Grid({
33
+ rows = {},
34
+ sizes = {},
35
+ layout,
36
+ renderItem,
37
+ rowGap = 16,
38
+ colGap = 16,
39
+ dragOverlayRect,
40
+ }: GridProps) {
41
+ const normalizedLayout = layout || normalizeGridLayout({ rows, sizes });
42
+ if (!normalizedLayout.rows.length) {
33
43
  return (
34
- <div style={{ position: 'relative' }} data-grid-empty-container>
44
+ <div style={{ position: 'relative' }} data-grid-root data-grid-empty-container>
35
45
  {dragOverlayRect && <GridDragOverlay rect={dragOverlayRect} />}
36
46
  </div>
37
47
  );
38
48
  }
39
49
 
40
50
  return (
41
- <div style={{ position: 'relative', display: 'flex', flexDirection: 'column', gap: rowGap }}>
51
+ <div style={{ position: 'relative', display: 'flex', flexDirection: 'column', gap: rowGap }} data-grid-root>
42
52
  {dragOverlayRect && <GridDragOverlay rect={dragOverlayRect} />}
43
- {Object.entries(rows).map(([rowKey, cells]) => {
44
- const colCount = cells.length;
45
- const rowSizes = sizes[rowKey] || [];
53
+ <GridRows rows={normalizedLayout.rows} renderItem={renderItem} rowGap={rowGap} colGap={colGap} parentPath={[]} />
54
+ </div>
55
+ );
56
+ }
57
+
58
+ interface GridRowsProps {
59
+ readonly rows: GridLayoutV2['rows'];
60
+ readonly renderItem: (uid: string) => React.ReactNode;
61
+ readonly rowGap: number;
62
+ readonly colGap: number;
63
+ readonly parentPath: GridLayoutPath;
64
+ }
65
+
66
+ function GridRows({ rows, renderItem, rowGap, colGap, parentPath }: GridRowsProps) {
67
+ return (
68
+ <>
69
+ {rows.map((row) => {
70
+ const colCount = row.cells.length;
71
+ const rowSizes = row.sizes || [];
46
72
  const hasAnySize = rowSizes.some((v) => v != null && v !== undefined);
73
+ const rowPath = [...parentPath, { rowId: row.id }];
47
74
 
48
- // 计算每个 cell span
49
- const spans = cells.map((_, cellIdx) => {
75
+ const spans = row.cells.map((_, cellIdx) => {
50
76
  if (hasAnySize) {
51
77
  const assigned = rowSizes.reduce((sum, v) => sum + (v || 0), 0);
52
78
  const unassignedCount = colCount - rowSizes.filter(Boolean).length;
@@ -58,22 +84,24 @@ export function Grid({ rows, sizes = {}, renderItem, rowGap = 16, colGap = 16, d
58
84
  });
59
85
 
60
86
  return (
61
- <Row key={rowKey} gutter={colGap} data-grid-row-id={rowKey}>
62
- {cells.map((cell, cellIdx) => (
87
+ <Row key={row.id} gutter={colGap} data-grid-row-id={row.id} data-grid-path={JSON.stringify(rowPath)}>
88
+ {row.cells.map((cell, cellIdx) => (
63
89
  <GridColumn
64
- key={`${rowKey}:${cell.join('|') || 'empty'}`}
65
- rowKey={rowKey}
90
+ key={cell.id}
91
+ rowKey={row.id}
66
92
  columnIndex={cellIdx}
67
93
  span={spans[cellIdx]}
68
94
  rowGap={rowGap}
95
+ colGap={colGap}
69
96
  cell={cell}
97
+ path={[...rowPath.slice(0, -1), { rowId: row.id, cellId: cell.id }]}
70
98
  renderItem={renderItem}
71
99
  />
72
100
  ))}
73
101
  </Row>
74
102
  );
75
103
  })}
76
- </div>
104
+ </>
77
105
  );
78
106
  }
79
107
 
@@ -82,7 +110,9 @@ interface GridColumnProps {
82
110
  readonly columnIndex: number;
83
111
  readonly span: number;
84
112
  readonly rowGap: number;
85
- readonly cell: readonly string[];
113
+ readonly colGap: number;
114
+ readonly cell: GridLayoutV2['rows'][number]['cells'][number];
115
+ readonly path: GridLayoutPath;
86
116
  readonly renderItem: (uid: string) => React.ReactNode;
87
117
  }
88
118
 
@@ -91,13 +121,21 @@ const GridColumn = React.memo(function GridColumn({
91
121
  columnIndex,
92
122
  span,
93
123
  rowGap,
124
+ colGap,
94
125
  cell,
126
+ path,
95
127
  renderItem,
96
128
  }: GridColumnProps) {
129
+ const items = cell.items || [];
97
130
  return (
98
- <Col span={span} data-grid-column-row-id={rowKey} data-grid-column-index={columnIndex}>
131
+ <Col
132
+ span={span}
133
+ data-grid-column-row-id={rowKey}
134
+ data-grid-column-index={columnIndex}
135
+ data-grid-path={JSON.stringify(path)}
136
+ >
99
137
  <div style={{ display: 'flex', flexDirection: 'column', gap: rowGap }}>
100
- {cell.map((uid, itemIdx) => {
138
+ {items.map((uid, itemIdx) => {
101
139
  if (uid === EMPTY_COLUMN_UID) {
102
140
  return null;
103
141
  }
@@ -108,11 +146,15 @@ const GridColumn = React.memo(function GridColumn({
108
146
  data-grid-column-index={columnIndex}
109
147
  data-grid-item-index={itemIdx}
110
148
  data-grid-item-uid={uid}
149
+ data-grid-path={JSON.stringify(path)}
111
150
  >
112
151
  {renderItem(uid)}
113
152
  </div>
114
153
  );
115
154
  })}
155
+ {cell.rows?.length ? (
156
+ <GridRows rows={cell.rows} renderItem={renderItem} rowGap={rowGap} colGap={colGap} parentPath={path} />
157
+ ) : null}
116
158
  </div>
117
159
  </Col>
118
160
  );
@@ -152,6 +194,10 @@ function GridDragOverlay({ rect }: Readonly<{ rect: DragOverlayRect }>) {
152
194
  border: '2px dashed var(--colorPrimary)',
153
195
  backgroundColor: 'rgba(22, 119, 255, 0.04)',
154
196
  },
197
+ 'item-edge': {
198
+ border: '2px solid var(--colorPrimary)',
199
+ backgroundColor: 'rgba(22, 119, 255, 0.12)',
200
+ },
155
201
  };
156
202
 
157
203
  return <div style={{ ...baseStyle, ...typeStyles[rect.type] }} />;
@@ -35,7 +35,7 @@ describe('rebuildFieldSubModel', () => {
35
35
  });
36
36
  });
37
37
 
38
- test('rebuilds field with same uid and updates binding use', async () => {
38
+ test('rebuilds field with same uid and direct target field model use', async () => {
39
39
  const staleClickHandler = () => null;
40
40
  const parent = engine.createModel<DummyParentModel>({
41
41
  use: DummyParentModel,
@@ -66,7 +66,8 @@ describe('rebuildFieldSubModel', () => {
66
66
 
67
67
  expect(rebuilt).toBeInstanceOf(DummyTargetFieldModel);
68
68
  expect(rebuilt.uid).toBe('field-1');
69
- expect(getFieldBindingUse(rebuilt)).toBe('DummyTargetFieldModel');
69
+ expect(getFieldBindingUse(rebuilt)).toBeUndefined();
70
+ expect(rebuilt.use).toBe('DummyTargetFieldModel');
70
71
  expect(rebuilt.props).toMatchObject({ added: 'yes', pattern: 'readPretty' });
71
72
  expect((rebuilt.props as any).onClick).toBeUndefined();
72
73
 
@@ -106,4 +107,78 @@ describe('rebuildFieldSubModel', () => {
106
107
  expect(Array.isArray(cols)).toBe(true);
107
108
  expect(cols.map((c) => c.uid)).toEqual(['col-1', 'col-2']);
108
109
  });
110
+
111
+ test('preserves compatible step params when rebuilding with the same field model use', async () => {
112
+ const parent = engine.createModel<DummyParentModel>({
113
+ use: DummyParentModel,
114
+ uid: 'parent-3',
115
+ subModels: {
116
+ field: {
117
+ use: DummyTargetFieldModel,
118
+ uid: 'field-3',
119
+ stepParams: {
120
+ fieldSettings: { init: { initKey: true } },
121
+ displayFieldSettings: {
122
+ overflowMode: {
123
+ overflowMode: true,
124
+ },
125
+ },
126
+ } as any,
127
+ },
128
+ },
129
+ });
130
+
131
+ await rebuildFieldSubModel({
132
+ parentModel: parent,
133
+ targetUse: 'DummyTargetFieldModel',
134
+ fieldSettingsInit: { fieldPath: 'title' },
135
+ });
136
+
137
+ const rebuilt = parent.subModels.field as DummyTargetFieldModel;
138
+ expect(rebuilt.stepParams).toEqual({
139
+ fieldSettings: {
140
+ init: { fieldPath: 'title' },
141
+ },
142
+ displayFieldSettings: {
143
+ overflowMode: {
144
+ overflowMode: true,
145
+ },
146
+ },
147
+ });
148
+ });
149
+
150
+ test('drops incompatible step params when rebuilding to a different field model use', async () => {
151
+ const parent = engine.createModel<DummyParentModel>({
152
+ use: DummyParentModel,
153
+ uid: 'parent-4',
154
+ subModels: {
155
+ field: {
156
+ use: FieldModel,
157
+ uid: 'field-4',
158
+ stepParams: {
159
+ fieldBinding: { use: 'FieldModel' },
160
+ fieldSettings: { init: { initKey: true } },
161
+ numberSettings: {
162
+ format: {
163
+ separator: '0,0.00',
164
+ },
165
+ },
166
+ } as any,
167
+ },
168
+ },
169
+ });
170
+
171
+ await rebuildFieldSubModel({
172
+ parentModel: parent,
173
+ targetUse: 'DummyTargetFieldModel',
174
+ fieldSettingsInit: { fieldPath: 'title' },
175
+ });
176
+
177
+ const rebuilt = parent.subModels.field as DummyTargetFieldModel;
178
+ expect(rebuilt.stepParams).toEqual({
179
+ fieldSettings: {
180
+ init: { fieldPath: 'title' },
181
+ },
182
+ });
183
+ });
109
184
  });