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

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 (103) hide show
  1. package/.agents/rules/components.md +3 -1
  2. package/.agents/rules/shell.md +12 -2
  3. package/AGENTS.md +1 -1
  4. package/LICENSE.txt +129 -0
  5. package/client/components/back-button.tsx +37 -12
  6. package/client/components/brand-loading-mark.tsx +5 -2
  7. package/client/components/brand-mark-geometry.ts +3 -3
  8. package/client/components/breadcrumbs.tsx +47 -3
  9. package/client/components/data-table/data-table.tsx +90 -19
  10. package/client/components/data-table/pagination.tsx +17 -2
  11. package/client/components/list-feedback.tsx +31 -22
  12. package/client/components/loading.tsx +127 -16
  13. package/client/components/pending-button.tsx +111 -0
  14. package/client/components/role-list.tsx +7 -0
  15. package/client/components/route-child-page.tsx +72 -1
  16. package/client/components/route-overlay.tsx +149 -15
  17. package/client/components/task-progress.tsx +155 -0
  18. package/client/components/use-route-overlay.ts +8 -11
  19. package/client/extensions/nocobase-file-component-ui/components/file-upload-field.tsx +62 -32
  20. package/client/extensions/nocobase-file-component-ui/locales/de-DE.ts +2 -0
  21. package/client/extensions/nocobase-file-component-ui/locales/en-US.ts +1 -0
  22. package/client/extensions/nocobase-file-component-ui/locales/es-ES.ts +1 -0
  23. package/client/extensions/nocobase-file-component-ui/locales/fr-FR.ts +1 -0
  24. package/client/extensions/nocobase-file-component-ui/locales/id-ID.ts +1 -0
  25. package/client/extensions/nocobase-file-component-ui/locales/ja-JP.ts +2 -0
  26. package/client/extensions/nocobase-file-component-ui/locales/pt-BR.ts +1 -0
  27. package/client/extensions/nocobase-file-component-ui/locales/vi-VN.ts +1 -0
  28. package/client/extensions/nocobase-file-component-ui/locales/zh-CN.ts +1 -0
  29. package/client/extensions/nocobase-file-component-ui/locales/zh-TW.ts +1 -0
  30. package/client/extensions/nocobase-workflow-management/README.md +5 -5
  31. package/client/extensions/nocobase-workflow-management/definition-list.tsx +2 -9
  32. package/client/extensions/nocobase-workflow-management/execution-list.tsx +2 -4
  33. package/client/layouts/app-layout.tsx +0 -4
  34. package/client/lib/navigation-transform.ts +29 -1
  35. package/client/locales/system/en-US.ts +23 -0
  36. package/client/locales/system/zh-CN.ts +23 -0
  37. package/client/pages/home/index.tsx +66 -51
  38. package/client/pages/settings/database-explorer/en-US.ts +33 -0
  39. package/client/pages/settings/database-explorer/index.tsx +358 -0
  40. package/client/pages/settings/database-explorer/routes.ts +14 -0
  41. package/client/pages/settings/database-explorer/zh-CN.ts +30 -0
  42. package/client/pages/settings/default-access.tsx +1 -1
  43. package/client/pages/settings/roles.tsx +27 -27
  44. package/client/pages/settings/routes.ts +15 -7
  45. package/client/pages/settings/user-detail.tsx +3 -17
  46. package/client/pages/settings/user-form.tsx +9 -15
  47. package/client/pages/settings/users.tsx +11 -27
  48. package/client/plugins.ts +8 -1
  49. package/client/routes.ts +2 -1
  50. package/client/routing/client-route.tsx +199 -37
  51. package/client/routing/navigation-destination.ts +42 -0
  52. package/client/routing/route-leave-boundary.tsx +13 -1
  53. package/client/styles.css +18 -1
  54. package/config.example.yml +17 -11
  55. package/index.html +8 -11
  56. package/package.json +27 -27
  57. package/server/config/auth.ts +1 -1
  58. package/server/config/index.ts +3 -0
  59. package/server/config/jobs.ts +16 -8
  60. package/server/config/queue.ts +14 -13
  61. package/server/config/realtime.ts +4 -0
  62. package/tests/client/auth-i18n.test.tsx +1 -1
  63. package/tests/client/authenticated-client-fixture.test.tsx +37 -0
  64. package/tests/client/back-button.test.tsx +34 -0
  65. package/tests/client/client-route-recovery.test.tsx +93 -1
  66. package/tests/client/client-routes.test.ts +5 -3
  67. package/tests/client/database-explorer-integration.test.tsx +30 -0
  68. package/tests/client/database-explorer.test.tsx +152 -0
  69. package/tests/client/foundation-integration.test.ts +42 -29
  70. package/tests/client/home.test.tsx +26 -85
  71. package/tests/client/layout-i18n.test.tsx +83 -2
  72. package/tests/client/list-feedback.test.tsx +5 -5
  73. package/tests/client/list-loading.test.tsx +397 -0
  74. package/tests/client/loading.test.tsx +20 -11
  75. package/tests/client/manual-list-retry.test.tsx +2 -2
  76. package/tests/client/navigation-parent.test.tsx +172 -0
  77. package/tests/client/pending-button.test.tsx +106 -0
  78. package/tests/client/permission-management-tabs.test.tsx +48 -12
  79. package/tests/client/primitive-labels.test.tsx +2 -2
  80. package/tests/client/role-return.test.tsx +21 -3
  81. package/tests/client/roles.test.tsx +84 -3
  82. package/tests/client/route-leave-recheck.test.tsx +74 -0
  83. package/tests/client/route-presentation.test.tsx +557 -0
  84. package/tests/client/session-preparation.test.tsx +20 -17
  85. package/tests/client/settings-continuity.test.tsx +35 -11
  86. package/tests/client/settings-home.test.tsx +4 -2
  87. package/tests/client/settings-idle-list.test.tsx +124 -0
  88. package/tests/client/settings-navigation.test.ts +20 -0
  89. package/tests/client/user-drawer.test.tsx +3 -4
  90. package/tests/fixtures/dialog-footer.html +12 -0
  91. package/tests/fixtures/dialog-footer.tsx +215 -0
  92. package/tests/helpers/authenticated-client.tsx +77 -22
  93. package/tests/playwright/dialog-footer.test.ts +227 -0
  94. package/tests/project/bootstrap-loading.test.ts +60 -0
  95. package/tests/server/app-server.test.ts +18 -12
  96. package/tests/server/auth-email-config.test.ts +21 -0
  97. package/tests/server/backends-config.test.ts +118 -0
  98. package/tests/server/concurrency.test.ts +1 -1
  99. package/tests/server/config.test.ts +22 -14
  100. package/tests/server/lifecycle.test.ts +2 -2
  101. package/tests/server/realtime-config.test.ts +114 -0
  102. package/client/pages/home/capabilities.ts +0 -62
  103. package/client/pages/home/prompt-dialog.tsx +0 -122
