@nocobase/client-v2 2.1.0-beta.27 → 2.1.0-beta.30

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 (68) hide show
  1. package/es/components/form/JsonTextArea.d.ts +18 -0
  2. package/es/components/index.d.ts +1 -0
  3. package/es/flow/actions/dateRangeLimit.d.ts +9 -0
  4. package/es/flow/actions/index.d.ts +2 -1
  5. package/es/flow/actions/linkageRules.d.ts +2 -0
  6. package/es/flow/admin-shell/admin-layout/AdminLayoutMenuModels.d.ts +4 -0
  7. package/es/flow/admin-shell/admin-layout/AdminLayoutModel.d.ts +7 -0
  8. package/es/flow/admin-shell/admin-layout/resolveAdminRouteRuntimeTarget.d.ts +5 -0
  9. package/es/flow/models/base/PageModel/PageModel.d.ts +4 -0
  10. package/es/flow/models/base/PageModel/RootPageModel.d.ts +9 -0
  11. package/es/flow/models/blocks/form/value-runtime/runtime.d.ts +7 -0
  12. package/es/flow/models/fields/AssociationFieldModel/SubTableFieldModel/SubTableColumnModel.d.ts +5 -0
  13. package/es/flow/models/fields/DateTimeFieldModel/dateLimit.d.ts +20 -0
  14. package/es/flow/models/fields/JSEditableFieldModel.d.ts +4 -0
  15. package/es/index.mjs +79 -67
  16. package/lib/index.js +80 -68
  17. package/package.json +6 -5
  18. package/src/__tests__/nocobase-buildin-plugin-auth.test.tsx +67 -46
  19. package/src/__tests__/settings-center.test.tsx +30 -0
  20. package/src/components/form/JsonTextArea.tsx +129 -0
  21. package/src/components/index.ts +1 -0
  22. package/src/flow/__tests__/FlowRoute.test.tsx +4 -5
  23. package/src/flow/actions/__tests__/actionLinkageRules.race.repro.test.ts +199 -0
  24. package/src/flow/actions/__tests__/fieldLinkageRules.scopeDepth.test.ts +478 -0
  25. package/src/flow/actions/__tests__/linkageRules.formValueDrivenRefresh.test.ts +6 -1
  26. package/src/flow/actions/__tests__/linkageRules.menu.test.ts +90 -0
  27. package/src/flow/actions/__tests__/pattern.test.ts +190 -0
  28. package/src/flow/actions/dateRangeLimit.tsx +66 -0
  29. package/src/flow/actions/index.ts +3 -0
  30. package/src/flow/actions/linkageRules.tsx +194 -42
  31. package/src/flow/actions/linkageRulesFormValueRefresh.ts +2 -8
  32. package/src/flow/actions/openView.tsx +2 -1
  33. package/src/flow/actions/pattern.tsx +25 -2
  34. package/src/flow/admin-shell/AdminLayoutRouteCoordinator.ts +7 -1
  35. package/src/flow/admin-shell/__tests__/AdminLayoutRouteCoordinator.test.ts +117 -0
  36. package/src/flow/admin-shell/admin-layout/AdminLayoutComponent.tsx +8 -1
  37. package/src/flow/admin-shell/admin-layout/AdminLayoutMenuModels.tsx +70 -12
  38. package/src/flow/admin-shell/admin-layout/AdminLayoutMenuUtils.tsx +26 -87
  39. package/src/flow/admin-shell/admin-layout/AdminLayoutModel.tsx +11 -0
  40. package/src/flow/admin-shell/admin-layout/AdminLayoutSlotModels.tsx +5 -1
  41. package/src/flow/admin-shell/admin-layout/__tests__/AdminLayoutMenuModels.test.ts +292 -31
  42. package/src/flow/admin-shell/admin-layout/resolveAdminRouteRuntimeTarget.test.ts +50 -12
  43. package/src/flow/admin-shell/admin-layout/resolveAdminRouteRuntimeTarget.ts +77 -56
  44. package/src/flow/components/AdminLayout.tsx +2 -2
  45. package/src/flow/components/FlowRoute.tsx +17 -4
  46. package/src/flow/models/base/PageModel/PageModel.tsx +15 -3
  47. package/src/flow/models/base/PageModel/RootPageModel.tsx +37 -2
  48. package/src/flow/models/base/PageModel/__tests__/PageModel.test.ts +73 -0
  49. package/src/flow/models/base/PageModel/__tests__/RootPageModel.test.ts +116 -0
  50. package/src/flow/models/blocks/form/value-runtime/__tests__/runtime.test.ts +167 -1
  51. package/src/flow/models/blocks/form/value-runtime/runtime.ts +103 -11
  52. package/src/flow/models/fields/AssociationFieldModel/PopupSubTableFieldModel/PopupSubTableFieldModel.tsx +4 -0
  53. package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/SubTableColumnModel.tsx +34 -3
  54. package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/SubTableField.tsx +47 -0
  55. package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/__tests__/SubTableColumnModel.rowRecord.test.ts +42 -0
  56. package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/__tests__/SubTableField.refresh.test.tsx +122 -0
  57. package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/index.tsx +6 -1
  58. package/src/flow/models/fields/ClickableFieldModel.tsx +21 -9
  59. package/src/flow/models/fields/DateTimeFieldModel/DateOnlyFieldModel.tsx +9 -0
  60. package/src/flow/models/fields/DateTimeFieldModel/DateTimeFieldModel.tsx +4 -0
  61. package/src/flow/models/fields/DateTimeFieldModel/DateTimeNoTzFieldModel.tsx +9 -0
  62. package/src/flow/models/fields/DateTimeFieldModel/DateTimeTzFieldModel.tsx +9 -0
  63. package/src/flow/models/fields/DateTimeFieldModel/__tests__/DateTimeNoTzFieldModel.dateLimit.test.tsx +242 -0
  64. package/src/flow/models/fields/DateTimeFieldModel/dateLimit.ts +152 -0
  65. package/src/flow/models/fields/JSEditableFieldModel.tsx +110 -14
  66. package/src/flow/models/fields/__tests__/ClickableFieldModel.test.ts +87 -0
  67. package/src/flow/models/fields/__tests__/JSEditableFieldModel.test.tsx +210 -0
  68. package/src/flow/system-settings/useSystemSettings.tsx +36 -1
