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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (154) hide show
  1. package/es/Application.d.ts +1 -0
  2. package/es/BaseApplication.d.ts +4 -0
  3. package/es/RouterManager.d.ts +1 -0
  4. package/es/components/KeepAlive.d.ts +22 -0
  5. package/es/components/RouterBridge.d.ts +9 -0
  6. package/es/components/form/DialogFormLayout.d.ts +5 -29
  7. package/es/components/form/filter/CollectionFilter.d.ts +41 -0
  8. package/es/components/form/filter/CollectionFilterItem.d.ts +41 -0
  9. package/es/components/form/filter/DateFilterDynamicComponent.d.ts +57 -0
  10. package/es/components/form/filter/FilterValueInput.d.ts +29 -0
  11. package/es/components/form/filter/index.d.ts +11 -0
  12. package/es/components/form/filter/useFilterActionProps.d.ts +96 -0
  13. package/es/components/form/index.d.ts +1 -0
  14. package/es/data-source/ExtendCollectionsProvider.d.ts +50 -0
  15. package/es/data-source/index.d.ts +9 -0
  16. package/es/flow/FlowPage.d.ts +2 -1
  17. package/es/flow/admin-shell/AdminLayoutRouteCoordinator.d.ts +8 -40
  18. package/es/flow/admin-shell/BaseLayoutModel.d.ts +89 -0
  19. package/es/flow/admin-shell/BaseLayoutRouteCoordinator.d.ts +74 -0
  20. package/es/flow/admin-shell/admin-layout/AdminLayoutEntryGuard.d.ts +12 -0
  21. package/es/flow/admin-shell/admin-layout/AdminLayoutModel.d.ts +7 -92
  22. package/es/flow/admin-shell/admin-layout/index.d.ts +2 -0
  23. package/es/flow/admin-shell/useAdminLayoutRoutePage.d.ts +2 -2
  24. package/es/flow/admin-shell/useLayoutRoutePage.d.ts +23 -0
  25. package/es/flow/components/FlowRoute.d.ts +10 -1
  26. package/es/flow/components/filter/index.d.ts +2 -0
  27. package/es/flow/components/filter/useFilterOptions.d.ts +54 -0
  28. package/es/flow/index.d.ts +4 -0
  29. package/es/flow/models/base/PageModel/PageModel.d.ts +3 -1
  30. package/es/flow/models/blocks/form/FormActionGroupModel.d.ts +1 -0
  31. package/es/flow/models/blocks/table/TableBlockModel.d.ts +10 -0
  32. package/es/flow/models/fields/AssociationFieldModel/SubTableFieldModel/index.d.ts +1 -1
  33. package/es/flow-compat/passwordUtils.d.ts +1 -1
  34. package/es/index.d.ts +2 -0
  35. package/es/index.mjs +491 -439
  36. package/es/layout-manager/LayoutContentRoute.d.ts +14 -0
  37. package/es/layout-manager/LayoutManager.d.ts +22 -0
  38. package/es/layout-manager/LayoutRoute.d.ts +14 -0
  39. package/es/layout-manager/index.d.ts +13 -0
  40. package/es/layout-manager/types.d.ts +20 -0
  41. package/es/layout-manager/utils.d.ts +14 -0
  42. package/es/nocobase-buildin-plugin/index.d.ts +3 -10
  43. package/es/settings-center/index.d.ts +1 -1
  44. package/es/settings-center/plugin-manager/BulkEnableButton.d.ts +15 -0
  45. package/es/settings-center/plugin-manager/PluginCard.d.ts +15 -0
  46. package/es/settings-center/plugin-manager/PluginDetail.d.ts +16 -0
  47. package/es/settings-center/{PluginManagerPage.d.ts → plugin-manager/index.d.ts} +1 -7
  48. package/es/settings-center/plugin-manager/types.d.ts +34 -0
  49. package/lib/index.js +491 -439
  50. package/package.json +8 -7
  51. package/src/Application.tsx +27 -12
  52. package/src/BaseApplication.tsx +19 -0
  53. package/src/PluginSettingsManager.ts +1 -1
  54. package/src/RouterManager.tsx +17 -1
  55. package/src/__tests__/PluginSettingsManager.test.ts +41 -2
  56. package/src/__tests__/app.test.tsx +17 -1
  57. package/src/__tests__/globalDeps.test.ts +1 -0
  58. package/src/__tests__/nocobase-buildin-plugin-auth.test.tsx +45 -2
  59. package/src/__tests__/plugin-manager.test.tsx +177 -0
  60. package/src/__tests__/settings-center.test.tsx +24 -2
  61. package/src/components/KeepAlive.tsx +131 -0
  62. package/src/components/README.md +89 -6
  63. package/src/components/README.zh-CN.md +89 -7
  64. package/src/components/RouterBridge.tsx +28 -4
  65. package/src/components/__tests__/KeepAlive.test.tsx +63 -0
  66. package/src/components/__tests__/RouterBridge.test.tsx +27 -0
  67. package/src/components/form/DialogFormLayout.tsx +5 -29
  68. package/src/components/form/filter/CollectionFilter.tsx +101 -0
  69. package/src/components/form/filter/CollectionFilterItem.tsx +176 -0
  70. package/src/components/form/filter/DateFilterDynamicComponent.tsx +283 -0
  71. package/src/components/form/filter/FilterValueInput.tsx +198 -0
  72. package/src/components/form/filter/__tests__/CollectionFilterItem.test.tsx +205 -0
  73. package/src/components/form/filter/__tests__/DateFilterDynamicComponent.test.tsx +148 -0
  74. package/src/components/form/filter/__tests__/FilterValueInput.test.tsx +243 -0
  75. package/src/components/form/filter/__tests__/compileFilterGroup.test.ts +146 -0
  76. package/src/components/form/filter/index.ts +13 -0
  77. package/src/components/form/filter/useFilterActionProps.ts +200 -0
  78. package/src/components/form/index.tsx +1 -0
  79. package/src/data-source/ExtendCollectionsProvider.tsx +144 -0
  80. package/src/data-source/__tests__/ExtendCollectionsProvider.test.tsx +264 -0
  81. package/src/data-source/index.ts +10 -0
  82. package/src/flow/FlowPage.tsx +35 -7
  83. package/src/flow/__tests__/FlowPage.test.tsx +79 -0
  84. package/src/flow/__tests__/FlowRoute.test.tsx +529 -2
  85. package/src/flow/actions/__tests__/linkageRules.subFormSetFieldProps.test.ts +191 -0
  86. package/src/flow/actions/__tests__/openView.subModelKey.test.tsx +33 -0
  87. package/src/flow/actions/aclCheck.tsx +4 -0
  88. package/src/flow/actions/aclCheckRefresh.tsx +4 -0
  89. package/src/flow/actions/dateTimeFormat.tsx +12 -8
  90. package/src/flow/actions/linkageRules.tsx +122 -0
  91. package/src/flow/actions/openView.tsx +28 -4
  92. package/src/flow/admin-shell/AdminLayoutRouteCoordinator.ts +11 -329
  93. package/src/flow/admin-shell/BaseLayoutModel.tsx +455 -0
  94. package/src/flow/admin-shell/BaseLayoutRouteCoordinator.ts +502 -0
  95. package/src/flow/admin-shell/__tests__/AdminLayoutRouteCoordinator.test.ts +547 -3
  96. package/src/flow/admin-shell/admin-layout/AdminLayoutComponent.tsx +4 -4
  97. package/src/flow/admin-shell/admin-layout/AdminLayoutEntryGuard.tsx +160 -0
  98. package/src/flow/admin-shell/admin-layout/AdminLayoutMenuModels.tsx +0 -12
  99. package/src/flow/admin-shell/admin-layout/AdminLayoutModel.tsx +28 -201
  100. package/src/flow/admin-shell/admin-layout/AdminLayoutSlotModels.tsx +11 -2
  101. package/src/flow/admin-shell/admin-layout/__tests__/AdminLayoutMenuModels.test.ts +1 -26
  102. package/src/flow/admin-shell/admin-layout/__tests__/AdminLayoutModel.test.tsx +149 -27
  103. package/src/flow/admin-shell/admin-layout/index.ts +2 -0
  104. package/src/flow/admin-shell/useAdminLayoutRoutePage.ts +10 -26
  105. package/src/flow/admin-shell/useLayoutRoutePage.ts +61 -0
  106. package/src/flow/components/AdminLayout.tsx +4 -154
  107. package/src/flow/components/FlowRoute.tsx +105 -15
  108. package/src/flow/components/filter/index.ts +3 -0
  109. package/src/flow/components/filter/useFilterOptions.ts +80 -0
  110. package/src/flow/index.ts +4 -0
  111. package/src/flow/models/base/ActionModel.tsx +8 -1
  112. package/src/flow/models/base/PageModel/PageModel.tsx +51 -18
  113. package/src/flow/models/base/PageModel/RootPageModel.tsx +6 -13
  114. package/src/flow/models/base/PageModel/__tests__/PageModel.test.ts +102 -1
  115. package/src/flow/models/base/RouteModel.tsx +1 -1
  116. package/src/flow/models/blocks/form/FormActionGroupModel.tsx +14 -0
  117. package/src/flow/models/blocks/form/FormItemModel.tsx +8 -1
  118. package/src/flow/models/blocks/form/__tests__/FormActionGroupModel.test.ts +46 -0
  119. package/src/flow/models/blocks/form/submitValues.ts +4 -1
  120. package/src/flow/models/blocks/table/TableBlockModel.tsx +118 -16
  121. package/src/flow/models/blocks/table/__tests__/TableBlockModel.rowSelection.test.tsx +114 -0
  122. package/src/flow/models/fields/AssociationFieldModel/SubFormFieldModel.tsx +7 -1
  123. package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/SubTableField.tsx +1 -1
  124. package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/index.tsx +6 -5
  125. package/src/flow/models/fields/ClickableFieldModel.tsx +9 -1
  126. package/src/flow/models/fields/DisplayTimeFieldModel.tsx +1 -1
  127. package/src/flow/models/fields/TimeFieldModel.tsx +1 -1
  128. package/src/flow/models/fields/__tests__/TimeFieldModel.test.tsx +61 -0
  129. package/src/flow/models/fields/mobile-components/MobileDatePicker.tsx +19 -3
  130. package/src/flow/models/fields/mobile-components/__tests__/MobileDatePicker.test.tsx +94 -0
  131. package/src/flow/models/topbar/TopbarActionModel.tsx +1 -1
  132. package/src/flow/utils/__tests__/dateTimeFormat.test.ts +91 -0
  133. package/src/index.ts +2 -0
  134. package/src/layout-manager/LayoutContentRoute.tsx +90 -0
  135. package/src/layout-manager/LayoutManager.tsx +185 -0
  136. package/src/layout-manager/LayoutRoute.tsx +138 -0
  137. package/src/layout-manager/__tests__/LayoutManager.test.tsx +335 -0
  138. package/src/layout-manager/__tests__/LayoutRoute.test.tsx +473 -0
  139. package/src/layout-manager/index.ts +14 -0
  140. package/src/layout-manager/types.ts +22 -0
  141. package/src/layout-manager/utils.ts +37 -0
  142. package/src/nocobase-buildin-plugin/index.tsx +69 -67
  143. package/src/nocobase-buildin-plugin/plugins/LocalePlugin.ts +1 -0
  144. package/src/settings-center/index.ts +1 -1
  145. package/src/settings-center/plugin-manager/BulkEnableButton.tsx +111 -0
  146. package/src/settings-center/plugin-manager/PluginCard.tsx +270 -0
  147. package/src/settings-center/plugin-manager/PluginDetail.tsx +195 -0
  148. package/src/settings-center/plugin-manager/index.tsx +254 -0
  149. package/src/settings-center/plugin-manager/types.ts +35 -0
  150. package/src/settings-center/utils.tsx +8 -1
  151. package/src/theme/__tests__/globalStyles.test.ts +24 -0
  152. package/src/theme/globalStyles.ts +10 -0
  153. package/src/utils/globalDeps.ts +2 -0
  154. package/src/settings-center/PluginManagerPage.tsx +0 -162
