@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.
Files changed (36) hide show
  1. package/.agents/rules/components.md +1 -1
  2. package/.agents/rules/shell.md +3 -1
  3. package/client/components/page-header.tsx +1 -1
  4. package/client/components/status-badge.tsx +7 -1
  5. package/client/extensions/nocobase-inbox/inbox-item.tsx +44 -58
  6. package/client/extensions/nocobase-inbox/inbox-list.tsx +73 -75
  7. package/client/index.tsx +4 -0
  8. package/client/layouts/components/app-sidebar.tsx +5 -5
  9. package/client/layouts/components/layout-header.tsx +1 -1
  10. package/client/layouts/components/navigation-menu.tsx +223 -12
  11. package/client/layouts/components/sidebar-footer.tsx +1 -1
  12. package/client/layouts/components/user-menu.tsx +1 -1
  13. package/client/lib/navigation-transform.ts +8 -0
  14. package/client/lib/scrollbar.ts +88 -0
  15. package/client/locales/system/en-US.ts +4 -0
  16. package/client/locales/system/zh-CN.ts +15 -12
  17. package/client/pages/auth/auth-page.css +37 -0
  18. package/client/pages/auth/reset-password.tsx +57 -14
  19. package/client/pages/auth/shared.tsx +24 -24
  20. package/client/pages/home/index.tsx +2 -2
  21. package/client/routing/route-navigation.ts +10 -1
  22. package/client/styles.css +253 -17
  23. package/client/theme/theme-settings.tsx +12 -7
  24. package/client/theme/themes/compact.css +9 -14
  25. package/client/theme/themes/default.css +7 -12
  26. package/package.json +7 -7
  27. package/server/app.ts +2 -0
  28. package/server/config/notification.ts +4 -7
  29. package/tests/components/auth-i18n.test.tsx +57 -5
  30. package/tests/components/layout-i18n.test.tsx +2 -2
  31. package/tests/components/navigation-menu.test.tsx +133 -4
  32. package/tests/components/sidebar-permissions.test.tsx +5 -15
  33. package/tests/components/status-badge.test.tsx +22 -0
  34. package/tests/logic/scrollbar.test.ts +88 -0
  35. package/tests/logic/theme-tokens.test.ts +1 -0
  36. 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.67",
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.67"
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.34",
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.7",
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.11",
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.5",
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.1",
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
- defineAppConfig,
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> = defineAppConfig(
8
- (_runtime) => ({ channels: {} }),
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: true },
18
- isPending: false,
19
- isError: false,
20
- refetch: vi.fn(),
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: () => ({ isPending: false, submit: vi.fn() }),
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 自由构建。')).toBeVisible();
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 { NavigationMenu } from '../../client/layouts/components/navigation-menu.js';
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(items: readonly RouteNavigationItem[], selectedKey?: string) {
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
- const tree = (selectedKey: string | undefined) => shell([item], selectedKey);
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, within } from '@testing-library/react';
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
- const user = userEvent.setup();
84
- if (mode === 'collapsed') {
85
- await user.hover(await screen.findByRole('button', { name: 'Group' }));
86
- expect(
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
+ });
@@ -40,6 +40,7 @@ const colors = [
40
40
  'muted-foreground',
41
41
  'accent',
42
42
  'accent-foreground',
43
+ 'hover',
43
44
  'destructive',
44
45
  'border',
45
46
  'input',
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
  },