@nocobase/client-v2 2.1.0-beta.36 → 2.1.0-beta.38

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 (154) hide show
  1. package/es/Application.d.ts +1 -0
  2. package/es/BaseApplication.d.ts +4 -0
  3. package/es/RouterManager.d.ts +1 -0
  4. package/es/components/KeepAlive.d.ts +22 -0
  5. package/es/components/RouterBridge.d.ts +9 -0
  6. package/es/components/form/DialogFormLayout.d.ts +5 -29
  7. package/es/components/form/filter/CollectionFilter.d.ts +41 -0
  8. package/es/components/form/filter/CollectionFilterItem.d.ts +41 -0
  9. package/es/components/form/filter/DateFilterDynamicComponent.d.ts +57 -0
  10. package/es/components/form/filter/FilterValueInput.d.ts +29 -0
  11. package/es/components/form/filter/index.d.ts +11 -0
  12. package/es/components/form/filter/useFilterActionProps.d.ts +96 -0
  13. package/es/components/form/index.d.ts +1 -0
  14. package/es/data-source/ExtendCollectionsProvider.d.ts +50 -0
  15. package/es/data-source/index.d.ts +9 -0
  16. package/es/flow/FlowPage.d.ts +2 -1
  17. package/es/flow/admin-shell/AdminLayoutRouteCoordinator.d.ts +8 -40
  18. package/es/flow/admin-shell/BaseLayoutModel.d.ts +89 -0
  19. package/es/flow/admin-shell/BaseLayoutRouteCoordinator.d.ts +74 -0
  20. package/es/flow/admin-shell/admin-layout/AdminLayoutEntryGuard.d.ts +12 -0
  21. package/es/flow/admin-shell/admin-layout/AdminLayoutModel.d.ts +7 -92
  22. package/es/flow/admin-shell/admin-layout/index.d.ts +2 -0
  23. package/es/flow/admin-shell/useAdminLayoutRoutePage.d.ts +2 -2
  24. package/es/flow/admin-shell/useLayoutRoutePage.d.ts +23 -0
  25. package/es/flow/components/FlowRoute.d.ts +10 -1
  26. package/es/flow/components/filter/index.d.ts +2 -0
  27. package/es/flow/components/filter/useFilterOptions.d.ts +54 -0
  28. package/es/flow/index.d.ts +4 -0
  29. package/es/flow/models/base/PageModel/PageModel.d.ts +3 -1
  30. package/es/flow/models/blocks/form/FormActionGroupModel.d.ts +1 -0
  31. package/es/flow/models/blocks/table/TableBlockModel.d.ts +10 -0
  32. package/es/flow/models/fields/AssociationFieldModel/SubTableFieldModel/index.d.ts +1 -1
  33. package/es/flow-compat/passwordUtils.d.ts +1 -1
  34. package/es/index.d.ts +2 -0
  35. package/es/index.mjs +491 -439
  36. package/es/layout-manager/LayoutContentRoute.d.ts +14 -0
  37. package/es/layout-manager/LayoutManager.d.ts +22 -0
  38. package/es/layout-manager/LayoutRoute.d.ts +14 -0
  39. package/es/layout-manager/index.d.ts +13 -0
  40. package/es/layout-manager/types.d.ts +20 -0
  41. package/es/layout-manager/utils.d.ts +14 -0
  42. package/es/nocobase-buildin-plugin/index.d.ts +3 -10
  43. package/es/settings-center/index.d.ts +1 -1
  44. package/es/settings-center/plugin-manager/BulkEnableButton.d.ts +15 -0
  45. package/es/settings-center/plugin-manager/PluginCard.d.ts +15 -0
  46. package/es/settings-center/plugin-manager/PluginDetail.d.ts +16 -0
  47. package/es/settings-center/{PluginManagerPage.d.ts → plugin-manager/index.d.ts} +1 -7
  48. package/es/settings-center/plugin-manager/types.d.ts +34 -0
  49. package/lib/index.js +491 -439
  50. package/package.json +8 -7
  51. package/src/Application.tsx +27 -12
  52. package/src/BaseApplication.tsx +19 -0
  53. package/src/PluginSettingsManager.ts +1 -1
  54. package/src/RouterManager.tsx +17 -1
  55. package/src/__tests__/PluginSettingsManager.test.ts +41 -2
  56. package/src/__tests__/app.test.tsx +17 -1
  57. package/src/__tests__/globalDeps.test.ts +1 -0
  58. package/src/__tests__/nocobase-buildin-plugin-auth.test.tsx +45 -2
  59. package/src/__tests__/plugin-manager.test.tsx +177 -0
  60. package/src/__tests__/settings-center.test.tsx +24 -2
  61. package/src/components/KeepAlive.tsx +131 -0
  62. package/src/components/README.md +89 -6
  63. package/src/components/README.zh-CN.md +89 -7
  64. package/src/components/RouterBridge.tsx +28 -4
  65. package/src/components/__tests__/KeepAlive.test.tsx +63 -0
  66. package/src/components/__tests__/RouterBridge.test.tsx +27 -0
  67. package/src/components/form/DialogFormLayout.tsx +5 -29
  68. package/src/components/form/filter/CollectionFilter.tsx +101 -0
  69. package/src/components/form/filter/CollectionFilterItem.tsx +176 -0
  70. package/src/components/form/filter/DateFilterDynamicComponent.tsx +283 -0
  71. package/src/components/form/filter/FilterValueInput.tsx +198 -0
  72. package/src/components/form/filter/__tests__/CollectionFilterItem.test.tsx +205 -0
  73. package/src/components/form/filter/__tests__/DateFilterDynamicComponent.test.tsx +148 -0
  74. package/src/components/form/filter/__tests__/FilterValueInput.test.tsx +243 -0
  75. package/src/components/form/filter/__tests__/compileFilterGroup.test.ts +146 -0
  76. package/src/components/form/filter/index.ts +13 -0
  77. package/src/components/form/filter/useFilterActionProps.ts +200 -0
  78. package/src/components/form/index.tsx +1 -0
  79. package/src/data-source/ExtendCollectionsProvider.tsx +144 -0
  80. package/src/data-source/__tests__/ExtendCollectionsProvider.test.tsx +264 -0
  81. package/src/data-source/index.ts +10 -0
  82. package/src/flow/FlowPage.tsx +35 -7
  83. package/src/flow/__tests__/FlowPage.test.tsx +79 -0
  84. package/src/flow/__tests__/FlowRoute.test.tsx +529 -2
  85. package/src/flow/actions/__tests__/linkageRules.subFormSetFieldProps.test.ts +191 -0
  86. package/src/flow/actions/__tests__/openView.subModelKey.test.tsx +33 -0
  87. package/src/flow/actions/aclCheck.tsx +4 -0
  88. package/src/flow/actions/aclCheckRefresh.tsx +4 -0
  89. package/src/flow/actions/dateTimeFormat.tsx +12 -8
  90. package/src/flow/actions/linkageRules.tsx +122 -0
  91. package/src/flow/actions/openView.tsx +28 -4
  92. package/src/flow/admin-shell/AdminLayoutRouteCoordinator.ts +11 -329
  93. package/src/flow/admin-shell/BaseLayoutModel.tsx +455 -0
  94. package/src/flow/admin-shell/BaseLayoutRouteCoordinator.ts +502 -0
  95. package/src/flow/admin-shell/__tests__/AdminLayoutRouteCoordinator.test.ts +547 -3
  96. package/src/flow/admin-shell/admin-layout/AdminLayoutComponent.tsx +4 -4
  97. package/src/flow/admin-shell/admin-layout/AdminLayoutEntryGuard.tsx +160 -0
  98. package/src/flow/admin-shell/admin-layout/AdminLayoutMenuModels.tsx +0 -12
  99. package/src/flow/admin-shell/admin-layout/AdminLayoutModel.tsx +28 -201
  100. package/src/flow/admin-shell/admin-layout/AdminLayoutSlotModels.tsx +11 -2
  101. package/src/flow/admin-shell/admin-layout/__tests__/AdminLayoutMenuModels.test.ts +1 -26
  102. package/src/flow/admin-shell/admin-layout/__tests__/AdminLayoutModel.test.tsx +149 -27
  103. package/src/flow/admin-shell/admin-layout/index.ts +2 -0
  104. package/src/flow/admin-shell/useAdminLayoutRoutePage.ts +10 -26
  105. package/src/flow/admin-shell/useLayoutRoutePage.ts +61 -0
  106. package/src/flow/components/AdminLayout.tsx +4 -154
  107. package/src/flow/components/FlowRoute.tsx +105 -15
  108. package/src/flow/components/filter/index.ts +3 -0
  109. package/src/flow/components/filter/useFilterOptions.ts +80 -0
  110. package/src/flow/index.ts +4 -0
  111. package/src/flow/models/base/ActionModel.tsx +8 -1
  112. package/src/flow/models/base/PageModel/PageModel.tsx +51 -18
  113. package/src/flow/models/base/PageModel/RootPageModel.tsx +6 -13
  114. package/src/flow/models/base/PageModel/__tests__/PageModel.test.ts +102 -1
  115. package/src/flow/models/base/RouteModel.tsx +1 -1
  116. package/src/flow/models/blocks/form/FormActionGroupModel.tsx +14 -0
  117. package/src/flow/models/blocks/form/FormItemModel.tsx +8 -1
  118. package/src/flow/models/blocks/form/__tests__/FormActionGroupModel.test.ts +46 -0
  119. package/src/flow/models/blocks/form/submitValues.ts +4 -1
  120. package/src/flow/models/blocks/table/TableBlockModel.tsx +118 -16
  121. package/src/flow/models/blocks/table/__tests__/TableBlockModel.rowSelection.test.tsx +114 -0
  122. package/src/flow/models/fields/AssociationFieldModel/SubFormFieldModel.tsx +7 -1
  123. package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/SubTableField.tsx +1 -1
  124. package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/index.tsx +6 -5
  125. package/src/flow/models/fields/ClickableFieldModel.tsx +9 -1
  126. package/src/flow/models/fields/DisplayTimeFieldModel.tsx +1 -1
  127. package/src/flow/models/fields/TimeFieldModel.tsx +1 -1
  128. package/src/flow/models/fields/__tests__/TimeFieldModel.test.tsx +61 -0
  129. package/src/flow/models/fields/mobile-components/MobileDatePicker.tsx +19 -3
  130. package/src/flow/models/fields/mobile-components/__tests__/MobileDatePicker.test.tsx +94 -0
  131. package/src/flow/models/topbar/TopbarActionModel.tsx +1 -1
  132. package/src/flow/utils/__tests__/dateTimeFormat.test.ts +91 -0
  133. package/src/index.ts +2 -0
  134. package/src/layout-manager/LayoutContentRoute.tsx +90 -0
  135. package/src/layout-manager/LayoutManager.tsx +185 -0
  136. package/src/layout-manager/LayoutRoute.tsx +138 -0
  137. package/src/layout-manager/__tests__/LayoutManager.test.tsx +335 -0
  138. package/src/layout-manager/__tests__/LayoutRoute.test.tsx +473 -0
  139. package/src/layout-manager/index.ts +14 -0
  140. package/src/layout-manager/types.ts +22 -0
  141. package/src/layout-manager/utils.ts +37 -0
  142. package/src/nocobase-buildin-plugin/index.tsx +69 -67
  143. package/src/nocobase-buildin-plugin/plugins/LocalePlugin.ts +1 -0
  144. package/src/settings-center/index.ts +1 -1
  145. package/src/settings-center/plugin-manager/BulkEnableButton.tsx +111 -0
  146. package/src/settings-center/plugin-manager/PluginCard.tsx +270 -0
  147. package/src/settings-center/plugin-manager/PluginDetail.tsx +195 -0
  148. package/src/settings-center/plugin-manager/index.tsx +254 -0
  149. package/src/settings-center/plugin-manager/types.ts +35 -0
  150. package/src/settings-center/utils.tsx +8 -1
  151. package/src/theme/__tests__/globalStyles.test.ts +24 -0
  152. package/src/theme/globalStyles.ts +10 -0
  153. package/src/utils/globalDeps.ts +2 -0
  154. package/src/settings-center/PluginManagerPage.tsx +0 -162
