@nocobase/app-template-default 1.0.0-beta.88 → 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 (70) hide show
  1. package/.agents/rules/components.md +3 -1
  2. package/.agents/rules/shell.md +8 -2
  3. package/AGENTS.md +1 -1
  4. package/client/components/back-button.tsx +37 -12
  5. package/client/components/brand-loading-mark.tsx +5 -2
  6. package/client/components/brand-mark-geometry.ts +3 -3
  7. package/client/components/breadcrumbs.tsx +47 -3
  8. package/client/components/data-table/data-table.tsx +90 -19
  9. package/client/components/data-table/pagination.tsx +17 -2
  10. package/client/components/list-feedback.tsx +31 -22
  11. package/client/components/loading.tsx +127 -16
  12. package/client/components/pending-button.tsx +111 -0
  13. package/client/components/role-list.tsx +7 -0
  14. package/client/components/route-child-page.tsx +72 -1
  15. package/client/components/route-overlay.tsx +149 -15
  16. package/client/components/task-progress.tsx +155 -0
  17. package/client/components/use-route-overlay.ts +8 -11
  18. package/client/extensions/nocobase-file-component-ui/components/file-upload-field.tsx +62 -32
  19. package/client/extensions/nocobase-file-component-ui/locales/de-DE.ts +2 -0
  20. package/client/extensions/nocobase-file-component-ui/locales/en-US.ts +1 -0
  21. package/client/extensions/nocobase-file-component-ui/locales/es-ES.ts +1 -0
  22. package/client/extensions/nocobase-file-component-ui/locales/fr-FR.ts +1 -0
  23. package/client/extensions/nocobase-file-component-ui/locales/id-ID.ts +1 -0
  24. package/client/extensions/nocobase-file-component-ui/locales/ja-JP.ts +2 -0
  25. package/client/extensions/nocobase-file-component-ui/locales/pt-BR.ts +1 -0
  26. package/client/extensions/nocobase-file-component-ui/locales/vi-VN.ts +1 -0
  27. package/client/extensions/nocobase-file-component-ui/locales/zh-CN.ts +1 -0
  28. package/client/extensions/nocobase-file-component-ui/locales/zh-TW.ts +1 -0
  29. package/client/extensions/nocobase-workflow-management/README.md +5 -5
  30. package/client/extensions/nocobase-workflow-management/definition-list.tsx +2 -9
  31. package/client/extensions/nocobase-workflow-management/execution-list.tsx +2 -4
  32. package/client/layouts/app-layout.tsx +0 -4
  33. package/client/locales/system/en-US.ts +19 -0
  34. package/client/locales/system/zh-CN.ts +19 -0
  35. package/client/pages/settings/default-access.tsx +1 -1
  36. package/client/pages/settings/roles.tsx +27 -27
  37. package/client/pages/settings/routes.ts +15 -7
  38. package/client/pages/settings/user-detail.tsx +3 -17
  39. package/client/pages/settings/user-form.tsx +9 -15
  40. package/client/pages/settings/users.tsx +11 -27
  41. package/client/routing/client-route.tsx +199 -37
  42. package/client/routing/navigation-destination.ts +42 -0
  43. package/client/routing/route-leave-boundary.tsx +13 -1
  44. package/client/styles.css +18 -1
  45. package/package.json +8 -8
  46. package/tests/client/auth-i18n.test.tsx +1 -1
  47. package/tests/client/authenticated-client-fixture.test.tsx +37 -0
  48. package/tests/client/back-button.test.tsx +34 -0
  49. package/tests/client/client-route-recovery.test.tsx +93 -1
  50. package/tests/client/list-feedback.test.tsx +5 -5
  51. package/tests/client/list-loading.test.tsx +397 -0
  52. package/tests/client/loading.test.tsx +20 -11
  53. package/tests/client/manual-list-retry.test.tsx +2 -2
  54. package/tests/client/navigation-parent.test.tsx +172 -0
  55. package/tests/client/pending-button.test.tsx +106 -0
  56. package/tests/client/permission-management-tabs.test.tsx +48 -12
  57. package/tests/client/primitive-labels.test.tsx +2 -2
  58. package/tests/client/role-return.test.tsx +21 -3
  59. package/tests/client/roles.test.tsx +84 -3
  60. package/tests/client/route-leave-recheck.test.tsx +74 -0
  61. package/tests/client/route-presentation.test.tsx +557 -0
  62. package/tests/client/session-preparation.test.tsx +20 -17
  63. package/tests/client/settings-continuity.test.tsx +35 -11
  64. package/tests/client/settings-home.test.tsx +4 -2
  65. package/tests/client/settings-idle-list.test.tsx +124 -0
  66. package/tests/client/user-drawer.test.tsx +3 -4
  67. package/tests/fixtures/dialog-footer.html +12 -0
  68. package/tests/fixtures/dialog-footer.tsx +215 -0
  69. package/tests/helpers/authenticated-client.tsx +70 -22
  70. package/tests/playwright/dialog-footer.test.ts +227 -0
