@nocobase/app-template-default 1.0.0-beta.88 → 1.0.0-beta.89
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/rules/components.md +3 -1
- package/.agents/rules/shell.md +8 -2
- package/AGENTS.md +1 -1
- package/client/components/back-button.tsx +37 -12
- package/client/components/brand-loading-mark.tsx +5 -2
- package/client/components/brand-mark-geometry.ts +3 -3
- package/client/components/breadcrumbs.tsx +47 -3
- package/client/components/data-table/data-table.tsx +90 -19
- package/client/components/data-table/pagination.tsx +17 -2
- package/client/components/list-feedback.tsx +31 -22
- package/client/components/loading.tsx +127 -16
- package/client/components/pending-button.tsx +111 -0
- package/client/components/role-list.tsx +7 -0
- package/client/components/route-child-page.tsx +72 -1
- package/client/components/route-overlay.tsx +149 -15
- package/client/components/task-progress.tsx +155 -0
- package/client/components/use-route-overlay.ts +8 -11
- package/client/extensions/nocobase-file-component-ui/components/file-upload-field.tsx +62 -32
- package/client/extensions/nocobase-file-component-ui/locales/de-DE.ts +2 -0
- package/client/extensions/nocobase-file-component-ui/locales/en-US.ts +1 -0
- package/client/extensions/nocobase-file-component-ui/locales/es-ES.ts +1 -0
- package/client/extensions/nocobase-file-component-ui/locales/fr-FR.ts +1 -0
- package/client/extensions/nocobase-file-component-ui/locales/id-ID.ts +1 -0
- package/client/extensions/nocobase-file-component-ui/locales/ja-JP.ts +2 -0
- package/client/extensions/nocobase-file-component-ui/locales/pt-BR.ts +1 -0
- package/client/extensions/nocobase-file-component-ui/locales/vi-VN.ts +1 -0
- package/client/extensions/nocobase-file-component-ui/locales/zh-CN.ts +1 -0
- package/client/extensions/nocobase-file-component-ui/locales/zh-TW.ts +1 -0
- package/client/extensions/nocobase-workflow-management/README.md +5 -5
- package/client/extensions/nocobase-workflow-management/definition-list.tsx +2 -9
- package/client/extensions/nocobase-workflow-management/execution-list.tsx +2 -4
- package/client/layouts/app-layout.tsx +0 -4
- package/client/locales/system/en-US.ts +19 -0
- package/client/locales/system/zh-CN.ts +19 -0
- package/client/pages/settings/default-access.tsx +1 -1
- package/client/pages/settings/roles.tsx +27 -27
- package/client/pages/settings/routes.ts +15 -7
- package/client/pages/settings/user-detail.tsx +3 -17
- package/client/pages/settings/user-form.tsx +9 -15
- package/client/pages/settings/users.tsx +11 -27
- package/client/routing/client-route.tsx +199 -37
- package/client/routing/navigation-destination.ts +42 -0
- package/client/routing/route-leave-boundary.tsx +13 -1
- package/client/styles.css +18 -1
- package/package.json +8 -8
- package/tests/client/auth-i18n.test.tsx +1 -1
- package/tests/client/authenticated-client-fixture.test.tsx +37 -0
- package/tests/client/back-button.test.tsx +34 -0
- package/tests/client/client-route-recovery.test.tsx +93 -1
- package/tests/client/list-feedback.test.tsx +5 -5
- package/tests/client/list-loading.test.tsx +397 -0
- package/tests/client/loading.test.tsx +20 -11
- package/tests/client/manual-list-retry.test.tsx +2 -2
- package/tests/client/navigation-parent.test.tsx +172 -0
- package/tests/client/pending-button.test.tsx +106 -0
- package/tests/client/permission-management-tabs.test.tsx +48 -12
- package/tests/client/primitive-labels.test.tsx +2 -2
- package/tests/client/role-return.test.tsx +21 -3
- package/tests/client/roles.test.tsx +84 -3
- package/tests/client/route-leave-recheck.test.tsx +74 -0
- package/tests/client/route-presentation.test.tsx +557 -0
- package/tests/client/session-preparation.test.tsx +20 -17
- package/tests/client/settings-continuity.test.tsx +35 -11
- package/tests/client/settings-home.test.tsx +4 -2
- package/tests/client/settings-idle-list.test.tsx +124 -0
- package/tests/client/user-drawer.test.tsx +3 -4
- package/tests/fixtures/dialog-footer.html +12 -0
- package/tests/fixtures/dialog-footer.tsx +215 -0
- package/tests/helpers/authenticated-client.tsx +70 -22
- package/tests/playwright/dialog-footer.test.ts +227 -0
|
@@ -12,7 +12,6 @@ import { PlusIcon } from 'lucide-react';
|
|
|
12
12
|
import { PermissionManagementPage } from './permission-management-page.js';
|
|
13
13
|
import { Input } from '#components/ui/input';
|
|
14
14
|
import { Button } from '#components/ui/button';
|
|
15
|
-
import { Spinner } from '#components/ui/spinner';
|
|
16
15
|
import { useTableUrlState } from '#hooks/use-table-url-state';
|
|
17
16
|
import { RoleList } from '#components/role-list';
|
|
18
17
|
import { ListFeedback, ListFeedbackActions } from '#components/list-feedback';
|
|
@@ -123,34 +122,35 @@ export default function RolesPage(): ReactElement {
|
|
|
123
122
|
state={listState}
|
|
124
123
|
onClear={clearSearch}
|
|
125
124
|
onRetry={request.reload}
|
|
126
|
-
busy={request.loading}
|
|
125
|
+
busy={active && request.loading}
|
|
127
126
|
/>
|
|
128
|
-
{active && request.loading && request.data ? (
|
|
129
|
-
<Spinner className='text-muted-foreground' />
|
|
130
|
-
) : null}
|
|
131
127
|
</div>
|
|
132
|
-
|
|
133
|
-
<
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
128
|
+
{active || listState !== 'loading' ? (
|
|
129
|
+
<ListFeedback state={listState}>
|
|
130
|
+
<RoleList
|
|
131
|
+
tableState={tableState}
|
|
132
|
+
// After a refresh failure the toolbar's retry button owns the wait.
|
|
133
|
+
loading={active && request.loading && listState === 'ready'}
|
|
134
|
+
ariaLabel={t('settings.roles')}
|
|
135
|
+
roles={rows.map((role) => ({
|
|
136
|
+
key: role.key,
|
|
137
|
+
title: text(role.title ?? role.key),
|
|
138
|
+
to: {
|
|
139
|
+
pathname: `${encodeURIComponent(role.key)}/edit`,
|
|
140
|
+
search: location.search,
|
|
141
|
+
},
|
|
142
|
+
protected: Boolean(role.protection),
|
|
143
|
+
}))}
|
|
144
|
+
labels={{
|
|
145
|
+
name: t('settings.roleName'),
|
|
146
|
+
key: t('settings.key'),
|
|
147
|
+
type: t('settings.roleType'),
|
|
148
|
+
protected: t('settings.protected'),
|
|
149
|
+
custom: t('settings.custom'),
|
|
150
|
+
}}
|
|
151
|
+
/>
|
|
152
|
+
</ListFeedback>
|
|
153
|
+
) : null}
|
|
154
154
|
</PermissionManagementPage>
|
|
155
155
|
<Outlet context={{ reload: request.reload }} />
|
|
156
156
|
</>
|
|
@@ -24,23 +24,27 @@ const systemSettingsRoutes: AppClientRouteContribution = defineAppRoutes([
|
|
|
24
24
|
breadcrumb: { title: 'settings.users' },
|
|
25
25
|
componentLoader: () => import('./users.js'),
|
|
26
26
|
children: [
|
|
27
|
+
// The forms declare their presentation, so the dialog or drawer opens at once and the wait for the form's module,
|
|
28
|
+
// its authorization and its data stays inside it instead of under the list.
|
|
27
29
|
{
|
|
28
30
|
name: 'settingsUserCreate',
|
|
29
31
|
path: 'create',
|
|
30
32
|
authz: { resource: { type: 'user', id: '*' }, action: 'create' },
|
|
33
|
+
presentation: { kind: 'dialog', title: 'settings.newUser' },
|
|
31
34
|
componentLoader: () => import('./user-form.js'),
|
|
32
35
|
},
|
|
36
|
+
// A sibling rather than the parent of the editor, so closing the drawer returns to the list instead of to this
|
|
37
|
+
// route, which only forwards older detail links to the editor.
|
|
33
38
|
{
|
|
34
39
|
name: 'settingsUser',
|
|
35
40
|
path: ':userId',
|
|
36
41
|
componentLoader: () => import('./user-detail.js'),
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
],
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
name: 'settingsUserEdit',
|
|
45
|
+
path: ':userId/edit',
|
|
46
|
+
presentation: { kind: 'drawer', title: 'settings.editUser' },
|
|
47
|
+
componentLoader: () => import('./user-form.js'),
|
|
44
48
|
},
|
|
45
49
|
],
|
|
46
50
|
},
|
|
@@ -80,12 +84,14 @@ const systemSettingsRoutes: AppClientRouteContribution = defineAppRoutes([
|
|
|
80
84
|
action: 'create',
|
|
81
85
|
},
|
|
82
86
|
breadcrumb: { title: 'settings.newRole' },
|
|
87
|
+
presentation: { kind: 'child-page' },
|
|
83
88
|
componentLoader: () => import('./role-form.js'),
|
|
84
89
|
},
|
|
85
90
|
{
|
|
86
91
|
name: 'settingsRoleEdit',
|
|
87
92
|
path: ':key/edit',
|
|
88
93
|
breadcrumb: { title: 'settings.editRole' },
|
|
94
|
+
presentation: { kind: 'child-page' },
|
|
89
95
|
componentLoader: () => import('./role-form.js'),
|
|
90
96
|
},
|
|
91
97
|
],
|
|
@@ -119,6 +125,7 @@ const systemSettingsRoutes: AppClientRouteContribution = defineAppRoutes([
|
|
|
119
125
|
action: 'create',
|
|
120
126
|
},
|
|
121
127
|
breadcrumb: { title: 'settings.defaultAccess.new' },
|
|
128
|
+
presentation: { kind: 'child-page' },
|
|
122
129
|
componentLoader: () => import('./default-access-form.js'),
|
|
123
130
|
},
|
|
124
131
|
// Read permission opens the full rule even when the viewer may not update it.
|
|
@@ -126,6 +133,7 @@ const systemSettingsRoutes: AppClientRouteContribution = defineAppRoutes([
|
|
|
126
133
|
name: 'settingsDefaultAccessEdit',
|
|
127
134
|
path: ':key/edit',
|
|
128
135
|
breadcrumb: { title: 'settings.defaultAccess.edit' },
|
|
136
|
+
presentation: { kind: 'child-page' },
|
|
129
137
|
componentLoader: () => import('./default-access-form.js'),
|
|
130
138
|
},
|
|
131
139
|
],
|
|
@@ -1,24 +1,10 @@
|
|
|
1
1
|
import type { ReactElement } from 'react';
|
|
2
|
-
import {
|
|
3
|
-
Navigate,
|
|
4
|
-
useLocation,
|
|
5
|
-
useOutlet,
|
|
6
|
-
useOutletContext,
|
|
7
|
-
useResolvedPath,
|
|
8
|
-
} from 'react-router';
|
|
9
|
-
import type { ManagedUser } from '@nocobase/app-plugin-users/client/user-client';
|
|
2
|
+
import { Navigate, useLocation } from 'react-router';
|
|
10
3
|
|
|
11
|
-
/**
|
|
4
|
+
/** Keeps stored route identifiers and old detail links working by forwarding them to the editor. */
|
|
12
5
|
export default function UserDetail(): ReactElement {
|
|
13
|
-
const parent = useOutletContext<{
|
|
14
|
-
reload: (updated?: ManagedUser) => void;
|
|
15
|
-
}>();
|
|
16
6
|
const location = useLocation();
|
|
17
|
-
const listPath = useResolvedPath('..').pathname;
|
|
18
|
-
const outlet = useOutlet({ ...parent, listPath });
|
|
19
7
|
return (
|
|
20
|
-
|
|
21
|
-
<Navigate to={{ pathname: 'edit', search: location.search }} replace />
|
|
22
|
-
)
|
|
8
|
+
<Navigate to={{ pathname: 'edit', search: location.search }} replace />
|
|
23
9
|
);
|
|
24
10
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { PendingButton } from '#components/pending-button';
|
|
1
2
|
import { FeedbackForm } from '#components/feedback-form';
|
|
2
3
|
import { useAuthentication } from '@nocobase/app-plugin-authentication/client';
|
|
3
4
|
import { useSubmission } from '@nocobase/app-client';
|
|
@@ -197,7 +198,6 @@ function UserEditor(): ReactElement {
|
|
|
197
198
|
const location = useLocation();
|
|
198
199
|
const parent = useOutletContext<{
|
|
199
200
|
reload: (updated?: ManagedUser) => void;
|
|
200
|
-
listPath?: string;
|
|
201
201
|
}>();
|
|
202
202
|
const data = useSettingsData<{ data: ManagedUser }>(
|
|
203
203
|
userId ? `users/${encodeURIComponent(userId)}` : 'users/options',
|
|
@@ -281,11 +281,8 @@ function UserEditor(): ReactElement {
|
|
|
281
281
|
const [serverErrors, setServerErrors] = useState<Record<string, string>>({});
|
|
282
282
|
const [submitted, setSubmitted] = useState(false);
|
|
283
283
|
|
|
284
|
-
const
|
|
285
|
-
const closeTo = {
|
|
286
|
-
pathname: creating ? parentPath : (parent.listPath ?? parentPath),
|
|
287
|
-
search: location.search,
|
|
288
|
-
};
|
|
284
|
+
const listPath = useResolvedPath('..').pathname;
|
|
285
|
+
const closeTo = { pathname: listPath, search: location.search };
|
|
289
286
|
const fieldError = (name: keyof typeof fields): string | undefined => {
|
|
290
287
|
if (!submitted) return;
|
|
291
288
|
if (serverErrors[name]) return serverErrors[name];
|
|
@@ -436,7 +433,9 @@ function UserEditor(): ReactElement {
|
|
|
436
433
|
{t('settings.cancel')}
|
|
437
434
|
</Button>
|
|
438
435
|
{allowed && (
|
|
439
|
-
<
|
|
436
|
+
<PendingButton
|
|
437
|
+
busy={busy}
|
|
438
|
+
pendingLabel={t('settings.saving')}
|
|
440
439
|
form={formId}
|
|
441
440
|
type='submit'
|
|
442
441
|
disabled={
|
|
@@ -455,14 +454,8 @@ function UserEditor(): ReactElement {
|
|
|
455
454
|
(options.loading || Boolean(options.error) || !options.data))
|
|
456
455
|
}
|
|
457
456
|
>
|
|
458
|
-
{t(
|
|
459
|
-
|
|
460
|
-
? 'settings.saving'
|
|
461
|
-
: creating
|
|
462
|
-
? 'settings.create'
|
|
463
|
-
: 'settings.save',
|
|
464
|
-
)}
|
|
465
|
-
</Button>
|
|
457
|
+
{t(creating ? 'settings.create' : 'settings.save')}
|
|
458
|
+
</PendingButton>
|
|
466
459
|
)}
|
|
467
460
|
</>
|
|
468
461
|
);
|
|
@@ -475,6 +468,7 @@ function UserEditor(): ReactElement {
|
|
|
475
468
|
) : (
|
|
476
469
|
!data.error && (
|
|
477
470
|
<FeedbackForm
|
|
471
|
+
aria-busy={busy}
|
|
478
472
|
submitting={busy}
|
|
479
473
|
error={error}
|
|
480
474
|
errorContent={<SettingsError error={error} inline />}
|
|
@@ -31,12 +31,12 @@ import {
|
|
|
31
31
|
} from 'react';
|
|
32
32
|
import { Link, Outlet, useLocation, useResolvedPath } from 'react-router';
|
|
33
33
|
import { PlusIcon } from 'lucide-react';
|
|
34
|
+
import { cn } from 'cn';
|
|
34
35
|
import { PageContainer } from '#components/page-container';
|
|
35
36
|
import { PageHeader } from '#components/page-header';
|
|
36
37
|
import { Button } from '#components/ui/button';
|
|
37
38
|
import { Input } from '#components/ui/input';
|
|
38
39
|
import { StatusBadge } from '#components/status-badge';
|
|
39
|
-
import { Spinner } from '#components/ui/spinner';
|
|
40
40
|
import {
|
|
41
41
|
Select,
|
|
42
42
|
SelectTrigger,
|
|
@@ -46,25 +46,6 @@ import {
|
|
|
46
46
|
} from '#components/ui/select';
|
|
47
47
|
import { ConfirmAction, SettingsError } from './shared.js';
|
|
48
48
|
|
|
49
|
-
function UsersRefreshing(): ReactElement | null {
|
|
50
|
-
const { t } = useTranslation();
|
|
51
|
-
const [visible, setVisible] = useState(false);
|
|
52
|
-
useEffect(() => {
|
|
53
|
-
// Fast background reads should not flash feedback on overlay close.
|
|
54
|
-
const timer = setTimeout(() => setVisible(true), 300);
|
|
55
|
-
return () => clearTimeout(timer);
|
|
56
|
-
}, []);
|
|
57
|
-
return visible ? (
|
|
58
|
-
<span
|
|
59
|
-
role='status'
|
|
60
|
-
className='flex items-center gap-2 text-sm text-muted-foreground'
|
|
61
|
-
>
|
|
62
|
-
<Spinner aria-hidden='true' className='size-4' />
|
|
63
|
-
{t('settings.loading')}
|
|
64
|
-
</span>
|
|
65
|
-
) : null;
|
|
66
|
-
}
|
|
67
|
-
|
|
68
49
|
function UserName({ user }: { readonly user: ManagedUser }): ReactElement {
|
|
69
50
|
const canUpdate = useUserPermission('update', user.id);
|
|
70
51
|
const canAssign = useUserPermission('assign-role', user.id);
|
|
@@ -515,11 +496,6 @@ export default function UsersPage(): ReactElement {
|
|
|
515
496
|
{t('settings.clear')}
|
|
516
497
|
</Button>
|
|
517
498
|
)}
|
|
518
|
-
<div className='ml-auto flex h-8 w-28 items-center justify-end'>
|
|
519
|
-
{active && hasCurrentResponse && request.loading && request.data ? (
|
|
520
|
-
<UsersRefreshing />
|
|
521
|
-
) : null}
|
|
522
|
-
</div>
|
|
523
499
|
</div>
|
|
524
500
|
{Boolean(roleScope) !== Boolean(role) && (
|
|
525
501
|
<p role='alert' className='text-sm text-destructive'>
|
|
@@ -536,13 +512,21 @@ export default function UsersPage(): ReactElement {
|
|
|
536
512
|
{...tableState}
|
|
537
513
|
manualSorting
|
|
538
514
|
showSelectedCount={false}
|
|
539
|
-
loading={request.loading}
|
|
515
|
+
loading={active && request.loading}
|
|
516
|
+
loadingIdentity={requestPath}
|
|
540
517
|
listState={listState}
|
|
541
518
|
onRetry={request.reload}
|
|
542
519
|
onClearFilters={clearFilters}
|
|
543
520
|
/>
|
|
544
521
|
{request.data && (
|
|
545
|
-
|
|
522
|
+
// A previous query's total keeps its line but is never shown as the current one.
|
|
523
|
+
<p
|
|
524
|
+
className={cn(
|
|
525
|
+
'text-sm text-muted-foreground',
|
|
526
|
+
!hasCurrentResponse && 'invisible',
|
|
527
|
+
)}
|
|
528
|
+
aria-hidden={!hasCurrentResponse || undefined}
|
|
529
|
+
>
|
|
546
530
|
{t('settings.total', { count: request.data.meta.total })}
|
|
547
531
|
</p>
|
|
548
532
|
)}
|
|
@@ -1,22 +1,51 @@
|
|
|
1
1
|
import { useTranslation } from '@nocobase/i18n/client';
|
|
2
|
+
import { useNavigationGuard } from '@nocobase/app-client';
|
|
2
3
|
import type {
|
|
3
4
|
AppClientRegisteredRoute,
|
|
4
5
|
AppClientRouteComponentModule,
|
|
5
6
|
} from '@nocobase/app-client/plugins';
|
|
6
7
|
import { useCan } from '@nocobase/app-plugin-authorization/client';
|
|
7
8
|
import { NamespaceScope } from '@nocobase/i18n/client';
|
|
8
|
-
import {
|
|
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 {
|
|
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
|
-
|
|
56
|
-
|
|
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
|
-
|
|
59
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
{
|
|
68
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
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=
|
|
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(
|
|
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
|
|
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
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
+
}
|