@@ -0,0 +1,122 @@
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 { EventEmitter } from 'events';
12
+ import { describe, expect, it, vi } from 'vitest';
13
+ import { act, render, screen } from '@nocobase/test/client';
14
+ import { SubTableField } from '../SubTableField';
15
+
16
+ vi.mock('react-i18next', async (importOriginal) => ({
17
+ ...(await importOriginal<any>()),
18
+ useTranslation: () => ({
19
+ t: (value: string) => value,
20
+ }),
21
+ }));
22
+
23
+ vi.mock('antd', async () => {
24
+ const actual = await vi.importActual<any>('antd');
25
+ return {
26
+ ...actual,
27
+ Table: ({ dataSource = [], columns = [] }: any) => (
28
+ <div data-testid="subtable">
29
+ {dataSource.map((record: any, rowIdx: number) => (
30
+ <div data-testid={`row-${rowIdx}`} key={record.__index__ || rowIdx}>
31
+ {columns.map((column: any) => (
32
+ <div data-testid={`cell-${rowIdx}-${String(column.dataIndex || column.key)}`} key={column.key}>
33
+ {column.render?.(record[column.dataIndex], record, rowIdx)}
34
+ </div>
35
+ ))}
36
+ </div>
37
+ ))}
38
+ </div>
39
+ ),
40
+ };
41
+ });
42
+
43
+ describe('SubTableField refresh', () => {
44
+ it('rerenders from current form value when a nested subtable path changes', () => {
45
+ const emitter = new EventEmitter();
46
+ const store = {
47
+ roles: [{ __is_new__: true, __index__: 'row-1', uid: 'role-uid-1', name: '' }],
48
+ };
49
+ const columns = [
50
+ {
51
+ key: 'name',
52
+ dataIndex: 'name',
53
+ render: ({ value }: any) => <span>{value || 'empty'}</span>,
54
+ },
55
+ ];
56
+
57
+ render(
58
+ <SubTableField
59
+ columns={columns}
60
+ pageSize={10}
61
+ filterTargetKey="id"
62
+ fieldPathArray={['roles']}
63
+ formValuesChangeEmitter={emitter}
64
+ getCurrentValue={() => store.roles}
65
+ />,
66
+ );
67
+
68
+ expect(screen.getByTestId('cell-0-name')).toHaveTextContent('empty');
69
+
70
+ act(() => {
71
+ store.roles = [{ ...store.roles[0], name: 'role-uid-1' }];
72
+ emitter.emit('formValuesChange', {
73
+ source: 'linkage',
74
+ changedPaths: [['roles', 0, 'name']],
75
+ });
76
+ });
77
+
78
+ expect(screen.getByTestId('cell-0-name')).toHaveTextContent('role-uid-1');
79
+ });
80
+
81
+ it('ignores unrelated form value changes', () => {
82
+ const emitter = new EventEmitter();
83
+ let renderCount = 0;
84
+ const store = {
85
+ roles: [{ __is_new__: true, __index__: 'row-1', uid: 'role-uid-1', name: '' }],
86
+ };
87
+ const columns = [
88
+ {
89
+ key: 'name',
90
+ dataIndex: 'name',
91
+ render: ({ value }: any) => {
92
+ renderCount += 1;
93
+ return <span>{value || 'empty'}</span>;
94
+ },
95
+ },
96
+ ];
97
+
98
+ render(
99
+ <SubTableField
100
+ columns={columns}
101
+ pageSize={10}
102
+ filterTargetKey="id"
103
+ fieldPathArray={['roles']}
104
+ formValuesChangeEmitter={emitter}
105
+ getCurrentValue={() => store.roles}
106
+ />,
107
+ );
108
+
109
+ expect(renderCount).toBe(1);
110
+
111
+ act(() => {
112
+ store.roles = [{ ...store.roles[0], name: 'role-uid-1' }];
113
+ emitter.emit('formValuesChange', {
114
+ source: 'user',
115
+ changedPaths: [['profile', 'name']],
116
+ });
117
+ });
118
+
119
+ expect(renderCount).toBe(1);
120
+ expect(screen.getByTestId('cell-0-name')).toHaveTextContent('empty');
121
+ });
122
+ });
@@ -21,7 +21,7 @@ import { uid } from '@formily/shared';
21
21
  import { FormItemModel } from '../../../blocks/form';
