@nocobase/app-template-default 1.0.0-beta.86 → 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 (63) hide show
  1. package/.agents/rules/notification-templates.md +5 -0
  2. package/.agents/rules/shell.md +4 -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/extensions/nocobase-file-component-ui/components/previewers/file-preview-content.tsx +1 -1
  9. package/client/layouts/app-layout.tsx +8 -4
  10. package/client/layouts/components/header-actions.tsx +0 -2
  11. package/client/layouts/components/sidebar-footer.tsx +18 -25
  12. package/client/layouts/components/user-menu.tsx +6 -13
  13. package/client/lib/navigation-transform.ts +156 -22
  14. package/client/locales/system/en-US.ts +8 -6
  15. package/client/locales/system/settings-en-US.ts +17 -0
  16. package/client/locales/system/settings-zh-CN.ts +15 -0
  17. package/client/locales/system/zh-CN.ts +8 -6
  18. package/client/pages/auth/register.tsx +39 -22
  19. package/client/pages/auth/shared.tsx +1 -3
  20. package/client/pages/settings/hooks.tsx +3 -68
  21. package/client/pages/settings/notification-routes.ts +109 -0
  22. package/client/pages/settings/notification-templates.tsx +26 -0
  23. package/client/pages/settings/permission-management-page.tsx +15 -90
  24. package/client/pages/settings/routes.ts +96 -98
  25. package/client/pages/settings/settings-home.tsx +109 -0
  26. package/client/pages/settings/settings-module-page.tsx +158 -0
  27. package/client/pages/settings/shell-routes.ts +53 -0
  28. package/client/routes.ts +17 -1
  29. package/client/routing/app-router.tsx +1 -14
  30. package/client/routing/client-route.tsx +16 -9
  31. package/client/routing/route-context.tsx +30 -3
  32. package/client/routing/route-leave-boundary.tsx +249 -0
  33. package/client/routing/route-navigation.ts +72 -8
  34. package/client/routing/route-tree.tsx +7 -12
  35. package/client/routing/standalone-page-layout.tsx +0 -5
  36. package/client/runtime.ts +2 -2
  37. package/client/theme/theme-settings.tsx +60 -162
  38. package/config.example.yml +51 -32
  39. package/package.json +28 -28
  40. package/server/config/auth.ts +2 -2
  41. package/server/config/index.ts +3 -0
  42. package/server/config/mailers.ts +11 -0
  43. package/server/providers/index.ts +2 -0
  44. package/server/providers/notification-management.ts +27 -0
  45. package/tests/client/account-permissions.test.tsx +1 -1
  46. package/tests/client/client-auth.test.tsx +10 -1
  47. package/tests/client/client-routes.test.ts +137 -4
  48. package/tests/client/client-shell.test.tsx +29 -10
  49. package/tests/client/client-theme.test.tsx +118 -39
  50. package/tests/client/foundation-integration.test.ts +38 -17
  51. package/tests/client/header-hover.test.tsx +33 -5
  52. package/tests/client/layout-i18n.test.tsx +9 -4
  53. package/tests/client/permission-management-tabs.test.tsx +421 -16
  54. package/tests/client/registration-feedback.test.tsx +143 -0
  55. package/tests/client/settings-home.test.tsx +110 -0
  56. package/tests/client/settings-navigation.test.ts +230 -0
  57. package/tests/client/standalone-page-layout.test.tsx +45 -0
  58. package/tests/client/user-menu-sign-out.test.tsx +38 -14
  59. package/tests/client/user-menu.test.tsx +65 -0
  60. package/tests/fixtures/theme-tokens.tsx +13 -1
  61. package/tests/server/auth-email-config.test.ts +51 -17
  62. package/tests/server/notification-templates.test.ts +144 -0
  63. package/tests/client/permission-management-routes.test.ts +0 -64
@@ -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
  }
