@nocobase/client-v2 2.2.0-beta.9 → 3.0.0-alpha.1

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 (208) hide show
  1. package/es/APIClient.d.ts +1 -3
  2. package/es/BaseApplication.d.ts +1 -0
  3. package/es/acl/ACLProvider.d.ts +10 -0
  4. package/es/authRedirect.d.ts +5 -0
  5. package/es/collection-field-interface/CollectionFieldInterface.d.ts +1 -0
  6. package/es/collection-field-interface/CollectionFieldInterfaceManager.d.ts +1 -0
  7. package/es/components/AttachmentUpload.d.ts +51 -0
  8. package/es/components/category-tabs/SortableCategoryTabs.d.ts +55 -0
  9. package/es/components/category-tabs/index.d.ts +9 -0
  10. package/es/components/form/ScanInput/useCodeScanner.d.ts +3 -2
  11. package/es/components/form/TypedVariableInput.d.ts +31 -5
  12. package/es/components/form/filter/CollectionFilter.d.ts +2 -0
  13. package/es/components/form/filter/CollectionFilterItem.d.ts +11 -1
  14. package/es/components/form/filter/CollectionFilterPanel.d.ts +2 -0
  15. package/es/components/form/filter/index.d.ts +2 -0
  16. package/es/components/form/filter/useFilterActionProps.d.ts +2 -0
  17. package/es/components/index.d.ts +2 -0
  18. package/es/flow/actions/index.d.ts +1 -1
  19. package/es/flow/actions/linkageRules.d.ts +3 -0
  20. package/es/flow/admin-shell/admin-layout/AdminLayoutMenuModels.d.ts +1 -0
  21. package/es/flow/admin-shell/admin-layout/NocoBaseLogo.d.ts +10 -0
  22. package/es/flow/components/FieldAssignExactDatePicker.d.ts +1 -0
  23. package/es/flow/components/FieldAssignValueInput.d.ts +9 -0
  24. package/es/flow/components/FlowRoute.d.ts +2 -2
  25. package/es/flow/components/RunJSValueEditor.d.ts +1 -0
  26. package/es/flow/components/filter/FilterGroup.d.ts +1 -0
  27. package/es/flow/components/filter/VariableFilterItem.d.ts +6 -1
  28. package/es/flow/index.d.ts +1 -0
  29. package/es/flow/internal/registerDeviceTypeContext.d.ts +10 -0
  30. package/es/flow/models/base/ActionModelCore.d.ts +4 -2
  31. package/es/flow/models/base/BlockGridModel.d.ts +3 -0
  32. package/es/flow/models/base/PageModel/PageModel.d.ts +12 -0
  33. package/es/flow/models/base/PageModel/PageModelTabBar.d.ts +22 -0
  34. package/es/flow/models/base/PageModel/PageTabModel.d.ts +12 -0
  35. package/es/flow/models/blocks/form/QuickEditFormModel.d.ts +17 -2
  36. package/es/flow/models/blocks/table/TableColumnModel.d.ts +1 -0
  37. package/es/flow/models/fields/DateTimeFieldModel/dateLimit.d.ts +15 -7
  38. package/es/flow-compat/fieldValidationConstants.d.ts +1 -1
  39. package/es/flow-compat/routeTypes.d.ts +1 -0
  40. package/es/index.d.ts +8 -1
  41. package/es/index.mjs +190 -141
  42. package/es/nocobase-buildin-plugin/currentUserAuthStatus.d.ts +11 -0
  43. package/es/nocobase-buildin-plugin/index.d.ts +9 -0
  44. package/es/settings-app/SettingsApplication.d.ts +18 -0
  45. package/es/settings-app/SettingsBrand.d.ts +16 -0
  46. package/es/settings-app/SettingsBuildInPlugin.d.ts +9 -0
  47. package/es/settings-app/SettingsDocumentRedirect.d.ts +10 -0
  48. package/es/settings-app/SettingsGroupNav.d.ts +17 -0
  49. package/es/settings-app/SettingsPluginSettingsManager.d.ts +14 -0
  50. package/es/settings-app/SettingsRouterManager.d.ts +15 -0
  51. package/es/settings-app/SettingsSearch.d.ts +17 -0
  52. package/es/settings-app/SettingsShell.d.ts +10 -0
  53. package/es/settings-app/isSettingsApp.d.ts +18 -0
  54. package/es/settings-app/runtimePaths.d.ts +18 -0
  55. package/es/settings-app/settingsDocumentPath.d.ts +14 -0
  56. package/es/settings-app/settingsTheme.d.ts +87 -0
  57. package/es/settings-app/useSettingsThemeConfig.d.ts +22 -0
  58. package/es/settings-center/AdminSettingsLayout.d.ts +2 -2
  59. package/es/settings-center/groups.d.ts +66 -0
  60. package/es/settings-center/useSettingsGroups.d.ts +36 -0
  61. package/es/settings-center/useSettingsSearch.d.ts +35 -0
  62. package/es/settings-center/utils.d.ts +24 -0
  63. package/es/theme/index.d.ts +1 -0
  64. package/es/theme/itemActive.d.ts +24 -0
  65. package/es/theme/type.d.ts +9 -0
  66. package/es/utils/getRouteRuntimeVersion.d.ts +23 -0
  67. package/es/utils/index.d.ts +1 -0
  68. package/lib/index.js +195 -146
  69. package/lib/locale/languageCodes.js +3 -1
  70. package/package.json +9 -7
  71. package/src/APIClient.ts +1 -10
  72. package/src/Application.tsx +4 -0
  73. package/src/BaseApplication.tsx +11 -6
  74. package/src/__tests__/app.test.tsx +73 -0
  75. package/src/__tests__/authRedirect.test.ts +137 -0
  76. package/src/__tests__/browserChecker.test.ts +185 -6
  77. package/src/__tests__/getRouteRuntimeVersion.test.ts +68 -0
  78. package/src/__tests__/mockSettingsApplication.ts +29 -0
  79. package/src/__tests__/nocobase-buildin-plugin-auth.test.tsx +79 -10
  80. package/src/__tests__/plugin-manager.test.tsx +7 -6
  81. package/src/__tests__/settings-application.test.ts +164 -0
  82. package/src/__tests__/settings-auth-routes.test.ts +71 -0
  83. package/src/__tests__/settings-center.test.tsx +38 -37
  84. package/src/__tests__/settings-layout-root.test.tsx +260 -0
  85. package/src/__tests__/settings-runtime-paths.test.ts +99 -0
  86. package/src/__tests__/settings-shell.test.tsx +200 -0
  87. package/src/acl/ACLProvider.tsx +21 -0
  88. package/src/authRedirect.ts +72 -3
  89. package/src/collection-field-interface/CollectionFieldInterface.ts +1 -0
  90. package/src/collection-field-interface/CollectionFieldInterfaceManager.ts +1 -0
  91. package/src/collection-manager/field-validation.ts +1 -1
  92. package/src/components/AttachmentUpload.tsx +275 -0
  93. package/src/components/README.md +7 -1
  94. package/src/components/README.zh-CN.md +6 -1
  95. package/src/components/category-tabs/SortableCategoryTabs.tsx +210 -0
  96. package/src/components/category-tabs/index.ts +10 -0
  97. package/src/components/form/JsonTextArea.tsx +4 -0
  98. package/src/components/form/ScanInput/CodeScanner.tsx +79 -35
  99. package/src/components/form/ScanInput/__tests__/CodeScanner.test.tsx +101 -0
  100. package/src/components/form/ScanInput/__tests__/useCodeScanner.test.tsx +284 -11
  101. package/src/components/form/ScanInput/useCodeScanner.ts +332 -24
  102. package/src/components/form/TypedVariableInput.tsx +452 -101
  103. package/src/components/form/__tests__/TypedVariableInput.test.tsx +364 -12
  104. package/src/components/form/filter/CollectionFilter.tsx +4 -0
  105. package/src/components/form/filter/CollectionFilterItem.tsx +32 -7
  106. package/src/components/form/filter/CollectionFilterPanel.tsx +4 -0
  107. package/src/components/form/filter/__tests__/CollectionFilterItem.test.tsx +38 -0
  108. package/src/components/form/filter/__tests__/useFilterActionProps.test.tsx +95 -0
  109. package/src/components/form/filter/index.ts +2 -0
  110. package/src/components/form/filter/useFilterActionProps.ts +37 -6
  111. package/src/components/index.ts +2 -0
  112. package/src/flow/FlowPage.tsx +9 -1
  113. package/src/flow/__tests__/FlowPage.test.tsx +50 -3
  114. package/src/flow/__tests__/FlowRoute.test.tsx +128 -10
  115. package/src/flow/__tests__/PluginFlowEngine.test.ts +58 -0
  116. package/src/flow/actions/__tests__/actionLinkageRules.forkProps.test.ts +314 -0
  117. package/src/flow/actions/__tests__/dataScopeFormValueClear.test.ts +102 -0
  118. package/src/flow/actions/__tests__/linkageRules.tab.test.ts +171 -0
  119. package/src/flow/actions/__tests__/linkageRulesRefresh.test.ts +7 -3
  120. package/src/flow/actions/index.ts +2 -0
  121. package/src/flow/actions/linkageRules.tsx +111 -16
  122. package/src/flow/actions/linkageRulesRefresh.tsx +2 -6
  123. package/src/flow/admin-shell/admin-layout/AdminLayoutComponent.tsx +1 -57
  124. package/src/flow/admin-shell/admin-layout/NocoBaseLogo.tsx +77 -0
  125. package/src/flow/admin-shell/admin-layout/TopbarActionsBar.tsx +37 -14
  126. package/src/flow/admin-shell/admin-layout/__tests__/AdminLayoutComponent.test.tsx +253 -6
  127. package/src/flow/admin-shell/admin-layout/__tests__/TopbarActionsBar.test.tsx +77 -13
  128. package/src/flow/common/Markdown/style.ts +4 -0
  129. package/src/flow/components/BlockItemCard.tsx +2 -2
  130. package/src/flow/components/FieldAssignExactDatePicker.tsx +25 -11
  131. package/src/flow/components/FieldAssignValueInput.tsx +91 -20
  132. package/src/flow/components/FlowRoute.tsx +78 -32
  133. package/src/flow/components/RunJSValueEditor.tsx +9 -1
  134. package/src/flow/components/__tests__/FieldAssignValueInput.context.test.tsx +216 -0
  135. package/src/flow/components/filter/FilterGroup.tsx +33 -5
  136. package/src/flow/components/filter/VariableFilterItem.tsx +158 -10
  137. package/src/flow/components/filter/__tests__/FilterGroup.test.tsx +45 -0
  138. package/src/flow/components/filter/__tests__/VariableFilterItem.leftMetaTree.test.tsx +9 -0
  139. package/src/flow/components/filter/__tests__/VariableFilterItem.test.tsx +70 -1
  140. package/src/flow/index.ts +3 -0
  141. package/src/flow/internal/registerDeviceTypeContext.ts +39 -0
  142. package/src/flow/models/base/ActionModelCore.tsx +11 -2
  143. package/src/flow/models/base/BlockGridModel.tsx +26 -0
  144. package/src/flow/models/base/CollectionBlockModel.tsx +6 -2
  145. package/src/flow/models/base/PageModel/PageModel.tsx +387 -53
  146. package/src/flow/models/base/PageModel/PageModelTabBar.tsx +114 -0
  147. package/src/flow/models/base/PageModel/PageTabModel.tsx +249 -18
  148. package/src/flow/models/base/PageModel/__tests__/PageModel.test.ts +790 -10
  149. package/src/flow/models/base/PageModel/__tests__/PageModelTabBar.module-isolation.test.tsx +130 -0
  150. package/src/flow/models/base/PageModel/__tests__/PageModelTabBar.test.tsx +118 -0
  151. package/src/flow/models/base/PageModel/__tests__/PageTabModel.test.ts +1075 -7
  152. package/src/flow/models/base/__tests__/ActionModelCore.render.test.tsx +49 -0
  153. package/src/flow/models/base/__tests__/BlockGridModel.selectSceneActivation.test.ts +124 -0
  154. package/src/flow/models/base/__tests__/CollectionBlockModel.addAppends.test.ts +41 -0
  155. package/src/flow/models/blocks/filter-form/FilterFormBlockModel.tsx +28 -3
  156. package/src/flow/models/blocks/filter-form/FilterFormItemModel.tsx +27 -12
  157. package/src/flow/models/blocks/filter-form/__tests__/FilterFormItemModel.defineChildren.test.ts +36 -0
  158. package/src/flow/models/blocks/filter-form/__tests__/defaultValues.wiring.test.ts +66 -4
  159. package/src/flow/models/blocks/form/QuickEditFormModel.tsx +189 -36
  160. package/src/flow/models/blocks/form/__tests__/QuickEditFormModel.quickEdit.test.ts +350 -2
  161. package/src/flow/models/blocks/js-block/JSBlock.tsx +1 -1
  162. package/src/flow/models/blocks/table/TableActionsColumnModel.tsx +2 -1
  163. package/src/flow/models/blocks/table/TableBlockModel.tsx +4 -2
  164. package/src/flow/models/blocks/table/TableColumnModel.tsx +30 -1
  165. package/src/flow/models/blocks/table/__tests__/TableBlockModel.quickEditRefresh.test.ts +12 -0
  166. package/src/flow/models/blocks/table/__tests__/TableColumnModel.test.tsx +33 -0
  167. package/src/flow/models/fields/AssociationFieldModel/PopupSubTableFieldModel/PopupSubTableFieldModel.tsx +2 -1
  168. package/src/flow/models/fields/DateTimeFieldModel/__tests__/DateTimeNoTzFieldModel.dateLimit.test.tsx +149 -0
  169. package/src/flow/models/fields/DateTimeFieldModel/dateLimit.ts +149 -113
  170. package/src/flow/models/fields/JsonFieldModel.tsx +31 -8
  171. package/src/flow/models/fields/VariableFieldFormModel.tsx +3 -3
  172. package/src/flow/models/fields/__tests__/JsonFieldModel.test.ts +82 -0
  173. package/src/flow/models/fields/__tests__/VariableFieldFormModel.test.tsx +51 -0
  174. package/src/flow/models/fields/mobile-components/MobileLazySelect.tsx +20 -10
  175. package/src/flow/models/fields/mobile-components/MobileSelect.tsx +24 -12
  176. package/src/flow/models/topbar/TopbarActionModel.tsx +52 -180
  177. package/src/flow/utils/dataScopeFormValueClear.ts +4 -3
  178. package/src/flow-compat/fieldValidationConstants.ts +1 -1
  179. package/src/flow-compat/routeTypes.ts +1 -0
  180. package/src/index.ts +14 -1
  181. package/src/locale/languageCodes.ts +3 -1
  182. package/src/nocobase-buildin-plugin/currentUserAuthStatus.ts +58 -0
  183. package/src/nocobase-buildin-plugin/index.tsx +110 -70
  184. package/src/settings-app/SettingsApplication.ts +31 -0
  185. package/src/settings-app/SettingsBrand.tsx +108 -0
  186. package/src/settings-app/SettingsBuildInPlugin.ts +10 -0
  187. package/src/settings-app/SettingsDocumentRedirect.tsx +26 -0
  188. package/src/settings-app/SettingsGroupNav.tsx +124 -0
  189. package/src/settings-app/SettingsPluginSettingsManager.ts +38 -0
  190. package/src/settings-app/SettingsRouterManager.ts +92 -0
  191. package/src/settings-app/SettingsSearch.tsx +238 -0
  192. package/src/settings-app/SettingsShell.tsx +167 -0
  193. package/src/settings-app/isSettingsApp.ts +21 -0
  194. package/src/settings-app/runtimePaths.ts +104 -0
  195. package/src/settings-app/settingsDocumentPath.ts +66 -0
  196. package/src/settings-app/settingsTheme.ts +301 -0
  197. package/src/settings-app/useSettingsThemeConfig.ts +100 -0
  198. package/src/settings-center/AdminSettingsLayout.tsx +122 -141
  199. package/src/settings-center/SystemSettingsPage.tsx +4 -170
  200. package/src/settings-center/groups.ts +108 -0
  201. package/src/settings-center/useSettingsGroups.ts +105 -0
  202. package/src/settings-center/useSettingsSearch.ts +173 -0
  203. package/src/settings-center/utils.tsx +50 -1
  204. package/src/theme/index.tsx +1 -0
  205. package/src/theme/itemActive.ts +31 -0
  206. package/src/theme/type.ts +10 -0
  207. package/src/utils/getRouteRuntimeVersion.ts +185 -0
  208. package/src/utils/index.tsx +1 -0