@@ -12,7 +12,6 @@ import { PlusIcon } from 'lucide-react';
12
12
  import { PermissionManagementPage } from './permission-management-page.js';
13
13
  import { Input } from '#components/ui/input';
14
14
  import { Button } from '#components/ui/button';
15
- import { Spinner } from '#components/ui/spinner';
16
15
  import { useTableUrlState } from '#hooks/use-table-url-state';
17
16
  import { RoleList } from '#components/role-list';
18
17
  import { ListFeedback, ListFeedbackActions } from '#components/list-feedback';
@@ -123,34 +122,35 @@ export default function RolesPage(): ReactElement {
123
122
  state={listState}
124
123
  onClear={clearSearch}
125
124
  onRetry={request.reload}
126
- busy={request.loading}
125
+ busy={active && request.loading}
127
126
  />
128
- {active && request.loading && request.data ? (
129
- <Spinner className='text-muted-foreground' />
130
- ) : null}
131
127
  </div>
132
- <ListFeedback state={listState}>
133
- <RoleList
134
- tableState={tableState}
135
- ariaLabel={t('settings.roles')}
136
- roles={rows.map((role) => ({
137
- key: role.key,
138
- title: text(role.title ?? role.key),
139
- to: {
140
- pathname: `${encodeURIComponent(role.key)}/edit`,
141
- search: location.search,
142
- },
143
- protected: Boolean(role.protection),
144
- }))}
145
- labels={{
146
- name: t('settings.roleName'),
147
- key: t('settings.key'),
148
- type: t('settings.roleType'),
149
- protected: t('settings.protected'),
150
- custom: t('settings.custom'),
151
- }}
152
- />
153
- </ListFeedback>
128
+ {active || listState !== 'loading' ? (
129
+ <ListFeedback state={listState}>
130
+ <RoleList
131
+ tableState={tableState}
132
+ // After a refresh failure the toolbar's retry button owns the wait.
133
+ loading={active && request.loading && listState === 'ready'}
134
+ ariaLabel={t('settings.roles')}
135
+ roles={rows.map((role) => ({
136
+ key: role.key,
137
+ title: text(role.title ?? role.key),
138
+ to: {
139
+ pathname: `${encodeURIComponent(role.key)}/edit`,
140
+ search: location.search,
141
+ },
142
+ protected: Boolean(role.protection),
143
+ }))}
144
+ labels={{
145
+ name: t('settings.roleName'),
146
+ key: t('settings.key'),
147
+ type: t('settings.roleType'),
148
+ protected: t('settings.protected'),
149
+ custom: t('settings.custom'),
150
+ }}
151
+ />
152
+ </ListFeedback>
153
+ ) : null}
154
154
  </PermissionManagementPage>
155
155
  <Outlet context={{ reload: request.reload }} />
156
156
  </>
@@ -24,23 +24,27 @@ const systemSettingsRoutes: AppClientRouteContribution = defineAppRoutes([
24
24
  breadcrumb: { title: 'settings.users' },
25
25
  componentLoader: () => import('./users.js'),
26
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.
27
29
  {
28
30
  name: 'settingsUserCreate',
29
31
  path: 'create',
30
32
  authz: { resource: { type: 'user', id: '*' }, action: 'create' },
33
+ presentation: { kind: 'dialog', title: 'settings.newUser' },
31
34
  componentLoader: () => import('./user-form.js'),
32
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.
33
38
  {
34
39
  name: 'settingsUser',
35
40
  path: ':userId',
36
41
  componentLoader: () => import('./user-detail.js'),
37
- children: [
38
- {
39
- name: 'settingsUserEdit',
40
- path: 'edit',
41
- componentLoader: () => import('./user-form.js'),
42
- },
43
- ],
42
+ },
43
+ {
44
+ name: 'settingsUserEdit',
45
+ path: ':userId/edit',
46
+ presentation: { kind: 'drawer', title: 'settings.editUser' },
47
+ componentLoader: () => import('./user-form.js'),
44
48
  },
45
49
  ],
46
50
  },
@@ -80,12 +84,14 @@ const systemSettingsRoutes: AppClientRouteContribution = defineAppRoutes([
80
84
  action: 'create',
81
85
  },
82
86
  breadcrumb: { title: 'settings.newRole' },
87
+ presentation: { kind: 'child-page' },
83
88
  componentLoader: () => import('./role-form.js'),
84
89
  },
85
90
  {
86
91
  name: 'settingsRoleEdit',
87
92
  path: ':key/edit',
88
93
  breadcrumb: { title: 'settings.editRole' },
94
+ presentation: { kind: 'child-page' },
89
95
  componentLoader: () => import('./role-form.js'),
90
96
  },
91
97
  ],
@@ -119,6 +125,7 @@ const systemSettingsRoutes: AppClientRouteContribution = defineAppRoutes([
119
125
  action: 'create',
120
126
  },
121
127
  breadcrumb: { title: 'settings.defaultAccess.new' },
128
+ presentation: { kind: 'child-page' },
122
129
  componentLoader: () => import('./default-access-form.js'),
123
130
  },
124
131
  // Read permission opens the full rule even when the viewer may not update it.
@@ -126,6 +133,7 @@ const systemSettingsRoutes: AppClientRouteContribution = defineAppRoutes([
126
133
  name: 'settingsDefaultAccessEdit',
127
134
  path: ':key/edit',
128
135
  breadcrumb: { title: 'settings.defaultAccess.edit' },
136
+ presentation: { kind: 'child-page' },
129
137
  componentLoader: () => import('./default-access-form.js'),
130
138
  },
131
139
  ],
@@ -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 />}
@@ -31,12 +31,12 @@ import {
31
31
  } from 'react';
