@nocobase/app-template-default 1.0.0-beta.88 → 1.0.0-beta.90
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 +12 -2
- package/AGENTS.md +1 -1
- package/LICENSE.txt +129 -0
- 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/lib/navigation-transform.ts +29 -1
- package/client/locales/system/en-US.ts +23 -0
- package/client/locales/system/zh-CN.ts +23 -0
- package/client/pages/home/index.tsx +66 -51
- package/client/pages/settings/database-explorer/en-US.ts +33 -0
- package/client/pages/settings/database-explorer/index.tsx +358 -0
- package/client/pages/settings/database-explorer/routes.ts +14 -0
- package/client/pages/settings/database-explorer/zh-CN.ts +30 -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/plugins.ts +8 -1
- package/client/routes.ts +2 -1
- 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/config.example.yml +17 -11
- package/index.html +8 -11
- package/package.json +27 -27
- package/server/config/auth.ts +1 -1
- package/server/config/index.ts +3 -0
- package/server/config/jobs.ts +16 -8
- package/server/config/queue.ts +14 -13
- package/server/config/realtime.ts +4 -0
- 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/client-routes.test.ts +5 -3
- package/tests/client/database-explorer-integration.test.tsx +30 -0
- package/tests/client/database-explorer.test.tsx +152 -0
- package/tests/client/foundation-integration.test.ts +42 -29
- package/tests/client/home.test.tsx +26 -85
- package/tests/client/layout-i18n.test.tsx +83 -2
- 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/settings-navigation.test.ts +20 -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 +77 -22
- package/tests/playwright/dialog-footer.test.ts +227 -0
- package/tests/project/bootstrap-loading.test.ts +60 -0
- package/tests/server/app-server.test.ts +18 -12
- package/tests/server/auth-email-config.test.ts +21 -0
- package/tests/server/backends-config.test.ts +118 -0
- package/tests/server/concurrency.test.ts +1 -1
- package/tests/server/config.test.ts +22 -14
- package/tests/server/lifecycle.test.ts +2 -2
- package/tests/server/realtime-config.test.ts +114 -0
- package/client/pages/home/capabilities.ts +0 -62
- package/client/pages/home/prompt-dialog.tsx +0 -122
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { Progress } from '@base-ui/react/progress';
|
|
2
|
+
import { useTranslation } from '@nocobase/i18n/client';
|
|
3
|
+
import type { ReactElement, ReactNode } from 'react';
|
|
4
|
+
import { cn } from 'cn';
|
|
5
|
+
|
|
6
|
+
export type TaskProgressStatus =
|
|
7
|
+
'queued' | 'running' | 'completed' | 'failed' | 'canceled';
|
|
8
|
+
|
|
9
|
+
export interface TaskProgressProps {
|
|
10
|
+
/** The task's real lifecycle status, from the server's task record. */
|
|
11
|
+
readonly status: TaskProgressStatus;
|
|
12
|
+
/** What the task is, such as "Customer export". */
|
|
13
|
+
readonly label: string;
|
|
14
|
+
/** The application's own word for the status; defaults to the standard copy. */
|
|
15
|
+
readonly statusLabel?: string;
|
|
16
|
+
/** Units actually processed, as reported by the task. */
|
|
17
|
+
readonly processed?: number;
|
|
18
|
+
/** The real total; leave it out or pass null while it is unknown. */
|
|
19
|
+
readonly total?: number | null;
|
|
20
|
+
/** For tasks that report only a percentage (0–100) instead of counts. */
|
|
21
|
+
readonly percent?: number | null;
|
|
22
|
+
/** Show that the task keeps running after the user leaves the page. */
|
|
23
|
+
readonly continuesInBackground?: boolean;
|
|
24
|
+
/** Caller-owned detail: a failure reason, or where the result is. */
|
|
25
|
+
readonly message?: ReactNode;
|
|
26
|
+
/** Real actions only: download, retry, or a cancel the server supports. */
|
|
27
|
+
readonly actions?: ReactNode;
|
|
28
|
+
readonly className?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* One long-running task record. A known total shows a determinate bar and
|
|
33
|
+
* count; an unknown total shows an indeterminate bar without a percentage.
|
|
34
|
+
* Status changes are announced once; percentage updates are not.
|
|
35
|
+
*/
|
|
36
|
+
export function TaskProgress({
|
|
37
|
+
status,
|
|
38
|
+
label,
|
|
39
|
+
statusLabel,
|
|
40
|
+
processed,
|
|
41
|
+
total,
|
|
42
|
+
percent: reportedPercent,
|
|
43
|
+
continuesInBackground = false,
|
|
44
|
+
message,
|
|
45
|
+
actions,
|
|
46
|
+
className,
|
|
47
|
+
}: TaskProgressProps): ReactElement {
|
|
48
|
+
const { t } = useTranslation();
|
|
49
|
+
const active = status === 'queued' || status === 'running';
|
|
50
|
+
const counted = total !== undefined && total !== null && total > 0;
|
|
51
|
+
const done = processed ?? 0;
|
|
52
|
+
const known =
|
|
53
|
+
counted || (reportedPercent !== undefined && reportedPercent !== null);
|
|
54
|
+
const percent = counted
|
|
55
|
+
? Math.min(100, Math.floor((done / total) * 100))
|
|
56
|
+
: Math.min(100, Math.max(0, Math.floor(reportedPercent ?? 0)));
|
|
57
|
+
const statusText =
|
|
58
|
+
statusLabel ??
|
|
59
|
+
{
|
|
60
|
+
queued: t('taskProgress.queued', { defaultValue: 'Waiting to start' }),
|
|
61
|
+
running: t('taskProgress.running', { defaultValue: 'In progress' }),
|
|
62
|
+
completed: t('taskProgress.completed', { defaultValue: 'Completed' }),
|
|
63
|
+
failed: t('taskProgress.failed', { defaultValue: 'Failed' }),
|
|
64
|
+
canceled: t('taskProgress.canceled', { defaultValue: 'Canceled' }),
|
|
65
|
+
}[status];
|
|
66
|
+
const countText = counted
|
|
67
|
+
? t('taskProgress.count', {
|
|
68
|
+
defaultValue: '{{processed}} of {{total}}',
|
|
69
|
+
processed: done,
|
|
70
|
+
total,
|
|
71
|
+
})
|
|
72
|
+
: processed
|
|
73
|
+
? t('taskProgress.processed', {
|
|
74
|
+
defaultValue: '{{processed}} processed',
|
|
75
|
+
processed: done,
|
|
76
|
+
})
|
|
77
|
+
: undefined;
|
|
78
|
+
return (
|
|
79
|
+
<div
|
|
80
|
+
data-slot='task-progress'
|
|
81
|
+
data-status={status}
|
|
82
|
+
className={cn('flex flex-col gap-2', className)}
|
|
83
|
+
>
|
|
84
|
+
<div className='flex flex-wrap items-baseline justify-between gap-x-3 gap-y-1'>
|
|
85
|
+
<span className='min-w-0 text-sm font-medium wrap-anywhere'>
|
|
86
|
+
{label}
|
|
87
|
+
</span>
|
|
88
|
+
<span
|
|
89
|
+
className={cn(
|
|
90
|
+
'text-sm',
|
|
91
|
+
status === 'failed' ? 'text-destructive' : 'text-muted-foreground',
|
|
92
|
+
)}
|
|
93
|
+
>
|
|
94
|
+
{statusText}
|
|
95
|
+
</span>
|
|
96
|
+
</div>
|
|
97
|
+
{active ? (
|
|
98
|
+
<Progress.Root
|
|
99
|
+
value={known ? percent : null}
|
|
100
|
+
aria-label={label}
|
|
101
|
+
getAriaValueText={() =>
|
|
102
|
+
[statusText, countText, known ? `${percent}%` : undefined]
|
|
103
|
+
.filter(Boolean)
|
|
104
|
+
.join(', ')
|
|
105
|
+
}
|
|
106
|
+
className='flex flex-col gap-1.5'
|
|
107
|
+
>
|
|
108
|
+
<Progress.Track className='relative h-1.5 w-full overflow-hidden rounded-full bg-muted'>
|
|
109
|
+
{/* An unknown amount fills the whole track faintly, so no segment can be read as a fraction done. */}
|
|
110
|
+
<Progress.Indicator
|
|
111
|
+
data-slot='task-progress-indicator'
|
|
112
|
+
className={
|
|
113
|
+
known
|
|
114
|
+
? 'h-full bg-primary motion-safe:transition-[width] motion-safe:duration-500'
|
|
115
|
+
: 'h-full w-full bg-primary/25 motion-safe:animate-pulse'
|
|
116
|
+
}
|
|
117
|
+
/>
|
|
118
|
+
</Progress.Track>
|
|
119
|
+
{countText || known ? (
|
|
120
|
+
<span className='text-xs text-muted-foreground tabular-nums'>
|
|
121
|
+
{[countText, known ? `${percent}%` : undefined]
|
|
122
|
+
.filter(Boolean)
|
|
123
|
+
.join(' · ')}
|
|
124
|
+
</span>
|
|
125
|
+
) : null}
|
|
126
|
+
</Progress.Root>
|
|
127
|
+
) : countText && status !== 'completed' ? (
|
|
128
|
+
<span className='text-xs text-muted-foreground tabular-nums'>
|
|
129
|
+
{countText}
|
|
130
|
+
</span>
|
|
131
|
+
) : null}
|
|
132
|
+
{active && continuesInBackground ? (
|
|
133
|
+
<p className='text-xs text-muted-foreground'>
|
|
134
|
+
{t('taskProgress.background', {
|
|
135
|
+
defaultValue: 'It keeps running if you leave this page.',
|
|
136
|
+
})}
|
|
137
|
+
</p>
|
|
138
|
+
) : null}
|
|
139
|
+
{message ? (
|
|
140
|
+
<div
|
|
141
|
+
className={cn(
|
|
142
|
+
'text-sm',
|
|
143
|
+
status === 'failed' ? 'text-destructive' : 'text-muted-foreground',
|
|
144
|
+
)}
|
|
145
|
+
>
|
|
146
|
+
{message}
|
|
147
|
+
</div>
|
|
148
|
+
) : null}
|
|
149
|
+
{actions ? <div className='flex flex-wrap gap-2'>{actions}</div> : null}
|
|
150
|
+
<span role='status' aria-live='polite' className='sr-only'>
|
|
151
|
+
{`${label}: ${statusText}`}
|
|
152
|
+
</span>
|
|
153
|
+
</div>
|
|
154
|
+
);
|
|
155
|
+
}
|
|
@@ -1,15 +1,12 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
RouteOverlayContext,
|
|
3
|
+
type RouteOverlayContextValue,
|
|
4
|
+
} from '@nocobase/app-client/route-containers';
|
|
5
|
+
import { useContext } from 'react';
|
|
2
6
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
/** True while a close request for the current location is pending. */
|
|
7
|
-
readonly isClosing: boolean;
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
/** Internal context shared by both route overlay components. */
|
|
11
|
-
export const RouteOverlayContext: Context<RouteOverlayContextValue | null> =
|
|
12
|
-
createContext<RouteOverlayContextValue | null>(null);
|
|
7
|
+
// The context lives in `@nocobase/app-client` so every copy of the route overlay components, including a plugin's,
|
|
8
|
+
// shares it: a footer button inside a panel the application opened reaches that panel's close request.
|
|
9
|
+
export { RouteOverlayContext, type RouteOverlayContextValue };
|
|
13
10
|
|
|
14
11
|
/**
|
|
15
12
|
* Closes the enclosing `RouteDialog` or `RouteDrawer`. Call it from a component rendered inside the overlay — its
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { messageKey } from '../lib/message-key.js';
|
|
2
|
+
import { usePendingFeedback } from '@nocobase/app-client';
|
|
2
3
|
import { useTranslation } from '@nocobase/i18n/client';
|
|
3
4
|
import { RotateCcw, Trash2, UploadCloud, X } from 'lucide-react';
|
|
4
5
|
import {
|
|
@@ -8,11 +9,12 @@ import {
|
|
|
8
9
|
type ChangeEvent,
|
|
9
10
|
type DragEvent,
|
|
10
11
|
type ReactElement,
|
|
12
|
+
type ReactNode,
|
|
11
13
|
} from 'react';
|
|
12
14
|
|
|
13
15
|
import type { FileRecord, FileUploadFieldProps } from '../types.js';
|
|
14
16
|
import { Button } from '#components/ui/button';
|
|
15
|
-
import {
|
|
17
|
+
import { BrandLoadingMark } from '#components/brand-loading-mark';
|
|
16
18
|
import { FileThumbnail } from './file-thumbnail.js';
|
|
17
19
|
|
|
18
20
|
type UploadItem = {
|
|
@@ -23,6 +25,48 @@ type UploadItem = {
|
|
|
23
25
|
readonly error?: Error;
|
|
24
26
|
};
|
|
25
27
|
|
|
28
|
+
/**
|
|
29
|
+
* The upload request reports no byte progress, so the item shows an honest
|
|
30
|
+
* indeterminate wait from one display hook: the mark appears after the shared
|
|
31
|
+
* display delay and a long upload says so after 10 seconds, next to the real
|
|
32
|
+
* cancel action.
|
|
33
|
+
*/
|
|
34
|
+
function UploadItemBody({
|
|
35
|
+
status,
|
|
36
|
+
failure,
|
|
37
|
+
children,
|
|
38
|
+
}: {
|
|
39
|
+
readonly status: UploadItem['status'];
|
|
40
|
+
readonly failure?: string;
|
|
41
|
+
readonly children: ReactNode;
|
|
42
|
+
}): ReactElement {
|
|
43
|
+
const { t } = useTranslation();
|
|
44
|
+
const feedback = usePendingFeedback(status === 'uploading');
|
|
45
|
+
return (
|
|
46
|
+
<>
|
|
47
|
+
<div className='flex h-20 items-center justify-center overflow-hidden rounded-sm bg-muted/30'>
|
|
48
|
+
{feedback.visible ? (
|
|
49
|
+
<BrandLoadingMark className='size-8 text-muted-foreground' />
|
|
50
|
+
) : (
|
|
51
|
+
<UploadCloud aria-hidden='true' />
|
|
52
|
+
)}
|
|
53
|
+
</div>
|
|
54
|
+
{children}
|
|
55
|
+
<div className='text-xs text-muted-foreground'>
|
|
56
|
+
{status === 'error'
|
|
57
|
+
? (failure ?? t('fileUi.files.failed', { defaultValue: 'Failed' }))
|
|
58
|
+
: status === 'uploading'
|
|
59
|
+
? feedback.longWaiting
|
|
60
|
+
? t('fileUi.files.longWait', {
|
|
61
|
+
defaultValue: 'Still uploading. Keep waiting or cancel.',
|
|
62
|
+
})
|
|
63
|
+
: t('fileUi.files.uploading', { defaultValue: 'Uploading' })
|
|
64
|
+
: t('fileUi.files.pending', { defaultValue: 'Pending' })}
|
|
65
|
+
</div>
|
|
66
|
+
</>
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
26
70
|
function accepts(file: File, rules: readonly string[]): boolean {
|
|
27
71
|
if (!rules.length) return true;
|
|
28
72
|
const name = file.name.toLowerCase();
|
|
@@ -249,12 +293,10 @@ export function FileUploadField(
|
|
|
249
293
|
};
|
|
250
294
|
|
|
251
295
|
return (
|
|
252
|
-
<div
|
|
253
|
-
|
|
254
|
-
className='space-y-3'
|
|
255
|
-
aria-busy={items.some((item) => item.status === 'uploading')}
|
|
256
|
-
>
|
|
296
|
+
<div data-slot='file-upload-field' className='space-y-3'>
|
|
297
|
+
{/* The polite summary below stays outside the busy drop zone. */}
|
|
257
298
|
<div
|
|
299
|
+
aria-busy={items.some((item) => item.status === 'uploading')}
|
|
258
300
|
className={`flex min-h-24 flex-wrap gap-3 rounded-md border border-dashed p-3 ${dragging ? 'border-primary bg-primary/5' : ''}`}
|
|
259
301
|
onDragOver={(event) => {
|
|
260
302
|
event.preventDefault();
|
|
@@ -288,32 +330,20 @@ export function FileUploadField(
|
|
|
288
330
|
))}
|
|
289
331
|
{items.map((item) => (
|
|
290
332
|
<div key={item.key} className='w-36 rounded-md border p-2'>
|
|
291
|
-
<
|
|
292
|
-
{item.status
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
</
|
|
305
|
-
<div className='text-xs text-muted-foreground'>
|
|
306
|
-
{item.status === 'error'
|
|
307
|
-
? ((item.error
|
|
308
|
-
? t(messageKey(item.error.message), {
|
|
309
|
-
defaultValue: item.error.message,
|
|
310
|
-
})
|
|
311
|
-
: undefined) ??
|
|
312
|
-
t('fileUi.files.failed', { defaultValue: 'Failed' }))
|
|
313
|
-
: item.status === 'uploading'
|
|
314
|
-
? t('fileUi.files.uploading', { defaultValue: 'Uploading' })
|
|
315
|
-
: t('fileUi.files.pending', { defaultValue: 'Pending' })}
|
|
316
|
-
</div>
|
|
333
|
+
<UploadItemBody
|
|
334
|
+
status={item.status}
|
|
335
|
+
failure={
|
|
336
|
+
item.status === 'error' && item.error
|
|
337
|
+
? t(messageKey(item.error.message), {
|
|
338
|
+
defaultValue: item.error.message,
|
|
339
|
+
})
|
|
340
|
+
: undefined
|
|
341
|
+
}
|
|
342
|
+
>
|
|
343
|
+
<div className='mt-2 truncate text-sm' title={item.file.name}>
|
|
344
|
+
{item.file.name}
|
|
345
|
+
</div>
|
|
346
|
+
</UploadItemBody>
|
|
317
347
|
<div className='mt-2 flex gap-1'>
|
|
318
348
|
{item.status === 'error' ? (
|
|
319
349
|
<Button
|
|
@@ -26,6 +26,8 @@ const messages = {
|
|
|
26
26
|
'files.retry': 'Erneut versuchen',
|
|
27
27
|
'files.done': 'Erledigt',
|
|
28
28
|
'files.uploading': 'Hochladen',
|
|
29
|
+
'files.longWait':
|
|
30
|
+
'Wird noch hochgeladen. Warten Sie weiter oder brechen Sie ab.',
|
|
29
31
|
'files.failed': 'Fehlgeschlagen',
|
|
30
32
|
'files.pending': 'Ausstehend',
|
|
31
33
|
'files.previous': 'Vorherige Datei',
|
|
@@ -24,6 +24,7 @@ const messages = {
|
|
|
24
24
|
'files.retry': 'Retry',
|
|
25
25
|
'files.done': 'Done',
|
|
26
26
|
'files.uploading': 'Uploading',
|
|
27
|
+
'files.longWait': 'Still uploading. Keep waiting or cancel.',
|
|
27
28
|
'files.failed': 'Failed',
|
|
28
29
|
'files.pending': 'Pending',
|
|
29
30
|
'files.previous': 'Previous file',
|
|
@@ -25,6 +25,7 @@ const messages = {
|
|
|
25
25
|
'files.retry': 'Reintentar',
|
|
26
26
|
'files.done': 'Hecho',
|
|
27
27
|
'files.uploading': 'Subiendo',
|
|
28
|
+
'files.longWait': 'Aún se está subiendo. Siga esperando o cancele.',
|
|
28
29
|
'files.failed': 'Falló',
|
|
29
30
|
'files.pending': 'Pendiente',
|
|
30
31
|
'files.previous': 'Archivo anterior',
|
|
@@ -26,6 +26,7 @@ const messages = {
|
|
|
26
26
|
'files.retry': 'Réessayer',
|
|
27
27
|
'files.done': 'Terminé',
|
|
28
28
|
'files.uploading': 'Téléchargement',
|
|
29
|
+
'files.longWait': 'Envoi toujours en cours. Patientez ou annulez.',
|
|
29
30
|
'files.failed': 'Échec',
|
|
30
31
|
'files.pending': 'En attente',
|
|
31
32
|
'files.previous': 'Fichier précédent',
|
|
@@ -24,6 +24,7 @@ const messages = {
|
|
|
24
24
|
'files.retry': 'Coba lagi',
|
|
25
25
|
'files.done': 'Selesai',
|
|
26
26
|
'files.uploading': 'Mengunggah',
|
|
27
|
+
'files.longWait': 'Masih mengunggah. Tunggu atau batalkan.',
|
|
27
28
|
'files.failed': 'Gagal',
|
|
28
29
|
'files.pending': 'Tertunda',
|
|
29
30
|
'files.previous': 'Berkas sebelumnya',
|
|
@@ -26,6 +26,7 @@ const messages = {
|
|
|
26
26
|
'files.retry': 'Tentar novamente',
|
|
27
27
|
'files.done': 'Concluído',
|
|
28
28
|
'files.uploading': 'Carregando',
|
|
29
|
+
'files.longWait': 'Ainda enviando. Continue aguardando ou cancele.',
|
|
29
30
|
'files.failed': 'Falha',
|
|
30
31
|
'files.pending': 'Pendente',
|
|
31
32
|
'files.previous': 'Arquivo anterior',
|
|
@@ -25,6 +25,7 @@ const messages = {
|
|
|
25
25
|
'files.retry': 'Thử lại',
|
|
26
26
|
'files.done': 'Hoàn thành',
|
|
27
27
|
'files.uploading': 'Đang tải lên',
|
|
28
|
+
'files.longWait': 'Vẫn đang tải lên. Hãy tiếp tục chờ hoặc hủy.',
|
|
28
29
|
'files.failed': 'Thất bại',
|
|
29
30
|
'files.pending': 'Đang chờ',
|
|
30
31
|
'files.previous': 'Tệp trước đó',
|