@nocobase/client-v2 2.1.8 → 2.1.10

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 (38) 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/flow-compat/FieldValidation.d.ts +2 -1
  11. package/es/index.mjs +75 -49
  12. package/lib/index.js +81 -55
  13. package/package.json +7 -7
  14. package/src/components/form/JsonTextArea.tsx +21 -11
  15. package/src/components/form/VariableJsonTextArea.tsx +175 -0
  16. package/src/components/form/__tests__/JsonTextArea.test.tsx +43 -0
  17. package/src/components/form/__tests__/VariableJsonTextArea.test.tsx +86 -0
  18. package/src/components/form/index.tsx +1 -0
  19. package/src/flow/actions/__tests__/fieldLinkageRules.scopeDepth.test.ts +150 -1
  20. package/src/flow/actions/__tests__/linkageAssignField.legacy.test.ts +135 -0
  21. package/src/flow/actions/linkageRules.tsx +75 -16
  22. package/src/flow/actions/validation.tsx +62 -30
  23. package/src/flow/components/FieldAssignRulesEditor.tsx +62 -12
  24. package/src/flow/components/__tests__/FieldAssignRulesEditor.test.tsx +81 -4
  25. package/src/flow/models/base/PageModel/PageModel.tsx +4 -1
  26. package/src/flow/models/base/PageModel/__tests__/PageModel.test.ts +28 -0
  27. package/src/flow/models/blocks/filter-form/FilterFormBlockModel.tsx +30 -1
  28. package/src/flow/models/blocks/filter-form/__tests__/defaultValues.wiring.test.ts +77 -0
  29. package/src/flow/models/blocks/form/EditFormModel.tsx +1 -0
  30. package/src/flow/models/blocks/form/FormBlockModel.tsx +7 -0
  31. package/src/flow/models/blocks/form/__tests__/FormBlockModel.test.tsx +17 -0
  32. package/src/flow/models/blocks/form/value-runtime/__tests__/runtime.test.ts +880 -102
  33. package/src/flow/models/blocks/form/value-runtime/rules.ts +443 -11
  34. package/src/flow/models/blocks/form/value-runtime/runtime.ts +257 -13
  35. package/src/flow/models/blocks/form/value-runtime/types.ts +2 -2
  36. package/src/flow/models/fields/InputFieldModel.tsx +14 -22
  37. package/src/flow/models/fields/__tests__/InputFieldModel.test.tsx +17 -0
  38. package/src/flow-compat/FieldValidation.tsx +122 -60
@@ -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');
@@ -375,12 +375,15 @@ export class PageModel extends FlowModel<PageModelStructure> {
375
375
  tabBarStyle={this.props.tabBarStyle}
376
376
  items={this.mapTabs()}
377
377
  onChange={(activeKey) => {
378
+ const previousActiveKey = this.props.tabActiveKey || this.getActiveTabKey();
378
379
  this.context.view.navigation?.changeTo?.({
379
380
  tabUid: activeKey,
380
381
  });
381
382
 
382
383
  this.invokeTabModelLifecycleMethod(activeKey, 'onActive');
383
- this.invokeTabModelLifecycleMethod(this.props.tabActiveKey, 'onInactive');
384
+ if (previousActiveKey && previousActiveKey !== activeKey) {
385
+ this.invokeTabModelLifecycleMethod(previousActiveKey, 'onInactive');
386
+ }
384
387
  this.setProps('tabActiveKey', activeKey);
385
388
  }}
386
389
  // destroyInactiveTabPane
@@ -452,6 +452,34 @@ describe('PageModel', () => {
452
452
  marginInlineEnd: 24,
453
453
  });
454
454
  });
