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