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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (154) hide show
  1. package/es/Application.d.ts +1 -0
  2. package/es/BaseApplication.d.ts +4 -0
  3. package/es/RouterManager.d.ts +1 -0
  4. package/es/components/KeepAlive.d.ts +22 -0
  5. package/es/components/RouterBridge.d.ts +9 -0
  6. package/es/components/form/DialogFormLayout.d.ts +5 -29
  7. package/es/components/form/filter/CollectionFilter.d.ts +41 -0
  8. package/es/components/form/filter/CollectionFilterItem.d.ts +41 -0
  9. package/es/components/form/filter/DateFilterDynamicComponent.d.ts +57 -0
  10. package/es/components/form/filter/FilterValueInput.d.ts +29 -0
  11. package/es/components/form/filter/index.d.ts +11 -0
  12. package/es/components/form/filter/useFilterActionProps.d.ts +96 -0
  13. package/es/components/form/index.d.ts +1 -0
  14. package/es/data-source/ExtendCollectionsProvider.d.ts +50 -0
  15. package/es/data-source/index.d.ts +9 -0
  16. package/es/flow/FlowPage.d.ts +2 -1
  17. package/es/flow/admin-shell/AdminLayoutRouteCoordinator.d.ts +8 -40
  18. package/es/flow/admin-shell/BaseLayoutModel.d.ts +89 -0
  19. package/es/flow/admin-shell/BaseLayoutRouteCoordinator.d.ts +74 -0
  20. package/es/flow/admin-shell/admin-layout/AdminLayoutEntryGuard.d.ts +12 -0
  21. package/es/flow/admin-shell/admin-layout/AdminLayoutModel.d.ts +7 -92
  22. package/es/flow/admin-shell/admin-layout/index.d.ts +2 -0
  23. package/es/flow/admin-shell/useAdminLayoutRoutePage.d.ts +2 -2
  24. package/es/flow/admin-shell/useLayoutRoutePage.d.ts +23 -0
  25. package/es/flow/components/FlowRoute.d.ts +10 -1
  26. package/es/flow/components/filter/index.d.ts +2 -0
  27. package/es/flow/components/filter/useFilterOptions.d.ts +54 -0
  28. package/es/flow/index.d.ts +4 -0
  29. package/es/flow/models/base/PageModel/PageModel.d.ts +3 -1
  30. package/es/flow/models/blocks/form/FormActionGroupModel.d.ts +1 -0
  31. package/es/flow/models/blocks/table/TableBlockModel.d.ts +10 -0
  32. package/es/flow/models/fields/AssociationFieldModel/SubTableFieldModel/index.d.ts +1 -1
  33. package/es/flow-compat/passwordUtils.d.ts +1 -1
  34. package/es/index.d.ts +2 -0
  35. package/es/index.mjs +491 -439
  36. package/es/layout-manager/LayoutContentRoute.d.ts +14 -0
  37. package/es/layout-manager/LayoutManager.d.ts +22 -0
  38. package/es/layout-manager/LayoutRoute.d.ts +14 -0
  39. package/es/layout-manager/index.d.ts +13 -0
  40. package/es/layout-manager/types.d.ts +20 -0
  41. package/es/layout-manager/utils.d.ts +14 -0
  42. package/es/nocobase-buildin-plugin/index.d.ts +3 -10
  43. package/es/settings-center/index.d.ts +1 -1
  44. package/es/settings-center/plugin-manager/BulkEnableButton.d.ts +15 -0
  45. package/es/settings-center/plugin-manager/PluginCard.d.ts +15 -0
  46. package/es/settings-center/plugin-manager/PluginDetail.d.ts +16 -0
  47. package/es/settings-center/{PluginManagerPage.d.ts → plugin-manager/index.d.ts} +1 -7
  48. package/es/settings-center/plugin-manager/types.d.ts +34 -0
  49. package/lib/index.js +491 -439
  50. package/package.json +8 -7
  51. package/src/Application.tsx +27 -12
  52. package/src/BaseApplication.tsx +19 -0
  53. package/src/PluginSettingsManager.ts +1 -1
  54. package/src/RouterManager.tsx +17 -1
  55. package/src/__tests__/PluginSettingsManager.test.ts +41 -2
  56. package/src/__tests__/app.test.tsx +17 -1
  57. package/src/__tests__/globalDeps.test.ts +1 -0
  58. package/src/__tests__/nocobase-buildin-plugin-auth.test.tsx +45 -2
  59. package/src/__tests__/plugin-manager.test.tsx +177 -0
  60. package/src/__tests__/settings-center.test.tsx +24 -2
  61. package/src/components/KeepAlive.tsx +131 -0
  62. package/src/components/README.md +89 -6
  63. package/src/components/README.zh-CN.md +89 -7
  64. package/src/components/RouterBridge.tsx +28 -4
  65. package/src/components/__tests__/KeepAlive.test.tsx +63 -0
  66. package/src/components/__tests__/RouterBridge.test.tsx +27 -0
  67. package/src/components/form/DialogFormLayout.tsx +5 -29
  68. package/src/components/form/filter/CollectionFilter.tsx +101 -0
  69. package/src/components/form/filter/CollectionFilterItem.tsx +176 -0
  70. package/src/components/form/filter/DateFilterDynamicComponent.tsx +283 -0
  71. package/src/components/form/filter/FilterValueInput.tsx +198 -0
  72. package/src/components/form/filter/__tests__/CollectionFilterItem.test.tsx +205 -0
  73. package/src/components/form/filter/__tests__/DateFilterDynamicComponent.test.tsx +148 -0
  74. package/src/components/form/filter/__tests__/FilterValueInput.test.tsx +243 -0
  75. package/src/components/form/filter/__tests__/compileFilterGroup.test.ts +146 -0
  76. package/src/components/form/filter/index.ts +13 -0
  77. package/src/components/form/filter/useFilterActionProps.ts +200 -0
  78. package/src/components/form/index.tsx +1 -0
  79. package/src/data-source/ExtendCollectionsProvider.tsx +144 -0
  80. package/src/data-source/__tests__/ExtendCollectionsProvider.test.tsx +264 -0
  81. package/src/data-source/index.ts +10 -0
  82. package/src/flow/FlowPage.tsx +35 -7
  83. package/src/flow/__tests__/FlowPage.test.tsx +79 -0
  84. package/src/flow/__tests__/FlowRoute.test.tsx +529 -2
  85. package/src/flow/actions/__tests__/linkageRules.subFormSetFieldProps.test.ts +191 -0
  86. package/src/flow/actions/__tests__/openView.subModelKey.test.tsx +33 -0
  87. package/src/flow/actions/aclCheck.tsx +4 -0
  88. package/src/flow/actions/aclCheckRefresh.tsx +4 -0
  89. package/src/flow/actions/dateTimeFormat.tsx +12 -8
  90. package/src/flow/actions/linkageRules.tsx +122 -0
  91. package/src/flow/actions/openView.tsx +28 -4
  92. package/src/flow/admin-shell/AdminLayoutRouteCoordinator.ts +11 -329
  93. package/src/flow/admin-shell/BaseLayoutModel.tsx +455 -0
  94. package/src/flow/admin-shell/BaseLayoutRouteCoordinator.ts +502 -0
  95. package/src/flow/admin-shell/__tests__/AdminLayoutRouteCoordinator.test.ts +547 -3
  96. package/src/flow/admin-shell/admin-layout/AdminLayoutComponent.tsx +4 -4
  97. package/src/flow/admin-shell/admin-layout/AdminLayoutEntryGuard.tsx +160 -0
  98. package/src/flow/admin-shell/admin-layout/AdminLayoutMenuModels.tsx +0 -12
  99. package/src/flow/admin-shell/admin-layout/AdminLayoutModel.tsx +28 -201
  100. package/src/flow/admin-shell/admin-layout/AdminLayoutSlotModels.tsx +11 -2
  101. package/src/flow/admin-shell/admin-layout/__tests__/AdminLayoutMenuModels.test.ts +1 -26
  102. package/src/flow/admin-shell/admin-layout/__tests__/AdminLayoutModel.test.tsx +149 -27
  103. package/src/flow/admin-shell/admin-layout/index.ts +2 -0
  104. package/src/flow/admin-shell/useAdminLayoutRoutePage.ts +10 -26
  105. package/src/flow/admin-shell/useLayoutRoutePage.ts +61 -0
  106. package/src/flow/components/AdminLayout.tsx +4 -154
  107. package/src/flow/components/FlowRoute.tsx +105 -15
  108. package/src/flow/components/filter/index.ts +3 -0
  109. package/src/flow/components/filter/useFilterOptions.ts +80 -0
  110. package/src/flow/index.ts +4 -0
  111. package/src/flow/models/base/ActionModel.tsx +8 -1
  112. package/src/flow/models/base/PageModel/PageModel.tsx +51 -18
  113. package/src/flow/models/base/PageModel/RootPageModel.tsx +6 -13
  114. package/src/flow/models/base/PageModel/__tests__/PageModel.test.ts +102 -1
  115. package/src/flow/models/base/RouteModel.tsx +1 -1
  116. package/src/flow/models/blocks/form/FormActionGroupModel.tsx +14 -0
  117. package/src/flow/models/blocks/form/FormItemModel.tsx +8 -1
  118. package/src/flow/models/blocks/form/__tests__/FormActionGroupModel.test.ts +46 -0
  119. package/src/flow/models/blocks/form/submitValues.ts +4 -1
  120. package/src/flow/models/blocks/table/TableBlockModel.tsx +118 -16
  121. package/src/flow/models/blocks/table/__tests__/TableBlockModel.rowSelection.test.tsx +114 -0
  122. package/src/flow/models/fields/AssociationFieldModel/SubFormFieldModel.tsx +7 -1
  123. package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/SubTableField.tsx +1 -1
  124. package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/index.tsx +6 -5
  125. package/src/flow/models/fields/ClickableFieldModel.tsx +9 -1
  126. package/src/flow/models/fields/DisplayTimeFieldModel.tsx +1 -1
  127. package/src/flow/models/fields/TimeFieldModel.tsx +1 -1
  128. package/src/flow/models/fields/__tests__/TimeFieldModel.test.tsx +61 -0
  129. package/src/flow/models/fields/mobile-components/MobileDatePicker.tsx +19 -3
  130. package/src/flow/models/fields/mobile-components/__tests__/MobileDatePicker.test.tsx +94 -0
  131. package/src/flow/models/topbar/TopbarActionModel.tsx +1 -1
  132. package/src/flow/utils/__tests__/dateTimeFormat.test.ts +91 -0
  133. package/src/index.ts +2 -0
  134. package/src/layout-manager/LayoutContentRoute.tsx +90 -0
  135. package/src/layout-manager/LayoutManager.tsx +185 -0
  136. package/src/layout-manager/LayoutRoute.tsx +138 -0
  137. package/src/layout-manager/__tests__/LayoutManager.test.tsx +335 -0
  138. package/src/layout-manager/__tests__/LayoutRoute.test.tsx +473 -0
  139. package/src/layout-manager/index.ts +14 -0
  140. package/src/layout-manager/types.ts +22 -0
  141. package/src/layout-manager/utils.ts +37 -0
  142. package/src/nocobase-buildin-plugin/index.tsx +69 -67
  143. package/src/nocobase-buildin-plugin/plugins/LocalePlugin.ts +1 -0
  144. package/src/settings-center/index.ts +1 -1
  145. package/src/settings-center/plugin-manager/BulkEnableButton.tsx +111 -0
  146. package/src/settings-center/plugin-manager/PluginCard.tsx +270 -0
  147. package/src/settings-center/plugin-manager/PluginDetail.tsx +195 -0
  148. package/src/settings-center/plugin-manager/index.tsx +254 -0
  149. package/src/settings-center/plugin-manager/types.ts +35 -0
  150. package/src/settings-center/utils.tsx +8 -1
  151. package/src/theme/__tests__/globalStyles.test.ts +24 -0
  152. package/src/theme/globalStyles.ts +10 -0
  153. package/src/utils/globalDeps.ts +2 -0
  154. package/src/settings-center/PluginManagerPage.tsx +0 -162