32
32
  import { Link, Outlet, useLocation, useResolvedPath } from 'react-router';
33
33
  import { PlusIcon } from 'lucide-react';
34
+ import { cn } from 'cn';
34
35
  import { PageContainer } from '#components/page-container';
35
36
  import { PageHeader } from '#components/page-header';
36
37
  import { Button } from '#components/ui/button';
37
38
  import { Input } from '#components/ui/input';
38
39
  import { StatusBadge } from '#components/status-badge';
39
- import { Spinner } from '#components/ui/spinner';
40
40
  import {
41
41
  Select,
42
42
  SelectTrigger,
@@ -46,25 +46,6 @@ import {
46
46
  } from '#components/ui/select';
47
47
  import { ConfirmAction, SettingsError } from './shared.js';
48
48
 
49
- function UsersRefreshing(): ReactElement | null {
50
- const { t } = useTranslation();
51
- const [visible, setVisible] = useState(false);
52
- useEffect(() => {
53
- // Fast background reads should not flash feedback on overlay close.
54
- const timer = setTimeout(() => setVisible(true), 300);
55
- return () => clearTimeout(timer);
56
- }, []);
57
- return visible ? (
58
- <span
59
- role='status'
60
- className='flex items-center gap-2 text-sm text-muted-foreground'
61
- >
62
- <Spinner aria-hidden='true' className='size-4' />
63
- {t('settings.loading')}
64
- </span>
65
- ) : null;
66
- }
67
-
68
49
  function UserName({ user }: { readonly user: ManagedUser }): ReactElement {
69
50
  const canUpdate = useUserPermission('update', user.id);
70
51
  const canAssign = useUserPermission('assign-role', user.id);
@@ -515,11 +496,6 @@ export default function UsersPage(): ReactElement {
515
496
  {t('settings.clear')}
516
497
  </Button>
517
498
  )}
518
- <div className='ml-auto flex h-8 w-28 items-center justify-end'>
519
- {active && hasCurrentResponse && request.loading && request.data ? (
520
- <UsersRefreshing />
521
- ) : null}
522
- </div>
523
499
  </div>
524
500
  {Boolean(roleScope) !== Boolean(role) && (
525
501
  <p role='alert' className='text-sm text-destructive'>
@@ -536,13 +512,21 @@ export default function UsersPage(): ReactElement {
536
512
  {...tableState}
537
513
  manualSorting
538
514
  showSelectedCount={false}
539
- loading={request.loading}
515
+ loading={active && request.loading}
516
+ loadingIdentity={requestPath}
540
517
  listState={listState}
541
518
  onRetry={request.reload}
542
519
  onClearFilters={clearFilters}
543
520
  />
544
521
  {request.data && (
545
- <p className='text-sm text-muted-foreground'>
522
+ // A previous query's total keeps its line but is never shown as the current one.
523
+ <p
524
+ className={cn(
525
+ 'text-sm text-muted-foreground',
526
+ !hasCurrentResponse && 'invisible',
527
+ )}
528
+ aria-hidden={!hasCurrentResponse || undefined}
529
+ >
546
530
  {t('settings.total', { count: request.data.meta.total })}
547
531
  </p>
548
532
  )}
@@ -1,22 +1,51 @@
1
1
  import { useTranslation } from '@nocobase/i18n/client';
2
+ import { useNavigationGuard } from '@nocobase/app-client';
2
3
  import type {
3
4
  AppClientRegisteredRoute,
4
5
  AppClientRouteComponentModule,
5
6
  } from '@nocobase/app-client/plugins';
6
7
  import { useCan } from '@nocobase/app-plugin-authorization/client';
7
8
  import { NamespaceScope } from '@nocobase/i18n/client';
8
- import { Activity, type ReactElement, useEffect, useState } from 'react';
9
+ import {
10
+ Activity,
11
+ type ReactElement,
12
+ type ReactNode,
13
+ useCallback,
14
+ useEffect,
15
+ useRef,
16
+ useState,
17
+ } from 'react';
9
18
  import { ErrorBoundary } from 'react-error-boundary';
10
19
 
11
20
  import { Loading } from '#components/loading';
21
+ import {
22
+ RouteChildPageHost,
23
+ RouteChildPageHostBoundary,
24
+ } from '#components/route-child-page';
25
+ import {
26
+ RouteOverlayHost,
27
+ RouteOverlayHostBoundary,
28
+ } from '#components/route-overlay';
12
29
  import { Button } from '#components/ui/button';
30
+ import { cn } from 'cn';
13
31
 
14
32
  export interface ClientRouteProps {
15
33
  readonly route: AppClientRegisteredRoute;
16
34
  }
17
35
 
36
+ // Where a route's waiting, denial and failure render: the content area for a page, or the container the application
37
+ // opened for a declared child page, dialog or drawer. A route without a presentation keeps the page-sized placeholder.
38
+ type ClientRouteRegion = 'page' | 'child-page' | 'overlay';
39
+
40
+ const REGION_CLASS_NAMES: Record<ClientRouteRegion, string> = {
41
+ page: 'min-h-[calc(100svh-4rem)]',
42
+ 'child-page': 'min-h-full',
43
+ overlay: 'min-h-40',
44
+ };
45
+
18
46
  export function ClientRoute({ route }: ClientRouteProps): ReactElement {
19
- const { authz, componentLoader } = route;
47
+ const { t } = useTranslation();
48
+ const { authz, componentLoader, presentation } = route;
20
49
  const checkAccess = authz !== 'skip';
21
50
  const { can: allowed, isPending: accessLoading } = useCan(
22
51
  authz === 'skip' ? undefined : authz,
@@ -24,14 +53,33 @@ export function ClientRoute({ route }: ClientRouteProps): ReactElement {
24
53
  const [componentModule, setComponentModule] =
25
54
  useState<AppClientRouteComponentModule>();
26
55
  const [loadError, setLoadError] = useState<unknown>();
56
+ const [attempt, setAttempt] = useState(0);
57
+ const [renderFailed, setRenderFailed] = useState(false);
58
+ const loadedRef = useRef<
59
+ | {
60
+ loader: typeof componentLoader;
61
+ attempt: number;
62
+ }
63
+ | undefined
64
+ >(undefined);
27
65
 
28
66
  useEffect(() => {
29
67
  if (checkAccess && !allowed) return;
68
+ // Activity reconnects Effects after a parent permission recheck. Keep the loaded component's identity:
69
+ // loaders that wrap a module (for example, withNamespace) can return a new component on every call.
70
+ if (
71
+ loadedRef.current &&
72
+ loadedRef.current.loader === componentLoader &&
73
+ loadedRef.current.attempt === attempt
74
+ )
75
+ return;
76
+ loadedRef.current = undefined;
30
77
  let active = true;
31
78
 
32
79
  componentLoader!().then(
33
80
  (module) => {
34
81
  if (active) {
82
+ loadedRef.current = { loader: componentLoader, attempt };
35
83
  setComponentModule(module);
36
84
  setLoadError(undefined);
37
85
  }
@@ -46,44 +94,126 @@ export function ClientRoute({ route }: ClientRouteProps): ReactElement {
46
94
  return () => {
47
95
  active = false;
48
96
  };
49
- }, [allowed, checkAccess, componentLoader]);
50
-
51
- if (loadError) {
52
- return <ClientPageError route={route} />;
53
- }
97
+ }, [allowed, attempt, checkAccess, componentLoader]);
54
98
 
55
- if (checkAccess && !accessLoading && !allowed)
56
- return <ClientPageDenied route={route} />;
99
+ // Retries only this route's module, so the pages around it keep their drafts and scroll positions.
100
+ const retryLoad = useCallback(() => {
101
+ setLoadError(undefined);
102
+ setAttempt((value) => value + 1);
103
+ }, []);
57
104
 
58
- if (!componentModule) {
59
- return <ClientPageLoading />;
60
- }
105
+ const kind = presentation?.kind;
106
+ const region: ClientRouteRegion =
107
+ kind === 'dialog' || kind === 'drawer'
108
+ ? 'overlay'
109
+ : kind === 'child-page'
110
+ ? 'child-page'
111
+ : 'page';
112
+ const checking = checkAccess && accessLoading;
113
+ const denied = checkAccess && !accessLoading && !allowed;
114
+ const Component = componentModule?.default;
115
+ const [mounted, setMounted] = useState(false);
116
+ const present = Boolean(Component) && !denied && !loadError && !renderFailed;
117
+ if (!checking && mounted !== present) setMounted(present);
118
+ const rechecking = checking && mounted && !loadError && !renderFailed;
119
+ // Activity disconnects form effects, including dirty registrations. This guard stays outside it and refuses
120
+ // route departures until the page's effects reconnect; settled denial still unmounts the protected page.
121
+ useNavigationGuard(() => !rechecking);
122
+ useEffect(() => {
123
+ if (!rechecking) return;
124
+ const warn = (event: BeforeUnloadEvent): void => {
125
+ event.preventDefault();
126
+ event.returnValue = '';
127
+ };
128
+ window.addEventListener('beforeunload', warn);
129
+ return () => window.removeEventListener('beforeunload', warn);
130
+ }, [rechecking]);
61
131
 
62
- const Component = componentModule.default;
132
+ let content: ReactNode;
133
+ // A settled denial wins over an older load failure: the viewer may not see this page at all.
134
+ if (denied) content = <ClientPageDenied route={route} region={region} />;
135
+ else if (loadError)
136
+ content = (
137
+ <ClientPageError
138
+ route={route}
139
+ region={region}
140
+ onRetry={retryLoad}
141
+ retried={attempt > 0}
142
+ />
143
+ );
144
+ else if (!Component) content = <ClientPageLoading region={region} />;
145
+ else
146
+ content = (
147
+ <>
148
+ {checking ? <ClientPageLoading region={region} /> : null}
149
+ {/* Conceal a previously allowed page during rechecks without discarding its draft. A denial unmounts it. */}
150
+ <Activity mode={checking ? 'hidden' : 'visible'}>
151
+ <ErrorBoundary
152
+ onError={() => setRenderFailed(true)}
153
+ onReset={() => setRenderFailed(false)}
154
+ fallbackRender={({ resetErrorBoundary }) => (
155
+ <ClientPageError
156
+ route={route}
157
+ region={region}
158
+ onRetry={resetErrorBoundary}
159
+ />
160
+ )}
161
+ >
162
+ {/* The owning package is the page's namespace, so a plugin page translates with a bare useTranslation(). */}
163
+ <NamespaceScope ns={route.packageName}>
164
+ <Component />
165
+ </NamespaceScope>
166
+ </ErrorBoundary>
167
+ </Activity>
168
+ </>
169
+ );
63
170
 
64
- const checking = checkAccess && accessLoading;
171
+ if (kind === 'dialog' || kind === 'drawer') {
172
+ const title = presentation!.title!;
173
+ return (
174
+ <RouteChildPageHostBoundary>
175
+ <RouteOverlayHost
176
+ drawer={kind === 'drawer'}
177
+ title={t(title, {
178
+ ns: presentation!.namespace ?? route.packageName,
179
+ defaultValue: title,
180
+ })}
181
+ ready={mounted && !denied && !loadError && !renderFailed}
182
+ concealed={checking}
183
+ >
184
+ {content}
185
+ </RouteOverlayHost>
186
+ </RouteChildPageHostBoundary>
187
+ );
188
+ }
189
+ if (kind === 'child-page')
190
+ return (
191
+ <RouteOverlayHostBoundary>
192
+ <RouteChildPageHost>{content}</RouteChildPageHost>
193
+ </RouteOverlayHostBoundary>
194
+ );
195
+ // A page that is not hosted builds its own container, including one rendered through a hosted page's outlet.
65
196
  return (
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
- </>
197
+ <RouteOverlayHostBoundary>
198
+ <RouteChildPageHostBoundary>{content}</RouteChildPageHostBoundary>
199
+ </RouteOverlayHostBoundary>
78
200
  );
79
201
  }
80
202
 
81
- function ClientPageDenied(inputProps: ClientRouteProps): ReactElement {
203
+ interface ClientRouteStateProps extends ClientRouteProps {
204
+ readonly region: ClientRouteRegion;
205
+ }
206
+
207
+ function ClientPageDenied({
208
+ route,
209
+ region,
210
+ }: ClientRouteStateProps): ReactElement {
82
211
  const { t } = useTranslation();
83
- const { route } = inputProps;
84
212
 
85
213
  return (
86
- <section className='grid min-h-[calc(100svh-4rem)] place-items-center px-6'>
214
+ <section
215
+ className={cn('grid place-items-center px-6', REGION_CLASS_NAMES[region])}
216
+ >
87
217
  <section className='w-full max-w-lg space-y-3 text-center'>
88
218
  <h1 className='text-xl font-semibold'>
89
219
  {t('status.denied', { defaultValue: 'Access denied' })}
@@ -99,23 +229,40 @@ function ClientPageDenied(inputProps: ClientRouteProps): ReactElement {
99
229
  );
100
230
  }
101
231
 
102
- function ClientPageLoading(): ReactElement {
232
+ function ClientPageLoading({
233
+ region,
234
+ }: {
235
+ readonly region: ClientRouteRegion;
236
+ }): ReactElement {
103
237
  const { t } = useTranslation();
104
238
 
105
239
  return (
106
240
  <Loading
107
- className='min-h-[calc(100svh-4rem)]'
241
+ className={REGION_CLASS_NAMES[region]}
108
242
  label={t('status.loadingPage', { defaultValue: 'Loading page' })}
109
243
  />
110
244
  );
111
245
  }
112
246
 
113
- function ClientPageError(inputProps: ClientRouteProps): ReactElement {
247
+ function ClientPageError({
248
+ route,
249
+ region,
250
+ onRetry,
251
+ retried = false,
252
+ }: ClientRouteStateProps & {
253
+ readonly onRetry: () => void;
254
+ /**
255
+ * Set once a retry of the module failed too. A browser may keep answering a failed module import from its module
256
+ * map without fetching again, so the full reload that does fetch it becomes the second way out.
257
+ */
258
+ readonly retried?: boolean;
259
+ }): ReactElement {
114
260
  const { t } = useTranslation();
115
- const { route } = inputProps;
116
261
 
117
262
  return (
118
- <section className='grid min-h-[calc(100svh-4rem)] place-items-center px-6'>
263
+ <section
264
+ className={cn('grid place-items-center px-6', REGION_CLASS_NAMES[region])}
265
+ >
119
266
  <section className='w-full max-w-lg space-y-4 text-center'>
120
267
  <h1 className='text-xl font-semibold'>
121
268
  {t('status.pageFailed', { defaultValue: 'Unable to load page' })}
@@ -127,9 +274,24 @@ function ClientPageError(inputProps: ClientRouteProps): ReactElement {
127
274
  defaultValue: `Route ${route.name} from ${route.packageName} could not be loaded.`,
128
275
  })}
129
276
  </p>
130
- <Button onClick={() => window.location.reload()}>
131
- {t('status.retry', { defaultValue: 'Retry' })}
132
- </Button>
277
+ {retried && (
278
+ <p className='text-sm text-muted-foreground'>
279
+ {t('status.retryFailedDescription', {
280
+ defaultValue:
281
+ 'It still could not be loaded. Reloading the whole page may help; unsaved changes on this page may be lost.',
282
+ })}
283
+ </p>
284
+ )}
285
+ <div className='flex flex-wrap justify-center gap-2'>
286
+ <Button onClick={onRetry}>
287
+ {t('status.retry', { defaultValue: 'Retry' })}
288
+ </Button>
289
+ {retried && (
290
+ <Button variant='outline' onClick={() => window.location.reload()}>
291
+ {t('status.reloadPage', { defaultValue: 'Reload page' })}
292
+ </Button>
293
+ )}
294
+ </div>
133
295
  </section>
134
296
  </section>
135
297
  );
@@ -0,0 +1,42 @@
1
+ import type { AppClientRegisteredRoute } from '@nocobase/app-client/plugins';
2
+ import { parsePath, type To, type Path } from 'react-router';
3
+
4
+ import type { RouteAccess } from './route-context.js';
5
+ import { matchRouteTree, routeKey } from './route-navigation.js';
6
+
7
+ /** Only application-absolute, registered, authorized pages can be navigation destinations. */
8
+ export function navigationDestination(
9
+ to: To,
10
+ routes: readonly AppClientRegisteredRoute[],
11
+ access: RouteAccess,
12
+ ): Partial<Path> | undefined {
13
+ if (access.loading) return undefined;
14
+ const path = typeof to === 'string' ? parsePath(to) : to;
15
+ const pathname = path.pathname;
16
+ if (
17
+ !pathname ||
18
+ !pathname.startsWith('/') ||
19
+ pathname.startsWith('//') ||
20
+ pathname.includes('\\') ||
21
+ [...pathname].some((character) => character.charCodeAt(0) <= 32)
22
+ )
23
+ return undefined;
24
+ // Do not normalize dot segments or malformed escapes into a different authorized route.
25
+ try {
26
+ if (
27
+ pathname
28
+ .split('/')
29
+ .some((segment) => ['.', '..'].includes(decodeURIComponent(segment)))
30
+ )
31
+ return undefined;
32
+ } catch {
33
+ return undefined;
34
+ }
35
+ const matches = matchRouteTree(routes, pathname);
36
+ if (
37
+ !matches?.at(-1)?.route.componentLoader ||
38
+ matches.some(({ route }) => access.denied.has(routeKey(route)))
39
+ )
40
+ return undefined;
41
+ return { pathname, search: path.search ?? '', hash: path.hash ?? '' };
42
+ }