@nocobase/client-v2 2.1.0-beta.26 → 2.1.0-beta.29

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 (83) 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 +1 -0
  5. package/es/flow/components/code-editor/types.d.ts +1 -0
  6. package/es/flow/models/base/PageModel/PageModel.d.ts +4 -0
  7. package/es/flow/models/base/PageModel/RootPageModel.d.ts +9 -0
  8. package/es/flow/models/blocks/filter-form/FilterFormGridModel.d.ts +15 -6
  9. package/es/flow/models/blocks/form/value-runtime/runtime.d.ts +7 -0
  10. package/es/flow/models/blocks/shared/filterOperators.d.ts +9 -0
  11. package/es/flow/models/fields/AssociationFieldModel/SubTableFieldModel/SubTableColumnModel.d.ts +2 -0
  12. package/es/flow/models/fields/DateTimeFieldModel/dateLimit.d.ts +20 -0
  13. package/es/flow/models/fields/JSEditableFieldModel.d.ts +4 -0
  14. package/es/flow-compat/data.d.ts +9 -2
  15. package/es/flow-compat/index.d.ts +1 -1
  16. package/es/index.d.ts +1 -1
  17. package/es/index.mjs +97 -90
  18. package/lib/index.js +99 -92
  19. package/package.json +6 -5
  20. package/src/BaseApplication.tsx +1 -1
  21. package/src/__tests__/app.test.tsx +23 -6
  22. package/src/components/form/JsonTextArea.tsx +129 -0
  23. package/src/components/index.ts +1 -0
  24. package/src/flow/actions/__tests__/fieldLinkageRules.scopeDepth.test.ts +478 -0
  25. package/src/flow/actions/__tests__/pattern.test.ts +190 -0
  26. package/src/flow/actions/dateRangeLimit.tsx +66 -0
  27. package/src/flow/actions/index.ts +1 -0
  28. package/src/flow/actions/linkageRules.tsx +117 -19
  29. package/src/flow/actions/openView.tsx +2 -1
  30. package/src/flow/actions/pattern.tsx +25 -2
  31. package/src/flow/actions/titleField.tsx +8 -3
  32. package/src/flow/admin-shell/AdminLayoutRouteCoordinator.ts +7 -1
  33. package/src/flow/admin-shell/__tests__/AdminLayoutRouteCoordinator.test.ts +117 -0
  34. package/src/flow/components/FieldAssignValueInput.tsx +1 -0
  35. package/src/flow/components/code-editor/__tests__/linter.test.ts +18 -0
  36. package/src/flow/components/code-editor/__tests__/runjsDiagnostics.test.ts +23 -0
  37. package/src/flow/components/code-editor/index.tsx +18 -17
  38. package/src/flow/components/code-editor/linter.ts +222 -158
  39. package/src/flow/components/code-editor/runjsDiagnostics.ts +161 -97
  40. package/src/flow/components/code-editor/types.ts +1 -0
  41. package/src/flow/components/filter/LinkageFilterItem.tsx +6 -5
  42. package/src/flow/components/filter/VariableFilterItem.tsx +14 -13
  43. package/src/flow/components/filter/__tests__/LinkageFilterItem.test.tsx +33 -0
  44. package/src/flow/components/filter/__tests__/VariableFilterItem.test.tsx +48 -5
  45. package/src/flow/internal/utils/__tests__/titleFieldQuickSync.test.ts +1 -0
  46. package/src/flow/internal/utils/titleFieldQuickSync.ts +2 -2
  47. package/src/flow/models/actions/FilterActionModel.tsx +17 -9
  48. package/src/flow/models/base/PageModel/PageModel.tsx +15 -3
  49. package/src/flow/models/base/PageModel/RootPageModel.tsx +37 -2
  50. package/src/flow/models/base/PageModel/__tests__/PageModel.test.ts +73 -0
  51. package/src/flow/models/base/PageModel/__tests__/RootPageModel.test.ts +116 -0
  52. package/src/flow/models/blocks/filter-form/FilterFormGridModel.tsx +200 -36
  53. package/src/flow/models/blocks/filter-form/__tests__/FilterFormGridModel.toggleFormFieldsCollapse.test.ts +270 -1
  54. package/src/flow/models/blocks/filter-form/__tests__/customFieldOperators.test.tsx +23 -0
  55. package/src/flow/models/blocks/filter-form/customFieldOperators.ts +12 -1
  56. package/src/flow/models/blocks/filter-form/fields/FieldComponentProps.tsx +22 -8
  57. package/src/flow/models/blocks/filter-form/fields/__tests__/FilterFormCustomFieldModel.recordSelect.test.tsx +18 -0
  58. package/src/flow/models/blocks/filter-manager/FilterManager.ts +51 -1
  59. package/src/flow/models/blocks/filter-manager/__tests__/FilterManager.test.ts +75 -0
  60. package/src/flow/models/blocks/form/FormItemModel.tsx +48 -28
  61. package/src/flow/models/blocks/form/value-runtime/__tests__/runtime.test.ts +167 -1
  62. package/src/flow/models/blocks/form/value-runtime/runtime.ts +103 -11
  63. package/src/flow/models/blocks/shared/filterOperators.ts +14 -0
  64. package/src/flow/models/blocks/table/TableBlockModel.tsx +19 -3
  65. package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/SubTableColumnModel.tsx +27 -3
  66. package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/SubTableField.tsx +47 -0
  67. package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/__tests__/SubTableColumnModel.rowRecord.test.ts +42 -0
  68. package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/__tests__/SubTableField.refresh.test.tsx +122 -0
  69. package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/index.tsx +2 -0
  70. package/src/flow/models/fields/ClickableFieldModel.tsx +21 -9
  71. package/src/flow/models/fields/DateTimeFieldModel/DateOnlyFieldModel.tsx +9 -0
  72. package/src/flow/models/fields/DateTimeFieldModel/DateTimeFieldModel.tsx +4 -0
  73. package/src/flow/models/fields/DateTimeFieldModel/DateTimeNoTzFieldModel.tsx +9 -0
  74. package/src/flow/models/fields/DateTimeFieldModel/DateTimeTzFieldModel.tsx +9 -0
  75. package/src/flow/models/fields/DateTimeFieldModel/__tests__/DateTimeNoTzFieldModel.dateLimit.test.tsx +242 -0
  76. package/src/flow/models/fields/DateTimeFieldModel/dateLimit.ts +152 -0
  77. package/src/flow/models/fields/DividerItemModel.tsx +30 -15
  78. package/src/flow/models/fields/JSEditableFieldModel.tsx +110 -14
  79. package/src/flow/models/fields/__tests__/ClickableFieldModel.test.ts +87 -0
  80. package/src/flow/models/fields/__tests__/JSEditableFieldModel.test.tsx +210 -0
  81. package/src/flow-compat/data.ts +25 -3
  82. package/src/flow-compat/index.ts +7 -1
  83. package/src/index.ts +1 -1
