@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,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,8 +8,12 @@
8
8
  */
9
9
 
10
10
  export * from './createFormRegistry';
11
+ export * from './filter';
12
+ export * from './DialogFormLayout';
11
13
  export * from './DrawerFormLayout';
12
14
  export * from './EnvVariableInput';
13
15
  export * from './FileSizeInput';
14
16
  export * from './JsonTextArea';
17
+ export * from './PasswordInput';
18
+ export * from './RemoteSelect';
15
19
  export * from './VariableInput';
@@ -19,7 +19,7 @@ import React, { useMemo, useState } from 'react';
19
19
  import { SortableRow, SortHandle } from './dnd/SortableRow';
20
20
  import { RowOverlayPreview } from './RowOverlayPreview';
21
21
  import { SelectionCell } from './SelectionCell';
22
- import { indexSwapClassName, selectionGutterClassName } from './styles';
22
+ import { indexSwapClassName, selectionGutterClassName, tableScrollClassName } from './styles';
23
23
  import { readRowKey, snapshotSourceRow, type RowKey, type RowSnapshot } from './utils';
24
24
 
25
25
  type RowSelectionRenderCellResult<RecordType> = React.ReactNode | RenderedCell<RecordType>;
@@ -227,6 +227,7 @@ export function Table<RecordType extends object = any>(props: TableProps<RecordT
227
227
 
228
228
  const tableClassName = cx(
229
229
  className,
230
+ tableScrollClassName,
230
231
  showHandleInSelection && selectionGutterClassName,
231
232
  showIndex && rowSelection && indexSwapClassName,
232
233
  );
@@ -10,6 +10,25 @@
10
10
  import { css } from '@emotion/css';
11
11
  import { SORT_HANDLE_GUTTER } from './constants';
12
12
 
13
+ /**
14
+ * Restore horizontal scrolling on `.ant-table-content` so wide tables in
15
+ * narrow containers (drawer / settings panel) scroll their inner `<table>`
16
+ * instead of getting clipped or forcing the outer container to grow.
17
+ *
18
+ * `width: max-content` on the inner `<table>` lets columns size to their
19
+ * natural width; `min-width: 100%` keeps the table filling the viewport
20
+ * when total column width is smaller than the container.
21
+ */
22
+ export const tableScrollClassName = css`
23
+ &.ant-table-wrapper .ant-table-content {
24
+ overflow: auto hidden;
25
+ }
26
+ &.ant-table-wrapper .ant-table-content > table {
27
+ width: max-content;
28
+ min-width: 100%;
29
+ }
30
+ `;
31
+
13
32
  /**
14
33
  * Reserve a `SORT_HANDLE_GUTTER`-wide gap on the left of the rowSelection
15
34
  * column so the handle's `left:0` lands inside a `position:relative` cell.
@@ -12,5 +12,7 @@ export * from './BlankComponent';
12
12
  export * from './form/table/dnd';
13
13
  export * from './form';
14
14
  export * from './Icon';
15
+ export * from './PoweredBy';
15
16
  export * from './RouterContextCleaner';
17
+ export * from './SwitchLanguage';
16
18
  export * from './form/table';
@@ -10,7 +10,8 @@
10
10
  import { TinyColor } from '@ctrl/tinycolor';
11
11
  import { useEffect } from 'react';
12
12
  import { theme } from 'antd';
13
- import { type CustomToken, defaultTheme } from '../theme';
13
+ import { defaultTheme } from '../theme';
14
+ import type { CustomToken } from '../theme';
14
15
 
15
16
  interface Result extends ReturnType<typeof theme.useToken> {
16
17
  token: CustomToken;
@@ -42,6 +43,10 @@ export const CSSVariableProvider = ({ children }) => {
42
43
  document.body.style.setProperty('--colorWarningBg', token.colorWarningBg);
43
44
  document.body.style.setProperty('--colorWarningBorder', token.colorWarningBorder);
44
45
  document.body.style.setProperty('--colorText', token.colorText);
46
+ document.body.style.setProperty('--colorTextDescription', token.colorTextDescription);
47
+ document.body.style.setProperty('--colorBgTextHover', token.colorBgTextHover);
48
+ document.body.style.setProperty('--colorSplit', token.colorSplit);
49
+ document.body.style.setProperty('--borderRadiusOuter', `${token.borderRadiusOuter}px`);
45
50
  document.body.style.setProperty('--colorTextHeaderMenu', token.colorTextHeaderMenu);
46
51
  document.body.style.setProperty('--colorPrimaryText', token.colorPrimaryText);
47
52
  document.body.style.setProperty('--colorPrimaryTextActive', token.colorPrimaryTextActive);
@@ -81,9 +86,13 @@ export const CSSVariableProvider = ({ children }) => {
81
86
  token.colorPrimaryTextActive,
82
87
  token.colorPrimaryTextHover,
83
88
  token.colorSettings,
89
+ token.colorBgTextHover,
90
+ token.colorSplit,
84
91
  token.colorText,
92
+ token.colorTextDescription,
85
93
  token.colorWarningBg,
86
94
  token.colorWarningBorder,
95
+ token.borderRadiusOuter,
87
96
  token.controlHeightLG,
88
97
  token.marginLG,
89
98
  token.marginSM,
@@ -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';
@@ -0,0 +1,96 @@
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, vi } from 'vitest';
11
+ import { EventEmitter } from 'events';
12
+ import { ensureFormValueDrivenDataScopeClear } from '../../utils/dataScopeFormValueClear';
13
+
14
+ describe('ensureFormValueDrivenDataScopeClear', () => {
15
+ it('clears field value when referenced formValues dependency changes', () => {
16
+ const emitter = new EventEmitter();
17
+ const formBlock = {
18
+ uid: 'form-1',
19
+ disposed: false,
20
+ emitter,
21
+ context: { form: {} },
22
+ };
23
+
24
+ const onChange = vi.fn();
25
+ const model: any = {
26
+ disposed: false,
27
+ props: {
28
+ value: { id: 1 },
29
+ onChange,
30
+ },
31
+ context: {
32
+ blockModel: formBlock,
33
+ },
34
+ };
35
+
36
+ const ctx: any = {
37
+ model,
38
+ flowKey: 'selectSettings',
39
+ };
40
+
41
+ const filter = {
42
+ logic: '$and',
43
+ items: [{ path: 'schoolId', operator: '$eq', value: '{{ ctx.formValues.school.id }}' }],
44
+ };
45
+
46
+ ensureFormValueDrivenDataScopeClear(ctx, filter);
47
+
48
+ emitter.emit('formValuesChange', {
49
+ changedValues: { school: { id: 2 } },
50
+ allValues: { school: { id: 2 }, class: { id: 1 } },
51
+ });
52
+
53
+ expect(onChange).toHaveBeenCalledWith(null);
54
+ });
55
+
56
+ it('does not clear when dependency did not change', () => {
57
+ const emitter = new EventEmitter();
58
+ const formBlock = {
59
+ uid: 'form-1',
60
+ disposed: false,
61
+ emitter,
62
+ context: { form: {} },
63
+ };
64
+
65
+ const onChange = vi.fn();
66
+ const model: any = {
67
+ disposed: false,
68
+ props: {
69
+ value: { id: 1 },
70
+ onChange,
71
+ },
72
+ context: {
73
+ blockModel: formBlock,
74
+ },
75
+ };
76
+
77
+ const ctx: any = {
78
+ model,
79
+ flowKey: 'selectSettings',
80
+ };
81
+
82
+ const filter = {
83
+ logic: '$and',
84
+ items: [{ path: 'schoolId', operator: '$eq', value: '{{ ctx.formValues.school.id }}' }],
85
+ };
86
+
87
+ ensureFormValueDrivenDataScopeClear(ctx, filter);
88
+
89
+ emitter.emit('formValuesChange', {
90
+ changedValues: { class: null },
91
+ allValues: { school: { id: 2 }, class: null },
92
+ });
93
+
94
+ expect(onChange).not.toHaveBeenCalled();
95
+ });
96
+ });
@@ -13,6 +13,7 @@ import React from 'react';
13
13
  import { FilterGroup, VariableFilterItem } from '../components/filter';
14
14
  import { FieldModel } from '../models/base/FieldModel';
15
15
  import { normalizeDataScopeFilter } from './dataScopeFilter';
16
+ import { ensureFormValueDrivenDataScopeClear } from '../utils/dataScopeFormValueClear';
16
17
 
17
18
  export const dataScope = defineAction({
18
19
  name: 'dataScope',
@@ -54,6 +55,8 @@ export const dataScope = defineAction({
54
55
  const resolvedFilter = await ctx.resolveJsonTemplate(params.filter);
55
56
  const filter = normalizeDataScopeFilter(params.filter, resolvedFilter);
56
57
 
58
+ ensureFormValueDrivenDataScopeClear(ctx as any, params.filter);
59
+
57
60
  if (isEmptyFilter(filter)) {
58
61
  resource.removeFilterGroup(ctx.model.uid);
59
62
  } else {
@@ -371,15 +371,12 @@ export const AdminLayoutComponent = observer((props: any) => {
371
371
  const [allAccessRoutes, setAllAccessRoutes] = useState<NocoBaseDesktopRoute[]>(
372
372
  () => flowEngine.context.routeRepository?.listAccessible?.() || [],
373
373
  );
374
- const screens = Grid.useBreakpoint();
375
- const isMobileViewport =
376
- screens.md === false || (screens.md === undefined && typeof window !== 'undefined' && window.innerWidth < 768);
377
374
  const location = useLocation();
378
375
  const { token } = antdTheme.useToken();
379
376
  const customToken = token as CustomToken;
380
377
  const isMobileLayout = !!adminLayoutModel?.isMobileLayout;
381
378
  const menuRouteRefreshVersion = adminLayoutModel?.menuRouteRefreshVersion || 0;
382
- const isMobileSider = isMobileLayout || isMobileViewport;
379
+ const isMobileSider = isMobileLayout;
383
380
  const [collapsed, setCollapsed] = useState(isMobileSider);
384
381
  const [preferredFlowSettingsEnabled, setPreferredFlowSettingsEnabled] = useState(() => readFlowSettingsPreference());
385
382
  const [route, setRoute] = useState<{ path: string; children: AdminLayoutMenuNode[] }>({
@@ -9,45 +9,19 @@
9
9
 
10
10
  import { QuestionCircleOutlined } from '@ant-design/icons';
11
11
  import { css } from '@emotion/css';
12
- import { observer, useFlowEngine } from '@nocobase/flow-engine';
12
+ import { observer } from '@nocobase/flow-engine';
13
13
  import { parseHTML } from '@nocobase/utils/client';
14
14
  import { Dropdown, Menu, Popover, theme as antdTheme } from 'antd';
15
15
  import type { MenuItemType, MenuDividerType } from 'antd/es/menu/interface';
16
- import React, { useEffect, useMemo, useState } from 'react';
16
+ import React, { useMemo, useState } from 'react';
17
17
  import { useTranslation } from 'react-i18next';
18
18
  import { usePlugin } from '../../../flow-compat';
19
+ import { useCurrentAppInfo } from '../../../hooks';
19
20
  import type { CustomToken } from '../../../theme';
21
+ import { getAppVersionHTML } from '../../../utils';
20
22
 
21
23
  type SettingsMenuItemType = MenuItemType | MenuDividerType;
22
24
 
23
- /**
24
- * 读取当前应用信息,避免继续依赖旧的 CurrentAppInfoProvider。
25
- */
26
- function useCurrentAppInfoLite() {
27
- const flowEngine = useFlowEngine();
28
- const [data, setData] = useState<any>();
29
-
30
- useEffect(() => {
31
- let active = true;
32
-
33
- Promise.resolve(flowEngine.context.appInfo)
34
- .then((info) => {
35
- if (active) {
36
- setData(info);
37
- }
38
- })
39
- .catch((error) => {
40
- console.error(error);
41
- });
42
-
43
- return () => {
44
- active = false;
45
- };
46
- }, [flowEngine]);
47
-
48
- return data;
49
- }
50
-
51
25
  const helpClassName = css`
52
26
  display: inline-block;
53
27
  vertical-align: top;
@@ -60,7 +34,7 @@ const helpClassName = css`
60
34
 
61
35
  const SettingsMenu: React.FC = () => {
62
36
  const { t } = useTranslation();
63
- const appInfo = useCurrentAppInfoLite();
37
+ const appInfo = useCurrentAppInfo();
64
38
  const { token } = antdTheme.useToken();
65
39
  const isSimplifiedChinese = appInfo?.lang === 'zh-CN';
66
40
 
@@ -136,7 +110,7 @@ export const HelpLite = observer(
136
110
  const { token } = antdTheme.useToken();
137
111
  const customToken = token as CustomToken;
138
112
  const customBrandPlugin: any = usePlugin('@nocobase/plugin-custom-brand');
139
- const appInfo = useCurrentAppInfoLite();
113
+ const appInfo = useCurrentAppInfo();
140
114
 
141
115
  const icon = (
142
116
  <span
@@ -156,7 +130,7 @@ export const HelpLite = observer(
156
130
  );
157
131
 
158
132
  if (customBrandPlugin?.options?.options?.about) {
159
- const appVersion = `<span class="nb-app-version">v${appInfo?.version}</span>`;
133
+ const appVersion = getAppVersionHTML(appInfo?.version);
160
134
  const content = parseHTML(customBrandPlugin.options.options.about, { appVersion });
161
135
 
162
136
  return (
@@ -91,7 +91,7 @@ const useBlockHeight = ({
91
91
  const padding = getPadding(root);
92
92
  const addBlockContainer = getAddBlockContainer(root);
93
93
  const pageTop = rootRect.top + padding.top;
94
- const topOffset = Math.min(Math.max(0, cardRect.top - pageTop), 0);
94
+ const topOffset = Math.max(0, cardRect.top - pageTop);
95
95
  let bottomOffset = padding.bottom + ctx.themeToken.marginBlock;
96
96
  if (addBlockContainer) {
97
97
  const gapBetween = ctx.themeToken.marginBlock;
@@ -99,7 +99,7 @@ const useBlockHeight = ({
99
99
  }
100
100
  const nextHeight = Math.max(
101
101
  0,
102
- Math.floor(window.innerHeight - getValidPageTop(pageTop, 110) - topOffset - bottomOffset),
102
+ Math.floor(window.innerHeight - getValidPageTop(pageTop, 110) - topOffset - bottomOffset - 1),
103
103
  );
104
104
  setFullHeight((prev) => (prev === nextHeight ? prev : nextHeight));
105
105
  }, [heightMode, cardRef]);
@@ -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.