@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.
Files changed (107) hide show
  1. package/.agents/rules/components.md +3 -1
  2. package/.agents/rules/notification-templates.md +5 -0
  3. package/.agents/rules/shell.md +9 -3
  4. package/AGENTS.md +4 -2
  5. package/README.MD +11 -5
  6. package/client/components/back-button.tsx +37 -12
  7. package/client/components/body-portal.tsx +25 -0
  8. package/client/components/brand-loading-mark.tsx +5 -2
  9. package/client/components/brand-mark-geometry.ts +3 -3
  10. package/client/components/breadcrumbs.tsx +60 -3
  11. package/client/components/data-table/data-table.tsx +90 -19
  12. package/client/components/data-table/pagination.tsx +17 -2
  13. package/client/components/flow-diagram.tsx +2 -2
  14. package/client/components/list-feedback.tsx +31 -22
  15. package/client/components/loading.tsx +127 -16
  16. package/client/components/pending-button.tsx +111 -0
  17. package/client/components/role-list.tsx +7 -0
  18. package/client/components/route-child-page.tsx +72 -1
  19. package/client/components/route-overlay.tsx +149 -15
  20. package/client/components/task-progress.tsx +155 -0
  21. package/client/components/use-route-overlay.ts +8 -11
  22. package/client/extensions/nocobase-file-component-ui/components/file-upload-field.tsx +62 -32
  23. package/client/extensions/nocobase-file-component-ui/locales/de-DE.ts +2 -0
  24. package/client/extensions/nocobase-file-component-ui/locales/en-US.ts +1 -0
  25. package/client/extensions/nocobase-file-component-ui/locales/es-ES.ts +1 -0
  26. package/client/extensions/nocobase-file-component-ui/locales/fr-FR.ts +1 -0
  27. package/client/extensions/nocobase-file-component-ui/locales/id-ID.ts +1 -0
  28. package/client/extensions/nocobase-file-component-ui/locales/ja-JP.ts +2 -0
  29. package/client/extensions/nocobase-file-component-ui/locales/pt-BR.ts +1 -0
  30. package/client/extensions/nocobase-file-component-ui/locales/vi-VN.ts +1 -0
  31. package/client/extensions/nocobase-file-component-ui/locales/zh-CN.ts +1 -0
  32. package/client/extensions/nocobase-file-component-ui/locales/zh-TW.ts +1 -0
  33. package/client/extensions/nocobase-workflow-management/README.md +5 -5
  34. package/client/extensions/nocobase-workflow-management/definition-list.tsx +2 -9
  35. package/client/extensions/nocobase-workflow-management/execution-list.tsx +2 -4
  36. package/client/layouts/app-layout.tsx +8 -8
  37. package/client/layouts/components/sidebar-footer.tsx +18 -25
  38. package/client/lib/navigation-transform.ts +156 -22
  39. package/client/locales/system/en-US.ts +26 -5
  40. package/client/locales/system/settings-en-US.ts +17 -0
  41. package/client/locales/system/settings-zh-CN.ts +15 -0
  42. package/client/locales/system/zh-CN.ts +26 -5
  43. package/client/pages/auth/register.tsx +39 -22
  44. package/client/pages/auth/shared.tsx +1 -3
  45. package/client/pages/settings/default-access.tsx +1 -1
  46. package/client/pages/settings/hooks.tsx +3 -68
  47. package/client/pages/settings/notification-routes.ts +109 -0
  48. package/client/pages/settings/notification-templates.tsx +26 -0
  49. package/client/pages/settings/permission-management-page.tsx +15 -90
  50. package/client/pages/settings/roles.tsx +27 -27
  51. package/client/pages/settings/routes.ts +107 -101
  52. package/client/pages/settings/settings-home.tsx +109 -0
  53. package/client/pages/settings/settings-module-page.tsx +158 -0
  54. package/client/pages/settings/shell-routes.ts +53 -0
  55. package/client/pages/settings/user-detail.tsx +3 -17
  56. package/client/pages/settings/user-form.tsx +9 -15
  57. package/client/pages/settings/users.tsx +11 -27
  58. package/client/routes.ts +17 -1
  59. package/client/routing/app-router.tsx +1 -14
  60. package/client/routing/client-route.tsx +199 -30
  61. package/client/routing/navigation-destination.ts +42 -0
  62. package/client/routing/route-context.tsx +30 -3
  63. package/client/routing/route-leave-boundary.tsx +261 -0
  64. package/client/routing/route-navigation.ts +72 -8
  65. package/client/routing/route-tree.tsx +7 -12
  66. package/client/runtime.ts +2 -2
  67. package/client/styles.css +18 -1
  68. package/config.example.yml +1 -1
  69. package/package.json +13 -13
  70. package/server/config/auth.ts +1 -1
  71. package/server/providers/index.ts +2 -0
  72. package/server/providers/notification-management.ts +27 -0
  73. package/tests/client/account-permissions.test.tsx +1 -1
  74. package/tests/client/auth-i18n.test.tsx +1 -1
  75. package/tests/client/authenticated-client-fixture.test.tsx +37 -0
  76. package/tests/client/back-button.test.tsx +34 -0
  77. package/tests/client/client-auth.test.tsx +10 -1
  78. package/tests/client/client-route-recovery.test.tsx +93 -1
  79. package/tests/client/client-routes.test.ts +137 -4
  80. package/tests/client/client-shell.test.tsx +23 -8
  81. package/tests/client/foundation-integration.test.ts +38 -17
  82. package/tests/client/layout-i18n.test.tsx +9 -4
  83. package/tests/client/list-feedback.test.tsx +5 -5
  84. package/tests/client/list-loading.test.tsx +397 -0
  85. package/tests/client/loading.test.tsx +20 -11
  86. package/tests/client/manual-list-retry.test.tsx +2 -2
  87. package/tests/client/navigation-parent.test.tsx +172 -0
  88. package/tests/client/pending-button.test.tsx +106 -0
  89. package/tests/client/permission-management-tabs.test.tsx +458 -17
  90. package/tests/client/primitive-labels.test.tsx +2 -2
  91. package/tests/client/registration-feedback.test.tsx +143 -0
  92. package/tests/client/role-return.test.tsx +21 -3
  93. package/tests/client/roles.test.tsx +84 -3
  94. package/tests/client/route-leave-recheck.test.tsx +74 -0
  95. package/tests/client/route-presentation.test.tsx +557 -0
  96. package/tests/client/session-preparation.test.tsx +20 -17
  97. package/tests/client/settings-continuity.test.tsx +35 -11
  98. package/tests/client/settings-home.test.tsx +112 -0
  99. package/tests/client/settings-idle-list.test.tsx +124 -0
  100. package/tests/client/settings-navigation.test.ts +230 -0
  101. package/tests/client/user-drawer.test.tsx +3 -4
  102. package/tests/fixtures/dialog-footer.html +12 -0
  103. package/tests/fixtures/dialog-footer.tsx +215 -0
  104. package/tests/helpers/authenticated-client.tsx +70 -22
  105. package/tests/playwright/dialog-footer.test.ts +227 -0
  106. package/tests/server/notification-templates.test.ts +144 -0
  107. 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
