@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
|
@@ -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
|
)}
|
package/client/plugins.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import foundation from '@nocobase/app-plugin-foundation/client';
|
|
2
|
+
import applicationManifest from '../package.json' with { type: 'json' };
|
|
2
3
|
import defaultAccess from '@nocobase/app-plugin-authz-default-access/client';
|
|
3
4
|
import sharingRules from '@nocobase/app-plugin-authz-sharing-rules/client';
|
|
4
5
|
import restrictionRules from '@nocobase/app-plugin-authz-restriction-rules/client';
|
|
@@ -35,7 +36,13 @@ const clientPlugins: AppClientPlugins = defineClientPlugins([
|
|
|
35
36
|
file(),
|
|
36
37
|
scheduler(),
|
|
37
38
|
mail(),
|
|
38
|
-
|
|
39
|
+
// The manifest is what the capability guide lists: every plugin this application declares.
|
|
40
|
+
foundation({
|
|
41
|
+
settingsGroup: true,
|
|
42
|
+
manifest: applicationManifest,
|
|
43
|
+
guideSettingsPath: '/settings',
|
|
44
|
+
navigation: false,
|
|
45
|
+
}),
|
|
39
46
|
]);
|
|
40
47
|
|
|
41
48
|
export default clientPlugins;
|
package/client/routes.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import databaseExplorerRoutes from './pages/settings/database-explorer/routes.js';
|
|
1
2
|
import { createAppClientConfig } from '@nocobase/app-client';
|
|
2
3
|
import { readAppClientRuntimeConfig } from '@nocobase/app-client/runtime';
|
|
3
4
|
import { includeNotificationTemplateRoutes } from './pages/settings/notification-routes.js';
|
|
@@ -75,7 +76,7 @@ export function createApplicationRoutes(
|
|
|
75
76
|
): readonly AppClientRouteContribution[] {
|
|
76
77
|
const config = createAppClientConfig({ rawConfig });
|
|
77
78
|
return includeNotificationTemplateRoutes(
|
|
78
|
-
[appRoutes, settingsShellRoutes],
|
|
79
|
+
[appRoutes, settingsShellRoutes, databaseExplorerRoutes],
|
|
79
80
|
config.get<boolean>('notificationTemplates.invitationConfigured') === true,
|
|
80
81
|
);
|
|
81
82
|
}
|
|
@@ -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
|
+
}
|
|
@@ -95,6 +95,7 @@ class LeaveController {
|
|
|
95
95
|
class LeaveScope {
|
|
96
96
|
private readonly entries = new Set<LeaveController>();
|
|
97
97
|
private hidden = false;
|
|
98
|
+
private generation = 0;
|
|
98
99
|
constructor(private readonly dirtyChanged: (dirty: boolean) => void) {}
|
|
99
100
|
|
|
100
101
|
changed = (): void => {
|
|
@@ -118,7 +119,12 @@ class LeaveScope {
|
|
|
118
119
|
this.changed();
|
|
119
120
|
}
|
|
120
121
|
update(hidden: boolean, pathname: string): void {
|
|
121
|
-
this.hidden
|
|
122
|
+
if (this.hidden !== hidden) {
|
|
123
|
+
this.generation += 1;
|
|
124
|
+
this.hidden = hidden;
|
|
125
|
+
// A confirmation started before concealment must not discard or resume its old navigation on recovery.
|
|
126
|
+
if (hidden) for (const entry of this.entries) entry.cancel();
|
|
127
|
+
}
|
|
122
128
|
for (const entry of this.entries) {
|
|
123
129
|
if (pathname !== entry.origin && !pathname.startsWith(`${entry.origin}/`))
|
|
124
130
|
this.remove(entry);
|
|
@@ -130,8 +136,14 @@ class LeaveScope {
|
|
|
130
136
|
for (const entry of this.entries) if (!entry.active) this.remove(entry);
|
|
131
137
|
}
|
|
132
138
|
guard = (): Decision => {
|
|
139
|
+
const generation = this.generation;
|
|
133
140
|
const remaining = [...this.entries];
|
|
134
141
|
const next = (): Decision => {
|
|
142
|
+
if (
|
|
143
|
+
generation !== this.generation ||
|
|
144
|
+
(this.hidden && this.entries.size > 0)
|
|
145
|
+
)
|
|
146
|
+
return false;
|
|
135
147
|
const entry = remaining.shift();
|
|
136
148
|
if (!entry) return true;
|
|
137
149
|
const value = entry.guard();
|
package/client/styles.css
CHANGED
|
@@ -252,9 +252,26 @@ label:has(> .sr-only:focus-visible),
|
|
|
252
252
|
:is([data-slot='dialog-description'], [data-slot='alert-dialog-description']) {
|
|
253
253
|
line-height: 1.65;
|
|
254
254
|
}
|
|
255
|
-
|
|
255
|
+
/* Size semantic actions equally, including Button render={<Link />} and the
|
|
256
|
+
AlertDialog wrappers, which replace Button's slot. Footer sizing takes
|
|
257
|
+
precedence over primitive size utilities; long labels may grow and wrap. */
|
|
258
|
+
:is([data-slot='dialog-footer'], [data-slot='alert-dialog-footer'])
|
|
259
|
+
> :is(
|
|
260
|
+
[data-slot='button'],
|
|
261
|
+
[data-slot='dialog-close'],
|
|
262
|
+
[data-slot='alert-dialog-action'],
|
|
263
|
+
[data-slot='alert-dialog-cancel']
|
|
264
|
+
) {
|
|
265
|
+
height: auto;
|
|
256
266
|
min-height: calc(var(--spacing) * 10);
|
|
267
|
+
min-width: 0;
|
|
268
|
+
align-self: stretch;
|
|
269
|
+
flex-shrink: 1;
|
|
270
|
+
padding-block: calc(var(--spacing) * 2);
|
|
257
271
|
padding-inline: calc(var(--spacing) * 4);
|
|
272
|
+
white-space: normal;
|
|
273
|
+
overflow-wrap: anywhere;
|
|
274
|
+
text-align: center;
|
|
258
275
|
}
|
|
259
276
|
/* Route overlays compose the dialog primitives, so their close button sits where a dialog's does. */
|
|
260
277
|
:is([data-slot='dialog-content'], [data-slot='route-overlay-content'])
|
package/config.example.yml
CHANGED
|
@@ -22,13 +22,19 @@
|
|
|
22
22
|
# max: 600
|
|
23
23
|
# window: 1m
|
|
24
24
|
|
|
25
|
-
# Realtime
|
|
25
|
+
# Realtime defaults to inMemory when broker is omitted. An explicit broker requires adapter.
|
|
26
|
+
# For local-only selection: realtime: { broker: { adapter: inMemory } }.
|
|
27
|
+
# For multiple processes, install @nocobase/app-plugin-realtime-redis and register its
|
|
28
|
+
# @nocobase/app-plugin-realtime-redis/server definition in server/plugins.ts on every instance.
|
|
29
|
+
# Realtime events are transient; clients refetch after subscription acknowledgement.
|
|
26
30
|
# realtime:
|
|
27
|
-
#
|
|
28
|
-
#
|
|
31
|
+
# broker:
|
|
32
|
+
# adapter: redis
|
|
33
|
+
# setupTimeoutMs: 5000
|
|
29
34
|
# connection:
|
|
30
35
|
# host: 127.0.0.1
|
|
31
36
|
# port: 6379
|
|
37
|
+
# commandTimeout: 5000
|
|
32
38
|
# namespace: my-app # Defaults to the application name; keep applications isolated.
|
|
33
39
|
# authorizationTimeoutMs: 5000
|
|
34
40
|
# sessionCheckIntervalMs: 30000
|
|
@@ -116,8 +122,7 @@ auth:
|
|
|
116
122
|
# Set app.publicOrigin before explicitly enabling automatic password recovery.
|
|
117
123
|
# passwordReset:
|
|
118
124
|
# enabled: true
|
|
119
|
-
#
|
|
120
|
-
# mailer: system # omit to use mailers.default
|
|
125
|
+
# mailer: system # omit to preserve existing default selection
|
|
121
126
|
# Existing inline auth.email and AUTH_EMAIL_* remain supported for compatibility.
|
|
122
127
|
emailAndPassword:
|
|
123
128
|
enabled: true
|
|
@@ -190,12 +195,13 @@ snowflake:
|
|
|
190
195
|
# Upgrading deployments must set adapter: redis explicitly; templates no longer supply Redis defaults.
|
|
191
196
|
# jobs:
|
|
192
197
|
# default: redis
|
|
193
|
-
#
|
|
194
|
-
#
|
|
195
|
-
#
|
|
196
|
-
#
|
|
197
|
-
#
|
|
198
|
-
#
|
|
198
|
+
# backends:
|
|
199
|
+
# redis:
|
|
200
|
+
# adapter: redis
|
|
201
|
+
# connection:
|
|
202
|
+
# host: 127.0.0.1
|
|
203
|
+
# port: 6379
|
|
204
|
+
# # namespace: my-app # defaults to the application name; set it when deployments share one Redis
|
|
199
205
|
|
|
200
206
|
# The jobs configuration the Scheduler plugin's schedules run on, by name. Left out, they follow jobs.default.
|
|
201
207
|
# scheduler:
|
package/index.html
CHANGED
|
@@ -32,9 +32,6 @@
|
|
|
32
32
|
transform-origin: 32px 32px;
|
|
33
33
|
animation: app-bootstrap-spin 1.1s linear infinite;
|
|
34
34
|
}
|
|
35
|
-
.app-bootstrap-loading span {
|
|
36
|
-
color: oklch(0.556 0 0);
|
|
37
|
-
}
|
|
38
35
|
|
|
39
36
|
html,
|
|
40
37
|
body {
|
|
@@ -60,11 +57,6 @@
|
|
|
60
57
|
}
|
|
61
58
|
}
|
|
62
59
|
|
|
63
|
-
@media (prefers-color-scheme: dark) {
|
|
64
|
-
.app-bootstrap-loading span {
|
|
65
|
-
color: oklch(0.708 0 0);
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
60
|
@media (prefers-reduced-motion: reduce) {
|
|
69
61
|
.app-bootstrap-loading .nocobase-loading-orbit {
|
|
70
62
|
animation: none;
|
|
@@ -76,10 +68,16 @@
|
|
|
76
68
|
<body>
|
|
77
69
|
<noscript>You need to enable JavaScript to run this app.</noscript>
|
|
78
70
|
<div id="root">
|
|
71
|
+
<!--
|
|
72
|
+
The language is unknown until the client starts, so this placeholder shows no text. A screen reader still
|
|
73
|
+
perceives it as an indeterminate progress bar, a role it announces in its own language, named after the
|
|
74
|
+
brand in <title>, which reads the same in every language. The client's localized loading, a polite status,
|
|
75
|
+
replaces it once the language is ready, so nothing is announced twice.
|
|
76
|
+
-->
|
|
79
77
|
<div
|
|
80
78
|
class="app-bootstrap-loading"
|
|
81
|
-
role="
|
|
82
|
-
aria-label="
|
|
79
|
+
role="progressbar"
|
|
80
|
+
aria-label="NocoBase"
|
|
83
81
|
>
|
|
84
82
|
<svg
|
|
85
83
|
data-slot="loading-indicator"
|
|
@@ -114,7 +112,6 @@
|
|
|
114
112
|
</g>
|
|
115
113
|
</g>
|
|
116
114
|
</svg>
|
|
117
|
-
<span>Loading application…</span>
|
|
118
115
|
</div>
|
|
119
116
|
</div>
|
|
120
117
|
<script type="module" src="/client/index.tsx"></script>
|