@@ -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 { render, screen } from '@testing-library/react';
11
+ import { ConfigProvider } from 'antd';
12
+ import React from 'react';
13
+ import { MemoryRouter } from 'react-router-dom';
14
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
15
+ import { setCurrentUserAuthStatus } from '../nocobase-buildin-plugin/currentUserAuthStatus';
16
+ import { SettingsShell } from '../settings-app/SettingsShell';
17
+ import type { ThemeConfig } from '../theme';
18
+
19
+ const userCenterModel = { uid: 'settings-user-center' };
20
+ const createModel = vi.fn(() => userCenterModel);
21
+ const matchRoutes = vi.fn(() => [{ route: { id: 'settings' } }]);
22
+ const mockApp = {
23
+ flowEngine: {
24
+ createModel,
25
+ getModel: vi.fn(() => null),
26
+ getModelClass: vi.fn(() => true),
27
+ },
28
+ router: {
29
+ matchRoutes,
30
+ },
31
+ };
32
+
33
+ vi.mock('../hooks/useApp', () => ({
34
+ useApp: () => mockApp,
35
+ }));
36
+
37
+ vi.mock('../settings-app/SettingsBrand', () => ({
38
+ SettingsBrand: () => <div data-testid="settings-logo">logo</div>,
39
+ }));
40
+
41
+ vi.mock('../settings-app/SettingsGroupNav', () => ({
42
+ SettingsGroupNav: () => <div data-testid="settings-group-nav">groups</div>,
43
+ }));
44
+
45
+ vi.mock('../settings-app/SettingsSearch', () => ({
46
+ SettingsSearch: () => <div data-testid="settings-search">search</div>,
47
+ }));
48
+
49
+ vi.mock('../flow/admin-shell/admin-layout/HelpLite', () => ({
50
+ HelpLite: () => <div data-testid="settings-help">help</div>,
51
+ }));
52
+
53
+ vi.mock('@nocobase/flow-engine', async (importOriginal) => {
54
+ const actual = await importOriginal<typeof import('@nocobase/flow-engine')>();
55
+ return {
56
+ ...actual,
57
+ FlowModelRenderer: ({ model }: { model: { uid: string } }) => (
58
+ <div data-testid="settings-user-center">{model.uid}</div>
59
+ ),
60
+ };
61
+ });
62
+
63
+ describe('SettingsShell', () => {
64
+ beforeEach(() => {
65
+ createModel.mockClear();
66
+ matchRoutes.mockReset();
67
+ matchRoutes.mockReturnValue([{ route: { id: 'settings' } }]);
68
+ setCurrentUserAuthStatus(mockApp, 'authenticated');
69
+ });
70
+
71
+ it('renders only the settings brand, group nav, help and user center around its content', () => {
72
+ render(
73
+ <MemoryRouter initialEntries={['/settings/system-settings']}>
74
+ <SettingsShell>
75
+ <div>settings content</div>
76
+ </SettingsShell>
77
+ </MemoryRouter>,
78
+ );
79
+
80
+ expect(screen.getByTestId('settings-logo')).toBeInTheDocument();
81
+ expect(screen.getByTestId('settings-group-nav')).toBeInTheDocument();
82
+ expect(screen.getByTestId('settings-search')).toBeInTheDocument();
83
+ expect(screen.getByTestId('settings-help')).toBeInTheDocument();
84
+ expect(screen.getByTestId('settings-user-center')).toHaveTextContent('settings-user-center');
85
+ expect(screen.getByText('settings content')).toBeInTheDocument();
86
+ expect(screen.queryByTestId('plugin-settings-button')).not.toBeInTheDocument();
87
+ expect(screen.queryByTestId('notifications-button')).not.toBeInTheDocument();
88
+ });
89
+
90
+ it('keeps its neutral header instead of following the business-side header color', () => {
91
+ render(
92
+ <ConfigProvider
93
+ theme={
94
+ {
95
+ token: {
96
+ colorBgHeader: '#001529',
97
+ colorPrimary: '#1777FF',
98
+ },
99
+ } as ThemeConfig
100
+ }
101
+ >
102
+ <MemoryRouter initialEntries={['/settings/system-settings']}>
103
+ <SettingsShell>
104
+ <div>settings content</div>
105
+ </SettingsShell>
106
+ </MemoryRouter>
107
+ </ConfigProvider>,
108
+ );
109
+
110
+ // 主题编辑器里的深色顶栏只作用于业务端;设置中心固定用容器底色。
111
+ expect(screen.getByRole('banner')).toHaveStyle({ background: '#ffffff' });
112
+ });
113
+
114
+ it('places the settings content and embed container side by side below the header', () => {
115
+ const { container } = render(
116
+ <MemoryRouter initialEntries={['/settings/theme-editor']}>
117
+ <SettingsShell>
118
+ <div>settings content</div>
119
+ </SettingsShell>
120
+ </MemoryRouter>,
121
+ );
122
+
123
+ const header = screen.getByRole('banner');
124
+ const content = screen.getByRole('main');
125
+ const embedContainer = container.querySelector<HTMLElement>('#nocobase-embed-container');
126
+ const workspace = content.parentElement;
127
+
128
+ expect(embedContainer).not.toBeNull();
129
+ if (!embedContainer) {
130
+ throw new Error('Expected the Settings shell to render the global embed container');
131
+ }
132
+ expect(header.nextElementSibling).toBe(workspace);
133
+ expect(workspace).toHaveStyle({
134
+ display: 'flex',
135
+ flex: '1',
136
+ minWidth: '0',
137
+ minHeight: '0',
138
+ overflow: 'hidden',
139
+ });
140
+ expect(workspace?.children).toHaveLength(2);
141
+ expect(workspace?.firstElementChild).toBe(content);
142
+ expect(workspace?.lastElementChild).toBe(embedContainer);
143
+ expect(content).toHaveStyle({
144
+ flex: '1',
145
+ minWidth: '0',
146
+ minHeight: '0',
147
+ overflow: 'hidden',
148
+ });
149
+ expect(embedContainer).toHaveStyle({
150
+ flexShrink: '0',
151
+ height: '100%',
152
+ position: 'relative',
153
+ });
154
+
155
+ embedContainer.style.width = '33.3%';
156
+ embedContainer.style.maxWidth = '800px';
157
+ expect(workspace?.lastElementChild).toBe(embedContainer);
158
+ expect(embedContainer).toHaveStyle({ width: '33.3%', maxWidth: '800px' });
159
+
160
+ embedContainer.style.width = 'auto';
161
+ embedContainer.style.maxWidth = 'none';
162
+ expect(content).toHaveStyle({ flex: '1' });
163
+ expect(embedContainer).toHaveStyle({ width: 'auto', maxWidth: 'none' });
164
+ });
165
+
166
+ it.each(['auth.signin', '2fa.verify'])('does not render the settings shell for %s', (routeId) => {
167
+ matchRoutes.mockReturnValue([{ route: { id: routeId } }]);
168
+
169
+ render(
170
+ <MemoryRouter initialEntries={[routeId === 'auth.signin' ? '/settings/signin' : '/settings/2fa']}>
171
+ <SettingsShell>
172
+ <div>authentication content</div>
173
+ </SettingsShell>
174
+ </MemoryRouter>,
175
+ );
176
+
177
+ expect(screen.getByText('authentication content')).toBeInTheDocument();
178
+ expect(screen.queryByRole('banner')).not.toBeInTheDocument();
179
+ expect(screen.queryByTestId('settings-logo')).not.toBeInTheDocument();
180
+ expect(screen.queryByTestId('settings-help')).not.toBeInTheDocument();
181
+ expect(screen.queryByTestId('settings-user-center')).not.toBeInTheDocument();
182
+ expect(document.querySelector('#nocobase-embed-container')).not.toBeInTheDocument();
183
+ });
184
+
185
+ it('does not render the Settings header before authentication completes', () => {
186
+ setCurrentUserAuthStatus(mockApp, 'unknown');
187
+
188
+ render(
189
+ <MemoryRouter initialEntries={['/settings/system-settings']}>
190
+ <SettingsShell>
191
+ <div>settings content</div>
192
+ </SettingsShell>
193
+ </MemoryRouter>,
194
+ );
195
+
196
+ expect(screen.getByText('settings content')).toBeInTheDocument();
197
+ expect(document.querySelector('header')).toHaveStyle({ display: 'none' });
198
+ expect(screen.queryByTestId('settings-logo')).not.toBeVisible();
199
+ });
200
+ });
@@ -167,6 +167,27 @@ export const useACLContext = () => {
167
167
  return useContext(ACLContext);
168
168
  };
