@nocobase/app-template-default 1.0.0-beta.87 → 1.0.0-beta.89
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/.agents/rules/components.md +3 -1
- package/.agents/rules/notification-templates.md +5 -0
- package/.agents/rules/shell.md +9 -3
- package/AGENTS.md +4 -2
- package/README.MD +11 -5
- package/client/components/back-button.tsx +37 -12
- package/client/components/body-portal.tsx +25 -0
- package/client/components/brand-loading-mark.tsx +5 -2
- package/client/components/brand-mark-geometry.ts +3 -3
- package/client/components/breadcrumbs.tsx +60 -3
- package/client/components/data-table/data-table.tsx +90 -19
- package/client/components/data-table/pagination.tsx +17 -2
- package/client/components/flow-diagram.tsx +2 -2
- package/client/components/list-feedback.tsx +31 -22
- package/client/components/loading.tsx +127 -16
- package/client/components/pending-button.tsx +111 -0
- package/client/components/role-list.tsx +7 -0
- package/client/components/route-child-page.tsx +72 -1
- package/client/components/route-overlay.tsx +149 -15
- package/client/components/task-progress.tsx +155 -0
- package/client/components/use-route-overlay.ts +8 -11
- package/client/extensions/nocobase-file-component-ui/components/file-upload-field.tsx +62 -32
- package/client/extensions/nocobase-file-component-ui/locales/de-DE.ts +2 -0
- package/client/extensions/nocobase-file-component-ui/locales/en-US.ts +1 -0
- package/client/extensions/nocobase-file-component-ui/locales/es-ES.ts +1 -0
- package/client/extensions/nocobase-file-component-ui/locales/fr-FR.ts +1 -0
- package/client/extensions/nocobase-file-component-ui/locales/id-ID.ts +1 -0
- package/client/extensions/nocobase-file-component-ui/locales/ja-JP.ts +2 -0
- package/client/extensions/nocobase-file-component-ui/locales/pt-BR.ts +1 -0
- package/client/extensions/nocobase-file-component-ui/locales/vi-VN.ts +1 -0
- package/client/extensions/nocobase-file-component-ui/locales/zh-CN.ts +1 -0
- package/client/extensions/nocobase-file-component-ui/locales/zh-TW.ts +1 -0
- package/client/extensions/nocobase-workflow-management/README.md +5 -5
- package/client/extensions/nocobase-workflow-management/definition-list.tsx +2 -9
- package/client/extensions/nocobase-workflow-management/execution-list.tsx +2 -4
- package/client/layouts/app-layout.tsx +8 -8
- package/client/layouts/components/sidebar-footer.tsx +18 -25
- package/client/lib/navigation-transform.ts +156 -22
- package/client/locales/system/en-US.ts +26 -5
- package/client/locales/system/settings-en-US.ts +17 -0
- package/client/locales/system/settings-zh-CN.ts +15 -0
- package/client/locales/system/zh-CN.ts +26 -5
- package/client/pages/auth/register.tsx +39 -22
- package/client/pages/auth/shared.tsx +1 -3
- package/client/pages/settings/default-access.tsx +1 -1
- package/client/pages/settings/hooks.tsx +3 -68
- package/client/pages/settings/notification-routes.ts +109 -0
- package/client/pages/settings/notification-templates.tsx +26 -0
- package/client/pages/settings/permission-management-page.tsx +15 -90
- package/client/pages/settings/roles.tsx +27 -27
- package/client/pages/settings/routes.ts +107 -101
- package/client/pages/settings/settings-home.tsx +109 -0
- package/client/pages/settings/settings-module-page.tsx +158 -0
- package/client/pages/settings/shell-routes.ts +53 -0
- package/client/pages/settings/user-detail.tsx +3 -17
- package/client/pages/settings/user-form.tsx +9 -15
- package/client/pages/settings/users.tsx +11 -27
- package/client/routes.ts +17 -1
- package/client/routing/app-router.tsx +1 -14
- package/client/routing/client-route.tsx +199 -30
- package/client/routing/navigation-destination.ts +42 -0
- package/client/routing/route-context.tsx +30 -3
- package/client/routing/route-leave-boundary.tsx +261 -0
- package/client/routing/route-navigation.ts +72 -8
- package/client/routing/route-tree.tsx +7 -12
- package/client/runtime.ts +2 -2
- package/client/styles.css +18 -1
- package/config.example.yml +1 -1
- package/package.json +13 -13
- package/server/config/auth.ts +1 -1
- package/server/providers/index.ts +2 -0
- package/server/providers/notification-management.ts +27 -0
- package/tests/client/account-permissions.test.tsx +1 -1
- package/tests/client/auth-i18n.test.tsx +1 -1
- package/tests/client/authenticated-client-fixture.test.tsx +37 -0
- package/tests/client/back-button.test.tsx +34 -0
- package/tests/client/client-auth.test.tsx +10 -1
- package/tests/client/client-route-recovery.test.tsx +93 -1
- package/tests/client/client-routes.test.ts +137 -4
- package/tests/client/client-shell.test.tsx +23 -8
- package/tests/client/foundation-integration.test.ts +38 -17
- package/tests/client/layout-i18n.test.tsx +9 -4
- package/tests/client/list-feedback.test.tsx +5 -5
- package/tests/client/list-loading.test.tsx +397 -0
- package/tests/client/loading.test.tsx +20 -11
- package/tests/client/manual-list-retry.test.tsx +2 -2
- package/tests/client/navigation-parent.test.tsx +172 -0
- package/tests/client/pending-button.test.tsx +106 -0
- package/tests/client/permission-management-tabs.test.tsx +458 -17
- package/tests/client/primitive-labels.test.tsx +2 -2
- package/tests/client/registration-feedback.test.tsx +143 -0
- package/tests/client/role-return.test.tsx +21 -3
- package/tests/client/roles.test.tsx +84 -3
- package/tests/client/route-leave-recheck.test.tsx +74 -0
- package/tests/client/route-presentation.test.tsx +557 -0
- package/tests/client/session-preparation.test.tsx +20 -17
- package/tests/client/settings-continuity.test.tsx +35 -11
- package/tests/client/settings-home.test.tsx +112 -0
- package/tests/client/settings-idle-list.test.tsx +124 -0
- package/tests/client/settings-navigation.test.ts +230 -0
- package/tests/client/user-drawer.test.tsx +3 -4
- package/tests/fixtures/dialog-footer.html +12 -0
- package/tests/fixtures/dialog-footer.tsx +215 -0
- package/tests/helpers/authenticated-client.tsx +70 -22
- package/tests/playwright/dialog-footer.test.ts +227 -0
- package/tests/server/notification-templates.test.ts +144 -0
- package/tests/client/permission-management-routes.test.ts +0 -64
|
@@ -1,134 +1,140 @@
|
|
|
1
|
-
import {
|
|
2
|
-
Settings,
|
|
3
|
-
Users,
|
|
4
|
-
ShieldCheck,
|
|
5
|
-
KeyRound,
|
|
6
|
-
ListFilter,
|
|
7
|
-
} from 'lucide-react';
|
|
1
|
+
import { Users, ShieldCheck, KeyRound, ListFilter } from 'lucide-react';
|
|
8
2
|
import {
|
|
9
3
|
defineAppRoutes,
|
|
10
4
|
type AppClientRouteContribution,
|
|
11
5
|
} from '@nocobase/app-client/plugins';
|
|
12
6
|
|
|
13
|
-
/**
|
|
7
|
+
/**
|
|
8
|
+
* Users and Permission management, in the Accounts and permissions category of the System settings shell
|
|
9
|
+
* (`shell-routes.ts`). Register this contribution as business capabilities are developed; a bare application stays
|
|
10
|
+
* without them.
|
|
11
|
+
*/
|
|
14
12
|
const systemSettingsRoutes: AppClientRouteContribution = defineAppRoutes([
|
|
15
13
|
{
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
14
|
+
parent: 'settingsAccount',
|
|
15
|
+
name: 'settingsUsers',
|
|
16
|
+
path: '/users',
|
|
17
|
+
auth: 'required',
|
|
18
|
+
authz: { resource: { type: 'user', id: '*' }, action: 'read' },
|
|
19
|
+
navigation: {
|
|
20
|
+
title: 'settings.users',
|
|
21
|
+
description: 'settings.usersDescription',
|
|
22
|
+
icon: Users,
|
|
23
|
+
},
|
|
24
|
+
breadcrumb: { title: 'settings.users' },
|
|
25
|
+
componentLoader: () => import('./users.js'),
|
|
19
26
|
children: [
|
|
27
|
+
// The forms declare their presentation, so the dialog or drawer opens at once and the wait for the form's module,
|
|
28
|
+
// its authorization and its data stays inside it instead of under the list.
|
|
29
|
+
{
|
|
30
|
+
name: 'settingsUserCreate',
|
|
31
|
+
path: 'create',
|
|
32
|
+
authz: { resource: { type: 'user', id: '*' }, action: 'create' },
|
|
33
|
+
presentation: { kind: 'dialog', title: 'settings.newUser' },
|
|
34
|
+
componentLoader: () => import('./user-form.js'),
|
|
35
|
+
},
|
|
36
|
+
// A sibling rather than the parent of the editor, so closing the drawer returns to the list instead of to this
|
|
37
|
+
// route, which only forwards older detail links to the editor.
|
|
38
|
+
{
|
|
39
|
+
name: 'settingsUser',
|
|
40
|
+
path: ':userId',
|
|
41
|
+
componentLoader: () => import('./user-detail.js'),
|
|
42
|
+
},
|
|
20
43
|
{
|
|
21
|
-
name: '
|
|
22
|
-
path: '/
|
|
44
|
+
name: 'settingsUserEdit',
|
|
45
|
+
path: ':userId/edit',
|
|
46
|
+
presentation: { kind: 'drawer', title: 'settings.editUser' },
|
|
47
|
+
componentLoader: () => import('./user-form.js'),
|
|
48
|
+
},
|
|
49
|
+
],
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
parent: 'settingsAccount',
|
|
53
|
+
name: 'permissionManagement',
|
|
54
|
+
navigation: {
|
|
55
|
+
title: 'settings.permissionManagement',
|
|
56
|
+
description: 'settings.permissionManagementDescription',
|
|
57
|
+
icon: ShieldCheck,
|
|
58
|
+
},
|
|
59
|
+
breadcrumb: { title: 'settings.permissionManagement' },
|
|
60
|
+
children: [
|
|
61
|
+
{
|
|
62
|
+
name: 'settingsRoles',
|
|
63
|
+
path: '/roles',
|
|
23
64
|
auth: 'required',
|
|
24
|
-
authz: {
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
65
|
+
authz: {
|
|
66
|
+
resource: {
|
|
67
|
+
type: 'settings',
|
|
68
|
+
id: 'authorization.permission-sets',
|
|
69
|
+
},
|
|
70
|
+
action: 'read',
|
|
71
|
+
},
|
|
72
|
+
navigation: { title: 'settings.roles', icon: KeyRound },
|
|
73
|
+
breadcrumb: { title: 'settings.roles' },
|
|
74
|
+
componentLoader: () => import('./roles.js'),
|
|
28
75
|
children: [
|
|
29
76
|
{
|
|
30
|
-
name: '
|
|
77
|
+
name: 'settingsRoleCreate',
|
|
31
78
|
path: 'create',
|
|
32
|
-
authz: {
|
|
33
|
-
|
|
79
|
+
authz: {
|
|
80
|
+
resource: {
|
|
81
|
+
type: 'settings',
|
|
82
|
+
id: 'authorization.permission-sets',
|
|
83
|
+
},
|
|
84
|
+
action: 'create',
|
|
85
|
+
},
|
|
86
|
+
breadcrumb: { title: 'settings.newRole' },
|
|
87
|
+
presentation: { kind: 'child-page' },
|
|
88
|
+
componentLoader: () => import('./role-form.js'),
|
|
34
89
|
},
|
|
35
90
|
{
|
|
36
|
-
name: '
|
|
37
|
-
path: ':
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
name: 'settingsUserEdit',
|
|
42
|
-
path: 'edit',
|
|
43
|
-
componentLoader: () => import('./user-form.js'),
|
|
44
|
-
},
|
|
45
|
-
],
|
|
91
|
+
name: 'settingsRoleEdit',
|
|
92
|
+
path: ':key/edit',
|
|
93
|
+
breadcrumb: { title: 'settings.editRole' },
|
|
94
|
+
presentation: { kind: 'child-page' },
|
|
95
|
+
componentLoader: () => import('./role-form.js'),
|
|
46
96
|
},
|
|
47
97
|
],
|
|
48
98
|
},
|
|
49
99
|
{
|
|
50
|
-
name: '
|
|
100
|
+
name: 'settingsDefaultAccess',
|
|
101
|
+
path: '/default-access',
|
|
102
|
+
auth: 'required',
|
|
103
|
+
authz: {
|
|
104
|
+
resource: {
|
|
105
|
+
type: 'settings',
|
|
106
|
+
id: 'authorization.default-access',
|
|
107
|
+
},
|
|
108
|
+
action: 'read',
|
|
109
|
+
},
|
|
51
110
|
navigation: {
|
|
52
|
-
title: 'settings.
|
|
53
|
-
icon:
|
|
111
|
+
title: 'settings.defaultAccess.title',
|
|
112
|
+
icon: ListFilter,
|
|
54
113
|
},
|
|
55
|
-
breadcrumb: { title: 'settings.
|
|
114
|
+
breadcrumb: { title: 'settings.defaultAccess.title' },
|
|
115
|
+
componentLoader: () => import('./default-access.js'),
|
|
56
116
|
children: [
|
|
57
117
|
{
|
|
58
|
-
name: '
|
|
59
|
-
path: '
|
|
60
|
-
auth: 'required',
|
|
118
|
+
name: 'settingsDefaultAccessCreate',
|
|
119
|
+
path: 'create',
|
|
61
120
|
authz: {
|
|
62
121
|
resource: {
|
|
63
122
|
type: 'settings',
|
|
64
|
-
id: 'authorization.
|
|
123
|
+
id: 'authorization.default-access',
|
|
65
124
|
},
|
|
66
|
-
action: '
|
|
125
|
+
action: 'create',
|
|
67
126
|
},
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
componentLoader: () => import('./
|
|
71
|
-
children: [
|
|
72
|
-
{
|
|
73
|
-
name: 'settingsRoleCreate',
|
|
74
|
-
path: 'create',
|
|
75
|
-
authz: {
|
|
76
|
-
resource: {
|
|
77
|
-
type: 'settings',
|
|
78
|
-
id: 'authorization.permission-sets',
|
|
79
|
-
},
|
|
80
|
-
action: 'create',
|
|
81
|
-
},
|
|
82
|
-
breadcrumb: { title: 'settings.newRole' },
|
|
83
|
-
componentLoader: () => import('./role-form.js'),
|
|
84
|
-
},
|
|
85
|
-
{
|
|
86
|
-
name: 'settingsRoleEdit',
|
|
87
|
-
path: ':key/edit',
|
|
88
|
-
breadcrumb: { title: 'settings.editRole' },
|
|
89
|
-
componentLoader: () => import('./role-form.js'),
|
|
90
|
-
},
|
|
91
|
-
],
|
|
127
|
+
breadcrumb: { title: 'settings.defaultAccess.new' },
|
|
128
|
+
presentation: { kind: 'child-page' },
|
|
129
|
+
componentLoader: () => import('./default-access-form.js'),
|
|
92
130
|
},
|
|
131
|
+
// Read permission opens the full rule even when the viewer may not update it.
|
|
93
132
|
{
|
|
94
|
-
name: '
|
|
95
|
-
path: '/
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
type: 'settings',
|
|
100
|
-
id: 'authorization.default-access',
|
|
101
|
-
},
|
|
102
|
-
action: 'read',
|
|
103
|
-
},
|
|
104
|
-
navigation: {
|
|
105
|
-
title: 'settings.defaultAccess.title',
|
|
106
|
-
icon: ListFilter,
|
|
107
|
-
},
|
|
108
|
-
breadcrumb: { title: 'settings.defaultAccess.title' },
|
|
109
|
-
componentLoader: () => import('./default-access.js'),
|
|
110
|
-
children: [
|
|
111
|
-
{
|
|
112
|
-
name: 'settingsDefaultAccessCreate',
|
|
113
|
-
path: 'create',
|
|
114
|
-
authz: {
|
|
115
|
-
resource: {
|
|
116
|
-
type: 'settings',
|
|
117
|
-
id: 'authorization.default-access',
|
|
118
|
-
},
|
|
119
|
-
action: 'create',
|
|
120
|
-
},
|
|
121
|
-
breadcrumb: { title: 'settings.defaultAccess.new' },
|
|
122
|
-
componentLoader: () => import('./default-access-form.js'),
|
|
123
|
-
},
|
|
124
|
-
// Read permission opens the full rule even when the viewer may not update it.
|
|
125
|
-
{
|
|
126
|
-
name: 'settingsDefaultAccessEdit',
|
|
127
|
-
path: ':key/edit',
|
|
128
|
-
breadcrumb: { title: 'settings.defaultAccess.edit' },
|
|
129
|
-
componentLoader: () => import('./default-access-form.js'),
|
|
130
|
-
},
|
|
131
|
-
],
|
|
133
|
+
name: 'settingsDefaultAccessEdit',
|
|
134
|
+
path: ':key/edit',
|
|
135
|
+
breadcrumb: { title: 'settings.defaultAccess.edit' },
|
|
136
|
+
presentation: { kind: 'child-page' },
|
|
137
|
+
componentLoader: () => import('./default-access-form.js'),
|
|
132
138
|
},
|
|
133
139
|
],
|
|
134
140
|
},
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import type { AppClientRegisteredRoute } from '@nocobase/app-client/plugins';
|
|
2
|
+
import { useTranslation } from '@nocobase/i18n/client';
|
|
3
|
+
import { ChevronRight, Settings } from 'lucide-react';
|
|
4
|
+
import { useMemo, type ReactElement } from 'react';
|
|
5
|
+
import { Link } from 'react-router';
|
|
6
|
+
|
|
7
|
+
import { Loading } from '#components/loading';
|
|
8
|
+
import { PageContainer } from '#components/page-container';
|
|
9
|
+
import { PageHeader } from '#components/page-header';
|
|
10
|
+
import { Empty, EmptyHeader, EmptyTitle } from '#components/ui/empty';
|
|
11
|
+
import type { SettingsModule } from '#lib/navigation-transform';
|
|
12
|
+
|
|
13
|
+
import { useRouteAccess, useRouteTree } from '../../routing/route-context.js';
|
|
14
|
+
import {
|
|
15
|
+
buildSettingsCatalog,
|
|
16
|
+
routeKey,
|
|
17
|
+
} from '../../routing/route-navigation.js';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The System settings home: the settings modules the viewer may open, as cards grouped by category. Everything shown
|
|
21
|
+
* comes from the registered routes and the shell's permission check, so a module appears here once it is registered
|
|
22
|
+
* under a settings category, and disappears with its permission.
|
|
23
|
+
*/
|
|
24
|
+
export default function SettingsHomePage(): ReactElement {
|
|
25
|
+
const { t } = useTranslation();
|
|
26
|
+
const routes = useRouteTree();
|
|
27
|
+
const { denied, loading } = useRouteAccess();
|
|
28
|
+
const categories = useMemo(
|
|
29
|
+
() => buildSettingsCatalog(routes, denied),
|
|
30
|
+
[routes, denied],
|
|
31
|
+
);
|
|
32
|
+
return (
|
|
33
|
+
<PageContainer className='space-y-8'>
|
|
34
|
+
<PageHeader
|
|
35
|
+
title={t('settings.title')}
|
|
36
|
+
description={t('settings.home.description')}
|
|
37
|
+
/>
|
|
38
|
+
{loading ? (
|
|
39
|
+
<Loading />
|
|
40
|
+
) : categories.length === 0 ? (
|
|
41
|
+
<Empty className='border border-dashed'>
|
|
42
|
+
<EmptyHeader>
|
|
43
|
+
<EmptyTitle>{t('settings.home.empty')}</EmptyTitle>
|
|
44
|
+
</EmptyHeader>
|
|
45
|
+
</Empty>
|
|
46
|
+
) : (
|
|
47
|
+
categories.map((category) => {
|
|
48
|
+
const title = routeText(t, category.route, 'title');
|
|
49
|
+
return (
|
|
50
|
+
<section
|
|
51
|
+
key={routeKey(category.route)}
|
|
52
|
+
aria-label={title}
|
|
53
|
+
className='space-y-3'
|
|
54
|
+
>
|
|
55
|
+
<h2 className='font-heading text-base font-semibold'>{title}</h2>
|
|
56
|
+
<ul className='grid gap-3 sm:grid-cols-2 xl:grid-cols-3'>
|
|
57
|
+
{category.modules.map((module) => (
|
|
58
|
+
<li key={routeKey(module.route)} className='min-w-0'>
|
|
59
|
+
<SettingsCard module={module} />
|
|
60
|
+
</li>
|
|
61
|
+
))}
|
|
62
|
+
</ul>
|
|
63
|
+
</section>
|
|
64
|
+
);
|
|
65
|
+
})
|
|
66
|
+
)}
|
|
67
|
+
</PageContainer>
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function SettingsCard({ module }: { module: SettingsModule }): ReactElement {
|
|
72
|
+
const { t } = useTranslation();
|
|
73
|
+
// The card's text is its accessible name: the module and what it is for.
|
|
74
|
+
const title = routeText(t, module.route, 'title');
|
|
75
|
+
const description = routeText(t, module.route, 'description');
|
|
76
|
+
const Icon = module.route.navigation?.icon ?? Settings;
|
|
77
|
+
return (
|
|
78
|
+
<Link
|
|
79
|
+
to={module.target.path}
|
|
80
|
+
className='group flex h-full items-start gap-3 rounded-lg border bg-card p-4 text-card-foreground shadow-xs transition-colors outline-none hover:border-primary/40 hover:bg-accent/40 focus-visible:ring-3 focus-visible:ring-ring/50'
|
|
81
|
+
>
|
|
82
|
+
<span className='flex size-9 shrink-0 items-center justify-center rounded-md bg-muted text-muted-foreground group-hover:text-foreground'>
|
|
83
|
+
<Icon className='size-4' />
|
|
84
|
+
</span>
|
|
85
|
+
<span className='min-w-0 flex-1'>
|
|
86
|
+
<span className='block font-medium'>{title}</span>
|
|
87
|
+
{description ? (
|
|
88
|
+
<span className='mt-1 block text-sm leading-6 text-muted-foreground'>
|
|
89
|
+
{description}
|
|
90
|
+
</span>
|
|
91
|
+
) : null}
|
|
92
|
+
</span>
|
|
93
|
+
<ChevronRight
|
|
94
|
+
aria-hidden
|
|
95
|
+
className='mt-0.5 size-4 shrink-0 text-muted-foreground transition-transform group-hover:translate-x-0.5 group-hover:text-foreground'
|
|
96
|
+
/>
|
|
97
|
+
</Link>
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** A navigation text of a route, translated in the namespace of the package that registered it. */
|
|
102
|
+
function routeText(
|
|
103
|
+
t: ReturnType<typeof useTranslation>['t'],
|
|
104
|
+
route: AppClientRegisteredRoute,
|
|
105
|
+
field: 'title' | 'description',
|
|
106
|
+
): string | undefined {
|
|
107
|
+
const key = route.navigation?.[field];
|
|
108
|
+
return key ? t(key, { ns: route.packageName, defaultValue: key }) : undefined;
|
|
109
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import type { AppClientRegisteredRoute } from '@nocobase/app-client/plugins';
|
|
2
|
+
import { useTranslation } from '@nocobase/i18n/client';
|
|
3
|
+
import {
|
|
4
|
+
useEffect,
|
|
5
|
+
useMemo,
|
|
6
|
+
useRef,
|
|
7
|
+
type ReactElement,
|
|
8
|
+
type ReactNode,
|
|
9
|
+
} from 'react';
|
|
10
|
+
import { useLocation, useNavigate } from 'react-router';
|
|
11
|
+
|
|
12
|
+
import { PageContainer } from '#components/page-container';
|
|
13
|
+
import { PageHeader } from '#components/page-header';
|
|
14
|
+
import { Tabs, TabsContent, TabsList, TabsTrigger } from '#components/ui/tabs';
|
|
15
|
+
import { settingsHomeRoute } from '#lib/navigation-transform';
|
|
16
|
+
|
|
17
|
+
import { useRouteAccess, useRouteTrail } from '../../routing/route-context.js';
|
|
18
|
+
import { routeKey } from '../../routing/route-navigation.js';
|
|
19
|
+
|
|
20
|
+
const TAB_NAVIGATION = 'settingsTabNavigation';
|
|
21
|
+
|
|
22
|
+
interface SettingsModuleTabsState {
|
|
23
|
+
/** The module group: its pages are the tabs. */
|
|
24
|
+
readonly module?: AppClientRegisteredRoute;
|
|
25
|
+
/** The tabs the viewer may open, in route order. */
|
|
26
|
+
readonly tabs: readonly AppClientRegisteredRoute[];
|
|
27
|
+
/** The tab page the current URL is on. */
|
|
28
|
+
readonly active?: AppClientRegisteredRoute;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The module the current page belongs to, read from the route trail: a settings module registered as a group has its
|
|
33
|
+
* menu pages as tabs. A direct child group of the settings group is a category rather than a module, so a page placed
|
|
34
|
+
* straight in a category has no tabs.
|
|
35
|
+
*/
|
|
36
|
+
function useSettingsModuleTabs(): SettingsModuleTabsState {
|
|
37
|
+
const trail = useRouteTrail();
|
|
38
|
+
const { denied } = useRouteAccess();
|
|
39
|
+
return useMemo(() => {
|
|
40
|
+
const pageIndex = trail.findIndex((entry) => entry.route.componentLoader);
|
|
41
|
+
const page = trail[pageIndex]?.route;
|
|
42
|
+
const group = pageIndex > 0 ? trail[pageIndex - 1].route : undefined;
|
|
43
|
+
const parent = pageIndex > 1 ? trail[pageIndex - 2].route : undefined;
|
|
44
|
+
if (!page || !group || group.componentLoader || settingsHomeRoute(group))
|
|
45
|
+
return { tabs: [], active: page };
|
|
46
|
+
if (parent && settingsHomeRoute(parent)) return { tabs: [], active: page };
|
|
47
|
+
const tabs = (group.children ?? []).filter(
|
|
48
|
+
(child) =>
|
|
49
|
+
child.componentLoader &&
|
|
50
|
+
child.navigation &&
|
|
51
|
+
!denied.has(routeKey(child)),
|
|
52
|
+
);
|
|
53
|
+
return { module: group, tabs, active: page };
|
|
54
|
+
}, [trail, denied]);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The frame of a settings module page. When the module has several views it is a route group whose pages are its tabs:
|
|
59
|
+
* each tab is its own URL, so refresh, bookmarks and Back keep the view, and a tab the viewer may not open is not
|
|
60
|
+
* shown. A module with one view the viewer may open shows no tab bar.
|
|
61
|
+
*/
|
|
62
|
+
export function SettingsModulePage({
|
|
63
|
+
title,
|
|
64
|
+
description,
|
|
65
|
+
actions,
|
|
66
|
+
children,
|
|
67
|
+
}: {
|
|
68
|
+
/** Defaults to the module group's navigation title. */
|
|
69
|
+
readonly title?: string;
|
|
70
|
+
readonly description?: ReactNode;
|
|
71
|
+
readonly actions?: ReactNode;
|
|
72
|
+
readonly children: ReactNode;
|
|
73
|
+
}): ReactElement {
|
|
74
|
+
const { t } = useTranslation();
|
|
75
|
+
const navigate = useNavigate();
|
|
76
|
+
const location = useLocation();
|
|
77
|
+
const { module, tabs, active } = useSettingsModuleTabs();
|
|
78
|
+
const { loading } = useRouteAccess();
|
|
79
|
+
const activeRef = useRef<HTMLButtonElement>(null);
|
|
80
|
+
const focusedRef = useRef(false);
|
|
81
|
+
const moduleTitle = module?.navigation
|
|
82
|
+
? t(module.navigation.title, {
|
|
83
|
+
ns: module.packageName,
|
|
84
|
+
defaultValue: module.navigation.title,
|
|
85
|
+
})
|
|
86
|
+
: undefined;
|
|
87
|
+
const heading = title ?? moduleTitle;
|
|
88
|
+
// Until the shell's permission check answers, a tab the viewer may not open could still be listed.
|
|
89
|
+
const showTabs = !loading && tabs.length > 1 && active !== undefined;
|
|
90
|
+
// Each tab is a route, so switching remounts the page; keep keyboard focus on the selected tab.
|
|
91
|
+
useEffect(() => {
|
|
92
|
+
if (
|
|
93
|
+
!focusedRef.current &&
|
|
94
|
+
location.state &&
|
|
95
|
+
typeof location.state === 'object' &&
|
|
96
|
+
Reflect.get(location.state, TAB_NAVIGATION) === true &&
|
|
97
|
+
activeRef.current
|
|
98
|
+
) {
|
|
99
|
+
const frame = requestAnimationFrame(() => {
|
|
100
|
+
const selected = activeRef.current;
|
|
101
|
+
if (selected) {
|
|
102
|
+
if (
|
|
103
|
+
!selected
|
|
104
|
+
.closest('[role="tablist"]')
|
|
105
|
+
?.contains(selected.ownerDocument.activeElement)
|
|
106
|
+
)
|
|
107
|
+
selected.focus();
|
|
108
|
+
focusedRef.current = true;
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
return () => cancelAnimationFrame(frame);
|
|
112
|
+
}
|
|
113
|
+
}, [location.state, tabs]);
|
|
114
|
+
const header = (
|
|
115
|
+
<PageHeader title={heading} description={description} actions={actions} />
|
|
116
|
+
);
|
|
117
|
+
// Keep the content under the same frame while permissions reload or the tab count changes.
|
|
118
|
+
const value = active ? routeKey(active) : 'settings-content';
|
|
119
|
+
return (
|
|
120
|
+
<PageContainer>
|
|
121
|
+
{header}
|
|
122
|
+
<Tabs
|
|
123
|
+
className='gap-6'
|
|
124
|
+
value={value}
|
|
125
|
+
onValueChange={(next) => {
|
|
126
|
+
const tab = tabs.find((candidate) => routeKey(candidate) === next);
|
|
127
|
+
if (tab && tab !== active)
|
|
128
|
+
void navigate(tab.path, { state: { [TAB_NAVIGATION]: true } });
|
|
129
|
+
}}
|
|
130
|
+
>
|
|
131
|
+
{/* Many tabs scroll sideways on a narrow screen instead of widening the page. */}
|
|
132
|
+
{showTabs ? (
|
|
133
|
+
<div className='-mx-1 max-w-full overflow-x-auto px-1'>
|
|
134
|
+
<TabsList variant='line' aria-label={heading}>
|
|
135
|
+
{tabs.map((tab) => (
|
|
136
|
+
<TabsTrigger
|
|
137
|
+
key={routeKey(tab)}
|
|
138
|
+
ref={tab === active ? activeRef : undefined}
|
|
139
|
+
value={routeKey(tab)}
|
|
140
|
+
>
|
|
141
|
+
{tab.navigation
|
|
142
|
+
? t(tab.navigation.title, {
|
|
143
|
+
ns: tab.packageName,
|
|
144
|
+
defaultValue: tab.navigation.title,
|
|
145
|
+
})
|
|
146
|
+
: null}
|
|
147
|
+
</TabsTrigger>
|
|
148
|
+
))}
|
|
149
|
+
</TabsList>
|
|
150
|
+
</div>
|
|
151
|
+
) : null}
|
|
152
|
+
<TabsContent value={value} className='space-y-6'>
|
|
153
|
+
{children}
|
|
154
|
+
</TabsContent>
|
|
155
|
+
</Tabs>
|
|
156
|
+
</PageContainer>
|
|
157
|
+
);
|
|
158
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { Settings } from 'lucide-react';
|
|
2
|
+
import {
|
|
3
|
+
defineAppRoutes,
|
|
4
|
+
type AppClientRouteContribution,
|
|
5
|
+
} from '@nocobase/app-client/plugins';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The System settings shell: one sidebar entry opening a home page of cards. Child groups are the home page's
|
|
9
|
+
* categories; register an administration module with `parent` naming its category, such as
|
|
10
|
+
* `parent: 'settingsAccount'`. A module with several views is a group whose pages become its tabs
|
|
11
|
+
* (`SettingsModulePage`). A category the viewer can open nothing in is not shown, so a bare application shows only
|
|
12
|
+
* what its plugins contribute, such as App Foundation for an unrestricted administrator.
|
|
13
|
+
*/
|
|
14
|
+
const settingsShellRoutes: AppClientRouteContribution = defineAppRoutes([
|
|
15
|
+
{
|
|
16
|
+
name: 'settings',
|
|
17
|
+
path: '/settings',
|
|
18
|
+
navigation: { title: 'settings.title', icon: Settings, order: 100 },
|
|
19
|
+
breadcrumb: { title: 'settings.title' },
|
|
20
|
+
children: [
|
|
21
|
+
{
|
|
22
|
+
// Lists only modules the viewer may open, so it needs no grant of its own.
|
|
23
|
+
name: 'settingsHome',
|
|
24
|
+
path: '/',
|
|
25
|
+
auth: 'required',
|
|
26
|
+
authz: 'skip',
|
|
27
|
+
componentLoader: () => import('./settings-home.js'),
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
name: 'settingsAccount',
|
|
31
|
+
navigation: { title: 'settings.categories.account', order: 10 },
|
|
32
|
+
children: [],
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
name: 'settingsBusiness',
|
|
36
|
+
navigation: { title: 'settings.categories.business', order: 20 },
|
|
37
|
+
children: [],
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
name: 'settingsAi',
|
|
41
|
+
navigation: { title: 'settings.categories.ai', order: 30 },
|
|
42
|
+
children: [],
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
name: 'settingsSystem',
|
|
46
|
+
navigation: { title: 'settings.categories.system', order: 40 },
|
|
47
|
+
children: [],
|
|
48
|
+
},
|
|
49
|
+
],
|
|
50
|
+
},
|
|
51
|
+
]);
|
|
52
|
+
|
|
53
|
+
export default settingsShellRoutes;
|
|
@@ -1,24 +1,10 @@
|
|
|
1
1
|
import type { ReactElement } from 'react';
|
|
2
|
-
import {
|
|
3
|
-
Navigate,
|
|
4
|
-
useLocation,
|
|
5
|
-
useOutlet,
|
|
6
|
-
useOutletContext,
|
|
7
|
-
useResolvedPath,
|
|
8
|
-
} from 'react-router';
|
|
9
|
-
import type { ManagedUser } from '@nocobase/app-plugin-users/client/user-client';
|
|
2
|
+
import { Navigate, useLocation } from 'react-router';
|
|
10
3
|
|
|
11
|
-
/**
|
|
4
|
+
/** Keeps stored route identifiers and old detail links working by forwarding them to the editor. */
|
|
12
5
|
export default function UserDetail(): ReactElement {
|
|
13
|
-
const parent = useOutletContext<{
|
|
14
|
-
reload: (updated?: ManagedUser) => void;
|
|
15
|
-
}>();
|
|
16
6
|
const location = useLocation();
|
|
17
|
-
const listPath = useResolvedPath('..').pathname;
|
|
18
|
-
const outlet = useOutlet({ ...parent, listPath });
|
|
19
7
|
return (
|
|
20
|
-
|
|
21
|
-
<Navigate to={{ pathname: 'edit', search: location.search }} replace />
|
|
22
|
-
)
|
|
8
|
+
<Navigate to={{ pathname: 'edit', search: location.search }} replace />
|
|
23
9
|
);
|
|
24
10
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { PendingButton } from '#components/pending-button';
|
|
1
2
|
import { FeedbackForm } from '#components/feedback-form';
|
|
2
3
|
import { useAuthentication } from '@nocobase/app-plugin-authentication/client';
|
|
3
4
|
import { useSubmission } from '@nocobase/app-client';
|
|
@@ -197,7 +198,6 @@ function UserEditor(): ReactElement {
|
|
|
197
198
|
const location = useLocation();
|
|
198
199
|
const parent = useOutletContext<{
|
|
199
200
|
reload: (updated?: ManagedUser) => void;
|
|
200
|
-
listPath?: string;
|
|
201
201
|
}>();
|
|
202
202
|
const data = useSettingsData<{ data: ManagedUser }>(
|
|
203
203
|
userId ? `users/${encodeURIComponent(userId)}` : 'users/options',
|
|
@@ -281,11 +281,8 @@ function UserEditor(): ReactElement {
|
|
|
281
281
|
const [serverErrors, setServerErrors] = useState<Record<string, string>>({});
|
|
282
282
|
const [submitted, setSubmitted] = useState(false);
|
|
283
283
|
|
|
284
|
-
const
|
|
285
|
-
const closeTo = {
|
|
286
|
-
pathname: creating ? parentPath : (parent.listPath ?? parentPath),
|
|
287
|
-
search: location.search,
|
|
288
|
-
};
|
|
284
|
+
const listPath = useResolvedPath('..').pathname;
|
|
285
|
+
const closeTo = { pathname: listPath, search: location.search };
|
|
289
286
|
const fieldError = (name: keyof typeof fields): string | undefined => {
|
|
290
287
|
if (!submitted) return;
|
|
291
288
|
if (serverErrors[name]) return serverErrors[name];
|
|
@@ -436,7 +433,9 @@ function UserEditor(): ReactElement {
|
|
|
436
433
|
{t('settings.cancel')}
|
|
437
434
|
</Button>
|
|
438
435
|
{allowed && (
|
|
439
|
-
<
|
|
436
|
+
<PendingButton
|
|
437
|
+
busy={busy}
|
|
438
|
+
pendingLabel={t('settings.saving')}
|
|
440
439
|
form={formId}
|
|
441
440
|
type='submit'
|
|
442
441
|
disabled={
|
|
@@ -455,14 +454,8 @@ function UserEditor(): ReactElement {
|
|
|
455
454
|
(options.loading || Boolean(options.error) || !options.data))
|
|
456
455
|
}
|
|
457
456
|
>
|
|
458
|
-
{t(
|
|
459
|
-
|
|
460
|
-
? 'settings.saving'
|
|
461
|
-
: creating
|
|
462
|
-
? 'settings.create'
|
|
463
|
-
: 'settings.save',
|
|
464
|
-
)}
|
|
465
|
-
</Button>
|
|
457
|
+
{t(creating ? 'settings.create' : 'settings.save')}
|
|
458
|
+
</PendingButton>
|
|
466
459
|
)}
|
|
467
460
|
</>
|
|
468
461
|
);
|
|
@@ -475,6 +468,7 @@ function UserEditor(): ReactElement {
|
|
|
475
468
|
) : (
|
|
476
469
|
!data.error && (
|
|
477
470
|
<FeedbackForm
|
|
471
|
+
aria-busy={busy}
|
|
478
472
|
submitting={busy}
|
|
479
473
|
error={error}
|
|
480
474
|
errorContent={<SettingsError error={error} inline />}
|