@nocobase/app-template-default 0.0.0-stage → 1.0.0-beta.58
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 +434 -0
- package/CLAUDE.md +9 -0
- package/Dockerfile +132 -0
- package/Dockerfile.dockerignore +45 -0
- package/README.MD +485 -0
- package/cli/plugins.ts +8 -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 +37 -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 +156 -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 +198 -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/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 +67 -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 +37 -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 +53 -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 +44 -0
- package/README.md +0 -3
|
@@ -0,0 +1,466 @@
|
|
|
1
|
+
import {
|
|
2
|
+
apiClientToken,
|
|
3
|
+
ClientApplicationContext,
|
|
4
|
+
type ClientApplication,
|
|
5
|
+
createAppClientConfig,
|
|
6
|
+
} from '@nocobase/app-client';
|
|
7
|
+
import type { AppClientRegisteredRoute } from '@nocobase/app-client/plugins';
|
|
8
|
+
import {
|
|
9
|
+
AuthenticationProvider,
|
|
10
|
+
authenticationClientToken,
|
|
11
|
+
} from '@nocobase/app-plugin-authentication/client';
|
|
12
|
+
import {
|
|
13
|
+
AuthorizationClient,
|
|
14
|
+
type AuthorizationCheck,
|
|
15
|
+
authorizationClientToken,
|
|
16
|
+
} from '@nocobase/app-plugin-authorization/client';
|
|
17
|
+
import {
|
|
18
|
+
act,
|
|
19
|
+
fireEvent,
|
|
20
|
+
render,
|
|
21
|
+
screen,
|
|
22
|
+
waitFor,
|
|
23
|
+
within,
|
|
24
|
+
} from '@testing-library/react';
|
|
25
|
+
import type { ComponentType, ReactElement } from 'react';
|
|
26
|
+
import { Outlet, MemoryRouter } from 'react-router';
|
|
27
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
28
|
+
|
|
29
|
+
import { AppRouter } from '../../client/routing/app-router.tsx';
|
|
30
|
+
import { AppThemeProvider } from '../../client/theme/index.ts';
|
|
31
|
+
|
|
32
|
+
// The header's inbox button reads the in-app notification plugin's API, which these tests do not serve; it is
|
|
33
|
+
// covered by tests/components/inbox.test.tsx.
|
|
34
|
+
vi.mock('../../client/components/inbox-header-button', () => ({
|
|
35
|
+
InboxHeaderButton: () => null,
|
|
36
|
+
}));
|
|
37
|
+
|
|
38
|
+
afterEach(() => vi.unstubAllGlobals());
|
|
39
|
+
|
|
40
|
+
/** The sidebar's entries; the header's breadcrumb names the current page as well. */
|
|
41
|
+
const sidebar = (): HTMLElement =>
|
|
42
|
+
document.querySelector<HTMLElement>('[data-slot=sidebar]') ?? document.body;
|
|
43
|
+
const findSidebarLink = (name: string): Promise<HTMLElement> =>
|
|
44
|
+
waitFor(() => within(sidebar()).getByRole('link', { name }));
|
|
45
|
+
|
|
46
|
+
describe('application shell', () => {
|
|
47
|
+
beforeEach(() => {
|
|
48
|
+
window.matchMedia = vi.fn().mockImplementation((query: string) => ({
|
|
49
|
+
addEventListener: vi.fn(),
|
|
50
|
+
addListener: vi.fn(),
|
|
51
|
+
dispatchEvent: vi.fn(),
|
|
52
|
+
matches:
|
|
53
|
+
query === '(prefers-color-scheme: dark)' ||
|
|
54
|
+
query === '(min-width: 768px)',
|
|
55
|
+
media: query,
|
|
56
|
+
onchange: null,
|
|
57
|
+
removeEventListener: vi.fn(),
|
|
58
|
+
removeListener: vi.fn(),
|
|
59
|
+
}));
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('wraps authenticated application pages with navigation and user controls', async () => {
|
|
63
|
+
renderApplication('/', true);
|
|
64
|
+
|
|
65
|
+
expect(
|
|
66
|
+
await screen.findByRole('navigation', { name: 'Application navigation' }),
|
|
67
|
+
).toBeVisible();
|
|
68
|
+
expect(await findSidebarLink('Home')).toHaveAttribute(
|
|
69
|
+
'aria-current',
|
|
70
|
+
'page',
|
|
71
|
+
);
|
|
72
|
+
expect(
|
|
73
|
+
screen
|
|
74
|
+
.getByRole('complementary', { name: 'Application navigation' })
|
|
75
|
+
.querySelector('[data-sidebar="sidebar"]'),
|
|
76
|
+
).toHaveClass('bg-sidebar');
|
|
77
|
+
expect(
|
|
78
|
+
within(sidebar()).getByRole('link', { name: 'Home' }),
|
|
79
|
+
).toHaveAttribute('data-active');
|
|
80
|
+
expect(within(sidebar()).getByRole('link', { name: 'Home' })).toHaveClass(
|
|
81
|
+
'data-active:bg-sidebar-primary',
|
|
82
|
+
'data-active:text-sidebar-primary-foreground',
|
|
83
|
+
'ring-sidebar-ring',
|
|
84
|
+
);
|
|
85
|
+
// The account menu exposes user details in its panel without a native tooltip.
|
|
86
|
+
expect(
|
|
87
|
+
await screen.findByRole('button', { name: 'Open account menu' }),
|
|
88
|
+
).not.toHaveAttribute('title');
|
|
89
|
+
expect(screen.getByRole('button', { name: 'Appearance' })).toBeVisible();
|
|
90
|
+
expect(
|
|
91
|
+
screen.queryByRole('link', { name: 'Settings' }),
|
|
92
|
+
).not.toBeInTheDocument();
|
|
93
|
+
expect(screen.getByText('AI builds freely.')).toBeVisible();
|
|
94
|
+
expect(screen.getByRole('link', { name: 'NocoBase' })).toHaveAttribute(
|
|
95
|
+
'href',
|
|
96
|
+
'https://www.nocobase.com',
|
|
97
|
+
);
|
|
98
|
+
expect(
|
|
99
|
+
screen.getByText('NocoBase', { selector: 'a' }).parentElement,
|
|
100
|
+
).toHaveTextContent('NocoBase keeps it reliable.');
|
|
101
|
+
expect(screen.getByText('Default Template')).toHaveClass('truncate');
|
|
102
|
+
expect(screen.getByText('v0.0.0')).toHaveClass('truncate');
|
|
103
|
+
// shadcn's edge rail, with a translated label in place of its built-in one.
|
|
104
|
+
expect(
|
|
105
|
+
screen.getByRole('button', { name: 'Expand or collapse navigation' }),
|
|
106
|
+
).toHaveAttribute('title', 'Expand or collapse navigation');
|
|
107
|
+
expect(
|
|
108
|
+
await screen.findByRole('heading', { name: 'App client is ready' }),
|
|
109
|
+
).toBeVisible();
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it.each([true, false])(
|
|
113
|
+
'shows the Settings entry only when a page is accessible (%s)',
|
|
114
|
+
async (allowed) => {
|
|
115
|
+
const can = vi.fn(
|
|
116
|
+
async ({ resource }: AuthorizationCheck) =>
|
|
117
|
+
resource.id !== 'preferences' || allowed,
|
|
118
|
+
);
|
|
119
|
+
renderApplication('/', true, [], {
|
|
120
|
+
authorization: { can },
|
|
121
|
+
settingsRouteTree: [
|
|
122
|
+
createRoute(
|
|
123
|
+
'preferences',
|
|
124
|
+
'/settings/preferences',
|
|
125
|
+
'required',
|
|
126
|
+
() => <h2>Preferences</h2>,
|
|
127
|
+
'plugin',
|
|
128
|
+
'Preferences',
|
|
129
|
+
true,
|
|
130
|
+
),
|
|
131
|
+
],
|
|
132
|
+
});
|
|
133
|
+
await screen.findByRole('heading', { name: 'App client is ready' });
|
|
134
|
+
await waitFor(() =>
|
|
135
|
+
expect(can).toHaveBeenCalledWith(
|
|
136
|
+
expect.objectContaining({
|
|
137
|
+
resource: { type: 'page', id: 'preferences' },
|
|
138
|
+
action: 'access',
|
|
139
|
+
}),
|
|
140
|
+
),
|
|
141
|
+
);
|
|
142
|
+
if (allowed) {
|
|
143
|
+
expect(
|
|
144
|
+
await screen.findByRole('link', { name: 'Settings' }),
|
|
145
|
+
).toBeVisible();
|
|
146
|
+
} else {
|
|
147
|
+
expect(
|
|
148
|
+
screen.queryByRole('link', { name: 'Settings' }),
|
|
149
|
+
).not.toBeInTheDocument();
|
|
150
|
+
}
|
|
151
|
+
},
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
it('renders nested pages through manual outlets and selects the nearest menu ancestor', async () => {
|
|
155
|
+
const child = createRoute('detail', '/orders/42', 'required', () => (
|
|
156
|
+
<h3>Order detail</h3>
|
|
157
|
+
));
|
|
158
|
+
const parent = {
|
|
159
|
+
...createRoute('orders', '/orders', 'required', () => (
|
|
160
|
+
<>
|
|
161
|
+
<h2>Orders layout</h2>
|
|
162
|
+
<Outlet />
|
|
163
|
+
</>
|
|
164
|
+
)),
|
|
165
|
+
navigation: { title: 'Orders' },
|
|
166
|
+
children: [child],
|
|
167
|
+
};
|
|
168
|
+
renderApplication('/orders/42', true, [parent]);
|
|
169
|
+
expect(await screen.findByText('Order detail')).toBeVisible();
|
|
170
|
+
expect(screen.getByText('Orders layout')).toBeVisible();
|
|
171
|
+
expect(
|
|
172
|
+
within(sidebar())
|
|
173
|
+
.getByRole('link', { name: 'Orders' })
|
|
174
|
+
.querySelector('svg'),
|
|
175
|
+
).toBeNull();
|
|
176
|
+
expect(
|
|
177
|
+
within(sidebar()).getByRole('link', { name: 'Orders' }),
|
|
178
|
+
).toHaveAttribute('aria-current', 'page');
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it('keeps a parent page link clickable independently of its menu disclosure', async () => {
|
|
182
|
+
const child = {
|
|
183
|
+
...createRoute('reports', '/orders/reports', 'required', () => (
|
|
184
|
+
<h3>Reports page</h3>
|
|
185
|
+
)),
|
|
186
|
+
navigation: { title: 'Reports' },
|
|
187
|
+
};
|
|
188
|
+
const parent = {
|
|
189
|
+
...createRoute('orders', '/orders', 'required', () => (
|
|
190
|
+
<>
|
|
191
|
+
<h2>Orders layout</h2>
|
|
192
|
+
<Outlet />
|
|
193
|
+
</>
|
|
194
|
+
)),
|
|
195
|
+
navigation: { title: 'Orders' },
|
|
196
|
+
children: [child],
|
|
197
|
+
};
|
|
198
|
+
renderApplication('/orders', true, [parent]);
|
|
199
|
+
expect(await findSidebarLink('Orders')).toHaveAttribute('href', '/orders');
|
|
200
|
+
const toggle = screen.getByRole('button', { name: 'Orders' });
|
|
201
|
+
fireEvent.click(toggle);
|
|
202
|
+
expect(
|
|
203
|
+
within(sidebar()).queryByRole('link', { name: 'Reports' }),
|
|
204
|
+
).not.toBeInTheDocument();
|
|
205
|
+
expect(
|
|
206
|
+
within(sidebar()).getByRole('link', { name: 'Orders' }),
|
|
207
|
+
).toBeVisible();
|
|
208
|
+
fireEvent.click(toggle);
|
|
209
|
+
fireEvent.click(within(sidebar()).getByRole('link', { name: 'Reports' }));
|
|
210
|
+
expect(await screen.findByText('Reports page')).toBeVisible();
|
|
211
|
+
expect(screen.getByText('Orders layout')).toBeVisible();
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it('collapses and expands the desktop navigation', async () => {
|
|
215
|
+
renderApplication('/', true);
|
|
216
|
+
|
|
217
|
+
const sidebar = await screen.findByRole('complementary', {
|
|
218
|
+
name: 'Application navigation',
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
// The toggle writes the shared sidebar preference, an external store the shell only re-renders from once its
|
|
222
|
+
// subscription effect has run; after a lazily loaded mount that effect can still be pending when the click lands,
|
|
223
|
+
// so the collapsed shell is awaited rather than read synchronously.
|
|
224
|
+
fireEvent.click(
|
|
225
|
+
screen.getByRole('button', { name: 'Collapse navigation' }),
|
|
226
|
+
);
|
|
227
|
+
await waitFor(() =>
|
|
228
|
+
expect(sidebar.closest('[data-state]')).toHaveAttribute(
|
|
229
|
+
'data-state',
|
|
230
|
+
'collapsed',
|
|
231
|
+
),
|
|
232
|
+
);
|
|
233
|
+
expect(
|
|
234
|
+
await screen.findByRole('button', { name: 'Expand navigation' }),
|
|
235
|
+
).toHaveAttribute('aria-pressed', 'true');
|
|
236
|
+
// The footer keeps only its shield; focusing it shows the slogan, name and version.
|
|
237
|
+
const footerIcon = screen.getByRole('img', {
|
|
238
|
+
name: 'AI builds freely. NocoBase keeps it reliable. Default Template v0.0.0',
|
|
239
|
+
});
|
|
240
|
+
act(() => footerIcon.focus());
|
|
241
|
+
expect(
|
|
242
|
+
await screen.findByText('Default Template v0.0.0', {
|
|
243
|
+
selector: '[data-slot="tooltip-content"] span',
|
|
244
|
+
}),
|
|
245
|
+
).toBeVisible();
|
|
246
|
+
|
|
247
|
+
// The edge rail switches the mode back.
|
|
248
|
+
fireEvent.click(
|
|
249
|
+
screen.getByRole('button', { name: 'Expand or collapse navigation' }),
|
|
250
|
+
);
|
|
251
|
+
await waitFor(() =>
|
|
252
|
+
expect(sidebar.closest('[data-state]')).toHaveAttribute(
|
|
253
|
+
'data-state',
|
|
254
|
+
'expanded',
|
|
255
|
+
),
|
|
256
|
+
);
|
|
257
|
+
expect(
|
|
258
|
+
screen.queryByRole('img', { name: /Default Template/ }),
|
|
259
|
+
).not.toBeInTheDocument();
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it('opens and closes the mobile navigation without changing the route', async () => {
|
|
263
|
+
vi.stubGlobal('innerWidth', 390);
|
|
264
|
+
vi.mocked(window.matchMedia).mockImplementation((query: string) => ({
|
|
265
|
+
matches: false,
|
|
266
|
+
media: query,
|
|
267
|
+
onchange: null,
|
|
268
|
+
addEventListener: vi.fn(),
|
|
269
|
+
removeEventListener: vi.fn(),
|
|
270
|
+
addListener: vi.fn(),
|
|
271
|
+
removeListener: vi.fn(),
|
|
272
|
+
dispatchEvent: vi.fn(),
|
|
273
|
+
}));
|
|
274
|
+
renderApplication('/', true);
|
|
275
|
+
fireEvent.click(
|
|
276
|
+
await screen.findByRole('button', { name: 'Open navigation' }),
|
|
277
|
+
);
|
|
278
|
+
expect(
|
|
279
|
+
await screen.findByRole('dialog', { name: 'Application navigation' }),
|
|
280
|
+
).toBeVisible();
|
|
281
|
+
fireEvent.click(screen.getByRole('button', { name: 'Close navigation' }));
|
|
282
|
+
await waitFor(() =>
|
|
283
|
+
expect(
|
|
284
|
+
screen.queryByRole('dialog', { name: 'Application navigation' }),
|
|
285
|
+
).toBeNull(),
|
|
286
|
+
);
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
it('renders dev pages inside the application shell, outside its navigation', async () => {
|
|
290
|
+
const playground = createRoute(
|
|
291
|
+
'playground',
|
|
292
|
+
'/dev/playground',
|
|
293
|
+
'required',
|
|
294
|
+
() => <h2>Playground page</h2>,
|
|
295
|
+
'plugin',
|
|
296
|
+
'Playground',
|
|
297
|
+
);
|
|
298
|
+
const demos: AppClientRegisteredRoute = {
|
|
299
|
+
...createRoute('demos', '/dev/demos', 'required', () => null),
|
|
300
|
+
componentLoader: undefined,
|
|
301
|
+
navigation: { title: 'Demos' },
|
|
302
|
+
children: [
|
|
303
|
+
createRoute('chat', '/dev/demos/chat', 'required', () => (
|
|
304
|
+
<h2>Chat demo page</h2>
|
|
305
|
+
)),
|
|
306
|
+
],
|
|
307
|
+
};
|
|
308
|
+
renderApplication('/dev/playground', true, [], {
|
|
309
|
+
devRouteTree: [playground, demos],
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
expect(await screen.findByText('Playground page')).toBeVisible();
|
|
313
|
+
expect(
|
|
314
|
+
screen.getByRole('navigation', { name: 'Application navigation' }),
|
|
315
|
+
).toBeVisible();
|
|
316
|
+
expect(
|
|
317
|
+
within(sidebar()).queryByRole('link', { name: 'Playground' }),
|
|
318
|
+
).not.toBeInTheDocument();
|
|
319
|
+
expect(screen.queryByText('Demos')).not.toBeInTheDocument();
|
|
320
|
+
expect(
|
|
321
|
+
screen.queryByRole('link', { name: 'Settings' }),
|
|
322
|
+
).not.toBeInTheDocument();
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
it('renders a dev page nested under a dev group', async () => {
|
|
326
|
+
const demos: AppClientRegisteredRoute = {
|
|
327
|
+
...createRoute('demos', '/dev/demos', 'required', () => null),
|
|
328
|
+
componentLoader: undefined,
|
|
329
|
+
children: [
|
|
330
|
+
createRoute('chat', '/dev/demos/chat', 'required', () => (
|
|
331
|
+
<h2>Chat demo page</h2>
|
|
332
|
+
)),
|
|
333
|
+
],
|
|
334
|
+
};
|
|
335
|
+
renderApplication('/dev/demos/chat', true, [], { devRouteTree: [demos] });
|
|
336
|
+
|
|
337
|
+
expect(await screen.findByText('Chat demo page')).toBeVisible();
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
it('keeps guest pages outside the application shell', async () => {
|
|
341
|
+
renderApplication('/login', false, [
|
|
342
|
+
createRoute('login', '/login', 'guest', GuestPage),
|
|
343
|
+
]);
|
|
344
|
+
|
|
345
|
+
expect(await screen.findByText('Guest login page')).toBeVisible();
|
|
346
|
+
expect(
|
|
347
|
+
screen.queryByRole('navigation', { name: 'Application navigation' }),
|
|
348
|
+
).not.toBeInTheDocument();
|
|
349
|
+
expect(screen.getByRole('button', { name: 'Appearance' })).toBeVisible();
|
|
350
|
+
});
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
function renderApplication(
|
|
354
|
+
initialEntry: string,
|
|
355
|
+
authenticated: boolean,
|
|
356
|
+
routes: readonly AppClientRegisteredRoute[] = [],
|
|
357
|
+
options: {
|
|
358
|
+
readonly authorization?: Pick<AuthorizationClient, 'can'>;
|
|
359
|
+
readonly settingsRouteTree?: readonly AppClientRegisteredRoute[];
|
|
360
|
+
readonly devRouteTree?: readonly AppClientRegisteredRoute[];
|
|
361
|
+
} = {},
|
|
362
|
+
): void {
|
|
363
|
+
const clientRoutes = [
|
|
364
|
+
createRoute('home', '/', 'required', HomePage, 'application'),
|
|
365
|
+
...routes,
|
|
366
|
+
];
|
|
367
|
+
const authClient = createTestAuthClient(authenticated);
|
|
368
|
+
const authorizationClient = new AuthorizationClient({
|
|
369
|
+
request: vi.fn(),
|
|
370
|
+
} as never);
|
|
371
|
+
vi.spyOn(authorizationClient, 'can').mockImplementation(
|
|
372
|
+
(request) => options.authorization?.can(request) ?? Promise.resolve(true),
|
|
373
|
+
);
|
|
374
|
+
const apiClient = {
|
|
375
|
+
request: vi.fn().mockResolvedValue({
|
|
376
|
+
data: { fallback: false, locale: 'en-US', requestedLocale: 'en-US' },
|
|
377
|
+
}),
|
|
378
|
+
};
|
|
379
|
+
const registered = new Map<unknown, unknown>([
|
|
380
|
+
[apiClientToken, apiClient],
|
|
381
|
+
[authenticationClientToken, authClient],
|
|
382
|
+
[authorizationClientToken, authorizationClient],
|
|
383
|
+
]);
|
|
384
|
+
const app = {
|
|
385
|
+
config: createAppClientConfig({ rawConfig: {} }),
|
|
386
|
+
runtime: { settingsRouteTree: options.settingsRouteTree ?? [] },
|
|
387
|
+
services: {
|
|
388
|
+
has: (token: unknown) => registered.has(token),
|
|
389
|
+
resolve: (token: unknown) => {
|
|
390
|
+
if (registered.has(token)) return registered.get(token);
|
|
391
|
+
throw new Error(`Unexpected service token: ${String(token)}`);
|
|
392
|
+
},
|
|
393
|
+
},
|
|
394
|
+
} as unknown as ClientApplication;
|
|
395
|
+
render(
|
|
396
|
+
<ClientApplicationContext.Provider value={app}>
|
|
397
|
+
<AuthenticationProvider>
|
|
398
|
+
<MemoryRouter initialEntries={[initialEntry]}>
|
|
399
|
+
<AppThemeProvider>
|
|
400
|
+
<AppRouter
|
|
401
|
+
devRouteTree={options.devRouteTree ?? []}
|
|
402
|
+
clientRoutes={clientRoutes}
|
|
403
|
+
settingsRouteTree={options.settingsRouteTree ?? []}
|
|
404
|
+
/>
|
|
405
|
+
</AppThemeProvider>
|
|
406
|
+
</MemoryRouter>
|
|
407
|
+
</AuthenticationProvider>
|
|
408
|
+
</ClientApplicationContext.Provider>,
|
|
409
|
+
);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function createTestAuthClient(authenticated: boolean) {
|
|
413
|
+
return {
|
|
414
|
+
getSession: vi.fn().mockResolvedValue({
|
|
415
|
+
data: authenticated
|
|
416
|
+
? {
|
|
417
|
+
session: null,
|
|
418
|
+
user: {
|
|
419
|
+
email: 'alice@example.com',
|
|
420
|
+
id: '1',
|
|
421
|
+
image: null,
|
|
422
|
+
name: 'Alice',
|
|
423
|
+
},
|
|
424
|
+
}
|
|
425
|
+
: null,
|
|
426
|
+
}),
|
|
427
|
+
signOut: vi.fn().mockResolvedValue({ data: null }),
|
|
428
|
+
};
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
function createRoute(
|
|
432
|
+
name: string,
|
|
433
|
+
path: string,
|
|
434
|
+
auth: AppClientRegisteredRoute['auth'],
|
|
435
|
+
Component: ComponentType,
|
|
436
|
+
source: AppClientRegisteredRoute['source'] = 'plugin',
|
|
437
|
+
navigationTitle?: string,
|
|
438
|
+
protectedRoute: boolean = false,
|
|
439
|
+
): AppClientRegisteredRoute {
|
|
440
|
+
const packageName =
|
|
441
|
+
source === 'application'
|
|
442
|
+
? '@nocobase/app-template-default'
|
|
443
|
+
: '@nocobase/app-plugin-test';
|
|
444
|
+
return {
|
|
445
|
+
auth,
|
|
446
|
+
...(name === 'home' ? { navigation: { title: 'Home' } } : {}),
|
|
447
|
+
componentLoader: async () => ({ default: Component }),
|
|
448
|
+
id: `${packageName}:${name}`,
|
|
449
|
+
name,
|
|
450
|
+
packageName,
|
|
451
|
+
path,
|
|
452
|
+
source,
|
|
453
|
+
...(navigationTitle ? { navigation: { title: navigationTitle } } : {}),
|
|
454
|
+
...(protectedRoute
|
|
455
|
+
? { authz: { resource: { type: 'page', id: name }, action: 'access' } }
|
|
456
|
+
: { authz: 'skip' as const }),
|
|
457
|
+
};
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
function HomePage(): ReactElement {
|
|
461
|
+
return <h2>App client is ready</h2>;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
function GuestPage(): ReactElement {
|
|
465
|
+
return <div>Guest login page</div>;
|
|
466
|
+
}
|