@nocobase/app-template-default 1.0.0-beta.87 → 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/notification-templates.md +5 -0
- package/.agents/rules/shell.md +9 -3
- package/AGENTS.md +4 -2
- package/README.MD +11 -5
- package/client/components/back-button.tsx +37 -12
- package/client/components/body-portal.tsx +25 -0
- package/client/components/brand-loading-mark.tsx +5 -2
- package/client/components/brand-mark-geometry.ts +3 -3
- package/client/components/breadcrumbs.tsx +60 -3
- package/client/components/data-table/data-table.tsx +90 -19
- package/client/components/data-table/pagination.tsx +17 -2
- package/client/components/flow-diagram.tsx +2 -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 +8 -8
- package/client/layouts/components/sidebar-footer.tsx +18 -25
- package/client/lib/navigation-transform.ts +156 -22
- package/client/locales/system/en-US.ts +26 -5
- package/client/locales/system/settings-en-US.ts +17 -0
- package/client/locales/system/settings-zh-CN.ts +15 -0
- package/client/locales/system/zh-CN.ts +26 -5
- package/client/pages/auth/register.tsx +39 -22
- package/client/pages/auth/shared.tsx +1 -3
- package/client/pages/settings/default-access.tsx +1 -1
- package/client/pages/settings/hooks.tsx +3 -68
- package/client/pages/settings/notification-routes.ts +109 -0
- package/client/pages/settings/notification-templates.tsx +26 -0
- package/client/pages/settings/permission-management-page.tsx +15 -90
- package/client/pages/settings/roles.tsx +27 -27
- package/client/pages/settings/routes.ts +107 -101
- package/client/pages/settings/settings-home.tsx +109 -0
- package/client/pages/settings/settings-module-page.tsx +158 -0
- package/client/pages/settings/shell-routes.ts +53 -0
- 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/routes.ts +17 -1
- package/client/routing/app-router.tsx +1 -14
- package/client/routing/client-route.tsx +199 -30
- package/client/routing/navigation-destination.ts +42 -0
- package/client/routing/route-context.tsx +30 -3
- package/client/routing/route-leave-boundary.tsx +261 -0
- package/client/routing/route-navigation.ts +72 -8
- package/client/routing/route-tree.tsx +7 -12
- package/client/runtime.ts +2 -2
- package/client/styles.css +18 -1
- package/config.example.yml +1 -1
- package/package.json +13 -13
- package/server/config/auth.ts +1 -1
- package/server/providers/index.ts +2 -0
- package/server/providers/notification-management.ts +27 -0
- package/tests/client/account-permissions.test.tsx +1 -1
- 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-auth.test.tsx +10 -1
- package/tests/client/client-route-recovery.test.tsx +93 -1
- package/tests/client/client-routes.test.ts +137 -4
- package/tests/client/client-shell.test.tsx +23 -8
- package/tests/client/foundation-integration.test.ts +38 -17
- package/tests/client/layout-i18n.test.tsx +9 -4
- 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 +458 -17
- package/tests/client/primitive-labels.test.tsx +2 -2
- package/tests/client/registration-feedback.test.tsx +143 -0
- 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 +112 -0
- package/tests/client/settings-idle-list.test.tsx +124 -0
- package/tests/client/settings-navigation.test.ts +230 -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
- package/tests/server/notification-templates.test.ts +144 -0
- package/tests/client/permission-management-routes.test.ts +0 -64
|
@@ -8,7 +8,7 @@ import {
|
|
|
8
8
|
type ReactElement,
|
|
9
9
|
type ReactNode,
|
|
10
10
|
} from 'react';
|
|
11
|
-
import {
|
|
11
|
+
import { BodyPortal } from './body-portal.js';
|
|
12
12
|
import {
|
|
13
13
|
BaseEdge,
|
|
14
14
|
Controls,
|
|
@@ -934,7 +934,7 @@ export function FlowDiagram({
|
|
|
934
934
|
)}
|
|
935
935
|
</div>
|
|
936
936
|
);
|
|
937
|
-
return expanded ?
|
|
937
|
+
return expanded ? <BodyPortal>{canvas}</BodyPortal> : canvas;
|
|
938
938
|
}
|
|
939
939
|
|
|
940
940
|
/** View controls belong inside React Flow so they always use this canvas's viewport. */
|
|
@@ -3,7 +3,6 @@ import { useTranslation } from '@nocobase/i18n/client';
|
|
|
3
3
|
import {
|
|
4
4
|
CircleAlert,
|
|
5
5
|
Inbox,
|
|
6
|
-
LoaderCircle,
|
|
7
6
|
LockKeyhole,
|
|
8
7
|
LogIn,
|
|
9
8
|
RefreshCw,
|
|
@@ -14,6 +13,8 @@ import { Fragment, type ReactElement, type ReactNode } from 'react';
|
|
|
14
13
|
import { Link } from 'react-router';
|
|
15
14
|
import { Button } from '#components/ui/button';
|
|
16
15
|
import { Alert } from '#components/ui/alert';
|
|
16
|
+
import { Loading } from '#components/loading';
|
|
17
|
+
import { PendingButton } from '#components/pending-button';
|
|
17
18
|
import {
|
|
18
19
|
Empty,
|
|
19
20
|
EmptyContent,
|
|
@@ -23,7 +24,6 @@ import {
|
|
|
23
24
|
} from '#components/ui/empty';
|
|
24
25
|
|
|
25
26
|
const feedback = {
|
|
26
|
-
loading: ['listFeedback.loading', 'Loading', LoaderCircle],
|
|
27
27
|
empty: ['listFeedback.empty', 'No data yet', Inbox],
|
|
28
28
|
'no-results': ['listFeedback.noResults', 'No results', SearchX],
|
|
29
29
|
forbidden: ['listFeedback.forbidden', 'Access denied', LockKeyhole],
|
|
@@ -38,6 +38,11 @@ export interface ListFeedbackProps {
|
|
|
38
38
|
readonly empty?: ReactNode;
|
|
39
39
|
/** Optional business-specific action; the standard create action belongs in the page header. */
|
|
40
40
|
readonly emptyAction?: ReactNode;
|
|
41
|
+
/**
|
|
42
|
+
* The current query's stable identity, such as its request path. A new
|
|
43
|
+
* query restarts the display delay without remounting the list.
|
|
44
|
+
*/
|
|
45
|
+
readonly identity?: unknown;
|
|
41
46
|
}
|
|
42
47
|
|
|
43
48
|
export interface ListFeedbackActionsProps {
|
|
@@ -67,19 +72,17 @@ export function ListFeedbackActions({
|
|
|
67
72
|
|
|
68
73
|
if ((state === 'failed' || state === 'refresh-failed') && onRetry) {
|
|
69
74
|
return (
|
|
70
|
-
<
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
{
|
|
80
|
-
|
|
81
|
-
: t('listFeedback.retry', { defaultValue: 'Retry' })}
|
|
82
|
-
</Button>
|
|
75
|
+
<PendingButton
|
|
76
|
+
variant='outline'
|
|
77
|
+
busy={busy}
|
|
78
|
+
pendingLabel={t('listFeedback.retrying', {
|
|
79
|
+
defaultValue: 'Retrying…',
|
|
80
|
+
})}
|
|
81
|
+
onClick={onRetry}
|
|
82
|
+
>
|
|
83
|
+
<RefreshCw aria-hidden='true' />
|
|
84
|
+
{t('listFeedback.retry', { defaultValue: 'Retry' })}
|
|
85
|
+
</PendingButton>
|
|
83
86
|
);
|
|
84
87
|
}
|
|
85
88
|
|
|
@@ -105,6 +108,7 @@ export function ListFeedback({
|
|
|
105
108
|
children,
|
|
106
109
|
empty,
|
|
107
110
|
emptyAction,
|
|
111
|
+
identity,
|
|
108
112
|
}: ListFeedbackProps): ReactElement {
|
|
109
113
|
const { t } = useTranslation();
|
|
110
114
|
|
|
@@ -134,6 +138,17 @@ export function ListFeedback({
|
|
|
134
138
|
);
|
|
135
139
|
}
|
|
136
140
|
|
|
141
|
+
if (state === 'loading') {
|
|
142
|
+
// The body keeps its footprint at once; the indicator and one status follow the shared display delay.
|
|
143
|
+
return (
|
|
144
|
+
<Loading
|
|
145
|
+
identity={identity}
|
|
146
|
+
label={t('listFeedback.loading', { defaultValue: 'Loading' })}
|
|
147
|
+
className='min-h-24 px-4 py-4'
|
|
148
|
+
/>
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
137
152
|
const [key, fallback, Icon] = feedback[state];
|
|
138
153
|
const isError =
|
|
139
154
|
state === 'failed' || state === 'forbidden' || state === 'unauthenticated';
|
|
@@ -145,15 +160,9 @@ export function ListFeedback({
|
|
|
145
160
|
<Empty
|
|
146
161
|
role={isError ? 'alert' : 'status'}
|
|
147
162
|
className='min-h-24 flex-row flex-none gap-2 rounded-none border-0 px-4 py-6 text-pretty text-card-foreground'
|
|
148
|
-
aria-busy={state === 'loading'}
|
|
149
163
|
>
|
|
150
164
|
<EmptyMedia className='mb-0 size-4 text-muted-foreground'>
|
|
151
|
-
<Icon
|
|
152
|
-
aria-hidden='true'
|
|
153
|
-
className={
|
|
154
|
-
state === 'loading' ? 'size-4 motion-safe:animate-spin' : 'size-4'
|
|
155
|
-
}
|
|
156
|
-
/>
|
|
165
|
+
<Icon aria-hidden='true' className='size-4' />
|
|
157
166
|
</EmptyMedia>
|
|
158
167
|
<EmptyTitle className='text-sm font-medium text-muted-foreground'>
|
|
159
168
|
{t(key, { defaultValue: fallback })}
|
|
@@ -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
|
+
}
|