@@ -0,0 +1,249 @@
1
+ import { useNavigationGuard } from '@nocobase/app-client';
2
+ import { useTranslation } from '@nocobase/i18n/client';
3
+ import {
4
+ createContext,
5
+ useContext,
6
+ useEffect,
7
+ useLayoutEffect,
8
+ useState,
9
+ type PropsWithChildren,
10
+ type ReactElement,
11
+ } from 'react';
12
+ import { useLocation } from 'react-router';
13
+ import { ConfirmAction } from '../pages/settings/shared.js';
14
+
15
+ /* eslint-disable react-refresh/only-export-components -- the route boundary and its hook share one context */
16
+
17
+ type Decision = boolean | Promise<boolean>;
18
+ interface Prompt {
19
+ readonly owner: LeaveController;
20
+ readonly title: string;
21
+ readonly description: string;
22
+ }
23
+
24
+ class LeaveController {
25
+ dirty = false;
26
+ busy = false;
27
+ active = false;
28
+ private allowed = false;
29
+ private pending?: {
30
+ promise: Promise<boolean>;
31
+ resolve: (value: boolean) => void;
32
+ };
33
+
34
+ constructor(
35
+ readonly origin: string,
36
+ private readonly show: (prompt: Prompt | undefined) => void,
37
+ ) {}
38
+
39
+ title = '';
40
+ description = '';
41
+ onDiscard?: () => void;
42
+
43
+ update(
44
+ dirty: boolean,
45
+ busy: boolean,
46
+ onDiscard: (() => void) | undefined,
47
+ title: string,
48
+ description: string,
49
+ ): void {
50
+ this.dirty = dirty;
51
+ this.busy = busy;
52
+ this.onDiscard = onDiscard;
53
+ this.title = title;
54
+ this.description = description;
55
+ }
56
+
57
+ guard = (): Decision => {
58
+ if (this.allowed) return true;
59
+ if (this.busy) return false;
60
+ if (!this.dirty) return true;
61
+ if (!this.pending) {
62
+ let resolve!: (value: boolean) => void;
63
+ const promise = new Promise<boolean>((done) => {
64
+ resolve = done;
65
+ });
66
+ this.pending = { promise, resolve };
67
+ this.show({
68
+ owner: this,
69
+ title: this.title,
70
+ description: this.description,
71
+ });
72
+ }
73
+ return this.pending.promise;
74
+ };
75
+
76
+ decide(value: boolean): void {
77
+ if (value) this.onDiscard?.();
78
+ this.allowed = value;
79
+ this.pending?.resolve(value);
80
+ this.pending = undefined;
81
+ this.show(undefined);
82
+ }
83
+
84
+ allow = (): void => {
85
+ this.allowed = true;
86
+ };
87
+ reset(): void {
88
+ this.allowed = false;
89
+ }
90
+ cancel(): void {
91
+ if (this.pending) this.decide(false);
92
+ }
93
+ }
94
+
95
+ class LeaveScope {
96
+ private readonly entries = new Set<LeaveController>();
97
+ private hidden = false;
98
+ constructor(private readonly dirtyChanged: (dirty: boolean) => void) {}
99
+
100
+ changed = (): void => {
101
+ this.dirtyChanged([...this.entries].some((entry) => entry.dirty));
102
+ };
103
+ attach(entry: LeaveController): void {
104
+ entry.active = true;
105
+ this.entries.add(entry);
106
+ this.changed();
107
+ }
108
+ detach(entry: LeaveController): void {
109
+ entry.active = false;
110
+ // Activity disconnects Effects before the shell commits its new visibility.
111
+ queueMicrotask(() => {
112
+ if (!entry.active && !this.hidden) this.remove(entry);
113
+ });
114
+ }
115
+ private remove(entry: LeaveController): void {
116
+ this.entries.delete(entry);
117
+ entry.cancel();
118
+ this.changed();
119
+ }
120
+ update(hidden: boolean, pathname: string): void {
121
+ this.hidden = hidden;
122
+ for (const entry of this.entries) {
123
+ if (pathname !== entry.origin && !pathname.startsWith(`${entry.origin}/`))
124
+ this.remove(entry);
125
+ else entry.reset();
126
+ }
127
+ }
128
+ prune(): void {
129
+ if (!this.hidden)
130
+ for (const entry of this.entries) if (!entry.active) this.remove(entry);
131
+ }
132
+ guard = (): Decision => {
133
+ const remaining = [...this.entries];
134
+ const next = (): Decision => {
135
+ const entry = remaining.shift();
136
+ if (!entry) return true;
137
+ const value = entry.guard();
138
+ return typeof value === 'boolean'
139
+ ? value && next()
140
+ : value.then((allowed) => allowed && next());
141
+ };
142
+ return next();
143
+ };
144
+ dispose(): void {
145
+ for (const entry of this.entries) entry.cancel();
146
+ this.entries.clear();
147
+ }
148
+ }
149
+
150
+ interface BoundaryContext {
151
+ readonly scope: LeaveScope;
152
+ readonly show: (prompt: Prompt | undefined) => void;
153
+ }
154
+ const Context = createContext<BoundaryContext | undefined>(undefined);
155
+
156
+ function useBeforeUnload(dirty: boolean): void {
157
+ useEffect(() => {
158
+ if (!dirty) return;
159
+ const handler = (event: BeforeUnloadEvent) => {
160
+ event.preventDefault();
161
+ event.returnValue = '';
162
+ };
163
+ window.addEventListener('beforeunload', handler);
164
+ return () => window.removeEventListener('beforeunload', handler);
165
+ }, [dirty]);
166
+ }
167
+
168
+ function promptDialog(prompt: Prompt | undefined): ReactElement | null {
169
+ return prompt ? (
170
+ <ConfirmAction
171
+ open
172
+ title={prompt.title}
173
+ description={prompt.description}
174
+ busy={false}
175
+ onCancel={() => prompt.owner.decide(false)}
176
+ onConfirm={() => prompt.owner.decide(true)}
177
+ />
178
+ ) : null;
179
+ }
180
+
181
+ /** The shell owns leave decisions and their UI, outside every page's Activity and its nested routes. */
182
+ export function RouteLeaveBoundary({
183
+ hidden,
184
+ children,
185
+ }: PropsWithChildren<{ readonly hidden: boolean }>): ReactElement {
186
+ const location = useLocation();
187
+ const [prompt, setPrompt] = useState<Prompt>();
188
+ const [dirty, setDirty] = useState(false);
189
+ const [context] = useState(() => ({
190
+ scope: new LeaveScope(setDirty),
191
+ show: setPrompt,
192
+ }));
193
+ useNavigationGuard(context.scope.guard);
194
+ useBeforeUnload(dirty);
195
+ useLayoutEffect(
196
+ () => context.scope.update(hidden, location.pathname),
197
+ [context, hidden, location.pathname, location.key],
198
+ );
199
+ useEffect(() => {
200
+ context.scope.prune();
201
+ }, [context, hidden]);
202
+ useEffect(() => () => context.scope.dispose(), [context]);
203
+ return (
204
+ <Context.Provider value={context}>
205
+ {children}
206
+ {promptDialog(prompt)}
207
+ </Context.Provider>
208
+ );
209
+ }
210
+
211
+ /** Compatible with standalone form renders; routed forms keep protection in the shell during permission rechecks. */
212
+ export function usePageLeave(
213
+ dirty: boolean,
214
+ busy: boolean,
215
+ onDiscard?: () => void,
216
+ ) {
217
+ const boundary = useContext(Context);
218
+ const { t } = useTranslation();
219
+ const location = useLocation();
220
+ const [prompt, setPrompt] = useState<Prompt>();
221
+ const [controller] = useState(
222
+ () => new LeaveController(location.pathname, boundary?.show ?? setPrompt),
223
+ );
224
+ useNavigationGuard(boundary ? () => true : controller.guard);
225
+ useBeforeUnload(!boundary && dirty);
226
+ useLayoutEffect(() => {
227
+ controller.update(
228
+ dirty,
229
+ busy,
230
+ onDiscard,
231
+ t('settings.discardTitle'),
232
+ t('settings.discardHint'),
233
+ );
234
+ boundary?.scope.changed();
235
+ }, [controller, dirty, busy, onDiscard, boundary, t]);
236
+ useLayoutEffect(() => {
237
+ controller.reset();
238
+ }, [controller, location.key]);
239
+ useLayoutEffect(() => {
240
+ if (!boundary) return () => controller.cancel();
241
+ boundary.scope.attach(controller);
242
+ return () => boundary.scope.detach(controller);
243
+ }, [boundary, controller]);
244
+ return {
245
+ guard: controller.guard,
246
+ allow: controller.allow,
247
+ dialog: boundary ? null : promptDialog(prompt),
248
+ };
249
+ }
@@ -8,7 +8,12 @@ import { useEffect, useMemo, useState } from 'react';
8
8
  import { matchPath, matchRoutes, type RouteObject } from 'react-router';
