@djangocfg/layouts 2.1.606 → 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/AuthLayout/components/oauth/OAuthSection.tsx +17 -11
- package/src/layouts/AuthLayout/components/oauth/errors/errorCopy.ts +1 -1
- package/src/layouts/AuthLayout/components/oauth/notice/OAuthNotice.tsx +1 -4
- package/src/layouts/AuthLayout/components/oauth/notice/OAuthNoticeView.test.tsx +17 -3
- package/src/layouts/AuthLayout/components/oauth/notice/OAuthNoticeView.tsx +21 -25
- package/src/layouts/AuthLayout/components/steps/IdentifierStep.tsx +2 -2
- package/src/layouts/AuthLayout/styles/oauth.css +45 -0
- package/src/layouts/PrivateLayout/PrivateLayout.tsx +32 -1
- package/src/layouts/PrivateLayout/README.md +23 -5
- package/src/layouts/PrivateLayout/brand.test.tsx +208 -0
- package/src/layouts/PrivateLayout/components/PrivateContent.test.tsx +230 -0
- package/src/layouts/PrivateLayout/components/PrivateContent.tsx +86 -19
- 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 +13 -8
- package/src/layouts/PrivateLayout/components/SidebarNavItem.tsx +2 -2
- package/src/layouts/PrivateLayout/components/SidebarPeek.tsx +1 -1
- package/src/layouts/PrivateLayout/context.tsx +15 -3
- package/src/layouts/PrivateLayout/geometry.test.ts +44 -0
- package/src/layouts/PrivateLayout/geometry.ts +40 -16
- 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)"
|
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import React, { memo, useCallback } from 'react';
|
|
3
|
+
import React, { memo, useCallback, useEffect } from 'react';
|
|
4
|
+
|
|
5
|
+
import { isOAuthProviderId } from '@djangocfg/api/auth';
|
|
4
6
|
|
|
5
7
|
import { useAuthFormContext } from '../../context';
|
|
6
8
|
import { AuthDivider } from '../shared/AuthDivider';
|
|
7
9
|
import { OAuthButtons } from './buttons/OAuthButtons';
|
|
10
|
+
import { describeOAuthFailure } from './errors/errorCopy';
|
|
8
11
|
import { OAuthNotice } from './notice/OAuthNotice';
|
|
9
12
|
|
|
10
13
|
export interface OAuthSectionProps {
|
|
@@ -13,8 +16,8 @@ export interface OAuthSectionProps {
|
|
|
13
16
|
disabled?: boolean;
|
|
14
17
|
/** A provider sign-in that could not start: the sentence to show under the form field. */
|
|
15
18
|
onError?: (message: string) => void;
|
|
16
|
-
/** Called
|
|
17
|
-
|
|
19
|
+
/** Called when a notice appears whose only way forward is email: put the cursor in the email field. */
|
|
20
|
+
onFocusEmail: () => void;
|
|
18
21
|
}
|
|
19
22
|
|
|
20
23
|
/**
|
|
@@ -25,20 +28,23 @@ export interface OAuthSectionProps {
|
|
|
25
28
|
* the list loads it holds the room the buttons will take, so the email field
|
|
26
29
|
* does not jump; with no providers it renders nothing.
|
|
27
30
|
*/
|
|
28
|
-
function OAuthSectionRaw({ sourceUrl, disabled, onError,
|
|
31
|
+
function OAuthSectionRaw({ sourceUrl, disabled, onError, onFocusEmail }: OAuthSectionProps) {
|
|
29
32
|
const { oauth, oauthNotice, setOAuthNotice } = useAuthFormContext();
|
|
30
33
|
|
|
31
34
|
const dismissNotice = useCallback(() => setOAuthNotice(null), [setOAuthNotice]);
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
35
|
+
|
|
36
|
+
// The email field is right below: when the notice says email is the way
|
|
37
|
+
// forward (nothing to retry), the cursor goes there instead of a button.
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
if (!oauthNotice) return;
|
|
40
|
+
const action = describeOAuthFailure(oauthNotice.code)?.action;
|
|
41
|
+
// A retry for a provider this client has no button for is email too.
|
|
42
|
+
if (action === 'email' || (action === 'retry' && !isOAuthProviderId(oauthNotice.provider))) onFocusEmail();
|
|
43
|
+
}, [oauthNotice, onFocusEmail]);
|
|
36
44
|
|
|
37
45
|
return (
|
|
38
46
|
<>
|
|
39
|
-
{oauthNotice &&
|
|
40
|
-
<OAuthNotice failure={oauthNotice} sourceUrl={sourceUrl} onDismiss={dismissNotice} onUseEmail={useEmail} />
|
|
41
|
-
)}
|
|
47
|
+
{oauthNotice && <OAuthNotice failure={oauthNotice} sourceUrl={sourceUrl} onDismiss={dismissNotice} />}
|
|
42
48
|
|
|
43
49
|
{oauth.providers.length > 0 && (
|
|
44
50
|
<>
|
|
@@ -19,7 +19,7 @@ export const OAUTH_ERROR_KEYS = {
|
|
|
19
19
|
|
|
20
20
|
export type OAuthErrorKey = (typeof OAUTH_ERROR_KEYS)[keyof typeof OAUTH_ERROR_KEYS];
|
|
21
21
|
|
|
22
|
-
/** The
|
|
22
|
+
/** The next step a notice points at: the email field below (no button; focus moves there), a retry button, or nothing the visitor can do. */
|
|
23
23
|
export type OAuthNoticeAction = 'email' | 'retry' | 'none';
|
|
24
24
|
|
|
25
25
|
interface Rule {
|
|
@@ -12,12 +12,10 @@ export interface OAuthNoticeProps {
|
|
|
12
12
|
sourceUrl?: string;
|
|
13
13
|
/** Closes the notice (button or Escape). */
|
|
14
14
|
onDismiss: () => void;
|
|
15
|
-
/** Takes the visitor to the email field; the notice closes with it. */
|
|
16
|
-
onUseEmail: () => void;
|
|
17
15
|
}
|
|
18
16
|
|
|
19
17
|
/** The failure notice with its retry wired to `useOAuthLogin`. */
|
|
20
|
-
function OAuthNoticeRaw({ failure, sourceUrl, onDismiss
|
|
18
|
+
function OAuthNoticeRaw({ failure, sourceUrl, onDismiss }: OAuthNoticeProps) {
|
|
21
19
|
const { start, isLoading } = useOAuthLogin({ sourceUrl });
|
|
22
20
|
const { provider } = failure;
|
|
23
21
|
|
|
@@ -33,7 +31,6 @@ function OAuthNoticeRaw({ failure, sourceUrl, onDismiss, onUseEmail }: OAuthNoti
|
|
|
33
31
|
onRetry={isOAuthProviderId(provider) ? handleRetry : undefined}
|
|
34
32
|
retrying={isLoading}
|
|
35
33
|
onDismiss={onDismiss}
|
|
36
|
-
onUseEmail={onUseEmail}
|
|
37
34
|
/>
|
|
38
35
|
);
|
|
39
36
|
}
|
|
@@ -16,7 +16,7 @@ function render(props: Partial<OAuthNoticeViewProps> = {}) {
|
|
|
16
16
|
const host = document.createElement('div');
|
|
17
17
|
document.body.append(host);
|
|
18
18
|
const root = createRoot(host);
|
|
19
|
-
const handlers = { onDismiss: vi.fn(),
|
|
19
|
+
const handlers = { onDismiss: vi.fn(), onRetry: vi.fn() };
|
|
20
20
|
act(() => root.render(<OAuthNoticeView provider="github" code="invalid_state" {...handlers} {...props} />));
|
|
21
21
|
return { host, ...handlers, buttons: () => [...host.querySelectorAll('button')] };
|
|
22
22
|
}
|
|
@@ -37,10 +37,24 @@ describe('OAuthNoticeView', () => {
|
|
|
37
37
|
expect(onRetry).toHaveBeenCalledOnce();
|
|
38
38
|
});
|
|
39
39
|
|
|
40
|
-
it('offers
|
|
40
|
+
it('offers no retry when the provider has no button, and never a button for email', () => {
|
|
41
41
|
const { buttons } = render({ onRetry: undefined });
|
|
42
42
|
expect(buttons().some((b) => b.textContent!.includes('notice.retry'))).toBe(false);
|
|
43
|
-
expect(buttons().
|
|
43
|
+
expect(render({ code: 'link_not_allowed' }).buttons().map((b) => b.getAttribute('aria-label'))).toEqual([
|
|
44
|
+
'layouts.auth.oauth.notice.dismiss',
|
|
45
|
+
]);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('uses one layout for every kind of notice: icon, text, close', () => {
|
|
49
|
+
for (const code of ['invalid_state', 'link_not_allowed', 'origin_not_allowed', 'rate_limited'] as const) {
|
|
50
|
+
const { host } = render({ code, origin: 'https://app.example.com' });
|
|
51
|
+
const notice = host.querySelector('.auth-oauth-notice')!;
|
|
52
|
+
expect(notice.querySelector('.auth-oauth-notice-icon')).not.toBeNull();
|
|
53
|
+
expect(notice.querySelector('.auth-oauth-notice-text .auth-oauth-notice-body')).not.toBeNull();
|
|
54
|
+
expect(notice.querySelector('.auth-oauth-notice-close')!.getAttribute('aria-label')).toBe(
|
|
55
|
+
'layouts.auth.oauth.notice.dismiss',
|
|
56
|
+
);
|
|
57
|
+
}
|
|
44
58
|
});
|
|
45
59
|
|
|
46
60
|
it('closes with the dismiss button and with Escape', () => {
|