@nocobase/app-template-default 0.0.0-stage → 1.0.0-beta.57
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/.cursor/mcp.json +8 -0
- package/.mcp.json +8 -0
- package/.prettierignore +20 -0
- package/.vscode/mcp.json +8 -0
- package/AGENTS.md +432 -0
- package/CLAUDE.md +9 -0
- package/Dockerfile +132 -0
- package/Dockerfile.dockerignore +45 -0
- package/README.MD +481 -0
- package/cli/plugins.ts +14 -0
- package/client/agent-annotations-host.ts +19 -0
- package/client/app.ts +38 -0
- package/client/components/back-button.tsx +49 -0
- package/client/components/breadcrumbs.tsx +167 -0
- package/client/components/inbox-badge.ts +49 -0
- package/client/components/inbox-button.tsx +132 -0
- package/client/components/inbox-header-button.tsx +25 -0
- package/client/components/loading.tsx +35 -0
- package/client/components/page-container.tsx +24 -0
- package/client/components/page-header.tsx +33 -0
- package/client/components/route-child-page.tsx +121 -0
- package/client/components/route-dialog.tsx +10 -0
- package/client/components/route-drawer.tsx +10 -0
- package/client/components/route-overlay.tsx +190 -0
- package/client/components/ui/alert.tsx +75 -0
- package/client/components/ui/badge.tsx +51 -0
- package/client/components/ui/breadcrumb.tsx +121 -0
- package/client/components/ui/button.tsx +58 -0
- package/client/components/ui/collapsible.tsx +19 -0
- package/client/components/ui/context-menu.tsx +271 -0
- package/client/components/ui/dialog.tsx +160 -0
- package/client/components/ui/dropdown-menu.tsx +270 -0
- package/client/components/ui/empty.tsx +103 -0
- package/client/components/ui/field.tsx +236 -0
- package/client/components/ui/input-group.tsx +158 -0
- package/client/components/ui/input.tsx +20 -0
- package/client/components/ui/kbd.tsx +26 -0
- package/client/components/ui/label.tsx +18 -0
- package/client/components/ui/popover.tsx +89 -0
- package/client/components/ui/select.tsx +200 -0
- package/client/components/ui/separator.tsx +22 -0
- package/client/components/ui/sheet.tsx +137 -0
- package/client/components/ui/sidebar.tsx +723 -0
- package/client/components/ui/skeleton.tsx +13 -0
- package/client/components/ui/spinner.tsx +20 -0
- package/client/components/ui/tabs.tsx +79 -0
- package/client/components/ui/textarea.tsx +17 -0
- package/client/components/ui/toast.tsx +231 -0
- package/client/components/ui/tooltip.tsx +63 -0
- package/client/components/use-route-overlay.ts +26 -0
- package/client/config/api.ts +13 -0
- package/client/config/app.ts +8 -0
- package/client/config/auth.ts +18 -0
- package/client/config/index.ts +17 -0
- package/client/extensions/nocobase-auth-forms/field-props.ts +13 -0
- package/client/extensions/nocobase-auth-forms/form-parts.tsx +101 -0
- package/client/extensions/nocobase-auth-forms/password-input.tsx +41 -0
- package/client/extensions/nocobase-auth-forms/password-login-form.tsx +123 -0
- package/client/extensions/nocobase-auth-forms/password-registration-form.tsx +193 -0
- package/client/extensions/nocobase-auth-forms/password-reset-form.tsx +132 -0
- package/client/extensions/nocobase-auth-forms/password-reset-request-form.tsx +99 -0
- package/client/extensions/nocobase-auth-methods/auth-methods.tsx +170 -0
- package/client/extensions/nocobase-auth-split-layout/auth-split-layout.tsx +126 -0
- package/client/extensions/nocobase-device-approval/device-approval.tsx +277 -0
- package/client/extensions/nocobase-device-approval/locales/en-US.ts +55 -0
- package/client/extensions/nocobase-device-approval/locales/zh-CN.ts +27 -0
- package/client/extensions/nocobase-device-approval/use-device-approval.ts +176 -0
- package/client/extensions/nocobase-file-component-ui/README.md +13 -0
- package/client/extensions/nocobase-file-component-ui/components/file-list.tsx +147 -0
- package/client/extensions/nocobase-file-component-ui/components/file-preview-dialog.tsx +263 -0
- package/client/extensions/nocobase-file-component-ui/components/file-preview-field.tsx +68 -0
- package/client/extensions/nocobase-file-component-ui/components/file-thumbnail.tsx +73 -0
- package/client/extensions/nocobase-file-component-ui/components/file-upload-field.tsx +386 -0
- package/client/extensions/nocobase-file-component-ui/components/previewers/file-preview-content.tsx +255 -0
- package/client/extensions/nocobase-file-component-ui/components/previewers/office-open-xml-preview.tsx +226 -0
- package/client/extensions/nocobase-file-component-ui/index.ts +8 -0
- package/client/extensions/nocobase-file-component-ui/lib/file-preview.ts +194 -0
- package/client/extensions/nocobase-file-component-ui/lib/file-url.ts +22 -0
- package/client/extensions/nocobase-file-component-ui/lib/message-key.ts +18 -0
- package/client/extensions/nocobase-file-component-ui/types.ts +69 -0
- package/client/extensions/nocobase-inbox/decision-actions-bar.tsx +140 -0
- package/client/extensions/nocobase-inbox/frame.ts +31 -0
- package/client/extensions/nocobase-inbox/inbox-actions.ts +21 -0
- package/client/extensions/nocobase-inbox/inbox-detail.tsx +386 -0
- package/client/extensions/nocobase-inbox/inbox-item.tsx +274 -0
- package/client/extensions/nocobase-inbox/inbox-list.tsx +433 -0
- package/client/extensions/nocobase-inbox/inbox-page.tsx +545 -0
- package/client/extensions/nocobase-inbox/locales/en-US.ts +111 -0
- package/client/extensions/nocobase-inbox/locales/zh-CN.ts +57 -0
- package/client/extensions/nocobase-inbox/model.ts +428 -0
- package/client/extensions/nocobase-inbox/registry-scope.tsx +49 -0
- package/client/extensions/nocobase-inbox/registry.ts +303 -0
- package/client/extensions/nocobase-inbox/source.ts +117 -0
- package/client/hooks/use-mobile.ts +21 -0
- package/client/index.tsx +60 -0
- package/client/layouts/app-layout.tsx +105 -0
- package/client/layouts/components/app-brand.tsx +49 -0
- package/client/layouts/components/app-sidebar.tsx +196 -0
- package/client/layouts/components/header-actions.tsx +50 -0
- package/client/layouts/components/language-switcher.tsx +95 -0
- package/client/layouts/components/layout-header.tsx +17 -0
- package/client/layouts/components/navigation-menu.tsx +411 -0
- package/client/layouts/components/sidebar-footer.tsx +107 -0
- package/client/layouts/components/surface-empty.tsx +35 -0
- package/client/layouts/components/user-menu.tsx +127 -0
- package/client/layouts/index.ts +2 -0
- package/client/layouts/settings-layout.tsx +172 -0
- package/client/layouts/use-sidebar-preference.ts +57 -0
- package/client/lib/constants.ts +5 -0
- package/client/lib/toaster.ts +56 -0
- package/client/lib/utils.ts +1 -0
- package/client/locales/en-US.ts +178 -0
- package/client/locales/index.ts +9 -0
- package/client/locales/zh-CN.ts +161 -0
- package/client/pages/auth/device.tsx +41 -0
- package/client/pages/auth/forgot-password.tsx +39 -0
- package/client/pages/auth/login.tsx +66 -0
- package/client/pages/auth/register.tsx +46 -0
- package/client/pages/auth/reset-password.tsx +38 -0
- package/client/pages/auth/shared.tsx +111 -0
- package/client/pages/home.tsx +16 -0
- package/client/pages/inbox.tsx +12 -0
- package/client/plugins.ts +41 -0
- package/client/react-providers.ts +27 -0
- package/client/route-overrides.ts +9 -0
- package/client/routes.ts +75 -0
- package/client/routing/app-router.tsx +127 -0
- package/client/routing/client-route.tsx +129 -0
- package/client/routing/route-context.tsx +85 -0
- package/client/routing/route-navigation.ts +153 -0
- package/client/routing/route-tree.tsx +42 -0
- package/client/routing/standalone-page-layout.tsx +15 -0
- package/client/runtime.ts +26 -0
- package/client/service-provider.ts +40 -0
- package/client/source-extensions.ts +19 -0
- package/client/startup.tsx +22 -0
- package/client/styles.css +122 -0
- package/client/theme/index.ts +4 -0
- package/client/theme/theme-context.ts +12 -0
- package/client/theme/theme-preferences.ts +84 -0
- package/client/theme/theme-presets.ts +7 -0
- package/client/theme/theme-provider.tsx +106 -0
- package/client/theme/theme-settings.tsx +176 -0
- package/client/theme/themes/compact.css +140 -0
- package/client/theme/themes/default.css +117 -0
- package/client/vite-env.d.ts +5 -0
- package/components.json +27 -0
- package/config.example.yml +172 -0
- package/database/main/migrations/.gitkeep +0 -0
- package/database/main/migrations/202610060001_create_device_code.ts +37 -0
- package/database/main/seeds/.gitkeep +0 -0
- package/database/tsconfig.json +4 -0
- package/ecosystem.config.js +35 -0
- package/eslint.config.js +24 -0
- package/index.html +100 -0
- package/package.json +200 -3
- package/playwright.config.ts +10 -0
- package/public/assets/favicon.ico +0 -0
- package/public/assets/logo-dark.png +0 -0
- package/public/assets/logo-mark-dark.png +0 -0
- package/public/assets/logo-mark.png +0 -0
- package/public/assets/logo.png +0 -0
- package/server/app.ts +50 -0
- package/server/config/ai.ts +27 -0
- package/server/config/api.ts +8 -0
- package/server/config/app.ts +40 -0
- package/server/config/auth.ts +36 -0
- package/server/config/authorization.ts +18 -0
- package/server/config/caching.ts +21 -0
- package/server/config/database.ts +22 -0
- package/server/config/drive.ts +30 -0
- package/server/config/i18n.ts +17 -0
- package/server/config/index.ts +73 -0
- package/server/config/jobs.ts +27 -0
- package/server/config/logging.ts +23 -0
- package/server/config/notification.ts +11 -0
- package/server/config/queue.ts +28 -0
- package/server/config/scheduler.ts +23 -0
- package/server/config/secrets.ts +10 -0
- package/server/config/server.ts +26 -0
- package/server/config/session.ts +60 -0
- package/server/config/snowflake.ts +21 -0
- package/server/config/spa.ts +24 -0
- package/server/config/users.ts +11 -0
- package/server/config/workflow.ts +24 -0
- package/server/config.ts +24 -0
- package/server/embedded.ts +23 -0
- package/server/locales/en-US.ts +10 -0
- package/server/locales/index.ts +10 -0
- package/server/locales/zh-CN.ts +5 -0
- package/server/plugins.ts +41 -0
- package/server/providers/index.ts +5 -0
- package/server/routes/index.ts +6 -0
- package/server/runtime.ts +24 -0
- package/server/standalone.ts +29 -0
- package/server/tsconfig.json +4 -0
- package/tailwind.config.mjs +59 -0
- package/tests/components/app-layout-routes.test.tsx +132 -0
- package/tests/components/app-title.test.tsx +57 -0
- package/tests/components/auth-i18n.test.tsx +87 -0
- package/tests/components/back-button.test.tsx +80 -0
- package/tests/components/file-preview.test.tsx +101 -0
- package/tests/components/header-hover.test.tsx +87 -0
- package/tests/components/inbox.test.tsx +139 -0
- package/tests/components/language-switcher.test.tsx +298 -0
- package/tests/components/layout-containers.test.tsx +15 -0
- package/tests/components/layout-i18n.test.tsx +187 -0
- package/tests/components/loading.test.tsx +25 -0
- package/tests/components/navigation-menu.test.tsx +355 -0
- package/tests/components/page-harness.test.tsx +318 -0
- package/tests/components/primitive-labels.test.tsx +80 -0
- package/tests/components/sidebar-permissions.test.tsx +110 -0
- package/tests/components/sidebar-preference.test.tsx +42 -0
- package/tests/fixtures/theme-tokens.html +12 -0
- package/tests/fixtures/theme-tokens.tsx +111 -0
- package/tests/logic/account-permissions.test.tsx +204 -0
- package/tests/logic/app-locale-coverage.test.ts +136 -0
- package/tests/logic/app-server.test.ts +1589 -0
- package/tests/logic/asset-url-build.test.ts +106 -0
- package/tests/logic/client-auth.test.tsx +122 -0
- package/tests/logic/client-plugin-registry.test.ts +63 -0
- package/tests/logic/client-route-recovery.test.tsx +80 -0
- package/tests/logic/client-routes.test.ts +126 -0
- package/tests/logic/client-runtime.test.ts +112 -0
- package/tests/logic/client-settings.test.tsx +841 -0
- package/tests/logic/client-shell.test.tsx +466 -0
- package/tests/logic/client-theme.test.tsx +441 -0
- package/tests/logic/config.test.ts +197 -0
- package/tests/logic/dev-client-watch.test.ts +108 -0
- package/tests/logic/dev-routes-production-build.test.ts +168 -0
- package/tests/logic/device-code-migration.test.ts +70 -0
- package/tests/logic/lifecycle.test.ts +221 -0
- package/tests/logic/plugin-commands.test.ts +69 -0
- package/tests/logic/plugin-page-authz.test.ts +76 -0
- package/tests/logic/route-navigation.test.ts +66 -0
- package/tests/logic/route-overlay.test.tsx +353 -0
- package/tests/logic/server-imports.test.ts +88 -0
- package/tests/logic/tailwind-sources.test.ts +151 -0
- package/tests/logic/template-package-name.test.ts +88 -0
- package/tests/logic/theme-preferences.test.ts +86 -0
- package/tests/logic/theme-tokens.test.ts +248 -0
- package/tests/logic/toaster.test.tsx +108 -0
- package/tests/logic/user-menu-sign-out.test.tsx +98 -0
- package/tests/setup/client-config.ts +27 -0
- package/tsconfig.json +11 -0
- package/tsconfig.node.json +12 -0
- package/tsconfig.server.json +19 -0
- package/vite.config.ts +92 -0
- package/vitest.config.ts +37 -0
- package/README.md +0 -3
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// @vitest-environment node
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
resolveAppClientContributions,
|
|
5
|
+
type AppClientRegisteredRoute,
|
|
6
|
+
} from '@nocobase/app-client/plugins';
|
|
7
|
+
import { describe, expect, it } from 'vitest';
|
|
8
|
+
|
|
9
|
+
import clientPlugins from '../../client/plugins.js';
|
|
10
|
+
|
|
11
|
+
// The plugins whose pages the permission workspace offers as page grants. The AI employee settings are granted under
|
|
12
|
+
// System management instead, one settings item per page.
|
|
13
|
+
const PAGE_GRANT_PLUGINS = [
|
|
14
|
+
'@nocobase/app-plugin-users',
|
|
15
|
+
'@nocobase/app-plugin-notification',
|
|
16
|
+
'@nocobase/app-plugin-api-keys',
|
|
17
|
+
'@nocobase/app-plugin-database-explorer',
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
describe('plugin page authorization', () => {
|
|
21
|
+
it('offers a page grant for each listed plugin', () => {
|
|
22
|
+
const resolved = resolveAppClientContributions(
|
|
23
|
+
clientPlugins.plugins.map((plugin) => ({
|
|
24
|
+
packageName: plugin.packageName,
|
|
25
|
+
source: 'plugin' as const,
|
|
26
|
+
routes: plugin.routes,
|
|
27
|
+
})),
|
|
28
|
+
);
|
|
29
|
+
const pages = entryPages([
|
|
30
|
+
...resolved.routes,
|
|
31
|
+
...resolved.settingsRouteTree,
|
|
32
|
+
]).filter((route) => PAGE_GRANT_PLUGINS.includes(route.packageName));
|
|
33
|
+
|
|
34
|
+
// A page may check another resource type; each plugin still offers a page grant.
|
|
35
|
+
const granted = pages.filter(
|
|
36
|
+
(route) =>
|
|
37
|
+
typeof route.authz === 'object' &&
|
|
38
|
+
route.authz.resource.type === 'page' &&
|
|
39
|
+
route.authz.action === 'access',
|
|
40
|
+
);
|
|
41
|
+
expect(new Set(granted.map((route) => route.packageName))).toEqual(
|
|
42
|
+
new Set(PAGE_GRANT_PLUGINS),
|
|
43
|
+
);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('checks a System management settings item on every AI employee settings page', () => {
|
|
47
|
+
const resolved = resolveAppClientContributions(
|
|
48
|
+
clientPlugins.plugins.map((plugin) => ({
|
|
49
|
+
packageName: plugin.packageName,
|
|
50
|
+
source: 'plugin' as const,
|
|
51
|
+
routes: plugin.routes,
|
|
52
|
+
})),
|
|
53
|
+
);
|
|
54
|
+
const pages = entryPages(resolved.settingsRouteTree).filter(
|
|
55
|
+
(route) => route.packageName === '@nocobase/app-plugin-ai-employee',
|
|
56
|
+
);
|
|
57
|
+
expect(pages.length).toBeGreaterThan(0);
|
|
58
|
+
for (const page of pages)
|
|
59
|
+
expect({ id: page.id, authz: page.authz }).toMatchObject({
|
|
60
|
+
id: page.id,
|
|
61
|
+
authz: {
|
|
62
|
+
resource: { type: 'settings', id: expect.stringMatching(/^ai\./) },
|
|
63
|
+
action: 'read',
|
|
64
|
+
},
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
/** Pages without a page above them: nested pages share their parent's check. */
|
|
70
|
+
function entryPages(
|
|
71
|
+
routes: readonly AppClientRegisteredRoute[],
|
|
72
|
+
): AppClientRegisteredRoute[] {
|
|
73
|
+
return routes.flatMap((route) =>
|
|
74
|
+
route.componentLoader ? [route] : entryPages(route.children ?? []),
|
|
75
|
+
);
|
|
76
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { AppClientRegisteredRoute } from '@nocobase/app-client/plugins';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
import {
|
|
4
|
+
buildRouteNavigation,
|
|
5
|
+
selectedNavigationId,
|
|
6
|
+
} from '../../client/routing/route-navigation.js';
|
|
7
|
+
|
|
8
|
+
const page = (name: string, path: string): AppClientRegisteredRoute => ({
|
|
9
|
+
name,
|
|
10
|
+
id: name,
|
|
11
|
+
path,
|
|
12
|
+
auth: 'required',
|
|
13
|
+
packageName: 'test',
|
|
14
|
+
source: 'application',
|
|
15
|
+
navigation: { title: name },
|
|
16
|
+
componentLoader: async () => ({ default: () => null }),
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
describe('route navigation', () => {
|
|
20
|
+
it('promotes visible descendants of hidden pages and removes denied subtrees and empty groups', () => {
|
|
21
|
+
const routes = [
|
|
22
|
+
{
|
|
23
|
+
...page('hidden', '/hidden'),
|
|
24
|
+
navigation: undefined,
|
|
25
|
+
children: [page('child', '/hidden/child')],
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
...page('denied', '/denied'),
|
|
29
|
+
children: [page('secret', '/denied/secret')],
|
|
30
|
+
},
|
|
31
|
+
{ ...page('empty', '/empty'), componentLoader: undefined, children: [] },
|
|
32
|
+
];
|
|
33
|
+
expect(
|
|
34
|
+
buildRouteNavigation(routes, new Set(['test:/denied:denied'])).map(
|
|
35
|
+
({ route }) => route.id,
|
|
36
|
+
),
|
|
37
|
+
).toEqual(['child']);
|
|
38
|
+
});
|
|
39
|
+
it('does not select an unrelated menu for a matched hidden root page', () => {
|
|
40
|
+
const routes = [
|
|
41
|
+
page('home', '/'),
|
|
42
|
+
{ ...page('hidden', '/hidden'), navigation: undefined },
|
|
43
|
+
];
|
|
44
|
+
expect(selectedNavigationId(routes, '/hidden')).toBeUndefined();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('selects the deepest menu page through hidden descendants and multiple groups', () => {
|
|
48
|
+
const routes = [
|
|
49
|
+
{
|
|
50
|
+
...page('group', '/'),
|
|
51
|
+
componentLoader: undefined,
|
|
52
|
+
children: [
|
|
53
|
+
{
|
|
54
|
+
...page('orders', '/orders'),
|
|
55
|
+
children: [
|
|
56
|
+
{ ...page('detail', '/orders/:orderId'), navigation: undefined },
|
|
57
|
+
],
|
|
58
|
+
},
|
|
59
|
+
],
|
|
60
|
+
},
|
|
61
|
+
];
|
|
62
|
+
expect(selectedNavigationId(routes, '/orders/42')).toBe(
|
|
63
|
+
'test:/orders:orders',
|
|
64
|
+
);
|
|
65
|
+
});
|
|
66
|
+
});
|
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
import {
|
|
2
|
+
act,
|
|
3
|
+
fireEvent,
|
|
4
|
+
render,
|
|
5
|
+
screen,
|
|
6
|
+
waitFor,
|
|
7
|
+
} from '@testing-library/react';
|
|
8
|
+
import userEvent from '@testing-library/user-event';
|
|
9
|
+
import {
|
|
10
|
+
TestI18nProvider,
|
|
11
|
+
createTestI18nRuntime,
|
|
12
|
+
} from '@nocobase/i18n/testing';
|
|
13
|
+
import { useEffect, type ReactNode } from 'react';
|
|
14
|
+
import { createMemoryRouter, Link, Outlet, RouterProvider } from 'react-router';
|
|
15
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
16
|
+
import { RouteDialog } from '../../client/components/route-dialog';
|
|
17
|
+
import { RouteDrawer } from '../../client/components/route-drawer';
|
|
18
|
+
import { useRouteOverlay } from '../../client/components/use-route-overlay';
|
|
19
|
+
import enUS from '../../client/locales/en-US.js';
|
|
20
|
+
|
|
21
|
+
const runtime = await createTestI18nRuntime({
|
|
22
|
+
application: { namespace: '@nocobase/app-template-default', resources: enUS },
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
function I18n({ children }: { readonly children: ReactNode }) {
|
|
26
|
+
return <TestI18nProvider runtime={runtime}>{children}</TestI18nProvider>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function Actions() {
|
|
30
|
+
const { close, isClosing } = useRouteOverlay();
|
|
31
|
+
return (
|
|
32
|
+
<button disabled={isClosing} onClick={() => void close()}>
|
|
33
|
+
Cancel
|
|
34
|
+
</button>
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
function setup(
|
|
38
|
+
beforeClose?: () => boolean | Promise<boolean>,
|
|
39
|
+
closeTo?: string,
|
|
40
|
+
nested = false,
|
|
41
|
+
) {
|
|
42
|
+
const router = createMemoryRouter(
|
|
43
|
+
[
|
|
44
|
+
{
|
|
45
|
+
path: '/orders',
|
|
46
|
+
element: (
|
|
47
|
+
<>
|
|
48
|
+
<span>Orders</span>
|
|
49
|
+
<Outlet />
|
|
50
|
+
</>
|
|
51
|
+
),
|
|
52
|
+
children: [
|
|
53
|
+
{
|
|
54
|
+
path: 'edit/:id',
|
|
55
|
+
element: (
|
|
56
|
+
<RouteDialog
|
|
57
|
+
title='Edit'
|
|
58
|
+
beforeClose={beforeClose}
|
|
59
|
+
closeTo={closeTo}
|
|
60
|
+
footer={<Actions />}
|
|
61
|
+
>
|
|
62
|
+
<input aria-label='Name' defaultValue='Original' />
|
|
63
|
+
<Link to='details'>Open details</Link>
|
|
64
|
+
{/* A page places the outlet wherever its next child belongs. */}
|
|
65
|
+
<Outlet />
|
|
66
|
+
</RouteDialog>
|
|
67
|
+
),
|
|
68
|
+
children: [
|
|
69
|
+
{
|
|
70
|
+
path: 'details',
|
|
71
|
+
element: (
|
|
72
|
+
<RouteDrawer title='Details' footer={<Actions />}>
|
|
73
|
+
Detail
|
|
74
|
+
</RouteDrawer>
|
|
75
|
+
),
|
|
76
|
+
},
|
|
77
|
+
],
|
|
78
|
+
},
|
|
79
|
+
],
|
|
80
|
+
},
|
|
81
|
+
{ path: '/other', element: <span>Other</span> },
|
|
82
|
+
],
|
|
83
|
+
{
|
|
84
|
+
basename: '/main',
|
|
85
|
+
initialEntries: [
|
|
86
|
+
`/main/orders/edit/42${nested ? '/details' : ''}?filter=recent#field`,
|
|
87
|
+
],
|
|
88
|
+
},
|
|
89
|
+
);
|
|
90
|
+
render(<RouterProvider router={router} />, { wrapper: I18n });
|
|
91
|
+
return router;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
describe('route overlays', () => {
|
|
95
|
+
it('closes a direct link to its route parent, preserving query and removing hash', async () => {
|
|
96
|
+
const router = setup();
|
|
97
|
+
fireEvent.click(await screen.findByRole('button', { name: 'Cancel' }));
|
|
98
|
+
await waitFor(() =>
|
|
99
|
+
expect(router.state.location.pathname).toBe('/main/orders'),
|
|
100
|
+
);
|
|
101
|
+
expect(router.state.location.search).toBe('?filter=recent');
|
|
102
|
+
expect(router.state.location.hash).toBe('');
|
|
103
|
+
expect(router.state.historyAction).toBe('REPLACE');
|
|
104
|
+
});
|
|
105
|
+
it('uses the explicit target without merging query', async () => {
|
|
106
|
+
const router = setup(undefined, '/other');
|
|
107
|
+
fireEvent.click(await screen.findByRole('button', { name: 'Close' }));
|
|
108
|
+
await waitFor(() =>
|
|
109
|
+
expect(router.state.location.pathname).toBe('/main/other'),
|
|
110
|
+
);
|
|
111
|
+
expect(router.state.location.search).toBe('');
|
|
112
|
+
});
|
|
113
|
+
it('keeps the dialog open while checking and when rejected', async () => {
|
|
114
|
+
let resolve!: (value: boolean) => void;
|
|
115
|
+
const check = vi.fn(
|
|
116
|
+
() =>
|
|
117
|
+
new Promise<boolean>((done) => {
|
|
118
|
+
resolve = done;
|
|
119
|
+
}),
|
|
120
|
+
);
|
|
121
|
+
const router = setup(check);
|
|
122
|
+
fireEvent.click(await screen.findByRole('button', { name: 'Cancel' }));
|
|
123
|
+
fireEvent.click(screen.getByRole('button', { name: 'Close' }));
|
|
124
|
+
expect(screen.getByRole('button', { name: 'Cancel' })).toBeDisabled();
|
|
125
|
+
await waitFor(() => expect(check).toHaveBeenCalledTimes(1));
|
|
126
|
+
await act(async () => {
|
|
127
|
+
resolve(false);
|
|
128
|
+
});
|
|
129
|
+
expect(check).toHaveBeenCalledTimes(1);
|
|
130
|
+
expect(router.state.location.pathname).toBe('/main/orders/edit/42');
|
|
131
|
+
expect(screen.getByRole('button', { name: 'Cancel' })).toBeEnabled();
|
|
132
|
+
});
|
|
133
|
+
it('ignores a pending result after navigation away', async () => {
|
|
134
|
+
let resolve!: (value: boolean) => void;
|
|
135
|
+
const router = setup(
|
|
136
|
+
() =>
|
|
137
|
+
new Promise<boolean>((done) => {
|
|
138
|
+
resolve = done;
|
|
139
|
+
}),
|
|
140
|
+
);
|
|
141
|
+
fireEvent.click(await screen.findByRole('button', { name: 'Cancel' }));
|
|
142
|
+
await act(() => router.navigate('/other'));
|
|
143
|
+
await act(async () => {
|
|
144
|
+
resolve(true);
|
|
145
|
+
});
|
|
146
|
+
expect(router.state.location.pathname).toBe('/main/other');
|
|
147
|
+
});
|
|
148
|
+
it('closes only the top drawer and retains its parent dialog', async () => {
|
|
149
|
+
const router = setup(undefined, undefined, true);
|
|
150
|
+
fireEvent.click(await screen.findByRole('button', { name: 'Cancel' }));
|
|
151
|
+
await waitFor(() =>
|
|
152
|
+
expect(router.state.location.pathname).toBe('/main/orders/edit/42'),
|
|
153
|
+
);
|
|
154
|
+
expect(await screen.findByRole('dialog', { name: 'Edit' })).toBeVisible();
|
|
155
|
+
expect(screen.getByRole('textbox', { name: 'Name' })).toHaveValue(
|
|
156
|
+
'Original',
|
|
157
|
+
);
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
describe('route overlay interactions', () => {
|
|
162
|
+
it('renders an owning backdrop for the nested layer and dismisses only that layer', async () => {
|
|
163
|
+
const user = userEvent.setup();
|
|
164
|
+
const parentCheck = vi.fn(() => true);
|
|
165
|
+
const router = setup(parentCheck, undefined, true);
|
|
166
|
+
const child = await screen.findByRole('dialog', { name: 'Details' });
|
|
167
|
+
await waitFor(() =>
|
|
168
|
+
expect(child.contains(document.activeElement)).toBe(true),
|
|
169
|
+
);
|
|
170
|
+
// The hit target must belong to the child portal. A parent's z-indexed
|
|
171
|
+
// backdrop otherwise obscures the child's unstyled internal backdrop.
|
|
172
|
+
const portal = child.closest('[data-base-ui-portal]');
|
|
173
|
+
const backdrop = portal?.querySelector(
|
|
174
|
+
':scope > [data-slot="dialog-overlay"]',
|
|
175
|
+
);
|
|
176
|
+
expect(backdrop).toBeInTheDocument();
|
|
177
|
+
await user.click(backdrop!);
|
|
178
|
+
await waitFor(() =>
|
|
179
|
+
expect(router.state.location.pathname).toBe('/main/orders/edit/42'),
|
|
180
|
+
);
|
|
181
|
+
expect(parentCheck).not.toHaveBeenCalled();
|
|
182
|
+
await waitFor(() =>
|
|
183
|
+
expect(screen.getByRole('dialog', { name: 'Edit' })).toBeVisible(),
|
|
184
|
+
);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it('dismisses only the top layer with Escape', async () => {
|
|
188
|
+
const user = userEvent.setup();
|
|
189
|
+
const parentCheck = vi.fn(() => true);
|
|
190
|
+
const router = setup(parentCheck, undefined, true);
|
|
191
|
+
const child = await screen.findByRole('dialog', { name: 'Details' });
|
|
192
|
+
await waitFor(() =>
|
|
193
|
+
expect(child.contains(document.activeElement)).toBe(true),
|
|
194
|
+
);
|
|
195
|
+
await user.keyboard('{Escape}');
|
|
196
|
+
await waitFor(() =>
|
|
197
|
+
expect(router.state.location.pathname).toBe('/main/orders/edit/42'),
|
|
198
|
+
);
|
|
199
|
+
expect(parentCheck).not.toHaveBeenCalled();
|
|
200
|
+
expect(await screen.findByRole('dialog', { name: 'Edit' })).toBeVisible();
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it('retains edited parent state and restores focus to the link that opened its child', async () => {
|
|
204
|
+
const user = userEvent.setup();
|
|
205
|
+
const router = setup();
|
|
206
|
+
const name = await screen.findByRole('textbox', { name: 'Name' });
|
|
207
|
+
await user.clear(name);
|
|
208
|
+
await user.type(name, 'Changed');
|
|
209
|
+
const link = screen.getByRole('link', { name: 'Open details' });
|
|
210
|
+
await user.click(link);
|
|
211
|
+
const child = await screen.findByRole('dialog', { name: 'Details' });
|
|
212
|
+
await waitFor(() =>
|
|
213
|
+
expect(child.contains(document.activeElement)).toBe(true),
|
|
214
|
+
);
|
|
215
|
+
await user.click(screen.getByRole('button', { name: 'Cancel' }));
|
|
216
|
+
await waitFor(() =>
|
|
217
|
+
expect(router.state.location.pathname).toBe('/main/orders/edit/42'),
|
|
218
|
+
);
|
|
219
|
+
await waitFor(() =>
|
|
220
|
+
expect(screen.getByRole('textbox', { name: 'Name' })).toHaveValue(
|
|
221
|
+
'Changed',
|
|
222
|
+
),
|
|
223
|
+
);
|
|
224
|
+
await waitFor(() => expect(link).toHaveFocus());
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it('restores focus into the parent when closing a directly loaded nested route', async () => {
|
|
228
|
+
const user = userEvent.setup();
|
|
229
|
+
setup(undefined, undefined, true);
|
|
230
|
+
const child = await screen.findByRole('dialog', { name: 'Details' });
|
|
231
|
+
await waitFor(() =>
|
|
232
|
+
expect(child.contains(document.activeElement)).toBe(true),
|
|
233
|
+
);
|
|
234
|
+
await user.click(screen.getByRole('button', { name: 'Cancel' }));
|
|
235
|
+
const parent = await screen.findByRole('dialog', { name: 'Edit' });
|
|
236
|
+
await waitFor(() =>
|
|
237
|
+
expect(parent.contains(document.activeElement)).toBe(true),
|
|
238
|
+
);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it('allows history back and forward without running the close guard', async () => {
|
|
242
|
+
const guard = vi.fn(() => false);
|
|
243
|
+
const router = createMemoryRouter(
|
|
244
|
+
[
|
|
245
|
+
{
|
|
246
|
+
path: '/orders',
|
|
247
|
+
element: (
|
|
248
|
+
<>
|
|
249
|
+
<span>Orders</span>
|
|
250
|
+
<Outlet />
|
|
251
|
+
</>
|
|
252
|
+
),
|
|
253
|
+
children: [
|
|
254
|
+
{
|
|
255
|
+
path: 'edit',
|
|
256
|
+
element: (
|
|
257
|
+
<RouteDialog title='Edit' beforeClose={guard}>
|
|
258
|
+
Form
|
|
259
|
+
</RouteDialog>
|
|
260
|
+
),
|
|
261
|
+
},
|
|
262
|
+
],
|
|
263
|
+
},
|
|
264
|
+
],
|
|
265
|
+
{ initialEntries: ['/orders', '/orders/edit'] },
|
|
266
|
+
);
|
|
267
|
+
render(<RouterProvider router={router} />, { wrapper: I18n });
|
|
268
|
+
expect(await screen.findByRole('dialog', { name: 'Edit' })).toBeVisible();
|
|
269
|
+
await act(() => router.navigate(-1));
|
|
270
|
+
expect(router.state.location.pathname).toBe('/orders');
|
|
271
|
+
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
|
272
|
+
await act(() => router.navigate(1));
|
|
273
|
+
expect(await screen.findByRole('dialog', { name: 'Edit' })).toBeVisible();
|
|
274
|
+
expect(guard).not.toHaveBeenCalled();
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it('logs a rejected internal close request and leaves the dialog open', async () => {
|
|
278
|
+
const failure = new Error('Confirmation unavailable');
|
|
279
|
+
const log = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
280
|
+
try {
|
|
281
|
+
const router = setup(() => Promise.reject(failure));
|
|
282
|
+
fireEvent.click(await screen.findByRole('button', { name: 'Close' }));
|
|
283
|
+
await waitFor(() =>
|
|
284
|
+
expect(log).toHaveBeenCalledWith(
|
|
285
|
+
'Failed to close route overlay',
|
|
286
|
+
failure,
|
|
287
|
+
),
|
|
288
|
+
);
|
|
289
|
+
expect(router.state.location.pathname).toBe('/main/orders/edit/42');
|
|
290
|
+
expect(screen.getByRole('button', { name: 'Cancel' })).toBeEnabled();
|
|
291
|
+
} finally {
|
|
292
|
+
log.mockRestore();
|
|
293
|
+
}
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
it('propagates a failed close check to the hook caller and permits a retry', async () => {
|
|
297
|
+
const failure = new Error('Confirmation unavailable');
|
|
298
|
+
const guard = vi
|
|
299
|
+
.fn()
|
|
300
|
+
.mockRejectedValueOnce(failure)
|
|
301
|
+
.mockResolvedValueOnce(true);
|
|
302
|
+
let overlay: ReturnType<typeof useRouteOverlay> | undefined;
|
|
303
|
+
function Capture() {
|
|
304
|
+
const value = useRouteOverlay();
|
|
305
|
+
useEffect(() => {
|
|
306
|
+
overlay = value;
|
|
307
|
+
}, [value]);
|
|
308
|
+
return <span>Form</span>;
|
|
309
|
+
}
|
|
310
|
+
const router = createMemoryRouter(
|
|
311
|
+
[
|
|
312
|
+
{
|
|
313
|
+
path: '/orders',
|
|
314
|
+
element: <Outlet />,
|
|
315
|
+
children: [
|
|
316
|
+
{
|
|
317
|
+
path: 'edit',
|
|
318
|
+
element: (
|
|
319
|
+
<RouteDialog title='Edit' beforeClose={guard}>
|
|
320
|
+
<Capture />
|
|
321
|
+
</RouteDialog>
|
|
322
|
+
),
|
|
323
|
+
},
|
|
324
|
+
],
|
|
325
|
+
},
|
|
326
|
+
],
|
|
327
|
+
{ initialEntries: ['/orders/edit'] },
|
|
328
|
+
);
|
|
329
|
+
render(<RouterProvider router={router} />, { wrapper: I18n });
|
|
330
|
+
await screen.findByRole('dialog', { name: 'Edit' });
|
|
331
|
+
await act(async () => {
|
|
332
|
+
await expect(overlay!.close()).rejects.toBe(failure);
|
|
333
|
+
});
|
|
334
|
+
expect(router.state.location.pathname).toBe('/orders/edit');
|
|
335
|
+
expect(overlay!.isClosing).toBe(false);
|
|
336
|
+
await act(async () => {
|
|
337
|
+
await overlay!.close();
|
|
338
|
+
});
|
|
339
|
+
expect(router.state.location.pathname).toBe('/orders');
|
|
340
|
+
expect(guard).toHaveBeenCalledTimes(2);
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
it('reports hook use outside a route overlay', () => {
|
|
344
|
+
const log = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
345
|
+
try {
|
|
346
|
+
expect(() => render(<Actions />)).toThrow(
|
|
347
|
+
'useRouteOverlay must be used inside RouteDialog or RouteDrawer',
|
|
348
|
+
);
|
|
349
|
+
} finally {
|
|
350
|
+
log.mockRestore();
|
|
351
|
+
}
|
|
352
|
+
});
|
|
353
|
+
});
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// @vitest-environment node
|
|
2
|
+
import { execFile } from 'node:child_process';
|
|
3
|
+
import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
import { promisify } from 'node:util';
|
|
8
|
+
|
|
9
|
+
import { expect, it } from 'vitest';
|
|
10
|
+
|
|
11
|
+
const exec = promisify(execFile);
|
|
12
|
+
async function compile(config: string): Promise<void> {
|
|
13
|
+
try {
|
|
14
|
+
await exec('pnpm', ['exec', 'tsc', '-p', config], { cwd: appRoot });
|
|
15
|
+
} catch (error) {
|
|
16
|
+
throw new Error((error as { stdout: string }).stdout, { cause: error });
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
const appRoot = fileURLToPath(new URL('../../', import.meta.url));
|
|
20
|
+
|
|
21
|
+
it('runs compiled extensionless server imports without source files or a loader', async () => {
|
|
22
|
+
const directory = await mkdtemp(path.join(os.tmpdir(), 'app-esm-imports-'));
|
|
23
|
+
try {
|
|
24
|
+
const source = path.join(directory, 'source');
|
|
25
|
+
await mkdir(path.join(source, 'nested'), { recursive: true });
|
|
26
|
+
await writeFile(path.join(directory, 'package.json'), '{"type":"module"}');
|
|
27
|
+
await writeFile(
|
|
28
|
+
path.join(source, 'value.ts'),
|
|
29
|
+
'export const value: number = 42;',
|
|
30
|
+
);
|
|
31
|
+
await writeFile(
|
|
32
|
+
path.join(source, 'nested/index.ts'),
|
|
33
|
+
"export { value } from '../value';",
|
|
34
|
+
);
|
|
35
|
+
await writeFile(
|
|
36
|
+
path.join(source, 'main.ts'),
|
|
37
|
+
`
|
|
38
|
+
import { value } from './nested';
|
|
39
|
+
import { value as explicit } from './value.js';
|
|
40
|
+
const dynamic = await import('./value');
|
|
41
|
+
export const result: number[] = [value, explicit, dynamic.value];
|
|
42
|
+
`,
|
|
43
|
+
);
|
|
44
|
+
const config = path.join(directory, 'tsconfig.json');
|
|
45
|
+
await writeFile(
|
|
46
|
+
config,
|
|
47
|
+
JSON.stringify({
|
|
48
|
+
extends: path.join(appRoot, 'tsconfig.server.json'),
|
|
49
|
+
compilerOptions: {
|
|
50
|
+
rootDir: source,
|
|
51
|
+
outDir: path.join(directory, 'dist'),
|
|
52
|
+
types: [],
|
|
53
|
+
},
|
|
54
|
+
include: [path.join(source, '**/*.ts')],
|
|
55
|
+
}),
|
|
56
|
+
);
|
|
57
|
+
// Use the same two stages as the shared application build.
|
|
58
|
+
await compile(config);
|
|
59
|
+
await exec(
|
|
60
|
+
'pnpm',
|
|
61
|
+
[
|
|
62
|
+
'exec',
|
|
63
|
+
'tsc-alias',
|
|
64
|
+
'-p',
|
|
65
|
+
'tsconfig.server.json',
|
|
66
|
+
'--outDir',
|
|
67
|
+
path.join(directory, 'dist'),
|
|
68
|
+
],
|
|
69
|
+
{ cwd: appRoot },
|
|
70
|
+
);
|
|
71
|
+
await rm(source, { recursive: true });
|
|
72
|
+
const runner = path.join(directory, 'runner.mjs');
|
|
73
|
+
await writeFile(
|
|
74
|
+
runner,
|
|
75
|
+
`
|
|
76
|
+
import { result } from './dist/main.js';
|
|
77
|
+
import { writeFileSync } from 'node:fs';
|
|
78
|
+
writeFileSync(new URL('./result.json', import.meta.url), JSON.stringify(result));
|
|
79
|
+
`,
|
|
80
|
+
);
|
|
81
|
+
await exec(process.execPath, [runner], { env: { PATH: process.env.PATH } });
|
|
82
|
+
expect(
|
|
83
|
+
JSON.parse(await readFile(path.join(directory, 'result.json'), 'utf8')),
|
|
84
|
+
).toEqual([42, 42, 42]);
|
|
85
|
+
} finally {
|
|
86
|
+
await rm(directory, { recursive: true, force: true });
|
|
87
|
+
}
|
|
88
|
+
}, 30_000);
|