@nocobase/client-v2 2.1.0-beta.32 → 2.1.0-beta.33

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 (39) hide show
  1. package/es/flow/components/code-editor/index.d.ts +1 -0
  2. package/es/flow/models/blocks/shared/legacyDefaultValueMigrationBase.d.ts +1 -0
  3. package/es/flow/models/fields/AssociationFieldModel/SubTableFieldModel/SubTableColumnModel.d.ts +4 -0
  4. package/es/flow/models/fields/ClickableFieldModel.d.ts +3 -0
  5. package/es/flow/models/fields/DisplayEnumFieldModel.d.ts +6 -0
  6. package/es/flow/models/fields/DisplayTitleFieldModel.d.ts +2 -2
  7. package/es/index.mjs +76 -76
  8. package/lib/index.js +67 -67
  9. package/package.json +8 -5
  10. package/src/__tests__/globalDeps.test.ts +1 -0
  11. package/src/flow/actions/__tests__/formAssignRules.legacyMigration.test.tsx +173 -0
  12. package/src/flow/actions/__tests__/pattern.test.ts +134 -0
  13. package/src/flow/actions/__tests__/titleField.test.ts +45 -0
  14. package/src/flow/actions/filterFormDefaultValues.tsx +30 -9
  15. package/src/flow/actions/formAssignRules.tsx +24 -9
  16. package/src/flow/actions/pattern.tsx +41 -6
  17. package/src/flow/actions/titleField.tsx +4 -2
  18. package/src/flow/actions/validation.tsx +1 -1
  19. package/src/flow/admin-shell/admin-layout/AdminLayoutMenuModels.tsx +0 -4
  20. package/src/flow/admin-shell/admin-layout/__tests__/AdminLayoutMenuModels.test.ts +13 -5
  21. package/src/flow/components/DynamicFlowsIcon.tsx +87 -13
  22. package/src/flow/components/__tests__/DynamicFlowsIcon.test.tsx +195 -8
  23. package/src/flow/components/code-editor/index.tsx +12 -8
  24. package/src/flow/models/base/PageModel/RootPageModel.tsx +1 -1
  25. package/src/flow/models/blocks/filter-form/__tests__/legacyDefaultValueMigration.test.ts +3 -0
  26. package/src/flow/models/blocks/form/FormActionModel.tsx +2 -8
  27. package/src/flow/models/blocks/form/FormItemModel.tsx +1 -1
  28. package/src/flow/models/blocks/form/__tests__/legacyDefaultValueMigration.test.ts +3 -0
  29. package/src/flow/models/blocks/shared/legacyDefaultValueMigrationBase.ts +21 -5
  30. package/src/flow/models/blocks/table/TableColumnModel.tsx +5 -2
  31. package/src/flow/models/blocks/table/__tests__/TableColumnModel.test.tsx +36 -0
  32. package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/SubTableColumnModel.tsx +144 -3
  33. package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/__tests__/SubTableColumnModel.rowRecord.test.ts +170 -1
  34. package/src/flow/models/fields/ClickableFieldModel.tsx +46 -2
  35. package/src/flow/models/fields/DisplayEnumFieldModel.tsx +44 -0
  36. package/src/flow/models/fields/DisplayTitleFieldModel.tsx +40 -15
  37. package/src/flow/models/fields/__tests__/ClickableFieldModel.test.ts +180 -2
  38. package/src/flow/models/fields/__tests__/DisplayEnumFieldModel.test.tsx +39 -0
  39. package/src/utils/globalDeps.ts +9 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nocobase/client-v2",
3
- "version": "2.1.0-beta.32",
3
+ "version": "2.1.0-beta.33",
4
4
  "license": "Apache-2.0",
5
5
  "main": "lib/index.js",
6
6
  "module": "es/index.mjs",
@@ -24,19 +24,22 @@
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.32",
28
- "@nocobase/sdk": "2.1.0-beta.32",
29
- "@nocobase/shared": "2.1.0-beta.32",
27
+ "@nocobase/evaluators": "2.1.0-beta.33",
28
+ "@nocobase/flow-engine": "2.1.0-beta.33",
29
+ "@nocobase/sdk": "2.1.0-beta.33",
30
+ "@nocobase/shared": "2.1.0-beta.33",
30
31
  "ahooks": "^3.7.2",
31
32
  "antd": "5.24.2",
