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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/es/BaseApplication.d.ts +1 -0
  2. package/es/components/form/DialogFormLayout.d.ts +5 -29
  3. package/es/components/form/filter/CollectionFilter.d.ts +41 -0
  4. package/es/components/form/filter/CollectionFilterItem.d.ts +41 -0
  5. package/es/components/form/filter/DateFilterDynamicComponent.d.ts +57 -0
  6. package/es/components/form/filter/FilterValueInput.d.ts +29 -0
  7. package/es/components/form/filter/index.d.ts +11 -0
  8. package/es/components/form/filter/useFilterActionProps.d.ts +96 -0
  9. package/es/components/form/index.d.ts +1 -0
  10. package/es/data-source/ExtendCollectionsProvider.d.ts +24 -0
  11. package/es/data-source/index.d.ts +9 -0
  12. package/es/flow/components/filter/index.d.ts +2 -0
  13. package/es/flow/components/filter/useFilterOptions.d.ts +54 -0
  14. package/es/flow-compat/passwordUtils.d.ts +1 -1
  15. package/es/index.d.ts +1 -0
  16. package/es/index.mjs +22 -17
  17. package/es/nocobase-buildin-plugin/index.d.ts +3 -10
  18. package/lib/index.js +25 -20
  19. package/package.json +7 -7
  20. package/src/BaseApplication.tsx +13 -0
  21. package/src/__tests__/app.test.tsx +9 -0
  22. package/src/components/README.md +89 -6
  23. package/src/components/README.zh-CN.md +89 -7
  24. package/src/components/form/DialogFormLayout.tsx +5 -29
  25. package/src/components/form/filter/CollectionFilter.tsx +101 -0
  26. package/src/components/form/filter/CollectionFilterItem.tsx +176 -0
  27. package/src/components/form/filter/DateFilterDynamicComponent.tsx +283 -0
  28. package/src/components/form/filter/FilterValueInput.tsx +198 -0
  29. package/src/components/form/filter/__tests__/CollectionFilterItem.test.tsx +205 -0
  30. package/src/components/form/filter/__tests__/DateFilterDynamicComponent.test.tsx +148 -0
  31. package/src/components/form/filter/__tests__/FilterValueInput.test.tsx +243 -0
  32. package/src/components/form/filter/__tests__/compileFilterGroup.test.ts +146 -0
  33. package/src/components/form/filter/index.ts +13 -0
  34. package/src/components/form/filter/useFilterActionProps.ts +200 -0
  35. package/src/components/form/index.tsx +1 -0
  36. package/src/data-source/ExtendCollectionsProvider.tsx +70 -0
  37. package/src/data-source/index.ts +10 -0
  38. package/src/flow/components/filter/index.ts +3 -0
  39. package/src/flow/components/filter/useFilterOptions.ts +80 -0
  40. package/src/index.ts +1 -0
  41. package/src/nocobase-buildin-plugin/index.tsx +13 -19
  42. package/src/nocobase-buildin-plugin/plugins/LocalePlugin.ts +1 -0
