@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,87 @@
|
|
|
1
|
+
import { fireEvent, render, screen } from '@testing-library/react';
|
|
2
|
+
import userEvent from '@testing-library/user-event';
|
|
3
|
+
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
|
|
4
|
+
import { AppThemeProvider } from '../../client/theme/theme-provider';
|
|
5
|
+
import { ThemeSettings } from '../../client/theme/theme-settings';
|
|
6
|
+
import { UserMenu } from '../../client/layouts/components/user-menu';
|
|
7
|
+
|
|
8
|
+
// Session requests belong to the authentication tests.
|
|
9
|
+
vi.mock('@nocobase/app-plugin-authentication/client', () => ({
|
|
10
|
+
useAuthentication: () => ({
|
|
11
|
+
session: { user: { id: 'operator', name: 'Operator' } },
|
|
12
|
+
isPending: false,
|
|
13
|
+
}),
|
|
14
|
+
}));
|
|
15
|
+
// Language selection has its own integration tests with the real i18n runtime.
|
|
16
|
+
vi.mock('../../client/layouts/components/language-switcher.js', () => ({
|
|
17
|
+
LanguageSwitcher: () => null,
|
|
18
|
+
}));
|
|
19
|
+
|
|
20
|
+
beforeEach(() => {
|
|
21
|
+
// jsdom does not implement the media queries used by the real theme provider.
|
|
22
|
+
vi.stubGlobal('matchMedia', () => ({
|
|
23
|
+
matches: false,
|
|
24
|
+
addListener() {},
|
|
25
|
+
removeListener() {},
|
|
26
|
+
}));
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
afterEach(() => {
|
|
30
|
+
vi.unstubAllGlobals();
|
|
31
|
+
localStorage.clear();
|
|
32
|
+
document.documentElement.removeAttribute('class');
|
|
33
|
+
document.documentElement.removeAttribute('style');
|
|
34
|
+
document.documentElement.removeAttribute('data-theme');
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
const panels = [
|
|
38
|
+
['Appearance', ThemeSettings, 'dialog'],
|
|
39
|
+
['Open account menu', UserMenu, 'menu'],
|
|
40
|
+
] as const;
|
|
41
|
+
|
|
42
|
+
it.each(panels)(
|
|
43
|
+
'opens and closes %s on hover',
|
|
44
|
+
async (label, Component, role) => {
|
|
45
|
+
const user = userEvent.setup();
|
|
46
|
+
render(
|
|
47
|
+
<AppThemeProvider>
|
|
48
|
+
<Component />
|
|
49
|
+
</AppThemeProvider>,
|
|
50
|
+
);
|
|
51
|
+
const trigger = screen.getByRole('button', { name: label });
|
|
52
|
+
|
|
53
|
+
await user.hover(trigger);
|
|
54
|
+
const panel = screen.getByRole(role);
|
|
55
|
+
expect(trigger).not.toHaveAttribute('title');
|
|
56
|
+
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
|
|
57
|
+
// Supply the destination because user-event does not set relatedTarget.
|
|
58
|
+
fireEvent.mouseLeave(trigger, { relatedTarget: panel });
|
|
59
|
+
fireEvent.mouseEnter(panel, { relatedTarget: trigger });
|
|
60
|
+
fireEvent.mouseMove(panel);
|
|
61
|
+
expect(panel).toBeVisible();
|
|
62
|
+
|
|
63
|
+
fireEvent.mouseLeave(panel, { relatedTarget: document.body });
|
|
64
|
+
fireEvent.mouseMove(document.body, { clientX: 1000, clientY: 1000 });
|
|
65
|
+
expect(trigger).toHaveAttribute('aria-expanded', 'false');
|
|
66
|
+
},
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
it.each(panels)(
|
|
70
|
+
'opens %s by keyboard and closes with Escape',
|
|
71
|
+
async (label, Component, role) => {
|
|
72
|
+
const user = userEvent.setup();
|
|
73
|
+
render(
|
|
74
|
+
<AppThemeProvider>
|
|
75
|
+
<Component />
|
|
76
|
+
</AppThemeProvider>,
|
|
77
|
+
);
|
|
78
|
+
const trigger = screen.getByRole('button', { name: label });
|
|
79
|
+
|
|
80
|
+
await user.tab();
|
|
81
|
+
expect(trigger).toHaveFocus();
|
|
82
|
+
await user.keyboard('{Enter}');
|
|
83
|
+
expect(screen.getByRole(role)).toBeVisible();
|
|
84
|
+
await user.keyboard('{Escape}');
|
|
85
|
+
expect(trigger).toHaveAttribute('aria-expanded', 'false');
|
|
86
|
+
},
|
|
87
|
+
);
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
|
2
|
+
import { I18nRuntime } from '@nocobase/i18n';
|
|
3
|
+
import { I18nProvider } from '@nocobase/i18n/client';
|
|
4
|
+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
|
5
|
+
import type { ReactElement } from 'react';
|
|
6
|
+
import { MemoryRouter } from 'react-router';
|
|
7
|
+
import { beforeEach, expect, it, vi } from 'vitest';
|
|
8
|
+
|
|
9
|
+
import locales from '../../client/locales/index.js';
|
|
10
|
+
import { TooltipProvider } from '../../client/components/ui/tooltip';
|
|
11
|
+
|
|
12
|
+
const mocks = vi.hoisted(() => ({
|
|
13
|
+
session: { user: { id: 'user-1' } } as { user: { id: string } } | null,
|
|
14
|
+
unread: 2,
|
|
15
|
+
request: vi.fn(),
|
|
16
|
+
}));
|
|
17
|
+
vi.mock('@nocobase/app-plugin-authentication/client', () => ({
|
|
18
|
+
useAuthentication: () => ({ session: mocks.session, isPending: false }),
|
|
19
|
+
}));
|
|
20
|
+
vi.mock('@nocobase/app-client', async (importOriginal) => {
|
|
21
|
+
const actual = await importOriginal<typeof import('@nocobase/app-client')>();
|
|
22
|
+
const client = { request: mocks.request };
|
|
23
|
+
const realtime = { subscribe: () => vi.fn(), onOpen: () => vi.fn() };
|
|
24
|
+
return {
|
|
25
|
+
...actual,
|
|
26
|
+
useApiClient: () => client,
|
|
27
|
+
useService: (token: unknown) =>
|
|
28
|
+
token === actual.realtimeClientToken ? realtime : client,
|
|
29
|
+
};
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
import { InboxHeaderButton } from '../../client/components/inbox-header-button.tsx';
|
|
33
|
+
import InboxPage from '../../client/pages/inbox.tsx';
|
|
34
|
+
|
|
35
|
+
const messages = [
|
|
36
|
+
{
|
|
37
|
+
id: 'm1',
|
|
38
|
+
deliveryId: 'd1',
|
|
39
|
+
notificationId: 'n1',
|
|
40
|
+
title: 'Weekly report is ready',
|
|
41
|
+
body: 'The sales report for week 40 is ready.',
|
|
42
|
+
createdAt: '2026-10-01T08:00:00.000Z',
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
id: 'm2',
|
|
46
|
+
deliveryId: 'd2',
|
|
47
|
+
notificationId: 'n2',
|
|
48
|
+
title: 'Backup finished',
|
|
49
|
+
body: 'All databases were backed up.',
|
|
50
|
+
readAt: '2026-10-01T09:00:00.000Z',
|
|
51
|
+
createdAt: '2026-10-01T07:00:00.000Z',
|
|
52
|
+
},
|
|
53
|
+
];
|
|
54
|
+
|
|
55
|
+
let runtime: I18nRuntime;
|
|
56
|
+
beforeEach(async () => {
|
|
57
|
+
mocks.session = { user: { id: 'user-1' } };
|
|
58
|
+
mocks.unread = 2;
|
|
59
|
+
mocks.request.mockReset();
|
|
60
|
+
mocks.request.mockImplementation(
|
|
61
|
+
async ({ path }: { readonly path: string }) => {
|
|
62
|
+
if (path.endsWith('/unreadCount'))
|
|
63
|
+
return { data: { count: mocks.unread } };
|
|
64
|
+
if (path.startsWith('notificationInApp/messages?'))
|
|
65
|
+
return { data: messages, meta: {} };
|
|
66
|
+
return { data: { ...messages[0], readAt: '2026-10-01T10:00:00.000Z' } };
|
|
67
|
+
},
|
|
68
|
+
);
|
|
69
|
+
runtime = new I18nRuntime({
|
|
70
|
+
defaultLocale: 'en-US',
|
|
71
|
+
locales: ['en-US', 'zh-CN'],
|
|
72
|
+
applicationNamespace: 'test-app',
|
|
73
|
+
});
|
|
74
|
+
runtime.registerApplicationNamespace('test-app', locales);
|
|
75
|
+
await runtime.init('en-US');
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
function Frame({
|
|
79
|
+
children,
|
|
80
|
+
path,
|
|
81
|
+
}: {
|
|
82
|
+
readonly children: ReactElement;
|
|
83
|
+
readonly path: string;
|
|
84
|
+
}): ReactElement {
|
|
85
|
+
return (
|
|
86
|
+
<QueryClientProvider
|
|
87
|
+
client={
|
|
88
|
+
new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
|
89
|
+
}
|
|
90
|
+
>
|
|
91
|
+
<MemoryRouter initialEntries={[path]}>
|
|
92
|
+
<I18nProvider runtime={runtime}>
|
|
93
|
+
<TooltipProvider>{children}</TooltipProvider>
|
|
94
|
+
</I18nProvider>
|
|
95
|
+
</MemoryRouter>
|
|
96
|
+
</QueryClientProvider>
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
it('links the header to the inbox with the unread count, for a signed-in person only', async () => {
|
|
101
|
+
const view = render(
|
|
102
|
+
<Frame path='/'>
|
|
103
|
+
<InboxHeaderButton />
|
|
104
|
+
</Frame>,
|
|
105
|
+
);
|
|
106
|
+
const link = await screen.findByRole('link', { name: 'Inbox, 2 unread' });
|
|
107
|
+
expect(link).toHaveAttribute('href', '/inbox');
|
|
108
|
+
expect(screen.getByText('2')).toBeInTheDocument();
|
|
109
|
+
mocks.session = null;
|
|
110
|
+
view.rerender(
|
|
111
|
+
<Frame path='/'>
|
|
112
|
+
<InboxHeaderButton />
|
|
113
|
+
</Frame>,
|
|
114
|
+
);
|
|
115
|
+
expect(screen.queryByRole('link')).toBeNull();
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("lists the plugin's messages as notifications and marks one read when it is opened", async () => {
|
|
119
|
+
render(
|
|
120
|
+
<Frame path='/inbox'>
|
|
121
|
+
<InboxPage />
|
|
122
|
+
</Frame>,
|
|
123
|
+
);
|
|
124
|
+
expect(
|
|
125
|
+
await screen.findByRole('heading', { name: /Notifications/u }),
|
|
126
|
+
).toBeInTheDocument();
|
|
127
|
+
expect(screen.getByText('Backup finished')).toBeInTheDocument();
|
|
128
|
+
// The first message is shown, and opening it marks it read.
|
|
129
|
+
expect(
|
|
130
|
+
screen.getByRole('heading', { name: 'Weekly report is ready', level: 2 }),
|
|
131
|
+
).toBeInTheDocument();
|
|
132
|
+
fireEvent.click(screen.getAllByText('Weekly report is ready')[0]!);
|
|
133
|
+
await waitFor(() =>
|
|
134
|
+
expect(mocks.request).toHaveBeenCalledWith({
|
|
135
|
+
path: 'notificationInApp/messages/m1/markRead',
|
|
136
|
+
method: 'POST',
|
|
137
|
+
}),
|
|
138
|
+
);
|
|
139
|
+
});
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ClientApplication,
|
|
3
|
+
ClientApplicationContext,
|
|
4
|
+
createAppClientConfig,
|
|
5
|
+
defineAppClientRenderConfig,
|
|
6
|
+
readStoredLocale,
|
|
7
|
+
toasterToken,
|
|
8
|
+
type Toaster,
|
|
9
|
+
} from '@nocobase/app-client';
|
|
10
|
+
import { defineClientPlugins } from '@nocobase/app-client/plugins';
|
|
11
|
+
import {
|
|
12
|
+
defineAppRuntime,
|
|
13
|
+
resolveAppRuntime,
|
|
14
|
+
} from '@nocobase/app-client/runtime';
|
|
15
|
+
import { I18nProvider } from '@nocobase/i18n/client';
|
|
16
|
+
import { I18nRuntime } from '@nocobase/i18n';
|
|
17
|
+
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
|
18
|
+
import userEvent from '@testing-library/user-event';
|
|
19
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
20
|
+
|
|
21
|
+
import {
|
|
22
|
+
DropdownMenu,
|
|
23
|
+
DropdownMenuContent,
|
|
24
|
+
DropdownMenuTrigger,
|
|
25
|
+
} from '#components/ui/dropdown-menu';
|
|
26
|
+
|
|
27
|
+
import { LanguageSwitcher } from '../../client/layouts/components/language-switcher.js';
|
|
28
|
+
|
|
29
|
+
const APP = '@nocobase/app-template-default';
|
|
30
|
+
const FALLBACK_NOTICE = '服务端不支持该语言,服务端内容已回落为英文。';
|
|
31
|
+
const CHANGE_FAILED_NOTICE = '未能完成语言切换,请重试。';
|
|
32
|
+
const fetchMock = vi.fn();
|
|
33
|
+
const toaster = {
|
|
34
|
+
show: vi.fn<Toaster['show']>(() => 'toast'),
|
|
35
|
+
close: vi.fn<Toaster['close']>(),
|
|
36
|
+
};
|
|
37
|
+
const applications: ClientApplication[] = [];
|
|
38
|
+
|
|
39
|
+
function createServerResponse(fallback = false): Response {
|
|
40
|
+
return new Response(
|
|
41
|
+
JSON.stringify({
|
|
42
|
+
data: {
|
|
43
|
+
locale: fallback ? 'en-US' : 'zh-CN',
|
|
44
|
+
requestedLocale: 'zh-CN',
|
|
45
|
+
fallback,
|
|
46
|
+
},
|
|
47
|
+
}),
|
|
48
|
+
{ headers: { 'Content-Type': 'application/json' } },
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function createRuntime(locales: string[]): Promise<{
|
|
53
|
+
app: ClientApplication;
|
|
54
|
+
runtime: I18nRuntime;
|
|
55
|
+
}> {
|
|
56
|
+
const runtime = new I18nRuntime({
|
|
57
|
+
defaultLocale: 'en-US',
|
|
58
|
+
locales,
|
|
59
|
+
applicationNamespace: APP,
|
|
60
|
+
});
|
|
61
|
+
runtime.registerApplicationNamespace(APP, {
|
|
62
|
+
'en-US': () =>
|
|
63
|
+
Promise.resolve({
|
|
64
|
+
default: {
|
|
65
|
+
actions: { language: 'Language' },
|
|
66
|
+
notices: {
|
|
67
|
+
serverLocaleFallback:
|
|
68
|
+
'The server does not support this language, so server messages will use English.',
|
|
69
|
+
languageChangeFailed:
|
|
70
|
+
'Unable to complete the language change. Please try again.',
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
}),
|
|
74
|
+
'zh-CN': () =>
|
|
75
|
+
Promise.resolve({
|
|
76
|
+
default: {
|
|
77
|
+
actions: { language: '语言' },
|
|
78
|
+
notices: {
|
|
79
|
+
serverLocaleFallback: FALLBACK_NOTICE,
|
|
80
|
+
languageChangeFailed: CHANGE_FAILED_NOTICE,
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
}),
|
|
84
|
+
});
|
|
85
|
+
await runtime.init('en-US');
|
|
86
|
+
const appRuntime = await resolveAppRuntime(
|
|
87
|
+
defineAppRuntime({
|
|
88
|
+
packageName: APP,
|
|
89
|
+
createAppConfig: createAppClientConfig,
|
|
90
|
+
plugins: defineClientPlugins([]),
|
|
91
|
+
}),
|
|
92
|
+
{ rawConfig: { api: { baseURL: '/api' } } },
|
|
93
|
+
);
|
|
94
|
+
const app = new ClientApplication({
|
|
95
|
+
runtime: appRuntime,
|
|
96
|
+
createRenderConfig: () => defineAppClientRenderConfig({ routes: null }),
|
|
97
|
+
});
|
|
98
|
+
// Stands in for the toaster client/service-provider.ts registers, so the test sees what the menu reports.
|
|
99
|
+
app.container.instance(toasterToken, toaster);
|
|
100
|
+
await app.start();
|
|
101
|
+
applications.push(app);
|
|
102
|
+
return { app, runtime };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
beforeEach(() => {
|
|
106
|
+
vi.clearAllMocks();
|
|
107
|
+
globalThis.localStorage.clear();
|
|
108
|
+
fetchMock.mockImplementation(() => Promise.resolve(createServerResponse()));
|
|
109
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
afterEach(async () => {
|
|
113
|
+
await Promise.all(applications.splice(0).map((app) => app.shutdown()));
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
function renderMenu(app: ClientApplication, runtime: I18nRuntime) {
|
|
117
|
+
return render(
|
|
118
|
+
<ClientApplicationContext.Provider value={app}>
|
|
119
|
+
<I18nProvider runtime={runtime}>
|
|
120
|
+
<DropdownMenu>
|
|
121
|
+
<DropdownMenuTrigger>Account</DropdownMenuTrigger>
|
|
122
|
+
<DropdownMenuContent>
|
|
123
|
+
<LanguageSwitcher />
|
|
124
|
+
</DropdownMenuContent>
|
|
125
|
+
</DropdownMenu>
|
|
126
|
+
</I18nProvider>
|
|
127
|
+
</ClientApplicationContext.Provider>,
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
describe('LanguageSwitcher', () => {
|
|
132
|
+
it('shows the current language by name rather than by locale code', async () => {
|
|
133
|
+
const { app, runtime } = await createRuntime(['en-US', 'zh-CN']);
|
|
134
|
+
|
|
135
|
+
renderMenu(app, runtime);
|
|
136
|
+
screen.getByRole('button', { name: 'Account' }).focus();
|
|
137
|
+
await userEvent.keyboard('{ArrowDown}');
|
|
138
|
+
|
|
139
|
+
const trigger = await screen.findByRole('menuitem', {
|
|
140
|
+
name: /Language\s*English/,
|
|
141
|
+
});
|
|
142
|
+
expect(trigger).toHaveTextContent('English');
|
|
143
|
+
expect(trigger).not.toHaveTextContent('en-US');
|
|
144
|
+
await userEvent.click(trigger);
|
|
145
|
+
expect(
|
|
146
|
+
await screen.findByRole('menuitemradio', { name: 'English' }),
|
|
147
|
+
).toBeChecked();
|
|
148
|
+
expect(
|
|
149
|
+
screen.getByRole('menuitemradio', { name: '中文' }),
|
|
150
|
+
).not.toBeChecked();
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('switches the language when another is chosen', async () => {
|
|
154
|
+
const { app, runtime } = await createRuntime(['en-US', 'zh-CN']);
|
|
155
|
+
const user = userEvent.setup();
|
|
156
|
+
|
|
157
|
+
renderMenu(app, runtime);
|
|
158
|
+
screen.getByRole('button', { name: 'Account' }).focus();
|
|
159
|
+
await userEvent.keyboard('{ArrowDown}');
|
|
160
|
+
|
|
161
|
+
await user.click(
|
|
162
|
+
await screen.findByRole('menuitem', { name: /Language\s*English/ }),
|
|
163
|
+
);
|
|
164
|
+
// jsdom has no submenu geometry for the pointer safe corridor.
|
|
165
|
+
fireEvent.click(await screen.findByRole('menuitemradio', { name: '中文' }));
|
|
166
|
+
|
|
167
|
+
await waitFor(() => expect(runtime.getLocale()).toBe('zh-CN'));
|
|
168
|
+
await waitFor(() => expect(fetchMock).toHaveBeenCalledOnce());
|
|
169
|
+
await waitFor(() =>
|
|
170
|
+
expect(screen.queryByRole('menu')).not.toBeInTheDocument(),
|
|
171
|
+
);
|
|
172
|
+
await user.click(screen.getByRole('button', { name: 'Account' }));
|
|
173
|
+
await user.click(
|
|
174
|
+
await screen.findByRole('menuitem', { name: /语言\s*中文/ }),
|
|
175
|
+
);
|
|
176
|
+
expect(
|
|
177
|
+
await screen.findByRole('menuitemradio', { name: '中文' }),
|
|
178
|
+
).toBeChecked();
|
|
179
|
+
expect(toaster.show).not.toHaveBeenCalled();
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it('closes while synchronization is pending and reports server fallback', async () => {
|
|
183
|
+
const { app, runtime } = await createRuntime(['en-US', 'zh-CN']);
|
|
184
|
+
const user = userEvent.setup();
|
|
185
|
+
let resolveRequest: ((response: Response) => void) | undefined;
|
|
186
|
+
fetchMock.mockImplementation(
|
|
187
|
+
() =>
|
|
188
|
+
new Promise<Response>((resolve) => {
|
|
189
|
+
resolveRequest = resolve;
|
|
190
|
+
}),
|
|
191
|
+
);
|
|
192
|
+
|
|
193
|
+
renderMenu(app, runtime);
|
|
194
|
+
screen.getByRole('button', { name: 'Account' }).focus();
|
|
195
|
+
await user.keyboard('{ArrowDown}');
|
|
196
|
+
await user.click(
|
|
197
|
+
await screen.findByRole('menuitem', { name: /Language\s*English/ }),
|
|
198
|
+
);
|
|
199
|
+
fireEvent.click(await screen.findByRole('menuitemradio', { name: '中文' }));
|
|
200
|
+
|
|
201
|
+
await waitFor(() => expect(fetchMock).toHaveBeenCalledOnce());
|
|
202
|
+
expect(runtime.getLocale()).toBe('zh-CN');
|
|
203
|
+
expect(readStoredLocale()).toBe('zh-CN');
|
|
204
|
+
await waitFor(() =>
|
|
205
|
+
expect(screen.queryByRole('menu')).not.toBeInTheDocument(),
|
|
206
|
+
);
|
|
207
|
+
if (!resolveRequest) throw new Error('Locale request did not start.');
|
|
208
|
+
resolveRequest(createServerResponse(true));
|
|
209
|
+
|
|
210
|
+
await waitFor(() =>
|
|
211
|
+
expect(toaster.show).toHaveBeenCalledWith({
|
|
212
|
+
type: 'info',
|
|
213
|
+
title: FALLBACK_NOTICE,
|
|
214
|
+
}),
|
|
215
|
+
);
|
|
216
|
+
await user.click(screen.getByRole('button', { name: 'Account' }));
|
|
217
|
+
expect(
|
|
218
|
+
await screen.findByRole('menuitem', { name: /语言\s*中文/ }),
|
|
219
|
+
).not.toHaveAttribute('aria-disabled');
|
|
220
|
+
expect(toaster.show).toHaveBeenCalledOnce();
|
|
221
|
+
await user.keyboard('{Escape}{Escape}');
|
|
222
|
+
await waitFor(() =>
|
|
223
|
+
expect(screen.getByRole('button', { name: 'Account' })).toHaveFocus(),
|
|
224
|
+
);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it('keeps the interface language and reports a server synchronization failure', async () => {
|
|
228
|
+
const { app, runtime } = await createRuntime(['en-US', 'zh-CN']);
|
|
229
|
+
const user = userEvent.setup();
|
|
230
|
+
fetchMock.mockImplementation(() => Promise.reject(new Error('offline')));
|
|
231
|
+
|
|
232
|
+
renderMenu(app, runtime);
|
|
233
|
+
screen.getByRole('button', { name: 'Account' }).focus();
|
|
234
|
+
await user.keyboard('{ArrowDown}');
|
|
235
|
+
await user.click(
|
|
236
|
+
await screen.findByRole('menuitem', { name: /Language\s*English/ }),
|
|
237
|
+
);
|
|
238
|
+
fireEvent.click(await screen.findByRole('menuitemradio', { name: '中文' }));
|
|
239
|
+
|
|
240
|
+
await waitFor(() => expect(runtime.getLocale()).toBe('zh-CN'));
|
|
241
|
+
await waitFor(() =>
|
|
242
|
+
expect(toaster.show).toHaveBeenCalledWith({
|
|
243
|
+
type: 'error',
|
|
244
|
+
title: CHANGE_FAILED_NOTICE,
|
|
245
|
+
}),
|
|
246
|
+
);
|
|
247
|
+
expect(readStoredLocale()).toBe('zh-CN');
|
|
248
|
+
expect(toaster.show).toHaveBeenCalledOnce();
|
|
249
|
+
await user.keyboard('{Escape}{Escape}');
|
|
250
|
+
await waitFor(() =>
|
|
251
|
+
expect(screen.getByRole('button', { name: 'Account' })).toHaveFocus(),
|
|
252
|
+
);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it('supports switching from the keyboard', async () => {
|
|
256
|
+
const { app, runtime } = await createRuntime(['en-US', 'zh-CN']);
|
|
257
|
+
const user = userEvent.setup();
|
|
258
|
+
renderMenu(app, runtime);
|
|
259
|
+
|
|
260
|
+
await user.tab();
|
|
261
|
+
await user.keyboard('{ArrowDown}');
|
|
262
|
+
await waitFor(() =>
|
|
263
|
+
expect(
|
|
264
|
+
screen.getByRole('menuitem', { name: /Language\s*English/ }),
|
|
265
|
+
).toHaveFocus(),
|
|
266
|
+
);
|
|
267
|
+
await user.keyboard('{ArrowRight}');
|
|
268
|
+
await waitFor(() =>
|
|
269
|
+
expect(
|
|
270
|
+
screen.getByRole('menuitemradio', { name: 'English' }),
|
|
271
|
+
).toHaveFocus(),
|
|
272
|
+
);
|
|
273
|
+
await user.keyboard('{ArrowDown}{Enter}');
|
|
274
|
+
expect(runtime.getLocale()).toBe('zh-CN');
|
|
275
|
+
await waitFor(() => expect(fetchMock).toHaveBeenCalledOnce());
|
|
276
|
+
expect(readStoredLocale()).toBe('zh-CN');
|
|
277
|
+
await waitFor(() =>
|
|
278
|
+
expect(screen.queryByRole('menu')).not.toBeInTheDocument(),
|
|
279
|
+
);
|
|
280
|
+
await waitFor(() =>
|
|
281
|
+
expect(screen.getByRole('button', { name: 'Account' })).toHaveFocus(),
|
|
282
|
+
);
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
it('renders nothing when the application offers one language', async () => {
|
|
286
|
+
const { app, runtime } = await createRuntime(['en-US']);
|
|
287
|
+
|
|
288
|
+
const { container } = render(
|
|
289
|
+
<ClientApplicationContext.Provider value={app}>
|
|
290
|
+
<I18nProvider runtime={runtime}>
|
|
291
|
+
<LanguageSwitcher />
|
|
292
|
+
</I18nProvider>
|
|
293
|
+
</ClientApplicationContext.Provider>,
|
|
294
|
+
);
|
|
295
|
+
|
|
296
|
+
expect(container).toBeEmptyDOMElement();
|
|
297
|
+
});
|
|
298
|
+
});
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { render, screen } from '@testing-library/react';
|
|
2
|
+
import { expect, it } from 'vitest';
|
|
3
|
+
import { LayoutHeader } from '../../client/layouts/components/layout-header.js';
|
|
4
|
+
|
|
5
|
+
it('renders arbitrary header content and native attributes without providers', () => {
|
|
6
|
+
render(
|
|
7
|
+
<LayoutHeader aria-label='Tools' className='justify-end'>
|
|
8
|
+
<input aria-label='Search' />
|
|
9
|
+
</LayoutHeader>,
|
|
10
|
+
);
|
|
11
|
+
expect(screen.getByRole('banner', { name: 'Tools' })).toHaveClass(
|
|
12
|
+
'justify-end',
|
|
13
|
+
);
|
|
14
|
+
expect(screen.getByRole('textbox', { name: 'Search' })).toBeVisible();
|
|
15
|
+
});
|