@nocobase/app-template-default 1.0.0-beta.87 → 1.0.0-beta.89
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/.agents/rules/components.md +3 -1
- package/.agents/rules/notification-templates.md +5 -0
- package/.agents/rules/shell.md +9 -3
- package/AGENTS.md +4 -2
- package/README.MD +11 -5
- package/client/components/back-button.tsx +37 -12
- package/client/components/body-portal.tsx +25 -0
- package/client/components/brand-loading-mark.tsx +5 -2
- package/client/components/brand-mark-geometry.ts +3 -3
- package/client/components/breadcrumbs.tsx +60 -3
- package/client/components/data-table/data-table.tsx +90 -19
- package/client/components/data-table/pagination.tsx +17 -2
- package/client/components/flow-diagram.tsx +2 -2
- package/client/components/list-feedback.tsx +31 -22
- package/client/components/loading.tsx +127 -16
- package/client/components/pending-button.tsx +111 -0
- package/client/components/role-list.tsx +7 -0
- package/client/components/route-child-page.tsx +72 -1
- package/client/components/route-overlay.tsx +149 -15
- package/client/components/task-progress.tsx +155 -0
- package/client/components/use-route-overlay.ts +8 -11
- package/client/extensions/nocobase-file-component-ui/components/file-upload-field.tsx +62 -32
- package/client/extensions/nocobase-file-component-ui/locales/de-DE.ts +2 -0
- package/client/extensions/nocobase-file-component-ui/locales/en-US.ts +1 -0
- package/client/extensions/nocobase-file-component-ui/locales/es-ES.ts +1 -0
- package/client/extensions/nocobase-file-component-ui/locales/fr-FR.ts +1 -0
- package/client/extensions/nocobase-file-component-ui/locales/id-ID.ts +1 -0
- package/client/extensions/nocobase-file-component-ui/locales/ja-JP.ts +2 -0
- package/client/extensions/nocobase-file-component-ui/locales/pt-BR.ts +1 -0
- package/client/extensions/nocobase-file-component-ui/locales/vi-VN.ts +1 -0
- package/client/extensions/nocobase-file-component-ui/locales/zh-CN.ts +1 -0
- package/client/extensions/nocobase-file-component-ui/locales/zh-TW.ts +1 -0
- package/client/extensions/nocobase-workflow-management/README.md +5 -5
- package/client/extensions/nocobase-workflow-management/definition-list.tsx +2 -9
- package/client/extensions/nocobase-workflow-management/execution-list.tsx +2 -4
- package/client/layouts/app-layout.tsx +8 -8
- package/client/layouts/components/sidebar-footer.tsx +18 -25
- package/client/lib/navigation-transform.ts +156 -22
- package/client/locales/system/en-US.ts +26 -5
- package/client/locales/system/settings-en-US.ts +17 -0
- package/client/locales/system/settings-zh-CN.ts +15 -0
- package/client/locales/system/zh-CN.ts +26 -5
- package/client/pages/auth/register.tsx +39 -22
- package/client/pages/auth/shared.tsx +1 -3
- package/client/pages/settings/default-access.tsx +1 -1
- package/client/pages/settings/hooks.tsx +3 -68
- package/client/pages/settings/notification-routes.ts +109 -0
- package/client/pages/settings/notification-templates.tsx +26 -0
- package/client/pages/settings/permission-management-page.tsx +15 -90
- package/client/pages/settings/roles.tsx +27 -27
- package/client/pages/settings/routes.ts +107 -101
- package/client/pages/settings/settings-home.tsx +109 -0
- package/client/pages/settings/settings-module-page.tsx +158 -0
- package/client/pages/settings/shell-routes.ts +53 -0
- package/client/pages/settings/user-detail.tsx +3 -17
- package/client/pages/settings/user-form.tsx +9 -15
- package/client/pages/settings/users.tsx +11 -27
- package/client/routes.ts +17 -1
- package/client/routing/app-router.tsx +1 -14
- package/client/routing/client-route.tsx +199 -30
- package/client/routing/navigation-destination.ts +42 -0
- package/client/routing/route-context.tsx +30 -3
- package/client/routing/route-leave-boundary.tsx +261 -0
- package/client/routing/route-navigation.ts +72 -8
- package/client/routing/route-tree.tsx +7 -12
- package/client/runtime.ts +2 -2
- package/client/styles.css +18 -1
- package/config.example.yml +1 -1
- package/package.json +13 -13
- package/server/config/auth.ts +1 -1
- package/server/providers/index.ts +2 -0
- package/server/providers/notification-management.ts +27 -0
- package/tests/client/account-permissions.test.tsx +1 -1
- package/tests/client/auth-i18n.test.tsx +1 -1
- package/tests/client/authenticated-client-fixture.test.tsx +37 -0
- package/tests/client/back-button.test.tsx +34 -0
- package/tests/client/client-auth.test.tsx +10 -1
- package/tests/client/client-route-recovery.test.tsx +93 -1
- package/tests/client/client-routes.test.ts +137 -4
- package/tests/client/client-shell.test.tsx +23 -8
- package/tests/client/foundation-integration.test.ts +38 -17
- package/tests/client/layout-i18n.test.tsx +9 -4
- package/tests/client/list-feedback.test.tsx +5 -5
- package/tests/client/list-loading.test.tsx +397 -0
- package/tests/client/loading.test.tsx +20 -11
- package/tests/client/manual-list-retry.test.tsx +2 -2
- package/tests/client/navigation-parent.test.tsx +172 -0
- package/tests/client/pending-button.test.tsx +106 -0
- package/tests/client/permission-management-tabs.test.tsx +458 -17
- package/tests/client/primitive-labels.test.tsx +2 -2
- package/tests/client/registration-feedback.test.tsx +143 -0
- package/tests/client/role-return.test.tsx +21 -3
- package/tests/client/roles.test.tsx +84 -3
- package/tests/client/route-leave-recheck.test.tsx +74 -0
- package/tests/client/route-presentation.test.tsx +557 -0
- package/tests/client/session-preparation.test.tsx +20 -17
- package/tests/client/settings-continuity.test.tsx +35 -11
- package/tests/client/settings-home.test.tsx +112 -0
- package/tests/client/settings-idle-list.test.tsx +124 -0
- package/tests/client/settings-navigation.test.ts +230 -0
- package/tests/client/user-drawer.test.tsx +3 -4
- package/tests/fixtures/dialog-footer.html +12 -0
- package/tests/fixtures/dialog-footer.tsx +215 -0
- package/tests/helpers/authenticated-client.tsx +70 -22
- package/tests/playwright/dialog-footer.test.ts +227 -0
- package/tests/server/notification-templates.test.ts +144 -0
- package/tests/client/permission-management-routes.test.ts +0 -64
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import { createRoot } from 'react-dom/client';
|
|
3
|
+
import { createMemoryRouter, Link, Outlet, RouterProvider } from 'react-router';
|
|
4
|
+
import {
|
|
5
|
+
createTestI18nRuntime,
|
|
6
|
+
TestI18nProvider,
|
|
7
|
+
} from '@nocobase/i18n/testing';
|
|
8
|
+
import { Button } from '../../client/components/ui/button';
|
|
9
|
+
import { Input } from '../../client/components/ui/input';
|
|
10
|
+
import { Spinner } from '../../client/components/ui/spinner';
|
|
11
|
+
import {
|
|
12
|
+
Dialog,
|
|
13
|
+
DialogClose,
|
|
14
|
+
DialogContent,
|
|
15
|
+
DialogDescription,
|
|
16
|
+
DialogFooter,
|
|
17
|
+
DialogHeader,
|
|
18
|
+
DialogTitle,
|
|
19
|
+
} from '../../client/components/ui/dialog';
|
|
20
|
+
import {
|
|
21
|
+
AlertDialog,
|
|
22
|
+
AlertDialogAction,
|
|
23
|
+
AlertDialogCancel,
|
|
24
|
+
AlertDialogContent,
|
|
25
|
+
AlertDialogDescription,
|
|
26
|
+
AlertDialogFooter,
|
|
27
|
+
AlertDialogHeader,
|
|
28
|
+
AlertDialogTitle,
|
|
29
|
+
} from '../../client/components/ui/alert-dialog';
|
|
30
|
+
import { RouteDialog } from '../../client/components/route-dialog';
|
|
31
|
+
import { RouteDrawer } from '../../client/components/route-drawer';
|
|
32
|
+
import enUS from '../../client/locales/en-US';
|
|
33
|
+
import '../../client/styles.css';
|
|
34
|
+
|
|
35
|
+
// Exercise installed primitives, portals, links and route footers with the real
|
|
36
|
+
// application stylesheet. No backend or authentication is needed for this fixture.
|
|
37
|
+
const params = new URLSearchParams(location.search);
|
|
38
|
+
const kind = params.get('kind') ?? 'dialog';
|
|
39
|
+
const chinese = params.has('chinese');
|
|
40
|
+
const long = params.has('long');
|
|
41
|
+
const pending = params.has('pending');
|
|
42
|
+
const horizontal = params.has('horizontal');
|
|
43
|
+
document.documentElement.dataset.theme = params.get('preset') ?? 'default';
|
|
44
|
+
document.documentElement.classList.toggle('dark', params.has('dark'));
|
|
45
|
+
const runtime = await createTestI18nRuntime({
|
|
46
|
+
application: { namespace: '@nocobase/app-template-default', resources: enUS },
|
|
47
|
+
});
|
|
48
|
+
const cancel = chinese ? '取消并返回实例列表' : 'Cancel';
|
|
49
|
+
const submit = chinese
|
|
50
|
+
? '确认创建这个具有较长中文名称的应用实例并继续配置'
|
|
51
|
+
: long
|
|
52
|
+
? 'Create this application instance and continue configuring its workspace'
|
|
53
|
+
: 'Create instance';
|
|
54
|
+
const title = chinese ? '创建应用实例' : 'Create application instance';
|
|
55
|
+
const description = chinese
|
|
56
|
+
? '输入实例名称,然后创建实例。'
|
|
57
|
+
: 'Enter a name to create an application instance.';
|
|
58
|
+
|
|
59
|
+
function Editor() {
|
|
60
|
+
return (
|
|
61
|
+
<form
|
|
62
|
+
id='instance-form'
|
|
63
|
+
onSubmit={(event) => {
|
|
64
|
+
event.preventDefault();
|
|
65
|
+
void router.navigate('/instances?created=1');
|
|
66
|
+
}}
|
|
67
|
+
>
|
|
68
|
+
<label htmlFor='instance-name'>
|
|
69
|
+
{chinese ? '实例名称' : 'Instance name'}
|
|
70
|
+
</label>
|
|
71
|
+
<Input id='instance-name' required defaultValue='Customer portal' />
|
|
72
|
+
{long && !kind.startsWith('alert') && (
|
|
73
|
+
<div className='h-[900px]' aria-hidden='true' />
|
|
74
|
+
)}
|
|
75
|
+
</form>
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function Actions() {
|
|
80
|
+
return (
|
|
81
|
+
<>
|
|
82
|
+
{kind === 'dialog-close' ? (
|
|
83
|
+
<DialogClose
|
|
84
|
+
disabled={pending}
|
|
85
|
+
data-testid='cancel'
|
|
86
|
+
render={<Button variant='outline' />}
|
|
87
|
+
>
|
|
88
|
+
{cancel}
|
|
89
|
+
</DialogClose>
|
|
90
|
+
) : (
|
|
91
|
+
<Button
|
|
92
|
+
variant='outline'
|
|
93
|
+
nativeButton={false}
|
|
94
|
+
disabled={pending}
|
|
95
|
+
data-testid='cancel'
|
|
96
|
+
render={<Link to='/instances?filter=recent' />}
|
|
97
|
+
>
|
|
98
|
+
{cancel}
|
|
99
|
+
</Button>
|
|
100
|
+
)}
|
|
101
|
+
<Button
|
|
102
|
+
type='submit'
|
|
103
|
+
form='instance-form'
|
|
104
|
+
disabled={pending}
|
|
105
|
+
aria-busy={pending}
|
|
106
|
+
data-testid='submit'
|
|
107
|
+
>
|
|
108
|
+
{pending && <Spinner data-icon='inline-start' />}
|
|
109
|
+
{pending ? 'Creating instance…' : submit}
|
|
110
|
+
</Button>
|
|
111
|
+
</>
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export default function Overlay() {
|
|
116
|
+
const [open, setOpen] = useState(true);
|
|
117
|
+
if (kind === 'route' || kind === 'drawer') {
|
|
118
|
+
const Component = kind === 'drawer' ? RouteDrawer : RouteDialog;
|
|
119
|
+
return (
|
|
120
|
+
<Component
|
|
121
|
+
title={title}
|
|
122
|
+
description={description}
|
|
123
|
+
footer={<Actions />}
|
|
124
|
+
beforeClose={() => !pending}
|
|
125
|
+
>
|
|
126
|
+
<Editor />
|
|
127
|
+
</Component>
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
if (kind === 'alert' || kind === 'alert-small') {
|
|
131
|
+
return (
|
|
132
|
+
<AlertDialog
|
|
133
|
+
open={open}
|
|
134
|
+
onOpenChange={(value) => {
|
|
135
|
+
if (!pending) setOpen(value);
|
|
136
|
+
}}
|
|
137
|
+
>
|
|
138
|
+
<AlertDialogContent size={kind === 'alert-small' ? 'sm' : 'default'}>
|
|
139
|
+
<AlertDialogHeader>
|
|
140
|
+
<AlertDialogTitle>{title}</AlertDialogTitle>
|
|
141
|
+
<AlertDialogDescription>{description}</AlertDialogDescription>
|
|
142
|
+
</AlertDialogHeader>
|
|
143
|
+
<Editor />
|
|
144
|
+
<AlertDialogFooter
|
|
145
|
+
className={horizontal ? 'flex-row items-center' : undefined}
|
|
146
|
+
>
|
|
147
|
+
<AlertDialogCancel disabled={pending} data-testid='cancel'>
|
|
148
|
+
{cancel}
|
|
149
|
+
</AlertDialogCancel>
|
|
150
|
+
<AlertDialogAction
|
|
151
|
+
type='submit'
|
|
152
|
+
form='instance-form'
|
|
153
|
+
disabled={pending}
|
|
154
|
+
aria-busy={pending}
|
|
155
|
+
data-testid='submit'
|
|
156
|
+
>
|
|
157
|
+
{pending && <Spinner data-icon='inline-start' />}
|
|
158
|
+
{pending ? 'Creating instance…' : submit}
|
|
159
|
+
</AlertDialogAction>
|
|
160
|
+
</AlertDialogFooter>
|
|
161
|
+
</AlertDialogContent>
|
|
162
|
+
</AlertDialog>
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
return (
|
|
166
|
+
<Dialog
|
|
167
|
+
open={open}
|
|
168
|
+
onOpenChange={(value) => {
|
|
169
|
+
if (!pending) setOpen(value);
|
|
170
|
+
}}
|
|
171
|
+
>
|
|
172
|
+
<DialogContent className='flex flex-col overflow-hidden'>
|
|
173
|
+
<DialogHeader>
|
|
174
|
+
<DialogTitle>{title}</DialogTitle>
|
|
175
|
+
<DialogDescription>{description}</DialogDescription>
|
|
176
|
+
</DialogHeader>
|
|
177
|
+
<div className='min-h-0 overflow-y-auto'>
|
|
178
|
+
<Editor />
|
|
179
|
+
</div>
|
|
180
|
+
<DialogFooter
|
|
181
|
+
className={horizontal ? 'flex-row items-center' : undefined}
|
|
182
|
+
>
|
|
183
|
+
<Actions />
|
|
184
|
+
</DialogFooter>
|
|
185
|
+
</DialogContent>
|
|
186
|
+
</Dialog>
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const router = createMemoryRouter(
|
|
191
|
+
[
|
|
192
|
+
{
|
|
193
|
+
path: '/instances',
|
|
194
|
+
element: (
|
|
195
|
+
<main className='p-6'>
|
|
196
|
+
<h1>Application instances</h1>
|
|
197
|
+
<p data-testid='location'>Instance list</p>
|
|
198
|
+
<Outlet />
|
|
199
|
+
</main>
|
|
200
|
+
),
|
|
201
|
+
children: [{ path: 'new', element: <Overlay /> }],
|
|
202
|
+
},
|
|
203
|
+
],
|
|
204
|
+
{ initialEntries: ['/instances/new?filter=recent'] },
|
|
205
|
+
);
|
|
206
|
+
router.subscribe((state) => {
|
|
207
|
+
const output = document.querySelector('[data-testid=location]');
|
|
208
|
+
if (output)
|
|
209
|
+
output.textContent = state.location.pathname + state.location.search;
|
|
210
|
+
});
|
|
211
|
+
createRoot(document.getElementById('root')!).render(
|
|
212
|
+
<TestI18nProvider runtime={runtime}>
|
|
213
|
+
<RouterProvider router={router} />
|
|
214
|
+
</TestI18nProvider>,
|
|
215
|
+
);
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { ReactElement } from 'react';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
2
3
|
import { CookieJar } from 'jsdom';
|
|
3
|
-
import { onTestFinished, vi } from 'vitest';
|
|
4
|
+
import { afterAll, beforeAll, onTestFinished, vi } from 'vitest';
|
|
4
5
|
import { act, waitFor } from '@testing-library/react';
|
|
5
6
|
import { createTestApp, type TestApp } from '@nocobase/app-testing/server';
|
|
6
7
|
import { renderWithApp } from '@nocobase/app-testing/client';
|
|
@@ -36,27 +37,18 @@ export async function renderAuthenticatedClient(
|
|
|
36
37
|
response: Response,
|
|
37
38
|
) => void | Promise<void>;
|
|
38
39
|
} = {},
|
|
40
|
+
sharedServer?: TestApp,
|
|
39
41
|
) {
|
|
40
|
-
const server = await
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
logging: { enabled: false },
|
|
45
|
-
app: { publicOrigin: 'http://localhost' },
|
|
46
|
-
auth: {
|
|
47
|
-
secret: 'test-auth-secret-at-least-32-characters',
|
|
48
|
-
emailAndPassword: { disableSignUp: false },
|
|
49
|
-
advanced: { disableOriginCheck: false, disableCSRFCheck: false },
|
|
50
|
-
},
|
|
51
|
-
},
|
|
52
|
-
});
|
|
53
|
-
onTestFinished(() => server.close());
|
|
42
|
+
const server = sharedServer ?? (await createClientTestServer());
|
|
43
|
+
if (!sharedServer) onTestFinished(() => server.close());
|
|
44
|
+
const identity = sharedServer ? randomUUID() : '';
|
|
45
|
+
const permissionSetKey = identity ? `test-access-${identity}` : 'test-access';
|
|
54
46
|
const member = {
|
|
55
47
|
name: 'Test member',
|
|
56
|
-
email: 'member@example.test',
|
|
48
|
+
email: identity ? `member-${identity}@example.test` : 'member@example.test',
|
|
57
49
|
password: 'test-member-password',
|
|
58
50
|
};
|
|
59
|
-
if (options.member) {
|
|
51
|
+
if (options.member || sharedServer) {
|
|
60
52
|
const response = await server.request('/auth/sign-up/email', {
|
|
61
53
|
method: 'POST',
|
|
62
54
|
headers: {
|
|
@@ -68,22 +60,77 @@ export async function renderAuthenticatedClient(
|
|
|
68
60
|
if (!response.ok)
|
|
69
61
|
throw new Error(`Member registration failed: ${await response.text()}`);
|
|
70
62
|
}
|
|
63
|
+
const authz = server.application.container.resolve(authorizationToken);
|
|
71
64
|
const session = await signIn(
|
|
72
65
|
server,
|
|
73
|
-
options.member ? member : DEFAULT_ADMIN_CREDENTIALS,
|
|
66
|
+
options.member || sharedServer ? member : DEFAULT_ADMIN_CREDENTIALS,
|
|
74
67
|
);
|
|
68
|
+
if (sharedServer && !options.member) {
|
|
69
|
+
await authz.permissionSets.assign({
|
|
70
|
+
id: randomUUID(),
|
|
71
|
+
permissionSet: 'root',
|
|
72
|
+
subject: { type: 'user', id: session.user.id },
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
75
|
if (options.grants) {
|
|
76
|
-
const authz = server.application.container.resolve(authorizationToken);
|
|
77
76
|
await authz.permissionSets.create({
|
|
78
|
-
key:
|
|
77
|
+
key: permissionSetKey,
|
|
79
78
|
grants: options.grants,
|
|
80
79
|
});
|
|
81
80
|
await authz.permissionSets.assign({
|
|
82
|
-
id: 'test-assignment',
|
|
83
|
-
permissionSet:
|
|
81
|
+
id: identity ? `test-assignment-${identity}` : 'test-assignment',
|
|
82
|
+
permissionSet: permissionSetKey,
|
|
84
83
|
subject: { type: 'user', id: session.user.id },
|
|
85
84
|
});
|
|
86
85
|
}
|
|
86
|
+
return renderClient(server, session, permissionSetKey, ui, options);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function createClientTestServer() {
|
|
90
|
+
return createTestApp({
|
|
91
|
+
createServer: createStandaloneServer,
|
|
92
|
+
connections: ['main'],
|
|
93
|
+
config: {
|
|
94
|
+
logging: { enabled: false },
|
|
95
|
+
app: { publicOrigin: 'http://localhost' },
|
|
96
|
+
auth: {
|
|
97
|
+
secret: 'test-auth-secret-at-least-32-characters',
|
|
98
|
+
emailAndPassword: { disableSignUp: false },
|
|
99
|
+
advanced: { disableOriginCheck: false, disableCSRFCheck: false },
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Reuse installation within a file whose tests only change their own identity's state.
|
|
107
|
+
* Each render gets a new user, session, grants, cookies and client. Tests that mutate
|
|
108
|
+
* shared application configuration or business data use renderAuthenticatedClient instead.
|
|
109
|
+
*/
|
|
110
|
+
export function createAuthenticatedClientRenderer() {
|
|
111
|
+
let server: TestApp | undefined;
|
|
112
|
+
beforeAll(async () => {
|
|
113
|
+
server = await createClientTestServer();
|
|
114
|
+
});
|
|
115
|
+
afterAll(async () => {
|
|
116
|
+
await server?.close();
|
|
117
|
+
});
|
|
118
|
+
return (
|
|
119
|
+
ui: ReactElement,
|
|
120
|
+
options: Parameters<typeof renderAuthenticatedClient>[1] = {},
|
|
121
|
+
) => {
|
|
122
|
+
if (!server) throw new Error('The test application has not started.');
|
|
123
|
+
return renderAuthenticatedClient(ui, options, server);
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async function renderClient(
|
|
128
|
+
server: TestApp,
|
|
129
|
+
session: TestSession,
|
|
130
|
+
permissionSetKey: string,
|
|
131
|
+
ui: ReactElement,
|
|
132
|
+
options: NonNullable<Parameters<typeof renderAuthenticatedClient>[1]>,
|
|
133
|
+
) {
|
|
87
134
|
await options.prepare?.(server, session);
|
|
88
135
|
const jar = new CookieJar();
|
|
89
136
|
const root = `http://localhost${server.publicBasePath.replace(/\/$/u, '')}`;
|
|
@@ -135,6 +182,7 @@ export async function renderAuthenticatedClient(
|
|
|
135
182
|
...view,
|
|
136
183
|
server,
|
|
137
184
|
session,
|
|
185
|
+
permissionSetKey,
|
|
138
186
|
identity: () => identity!,
|
|
139
187
|
async signInAs(credentials: SignInCredentials) {
|
|
140
188
|
const next = await signIn(server, credentials);
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
/// <reference lib="dom" />
|
|
2
|
+
import { expect, test, type Locator, type Page } from '@playwright/test';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
const applicationUrl = process.env.APP_URL;
|
|
6
|
+
test.skip(!applicationUrl, 'Set APP_URL to the running application entry.');
|
|
7
|
+
const fixture = `${applicationUrl?.replace(/\/$/, '')}/tests/fixtures/dialog-footer.html`;
|
|
8
|
+
|
|
9
|
+
async function geometry(page: Page) {
|
|
10
|
+
return page.getByTestId('cancel').evaluate((cancel) => {
|
|
11
|
+
const submit = document.querySelector('[data-testid=submit]')!;
|
|
12
|
+
const footer = cancel.parentElement!;
|
|
13
|
+
const panel = cancel.closest('[role=dialog], [role=alertdialog]')!;
|
|
14
|
+
const measure = (element: Element) => {
|
|
15
|
+
const rect = element.getBoundingClientRect();
|
|
16
|
+
const css = getComputedStyle(element);
|
|
17
|
+
return {
|
|
18
|
+
x: rect.x,
|
|
19
|
+
y: rect.y,
|
|
20
|
+
right: rect.right,
|
|
21
|
+
bottom: rect.bottom,
|
|
22
|
+
height: rect.height,
|
|
23
|
+
width: rect.width,
|
|
24
|
+
paddingLeft: css.paddingLeft,
|
|
25
|
+
paddingRight: css.paddingRight,
|
|
26
|
+
paddingTop: css.paddingTop,
|
|
27
|
+
paddingBottom: css.paddingBottom,
|
|
28
|
+
minHeight: parseFloat(css.minHeight),
|
|
29
|
+
overflow: element.scrollWidth > element.clientWidth + 1,
|
|
30
|
+
};
|
|
31
|
+
};
|
|
32
|
+
return {
|
|
33
|
+
cancel: measure(cancel),
|
|
34
|
+
submit: measure(submit),
|
|
35
|
+
footer: measure(footer),
|
|
36
|
+
panel: measure(panel),
|
|
37
|
+
horizontal:
|
|
38
|
+
getComputedStyle(footer).flexDirection === 'row' ||
|
|
39
|
+
getComputedStyle(footer).display === 'grid',
|
|
40
|
+
};
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function checkGeometry(page: Page) {
|
|
45
|
+
await expect(page.getByTestId('cancel')).toBeVisible();
|
|
46
|
+
let previous = '';
|
|
47
|
+
await expect
|
|
48
|
+
.poll(async () => {
|
|
49
|
+
const current = JSON.stringify(await geometry(page));
|
|
50
|
+
const stable = current === previous;
|
|
51
|
+
previous = current;
|
|
52
|
+
return stable;
|
|
53
|
+
})
|
|
54
|
+
.toBe(true);
|
|
55
|
+
const result = await geometry(page);
|
|
56
|
+
const { cancel, submit, footer, panel, horizontal } = result;
|
|
57
|
+
expect(cancel.height).toBeGreaterThanOrEqual(cancel.minHeight - 1);
|
|
58
|
+
expect(submit.height).toBeGreaterThanOrEqual(submit.minHeight - 1);
|
|
59
|
+
expect(cancel.paddingLeft).toBe(submit.paddingLeft);
|
|
60
|
+
expect(cancel.paddingRight).toBe(submit.paddingRight);
|
|
61
|
+
expect(cancel.paddingTop).toBe(submit.paddingTop);
|
|
62
|
+
expect(cancel.paddingBottom).toBe(submit.paddingBottom);
|
|
63
|
+
if (horizontal) {
|
|
64
|
+
expect(Math.abs(cancel.y - submit.y)).toBeLessThan(1);
|
|
65
|
+
expect(Math.abs(cancel.bottom - submit.bottom)).toBeLessThan(1);
|
|
66
|
+
expect(cancel.right).toBeLessThanOrEqual(submit.x + 1);
|
|
67
|
+
} else {
|
|
68
|
+
expect(submit.bottom).toBeLessThanOrEqual(cancel.y + 1);
|
|
69
|
+
}
|
|
70
|
+
for (const button of [cancel, submit]) {
|
|
71
|
+
expect(button.overflow).toBe(false);
|
|
72
|
+
expect(button.x).toBeGreaterThanOrEqual(panel.x);
|
|
73
|
+
expect(button.right).toBeLessThanOrEqual(panel.right + 1);
|
|
74
|
+
expect(button.y).toBeGreaterThanOrEqual(footer.y);
|
|
75
|
+
expect(button.bottom).toBeLessThanOrEqual(panel.bottom + 1);
|
|
76
|
+
expect(button.bottom).toBeLessThanOrEqual(page.viewportSize()!.height);
|
|
77
|
+
}
|
|
78
|
+
expect(panel.x).toBeGreaterThanOrEqual(0);
|
|
79
|
+
expect(panel.right).toBeLessThanOrEqual(page.viewportSize()!.width);
|
|
80
|
+
expect(panel.overflow).toBe(false);
|
|
81
|
+
return result;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function screenshot(page: Page, name: string) {
|
|
85
|
+
if (process.env.DIALOG_EVIDENCE_DIR) {
|
|
86
|
+
await page.screenshot({
|
|
87
|
+
path: path.join(process.env.DIALOG_EVIDENCE_DIR, `${name}.png`),
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
for (const kind of [
|
|
93
|
+
'dialog',
|
|
94
|
+
'dialog-close',
|
|
95
|
+
'alert',
|
|
96
|
+
'alert-small',
|
|
97
|
+
'route',
|
|
98
|
+
'drawer',
|
|
99
|
+
]) {
|
|
100
|
+
for (const preset of ['compact', 'default']) {
|
|
101
|
+
for (const width of [1440, 375]) {
|
|
102
|
+
test(`${kind}, ${preset}, ${width}: semantic actions align and keep their padding`, async ({
|
|
103
|
+
page,
|
|
104
|
+
}) => {
|
|
105
|
+
await page.setViewportSize({ width, height: 900 });
|
|
106
|
+
await page.goto(`${fixture}?kind=${kind}&preset=${preset}`);
|
|
107
|
+
const result = await checkGeometry(page);
|
|
108
|
+
expect(result.cancel.height).toBeCloseTo(result.submit.height, 0);
|
|
109
|
+
expect(result.cancel.minHeight).toBe(preset === 'compact' ? 36 : 40);
|
|
110
|
+
expect(result.cancel.paddingLeft).toBe(
|
|
111
|
+
preset === 'compact' ? '14.4px' : '16px',
|
|
112
|
+
);
|
|
113
|
+
if (width > 640 && kind !== 'alert-small')
|
|
114
|
+
expect(result.cancel.width).toBeLessThan(result.submit.width);
|
|
115
|
+
if (preset === 'default') await screenshot(page, `${kind}-${width}-en`);
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
for (const language of ['english', 'chinese']) {
|
|
121
|
+
test(`${kind}: long ${language} labels remain visible on a short phone viewport`, async ({
|
|
122
|
+
page,
|
|
123
|
+
}) => {
|
|
124
|
+
await page.setViewportSize({ width: 375, height: 667 });
|
|
125
|
+
await page.goto(
|
|
126
|
+
`${fixture}?kind=${kind}&long&${language === 'chinese' ? 'chinese&' : ''}dark`,
|
|
127
|
+
);
|
|
128
|
+
await checkGeometry(page);
|
|
129
|
+
await screenshot(page, `${kind}-375-long-${language}`);
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
test(`${kind}: disabled and submitting actions retain geometry`, async ({
|
|
134
|
+
page,
|
|
135
|
+
}) => {
|
|
136
|
+
await page.setViewportSize({ width: 375, height: 900 });
|
|
137
|
+
await page.goto(`${fixture}?kind=${kind}&pending`);
|
|
138
|
+
await checkGeometry(page);
|
|
139
|
+
await expect(page.getByTestId('cancel')).toBeDisabled();
|
|
140
|
+
await expect(page.getByTestId('submit')).toBeDisabled();
|
|
141
|
+
await expect(page.getByTestId('submit')).toHaveAttribute(
|
|
142
|
+
'aria-busy',
|
|
143
|
+
'true',
|
|
144
|
+
);
|
|
145
|
+
await page.keyboard.press('Escape');
|
|
146
|
+
await expect(page.getByTestId('submit')).toBeVisible();
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
for (const kind of ['dialog', 'alert']) {
|
|
151
|
+
test(`${kind}: a phone row wraps long labels without overflowing`, async ({
|
|
152
|
+
page,
|
|
153
|
+
}) => {
|
|
154
|
+
await page.setViewportSize({ width: 375, height: 667 });
|
|
155
|
+
await page.goto(`${fixture}?kind=${kind}&horizontal&chinese`);
|
|
156
|
+
await checkGeometry(page);
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async function expectKeyboardFocus(action: Locator) {
|
|
161
|
+
await action.focus();
|
|
162
|
+
await expect(action).toBeFocused();
|
|
163
|
+
expect(
|
|
164
|
+
await action.evaluate(
|
|
165
|
+
(element) =>
|
|
166
|
+
getComputedStyle(element).outlineStyle !== 'none' ||
|
|
167
|
+
getComputedStyle(element).boxShadow !== 'none',
|
|
168
|
+
),
|
|
169
|
+
).toBe(true);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
for (const kind of ['dialog', 'route', 'drawer']) {
|
|
173
|
+
test(`${kind}: link cancel and native form submit remain independent`, async ({
|
|
174
|
+
page,
|
|
175
|
+
}) => {
|
|
176
|
+
await page.goto(`${fixture}?kind=${kind}`);
|
|
177
|
+
await checkGeometry(page);
|
|
178
|
+
const cancel = page.getByTestId('cancel');
|
|
179
|
+
await expect(cancel).toHaveAttribute('href', '/instances?filter=recent');
|
|
180
|
+
expect(await cancel.evaluate((element) => element.tagName)).toBe('A');
|
|
181
|
+
await expectKeyboardFocus(cancel);
|
|
182
|
+
await page.keyboard.press('Enter');
|
|
183
|
+
await expect(page.getByTestId('location')).toHaveText(
|
|
184
|
+
'/instances?filter=recent',
|
|
185
|
+
);
|
|
186
|
+
await expect(page.getByTestId('submit')).toHaveCount(0);
|
|
187
|
+
await page.goto(`${fixture}?kind=${kind}`);
|
|
188
|
+
await checkGeometry(page);
|
|
189
|
+
await page.getByLabel('Instance name').fill('New customer portal');
|
|
190
|
+
await expectKeyboardFocus(page.getByTestId('submit'));
|
|
191
|
+
await page.keyboard.press('Enter');
|
|
192
|
+
await expect(page.getByTestId('location')).toHaveText(
|
|
193
|
+
'/instances?created=1',
|
|
194
|
+
);
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
test('route footer stays visible when the body scrolls', async ({ page }) => {
|
|
199
|
+
await page.setViewportSize({ width: 375, height: 667 });
|
|
200
|
+
await page.goto(`${fixture}?kind=route&long`);
|
|
201
|
+
const before = await checkGeometry(page);
|
|
202
|
+
await page.locator('[data-slot=route-overlay-body]').evaluate((element) => {
|
|
203
|
+
element.scrollTop = element.scrollHeight;
|
|
204
|
+
});
|
|
205
|
+
await expect
|
|
206
|
+
.poll(() =>
|
|
207
|
+
page
|
|
208
|
+
.locator('[data-slot=route-overlay-body]')
|
|
209
|
+
.evaluate((element) => element.scrollTop),
|
|
210
|
+
)
|
|
211
|
+
.toBeGreaterThan(0);
|
|
212
|
+
const after = await checkGeometry(page);
|
|
213
|
+
expect(after.footer.y).toBe(before.footer.y);
|
|
214
|
+
await page.getByTestId('cancel').click();
|
|
215
|
+
await expect(page.getByTestId('location')).toHaveText(
|
|
216
|
+
'/instances?filter=recent',
|
|
217
|
+
);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
// DialogClose replaces Button's semantic slot just as AlertDialog's actions do.
|
|
221
|
+
test('DialogClose cancels without submitting the form', async ({ page }) => {
|
|
222
|
+
await page.goto(`${fixture}?kind=dialog-close`);
|
|
223
|
+
await checkGeometry(page);
|
|
224
|
+
await page.getByTestId('cancel').click();
|
|
225
|
+
await expect(page.getByTestId('submit')).toHaveCount(0);
|
|
226
|
+
await expect(page.getByTestId('location')).toHaveText('Instance list');
|
|
227
|
+
});
|