@nocobase/client-v2 2.1.0-alpha.33 → 2.1.0-alpha.35

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 (88) hide show
  1. package/es/BaseApplication.d.ts +1 -0
  2. package/es/PluginManager.d.ts +1 -0
  3. package/es/components/form/DrawerFormLayout.d.ts +49 -0
  4. package/es/components/form/EnvVariableInput.d.ts +42 -0
  5. package/es/components/form/FileSizeInput.d.ts +27 -0
  6. package/es/components/form/createFormRegistry.d.ts +33 -0
  7. package/es/components/form/index.d.ts +13 -0
  8. package/es/components/index.d.ts +1 -1
  9. package/es/flow/components/FieldAssignRulesEditor.d.ts +1 -0
  10. package/es/flow/internal/utils/enumOptionsUtils.d.ts +5 -0
  11. package/es/flow/models/actions/AssociationActionUtils.d.ts +5 -0
  12. package/es/flow/models/blocks/filter-form/FilterFormBlockModel.d.ts +4 -0
  13. package/es/flow/models/blocks/filter-manager/FilterManager.d.ts +5 -1
  14. package/es/flow/models/blocks/shared/legacyDefaultValueMigrationBase.d.ts +1 -0
  15. package/es/flow/models/blocks/table/TableSelectModel.d.ts +8 -0
  16. package/es/flow/models/fields/DisplayEnumFieldModel.d.ts +6 -0
  17. package/es/flow/models/fields/DisplayTitleFieldModel.d.ts +1 -1
  18. package/es/flow/models/utils/displayValueUtils.d.ts +12 -0
  19. package/es/flow-compat/passwordUtils.d.ts +1 -1
  20. package/es/index.mjs +117 -106
  21. package/es/utils/remotePlugins.d.ts +0 -4
  22. package/lib/index.js +122 -111
  23. package/package.json +8 -5
  24. package/src/BaseApplication.tsx +14 -8
  25. package/src/PluginManager.ts +1 -0
  26. package/src/__tests__/app.test.tsx +28 -1
  27. package/src/__tests__/remotePlugins.test.ts +29 -18
  28. package/src/components/form/DrawerFormLayout.tsx +103 -0
  29. package/src/components/form/EnvVariableInput.tsx +126 -0
  30. package/src/components/form/FileSizeInput.tsx +105 -0
  31. package/src/components/form/createFormRegistry.ts +60 -0
  32. package/src/components/form/index.tsx +14 -0
  33. package/src/components/index.ts +1 -1
  34. package/src/flow/actions/__tests__/dataScopeFilter.test.ts +92 -13
  35. package/src/flow/actions/__tests__/formAssignRules.legacyMigration.test.tsx +173 -0
  36. package/src/flow/actions/__tests__/linkageRules.subFormSetFieldProps.test.ts +476 -1
  37. package/src/flow/actions/filterFormDefaultValues.tsx +30 -9
  38. package/src/flow/actions/formAssignRules.tsx +24 -9
  39. package/src/flow/actions/linkageRules.tsx +240 -258
  40. package/src/flow/actions/setTargetDataScope.tsx +32 -3
  41. package/src/flow/actions/titleField.tsx +1 -1
  42. package/src/flow/actions/validation.tsx +1 -1
  43. package/src/flow/admin-shell/admin-layout/AdminLayoutMenuModels.tsx +0 -4
  44. package/src/flow/admin-shell/admin-layout/__tests__/AdminLayoutMenuModels.test.ts +13 -5
  45. package/src/flow/components/FieldAssignRulesEditor.tsx +2 -0
  46. package/src/flow/components/__tests__/FieldAssignRulesEditor.test.tsx +81 -4
  47. package/src/flow/components/filter/LinkageFilterItem.tsx +9 -2
  48. package/src/flow/components/filter/VariableFilterItem.tsx +2 -6
  49. package/src/flow/components/filter/__tests__/LinkageFilterItem.test.tsx +71 -0
  50. package/src/flow/components/filter/__tests__/VariableFilterItem.test.tsx +48 -0
  51. package/src/flow/internal/utils/__tests__/enumOptionsUtils.test.ts +10 -1
  52. package/src/flow/internal/utils/enumOptionsUtils.ts +29 -0
  53. package/src/flow/models/actions/AssociateActionModel.tsx +2 -2
  54. package/src/flow/models/actions/AssociationActionUtils.ts +14 -0
  55. package/src/flow/models/actions/__tests__/AssociationActionModel.test.ts +63 -0
  56. package/src/flow/models/base/CollectionBlockModel.tsx +7 -0
  57. package/src/flow/models/base/PageModel/RootPageModel.tsx +1 -1
  58. package/src/flow/models/blocks/filter-form/FilterFormBlockModel.tsx +33 -9
  59. package/src/flow/models/blocks/filter-form/FilterFormItemModel.tsx +53 -13
  60. package/src/flow/models/blocks/filter-form/__tests__/FilterFormItemModel.getFilterValue.test.ts +63 -3
  61. package/src/flow/models/blocks/filter-form/__tests__/defaultValues.wiring.test.ts +33 -1
  62. package/src/flow/models/blocks/filter-form/__tests__/legacyDefaultValueMigration.test.ts +3 -0
  63. package/src/flow/models/blocks/filter-manager/FilterManager.ts +66 -2
  64. package/src/flow/models/blocks/filter-manager/__tests__/FilterManager.test.ts +270 -0
  65. package/src/flow/models/blocks/form/FormBlockModel.tsx +8 -5
  66. package/src/flow/models/blocks/form/FormItemModel.tsx +1 -1
  67. package/src/flow/models/blocks/form/__tests__/FormBlockModel.test.tsx +30 -0
  68. package/src/flow/models/blocks/form/__tests__/legacyDefaultValueMigration.test.ts +3 -0
  69. package/src/flow/models/blocks/form/value-runtime/rules.ts +6 -1
  70. package/src/flow/models/blocks/form/value-runtime/runtime.ts +6 -1
  71. package/src/flow/models/blocks/shared/legacyDefaultValueMigrationBase.ts +21 -5
  72. package/src/flow/models/blocks/table/TableBlockModel.tsx +11 -6
  73. package/src/flow/models/blocks/table/TableColumnModel.tsx +8 -2
  74. package/src/flow/models/blocks/table/TableSelectModel.tsx +36 -26
  75. package/src/flow/models/blocks/table/__tests__/TableBlockModel.rowClick.test.ts +69 -0
  76. package/src/flow/models/blocks/table/__tests__/TableColumnModel.test.tsx +132 -1
  77. package/src/flow/models/blocks/table/__tests__/TableSelectModel.test.ts +41 -0
  78. package/src/flow/models/fields/ClickableFieldModel.tsx +9 -4
  79. package/src/flow/models/fields/DisplayEnumFieldModel.tsx +44 -0
  80. package/src/flow/models/fields/DisplayTitleFieldModel.tsx +12 -4
  81. package/src/flow/models/fields/SelectFieldModel.tsx +31 -1
  82. package/src/flow/models/fields/__tests__/ClickableFieldModel.test.ts +23 -0
  83. package/src/flow/models/fields/__tests__/DisplayEnumFieldModel.test.tsx +39 -0
  84. package/src/flow/models/fields/mobile-components/MobileSelect.tsx +2 -1
  85. package/src/flow/models/fields/mobile-components/__tests__/MobileSelect.test.tsx +7 -0
  86. package/src/flow/models/utils/displayValueUtils.ts +57 -0
  87. package/src/utils/globalDeps.ts +8 -0
  88. package/src/utils/remotePlugins.ts +7 -27