@@ -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';
@@ -0,0 +1,70 @@
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 type { CollectionOptions } from '@nocobase/flow-engine';
11
+ import React, { FC, ReactNode, useEffect, useRef } from 'react';
12
+ import { useApp } from '../hooks';
13
+
14
+ export interface ExtendCollectionsProviderProps {
15
+ /** Data source key to extend. Defaults to `'main'`. */
16
+ dataSource?: string;
17
+ /** Collections to surface for the lifetime of this provider's subtree. */
18
+ collections: CollectionOptions[];
19
+ children?: ReactNode;
20
+ }
21
+
22
+ /**
23
+ * Mount-scoped collection injector. Adds the given `collections` to the target data source on mount and removes them on unmount. Survives mid-session reloads via `dataSource:loaded` events.
24
+ *
25
+ * Use this for client-only collections — e.g. a `schema-only` server collection that isn't auto-published to the v2 data source, or a pure UI-side mirror — so downstream components (like `<CollectionFilter>`) can resolve the collection by name.
26
+ */
27
+ export const ExtendCollectionsProvider: FC<ExtendCollectionsProviderProps> = ({
28
+ dataSource = 'main',
29
+ collections,
30
+ children,
31
+ }) => {
32
+ const app = useApp();
33
+ const ownedRef = useRef<Set<string>>(new Set());
34
+
35
+ const apply = () => {
36
+ const ds = app.dataSourceManager?.getDataSource?.(dataSource);
37
+ if (!ds) return;
38
+ for (const collection of collections) {
39
+ if (ds.getCollection?.(collection.name)) continue;
40
+ ds.addCollection?.(collection);
41
+ ownedRef.current.add(collection.name);
42
+ }
43
+ };
44
+
45
+ apply();
46
+
47
+ useEffect(() => {
48
+ const onLoaded = (event: Event) => {
49
+ const key = (event as CustomEvent<{ dataSourceKey: string }>).detail?.dataSourceKey;
50
+ if (key === dataSource || key === '*') apply();
51
+ };
52
+ app.eventBus?.addEventListener('dataSource:loaded', onLoaded);
53
+
54
+ return () => {
55
+ app.eventBus?.removeEventListener('dataSource:loaded', onLoaded);
56
+ const ds = app.dataSourceManager?.getDataSource?.(dataSource);
57
+ const owned = ownedRef.current;
58
+ ownedRef.current = new Set();
59
+ if (!ds) return;
60
+ for (const name of owned) {
61
+ ds.removeCollection?.(name);
62
+ }
63
+ };
64
+ // eslint-disable-next-line react-hooks/exhaustive-deps
65
+ }, [app, dataSource, collections]);
66
+
67
+ return <>{children}</>;
68
+ };
69
+
70
+ export default ExtendCollectionsProvider;
@@ -0,0 +1,10 @@
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
+ export * from './ExtendCollectionsProvider';
@@ -15,3 +15,6 @@ export { VariableFilterItem } from './VariableFilterItem';
15
15
  export type { VariableFilterItemProps, VariableFilterItemValue } from './VariableFilterItem';
16
16
  export { LinkageFilterItem } from './LinkageFilterItem';
17
17
  export type { LinkageFilterItemProps, LinkageFilterItemValue } from './LinkageFilterItem';
18
+ export { useFilterOptions } from './useFilterOptions';
19
+ export type { FilterOption, UseFilterOptionsArgs } from './useFilterOptions';
20
+ // Higher-level filter compositions (`CollectionFilterItem`, `useFilterActionProps`, `createCollectionFilterItem`) live under `src/components/form/filter/`. They compose these flow primitives on top of a `Collection` binding — the dependency direction is form/filter → flow/components/filter, never the reverse.
@@ -0,0 +1,80 @@
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 type { Collection } from '@nocobase/flow-engine';
11
+ import { useMemo } from 'react';
12
+ import { fieldsToOptions } from './fieldsToOptions';
13
+
14
+ /**
15
+ * One operator entry on a `FilterOption`. Mirrors v1's interface-defined operator shape so the v2 filter value renderer can pick up the same per-operator value-side schema (e.g. datetime operators wanting the smart date picker, array/enum operators wanting a tag-mode Select).
16
+ */
17
+ export type FilterOperator = {
18
+ value: string;
19
+ label: string;
20
+ /**
21
+ * Per-operator override for the value-side renderer. Wins over the field's own `uiSchema` when set. The `x-component` string is looked up against the v2 filter component registry.
22
+ */
23
+ schema?: { 'x-component'?: string; 'x-component-props'?: Record<string, any> } & Record<string, any>;
24
+ /** Operator takes no right-hand value (e.g. `$empty`, `$notEmpty`). */
25
+ noValue?: boolean;
26
+ [key: string]: any;
27
+ };
28
+
29
+ /** Single field-tree node returned by `useFilterOptions`. */
30
+ export type FilterOption = {
31
+ name: string;
32
+ type?: string;
33
+ target?: string;
34
+ title: string;
35
+ schema?: Record<string, any>;
36
+ operators?: FilterOperator[];
37
+ children?: FilterOption[];
38
+ };
39
+
40
+ export interface UseFilterOptionsArgs {
41
+ /**
42
+ * Whitelist of root-level field names to expose. Empty/undefined means "all filterable fields". The whitelist applies only at depth 1 — nested fields under an allowed association field are always included, matching the legacy v1 `Filter.Action` behaviour.
43
+ */
44
+ filterableFieldNames?: string[];
45
+ /** Bypass the `filterableFieldNames` whitelist (mirrors v1 `noIgnore`). */
46
+ noIgnore?: boolean;
47
+ /** Translator used for field/operator labels. Defaults to identity. */
48
+ t?: (key: string) => string;
49
+ }
50
+
51
+ const identity = (s: string) => s;
52
+
53
+ /**
54
+ * v2 equivalent of v1's `useFilterOptions`/`useFilterFieldOptions`. Walks a `Collection`'s fields and returns the nested option tree consumed by antd `Cascader` in `CollectionFilterItem` (and any other v2 filter surface that wants the same field picker).
55
+ *
56
+ * Mirrors v1 in two ways that matter:
57
+ * - association fields (belongsTo / hasMany / m2m / etc.) are kept and recursed into via `fieldsToOptions`'s `nested` branch — so picking `user.username` is a first-class action, just like the legacy cascader.
58
+ * - the whitelist applies at depth 1 only, so capping the root field list (e.g. to `['lockedTs', 'unlockTs', 'user']`) doesn't accidentally hide the nested `user.username` / `user.nickname` leaves.
59
+ */
60
+ export function useFilterOptions(collection: Collection | undefined, args: UseFilterOptionsArgs = {}): FilterOption[] {
61
+ const { filterableFieldNames, noIgnore = false, t = identity } = args;
62
+
63
+ const fields = useMemo(() => collection?.getFields() || [], [collection]);
64
+
65
+ const ignoreFieldsNames = useMemo(() => {
66
+ if (noIgnore || !filterableFieldNames?.length) return [];
67
+ return fields.map((f) => f.name).filter((n) => !filterableFieldNames.includes(n));
68
+ }, [fields, filterableFieldNames, noIgnore]);
69
+
70
+ return useMemo(
71
+ () =>
72
+ fieldsToOptions(
73
+ fields.filter((field) => field.target !== 'attachments' && field.interface !== 'formula'),
74
+ 1,
75
+ ignoreFieldsNames,
76
+ t,
77
+ ).filter(Boolean) as FilterOption[],
78
+ [fields, ignoreFieldsNames, t],
79
+ );
80
+ }
package/src/index.ts CHANGED
@@ -32,6 +32,7 @@ export * from './nocobase-buildin-plugin';
32
32
  export * from './collection-field-interface/CollectionFieldInterface';
