@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.
Files changed (94) hide show
  1. package/es/BaseApplication.d.ts +2 -1
  2. package/es/components/PoweredBy.d.ts +18 -0
  3. package/es/components/SwitchLanguage.d.ts +11 -0
  4. package/es/components/form/DialogFormLayout.d.ts +51 -0
  5. package/es/components/form/DrawerFormLayout.d.ts +11 -11
  6. package/es/components/form/PasswordInput.d.ts +40 -0
  7. package/es/components/form/RemoteSelect.d.ts +79 -0
  8. package/es/components/form/filter/CollectionFilter.d.ts +41 -0
  9. package/es/components/form/filter/CollectionFilterItem.d.ts +41 -0
  10. package/es/components/form/filter/DateFilterDynamicComponent.d.ts +57 -0
  11. package/es/components/form/filter/FilterValueInput.d.ts +29 -0
  12. package/es/components/form/filter/index.d.ts +11 -0
  13. package/es/components/form/filter/useFilterActionProps.d.ts +96 -0
  14. package/es/components/form/index.d.ts +4 -0
  15. package/es/components/form/table/styles.d.ts +10 -0
  16. package/es/components/index.d.ts +2 -0
  17. package/es/data-source/ExtendCollectionsProvider.d.ts +24 -0
  18. package/es/data-source/index.d.ts +9 -0
  19. package/es/flow/components/filter/index.d.ts +2 -0
  20. package/es/flow/components/filter/useFilterOptions.d.ts +54 -0
  21. package/es/flow/models/base/ActionModelCore.d.ts +6 -0
  22. package/es/flow/models/base/GridModel.d.ts +2 -0
  23. package/es/flow/utils/dataScopeFormValueClear.d.ts +14 -0
  24. package/es/flow-compat/passwordUtils.d.ts +1 -1
  25. package/es/hooks/index.d.ts +2 -0
  26. package/es/hooks/useCurrentAppInfo.d.ts +9 -0
  27. package/es/index.d.ts +1 -0
  28. package/es/index.mjs +109 -92
  29. package/es/nocobase-buildin-plugin/index.d.ts +20 -2
  30. package/es/utils/appVersionHTML.d.ts +10 -0
  31. package/es/utils/index.d.ts +1 -0
  32. package/es/utils/remotePlugins.d.ts +4 -1
  33. package/lib/index.js +115 -98
  34. package/package.json +7 -7
  35. package/src/BaseApplication.tsx +16 -3
  36. package/src/PluginSettingsManager.ts +2 -1
  37. package/src/__tests__/PluginSettingsManager.test.ts +19 -0
  38. package/src/__tests__/PoweredBy.test.tsx +130 -0
  39. package/src/__tests__/app.test.tsx +40 -0
  40. package/src/__tests__/nocobase-buildin-plugin-auth.test.tsx +39 -72
  41. package/src/__tests__/remotePlugins.test.ts +55 -0
  42. package/src/__tests__/useCurrentRoles.test.tsx +100 -0
  43. package/src/components/PoweredBy.tsx +71 -0
  44. package/src/components/README.md +397 -0
  45. package/src/components/README.zh-CN.md +394 -0
  46. package/src/components/SwitchLanguage.tsx +48 -0
  47. package/src/components/form/DialogFormLayout.tsx +87 -0
  48. package/src/components/form/DrawerFormLayout.tsx +13 -32
  49. package/src/components/form/PasswordInput.tsx +211 -0
  50. package/src/components/form/RemoteSelect.tsx +137 -0
  51. package/src/components/form/filter/CollectionFilter.tsx +101 -0
  52. package/src/components/form/filter/CollectionFilterItem.tsx +176 -0
  53. package/src/components/form/filter/DateFilterDynamicComponent.tsx +283 -0
  54. package/src/components/form/filter/FilterValueInput.tsx +198 -0
  55. package/src/components/form/filter/__tests__/CollectionFilterItem.test.tsx +205 -0
  56. package/src/components/form/filter/__tests__/DateFilterDynamicComponent.test.tsx +148 -0
  57. package/src/components/form/filter/__tests__/FilterValueInput.test.tsx +243 -0
  58. package/src/components/form/filter/__tests__/compileFilterGroup.test.ts +146 -0
  59. package/src/components/form/filter/index.ts +13 -0
  60. package/src/components/form/filter/useFilterActionProps.ts +200 -0
  61. package/src/components/form/index.tsx +4 -0
  62. package/src/components/form/table/Table.tsx +2 -1
  63. package/src/components/form/table/styles.ts +19 -0
  64. package/src/components/index.ts +2 -0
  65. package/src/css-variable/CSSVariableProvider.tsx +10 -1
  66. package/src/data-source/ExtendCollectionsProvider.tsx +70 -0
  67. package/src/data-source/index.ts +10 -0
  68. package/src/flow/actions/__tests__/dataScopeFormValueClear.test.ts +96 -0
  69. package/src/flow/actions/dataScope.tsx +3 -0
  70. package/src/flow/admin-shell/admin-layout/AdminLayoutComponent.tsx +1 -4
  71. package/src/flow/admin-shell/admin-layout/HelpLite.tsx +7 -33
  72. package/src/flow/components/BlockItemCard.tsx +2 -2
  73. package/src/flow/components/filter/index.ts +3 -0
  74. package/src/flow/components/filter/useFilterOptions.ts +80 -0
  75. package/src/flow/models/base/ActionModel.tsx +8 -7
  76. package/src/flow/models/base/ActionModelCore.tsx +15 -7
  77. package/src/flow/models/base/GridModel.tsx +93 -36
  78. package/src/flow/models/base/__tests__/GridModel.visibleLayout.test.ts +83 -11
  79. package/src/flow/models/blocks/details/DetailsItemModel.tsx +2 -0
  80. package/src/flow/models/blocks/form/FormItemModel.tsx +5 -3
  81. package/src/flow/models/blocks/form/__tests__/FormItemModel.defineChildren.test.ts +108 -0
  82. package/src/flow/models/blocks/table/TableActionsColumnModel.tsx +5 -0
  83. package/src/flow/models/blocks/table/TableColumnModel.tsx +2 -0
  84. package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/SubTableColumnModel.tsx +2 -0
  85. package/src/flow/utils/dataScopeFormValueClear.ts +278 -0
  86. package/src/hooks/index.ts +2 -0
  87. package/src/hooks/useCurrentAppInfo.ts +36 -0
  88. package/src/index.ts +1 -0
  89. package/src/nocobase-buildin-plugin/index.tsx +66 -18
  90. package/src/nocobase-buildin-plugin/plugins/LocalePlugin.ts +1 -0
  91. package/src/utils/appVersionHTML.ts +28 -0
  92. package/src/utils/globalDeps.ts +2 -2
  93. package/src/utils/index.tsx +2 -0
  94. package/src/utils/remotePlugins.ts +12 -7