33
+ "antd-style": "3.7.1",
32
34
  "axios": "^1.7.0",
33
35
  "classnames": "^2.3.1",
34
36
  "dayjs": "^1.11.10",
37
+ "file-saver": "^2.0.5",
35
38
  "i18next": "^22.4.9",
36
39
  "json5": "^2.2.3",
37
40
  "lodash": "4.17.21",
38
41
  "react-i18next": "^11.15.1",
39
42
  "react-router-dom": "^6.30.1"
40
43
  },
41
- "gitHead": "659c5efe992da7118d33c768bbd9e837a2c4716f"
44
+ "gitHead": "4815c394e80a264fa8ed619246280923c47aeb72"
42
45
  }
@@ -25,6 +25,7 @@ describe('client-v2 defineGlobalDeps', () => {
25
25
  expect(define).toHaveBeenCalledWith('@nocobase/utils/client', expect.any(Function));
26
26
  expect(define).toHaveBeenCalledWith('@nocobase/client-v2', expect.any(Function));
27
27
  expect(define).toHaveBeenCalledWith('@nocobase/flow-engine', expect.any(Function));
28
+ expect(define).toHaveBeenCalledWith('@nocobase/evaluators/client', expect.any(Function));
28
29
  expect(define).toHaveBeenCalledWith('ahooks', expect.any(Function));
29
30
  expect(define).toHaveBeenCalledWith('dayjs', expect.any(Function));
30
31
  expect(define).toHaveBeenCalledWith('lodash', expect.any(Function));
@@ -0,0 +1,173 @@
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 React from 'react';
11
+ import { render, waitFor } from '@testing-library/react';
12
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
13
+ import { FlowEngine, FlowModel, FlowSettingsContextProvider } from '@nocobase/flow-engine';
14
+ import { formAssignRules } from '../formAssignRules';
15
+ import { filterFormDefaultValues } from '../filterFormDefaultValues';
16
+
17
+ const mockState = vi.hoisted(() => ({
18
+ editorProps: [] as any[],
19
+ }));
20
+
21
+ vi.mock('../../components/FieldAssignRulesEditor', () => ({
22
+ FieldAssignRulesEditor: (props: any) => {
23
+ mockState.editorProps.push(props);
24
+ return null;
25
+ },
26
+ }));
27
+
28
+ vi.mock('../../components/fieldAssignOptions', () => ({
29
+ collectFieldAssignCascaderOptions: vi.fn(() => []),
30
+ }));
31
+
32
+ vi.mock('../../components/useAssociationTitleFieldSync', () => ({
33
+ useAssociationTitleFieldSync: () => ({
34
+ isTitleFieldCandidate: vi.fn(() => false),
35
+ onSyncAssociationTitleField: vi.fn(),
36
+ }),
37
+ }));
38
+
39
+ vi.mock('../../internal/utils/modelUtils', () => ({
40
+ findFormItemModelByFieldPath: vi.fn(() => null),
41
+ getCollectionFromModel: vi.fn(() => null),
42
+ }));
43
+
44
+ function createLegacyField(fieldPath: string, value: any, legacyFlowKey: string) {
45
+ return {
46
+ props: {},
47
+ stepParams: {
48
+ fieldSettings: { init: { fieldPath } },
49
+ [legacyFlowKey]: { initialValue: { defaultValue: value } },
50
+ },
51
+ getProps() {
52
+ return this.props;
53
+ },
54
+ getStepParams(flowKey: string, stepKey: string) {
55
+ return this.stepParams?.[flowKey]?.[stepKey];
56
+ },
57
+ };
58
+ }
59
+
60
+ function createModel(legacyFlowKey: string, fields = [{ fieldPath: 'description', value: 'Legacy description' }]) {
61
+ const engine = new FlowEngine();
62
+ engine.translate = vi.fn((key: string) => key) as any;
63
+ const model = new FlowModel({ uid: `model-${legacyFlowKey}`, flowEngine: engine }) as any;
64
+ model.subModels.grid = {
65
+ subModels: {
66
+ items: fields.map((field) => createLegacyField(field.fieldPath, field.value, legacyFlowKey)),
67
+ },
68
+ };
69
+ return model;
70
+ }
71
+
72
+ function getActionComponent(action: any) {
73
+ const schema = typeof action.uiSchema === 'function' ? action.uiSchema() : action.uiSchema;
74
+ return schema.value['x-component'];
75
+ }
76
+
77
+ function renderAction(action: any, model: any, initialValue: any[] = []) {
78
+ const Comp = getActionComponent(action);
79
+ const onChange = vi.fn();
80
+
81
+ const Harness = () => {
82
+ const [value, setValue] = React.useState(initialValue);
83
+ const handleChange = React.useCallback(
84
+ (next: any[]) => {
85
+ onChange(next);
86
+ setValue(next);
87
+ },
88
+ [onChange],
89
+ );
90
+
91
+ return (
92
+ <FlowSettingsContextProvider value={model.context}>
93
+ <Comp value={value} onChange={handleChange} />
94
+ </FlowSettingsContextProvider>
95
+ );
96
+ };
97
+
98
+ render(<Harness />);
99
+ return { onChange };
100
+ }
101
+
102
+ function lastEditorValue() {
103
+ return mockState.editorProps.at(-1)?.value;
104
+ }
105
+
106
+ describe('Field values legacy default migration', () => {
107
+ beforeEach(() => {
108
+ mockState.editorProps.length = 0;
109
+ vi.clearAllMocks();
110
+ });
111
+
112
+ it('imports form field legacy defaults before form-level value is persisted', async () => {
113
+ const model = createModel('editItemSettings');
114
+ const { onChange } = renderAction(formAssignRules, model);
115
+
116
+ await waitFor(() => {
117
+ expect(onChange).toHaveBeenCalledWith([
118
+ expect.objectContaining({
119
+ key: 'legacy-default:description',
120
+ targetPath: 'description',
121
+ mode: 'default',
122
+ value: 'Legacy description',
123
+ }),
124
+ ]);
125
+ });
126
+ expect(lastEditorValue()).toEqual([
127
+ expect.objectContaining({
128
+ targetPath: 'description',
129
+ value: 'Legacy description',
130
+ }),
131
+ ]);
132
+ });
133
+
134
+ it('does not re-import form legacy defaults after an empty form-level value is persisted', async () => {
135
+ const model = createModel('editItemSettings');
136
+ model.setStepParams('formModelSettings', 'assignRules', { value: [] });
137
+ const { onChange } = renderAction(formAssignRules, model, []);
138
+
139
+ await waitFor(() => {
140
+ expect(mockState.editorProps.length).toBeGreaterThan(1);
141
+ });
142
+ expect(onChange).not.toHaveBeenCalled();
143
+ expect(lastEditorValue()).toEqual([]);
144
+ });
145
+
146
+ it('does not append deleted legacy form targets when another rule is already persisted', async () => {
147
+ const persisted = [{ key: 'kept', targetPath: 'title', mode: 'default', value: 'Kept title' }];
148
+ const model = createModel('editItemSettings', [
149
+ { fieldPath: 'title', value: 'Kept title' },
150
+ { fieldPath: 'description', value: 'Legacy description' },
151
+ ]);
152
+ model.setStepParams('formModelSettings', 'assignRules', { value: persisted });
153
+ const { onChange } = renderAction(formAssignRules, model, persisted);
154
+
155
+ await waitFor(() => {
156
+ expect(mockState.editorProps.length).toBeGreaterThan(1);
157
+ });
158
+ expect(onChange).not.toHaveBeenCalled();
159
+ expect(lastEditorValue()).toEqual(persisted);
160
+ });
161
+
162
+ it('does not re-import filter form legacy defaults after an empty form-level value is persisted', async () => {
163
+ const model = createModel('filterFormItemSettings');
164
+ model.setStepParams('formFilterBlockModelSettings', 'defaultValues', { value: [] });
165
+ const { onChange } = renderAction(filterFormDefaultValues, model, []);
166
+
167
+ await waitFor(() => {
168
+ expect(mockState.editorProps.length).toBeGreaterThan(1);
169
+ });
170
+ expect(onChange).not.toHaveBeenCalled();
171
+ expect(lastEditorValue()).toEqual([]);
172
+ });
173
+ });
@@ -187,4 +187,138 @@ describe('pattern action', () => {
187
187
 
188
188
  getDisplayBindingSpy.mockRestore();
189
189
  });
190
+
191
+ it('falls back to the target collection title field for association display only mode', async () => {
192
+ const engine = new FlowEngine();
193
+ engine.registerModels({
194
+ DummyFormItemModel,
195
+ FieldModel,
196
+ });
197
+
198
+ const parent = engine.createModel<DummyFormItemModel>({
199
+ use: DummyFormItemModel,
200
+ uid: 'form-item-association',
201
+ subModels: {
202
+ field: {
203
+ use: FieldModel,
204
+ uid: 'field-association',
205
+ props: {},
206
+ },
207
+ },
208
+ });
209
+ const collectionField = {
210
+ targetCollectionTitleFieldName: 'name',
211
+ isAssociationField: () => true,
212
+ };
213
+ parent.collectionField = collectionField;
214
+
215
+ await pattern.beforeParamsSave?.(
216
+ {
217
+ model: parent,
218
+ collectionField,
219
+ } as any,
220
+ { pattern: 'readPretty' },
221
+ { pattern: 'editable' },
222
+ );
223
+
224
+ expect(parent.props).toMatchObject({
225
+ pattern: 'readPretty',
226
+ disabled: false,
227
+ titleField: 'name',
228
+ });
229
+ expect(parent.getStepParams('editItemSettings', 'titleField')).toEqual({
230
+ titleField: 'name',
231
+ });
232
+ });
233
+
234
+ it('passes the association title field to the rebuilt display field model', async () => {
235
+ const engine = new FlowEngine();
236
+ engine.registerModels({
237
+ DummyFormItemModel,
238
+ FieldModel,
239
+ DummyDisplayFieldModel,
240
+ });
241
+
242
+ const parent = engine.createModel<DummyFormItemModel>({
243
+ use: DummyFormItemModel,
244
+ uid: 'form-item-association-rebuild',
245
+ subModels: {
246
+ field: {
247
+ use: FieldModel,
248
+ uid: 'field-association-rebuild',
249
+ props: {},
250
+ },
251
+ },
252
+ });
253
+ const targetTitleField = { name: 'name' };
254
+ const collectionField = {
255
+ targetCollectionTitleFieldName: 'name',
256
+ targetCollection: {
257
+ getField: vi.fn(() => targetTitleField),
258
+ },
259
+ isAssociationField: () => true,
260
+ };
261
+ parent.collectionField = collectionField;
262
+ const getDisplayBindingSpy = vi.spyOn(DetailsItemModel, 'getDefaultBindingByField').mockReturnValue({
263
+ modelName: 'DummyDisplayFieldModel',
264
+ defaultProps: { display: true },
265
+ } as any);
266
+
267
+ await pattern.afterParamsSave?.(
268
+ {
269
+ model: parent,
270
+ collectionField,
271
+ engine,
272
+ } as any,
273
+ { pattern: 'readPretty' },
274
+ { pattern: 'editable' },
275
+ );
276
+
277
+ expect(parent.subModels.field).toBeInstanceOf(DummyDisplayFieldModel);
278
+ expect(parent.subModels.field?.props).toMatchObject({
279
+ display: true,
280
+ titleField: 'name',
281
+ });
282
+
283
+ getDisplayBindingSpy.mockRestore();
284
+ });
285
+
286
+ it('refreshes the parent model after leaving display only mode', async () => {
287
+ const engine = new FlowEngine();
288
+ engine.registerModels({
289
+ DummyFormItemModel,
290
+ FieldModel,
291
+ DummyDisplayFieldModel,
292
+ });
293
+
294
+ const host = engine.createModel<FlowModel>({
295
+ use: FlowModel,
296
+ uid: 'sub-table-host',
297
+ });
298
+ const parent = engine.createModel<DummyFormItemModel>({
299
+ use: DummyFormItemModel,
300
+ uid: 'sub-table-column',
301
+ subModels: {
302
+ field: {
303
+ use: FieldModel,
304
+ uid: 'sub-table-column-field',
305
+ stepParams: {
306
+ fieldBinding: {
307
+ use: 'DummyDisplayFieldModel',
308
+ },
309
+ },
310
+ },
311
+ },
312
+ });
313
+ parent.setParent(host);
314
+ const hostSetPropsSpy = vi.spyOn(host, 'setProps');
315
+
316
+ await pattern.afterParamsSave?.(makeCtx(parent), { pattern: 'editable' }, { pattern: 'readPretty' });
317
+
318
+ expect(parent.subModels.field).toBeInstanceOf(FieldModel);
319
+ expect(parent.subModels.field?.uid).toBe('sub-table-column-field');
320
+ expect(hostSetPropsSpy).toHaveBeenCalledWith({
321
+ __patternRefreshKey: expect.any(String),
322
+ });
323
+ });
190
324
  });
