@nocobase/client-v2 2.1.7 → 2.1.9

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 (33) hide show
  1. package/es/components/form/JsonTextArea.d.ts +2 -1
  2. package/es/components/form/VariableJsonTextArea.d.ts +19 -0
  3. package/es/components/form/index.d.ts +1 -0
  4. package/es/flow/components/FieldAssignRulesEditor.d.ts +3 -1
  5. package/es/flow/models/blocks/filter-form/FilterFormBlockModel.d.ts +5 -0
  6. package/es/flow/models/blocks/form/FormBlockModel.d.ts +4 -0
  7. package/es/flow/models/blocks/form/value-runtime/rules.d.ts +21 -0
  8. package/es/flow/models/blocks/form/value-runtime/runtime.d.ts +21 -0
  9. package/es/flow/models/blocks/form/value-runtime/types.d.ts +2 -2
  10. package/es/index.mjs +102 -76
  11. package/lib/index.js +77 -51
  12. package/package.json +7 -7
  13. package/src/components/form/JsonTextArea.tsx +21 -11
  14. package/src/components/form/VariableJsonTextArea.tsx +175 -0
  15. package/src/components/form/__tests__/JsonTextArea.test.tsx +43 -0
  16. package/src/components/form/__tests__/VariableJsonTextArea.test.tsx +86 -0
  17. package/src/components/form/index.tsx +1 -0
  18. package/src/flow/actions/__tests__/fieldLinkageRules.scopeDepth.test.ts +150 -1
  19. package/src/flow/actions/__tests__/linkageAssignField.legacy.test.ts +135 -0
  20. package/src/flow/actions/linkageRules.tsx +75 -16
  21. package/src/flow/components/FieldAssignRulesEditor.tsx +62 -12
  22. package/src/flow/components/__tests__/FieldAssignRulesEditor.test.tsx +81 -4
  23. package/src/flow/models/blocks/filter-form/FilterFormBlockModel.tsx +30 -1
  24. package/src/flow/models/blocks/filter-form/__tests__/defaultValues.wiring.test.ts +77 -0
  25. package/src/flow/models/blocks/form/EditFormModel.tsx +1 -0
  26. package/src/flow/models/blocks/form/FormBlockModel.tsx +7 -0
  27. package/src/flow/models/blocks/form/__tests__/FormBlockModel.test.tsx +17 -0
  28. package/src/flow/models/blocks/form/value-runtime/__tests__/runtime.test.ts +880 -102
  29. package/src/flow/models/blocks/form/value-runtime/rules.ts +443 -11
  30. package/src/flow/models/blocks/form/value-runtime/runtime.ts +257 -13
  31. package/src/flow/models/blocks/form/value-runtime/types.ts +2 -2
  32. package/src/flow/models/fields/InputFieldModel.tsx +14 -22
  33. package/src/flow/models/fields/__tests__/InputFieldModel.test.tsx +17 -0
@@ -69,6 +69,72 @@ describe('linkage assign actions - legacy params', () => {
69
69
  expect(setProps).not.toHaveBeenCalledWith(fieldModel, { value: 'default-in-update' });
70
70
  });
71
71
 