455
+
456
+ it('should deactivate the previous tab when navigation updates active tab synchronously', () => {
457
+ pageModel.props = { tabActiveKey: 'tab-old' } as any;
458
+ const invokeSpy = vi.spyOn(pageModel as any, 'invokeTabModelLifecycleMethod').mockImplementation(() => undefined);
459
+ const changeTo = vi.fn((params: { tabUid: string }) => {
460
+ pageModel.props.tabActiveKey = params.tabUid;
461
+ });
462
+
463
+ pageModel.context.view = {
464
+ navigation: {
465
+ viewParams: {
466
+ tabUid: 'tab-old',
467
+ },
468
+ changeTo,
469
+ },
470
+ } as any;
471
+
472
+ const result = pageModel.renderTabs() as any;
473
+ const tabsElement = result.props.children;
474
+
475
+ tabsElement.props.onChange('tab-new');
476
+
477
+ expect(changeTo).toHaveBeenCalledWith({ tabUid: 'tab-new' });
478
+ expect(invokeSpy).toHaveBeenNthCalledWith(1, 'tab-new', 'onActive');
479
+ expect(invokeSpy).toHaveBeenNthCalledWith(2, 'tab-old', 'onInactive');
480
+ expect(invokeSpy).not.toHaveBeenCalledWith('tab-new', 'onInactive');
481
+ expect(pageModel.props.tabActiveKey).toBe('tab-new');
482
+ });
455
483
  });
456
484
 