@@ -7,8 +7,11 @@
7
7
  * For more information, please refer to: https://www.nocobase.com/agreement.
8
8
  */
9
9
 
10
+ import React from 'react';
11
+ import { fireEvent, render, screen } from '@testing-library/react';
12
+ import { FlowSettingsContextProvider } from '@nocobase/flow-engine';
10
13
  import { describe, expect, it, vi } from 'vitest';
11
- import { subFormLinkageSetFieldProps } from '../linkageRules';
14
+ import { fieldLinkageRules, linkageSetFieldProps, subFormLinkageSetFieldProps } from '../linkageRules';
12
15
 
13
16
  describe('subFormLinkageSetFieldProps action', () => {
14
17
  it('should not throw when engine.getModel returns undefined', () => {
@@ -106,4 +109,476 @@ describe('subFormLinkageSetFieldProps action', () => {
106
109
  expect(getFork).toHaveBeenCalledWith('roles:0:field-2');
107
110
  expect(setProps).toHaveBeenCalledWith(formItemModel, { disabled: false });
108
111
  });
112
+
113
+ it('should limit options on the fork model', () => {
114
+ const setProps = vi.fn();
115
+ const selectedOptions = [{ label: 'Open', value: 'open' }];
116
+ const forkModel: any = {
117
+ uid: 'field-3',
118
+ isFork: true,
119
+ };
120
+ const formItemModel: any = {
121
+ uid: 'field-3',
122
+ getFork: vi.fn(() => forkModel),
123
+ };
124
+
125
+ const ctx: any = {
126
+ model: {
127
+ context: {
128
+ fieldKey: ['roles:0'],
129
+ },
130
+ },
131
+ engine: {
132
+ getModel: vi.fn(() => formItemModel),
133
+ },
134
+ };
135
+
136
+ subFormLinkageSetFieldProps.handler(ctx, {
137
+ value: {
138
+ fields: ['field-3'],
139
+ state: 'limitOptions',
140
+ selectedOptions,
141
+ },
142
+ setProps,
143
+ });
144
+
145
+ expect(setProps).toHaveBeenCalledWith(forkModel, { options: selectedOptions });
146
+ });
147
+ });
148
+
149
+ describe('linkageSetFieldProps action', () => {
150
+ it('should limit field options', () => {
151
+ const setProps = vi.fn();
152
+ const selectedOptions = [{ label: 'Draft', value: 'draft' }];
153
+ const fieldComponentModel: any = {
154
+ uid: 'status-field-component',
155
+ };
156
+ const fieldModel: any = {
157
+ uid: 'status-field',
158
+ subModels: {
159
+ field: fieldComponentModel,
160
+ },
161
+ };
162
+ const ctx: any = {
163
+ model: {
164
+ subModels: {
165
+ grid: {
166
+ subModels: {
167
+ items: [fieldModel],
168
+ },
169
+ },
170
+ },
171
+ },
172
+ };
173
+
174
+ linkageSetFieldProps.handler(ctx, {
175
+ value: {
176
+ fields: ['status-field'],
177
+ state: 'limitOptions',
178
+ selectedOptions,
179
+ },
180
+ setProps,
181
+ });
182
+
183
+ expect(setProps).toHaveBeenCalledWith(fieldComponentModel, { options: selectedOptions });
184
+ });
185
+
186
+ it('should sync limited options to existing field forks immediately', async () => {
187
+ const selectedOptions = [{ label: 'Draft', value: 'draft' }];
188
+ const forkModel: any = {
189
+ uid: 'status-field-component',
190
+ isFork: true,
191
+ setProps: vi.fn(),
192
+ };
193
+ const fieldComponentModel: any = {
194
+ uid: 'status-field-component',
195
+ props: {
196
+ options: [
197
+ { label: 'Draft', value: 'draft' },
198
+ { label: 'Published', value: 'published' },
199
+ ],
200
+ },
201
+ forks: new Set([forkModel]),
202
+ setProps(key: any, value?: any) {
203
+ if (typeof key === 'string') {
204
+ this.props[key] = value;
205
+ } else {
206
+ this.props = { ...this.props, ...key };
207
+ }
208
+ },
209
+ };
210
+ const staleFieldComponentModel: any = {
211
+ uid: 'status-field-component',
212
+ props: {
213
+ options: [
214
+ { label: 'Draft', value: 'draft' },
215
+ { label: 'Published', value: 'published' },
216
+ ],
217
+ },
218
+ forks: new Set(),
219
+ setProps: vi.fn(),
220
+ };
221
+ const fieldModel: any = {
222
+ uid: 'status-field',
223
+ subModels: {
224
+ field: fieldComponentModel,
225
+ },
226
+ };
227
+ const ctx: any = {
228
+ app: {
229
+ jsonLogic: {
230
+ apply: vi.fn(() => true),
231
+ },
232
+ },
233
+ model: {
234
+ __allModels: [staleFieldComponentModel],
235
+ subModels: {
236
+ grid: {
237
+ subModels: {
238
+ items: [fieldModel],
239
+ },
240
+ },
241
+ },
242
+ },
243
+ getAction: (name: string) => (name === 'linkageSetFieldProps' ? linkageSetFieldProps : null),
244
+ resolveJsonTemplate: vi.fn(async (value) => value),
245
+ };
246
+
247
+ await fieldLinkageRules.handler(ctx, {
248
+ value: [
249
+ {
250
+ key: 'rule-1',
251
+ enable: true,
252
+ condition: { logic: '$and', items: [] },
253
+ actions: [
254
+ {
255
+ key: 'action-1',
256
+ name: 'linkageSetFieldProps',
257
+ params: {
258
+ value: {
259
+ fields: ['status-field'],
260
+ state: 'limitOptions',
261
+ selectedOptions,
262
+ },
263
+ },
264
+ },
265
+ ],
266
+ },
267
+ ],
268
+ });
269
+
270
+ expect(fieldComponentModel.props.options).toEqual(selectedOptions);
271
+ expect(staleFieldComponentModel.setProps).not.toHaveBeenCalledWith({ options: selectedOptions });
272
+ expect(forkModel.setProps).toHaveBeenCalledWith({ options: selectedOptions });
273
+ });
274
+
275
+ it('should not clear form value when limit options reruns after selection', async () => {
276
+ const selectedOptions = [{ label: 'Draft', value: 'draft' }];
277
+ const form = {
278
+ getFieldValue: vi.fn(() => 'draft'),
279
+ setFieldValue: vi.fn(),
280
+ };
281
+ const fieldComponentModel: any = {
282
+ uid: 'status-field-component',
283
+ context: { form },
284
+ props: {
285
+ name: 'status',
286
+ value: undefined,
287
+ options: [
288
+ { label: 'Draft', value: 'draft' },
289
+ { label: 'Published', value: 'published' },
290
+ ],
291
+ },
292
+ setProps(key: any, value?: any) {
293
+ if (typeof key === 'string') {
294
+ this.props[key] = value;
295
+ } else {
296
+ this.props = { ...this.props, ...key };
297
+ }
298
+ },
299
+ };
300
+ const fieldModel: any = {
301
+ uid: 'status-field',
302
+ subModels: {
303
+ field: fieldComponentModel,
304
+ },
305
+ };
306
+ const ctx: any = {
307
+ app: {
308
+ jsonLogic: {
309
+ apply: vi.fn(() => true),
310
+ },
311
+ },
312
+ model: {
313
+ context: { form },
314
+ subModels: {
315
+ grid: {
316
+ subModels: {
317
+ items: [fieldModel],
318
+ },
319
+ },
320
+ },
321
+ },
322
+ getAction: (name: string) => (name === 'linkageSetFieldProps' ? linkageSetFieldProps : null),
323
+ resolveJsonTemplate: vi.fn(async (value) => value),
324
+ };
325
+
326
+ await fieldLinkageRules.handler(ctx, {
327
+ value: [
328
+ {
329
+ key: 'rule-1',
330
+ enable: true,
331
+ condition: { logic: '$and', items: [] },
332
+ actions: [
333
+ {
334
+ key: 'action-1',
335
+ name: 'linkageSetFieldProps',
336
+ params: {
337
+ value: {
338
+ fields: ['status-field'],
339
+ state: 'limitOptions',
340
+ selectedOptions,
341
+ },
342
+ },
343
+ },
344
+ ],
345
+ },
346
+ ],
347
+ });
348
+
349
+ expect(fieldComponentModel.props.options).toEqual(selectedOptions);
350
+ expect(form.setFieldValue).not.toHaveBeenCalled();
351
+ });
352
+
353
+ it('should keep all options selectable after options were limited once', async () => {
354
+ const fieldComponentModel: any = {
355
+ uid: 'status-field-component',
356
+ props: {
357
+ options: [{ label: 'Draft', value: 'draft' }],
358
+ },
359
+ };
360
+ const fieldModel: any = {
361
+ uid: 'status-field',
362
+ props: {
363
+ label: 'Status',
364
+ },
365
+ collectionField: {
366
+ interface: 'select',
367
+ uiSchema: {
368
+ enum: [
369
+ { label: 'Draft', value: 'draft' },
370
+ { label: 'Published', value: 'published' },
371
+ ],
372
+ },
373
+ },
374
+ subModels: {
375
+ field: fieldComponentModel,
376
+ },
377
+ };
378
+ const ctx: any = {
379
+ model: {
380
+ translate: (text: string) => text,
381
+ subModels: {
382
+ grid: {
383
+ subModels: {
384
+ items: [fieldModel],
385
+ },
386
+ },
387
+ },
388
+ },
389
+ };
390
+ const Comp: any = linkageSetFieldProps.uiSchema.value['x-component'];
391
+ const value = {
392
+ fields: ['status-field'],
393
+ state: 'limitOptions',
394
+ selectedOptions: [{ label: 'Draft', value: 'draft' }],
395
+ };
396
+ const view = render(
397
+ React.createElement(
398
+ FlowSettingsContextProvider,
399
+ { value: ctx },
400
+ React.createElement(Comp, { value, onChange: () => {} }),
401
+ ),
402
+ );
403
+
404
+ const selectors = view.container.querySelectorAll('.ant-select-selector');
405
+ fireEvent.mouseDown(selectors[2] as Element);
406
+ expect(await screen.findByText('Published')).toBeTruthy();
407
+ });
408
+
409
+ it('should only show options state for supported single field selection', () => {
410
+ const supportedField: any = {
411
+ uid: 'status-field',
412
+ props: {
413
+ label: 'Status',
414
+ },
415
+ collectionField: {
416
+ interface: 'select',
417
+ uiSchema: {
418
+ enum: [{ label: 'Draft', value: 'draft' }],
419
+ },
420
+ },
421
+ subModels: {
422
+ field: {
423
+ uid: 'status-field-component',
424
+ props: {
425
+ options: [{ label: 'Draft', value: 'draft' }],
426
+ },
427
+ },
428
+ },
429
+ };
430
+ const unsupportedField: any = {
431
+ uid: 'note-field',
432
+ props: {
433
+ label: 'Note',
434
+ },
435
+ collectionField: {
436
+ interface: 'input',
437
+ },
438
+ subModels: {
439
+ field: {
440
+ uid: 'note-field-component',
441
+ props: {},
442
+ },
443
+ },
444
+ };
445
+ const ctx: any = {
446
+ model: {
447
+ translate: (text: string) => text,
448
+ subModels: {
449
+ grid: {
450
+ subModels: {
451
+ items: [supportedField, unsupportedField],
452
+ },
453
+ },
454
+ },
455
+ },
456
+ };
457
+ const Comp: any = linkageSetFieldProps.uiSchema.value['x-component'];
458
+
459
+ const { rerender } = render(
460
+ React.createElement(
461
+ FlowSettingsContextProvider,
462
+ { value: ctx },
463
+ React.createElement(Comp, {
464
+ value: {
465
+ fields: ['status-field'],
466
+ state: 'limitOptions',
467
+ },
468
+ onChange: () => {},
469
+ }),
470
+ ),
471
+ );
472
+
473
+ expect(screen.getAllByText('Options')).toHaveLength(2);
474
+
475
+ rerender(
476
+ React.createElement(
477
+ FlowSettingsContextProvider,
478
+ { value: ctx },
479
+ React.createElement(Comp, {
480
+ value: {
481
+ fields: ['status-field', 'note-field'],
482
+ state: 'limitOptions',
483
+ },
484
+ onChange: () => {},
485
+ }),
486
+ ),
487
+ );
488
+
489
+ expect(screen.queryAllByText('Options')).toHaveLength(1);
490
+ });
491
+
492
+ it('should clear selected options when changing fields under limit options', () => {
493
+ const statusField: any = {
494
+ uid: 'status-field',
495
+ props: {
496
+ label: 'Status',
497
+ },
498
+ collectionField: {
499
+ interface: 'select',
500
+ uiSchema: {
501
+ enum: [
502
+ { label: 'Draft', value: 'draft' },
503
+ { label: 'Published', value: 'published' },
504
+ ],
505
+ },
506
+ },
507
+ subModels: {
508
+ field: {
509
+ uid: 'status-field-component',
510
+ props: {
511
+ options: [
512
+ { label: 'Draft', value: 'draft' },
513
+ { label: 'Published', value: 'published' },
514
+ ],
515
+ },
516
+ },
517
+ },
518
+ };
519
+ const categoryField: any = {
520
+ uid: 'category-field',
521
+ props: {
522
+ label: 'Category',
523
+ },
524
+ collectionField: {
525
+ interface: 'select',
526
+ uiSchema: {
527
+ enum: [
528
+ { label: 'A', value: 'a' },
529
+ { label: 'B', value: 'b' },
530
+ ],
531
+ },
532
+ },
533
+ subModels: {
534
+ field: {
535
+ uid: 'category-field-component',
536
+ props: {
537
+ options: [
538
+ { label: 'A', value: 'a' },
539
+ { label: 'B', value: 'b' },
540
+ ],
541
+ },
542
+ },
543
+ },
544
+ };
545
+ const ctx: any = {
546
+ model: {
547
+ translate: (text: string) => text,
548
+ subModels: {
549
+ grid: {
550
+ subModels: {
551
+ items: [statusField, categoryField],
552
+ },
553
+ },
554
+ },
555
+ },
556
+ };
557
+ const onChange = vi.fn();
558
+ const Comp: any = linkageSetFieldProps.uiSchema.value['x-component'];
559
+ const view = render(
560
+ React.createElement(
561
+ FlowSettingsContextProvider,
562
+ { value: ctx },
563
+ React.createElement(Comp, {
564
+ value: {
565
+ fields: ['status-field'],
566
+ state: 'limitOptions',
567
+ selectedOptions: [{ label: 'Draft', value: 'draft' }],
568
+ },
569
+ onChange,
570
+ }),
571
+ ),
572
+ );
573
+
574
+ const selectors = view.container.querySelectorAll('.ant-select-selector');
575
+ fireEvent.mouseDown(selectors[0] as Element);
576
+ fireEvent.click(screen.getByText('Category'));
577
+
578
+ expect(onChange).toHaveBeenLastCalledWith({
579
+ fields: ['status-field', 'category-field'],
580
+ state: undefined,
581
+ selectedOptions: [],
582
+ });
583
+ });
109
584
  });
@@ -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