@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,211 @@
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 { Input, type InputProps } from 'antd';
11
+ import type { PasswordProps as AntdPasswordProps } from 'antd/es/input';
12
+ import React from 'react';
13
+
14
+ // --- Strength scoring -------------------------------------------------------
15
+ // Pure scoring function ported from the v1 client's password utils. Returns a
16
+ // bucketed score in `[20, 40, 60, 80, 100]` based on character-class diversity,
17
+ // length, repeated / sequential / consecutive character penalties, and a
18
+ // "middle non-letter / non-symbol" bonus. No external dependencies — safe to
19
+ // run on any string.
20
+ //
21
+ // Kept private to this module on purpose. Callers consume the visual strength
22
+ // bar via `<PasswordInput checkStrength>`; they shouldn't need to compute the
23
+ // raw score themselves.
24
+
25
+ const isNum = (c: number) => c >= 48 && c <= 57;
26
+ const isLower = (c: number) => c >= 97 && c <= 122;
27
+ const isUpper = (c: number) => c >= 65 && c <= 90;
28
+ const isSymbol = (c: number) => !(isLower(c) || isUpper(c) || isNum(c));
29
+ const isLetter = (c: number) => isLower(c) || isUpper(c);
30
+
31
+ function getStrength(val: string): number {
32
+ if (!val) return 0;
33
+ let num = 0;
34
+ let lower = 0;
35
+ let upper = 0;
36
+ let symbol = 0;
37
+ let MNS = 0;
38
+ let rep = 0;
39
+ let repC = 0;
40
+ let consecutive = 0;
41
+ let sequential = 0;
42
+ const len = () => num + lower + upper + symbol;
43
+ const callMe = () => {
44
+ let re = num > 0 ? 1 : 0;
45
+ re += lower > 0 ? 1 : 0;
46
+ re += upper > 0 ? 1 : 0;
47
+ re += symbol > 0 ? 1 : 0;
48
+ return re > 2 && len() >= 8 ? re + 1 : 0;
49
+ };
50
+ for (let i = 0; i < val.length; i++) {
51
+ const c = val.charCodeAt(i);
52
+ if (isNum(c)) {
53
+ num++;
54
+ if (i !== 0 && i !== val.length - 1) MNS++;
55
+ if (i > 0 && isNum(val.charCodeAt(i - 1))) consecutive++;
56
+ } else if (isLower(c)) {
57
+ lower++;
58
+ if (i > 0 && isLower(val.charCodeAt(i - 1))) consecutive++;
59
+ } else if (isUpper(c)) {
60
+ upper++;
61
+ if (i > 0 && isUpper(val.charCodeAt(i - 1))) consecutive++;
62
+ } else {
63
+ symbol++;
64
+ if (i !== 0 && i !== val.length - 1) MNS++;
65
+ }
66
+ let exists = false;
67
+ for (let j = 0; j < val.length; j++) {
68
+ if (val[i] === val[j] && i !== j) {
69
+ exists = true;
70
+ repC += Math.abs(val.length / (j - i));
71
+ }
72
+ }
73
+ if (exists) {
74
+ rep++;
75
+ const unique = val.length - rep;
76
+ repC = unique ? Math.ceil(repC / unique) : Math.ceil(repC);
77
+ }
78
+ if (i > 1) {
79
+ const last1 = val.charCodeAt(i - 1);
80
+ const last2 = val.charCodeAt(i - 2);
81
+ if (isLetter(c)) {
82
+ if (isLetter(last1) && isLetter(last2)) {
83
+ const v = val.toLowerCase();
84
+ const vi = v.charCodeAt(i);
85
+ const vi1 = v.charCodeAt(i - 1);
86
+ const vi2 = v.charCodeAt(i - 2);
87
+ if (vi - vi1 === vi1 - vi2 && Math.abs(vi - vi1) === 1) sequential++;
88
+ }
89
+ } else if (isNum(c)) {
90
+ if (isNum(last1) && isNum(last2)) {
91
+ if (c - last1 === last1 - last2 && Math.abs(c - last1) === 1) sequential++;
92
+ }
93
+ } else if (isSymbol(last1) && isSymbol(last2)) {
94
+ if (c - last1 === last1 - last2 && Math.abs(c - last1) === 1) sequential++;
95
+ }
96
+ }
97
+ }
98
+ let sum = 0;
99
+ const length = len();
100
+ sum += 4 * length;
101
+ if (lower > 0) sum += 2 * (length - lower);
102
+ if (upper > 0) sum += 2 * (length - upper);
103
+ if (num !== length) sum += 4 * num;
104
+ sum += 6 * symbol;
105
+ sum += 2 * MNS;
106
+ sum += 2 * callMe();
107
+ if (length === lower + upper) sum -= length;
108
+ if (length === num) sum -= num;
109
+ sum -= repC;
110
+ sum -= 2 * consecutive;
111
+ sum -= 3 * sequential;
112
+ sum = Math.max(0, Math.min(100, sum));
113
+
114
+ if (sum >= 80) return 100;
115
+ if (sum >= 60) return 80;
116
+ if (sum >= 40) return 60;
117
+ if (sum >= 20) return 40;
118
+ return 20;
119
+ }
120
+
121
+ // --- Strength bar UI --------------------------------------------------------
122
+ // Colours and pixel sizes are intentionally kept identical to v1's
123
+ // `PasswordStrength` so the visual remains unchanged across the v1 → v2
124
+ // migration. When the design system formalises tokens for "strength signal"
125
+ // colours, swap these literals for the matching token expressions.
126
+
127
+ const segmentDividerStyle: React.CSSProperties = {
128
+ position: 'absolute',
129
+ zIndex: 1,
130
+ height: 8,
131
+ top: 0,
132
+ background: '#fff',
133
+ width: 1,
134
+ transform: 'translate(-50%, 0)',
135
+ };
136
+
137
+ function StrengthBar({ score }: { score: number }) {
138
+ return (
139
+ <div
140
+ style={{
141
+ background: '#e0e0e0',
142
+ marginBottom: 3,
143
+ position: 'relative',
144
+ }}
145
+ >
146
+ {/* Four white dividers split the bar into five strength brackets that
147
+ line up with the bucketed scoring in `getStrength`. */}
148
+ <div style={{ ...segmentDividerStyle, left: '20%' }} />
149
+ <div style={{ ...segmentDividerStyle, left: '40%' }} />
150
+ <div style={{ ...segmentDividerStyle, left: '60%' }} />
151
+ <div style={{ ...segmentDividerStyle, left: '80%' }} />
152
+ {/* The full gradient is always laid down, then `clip-path` trims it back
153
+ to the current score percentage — gives a smooth fill animation on
154
+ value change without re-painting the gradient on every render. */}
155
+ <div
156
+ style={{
157
+ position: 'relative',
158
+ backgroundImage: '-webkit-linear-gradient(left, #ff5500, #ff9300)',
159
+ transition: 'all 0.35s ease-in-out',
160
+ height: 8,
161
+ width: '100%',
162
+ marginTop: 5,
163
+ clipPath: `polygon(0 0,${score}% 0,${score}% 100%,0 100%)`,
164
+ }}
165
+ />
166
+ </div>
167
+ );
168
+ }
169
+
170
+ // --- Public component -------------------------------------------------------
171
+
172
+ export interface PasswordInputProps extends AntdPasswordProps {
173
+ /**
174
+ * Render a visual strength bar beneath the input. Defaults to `false`. The
175
+ * score is computed locally — opting in does NOT add any form validation;
176
+ * use a separate `Form.Item.rules` entry for that (or wire the entry up to
177
+ * a cross-plugin password-validator extension point if your project
178
+ * provides one).
179
+ */
180
+ checkStrength?: boolean;
181
+ }
182
+
183
+ /**
184
+ * `Input.Password` plus an optional strength meter, ported from the v1
185
+ * `Password` component. The strength scoring and bar UI are identical to v1,
186
+ * so users who switch from a v1 page to a v2 page see the same visual signal.
187
+ *
188
+ * The component is value-shape compatible with antd `Input.Password` — drop
189
+ * it into any existing `Form.Item<password>` and toggle the meter with
190
+ * `checkStrength`.
191
+ *
192
+ * Caveats:
193
+ *
194
+ * - Strength scoring is purely a UX hint, not validation. Submitting a weak
195
+ * password is still allowed unless the server (or a separately installed
196
+ * password-policy plugin) rejects it.
197
+ * - The meter swallows the gap between `<Input.Password>` and the next form
198
+ * element. If your `Form.Item` already adds vertical rhythm, the meter
199
+ * inherits it; no extra spacing is added.
200
+ */
201
+ export function PasswordInput(props: PasswordInputProps) {
202
+ const { value, checkStrength, ...rest } = props;
203
+ return (
204
+ <span>
205
+ <Input.Password {...(rest as InputProps)} value={value} />
206
+ {checkStrength ? <StrengthBar score={getStrength(String(value || ''))} /> : null}
207
+ </span>
208
+ );
209
+ }
210
+
211
+ export default PasswordInput;
@@ -0,0 +1,137 @@
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 { useRequest } from 'ahooks';
11
+ import { Select, type SelectProps } from 'antd';
12
+ import React, { useMemo } from 'react';
13
+
14
+ export interface RemoteSelectFieldNames {
15
+ label?: string;
16
+ value?: string;
17
+ }
18
+
19
+ export interface RemoteSelectProps<RawItem = any, Resp = RawItem[], V = any>
20
+ extends Omit<SelectProps<V>, 'options' | 'loading'> {
21
+ /**
22
+ * Fetch the option source. Receives no arguments; caller closes over the
23
+ * `ctx.api.resource(...)` (or any other source) it needs. May resolve
24
+ * with either an array of raw items (the common case) or an arbitrary
25
+ * envelope object — in the latter case, supply `selectItems` to pluck
26
+ * the array out.
27
+ */
28
+ request: () => Promise<Resp | undefined>;
29
+ /**
30
+ * When `request` returns an envelope (object with metadata around the
31
+ * list), use this to extract the array of items that drives the
32
+ * dropdown. Defaults to identity, i.e. `request` itself returns the
33
+ * array.
34
+ */
35
+ selectItems?: (response: Resp) => RawItem[] | undefined;
36
+ /**
37
+ * Names of the raw item properties that hold the display label and the
38
+ * persisted value. Defaults to `{ label: 'label', value: 'value' }`.
39
+ * Ignored when `mapOptions` is supplied.
40
+ */
41
+ fieldNames?: RemoteSelectFieldNames;
42
+ /**
43
+ * Full custom mapping from a raw item to an antd `OptionType`. When
44
+ * provided, overrides `fieldNames`.
45
+ */
46
+ mapOptions?: (item: RawItem, index: number) => { label: React.ReactNode; value: any };
47
+ /**
48
+ * Stable cache key for ahooks `useRequest` so the dropdown doesn't re-fetch
49
+ * on every re-mount. Pass a value tied to the request's effective inputs.
50
+ */
51
+ cacheKey?: string;
52
+ /**
53
+ * Re-run the request when any of these values changes. Forwarded to
54
+ * `useRequest`'s `refreshDeps`.
55
+ */
56
+ refreshDeps?: unknown[];
57
+ /**
58
+ * Skip the auto-fetch on mount when `false`. Defaults to `true`.
59
+ */
60
+ ready?: boolean;
61
+ /**
62
+ * Notified once the request resolves. Receives both the mapped item
63
+ * array and the raw response envelope — useful when callers need to
64
+ * read sibling metadata (counts, availability hints, etc.) without
65
+ * issuing a second request.
66
+ */
67
+ onLoaded?: (items: RawItem[], response: Resp) => void;
68
+ }
69
+
70
+ /**
71
+ * Generic settings-page Select bound to an async option source. The
72
+ * component itself stays framework-agnostic — it knows nothing about
73
+ * NocoBase resources, data sources, or Formily. Pass any async `request`
74
+ * that resolves with an array, and supply `fieldNames` (or `mapOptions`)
75
+ * to map raw items to antd option shape.
76
+ *
77
+ * Search is local-only (antd's default `optionFilterProp="label"`). For
78
+ * server-side search, drive `request` from external state and pass the
79
+ * search input via `refreshDeps`.
80
+ */
81
+ export function RemoteSelect<RawItem = any, Resp = RawItem[], V = any>(props: RemoteSelectProps<RawItem, Resp, V>) {
82
+ const {
83
+ request,
84
+ selectItems,
85
+ fieldNames,
86
+ mapOptions,
87
+ cacheKey,
88
+ refreshDeps,
89
+ ready = true,
90
+ onLoaded,
91
+ showSearch = true,
92
+ allowClear = true,
93
+ ...selectProps
94
+ } = props;
95
+
96
+ const { data: response, loading } = useRequest<Resp | undefined, []>(request, {
97
+ cacheKey,
98
+ refreshDeps,
99
+ ready,
100
+ onSuccess: (resp) => {
101
+ if (resp === undefined) return;
102
+ const items = (selectItems ? selectItems(resp) : (resp as unknown as RawItem[])) || [];
103
+ onLoaded?.(items, resp);
104
+ },
105
+ });
106
+
107
+ const items = useMemo<RawItem[]>(() => {
108
+ if (response === undefined) return [];
109
+ return (selectItems ? selectItems(response) : (response as unknown as RawItem[])) || [];
110
+ }, [response, selectItems]);
111
+
112
+ const labelKey = fieldNames?.label ?? 'label';
113
+ const valueKey = fieldNames?.value ?? 'value';
114
+
115
+ const options = useMemo(() => {
116
+ if (mapOptions) {
117
+ return items.map((item, index) => mapOptions(item, index));
118
+ }
119
+ return items.map((item: any) => ({
120
+ label: item?.[labelKey],
121
+ value: item?.[valueKey],
122
+ }));
123
+ }, [items, mapOptions, labelKey, valueKey]);
124
+
125
+ return (
126
+ <Select
127
+ {...selectProps}
128
+ showSearch={showSearch}
129
+ allowClear={allowClear}
130
+ optionFilterProp="label"
131
+ loading={loading}
132
+ options={options}
133
+ />
134
+ );
135
+ }
136
+
137
+ export default RemoteSelect;
@@ -0,0 +1,101 @@
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 { FilterOutlined } from '@ant-design/icons';
11
+ import type { Collection } from '@nocobase/flow-engine';
12
+ import { Button, type ButtonProps, Popover, type PopoverProps } from 'antd';
13
+ import React, { FC, useState } from 'react';
14
+ import { FilterContent } from '../../../flow/components/filter';
15
+ import { CompiledFilter, FilterApplyAction, useFilterActionProps } from './useFilterActionProps';
16
+
17
+ const identity = (s: string) => s;
18
+
19
+ export interface CollectionFilterProps {
20
+ /** Collection whose fields drive the filter row's field picker. */
21
+ collection: Collection | undefined;
22
+ /** Called on Submit or Reset with the compiled NocoBase filter param (`undefined` when cleared). */
23
+ onChange: (filter: CompiledFilter) => void;
24
+ /** Translator. Defaults to identity. */
25
+ t?: (key: string, options?: Record<string, any>) => string;
26
+ /** Whitelist of root-level field names to expose. */
27
+ filterableFieldNames?: string[];
28
+ /** Bypass the `filterableFieldNames` whitelist. */
29
+ noIgnore?: boolean;
30
+ /** Override the trigger button's label. Defaults to `t('Filter')`, or the v1-style `t('{{count}} filter items', { count })` when conditions are present. */
31
+ buttonText?: React.ReactNode;
32
+ /** Swap the default `t('Filter')` label for v1's `t('{{count}} filter items', { count })` when conditions are present. Defaults to `true`. */
33
+ showCount?: boolean;
34
+ /** Pass-through props for the antd `<Popover>`. */
35
+ popoverProps?: Omit<PopoverProps, 'open' | 'onOpenChange' | 'content' | 'children'>;
36
+ /** Pass-through props for the trigger `<Button>`. */
37
+ buttonProps?: Omit<ButtonProps, 'icon' | 'type' | 'children' | 'onClick'>;
38
+ /** Min-width applied to the popover body. Defaults to `520`. */
39
+ popoverMinWidth?: number;
40
+ }
41
+
42
+ /**
43
+ * Filter button bound to a collection. Renders an antd `<Popover>` over a `<Button>`; the popover hosts a multi-condition filter form (field picker, operator, value). Submit dismisses the popover and emits the compiled filter via `onChange`; Reset keeps the popover open and emits `undefined`.
44
+ *
45
+ * Pair with `<ExtendCollectionsProvider>` when the target collection is client-only (e.g. a `schema-only` server collection that isn't auto-published to the v2 data source).
46
+ */
47
+ export const CollectionFilter: FC<CollectionFilterProps> = (props) => {
48
+ const {
49
+ collection,
50
+ onChange,
51
+ t = identity,
52
+ filterableFieldNames,
53
+ noIgnore,
54
+ buttonText,
55
+ showCount = true,
56
+ popoverProps,
57
+ buttonProps,
58
+ popoverMinWidth = 520,
59
+ } = props;
60
+
61
+ const [open, setOpen] = useState(false);
62
+
63
+ const filterAction = useFilterActionProps({
64
+ collection,
65
+ filterableFieldNames,
66
+ noIgnore,
67
+ t,
68
+ onApply: (filter: CompiledFilter, action: FilterApplyAction) => {
69
+ onChange(filter);
70
+ if (action === 'submit') setOpen(false);
71
+ },
72
+ });
73
+
74
+ // Matches v1's `Filter.Action`: when at least one condition is set, the button label switches to the count-aware string (`"N 个筛选项"` in zh-CN). The button itself stays in the default (white) style — v1 never flipped it to `type='primary'`.
75
+ const label =
76
+ buttonText ??
77
+ (showCount && filterAction.conditionCount > 0
78
+ ? t('{{count}} filter items', { count: filterAction.conditionCount })
79
+ : t('Filter'));
80
+
81
+ return (
82
+ <Popover
83
+ trigger="click"
84
+ placement="bottomLeft"
85
+ {...popoverProps}
86
+ open={open}
87
+ onOpenChange={setOpen}
88
+ content={
89
+ <div style={{ minWidth: popoverMinWidth }}>
90
+ <FilterContent value={filterAction.value} ctx={filterAction.ctx} FilterItem={filterAction.FilterItem} />
91
+ </div>
92
+ }
93
+ >
94
+ <Button icon={<FilterOutlined />} disabled={!collection} {...buttonProps}>
95
+ {label}
96
+ </Button>
97
+ </Popover>
98
+ );
99
+ };
100
+
101
+ export default CollectionFilter;
@@ -0,0 +1,176 @@
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 { css } from '@emotion/css';
11
+ import { Collection, observer } from '@nocobase/flow-engine';
12
+ import { Cascader, Select, Space } from 'antd';
13
+ import React, { FC, useMemo } from 'react';
14
+ import { FilterOption, useFilterOptions } from '../../../flow/components/filter/useFilterOptions';
15
+ import { FilterValueInput } from './FilterValueInput';
16
+
17
+ /**
18
+ * Lift the Cascader sub-menu height so a target collection with many fields (e.g. `users` → id / nickname / username / email / phone / password / createdAt / updatedAt / roles / createdBy / updatedBy) doesn't get truncated below the default antd menu viewport.
19
+ */
20
+ const cascaderPopupClass = css`
21
+ .ant-cascader-menu {
22
+ height: fit-content;
23
+ max-height: 50vh;
24
+ }
25
+ `;
26
+
27
+ export interface CollectionFilterItemValue {
28
+ path: string;
29
+ operator: string;
30
+ /** Operator-dependent — string for default ops, descriptor for $date*, etc. */
31
+ value: any;
32
+ }
33
+
34
+ type CascaderOption = {
35
+ value: string;
36
+ label: string;
37
+ children?: CascaderOption[];
38
+ };
39
+
40
+ export interface CollectionFilterItemProps {
41
+ /** Reactive filter row managed by the parent `FilterContainer`. */
42
+ value: CollectionFilterItemValue;
43
+ /** Target collection whose fields populate the field selector. */
44
+ collection: Collection;
45
+ /** Whitelist of field names to expose; empty/undefined means all filterable fields. */
46
+ filterableFieldNames?: string[];
47
+ /** Bypass the `filterableFieldNames` whitelist (matches the legacy FilterItem `noIgnore`). */
48
+ noIgnore?: boolean;
49
+ /** Translator; defaults to identity so callers can omit it. */
50
+ t?: (key: string) => string;
51
+ }
52
+
53
+ const identity = (s: string) => s;
54
+
55
+ /**
56
+ * Walk a tree of field options by name path, returning the leaf option (or undefined when the path doesn't resolve). Used to look up operators for the currently selected field.
57
+ */
58
+ const findOptionByPath = (options: FilterOption[], path: string[]): FilterOption | undefined => {
59
+ if (!path.length) return undefined;
60
+ const [head, ...rest] = path;
61
+ const match = options.find((option) => option.name === head);
62
+ if (!match) return undefined;
63
+ if (!rest.length) return match;
64
+ return findOptionByPath(match.children || [], rest);
65
+ };
66
+
67
+ /**
68
+ * Convert the field-option tree (as produced by `useFilterOptions`) into antd `Cascader`'s expected `{ value, label, children }` shape. With `changeOnSelect={false}` (see the render below), antd already requires selection at a leaf — we don't need to mark association parents as `disabled`, and doing so would also block `expandTrigger="hover"` from descending into them.
69
+ */
70
+ const toCascaderOptions = (options: FilterOption[]): CascaderOption[] =>
71
+ options.map((option) => {
72
+ const children = option.children?.length ? toCascaderOptions(option.children) : undefined;
73
+ return {
74
+ value: option.name,
75
+ label: option.title,
76
+ children,
77
+ };
78
+ });
79
+
80
+ /**
81
+ * Filter row bound directly to a `Collection`, with no `FlowModel` dependency. Use this from settings pages or other surfaces that need a filter UI but don't have (and shouldn't synthesise) a block model just to satisfy `FilterItem`. Pair with `FilterContainer` via either an inline wrapper or `createCollectionFilterItem(collection)`.
82
+ *
83
+ * The field selector is an antd `Cascader`, mirroring v1's `Filter.Action` so association fields (belongsTo / m2o / etc.) can be drilled into — picking `user.username` is a first-class action. The value renderer is delegated to `FilterValueInput`, which dispatches to interface-specific controls (the smart date picker for `$date*` operators, tag-mode Select for array/enum, etc.) the same way v1's `DynamicComponent` did.
84
+ */
85
+ export const CollectionFilterItem: FC<CollectionFilterItemProps> = observer(
86
+ (props) => {
87
+ const { collection, filterableFieldNames, noIgnore = false, t = identity } = props;
88
+ const { path: leftValue, operator, value: rightValue } = props.value;
89
+
90
+ const options = useFilterOptions(collection, { filterableFieldNames, noIgnore, t });
91
+
92
+ const cascaderOptions = useMemo(() => toCascaderOptions(options), [options]);
93
+
94
+ const fieldPath = useMemo(() => (leftValue ? leftValue.split('.') : []), [leftValue]);
95
+
96
+ const selectedField = useMemo(() => findOptionByPath(options, fieldPath), [options, fieldPath]);
97
+
98
+ const operatorOptions = useMemo(() => selectedField?.operators || [], [selectedField]);
99
+
100
+ const selectedOperator = useMemo(
101
+ () => operatorOptions.find((op) => op.value === operator),
102
+ [operatorOptions, operator],
103
+ );
104
+
105
+ const handleFieldChange = (next: (string | number)[]) => {
106
+ const path = next.map(String);
107
+ props.value.path = path.join('.');
108
+ const leaf = findOptionByPath(options, path);
109
+ props.value.operator = leaf?.operators?.[0]?.value || '';
110
+ // The value's shape is operator-dependent (e.g. string for `$eq`, `{ type, number, unit }` for `$dateInPast`); reset on every field change so stale shapes don't leak across interfaces.
111
+ props.value.value = undefined;
112
+ };
113
+ const handleOperatorChange = (next: string) => {
114
+ props.value.operator = next;
115
+ // Same rationale as above — switching from `$eq` (string) to `$dateOn` (date descriptor) makes the previous value structurally incompatible. v1 handled this by remounting the DynamicComponent on operator change; we explicitly clear.
116
+ props.value.value = undefined;
117
+ };
118
+ const handleValueChange = (next: any) => {
119
+ props.value.value = next;
120
+ };
121
+
122
+ // Widths mirror the long-standing `FilterItem` row (200 / 120) so a settings page mixing CollectionFilterItem and FilterContainer doesn't visually drift from existing block-bound filter rows.
123
+ return (
124
+ <Space wrap>
125
+ <Cascader
126
+ style={{ width: 200 }}
127
+ placeholder={t('Select field')}
128
+ options={cascaderOptions}
129
+ value={fieldPath}
130
+ onChange={handleFieldChange}
131
+ changeOnSelect={false}
132
+ expandTrigger="click"
133
+ popupClassName={cascaderPopupClass}
134
+ />
135
+ <Select
136
+ style={{ width: 120 }}
137
+ placeholder={t('Comparision')}
138
+ value={operator || undefined}
139
+ onChange={handleOperatorChange}
140
+ disabled={!leftValue || operatorOptions.length === 0}
141
+ >
142
+ {operatorOptions.map((op) => (
143
+ <Select.Option key={op.value} value={op.value}>
144
+ {op.label}
145
+ </Select.Option>
146
+ ))}
147
+ </Select>
148
+ <FilterValueInput
149
+ field={selectedField}
150
+ operator={selectedOperator}
151
+ value={rightValue}
152
+ onChange={handleValueChange}
153
+ placeholder={t('Enter value')}
154
+ t={t}
155
+ />
156
+ </Space>
157
+ );
158
+ },
159
+ { displayName: 'CollectionFilterItem' },
160
+ );
161
+
162
+ /**
163
+ * Convenience factory returning a `FilterContainer`-compatible `FilterItem` component bound to a specific collection. Avoids creating an inline closure on every parent render, which would otherwise reset any focused inner antd control.
164
+ */
165
+ export function createCollectionFilterItem(
166
+ collection: Collection,
167
+ bound?: Pick<CollectionFilterItemProps, 'filterableFieldNames' | 'noIgnore' | 't'>,
168
+ ) {
169
+ const Component: FC<{ value: CollectionFilterItemValue }> = (props) => (
170
+ <CollectionFilterItem {...bound} value={props.value} collection={collection} />
171
+ );
172
+ Component.displayName = 'BoundCollectionFilterItem';
173
+ return Component;
174
+ }
175
+
176
+ export default CollectionFilterItem;