- /** Register this contribution as business capabilities are developed; keep a bare Home-only application free of settings. */
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
- name: 'settings',
17
- path: '/settings',
18
- navigation: { title: 'settings.title', icon: Settings, order: 100 },
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: 'settingsUsers',
22
- path: '/users',
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: { resource: { type: 'user', id: '*' }, action: 'read' },
25
- navigation: { title: 'settings.users', icon: Users },
26
- breadcrumb: { title: 'settings.users' },
27
- componentLoader: () => import('./users.js'),
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: 'settingsUserCreate',
77
+ name: 'settingsRoleCreate',
31
78
  path: 'create',
32
- authz: { resource: { type: 'user', id: '*' }, action: 'create' },
33
- componentLoader: () => import('./user-form.js'),
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: 'settingsUser',
37
- path: ':userId',
38
- componentLoader: () => import('./user-detail.js'),
39
- children: [
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: 'permissionManagement',
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.permissionManagement',
53
- icon: ShieldCheck,
111
+ title: 'settings.defaultAccess.title',
112
+ icon: ListFilter,
54
113
  },
55
- breadcrumb: { title: 'settings.permissionManagement' },
114
+ breadcrumb: { title: 'settings.defaultAccess.title' },
115
+ componentLoader: () => import('./default-access.js'),
56
116
  children: [
57
117
  {
58
- name: 'settingsRoles',
59
- path: '/roles',
60
- auth: 'required',
118
+ name: 'settingsDefaultAccessCreate',
119
+ path: 'create',
61
120
  authz: {
62
121
  resource: {
63
122
  type: 'settings',
64
- id: 'authorization.permission-sets',
123
+ id: 'authorization.default-access',
65
124
  },
66
- action: 'read',
125
+ action: 'create',
67
126
  },
68
- navigation: { title: 'settings.roles', icon: KeyRound },
69
- breadcrumb: { title: 'settings.roles' },
70
- componentLoader: () => import('./roles.js'),
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: 'settingsDefaultAccess',
95
- path: '/default-access',
96
- auth: 'required',
97
- authz: {
98
- resource: {
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
- /** Keep stored route identifiers and old detail links while using one editor. */
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
- outlet ?? (
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 parentPath = useResolvedPath('..').pathname;
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
- <Button
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
- busy
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 />}