169
169
 
170
+ /**
171
+ * 读取当前角色的 snippets,且不要求调用方位于 `ACLRolesCheckProvider` 之内。
172
+ *
173
+ * 设置中心顶栏导航挂在 app 级 provider 上,位置比 `ACLRolesCheckProvider` 更外层,
174
+ * 这种情况下退回到 app context 上的 ACL observable store。
175
+ * 调用方需要用 `observer` 包裹,store 更新后才会重新渲染。
176
+ *
177
+ * @returns {string[]} 当前角色的 snippets
178
+ */
179
+ export const useACLSnippets = (): string[] => {
180
+ const app = useApp();
181
+ const ctx = useACLContext();
182
+ const contextSnippets = ctx?.data?.data?.snippets;
183
+ // 必须走 ensureACLStore:调用方可能位于 `ACLRolesCheckProvider` 之外,此时 store 还没建,
184
+ // 直接读 `app.context.acl` 拿到的是 undefined,observer 就订阅不到后续的权限写入,
185
+ // 会一直停在首帧的空 snippets 上。
186
+ const storeSnippets = ensureACLStore(app)?.data?.snippets;
187
+
188
+ return contextSnippets?.length ? contextSnippets : storeSnippets || [];
189
+ };
190
+
170
191
  /**
171
192
  * 触发当前角色权限重新检查。
172
193
  */