@@ -0,0 +1,45 @@
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 { titleField } from '../titleField';
12
+
13
+ describe('titleField action', () => {
14
+ it('builds options from target field interface metadata', () => {
15
+ const titleableField = {
16
+ name: 'nickname',
17
+ title: 'Nickname',
18
+ getInterfaceOptions: vi.fn(() => ({ titleUsable: true })),
19
+ };
20
+ const nonTitleableField = {
21
+ name: 'profile',
22
+ title: 'Profile',
23
+ getInterfaceOptions: vi.fn(() => ({ titleUsable: false })),
24
+ };
25
+ const missingContextManager = {
26
+ collectionFieldInterfaceManager: {
27
+ getFieldInterface: vi.fn(() => undefined),
28
+ },
29
+ };
30
+
31
+ const uiMode = (titleField as any).uiMode({
32
+ dataSourceManager: missingContextManager,
33
+ collectionField: {
34
+ targetCollection: {
35
+ getFields: () => [titleableField, nonTitleableField],
36
+ },
37
+ },
38
+ });
39
+
40
+ expect(uiMode.props.options).toEqual([{ value: 'nickname', label: 'Nickname' }]);
41
+ expect(titleableField.getInterfaceOptions).toHaveBeenCalled();
42
+ expect(nonTitleableField.getInterfaceOptions).toHaveBeenCalled();
43
+ expect(missingContextManager.collectionFieldInterfaceManager.getFieldInterface).not.toHaveBeenCalled();
44
+ });
45
+ });
@@ -10,7 +10,8 @@
10
10
  import { defineAction, observer, tExpr, useFlowContext } from '@nocobase/flow-engine';
