@nocobase/client-v2 2.1.0-beta.35 → 2.1.0-beta.37
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.
- package/es/BaseApplication.d.ts +2 -1
- package/es/components/PoweredBy.d.ts +18 -0
- package/es/components/SwitchLanguage.d.ts +11 -0
- package/es/components/form/DialogFormLayout.d.ts +51 -0
- package/es/components/form/DrawerFormLayout.d.ts +11 -11
- package/es/components/form/PasswordInput.d.ts +40 -0
- package/es/components/form/RemoteSelect.d.ts +79 -0
- package/es/components/form/filter/CollectionFilter.d.ts +41 -0
- package/es/components/form/filter/CollectionFilterItem.d.ts +41 -0
- package/es/components/form/filter/DateFilterDynamicComponent.d.ts +57 -0
- package/es/components/form/filter/FilterValueInput.d.ts +29 -0
- package/es/components/form/filter/index.d.ts +11 -0
- package/es/components/form/filter/useFilterActionProps.d.ts +96 -0
- package/es/components/form/index.d.ts +4 -0
- package/es/components/form/table/styles.d.ts +10 -0
- package/es/components/index.d.ts +2 -0
- package/es/data-source/ExtendCollectionsProvider.d.ts +24 -0
- package/es/data-source/index.d.ts +9 -0
- package/es/flow/components/filter/index.d.ts +2 -0
- package/es/flow/components/filter/useFilterOptions.d.ts +54 -0
- package/es/flow/models/base/ActionModelCore.d.ts +6 -0
- package/es/flow/models/base/GridModel.d.ts +2 -0
- package/es/flow/utils/dataScopeFormValueClear.d.ts +14 -0
- package/es/flow-compat/passwordUtils.d.ts +1 -1
- package/es/hooks/index.d.ts +2 -0
- package/es/hooks/useCurrentAppInfo.d.ts +9 -0
- package/es/index.d.ts +1 -0
- package/es/index.mjs +109 -92
- package/es/nocobase-buildin-plugin/index.d.ts +20 -2
- package/es/utils/appVersionHTML.d.ts +10 -0
- package/es/utils/index.d.ts +1 -0
- package/es/utils/remotePlugins.d.ts +4 -1
- package/lib/index.js +115 -98
- package/package.json +7 -7
- package/src/BaseApplication.tsx +16 -3
- package/src/PluginSettingsManager.ts +2 -1
- package/src/__tests__/PluginSettingsManager.test.ts +19 -0
- package/src/__tests__/PoweredBy.test.tsx +130 -0
- package/src/__tests__/app.test.tsx +40 -0
- package/src/__tests__/nocobase-buildin-plugin-auth.test.tsx +39 -72
- package/src/__tests__/remotePlugins.test.ts +55 -0
- package/src/__tests__/useCurrentRoles.test.tsx +100 -0
- package/src/components/PoweredBy.tsx +71 -0
- package/src/components/README.md +397 -0
- package/src/components/README.zh-CN.md +394 -0
- package/src/components/SwitchLanguage.tsx +48 -0
- package/src/components/form/DialogFormLayout.tsx +87 -0
- package/src/components/form/DrawerFormLayout.tsx +13 -32
- package/src/components/form/PasswordInput.tsx +211 -0
- package/src/components/form/RemoteSelect.tsx +137 -0
- package/src/components/form/filter/CollectionFilter.tsx +101 -0
- package/src/components/form/filter/CollectionFilterItem.tsx +176 -0
- package/src/components/form/filter/DateFilterDynamicComponent.tsx +283 -0
- package/src/components/form/filter/FilterValueInput.tsx +198 -0
- package/src/components/form/filter/__tests__/CollectionFilterItem.test.tsx +205 -0
- package/src/components/form/filter/__tests__/DateFilterDynamicComponent.test.tsx +148 -0
- package/src/components/form/filter/__tests__/FilterValueInput.test.tsx +243 -0
- package/src/components/form/filter/__tests__/compileFilterGroup.test.ts +146 -0
- package/src/components/form/filter/index.ts +13 -0
- package/src/components/form/filter/useFilterActionProps.ts +200 -0
- package/src/components/form/index.tsx +4 -0
- package/src/components/form/table/Table.tsx +2 -1
- package/src/components/form/table/styles.ts +19 -0
- package/src/components/index.ts +2 -0
- package/src/css-variable/CSSVariableProvider.tsx +10 -1
- package/src/data-source/ExtendCollectionsProvider.tsx +70 -0
- package/src/data-source/index.ts +10 -0
- package/src/flow/actions/__tests__/dataScopeFormValueClear.test.ts +96 -0
- package/src/flow/actions/dataScope.tsx +3 -0
- package/src/flow/admin-shell/admin-layout/AdminLayoutComponent.tsx +1 -4
- package/src/flow/admin-shell/admin-layout/HelpLite.tsx +7 -33
- package/src/flow/components/BlockItemCard.tsx +2 -2
- package/src/flow/components/filter/index.ts +3 -0
- package/src/flow/components/filter/useFilterOptions.ts +80 -0
- package/src/flow/models/base/ActionModel.tsx +8 -7
- package/src/flow/models/base/ActionModelCore.tsx +15 -7
- package/src/flow/models/base/GridModel.tsx +93 -36
- package/src/flow/models/base/__tests__/GridModel.visibleLayout.test.ts +83 -11
- package/src/flow/models/blocks/details/DetailsItemModel.tsx +2 -0
- package/src/flow/models/blocks/form/FormItemModel.tsx +5 -3
- package/src/flow/models/blocks/form/__tests__/FormItemModel.defineChildren.test.ts +108 -0
- package/src/flow/models/blocks/table/TableActionsColumnModel.tsx +5 -0
- package/src/flow/models/blocks/table/TableColumnModel.tsx +2 -0
- package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/SubTableColumnModel.tsx +2 -0
- package/src/flow/utils/dataScopeFormValueClear.ts +278 -0
- package/src/hooks/index.ts +2 -0
- package/src/hooks/useCurrentAppInfo.ts +36 -0
- package/src/index.ts +1 -0
- package/src/nocobase-buildin-plugin/index.tsx +66 -18
- package/src/nocobase-buildin-plugin/plugins/LocalePlugin.ts +1 -0
- package/src/utils/appVersionHTML.ts +28 -0
- package/src/utils/globalDeps.ts +2 -2
- package/src/utils/index.tsx +2 -0
- package/src/utils/remotePlugins.ts +12 -7
|
@@ -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;
|
|
@@ -0,0 +1,198 @@
|
|
|
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 { Checkbox, ColorPicker, DatePicker, Input, InputNumber, Radio, Select, TimePicker } from 'antd';
|
|
11
|
+
import React from 'react';
|
|
12
|
+
import type { FilterOperator, FilterOption } from '../../../flow/components/filter/useFilterOptions';
|
|
13
|
+
import { PasswordInput } from '../PasswordInput';
|
|
14
|
+
import { DateFilterDynamicComponent } from './DateFilterDynamicComponent';
|
|
15
|
+
|
|
16
|
+
export interface FilterValueInputProps {
|
|
17
|
+
/** The currently selected leaf field option from the field picker. */
|
|
18
|
+
field?: FilterOption;
|
|
19
|
+
/** The currently selected operator (full object, not just `.value`). */
|
|
20
|
+
operator?: FilterOperator;
|
|
21
|
+
/** Current value. Shape depends on operator/field. */
|
|
22
|
+
value: any;
|
|
23
|
+
/** Notify the parent when the user edits the value. */
|
|
24
|
+
onChange: (value: any) => void;
|
|
25
|
+
/** Translator used by sub-renderers. */
|
|
26
|
+
t?: (key: string) => string;
|
|
27
|
+
/** Optional placeholder for the fallback `Input`. */
|
|
28
|
+
placeholder?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const identity = (s: string) => s;
|
|
32
|
+
|
|
33
|
+
type EffectiveSchema = {
|
|
34
|
+
'x-component'?: string;
|
|
35
|
+
'x-component-props'?: Record<string, any>;
|
|
36
|
+
enum?: Array<{ value: any; label: string }> | any[];
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const baseStyle = { minWidth: 200 } as const;
|
|
40
|
+
|
|
41
|
+
/** Resolve operator-level schema → field uiSchema → fallback Input. */
|
|
42
|
+
const resolveSchema = (field?: FilterOption, operator?: FilterOperator): EffectiveSchema => {
|
|
43
|
+
if (operator?.schema?.['x-component']) {
|
|
44
|
+
return operator.schema as EffectiveSchema;
|
|
45
|
+
}
|
|
46
|
+
const fieldSchema = field?.schema as EffectiveSchema | undefined;
|
|
47
|
+
if (fieldSchema?.['x-component']) {
|
|
48
|
+
return fieldSchema;
|
|
49
|
+
}
|
|
50
|
+
return { 'x-component': 'Input' };
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Interface-aware value renderer for filter rows. Returns `null` for `noValue` operators (`$empty`, `$notEmpty`). Otherwise dispatches the effective `x-component` (operator schema > field uiSchema > Input) to a small registry of antd controls.
|
|
55
|
+
*/
|
|
56
|
+
export const FilterValueInput: React.FC<FilterValueInputProps> = (props) => {
|
|
57
|
+
const { field, operator, value, onChange, t = identity, placeholder } = props;
|
|
58
|
+
|
|
59
|
+
if (operator?.noValue) {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const schema = resolveSchema(field, operator);
|
|
64
|
+
const componentName = schema['x-component'];
|
|
65
|
+
const componentProps = schema['x-component-props'] || {};
|
|
66
|
+
const enumOptions = (schema as any).enum || (field?.schema as any)?.enum;
|
|
67
|
+
|
|
68
|
+
switch (componentName) {
|
|
69
|
+
case 'DateFilterDynamicComponent':
|
|
70
|
+
return <DateFilterDynamicComponent value={value} onChange={onChange} isRange={!!componentProps.isRange} t={t} />;
|
|
71
|
+
|
|
72
|
+
case 'DatePicker':
|
|
73
|
+
case 'UnixTimestamp':
|
|
74
|
+
return (
|
|
75
|
+
<DatePicker
|
|
76
|
+
value={value}
|
|
77
|
+
onChange={onChange}
|
|
78
|
+
{...componentProps}
|
|
79
|
+
style={{ ...baseStyle, ...(componentProps.style || {}) }}
|
|
80
|
+
/>
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
case 'TimePicker':
|
|
84
|
+
return (
|
|
85
|
+
<TimePicker
|
|
86
|
+
value={value}
|
|
87
|
+
onChange={onChange}
|
|
88
|
+
{...componentProps}
|
|
89
|
+
style={{ ...baseStyle, ...(componentProps.style || {}) }}
|
|
90
|
+
/>
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
case 'InputNumber':
|
|
94
|
+
case 'Percent':
|
|
95
|
+
return (
|
|
96
|
+
<InputNumber
|
|
97
|
+
value={value}
|
|
98
|
+
onChange={(next) => onChange(next ?? undefined)}
|
|
99
|
+
{...componentProps}
|
|
100
|
+
style={{ ...baseStyle, ...(componentProps.style || {}) }}
|
|
101
|
+
/>
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
case 'ColorPicker':
|
|
105
|
+
return <ColorPicker value={value} onChange={(_color, hex) => onChange(hex)} {...componentProps} />;
|
|
106
|
+
|
|
107
|
+
case 'Checkbox':
|
|
108
|
+
return <Checkbox checked={!!value} onChange={(e) => onChange(e.target.checked)} {...componentProps} />;
|
|
109
|
+
|
|
110
|
+
case 'Checkbox.Group': {
|
|
111
|
+
const options = Array.isArray(componentProps.options) ? componentProps.options : enumOptions;
|
|
112
|
+
return (
|
|
113
|
+
<Checkbox.Group
|
|
114
|
+
value={value}
|
|
115
|
+
onChange={onChange}
|
|
116
|
+
options={options as any}
|
|
117
|
+
{...componentProps}
|
|
118
|
+
style={{ ...baseStyle, ...(componentProps.style || {}) }}
|
|
119
|
+
/>
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
case 'Radio.Group': {
|
|
124
|
+
const options = Array.isArray(componentProps.options) ? componentProps.options : enumOptions;
|
|
125
|
+
return (
|
|
126
|
+
<Radio.Group
|
|
127
|
+
value={value}
|
|
128
|
+
onChange={(e) => onChange(e.target.value)}
|
|
129
|
+
options={options as any}
|
|
130
|
+
{...componentProps}
|
|
131
|
+
style={{ ...baseStyle, ...(componentProps.style || {}) }}
|
|
132
|
+
/>
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
case 'Select': {
|
|
137
|
+
const { mode, options, multiple } = componentProps;
|
|
138
|
+
const resolvedMode = mode === 'tags' || mode === 'multiple' ? mode : multiple ? 'multiple' : undefined;
|
|
139
|
+
const inlineOptions = Array.isArray(options) ? options : enumOptions;
|
|
140
|
+
const resolvedOptions = Array.isArray(inlineOptions)
|
|
141
|
+
? inlineOptions.map((option: any) => ({
|
|
142
|
+
...option,
|
|
143
|
+
label: typeof option.label === 'string' ? t(option.label) : option.label,
|
|
144
|
+
}))
|
|
145
|
+
: undefined;
|
|
146
|
+
return (
|
|
147
|
+
<Select
|
|
148
|
+
value={value}
|
|
149
|
+
onChange={onChange}
|
|
150
|
+
mode={resolvedMode}
|
|
151
|
+
options={resolvedOptions}
|
|
152
|
+
allowClear
|
|
153
|
+
{...componentProps}
|
|
154
|
+
style={{ ...baseStyle, ...(componentProps.style || {}) }}
|
|
155
|
+
/>
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
case 'Password':
|
|
160
|
+
return (
|
|
161
|
+
<PasswordInput
|
|
162
|
+
value={value}
|
|
163
|
+
onChange={(e: any) => onChange(e?.target?.value ?? e)}
|
|
164
|
+
{...componentProps}
|
|
165
|
+
style={{ ...baseStyle, ...(componentProps.style || {}) }}
|
|
166
|
+
/>
|
|
167
|
+
);
|
|
168
|
+
|
|
169
|
+
case 'Input.TextArea':
|
|
170
|
+
case 'Input.JSON':
|
|
171
|
+
case 'Markdown':
|
|
172
|
+
case 'RichText':
|
|
173
|
+
return (
|
|
174
|
+
<Input.TextArea
|
|
175
|
+
value={value}
|
|
176
|
+
onChange={(e) => onChange(e.target.value)}
|
|
177
|
+
{...componentProps}
|
|
178
|
+
style={{ ...baseStyle, ...(componentProps.style || {}) }}
|
|
179
|
+
/>
|
|
180
|
+
);
|
|
181
|
+
|
|
182
|
+
case 'Input':
|
|
183
|
+
case 'Input.URL':
|
|
184
|
+
case 'NanoIDInput':
|
|
185
|
+
default:
|
|
186
|
+
return (
|
|
187
|
+
<Input
|
|
188
|
+
value={value}
|
|
189
|
+
onChange={(e) => onChange(e.target.value)}
|
|
190
|
+
placeholder={placeholder}
|
|
191
|
+
{...componentProps}
|
|
192
|
+
style={{ ...baseStyle, ...(componentProps.style || {}) }}
|
|
193
|
+
/>
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
export default FilterValueInput;
|
|
@@ -0,0 +1,205 @@
|
|
|
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 { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
|
11
|
+
import { observable } from '@nocobase/flow-engine';
|
|
12
|
+
import React from 'react';
|
|
13
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
14
|
+
|
|
15
|
+
type StubField = {
|
|
16
|
+
name: string;
|
|
17
|
+
title: string;
|
|
18
|
+
children?: StubField[];
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
// `fieldsToOptions` walks the live data-source registry to build option trees with operator lists per field interface. For a focused unit test we stub it so the test isn't coupled to interface registration. The stub returns the same nested shape `fieldsToOptions` would produce so `CollectionFilterItem`'s Cascader sees children for association-like fields.
|
|
22
|
+
vi.mock('../../../../flow/components/filter/fieldsToOptions', () => {
|
|
23
|
+
const toOption = (field: StubField): any => ({
|
|
24
|
+
name: field.name,
|
|
25
|
+
title: field.title,
|
|
26
|
+
operators: field.children
|
|
27
|
+
? undefined
|
|
28
|
+
: [
|
|
29
|
+
{ value: '$eq', label: 'equals' },
|
|
30
|
+
{ value: '$ne', label: 'not equals' },
|
|
31
|
+
],
|
|
32
|
+
children: field.children?.map(toOption),
|
|
33
|
+
});
|
|
34
|
+
return {
|
|
35
|
+
fieldsToOptions: (fields: StubField[], _depth: number, ignore: string[]) =>
|
|
36
|
+
fields.filter((f) => !ignore.includes(f.name)).map(toOption),
|
|
37
|
+
};
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
import { CollectionFilterItem, createCollectionFilterItem } from '../CollectionFilterItem';
|
|
41
|
+
|
|
42
|
+
function buildStubCollection(fieldDefs: StubField[]) {
|
|
43
|
+
return {
|
|
44
|
+
getFields: () =>
|
|
45
|
+
fieldDefs.map((f) => ({
|
|
46
|
+
name: f.name,
|
|
47
|
+
title: f.title,
|
|
48
|
+
type: 'string',
|
|
49
|
+
interface: 'input',
|
|
50
|
+
target: undefined as string | undefined,
|
|
51
|
+
// Mirror the stub's children so any downstream call that descends through the field tree behaves the same as `fieldsToOptions` does.
|
|
52
|
+
children: f.children,
|
|
53
|
+
})),
|
|
54
|
+
} as any;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function openFieldCascader(container: HTMLElement) {
|
|
58
|
+
const selector = container.querySelector('.ant-select-selector');
|
|
59
|
+
if (!selector) throw new Error('expected Cascader trigger to be rendered');
|
|
60
|
+
// antd Cascader renders an internal Select-style selector; mouseDown opens its popup just like a plain Select.
|
|
61
|
+
fireEvent.mouseDown(selector);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function openOperatorSelect(container: HTMLElement) {
|
|
65
|
+
// Operator dropdown is the second `.ant-select-selector` on the row; the first one is the Cascader's internal selector.
|
|
66
|
+
const selectors = container.querySelectorAll('.ant-select-selector');
|
|
67
|
+
if (selectors.length < 2) throw new Error('expected operator Select to be rendered');
|
|
68
|
+
fireEvent.mouseDown(selectors[1]);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
describe('CollectionFilterItem', () => {
|
|
72
|
+
it('renders field options from the bound collection', async () => {
|
|
73
|
+
const value = observable({ path: '', operator: '', value: '' });
|
|
74
|
+
const collection = buildStubCollection([
|
|
75
|
+
{ name: 'username', title: 'Username' },
|
|
76
|
+
{ name: 'lockReason', title: 'Lock reason' },
|
|
77
|
+
]);
|
|
78
|
+
|
|
79
|
+
const { container } = render(<CollectionFilterItem value={value} collection={collection} />);
|
|
80
|
+
|
|
81
|
+
openFieldCascader(container);
|
|
82
|
+
expect(await screen.findByText('Username')).toBeInTheDocument();
|
|
83
|
+
expect(await screen.findByText('Lock reason')).toBeInTheDocument();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('updates value.path and seeds operator on leaf selection', async () => {
|
|
87
|
+
const value = observable({ path: '', operator: '', value: '' });
|
|
88
|
+
const collection = buildStubCollection([
|
|
89
|
+
{ name: 'username', title: 'Username' },
|
|
90
|
+
{ name: 'lockReason', title: 'Lock reason' },
|
|
91
|
+
]);
|
|
92
|
+
|
|
93
|
+
const { container } = render(<CollectionFilterItem value={value} collection={collection} />);
|
|
94
|
+
|
|
95
|
+
openFieldCascader(container);
|
|
96
|
+
fireEvent.click(await screen.findByText('Username'));
|
|
97
|
+
|
|
98
|
+
await waitFor(() => {
|
|
99
|
+
expect(value.path).toBe('username');
|
|
100
|
+
// First operator from the stubbed options must be seeded automatically so the row is immediately usable without an extra click.
|
|
101
|
+
expect(value.operator).toBe('$eq');
|
|
102
|
+
// Field change clears the value — its shape is operator/field dependent (string for `$eq`, descriptor for `$dateOn`, etc.), so we don't carry the old value across field switches.
|
|
103
|
+
expect(value.value).toBeUndefined();
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('clears value when the operator changes', async () => {
|
|
108
|
+
const value = observable({ path: 'username', operator: '$eq', value: 'alice' });
|
|
109
|
+
const collection = buildStubCollection([{ name: 'username', title: 'Username' }]);
|
|
110
|
+
|
|
111
|
+
const { container } = render(<CollectionFilterItem value={value} collection={collection} />);
|
|
112
|
+
|
|
113
|
+
openOperatorSelect(container);
|
|
114
|
+
fireEvent.click(await screen.findByText('not equals'));
|
|
115
|
+
|
|
116
|
+
await waitFor(() => {
|
|
117
|
+
expect(value.operator).toBe('$ne');
|
|
118
|
+
// Same rationale as the field-change branch — a stale string from `$eq` would be structurally incompatible with e.g. a `$dateOn` descriptor if the user picks a date operator next.
|
|
119
|
+
expect(value.value).toBeUndefined();
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('honours filterableFieldNames whitelist', async () => {
|
|
124
|
+
const value = observable({ path: '', operator: '', value: '' });
|
|
125
|
+
const collection = buildStubCollection([
|
|
126
|
+
{ name: 'username', title: 'Username' },
|
|
127
|
+
{ name: 'lockReason', title: 'Lock reason' },
|
|
128
|
+
]);
|
|
129
|
+
|
|
130
|
+
const { container } = render(
|
|
131
|
+
<CollectionFilterItem value={value} collection={collection} filterableFieldNames={['username']} />,
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
openFieldCascader(container);
|
|
135
|
+
expect(await screen.findByText('Username')).toBeInTheDocument();
|
|
136
|
+
expect(screen.queryByText('Lock reason')).not.toBeInTheDocument();
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('writes through value.value on input change', () => {
|
|
140
|
+
const value = observable({ path: 'username', operator: '$eq', value: '' });
|
|
141
|
+
const collection = buildStubCollection([{ name: 'username', title: 'Username' }]);
|
|
142
|
+
|
|
143
|
+
render(<CollectionFilterItem value={value} collection={collection} />);
|
|
144
|
+
|
|
145
|
+
fireEvent.change(screen.getByPlaceholderText('Enter value'), { target: { value: 'alice' } });
|
|
146
|
+
expect(value.value).toBe('alice');
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('lets the user change the operator independently', async () => {
|
|
150
|
+
const value = observable({ path: 'username', operator: '$eq', value: '' });
|
|
151
|
+
const collection = buildStubCollection([{ name: 'username', title: 'Username' }]);
|
|
152
|
+
|
|
153
|
+
const { container } = render(<CollectionFilterItem value={value} collection={collection} />);
|
|
154
|
+
|
|
155
|
+
openOperatorSelect(container);
|
|
156
|
+
fireEvent.click(await screen.findByText('not equals'));
|
|
157
|
+
|
|
158
|
+
await waitFor(() => {
|
|
159
|
+
expect(value.operator).toBe('$ne');
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('drills into nested association fields and joins the path with dots', async () => {
|
|
164
|
+
const value = observable({ path: '', operator: '', value: '' });
|
|
165
|
+
const collection = buildStubCollection([
|
|
166
|
+
{
|
|
167
|
+
name: 'user',
|
|
168
|
+
title: 'User',
|
|
169
|
+
// Association parent — no operators of its own; should appear disabled in the Cascader so users have to drill into a leaf.
|
|
170
|
+
children: [
|
|
171
|
+
{ name: 'username', title: 'Username' },
|
|
172
|
+
{ name: 'nickname', title: 'Nickname' },
|
|
173
|
+
],
|
|
174
|
+
},
|
|
175
|
+
{ name: 'lockedTs', title: 'Locked time' },
|
|
176
|
+
]);
|
|
177
|
+
|
|
178
|
+
const { container } = render(<CollectionFilterItem value={value} collection={collection} />);
|
|
179
|
+
|
|
180
|
+
openFieldCascader(container);
|
|
181
|
+
// Click the association parent to reveal its children. Cascader is configured with `expandTrigger="click"`.
|
|
182
|
+
fireEvent.click(await screen.findByText('User'));
|
|
183
|
+
fireEvent.click(await screen.findByText('Username'));
|
|
184
|
+
|
|
185
|
+
await waitFor(() => {
|
|
186
|
+
// The selected leaf path is dot-joined so callers can use it directly as a filter path (e.g. `user.username`).
|
|
187
|
+
expect(value.path).toBe('user.username');
|
|
188
|
+
expect(value.operator).toBe('$eq');
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it('createCollectionFilterItem binds the collection so FilterContainer can drive it', async () => {
|
|
193
|
+
const value = observable({ path: '', operator: '', value: '' });
|
|
194
|
+
const collection = buildStubCollection([{ name: 'username', title: 'Username' }]);
|
|
195
|
+
|
|
196
|
+
const Item = createCollectionFilterItem(collection, { filterableFieldNames: ['username'] });
|
|
197
|
+
const { container } = render(<Item value={value} />);
|
|
198
|
+
|
|
199
|
+
openFieldCascader(container);
|
|
200
|
+
fireEvent.click(await screen.findByText('Username'));
|
|
201
|
+
await waitFor(() => {
|
|
202
|
+
expect(value.path).toBe('username');
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
});
|