@@ -0,0 +1,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';
@@ -0,0 +1,200 @@
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 { Collection, observable } from '@nocobase/flow-engine';
11
+ import { useMemoizedFn } from 'ahooks';
12
+ import { useMemo, useRef } from 'react';
13
+ import { FilterOption, useFilterOptions, UseFilterOptionsArgs } from '../../../flow/components/filter/useFilterOptions';
14
+ import { CollectionFilterItemValue, createCollectionFilterItem } from './CollectionFilterItem';
15
+
16
+ /** A single condition row (`{ path, operator, value }`) or a nested group. */
17
+ export type FilterGroupItem = CollectionFilterItemValue | FilterGroupValue;
18
+
19
+ /**
20
+ * Reactive shape consumed by `FilterContainer` / `FilterGroup`. `logic` is the join (`$and` / `$or`) and `items` is a heterogeneous list of leaf conditions and nested groups.
21
+ */
22
+ export type FilterGroupValue = {
23
+ logic: '$and' | '$or';
24
+ items: FilterGroupItem[];
25
+ };
26
+
27
+ /** Compiled filter param accepted by NocoBase resource `list`. */
28
+ export type CompiledFilter = Record<string, unknown> | undefined;
29
+
30
+ interface FilterCtxModel {
31
+ translate: (key: string) => string;
32
+ dispatchEvent: (event: 'submit' | 'reset' | (string & {})) => void;
33
+ }
34
+
35
+ export interface FilterCtx {
36
+ model: FilterCtxModel;
37
+ }
38
+
39
+ const isGroup = (item: FilterGroupItem): item is FilterGroupValue =>
40
+ Array.isArray((item as FilterGroupValue).items) && typeof (item as FilterGroupValue).logic === 'string';
41
+
42
+ const isCondition = (item: FilterGroupItem): item is CollectionFilterItemValue =>
43
+ typeof (item as CollectionFilterItemValue).path === 'string' &&
44
+ Object.prototype.hasOwnProperty.call(item, 'operator');
45
+
46
+ /**
47
+ * `true` when the rhs of a condition is "no real value yet" — covers `undefined` / `null` / empty string / empty array / empty plain object. Mirrors v1's `removeNullCondition` `isEmpty` predicate so half-filled rows ("Locked time → is → (no date picked yet)") get dropped on Submit instead of being sent to the server as `{lockedTs:{}}` and triggering a 500.
48
+ */
49
+ const isEmptyFilterValue = (value: unknown): boolean => {
50
+ if (value === undefined || value === null || value === '') return true;
51
+ if (Array.isArray(value)) return value.length === 0;
52
+ if (typeof value === 'object') {
53
+ // Plain `{}` only — descriptor shapes like `{ type: 'today' }` have own keys and survive this check.
54
+ return Object.keys(value as Record<string, unknown>).length === 0;
55
+ }
56
+ return false;
57
+ };
58
+
59
+ /**
60
+ * Build a nested object from a dotted path. `'user.createdBy.password'` + `{ $includes: '123' }` becomes `{ user: { createdBy: { password: { $includes: '123' } } } }`. Matches v1's filter payload shape so server-side filter resolution sees the same association chain whether the request came from a v1 or v2 page.
61
+ */
62
+ const nestPath = (path: string, leaf: unknown): Record<string, unknown> => {
63
+ const segments = path.split('.');
64
+ let result: unknown = leaf;
65
+ for (let i = segments.length - 1; i >= 0; i--) {
66
+ result = { [segments[i]]: result };
67
+ }
68
+ return result as Record<string, unknown>;
69
+ };
70
+
71
+ /**
72
+ * Compile a reactive filter group into the `{ $and: [{ path: { op: val } }] }` envelope accepted by NocoBase's resource `list` filter param. Returns `undefined` when the group is empty so callers can drop the param.
73
+ *
74
+ * Mirrors v1's `removeNullCondition` + filter compile path, but works on the v2 `{ logic, items }` group structure rather than v1's Formily-bracketed `$and.0.path.$eq` shape:
75
+ *
76
+ * - Rows missing `path` or `operator` are dropped (still mid-edit).
77
+ * - Rows whose `value` is empty (`undefined`, `''`, `[]`, `{}`) are dropped — matches v1, which sends `filter={}` for a row with only a field/operator picked. Sending `{lockedTs:{}}` would 500.
78
+ * - Dotted association paths (`user.createdBy.password`) are expanded into nested objects — matches v1's payload shape, which the server resolves along the association chain rather than treating the dotted string as a single key.
79
+ * - Empty groups (after pruning) propagate as `undefined` so the outermost caller can drop the whole `filter` param.
80
+ */
81
+ export function compileFilterGroup(group: FilterGroupValue | undefined): CompiledFilter {
82
+ if (!group?.items?.length) return undefined;
83
+ const compiled = group.items
84
+ .map((entry) => {
85
+ if (isGroup(entry)) return compileFilterGroup(entry);
86
+ if (!isCondition(entry) || !entry.path || !entry.operator) return undefined;
87
+ if (isEmptyFilterValue(entry.value)) return undefined;
88
+ return nestPath(entry.path, { [entry.operator]: entry.value });
89
+ })
90
+ .filter((v): v is Record<string, unknown> => !!v);
91
+ if (!compiled.length) return undefined;
92
+ return { [group.logic]: compiled };
93
+ }
94
+
95
+ const createEmptyGroup = (): FilterGroupValue => ({ logic: '$and', items: [] });
96
+
97
+ /** Which footer button triggered the apply — useful for closing a popover on Submit but keeping it open on Reset. */
98
+ export type FilterApplyAction = 'submit' | 'reset';
99
+
100
+ export interface UseFilterActionPropsArgs extends UseFilterOptionsArgs {
101
+ /** Collection whose fields populate the filter row's field picker. */
102
+ collection: Collection | undefined;
103
+ /**
104
+ * Called when the user submits or resets the filter popover. Receives the compiled filter param (`undefined` when cleared) and which footer button triggered the call. Typical implementation: `(filter, action) => { listRequest.run(filter); if (action === 'submit') closePopover(); }`.
105
+ */
106
+ onApply: (filter: CompiledFilter, action: FilterApplyAction) => void;
107
+ }
108
+
109
+ export interface UseFilterActionPropsResult {
110
+ /**
111
+ * Reactive filter group state. Pass directly to `<FilterContent value={...}>`. Stable across renders.
112
+ */
113
+ value: FilterGroupValue;
114
+ /** Field-option tree (for inspection or custom badges). */
115
+ options: FilterOption[];
116
+ /** Bound `FilterItem` component to plug into `<FilterContent FilterItem={...}>`. */
117
+ FilterItem: ReturnType<typeof createCollectionFilterItem> | undefined;
118
+ /**
119
+ * Ready-to-use `ctx` for `<FilterContent ctx={...}>`. Wires Submit / Reset buttons to `onSubmit` / `onReset` below.
120
+ */
121
+ ctx: FilterCtx;
122
+ /** Imperative trigger — submit current group as a compiled filter. */
123
+ onSubmit: () => void;
124
+ /** Imperative trigger — clear the group and emit an empty filter. */
125
+ onReset: () => void;
126
+ /**
127
+ * Count of top-level condition rows. Useful for showing a badge like `Filter (3)` on the trigger button — matches v1's `field.title = t('{{count}} filter items', { count })`.
128
+ */
129
+ conditionCount: number;
130
+ }
131
+
132
+ /**
133
+ * v2 equivalent of v1's `useFilterActionProps` for non-schema surfaces (settings pages, panels, side drawers). Bundles three things v1's hook returned implicitly through schema:
134
+ *
135
+ * - A reactive `{ logic, items }` group state that `<FilterContent>` reads.
136
+ * - A bound `FilterItem` component (driven by `createCollectionFilterItem`).
137
+ * - A `ctx` object that turns `<FilterContent>`'s `dispatchEvent('submit' | 'reset')` into a compiled filter param passed to `onApply`.
138
+ *
139
+ * Pair with antd `Popover` to recreate the legacy `Filter.Action` UX:
140
+ *
141
+ * ```tsx
142
+ * const { value, ctx, FilterItem, onSubmit, conditionCount } = useFilterActionProps({
143
+ * collection,
144
+ * onApply: (filter) => listRequest.run(filter),
145
+ * t,
146
+ * });
147
+ * return (
148
+ * <Popover content={<FilterContent value={value} ctx={ctx} FilterItem={FilterItem} />}>
149
+ * <Button>{t('Filter')}{conditionCount ? ` (${conditionCount})` : ''}</Button>
150
+ * </Popover>
151
+ * );
152
+ * ```
153
+ */
154
+ export function useFilterActionProps(args: UseFilterActionPropsArgs): UseFilterActionPropsResult {
155
+ const { collection, onApply, filterableFieldNames, noIgnore, t } = args;
156
+
157
+ // Held in a ref so the group object identity is stable for the lifetime of the host component — `<FilterContent>` mutates this object directly (push/splice on `items`, swap `logic`), and a fresh observable on every render would reset that internal state.
158
+ const valueRef = useRef<FilterGroupValue>();
159
+ if (!valueRef.current) {
160
+ valueRef.current = observable(createEmptyGroup()) as FilterGroupValue;
161
+ }
162
+ const value = valueRef.current;
163
+
164
+ const options = useFilterOptions(collection, { filterableFieldNames, noIgnore, t });
165
+
166
+ const FilterItem = useMemo(
167
+ () => (collection ? createCollectionFilterItem(collection, { filterableFieldNames, noIgnore, t }) : undefined),
168
+ [collection, filterableFieldNames, noIgnore, t],
169
+ );
170
+
171
+ const onSubmit = useMemoizedFn(() => {
172
+ onApply(compileFilterGroup(value), 'submit');
173
+ });
174
+
175
+ const onReset = useMemoizedFn(() => {
176
+ value.logic = '$and';
177
+ value.items = [];
178
+ onApply(undefined, 'reset');
179
+ });
180
+
181
+ const translate = useMemoizedFn((key: string) => (t ? t(key) : key));
182
+
183
+ const ctx = useMemo<FilterCtx>(
184
+ () => ({
185
+ model: {
186
+ translate,
187
+ dispatchEvent: (event: string) => {
188
+ if (event === 'submit') onSubmit();
189
+ else if (event === 'reset') onReset();
190
+ },
191
+ },
192
+ }),
193
+ [translate, onSubmit, onReset],
194
+ );
195
+
196
+ // Re-read on each render so `observer`-wrapped hosts re-render when the reactive `items` array length changes. No useMemo needed — the `value` object's identity is stable (held in a ref), but its observable `items.length` is what we actually care about, and the eslint exhaustive-deps rule rightly complains about depending on a mutable property of a stable ref.
197
+ const conditionCount = value.items.length;
198
+
199
+ return { value, options, FilterItem, ctx, onSubmit, onReset, conditionCount };
200
+ }
@@ -8,6 +8,7 @@
8
8
  */
9
9
 
10
10
  export * from './createFormRegistry';
11
+ export * from './filter';
11
12
  export * from './DialogFormLayout';
12
13
  export * from './DrawerFormLayout';
13
14
  export * from './EnvVariableInput';