@djangocfg/layouts 2.1.607 → 2.1.608
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.
- package/README.md +4 -0
- package/package.json +12 -12
- package/src/components/account/AccountInline.tsx +122 -0
- package/src/components/account/AccountMenu.test.tsx +110 -0
- package/src/components/account/AccountMenu.tsx +117 -0
- package/src/components/account/accountContext.ts +14 -0
- package/src/components/account/index.ts +13 -0
- package/src/components/account/triggers.tsx +100 -0
- package/src/{layouts/SettingsLayout/hooks → components/account}/useAccountMenuItems.tsx +45 -17
- package/src/components/account/useAccountModel.tsx +76 -0
- package/src/components/errors/AppErrorFallback.test.tsx +33 -0
- package/src/components/errors/AppErrorFallback.tsx +42 -0
- package/src/components/errors/ErrorBoundary.tsx +4 -41
- package/src/components/errors/ErrorLayout.tsx +17 -1
- package/src/components/errors/README.md +6 -1
- package/src/components/errors/chunkReload.test.ts +89 -0
- package/src/components/errors/index.ts +2 -0
- package/src/layouts/AppLayout/BaseApp.tsx +10 -55
- package/src/layouts/AppLayout/index.ts +1 -0
- package/src/layouts/PrivateLayout/PrivateLayout.tsx +32 -1
- package/src/layouts/PrivateLayout/README.md +22 -6
- package/src/layouts/PrivateLayout/brand.test.tsx +208 -0
- package/src/layouts/PrivateLayout/components/PrivateContent.test.tsx +176 -11
- package/src/layouts/PrivateLayout/components/PrivateContent.tsx +85 -25
- package/src/layouts/PrivateLayout/components/PrivateSidebar.tsx +4 -2
- package/src/layouts/PrivateLayout/components/PrivateSidebarAccount.tsx +16 -123
- package/src/layouts/PrivateLayout/components/PrivateSidebarTrigger.tsx +3 -3
- package/src/layouts/PrivateLayout/components/PrivateTopBarAccount.tsx +27 -0
- package/src/layouts/PrivateLayout/components/SidebarBody.test.tsx +73 -0
- package/src/layouts/PrivateLayout/components/SidebarBody.tsx +13 -4
- package/src/layouts/PrivateLayout/components/SidebarBrand.tsx +25 -14
- package/src/layouts/PrivateLayout/components/SidebarNavGroup.tsx +10 -5
- package/src/layouts/PrivateLayout/components/SidebarPeek.tsx +1 -1
- package/src/layouts/PrivateLayout/context.tsx +15 -3
- package/src/layouts/PrivateLayout/geometry.test.ts +19 -7
- package/src/layouts/PrivateLayout/geometry.ts +30 -10
- package/src/layouts/PrivateLayout/hooks/useLandingRoute.ts +16 -0
- package/src/layouts/PrivateLayout/hooks/useLayoutVisual.ts +2 -2
- package/src/layouts/PrivateLayout/hooks/useSidebarPeek.tsx +1 -1
- package/src/layouts/PrivateLayout/index.ts +2 -0
- package/src/layouts/PrivateLayout/topBar.test.tsx +213 -0
- package/src/layouts/PrivateLayout/types.ts +27 -2
- package/src/layouts/PublicLayout/components/UserMenu.tsx +24 -297
- package/src/layouts/PublicLayout/components/index.ts +2 -2
- package/src/layouts/PublicLayout/hooks/useNavbarScroll.ts +13 -7
- package/src/layouts/SettingsLayout/components/AccountMenuButton.tsx +19 -60
- package/src/layouts/SettingsLayout/hooks/index.ts +2 -2
- package/src/shared/ProjectContext.tsx +18 -0
- package/src/testing/MockAuthProvider.tsx +5 -2
- /package/src/{layouts/PublicLayout/components → components/account}/UserAvatar.tsx +0 -0
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the account menu shows, shared by every place it can live (sidebar footer, top bar avatar): the user, the
|
|
3
|
+
* display fields and the menu items. One source, so the menus can never drift apart.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
'use client';
|
|
7
|
+
|
|
8
|
+
import React from 'react';
|
|
9
|
+
|
|
10
|
+
import { useAuth } from '@djangocfg/api/auth';
|
|
11
|
+
import type { MenuItem } from '@djangocfg/ui-core/components';
|
|
12
|
+
|
|
13
|
+
import type { UserMenuGroup, UserMenuLocaleConfig } from '../../layouts/types';
|
|
14
|
+
import { useAccountMenuItems } from './useAccountMenuItems';
|
|
15
|
+
|
|
16
|
+
export interface AccountView {
|
|
17
|
+
source: 'user' | 'dev-fallback';
|
|
18
|
+
displayName: string;
|
|
19
|
+
email: string | null;
|
|
20
|
+
avatarUrl: string;
|
|
21
|
+
plan: string | null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface UseAccountModelOptions {
|
|
25
|
+
/** App link groups. */
|
|
26
|
+
groups?: UserMenuGroup[];
|
|
27
|
+
/** Plan shown after the name in the footer trigger. */
|
|
28
|
+
plan?: string | null;
|
|
29
|
+
i18n?: UserMenuLocaleConfig;
|
|
30
|
+
/** Extra items between the groups and Sign out (language, app links). */
|
|
31
|
+
extra?: MenuItem[];
|
|
32
|
+
/** Settings rows in the menu. @default true */
|
|
33
|
+
settings?: boolean;
|
|
34
|
+
/** Identity block above the items: the `email` (default) or a name + email `card`. */
|
|
35
|
+
identity?: 'email' | 'card';
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function useAccountModel({ groups, plan = null, i18n, settings = true, extra, identity = 'email' }: UseAccountModelOptions = {}) {
|
|
39
|
+
const { user } = useAuth();
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* When `useAuth` returns a real user we use it; otherwise (dev only) a placeholder keeps the menu reachable for
|
|
43
|
+
* debugging. Production stays strict: callers render nothing without a user.
|
|
44
|
+
*/
|
|
45
|
+
const account = React.useMemo<AccountView>(() => {
|
|
46
|
+
if (user) {
|
|
47
|
+
return {
|
|
48
|
+
source: 'user',
|
|
49
|
+
displayName: user.display_username || user.email || 'User',
|
|
50
|
+
email: user.email ?? null,
|
|
51
|
+
avatarUrl: user.avatar ?? '',
|
|
52
|
+
plan,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
return {
|
|
56
|
+
source: 'dev-fallback',
|
|
57
|
+
displayName: 'Guest (dev)',
|
|
58
|
+
email: null,
|
|
59
|
+
avatarUrl: '',
|
|
60
|
+
plan: plan ?? 'No session',
|
|
61
|
+
};
|
|
62
|
+
}, [user, plan]);
|
|
63
|
+
|
|
64
|
+
const headerLabel: React.ReactNode =
|
|
65
|
+
identity === 'card' ? (
|
|
66
|
+
<div className="flex flex-col space-y-1">
|
|
67
|
+
<p className="text-sm font-medium leading-none">{account.displayName}</p>
|
|
68
|
+
<p className="text-xs leading-none text-muted-foreground">{account.email}</p>
|
|
69
|
+
</div>
|
|
70
|
+
) : (
|
|
71
|
+
account.email ?? (account.source === 'dev-fallback' ? 'No active session' : null)
|
|
72
|
+
);
|
|
73
|
+
const menuItems = useAccountMenuItems({ groups, label: headerLabel, i18n, settings, extra });
|
|
74
|
+
|
|
75
|
+
return { user, account, menuItems };
|
|
76
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import React, { act } from 'react';
|
|
3
|
+
import { createRoot } from 'react-dom/client';
|
|
4
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
5
|
+
|
|
6
|
+
import { AppErrorFallback } from './AppErrorFallback';
|
|
7
|
+
|
|
8
|
+
vi.mock('@djangocfg/i18n', () => ({
|
|
9
|
+
getT: (key: string) => key,
|
|
10
|
+
}));
|
|
11
|
+
|
|
12
|
+
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
13
|
+
|
|
14
|
+
function render(props = {}) {
|
|
15
|
+
const host = document.createElement('div');
|
|
16
|
+
act(() => createRoot(host).render(<AppErrorFallback {...props} />));
|
|
17
|
+
return host;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe('AppErrorFallback', () => {
|
|
21
|
+
it('renders exactly one action button', () => {
|
|
22
|
+
const host = render();
|
|
23
|
+
const buttons = host.querySelectorAll('button');
|
|
24
|
+
expect(buttons).toHaveLength(1);
|
|
25
|
+
expect(buttons[0]!.textContent).toBe('layouts.errors.refreshPage');
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('keeps one button when a support email is shown', () => {
|
|
29
|
+
const host = render({ supportEmail: 'help@example.com' });
|
|
30
|
+
expect(host.querySelectorAll('button')).toHaveLength(1);
|
|
31
|
+
expect(host.querySelector('a[href="mailto:help@example.com"]')).not.toBeNull();
|
|
32
|
+
});
|
|
33
|
+
});
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AppErrorFallback — the one localized crash screen for Next app layouts.
|
|
3
|
+
*
|
|
4
|
+
* Exactly ONE action: "Refresh Page" (hard reload). A soft `reset()` re-mounts
|
|
5
|
+
* the same broken tree and usually crashes again, so it is not offered as a
|
|
6
|
+
* second near-identical button. Used by `BaseApp` (via UiProviders) and
|
|
7
|
+
* `ErrorBoundary`.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
'use client';
|
|
11
|
+
|
|
12
|
+
import { getT } from '@djangocfg/i18n';
|
|
13
|
+
|
|
14
|
+
export interface AppErrorFallbackProps {
|
|
15
|
+
supportEmail?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function AppErrorFallback({ supportEmail }: AppErrorFallbackProps) {
|
|
19
|
+
return (
|
|
20
|
+
<div className="flex min-h-screen items-center justify-center bg-background p-4">
|
|
21
|
+
<div className="max-w-md w-full space-y-4 text-center">
|
|
22
|
+
<h1 className="text-2xl font-bold text-foreground">{getT('layouts.errors.somethingWentWrong')}</h1>
|
|
23
|
+
<p className="text-muted-foreground">{getT('layouts.errors.tryRefreshing')}</p>
|
|
24
|
+
{supportEmail && (
|
|
25
|
+
<p className="text-sm text-muted-foreground">
|
|
26
|
+
{getT('layouts.errors.persistsContact', { email: '' })}{' '}
|
|
27
|
+
<a href={`mailto:${supportEmail}`} className="text-primary hover:underline">
|
|
28
|
+
{supportEmail}
|
|
29
|
+
</a>
|
|
30
|
+
</p>
|
|
31
|
+
)}
|
|
32
|
+
<button
|
|
33
|
+
type="button"
|
|
34
|
+
onClick={() => window.location.reload()}
|
|
35
|
+
className="rounded-md bg-primary px-4 py-2 text-primary-foreground hover:bg-primary/90"
|
|
36
|
+
>
|
|
37
|
+
{getT('layouts.errors.refreshPage')}
|
|
38
|
+
</button>
|
|
39
|
+
</div>
|
|
40
|
+
</div>
|
|
41
|
+
);
|
|
42
|
+
}
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
import { useMemo, type ComponentType, type ErrorInfo, type ReactNode } from 'react';
|
|
14
14
|
|
|
15
15
|
import { Boundary, type BoundaryRenderProps } from '@djangocfg/ui-core/components';
|
|
16
|
-
import {
|
|
16
|
+
import { AppErrorFallback } from './AppErrorFallback';
|
|
17
17
|
|
|
18
18
|
interface ErrorBoundaryProps {
|
|
19
19
|
children: ReactNode;
|
|
@@ -21,50 +21,13 @@ interface ErrorBoundaryProps {
|
|
|
21
21
|
onError?: (error: Error, errorInfo: ErrorInfo) => void;
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
-
/** Localized fullscreen fallback. `reset()` re-mounts the tree (ui-core); we
|
|
25
|
-
* also offer a hard reload, matching the prior behavior. */
|
|
26
|
-
function makeFallback(supportEmail?: string) {
|
|
27
|
-
return function ErrorFallback({ reset }: BoundaryRenderProps) {
|
|
28
|
-
const title = getT('layouts.errors.somethingWentWrong');
|
|
29
|
-
const description = getT('layouts.errors.tryRefreshing');
|
|
30
|
-
const refreshButton = getT('layouts.errors.refreshPage');
|
|
31
|
-
return (
|
|
32
|
-
<div className="flex min-h-screen items-center justify-center bg-background p-4">
|
|
33
|
-
<div className="max-w-md w-full space-y-4 text-center">
|
|
34
|
-
<h1 className="text-2xl font-bold text-foreground">{title}</h1>
|
|
35
|
-
<p className="text-muted-foreground">{description}</p>
|
|
36
|
-
{supportEmail && (
|
|
37
|
-
<p className="text-sm text-muted-foreground">
|
|
38
|
-
{getT('layouts.errors.persistsContact', { email: '' })}{' '}
|
|
39
|
-
<a
|
|
40
|
-
href={`mailto:${supportEmail}`}
|
|
41
|
-
className="text-primary hover:underline"
|
|
42
|
-
>
|
|
43
|
-
{supportEmail}
|
|
44
|
-
</a>
|
|
45
|
-
</p>
|
|
46
|
-
)}
|
|
47
|
-
<button
|
|
48
|
-
onClick={() => {
|
|
49
|
-
reset();
|
|
50
|
-
// Hard reload as a fallback if a pure reset can't recover.
|
|
51
|
-
window.location.reload();
|
|
52
|
-
}}
|
|
53
|
-
className="px-4 py-2 bg-primary text-primary-foreground rounded-md hover:bg-primary/90"
|
|
54
|
-
>
|
|
55
|
-
{refreshButton}
|
|
56
|
-
</button>
|
|
57
|
-
</div>
|
|
58
|
-
</div>
|
|
59
|
-
);
|
|
60
|
-
};
|
|
61
|
-
}
|
|
62
|
-
|
|
63
24
|
export function ErrorBoundary({ children, supportEmail, onError }: ErrorBoundaryProps) {
|
|
64
25
|
// Memoize so the boundary's FallbackComponent identity is stable across
|
|
65
26
|
// renders (no remount churn).
|
|
66
27
|
const Fallback = useMemo<ComponentType<BoundaryRenderProps>>(
|
|
67
|
-
() =>
|
|
28
|
+
() => function ErrorFallback() {
|
|
29
|
+
return <AppErrorFallback supportEmail={supportEmail} />;
|
|
30
|
+
},
|
|
68
31
|
[supportEmail],
|
|
69
32
|
);
|
|
70
33
|
return (
|
|
@@ -17,9 +17,10 @@
|
|
|
17
17
|
|
|
18
18
|
'use client';
|
|
19
19
|
|
|
20
|
-
import React, { useMemo } from 'react';
|
|
20
|
+
import React, { useEffect, useMemo } from 'react';
|
|
21
21
|
|
|
22
22
|
import { Button } from '@djangocfg/ui-core/components';
|
|
23
|
+
import { canAutoReloadForChunk, isChunkLoadError, reloadForChunkError } from '@djangocfg/ui-core/lib';
|
|
23
24
|
import { useAppT } from '@djangocfg/i18n';
|
|
24
25
|
|
|
25
26
|
import { getErrorContent } from './errorConfig';
|
|
@@ -54,6 +55,14 @@ export interface ErrorLayoutProps {
|
|
|
54
55
|
* it finally matters.
|
|
55
56
|
*/
|
|
56
57
|
statusUrl?: string;
|
|
58
|
+
/**
|
|
59
|
+
* The caught error (`global-error.tsx` / `error.tsx` receive it as `error`).
|
|
60
|
+
* When it is a stale-chunk failure (ChunkLoadError, cancelled `/_next/static`
|
|
61
|
+
* fetch after a deploy) the page reloads ONCE (sessionStorage loop guard) and
|
|
62
|
+
* renders nothing meanwhile; if the reload already happened, the normal error
|
|
63
|
+
* screen is shown.
|
|
64
|
+
*/
|
|
65
|
+
error?: unknown;
|
|
57
66
|
}
|
|
58
67
|
|
|
59
68
|
/**
|
|
@@ -82,8 +91,13 @@ export function ErrorLayout({
|
|
|
82
91
|
illustration,
|
|
83
92
|
supportEmail = 'support@example.com',
|
|
84
93
|
statusUrl,
|
|
94
|
+
error,
|
|
85
95
|
}: ErrorLayoutProps) {
|
|
86
96
|
const t = useAppT();
|
|
97
|
+
const staleChunk = error !== undefined && isChunkLoadError(error);
|
|
98
|
+
useEffect(() => {
|
|
99
|
+
if (staleChunk) reloadForChunkError();
|
|
100
|
+
}, [staleChunk]);
|
|
87
101
|
|
|
88
102
|
const labels = useMemo(() => ({
|
|
89
103
|
goBack: t('layouts.errors.goBack'),
|
|
@@ -121,6 +135,8 @@ export function ErrorLayout({
|
|
|
121
135
|
window.location.href = '/';
|
|
122
136
|
};
|
|
123
137
|
|
|
138
|
+
if (staleChunk && canAutoReloadForChunk()) return null;
|
|
139
|
+
|
|
124
140
|
return (
|
|
125
141
|
<div className="min-h-screen flex items-center justify-center px-4 bg-background">
|
|
126
142
|
<div className="max-w-2xl w-full text-center space-y-8">
|
|
@@ -11,11 +11,16 @@ All boundaries here build on the single `Boundary` primitive in
|
|
|
11
11
|
|
|
12
12
|
| File | What it does |
|
|
13
13
|
|------|--------------|
|
|
14
|
-
| `
|
|
14
|
+
| `AppErrorFallback.tsx` | The one localized crash screen: title, text, support email, ONE "Refresh Page" button (hard reload). Used by `BaseApp` and `ErrorBoundary`. |
|
|
15
|
+
| `ErrorBoundary.tsx` | Thin wrapper over ui-core `Boundary` — renders `AppErrorFallback` (for direct use; `BaseApp` no longer wraps it). |
|
|
15
16
|
| `MonitorBoundary.tsx` | ui-core `Boundary` that also reports to `@djangocfg/devtools`. |
|
|
16
17
|
| `ErrorLayout.tsx` | Full-page error layout (`getErrorContent` / `ERROR_CODES`). |
|
|
17
18
|
| `ErrorsTracker/` | Global runtime tracker — listens for error CustomEvents and shows toasts. |
|
|
18
19
|
|
|
20
|
+
## Stale chunks after a deploy
|
|
21
|
+
|
|
22
|
+
A chunk-load failure (ChunkLoadError, "Loading chunk N failed", dynamic import failure, cancelled fetch of `/_next/static/...`) is a stale tab, not a crash: the page reloads ONCE instead of showing the error screen. Logic lives in `@djangocfg/ui-core/lib` (`chunk-reload.ts`), wired in `Boundary` (render errors), `UiProviders` (window `error` + `unhandledrejection`) and `ErrorLayout error={error}` (`global-error.tsx`). Loop guard: `sessionStorage` key `djangocfg:chunk-reload-at`, 30 s window; with storage blocked no auto reload. Tests: `chunkReload.test.ts`, `AppErrorFallback.test.tsx`.
|
|
23
|
+
|
|
19
24
|
## ErrorLayout — `statusUrl`
|
|
20
25
|
|
|
21
26
|
`ErrorLayout` takes an optional `statusUrl`, rendered as a secondary link beside
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
CHUNK_RELOAD_STORAGE_KEY,
|
|
6
|
+
CHUNK_RELOAD_WINDOW_MS,
|
|
7
|
+
canAutoReloadForChunk,
|
|
8
|
+
installChunkReloadListeners,
|
|
9
|
+
isChunkLoadError,
|
|
10
|
+
isStaticAssetLoadEvent,
|
|
11
|
+
reloadForChunkError,
|
|
12
|
+
} from '@djangocfg/ui-core/lib';
|
|
13
|
+
|
|
14
|
+
describe('isChunkLoadError', () => {
|
|
15
|
+
it.each([
|
|
16
|
+
['ChunkLoadError by name', Object.assign(new Error('x'), { name: 'ChunkLoadError' })],
|
|
17
|
+
['webpack JS chunk', new Error('Loading chunk 123 failed.\n(error: /_next/static/chunks/123.js)')],
|
|
18
|
+
['webpack CSS chunk', new Error('Loading CSS chunk 45 failed.')],
|
|
19
|
+
['Chrome dynamic import', new TypeError('Failed to fetch dynamically imported module: /_next/static/a.js')],
|
|
20
|
+
['Firefox dynamic import', new TypeError('error loading dynamically imported module')],
|
|
21
|
+
['Safari module script', new TypeError('Importing a module script failed.')],
|
|
22
|
+
['cancelled fetch of static asset', new TypeError('Failed to fetch /_next/static/chunks/app/page-abc.js')],
|
|
23
|
+
['aborted static asset via stack', Object.assign(new Error('signal is aborted'), { stack: 'at https://x.io/_next/static/chunks/1.js:1' })],
|
|
24
|
+
['string reason', 'Loading chunk 9 failed.'],
|
|
25
|
+
])('detects %s', (_n, err) => expect(isChunkLoadError(err)).toBe(true));
|
|
26
|
+
|
|
27
|
+
it.each([
|
|
28
|
+
['ordinary error', new Error('Cannot read properties of undefined')],
|
|
29
|
+
['API fetch failure', new TypeError('Failed to fetch https://api.example.com/items')],
|
|
30
|
+
['null', null],
|
|
31
|
+
['undefined', undefined],
|
|
32
|
+
])('ignores %s', (_n, err) => expect(isChunkLoadError(err)).toBe(false));
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
describe('isStaticAssetLoadEvent', () => {
|
|
36
|
+
it('matches failed script/link under /_next/static and nothing else', () => {
|
|
37
|
+
const script = document.createElement('script');
|
|
38
|
+
script.src = 'https://x.io/_next/static/chunks/1.js';
|
|
39
|
+
const other = document.createElement('script');
|
|
40
|
+
other.src = 'https://cdn.io/lib.js';
|
|
41
|
+
const link = document.createElement('link');
|
|
42
|
+
link.href = 'https://x.io/_next/static/css/a.css';
|
|
43
|
+
const mk = (target: Element) => Object.defineProperty(new Event('error'), 'target', { value: target });
|
|
44
|
+
expect(isStaticAssetLoadEvent(mk(script))).toBe(true);
|
|
45
|
+
expect(isStaticAssetLoadEvent(mk(link))).toBe(true);
|
|
46
|
+
expect(isStaticAssetLoadEvent(mk(other))).toBe(false);
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe('reload guard', () => {
|
|
51
|
+
const reload = vi.fn();
|
|
52
|
+
beforeEach(() => {
|
|
53
|
+
sessionStorage.clear();
|
|
54
|
+
reload.mockClear();
|
|
55
|
+
Object.defineProperty(window, 'location', { configurable: true, value: { ...window.location, reload } });
|
|
56
|
+
});
|
|
57
|
+
afterEach(() => vi.restoreAllMocks());
|
|
58
|
+
|
|
59
|
+
it('reloads once, then blocks a second reload inside the window', () => {
|
|
60
|
+
expect(reloadForChunkError(1_000)).toBe(true);
|
|
61
|
+
expect(sessionStorage.getItem(CHUNK_RELOAD_STORAGE_KEY)).toBe('1000');
|
|
62
|
+
expect(reloadForChunkError(1_000 + 5_000)).toBe(false);
|
|
63
|
+
expect(reload).toHaveBeenCalledTimes(1);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('allows another reload after the window passes', () => {
|
|
67
|
+
reloadForChunkError(1_000);
|
|
68
|
+
expect(canAutoReloadForChunk(1_000 + CHUNK_RELOAD_WINDOW_MS + 1)).toBe(true);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('never reloads when sessionStorage is unusable (no loop risk)', () => {
|
|
72
|
+
vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
|
|
73
|
+
throw new Error('denied');
|
|
74
|
+
});
|
|
75
|
+
expect(canAutoReloadForChunk()).toBe(false);
|
|
76
|
+
expect(reloadForChunkError()).toBe(false);
|
|
77
|
+
expect(reload).not.toHaveBeenCalled();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('window listeners reload once for an unhandled chunk rejection', () => {
|
|
81
|
+
const off = installChunkReloadListeners();
|
|
82
|
+
const ev = new Event('unhandledrejection') as Event & { reason?: unknown };
|
|
83
|
+
ev.reason = new Error('Loading chunk 7 failed.');
|
|
84
|
+
window.dispatchEvent(ev);
|
|
85
|
+
window.dispatchEvent(ev);
|
|
86
|
+
expect(reload).toHaveBeenCalledTimes(1);
|
|
87
|
+
off();
|
|
88
|
+
});
|
|
89
|
+
});
|
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
export { ErrorBoundary } from './ErrorBoundary';
|
|
6
|
+
export { AppErrorFallback } from './AppErrorFallback';
|
|
7
|
+
export type { AppErrorFallbackProps } from './AppErrorFallback';
|
|
6
8
|
export { MonitorBoundary } from './MonitorBoundary';
|
|
7
9
|
export type { MonitorBoundaryProps } from './MonitorBoundary';
|
|
8
10
|
export { ErrorLayout } from './ErrorLayout';
|
|
@@ -54,65 +54,20 @@ import { useScrollToTop } from '@djangocfg/ui-core/hooks';
|
|
|
54
54
|
import { NextRouterAdapter, NextLinkProvider } from '@djangocfg/ui-core/adapters/nextjs';
|
|
55
55
|
import { NextIntlLinkBridge } from './NextIntlLinkBridge';
|
|
56
56
|
import { ThemeProvider } from '@djangocfg/ui-core/theme';
|
|
57
|
-
import
|
|
58
|
-
import { getT } from '@djangocfg/i18n';
|
|
57
|
+
import { AppErrorFallback } from '../../components/errors/AppErrorFallback';
|
|
59
58
|
import { ErrorTrackingProvider } from '../../components/errors/ErrorsTracker';
|
|
60
59
|
import { AnalyticsProvider } from '../../snippets/Analytics';
|
|
61
60
|
import { AuthDialog } from '../../snippets/AuthDialog';
|
|
62
61
|
|
|
63
62
|
import type { BaseLayoutProps } from '../types/layout.types';
|
|
64
63
|
import { LayoutI18nProvider } from '../../shared/LayoutI18nProvider';
|
|
64
|
+
import { ProjectNameProvider } from '../../shared/ProjectContext';
|
|
65
65
|
|
|
66
66
|
import React from 'react';
|
|
67
67
|
|
|
68
68
|
// For backwards compatibility, re-export as BaseAppProps
|
|
69
69
|
export type BaseAppProps = BaseLayoutProps;
|
|
70
70
|
|
|
71
|
-
/**
|
|
72
|
-
* Localized crash fallback fed into UiProviders' built-in Boundary. Renders the
|
|
73
|
-
* i18n'd "something went wrong" screen + optional support email. `reset()`
|
|
74
|
-
* re-mounts (soft recover); the Reload button hard-reloads when a soft reset
|
|
75
|
-
* can't recover (e.g. the crash is in a provider above the boundary's subtree).
|
|
76
|
-
*/
|
|
77
|
-
function AppErrorFallback({
|
|
78
|
-
reset,
|
|
79
|
-
supportEmail,
|
|
80
|
-
}: BoundaryRenderProps & { supportEmail?: string }) {
|
|
81
|
-
const title = getT('layouts.errors.somethingWentWrong');
|
|
82
|
-
const description = getT('layouts.errors.tryRefreshing');
|
|
83
|
-
const retry = getT('layouts.errors.refreshPage');
|
|
84
|
-
return (
|
|
85
|
-
<div className="flex min-h-screen items-center justify-center bg-background p-4">
|
|
86
|
-
<div className="max-w-md w-full space-y-4 text-center">
|
|
87
|
-
<h1 className="text-2xl font-bold text-foreground">{title}</h1>
|
|
88
|
-
<p className="text-muted-foreground">{description}</p>
|
|
89
|
-
{supportEmail && (
|
|
90
|
-
<p className="text-sm text-muted-foreground">
|
|
91
|
-
{getT('layouts.errors.persistsContact', { email: '' })}{' '}
|
|
92
|
-
<a href={`mailto:${supportEmail}`} className="text-primary hover:underline">
|
|
93
|
-
{supportEmail}
|
|
94
|
-
</a>
|
|
95
|
-
</p>
|
|
96
|
-
)}
|
|
97
|
-
<div className="flex items-center justify-center gap-2">
|
|
98
|
-
<button
|
|
99
|
-
onClick={reset}
|
|
100
|
-
className="rounded-md bg-primary px-4 py-2 text-primary-foreground hover:bg-primary/90"
|
|
101
|
-
>
|
|
102
|
-
{retry}
|
|
103
|
-
</button>
|
|
104
|
-
<button
|
|
105
|
-
onClick={() => window.location.reload()}
|
|
106
|
-
className="rounded-md border border-border px-4 py-2 text-foreground hover:bg-muted"
|
|
107
|
-
>
|
|
108
|
-
{getT('layouts.errors.refreshPage')}
|
|
109
|
-
</button>
|
|
110
|
-
</div>
|
|
111
|
-
</div>
|
|
112
|
-
</div>
|
|
113
|
-
);
|
|
114
|
-
}
|
|
115
|
-
|
|
116
71
|
/**
|
|
117
72
|
* Lands every forward navigation at the top of the page.
|
|
118
73
|
*
|
|
@@ -189,9 +144,7 @@ export function BaseApp({
|
|
|
189
144
|
onError={errorBoundary?.onError}
|
|
190
145
|
errorFallback={
|
|
191
146
|
enableErrorBoundary
|
|
192
|
-
? (
|
|
193
|
-
<AppErrorFallback {...props} supportEmail={errorBoundary?.supportEmail} />
|
|
194
|
-
)
|
|
147
|
+
? () => <AppErrorFallback supportEmail={errorBoundary?.supportEmail} />
|
|
195
148
|
: undefined
|
|
196
149
|
}
|
|
197
150
|
>
|
|
@@ -214,11 +167,13 @@ export function BaseApp({
|
|
|
214
167
|
<ScrollToTop />
|
|
215
168
|
<Devtools {...devtoolsProps} />
|
|
216
169
|
<LayoutI18nProvider value={i18n}>
|
|
217
|
-
{
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
170
|
+
<ProjectNameProvider value={project}>
|
|
171
|
+
{i18n?.routing ? (
|
|
172
|
+
<NextIntlLinkBridge routing={i18n.routing}>{children}</NextIntlLinkBridge>
|
|
173
|
+
) : (
|
|
174
|
+
children
|
|
175
|
+
)}
|
|
176
|
+
</ProjectNameProvider>
|
|
222
177
|
</LayoutI18nProvider>
|
|
223
178
|
<NextTopLoader
|
|
224
179
|
color="var(--primary)"
|
|
@@ -14,7 +14,13 @@
|
|
|
14
14
|
|
|
15
15
|
import React from 'react';
|
|
16
16
|
|
|
17
|
+
import { useAuth } from '@djangocfg/api/auth';
|
|
18
|
+
import { useLandingRoute } from './hooks/useLandingRoute';
|
|
19
|
+
import { useProjectName } from '../../shared/ProjectContext';
|
|
17
20
|
import { Preloader } from '@djangocfg/ui-core/components';
|
|
21
|
+
import { cn } from '@djangocfg/ui-core/lib';
|
|
22
|
+
|
|
23
|
+
import { SHELL_CLASS } from './geometry';
|
|
18
24
|
import { SidebarInset, SidebarProvider } from '@djangocfg/ui-core/components';
|
|
19
25
|
|
|
20
26
|
import { ForbiddenState, PrivateContent, PrivateSidebar } from './components';
|
|
@@ -55,6 +61,7 @@ export function PrivateLayout({
|
|
|
55
61
|
children,
|
|
56
62
|
sidebar,
|
|
57
63
|
header,
|
|
64
|
+
topBar,
|
|
58
65
|
pathname,
|
|
59
66
|
contentPadding = 'default',
|
|
60
67
|
contentScroll = 'auto',
|
|
@@ -73,9 +80,26 @@ export function PrivateLayout({
|
|
|
73
80
|
const { providerStyle, providerClassName, insetClassName, sidebarVariant } =
|
|
74
81
|
useLayoutVisual(visual);
|
|
75
82
|
|
|
83
|
+
// The account avatar lives in the top bar for a signed-in user unless `topBar.account === false`.
|
|
84
|
+
const { user } = useAuth();
|
|
85
|
+
// Bar title: topBar.title > header.title > the BaseApp project name > nothing. Only the last two link home.
|
|
86
|
+
const projectName = useProjectName();
|
|
87
|
+
const landing = useLandingRoute();
|
|
88
|
+
const barTitle = topBar?.title ?? (header?.title?.trim() || projectName || undefined);
|
|
89
|
+
const barHref = header?.href || sidebar?.homeHref || landing || '/';
|
|
90
|
+
const accountInBar = Boolean(user) && topBar?.account !== false;
|
|
91
|
+
|
|
76
92
|
const defaultOpen = useSidebarDefaultOpen();
|
|
77
93
|
const sidebarWidth = useSidebarWidth(Boolean(sidebar) && sidebar?.resizable !== false);
|
|
78
94
|
|
|
95
|
+
// Marks <html> while a private shell is mounted: base.css locks html/body (no document scroll, no rubber band)
|
|
96
|
+
// in an installed app, for private pages only. Public pages never carry it and stay scrollable.
|
|
97
|
+
React.useEffect(() => {
|
|
98
|
+
const root = document.documentElement;
|
|
99
|
+
root.setAttribute('data-private-shell', '');
|
|
100
|
+
return () => root.removeAttribute('data-private-shell');
|
|
101
|
+
}, []);
|
|
102
|
+
|
|
79
103
|
if (isLoading) {
|
|
80
104
|
return (
|
|
81
105
|
<Preloader
|
|
@@ -99,7 +123,7 @@ export function PrivateLayout({
|
|
|
99
123
|
<SidebarProvider
|
|
100
124
|
defaultOpen={defaultOpen}
|
|
101
125
|
style={{ ...providerStyle, ...sidebarWidth.style }}
|
|
102
|
-
className={providerClassName}
|
|
126
|
+
className={cn(SHELL_CLASS, providerClassName)}
|
|
103
127
|
data-sidebar-resizing={sidebarWidth.dragging || undefined}
|
|
104
128
|
>
|
|
105
129
|
<SidebarWidthContext.Provider value={sidebarWidth}>
|
|
@@ -110,6 +134,7 @@ export function PrivateLayout({
|
|
|
110
134
|
header={header}
|
|
111
135
|
pathname={pathname}
|
|
112
136
|
variant={sidebarVariant}
|
|
137
|
+
accountInBar={accountInBar}
|
|
113
138
|
/>
|
|
114
139
|
)}
|
|
115
140
|
|
|
@@ -118,6 +143,12 @@ export function PrivateLayout({
|
|
|
118
143
|
padding={contentPadding}
|
|
119
144
|
scroll={contentScroll}
|
|
120
145
|
hasSidebar={Boolean(sidebar)}
|
|
146
|
+
pathname={pathname}
|
|
147
|
+
title={barTitle}
|
|
148
|
+
titleHref={topBar?.title == null && barTitle ? barHref : undefined}
|
|
149
|
+
actions={topBar?.actions}
|
|
150
|
+
showAccount={accountInBar}
|
|
151
|
+
header={header}
|
|
121
152
|
visual={visual}
|
|
122
153
|
>
|
|
123
154
|
{children}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# PrivateLayout
|
|
2
2
|
|
|
3
|
-
Authenticated app shell — a docked, resizable sidebar + scrollable content area. Collapsed, a
|
|
3
|
+
Authenticated app shell — a docked, resizable sidebar + scrollable content area. Collapsed, a top bar remains and resting at the left screen edge peeks the sidebar open over the page (the toggle itself only clicks); on mobile it is a drawer.
|
|
4
4
|
|
|
5
5
|
```tsx
|
|
6
6
|
<PrivateLayout
|
|
@@ -137,7 +137,7 @@ For pages like Kanban boards where the shell must be exactly one viewport tall a
|
|
|
137
137
|
| Field | Type | Notes |
|
|
138
138
|
|---|---|---|
|
|
139
139
|
| `groups` | `SidebarGroupConfig[]` | Nav items grouped under labels. Set `group.label = ''` to render flat (no header). |
|
|
140
|
-
| `homeHref` | `string` | Brand link target. |
|
|
140
|
+
| `homeHref` | `string` | Brand link target. Order: `header.href` > `sidebar.homeHref` > auth `routes.defaultCallback` (the private landing page set in `BaseApp` `auth.routes`) > `/`. |
|
|
141
141
|
| `menuStart` / `menuEnd` | `ReactNode` | Free slots above / below `groups` in the scrollable column. |
|
|
142
142
|
| `menuStartShowOnCollapsed` / `menuEndShowOnCollapsed` | `boolean` | Deprecated, no effect (no icon rail). |
|
|
143
143
|
| `resizable` | `boolean` | Drag the sidebar edge (14–24rem, remembered under `cfg.layouts.sidebar-width`; double-click resets). Default `true`. |
|
|
@@ -163,7 +163,7 @@ For pages like Kanban boards where the shell must be exactly one viewport tall a
|
|
|
163
163
|
|
|
164
164
|
| Field | Type | Notes |
|
|
165
165
|
|---|---|---|
|
|
166
|
-
| `label`, `href`, `icon
|
|
166
|
+
| `label`, `href`, `icon` | — | Standard nav item fields (`tooltip` is deprecated, no effect). |
|
|
167
167
|
| `settingsSection` | section id | Instead of `href`: a row that opens the Settings dialog on that section. Rendered only while the mounted dialog lists the section (no dead door), active while the dialog shows it. Type it with `SidebarItem<AppSectionId>` (see [SettingsLayout](../SettingsLayout/README.md#typed-section-ids)). |
|
|
168
168
|
| `badge` | `string \| number` | Trailing badge content. |
|
|
169
169
|
| `badgeVariant` | `'count' \| 'pill'` | Visual style. `'pill'` is accent-tinted (e.g. "lite"/"new"); `'count'` is neutral (default). |
|
|
@@ -279,7 +279,23 @@ const header: HeaderConfig = {
|
|
|
279
279
|
| State | Behaviour |
|
|
280
280
|
|---|---|
|
|
281
281
|
| Docked | Width from `--sidebar-width`; drag the right edge, arrow keys, double-click to reset. |
|
|
282
|
-
| Collapsed (desktop) | Hidden.
|
|
283
|
-
| Mobile (< md) | Drawer
|
|
282
|
+
| Collapsed (desktop) | Hidden. The top bar (toggle top-left, title, actions, avatar) shows; resting the pointer at the left screen edge (not on the toggle) floats the sidebar over the page. The panel stays while the pointer is on it or a menu opened from it is open; Esc, navigation and moving away close it. Clicking the trigger docks it. |
|
|
283
|
+
| Mobile (< md) | Drawer 88vw wide (max 22rem, the dimmed strip taps it closed) and full height (`fixed inset-y-0`: the real visible area in a browser tab and in an iOS standalone PWA): 40px rows (44px hit area), 14px text, 20px icons, 8px container gutter + 10px row padding (icon column and logo at 18px; dividers, highlighted rows and footer share the 8px gutter), header is the brand only (48px under safe-top, no toggle or close button; it closes by backdrop tap, swipe, Escape or navigating). Three zones: pinned header (gets a bottom hairline once the nav scrolls), the nav as the ONLY scroller (`overscroll-contain`), pinned footer with a full-width top border and its own background; the drawer panel itself is `overflow-hidden` (`DrawerContent bare`). Header and footer respect the safe areas on top, bottom and sides (at least 1rem vertically). On a phone (below md) the layout draws a plain top bar: `safe-top + 3rem` tall (`--layout-bar-height`, `0px` from md up and without a sidebar), `bg-background` with a 1px bottom border, holding the sidebar toggle (the same ghost button as in the sidebar header) and `header.title`. It overlays the content column and slides away (`transform`, 200ms) on scroll-down and back on scroll-up or at the top, using the same `useNavbarScroll` policy as `PublicLayout` driven by the content scroller; it stays while the drawer is open, a field has focus, or the page is not scrollable. Content starts below the bar for every page, so pages do nothing; apps may use `--layout-bar-height` to offset their own sticky elements. `PrivateLayout` takes `topBar={{ title, actions }}` (types: `PrivateTopBarConfig`): `title` replaces `header.title` in the bar, `actions` is a ReactNode on the right (vertically centred, never shrinks the toggle; the title takes the rest and truncates). The same bar shows on desktop while the sidebar is collapsed (static, no auto-hide) and carries the account avatar for a signed-in user (`topBar.account={false}` to opt out); on a phone the drawer then has no footer account. Recommended actions are compact ghost icon `Button`s (36px); several are fine, keep them few. The toggle stays left and is not configurable. It is rendered by the phone bar only; desktop shows nothing (the sidebar is the navigation). |
|
|
284
284
|
|
|
285
|
-
Row metrics (`geometry.ts`): desktop rows 36px, mobile rows
|
|
285
|
+
Row metrics (`geometry.ts`): desktop rows 36px, mobile rows 40px (with a 44px hit area from an invisible `::after`), both `text-sm` with 16px (desktop) or 20px (mobile) icons and group labels in `text-xs`. Safe-area insets only exist when the host app sets `viewportFit: "cover"` in its Next.js `viewport` export; without it the offsets fall back to the fixed minimums. For an installed iOS PWA also set `appleWebApp.statusBarStyle` (`black-translucent` draws the page under the status bar and relies on the insets; `default` keeps it below). The drawer height is pinned with `inset-y-0` rather than `100dvh`, which can disagree with the visible area in standalone mode.
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
## Installed app (PWA) support
|
|
289
|
+
|
|
290
|
+
- Detection: `useDisplayMode()` / `useIsPwa()` from `@djangocfg/ui-core/hooks` (the `display-mode` media query, iOS `navigator.standalone`, Android TWA referrer). The server and hydration render say `browser`; for first-paint styling use CSS: `UiProviders` sets `<html data-display-mode="browser|standalone|fullscreen|minimal-ui|window-controls-overlay">`.
|
|
291
|
+
- Tokens (ui-core `base.css`): `--safe-top/right/bottom/left` (= `--app-safe-*` if set, else `env(safe-area-inset-*)`), `--app-height: 100dvh`. Tokens are for layout and spacing; the hook is for behaviour (hide an "install app" prompt, show a back button).
|
|
292
|
+
- Host requirements for a good PWA: `viewport-fit=cover` (`viewport.viewportFit = 'cover'`), `appleWebApp: { capable: true, statusBarStyle }` (`black-translucent` draws the page under the status bar and needs the insets; `default` keeps it below), a manifest with `display: 'standalone'`, and `themeColor`. `createViewport({ themeColor, viewportFit: 'cover' })` and `createManifestMetadata({ ..., statusBarStyle })` from `@djangocfg/nextjs` set them.
|
|
293
|
+
- Storybook: toolbar items "Display mode" and "Safe area" emulate an installed iPhone; see the PhonePwa* stories.
|
|
294
|
+
|
|
295
|
+
## The shell never scrolls the document
|
|
296
|
+
|
|
297
|
+
`PrivateLayout` is exactly one dynamic viewport tall (`h-[var(--app-height,100dvh)] overflow-hidden`, `SHELL_CLASS`): the (desktop) trigger bar is `shrink-0`, the content below it is `flex-1 min-h-0` and owns the scroll (per `contentScroll`). While mounted it sets `data-private-shell` on `<html>`; in an installed app base.css then locks `html`/`body` (`height: 100%; overflow: hidden; overscroll-behavior: none`), so pressing and dragging the top bar cannot move the page. Public pages never carry the attribute and stay scrollable. Consequences for pages: sticky elements and `window.scrollTo` now relate to the content region, not the window; the layout scrolls its content back to the top when `pathname` changes. Do not wrap the layout in `min-h-screen` / `h-screen`. On iOS < 16, `overscroll-behavior` is ignored, so a rubber band at the very edge can only be ruled out on a real device.
|
|
298
|
+
|
|
299
|
+
## Brand title and link
|
|
300
|
+
|
|
301
|
+
The product name is semibold (16px on desktop, 14px on a phone, theme type scale), one line, truncated. Title order: `header.title` > `BaseApp` `project` name > none (monogram only). The brand (sidebar header, phone drawer header) and the default top bar title link to `header.href` (default `sidebar.homeHref`, else `/`) through the router `Link` (target order: `header.href` > `sidebar.homeHref` > auth `defaultCallback` > `/`); the drawer closes on click. A custom `header.brand` node is not wrapped; `topBar.title` is shown as given.
|