72
+ it('linkageAssignField should mark override mode for field targets', async () => {
73
+ const fieldModel: any = { uid: 'f-override', fieldPath: 'a.b' };
74
+ const ctx: any = {
75
+ model: {
76
+ subModels: { grid: { subModels: { items: [fieldModel] } } },
77
+ },
78
+ engine: { getModel: vi.fn(() => ({ fieldPath: 'a.b' })) },
79
+ app: { jsonLogic: { apply: vi.fn() } },
80
+ };
81
+ const setProps = vi.fn();
82
+
83
+ await linkageAssignField.handler(ctx, {
84
+ value: [
85
+ {
86
+ key: 'r-override',
87
+ enable: true,
88
+ targetPath: 'a.b',
89
+ mode: 'override',
90
+ condition: { logic: '$and', items: [] },
91
+ value: 'override-value',
92
+ },
93
+ ],
94
+ setProps,
95
+ });
96
+
97
+ expect(setProps).toHaveBeenCalledTimes(1);
98
+ const [, props] = setProps.mock.calls[0];
99
+ expect(props).toMatchObject({
100
+ value: 'override-value',
101
+ __linkageAssignMode: 'override',
102
+ });
103
+ });
104
+
105
+ it('linkageAssignField should emit override patches for targets without form item models', async () => {
106
+ const ctx: any = {
107
+ model: { subModels: { grid: { subModels: { items: [] } } } },
108
+ engine: { getModel: vi.fn(() => null) },
109
+ app: { jsonLogic: { apply: vi.fn() } },
110
+ };
111
+ const setProps = vi.fn();
112
+ const addFormValuePatch = vi.fn();
113
+
114
+ await linkageAssignField.handler(ctx, {
115
+ value: [
116
+ {
117
+ key: 'r-override-direct',
118
+ enable: true,
119
+ targetPath: 'user.name',
120
+ mode: 'override',
121
+ condition: { logic: '$and', items: [] },
122
+ value: 'override-direct',
123
+ },
124
+ ],
125
+ setProps,
126
+ addFormValuePatch,
127
+ });
128
+
129
+ expect(setProps).not.toHaveBeenCalled();
130
+ expect(addFormValuePatch).toHaveBeenCalledWith({
131
+ path: 'user.name',
132
+ value: 'override-direct',
133
+ whenEmpty: false,
134
+ mode: 'override',
135
+ });
136
+ });
137
+
72
138
  it('subFormLinkageAssignField should apply legacy object params at runtime', () => {
73
139
  const forkModel: any = {
74
140
  uid: 'f1',
@@ -381,6 +447,75 @@ describe('linkage assign actions - legacy params', () => {
381
447
  expect(addFormValuePatch).toHaveBeenCalledTimes(1);
382
448
  });
383
449
 
450
+ it('subFormLinkageAssignField should skip override patch after user edits the target path', () => {
451
+ const ctx: any = {
452
+ model: {
453
+ uid: 'sub-form-override-path',
454
+ getAclActionName: vi.fn(() => 'create'),
455
+ context: {},
456
+ parent: {
457
+ getStepParams: (flowKey: string, stepKey: string) => {
458
+ if (flowKey === 'fieldSettings' && stepKey === 'init') {
459
+ return { fieldPath: 'M2M.M2M' };
460
+ }
461
+ return {};
462
+ },
463
+ },
464
+ subModels: { grid: { subModels: { items: [] } } },
465
+ },
466
+ engine: {
467
+ getModel: vi.fn(() => null),
468
+ },
469
+ app: { jsonLogic: { apply: vi.fn() } },
470
+ };
471
+ const addFormValuePatch = vi.fn();
472
+
473
+ subFormLinkageAssignField.handler(ctx, {
474
+ value: [
475
+ {
476
+ key: 'r-override-path-1',
477
+ enable: true,
478
+ targetPath: 'Name',
479
+ mode: 'override',
480
+ condition: { logic: '$and', items: [] },
481
+ value: 'override-1',
482
+ },
483
+ ],
484
+ addFormValuePatch,
485
+ setProps: vi.fn(),
486
+ });
487
+
488
+ expect(addFormValuePatch).toHaveBeenCalledTimes(1);
489
+ expect(addFormValuePatch).toHaveBeenNthCalledWith(1, {
490
+ path: 'M2M.M2M.Name',
491
+ value: 'override-1',
492
+ whenEmpty: false,
493
+ mode: 'override',
494
+ });
495
+
496
+ ctx.inputArgs = {
497
+ source: 'user',
498
+ changedPaths: [['M2M', 'M2M', 'Name']],
499
+ };
500
+
501
+ subFormLinkageAssignField.handler(ctx, {
502
+ value: [
503
+ {
504
+ key: 'r-override-path-2',
505
+ enable: true,
506
+ targetPath: 'Name',
507
+ mode: 'override',
508
+ condition: { logic: '$and', items: [] },
509
+ value: 'override-2',
510
+ },
511
+ ],
512
+ addFormValuePatch,
513
+ setProps: vi.fn(),
514
+ });
515
+
516
+ expect(addFormValuePatch).toHaveBeenCalledTimes(1);
517
+ });
518
+
384
519
  it('linkageAssignField should evaluate RunJS value before assign', async () => {
385
520
  const fieldModel: any = { uid: 'f-runjs-assign', fieldPath: 'a.b' };
386
521
  const runjs = vi.fn(async () => ({ success: true, value: 'from-runjs' }));
@@ -40,7 +40,7 @@ import { FilterGroup } from '../components/filter/FilterGroup';
40
40
  import { LinkageFilterItem } from '../components/filter';
41
41
  import { CodeEditor } from '../components/code-editor';
42
42
  import { FieldAssignRulesEditor } from '../components/FieldAssignRulesEditor';
43
- import type { FieldAssignRuleItem } from '../components/FieldAssignRulesEditor';
43
+ import type { AssignMode, FieldAssignRuleItem } from '../components/FieldAssignRulesEditor';
44
44
  import { collectFieldAssignCascaderOptions } from '../components/fieldAssignOptions';
45
45
  import { useAssociationTitleFieldSync } from '../components/useAssociationTitleFieldSync';
46
46
  import _ from 'lodash';
@@ -888,6 +888,20 @@ type ArrayFieldComponentProps = {
888
888
 
889
889
  const LEGACY_ASSIGN_RULE = { mode: 'assign', valueKey: 'assignValue' } as const;
890
890
  const LEGACY_DEFAULT_RULE = { mode: 'default', valueKey: 'initialValue' } as const;
891
+ const LINKAGE_ASSIGN_MODE_PROP = '__linkageAssignMode';
892
+
893
+ function normalizeLinkageAssignMode(mode: unknown): AssignMode {
894
+ if (mode === 'default') return 'default';
895
+ if (mode === 'override') return 'override';
896
+ return 'assign';
897
+ }
898
+
899
+ type LinkageValuePatch = {
900
+ path: Array<string | number>;
901
+ value: unknown;
902
+ whenEmpty?: boolean;
903
+ mode?: AssignMode;
904
+ };
891
905
 
892
906
  const FieldAssignRulesActionComponent: React.FC<
893
907
  ArrayFieldComponentProps & {
@@ -1002,16 +1016,24 @@ export const linkageAssignField = defineAction({
1002
1016
  continue;
1003
1017
  }
1004
1018
 
1005
- const mode = it?.mode === 'default' ? 'default' : 'assign';
1019
+ const mode = normalizeLinkageAssignMode(it?.mode);
1006
1020
  if (fieldModel) {
1007
1021
  if (mode === 'default') {
1008
1022
  setProps(fieldModel as FlowModel, { initialValue: finalValue });
1009
1023
  } else {
1010
- setProps(fieldModel as FlowModel, { value: finalValue });
1024
+ setProps(fieldModel as FlowModel, {
1025
+ value: finalValue,
1026
+ ...(mode === 'override' ? { [LINKAGE_ASSIGN_MODE_PROP]: mode } : {}),
1027
+ });
1011
1028
  }
1012
1029
  } else if (typeof addFormValuePatch === 'function') {
1013
1030
  // 对关联字段子属性(如 user.name)等没有独立 FormItemModel 的目标,直接写入表单值
1014
- addFormValuePatch({ path: targetPath, value: finalValue, whenEmpty: mode === 'default' });
1031
+ addFormValuePatch({
1032
+ path: targetPath,
1033
+ value: finalValue,
1034
+ whenEmpty: mode === 'default',
1035
+ ...(mode === 'override' ? { mode } : {}),
1036
+ });
1015
1037
  }
1016
1038
  }
1017
1039
  } catch (error) {
@@ -1177,7 +1199,7 @@ export const subFormLinkageAssignField = defineAction({
1177
1199
  continue;
1178
1200
  }
1179
1201
 
1180
- const mode = it?.mode === 'default' ? 'default' : 'assign';
1202
+ const mode = normalizeLinkageAssignMode(it?.mode);
1181
1203
  const actionName = (ctx.model as any)?.getAclActionName?.() ?? (ctx.model as any)?.context?.actionName;
1182
1204
  const isEditForm = actionName === 'update';
1183
1205
  const isNewItem = (ctx as any)?.item?.__is_new__ === true;
@@ -1207,12 +1229,21 @@ export const subFormLinkageAssignField = defineAction({
1207
1229
  continue;
1208
1230
  }
1209
1231
 
1232
+ if (mode === 'override' && hasExplicitPathHit(targetPath)) {
1233
+ continue;
1234
+ }
1235
+
1210
1236
  if (!fieldUid) {
1211
1237
  if (mode === 'default' && hasExplicitPathHit(targetPath)) {
1212
1238
  continue;
1213
1239
  }
1214
1240
  if (typeof addFormValuePatch === 'function') {
1215
- addFormValuePatch({ path: targetPath, value: finalValue, whenEmpty: mode === 'default' });
1241
+ addFormValuePatch({
1242
+ path: targetPath,
1243
+ value: finalValue,
1244
+ whenEmpty: mode === 'default',
1245
+ ...(mode === 'override' ? { mode } : {}),
1246
+ });
1216
1247
  }
1217
1248
  continue;
1218
1249
  }
@@ -1223,7 +1254,10 @@ export const subFormLinkageAssignField = defineAction({
1223
1254
  if (mode === 'default') {
1224
1255
  setProps(model, { initialValue: finalValue });
1225
1256
  } else {
1226
- setProps(model, { value: finalValue });
1257
+ setProps(model, {
1258
+ value: finalValue,
1259
+ ...(mode === 'override' ? { [LINKAGE_ASSIGN_MODE_PROP]: mode } : {}),
1260
+ });
1227
1261
  }
1228
1262
  }
1229
1263
  } catch (error) {
@@ -1781,7 +1815,7 @@ const commonLinkageRulesHandler = async (ctx: FlowContext, params: any) => {
1781
1815
  const modelsToApply = new Set<FlowModel>(allModels);
1782
1816
  const patchPropsByModel = new Map<FlowModel, any>();
1783
1817
  const clearValueOnHiddenModelUids = new Set<string>();
1784
- const directValuePatches: Array<{ path: Array<string | number>; value: any; whenEmpty?: boolean }> = [];
1818
+ const directValuePatches: LinkageValuePatch[] = [];
1785
1819
  const rootCollection = getCollectionFromModel((ctx.model as any)?.context?.blockModel ?? ctx.model);
1786
1820
  const isSafeToWriteAssociationSubpath = (namePath: any): boolean => {
1787
1821
  if (!Array.isArray(namePath) || !namePath.length) return true;
@@ -1836,13 +1870,18 @@ const commonLinkageRulesHandler = async (ctx: FlowContext, params: any) => {
1836
1870
  }
1837
1871
 
1838
1872
  for (const patch of lastPatchByPathKey.values()) {
1839
- if (!patch.whenEmpty) continue;
1873
+ if (!patch.whenEmpty && patch.mode !== 'default') continue;
1840
1874
  runtime.recordDefaultValuePatch(patch.path, patch.value);
1841
1875
  }
1842
1876
  };
1843
- const addFormValuePatch = (patch: { path: any; value: any; whenEmpty?: boolean }) => {
1877
+ const getPatchMode = (patch: { mode?: unknown; whenEmpty?: boolean }): AssignMode => {
1878
+ if (patch?.mode === 'default') return 'default';
1879
+ if (patch?.mode === 'override') return 'override';
1880
+ return patch?.whenEmpty ? 'default' : 'assign';
1881
+ };
1882
+ const addFormValuePatch = (patch: { path: unknown; value: unknown; whenEmpty?: boolean; mode?: AssignMode }) => {
1844
1883
  if (!patch) return;
1845
- const path = (patch as any)?.path;
1884
+ const path = patch.path;
1846
1885
  if (!path) return;
1847
1886
  const resolvedPath = resolveNamePathForPatch(path);
1848
1887
  if (!resolvedPath) {
@@ -1862,8 +1901,9 @@ const commonLinkageRulesHandler = async (ctx: FlowContext, params: any) => {
1862
1901
  });
1863
1902
  return;
1864
1903
  }
1865
- const whenEmpty = !!(patch as any)?.whenEmpty;
1866
- const value = (patch as any)?.value;
1904
+ const mode = getPatchMode(patch);
1905
+ const whenEmpty = mode === 'default';
1906
+ const value = patch.value;
1867
1907
  try {
1868
1908
  const form = ctx.model?.context?.form;
1869
1909
  const current = form?.getFieldValue?.(resolvedPath);
@@ -1878,6 +1918,15 @@ const commonLinkageRulesHandler = async (ctx: FlowContext, params: any) => {
1878
1918
  return;
1879
1919
  }
1880
1920
  }
1921
+ if (mode === 'override') {
1922
+ const runtime = getDefaultPatchRuntime();
1923
+ if (
1924
+ typeof runtime?.canApplyOverrideValuePatch === 'function' &&
1925
+ !runtime.canApplyOverrideValuePatch(resolvedPath)
1926
+ ) {
1927
+ return;
1928
+ }
1929
+ }
1881
1930
  if (_.isEqual(current, value)) {
1882
1931
  return;
1883
1932
  }
@@ -1889,6 +1938,7 @@ const commonLinkageRulesHandler = async (ctx: FlowContext, params: any) => {
1889
1938
  path: resolvedPath,
1890
1939
  value,
1891
1940
  ...(whenEmpty ? { whenEmpty: true } : {}),
1941
+ ...(mode === 'override' ? { mode } : {}),
1892
1942
  });
1893
1943
  };
1894
1944
  const removePendingFormValuePatches = (path: any) => {
@@ -2116,6 +2166,14 @@ const commonLinkageRulesHandler = async (ctx: FlowContext, params: any) => {
2116
2166
 
2117
2167
  for (const action of actions) {
2118
2168
  const setProps = (model: FlowModel & { __originalProps?: any; __shouldReset?: boolean }, props: any) => {
2169
+ const normalizedProps =
2170
+ props && typeof props === 'object' && Object.prototype.hasOwnProperty.call(props, 'value')
2171
+ ? {
2172
+ ...props,
2173
+ [LINKAGE_ASSIGN_MODE_PROP]: normalizeLinkageAssignMode(props?.[LINKAGE_ASSIGN_MODE_PROP]),
2174
+ }
2175
+ : props;
2176
+
2119
2177
  // 存储原始值,用于恢复
2120
2178
  if (!model.__originalProps) {
2121
2179
  model.__originalProps = {
@@ -2131,7 +2189,7 @@ const commonLinkageRulesHandler = async (ctx: FlowContext, params: any) => {
2131
2189
  // 临时存起来,遍历完所有规则后,再统一处理
2132
2190
  patchPropsByModel.set(model, {
2133
2191
  ...(patchPropsByModel.get(model) || {}),
2134
- ...props,
2192
+ ...normalizedProps,
2135
2193
  });
2136
2194
 
2137
2195
  if (
@@ -2185,7 +2243,7 @@ const commonLinkageRulesHandler = async (ctx: FlowContext, params: any) => {
2185
2243
  const prevHidden = !!model.hidden;
2186
2244
  const nextHidden = !!newProps.hiddenModel;
2187
2245
 
2188
- model.setProps(_.omit(newProps, ['hiddenModel', 'value', 'hiddenText']));
2246
+ model.setProps(_.omit(newProps, ['hiddenModel', 'value', 'hiddenText', LINKAGE_ASSIGN_MODE_PROP]));
2189
2247
  syncFieldOptionsToForks(model, patchProps);
2190
2248
  if (typeof model.setHidden === 'function') {
2191
2249
  model.setHidden(nextHidden);
@@ -2224,7 +2282,8 @@ const commonLinkageRulesHandler = async (ctx: FlowContext, params: any) => {
2224
2282
  targetUid: model?.uid,
2225
2283
  });
2226
2284
  } else {
2227
- addFormValuePatch({ path: targetPath, value: newProps.value });
2285
+ const mode = normalizeLinkageAssignMode(patchProps?.[LINKAGE_ASSIGN_MODE_PROP]);
2286
+ addFormValuePatch({ path: targetPath, value: newProps.value, ...(mode === 'override' ? { mode } : {}) });
2228
2287
  }
2229
2288
  }
2230
2289
 
@@ -28,7 +28,7 @@ import { buildFieldAssignCascaderOptionsFromCollection, type FieldAssignCascader
28
28
  import { isRunJSValue, isVariableExpression, type MetaTreeNode } from '@nocobase/flow-engine';
29
29
  import { isToManyAssociationField } from '../internal/utils/modelUtils';
30
30
 
31
- export type AssignMode = 'default' | 'assign';
31
+ export type AssignMode = 'default' | 'assign' | 'override';
32
32
 
33
33
  type CollectionFieldLike = {
34
34
  name?: unknown;
@@ -89,6 +89,8 @@ export interface FieldAssignRulesEditorProps {
89
89
  defaultMode?: AssignMode;
90
90
  /** 固定 mode:用于“仅默认值/仅赋值”的场景 */
91
91
  fixedMode?: AssignMode;
92
+ /** 可选 mode 列表;fixedMode 优先级更高 */
93
+ allowedModes?: AssignMode[];
92
94
  /** 是否显示 condition */
93
95
  showCondition?: boolean;
94
96
  /** 是否显示 enable 开关 */
@@ -109,6 +111,23 @@ export interface FieldAssignRulesEditorProps {
109
111
  maxAssociationFieldDepth?: number;
110
112
  }
111
113
 
114
+ const ALL_ASSIGN_MODES: AssignMode[] = ['default', 'override', 'assign'];
115
+
116
+ function isAssignMode(mode: unknown): mode is AssignMode {
117
+ return mode === 'default' || mode === 'override' || mode === 'assign';
118
+ }
119
+
120
+ function normalizeAssignModes(modes?: AssignMode[]): AssignMode[] {
121
+ const source = Array.isArray(modes) && modes.length ? modes : ALL_ASSIGN_MODES;
122
+ const out: AssignMode[] = [];
123
+ for (const mode of source) {
124
+ if (!isAssignMode(mode)) continue;
125
+ if (out.includes(mode)) continue;
126
+ out.push(mode);
127
+ }
128
+ return out.length ? out : ALL_ASSIGN_MODES;
129
+ }
130
+
112
131
  export const FieldAssignRulesEditor: React.FC<FieldAssignRulesEditorProps> = (props) => {
113
132
  const {
114
133
  t,
@@ -118,6 +137,7 @@ export const FieldAssignRulesEditor: React.FC<FieldAssignRulesEditorProps> = (pr
118
137
  onChange,
119
138
  defaultMode = 'assign',
120
139
  fixedMode,
140
+ allowedModes: rawAllowedModes,
121
141
  showCondition = true,
122
142
  showEnable = true,
123
143
  showValueEditorWhenNoField = false,
@@ -129,6 +149,28 @@ export const FieldAssignRulesEditor: React.FC<FieldAssignRulesEditorProps> = (pr
129
149
  } = props;
130
150
 
131
151
  const value = React.useMemo(() => (Array.isArray(rawValue) ? rawValue : []), [rawValue]);
152
+ const allowedModes = React.useMemo(
153
+ () => normalizeAssignModes(fixedMode ? [fixedMode] : rawAllowedModes),
154
+ [fixedMode, rawAllowedModes],
155
+ );
156
+ const getDefaultMode = React.useCallback((): AssignMode => {
157
+ if (fixedMode) return fixedMode;
158
+ if (isAssignMode(defaultMode) && allowedModes.includes(defaultMode)) return defaultMode;
159
+ return allowedModes[0] || 'assign';
160
+ }, [allowedModes, defaultMode, fixedMode]);
161
+ const normalizeItemMode = React.useCallback(
162
+ (item: FieldAssignRuleItem): FieldAssignRuleItem => {
163
+ const mode = isAssignMode(item?.mode) && allowedModes.includes(item.mode) ? item.mode : getDefaultMode();
164
+ return item?.mode === mode ? item : { ...item, mode };
165
+ },
166
+ [allowedModes, getDefaultMode],
167
+ );
168
+ const emitChange = React.useCallback(
169
+ (next: FieldAssignRuleItem[]) => {
170
+ onChange?.(next.map((item) => normalizeItemMode(item)));
171
+ },
172
+ [normalizeItemMode, onChange],
173
+ );
132
174
  const [cascaderOptions, setCascaderOptions] = React.useState<FieldAssignCascaderOption[]>(() =>
133
175
  Array.isArray(fieldOptions) ? (fieldOptions as FieldAssignCascaderOption[]) : [],
134
176
  );
@@ -312,7 +354,7 @@ export const FieldAssignRulesEditor: React.FC<FieldAssignRulesEditorProps> = (pr
312
354
 
313
355
  const patchItem = (index: number, patch: Partial<FieldAssignRuleItem>) => {
314
356
  const next = value.map((it, i) => (i === index ? { ...it, ...patch } : it));
315
- onChange?.(next);
357
+ emitChange(next);
316
358
  };
317
359
 
318
360
  const removeItem = (index: number) => {
@@ -334,7 +376,7 @@ export const FieldAssignRulesEditor: React.FC<FieldAssignRulesEditorProps> = (pr
334
376
  setSyncingRuleKey(null);
335
377
  }
336
378
  const next = value.filter((_, i) => i !== index);
337
- onChange?.(next);
379
+ emitChange(next);
338
380
  };
339
381
 
340
382
  const moveItem = (index: number, direction: 'up' | 'down') => {
@@ -343,7 +385,7 @@ export const FieldAssignRulesEditor: React.FC<FieldAssignRulesEditorProps> = (pr
343
385
  if (targetIndex < 0 || targetIndex >= next.length) return;
344
386
  const [item] = next.splice(index, 1);
345
387
  next.splice(targetIndex, 0, item);
346
- onChange?.(next);
388
+ emitChange(next);
347
389
  };
348
390
 
349
391
  const addItem = () => {
@@ -352,29 +394,33 @@ export const FieldAssignRulesEditor: React.FC<FieldAssignRulesEditorProps> = (pr
352
394
  {
353
395
  key: uid(),
354
396
  enable: true,
355
- mode: fixedMode || defaultMode,
397
+ mode: getDefaultMode(),
356
398
  condition: { logic: '$and', items: [] },
357
399
  targetPath: undefined,
358
400
  value: undefined,
359
401
  },
360
402
  ];
361
- onChange?.(next);
403
+ emitChange(next);
362
404
  };
363
405
 
364
406
  const getEffectiveMode = (item: FieldAssignRuleItem): AssignMode => {
365
407
  if (fixedMode) return fixedMode;
366
- return item?.mode === 'default' ? 'default' : 'assign';
408
+ if (isAssignMode(item?.mode) && allowedModes.includes(item.mode)) return item.mode;
409
+ return getDefaultMode();
367
410
  };
368
411
 
369
412
  const renderAssignModeLabel = React.useCallback(
370
413
  (mode: AssignMode) => {
371
- const modeText = mode === 'default' ? t('Default value') : t('Fixed value');
414
+ const modeText =
415
+ mode === 'default' ? t('Default value') : mode === 'override' ? t('Override value') : t('Fixed value');
372
416
  const modeHelpText =
373
417
  mode === 'default'
374
418
  ? t(
375
419
  'A pre-filled value. Editable, for new entries only, and won’t affect existing data (including empty values).',
376
420
  )
377
- : t('A system-set value. Read-only.');
421
+ : mode === 'override'
422
+ ? t('Overrides existing values until the user edits it.')
423
+ : t('A system-set value. Read-only.');
378
424
 
379
425
  const preventModeToggle = (e: React.MouseEvent<HTMLElement>) => {
380
426
  e.preventDefault();
@@ -627,7 +673,8 @@ export const FieldAssignRulesEditor: React.FC<FieldAssignRulesEditorProps> = (pr
627
673
 
628
674
  const renderPanelHeader = (item: FieldAssignRuleItem, index: number) => {
629
675
  const mode = getEffectiveMode(item);
630
- const modeText = mode === 'default' ? t('Default value') : t('Fixed value');
676
+ const modeText =
677
+ mode === 'default' ? t('Default value') : mode === 'override' ? t('Override value') : t('Fixed value');
631
678
  const fieldLabel = getFieldLabel(item.targetPath);
632
679
  const title = fieldLabel ? String(fieldLabel) : t('Please select field');
633
680
 
@@ -935,8 +982,11 @@ export const FieldAssignRulesEditor: React.FC<FieldAssignRulesEditorProps> = (pr
935
982
  onChange={(event) => patchItem(index, { mode: event.target.value as AssignMode })}
936
983
  >
937
984
  <Space size={16}>
938
- <Radio value="default">{renderAssignModeLabel('default')}</Radio>
939
- <Radio value="assign">{renderAssignModeLabel('assign')}</Radio>
985
+ {allowedModes.map((allowedMode) => (
986
+ <Radio key={allowedMode} value={allowedMode}>
987
+ {renderAssignModeLabel(allowedMode)}
988
+ </Radio>
989
+ ))}
940
990
  </Space>
941
991
  </Radio.Group>
942
992
  </div>
@@ -795,15 +795,23 @@ describe('FieldAssignRulesEditor', () => {
795
795
  );
796
796
 
797
797
  expect(screen.getByLabelText('Default value')).toBeInTheDocument();
798
+ expect(screen.getByLabelText('Override value')).toBeInTheDocument();
798
799
  expect(screen.getByLabelText('Fixed value')).toBeInTheDocument();
799
800
  expect(screen.getByLabelText('Fixed value')).toBeChecked();
800
801
  await userEvent.click(screen.getByLabelText('Default value'));
801
802
  await waitFor(() => {
802
803
  expect(onChange).toHaveBeenCalled();
803
804
  });
804
-
805
- const lastCall = onChange.mock.calls[onChange.mock.calls.length - 1]?.[0] as FieldAssignRuleItem[];
805
+ let lastCall = onChange.mock.calls[onChange.mock.calls.length - 1]?.[0] as FieldAssignRuleItem[];
806
806
  expect(lastCall?.[0]?.mode).toBe('default');
807
+
808
+ onChange.mockClear();
809
+ await userEvent.click(screen.getByLabelText('Override value'));
810
+ await waitFor(() => {
811
+ expect(onChange).toHaveBeenCalled();
812
+ });
813
+ lastCall = onChange.mock.calls[onChange.mock.calls.length - 1]?.[0] as FieldAssignRuleItem[];
814
+ expect(lastCall?.[0]?.mode).toBe('override');
807
815
  });
808
816
 
809
817
  it('shows assignment mode tooltips for each option', async () => {
@@ -821,7 +829,7 @@ describe('FieldAssignRulesEditor', () => {
821
829
  );
822
830
 
823
831
  const questionIcons = Array.from(container.querySelectorAll('.anticon-question-circle'));
824
- expect(questionIcons).toHaveLength(2);
832
+ expect(questionIcons).toHaveLength(3);
825
833
 
826
834
  await userEvent.hover(questionIcons[0] as Element);
827
835
  await waitFor(() => {
@@ -835,11 +843,80 @@ describe('FieldAssignRulesEditor', () => {
835
843
  await userEvent.unhover(questionIcons[0] as Element);
836
844
 
837
845
  await userEvent.hover(questionIcons[1] as Element);
846
+ await waitFor(() => {
847
+ expect(screen.getByText('Overrides existing values until the user edits it.')).toBeInTheDocument();
848
+ });
849
+
850
+ await userEvent.unhover(questionIcons[1] as Element);
851
+
852
+ await userEvent.hover(questionIcons[2] as Element);
838
853
  await waitFor(() => {
839
854
  expect(screen.getByText('A system-set value. Read-only.')).toBeInTheDocument();
840
855
  });
841
856
  });
842
857
 
858
+ it('honors allowed assignment modes', () => {
859
+ const value: FieldAssignRuleItem[] = [
860
+ {
861
+ key: '1',
862
+ enable: true,
863
+ targetPath: 'title',
864
+ mode: 'override',
865
+ },
866
+ ];
867
+
868
+ render(
869
+ wrap(
870
+ <FieldAssignRulesEditor
871
+ t={t}
872
+ fieldOptions={[]}
873
+ value={value}
874
+ allowedModes={['default', 'assign']}
875
+ showCondition={false}
876
+ showEnable={false}
877
+ />,
878
+ ),
879
+ );
880
+
881
+ expect(screen.getByLabelText('Default value')).toBeInTheDocument();
882
+ expect(screen.getByLabelText('Fixed value')).toBeInTheDocument();
883
+ expect(screen.queryByLabelText('Override value')).toBeNull();
884
+ expect(screen.getByLabelText('Fixed value')).toBeChecked();
885
+ });
886
+
887
+ it('normalizes disallowed assignment mode before emitting changes', async () => {
888
+ const onChange = vi.fn();
889
+ const value: FieldAssignRuleItem[] = [
890
+ {
891
+ key: '1',
892
+ enable: true,
893
+ targetPath: 'title',
894
+ mode: 'override',
895
+ },
896
+ ];
897
+
898
+ render(
899
+ wrap(
900
+ <FieldAssignRulesEditor
901
+ t={t}
902
+ fieldOptions={[]}
903
+ value={value}
904
+ onChange={onChange}
905
+ allowedModes={['default', 'assign']}
906
+ showCondition={false}
907
+ />,
908
+ ),
909
+ );
910
+
911
+ await userEvent.click(screen.getByRole('switch'));
912
+
913
+ await waitFor(() => {
914
+ expect(onChange).toHaveBeenCalled();
915
+ });
916
+ const lastCall = onChange.mock.calls[onChange.mock.calls.length - 1]?.[0] as FieldAssignRuleItem[];
917
+ expect(lastCall?.[0]?.mode).toBe('assign');
918
+ });
919
+
843
920
  it('merges extra item tree into base item while keeping order', () => {
844
921
  const base: MetaTreeNode[] = [
845
922
  { name: 'formValues', title: 'Current form', type: 'object', paths: ['formValues'] },
@@ -897,7 +974,7 @@ describe('FieldAssignRulesEditor', () => {
897
974
 
898
975
  const itemNode = merged.find((node) => node.name === 'item');
899
976
  expect(itemNode).toBeTruthy();
900
- const parent = (itemNode.children as MetaTreeNode[]).find((node) => node.name === 'parentItem');
977
+ const parent = ((itemNode?.children as MetaTreeNode[]) || []).find((node) => node.name === 'parentItem');
901
978
  expect(parent).toBeTruthy();
902
979
 
903
980
  const nestedParent = ((parent?.children as MetaTreeNode[]) || []).find((node) => node.name === 'parentItem');