33
33
  export * from './collection-field-interface/CollectionFieldInterfaceManager';
34
34
  export * from './collection-manager/interfaces';
35
+ export * from './data-source';
35
36
  export * from './flow';
36
37
  export { DEFAULT_DATA_SOURCE_KEY, isTitleField, isTitleFieldInterface } from './flow-compat';
37
38
  export { default as AntdAppProvider } from './theme/AntdAppProvider';
@@ -64,15 +64,9 @@ export function useCurrentUserContext() {
64
64
  }
65
65
 
66
66
  /**
67
- * 返回当前用户在 v2 应用上下文中可选的角色列表,等价于 v1 `useCurrentRoles`:
68
- * 从 FlowEngine 全局上下文 `engine.context.user.roles` 派生(CurrentUserProvider 在
69
- * `/auth:check` 成功后通过 `defineProperty('user', { value })` 写入),按需追加匿名角色,
70
- * 并去掉合并角色 `__union__`。v2 中角色 title 可能含有 `{{t('...')}}` 模板,因此用
71
- * flowEngine.context.t 解析。
67
+ * 返回当前用户在 v2 应用上下文中可选的角色列表,等价于 v1 `useCurrentRoles`:从 FlowEngine 全局上下文 `engine.context.user.roles` 派生(CurrentUserProvider 在 `/auth:check` 成功后通过 `defineProperty('user', { value })` 写入),按需追加匿名角色,并去掉合并角色 `__union__`。v2 中角色 title 可能含有 `{{t('...')}}` 模板,因此用 flowEngine.context.t 解析。
72
68
  *
73
- * 不读 React `CurrentUserContext`:FlowEngine 的 dialog/drawer/popover 内容通过 `ctx.viewer`
74
- * 渲染到独立的 ElementsHolder,部分场景会脱离原 Provider 树;FlowEngine 全局上下文是同一份
75
- * 数据但不受 React 树位置影响。
69
+ * 不读 React `CurrentUserContext`:FlowEngine 的 dialog/drawer/popover 内容通过 `ctx.viewer` 渲染到独立的 ElementsHolder,部分场景会脱离原 Provider 树;FlowEngine 全局上下文是同一份数据但不受 React 树位置影响。
76
70
  */
77
71
  export function useCurrentRoles(): CurrentRoleOption[] {
78
72
  const { allowAnonymous } = useACLRoleContext();
@@ -183,10 +177,7 @@ const CurrentUserProvider: FC = ({ children }) => {
183
177
  });
184
178
 
185
179
  const user = res?.data?.data;