9
9
 
10
10
  import { EMPTY_ARRAY } from '#lib/constants';
11
- import { transformNavigation } from '#lib/navigation-transform';
11
+ import {
12
+ organizeNavigation,
13
+ settingsCatalog,
14
+ transformNavigation,
15
+ type SettingsCategory,
16
+ } from '#lib/navigation-transform';
12
17
 
13
18
  export interface RouteNavigationItem {
14
19
  /** Route keys represented by a single sidebar entry whose content uses tabs. */
@@ -22,7 +27,17 @@ export function buildRouteNavigation(
22
27
  routes: readonly AppClientRegisteredRoute[],
23
28
  denied: ReadonlySet<string>,
24
29
  ): RouteNavigationItem[] {
25
- return transformNavigation(visibleRouteNavigation(routes, denied));
30
+ return transformNavigation(visibleRouteNavigation(routes, denied), routes);
31
+ }
32
+
33
+ /** The settings home's categories and modules the viewer may open (`settingsCatalog`). */
34
+ export function buildSettingsCatalog(
35
+ routes: readonly AppClientRegisteredRoute[],
36
+ denied: ReadonlySet<string>,
37
+ ): SettingsCategory[] {
38
+ return settingsCatalog(
39
+ organizeNavigation(visibleRouteNavigation(routes, denied), routes),
40
+ );
26
41
  }
27
42
 
28
43
  function visibleRouteNavigation(
@@ -96,6 +111,8 @@ function navigationPages(
96
111
  ]);
97
112
  }