457
485
  describe('render header spacing with tabs', () => {
@@ -231,6 +231,7 @@ export class FilterFormBlockModel extends FilterBlockModel<{
231
231
  private initialDefaultsPromise?: Promise<void>;
232
232
  private initialRefreshHandledTargetIds = new Set<string>();
233
233
  private lastDefaultValueByFieldName = new Map<string, any>();
234
+ private userEditedFieldNames = new Set<string>();
234
235
  private defaultValuesRefreshSeq = 0;
235
236
 
236
237
  get form() {
@@ -436,6 +437,28 @@ export class FilterFormBlockModel extends FilterBlockModel<{
436
437
  return isEqual(current, this.lastDefaultValueByFieldName.get(name));
437
438
  }
438
439
 
440
+ private canApplyFormOverrideValue(name: string, force?: boolean) {
441
+ if (force) return true;
442
+ return !this.userEditedFieldNames.has(name);
443
+ }
444
+
445
+ private normalizeFieldValueMode(mode: unknown): 'default' | 'assign' | 'override' {
446
+ if (mode === 'assign') return 'assign';
447
+ if (mode === 'override') return 'override';
448
+ return 'default';
449
+ }
450
+
451
+ private markFilterFormUserEditedFields(changedValues: any) {
452
+ if (!changedValues || typeof changedValues !== 'object' || Array.isArray(changedValues)) return;
453
+ for (const name of Object.keys(changedValues)) {
454
+ this.userEditedFieldNames.add(String(name));
455
+ }
456
+ }
457
+
458
+ private resetFilterFormUserEditedFields() {
459
+ this.userEditedFieldNames.clear();
460
+ }
461
+
439
462
  private async matchDefaultValueCondition(condition: any) {
440
463
  if (!condition) return true;
441
464
 
@@ -458,6 +481,10 @@ export class FilterFormBlockModel extends FilterBlockModel<{
458
481
  if (!form) return appliedValues;
459
482
 
460
483
  const force = options?.force === true;
484
+ if (force) {
485
+ this.resetFilterFormUserEditedFields();
486
+ }
487
+
461
488
  const params = this.getStepParams?.('formFilterBlockModelSettings', 'defaultValues');
462
489
  const rules = (params?.value || []) as any[];
463
490
  if (!Array.isArray(rules) || rules.length === 0) return appliedValues;
@@ -499,8 +526,9 @@ export class FilterFormBlockModel extends FilterBlockModel<{
499
526
 
500
527
  const operator = getDefaultOperator(itemModel as any);
501
528
  const normalized = normalizeFilterValueByOperator(operator, resolved);
502
- const mode = String(rule.mode || 'default') === 'assign' ? 'assign' : 'default';
529
+ const mode = this.normalizeFieldValueMode(rule.mode);
503
530
  if (mode === 'default' && !this.canApplyFormDefaultValue(String(name), current, force)) continue;
531
+ if (mode === 'override' && !this.canApplyFormOverrideValue(String(name), force)) continue;
504
532
  if (isEqual(current, normalized)) {
505
533
  if (mode === 'default') {
506
534
  this.lastDefaultValueByFieldName.set(String(name), normalized);
@@ -527,6 +555,7 @@ export class FilterFormBlockModel extends FilterBlockModel<{
527
555
  }
528
556
 
529
557
  private handleFilterFormValuesChange(changedValues: any, allValues: any) {
558
+ this.markFilterFormUserEditedFields(changedValues);
530
559
  const refreshSeq = ++this.defaultValuesRefreshSeq;
531
560
  void (async () => {
532
561
  const appliedValues = await this.applyFormDefaultValues({ refreshSeq });
@@ -50,6 +50,7 @@ function createFilterFormDefaultValuesModel(rules: any[], initialValues: Record<
50
50
  const model = {
51
51
  defaultValuesRefreshSeq: 0,
52
52
  lastDefaultValueByFieldName: new Map<string, any>(),
53
+ userEditedFieldNames: new Set<string>(),
53
54
  form: {
54
55
  getFieldsValue: () => ({ ...values }),
55
56
  getFieldValue: (name: string) => values[name],
@@ -86,6 +87,10 @@ function createFilterFormDefaultValuesModel(rules: any[], initialValues: Record<
86
87
  return undefined;
87
88
  }),
88
89
  canApplyFormDefaultValue: (FilterFormBlockModel.prototype as any).canApplyFormDefaultValue,
90
+ canApplyFormOverrideValue: (FilterFormBlockModel.prototype as any).canApplyFormOverrideValue,
91
+ normalizeFieldValueMode: (FilterFormBlockModel.prototype as any).normalizeFieldValueMode,
92
+ markFilterFormUserEditedFields: (FilterFormBlockModel.prototype as any).markFilterFormUserEditedFields,
93
+ resetFilterFormUserEditedFields: (FilterFormBlockModel.prototype as any).resetFilterFormUserEditedFields,
89
94
  matchDefaultValueCondition: (FilterFormBlockModel.prototype as any).matchDefaultValueCondition,
90
95
  applyFormDefaultValues: FilterFormBlockModel.prototype.applyFormDefaultValues,
91
96
  handleFilterFormValuesChange: (FilterFormBlockModel.prototype as any).handleFilterFormValuesChange,
@@ -315,6 +320,78 @@ describe('filter-form defaultValues wiring', () => {
315
320
  expect(values.username_user).toBe('Bob');
316
321
  });
317
322
 
323
+ it('applies override values until the target filter field is changed by user', async () => {
324
+ const { model, values } = createFilterFormDefaultValuesModel(
325
+ [
326
+ {
327
+ key: 'username-override',
328
+ enable: true,
329
+ targetPath: 'username',
330
+ mode: 'override',
331
+ value: '{{ ctx.formValues.nickname_nick }}',
332
+ },
333
+ ],
334
+ { nickname_nick: 'Bob', username_user: 'Manual' },
335
+ );
336
+
337
+ await FilterFormBlockModel.prototype.applyFormDefaultValues.call(model as any);
338
+ expect(values.username_user).toBe('Bob');
339
+
340
+ values.nickname_nick = 'Carol';
341
+ model.defaultValuesRefreshSeq += 1;
342
+ await FilterFormBlockModel.prototype.applyFormDefaultValues.call(model as any, {
343
+ refreshSeq: model.defaultValuesRefreshSeq,
344
+ });
345
+ expect(values.username_user).toBe('Carol');
346
+
347
+ values.username_user = 'User value';
348
+ (model as any).handleFilterFormValuesChange(
349
+ { username_user: 'User value' },
350
+ { nickname_nick: 'Carol', username_user: 'User value' },
351
+ );
352
+
353
+ await waitFor(() => {
354
+ expect(values.username_user).toBe('User value');
355
+ });
356
+
357
+ values.nickname_nick = 'Dora';
358
+ model.defaultValuesRefreshSeq += 1;
359
+ await FilterFormBlockModel.prototype.applyFormDefaultValues.call(model as any, {
360
+ refreshSeq: model.defaultValuesRefreshSeq,
361
+ });
362
+ expect(values.username_user).toBe('User value');
363
+ });
364
+
365
+ it('clears filter form user-edited override state on forced apply', async () => {
366
+ const { model, values } = createFilterFormDefaultValuesModel(
367
+ [
368
+ {
369
+ key: 'username-override',
370
+ enable: true,
371
+ targetPath: 'username',
372
+ mode: 'override',
373
+ condition: {
374
+ logic: '$and',
375
+ items: [{ path: '{{ ctx.formValues.nickname_nick }}', operator: '$eq', value: 'allow' }],
376
+ },
377
+ value: '{{ ctx.formValues.nickname_nick }}',
378
+ },
379
+ ],
380
+ { nickname_nick: 'deny', username_user: 'User value' },
381
+ );
382
+
383
+ model.userEditedFieldNames.add('username_user');
384
+
385
+ await FilterFormBlockModel.prototype.applyFormDefaultValues.call(model as any, { force: true });
386
+ expect(values.username_user).toBe('User value');
387
+ expect(model.userEditedFieldNames.has('username_user')).toBe(false);
388
+
389
+ values.nickname_nick = 'allow';
390
+ await FilterFormBlockModel.prototype.applyFormDefaultValues.call(model as any);
391
+
392
+ expect(values.username_user).toBe('allow');
393
+ });
394
+
318
395
  it('skips filter form field values when the rule condition does not match', async () => {
319
396
  const { model, values } = createFilterFormDefaultValuesModel(
320
397
  [
@@ -239,6 +239,7 @@ EditFormModel.registerFlow({
239
239
  // 切换记录(翻页等)时,清理“用户改动标记”,避免把上一条记录的编辑状态带到下一条
240
240
  if (recordChanged) {
241
241
  ctx.model.resetUserModifiedFields?.();
242
+ ctx.model.resetRuntimeUserEditedState?.();
242
243
  }
243
244
 
244
245
  ctx.form.setFieldsValue(currentRecord);
@@ -166,6 +166,13 @@ export class FormBlockModel<
166
166
  this.userModifiedTopLevelFields.clear();
167
167
  }
168
168
 
169
+ /**
170
+ * @internal
171
+ */
172
+ resetRuntimeUserEditedState(): void {
173
+ this.formValueRuntime?.resetUserEditedState?.();
174
+ }
175
+
169
176
  public async onDispatchEventStart(eventName: string, options?: any, inputArgs?: Record<string, any>): Promise<void> {
170
177
  if (eventName === 'beforeRender') {
171
178
  const grid = this.subModels.grid;
@@ -420,6 +420,23 @@ describe('FormBlockModel (form/formValues injection & server resolve anchors)',
420
420
  expect(savedUids).toContain('grid-1');
421
421
  });
422
422
 
423
+ it('keeps dirty reset separate from runtime user-edited reset', async () => {
424
+ const model = await setupFormModel();
425
+ const resetUserEditedState = vi.fn();
426
+ model.formValueRuntime = { resetUserEditedState } as any;
427
+
428
+ model.markUserModifiedFields({ status: 'draft' });
429
+ expect(model.getUserModifiedFields().has('status')).toBe(true);
430
+
431
+ model.resetUserModifiedFields();
432
+
433
+ expect(model.getUserModifiedFields().size).toBe(0);
434
+ expect(resetUserEditedState).not.toHaveBeenCalled();
435
+
436
+ model.resetRuntimeUserEditedState();
437
+ expect(resetUserEditedState).toHaveBeenCalledTimes(1);
438
+ });
439
+
423
440
  it('re-syncs delegated assignRules when grid submodel is added after block init', async () => {
424
441
  const model = await setupFormModel();
425
442
  const syncAssignRules = vi.fn();