@nocobase/client-v2 2.1.0-beta.36 → 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 +1 -0
- package/es/components/form/DialogFormLayout.d.ts +5 -29
- 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 +1 -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-compat/passwordUtils.d.ts +1 -1
- package/es/index.d.ts +1 -0
- package/es/index.mjs +22 -17
- package/es/nocobase-buildin-plugin/index.d.ts +3 -10
- package/lib/index.js +25 -20
- package/package.json +7 -7
- package/src/BaseApplication.tsx +13 -0
- package/src/__tests__/app.test.tsx +9 -0
- package/src/components/README.md +89 -6
- package/src/components/README.zh-CN.md +89 -7
- package/src/components/form/DialogFormLayout.tsx +5 -29
- 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 +1 -0
- package/src/data-source/ExtendCollectionsProvider.tsx +70 -0
- package/src/data-source/index.ts +10 -0
- package/src/flow/components/filter/index.ts +3 -0
- package/src/flow/components/filter/useFilterOptions.ts +80 -0
- package/src/index.ts +1 -0
- package/src/nocobase-buildin-plugin/index.tsx +13 -19
- package/src/nocobase-buildin-plugin/plugins/LocalePlugin.ts +1 -0
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,148 @@
|
|
|
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 } from '@testing-library/react';
|
|
11
|
+
import dayjs from 'dayjs';
|
|
12
|
+
import customParseFormat from 'dayjs/plugin/customParseFormat';
|
|
13
|
+
import quarterOfYear from 'dayjs/plugin/quarterOfYear';
|
|
14
|
+
import React from 'react';
|
|
15
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
16
|
+
import {
|
|
17
|
+
DateFilterDynamicComponent,
|
|
18
|
+
FORMAT_BY_PICKER,
|
|
19
|
+
formatDateFilterValue,
|
|
20
|
+
parseDateFilterValue,
|
|
21
|
+
} from '../DateFilterDynamicComponent';
|
|
22
|
+
|
|
23
|
+
// `formatDateFilterValue` needs the quarter plugin to format `[Q]Q`, and `parseDateFilterValue` needs customParseFormat to parse non-ISO strings like `2026-02`. The component itself imports `dayjs` directly and relies on plugins being registered at app boot; in tests we register them locally so the unit-style helpers can run standalone.
|
|
24
|
+
dayjs.extend(customParseFormat);
|
|
25
|
+
dayjs.extend(quarterOfYear);
|
|
26
|
+
|
|
27
|
+
function openModeDropdown(container: HTMLElement) {
|
|
28
|
+
const selector = container.querySelector('.ant-select-selector');
|
|
29
|
+
if (!selector) throw new Error('expected mode Select to be rendered');
|
|
30
|
+
fireEvent.mouseDown(selector);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
describe('DateFilterDynamicComponent', () => {
|
|
34
|
+
it('defaults to Exact day mode and shows the picker-granularity + DatePicker controls', () => {
|
|
35
|
+
const { container } = render(<DateFilterDynamicComponent value={undefined} onChange={() => undefined} />);
|
|
36
|
+
// Three controls in the compact row: mode select, granularity select, DatePicker. antd renders Select via `.ant-select`, DatePicker via `.ant-picker`.
|
|
37
|
+
expect(container.querySelectorAll('.ant-select').length).toBeGreaterThanOrEqual(2);
|
|
38
|
+
expect(container.querySelector('.ant-picker')).not.toBeNull();
|
|
39
|
+
// Mode display reads "Exact day" with no value yet.
|
|
40
|
+
expect(screen.getByText('Exact day')).toBeInTheDocument();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('emits { type: "past", number: 1, unit: "day" } when switching mode to Past', () => {
|
|
44
|
+
const onChange = vi.fn();
|
|
45
|
+
const { container } = render(<DateFilterDynamicComponent value={undefined} onChange={onChange} />);
|
|
46
|
+
|
|
47
|
+
openModeDropdown(container);
|
|
48
|
+
// "Past" appears in the dropdown's primary list (rendered via dropdownRender, so options have role="option" instead of antd's stock list).
|
|
49
|
+
const option = screen.getAllByRole('option').find((el) => el.textContent === 'Past');
|
|
50
|
+
if (!option) throw new Error('expected a Past option in the dropdown');
|
|
51
|
+
fireEvent.click(option);
|
|
52
|
+
|
|
53
|
+
expect(onChange).toHaveBeenCalledWith({ type: 'past', number: 1, unit: 'day' });
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('emits { type: "today" } when switching mode to Today (no number / unit / picker)', () => {
|
|
57
|
+
const onChange = vi.fn();
|
|
58
|
+
const { container } = render(<DateFilterDynamicComponent value={undefined} onChange={onChange} />);
|
|
59
|
+
|
|
60
|
+
openModeDropdown(container);
|
|
61
|
+
const option = screen.getAllByRole('option').find((el) => el.textContent === 'Today');
|
|
62
|
+
if (!option) throw new Error('expected a Today option in the dropdown');
|
|
63
|
+
fireEvent.click(option);
|
|
64
|
+
|
|
65
|
+
expect(onChange).toHaveBeenCalledWith({ type: 'today' });
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('emits undefined (clears the descriptor) when switching back to Exact day', () => {
|
|
69
|
+
const onChange = vi.fn();
|
|
70
|
+
const { container } = render(<DateFilterDynamicComponent value={{ type: 'today' }} onChange={onChange} />);
|
|
71
|
+
|
|
72
|
+
openModeDropdown(container);
|
|
73
|
+
const option = screen.getAllByRole('option').find((el) => el.textContent === 'Exact day');
|
|
74
|
+
if (!option) throw new Error('expected an Exact day option in the dropdown');
|
|
75
|
+
fireEvent.click(option);
|
|
76
|
+
|
|
77
|
+
// Exact mode owns its value via the DatePicker, so the descriptor is cleared first; the picker emits its own value on user pick.
|
|
78
|
+
expect(onChange).toHaveBeenCalledWith(undefined);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('shows InputNumber + unit Select when the value is a past/next descriptor', () => {
|
|
82
|
+
const onChange = vi.fn();
|
|
83
|
+
const { container } = render(
|
|
84
|
+
<DateFilterDynamicComponent value={{ type: 'past', number: 3, unit: 'week' }} onChange={onChange} />,
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
// InputNumber present (antd renders `.ant-input-number`); unit Select is the second `.ant-select` in the row (mode is first).
|
|
88
|
+
expect(container.querySelector('.ant-input-number')).not.toBeNull();
|
|
89
|
+
expect(container.querySelectorAll('.ant-select').length).toBeGreaterThanOrEqual(2);
|
|
90
|
+
// DatePicker should NOT be rendered in relative-number mode.
|
|
91
|
+
expect(container.querySelector('.ant-picker')).toBeNull();
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('renders the RangePicker when isRange is true', () => {
|
|
95
|
+
const { container } = render(<DateFilterDynamicComponent value={undefined} onChange={() => undefined} isRange />);
|
|
96
|
+
// RangePicker carries the `.ant-picker-range` class; the granularity Select is suppressed in range mode (only mode select + range picker).
|
|
97
|
+
expect(container.querySelector('.ant-picker-range')).not.toBeNull();
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('hydrates a stored granularity-formatted string back into the DatePicker', () => {
|
|
101
|
+
// `2026-02-15` is the canonical Date-picker shape NocoBase's `$dateOn` operator emits when the user picks an exact day. The DatePicker must hydrate from it (so reopening the popover or re-rendering keeps the value) instead of treating it as an opaque blob.
|
|
102
|
+
//
|
|
103
|
+
// Note: the picker-mode state is local to the component and defaults to `date` on every mount, so values stored at other granularities (`2026-02` for month, `2026` for year) won't pre-fill on remount — only when the user is still in the same session. This matches v1's behaviour, where reopening a filter popover also re-derives the picker mode from the input shape.
|
|
104
|
+
const { container } = render(<DateFilterDynamicComponent value="2026-02-15" onChange={() => undefined} />);
|
|
105
|
+
const dateInput = container.querySelector('.ant-picker input') as HTMLInputElement;
|
|
106
|
+
if (!dateInput) throw new Error('expected antd DatePicker input to be rendered');
|
|
107
|
+
expect(dateInput.value).toBe('2026-02-15');
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
describe('format helpers (parseDateFilterValue / formatDateFilterValue)', () => {
|
|
111
|
+
// These two helpers are the heart of the v1-compatible URL output: `formatDateFilterValue` converts a Dayjs into the picker-shaped string the server expects, and `parseDateFilterValue` round-trips back so the controlled DatePicker can hydrate. Driving them directly is more reliable than poking antd's picker panel under jsdom.
|
|
112
|
+
|
|
113
|
+
it('formats Dayjs values per picker granularity (date / month / quarter / year)', () => {
|
|
114
|
+
const sample = dayjs('2026-02-15');
|
|
115
|
+
expect(formatDateFilterValue(sample, 'date')).toBe('2026-02-15');
|
|
116
|
+
expect(formatDateFilterValue(sample, 'month')).toBe('2026-02');
|
|
117
|
+
expect(formatDateFilterValue(sample, 'year')).toBe('2026');
|
|
118
|
+
expect(formatDateFilterValue(sample, 'quarter')).toBe('2026-Q1');
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('returns undefined for empty / non-Dayjs input so callers can drop the value', () => {
|
|
122
|
+
expect(formatDateFilterValue(null, 'date')).toBeUndefined();
|
|
123
|
+
expect(formatDateFilterValue(undefined, 'date')).toBeUndefined();
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('round-trips a granularity string back into a Dayjs at the matching format', () => {
|
|
127
|
+
const parsed = parseDateFilterValue('2026-02', 'month');
|
|
128
|
+
expect(parsed?.isValid()).toBe(true);
|
|
129
|
+
expect(parsed?.format(FORMAT_BY_PICKER.month)).toBe('2026-02');
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('returns null for missing / unparseable strings', () => {
|
|
133
|
+
expect(parseDateFilterValue(undefined, 'month')).toBeNull();
|
|
134
|
+
expect(parseDateFilterValue('', 'month')).toBeNull();
|
|
135
|
+
expect(parseDateFilterValue('not-a-date', 'month')).toBeNull();
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('translates mode labels via the provided t function', () => {
|
|
140
|
+
const t = (key: string) => (key === 'Past' ? 'PAST_ZH' : key);
|
|
141
|
+
const { container } = render(<DateFilterDynamicComponent value={undefined} onChange={() => undefined} t={t} />);
|
|
142
|
+
|
|
143
|
+
openModeDropdown(container);
|
|
144
|
+
// The translated label should appear in the dropdown options.
|
|
145
|
+
expect(screen.getAllByRole('option').some((el) => el.textContent === 'PAST_ZH')).toBe(true);
|
|
146
|
+
expect(container).toBeTruthy();
|
|
147
|
+
});
|
|
148
|
+
});
|
|
@@ -0,0 +1,243 @@
|
|
|
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 } from '@testing-library/react';
|
|
11
|
+
import React from 'react';
|
|
12
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
13
|
+
import { FilterValueInput } from '../FilterValueInput';
|
|
14
|
+
import type { FilterOperator, FilterOption } from '../../../../flow/components/filter/useFilterOptions';
|
|
15
|
+
|
|
16
|
+
type Case = {
|
|
17
|
+
/** Human-readable description of the v1 interface this case mirrors. */
|
|
18
|
+
name: string;
|
|
19
|
+
/** Field uiSchema. Operator schema is optional and overrides this. */
|
|
20
|
+
fieldComponent?: string;
|
|
21
|
+
fieldEnum?: any[];
|
|
22
|
+
operator?: FilterOperator;
|
|
23
|
+
/** Selector used to confirm the right antd control rendered. */
|
|
24
|
+
selector: string;
|
|
25
|
+
/** Optional: drive an interaction and assert onChange payload. */
|
|
26
|
+
interact?: (container: HTMLElement, onChange: ReturnType<typeof vi.fn>) => void;
|
|
27
|
+
expectedOnChange?: any;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const fieldOf = (component: string, extra: Partial<FilterOption['schema']> = {}): FilterOption => ({
|
|
31
|
+
name: 'f',
|
|
32
|
+
title: 'F',
|
|
33
|
+
schema: { 'x-component': component, ...extra },
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
const opOf = (overrides: Partial<FilterOperator> = {}): FilterOperator => ({
|
|
37
|
+
value: '$eq',
|
|
38
|
+
label: 'is',
|
|
39
|
+
...overrides,
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
// One row per v1 field interface → expected v2 antd control. Detection is kept to a single CSS selector per case so the table reads as "interface ↔ selector"; interactive assertions are added only where the value-onChange contract is non-trivial (e.g. Checkbox.target.checked, ColorPicker hex extraction).
|
|
43
|
+
const CASES: Case[] = [
|
|
44
|
+
// string / email / phone / uuid / nanoid / url all default to Input
|
|
45
|
+
{ name: 'input → antd Input', fieldComponent: 'Input', selector: 'input[type="text"]' },
|
|
46
|
+
{ name: 'Input.URL → antd Input', fieldComponent: 'Input.URL', selector: 'input[type="text"]' },
|
|
47
|
+
{ name: 'NanoIDInput → antd Input', fieldComponent: 'NanoIDInput', selector: 'input[type="text"]' },
|
|
48
|
+
|
|
49
|
+
// textarea / markdown / richText / json all collapse to TextArea
|
|
50
|
+
{ name: 'textarea → antd Input.TextArea', fieldComponent: 'Input.TextArea', selector: 'textarea' },
|
|
51
|
+
{ name: 'markdown → antd Input.TextArea', fieldComponent: 'Markdown', selector: 'textarea' },
|
|
52
|
+
{ name: 'richText → antd Input.TextArea', fieldComponent: 'RichText', selector: 'textarea' },
|
|
53
|
+
{ name: 'json → antd Input.TextArea', fieldComponent: 'Input.JSON', selector: 'textarea' },
|
|
54
|
+
|
|
55
|
+
// numeric inputs
|
|
56
|
+
{ name: 'integer → antd InputNumber', fieldComponent: 'InputNumber', selector: '.ant-input-number' },
|
|
57
|
+
{ name: 'percent → antd InputNumber', fieldComponent: 'Percent', selector: '.ant-input-number' },
|
|
58
|
+
|
|
59
|
+
// password — the case that prompted this audit
|
|
60
|
+
{
|
|
61
|
+
name: 'password → PasswordInput (Input.Password)',
|
|
62
|
+
fieldComponent: 'Password',
|
|
63
|
+
selector: 'input[type="password"]',
|
|
64
|
+
},
|
|
65
|
+
|
|
66
|
+
// pickers
|
|
67
|
+
{
|
|
68
|
+
name: 'datetime field → antd DatePicker (no operator override)',
|
|
69
|
+
fieldComponent: 'DatePicker',
|
|
70
|
+
selector: '.ant-picker',
|
|
71
|
+
},
|
|
72
|
+
{ name: 'unixTimestamp → antd DatePicker', fieldComponent: 'UnixTimestamp', selector: '.ant-picker' },
|
|
73
|
+
{ name: 'time → antd TimePicker', fieldComponent: 'TimePicker', selector: '.ant-picker' },
|
|
74
|
+
{ name: 'color → antd ColorPicker', fieldComponent: 'ColorPicker', selector: '.ant-color-picker-trigger' },
|
|
75
|
+
|
|
76
|
+
// boolean / single-choice / multi-choice
|
|
77
|
+
{ name: 'checkbox → antd Checkbox', fieldComponent: 'Checkbox', selector: 'input[type="checkbox"]' },
|
|
78
|
+
{
|
|
79
|
+
name: 'checkboxGroup with field enum → antd Checkbox.Group',
|
|
80
|
+
fieldComponent: 'Checkbox.Group',
|
|
81
|
+
fieldEnum: [
|
|
82
|
+
{ label: 'A', value: 'a' },
|
|
83
|
+
{ label: 'B', value: 'b' },
|
|
84
|
+
],
|
|
85
|
+
selector: '.ant-checkbox-group',
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
name: 'radioGroup with field enum → antd Radio.Group',
|
|
89
|
+
fieldComponent: 'Radio.Group',
|
|
90
|
+
fieldEnum: [
|
|
91
|
+
{ label: 'A', value: 'a' },
|
|
92
|
+
{ label: 'B', value: 'b' },
|
|
93
|
+
],
|
|
94
|
+
selector: '.ant-radio-group',
|
|
95
|
+
},
|
|
96
|
+
{ name: 'select → antd Select', fieldComponent: 'Select', selector: '.ant-select' },
|
|
97
|
+
|
|
98
|
+
// operator-level overrides — datetime operator schema wins over field uiSchema
|
|
99
|
+
{
|
|
100
|
+
name: '$dateOn operator → smart date picker',
|
|
101
|
+
fieldComponent: 'DatePicker',
|
|
102
|
+
operator: opOf({
|
|
103
|
+
schema: { 'x-component': 'DateFilterDynamicComponent', 'x-component-props': { isRange: false } },
|
|
104
|
+
}),
|
|
105
|
+
selector: '.ant-picker',
|
|
106
|
+
},
|
|
107
|
+
{
|
|
108
|
+
name: '$anyOf array operator → Select with tags mode',
|
|
109
|
+
fieldComponent: 'Input',
|
|
110
|
+
operator: opOf({
|
|
111
|
+
value: '$anyOf',
|
|
112
|
+
schema: { 'x-component': 'Select', 'x-component-props': { mode: 'tags' } },
|
|
113
|
+
}),
|
|
114
|
+
selector: '.ant-select-selection-overflow',
|
|
115
|
+
},
|
|
116
|
+
|
|
117
|
+
// noValue operators render nothing
|
|
118
|
+
{
|
|
119
|
+
name: '$empty operator → renders nothing',
|
|
120
|
+
fieldComponent: 'Input',
|
|
121
|
+
operator: opOf({ value: '$empty', noValue: true }),
|
|
122
|
+
selector: '*',
|
|
123
|
+
// selector intentionally generic; we assert empty container via interact
|
|
124
|
+
},
|
|
125
|
+
];
|
|
126
|
+
|
|
127
|
+
function setup(c: Case) {
|
|
128
|
+
const onChange = vi.fn();
|
|
129
|
+
const field = c.fieldComponent
|
|
130
|
+
? fieldOf(c.fieldComponent, c.fieldEnum ? ({ enum: c.fieldEnum } as any) : {})
|
|
131
|
+
: undefined;
|
|
132
|
+
const utils = render(<FilterValueInput field={field} operator={c.operator} value={undefined} onChange={onChange} />);
|
|
133
|
+
return { onChange, ...utils };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
describe('FilterValueInput dispatch table', () => {
|
|
137
|
+
for (const c of CASES) {
|
|
138
|
+
it(c.name, () => {
|
|
139
|
+
const { container } = setup(c);
|
|
140
|
+
if (c.operator?.noValue) {
|
|
141
|
+
// For noValue operators the component returns null.
|
|
142
|
+
expect(
|
|
143
|
+
container.querySelector('input, textarea, .ant-select, .ant-picker, .ant-color-picker-trigger'),
|
|
144
|
+
).toBeNull();
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
expect(container.querySelector(c.selector)).not.toBeNull();
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
describe('FilterValueInput interaction wiring', () => {
|
|
153
|
+
// Spot-check a handful of write-through paths that have non-trivial event shapes (target.checked, event vs raw value, hex extraction from ColorPicker).
|
|
154
|
+
it('Checkbox emits the boolean from target.checked', () => {
|
|
155
|
+
const onChange = vi.fn();
|
|
156
|
+
const { container } = render(
|
|
157
|
+
<FilterValueInput field={fieldOf('Checkbox')} operator={opOf()} value={false} onChange={onChange} />,
|
|
158
|
+
);
|
|
159
|
+
fireEvent.click(container.querySelector('input[type="checkbox"]') as HTMLInputElement);
|
|
160
|
+
expect(onChange).toHaveBeenCalledWith(true);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('Password emits the unwrapped string from the input event', () => {
|
|
164
|
+
const onChange = vi.fn();
|
|
165
|
+
const { container } = render(
|
|
166
|
+
<FilterValueInput field={fieldOf('Password')} operator={opOf()} value="" onChange={onChange} />,
|
|
167
|
+
);
|
|
168
|
+
const input = container.querySelector('input[type="password"]') as HTMLInputElement;
|
|
169
|
+
fireEvent.change(input, { target: { value: 'hunter2' } });
|
|
170
|
+
expect(onChange).toHaveBeenCalledWith('hunter2');
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('TextArea emits the unwrapped string', () => {
|
|
174
|
+
const onChange = vi.fn();
|
|
175
|
+
const { container } = render(
|
|
176
|
+
<FilterValueInput field={fieldOf('Input.TextArea')} operator={opOf()} value="" onChange={onChange} />,
|
|
177
|
+
);
|
|
178
|
+
fireEvent.change(container.querySelector('textarea') as HTMLTextAreaElement, {
|
|
179
|
+
target: { value: 'abuse' },
|
|
180
|
+
});
|
|
181
|
+
expect(onChange).toHaveBeenCalledWith('abuse');
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it('Radio.Group emits the picked value (not the event)', () => {
|
|
185
|
+
const onChange = vi.fn();
|
|
186
|
+
const { container } = render(
|
|
187
|
+
<FilterValueInput
|
|
188
|
+
field={fieldOf('Radio.Group', {
|
|
189
|
+
enum: [
|
|
190
|
+
{ label: 'A', value: 'a' },
|
|
191
|
+
{ label: 'B', value: 'b' },
|
|
192
|
+
],
|
|
193
|
+
} as any)}
|
|
194
|
+
operator={opOf()}
|
|
195
|
+
value={undefined}
|
|
196
|
+
onChange={onChange}
|
|
197
|
+
/>,
|
|
198
|
+
);
|
|
199
|
+
fireEvent.click(container.querySelectorAll('input[type="radio"]')[1] as HTMLInputElement);
|
|
200
|
+
expect(onChange).toHaveBeenCalledWith('b');
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it('Fallback Input honours the placeholder prop', () => {
|
|
204
|
+
render(<FilterValueInput operator={opOf()} value="" onChange={() => undefined} placeholder="Enter value" />);
|
|
205
|
+
expect(screen.getByPlaceholderText('Enter value')).toBeInTheDocument();
|
|
206
|
+
});
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
describe('FilterValueInput schema precedence', () => {
|
|
210
|
+
it('operator schema wins over field uiSchema', () => {
|
|
211
|
+
// Field would render Input, but operator forces Select.
|
|
212
|
+
render(
|
|
213
|
+
<FilterValueInput
|
|
214
|
+
field={fieldOf('Input')}
|
|
215
|
+
operator={opOf({
|
|
216
|
+
schema: { 'x-component': 'Select', 'x-component-props': { mode: 'tags' } },
|
|
217
|
+
})}
|
|
218
|
+
value={[]}
|
|
219
|
+
onChange={() => undefined}
|
|
220
|
+
/>,
|
|
221
|
+
);
|
|
222
|
+
expect(document.querySelector('.ant-select')).not.toBeNull();
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it('field uiSchema is used when operator has none', () => {
|
|
226
|
+
const { container } = render(
|
|
227
|
+
<FilterValueInput
|
|
228
|
+
field={fieldOf('InputNumber')}
|
|
229
|
+
operator={opOf()}
|
|
230
|
+
value={undefined}
|
|
231
|
+
onChange={() => undefined}
|
|
232
|
+
/>,
|
|
233
|
+
);
|
|
234
|
+
expect(container.querySelector('.ant-input-number')).not.toBeNull();
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it('plain Input is used when neither side has a schema', () => {
|
|
238
|
+
const { container } = render(
|
|
239
|
+
<FilterValueInput operator={opOf()} value="" onChange={() => undefined} placeholder="x" />,
|
|
240
|
+
);
|
|
241
|
+
expect(container.querySelector('input[type="text"]')).not.toBeNull();
|
|
242
|
+
});
|
|
243
|
+
});
|