98
113
 
114
+ const NONE_DENIED: ReadonlySet<string> = new Set();
115
+
99
116
  export function useRouteNavigation(
100
117
  routes: readonly AppClientRegisteredRoute[],
101
118
  ) {
@@ -151,9 +168,8 @@ export function useRouteNavigation(
151
168
  result.guards !== guards ||
152
169
  result.revision !== revision),
153
170
  );
154
- const denied = guards.length
155
- ? (result?.denied ?? new Set<string>())
156
- : new Set<string>();
171
+ // A stable empty set keeps consumers that memoize on `denied` from recomputing on every render.
172
+ const denied = (guards.length && result?.denied) || NONE_DENIED;
157
173
  return {
158
174
  loading,
159
175
  items: loading ? [] : buildRouteNavigation(routes, denied),
@@ -165,11 +181,17 @@ export function matchRouteTree(
165
181
  routes: readonly AppClientRegisteredRoute[],
166
182
  pathname: string,
167
183
  ) {
168
- const toMatch = (nodes: readonly AppClientRegisteredRoute[]): RouteObject[] =>
184
+ const toMatch = (
185
+ nodes: readonly AppClientRegisteredRoute[],
186
+ parentPath = '',
187
+ ): RouteObject[] =>
169
188
  nodes.map((route) => ({
170
- path: route.path,
189
+ path: relativeRoutePath(route, parentPath),
171
190
  handle: route,
172
- children: toMatch(route.children ?? EMPTY_ARRAY),
191
+ children: toMatch(
192
+ route.children ?? EMPTY_ARRAY,
193
+ childRouteParentPath(route, parentPath),
194
+ ),
173
195
  }));
174
196
  return matchRoutes(toMatch(routes), pathname)?.map((match) => ({
175
197
  ...match,
@@ -177,6 +199,48 @@ export function matchRouteTree(
177
199
  }));
178
200
  }
179
201
 
202
+ /**
203
+ * A registered route's path relative to its parent's, as the router nests it. Registered paths are absolute; a route at
204
+ * its parent's own path (a group's home page, a pathless group) nests with an empty path.
205
+ */
206
+ export function relativeRoutePath(
207
+ route: AppClientRegisteredRoute,
208
+ parentPath: string,
209
+ ): string {
210
+ if (!parentPath) return route.path;
211
+ if (
212
+ route.path === parentPath ||
213
+ parentPath.startsWith(`${route.path.replace(/\/$/, '')}/`)
214
+ )
215
+ return '';
216
+ return route.path.slice(parentPath === '/' ? 1 : parentPath.length + 1);
217
+ }
218
+
219
+ /** The path a route's children are relative to. */
220
+ export function childRouteParentPath(
221
+ route: AppClientRegisteredRoute,
222
+ parentPath: string,
223
+ ): string {
224
+ return parentPath.startsWith(`${route.path.replace(/\/$/, '')}/`)
225
+ ? parentPath
226
+ : route.path;
227
+ }
228
+
180
229
  export function routeKey(route: AppClientRegisteredRoute): string {
181
230
  return `${route.packageName}:${route.path}:${route.id}`;
182
231
  }
232
+
233
+ /** Pure groups can span shells; a page and its descendants always share their shell. */
234
+ export function filterRouteTree(
235
+ routes: readonly AppClientRegisteredRoute[],
236
+ predicate: (route: AppClientRegisteredRoute) => boolean,
237
+ ): AppClientRegisteredRoute[] {
238
+ return routes.flatMap((route) => {
239
+ if (route.componentLoader) return predicate(route) ? [route] : [];
240
+ const children = filterRouteTree(route.children ?? EMPTY_ARRAY, predicate);
241
+ // A group declared empty, such as a settings category waiting for modules, stays so its title is still known.
242
+ return children.length || !route.children?.length
243
+ ? [{ ...route, children }]
244
+ : [];
245
+ });
246
+ }
@@ -4,7 +4,11 @@ import { Outlet, Route } from 'react-router';
4
4
 
5
5
  import { EMPTY_ARRAY } from '#lib/constants';
6
6
 
7
- import { routeKey } from './route-navigation.js';
7
+ import {
8
+ childRouteParentPath,
9
+ relativeRoutePath,
10
+ routeKey,
11
+ } from './route-navigation.js';
8
12
  import { ClientRoute } from './client-route.js';
9
13
 
10
14
  /** Groups pass children through; pages deliberately own their Outlet placement. */
@@ -15,14 +19,7 @@ export function renderRouteTree(
15
19
  return routes.map((route) => (
16
20
  <Route
17
21
  key={routeKey(route)}
18
- path={
19
- parentPath
20
- ? route.path === parentPath ||
21
- parentPath.startsWith(`${route.path.replace(/\/$/, '')}/`)
22
- ? ''
23
- : route.path.slice(parentPath === '/' ? 1 : parentPath.length + 1)
24
- : route.path
25
- }
22
+ path={relativeRoutePath(route, parentPath)}
26
23
  element={
27
24
  route.componentLoader ? (
28
25
  <ClientRoute key={routeKey(route)} route={route} />
@@ -33,9 +30,7 @@ export function renderRouteTree(
33
30
  >
34
31
  {renderRouteTree(
35
32
  route.children ?? EMPTY_ARRAY,
36
- parentPath.startsWith(`${route.path.replace(/\/$/, '')}/`)
37
- ? parentPath
38
- : route.path,
33
+ childRouteParentPath(route, parentPath),
39
34
  )}
40
35
  </Route>
41
36
  ));
@@ -1,14 +1,9 @@
1
1
  import type { ReactElement } from 'react';
2
2
  import { Outlet } from 'react-router';
3
3
 
4
- import { ThemeSettings } from '../theme/index.js';
5
-
6
4
  export function StandalonePageLayout(): ReactElement {
7
5
  return (
8
6
  <div className='relative min-h-svh'>
9
- <div className='fixed top-4 right-4 z-50'>
10
- <ThemeSettings />
11
- </div>
12
7
  <Outlet />
13
8
  </div>
14
9
  );
package/client/runtime.ts CHANGED
@@ -5,7 +5,7 @@ import { defineAppRuntime } from '@nocobase/app-client/runtime';
5
5
  import locales from './locales/index.js';
6
6
  import plugins from './plugins.js';
7
7
  import reactProviders from './react-providers.js';
8
- import routes from './routes.js';
8
+ import { createApplicationRoutes } from './routes.js';
9
9
  import serviceProviders from './service-provider.js';
10
10
 
11
11
  const appRuntime = defineAppRuntime({
@@ -15,7 +15,7 @@ const appRuntime = defineAppRuntime({
15
15
  serviceProviders,
16
16
  locales,
17
17
  reactProviders,
18
- routes,
18
+ routes: () => createApplicationRoutes(),
19
19
  plugins,
20
20
  });
21
21