@nocobase/app-template-default 1.0.0-beta.87 → 1.0.0-beta.88

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 (49) hide show
  1. package/.agents/rules/notification-templates.md +5 -0
  2. package/.agents/rules/shell.md +2 -2
  3. package/AGENTS.md +3 -1
  4. package/README.MD +11 -5
  5. package/client/components/body-portal.tsx +25 -0
  6. package/client/components/breadcrumbs.tsx +13 -0
  7. package/client/components/flow-diagram.tsx +2 -2
  8. package/client/layouts/app-layout.tsx +8 -4
  9. package/client/layouts/components/sidebar-footer.tsx +18 -25
  10. package/client/lib/navigation-transform.ts +156 -22
  11. package/client/locales/system/en-US.ts +7 -5
  12. package/client/locales/system/settings-en-US.ts +17 -0
  13. package/client/locales/system/settings-zh-CN.ts +15 -0
  14. package/client/locales/system/zh-CN.ts +7 -5
  15. package/client/pages/auth/register.tsx +39 -22
  16. package/client/pages/auth/shared.tsx +1 -3
  17. package/client/pages/settings/hooks.tsx +3 -68
  18. package/client/pages/settings/notification-routes.ts +109 -0
  19. package/client/pages/settings/notification-templates.tsx +26 -0
  20. package/client/pages/settings/permission-management-page.tsx +15 -90
  21. package/client/pages/settings/routes.ts +96 -98
  22. package/client/pages/settings/settings-home.tsx +109 -0
  23. package/client/pages/settings/settings-module-page.tsx +158 -0
  24. package/client/pages/settings/shell-routes.ts +53 -0
  25. package/client/routes.ts +17 -1
  26. package/client/routing/app-router.tsx +1 -14
  27. package/client/routing/client-route.tsx +16 -9
  28. package/client/routing/route-context.tsx +30 -3
  29. package/client/routing/route-leave-boundary.tsx +249 -0
  30. package/client/routing/route-navigation.ts +72 -8
  31. package/client/routing/route-tree.tsx +7 -12
  32. package/client/runtime.ts +2 -2
  33. package/config.example.yml +1 -1
  34. package/package.json +12 -12
  35. package/server/config/auth.ts +1 -1
  36. package/server/providers/index.ts +2 -0
  37. package/server/providers/notification-management.ts +27 -0
  38. package/tests/client/account-permissions.test.tsx +1 -1
  39. package/tests/client/client-auth.test.tsx +10 -1
  40. package/tests/client/client-routes.test.ts +137 -4
  41. package/tests/client/client-shell.test.tsx +23 -8
  42. package/tests/client/foundation-integration.test.ts +38 -17
  43. package/tests/client/layout-i18n.test.tsx +9 -4
  44. package/tests/client/permission-management-tabs.test.tsx +421 -16
  45. package/tests/client/registration-feedback.test.tsx +143 -0
  46. package/tests/client/settings-home.test.tsx +110 -0
  47. package/tests/client/settings-navigation.test.ts +230 -0
  48. package/tests/server/notification-templates.test.ts +144 -0
  49. package/tests/client/permission-management-routes.test.ts +0 -64
@@ -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;
package/client/routes.ts CHANGED
@@ -1,4 +1,8 @@
1
+ import { createAppClientConfig } from '@nocobase/app-client';
2
+ import { readAppClientRuntimeConfig } from '@nocobase/app-client/runtime';
3
+ import { includeNotificationTemplateRoutes } from './pages/settings/notification-routes.js';
1
4
  import { Home } from 'lucide-react';
5
+ import settingsShellRoutes from './pages/settings/shell-routes.js';
2
6
  import {
3
7
  defineAppRoutes,
4
8
  type AppClientRouteContribution,
@@ -64,6 +68,18 @@ const appRoutes: AppClientRouteContribution = defineAppRoutes([
64
68
  },
65
69
  ]);
66
70
 
67
- const routes: readonly AppClientRouteContribution[] = [appRoutes];
71
+ // The System settings shell stays registered: it shows only the modules a viewer may open, and none until the
72
+ // application or its plugins contribute one. Business administration such as `./pages/settings/routes.js` joins it.
73
+ export function createApplicationRoutes(
74
+ rawConfig: unknown = readAppClientRuntimeConfig(),
75
+ ): readonly AppClientRouteContribution[] {
76
+ const config = createAppClientConfig({ rawConfig });
77
+ return includeNotificationTemplateRoutes(
78
+ [appRoutes, settingsShellRoutes],
79
+ config.get<boolean>('notificationTemplates.invitationConfigured') === true,
80
+ );
81
+ }
82
+
83
+ const routes: readonly AppClientRouteContribution[] = createApplicationRoutes();
68
84
 
69
85
  export default routes;
@@ -6,9 +6,8 @@ import type { AppClientRegisteredRoute } from '@nocobase/app-client/plugins';
6
6
  import { useMemo, type ReactElement } from 'react';
7
7
  import { Navigate, Outlet, Route, Routes } from 'react-router';
8
8
 
9
- import { EMPTY_ARRAY } from '#lib/constants';
10
-
11
9
  import { AppLayout } from '../layouts/app-layout.js';
10
+ import { filterRouteTree } from './route-navigation.js';
12
11
  import { renderRouteTree } from './route-tree.js';
13
12
  import { StandalonePageLayout } from './standalone-page-layout.js';
14
13
  import { SessionPreparation } from './session-preparation.js';
@@ -66,15 +65,3 @@ export function AppRouter({ clientRoutes }: AppRouterProps): ReactElement {
66
65
  </Routes>
67
66
  );