22
22
  import { AssociationFieldModel } from '../AssociationFieldModel';
23
23
  import { buildRecordPickerPopupContextInputArgs, RecordPickerContent } from '../RecordPickerFieldModel';
24
- import { SubTableColumnModel } from './SubTableColumnModel';
24
+ import { isSubTableColumnFieldComponentContext, SubTableColumnModel } from './SubTableColumnModel';
25
25
  import { SubTableField } from './SubTableField';
26
26
  import { adjustColumnOrder } from '../../../blocks/table/utils';
27
27
 
@@ -120,6 +120,8 @@ export class SubTableFieldModel extends AssociationFieldModel {
120
120
  parentFieldIndex={this.context.fieldIndex}
121
121
  parentItem={this.context.item}
122
122
  filterTargetKey={this.collection.filterTargetKey}
123
+ formValuesChangeEmitter={this.context.blockModel?.emitter}
124
+ fieldPathArray={this.parent?.context?.fieldPathArray}
123
125
  getCurrentValue={this.getCurrentValue}
124
126
  />
125
127
  );
@@ -387,6 +389,9 @@ export { SubTableColumnModel };
387
389
  FormItemModel.bindModelToInterface('SubTableFieldModel', ['m2m', 'o2m', 'mbm'], {
388
390
  order: 200,
389
391
  when: (ctx, field) => {
392
+ if (isSubTableColumnFieldComponentContext(ctx)) {
393
+ return false;
394
+ }
390
395
  if (field.targetCollection) {
391
396
  return field.targetCollection.template !== 'file';
392
397
  }
@@ -11,10 +11,9 @@ import { CollectionField, tExpr } from '@nocobase/flow-engine';
11
11
  import { Tag } from 'antd';
12
12
  import { castArray, get } from 'lodash';
13
13
  import React from 'react';
14
- import { EllipsisWithTooltip } from '../../components';
14
+ import { EllipsisWithTooltip } from '../../components/EllipsisWithTooltip';
15
15
  import { openViewFlow } from '../../flows/openViewFlow';
16
16
  import { FieldModel } from '../base/FieldModel';
17
- import { EditFormModel } from '../blocks/form/EditFormModel';
18
17
 
19
18
  export function transformNestedData(inputData) {
20
19
  const resultArray = [];
@@ -36,6 +35,8 @@ export function transformNestedData(inputData) {
36
35
  const hasAssociationPathName = (parent: unknown): parent is { associationPathName?: string } =>
37
36
  !!parent && typeof parent === 'object' && 'associationPathName' in parent;
38
37
 
38
+ const hasUsableSourceId = (sourceId: unknown) => sourceId !== undefined && sourceId !== null && String(sourceId) !== '';
39
+
39
40
  export class ClickableFieldModel extends FieldModel {
40
41
  get collectionField(): CollectionField {
41
42
  return this.context.collectionField;
@@ -62,14 +63,18 @@ export class ClickableFieldModel extends FieldModel {
62
63
  const parentObj = associationPathName
63
64
  ? get(this.context.blockModel?.form?.getFieldsValue?.(true) || this.context.record, associationPathName)
64
65
  : this.context.record;
66
+ const sourceId = parentObj?.[sourceKey];
67
+ const useAssociationResource = hasUsableSourceId(sourceId);
65
68
  this.dispatchEvent(
66
69
  'click',
67
70
  {
68
71
  event,
69
72
  filterByTk,
70
- collectionName: this.collectionField.collection.name,
71
- associationName: `${sourceCollection.name}.${this.collectionField.name}`, // `${sourceCollection.name}.${this.collectionField.name}`,
72
- sourceId: parentObj[sourceKey],
73
+ collectionName: useAssociationResource
74
+ ? this.collectionField.collection.name
75
+ : targetCollection?.name || this.collectionField.target,
76
+ associationName: useAssociationResource ? `${sourceCollection.name}.${this.collectionField.name}` : null,
77
+ sourceId: useAssociationResource ? sourceId : null,
73
78
  },
74
79
  {
75
80
  debounce: true,
@@ -95,6 +100,10 @@ export class ClickableFieldModel extends FieldModel {
95
100
  const parentObj = associationPathName.includes('.')
96
101
  ? get(this.context.record, associationPathName.split('.')[0])
97
102
  : this.context.record;
103
+ const sourceId = hasUsableSourceId(parentObj?.[sourceKey])
104
+ ? parentObj?.[sourceKey]
105
+ : this.context.record?.[foreignKey];
106
+ const useAssociationResource = hasUsableSourceId(sourceId);
98
107
  let filterByTk = associationRecord?.[targetKey];
99
108
  if (associationField.interface === 'm2m') {
100
109
  // also incorrect for v1
@@ -106,10 +115,13 @@ export class ClickableFieldModel extends FieldModel {
106
115
  {
107
116
  event,
108
117
  filterByTk,
109
- collectionName: this.collectionField.collection.name,
110
- associationName: `${associationField.collection.name}.${this.collectionField.name}`,
111
- // list api, 如果append了关系字段的某个属性,它并不会将关系字段对应的 filterByTk (sourceKey) 属性值返回, 但是会返回foriegnKey对应的值
112
- sourceId: parentObj[sourceKey] || this.context.record[foreignKey],
118
+ collectionName: useAssociationResource
119
+ ? this.collectionField.collection.name
120
+ : targetCollection?.name || associationField.target || this.collectionField.collection.name,
121
+ associationName: useAssociationResource
122
+ ? `${associationField.collection.name}.${this.collectionField.name}`
123
+ : null,
124
+ sourceId: useAssociationResource ? sourceId : null,
113
125
  },
114
126
  {
115
127
  debounce: true,
@@ -12,17 +12,26 @@ import { EditableItemModel, useFlowModelContext } from '@nocobase/flow-engine';
12
12
  import React from 'react';
13
13
  import { DateTimeFieldModel } from './DateTimeFieldModel';
14
14
  import { MobileDatePicker } from '../mobile-components/MobileDatePicker';
15
+ import { useDateLimit } from './dateLimit';
15
16
 
16
17
  export const DateOnlyPicker = (props) => {
17
18
  const { value, format = 'YYYY-MM-DD', picker = 'date', showTime, ...rest } = props;
18
19
  const parsedValue = value && dayjs(value).isValid() ? dayjs(value) : null;
19
20
  const ctx = useFlowModelContext();
21
+ const { disabledDate, disabledTime, minDate, maxDate } = useDateLimit({
22
+ ...props,
23
+ currentForm: ctx.model?.context?.form,
24
+ });
20
25
  const componentProps = {
21
26
  ...rest,
22
27
  value: parsedValue,
23
28
  format,
24
29
  picker,
25
30
  showTime,
31
+ disabledDate,
32
+ disabledTime,
33
+ minDate,
34
+ maxDate,
26
35
  onChange: (val: any) => {
27
36
  const outputFormat = 'YYYY-MM-DD';
28
37
  if (!val) {
@@ -22,5 +22,9 @@ DateTimeFieldModel.registerFlow({
22
22
  use: 'dateDisplayFormat',
23
23
  title: tExpr('Date display format'),
24
24
  },
25
+ dateRangeLimit: {
26
+ use: 'dateRangeLimit',
27
+ title: tExpr('Date range limit'),
28
+ },
25
29
  },
26
30
  });
@@ -12,17 +12,26 @@ import React from 'react';
12
12
  import { EditableItemModel, useFlowModelContext } from '@nocobase/flow-engine';
13
13
  import { DateTimeFieldModel } from './DateTimeFieldModel';
14
14
  import { MobileDatePicker } from '../mobile-components/MobileDatePicker';
15
+ import { useDateLimit } from './dateLimit';
15
16
 
16
17
  export const DateTimeNoTzPicker = (props) => {
17
18
  const { value, format = 'YYYY-MM-DD HH:mm:ss', showTime, picker = 'date', onChange, ...rest } = props;
18
19
  const parsedValue = value ? dayjs(value) : null;
19
20
  const ctx = useFlowModelContext();
21
+ const { disabledDate, disabledTime, minDate, maxDate } = useDateLimit({
22
+ ...props,
23
+ currentForm: ctx.model?.context?.form,
24
+ });
20
25
  const componentProps = {
21
26
  ...rest,
22
27
  value: parsedValue,
23
28
  format,
24
29
  picker,
25
30
  showTime,
31
+ disabledDate,
32
+ disabledTime,
33
+ minDate,
34
+ maxDate,
26
35
  onChange: (val: any) => {
27
36
  if (!val) {
28
37
  return onChange(val);
@@ -12,6 +12,7 @@ import React from 'react';
12
12
  import { DateTimeFieldModel } from './DateTimeFieldModel';
13
13
  import { MobileDatePicker } from '../mobile-components/MobileDatePicker';
14
14
  import { DatePicker } from 'antd';
15
+ import { useDateLimit } from './dateLimit';
15
16
 
16
17
  function parseToDate(value: string | Date | dayjs.Dayjs | undefined, format?: string): Date | undefined {
17
18
  if (!value) return undefined;
@@ -49,12 +50,20 @@ function parseInitialValue(value: string | Date | undefined, format?: string): d
49
50
  export const DateTimeTzPicker = (props) => {
50
51
  const { value, format = 'YYYY-MM-DD HH:mm:ss', picker = 'date', showTime, ...rest } = props;
51
52
  const ctx = useFlowModelContext();
53
+ const { disabledDate, disabledTime, minDate, maxDate } = useDateLimit({
54
+ ...props,
55
+ currentForm: ctx.model?.context?.form,
56
+ });
52
57
  const componentProps = {
53
58
  ...rest,
54
59
  value: parseInitialValue(value, format),
55
60
  format,
56
61
  picker,
57
62
  showTime,
63
+ disabledDate,
64
+ disabledTime,
65
+ minDate,
66
+ maxDate,
58
67
  onChange: (val: any) => {
59
68
  let result = parseToDate(val, format);
60
69
  // Adjust to start of period for month/quarter/year pickers
@@ -0,0 +1,242 @@
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 { Form } from 'antd';
12
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
13
+ import { render, waitFor } from '@nocobase/test/client';
14
+ import { dayjs } from '@nocobase/utils/client';
15
+ import { DateTimeNoTzPicker } from '../DateTimeNoTzFieldModel';
16
+
17
+ let capturedDatePickerProps: any;
18
+ let currentForm: any;
19
+ const mockResolveJsonTemplate = vi.fn();
20
+
21
+ vi.mock('@nocobase/flow-engine', async (importOriginal) => {
22
+ const actual = await importOriginal<typeof import('@nocobase/flow-engine')>();
23
+ return {
24
+ ...actual,
25
+ useFlowModelContext: () => ({
26
+ isMobileLayout: false,
27
+ model: {
28
+ context: {
29
+ form: currentForm,
30
+ },
31
+ },
32
+ }),
33
+ useFlowContext: () => ({
34
+ resolveJsonTemplate: mockResolveJsonTemplate,
35
+ }),
36
+ };
37
+ });
38
+
39
+ vi.mock('antd', async (importOriginal) => {
40
+ const actual = await importOriginal<typeof import('antd')>();
41
+ return {
42
+ ...actual,
43
+ DatePicker: (props: any) => {
44
+ capturedDatePickerProps = props;
45
+ return <div data-testid="date-picker" />;
46
+ },
47
+ Form: actual.Form,
48
+ };
49
+ });
50
+
51
+ const TestWrapper = (props: any) => {
52
+ const [form] = Form.useForm();
53
+ currentForm = form;
54
+
55
+ return (
56
+ <Form form={form} initialValues={{ b: '2026-05-10 12:34:56' }}>
57
+ <DateTimeNoTzPicker {...props} />
58
+ </Form>
59
+ );
60
+ };
61
+
62
+ describe('DateTimeNoTzPicker date range limit', () => {
63
+ beforeEach(() => {
64
+ currentForm = undefined;
65
+ capturedDatePickerProps = undefined;
66
+ mockResolveJsonTemplate.mockReset();
67
+ mockResolveJsonTemplate.mockImplementation(async (params) => ({
68
+ ...params,
69
+ _maxDate: currentForm?.getFieldValue?.('b'),
70
+ }));
71
+ });
72
+
73
+ it('applies maxDate from current form field b', async () => {
74
+ render(<TestWrapper picker="date" showTime _maxDate={'{{ $nForm.b }}'} onChange={vi.fn()} value={null} />);
75
+
76
+ await waitFor(() => {
77
+ expect(mockResolveJsonTemplate).toHaveBeenCalledWith({
78
+ _minDate: undefined,
79
+ _maxDate: '{{ $nForm.b }}',
80
+ });
81
+ expect(capturedDatePickerProps?.disabledDate?.(dayjs('2026-05-11 00:00:00'))).toBe(true);
82
+ });
83
+
84
+ const timeConfig = capturedDatePickerProps?.disabledTime?.(dayjs('2026-05-10 00:00:00'));
85
+ expect(timeConfig?.disabledHours?.()).toContain(13);
86
+ expect(timeConfig?.disabledHours?.()).not.toContain(12);
87
+ expect(timeConfig?.disabledMinutes?.(12)).toContain(35);
88
+ expect(timeConfig?.disabledMinutes?.(12)).not.toContain(34);
89
+ expect(timeConfig?.disabledSeconds?.(12, 34)).toContain(57);
90
+ expect(timeConfig?.disabledSeconds?.(12, 34)).not.toContain(56);
91
+ });
92
+
93
+ it('updates field a maxDate immediately when field b changes', async () => {
94
+ render(<TestWrapper picker="date" showTime _maxDate={'{{ $nForm.b }}'} onChange={vi.fn()} value={null} />);
95
+
96
+ await waitFor(() => {
97
+ expect(capturedDatePickerProps?.disabledDate?.(dayjs('2026-05-11 00:00:00'))).toBe(true);
98
+ });
99
+
100
+ currentForm.setFieldValue('b', '2026-05-12 08:09:10');
101
+
102
+ await waitFor(() => {
103
+ expect(capturedDatePickerProps?.disabledDate?.(dayjs('2026-05-11 00:00:00'))).toBe(false);
104
+ expect(capturedDatePickerProps?.disabledDate?.(dayjs('2026-05-13 00:00:00'))).toBe(true);
105
+ });
106
+
107
+ const timeConfig = capturedDatePickerProps?.disabledTime?.(dayjs('2026-05-12 00:00:00'));
108
+ expect(timeConfig?.disabledHours?.()).toContain(9);
109
+ expect(timeConfig?.disabledHours?.()).not.toContain(8);
110
+ expect(timeConfig?.disabledMinutes?.(8)).toContain(10);
111
+ expect(timeConfig?.disabledMinutes?.(8)).not.toContain(9);
112
+ expect(timeConfig?.disabledSeconds?.(8, 9)).toContain(11);
113
+ expect(timeConfig?.disabledSeconds?.(8, 9)).not.toContain(10);
114
+ });
115
+
116
+ it('applies minDate from current form field b', async () => {
117
+ mockResolveJsonTemplate.mockImplementation(async (params) => ({
118
+ ...params,
119
+ _minDate: currentForm?.getFieldValue?.('b'),
120
+ _maxDate: undefined,
121
+ }));
122
+
123
+ render(<TestWrapper picker="date" showTime _minDate={'{{ $nForm.b }}'} onChange={vi.fn()} value={null} />);
124
+
125
+ await waitFor(() => {
126
+ expect(capturedDatePickerProps?.disabledDate?.(dayjs('2026-05-09 00:00:00'))).toBe(true);
127
+ expect(capturedDatePickerProps?.disabledDate?.(dayjs('2026-05-10 00:00:00'))).toBe(false);
128
+ });
129
+
130
+ const timeConfig = capturedDatePickerProps?.disabledTime?.(dayjs('2026-05-10 00:00:00'));
131
+ expect(timeConfig?.disabledHours?.()).toContain(11);
132
+ expect(timeConfig?.disabledHours?.()).not.toContain(12);
133
+ expect(timeConfig?.disabledMinutes?.(12)).toContain(33);
134
+ expect(timeConfig?.disabledMinutes?.(12)).not.toContain(34);
135
+ expect(timeConfig?.disabledSeconds?.(12, 34)).toContain(55);
136
+ expect(timeConfig?.disabledSeconds?.(12, 34)).not.toContain(56);
137
+ });
138
+
139
+ it('applies both minDate and maxDate together', async () => {
140
+ mockResolveJsonTemplate.mockImplementation(async (params) => ({
141
+ ...params,
142
+ _minDate: '2026-05-10 08:00:00',
143
+ _maxDate: '2026-05-12 18:30:40',
144
+ }));
145
+
146
+ render(
147
+ <TestWrapper
148
+ picker="date"
149
+ showTime
150
+ _minDate={'{{ $nForm.min }}'}
151
+ _maxDate={'{{ $nForm.max }}'}
152
+ onChange={vi.fn()}
153
+ value={null}
154
+ />,
155
+ );
156
+
157
+ await waitFor(() => {
158
+ expect(capturedDatePickerProps?.disabledDate?.(dayjs('2026-05-09 00:00:00'))).toBe(true);
159
+ expect(capturedDatePickerProps?.disabledDate?.(dayjs('2026-05-13 00:00:00'))).toBe(true);
160
+ });
161
+
162
+ expect(capturedDatePickerProps?.disabledDate?.(dayjs('2026-05-11 00:00:00'))).toBe(false);
163
+
164
+ const minDayTimeConfig = capturedDatePickerProps?.disabledTime?.(dayjs('2026-05-10 00:00:00'));
165
+ expect(minDayTimeConfig?.disabledHours?.()).toContain(7);
166
+ expect(minDayTimeConfig?.disabledHours?.()).not.toContain(8);
167
+
168
+ const maxDayTimeConfig = capturedDatePickerProps?.disabledTime?.(dayjs('2026-05-12 00:00:00'));
169
+ expect(maxDayTimeConfig?.disabledHours?.()).toContain(19);
170
+ expect(maxDayTimeConfig?.disabledHours?.()).not.toContain(18);
171
+ expect(maxDayTimeConfig?.disabledMinutes?.(18)).toContain(31);
172
+ expect(maxDayTimeConfig?.disabledMinutes?.(18)).not.toContain(30);
173
+ expect(maxDayTimeConfig?.disabledSeconds?.(18, 30)).toContain(41);
174
+ expect(maxDayTimeConfig?.disabledSeconds?.(18, 30)).not.toContain(40);
175
+ });
176
+
177
+ it('uses first minDate and last maxDate when resolved values are arrays', async () => {
178
+ mockResolveJsonTemplate.mockImplementation(async (params) => ({
179
+ ...params,
180
+ _minDate: ['2026-05-10 08:00:00', '2026-05-11 09:00:00'],
181
+ _maxDate: ['2026-05-12 10:00:00', '2026-05-13 11:12:13'],
182
+ }));
183
+
184
+ render(
185
+ <TestWrapper
186
+ picker="date"
187
+ showTime
188
+ _minDate={'{{ $nForm.min }}'}
189
+ _maxDate={'{{ $nForm.max }}'}
190
+ onChange={vi.fn()}
191
+ value={null}
192
+ />,
193
+ );
194
+
195
+ await waitFor(() => {
196
+ expect(capturedDatePickerProps?.disabledDate?.(dayjs('2026-05-09 00:00:00'))).toBe(true);
197
+ expect(capturedDatePickerProps?.disabledDate?.(dayjs('2026-05-14 00:00:00'))).toBe(true);
198
+ });
199
+
200
+ expect(capturedDatePickerProps?.disabledDate?.(dayjs('2026-05-10 00:00:00'))).toBe(false);
201
+ expect(capturedDatePickerProps?.disabledDate?.(dayjs('2026-05-13 00:00:00'))).toBe(false);
202
+
203
+ const timeConfig = capturedDatePickerProps?.disabledTime?.(dayjs('2026-05-13 00:00:00'));
204
+ expect(timeConfig?.disabledHours?.()).toContain(12);
205
+ expect(timeConfig?.disabledHours?.()).not.toContain(11);
206
+ expect(timeConfig?.disabledMinutes?.(11)).toContain(13);
207
+ expect(timeConfig?.disabledMinutes?.(11)).not.toContain(12);
208
+ expect(timeConfig?.disabledSeconds?.(11, 12)).toContain(14);
209
+ expect(timeConfig?.disabledSeconds?.(11, 12)).not.toContain(13);
210
+ });
211
+
212
+ it('clears date and time restrictions when resolved values are empty', async () => {
213
+ mockResolveJsonTemplate.mockImplementation(async (params) => ({
214
+ ...params,
215
+ _minDate: undefined,
216
+ _maxDate: undefined,
217
+ }));
218
+
219
+ render(
220
+ <TestWrapper
221
+ picker="date"
222
+ showTime
223
+ _minDate={'{{ $nForm.min }}'}
224
+ _maxDate={'{{ $nForm.max }}'}
225
+ onChange={vi.fn()}
226
+ value={null}
227
+ />,
228
+ );
229
+
230
+ await waitFor(() => {
231
+ expect(capturedDatePickerProps).toBeTruthy();
232
+ });
233
+
234
+ expect(capturedDatePickerProps?.disabledDate?.(dayjs('2026-05-01 00:00:00'))).toBe(false);
235
+ expect(capturedDatePickerProps?.disabledDate?.(dayjs('2026-05-31 00:00:00'))).toBe(false);
236
+
237
+ const timeConfig = capturedDatePickerProps?.disabledTime?.(dayjs('2026-05-15 00:00:00'));
238
+ expect(timeConfig?.disabledHours?.()).toEqual([]);
239
+ expect(timeConfig?.disabledMinutes?.(12)).toEqual([]);
240
+ expect(timeConfig?.disabledSeconds?.(12, 34)).toEqual([]);
241
+ });
242
+ });