186
- // 服务端通过 `{ code: 302, redirect }` 通知客户端先去某个中间页(例如 2FA 验证页)
187
- // 这类响应没有 user.id,但也不能视为未登录——否则会和处理 302 的全局响应拦截器
188
- // (例如 plugin-two-factor-authentication 注册的那一个)竞态,而 `window.location.replace`
189
- // 会覆盖更早发出的 `window.location.href`,把用户错误地弹回登录页。让响应拦截器接管跳转。
180
+ // 服务端通过 `{ code: 302, redirect }` 通知客户端先去某个中间页(例如 2FA 验证页)。这类响应没有 user.id,但也不能视为未登录——否则会和处理 302 的全局响应拦截器 (例如 plugin-two-factor-authentication 注册的那一个)竞态,而 `window.location.replace` 会覆盖更早发出的 `window.location.href`,把用户错误地弹回登录页。让响应拦截器接管跳转。
190
181
  if (user?.code === 302) {
191
182
  if (mounted) {
192
183
  setState({ loading: false });
@@ -194,9 +185,7 @@ const CurrentUserProvider: FC = ({ children }) => {
194
185
  return;
195
186
  }
196
187
  if (user?.id == null) {
197
- // 用 react-router navigate (虚拟跳转)而不是 location.replace, 这样如果有其他响应拦截器
198
- // 已经发起了 window.location.href 整页跳转(例如 2FA 插件接收到服务端 302 重定向),
199
- // 真实跳转可以胜出 navigate, 不会被这里的 signin 重定向覆盖。
188
+ // 用 react-router navigate (虚拟跳转)而不是 location.replace, 这样如果有其他响应拦截器已经发起了 window.location.href 整页跳转(例如 2FA 插件接收到服务端 302 重定向), 真实跳转可以胜出 navigate, 不会被这里的 signin 重定向覆盖。
200
189
  navigate(`/signin?redirect=${encodeURIComponent(getCurrentV2RedirectPath(app, locationRef.current))}`, {
201
190
  replace: true,
202
191
  });
@@ -256,8 +245,7 @@ const RootRedirect: FC = () => {
256
245
  const targetPath = getDefaultV2AdminRedirectPath(app);
257
246
 
258
247
  if (!hasToken) {
259
- // 用 react-router <Navigate /> 而非 location.replace, 避免覆盖同时段其它响应拦截器
260
- // 触发的 window.location.href (例如 2FA 接收到服务端 302 时设置的整页跳转)。
248
+ // 用 react-router <Navigate /> 而非 location.replace, 避免覆盖同时段其它响应拦截器触发的 window.location.href (例如 2FA 接收到服务端 302 时设置的整页跳转)。
261
249
  return <Navigate replace to={`/signin?redirect=${encodeURIComponent(targetPath)}`} />;
262
250
  }
263
251
 
@@ -267,8 +255,7 @@ const RootRedirect: FC = () => {
267
255
  /**
268
256
  * client-v2 使用的内建插件集合。
269
257
  *
270
- * 只迁移当前 v2 运行时仍然需要的部分,显式跳过 schemaInitializerManager
271
- * 以及用户标注暂不迁移的旧插件注册逻辑。
258
+ * 只迁移当前 v2 运行时仍然需要的部分,显式跳过 schemaInitializerManager 以及用户标注暂不迁移的旧插件注册逻辑。
272
259
  */
273
260
  export class NocoBaseBuildInPlugin extends Plugin<any, Application> {
274
261
  async afterAdd() {
@@ -317,6 +304,13 @@ export class NocoBaseBuildInPlugin extends Plugin<any, Application> {
317
304
  aclSnippet: 'pm.system-settings.system-settings',
318
305
  sort: -100,
319
306
  });
307
+ // Parent menu for security-related plugin settings (password policy, locked users, etc.). Registered here in the buildin plugin so any pro plugin can attach page tabs to `menuKey: 'security'` without each one re-registering the same parent.
308
+ this.app.pluginSettingsManager.addMenuItem({
309
+ key: 'security',
310
+ title: this.app.i18n.t('Security'),
311
+ icon: 'SafetyOutlined',
312
+ aclSnippet: 'pm.security',
313
+ });
320
314
  }
321
315
 
322
316
  addRoutes() {
@@ -39,6 +39,7 @@ export class LocalePlugin extends Plugin {
39
39
 
40
40
  if (data.lang) {
41
41
  api.auth.setLocale(data.lang);
42
+ this.app.setDocumentLanguage(data.lang);
42
43
  this.app.i18n.changeLanguage(data.lang);
43
44
  }
44
45