11
11
  import { isEqual } from 'lodash';
12
12
  import React from 'react';
13
- import { FieldAssignRulesEditor, type FieldAssignRuleItem } from '../components/FieldAssignRulesEditor';
13
+ import { FieldAssignRulesEditor } from '../components/FieldAssignRulesEditor';
14
+ import type { FieldAssignRuleItem } from '../components/FieldAssignRulesEditor';
14
15
  import { collectFieldAssignCascaderOptions } from '../components/fieldAssignOptions';
15
16
  import { useAssociationTitleFieldSync } from '../components/useAssociationTitleFieldSync';
16
17
  import { findFormItemModelByFieldPath, getCollectionFromModel } from '../internal/utils/modelUtils';
@@ -18,6 +19,7 @@ import {
18
19
  collectLegacyDefaultValueRulesFromFilterFormModel,
19
20
  mergeAssignRulesWithLegacyDefaults,
20
21
  } from '../models/blocks/filter-form/legacyDefaultValueMigration';
22
+ import { hasPersistedAssignRulesValue } from '../models/blocks/shared/legacyDefaultValueMigrationBase';
21
23
  import { getDefaultOperator } from '../models/blocks/filter-manager/utils';
22
24
  import { operators } from '../../flow-compat';
23
25
 
@@ -36,6 +38,10 @@ const FilterFormDefaultValuesUI = observer(
36
38
  return collectLegacyDefaultValueRulesFromFilterFormModel(ctx.model);
37
39
  }, [ctx.model]);
