@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,204 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ClientApplicationContext,
|
|
3
|
+
type ClientApplication,
|
|
4
|
+
} from '@nocobase/app-client';
|
|
5
|
+
import type { AppClientRegisteredRoute } from '@nocobase/app-client/plugins';
|
|
6
|
+
import {
|
|
7
|
+
AuthorizationClient,
|
|
8
|
+
authorizationClientToken,
|
|
9
|
+
} from '@nocobase/app-plugin-authorization/client';
|
|
10
|
+
import { reactProviders } from '@nocobase/app-plugin-authorization/client/react-providers';
|
|
11
|
+
import { ServiceContainer } from '@nocobase/service-provider';
|
|
12
|
+
import {
|
|
13
|
+
TestI18nProvider,
|
|
14
|
+
createTestI18nRuntime,
|
|
15
|
+
} from '@nocobase/i18n/testing';
|
|
16
|
+
import { act, render, screen, waitFor } from '@testing-library/react';
|
|
17
|
+
import type { ReactNode } from 'react';
|
|
18
|
+
import { MemoryRouter } from 'react-router';
|
|
19
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
20
|
+
import { ClientRoute } from '../../client/routing/client-route.js';
|
|
21
|
+
import { useRouteNavigation } from '../../client/routing/route-navigation.js';
|
|
22
|
+
import enUS from '../../client/locales/en-US.js';
|
|
23
|
+
|
|
24
|
+
const AuthorizationProvider = reactProviders[0].component;
|
|
25
|
+
|
|
26
|
+
const runtime = await createTestI18nRuntime({
|
|
27
|
+
application: { namespace: '@nocobase/app-template-default', resources: enUS },
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
function I18n({ children }: { readonly children: ReactNode }) {
|
|
31
|
+
return <TestI18nProvider runtime={runtime}>{children}</TestI18nProvider>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const authentication = vi.hoisted(() => ({
|
|
35
|
+
session: null as null | { user: { id: string }; session: { id: string } },
|
|
36
|
+
isPending: false,
|
|
37
|
+
}));
|
|
38
|
+
vi.mock('@nocobase/app-plugin-authentication/client', () => ({
|
|
39
|
+
useAuthentication: () => authentication,
|
|
40
|
+
}));
|
|
41
|
+
const routes: AppClientRegisteredRoute[] = ['apps', 'users'].map((name) => ({
|
|
42
|
+
id: name,
|
|
43
|
+
name,
|
|
44
|
+
path: `/${name}`,
|
|
45
|
+
auth: 'required',
|
|
46
|
+
authz: { resource: { type: 'page', id: name }, action: 'access' },
|
|
47
|
+
packageName: 'test',
|
|
48
|
+
source: 'application',
|
|
49
|
+
navigation: { title: name },
|
|
50
|
+
componentLoader: async () => ({ default: () => <div>{name} content</div> }),
|
|
51
|
+
}));
|
|
52
|
+
const permissions = (admin: boolean) => ({
|
|
53
|
+
data: {
|
|
54
|
+
permissions: (admin ? ['apps', 'users'] : ['apps']).map((id) => ({
|
|
55
|
+
resource: { type: 'page', id },
|
|
56
|
+
actions: ['access'],
|
|
57
|
+
})),
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
function Menu() {
|
|
61
|
+
const { items, loading } = useRouteNavigation(routes);
|
|
62
|
+
return (
|
|
63
|
+
<nav aria-label='Pages'>
|
|
64
|
+
{loading
|
|
65
|
+
? 'Checking'
|
|
66
|
+
: items.map(({ route }) => <span key={route.id}>{route.name}</span>)}
|
|
67
|
+
</nav>
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
function session(user: string, id = user) {
|
|
71
|
+
authentication.session = { user: { id: user }, session: { id } };
|
|
72
|
+
}
|
|
73
|
+
function setup(
|
|
74
|
+
request = vi
|
|
75
|
+
.fn()
|
|
76
|
+
.mockImplementation(() =>
|
|
77
|
+
Promise.resolve(permissions(authentication.session?.user.id === 'admin')),
|
|
78
|
+
),
|
|
79
|
+
) {
|
|
80
|
+
const client = new AuthorizationClient({ request } as never);
|
|
81
|
+
const container = new ServiceContainer();
|
|
82
|
+
container.instance(authorizationClientToken, client);
|
|
83
|
+
const app = { services: container } as unknown as ClientApplication;
|
|
84
|
+
const tree = () => (
|
|
85
|
+
<ClientApplicationContext.Provider value={app}>
|
|
86
|
+
<MemoryRouter>
|
|
87
|
+
<AuthorizationProvider>
|
|
88
|
+
<Menu />
|
|
89
|
+
<ClientRoute route={routes[1]} />
|
|
90
|
+
</AuthorizationProvider>
|
|
91
|
+
</MemoryRouter>
|
|
92
|
+
</ClientApplicationContext.Provider>
|
|
93
|
+
);
|
|
94
|
+
const view = render(tree(), { wrapper: I18n });
|
|
95
|
+
return { client, request, rerender: () => view.rerender(tree()) };
|
|
96
|
+
}
|
|
97
|
+
async function expectMenu(admin: boolean) {
|
|
98
|
+
await waitFor(() =>
|
|
99
|
+
expect(screen.getByRole('navigation', { name: 'Pages' }).textContent).toBe(
|
|
100
|
+
admin ? 'appsusers' : 'apps',
|
|
101
|
+
),
|
|
102
|
+
);
|
|
103
|
+
if (admin)
|
|
104
|
+
await waitFor(() =>
|
|
105
|
+
expect(screen.getByText('users content')).toBeInTheDocument(),
|
|
106
|
+
);
|
|
107
|
+
else {
|
|
108
|
+
await waitFor(() =>
|
|
109
|
+
expect(screen.getByText('Access denied')).toBeInTheDocument(),
|
|
110
|
+
);
|
|
111
|
+
expect(screen.queryByText('users content')).not.toBeInTheDocument();
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
describe('account permission changes without a browser reload', () => {
|
|
116
|
+
beforeEach(() => {
|
|
117
|
+
authentication.session = null;
|
|
118
|
+
authentication.isPending = false;
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('updates menus and cached route guards from admin to operator and back', async () => {
|
|
122
|
+
session('admin');
|
|
123
|
+
const view = setup();
|
|
124
|
+
await expectMenu(true);
|
|
125
|
+
session('operator');
|
|
126
|
+
view.rerender();
|
|
127
|
+
expect(screen.queryByText('users content')).not.toBeInTheDocument();
|
|
128
|
+
await expectMenu(false);
|
|
129
|
+
session('admin');
|
|
130
|
+
view.rerender();
|
|
131
|
+
await expectMenu(true);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('clears permissions on logout and same-user re-login with a new session', async () => {
|
|
135
|
+
session('admin', 'first');
|
|
136
|
+
const view = setup();
|
|
137
|
+
await expectMenu(true);
|
|
138
|
+
authentication.isPending = true;
|
|
139
|
+
view.rerender();
|
|
140
|
+
expect(screen.queryByRole('navigation')).not.toBeInTheDocument();
|
|
141
|
+
authentication.session = null;
|
|
142
|
+
authentication.isPending = false;
|
|
143
|
+
view.rerender();
|
|
144
|
+
await expectMenu(false);
|
|
145
|
+
view.request.mockResolvedValue(permissions(false));
|
|
146
|
+
session('admin', 'second');
|
|
147
|
+
view.rerender();
|
|
148
|
+
await expectMenu(false);
|
|
149
|
+
expect(view.request).toHaveBeenCalledTimes(3);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('updates a mounted menu and page when permissions change', async () => {
|
|
153
|
+
session('admin');
|
|
154
|
+
const view = setup();
|
|
155
|
+
await expectMenu(true);
|
|
156
|
+
view.request.mockResolvedValue(permissions(false));
|
|
157
|
+
act(() => view.client.invalidate());
|
|
158
|
+
expect(screen.queryByText('users content')).not.toBeInTheDocument();
|
|
159
|
+
await expectMenu(false);
|
|
160
|
+
view.request.mockResolvedValue(permissions(true));
|
|
161
|
+
act(() => view.client.invalidate());
|
|
162
|
+
await expectMenu(true);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('hides previously allowed pages when revalidation fails and recovers on retry', async () => {
|
|
166
|
+
session('admin');
|
|
167
|
+
const view = setup();
|
|
168
|
+
await expectMenu(true);
|
|
169
|
+
view.request.mockRejectedValue(new Error('Offline'));
|
|
170
|
+
act(() => view.client.invalidate());
|
|
171
|
+
await waitFor(() =>
|
|
172
|
+
expect(
|
|
173
|
+
screen.getByRole('navigation', { name: 'Pages' }).textContent,
|
|
174
|
+
).toBe(''),
|
|
175
|
+
);
|
|
176
|
+
await waitFor(() =>
|
|
177
|
+
expect(screen.getByText('Access denied')).toBeInTheDocument(),
|
|
178
|
+
);
|
|
179
|
+
expect(screen.queryByText('users content')).not.toBeInTheDocument();
|
|
180
|
+
view.request.mockResolvedValue(permissions(true));
|
|
181
|
+
act(() => view.client.invalidate());
|
|
182
|
+
await expectMenu(true);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it('does not restore admin menus when an old request returns after switching accounts', async () => {
|
|
186
|
+
const old = Promise.withResolvers<ReturnType<typeof permissions>>();
|
|
187
|
+
const request = vi
|
|
188
|
+
.fn()
|
|
189
|
+
.mockReturnValueOnce(old.promise)
|
|
190
|
+
.mockResolvedValue(permissions(false));
|
|
191
|
+
session('admin');
|
|
192
|
+
const view = setup(request);
|
|
193
|
+
await waitFor(() => expect(request).toHaveBeenCalledTimes(1));
|
|
194
|
+
session('operator');
|
|
195
|
+
view.rerender();
|
|
196
|
+
await expectMenu(false);
|
|
197
|
+
await act(async () => {
|
|
198
|
+
old.resolve(permissions(true));
|
|
199
|
+
await old.promise;
|
|
200
|
+
});
|
|
201
|
+
await expectMenu(false);
|
|
202
|
+
expect(request).toHaveBeenCalledTimes(2);
|
|
203
|
+
});
|
|
204
|
+
});
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// @vitest-environment node
|
|
2
|
+
|
|
3
|
+
import fs 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 enUS from '../../client/locales/en-US.ts';
|
|
10
|
+
import zhCN from '../../client/locales/zh-CN.ts';
|
|
11
|
+
|
|
12
|
+
const clientDirectory = fileURLToPath(new URL('../../client', import.meta.url));
|
|
13
|
+
|
|
14
|
+
/** Every addressable key of a locale, as the dot-separated path `t()` is called with. */
|
|
15
|
+
function translationKeys(
|
|
16
|
+
resource: Record<string, unknown>,
|
|
17
|
+
prefix = '',
|
|
18
|
+
): string[] {
|
|
19
|
+
return Object.entries(resource).flatMap(([key, value]) => {
|
|
20
|
+
if (!prefix && key === 'overrides') return [];
|
|
21
|
+
const keyPath = prefix ? prefix + '.' + key : key;
|
|
22
|
+
return value && typeof value === 'object'
|
|
23
|
+
? translationKeys(value as Record<string, unknown>, keyPath)
|
|
24
|
+
: [keyPath.replace(/_(?:zero|one|two|few|many|other)$/u, '')];
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function sourceFiles(directory: string): string[] {
|
|
29
|
+
return fs.readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
|
|
30
|
+
const entryPath = path.join(directory, entry.name);
|
|
31
|
+
if (entry.isDirectory()) return sourceFiles(entryPath);
|
|
32
|
+
return /\.tsx?$/u.test(entry.name) &&
|
|
33
|
+
!entryPath.includes(`${path.sep}locales${path.sep}`)
|
|
34
|
+
? [entryPath]
|
|
35
|
+
: [];
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const english = new Set(translationKeys(enUS as Record<string, unknown>));
|
|
40
|
+
const chinese = new Set(translationKeys(zhCN as Record<string, unknown>));
|
|
41
|
+
/** The literal first argument of `t()`: `t('projects.title')`, but not the prefix of `t('appearance.' + mode)`. */
|
|
42
|
+
const calledKey = /\bt\(\s*'([^'$]+)'(?!\s*\+)/gu;
|
|
43
|
+
/** A key completed by concatenation, `t('appearance.' + mode)`: only its prefix is static. */
|
|
44
|
+
const concatenatedKey = /\bt\(\s*'([^']+)'\s*\+/gu;
|
|
45
|
+
/** A key a route or registry names for later translation: `navigation: { title: 'navigation.projects' }`. */
|
|
46
|
+
const namedKey =
|
|
47
|
+
/\b(?:title|labelKey):\s*'([a-zA-Z][a-zA-Z0-9]*(?:\.[a-zA-Z0-9]+)+)'/gu;
|
|
48
|
+
/** A key the code completes at runtime, such as ``t(`projects.status.${status}`)``: only its prefix is static. */
|
|
49
|
+
const interpolatedKey = /\bt\(\s*\x60([^\x60]*?)\x24\{/gu;
|
|
50
|
+
/** `useTranslation('@nocobase/app-plugin-file')`: the file translates in a plugin's namespace, not the application's. */
|
|
51
|
+
const pluginNamespace = /\buseTranslation\(\s*'/u;
|
|
52
|
+
|
|
53
|
+
interface References {
|
|
54
|
+
readonly file: string;
|
|
55
|
+
readonly keys: readonly string[];
|
|
56
|
+
readonly prefixes: readonly string[];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The keys and key prefixes one source file references. Comment lines are skipped, and the rest is scanned as one
|
|
61
|
+
* text, so a `t(` call that Prettier wrapped onto several lines is still read.
|
|
62
|
+
*/
|
|
63
|
+
function scan(source: string): {
|
|
64
|
+
readonly keys: Set<string>;
|
|
65
|
+
readonly prefixes: Set<string>;
|
|
66
|
+
} {
|
|
67
|
+
const code = source
|
|
68
|
+
.split('\n')
|
|
69
|
+
.filter((line) => !/^\s*(?:\/\/|\*)/u.test(line))
|
|
70
|
+
.join('\n');
|
|
71
|
+
const keys = new Set<string>();
|
|
72
|
+
const prefixes = new Set<string>();
|
|
73
|
+
for (const match of code.matchAll(calledKey)) keys.add(match[1]);
|
|
74
|
+
for (const match of code.matchAll(namedKey)) keys.add(match[1]);
|
|
75
|
+
for (const match of code.matchAll(interpolatedKey)) prefixes.add(match[1]);
|
|
76
|
+
for (const match of code.matchAll(concatenatedKey)) prefixes.add(match[1]);
|
|
77
|
+
return { keys, prefixes };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function referencedKeys(): References[] {
|
|
81
|
+
return sourceFiles(clientDirectory).flatMap((file) => {
|
|
82
|
+
const source = fs.readFileSync(file, 'utf8');
|
|
83
|
+
if (pluginNamespace.test(source)) return [];
|
|
84
|
+
const { keys, prefixes } = scan(source);
|
|
85
|
+
return [
|
|
86
|
+
{
|
|
87
|
+
file: path.relative(clientDirectory, file),
|
|
88
|
+
keys: [...keys].sort(),
|
|
89
|
+
prefixes: [...prefixes].sort(),
|
|
90
|
+
},
|
|
91
|
+
];
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
describe('application translations', () => {
|
|
96
|
+
const references = referencedKeys();
|
|
97
|
+
|
|
98
|
+
const missing = (written: ReadonlySet<string>): string[] =>
|
|
99
|
+
references.flatMap(({ file, keys }) =>
|
|
100
|
+
keys
|
|
101
|
+
.filter((key) => !written.has(key))
|
|
102
|
+
.map((key) => key + ' (' + file + ')'),
|
|
103
|
+
);
|
|
104
|
+
const missingFamilies = (written: ReadonlySet<string>): string[] =>
|
|
105
|
+
references.flatMap(({ file, prefixes }) =>
|
|
106
|
+
prefixes
|
|
107
|
+
.filter((prefix) => ![...written].some((key) => key.startsWith(prefix)))
|
|
108
|
+
.map((prefix) => prefix + '… (' + file + ')'),
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
it('reads the keys the application uses', () => {
|
|
112
|
+
// Guards the scan itself: a regex that quietly stops matching would turn this suite into a no-op.
|
|
113
|
+
expect(
|
|
114
|
+
references.reduce((total, { keys }) => total + keys.length, 0),
|
|
115
|
+
).toBeGreaterThan(10);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('reads a t() call that Prettier wrapped onto several lines', () => {
|
|
119
|
+
expect([
|
|
120
|
+
...scan("t(\n 'projects.delete.title',\n { name },\n)").keys,
|
|
121
|
+
]).toEqual(['projects.delete.title']);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('translates every key the application uses into English', () => {
|
|
125
|
+
expect(missing(english)).toEqual([]);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('translates every key the application uses into Chinese', () => {
|
|
129
|
+
expect(missing(chinese)).toEqual([]);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('translates the key families the application completes at runtime', () => {
|
|
133
|
+
expect(missingFamilies(english)).toEqual([]);
|
|
134
|
+
expect(missingFamilies(chinese)).toEqual([]);
|
|
135
|
+
});
|
|
136
|
+
});
|