@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
|
@@ -1,36 +1,147 @@
|
|
|
1
|
+
import { usePendingFeedback } from '@nocobase/app-client';
|
|
1
2
|
import { useTranslation } from '@nocobase/i18n/client';
|
|
2
3
|
import type { ComponentPropsWithoutRef, ReactElement } from 'react';
|
|
3
|
-
|
|
4
|
-
import { BrandLoadingMark } from '#components/brand-loading-mark';
|
|
5
4
|
import { cn } from 'cn';
|
|
5
|
+
import { BrandLoadingMark } from './brand-loading-mark.js';
|
|
6
6
|
|
|
7
7
|
export interface LoadingProps extends ComponentPropsWithoutRef<'div'> {
|
|
8
8
|
readonly fullscreen?: boolean;
|
|
9
|
+
readonly pending?: boolean;
|
|
10
|
+
readonly identity?: unknown;
|
|
9
11
|
readonly label?: string;
|
|
12
|
+
readonly longWaitLabel?: string;
|
|
10
13
|
}
|
|
11
14
|
|
|
12
|
-
|
|
15
|
+
/** Mount inside the region that owns the work; fullscreen is for application startup. */
|
|
16
|
+
export function Loading({
|
|
17
|
+
className,
|
|
18
|
+
fullscreen = false,
|
|
19
|
+
pending = true,
|
|
20
|
+
identity,
|
|
21
|
+
label,
|
|
22
|
+
longWaitLabel,
|
|
23
|
+
...props
|
|
24
|
+
}: LoadingProps): ReactElement {
|
|
13
25
|
const { t } = useTranslation();
|
|
14
|
-
const
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
26
|
+
const feedback = usePendingFeedback(pending, identity);
|
|
27
|
+
const loadingLabel =
|
|
28
|
+
label ?? t('status.loading', { defaultValue: 'Loading' });
|
|
29
|
+
const waitingLabel =
|
|
30
|
+
longWaitLabel ??
|
|
31
|
+
t('pendingFeedback.longWait', {
|
|
32
|
+
defaultValue: 'This is taking longer than usual. You can keep waiting.',
|
|
33
|
+
});
|
|
34
|
+
const message = feedback.longWaiting ? waitingLabel : loadingLabel;
|
|
35
|
+
return (
|
|
36
|
+
<>
|
|
37
|
+
<div
|
|
38
|
+
{...props}
|
|
39
|
+
data-slot='loading'
|
|
40
|
+
aria-busy={pending}
|
|
41
|
+
className={cn(
|
|
42
|
+
'flex flex-col items-center justify-center',
|
|
43
|
+
fullscreen && 'min-h-svh w-full bg-background',
|
|
44
|
+
className,
|
|
45
|
+
)}
|
|
46
|
+
>
|
|
47
|
+
<div
|
|
48
|
+
aria-hidden='true'
|
|
49
|
+
className={cn(
|
|
50
|
+
'flex flex-col items-center gap-3 text-center',
|
|
51
|
+
!feedback.visible && 'invisible',
|
|
52
|
+
)}
|
|
53
|
+
>
|
|
54
|
+
<BrandLoadingMark />
|
|
55
|
+
<span className='grid text-sm text-muted-foreground'>
|
|
56
|
+
<span
|
|
57
|
+
className={cn(
|
|
58
|
+
'col-start-1 row-start-1',
|
|
59
|
+
feedback.longWaiting && 'invisible',
|
|
60
|
+
)}
|
|
61
|
+
>
|
|
62
|
+
{loadingLabel}
|
|
63
|
+
</span>
|
|
64
|
+
<span
|
|
65
|
+
className={cn(
|
|
66
|
+
'col-start-1 row-start-1',
|
|
67
|
+
!feedback.longWaiting && 'invisible',
|
|
68
|
+
)}
|
|
69
|
+
>
|
|
70
|
+
{waitingLabel}
|
|
71
|
+
</span>
|
|
72
|
+
</span>
|
|
73
|
+
</div>
|
|
74
|
+
</div>
|
|
75
|
+
{feedback.visible && (
|
|
76
|
+
<span
|
|
77
|
+
role='status'
|
|
78
|
+
aria-live='polite'
|
|
79
|
+
aria-label={message}
|
|
80
|
+
className='sr-only'
|
|
81
|
+
>
|
|
82
|
+
{message}
|
|
83
|
+
</span>
|
|
84
|
+
)}
|
|
85
|
+
</>
|
|
86
|
+
);
|
|
87
|
+
}
|
|
20
88
|
|
|
89
|
+
export interface RefreshIndicatorProps {
|
|
90
|
+
readonly pending: boolean;
|
|
91
|
+
readonly identity?: unknown;
|
|
92
|
+
readonly label?: string;
|
|
93
|
+
readonly longWaitLabel?: string;
|
|
94
|
+
readonly className?: string;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Background refresh of content that still belongs to the current query and permission scope. Render it inside the
|
|
99
|
+
* positioned element that owns that content; it takes no layout space and never replaces the content.
|
|
100
|
+
*/
|
|
101
|
+
export function RefreshIndicator({
|
|
102
|
+
pending,
|
|
103
|
+
identity,
|
|
104
|
+
label,
|
|
105
|
+
longWaitLabel,
|
|
106
|
+
className,
|
|
107
|
+
}: RefreshIndicatorProps): ReactElement | null {
|
|
108
|
+
const { t } = useTranslation();
|
|
109
|
+
const feedback = usePendingFeedback(pending, identity);
|
|
110
|
+
if (!feedback.visible) return null;
|
|
111
|
+
const message = feedback.longWaiting
|
|
112
|
+
? (longWaitLabel ??
|
|
113
|
+
t('pendingFeedback.longWait', {
|
|
114
|
+
defaultValue: 'This is taking longer than usual. You can keep waiting.',
|
|
115
|
+
}))
|
|
116
|
+
: (label ?? t('status.refreshing', { defaultValue: 'Refreshing' }));
|
|
21
117
|
return (
|
|
22
118
|
<div
|
|
23
|
-
|
|
119
|
+
data-slot='refresh-indicator'
|
|
24
120
|
className={cn(
|
|
25
|
-
'flex flex-col items-
|
|
26
|
-
fullscreen && 'min-h-svh w-full bg-background',
|
|
121
|
+
'pointer-events-none absolute inset-x-0 top-0 z-10 flex flex-col items-end',
|
|
27
122
|
className,
|
|
28
123
|
)}
|
|
29
|
-
role='status'
|
|
30
|
-
{...props}
|
|
31
124
|
>
|
|
32
|
-
<
|
|
33
|
-
|
|
125
|
+
<div
|
|
126
|
+
aria-hidden='true'
|
|
127
|
+
className='h-0.5 w-full bg-primary/70 motion-safe:animate-pulse'
|
|
128
|
+
/>
|
|
129
|
+
{feedback.longWaiting && (
|
|
130
|
+
<span
|
|
131
|
+
aria-hidden='true'
|
|
132
|
+
className='m-2 max-w-[calc(100%-1rem)] rounded-md border border-border bg-popover px-2 py-1 text-xs text-muted-foreground shadow-sm'
|
|
133
|
+
>
|
|
134
|
+
{message}
|
|
135
|
+
</span>
|
|
136
|
+
)}
|
|
137
|
+
<span
|
|
138
|
+
role='status'
|
|
139
|
+
aria-live='polite'
|
|
140
|
+
aria-label={message}
|
|
141
|
+
className='sr-only'
|
|
142
|
+
>
|
|
143
|
+
{message}
|
|
144
|
+
</span>
|
|
34
145
|
</div>
|
|
35
146
|
);
|
|
36
147
|
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { usePendingFeedback } from '@nocobase/app-client';
|
|
2
|
+
import { useTranslation } from '@nocobase/i18n/client';
|
|
3
|
+
import {
|
|
4
|
+
useImperativeHandle,
|
|
5
|
+
useRef,
|
|
6
|
+
type ComponentProps,
|
|
7
|
+
type ReactElement,
|
|
8
|
+
type ReactNode,
|
|
9
|
+
} from 'react';
|
|
10
|
+
import { Tooltip } from '@base-ui/react/tooltip';
|
|
11
|
+
import { cn } from 'cn';
|
|
12
|
+
import { Button } from '#components/ui/button';
|
|
13
|
+
import { BrandLoadingMark } from './brand-loading-mark.js';
|
|
14
|
+
|
|
15
|
+
export interface PendingButtonProps extends Omit<
|
|
16
|
+
ComponentProps<typeof Button>,
|
|
17
|
+
'children' | 'className' | 'render' | 'nativeButton'
|
|
18
|
+
> {
|
|
19
|
+
readonly busy: boolean;
|
|
20
|
+
readonly identity?: unknown;
|
|
21
|
+
readonly pendingLabel?: string;
|
|
22
|
+
readonly longWaitLabel?: string;
|
|
23
|
+
readonly children: ReactNode;
|
|
24
|
+
readonly className?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Consume useSubmission().submitting; the submission controller still owns the lock. */
|
|
28
|
+
export function PendingButton({
|
|
29
|
+
busy,
|
|
30
|
+
identity,
|
|
31
|
+
pendingLabel,
|
|
32
|
+
longWaitLabel,
|
|
33
|
+
children,
|
|
34
|
+
ref,
|
|
35
|
+
disabled,
|
|
36
|
+
className,
|
|
37
|
+
'aria-label': ariaLabel,
|
|
38
|
+
...props
|
|
39
|
+
}: PendingButtonProps): ReactElement {
|
|
40
|
+
const { t } = useTranslation();
|
|
41
|
+
const anchorRef = useRef<HTMLButtonElement>(null);
|
|
42
|
+
useImperativeHandle(ref, () => anchorRef.current!, []);
|
|
43
|
+
const feedback = usePendingFeedback(busy, identity);
|
|
44
|
+
const label =
|
|
45
|
+
pendingLabel ?? t('pendingFeedback.working', { defaultValue: 'Working…' });
|
|
46
|
+
const message = feedback.longWaiting
|
|
47
|
+
? (longWaitLabel ??
|
|
48
|
+
t('pendingFeedback.longWait', {
|
|
49
|
+
defaultValue: 'This is taking longer than usual. You can keep waiting.',
|
|
50
|
+
}))
|
|
51
|
+
: label;
|
|
52
|
+
return (
|
|
53
|
+
<>
|
|
54
|
+
<Button
|
|
55
|
+
{...props}
|
|
56
|
+
ref={anchorRef}
|
|
57
|
+
disabled={disabled || busy}
|
|
58
|
+
aria-busy={busy}
|
|
59
|
+
aria-label={feedback.visible ? label : ariaLabel}
|
|
60
|
+
className={cn('motion-reduce:transition-none', className)}
|
|
61
|
+
>
|
|
62
|
+
<span className='grid'>
|
|
63
|
+
<span
|
|
64
|
+
className={cn(
|
|
65
|
+
'col-start-1 row-start-1 inline-flex items-center justify-center gap-1.5',
|
|
66
|
+
feedback.visible && 'invisible',
|
|
67
|
+
)}
|
|
68
|
+
aria-hidden={feedback.visible || undefined}
|
|
69
|
+
>
|
|
70
|
+
{children}
|
|
71
|
+
</span>
|
|
72
|
+
<span
|
|
73
|
+
className={cn(
|
|
74
|
+
'col-start-1 row-start-1 inline-flex items-center justify-center gap-1.5',
|
|
75
|
+
!feedback.visible && 'invisible',
|
|
76
|
+
)}
|
|
77
|
+
aria-hidden='true'
|
|
78
|
+
>
|
|
79
|
+
<BrandLoadingMark className='size-4 text-current' />
|
|
80
|
+
{label}
|
|
81
|
+
</span>
|
|
82
|
+
</span>
|
|
83
|
+
</Button>
|
|
84
|
+
<Tooltip.Root open={feedback.longWaiting}>
|
|
85
|
+
<Tooltip.Portal>
|
|
86
|
+
<Tooltip.Positioner
|
|
87
|
+
anchor={anchorRef}
|
|
88
|
+
side='top'
|
|
89
|
+
sideOffset={8}
|
|
90
|
+
collisionPadding={12}
|
|
91
|
+
// The hint only explains the wait; it must not block the controls it covers.
|
|
92
|
+
className='pointer-events-none z-50'
|
|
93
|
+
>
|
|
94
|
+
<Tooltip.Popup
|
|
95
|
+
data-slot='pending-hint'
|
|
96
|
+
aria-hidden='true'
|
|
97
|
+
className='max-w-64 rounded-md bg-foreground px-3 py-2 text-xs text-background shadow-md'
|
|
98
|
+
>
|
|
99
|
+
{message}
|
|
100
|
+
</Tooltip.Popup>
|
|
101
|
+
</Tooltip.Positioner>
|
|
102
|
+
</Tooltip.Portal>
|
|
103
|
+
</Tooltip.Root>
|
|
104
|
+
{feedback.visible && (
|
|
105
|
+
<span className='sr-only' role='status' aria-live='polite'>
|
|
106
|
+
{message}
|
|
107
|
+
</span>
|
|
108
|
+
)}
|
|
109
|
+
</>
|
|
110
|
+
);
|
|
111
|
+
}
|
|
@@ -26,6 +26,11 @@ export interface RoleListProps {
|
|
|
26
26
|
readonly custom: string;
|
|
27
27
|
};
|
|
28
28
|
readonly ariaLabel?: string;
|
|
29
|
+
/**
|
|
30
|
+
* The host is refreshing these current roles. Pass false while another
|
|
31
|
+
* control, such as a retry after a refresh failure, already announces the wait.
|
|
32
|
+
*/
|
|
33
|
+
readonly loading?: boolean;
|
|
29
34
|
readonly tableState?: Pick<
|
|
30
35
|
DataTableProps<RoleListRole>,
|
|
31
36
|
| 'paginationState'
|
|
@@ -41,6 +46,7 @@ export function RoleList({
|
|
|
41
46
|
roles,
|
|
42
47
|
labels,
|
|
43
48
|
ariaLabel,
|
|
49
|
+
loading = false,
|
|
44
50
|
tableState,
|
|
45
51
|
}: RoleListProps): ReactElement {
|
|
46
52
|
const columns = useMemo<ColumnDef<RoleListRole>[]>(
|
|
@@ -95,6 +101,7 @@ export function RoleList({
|
|
|
95
101
|
data={roles}
|
|
96
102
|
getRowId={(role) => role.key}
|
|
97
103
|
showSelectedCount={false}
|
|
104
|
+
loading={loading}
|
|
98
105
|
/>
|
|
99
106
|
</div>
|
|
100
107
|
);
|
|
@@ -1,8 +1,15 @@
|
|
|
1
|
+
import {
|
|
2
|
+
RouteChildPageHostContext,
|
|
3
|
+
type RouteChildPageHostValue,
|
|
4
|
+
} from '@nocobase/app-client/route-containers';
|
|
1
5
|
import {
|
|
2
6
|
createContext,
|
|
3
7
|
useContext,
|
|
4
8
|
useEffect,
|
|
9
|
+
useLayoutEffect,
|
|
10
|
+
useMemo,
|
|
5
11
|
useRef,
|
|
12
|
+
useState,
|
|
6
13
|
type ReactElement,
|
|
7
14
|
type ReactNode,
|
|
8
15
|
type RefObject,
|
|
@@ -36,7 +43,58 @@ const EnclosingLayerContext =
|
|
|
36
43
|
* the tab's outlet — anchors to the outer element: it covers this layer whole instead of scrolling out of sight with
|
|
37
44
|
* the content once the user has scrolled this page.
|
|
38
45
|
*/
|
|
39
|
-
export function RouteChildPage({
|
|
46
|
+
export function RouteChildPage(props: RouteChildPageProps): ReactElement {
|
|
47
|
+
const host = useContext(RouteChildPageHostContext);
|
|
48
|
+
if (host) return <HostedRouteChildPage host={host} {...props} />;
|
|
49
|
+
return <RouteChildPageLayer {...props} />;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Inside the layer the application opened, the page's `RouteChildPage` adds no second layer: its content renders in
|
|
53
|
+
// that layer and its `className` moves onto it, so a migrated page keeps its layout.
|
|
54
|
+
function HostedRouteChildPage({
|
|
55
|
+
host,
|
|
56
|
+
children,
|
|
57
|
+
className,
|
|
58
|
+
}: RouteChildPageProps & {
|
|
59
|
+
readonly host: RouteChildPageHostValue;
|
|
60
|
+
}): ReactElement {
|
|
61
|
+
useLayoutEffect(() => {
|
|
62
|
+
host.configure({ className });
|
|
63
|
+
return () => host.configure({});
|
|
64
|
+
}, [host, className]);
|
|
65
|
+
return (
|
|
66
|
+
<RouteChildPageHostContext.Provider value={null}>
|
|
67
|
+
{children}
|
|
68
|
+
</RouteChildPageHostContext.Provider>
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The layer the application opens for a route that declares a `child-page` presentation, before the page module, its
|
|
74
|
+
* authorization check or its data are ready. Waiting, denial and failure render inside it; once the page renders, its
|
|
75
|
+
* own `RouteChildPage` contributes content to this layer. Everything the hosted page renders, including an outlet it
|
|
76
|
+
* places beside its `RouteChildPage`, is inside the layer, so a deeper child page covers this one whole.
|
|
77
|
+
*/
|
|
78
|
+
export function RouteChildPageHost({
|
|
79
|
+
children,
|
|
80
|
+
}: {
|
|
81
|
+
readonly children?: ReactNode;
|
|
82
|
+
}): ReactElement {
|
|
83
|
+
const [className, setClassName] = useState<string>();
|
|
84
|
+
const host = useMemo<RouteChildPageHostValue>(
|
|
85
|
+
() => ({ configure: (config) => setClassName(config.className) }),
|
|
86
|
+
[],
|
|
87
|
+
);
|
|
88
|
+
return (
|
|
89
|
+
<RouteChildPageLayer className={className}>
|
|
90
|
+
<RouteChildPageHostContext.Provider value={host}>
|
|
91
|
+
{children}
|
|
92
|
+
</RouteChildPageHostContext.Provider>
|
|
93
|
+
</RouteChildPageLayer>
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function RouteChildPageLayer({
|
|
40
98
|
children,
|
|
41
99
|
className,
|
|
42
100
|
}: RouteChildPageProps): ReactElement {
|
|
@@ -119,3 +177,16 @@ export function RouteChildPage({
|
|
|
119
177
|
</div>
|
|
120
178
|
);
|
|
121
179
|
}
|
|
180
|
+
|
|
181
|
+
/** Ends a `RouteChildPageHost`'s reach, so a route rendered inside one that is not hosted builds its own layer. */
|
|
182
|
+
export function RouteChildPageHostBoundary({
|
|
183
|
+
children,
|
|
184
|
+
}: {
|
|
185
|
+
readonly children?: ReactNode;
|
|
186
|
+
}): ReactElement {
|
|
187
|
+
return (
|
|
188
|
+
<RouteChildPageHostContext.Provider value={null}>
|
|
189
|
+
{children}
|
|
190
|
+
</RouteChildPageHostContext.Provider>
|
|
191
|
+
);
|
|
192
|
+
}
|
|
@@ -26,10 +26,14 @@ import {
|
|
|
26
26
|
DialogPortal,
|
|
27
27
|
DialogTitle,
|
|
28
28
|
} from '#components/ui/dialog';
|
|
29
|
+
import {
|
|
30
|
+
RouteOverlayContext,
|
|
31
|
+
RouteOverlayHostContext,
|
|
32
|
+
type RouteOverlayHostConfig,
|
|
33
|
+
type RouteOverlayHostValue,
|
|
34
|
+
} from '@nocobase/app-client/route-containers';
|
|
29
35
|
import { cn } from 'cn';
|
|
30
36
|
|
|
31
|
-
import { RouteOverlayContext } from './use-route-overlay.js';
|
|
32
|
-
|
|
33
37
|
export interface RouteOverlayProps {
|
|
34
38
|
readonly title: ReactNode;
|
|
35
39
|
readonly description?: ReactNode;
|
|
@@ -52,8 +56,103 @@ const ParentPopupContext =
|
|
|
52
56
|
/**
|
|
53
57
|
* The shared implementation of `RouteDialog` and `RouteDrawer`: a modal that is open for as long as its route matches,
|
|
54
58
|
* and closes by navigating away from it. Route registration stays with the application.
|
|
59
|
+
*
|
|
60
|
+
* On a route that declares a `dialog` or `drawer` presentation the application has already opened the panel, and this
|
|
61
|
+
* component configures that one instead of opening a second: its title, description, footer, `closeTo` and
|
|
62
|
+
* `beforeClose` move to the host and its children render in the host's body. On any other route it opens its own.
|
|
63
|
+
* The host context comes from `@nocobase/app-client`, so a plugin's own copy of this component joins the same panel.
|
|
64
|
+
*/
|
|
65
|
+
export function RouteOverlay(
|
|
66
|
+
props: RouteOverlayProps & { readonly drawer?: boolean },
|
|
67
|
+
): ReactElement {
|
|
68
|
+
const host = useContext(RouteOverlayHostContext);
|
|
69
|
+
return host ? (
|
|
70
|
+
<HostedRouteOverlay host={host} {...props} />
|
|
71
|
+
) : (
|
|
72
|
+
<RouteOverlayFrame {...props} />
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function HostedRouteOverlay({
|
|
77
|
+
host,
|
|
78
|
+
children,
|
|
79
|
+
drawer: _drawer,
|
|
80
|
+
...config
|
|
81
|
+
}: RouteOverlayProps & {
|
|
82
|
+
readonly host: RouteOverlayHostValue;
|
|
83
|
+
readonly drawer?: boolean;
|
|
84
|
+
}): ReactElement {
|
|
85
|
+
// Runs after every render so the footer and guard the host shows are always this render's. There is no cleanup:
|
|
86
|
+
// the host stops using the configuration once it is told the page is gone, and an `<Activity>` that only hides
|
|
87
|
+
// the page during a recheck must not take its close guard away.
|
|
88
|
+
useLayoutEffect(() => {
|
|
89
|
+
host.configure(config);
|
|
90
|
+
});
|
|
91
|
+
return (
|
|
92
|
+
<RouteOverlayHostContext.Provider value={null}>
|
|
93
|
+
{children}
|
|
94
|
+
</RouteOverlayHostContext.Provider>
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export interface RouteOverlayHostProps {
|
|
99
|
+
readonly drawer?: boolean;
|
|
100
|
+
/** Shown until the hosted page configures its own title, and whenever its content is concealed. */
|
|
101
|
+
readonly title: ReactNode;
|
|
102
|
+
/**
|
|
103
|
+
* True while the page component is mounted. While it is false — the module is loading, failed or was denied — the
|
|
104
|
+
* host ignores what an earlier mount configured and closes without a guard.
|
|
105
|
+
*/
|
|
106
|
+
readonly ready?: boolean;
|
|
107
|
+
/**
|
|
108
|
+
* True while the mounted page is hidden, for example during an authorization recheck: the host shows only its own
|
|
109
|
+
* title and no footer, so nothing the page derived from protected data stays on screen. The page's close guard
|
|
110
|
+
* is temporarily blocked until the page's effects reconnect, because hiding disconnects form dirty registrations.
|
|
111
|
+
*/
|
|
112
|
+
readonly concealed?: boolean;
|
|
113
|
+
readonly children?: ReactNode;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* The panel the application opens for a route that declares a `dialog` or `drawer` presentation, before the page
|
|
118
|
+
* module, its authorization check or its data are ready. Waiting, denial and failure render as its children; once the
|
|
119
|
+
* page renders its own `RouteDialog` or `RouteDrawer` inside, that configures this panel. There is still one panel
|
|
120
|
+
* and one focus trap, and the same close path and focus return as a standalone overlay. A hosted page renders its
|
|
121
|
+
* `RouteDialog` or `RouteDrawer` unconditionally, as its root.
|
|
55
122
|
*/
|
|
56
|
-
export function
|
|
123
|
+
export function RouteOverlayHost({
|
|
124
|
+
drawer = false,
|
|
125
|
+
title,
|
|
126
|
+
ready = false,
|
|
127
|
+
concealed = false,
|
|
128
|
+
children,
|
|
129
|
+
}: RouteOverlayHostProps): ReactElement {
|
|
130
|
+
const [config, setConfig] = useState<RouteOverlayHostConfig | null>(null);
|
|
131
|
+
const host = useMemo<RouteOverlayHostValue>(
|
|
132
|
+
() => ({ configure: setConfig }),
|
|
133
|
+
[],
|
|
134
|
+
);
|
|
135
|
+
const page = ready ? config : null;
|
|
136
|
+
const shown = concealed ? null : page;
|
|
137
|
+
return (
|
|
138
|
+
<RouteOverlayFrame
|
|
139
|
+
drawer={drawer}
|
|
140
|
+
title={shown?.title ?? title}
|
|
141
|
+
description={shown?.description}
|
|
142
|
+
footer={shown?.footer}
|
|
143
|
+
closeTo={page?.closeTo}
|
|
144
|
+
beforeClose={page?.beforeClose}
|
|
145
|
+
closeBlocked={ready && concealed}
|
|
146
|
+
className={page?.className}
|
|
147
|
+
>
|
|
148
|
+
<RouteOverlayHostContext.Provider value={host}>
|
|
149
|
+
{children}
|
|
150
|
+
</RouteOverlayHostContext.Provider>
|
|
151
|
+
</RouteOverlayFrame>
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function RouteOverlayFrame({
|
|
57
156
|
title,
|
|
58
157
|
description,
|
|
59
158
|
children,
|
|
@@ -62,10 +161,24 @@ export function RouteOverlay({
|
|
|
62
161
|
beforeClose,
|
|
63
162
|
className,
|
|
64
163
|
drawer = false,
|
|
65
|
-
|
|
164
|
+
closeBlocked = false,
|
|
165
|
+
}: RouteOverlayProps & {
|
|
166
|
+
readonly drawer?: boolean;
|
|
167
|
+
readonly closeBlocked?: boolean;
|
|
168
|
+
}): ReactElement {
|
|
66
169
|
const { t } = useTranslation();
|
|
67
170
|
const parentPopup = useContext(ParentPopupContext);
|
|
68
171
|
const popupRef = useRef<HTMLDivElement>(null);
|
|
172
|
+
const [portal, setPortal] = useState<HTMLDivElement | null>(null);
|
|
173
|
+
// A parent route's Activity can stop at its first DOM element, while this portal lives outside that element.
|
|
174
|
+
// Base UI creates the portal after mount, so track the actual node before connecting its visibility lifecycle.
|
|
175
|
+
useLayoutEffect(() => {
|
|
176
|
+
if (!portal) return;
|
|
177
|
+
portal.style.removeProperty('display');
|
|
178
|
+
return () => {
|
|
179
|
+
portal.style.display = 'none';
|
|
180
|
+
};
|
|
181
|
+
}, [portal]);
|
|
69
182
|
const previousFocusRef = useRef<HTMLElement | null>(null);
|
|
70
183
|
useLayoutEffect(() => {
|
|
71
184
|
previousFocusRef.current =
|
|
@@ -75,29 +188,37 @@ export function RouteOverlay({
|
|
|
75
188
|
}, []);
|
|
76
189
|
const navigate = useNavigate();
|
|
77
190
|
const location = useLocation();
|
|
78
|
-
const
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
191
|
+
const closeScope = useMemo(
|
|
192
|
+
() => ({ location, closeBlocked }),
|
|
193
|
+
[location, closeBlocked],
|
|
194
|
+
);
|
|
195
|
+
const [closingScope, setClosingScope] = useState<typeof closeScope | null>(
|
|
196
|
+
null,
|
|
197
|
+
);
|
|
198
|
+
const isClosing = closingScope === closeScope;
|
|
82
199
|
const pendingRef = useRef<Promise<void> | null>(null);
|
|
83
200
|
const generationRef = useRef(0);
|
|
84
201
|
|
|
85
|
-
// A
|
|
86
|
-
//
|
|
202
|
+
// A location change or concealment invalidates pending closes. Activity cleanup can resolve a form's old
|
|
203
|
+
// confirmation, but that result must never navigate, including after permission is restored.
|
|
87
204
|
useLayoutEffect(() => {
|
|
88
205
|
generationRef.current += 1;
|
|
89
206
|
pendingRef.current = null;
|
|
90
207
|
return () => {
|
|
91
208
|
generationRef.current += 1;
|
|
209
|
+
setClosingScope(null);
|
|
92
210
|
};
|
|
93
|
-
}, [location]);
|
|
211
|
+
}, [location, closeBlocked]);
|
|
94
212
|
|
|
95
213
|
const close = useCallback((): Promise<void> => {
|
|
214
|
+
if (closeBlocked) return Promise.resolve();
|
|
96
215
|
if (pendingRef.current) return pendingRef.current;
|
|
97
216
|
const requestGeneration = generationRef.current;
|
|
98
|
-
|
|
217
|
+
setClosingScope(closeScope);
|
|
99
218
|
const request = Promise.resolve()
|
|
100
219
|
.then(async () => {
|
|
220
|
+
// A recheck can start while an earlier confirmation is open, or before this microtask runs.
|
|
221
|
+
if (generationRef.current !== requestGeneration) return;
|
|
101
222
|
const allowed = beforeClose ? await beforeClose() : true;
|
|
102
223
|
if (allowed && generationRef.current === requestGeneration) {
|
|
103
224
|
await navigate(
|
|
@@ -109,12 +230,12 @@ export function RouteOverlay({
|
|
|
109
230
|
.finally(() => {
|
|
110
231
|
if (generationRef.current === requestGeneration) {
|
|
111
232
|
pendingRef.current = null;
|
|
112
|
-
|
|
233
|
+
setClosingScope(null);
|
|
113
234
|
}
|
|
114
235
|
});
|
|
115
236
|
pendingRef.current = request;
|
|
116
237
|
return request;
|
|
117
|
-
}, [beforeClose, closeTo, location, navigate]);
|
|
238
|
+
}, [beforeClose, closeBlocked, closeScope, closeTo, location, navigate]);
|
|
118
239
|
const value = useMemo(() => ({ close, isClosing }), [close, isClosing]);
|
|
119
240
|
|
|
120
241
|
return (
|
|
@@ -131,7 +252,7 @@ export function RouteOverlay({
|
|
|
131
252
|
});
|
|
132
253
|
}}
|
|
133
254
|
>
|
|
134
|
-
<DialogPortal>
|
|
255
|
+
<DialogPortal ref={setPortal}>
|
|
135
256
|
{/* Each nested panel needs its own backdrop above its parent panel. */}
|
|
136
257
|
<DialogOverlay forceRender />
|
|
137
258
|
<DialogPrimitive.Popup
|
|
@@ -212,3 +333,16 @@ export function RouteOverlay({
|
|
|
212
333
|
</RouteOverlayContext.Provider>
|
|
213
334
|
);
|
|
214
335
|
}
|
|
336
|
+
|
|
337
|
+
/** Ends a `RouteOverlayHost`'s reach, so a route rendered inside one that is not hosted builds its own overlay. */
|
|
338
|
+
export function RouteOverlayHostBoundary({
|
|
339
|
+
children,
|
|
340
|
+
}: {
|
|
341
|
+
readonly children?: ReactNode;
|
|
342
|
+
}): ReactElement {
|
|
343
|
+
return (
|
|
344
|
+
<RouteOverlayHostContext.Provider value={null}>
|
|
345
|
+
{children}
|
|
346
|
+
</RouteOverlayHostContext.Provider>
|
|
347
|
+
);
|
|
348
|
+
}
|