38
40
 
41
+ const hasPersistedValue = React.useMemo(() => {
42
+ return hasPersistedAssignRulesValue(ctx.model, 'formFilterBlockModelSettings', 'defaultValues');
43
+ }, [ctx.model]);
44
+
39
45
  const getValueInputProps = React.useCallback(
40
46
  (item: FieldAssignRuleItem) => {
41
47
  const targetPath = item?.targetPath ? String(item.targetPath) : '';
@@ -57,7 +63,7 @@ const FilterFormDefaultValuesUI = observer(
57
63
  );
58
64
 
59
65
  // 兼容:将字段级默认值(filterFormItemSettings.initialValue)合并到表单级 defaultValues 里展示。
60
- // 仅在首次打开时合并,后续以当前 step 表单值为准(便于用户在此处编辑/删除后统一保存)。
66
+ // 仅在表单级 defaultValues.value 尚未持久化时合并;已保存的空数组也代表用户显式删除。
61
67
  const hasInitializedMergeRef = React.useRef(false);
62
68
  const [hasInitializedMerge, setHasInitializedMerge] = React.useState(false);
63
69
  const markInitialized = React.useCallback(() => {
@@ -66,12 +72,23 @@ const FilterFormDefaultValuesUI = observer(
66
72
  setHasInitializedMerge(true);
67
73
  }, []);
68
74
 
75
+ const normalizedValue = React.useMemo(() => {
76
+ return Array.isArray(props.value) ? props.value : [];
77
+ }, [props.value]);
78
+
79
+ const legacyAwareValue = React.useMemo(() => {
80
+ if (hasPersistedValue) {
81
+ return normalizedValue;
82
+ }
83
+ return mergeAssignRulesWithLegacyDefaults(props.value, legacyDefaults);
84
+ }, [hasPersistedValue, legacyDefaults, normalizedValue, props.value]);
85
+
69
86
  const value = React.useMemo(() => {
70
87
  if (!canEdit || !hasInitializedMerge) {
71
- return mergeAssignRulesWithLegacyDefaults(props.value, legacyDefaults);
88
+ return legacyAwareValue;
72
89
  }
73
- return Array.isArray(props.value) ? props.value : [];
74
- }, [canEdit, hasInitializedMerge, legacyDefaults, props.value]);
90
+ return normalizedValue;
91
+ }, [canEdit, hasInitializedMerge, legacyAwareValue, normalizedValue]);
75
92
 
76
93
  const handleChange = React.useCallback(
77
94
  (next: FieldAssignRuleItem[]) => {
@@ -87,12 +104,16 @@ const FilterFormDefaultValuesUI = observer(
87
104
  if (hasInitializedMergeRef.current) return;
88
105
  if (!canEdit) return;
89
106
 
90
- const nextValue = mergeAssignRulesWithLegacyDefaults(props.value, legacyDefaults);
91
- if (!isEqual(props.value || [], nextValue || [])) {
92
- props.onChange?.(nextValue);
107
+ if (hasPersistedValue) {
108
+ markInitialized();
109
+ return;
110
+ }
111
+
112
+ if (!isEqual(normalizedValue, legacyAwareValue)) {
113
+ props.onChange?.(legacyAwareValue);
93
114
  }
94
115
  markInitialized();
95
- }, [canEdit, legacyDefaults, markInitialized, props.onChange, props.value]);
116
+ }, [canEdit, hasPersistedValue, legacyAwareValue, markInitialized, normalizedValue, props.onChange]);
96
117
 
97
118
  return (
98
119
  <FieldAssignRulesEditor
@@ -10,7 +10,8 @@
10
10
  import { defineAction, observer, tExpr, useFlowContext } from '@nocobase/flow-engine';
11
11
  import { isEqual } from 'lodash';
12
12
  import React from 'react';
13
- import { FieldAssignRulesEditor, type FieldAssignRuleItem } from '../components/FieldAssignRulesEditor';
13
+ import { FieldAssignRulesEditor } from '../components/FieldAssignRulesEditor';
14
+ import type { FieldAssignRuleItem } from '../components/FieldAssignRulesEditor';
14
15
  import { collectFieldAssignCascaderOptions } from '../components/fieldAssignOptions';
15
16
  import { useAssociationTitleFieldSync } from '../components/useAssociationTitleFieldSync';
16
17
  import { getCollectionFromModel } from '../internal/utils/modelUtils';
@@ -18,6 +19,7 @@ import {
18
19
  collectLegacyDefaultValueRulesFromFormModel,
19
20
  mergeAssignRulesWithLegacyDefaults,
20
21
  } from '../models/blocks/form/legacyDefaultValueMigration';
22
+ import { hasPersistedAssignRulesValue } from '../models/blocks/shared/legacyDefaultValueMigrationBase';
21
23
 
22
24
  const FormAssignRulesUI = observer(
23
25
  (props: { value?: FieldAssignRuleItem[]; onChange?: (value: FieldAssignRuleItem[]) => void }) => {
@@ -34,8 +36,12 @@ const FormAssignRulesUI = observer(
34
36
  return collectLegacyDefaultValueRulesFromFormModel(ctx.model);
35
37
  }, [ctx.model]);
36
38
 
39
+ const hasPersistedValue = React.useMemo(() => {
40
+ return hasPersistedAssignRulesValue(ctx.model, 'formModelSettings', 'assignRules');
41
+ }, [ctx.model]);
42
+
37
43
  // 兼容:将字段级默认值(editItemSettings/formItemSettings.initialValue)合并到表单级 assignRules 里展示。
38
- // 仅在首次打开时合并,后续以当前 step 表单值为准(便于用户在此处编辑/删除后统一保存)。
44
+ // 仅在表单级 assignRules.value 尚未持久化时合并;已保存的空数组也代表用户显式删除。
39
45
  const hasInitializedMergeRef = React.useRef(false);
40
46
  const [hasInitializedMerge, setHasInitializedMerge] = React.useState(false);
41
47
  const markInitialized = React.useCallback(() => {
@@ -49,12 +55,19 @@ const FormAssignRulesUI = observer(
49
55
  return base;
50
56
  }, [props.value]);
51
57
 
58
+ const legacyAwareValue = React.useMemo(() => {
59
+ if (hasPersistedValue) {
60
+ return normalizedValue;
61
+ }
62
+ return mergeAssignRulesWithLegacyDefaults(props.value, legacyDefaults);
63
+ }, [hasPersistedValue, legacyDefaults, normalizedValue, props.value]);
64
+
52
65
  const value = React.useMemo(() => {
53
66
  if (!canEdit || !hasInitializedMerge) {
54
- return mergeAssignRulesWithLegacyDefaults(props.value, legacyDefaults);
67
+ return legacyAwareValue;
55
68
  }
56
69
  return normalizedValue;
57
- }, [canEdit, hasInitializedMerge, legacyDefaults, normalizedValue, props.value]);
70
+ }, [canEdit, hasInitializedMerge, legacyAwareValue, normalizedValue]);
58
71
 
59
72
  const handleChange = React.useCallback(
60
73
  (next: FieldAssignRuleItem[]) => {
@@ -70,14 +83,16 @@ const FormAssignRulesUI = observer(
70
83
  if (hasInitializedMergeRef.current) return;
71
84
  if (!canEdit) return;
72
85
 
73
- const merged = mergeAssignRulesWithLegacyDefaults(props.value, legacyDefaults);
74
- const nextValue = merged;
86
+ if (hasPersistedValue) {
87
+ markInitialized();
88
+ return;
89
+ }
75
90
 
76
- if (!isEqual(props.value || [], nextValue || [])) {
77
- props.onChange?.(nextValue);
91
+ if (!isEqual(normalizedValue, legacyAwareValue)) {
92
+ props.onChange?.(legacyAwareValue);
78
93
  }
79
94
  markInitialized();
80
- }, [canEdit, legacyDefaults, markInitialized, props.onChange, props.value]);
95
+ }, [canEdit, hasPersistedValue, legacyAwareValue, markInitialized, normalizedValue, props.onChange]);
81
96
 
82
97
  return (
83
98
  <FieldAssignRulesEditor
@@ -7,6 +7,7 @@
7
7
  * For more information, please refer to: https://www.nocobase.com/agreement.
8
8
  */
9
9
 
10
+ import { uid } from '@formily/shared';
10
11
  import { defineAction, tExpr } from '@nocobase/flow-engine';
11
12
  import { DetailsItemModel } from '../models/blocks/details/DetailsItemModel';
12
13
  import { getFieldBindingUse, rebuildFieldSubModel } from '../internal/utils/rebuildFieldSubModel';
@@ -19,6 +20,14 @@ type PatternAwareFieldModel = {
19
20
  scheduleApplyJsSettings?: () => void;
20
21
  };
21
22
 
23
+ function resolveAssociationTitleField(ctx: any) {
24
+ return (
25
+ ctx.model.subModels?.field?.props?.fieldNames?.label ||
26
+ ctx.model.props.titleField ||
27
+ ctx.collectionField?.targetCollectionTitleFieldName
28
+ );
29
+ }
30
+
22
31
  function shouldPreserveFieldModelOnPatternChange(ctx: any) {
23
32
  const fieldModel = ctx.model.subModels.field;
24
33
  const fieldUse = getFieldBindingUse(fieldModel) ?? fieldModel?.use;
@@ -27,6 +36,22 @@ function shouldPreserveFieldModelOnPatternChange(ctx: any) {
27
36
  return ((ModelClass?.meta as PatternAwareFieldModelMeta | undefined)?.preserveOnPatternChange ?? false) === true;
28
37
  }
29
38
 
39
+ async function refreshPatternRuntime(ctx: any) {
40
+ ctx.model.invalidateFlowCache?.('beforeRender', true);
41
+ await ctx.model.rerender?.();
42
+
43
+ const parent = ctx.model.parent;
44
+ if (!parent) {
45
+ return;
46
+ }
47
+
48
+ parent.invalidateFlowCache?.('beforeRender', true);
49
+ parent.setProps?.({
50
+ __patternRefreshKey: uid(),
51
+ });
52
+ await parent.rerender?.();
53
+ }
54
+
30
55
  export const pattern = defineAction({
31
56
  name: 'pattern',
32
57
  title: tExpr('Display mode'),
@@ -76,17 +101,25 @@ export const pattern = defineAction({
76
101
  if (params.pattern !== previousParams.pattern) {
77
102
  (ctx.model.subModels.field as PatternAwareFieldModel | undefined)?.scheduleApplyJsSettings?.();
78
103
  }
104
+ await refreshPatternRuntime(ctx);
79
105
  return;
80
106
  }
81
107
 
82
108
  const targetCollection = ctx.collectionField.targetCollection;
83
- const targetCollectionTitleField = targetCollection?.getField(
84
- ctx.model.subModels.field.props?.fieldNames?.label || ctx.model.props.titleField,
85
- );
109
+ const associationTitleField = resolveAssociationTitleField(ctx);
110
+ const targetCollectionTitleField = targetCollection?.getField(associationTitleField);
86
111
  const { model } = ctx;
87
112
  const resolveDefaultProps = (binding, field = ctx.collectionField) => {
88
113
  if (!binding) return undefined;
89
- return typeof binding.defaultProps === 'function' ? binding.defaultProps(ctx, field) : binding.defaultProps;
114
+ const defaultProps =
115
+ typeof binding.defaultProps === 'function' ? binding.defaultProps(ctx, field) : binding.defaultProps;
116
+ if (!ctx.collectionField?.isAssociationField?.() || !associationTitleField) {
117
+ return defaultProps;
118
+ }
119
+ return {
120
+ ...(defaultProps || {}),
121
+ titleField: associationTitleField,
122
+ };
90
123
  };
91
124
  if (params.pattern === 'readPretty') {
92
125
  const binding = DetailsItemModel.getDefaultBindingByField(ctx, ctx.collectionField, {
@@ -111,17 +144,19 @@ export const pattern = defineAction({
111
144
  });
112
145
  }
113
146
  }
147
+ await refreshPatternRuntime(ctx);
114
148
  },
115
149
  async beforeParamsSave(ctx, params, previousParams) {
116
150
  if (params.pattern === 'readPretty') {
151
+ const titleField = resolveAssociationTitleField(ctx);
117
152
  ctx.model.setProps({
118
153
  pattern: 'readPretty',
119
154
  disabled: false,
120
- titleField: (ctx.model.subModels?.field as any)?.props.fieldNames?.label || ctx.model.props.titleField,
155
+ titleField,
121
156
  });
122
157
  if (ctx.collectionField.isAssociationField())
123
158
  await ctx.model.setStepParams('editItemSettings', 'titleField', {
124
- titleField: (ctx.model.subModels.field as any).props?.fieldNames?.label || ctx.model.props.titleField,
159
+ titleField,
125
160
  });
126
161
  } else {
127
162
  ctx.model.setProps({
@@ -43,7 +43,9 @@ export const titleField = defineAction({
43
43
  const options = targetFields
44
44
  .filter((field) =>
45
45
  isTitleFieldInterface(
46
- getFlowFieldInterfaceOptions(field.options?.interface || field.interface, dataSourceManager),
46
+ typeof field.getInterfaceOptions === 'function'
47
+ ? field.getInterfaceOptions()
48
+ : getFlowFieldInterfaceOptions(field.options?.interface || field.interface, dataSourceManager),
47
49
  ),
48
50
  )
49
51
  .map((field) => ({
@@ -59,7 +61,7 @@ export const titleField = defineAction({
59
61
  };
60
62
  },
61
63
  defaultParams: (ctx: any) => {
62
- const titleField = ctx.model.context.collectionField.targetCollectionTitleFieldName;
64
+ const titleField = ctx.model?.context?.collectionField?.targetCollectionTitleFieldName;
63
65
  return {
64
66
  label: ctx.model.parent?.props?.titleField || ctx.model.props.titleField || titleField,
65
67
  };