@@ -8,8 +8,19 @@
8
8
  */
9
9
 
10
10
  import type { BaseApplication } from './BaseApplication';
11
+ import {
12
+ resolveSettingsAppScope,
13
+ resolveSettingsAppScopeWithinPublicPath,
14
+ resolveSettingsDocumentPath,
15
+ type SettingsAppScope,
16
+ } from './settings-app/settingsDocumentPath';
11
17
 
12
18
  type AppLike = Pick<BaseApplication<any>, 'getPublicPath'> & {
19
+ name?: string;
20
+ pluginSettingsManager?: {
21
+ getRouteName?: (name: string) => string;
22
+ getRoutePath?: (name: string) => string;
23
+ };
13
24
  router?: {
14
25
  getBasename?: () => string | undefined;
15
26
  };
@@ -52,7 +63,8 @@ function normalizePublicPath(value?: string) {
52
63
  }
53
64
 
54
65
  function normalizePathname(value?: string) {
55
- const normalized = ensureLeadingSlash(String(value || '/').trim() || '/').replace(/\/{2,}/g, '/');
66
+ const pathname = ensureLeadingSlash(String(value || '/').trim() || '/');
67
+ const normalized = new URL(pathname, 'http://nocobase.local').pathname.replace(/\/{2,}/g, '/');
56
68
  if (normalized !== '/' && normalized.endsWith('/')) {
57
69
  return trimTrailingSlashes(normalized);
58
70
  }
@@ -179,7 +191,53 @@ function joinRootRelativePath(basePath: string, pathname: string) {
179
191
  return normalizePathname(`/${trimLeadingSlashes(normalizedBasePath)}/${trimLeadingSlashes(normalizedPathname)}`);
180
192
  }
181
193
 
194
+ function isStandaloneSettingsApp(app: AppLike) {
195
+ return app.pluginSettingsManager?.getRouteName?.('') === 'settings.';
196
+ }
197
+
198
+ function getSettingsRootPublicPath(app: AppLike) {
199
+ const publicPath = normalizePublicPath(app.getPublicPath());
200
+ if (isStandaloneSettingsApp(app)) {
201
+ return publicPath;
202
+ }
203
+
204
+ const segments = trimTrailingSlashes(publicPath).split('/');
205
+ segments.pop();
206
+ return normalizePublicPath(segments.join('/') || '/');
207
+ }
208
+
209
+ function getSettingsAppScope(app: AppLike): SettingsAppScope {
210
+ const publicPath = app.getPublicPath();
211
+ return (
212
+ resolveSettingsAppScopeWithinPublicPath(publicPath, app.router?.getBasename?.()) ||
213
+ (app.name && app.name !== 'main' ? resolveSettingsAppScope(`/apps/${app.name}`) : '')
214
+ );
215
+ }
216
+
217
+ function getStandaloneSettingsBasePath(app: AppLike) {
218
+ return resolveSettingsDocumentPath(getSettingsRootPublicPath(app), getSettingsAppScope(app), '/settings');
219
+ }
220
+
221
+ function isStandaloneSettingsTarget(app: AppLike, pathname: string) {
222
+ const normalizedPathname = normalizePathname(pathname);
223
+ const settingsBasePath = normalizePathname(getStandaloneSettingsBasePath(app));
224
+ return normalizedPathname === settingsBasePath || normalizedPathname.startsWith(`${settingsBasePath}/`);
225
+ }
226
+
227
+ function isSettingsTargetForAnotherApp(app: AppLike, pathname: string) {
228
+ const normalizedPathname = normalizePathname(pathname);
229
+ const rootPublicPath = trimTrailingSlashes(getSettingsRootPublicPath(app)) || '';
230
+ const pathWithinRoot =
231
+ rootPublicPath && normalizedPathname.startsWith(`${rootPublicPath}/`)
232
+ ? normalizedPathname.slice(rootPublicPath.length)
233
+ : normalizedPathname;
234
+ return /^\/settings(?:\/|$)/.test(pathWithinRoot);
235
+ }
236
+
182
237
  function getV2SigninPath(app: AppLike) {
238
+ if (isStandaloneSettingsApp(app)) {
239
+ return joinRootRelativePath(getStandaloneSettingsBasePath(app), '/signin');
240
+ }
183
241
  return joinRootRelativePath(getV2EffectiveBasePath(app), '/signin');
184
242
  }
185
243
 
@@ -204,7 +262,7 @@ function getDefaultV2AdminRedirectPath(app: AppLike) {
204
262
  return joinRootRelativePath(getV2EffectiveBasePath(app), '/admin');
205
263
  }
206
264
 
207
- function isSafeRootRelativePath(value?: string | null) {
265
+ function isSafeRootRelativePath(value?: string | null): value is string {
208
266
  return !!value && value.startsWith('/') && !value.startsWith('//') && !value.startsWith('/\\');
209
267
  }
210
268
 
@@ -224,6 +282,10 @@ export function normalizeV2RedirectPath(app: AppLike, target?: string | null, fa
224
282
  let { pathname, search, hash } = splitPathLike(rawTarget);
225
283
  let normalizedPathname = normalizePathname(pathname);
226
284
 
285
+ if (isStandaloneSettingsTarget(app, normalizedPathname)) {
286
+ return `${preserveTrailingSlash(pathname, normalizedPathname)}${normalizeSearch(search)}${normalizeHash(hash)}`;
287
+ }
288
+
227
289
  // Already under the current v2 runtime, e.g. `/v/apps/a/admin/`.
228
290
  if (basePath === '/' || normalizedPathname === basePath || normalizedPathname.startsWith(`${basePath}/`)) {
229
291
  return `${preserveTrailingSlash(pathname, normalizedPathname)}${normalizeSearch(search)}${normalizeHash(hash)}`;
@@ -237,6 +299,11 @@ export function normalizeV2RedirectPath(app: AppLike, target?: string | null, fa
237
299
  normalizedPathname = normalizePathname(pathname);
238
300
  }
239
301
 
302
+ if (isSettingsTargetForAnotherApp(app, normalizedPathname)) {
303
+ ({ pathname, search, hash } = splitPathLike(fallbackTarget));
304
+ normalizedPathname = normalizePathname(pathname);
305
+ }
306
+
240
307
  // Basename-relative target, e.g. `/admin/`, becomes `/v/apps/a/admin/`.
241
308
  const joinedPathname = preserveTrailingSlash(pathname, joinRootRelativePath(basePath, normalizedPathname));
242
309
  return `${joinedPathname}${normalizeSearch(search)}${normalizeHash(hash)}`;
@@ -306,7 +373,9 @@ export function resolveV2SigninRedirect(value: string | undefined | null, app: A
306
373
  return null;
307
374
  }
308
375
 
309
- const validPathnames = new Set(['/signin', getV2SigninPath(app)]);
376
+ const validPathnames = new Set(
377
+ isStandaloneSettingsApp(app) ? [getV2SigninPath(app)] : ['/signin', getV2SigninPath(app)],
378
+ );
310
379
  if (!validPathnames.has(url.pathname)) {
311
380
  return null;
312
381
  }
@@ -115,6 +115,7 @@ export abstract class CollectionFieldInterface {
115
115
  titleUsable?: boolean;
116
116
  usePathOptions?(field: any): any;
117
117
  hidden?: boolean;
118
+ creatable?: boolean;
118
119
 
119
120
  addComponentOption(componentOption: CollectionFieldInterfaceComponentOption) {
120
121
  if (!this.componentOptions) {
@@ -31,6 +31,7 @@ interface FieldInterfaceLike {
31
31
  title?: ReactNode;
32
32
  group?: string;
33
33
  order?: number;
34
+ creatable?: boolean;
34
35
  isAssociation?: boolean;
35
36
  supportDataSourceType?: string[];
36
37
  notSupportDataSourceType?: string[];
@@ -132,7 +132,7 @@ fieldValidationConfigureRegistry.registerMany([
132
132
  { key: 'uri', label: 'URI', hasValue: false, params: [], paramsType: 'object' },
133
133
  {
134
134
  key: 'multiple',
135
- label: 'Multiple',
135
+ label: 'Multiple of',
136
136
  hasValue: true,
137
137
  params: [{ key: 'base', label: 'Base', componentType: 'inputNumber', required: true }],
138
138
  },
@@ -0,0 +1,275 @@
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 { LoadingOutlined, PlusOutlined } from '@ant-design/icons';
11
+ import { css, cx } from '@emotion/css';
12
+ import { Upload, message } from 'antd';
13
+ import type { UploadFile } from 'antd';
14
+ import mime from 'mime';
15
+ import match from 'mime-match';
16
+ import type { RcFile, UploadRequestOption } from 'rc-upload/lib/interface';
17
+ import React, { useCallback, useEffect, useMemo, useState } from 'react';
18
+ import { useTranslation } from 'react-i18next';
19
+ import { useApp } from '../hooks/useApp';
20
+
21
+ export type UploadedAttachment = {
22
+ id?: number;
23
+ title?: string;
24
+ filename?: string;
25
+ url?: string;
26
+ };
27
+
28
+ export type AttachmentStorageRules = {
29
+ size?: number;
30
+ mimetype?: string | string[];
31
+ };
32
+
33
+ /**
34
+ * 读取默认附件存储的上传限制。
35
+ *
36
+ * 校验交给存储自身的配置(大小、mimetype),而不是在每个调用点各写一套硬编码规则。
37
+ *
38
+ * @returns {AttachmentStorageRules | null} 存储规则,取不到时为 null
39
+ */
40
+ export function useAttachmentStorageRules() {
41
+ const app = useApp();
42
+ const [storageRules, setStorageRules] = useState<AttachmentStorageRules | null>(null);
43
+
44
+ useEffect(() => {
45
+ let mounted = true;
46
+
47
+ const loadStorageRules = async () => {
48
+ try {
49
+ const attachmentsCollection = app.dataSourceManager?.getCollection?.('main', 'attachments');
50
+ const storageName = attachmentsCollection?.getOption?.('storage');
51
+
52
+ if (!storageName) {
53
+ if (mounted) {
54
+ setStorageRules(null);
55
+ }
56
+ return;
57
+ }
58
+
59
+ const response = await app.apiClient.request({
60
+ url: `storages:getBasicInfo/${storageName}`,
61
+ skipNotify: true,
62
+ });
63
+
64
+ if (mounted) {
65
+ setStorageRules(response?.data?.data?.rules || null);
66
+ }
67
+ } catch (error) {
68
+ if (mounted) {
69
+ setStorageRules(null);
70
+ }
71
+ }
72
+ };
73
+
74
+ loadStorageRules().catch(() => {
75
+ if (mounted) {
76
+ setStorageRules(null);
77
+ }
78
+ });
79
+
80
+ return () => {
81
+ mounted = false;
82
+ };
83
+ }, [app]);
84
+
85
+ return storageRules;
86
+ }
87
+
88
+ export type AttachmentUploadProps = {
89
+ value?: UploadedAttachment | null;
90
+ onChange?: (value: UploadedAttachment | null) => void;
91
+ /** 卡片尺寸;`fit` 对应 CSS object-fit */
92
+ preview?: {
93
+ width?: number;
94
+ height?: number;
95
+ fit?: 'contain' | 'cover';
96
+ };
97
+ /** 在存储规则之外再收窄可选文件类型,例如只收图片 */
98
+ accept?: string;
99
+ /** 上传卡片里 `+` 下方的文字 */
100
+ uploadText?: string;
101
+ disabled?: boolean;
102
+ };
103
+
104
+ const DEFAULT_PREVIEW = { width: 104, height: 104, fit: 'contain' as const };
105
+
106
+ /**
107
+ * 附件上传控件。
108
+ *
109
+ * 受控组件,值就是 `attachments:create` 返回的附件对象,可直接挂在 `Form.Item` 上。
110
+ * 外观沿用附件字段的原生形态:一张 picture-card,删除在卡片自身的悬浮操作里,不额外摆按钮。
111
+ * 上传前按默认存储的规则校验大小与 mimetype,与业务表单里的附件字段保持同一套限制。
112
+ */
113
+ export const AttachmentUpload: React.FC<AttachmentUploadProps> = (props) => {
114
+ const { value, onChange, preview, accept, uploadText, disabled } = props;
115
+ const app = useApp();
116
+ const { t } = useTranslation();
117
+ const [uploading, setUploading] = useState(false);
118
+ const storageRules = useAttachmentStorageRules();
119
+ const previewConfig = { ...DEFAULT_PREVIEW, ...preview };
120
+
121
+ const acceptValue = useMemo(() => {
122
+ if (accept) {
123
+ return accept;
124
+ }
125
+ const rule = storageRules?.mimetype;
126
+ if (!rule) {
127
+ return undefined;
128
+ }
129
+ return Array.isArray(rule) ? rule.join(',') : rule;
130
+ }, [accept, storageRules?.mimetype]);
131
+
132
+ const beforeUpload = useCallback(
133
+ async (file: RcFile) => {
134
+ const sizeLimit = storageRules?.size;
135
+
136
+ if (typeof sizeLimit === 'number' && sizeLimit > 0 && file.size > sizeLimit) {
137
+ message.error(t('File size exceeds the limit'));
138
+ return Upload.LIST_IGNORE;
139
+ }
140
+
141
+ const mimetypeRule = storageRules?.mimetype;
142
+ const normalizedRule = Array.isArray(mimetypeRule)
143
+ ? mimetypeRule.join(',').trim()
144
+ : `${mimetypeRule || ''}`.trim();
145
+ const acceptRule = accept?.trim();
146
+ const rules = [normalizedRule, acceptRule].filter((rule) => rule && rule !== '*');
147
+
148
+ if (!rules.length) {
149
+ return file;
150
+ }
151
+
152
+ // 有些浏览器对不常见扩展名给不出 type,这里按扩展名补一次再判断。
153
+ let targetFile = file;
154
+ if (!targetFile.type) {
155
+ const extname = targetFile.name?.match(/\.[^.]+$/)?.[0];
156
+ if (extname) {
157
+ targetFile = new File([targetFile], targetFile.name, {
158
+ type: mime.getType(extname) || 'application/octet-stream',
159
+ lastModified: targetFile.lastModified,
160
+ }) as RcFile;
161
+ }
162
+ }
163
+
164
+ const isAllowed = rules.every((rule) =>
165
+ rule
166
+ .split(',')
167
+ .filter(Boolean)
168
+ .some((item) => match(targetFile.type)(item.trim())),
169
+ );
170
+
171
+ if (!isAllowed) {
172
+ message.error(t('File type is not allowed'));
173
+ return Upload.LIST_IGNORE;
174
+ }
175
+
176
+ return targetFile;
177
+ },
178
+ [accept, storageRules?.mimetype, storageRules?.size, t],
179
+ );
180
+
181
+ const handleUpload = useCallback(
182
+ async (options: UploadRequestOption) => {
183
+ const { file, onError, onSuccess } = options;
184
+ setUploading(true);
185
+
186
+ try {
187
+ const formData = new FormData();
188
+ formData.append('file', file as RcFile);
189
+
190
+ const response = await app.apiClient.request({
191
+ url: 'attachments:create',
192
+ method: 'post',
193
+ data: formData,
194
+ });
195
+
196
+ onChange?.(response?.data?.data || null);
197
+ onSuccess?.(response?.data, file as RcFile);
198
+ } catch (error) {
199
+ onError?.(error as Error);
200
+ } finally {
201
+ setUploading(false);
202
+ }
203
+ },
204
+ [app.apiClient, onChange],
205
+ );
206
+
207
+ const fileList = useMemo<UploadFile[]>(() => {
208
+ if (!value?.url) {
209
+ return [];
210
+ }
211
+
212
+ return [
213
+ {
214
+ uid: `${value.id ?? value.url}`,
215
+ name: value.title || value.filename || '',
216
+ status: 'done',
217
+ url: value.url,
218
+ thumbUrl: value.url,
219
+ },
220
+ ];
221
+ }, [value?.filename, value?.id, value?.title, value?.url]);
222
+
223
+ const handlePreview = useCallback((file: UploadFile) => {
224
+ if (file.url) {
225
+ window.open(file.url, '_blank', 'noopener,noreferrer');
226
+ }
227
+ }, []);
228
+
229
+ const handleRemove = useCallback(() => {
230
+ onChange?.(null);
231
+ return true;
232
+ }, [onChange]);
233
+
234
+ // picture-card 的卡片尺寸写死在 104×104,这里按调用方要的比例覆盖掉。
235
+ const sizeClassName = useMemo(
236
+ () => css`
237
+ .ant-upload.ant-upload-select,
238
+ .ant-upload-list-item-container {
239
+ width: ${previewConfig.width}px !important;
240
+ height: ${previewConfig.height}px !important;
241
+ }
242
+
243
+ .ant-upload-list-item-thumbnail img {
244
+ object-fit: ${previewConfig.fit} !important;
245
+ }
246
+ `,
247
+ [previewConfig.fit, previewConfig.height, previewConfig.width],
248
+ );
249
+
250
+ return (
251
+ <Upload
252
+ accept={acceptValue}
253
+ beforeUpload={beforeUpload}
254
+ className={cx('nb-attachment-upload', sizeClassName)}
255
+ customRequest={handleUpload}
256
+ // 上传期间一并禁用:否则可以在第一个请求返回前再选一个文件,
257
+ // 两个请求谁后返回谁覆盖 onChange,最终留下的不一定是用户最后选的那张。
258
+ disabled={disabled || uploading}
259
+ fileList={fileList}
260
+ listType="picture-card"
261
+ maxCount={1}
262
+ onPreview={handlePreview}
263
+ onRemove={handleRemove}
264
+ >
265
+ {fileList.length ? null : (
266
+ <div>
267
+ {uploading ? <LoadingOutlined /> : <PlusOutlined />}
268
+ <div style={{ marginTop: 8 }}>{uploadText || t('Upload')}</div>
269
+ </div>
270
+ )}
271
+ </Upload>
272
+ );
273
+ };
274
+
275
+ export default AttachmentUpload;
@@ -190,6 +190,10 @@ import { TypedVariableInput } from '@nocobase/client-v2';
190
190
  <Form.Item name={['options', 'secure']} label={t('Secure')} initialValue={true}>
191
191
  <TypedVariableInput types={['boolean']} namespaces={['$env']} />
192
192
  </Form.Item>
193
+
194
+ // Inject a custom variable tree (e.g. a workflow node's upstream outputs,
195
+ // which are not in the global registry)
196
+ <TypedVariableInput types={['string', 'number']} metaTree={workflowMetaTree} />
193
197
  ```
194
198
 
195
199
  Key props:
@@ -197,6 +201,7 @@ Key props:
197
201
  - `types`: allowed constant types. Shape mirrors v1 `useTypedConstant` — pass bare type names (`['number', 'boolean']`) or `[type, editorProps]` tuples (`[['number', { min, max, step }]]`) to forward props to the underlying antd editor. Defaults to `['string', 'number', 'boolean', 'date']`. **Even when only one type is allowed, the `Constant` entry still expands into a typed submenu** (Number / Boolean / Date / String) — matches v1 so users can see what type the constant is
198
202
  - `namespaces`: restrict the variable picker to specific top-level namespaces (e.g. `['$env']`). Omit to expose every namespace registered on `flowEngine.context`
199
203
  - `extraNodes`: static leaves appended after the namespace-filtered nodes
204
+ - `metaTree`: **inject the variable tree directly** instead of reading the global `flowEngine.context` meta tree. When set, `namespaces`/`extraNodes` are ignored and this tree is used verbatim — for context-scoped variable sources that are not in the global registry (typically a workflow node's upstream outputs, `$jobsMapByNodeKey`). Nodes whose `children` is a thunk (`() => Promise<MetaTreeNode[]>`) are **lazy-loaded on expand** (via flow-engine's `loadMetaTreeChildren`)
200
205
  - `nullable`: whether to expose the `Null` switcher entry. Default `true`. Combined with `Form.Item.rules={[{ required: true }]}`, the user can explicitly clear the field but submission is still blocked by validation — mirrors v1's "Null + required" pairing
201
206
  - `delimiters`: variable-token delimiters, default `['{{', '}}']` — same as `VariableInput`
202
207
  - `value` / `onChange` / `placeholder` / `disabled` / `style` / `className`: standard controlled-input props
@@ -212,9 +217,10 @@ When **not** to use it:
212
217
  - **Pure literal fields** (users will never pass a variable) → use the antd primitive directly (`InputNumber` / `Select` / `DatePicker` / `Input`) and skip the Cascader column overhead
213
218
  - **Pure variable fields** (users will never pass a literal) → use `EnvVariableInput` (`$env`-only, with optional password masking) or `VariableInput` (general-purpose)
214
219
 
220
+ Supported constant types: `string` / `number` / `boolean` / `date` / `object`. The `object` (JSON) type renders an inline monospace textarea (2 rows by default, drag-resizable) — it keeps the raw text as a draft while editing and `JSON.parse`s it back into an object on blur. On a parse failure it shows the raw `JSON.parse` message (e.g. `Expected property name or '}' in JSON at position …`, matching v1) on its own row below the input, and does not emit. Mirrors v1 `useTypedConstant`'s object form (default value `{}`).
221
+
215
222
  Capabilities skipped (present in v1, not yet ported to v2):
216
223
 
217
- - `object` constant type (JSON editor) — v2 has no inline "JSON editor + Cascader switcher" yet; add when there's a concrete caller
218
224
  - Async `loadChildren` cascading — most MetaTree namespaces are already eagerly resolved by `useFilteredMetaTree`, so this hasn't been needed
219
225
 
220
226
  #### FileSizeInput