@@ -0,0 +1,101 @@
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 { FilterOutlined } from '@ant-design/icons';
11
+ import type { Collection } from '@nocobase/flow-engine';
12
+ import { Button, type ButtonProps, Popover, type PopoverProps } from 'antd';
13
+ import React, { FC, useState } from 'react';
14
+ import { FilterContent } from '../../../flow/components/filter';
15
+ import { CompiledFilter, FilterApplyAction, useFilterActionProps } from './useFilterActionProps';
16
+
17
+ const identity = (s: string) => s;
18
+
19
+ export interface CollectionFilterProps {
20
+ /** Collection whose fields drive the filter row's field picker. */
21
+ collection: Collection | undefined;
22
+ /** Called on Submit or Reset with the compiled NocoBase filter param (`undefined` when cleared). */
23
+ onChange: (filter: CompiledFilter) => void;
24
+ /** Translator. Defaults to identity. */
25
+ t?: (key: string, options?: Record<string, any>) => string;
26
+ /** Whitelist of root-level field names to expose. */
27
+ filterableFieldNames?: string[];
28
+ /** Bypass the `filterableFieldNames` whitelist. */
29
+ noIgnore?: boolean;
30
+ /** Override the trigger button's label. Defaults to `t('Filter')`, or the v1-style `t('{{count}} filter items', { count })` when conditions are present. */
31
+ buttonText?: React.ReactNode;
32
+ /** Swap the default `t('Filter')` label for v1's `t('{{count}} filter items', { count })` when conditions are present. Defaults to `true`. */
33
+ showCount?: boolean;
34
+ /** Pass-through props for the antd `<Popover>`. */
35
+ popoverProps?: Omit<PopoverProps, 'open' | 'onOpenChange' | 'content' | 'children'>;
36
+ /** Pass-through props for the trigger `<Button>`. */
37
+ buttonProps?: Omit<ButtonProps, 'icon' | 'type' | 'children' | 'onClick'>;
38
+ /** Min-width applied to the popover body. Defaults to `520`. */
39
+ popoverMinWidth?: number;
40
+ }
41
+
42
+ /**
43
+ * Filter button bound to a collection. Renders an antd `<Popover>` over a `<Button>`; the popover hosts a multi-condition filter form (field picker, operator, value). Submit dismisses the popover and emits the compiled filter via `onChange`; Reset keeps the popover open and emits `undefined`.
44
+ *
45
+ * Pair with `<ExtendCollectionsProvider>` when the target collection is client-only (e.g. a `schema-only` server collection that isn't auto-published to the v2 data source).
46
+ */
47
+ export const CollectionFilter: FC<CollectionFilterProps> = (props) => {
48
+ const {
49
+ collection,
50
+ onChange,
51
+ t = identity,
52
+ filterableFieldNames,
53
+ noIgnore,
54
+ buttonText,
55
+ showCount = true,
56
+ popoverProps,
57
+ buttonProps,
58
+ popoverMinWidth = 520,
59
+ } = props;
60
+
61
+ const [open, setOpen] = useState(false);
62
+
63
+ const filterAction = useFilterActionProps({
64
+ collection,
65
+ filterableFieldNames,
66
+ noIgnore,
67
+ t,
68
+ onApply: (filter: CompiledFilter, action: FilterApplyAction) => {
69
+ onChange(filter);
70
+ if (action === 'submit') setOpen(false);
71
+ },
72
+ });
73
+
74
+ // Matches v1's `Filter.Action`: when at least one condition is set, the button label switches to the count-aware string (`"N 个筛选项"` in zh-CN). The button itself stays in the default (white) style — v1 never flipped it to `type='primary'`.
75
+ const label =
76
+ buttonText ??
77
+ (showCount && filterAction.conditionCount > 0
78
+ ? t('{{count}} filter items', { count: filterAction.conditionCount })
79
+ : t('Filter'));
80
+
81
+ return (
82
+ <Popover
83
+ trigger="click"
84
+ placement="bottomLeft"
85
+ {...popoverProps}
86
+ open={open}
87
+ onOpenChange={setOpen}
88
+ content={
89
+ <div style={{ minWidth: popoverMinWidth }}>
90
+ <FilterContent value={filterAction.value} ctx={filterAction.ctx} FilterItem={filterAction.FilterItem} />
91
+ </div>
92
+ }
93
+ >
94
+ <Button icon={<FilterOutlined />} disabled={!collection} {...buttonProps}>
95
+ {label}
96
+ </Button>
97
+ </Popover>
98
+ );
99
+ };
100
+
101
+ export default CollectionFilter;
@@ -0,0 +1,176 @@
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 { css } from '@emotion/css';
11
+ import { Collection, observer } from '@nocobase/flow-engine';
12
+ import { Cascader, Select, Space } from 'antd';
13
+ import React, { FC, useMemo } from 'react';
14
+ import { FilterOption, useFilterOptions } from '../../../flow/components/filter/useFilterOptions';
15
+ import { FilterValueInput } from './FilterValueInput';
16
+
17
+ /**
18
+ * Lift the Cascader sub-menu height so a target collection with many fields (e.g. `users` → id / nickname / username / email / phone / password / createdAt / updatedAt / roles / createdBy / updatedBy) doesn't get truncated below the default antd menu viewport.
19
+ */
20
+ const cascaderPopupClass = css`
21
+ .ant-cascader-menu {
22
+ height: fit-content;
23
+ max-height: 50vh;
24
+ }
25
+ `;
26
+
27
+ export interface CollectionFilterItemValue {
28
+ path: string;
29
+ operator: string;
30
+ /** Operator-dependent — string for default ops, descriptor for $date*, etc. */
31
+ value: any;
32
+ }
33
+
34
+ type CascaderOption = {
35
+ value: string;
36
+ label: string;
37
+ children?: CascaderOption[];
38
+ };
39
+
40
+ export interface CollectionFilterItemProps {
41
+ /** Reactive filter row managed by the parent `FilterContainer`. */
42
+ value: CollectionFilterItemValue;
43
+ /** Target collection whose fields populate the field selector. */
44
+ collection: Collection;
45
+ /** Whitelist of field names to expose; empty/undefined means all filterable fields. */
46
+ filterableFieldNames?: string[];
47
+ /** Bypass the `filterableFieldNames` whitelist (matches the legacy FilterItem `noIgnore`). */
48
+ noIgnore?: boolean;
49
+ /** Translator; defaults to identity so callers can omit it. */
50
+ t?: (key: string) => string;
51
+ }
52
+
53
+ const identity = (s: string) => s;
54
+
55
+ /**
56
+ * Walk a tree of field options by name path, returning the leaf option (or undefined when the path doesn't resolve). Used to look up operators for the currently selected field.
57
+ */
58
+ const findOptionByPath = (options: FilterOption[], path: string[]): FilterOption | undefined => {
59
+ if (!path.length) return undefined;
60
+ const [head, ...rest] = path;
61
+ const match = options.find((option) => option.name === head);
62
+ if (!match) return undefined;
63
+ if (!rest.length) return match;
64
+ return findOptionByPath(match.children || [], rest);
65
+ };
66
+
67
+ /**
68
+ * Convert the field-option tree (as produced by `useFilterOptions`) into antd `Cascader`'s expected `{ value, label, children }` shape. With `changeOnSelect={false}` (see the render below), antd already requires selection at a leaf — we don't need to mark association parents as `disabled`, and doing so would also block `expandTrigger="hover"` from descending into them.
69
+ */
70
+ const toCascaderOptions = (options: FilterOption[]): CascaderOption[] =>
71
+ options.map((option) => {
72
+ const children = option.children?.length ? toCascaderOptions(option.children) : undefined;
73
+ return {
74
+ value: option.name,
75
+ label: option.title,
76
+ children,
77
+ };
78
+ });
79
+
80
+ /**
81
+ * Filter row bound directly to a `Collection`, with no `FlowModel` dependency. Use this from settings pages or other surfaces that need a filter UI but don't have (and shouldn't synthesise) a block model just to satisfy `FilterItem`. Pair with `FilterContainer` via either an inline wrapper or `createCollectionFilterItem(collection)`.
82
+ *
83
+ * The field selector is an antd `Cascader`, mirroring v1's `Filter.Action` so association fields (belongsTo / m2o / etc.) can be drilled into — picking `user.username` is a first-class action. The value renderer is delegated to `FilterValueInput`, which dispatches to interface-specific controls (the smart date picker for `$date*` operators, tag-mode Select for array/enum, etc.) the same way v1's `DynamicComponent` did.
84
+ */
85
+ export const CollectionFilterItem: FC<CollectionFilterItemProps> = observer(
86
+ (props) => {
87
+ const { collection, filterableFieldNames, noIgnore = false, t = identity } = props;
88
+ const { path: leftValue, operator, value: rightValue } = props.value;
89
+
90
+ const options = useFilterOptions(collection, { filterableFieldNames, noIgnore, t });
91
+
92
+ const cascaderOptions = useMemo(() => toCascaderOptions(options), [options]);
93
+
94
+ const fieldPath = useMemo(() => (leftValue ? leftValue.split('.') : []), [leftValue]);
95
+
96
+ const selectedField = useMemo(() => findOptionByPath(options, fieldPath), [options, fieldPath]);
97
+
98
+ const operatorOptions = useMemo(() => selectedField?.operators || [], [selectedField]);
99
+
100
+ const selectedOperator = useMemo(
101
+ () => operatorOptions.find((op) => op.value === operator),
102
+ [operatorOptions, operator],
103
+ );
104
+
105
+ const handleFieldChange = (next: (string | number)[]) => {
106
+ const path = next.map(String);
107
+ props.value.path = path.join('.');
108
+ const leaf = findOptionByPath(options, path);
109
+ props.value.operator = leaf?.operators?.[0]?.value || '';
110
+ // The value's shape is operator-dependent (e.g. string for `$eq`, `{ type, number, unit }` for `$dateInPast`); reset on every field change so stale shapes don't leak across interfaces.
111
+ props.value.value = undefined;
112
+ };
113
+ const handleOperatorChange = (next: string) => {
114
+ props.value.operator = next;
115
+ // Same rationale as above — switching from `$eq` (string) to `$dateOn` (date descriptor) makes the previous value structurally incompatible. v1 handled this by remounting the DynamicComponent on operator change; we explicitly clear.
116
+ props.value.value = undefined;
117
+ };
118
+ const handleValueChange = (next: any) => {
119
+ props.value.value = next;
120
+ };
121
+
122
+ // Widths mirror the long-standing `FilterItem` row (200 / 120) so a settings page mixing CollectionFilterItem and FilterContainer doesn't visually drift from existing block-bound filter rows.
123
+ return (
124
+ <Space wrap>
125
+ <Cascader
126
+ style={{ width: 200 }}
127
+ placeholder={t('Select field')}
128
+ options={cascaderOptions}
129
+ value={fieldPath}
130
+ onChange={handleFieldChange}
131
+ changeOnSelect={false}
132
+ expandTrigger="click"
133
+ popupClassName={cascaderPopupClass}
134
+ />
135
+ <Select
136
+ style={{ width: 120 }}
137
+ placeholder={t('Comparision')}
138
+ value={operator || undefined}
139
+ onChange={handleOperatorChange}
140
+ disabled={!leftValue || operatorOptions.length === 0}
141
+ >
142
+ {operatorOptions.map((op) => (
143
+ <Select.Option key={op.value} value={op.value}>
144
+ {op.label}
145
+ </Select.Option>
146
+ ))}
147
+ </Select>
148
+ <FilterValueInput
149
+ field={selectedField}
150
+ operator={selectedOperator}
151
+ value={rightValue}
152
+ onChange={handleValueChange}
153
+ placeholder={t('Enter value')}
154
+ t={t}
155
+ />
156
+ </Space>
157
+ );
158
+ },
159
+ { displayName: 'CollectionFilterItem' },
160
+ );
161
+
162
+ /**
163
+ * Convenience factory returning a `FilterContainer`-compatible `FilterItem` component bound to a specific collection. Avoids creating an inline closure on every parent render, which would otherwise reset any focused inner antd control.
164
+ */
165
+ export function createCollectionFilterItem(
166
+ collection: Collection,
167
+ bound?: Pick<CollectionFilterItemProps, 'filterableFieldNames' | 'noIgnore' | 't'>,
168
+ ) {
169
+ const Component: FC<{ value: CollectionFilterItemValue }> = (props) => (
170
+ <CollectionFilterItem {...bound} value={props.value} collection={collection} />
171
+ );
172
+ Component.displayName = 'BoundCollectionFilterItem';
173
+ return Component;
174
+ }
175
+
176
+ export default CollectionFilterItem;
@@ -0,0 +1,283 @@
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 { DatePicker, Divider, InputNumber, Select, Space, theme } from 'antd';
11
+ import dayjs, { type Dayjs } from 'dayjs';
12
+ import React, { useMemo, useState } from 'react';
13
+
14
+ /**
15
+ * Value shape used by NocoBase's `$date*` filter operators. Two flavours:
16
+ *
17
+ * - For "Exact day" mode: a **formatted string** at the granularity of the picker — `"2026-02-15"` (date), `"2026-02"` (month), `"2026"` (year), `"2026-Q1"` (quarter). For `$dateBetween` (`isRange`), a `[string, string]` tuple. Strings (not Dayjs) so the value serializes verbatim into the query string the same way v1 does (`filter=%7B%22$and%22:[%7B%22lockedTs%22:%7B%22$dateOn%22:%222026-02%22%7D%7D]%7D`).
18
+ * - For relative modes (Today / Past 3 days / This Week / …): a `{ type, number?, unit? }` descriptor that the server resolves to a concrete range at query time.
19
+ */
20
+ export type DateFilterValue =
21
+ | string
22
+ | [string, string]
23
+ | { type: string; number?: number; unit?: 'day' | 'week' | 'month' | 'year' }
24
+ | null
25
+ | undefined;
26
+
27
+ export interface DateFilterDynamicComponentProps {
28
+ value?: DateFilterValue;
29
+ onChange?: (value: DateFilterValue) => void;
30
+ /** `true` for `$dateBetween`; renders a `DatePicker.RangePicker`. */
31
+ isRange?: boolean;
32
+ /**
33
+ * Translator. Defaults to identity so callers can omit it when running outside a translation context (tests, storybook).
34
+ */
35
+ t?: (key: string) => string;
36
+ }
37
+
38
+ const identity = (s: string) => s;
39
+
40
+ const PICKER_OPTIONS = [
41
+ { label: 'Date', value: 'date' as const },
42
+ { label: 'Month', value: 'month' as const },
43
+ { label: 'Quarter', value: 'quarter' as const },
44
+ { label: 'Year', value: 'year' as const },
45
+ ];
46
+
47
+ const RELATIVE_PRIMARY = [
48
+ { value: 'exact', label: 'Exact day' },
49
+ { value: 'past', label: 'Past' },
50
+ { value: 'next', label: 'Next' },
51
+ ];
52
+
53
+ const RELATIVE_SECONDARY = [
54
+ { value: 'today', label: 'Today' },
55
+ { value: 'yesterday', label: 'Yesterday' },
56
+ { value: 'tomorrow', label: 'Tomorrow' },
57
+ { value: 'thisWeek', label: 'This Week' },
58
+ { value: 'lastWeek', label: 'Last Week' },
59
+ { value: 'nextWeek', label: 'Next Week' },
60
+ { value: 'thisMonth', label: 'This Month' },
61
+ { value: 'lastMonth', label: 'Last Month' },
62
+ { value: 'nextMonth', label: 'Next Month' },
63
+ { value: 'thisQuarter', label: 'This Quarter' },
64
+ { value: 'lastQuarter', label: 'Last Quarter' },
65
+ { value: 'nextQuarter', label: 'Next Quarter' },
66
+ { value: 'thisYear', label: 'This Year' },
67
+ { value: 'lastYear', label: 'Last Year' },
68
+ { value: 'nextYear', label: 'Next Year' },
69
+ ];
70
+
71
+ const UNIT_OPTIONS = [
72
+ { value: 'day' as const, label: 'Day' },
73
+ { value: 'week' as const, label: 'Calendar week' },
74
+ { value: 'month' as const, label: 'Calendar Month' },
75
+ { value: 'year' as const, label: 'Calendar Year' },
76
+ ];
77
+
78
+ const isRelativeDescriptor = (value: DateFilterValue): value is { type: string; number?: number; unit?: any } =>
79
+ !!value && typeof value === 'object' && !Array.isArray(value) && 'type' in (value as any);
80
+
81
+ type PickerMode = 'date' | 'month' | 'quarter' | 'year';
82
+
83
+ /**
84
+ * The exact format strings NocoBase's `$date*` server operators expect per picker granularity. Mirrors v1's `getPickerFormat` output under `underFilter=true`, so the URL-encoded query stays identical.
85
+ *
86
+ * Exported for unit tests and for downstream callers that need to parse / format `$date*` values outside this component.
87
+ */
88
+ export const FORMAT_BY_PICKER: Record<PickerMode, string> = {
89
+ date: 'YYYY-MM-DD',
90
+ month: 'YYYY-MM',
91
+ quarter: 'YYYY-[Q]Q',
92
+ year: 'YYYY',
93
+ };
94
+
95
+ /**
96
+ * Parse a granularity-formatted string back into a `Dayjs`. Returns `null` on missing or unparseable input so the DatePicker's controlled-value contract stays clean.
97
+ */
98
+ export const parseDateFilterValue = (value: string | undefined, picker: PickerMode): Dayjs | null => {
99
+ if (!value) return null;
100
+ const parsed = dayjs(value, FORMAT_BY_PICKER[picker]);
101
+ return parsed.isValid() ? parsed : null;
102
+ };
103
+
104
+ /**
105
+ * Format a `Dayjs` (typically from `DatePicker.onChange`) into the server-facing string at the active picker's granularity. Returns `undefined` for empty input so callers can drop the value entirely.
106
+ */
107
+ export const formatDateFilterValue = (value: Dayjs | null | undefined, picker: PickerMode): string | undefined => {
108
+ if (!value || !dayjs.isDayjs(value)) return undefined;
109
+ return value.format(FORMAT_BY_PICKER[picker]);
110
+ };
111
+
112
+ /**
113
+ * v2 port of v1's `DateFilterDynamicComponent` — a multi-mode value input for the `$date*` operator family. Three sub-controls glued in a `Space.Compact` row:
114
+ *
115
+ * 1. Mode select — `Exact day` / `Past` / `Next` / `Today` / `This Week` / … Picking a relative mode emits a `{ type, number?, unit? }` descriptor that the server resolves at query time; picking `Exact day` emits a raw `Dayjs` instead.
116
+ * 2. Picker granularity — only when mode is `Exact day`: `Date` / `Month` / `Quarter` / `Year`. Controls the antd `DatePicker`'s `picker` mode so admins can filter to e.g. "any day in 2026-03".
117
+ * 3. Date input — antd `DatePicker` for single dates, or `DatePicker.RangePicker` when `isRange` is true (used by `$dateBetween`).
118
+ *
119
+ * v1 wired its own `<DatePicker.FilterWithPicker>` for the third slot; v2 inlines the picker-granularity selector here so we don't have to fork antd's DatePicker. Drops v1's `@emotion/css` (uses antd token spacing) and the `useCompile` schema-template chain (call sites pass a plain `t` translator).
120
+ */
121
+ export const DateFilterDynamicComponent: React.FC<DateFilterDynamicComponentProps> = (props) => {
122
+ const { value, onChange, isRange, t = identity } = props;
123
+ const { token } = theme.useToken();
124
+ const [picker, setPicker] = useState<PickerMode>('date');
125
+ const [open, setOpen] = useState(false);
126
+
127
+ const mode = isRelativeDescriptor(value) ? value.type : 'exact';
128
+
129
+ const primaryOptions = useMemo(() => RELATIVE_PRIMARY.map((o) => ({ ...o, label: t(o.label) })), [t]);
130
+ const secondaryOptions = useMemo(() => RELATIVE_SECONDARY.map((o) => ({ ...o, label: t(o.label) })), [t]);
131
+ const pickerOptions = useMemo(() => PICKER_OPTIONS.map((o) => ({ ...o, label: t(o.label) })), [t]);
132
+ const unitOptions = useMemo(() => UNIT_OPTIONS.map((o) => ({ ...o, label: t(o.label) })), [t]);
133
+
134
+ const handleSelectMode = (next: string) => {
135
+ setOpen(false);
136
+ if (next === 'exact') {
137
+ onChange?.(undefined);
138
+ return;
139
+ }
140
+ if (next === 'past' || next === 'next') {
141
+ onChange?.({ type: next, number: 1, unit: 'day' });
142
+ return;
143
+ }
144
+ onChange?.({ type: next });
145
+ };
146
+
147
+ const renderModeDropdown = () => (
148
+ <div style={{ maxHeight: 300, overflowY: 'auto' }}>
149
+ {primaryOptions.map((opt) => (
150
+ <div
151
+ key={opt.value}
152
+ role="option"
153
+ aria-selected={mode === opt.value}
154
+ onClick={() => handleSelectMode(opt.value)}
155
+ style={{
156
+ padding: `${token.paddingXXS}px ${token.paddingSM}px`,
157
+ cursor: 'pointer',
158
+ whiteSpace: 'nowrap',
159
+ }}
160
+ >
161
+ {opt.label}
162
+ </div>
163
+ ))}
164
+ <Divider style={{ margin: `${token.marginXXS}px 0` }} />
165
+ {secondaryOptions.map((opt) => (
166
+ <div
167
+ key={opt.value}
168
+ role="option"
169
+ aria-selected={mode === opt.value}
170
+ onClick={() => handleSelectMode(opt.value)}
171
+ style={{
172
+ padding: `${token.paddingXXS}px ${token.paddingSM}px`,
173
+ cursor: 'pointer',
174
+ whiteSpace: 'nowrap',
175
+ overflow: 'hidden',
176
+ textOverflow: 'ellipsis',
177
+ }}
178
+ title={opt.label}
179
+ >
180
+ {opt.label}
181
+ </div>
182
+ ))}
183
+ </div>
184
+ );
185
+
186
+ // Mode select compresses when the chosen mode is "Exact day / Past / Next" (room is needed for the secondary picker / number+unit row beside it). For named ranges (Today, This Week, etc.) it stretches since no sub-control follows.
187
+ const compactModes = new Set(['exact', 'past', 'next']);
188
+ const isCompact = compactModes.has(mode);
189
+
190
+ return (
191
+ <Space.Compact style={{ width: '100%' }}>
192
+ <Select
193
+ open={open}
194
+ onDropdownVisibleChange={setOpen}
195
+ value={mode}
196
+ onChange={handleSelectMode}
197
+ style={{
198
+ flex: '0 0 auto',
199
+ minWidth: 100,
200
+ maxWidth: isCompact ? 100 : undefined,
201
+ width: isCompact ? 100 : 'auto',
202
+ }}
203
+ popupMatchSelectWidth={false}
204
+ dropdownRender={renderModeDropdown}
205
+ options={[...primaryOptions, ...secondaryOptions]}
206
+ />
207
+
208
+ {(mode === 'past' || mode === 'next') && (
209
+ <>
210
+ <InputNumber
211
+ value={isRelativeDescriptor(value) ? value.number : 1}
212
+ min={1}
213
+ onChange={(num) => {
214
+ if (!isRelativeDescriptor(value)) return;
215
+ onChange?.({ ...value, number: typeof num === 'number' ? num : 1 });
216
+ }}
217
+ style={{ flex: '0 0 auto' }}
218
+ />
219
+ <Select
220
+ value={isRelativeDescriptor(value) ? value.unit : 'day'}
221
+ onChange={(unit) => {
222
+ if (!isRelativeDescriptor(value)) return;
223
+ onChange?.({ ...value, unit });
224
+ }}
225
+ options={unitOptions}
226
+ style={{ flex: '0 0 auto', minWidth: 130 }}
227
+ popupMatchSelectWidth
228
+ />
229
+ </>
230
+ )}
231
+
232
+ {mode === 'exact' && !isRange && (
233
+ <>
234
+ <Select
235
+ value={picker}
236
+ onChange={(next) => {
237
+ setPicker(next);
238
+ // Picker change means the previously selected date is now expressed at a different granularity; clear it so the user doesn't carry a stale value into the new picker.
239
+ onChange?.(undefined);
240
+ }}
241
+ options={pickerOptions}
242
+ style={{ flex: '0 0 auto', width: 100 }}
243
+ popupMatchSelectWidth={false}
244
+ />
245
+ <DatePicker
246
+ // Stored value is a granularity-formatted string (e.g. `"2026-02"` for month picker). Hydrate it back to a Dayjs for antd's controlled-value contract, then re-emit a formatted string on change so the URL serialization matches v1 exactly. Without the format step, antd's Dayjs would JSON.stringify to a full ISO timestamp and the server's `$dateOn` would never match.
247
+ value={parseDateFilterValue(typeof value === 'string' ? value : undefined, picker)}
248
+ onChange={(next) => onChange?.(formatDateFilterValue(next, picker))}
249
+ picker={picker}
250
+ format={FORMAT_BY_PICKER[picker]}
251
+ style={{ flex: 1, minWidth: 0 }}
252
+ />
253
+ </>
254
+ )}
255
+
256
+ {mode === 'exact' && isRange && (
257
+ <DatePicker.RangePicker
258
+ // `$dateBetween` always operates at day granularity in v1's URL output, so we pin the range format to `YYYY-MM-DD`.
259
+ value={
260
+ Array.isArray(value)
261
+ ? ([parseDateFilterValue(value[0], 'date'), parseDateFilterValue(value[1], 'date')] as [
262
+ Dayjs | null,
263
+ Dayjs | null,
264
+ ])
265
+ : undefined
266
+ }
267
+ onChange={(next) => {
268
+ if (!next || !next[0] || !next[1]) {
269
+ onChange?.(undefined);
270
+ return;
271
+ }
272
+ const [start, end] = next as [Dayjs, Dayjs];
273
+ onChange?.([start.format(FORMAT_BY_PICKER.date), end.format(FORMAT_BY_PICKER.date)]);
274
+ }}
275
+ format={FORMAT_BY_PICKER.date}
276
+ style={{ flex: 1, minWidth: 0 }}
277
+ />
278
+ )}
279
+ </Space.Compact>
280
+ );
281
+ };
282
+
283
+ export default DateFilterDynamicComponent;