@@ -14,8 +14,41 @@ import { useTranslation } from 'react-i18next';
14
14
  import { PlusOutlined } from '@ant-design/icons';
15
15
  import React, { useEffect, useMemo, useState } from 'react';
16
16
  import { ActionWithoutPermission } from '../../../base/ActionModel';
17
+ import { parsePathString } from '../../../blocks/form/value-runtime/path';
17
18
  import { getSubTableRowIdentity, normalizeSubTableRows } from './rowIdentity';
18
19
 
20
+ type NamePath = Array<string | number>;
21
+
22
+ function isSamePathPrefix(prefix: NamePath, path: NamePath) {
23
+ if (!prefix.length || prefix.length > path.length) return false;
24
+ return prefix.every((seg, index) => seg === path[index]);
25
+ }
26
+
27
+ function isRelatedPath(a: NamePath, b: NamePath) {
28
+ return isSamePathPrefix(a, b) || isSamePathPrefix(b, a);
29
+ }
30
+
31
+ function normalizeChangedPath(path: unknown): NamePath | null {
32
+ const rawPath = Array.isArray(path) ? path : typeof path === 'string' ? [path] : null;
33
+ if (!rawPath) return null;
34
+ const normalized = rawPath.flatMap((seg) => {
35
+ if (typeof seg === 'number') return [seg];
36
+ if (typeof seg !== 'string') return [];
37
+ return parsePathString(seg).filter((parsed): parsed is string | number => typeof parsed !== 'object');
38
+ });
39
+ return normalized.length ? normalized : null;
40
+ }
41
+
42
+ function shouldRefreshForChangedPaths(fieldPath: unknown, changedPaths: unknown) {
43
+ const currentFieldPath = normalizeChangedPath(fieldPath);
44
+ if (!currentFieldPath) return false;
45
+ const paths = Array.isArray(changedPaths) ? changedPaths : [];
46
+ return paths.some((path) => {
47
+ const changedPath = normalizeChangedPath(path);
48
+ return changedPath ? isRelatedPath(currentFieldPath, changedPath) : false;
49
+ });
50
+ }
51
+
19
52
  export function SubTableField(props) {
20
53
  const { t } = useTranslation();
21
54
  const {
@@ -35,9 +68,12 @@ export function SubTableField(props) {
35
68
  resetPage,
36
69
  filterTargetKey = 'id',
37
70
  getCurrentValue,
71
+ fieldPathArray,
72
+ formValuesChangeEmitter,
38
73
  } = props;
39
74
  const [currentPage, setCurrentPage] = useState(1);
40
75
  const [currentPageSize, setCurrentPageSize] = useState(pageSize);
76
+ const [, forceRefresh] = useState(0);
41
77
  const rawCurrentValue = getCurrentValue();
42
78
  const currentValue = useMemo(() => normalizeSubTableRows(rawCurrentValue), [rawCurrentValue]);
43
79
  const getRecordIdentity = React.useCallback(
@@ -50,6 +86,17 @@ export function SubTableField(props) {
50
86
  useEffect(() => {
51
87
  resetPage && setCurrentPage(1);
52
88
  }, [resetPage]);
89
+ useEffect(() => {
90
+ if (!formValuesChangeEmitter?.on || !formValuesChangeEmitter?.off) return;
91
+ const listener = (payload: any) => {
92
+ if (!shouldRefreshForChangedPaths(fieldPathArray, payload?.changedPaths)) return;
93
+ forceRefresh((v) => v + 1);
94
+ };
95
+ formValuesChangeEmitter.on('formValuesChange', listener);
96
+ return () => {
97
+ formValuesChangeEmitter.off('formValuesChange', listener);
98
+ };
99
+ }, [fieldPathArray, formValuesChangeEmitter]);
53
100
  const applyValue = React.useCallback((nextValue: any) => onChange?.(normalizeSubTableRows(nextValue)), [onChange]);
54
101
  const getLatestValue = React.useCallback(() => normalizeSubTableRows(getCurrentValue()), [getCurrentValue]);
55
102
  useEffect(() => {
@@ -0,0 +1,42 @@
1
+ /**
2
+ * This file is part of the NocoBase (R) project.
3
+ * Copyright (c) 2020-2024 NocoBase Co., Ltd.
4
+ * Authors: NocoBase Team.
5
+ *
6
+ * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
7
+ * For more information, please refer to: https://www.nocobase.com/agreement.
8
+ */
9
+
10
+ import { describe, expect, it, vi } from 'vitest';
11
+ import { getLatestSubTableRowRecord, buildRowPathFromFieldIndex } from '../SubTableColumnModel';
12
+
13
+ describe('SubTableColumnModel row record helpers', () => {
14
+ it('builds the row path from fieldIndex entries', () => {
15
+ expect(buildRowPathFromFieldIndex(['roles:0'])).toEqual(['roles', 0]);
16
+ expect(buildRowPathFromFieldIndex(['users:1', 'roles:2'])).toEqual(['users', 1, 'roles', 2]);
17
+ });
18
+
19
+ it('prefers the latest row value from form over the fallback record', () => {
20
+ const form = {
21
+ getFieldValue: vi.fn((path: any) => {
22
+ if (JSON.stringify(path) === JSON.stringify(['roles', 0])) {
23
+ return { uid: 'role-uid-1', __is_new__: true };
24
+ }
25
+ }),
26
+ };
27
+ const fallback = { uid: 'stale-role', __is_new__: false };
28
+
29
+ expect(getLatestSubTableRowRecord(form, ['roles:0'], fallback)).toEqual({
30
+ uid: 'role-uid-1',
31
+ __is_new__: true,
32
+ });
33
+ expect(form.getFieldValue).toHaveBeenCalledWith(['roles', 0]);
34
+ });
35
+
36
+ it('falls back to the record when latest row value is unavailable', () => {
37
+ const form = { getFieldValue: vi.fn(() => undefined) };
38
+ const fallback = { uid: 'stale-role', __is_new__: false };
39
+
40
+ expect(getLatestSubTableRowRecord(form, ['roles:0'], fallback)).toBe(fallback);
41
+ });
42
+ });
@@ -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
+ });
@@ -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
  );
@@ -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
+ });