@@ -7,15 +7,18 @@
7
7
  * For more information, please refer to: https://www.nocobase.com/agreement.
8
8
  */
9
9
 
10
- import { useFlowEngine } from '@nocobase/flow-engine';
10
+ import { type FlowEngine, useFlowContext, useFlowEngine } from '@nocobase/flow-engine';
11
11
  import React, { useEffect, useMemo, useRef, useState } from 'react';
12
12
  import { deviceType } from 'react-device-detect';
13
- import { useAdminLayoutRoutePage } from '../admin-shell/useAdminLayoutRoutePage';
14
13
  import { useParams } from 'react-router-dom';
15
14
  import { useApp } from '../../hooks/useApp';
16
15
  import { NocoBaseDesktopRouteType } from '../../flow-compat';
17
16
  import { resolveAdminRouteRuntimeTarget } from '../admin-shell/admin-layout/resolveAdminRouteRuntimeTarget';
17
+ import { getAdminLayoutModel, type AdminLayoutModel } from '../admin-shell/admin-layout/AdminLayoutModel';
18
+ import { getLayoutModel, type BaseLayoutModel } from '../admin-shell/BaseLayoutModel';
19
+ import { useLayoutRoutePage } from '../admin-shell/useLayoutRoutePage';
18
20
  import { AppNotFound } from '../../components';
