@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,106 @@
|
|
|
1
|
+
// @vitest-environment node
|
|
2
|
+
import {
|
|
3
|
+
copyFile,
|
|
4
|
+
mkdtemp,
|
|
5
|
+
realpath,
|
|
6
|
+
rm,
|
|
7
|
+
symlink,
|
|
8
|
+
writeFile,
|
|
9
|
+
} from 'node:fs/promises';
|
|
10
|
+
import { tmpdir } from 'node:os';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
import { runInNewContext } from 'node:vm';
|
|
13
|
+
import { build } from 'vite';
|
|
14
|
+
import {
|
|
15
|
+
afterAll,
|
|
16
|
+
afterEach,
|
|
17
|
+
beforeAll,
|
|
18
|
+
describe,
|
|
19
|
+
expect,
|
|
20
|
+
it,
|
|
21
|
+
vi,
|
|
22
|
+
} from 'vitest';
|
|
23
|
+
|
|
24
|
+
const appRoot = path.resolve(import.meta.dirname, '../..');
|
|
25
|
+
let root: string;
|
|
26
|
+
|
|
27
|
+
beforeAll(async () => {
|
|
28
|
+
root = await realpath(await mkdtemp(path.join(tmpdir(), 'asset-url-build-')));
|
|
29
|
+
for (const file of ['vite.config.ts', 'package.json']) {
|
|
30
|
+
await copyFile(path.join(appRoot, file), path.join(root, file));
|
|
31
|
+
}
|
|
32
|
+
await symlink(
|
|
33
|
+
path.join(appRoot, 'node_modules'),
|
|
34
|
+
path.join(root, 'node_modules'),
|
|
35
|
+
'junction',
|
|
36
|
+
);
|
|
37
|
+
await writeFile(
|
|
38
|
+
path.join(root, 'entry.js'),
|
|
39
|
+
`import { resolveAssetUrl, resolveAppUrl } from '@nocobase/app-client';
|
|
40
|
+
globalThis.resolvedUrls = {
|
|
41
|
+
asset: resolveAssetUrl('/assets/logo.png?size=2#preview'),
|
|
42
|
+
external: resolveAssetUrl('https://images.example.com/logo.png'),
|
|
43
|
+
api: resolveAppUrl('/api'),
|
|
44
|
+
};`,
|
|
45
|
+
);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
afterEach(() => vi.unstubAllEnvs());
|
|
49
|
+
afterAll(async () => {
|
|
50
|
+
if (root) await rm(root, { recursive: true, force: true });
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe('asset URLs in the application build', () => {
|
|
54
|
+
it.each([undefined, 'https://cdn.example.com/releases/v1/'])(
|
|
55
|
+
'uses the compiled asset base while keeping API URLs on the runtime mount (%s)',
|
|
56
|
+
async (cdn) => {
|
|
57
|
+
vi.stubEnv('CDN_BASE_URL', cdn);
|
|
58
|
+
vi.stubEnv('AGENT_ANNOTATIONS_ENABLED', 'false');
|
|
59
|
+
// The same build must work when the deployment chooses a different mount path.
|
|
60
|
+
vi.stubEnv('APP_BASE_PATH', '/build-machine');
|
|
61
|
+
const result = await build({
|
|
62
|
+
root,
|
|
63
|
+
configFile: path.join(root, 'vite.config.ts'),
|
|
64
|
+
logLevel: 'silent',
|
|
65
|
+
build: {
|
|
66
|
+
write: false,
|
|
67
|
+
lib: {
|
|
68
|
+
entry: path.join(root, 'entry.js'),
|
|
69
|
+
formats: ['iife'],
|
|
70
|
+
name: 'AssetUrlFixture',
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
});
|
|
74
|
+
if ('on' in result) throw new Error('Expected a completed build');
|
|
75
|
+
const bundles = Array.isArray(result) ? result : [result];
|
|
76
|
+
const chunk = bundles
|
|
77
|
+
.flatMap((bundle) => bundle.output)
|
|
78
|
+
.find((item) => item.type === 'chunk');
|
|
79
|
+
if (!chunk || chunk.type !== 'chunk')
|
|
80
|
+
throw new Error('Expected an entry chunk');
|
|
81
|
+
|
|
82
|
+
// Execute the emitted JavaScript: stubbing an environment variable in a unit test does not prove
|
|
83
|
+
// that Vite replaced BASE_URL, or that the public library export survived the build.
|
|
84
|
+
for (const mount of ['/crm', '']) {
|
|
85
|
+
const config = {
|
|
86
|
+
textContent: JSON.stringify({
|
|
87
|
+
version: 1,
|
|
88
|
+
config: { app: { basePath: mount } },
|
|
89
|
+
}),
|
|
90
|
+
};
|
|
91
|
+
const browser = {
|
|
92
|
+
URL,
|
|
93
|
+
window: { location: { origin: 'https://app.example.com' } },
|
|
94
|
+
document: { getElementById: () => config },
|
|
95
|
+
resolvedUrls: { asset: '', external: '', api: '' },
|
|
96
|
+
};
|
|
97
|
+
runInNewContext(chunk.code, browser);
|
|
98
|
+
expect(browser.resolvedUrls).toEqual({
|
|
99
|
+
asset: `${cdn ?? `${mount}/`}assets/logo.png?size=2#preview`,
|
|
100
|
+
external: 'https://images.example.com/logo.png',
|
|
101
|
+
api: `${mount}/api`,
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
},
|
|
105
|
+
);
|
|
106
|
+
});
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import {
|
|
2
|
+
TestI18nProvider,
|
|
3
|
+
createTestI18nRuntime,
|
|
4
|
+
} from '@nocobase/i18n/testing';
|
|
5
|
+
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
|
6
|
+
import type { ReactElement } from 'react';
|
|
7
|
+
import { MemoryRouter, Route, Routes } from 'react-router';
|
|
8
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
9
|
+
|
|
10
|
+
import enUS from '../../client/locales/en-US.ts';
|
|
11
|
+
import LoginPage from '../../client/pages/auth/login.tsx';
|
|
12
|
+
import RegisterPage from '../../client/pages/auth/register.tsx';
|
|
13
|
+
|
|
14
|
+
const passwordLoginAction = vi.hoisted(() => ({
|
|
15
|
+
error: undefined as { message: string } | undefined,
|
|
16
|
+
isPending: false,
|
|
17
|
+
submit: vi.fn(),
|
|
18
|
+
}));
|
|
19
|
+
|
|
20
|
+
const signUpAvailable = vi.hoisted(() => ({ value: true }));
|
|
21
|
+
|
|
22
|
+
vi.mock('@nocobase/app-plugin-authentication/client/actions', () => ({
|
|
23
|
+
usePasswordLogin: () => passwordLoginAction,
|
|
24
|
+
usePasswordRegistration: () => ({ isPending: false, submit: vi.fn() }),
|
|
25
|
+
}));
|
|
26
|
+
|
|
27
|
+
vi.mock('@nocobase/app-plugin-authentication/client', () => ({
|
|
28
|
+
useSignUpAvailable: () => signUpAvailable.value,
|
|
29
|
+
}));
|
|
30
|
+
|
|
31
|
+
const runtime = await createTestI18nRuntime({
|
|
32
|
+
application: { namespace: 'app', resources: enUS },
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
function renderAt(path: string, page: ReactElement) {
|
|
36
|
+
return render(
|
|
37
|
+
<TestI18nProvider runtime={runtime}>
|
|
38
|
+
<MemoryRouter initialEntries={[path]}>
|
|
39
|
+
<Routes>
|
|
40
|
+
<Route element={page} path={path} />
|
|
41
|
+
<Route element={<p>Login route</p>} path='/login' />
|
|
42
|
+
</Routes>
|
|
43
|
+
</MemoryRouter>
|
|
44
|
+
</TestI18nProvider>,
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
describe('application authentication pages', () => {
|
|
49
|
+
it('wires the login form to the plugin authentication action', async () => {
|
|
50
|
+
passwordLoginAction.submit.mockClear();
|
|
51
|
+
renderAt('/', <LoginPage />);
|
|
52
|
+
|
|
53
|
+
expect(screen.getByRole('heading', { name: 'Welcome back' })).toBeVisible();
|
|
54
|
+
expect(screen.queryByRole('tablist')).not.toBeInTheDocument();
|
|
55
|
+
fireEvent.change(screen.getByLabelText('Username or email'), {
|
|
56
|
+
target: { value: 'alice' },
|
|
57
|
+
});
|
|
58
|
+
const passwordInput = screen.getByLabelText('Password');
|
|
59
|
+
fireEvent.change(passwordInput, { target: { value: 'password' } });
|
|
60
|
+
|
|
61
|
+
fireEvent.click(screen.getByRole('button', { name: 'Show password' }));
|
|
62
|
+
expect(passwordInput).toHaveAttribute('type', 'text');
|
|
63
|
+
fireEvent.click(screen.getByRole('button', { name: 'Hide password' }));
|
|
64
|
+
expect(passwordInput).toHaveAttribute('type', 'password');
|
|
65
|
+
expect(passwordLoginAction.submit).not.toHaveBeenCalled();
|
|
66
|
+
|
|
67
|
+
fireEvent.click(screen.getByRole('button', { name: 'Sign in' }));
|
|
68
|
+
await waitFor(() => {
|
|
69
|
+
expect(passwordLoginAction.submit).toHaveBeenCalledWith({
|
|
70
|
+
identifier: 'alice',
|
|
71
|
+
password: 'password',
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
expect(
|
|
75
|
+
screen.getByRole('link', { name: 'Forgot password?' }),
|
|
76
|
+
).toHaveAttribute('href', '/forgot-password');
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('puts the brand panel beside the form on wide screens', () => {
|
|
80
|
+
renderAt('/', <LoginPage />);
|
|
81
|
+
const panel = screen.getByRole('complementary', {
|
|
82
|
+
name: 'About this application',
|
|
83
|
+
});
|
|
84
|
+
expect(panel).toHaveClass('hidden', 'xl:block');
|
|
85
|
+
expect(panel).toHaveTextContent('AI-native application platform');
|
|
86
|
+
expect(panel).toHaveTextContent('AI-native frontend');
|
|
87
|
+
expect(panel).toHaveTextContent('NocoBase foundation');
|
|
88
|
+
expect(panel).toHaveTextContent('Freedom above. Confidence below.');
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('shows the action error', () => {
|
|
92
|
+
passwordLoginAction.error = { message: 'Invalid username or password.' };
|
|
93
|
+
renderAt('/', <LoginPage />);
|
|
94
|
+
expect(screen.getByRole('alert')).toHaveTextContent(
|
|
95
|
+
'Invalid username or password.',
|
|
96
|
+
);
|
|
97
|
+
passwordLoginAction.error = undefined;
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('offers sign-up only while the server accepts it', () => {
|
|
101
|
+
signUpAvailable.value = false;
|
|
102
|
+
const { unmount } = renderAt('/', <LoginPage />);
|
|
103
|
+
expect(
|
|
104
|
+
screen.queryByRole('link', { name: 'Sign up' }),
|
|
105
|
+
).not.toBeInTheDocument();
|
|
106
|
+
unmount();
|
|
107
|
+
|
|
108
|
+
signUpAvailable.value = true;
|
|
109
|
+
renderAt('/', <LoginPage />);
|
|
110
|
+
expect(screen.getByRole('link', { name: 'Sign up' })).toHaveAttribute(
|
|
111
|
+
'href',
|
|
112
|
+
'/register',
|
|
113
|
+
);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('sends a visitor back to sign-in while sign-up is off', () => {
|
|
117
|
+
signUpAvailable.value = false;
|
|
118
|
+
renderAt('/register', <RegisterPage />);
|
|
119
|
+
expect(screen.getByText('Login route')).toBeVisible();
|
|
120
|
+
signUpAvailable.value = true;
|
|
121
|
+
});
|
|
122
|
+
});
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// @vitest-environment node
|
|
2
|
+
|
|
3
|
+
import { readFileSync } from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
|
|
7
|
+
import { describe, expect, it } from 'vitest';
|
|
8
|
+
|
|
9
|
+
import clientPlugins from '../../client/plugins.js';
|
|
10
|
+
|
|
11
|
+
const appRoot = fileURLToPath(new URL('../..', import.meta.url));
|
|
12
|
+
|
|
13
|
+
interface AppPackageJson {
|
|
14
|
+
readonly dependencies?: Record<string, string>;
|
|
15
|
+
readonly devDependencies?: Record<string, string>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const appPackage = JSON.parse(
|
|
19
|
+
readFileSync(path.join(appRoot, 'package.json'), 'utf8'),
|
|
20
|
+
) as AppPackageJson;
|
|
21
|
+
|
|
22
|
+
const registeredClientPackages = clientPlugins.plugins.map(
|
|
23
|
+
(plugin) => plugin.packageName,
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
describe('client plugin registry consistency', () => {
|
|
27
|
+
it('declares every client plugin as a dependency', () => {
|
|
28
|
+
const undeclared = registeredClientPackages.filter(
|
|
29
|
+
(packageName) =>
|
|
30
|
+
appPackage.devDependencies?.[packageName] === undefined &&
|
|
31
|
+
appPackage.dependencies?.[packageName] === undefined,
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
expect(undeclared).toEqual([]);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('mounts the standard user and API key pages under Settings', () => {
|
|
38
|
+
for (const [packageName, routePath] of [
|
|
39
|
+
['@nocobase/app-plugin-users', '/users'],
|
|
40
|
+
['@nocobase/app-plugin-api-keys', '/api-keys'],
|
|
41
|
+
]) {
|
|
42
|
+
const plugin = clientPlugins.plugins.find(
|
|
43
|
+
(entry) => entry.packageName === packageName,
|
|
44
|
+
);
|
|
45
|
+
expect(plugin?.routes).toEqual(
|
|
46
|
+
expect.arrayContaining([
|
|
47
|
+
expect.objectContaining({
|
|
48
|
+
parent: 'settings',
|
|
49
|
+
routes: expect.arrayContaining([
|
|
50
|
+
expect.objectContaining({ path: routePath }),
|
|
51
|
+
]),
|
|
52
|
+
}),
|
|
53
|
+
]),
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('registers no package twice', () => {
|
|
59
|
+
expect(new Set(registeredClientPackages).size).toBe(
|
|
60
|
+
registeredClientPackages.length,
|
|
61
|
+
);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AppClientRegisteredRoute,
|
|
3
|
+
AppClientRouteComponentModule,
|
|
4
|
+
} from '@nocobase/app-client/plugins';
|
|
5
|
+
import { act, render, screen } from '@testing-library/react';
|
|
6
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
7
|
+
|
|
8
|
+
import { ClientRoute } from '../../client/routing/client-route.tsx';
|
|
9
|
+
|
|
10
|
+
vi.mock('@nocobase/app-plugin-authorization/client', () => ({
|
|
11
|
+
useCan: () => ({ can: true, isPending: false }),
|
|
12
|
+
}));
|
|
13
|
+
|
|
14
|
+
const route: AppClientRegisteredRoute = {
|
|
15
|
+
id: 'recoverable',
|
|
16
|
+
name: 'recoverable',
|
|
17
|
+
path: '/recoverable',
|
|
18
|
+
auth: 'required',
|
|
19
|
+
authz: 'skip',
|
|
20
|
+
packageName: 'test',
|
|
21
|
+
source: 'application',
|
|
22
|
+
componentLoader: async () => {
|
|
23
|
+
throw new Error('Module unavailable');
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
// Loading a lazy route and rendering its error boundary can take longer than Testing Library's one-second default on
|
|
28
|
+
// a loaded CI runner. A page that never renders still fails, well inside the 30-second test timeout.
|
|
29
|
+
const ROUTE_LOAD_TIMEOUT = { timeout: 10_000 };
|
|
30
|
+
|
|
31
|
+
const healthyModule: AppClientRouteComponentModule = {
|
|
32
|
+
default: () => <h2>Recovered page</h2>,
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
describe('client route loading recovery', () => {
|
|
36
|
+
it('clears a previous failure when the same mounted route loads successfully', async () => {
|
|
37
|
+
const { rerender } = render(<ClientRoute route={route} />);
|
|
38
|
+
expect(
|
|
39
|
+
await screen.findByText('Unable to load page', {}, ROUTE_LOAD_TIMEOUT),
|
|
40
|
+
).toBeVisible();
|
|
41
|
+
|
|
42
|
+
rerender(
|
|
43
|
+
<ClientRoute
|
|
44
|
+
route={{ ...route, componentLoader: async () => healthyModule }}
|
|
45
|
+
/>,
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
expect(
|
|
49
|
+
await screen.findByText('Recovered page', {}, ROUTE_LOAD_TIMEOUT),
|
|
50
|
+
).toBeVisible();
|
|
51
|
+
expect(screen.queryByText('Unable to load page')).not.toBeInTheDocument();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('ignores a superseded loader failure after the replacement succeeds', async () => {
|
|
55
|
+
let rejectLoad!: (error: Error) => void;
|
|
56
|
+
const pending = new Promise<AppClientRouteComponentModule>(
|
|
57
|
+
(_resolve, reject) => {
|
|
58
|
+
rejectLoad = reject;
|
|
59
|
+
},
|
|
60
|
+
);
|
|
61
|
+
const { rerender } = render(
|
|
62
|
+
<ClientRoute route={{ ...route, componentLoader: () => pending }} />,
|
|
63
|
+
);
|
|
64
|
+
rerender(
|
|
65
|
+
<ClientRoute
|
|
66
|
+
route={{ ...route, componentLoader: async () => healthyModule }}
|
|
67
|
+
/>,
|
|
68
|
+
);
|
|
69
|
+
expect(
|
|
70
|
+
await screen.findByText('Recovered page', {}, ROUTE_LOAD_TIMEOUT),
|
|
71
|
+
).toBeVisible();
|
|
72
|
+
|
|
73
|
+
await act(async () => {
|
|
74
|
+
rejectLoad(new Error('Stale failure'));
|
|
75
|
+
await pending.catch(() => undefined);
|
|
76
|
+
});
|
|
77
|
+
expect(screen.getByText('Recovered page')).toBeVisible();
|
|
78
|
+
expect(screen.queryByText('Unable to load page')).not.toBeInTheDocument();
|
|
79
|
+
});
|
|
80
|
+
});
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import {
|
|
2
|
+
resolveAppClientContributions,
|
|
3
|
+
type AppClientRegisteredRoute,
|
|
4
|
+
type AppClientRouteComponentLoader,
|
|
5
|
+
} from '@nocobase/app-client/plugins';
|
|
6
|
+
import { describe, expect, it } from 'vitest';
|
|
7
|
+
|
|
8
|
+
import applicationRoutes from '../../client/routes.ts';
|
|
9
|
+
|
|
10
|
+
describe('app client routes', () => {
|
|
11
|
+
it('keeps the landing page and the authentication pages', () => {
|
|
12
|
+
// The authentication plugin and this application have to agree on these paths: it sends an unknown visitor to
|
|
13
|
+
// /login, sends a signed-in user who opens a guest page back to /, and mails a reset link to /reset-password,
|
|
14
|
+
// while the sign-in form links to /register and /forgot-password. Only their presence is asserted, so a page the
|
|
15
|
+
// application adds is not a defect. Whether these four are guest pages is not checked here either: app-client
|
|
16
|
+
// refuses any route that claims one of those paths without `auth: 'guest'`.
|
|
17
|
+
expect(pagePaths(resolveRoutes().routes)).toEqual(
|
|
18
|
+
expect.arrayContaining([
|
|
19
|
+
'/',
|
|
20
|
+
'/login',
|
|
21
|
+
'/register',
|
|
22
|
+
'/forgot-password',
|
|
23
|
+
'/reset-password',
|
|
24
|
+
// The page `deviceAuthorization()`'s verificationUri names, where a CLI's sign-in is approved.
|
|
25
|
+
'/device',
|
|
26
|
+
]),
|
|
27
|
+
);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('loads every page component', async () => {
|
|
31
|
+
const resolved = resolveRoutes();
|
|
32
|
+
const loaders = [
|
|
33
|
+
...componentLoadersIn(resolved.routes),
|
|
34
|
+
...componentLoadersIn(resolved.settingsRouteTree),
|
|
35
|
+
...componentLoadersIn(resolved.devRouteTree),
|
|
36
|
+
];
|
|
37
|
+
// The trees above are filtered by loader, so an empty list would make the loop below pass without loading
|
|
38
|
+
// anything at all.
|
|
39
|
+
expect(loaders).not.toHaveLength(0);
|
|
40
|
+
|
|
41
|
+
for (const componentLoader of loaders) {
|
|
42
|
+
// The registered loader is already the wrapped one, so awaiting it holds every page to the contract that its
|
|
43
|
+
// module default-exports a component. A page that moved or lost its default export fails here.
|
|
44
|
+
await expect(componentLoader()).resolves.toMatchObject({
|
|
45
|
+
default: expect.any(Function),
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('pins the page authorization of every signed-in page', () => {
|
|
51
|
+
// A stored page grant records the page's `authz` resource id (`authorizedAs`), not its route `name`: changing an
|
|
52
|
+
// id is a data change that has to migrate the grants that reference it, not a refactor — so changing this list
|
|
53
|
+
// deliberately is the point. A new page that requires sign-in adds an entry here, because a page that checks
|
|
54
|
+
// access is a new grant somebody has to be given.
|
|
55
|
+
const resolved = resolveRoutes();
|
|
56
|
+
|
|
57
|
+
// The landing page and the inbox opted out of page authorization, so they are reachable by every signed-in user.
|
|
58
|
+
expect(pageAuthorizations(resolved.routes)).toEqual([
|
|
59
|
+
{ name: 'home', authorizedAs: null },
|
|
60
|
+
{ name: 'inbox', authorizedAs: null },
|
|
61
|
+
]);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('keeps the inbox out of the application navigation', () => {
|
|
65
|
+
// The inbox is reached from the header's inbox button, so it deliberately declares no menu entry.
|
|
66
|
+
expect(
|
|
67
|
+
applicationRoutes[0].routes.find((route) => route.name === 'inbox'),
|
|
68
|
+
).not.toHaveProperty('navigation');
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
/** This application's own contribution, registered the way the client runtime registers it. */
|
|
73
|
+
function resolveRoutes() {
|
|
74
|
+
return resolveAppClientContributions([
|
|
75
|
+
{
|
|
76
|
+
packageName: '@nocobase/app-template-default',
|
|
77
|
+
routes: applicationRoutes,
|
|
78
|
+
source: 'application',
|
|
79
|
+
},
|
|
80
|
+
]);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The paths of the pages a route tree registers. A menu group names no component, so it carries no path of its own
|
|
85
|
+
* and inherits its parent's — filtering on `componentLoader` is what keeps that inherited path out of the list.
|
|
86
|
+
*/
|
|
87
|
+
function pagePaths(routes: readonly AppClientRegisteredRoute[]): string[] {
|
|
88
|
+
return routes.flatMap((route) => [
|
|
89
|
+
...(route.componentLoader ? [route.path] : []),
|
|
90
|
+
...pagePaths(route.children ?? []),
|
|
91
|
+
]);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Every page loader in a tree, at any depth. */
|
|
95
|
+
function componentLoadersIn(
|
|
96
|
+
routes: readonly AppClientRegisteredRoute[],
|
|
97
|
+
): AppClientRouteComponentLoader[] {
|
|
98
|
+
return routes.flatMap((route) => [
|
|
99
|
+
...(route.componentLoader ? [route.componentLoader] : []),
|
|
100
|
+
...componentLoadersIn(route.children ?? []),
|
|
101
|
+
]);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Page authorization comes directly from the registered tree. */
|
|
105
|
+
function pageAuthorizations(
|
|
106
|
+
routes: readonly AppClientRegisteredRoute[],
|
|
107
|
+
): { name: string; authorizedAs: string | null }[] {
|
|
108
|
+
return routes.flatMap((route) => [
|
|
109
|
+
...(route.componentLoader && route.auth === 'required'
|
|
110
|
+
? [
|
|
111
|
+
{
|
|
112
|
+
name: route.name,
|
|
113
|
+
authorizedAs:
|
|
114
|
+
route.authz === 'skip'
|
|
115
|
+
? null
|
|
116
|
+
: route.authz === 'unrestricted'
|
|
117
|
+
? 'unrestricted'
|
|
118
|
+
: route.authz.resource.type === 'page'
|
|
119
|
+
? route.authz.resource.id
|
|
120
|
+
: `${route.authz.resource.type}:${route.authz.resource.id}`,
|
|
121
|
+
},
|
|
122
|
+
]
|
|
123
|
+
: []),
|
|
124
|
+
...pageAuthorizations(route.children ?? []),
|
|
125
|
+
]);
|
|
126
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { createApp } from '../../client/app.js';
|
|
2
|
+
import { ServiceProvider } from '@nocobase/service-provider';
|
|
3
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
4
|
+
|
|
5
|
+
import { ClientApplication, toasterToken } from '@nocobase/app-client';
|
|
6
|
+
import {
|
|
7
|
+
createAppClientConfig,
|
|
8
|
+
defineAppClientRenderConfig,
|
|
9
|
+
defineClientPlugins,
|
|
10
|
+
} from '@nocobase/app-client';
|
|
11
|
+
import {
|
|
12
|
+
defineAppRuntime,
|
|
13
|
+
resolveAppRuntime,
|
|
14
|
+
type AppRuntimeDefinition,
|
|
15
|
+
} from '@nocobase/app-client/runtime';
|
|
16
|
+
import type { ClientApplication as ClientApplicationType } from '@nocobase/app-client';
|
|
17
|
+
|
|
18
|
+
import appRuntime from '../../client/runtime.ts';
|
|
19
|
+
|
|
20
|
+
describe('app client runtime', () => {
|
|
21
|
+
it('resolves static contributions without running ServiceProvider lifecycle', async () => {
|
|
22
|
+
const calls: string[] = [];
|
|
23
|
+
class Provider extends ServiceProvider<ClientApplicationType> {
|
|
24
|
+
public readonly name: string = '@example/provider';
|
|
25
|
+
|
|
26
|
+
public override boot(): Promise<void> {
|
|
27
|
+
calls.push('boot');
|
|
28
|
+
return Promise.resolve();
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
const runtime = await resolveAppRuntime(
|
|
32
|
+
defineAppRuntime({
|
|
33
|
+
packageName: '@example/app',
|
|
34
|
+
createAppConfig: createAppClientConfig,
|
|
35
|
+
serviceProviders: [Provider],
|
|
36
|
+
reactProviders: [],
|
|
37
|
+
routes: [],
|
|
38
|
+
plugins: defineClientPlugins([]),
|
|
39
|
+
}),
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
expect(runtime.serviceProviders[0]).toMatchObject({
|
|
43
|
+
Provider,
|
|
44
|
+
context: { packageName: '@example/app', source: 'application' },
|
|
45
|
+
});
|
|
46
|
+
expect(runtime.reactProviders).toEqual([]);
|
|
47
|
+
expect(calls).toEqual([]);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('runs provider lifecycle and exposes Refine config through ClientApplication', async () => {
|
|
51
|
+
const authProvider = {
|
|
52
|
+
check: vi.fn(),
|
|
53
|
+
getIdentity: vi.fn(),
|
|
54
|
+
login: vi.fn(),
|
|
55
|
+
logout: vi.fn(),
|
|
56
|
+
onError: vi.fn(),
|
|
57
|
+
};
|
|
58
|
+
class Provider extends ServiceProvider<ClientApplicationType> {
|
|
59
|
+
public readonly name: string = '@example/provider';
|
|
60
|
+
|
|
61
|
+
public override boot(): Promise<void> {
|
|
62
|
+
this.app.refine.setAuthProvider(authProvider);
|
|
63
|
+
return Promise.resolve();
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
const runtime = await resolveAppRuntime(
|
|
67
|
+
defineAppRuntime({
|
|
68
|
+
packageName: '@example/app',
|
|
69
|
+
createAppConfig: createAppClientConfig,
|
|
70
|
+
serviceProviders: [Provider],
|
|
71
|
+
plugins: defineClientPlugins([]),
|
|
72
|
+
}),
|
|
73
|
+
);
|
|
74
|
+
const app = new ClientApplication({
|
|
75
|
+
runtime,
|
|
76
|
+
createRenderConfig: () => defineAppClientRenderConfig({ routes: null }),
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
await app.start();
|
|
80
|
+
expect(app.refineConfig.authProvider).toBe(authProvider);
|
|
81
|
+
await app.shutdown();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('uses the default template Application and static plugin declarations', async () => {
|
|
85
|
+
const runtime = await resolveAppRuntime(appRuntime, {
|
|
86
|
+
rawConfig: { app: { title: 'Configured application' } },
|
|
87
|
+
});
|
|
88
|
+
const app = createApp(runtime);
|
|
89
|
+
|
|
90
|
+
expect(app).toBeInstanceOf(ClientApplication);
|
|
91
|
+
await expect(app.start()).resolves.toBeUndefined();
|
|
92
|
+
expect(app.config.get('app.title')).toBe('Configured application');
|
|
93
|
+
expect(app.refineConfig.options?.title).toEqual({
|
|
94
|
+
text: 'Configured application',
|
|
95
|
+
});
|
|
96
|
+
// Plugins and pages report through the registered toaster, and the mounted Toaster renders what it forwards.
|
|
97
|
+
// Without either half, toasts disappear.
|
|
98
|
+
expect(app.services.has(toasterToken)).toBe(true);
|
|
99
|
+
expect(runtime.reactProviders).toContainEqual(
|
|
100
|
+
expect.objectContaining({ name: 'toaster', layer: 'application' }),
|
|
101
|
+
);
|
|
102
|
+
expect(app.refineConfig.resources ?? []).toEqual([]);
|
|
103
|
+
await app.shutdown();
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('requires a Client config factory in the breaking static Runtime protocol', async () => {
|
|
107
|
+
const { createAppConfig: _createAppConfig, ...withoutConfig } = appRuntime;
|
|
108
|
+
await expect(
|
|
109
|
+
resolveAppRuntime(withoutConfig as AppRuntimeDefinition),
|
|
110
|
+
).rejects.toThrow();
|
|
111
|
+
});
|
|
112
|
+
});
|