@@ -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
  )}
package/client/plugins.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import foundation from '@nocobase/app-plugin-foundation/client';
2
+ import applicationManifest from '../package.json' with { type: 'json' };
2
3
  import defaultAccess from '@nocobase/app-plugin-authz-default-access/client';
3
4
  import sharingRules from '@nocobase/app-plugin-authz-sharing-rules/client';
4
5
  import restrictionRules from '@nocobase/app-plugin-authz-restriction-rules/client';
@@ -35,7 +36,13 @@ const clientPlugins: AppClientPlugins = defineClientPlugins([
35
36
  file(),
36
37
  scheduler(),
37
38
  mail(),
38
- foundation({ settingsGroup: true }),
39
+ // The manifest is what the capability guide lists: every plugin this application declares.
40
+ foundation({
41
+ settingsGroup: true,
42
+ manifest: applicationManifest,
43
+ guideSettingsPath: '/settings',
44
+ navigation: false,
45
+ }),
39
46
  ]);
40
47
 
41
48
  export default clientPlugins;
package/client/routes.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import databaseExplorerRoutes from './pages/settings/database-explorer/routes.js';
1
2
  import { createAppClientConfig } from '@nocobase/app-client';
2
3
  import { readAppClientRuntimeConfig } from '@nocobase/app-client/runtime';