21
+ import { useKeepAlive } from '../../components/KeepAlive';
19
22
 
20
23
  type FlowRouteGuardState = {
21
24
  pending: boolean;
@@ -23,8 +26,66 @@ type FlowRouteGuardState = {
23
26
  notFound: boolean;
24
27
  };
25
28
 
26
- const BridgeFlowRoute = ({ pageUid }: { pageUid: string }) => {
29
+ export type LegacyPageBehavior = 'redirect' | 'notFound' | 'bridge';
30
+
31
+ export type FlowRouteProps = {
32
+ pageUid?: string;
33
+ active?: boolean;
34
+ getLayoutModel?: (flowEngine: FlowEngine) => BaseLayoutModel | undefined;
35
+ legacyPageBehavior?: LegacyPageBehavior;
36
+ };
37
+
38
+ const getDefaultAdminLayoutModel = (flowEngine: FlowEngine) =>
39
+ getAdminLayoutModel<AdminLayoutModel>(flowEngine, { required: true });
40
+
41
+ const getDefaultLayoutModel = (flowEngine: FlowEngine, contextLayout?: any) => {
42
+ const layout = contextLayout || flowEngine.context.layout;
43
+
44
+ if (layout?.uid) {
45
+ return getLayoutModel<BaseLayoutModel>(flowEngine, layout.uid, { required: true });
46
+ }
47
+
48
+ return getDefaultAdminLayoutModel(flowEngine);
49
+ };
50
+
51
+ const getDefaultLegacyPageBehavior = (flowEngine: FlowEngine, contextLayout?: any): LegacyPageBehavior => {
52
+ const layout = contextLayout || flowEngine.context.layout;
53
+
54
+ if (layout?.routeName && layout.routeName !== 'admin') {
55
+ return 'notFound';
56
+ }
57
+
58
+ return 'redirect';
59
+ };
60
+
61
+ const hasFlowModel = async (flowEngine: FlowEngine, pageUid: string) => {
62
+ if (flowEngine.getModel(pageUid)) {
63
+ return true;
64
+ }
65
+
66
+ const modelData = await flowEngine.modelRepository?.findOne({ uid: pageUid }).catch(() => null);
67
+ if (modelData?.uid) {
68
+ return true;
69
+ }
70
+
71
+ const model = await flowEngine.loadModel({ uid: pageUid }).catch(() => null);
72
+ if (model && flowEngine.getModel(pageUid) === model) {
73
+ flowEngine.removeModelWithSubModels(pageUid);
74
+ }
75
+ return !!model;
76
+ };
77
+
78
+ const BridgeFlowRoute = ({
79
+ pageUid,
80
+ active,
81
+ getLayoutModel,
82
+ }: {
83
+ pageUid: string;
84
+ active?: boolean;
85
+ getLayoutModel: (flowEngine: FlowEngine) => BaseLayoutModel | undefined;
86
+ }) => {
27
87
  const flowEngine = useFlowEngine();
88
+ const { active: keepAliveActive } = useKeepAlive();
28
89
  const routeRepository = flowEngine.context.routeRepository;
29
90
  const refreshDesktopRoutes = React.useMemo(
30
91
  () => routeRepository?.refreshAccessible.bind(routeRepository),
@@ -60,11 +121,13 @@ const BridgeFlowRoute = ({ pageUid }: { pageUid: string }) => {
60
121
  });
61
122
  }, [flowEngine]);
62
123
 
63
- useAdminLayoutRoutePage({
124
+ useLayoutRoutePage({
64
125
  flowEngine,
65
126
  pageUid,
127
+ active: active ?? keepAliveActive,
66
128
  refreshDesktopRoutes,
67
129
  layoutContentRef,
130
+ getLayoutModel,
68
131
  });
69
132
 
70
133
  return <div ref={layoutContentRef} />;
@@ -84,12 +147,20 @@ const BridgeFlowRoute = ({ pageUid }: { pageUid: string }) => {
84
147
  * @returns {JSX.Element} 当前动态页面的布局挂载节点
85
148
  * @throws {Error} 当缺少 `route.params.name` 时抛出异常
86
149
  */
87
- const FlowRoute = () => {
150
+ const FlowRoute = (props: FlowRouteProps = {}) => {
88
151
  const flowEngine = useFlowEngine();
152
+ const flowContext = useFlowContext<any>();
153
+ const contextLayout = flowContext?.layout;
154
+ const getLayoutModel = useMemo(
155
+ () => props.getLayoutModel || ((engine: FlowEngine) => getDefaultLayoutModel(engine, contextLayout)),
156
+ [contextLayout, props.getLayoutModel],
157
+ );
158
+ const legacyPageBehavior = props.legacyPageBehavior || getDefaultLegacyPageBehavior(flowEngine, contextLayout);
89
159
  const app = useApp();
90
160
  const routeRepository = flowEngine.context.routeRepository;
91
161
  const params = useParams();
92
- const pageUid = params?.name;
162
+ const pageUid = props.pageUid || params?.name;
163
+ const skipRouteRepositoryCheck = !routeRepository;
93
164
  const [guardState, setGuardState] = useState<FlowRouteGuardState>({
94
165
  pending: true,
95
166
  allowBridge: false,
@@ -99,7 +170,7 @@ const FlowRoute = () => {
99
170
  const requestIdRef = useRef(0);
100
171
 
101
172
  if (!pageUid) {
102
- throw new Error('[NocoBase] FlowRoute requires route.params.name.');
173
+ throw new Error('[NocoBase] FlowRoute requires pageUid or route.params.name.');
103
174
  }
104
175
 
105
176
  useEffect(() => {
@@ -109,7 +180,7 @@ const FlowRoute = () => {
109
180
  const run = async () => {
110
181
  setGuardState({ pending: true, allowBridge: false, notFound: false });
111
182
 
112
- if (!routeRepository?.isAccessibleLoaded?.()) {
183
+ if (!skipRouteRepositoryCheck && !routeRepository?.isAccessibleLoaded?.()) {
113
184
  try {
114
185
  await routeRepository?.ensureAccessibleLoaded?.();
115
186
  } catch (_error) {
@@ -124,8 +195,26 @@ const FlowRoute = () => {
124
195
  return;
125
196
  }
126
197
 
127
- const route = routeRepository?.getRouteBySchemaUid?.(pageUid);
198
+ const route = skipRouteRepositoryCheck ? undefined : routeRepository?.getRouteBySchemaUid?.(pageUid);
199
+ if (!route && legacyPageBehavior === 'notFound') {
200
+ const flowModelExists = await hasFlowModel(flowEngine, pageUid);
201
+ if (active && requestId === requestIdRef.current) {
202
+ setGuardState({ pending: false, allowBridge: flowModelExists, notFound: !flowModelExists });
203
+ }
204
+ return;
205
+ }
206
+
128
207
  if (route?.type === NocoBaseDesktopRouteType.page) {
208
+ if (legacyPageBehavior === 'notFound') {
209
+ setGuardState({ pending: false, allowBridge: false, notFound: true });
210
+ return;
211
+ }
212
+
213
+ if (legacyPageBehavior === 'bridge') {
214
+ setGuardState({ pending: false, allowBridge: true, notFound: false });
215
+ return;
216
+ }
217
+
129
218
  const target = resolveAdminRouteRuntimeTarget({
130
219
  app,
131
220
  route,
@@ -161,22 +250,23 @@ const FlowRoute = () => {
161
250
  return () => {
162
251
  active = false;
163
252
  };
164
- }, [app, pageUid, routeRepository]);
253
+ }, [app, flowEngine, legacyPageBehavior, pageUid, routeRepository, skipRouteRepositoryCheck]);
165
254
 
166
255
  const content = useMemo(() => {
167
256
  if (guardState.pending) {
168
257
  return null;
169
258
  }
170
259
 
260
+ if (guardState.notFound) {
261
+ return <AppNotFound />;
262
+ }
263
+
171
264
  if (!guardState.allowBridge) {
172
- if (guardState.notFound) {
173
- return <AppNotFound />;
174
- }
175
265
  return null;
176
266
  }
177
267
 
178
- return <BridgeFlowRoute pageUid={pageUid} />;
179
- }, [guardState.allowBridge, guardState.notFound, guardState.pending, pageUid]);
268
+ return <BridgeFlowRoute pageUid={pageUid} active={props.active} getLayoutModel={getLayoutModel} />;
269
+ }, [getLayoutModel, guardState.allowBridge, guardState.notFound, guardState.pending, pageUid, props.active]);
180
270
 
181
271
  return content;
182
272
  };
@@ -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/flow/index.ts CHANGED
@@ -100,7 +100,11 @@ export * from './utils';
100
100
  export * from './actions';
101
101
  export * from './system-settings';
102
102
  export * from './admin-shell/admin-layout';
103
+ export * from './admin-shell/BaseLayoutModel';
104
+ export * from './admin-shell/BaseLayoutRouteCoordinator';
103
105
  export * from './admin-shell/AdminLayoutRouteCoordinator';
106
+ export * from './admin-shell/useLayoutRoutePage';
107
+ export * from './admin-shell/useAdminLayoutRoutePage';
104
108
  export * from '../settings-center';
105
109
  export { openViewFlow } from './flows/openViewFlow';
106
110
  export { editMarkdownFlow } from './flows/editMarkdownFlow';
@@ -80,7 +80,14 @@ ActionModel.registerFlow({
80
80
  };
81
81
  },
82
82
  defaultParams(ctx) {
83
- return ctx.model.defaultProps;
83
+ const defaultProps = ctx.model.defaultProps || {};
84
+ if (!ctx.model.enableEditColor) {
85
+ return defaultProps;
86
+ }
87
+ return {
88
+ ...defaultProps,
89
+ color: ctx.model.props?.color ?? defaultProps.color ?? ctx.themeToken?.colorPrimary,
90
+ };
84
91
  },
85
92
  handler(ctx, params) {
86
93
  const { title, tooltip, ...rest } = params;
@@ -40,6 +40,15 @@ type PageModelStructure = {
40
40
  };
41
41
  };
42
42
 
43
+ type CurrentRouteWithTabs = {
44
+ id?: string | number | null;
45
+ enableTabs?: boolean;
46
+ };
47
+
48
+ type PageModelContextWithRoute = {
49
+ currentRoute?: CurrentRouteWithTabs | null;
50
+ };
51
+
43
52
  export class PageModel extends FlowModel<PageModelStructure> {
44
53
  tabBarExtraContent: { left?: ReactNode; right?: ReactNode } = {};
45
54
  private viewActivatedListener?: (_payload?: unknown) => void;
@@ -53,9 +62,15 @@ export class PageModel extends FlowModel<PageModelStructure> {
53
62
  * 根页面标签页开关以路由表为准,避免 flow model 里的旧配置覆盖路由管理设置。
54
63
  */
55
64
  private getEnableTabs(): boolean {
56
- const routeEnableTabs = (this.context as any)?.currentRoute?.enableTabs;
57
- if (this.props.routeId != null && typeof routeEnableTabs === 'boolean') {
58
- return routeEnableTabs;
65
+ const currentRoute = (this.context as PageModelContextWithRoute).currentRoute;
66
+ const routeId = this.props.routeId;
67
+ if (
68
+ routeId != null &&
69
+ currentRoute?.id != null &&
70
+ String(currentRoute.id) === String(routeId) &&
71
+ typeof currentRoute.enableTabs === 'boolean'
72
+ ) {
73
+ return currentRoute.enableTabs;
59
74
  }
60
75
  return !!this.props.enableTabs;
61
76
  }
@@ -77,16 +92,27 @@ export class PageModel extends FlowModel<PageModelStructure> {
77
92
  if (this.unmounted) return;
78
93
  // Only skip when explicitly inactive; treat "unknown" (undefined) as active for backward compatibility.
79
94
  if (getPageActive(this.context) === false) return;
80
- const activeKey = this.getActiveTabKey();
81
- if (activeKey) {
82
- this.invokeTabModelLifecycleMethod(activeKey, 'onActive', forceRefresh);
83
- }
95
+ this.activateCurrentTab(forceRefresh);
84
96
  })
85
97
  .catch(() => {
86
98
  // ignore
87
99
  });
88
100
  }
89
101
 
102
+ activateCurrentTab(forceRefresh = false) {
103
+ const activeKey = this.getActiveTabKey();
104
+ if (activeKey) {
105
+ this.invokeTabModelLifecycleMethod(activeKey, 'onActive', forceRefresh);
106
+ }
107
+ }
108
+
109
+ deactivateCurrentTab() {
110
+ const activeKey = this.props.tabActiveKey || this.getFirstTab()?.uid;
111
+ if (activeKey) {
112
+ this.invokeTabModelLifecycleMethod(activeKey, 'onInactive');
113
+ }
114
+ }
115
+
90
116
  onMount(): void {
91
117
  super.onMount();
92
118
  this.unmounted = false;
@@ -98,10 +124,7 @@ export class PageModel extends FlowModel<PageModelStructure> {
98
124
  // We align this with the existing tab lifecycle by invoking `onActive` for the current tab blocks.
99
125
  if (!this.viewActivatedListener) {
100
126
  this.viewActivatedListener = (_payload?: unknown) => {
101
- const activeKey = this.getActiveTabKey();
102
- if (activeKey) {
103
- this.invokeTabModelLifecycleMethod(activeKey, 'onActive');
104
- }
127
+ this.activateCurrentTab();
105
128
  };
106
129
  this.flowEngine?.emitter?.on?.(VIEW_ACTIVATED_EVENT, this.viewActivatedListener);
107
130
  }
@@ -113,10 +136,7 @@ export class PageModel extends FlowModel<PageModelStructure> {
113
136
  emitterActivatedVersion > 0 && emitterActivatedVersion !== this.lastSeenEmitterViewActivatedVersion;
114
137
  this.lastSeenEmitterViewActivatedVersion = emitterActivatedVersion;
115
138
  if (shouldCatchUp && getPageActive(this.context) !== false) {
116
- const activeKey = this.getActiveTabKey();
117
- if (activeKey) {
118
- this.invokeTabModelLifecycleMethod(activeKey, 'onActive');
119
- }
139
+ this.activateCurrentTab();
120
140
  }
121
141
 
122
142
  // When data is written within the same view, trigger an "active" lifecycle pass so blocks can refresh based on dirty.
@@ -141,11 +161,21 @@ export class PageModel extends FlowModel<PageModelStructure> {
141
161
  super.onUnmount();
142
162
  }
143
163
 
144
- invokeTabModelLifecycleMethod(tabActiveKey: string, method: 'onActive' | 'onInactive', forceRefresh = false) {
164
+ invokeTabModelLifecycleMethod(
165
+ tabActiveKey: string | undefined,
166
+ method: 'onActive' | 'onInactive',
167
+ forceRefresh = false,
168
+ ) {
169
+ if (!tabActiveKey) {
170
+ return;
171
+ }
172
+
145
173
  if (method === 'onActive' && this.context?.pageInfo) {
146
174
  this.context.pageInfo.version = 'v2';
147
175
  }
148
- const tabModel = this.flowEngine.getModel(tabActiveKey) as BasePageTabModel | undefined;
176
+ const tabModel =
177
+ this.findSubModel('tabs', (model) => model.uid === tabActiveKey) ||
178
+ (this.flowEngine.getModel(tabActiveKey) as BasePageTabModel | undefined);
149
179
 
150
180
  if (tabModel) {
151
181
  if (tabModel.context.tabActive) {
@@ -183,7 +213,10 @@ export class PageModel extends FlowModel<PageModelStructure> {
183
213
  const routePathname = this.flowEngine?.context?.route?.pathname;
184
214
  // In route-managed multi-view mode, only the top view in URL should mutate document.title.
185
215
  if (hasRouteNavigation && currentViewUid && typeof routePathname === 'string') {
186
- const topViewUid = parsePathnameToViewParams(routePathname).at(-1)?.viewUid;
216
+ const layoutRoutePath = this.context?.layout?.routePath;
217
+ const topViewUid = parsePathnameToViewParams(routePathname, {
218
+ basePath: this.context?.layoutRoute?.basePathname || (layoutRoutePath?.startsWith('/') ? layoutRoutePath : ''),
219
+ }).at(-1)?.viewUid;
187
220
  if (topViewUid && topViewUid !== currentViewUid) {
188
221
  return;
189
222
  }
@@ -68,22 +68,15 @@ export class RootPageModel extends PageModel {
68
68
  reaction(
69
69
  () => this.context.pageActive.value,
70
70
  () => {
71
+ if (this.context.view?.inputArgs?.activationControlledByLayout) {
72
+ this.mounted = true;
73
+ return;
74
+ }
71
75
  if (this.context.pageActive.value && this.mounted) {
72
- const firstTab = this.subModels.tabs?.[0];
73
- if (firstTab) {
74
- this.setProps('tabActiveKey', firstTab.uid);
75
- this.invokeTabModelLifecycleMethod(firstTab.uid, 'onActive', true);
76
- }
76
+ this.activateCurrentTab(true);
77
77
  }
78
78
  if (this.context.pageActive.value === false) {
79
- if (this.props.tabActiveKey) {
80
- this.invokeTabModelLifecycleMethod(this.props.tabActiveKey, 'onInactive');
81
- } else {
82
- const firstTab = this.subModels.tabs?.[0];
83
- if (firstTab) {
84
- this.invokeTabModelLifecycleMethod(firstTab.uid, 'onInactive');
85
- }
86
- }
79
+ this.deactivateCurrentTab();
87
80
  }
88
81
  this.mounted = true;
89
82
  },
@@ -47,6 +47,13 @@ vi.mock('@nocobase/flow-engine', () => {
47
47
  return [];
48
48
  }
49
49
 
50
+ findSubModel(key: string, callback: any) {
51
+ if (this.subModels[key]) {
52
+ return this.subModels[key].find(callback) || null;
53
+ }
54
+ return null;
55
+ }
56
+
50
57
  addSubModel() {}
51
58
  setSubModel() {}
52
59
 
@@ -260,6 +267,44 @@ describe('PageModel', () => {
260
267
  });
261
268
  });
262
269
 
270
+ describe('tab lifecycle', () => {
271
+ it('should invoke tab lifecycle on PageModel subModels before engine lookup', () => {
272
+ const blockOnActive = vi.fn();
273
+ const blockOnInactive = vi.fn();
274
+ const tabModel = {
275
+ uid: 'tab1',
276
+ context: {
277
+ tabActive: { value: false },
278
+ },
279
+ subModels: {
280
+ grid: {
281
+ mapSubModels: vi.fn((_key, callback) => {
282
+ callback({ onActive: blockOnActive, onInactive: blockOnInactive });
283
+ }),
284
+ },
285
+ },
286
+ };
287
+ (pageModel as any).subModels = { tabs: [tabModel] };
288
+ (pageModel as any).flowEngine = {
289
+ getModel: vi.fn(() => undefined),
290
+ };
291
+ (pageModel as any).context = {
292
+ pageInfo: {},
293
+ view: {
294
+ inputArgs: { pageActive: true },
295
+ },
296
+ };
297
+
298
+ pageModel.invokeTabModelLifecycleMethod('tab1', 'onActive', true);
299
+ pageModel.invokeTabModelLifecycleMethod('tab1', 'onInactive');
300
+
301
+ expect((pageModel as any).flowEngine.getModel).not.toHaveBeenCalled();
302
+ expect(tabModel.context.tabActive.value).toBe(false);
303
+ expect(blockOnActive).toHaveBeenCalledWith(true);
304
+ expect(blockOnInactive).toHaveBeenCalledWith(false);
305
+ });
306
+ });
307
+
263
308
  describe('renderTabs activeKey logic', () => {
264
309
  beforeEach(() => {
265
310
  // Mock mapTabs to avoid complex rendering logic inside it
@@ -455,6 +500,7 @@ describe('PageModel', () => {
455
500
  } as any;
456
501
  (pageModel as any).context = {
457
502
  currentRoute: {
503
+ id: 'route-1',
458
504
  enableTabs: false,
459
505
  },
460
506
  };
@@ -479,6 +525,7 @@ describe('PageModel', () => {
479
525
  } as any;
480
526
  (pageModel as any).context = {
481
527
  currentRoute: {
528
+ id: 'route-1',
482
529
  enableTabs: true,
483
530
  },
484
531
  };
@@ -495,6 +542,59 @@ describe('PageModel', () => {
495
542
  paddingBottom: 0,
496
543
  });
497
544
  });
545
+
546
+ it('should ignore stale desktop route enableTabs=false from another route', () => {
547
+ pageModel.props = {
548
+ routeId: 'route-1',
549
+ displayTitle: true,
550
+ enableTabs: true,
551
+ title: 'Title',
552
+ headerStyle: { backgroundColor: 'var(--colorBgLayout)' },
553
+ } as any;
554
+ (pageModel as any).context = {
555
+ currentRoute: {
556
+ id: 'route-2',
557
+ enableTabs: false,
558
+ },
559
+ };
560
+ pageModel.renderTabs = vi.fn(() => null);
561
+ pageModel.renderFirstTab = vi.fn(() => null);
562
+
563
+ const result = pageModel.render() as any;
564
+ const header = result.props.children[0];
565
+
566
+ expect(pageModel.renderTabs).toHaveBeenCalled();
567
+ expect(pageModel.renderFirstTab).not.toHaveBeenCalled();
568
+ expect(header.props.style).toMatchObject({
569
+ backgroundColor: 'var(--colorBgLayout)',
570
+ paddingBottom: 0,
571
+ });
572
+ });
573
+
574
+ it('should ignore stale desktop route enableTabs=true from another route', () => {
575
+ pageModel.props = {
576
+ routeId: 'route-1',
577
+ displayTitle: true,
578
+ enableTabs: false,
579
+ title: 'Title',
580
+ headerStyle: { backgroundColor: 'var(--colorBgLayout)' },
581
+ } as any;
582
+ (pageModel as any).context = {
583
+ currentRoute: {
584
+ id: 'route-2',
585
+ enableTabs: true,
586
+ },
587
+ };
588
+ pageModel.renderTabs = vi.fn(() => null);
589
+ pageModel.renderFirstTab = vi.fn(() => null);
590
+
591
+ const result = pageModel.render() as any;
592
+ const header = result.props.children[0];
593
+
594
+ expect(pageModel.renderTabs).not.toHaveBeenCalled();
595
+ expect(pageModel.renderFirstTab).toHaveBeenCalled();
596
+ expect(header.props.style).toEqual({ backgroundColor: 'var(--colorBgLayout)' });
597
+ });
498
598
  });
499
599
 
500
600
  describe('dirty refresh signal', () => {
@@ -554,7 +654,7 @@ describe('PageModel', () => {
554
654
  pageModel.onMount();
555
655
 
556
656
  expect(typeof listeners['view:activated']).toBe('function');
557
- expect(invokeSpy).toHaveBeenCalledWith('tab1', 'onActive');
657
+ expect(invokeSpy).toHaveBeenCalledWith('tab1', 'onActive', false);
558
658
  });
559
659
  });
560
660
 
@@ -630,6 +730,7 @@ describe('PageModel', () => {
630
730
  it('should use page documentTitle when desktop route disables tabs even if flow model enables tabs', async () => {
631
731
  pageModel.props = { routeId: 'route-1', enableTabs: true, title: 'Route page title' } as any;
632
732
  (pageModel as any).context.currentRoute = {
733
+ id: 'route-1',
633
734
  enableTabs: false,
634
735
  };
635
736
  (pageModel as any).stepParams = {
@@ -21,7 +21,7 @@ RouteModel.registerFlow({
21
21
  return {
22
22
  mode: 'embed',
23
23
  preventClose: true,
24
- pageModelClass: 'RootPageModel',
24
+ pageModelClass: ctx.layout?.rootPageModelClass || 'RootPageModel',
25
25
  };
26
26
  },
27
27
  },
@@ -12,4 +12,18 @@ import { FormActionModel } from './FormActionModel';
12
12
 
13
13
  export class FormActionGroupModel extends ActionGroupModel {
14
14
  static baseClass = FormActionModel;
15
+
16
+ static async defineChildren(ctx) {
17
+ const allowedModelNames = ctx.allowedFormActionModelNames;
18
+
19
+ if (!Array.isArray(allowedModelNames) || allowedModelNames.length === 0) {
20
+ return super.defineChildren(ctx);
21
+ }
22
+
23
+ await Promise.all(allowedModelNames.map((name) => ctx.engine?.getModelClassAsync?.(name)));
24
+
25
+ const items = await super.defineChildren(ctx);
26
+ const allowedSet = new Set(allowedModelNames);
27
+ return items.filter((item) => allowedSet.has(item.useModel || item.key));
28
+ }
15
29
  }
@@ -138,6 +138,10 @@ export class FormItemModel<T extends DefaultStructure = DefaultStructure> extend
138
138
  });
139
139
  }
140
140
  }
141
+ fork.context.defineProperty('fieldPathArray', {
142
+ get: () => this.context.fieldPathArray,
143
+ cache: false,
144
+ });
141
145
  if (isHiddenReservedValuePreview) {
142
146
  fork.setProps({ hidden: false });
143
147
  }
@@ -151,7 +155,10 @@ export class FormItemModel<T extends DefaultStructure = DefaultStructure> extend
151
155
  : { hidden, ...mergedPropsWithoutInitial };
152
156
  const fieldPath = buildDynamicNamePath(this.props.name, idx);
153
157
  this.context.defineProperty('fieldPathArray', {
154
- value: [...parentFieldPathArray, ..._.castArray(fieldPath)],
158
+ get: () => {
159
+ return [...parentFieldPathArray, ..._.castArray(fieldPath)];
160
+ },
161
+ cache: false,
155
162
  });
156
163
  const record = this.context.item?.value || this.context.record;
157
164
  const content = (