@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.
- package/es/APIClient.d.ts +1 -3
- package/es/BaseApplication.d.ts +1 -0
- package/es/acl/ACLProvider.d.ts +10 -0
- package/es/authRedirect.d.ts +5 -0
- package/es/collection-field-interface/CollectionFieldInterface.d.ts +1 -0
- package/es/collection-field-interface/CollectionFieldInterfaceManager.d.ts +1 -0
- package/es/components/AttachmentUpload.d.ts +51 -0
- package/es/components/category-tabs/SortableCategoryTabs.d.ts +55 -0
- package/es/components/category-tabs/index.d.ts +9 -0
- package/es/components/form/ScanInput/useCodeScanner.d.ts +3 -2
- package/es/components/form/TypedVariableInput.d.ts +31 -5
- package/es/components/form/filter/CollectionFilter.d.ts +2 -0
- package/es/components/form/filter/CollectionFilterItem.d.ts +11 -1
- package/es/components/form/filter/CollectionFilterPanel.d.ts +2 -0
- package/es/components/form/filter/index.d.ts +2 -0
- package/es/components/form/filter/useFilterActionProps.d.ts +2 -0
- package/es/components/index.d.ts +2 -0
- package/es/flow/actions/index.d.ts +1 -1
- package/es/flow/actions/linkageRules.d.ts +3 -0
- package/es/flow/admin-shell/admin-layout/AdminLayoutMenuModels.d.ts +1 -0
- package/es/flow/admin-shell/admin-layout/NocoBaseLogo.d.ts +10 -0
- package/es/flow/components/FieldAssignExactDatePicker.d.ts +1 -0
- package/es/flow/components/FieldAssignValueInput.d.ts +9 -0
- package/es/flow/components/FlowRoute.d.ts +2 -2
- package/es/flow/components/RunJSValueEditor.d.ts +1 -0
- package/es/flow/components/filter/FilterGroup.d.ts +1 -0
- package/es/flow/components/filter/VariableFilterItem.d.ts +6 -1
- package/es/flow/index.d.ts +1 -0
- package/es/flow/internal/registerDeviceTypeContext.d.ts +10 -0
- package/es/flow/models/base/ActionModelCore.d.ts +4 -2
- package/es/flow/models/base/BlockGridModel.d.ts +3 -0
- package/es/flow/models/base/PageModel/PageModel.d.ts +12 -0
- package/es/flow/models/base/PageModel/PageModelTabBar.d.ts +22 -0
- package/es/flow/models/base/PageModel/PageTabModel.d.ts +12 -0
- package/es/flow/models/blocks/form/QuickEditFormModel.d.ts +17 -2
- package/es/flow/models/blocks/table/TableColumnModel.d.ts +1 -0
- package/es/flow/models/fields/DateTimeFieldModel/dateLimit.d.ts +15 -7
- package/es/flow-compat/fieldValidationConstants.d.ts +1 -1
- package/es/flow-compat/routeTypes.d.ts +1 -0
- package/es/index.d.ts +8 -1
- package/es/index.mjs +190 -141
- package/es/nocobase-buildin-plugin/currentUserAuthStatus.d.ts +11 -0
- package/es/nocobase-buildin-plugin/index.d.ts +9 -0
- package/es/settings-app/SettingsApplication.d.ts +18 -0
- package/es/settings-app/SettingsBrand.d.ts +16 -0
- package/es/settings-app/SettingsBuildInPlugin.d.ts +9 -0
- package/es/settings-app/SettingsDocumentRedirect.d.ts +10 -0
- package/es/settings-app/SettingsGroupNav.d.ts +17 -0
- package/es/settings-app/SettingsPluginSettingsManager.d.ts +14 -0
- package/es/settings-app/SettingsRouterManager.d.ts +15 -0
- package/es/settings-app/SettingsSearch.d.ts +17 -0
- package/es/settings-app/SettingsShell.d.ts +10 -0
- package/es/settings-app/isSettingsApp.d.ts +18 -0
- package/es/settings-app/runtimePaths.d.ts +18 -0
- package/es/settings-app/settingsDocumentPath.d.ts +14 -0
- package/es/settings-app/settingsTheme.d.ts +87 -0
- package/es/settings-app/useSettingsThemeConfig.d.ts +22 -0
- package/es/settings-center/AdminSettingsLayout.d.ts +2 -2
- package/es/settings-center/groups.d.ts +66 -0
- package/es/settings-center/useSettingsGroups.d.ts +36 -0
- package/es/settings-center/useSettingsSearch.d.ts +35 -0
- package/es/settings-center/utils.d.ts +24 -0
- package/es/theme/index.d.ts +1 -0
- package/es/theme/itemActive.d.ts +24 -0
- package/es/theme/type.d.ts +9 -0
- package/es/utils/getRouteRuntimeVersion.d.ts +23 -0
- package/es/utils/index.d.ts +1 -0
- package/lib/index.js +195 -146
- package/lib/locale/languageCodes.js +3 -1
- package/package.json +9 -7
- package/src/APIClient.ts +1 -10
- package/src/Application.tsx +4 -0
- package/src/BaseApplication.tsx +11 -6
- package/src/__tests__/app.test.tsx +73 -0
- package/src/__tests__/authRedirect.test.ts +137 -0
- package/src/__tests__/browserChecker.test.ts +185 -6
- package/src/__tests__/getRouteRuntimeVersion.test.ts +68 -0
- package/src/__tests__/mockSettingsApplication.ts +29 -0
- package/src/__tests__/nocobase-buildin-plugin-auth.test.tsx +79 -10
- package/src/__tests__/plugin-manager.test.tsx +7 -6
- package/src/__tests__/settings-application.test.ts +164 -0
- package/src/__tests__/settings-auth-routes.test.ts +71 -0
- package/src/__tests__/settings-center.test.tsx +38 -37
- package/src/__tests__/settings-layout-root.test.tsx +260 -0
- package/src/__tests__/settings-runtime-paths.test.ts +99 -0
- package/src/__tests__/settings-shell.test.tsx +200 -0
- package/src/acl/ACLProvider.tsx +21 -0
- package/src/authRedirect.ts +72 -3
- package/src/collection-field-interface/CollectionFieldInterface.ts +1 -0
- package/src/collection-field-interface/CollectionFieldInterfaceManager.ts +1 -0
- package/src/collection-manager/field-validation.ts +1 -1
- package/src/components/AttachmentUpload.tsx +275 -0
- package/src/components/README.md +7 -1
- package/src/components/README.zh-CN.md +6 -1
- package/src/components/category-tabs/SortableCategoryTabs.tsx +210 -0
- package/src/components/category-tabs/index.ts +10 -0
- package/src/components/form/JsonTextArea.tsx +4 -0
- package/src/components/form/ScanInput/CodeScanner.tsx +79 -35
- package/src/components/form/ScanInput/__tests__/CodeScanner.test.tsx +101 -0
- package/src/components/form/ScanInput/__tests__/useCodeScanner.test.tsx +284 -11
- package/src/components/form/ScanInput/useCodeScanner.ts +332 -24
- package/src/components/form/TypedVariableInput.tsx +452 -101
- package/src/components/form/__tests__/TypedVariableInput.test.tsx +364 -12
- package/src/components/form/filter/CollectionFilter.tsx +4 -0
- package/src/components/form/filter/CollectionFilterItem.tsx +32 -7
- package/src/components/form/filter/CollectionFilterPanel.tsx +4 -0
- package/src/components/form/filter/__tests__/CollectionFilterItem.test.tsx +38 -0
- package/src/components/form/filter/__tests__/useFilterActionProps.test.tsx +95 -0
- package/src/components/form/filter/index.ts +2 -0
- package/src/components/form/filter/useFilterActionProps.ts +37 -6
- package/src/components/index.ts +2 -0
- package/src/flow/FlowPage.tsx +9 -1
- package/src/flow/__tests__/FlowPage.test.tsx +50 -3
- package/src/flow/__tests__/FlowRoute.test.tsx +128 -10
- package/src/flow/__tests__/PluginFlowEngine.test.ts +58 -0
- package/src/flow/actions/__tests__/actionLinkageRules.forkProps.test.ts +314 -0
- package/src/flow/actions/__tests__/dataScopeFormValueClear.test.ts +102 -0
- package/src/flow/actions/__tests__/linkageRules.tab.test.ts +171 -0
- package/src/flow/actions/__tests__/linkageRulesRefresh.test.ts +7 -3
- package/src/flow/actions/index.ts +2 -0
- package/src/flow/actions/linkageRules.tsx +111 -16
- package/src/flow/actions/linkageRulesRefresh.tsx +2 -6
- package/src/flow/admin-shell/admin-layout/AdminLayoutComponent.tsx +1 -57
- package/src/flow/admin-shell/admin-layout/NocoBaseLogo.tsx +77 -0
- package/src/flow/admin-shell/admin-layout/TopbarActionsBar.tsx +37 -14
- package/src/flow/admin-shell/admin-layout/__tests__/AdminLayoutComponent.test.tsx +253 -6
- package/src/flow/admin-shell/admin-layout/__tests__/TopbarActionsBar.test.tsx +77 -13
- package/src/flow/common/Markdown/style.ts +4 -0
- package/src/flow/components/BlockItemCard.tsx +2 -2
- package/src/flow/components/FieldAssignExactDatePicker.tsx +25 -11
- package/src/flow/components/FieldAssignValueInput.tsx +91 -20
- package/src/flow/components/FlowRoute.tsx +78 -32
- package/src/flow/components/RunJSValueEditor.tsx +9 -1
- package/src/flow/components/__tests__/FieldAssignValueInput.context.test.tsx +216 -0
- package/src/flow/components/filter/FilterGroup.tsx +33 -5
- package/src/flow/components/filter/VariableFilterItem.tsx +158 -10
- package/src/flow/components/filter/__tests__/FilterGroup.test.tsx +45 -0
- package/src/flow/components/filter/__tests__/VariableFilterItem.leftMetaTree.test.tsx +9 -0
- package/src/flow/components/filter/__tests__/VariableFilterItem.test.tsx +70 -1
- package/src/flow/index.ts +3 -0
- package/src/flow/internal/registerDeviceTypeContext.ts +39 -0
- package/src/flow/models/base/ActionModelCore.tsx +11 -2
- package/src/flow/models/base/BlockGridModel.tsx +26 -0
- package/src/flow/models/base/CollectionBlockModel.tsx +6 -2
- package/src/flow/models/base/PageModel/PageModel.tsx +387 -53
- package/src/flow/models/base/PageModel/PageModelTabBar.tsx +114 -0
- package/src/flow/models/base/PageModel/PageTabModel.tsx +249 -18
- package/src/flow/models/base/PageModel/__tests__/PageModel.test.ts +790 -10
- package/src/flow/models/base/PageModel/__tests__/PageModelTabBar.module-isolation.test.tsx +130 -0
- package/src/flow/models/base/PageModel/__tests__/PageModelTabBar.test.tsx +118 -0
- package/src/flow/models/base/PageModel/__tests__/PageTabModel.test.ts +1075 -7
- package/src/flow/models/base/__tests__/ActionModelCore.render.test.tsx +49 -0
- package/src/flow/models/base/__tests__/BlockGridModel.selectSceneActivation.test.ts +124 -0
- package/src/flow/models/base/__tests__/CollectionBlockModel.addAppends.test.ts +41 -0
- package/src/flow/models/blocks/filter-form/FilterFormBlockModel.tsx +28 -3
- package/src/flow/models/blocks/filter-form/FilterFormItemModel.tsx +27 -12
- package/src/flow/models/blocks/filter-form/__tests__/FilterFormItemModel.defineChildren.test.ts +36 -0
- package/src/flow/models/blocks/filter-form/__tests__/defaultValues.wiring.test.ts +66 -4
- package/src/flow/models/blocks/form/QuickEditFormModel.tsx +189 -36
- package/src/flow/models/blocks/form/__tests__/QuickEditFormModel.quickEdit.test.ts +350 -2
- package/src/flow/models/blocks/js-block/JSBlock.tsx +1 -1
- package/src/flow/models/blocks/table/TableActionsColumnModel.tsx +2 -1
- package/src/flow/models/blocks/table/TableBlockModel.tsx +4 -2
- package/src/flow/models/blocks/table/TableColumnModel.tsx +30 -1
- package/src/flow/models/blocks/table/__tests__/TableBlockModel.quickEditRefresh.test.ts +12 -0
- package/src/flow/models/blocks/table/__tests__/TableColumnModel.test.tsx +33 -0
- package/src/flow/models/fields/AssociationFieldModel/PopupSubTableFieldModel/PopupSubTableFieldModel.tsx +2 -1
- package/src/flow/models/fields/DateTimeFieldModel/__tests__/DateTimeNoTzFieldModel.dateLimit.test.tsx +149 -0
- package/src/flow/models/fields/DateTimeFieldModel/dateLimit.ts +149 -113
- package/src/flow/models/fields/JsonFieldModel.tsx +31 -8
- package/src/flow/models/fields/VariableFieldFormModel.tsx +3 -3
- package/src/flow/models/fields/__tests__/JsonFieldModel.test.ts +82 -0
- package/src/flow/models/fields/__tests__/VariableFieldFormModel.test.tsx +51 -0
- package/src/flow/models/fields/mobile-components/MobileLazySelect.tsx +20 -10
- package/src/flow/models/fields/mobile-components/MobileSelect.tsx +24 -12
- package/src/flow/models/topbar/TopbarActionModel.tsx +52 -180
- package/src/flow/utils/dataScopeFormValueClear.ts +4 -3
- package/src/flow-compat/fieldValidationConstants.ts +1 -1
- package/src/flow-compat/routeTypes.ts +1 -0
- package/src/index.ts +14 -1
- package/src/locale/languageCodes.ts +3 -1
- package/src/nocobase-buildin-plugin/currentUserAuthStatus.ts +58 -0
- package/src/nocobase-buildin-plugin/index.tsx +110 -70
- package/src/settings-app/SettingsApplication.ts +31 -0
- package/src/settings-app/SettingsBrand.tsx +108 -0
- package/src/settings-app/SettingsBuildInPlugin.ts +10 -0
- package/src/settings-app/SettingsDocumentRedirect.tsx +26 -0
- package/src/settings-app/SettingsGroupNav.tsx +124 -0
- package/src/settings-app/SettingsPluginSettingsManager.ts +38 -0
- package/src/settings-app/SettingsRouterManager.ts +92 -0
- package/src/settings-app/SettingsSearch.tsx +238 -0
- package/src/settings-app/SettingsShell.tsx +167 -0
- package/src/settings-app/isSettingsApp.ts +21 -0
- package/src/settings-app/runtimePaths.ts +104 -0
- package/src/settings-app/settingsDocumentPath.ts +66 -0
- package/src/settings-app/settingsTheme.ts +301 -0
- package/src/settings-app/useSettingsThemeConfig.ts +100 -0
- package/src/settings-center/AdminSettingsLayout.tsx +122 -141
- package/src/settings-center/SystemSettingsPage.tsx +4 -170
- package/src/settings-center/groups.ts +108 -0
- package/src/settings-center/useSettingsGroups.ts +105 -0
- package/src/settings-center/useSettingsSearch.ts +173 -0
- package/src/settings-center/utils.tsx +50 -1
- package/src/theme/index.tsx +1 -0
- package/src/theme/itemActive.ts +31 -0
- package/src/theme/type.ts +10 -0
- package/src/utils/getRouteRuntimeVersion.ts +185 -0
- 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
|
+
});
|
package/src/acl/ACLProvider.tsx
CHANGED
|
@@ -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
|
*/
|
package/src/authRedirect.ts
CHANGED
|
@@ -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
|
|
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(
|
|
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) {
|
|
@@ -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;
|
package/src/components/README.md
CHANGED
|
@@ -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
|