68
67
  }
69
-
70
- /** Pure groups can span shells; a page and its descendants always share their shell. */
71
- function filterRouteTree(
72
- routes: readonly AppClientRegisteredRoute[],
73
- predicate: (route: AppClientRegisteredRoute) => boolean,
74
- ): AppClientRegisteredRoute[] {
75
- return routes.flatMap((route) => {
76
- if (route.componentLoader) return predicate(route) ? [route] : [];
77
- const children = filterRouteTree(route.children ?? EMPTY_ARRAY, predicate);
78
- return children.length ? [{ ...route, children }] : [];
79
- });
80
- }
@@ -5,7 +5,7 @@ import type {
5
5
  } from '@nocobase/app-client/plugins';
6
6
  import { useCan } from '@nocobase/app-plugin-authorization/client';
7
7
  import { NamespaceScope } from '@nocobase/i18n/client';
8
- import { type ReactElement, useEffect, useState } from 'react';
8
+ import { Activity, type ReactElement, useEffect, useState } from 'react';
9
9
  import { ErrorBoundary } from 'react-error-boundary';
10
10
 
11
11
  import { Loading } from '#components/loading';
@@ -52,8 +52,8 @@ export function ClientRoute({ route }: ClientRouteProps): ReactElement {
52
52
  return <ClientPageError route={route} />;
53
53
  }
54
54
 
55
- if (checkAccess && accessLoading) return <ClientPageLoading />;
56
- if (checkAccess && !allowed) return <ClientPageDenied route={route} />;
55
+ if (checkAccess && !accessLoading && !allowed)
56
+ return <ClientPageDenied route={route} />;
57
57
 
58
58
  if (!componentModule) {
59
59
  return <ClientPageLoading />;
@@ -61,13 +61,20 @@ export function ClientRoute({ route }: ClientRouteProps): ReactElement {
61
61
 
62
62
  const Component = componentModule.default;
63
63
 
64
+ const checking = checkAccess && accessLoading;
64
65
  return (
65
- <ErrorBoundary fallback={<ClientPageError route={route} />}>
66
- {/* The owning package is the page's namespace, so a plugin page translates with a bare useTranslation(). */}
67
- <NamespaceScope ns={route.packageName}>
68
- <Component />
69
- </NamespaceScope>
70
- </ErrorBoundary>
66
+ <>
67
+ {checking ? <ClientPageLoading /> : null}
68
+ {/* Conceal a previously allowed page during rechecks without discarding its draft. A denial unmounts it. */}
69
+ <Activity mode={checking ? 'hidden' : 'visible'}>
70
+ <ErrorBoundary fallback={<ClientPageError route={route} />}>
71
+ {/* The owning package is the page's namespace, so a plugin page translates with a bare useTranslation(). */}
72
+ <NamespaceScope ns={route.packageName}>
73
+ <Component />
74
+ </NamespaceScope>
75
+ </ErrorBoundary>
76
+ </Activity>
77
+ </>
71
78
  );
72
79
  }
73
80
 
@@ -37,8 +37,32 @@ export function useRouteSearches(): ReadonlyMap<string, string> {
37
37
  }
38
38
 
39
39
  const RouteTreeContext = createContext<readonly AppClientRegisteredRoute[]>([]);
40
+
41
+ /** The shell's page permission check (`useRouteNavigation`), shared so pages reuse it instead of checking again. */
42
+ export interface RouteAccess {
43
+ /** Route keys the viewer may not open. */
44
+ readonly denied: ReadonlySet<string>;
45
+ /** The check is still running; `denied` is not yet known. */
46
+ readonly loading: boolean;
47
+ }
48
+
49
+ const DEFAULT_ACCESS: RouteAccess = { denied: new Set(), loading: false };
50
+ const RouteAccessContext = createContext<RouteAccess>(DEFAULT_ACCESS);
51
+
40
52
  export interface RouteTreeProviderProps extends PropsWithChildren {
41
53
  readonly routes: readonly AppClientRegisteredRoute[];
54
+ /** The shell's page permission result; without it every route counts as open. */
55
+ readonly access?: RouteAccess;
56
+ }
57
+
58
+ /** The registered business route tree the shell renders. */
59
+ export function useRouteTree(): readonly AppClientRegisteredRoute[] {
60
+ return useContext(RouteTreeContext);
61
+ }
62
+
63
+ /** Which routes the viewer may open, from the shell's single permission check. */
64
+ export function useRouteAccess(): RouteAccess {
65
+ return useContext(RouteAccessContext);
42
66
  }
43
67
 
44
68
  /**
@@ -51,6 +75,7 @@ export interface RouteTreeProviderProps extends PropsWithChildren {
51
75
  export function RouteTreeProvider({
52
76
  children,
53
77
  routes,
78
+ access,
54
79
  }: RouteTreeProviderProps): ReactElement {
55
80
  const location = useLocation();
56
81
  const pathname = location.pathname.replace(/\/+$/u, '') || '/';
@@ -74,9 +99,11 @@ export function RouteTreeProvider({
74
99
  }
75
100
  return (
76
101
  <RouteTreeContext.Provider value={routes}>
77
- <RouteSearchContext.Provider value={queries}>
78
- {children}
79
- </RouteSearchContext.Provider>
102
+ <RouteAccessContext.Provider value={access ?? DEFAULT_ACCESS}>
103
+ <RouteSearchContext.Provider value={queries}>
104
+ {children}
105
+ </RouteSearchContext.Provider>
106
+ </RouteAccessContext.Provider>
80
107
  </RouteTreeContext.Provider>
81
108
  );
82
109
  }