@@ -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
+ });
@@ -0,0 +1,146 @@
1
+ /**
2
+ * This file is part of the NocoBase (R) project.
3
+ * Copyright (c) 2020-2024 NocoBase Co., Ltd.
4
+ * Authors: NocoBase Team.
5
+ *
6
+ * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
7
+ * For more information, please refer to: https://www.nocobase.com/agreement.
8
+ */
9
+
10
+ import { describe, expect, it } from 'vitest';
11
+ import { compileFilterGroup } from '../useFilterActionProps';
12
+
13
+ describe('compileFilterGroup', () => {
14
+ it('returns undefined for an empty group so callers can drop the filter param', () => {
15
+ expect(compileFilterGroup(undefined)).toBeUndefined();
16
+ expect(compileFilterGroup({ logic: '$and', items: [] })).toBeUndefined();
17
+ });
18
+
19
+ it('compiles a single condition into the NocoBase {path: {op: val}} envelope', () => {
20
+ const out = compileFilterGroup({
21
+ logic: '$and',
22
+ items: [{ path: 'lockReason', operator: '$includes', value: 'abuse' }],
23
+ });
24
+ expect(out).toEqual({ $and: [{ lockReason: { $includes: 'abuse' } }] });
25
+ });
26
+
27
+ it('preserves the parent logic ($and / $or)', () => {
28
+ const out = compileFilterGroup({
29
+ logic: '$or',
30
+ items: [
31
+ { path: 'lockReason', operator: '$eq', value: 'abuse' },
32
+ { path: 'lockReason', operator: '$eq', value: 'spam' },
33
+ ],
34
+ });
35
+ expect(out).toEqual({
36
+ $or: [{ lockReason: { $eq: 'abuse' } }, { lockReason: { $eq: 'spam' } }],
37
+ });
38
+ });
39
+
40
+ it('compiles nested groups recursively', () => {
41
+ const out = compileFilterGroup({
42
+ logic: '$and',
43
+ items: [
44
+ { path: 'lockReason', operator: '$eq', value: 'abuse' },
45
+ {
46
+ logic: '$or',
47
+ items: [
48
+ { path: 'lockedTs', operator: '$dateAfter', value: '2026-01-01' },
49
+ { path: 'lockedTs', operator: '$dateBefore', value: '2026-12-31' },
50
+ ],
51
+ },
52
+ ],
53
+ });
54
+ expect(out).toEqual({
55
+ $and: [
56
+ { lockReason: { $eq: 'abuse' } },
57
+ {
58
+ $or: [{ lockedTs: { $dateAfter: '2026-01-01' } }, { lockedTs: { $dateBefore: '2026-12-31' } }],
59
+ },
60
+ ],
61
+ });
62
+ });
63
+
64
+ it('drops items with empty values (undefined / "" / [] / {}) so half-edited rows do not 500 the server', () => {
65
+ // Mirrors v1's `removeNullCondition` behaviour. A user who selected a field + operator but hasn't typed a value yet must NOT cause `{path:{operator:undefined}}` to fly out — the server rejects empty operator bodies on `$dateOn` etc.
66
+ const out = compileFilterGroup({
67
+ logic: '$and',
68
+ items: [
69
+ { path: 'lockedTs', operator: '$dateOn', value: undefined },
70
+ { path: 'lockReason', operator: '$eq', value: '' },
71
+ { path: 'lockReason', operator: '$in', value: [] },
72
+ { path: 'lockedTs', operator: '$dateOn', value: {} },
73
+ { path: 'lockReason', operator: '$eq', value: 'kept' },
74
+ ],
75
+ });
76
+ expect(out).toEqual({ $and: [{ lockReason: { $eq: 'kept' } }] });
77
+ });
78
+
79
+ it('expands dotted association paths into nested objects (matches v1 payload shape)', () => {
80
+ // `user.createdBy.password` must reach the server as a nested object so the filter resolver walks the association chain. Flattened-key form (`{ "user.createdBy.password": ... }`) leaves the server treating the whole string as one column name.
81
+ const out = compileFilterGroup({
82
+ logic: '$and',
83
+ items: [{ path: 'user.createdBy.password', operator: '$includes', value: '123' }],
84
+ });
85
+ expect(out).toEqual({
86
+ $and: [{ user: { createdBy: { password: { $includes: '123' } } } }],
87
+ });
88
+ });
89
+
90
+ it('keeps relative date descriptors (non-empty plain objects) intact', () => {
91
+ // `{ type: 'today' }` is an empty-keys-only check away from being pruned by accident. Confirm it survives — that's the server-readable shape for relative-date filters.
92
+ const out = compileFilterGroup({
93
+ logic: '$and',
94
+ items: [{ path: 'lockedTs', operator: '$dateOn', value: { type: 'today' } }],
95
+ });
96
+ expect(out).toEqual({ $and: [{ lockedTs: { $dateOn: { type: 'today' } } }] });
97
+ });
98
+
99
+ it('drops items missing path or operator so half-typed rows do not break the query', () => {
100
+ const out = compileFilterGroup({
101
+ logic: '$and',
102
+ items: [
103
+ { path: '', operator: '$eq', value: 'orphan' },
104
+ { path: 'lockReason', operator: '', value: 'orphan' },
105
+ { path: 'lockReason', operator: '$eq', value: 'kept' },
106
+ ],
107
+ });
108
+ expect(out).toEqual({ $and: [{ lockReason: { $eq: 'kept' } }] });
109
+ });
110
+
111
+ it('drops empty nested groups so a half-built sub-group does not produce { $or: [] }', () => {
112
+ const out = compileFilterGroup({
113
+ logic: '$and',
114
+ items: [
115
+ { path: 'lockReason', operator: '$eq', value: 'abuse' },
116
+ { logic: '$or', items: [] },
117
+ ],
118
+ });
119
+ expect(out).toEqual({ $and: [{ lockReason: { $eq: 'abuse' } }] });
120
+ });
121
+
122
+ it('returns undefined when every item drops out', () => {
123
+ const out = compileFilterGroup({
124
+ logic: '$and',
125
+ items: [
126
+ { path: '', operator: '', value: '' },
127
+ { logic: '$or', items: [] },
128
+ ],
129
+ });
130
+ expect(out).toBeUndefined();
131
+ });
132
+
133
+ it('passes complex value shapes (date descriptors, arrays) through unchanged', () => {
134
+ const dateDescriptor = { type: 'past', number: 3, unit: 'day' };
135
+ const out = compileFilterGroup({
136
+ logic: '$and',
137
+ items: [
138
+ { path: 'lockedTs', operator: '$dateOn', value: dateDescriptor },
139
+ { path: 'lockReason', operator: '$in', value: ['abuse', 'spam'] },
140
+ ],
141
+ });
142
+ expect(out).toEqual({
143
+ $and: [{ lockedTs: { $dateOn: dateDescriptor } }, { lockReason: { $in: ['abuse', 'spam'] } }],
144
+ });
145
+ });
146
+ });
@@ -0,0 +1,13 @@
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
+ // Higher-level filter compositions for non-schema surfaces (settings pages, panels, side drawers). The low-level primitives — `FilterContainer`, `FilterGroup`, `FilterItem`, `fieldsToOptions`, `useFilterOptions` — live under `src/flow/components/filter/`; this layer composes them with a `Collection` binding and exposes the hook/component pair callers actually reach for. The dependency direction is form/filter → flow/components/filter only.
11
+ export { CollectionFilter } from './CollectionFilter';
12
+ export type { CollectionFilterProps } from './CollectionFilter';
13
+ export type { CompiledFilter } from './useFilterActionProps';