@nocobase/app-template-default 1.0.0-beta.67 → 1.0.0-beta.68
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 +1 -1
- package/.agents/rules/shell.md +3 -1
- package/client/components/page-header.tsx +1 -1
- package/client/components/status-badge.tsx +7 -1
- package/client/extensions/nocobase-inbox/inbox-item.tsx +44 -58
- package/client/extensions/nocobase-inbox/inbox-list.tsx +73 -75
- package/client/index.tsx +4 -0
- package/client/layouts/components/app-sidebar.tsx +5 -5
- package/client/layouts/components/layout-header.tsx +1 -1
- package/client/layouts/components/navigation-menu.tsx +223 -12
- package/client/layouts/components/sidebar-footer.tsx +1 -1
- package/client/layouts/components/user-menu.tsx +1 -1
- package/client/lib/navigation-transform.ts +8 -0
- package/client/lib/scrollbar.ts +88 -0
- package/client/locales/system/en-US.ts +4 -0
- package/client/locales/system/zh-CN.ts +15 -12
- package/client/pages/auth/auth-page.css +37 -0
- package/client/pages/auth/reset-password.tsx +57 -14
- package/client/pages/auth/shared.tsx +24 -24
- package/client/pages/home/index.tsx +2 -2
- package/client/routing/route-navigation.ts +10 -1
- package/client/styles.css +253 -17
- package/client/theme/theme-settings.tsx +12 -7
- package/client/theme/themes/compact.css +9 -14
- package/client/theme/themes/default.css +7 -12
- package/package.json +7 -7
- package/server/app.ts +2 -0
- package/server/config/notification.ts +4 -7
- package/tests/components/auth-i18n.test.tsx +57 -5
- package/tests/components/layout-i18n.test.tsx +2 -2
- package/tests/components/navigation-menu.test.tsx +133 -4
- package/tests/components/sidebar-permissions.test.tsx +5 -15
- package/tests/components/status-badge.test.tsx +22 -0
- package/tests/logic/scrollbar.test.ts +88 -0
- package/tests/logic/theme-tokens.test.ts +1 -0
- package/vitest.config.ts +1 -0
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nocobase/app-template-default",
|
|
3
3
|
"displayName": "Default Template",
|
|
4
|
-
"version": "1.0.0-beta.
|
|
4
|
+
"version": "1.0.0-beta.68",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"prettier": "@nocobase/dev-config/prettier",
|
|
7
7
|
"engines": {
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
},
|
|
10
10
|
"nocobase": {
|
|
11
11
|
"templateKind": "app",
|
|
12
|
-
"defaultTemplateVersion": "1.0.0-beta.
|
|
12
|
+
"defaultTemplateVersion": "1.0.0-beta.68"
|
|
13
13
|
},
|
|
14
14
|
"devDependencies": {
|
|
15
15
|
"@ai-sdk/react": "2.0.0",
|
|
@@ -77,7 +77,7 @@
|
|
|
77
77
|
"tar": "^7.5.22",
|
|
78
78
|
"@nocobase/app-client": "^3.0.0-beta.1",
|
|
79
79
|
"@nocobase/app-plugin-template-print": "0.0.2-beta.2",
|
|
80
|
-
"@nocobase/app-skills": "0.1.0-beta.
|
|
80
|
+
"@nocobase/app-skills": "0.1.0-beta.35",
|
|
81
81
|
"@nocobase/app-testing": "0.1.0-beta.3",
|
|
82
82
|
"@nocobase/app-websocket": "^0.0.2-beta.0",
|
|
83
83
|
"@nocobase/dev-config": "0.1.0-beta.22",
|
|
@@ -143,7 +143,7 @@
|
|
|
143
143
|
"better-auth": "1.7.5",
|
|
144
144
|
"hono": "^4.13.2",
|
|
145
145
|
"@nocobase/app-plugin-api-keys": "^1.0.0-beta.16",
|
|
146
|
-
"@nocobase/app-plugin-authentication": "^2.0.0-beta.
|
|
146
|
+
"@nocobase/app-plugin-authentication": "^2.0.0-beta.8",
|
|
147
147
|
"@nocobase/app-plugin-authorization": "^1.0.0-beta.27",
|
|
148
148
|
"@nocobase/app-plugin-database-explorer": "^1.0.0-beta.14",
|
|
149
149
|
"@nocobase/app-plugin-file": "^1.0.0-beta.23",
|
|
@@ -151,10 +151,10 @@
|
|
|
151
151
|
"@nocobase/app-plugin-mail": "^1.0.0-beta.13",
|
|
152
152
|
"@nocobase/app-plugin-notification": "^1.0.0-beta.27",
|
|
153
153
|
"@nocobase/app-plugin-notification-in-app": "^1.0.0-beta.24",
|
|
154
|
-
"@nocobase/app-plugin-notification-providers": "^0.2.0-beta.
|
|
154
|
+
"@nocobase/app-plugin-notification-providers": "^0.2.0-beta.12",
|
|
155
155
|
"@nocobase/app-plugin-scheduler": "^1.0.0-beta.17",
|
|
156
156
|
"@nocobase/app-plugin-users": "^2.0.0-beta.4",
|
|
157
|
-
"@nocobase/app-server": "^2.0.0-beta.
|
|
157
|
+
"@nocobase/app-server": "^2.0.0-beta.6",
|
|
158
158
|
"@nocobase/config": "^0.1.0-beta.3",
|
|
159
159
|
"@nocobase/db": "^1.0.0-beta.19",
|
|
160
160
|
"@nocobase/db-sqlite": "^0.1.0-beta.5",
|
|
@@ -166,7 +166,7 @@
|
|
|
166
166
|
"@nocobase/authorization": "^1.0.0-beta.13",
|
|
167
167
|
"@nocobase/queue": "^0.1.0-beta.9",
|
|
168
168
|
"@nocobase/jobs": "^0.1.0-beta.3",
|
|
169
|
-
"@nocobase/lifecycle": "^0.1.0-beta.
|
|
169
|
+
"@nocobase/lifecycle": "^0.1.0-beta.2",
|
|
170
170
|
"@nocobase/i18n": "^1.0.0-beta.7",
|
|
171
171
|
"@nocobase/caching": "^0.1.0-beta.3",
|
|
172
172
|
"@nocobase/repository-input": "^0.1.0-beta.3",
|
package/server/app.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { MailerProvider } from '@nocobase/app-server/mailer';
|
|
1
2
|
import type { Application } from '@nocobase/app-server/application';
|
|
2
3
|
import { DatabaseProvider } from '@nocobase/app-server/database';
|
|
3
4
|
import { I18nProvider, i18nHttpMiddleware } from '@nocobase/app-server/i18n';
|
|
@@ -33,6 +34,7 @@ export function createApp(runtime: AppRuntimeContext): Application {
|
|
|
33
34
|
app.addServiceProvider(SecretsProvider);
|
|
34
35
|
app.addServiceProvider(SessionProvider);
|
|
35
36
|
app.addServiceProvider(DriveProvider);
|
|
37
|
+
app.addServiceProvider(MailerProvider);
|
|
36
38
|
app.addServiceProvider(QueueServiceProvider, {
|
|
37
39
|
nodeEnv: runtime.env.NODE_ENV,
|
|
38
40
|
});
|
|
@@ -1,11 +1,8 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
type AppConfigFactory,
|
|
4
|
-
} from '@nocobase/app-server/config';
|
|
1
|
+
import { defineMailChannelsConfig } from '@nocobase/app-server/mailer';
|
|
2
|
+
import { type AppConfigFactory } from '@nocobase/app-server/config';
|
|
5
3
|
import type { NotificationConfig } from '@nocobase/app-plugin-notification/server';
|
|
6
4
|
|
|
7
|
-
const notification: AppConfigFactory<NotificationConfig> =
|
|
8
|
-
(
|
|
9
|
-
);
|
|
5
|
+
const notification: AppConfigFactory<NotificationConfig> =
|
|
6
|
+
defineMailChannelsConfig({ defaults: { channels: {} } });
|
|
10
7
|
|
|
11
8
|
export default notification;
|
|
@@ -11,20 +11,32 @@ import RegisterPage from '../../client/pages/auth/register.js';
|
|
|
11
11
|
import ResetPasswordPage from '../../client/pages/auth/reset-password.js';
|
|
12
12
|
import { Loading } from '../../client/components/loading.js';
|
|
13
13
|
|
|
14
|
+
const resetState = vi.hoisted(() => ({
|
|
15
|
+
success: false,
|
|
16
|
+
available: true,
|
|
17
|
+
pending: false,
|
|
18
|
+
failed: false,
|
|
19
|
+
retry: vi.fn(),
|
|
20
|
+
}));
|
|
21
|
+
|
|
14
22
|
vi.mock('@nocobase/app-plugin-authentication/client', () => ({
|
|
15
23
|
useSignUpAvailable: () => true,
|
|
16
24
|
usePasswordResetCapability: () => ({
|
|
17
|
-
data: { passwordResetAvailable:
|
|
18
|
-
isPending:
|
|
19
|
-
isError:
|
|
20
|
-
refetch:
|
|
25
|
+
data: { passwordResetAvailable: resetState.available },
|
|
26
|
+
isPending: resetState.pending,
|
|
27
|
+
isError: resetState.failed,
|
|
28
|
+
refetch: resetState.retry,
|
|
21
29
|
}),
|
|
22
30
|
}));
|
|
23
31
|
|
|
24
32
|
vi.mock('@nocobase/app-plugin-authentication/client/actions', () => ({
|
|
25
33
|
usePasswordLogin: () => ({ isPending: false, submit: vi.fn() }),
|
|
26
34
|
usePasswordRegistration: () => ({ isPending: false, submit: vi.fn() }),
|
|
27
|
-
usePasswordReset: () => ({
|
|
35
|
+
usePasswordReset: () => ({
|
|
36
|
+
isPending: false,
|
|
37
|
+
isSuccess: resetState.success,
|
|
38
|
+
submit: vi.fn(),
|
|
39
|
+
}),
|
|
28
40
|
usePasswordResetRequest: () => ({
|
|
29
41
|
isPending: false,
|
|
30
42
|
isSuccess: true,
|
|
@@ -49,6 +61,46 @@ function renderPage(value: I18nRuntime, page: ReactElement) {
|
|
|
49
61
|
);
|
|
50
62
|
}
|
|
51
63
|
describe('authentication translations', () => {
|
|
64
|
+
it('shows translated success and stops offering a second reset', async () => {
|
|
65
|
+
resetState.success = true;
|
|
66
|
+
try {
|
|
67
|
+
const value = await runtime();
|
|
68
|
+
renderPage(value, <ResetPasswordPage />);
|
|
69
|
+
expect(screen.getByRole('status')).toHaveTextContent(
|
|
70
|
+
'Your password has been reset.',
|
|
71
|
+
);
|
|
72
|
+
expect(screen.queryByLabelText('New password')).not.toBeInTheDocument();
|
|
73
|
+
await act(() => value.changeLanguage('zh-CN'));
|
|
74
|
+
expect(screen.getByRole('status')).toHaveTextContent(
|
|
75
|
+
'密码已重置,请用新密码登录。',
|
|
76
|
+
);
|
|
77
|
+
} finally {
|
|
78
|
+
resetState.success = false;
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
it('hides the form when capability is disabled and allows retry after a failed lookup', async () => {
|
|
82
|
+
resetState.available = false;
|
|
83
|
+
try {
|
|
84
|
+
const value = await runtime();
|
|
85
|
+
const view = renderPage(value, <ResetPasswordPage />);
|
|
86
|
+
expect(screen.queryByLabelText('New password')).not.toBeInTheDocument();
|
|
87
|
+
resetState.failed = true;
|
|
88
|
+
view.rerender(
|
|
89
|
+
<I18nProvider runtime={value}>
|
|
90
|
+
<MemoryRouter>
|
|
91
|
+
<ResetPasswordPage />
|
|
92
|
+
</MemoryRouter>
|
|
93
|
+
</I18nProvider>,
|
|
94
|
+
);
|
|
95
|
+
fireEvent.click(screen.getByRole('button', { name: 'Retry' }));
|
|
96
|
+
expect(resetState.retry).toHaveBeenCalledOnce();
|
|
97
|
+
} finally {
|
|
98
|
+
resetState.available = true;
|
|
99
|
+
resetState.failed = false;
|
|
100
|
+
resetState.retry.mockReset();
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
|
|
52
104
|
it('updates the login page, brand panel and visibility control on language change', async () => {
|
|
53
105
|
const value = await runtime();
|
|
54
106
|
renderPage(value, <LoginPage />);
|
|
@@ -81,10 +81,10 @@ describe('shell translations', () => {
|
|
|
81
81
|
expect(screen.getByText('AI builds freely.')).toBeVisible();
|
|
82
82
|
await act(() => runtime.changeLanguage('zh-CN'));
|
|
83
83
|
expect(screen.getByRole('navigation', { name: '面包屑' })).toBeVisible();
|
|
84
|
-
expect(screen.getByText('AI
|
|
84
|
+
expect(screen.getByText('AI 自由构建')).toBeVisible();
|
|
85
85
|
expect(
|
|
86
86
|
screen.getByRole('link', { name: 'NocoBase' }).parentElement,
|
|
87
|
-
).toHaveTextContent('NocoBase
|
|
87
|
+
).toHaveTextContent('NocoBase 保障可靠');
|
|
88
88
|
expect(
|
|
89
89
|
screen.getByRole('button', { name: '展开或收起导航' }),
|
|
90
90
|
).toHaveAttribute('title', '展开或收起导航');
|
|
@@ -25,7 +25,10 @@ import {
|
|
|
25
25
|
AppSidebarProvider,
|
|
26
26
|
AppSidebarToggle,
|
|
27
27
|
} from '../../client/layouts/components/app-sidebar.js';
|
|
28
|
-
import {
|
|
28
|
+
import {
|
|
29
|
+
NavigationMenu,
|
|
30
|
+
type NavigationBadges,
|
|
31
|
+
} from '../../client/layouts/components/navigation-menu.js';
|
|
29
32
|
|
|
30
33
|
// Navigation titles are route data, not keys any namespace owns: the menu translates each through its package's
|
|
31
34
|
// namespace with the title itself as `defaultValue`, so the runtime is not strict.
|
|
@@ -58,7 +61,12 @@ function page(name: string): AppClientRegisteredRoute {
|
|
|
58
61
|
const child = page('child');
|
|
59
62
|
const sibling = page('sibling');
|
|
60
63
|
|
|
61
|
-
function shell(
|
|
64
|
+
function shell(
|
|
65
|
+
items: readonly RouteNavigationItem[],
|
|
66
|
+
selectedKey?: string,
|
|
67
|
+
sections = true,
|
|
68
|
+
badges?: NavigationBadges,
|
|
69
|
+
) {
|
|
62
70
|
return (
|
|
63
71
|
<MemoryRouter>
|
|
64
72
|
<AppSidebarProvider>
|
|
@@ -67,6 +75,8 @@ function shell(items: readonly RouteNavigationItem[], selectedKey?: string) {
|
|
|
67
75
|
items={items}
|
|
68
76
|
label='Menu'
|
|
69
77
|
selectedKey={selectedKey}
|
|
78
|
+
sections={sections}
|
|
79
|
+
badges={badges}
|
|
70
80
|
/>
|
|
71
81
|
</AppSidebar>
|
|
72
82
|
<AppSidebarToggle />
|
|
@@ -99,7 +109,10 @@ describe.each([
|
|
|
99
109
|
{ route: sibling, children: [] },
|
|
100
110
|
],
|
|
101
111
|
};
|
|
102
|
-
|
|
112
|
+
// The collapsible form: a clickable parent always, and a group without a page when sections are off or it is
|
|
113
|
+
// nested. A top-level group without a page is a section, covered below.
|
|
114
|
+
const tree = (selectedKey: string | undefined) =>
|
|
115
|
+
shell([item], selectedKey, false);
|
|
103
116
|
function toggle() {
|
|
104
117
|
return screen.getByRole('button', { name: 'Group' });
|
|
105
118
|
}
|
|
@@ -140,6 +153,61 @@ describe.each([
|
|
|
140
153
|
});
|
|
141
154
|
});
|
|
142
155
|
|
|
156
|
+
describe('sections', () => {
|
|
157
|
+
const group: RouteNavigationItem = {
|
|
158
|
+
route: { ...page('Group'), componentLoader: undefined },
|
|
159
|
+
children: [
|
|
160
|
+
{ route: child, children: [] },
|
|
161
|
+
{ route: sibling, children: [] },
|
|
162
|
+
],
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
it('draws a top-level group without a page as a heading over entries always shown', () => {
|
|
166
|
+
render(shell([{ route: page('Home'), children: [] }, group], 'elsewhere'), {
|
|
167
|
+
wrapper: I18n,
|
|
168
|
+
});
|
|
169
|
+
expect(screen.getByText('Group')).toHaveAttribute(
|
|
170
|
+
'data-sidebar',
|
|
171
|
+
'group-label',
|
|
172
|
+
);
|
|
173
|
+
expect(
|
|
174
|
+
screen.queryByRole('button', { name: 'Group' }),
|
|
175
|
+
).not.toBeInTheDocument();
|
|
176
|
+
expect(screen.getByRole('link', { name: 'child' })).toBeVisible();
|
|
177
|
+
expect(screen.getByRole('link', { name: 'sibling' })).toBeVisible();
|
|
178
|
+
expect(screen.getByRole('link', { name: 'Home' })).toBeVisible();
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it('keeps a group divided into sections as one collapsible entry', () => {
|
|
182
|
+
const divided: RouteNavigationItem = {
|
|
183
|
+
route: { ...page('Flows'), componentLoader: undefined },
|
|
184
|
+
children: [
|
|
185
|
+
{
|
|
186
|
+
section: true,
|
|
187
|
+
route: { ...page('Business'), componentLoader: undefined },
|
|
188
|
+
children: [{ route: child, children: [] }],
|
|
189
|
+
},
|
|
190
|
+
],
|
|
191
|
+
};
|
|
192
|
+
render(shell([divided], routeKey(child)), { wrapper: I18n });
|
|
193
|
+
expect(screen.getByRole('button', { name: 'Flows' })).toHaveAttribute(
|
|
194
|
+
'aria-expanded',
|
|
195
|
+
'true',
|
|
196
|
+
);
|
|
197
|
+
expect(screen.getByRole('group', { name: 'Business' })).toBeVisible();
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it('shows a section entries as icons, not a popup, in the collapsed sidebar', () => {
|
|
201
|
+
setCollapsed(true);
|
|
202
|
+
render(shell([group], routeKey(child)), { wrapper: I18n });
|
|
203
|
+
const link = screen.getByRole('link', { name: 'child' });
|
|
204
|
+
expect(link).toHaveAttribute('aria-current', 'page');
|
|
205
|
+
expect(
|
|
206
|
+
screen.queryByRole('button', { name: 'Group' }),
|
|
207
|
+
).not.toBeInTheDocument();
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
|
|
143
211
|
it('marks the selected page with the selected navigation colors', () => {
|
|
144
212
|
render(
|
|
145
213
|
shell([{ route: page('Home'), children: [] }], routeKey(page('Home'))),
|
|
@@ -156,11 +224,72 @@ it('marks the selected page with the selected navigation colors', () => {
|
|
|
156
224
|
);
|
|
157
225
|
});
|
|
158
226
|
|
|
227
|
+
describe('entry counts', () => {
|
|
228
|
+
const entries: RouteNavigationItem[] = [
|
|
229
|
+
{ route: page('inbox'), children: [] },
|
|
230
|
+
{ route: page('approvals'), children: [] },
|
|
231
|
+
{ route: page('runs'), children: [] },
|
|
232
|
+
{ route: page('archive'), children: [] },
|
|
233
|
+
];
|
|
234
|
+
const badges: NavigationBadges = {
|
|
235
|
+
inbox: 5,
|
|
236
|
+
approvals: 120,
|
|
237
|
+
runs: { count: 2, live: true, hint: '2 running' },
|
|
238
|
+
archive: 0,
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
it('shows a count beside its entry, in the entry name, and nothing at zero', () => {
|
|
242
|
+
render(shell(entries, undefined, true, badges), { wrapper: I18n });
|
|
243
|
+
const inbox = screen.getByRole('link', { name: 'inbox · 5' });
|
|
244
|
+
const badge = inbox.parentElement?.querySelector(
|
|
245
|
+
'[data-slot="sidebar-menu-badge"]',
|
|
246
|
+
);
|
|
247
|
+
expect(badge).toHaveTextContent('5');
|
|
248
|
+
expect(badge).toHaveAttribute('aria-hidden', 'true');
|
|
249
|
+
expect(screen.getByRole('link', { name: 'approvals · 120' })).toBeVisible();
|
|
250
|
+
expect(
|
|
251
|
+
screen
|
|
252
|
+
.getByRole('link', { name: 'approvals · 120' })
|
|
253
|
+
.parentElement?.querySelector('[data-slot="sidebar-menu-badge"]'),
|
|
254
|
+
).toHaveTextContent('99+');
|
|
255
|
+
const archive = screen.getByRole('link', { name: 'archive' });
|
|
256
|
+
expect(
|
|
257
|
+
archive.parentElement?.querySelector('[data-slot="sidebar-menu-badge"]'),
|
|
258
|
+
).toBeNull();
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it('names work under way by its hint', () => {
|
|
262
|
+
render(shell(entries, undefined, true, badges), { wrapper: I18n });
|
|
263
|
+
const runs = screen.getByRole('link', { name: 'runs · 2 running' });
|
|
264
|
+
expect(
|
|
265
|
+
runs.parentElement?.querySelector('[data-slot="sidebar-menu-badge"]'),
|
|
266
|
+
).toHaveAttribute('title', '2 running');
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
it('marks a counted entry with a dot and its count in the tooltip when collapsed', async () => {
|
|
270
|
+
setCollapsed(true);
|
|
271
|
+
const user = userEvent.setup();
|
|
272
|
+
render(shell(entries, undefined, true, badges), { wrapper: I18n });
|
|
273
|
+
const inbox = screen.getByRole('link', { name: 'inbox · 5' });
|
|
274
|
+
expect(
|
|
275
|
+
inbox.parentElement?.querySelector('[data-slot="navigation-badge-dot"]'),
|
|
276
|
+
).not.toBeNull();
|
|
277
|
+
expect(
|
|
278
|
+
screen
|
|
279
|
+
.getByRole('link', { name: 'archive' })
|
|
280
|
+
.parentElement?.querySelector('[data-slot="navigation-badge-dot"]'),
|
|
281
|
+
).toBeNull();
|
|
282
|
+
await user.hover(inbox);
|
|
283
|
+
expect(screen.getByRole('tooltip')).toHaveTextContent('inbox · 5');
|
|
284
|
+
});
|
|
285
|
+
});
|
|
286
|
+
|
|
159
287
|
describe('collapsed navigation', () => {
|
|
160
288
|
beforeEach(() => setCollapsed(true));
|
|
161
289
|
|
|
290
|
+
// A group's popup belongs to the collapsible form, so these render without sections.
|
|
162
291
|
function show(item: RouteNavigationItem, selectedKey = routeKey(child)) {
|
|
163
|
-
return render(shell([item], selectedKey), { wrapper: I18n });
|
|
292
|
+
return render(shell([item], selectedKey, false), { wrapper: I18n });
|
|
164
293
|
}
|
|
165
294
|
|
|
166
295
|
it('shows a leaf label immediately on hover and dismisses it on leave', async () => {
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import type { AppClientRegisteredRoute } from '@nocobase/app-client/plugins';
|
|
2
|
-
import { act, render, screen, waitFor
|
|
3
|
-
import userEvent from '@testing-library/user-event';
|
|
2
|
+
import { act, render, screen, waitFor } from '@testing-library/react';
|
|
4
3
|
import { MemoryRouter } from 'react-router';
|
|
5
4
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
6
5
|
import {
|
|
@@ -80,19 +79,10 @@ describe.each(['expanded', 'collapsed', 'mobile'])(
|
|
|
80
79
|
await act(async () => {
|
|
81
80
|
resolve.forEach((done, i) => done(i === 0));
|
|
82
81
|
});
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
await
|
|
86
|
-
|
|
87
|
-
within(await screen.findByRole('dialog')).getByRole('link', {
|
|
88
|
-
name: 'Allowed',
|
|
89
|
-
}),
|
|
90
|
-
).toHaveAttribute('href', '/Allowed');
|
|
91
|
-
} else {
|
|
92
|
-
expect(
|
|
93
|
-
await screen.findByRole('link', { name: 'Allowed' }),
|
|
94
|
-
).toBeVisible();
|
|
95
|
-
}
|
|
82
|
+
// Group is a section: its entries show in every mode, as icons in the collapsed sidebar.
|
|
83
|
+
expect(
|
|
84
|
+
await screen.findByRole('link', { name: 'Allowed' }),
|
|
85
|
+
).toHaveAttribute('href', '/Allowed');
|
|
96
86
|
expect(screen.queryByText('Denied')).not.toBeInTheDocument();
|
|
97
87
|
expect(screen.queryByText('Empty')).not.toBeInTheDocument();
|
|
98
88
|
auth.client.can.mockResolvedValue(false);
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { render, screen } from '@testing-library/react';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
|
|
4
|
+
import { StatusBadge } from '#components/status-badge';
|
|
5
|
+
|
|
6
|
+
// The two badge sizes are drawn by client/styles.css from `data-size`, which jsdom does not apply, so this checks the
|
|
7
|
+
// attribute that selects them.
|
|
8
|
+
describe('StatusBadge', () => {
|
|
9
|
+
it('is the default badge unless asked for the small one', () => {
|
|
10
|
+
render(
|
|
11
|
+
<>
|
|
12
|
+
<StatusBadge tone='success'>Done</StatusBadge>
|
|
13
|
+
<StatusBadge tone='warning' size='sm'>
|
|
14
|
+
Waiting
|
|
15
|
+
</StatusBadge>
|
|
16
|
+
</>,
|
|
17
|
+
);
|
|
18
|
+
expect(screen.getByText('Done')).not.toHaveAttribute('data-size');
|
|
19
|
+
expect(screen.getByText('Waiting')).toHaveAttribute('data-size', 'sm');
|
|
20
|
+
expect(screen.getByText('Waiting')).toHaveAttribute('data-slot', 'badge');
|
|
21
|
+
});
|
|
22
|
+
});
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { revealScrollbarsWhileScrolling } from '../../client/lib/scrollbar';
|
|
4
|
+
|
|
5
|
+
describe('revealScrollbarsWhileScrolling', () => {
|
|
6
|
+
beforeAll(() => revealScrollbarsWhileScrolling());
|
|
7
|
+
afterEach(() => vi.useRealTimers());
|
|
8
|
+
|
|
9
|
+
function scroll(target: EventTarget) {
|
|
10
|
+
target.dispatchEvent(new Event('scroll'));
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
it('marks a scrolling element until it has been still for a moment', () => {
|
|
14
|
+
vi.useFakeTimers();
|
|
15
|
+
const list = document.createElement('div');
|
|
16
|
+
document.body.append(list);
|
|
17
|
+
scroll(list);
|
|
18
|
+
expect(list).toHaveAttribute('data-scrolling');
|
|
19
|
+
vi.advanceTimersByTime(799);
|
|
20
|
+
expect(list).toHaveAttribute('data-scrolling');
|
|
21
|
+
vi.advanceTimersByTime(1);
|
|
22
|
+
expect(list).not.toHaveAttribute('data-scrolling');
|
|
23
|
+
list.remove();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('keeps the mark while scrolling goes on', () => {
|
|
27
|
+
vi.useFakeTimers();
|
|
28
|
+
const list = document.createElement('div');
|
|
29
|
+
document.body.append(list);
|
|
30
|
+
scroll(list);
|
|
31
|
+
vi.advanceTimersByTime(600);
|
|
32
|
+
scroll(list);
|
|
33
|
+
vi.advanceTimersByTime(600);
|
|
34
|
+
expect(list).toHaveAttribute('data-scrolling');
|
|
35
|
+
vi.advanceTimersByTime(200);
|
|
36
|
+
expect(list).not.toHaveAttribute('data-scrolling');
|
|
37
|
+
list.remove();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('marks the page root when the page itself scrolls', () => {
|
|
41
|
+
vi.useFakeTimers();
|
|
42
|
+
scroll(document);
|
|
43
|
+
expect(
|
|
44
|
+
document.scrollingElement ?? document.documentElement,
|
|
45
|
+
).toHaveAttribute('data-scrolling');
|
|
46
|
+
vi.advanceTimersByTime(800);
|
|
47
|
+
expect(
|
|
48
|
+
document.scrollingElement ?? document.documentElement,
|
|
49
|
+
).not.toHaveAttribute('data-scrolling');
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe('revealScrollbarsWhileScrolling near an edge', () => {
|
|
54
|
+
function scroller(): HTMLDivElement {
|
|
55
|
+
const element = document.createElement('div');
|
|
56
|
+
element.style.overflowY = 'auto';
|
|
57
|
+
Object.defineProperty(element, 'scrollHeight', { value: 500 });
|
|
58
|
+
Object.defineProperty(element, 'clientHeight', { value: 100 });
|
|
59
|
+
element.getBoundingClientRect = () =>
|
|
60
|
+
({ left: 0, top: 0, right: 200, bottom: 100 }) as DOMRect;
|
|
61
|
+
document.body.append(element);
|
|
62
|
+
return element;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function move(target: Element, clientX: number) {
|
|
66
|
+
const event = new MouseEvent('pointermove', {
|
|
67
|
+
bubbles: true,
|
|
68
|
+
clientX,
|
|
69
|
+
clientY: 50,
|
|
70
|
+
});
|
|
71
|
+
Object.defineProperty(event, 'pointerType', { value: 'mouse' });
|
|
72
|
+
target.dispatchEvent(event);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
it('shows the bar while the mouse is near the edge it runs along', () => {
|
|
76
|
+
vi.useFakeTimers();
|
|
77
|
+
const list = scroller();
|
|
78
|
+
move(list, 100);
|
|
79
|
+
vi.advanceTimersByTime(20);
|
|
80
|
+
expect(list).not.toHaveAttribute('data-scrolling');
|
|
81
|
+
move(list, 195);
|
|
82
|
+
vi.advanceTimersByTime(20);
|
|
83
|
+
expect(list).toHaveAttribute('data-scrolling');
|
|
84
|
+
vi.advanceTimersByTime(800);
|
|
85
|
+
expect(list).not.toHaveAttribute('data-scrolling');
|
|
86
|
+
list.remove();
|
|
87
|
+
});
|
|
88
|
+
});
|
package/vitest.config.ts
CHANGED
|
@@ -15,6 +15,7 @@ export default createReactVitestConfig({
|
|
|
15
15
|
// together with every client that resolves the authentication tokens and provider.
|
|
16
16
|
/@nocobase\/app-plugin-(?:authentication|api-keys|users)\/(?:dist\/)?client\//u,
|
|
17
17
|
/@nocobase\/app-plugin-authorization\/(?:dist\/)?client\//u,
|
|
18
|
+
/@nocobase\/app-plugin-(?:lifecycle-example|office-flows-example)\/(?:dist\/)?client\//u,
|
|
18
19
|
/@nocobase\/app-plugin-notification-in-app\/(?:dist\/)?client\//u,
|
|
19
20
|
],
|
|
20
21
|
},
|