3
4
  import { includeNotificationTemplateRoutes } from './pages/settings/notification-routes.js';
@@ -75,7 +76,7 @@ export function createApplicationRoutes(
75
76
  ): readonly AppClientRouteContribution[] {
76
77
  const config = createAppClientConfig({ rawConfig });
77
78
  return includeNotificationTemplateRoutes(
78
- [appRoutes, settingsShellRoutes],
79
+ [appRoutes, settingsShellRoutes, databaseExplorerRoutes],
79
80
  config.get<boolean>('notificationTemplates.invitationConfigured') === true,
80
81
  );
81
82
  }
@@ -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
+ }
@@ -95,6 +95,7 @@ class LeaveController {
95
95
  class LeaveScope {
96
96
  private readonly entries = new Set<LeaveController>();
97
97
  private hidden = false;
98
+ private generation = 0;
98
99
  constructor(private readonly dirtyChanged: (dirty: boolean) => void) {}
99
100
 
100
101
  changed = (): void => {
@@ -118,7 +119,12 @@ class LeaveScope {
118
119
  this.changed();
119
120
  }
120
121
  update(hidden: boolean, pathname: string): void {
121
- this.hidden = hidden;
122
+ if (this.hidden !== hidden) {
123
+ this.generation += 1;
124
+ this.hidden = hidden;
125
+ // A confirmation started before concealment must not discard or resume its old navigation on recovery.
126
+ if (hidden) for (const entry of this.entries) entry.cancel();
127
+ }
122
128
  for (const entry of this.entries) {
123
129
  if (pathname !== entry.origin && !pathname.startsWith(`${entry.origin}/`))
124
130
  this.remove(entry);
@@ -130,8 +136,14 @@ class LeaveScope {
130
136
  for (const entry of this.entries) if (!entry.active) this.remove(entry);
131
137
  }
132
138
  guard = (): Decision => {
139
+ const generation = this.generation;
133
140
  const remaining = [...this.entries];
134
141
  const next = (): Decision => {
142
+ if (
143
+ generation !== this.generation ||
144
+ (this.hidden && this.entries.size > 0)
145
+ )
146
+ return false;
135
147
  const entry = remaining.shift();
136
148
  if (!entry) return true;
137
149
  const value = entry.guard();
package/client/styles.css CHANGED
@@ -252,9 +252,26 @@ label:has(> .sr-only:focus-visible),
252
252
  :is([data-slot='dialog-description'], [data-slot='alert-dialog-description']) {
253
253
  line-height: 1.65;
254
254
  }
255
- :is([data-slot='dialog-footer'], [data-slot='alert-dialog-footer']) > button {
255
+ /* Size semantic actions equally, including Button render={<Link />} and the
256
+ AlertDialog wrappers, which replace Button's slot. Footer sizing takes
257
+ precedence over primitive size utilities; long labels may grow and wrap. */
258
+ :is([data-slot='dialog-footer'], [data-slot='alert-dialog-footer'])
259
+ > :is(
260
+ [data-slot='button'],
261
+ [data-slot='dialog-close'],
262
+ [data-slot='alert-dialog-action'],
263
+ [data-slot='alert-dialog-cancel']
264
+ ) {
265
+ height: auto;
256
266
  min-height: calc(var(--spacing) * 10);
267
+ min-width: 0;
268
+ align-self: stretch;
269
+ flex-shrink: 1;
270
+ padding-block: calc(var(--spacing) * 2);
257
271
  padding-inline: calc(var(--spacing) * 4);
272
+ white-space: normal;
273
+ overflow-wrap: anywhere;
274
+ text-align: center;
258
275
  }
259
276
  /* Route overlays compose the dialog primitives, so their close button sits where a dialog's does. */
260
277
  :is([data-slot='dialog-content'], [data-slot='route-overlay-content'])
@@ -22,13 +22,19 @@
22
22
  # max: 600
23
23
  # window: 1m
24
24
 
25
- # Realtime events are transient and local to this process. Clients refetch after subscription acknowledgement.
25
+ # Realtime defaults to inMemory when broker is omitted. An explicit broker requires adapter.
26
+ # For local-only selection: realtime: { broker: { adapter: inMemory } }.
27
+ # For multiple processes, install @nocobase/app-plugin-realtime-redis and register its
28
+ # @nocobase/app-plugin-realtime-redis/server definition in server/plugins.ts on every instance.
29
+ # Realtime events are transient; clients refetch after subscription acknowledgement.
26
30
  # realtime:
27
- # adapter: inMemory # Use redis for multiple application processes.
28
- # redis:
31
+ # broker:
32
+ # adapter: redis
33
+ # setupTimeoutMs: 5000
29
34
  # connection:
30
35
  # host: 127.0.0.1
31
36
  # port: 6379
37
+ # commandTimeout: 5000
32
38
  # namespace: my-app # Defaults to the application name; keep applications isolated.
33
39
  # authorizationTimeoutMs: 5000
34
40
  # sessionCheckIntervalMs: 30000
@@ -116,8 +122,7 @@ auth:
116
122
  # Set app.publicOrigin before explicitly enabling automatic password recovery.
117
123
  # passwordReset:
118
124
  # enabled: true
119
- # email:
120
- # mailer: system # omit to use mailers.default
125
+ # mailer: system # omit to preserve existing default selection
121
126
  # Existing inline auth.email and AUTH_EMAIL_* remain supported for compatibility.
122
127
  emailAndPassword:
123
128
  enabled: true
@@ -190,12 +195,13 @@ snowflake:
190
195
  # Upgrading deployments must set adapter: redis explicitly; templates no longer supply Redis defaults.
191
196
  # jobs:
192
197
  # default: redis
193
- # redis:
194
- # adapter: redis
195
- # connection:
196
- # host: 127.0.0.1
197
- # port: 6379
198
- # # namespace: my-app # defaults to the application name; set it when deployments share one Redis
198
+ # backends:
199
+ # redis:
200
+ # adapter: redis
201
+ # connection:
202
+ # host: 127.0.0.1
203
+ # port: 6379
204
+ # # namespace: my-app # defaults to the application name; set it when deployments share one Redis
199
205
 
200
206
  # The jobs configuration the Scheduler plugin's schedules run on, by name. Left out, they follow jobs.default.
201
207
  # scheduler:
package/index.html CHANGED
@@ -32,9 +32,6 @@
32
32
  transform-origin: 32px 32px;
33
33
  animation: app-bootstrap-spin 1.1s linear infinite;
34
34
  }
35
- .app-bootstrap-loading span {
36
- color: oklch(0.556 0 0);
37
- }
38
35
 
39
36
  html,
40
37
  body {
@@ -60,11 +57,6 @@
60
57
  }
61
58
  }
62
59
 
63
- @media (prefers-color-scheme: dark) {
64
- .app-bootstrap-loading span {
65
- color: oklch(0.708 0 0);
66
- }
67
- }
68
60
  @media (prefers-reduced-motion: reduce) {
69
61
  .app-bootstrap-loading .nocobase-loading-orbit {
70
62
  animation: none;
@@ -76,10 +68,16 @@
76
68
  <body>
77
69
  <noscript>You need to enable JavaScript to run this app.</noscript>
78
70
  <div id="root">
71
+ <!--
72
+ The language is unknown until the client starts, so this placeholder shows no text. A screen reader still
73
+ perceives it as an indeterminate progress bar, a role it announces in its own language, named after the
74
+ brand in <title>, which reads the same in every language. The client's localized loading, a polite status,
75
+ replaces it once the language is ready, so nothing is announced twice.
76
+ -->
79
77
  <div
80
78
  class="app-bootstrap-loading"
81
- role="status"
82
- aria-label="Loading application"
79
+ role="progressbar"
80
+ aria-label="NocoBase"
83
81
  >
84
82
  <svg
85
83
  data-slot="loading-indicator"
@@ -114,7 +112,6 @@
114
112
  </g>
115
113
  </g>
116
114
  </svg>
117
- <span>Loading application…</span>
118
115
  </div>
119
116
  </div>
120
117
  <script type="module" src="/client/index.tsx"></script>