@nocobase/app-template-default 1.0.0-beta.67 → 1.0.0-beta.69

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 (87) hide show
  1. package/.agents/rules/components.md +2 -2
  2. package/.agents/rules/shell.md +5 -1
  3. package/AGENTS.md +5 -3
  4. package/Dockerfile.dockerignore +0 -1
  5. package/README.MD +3 -1
  6. package/client/app.ts +15 -0
  7. package/client/components/data-table/column-header.tsx +91 -0
  8. package/client/components/data-table/data-table.tsx +216 -0
  9. package/client/components/data-table/index.ts +13 -0
  10. package/client/components/data-table/pagination.tsx +165 -0
  11. package/client/components/data-table/view-options.tsx +77 -0
  12. package/client/components/page-header.tsx +1 -1
  13. package/client/components/permission-editor.tsx +589 -0
  14. package/client/components/permission-filter.tsx +455 -0
  15. package/client/components/route-overlay.tsx +26 -13
  16. package/client/components/status-badge.tsx +7 -1
  17. package/client/components/ui/checkbox.tsx +25 -0
  18. package/client/components/ui/sidebar.tsx +1 -4
  19. package/client/components/ui/switch.tsx +30 -0
  20. package/client/components/ui/table.tsx +113 -0
  21. package/client/extensions/nocobase-inbox/inbox-item.tsx +44 -58
  22. package/client/extensions/nocobase-inbox/inbox-list.tsx +73 -75
  23. package/client/index.tsx +4 -0
  24. package/client/layouts/components/app-sidebar.tsx +6 -6
  25. package/client/layouts/components/layout-header.tsx +1 -1
  26. package/client/layouts/components/navigation-menu.tsx +223 -12
  27. package/client/layouts/components/sidebar-footer.tsx +1 -1
  28. package/client/layouts/components/user-menu.tsx +1 -1
  29. package/client/layouts/use-sidebar-preference.ts +62 -42
  30. package/client/lib/navigation-transform.ts +8 -0
  31. package/client/lib/scrollbar.ts +88 -0
  32. package/client/locales/system/en-US.ts +11 -0
  33. package/client/locales/system/settings-en-US.ts +141 -0
  34. package/client/locales/system/settings-zh-CN.ts +135 -0
  35. package/client/locales/system/zh-CN.ts +22 -12
  36. package/client/pages/auth/auth-page.css +52 -0
  37. package/client/pages/auth/login.tsx +1 -1
  38. package/client/pages/auth/reset-password.tsx +57 -14
  39. package/client/pages/auth/shared.tsx +26 -24
  40. package/client/pages/home/index.tsx +2 -2
  41. package/client/pages/settings/hooks.tsx +137 -0
  42. package/client/pages/settings/permission-filters.tsx +119 -0
  43. package/client/pages/settings/permissions-model.ts +260 -0
  44. package/client/pages/settings/role-form.tsx +426 -0
  45. package/client/pages/settings/roles.tsx +163 -0
  46. package/client/pages/settings/routes.ts +79 -0
  47. package/client/pages/settings/shared.tsx +119 -0
  48. package/client/pages/settings/user-detail.tsx +268 -0
  49. package/client/pages/settings/user-form.tsx +427 -0
  50. package/client/pages/settings/users.tsx +451 -0
  51. package/client/routing/app-router.tsx +4 -1
  52. package/client/routing/route-navigation.ts +10 -1
  53. package/client/routing/session-preparation.tsx +65 -0
  54. package/client/styles.css +257 -24
  55. package/client/theme/theme-settings.tsx +12 -7
  56. package/client/theme/themes/compact.css +9 -14
  57. package/client/theme/themes/default.css +7 -12
  58. package/package.json +26 -25
  59. package/server/app.ts +2 -0
  60. package/server/config/notification.ts +4 -7
  61. package/tests/components/app-layout-routes.test.tsx +14 -7
  62. package/tests/components/auth-i18n.test.tsx +57 -5
  63. package/tests/components/data-table.test.tsx +127 -0
  64. package/tests/components/layout-i18n.test.tsx +10 -3
  65. package/tests/components/navigation-menu.test.tsx +155 -16
  66. package/tests/components/permission-editor.test.tsx +477 -0
  67. package/tests/components/permission-filter-binding.test.tsx +150 -0
  68. package/tests/components/permission-filter.test.tsx +202 -0
  69. package/tests/components/settings.test.tsx +294 -0
  70. package/tests/components/sidebar-permissions.test.tsx +17 -19
  71. package/tests/components/sidebar-preference.test.tsx +209 -14
  72. package/tests/components/status-badge.test.tsx +22 -0
  73. package/tests/components/user-permissions.test.tsx +142 -0
  74. package/tests/fixtures/theme-tokens.tsx +9 -1
  75. package/tests/logic/asset-url-build.test.ts +0 -1
  76. package/tests/logic/client-routes.test.ts +40 -0
  77. package/tests/logic/client-shell.test.tsx +10 -2
  78. package/tests/logic/config-example.test.ts +30 -0
  79. package/tests/logic/dev-client-watch.test.ts +8 -1
  80. package/tests/logic/route-overlay.test.tsx +42 -0
  81. package/tests/logic/scrollbar.test.ts +88 -0
  82. package/tests/logic/session-preparation.test.tsx +223 -0
  83. package/tests/logic/settings-permissions.test.ts +168 -0
  84. package/tests/logic/theme-tokens.test.ts +1 -0
  85. package/vite.config.ts +0 -22
  86. package/vitest.config.ts +1 -0
  87. package/client/agent-annotations-host.ts +0 -25
@@ -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;
@@ -10,6 +10,10 @@ import { MemoryRouter, Route, Routes } from 'react-router';
10
10
  import { afterEach, beforeEach, expect, it, vi } from 'vitest';
11
11
 
12
12
  import { AppLayout } from '../../client/layouts/app-layout.js';
13
+ import {
14
+ createAppSidebarPreferenceStore,
15
+ SidebarPreferenceProvider,
16
+ } from '../../client/layouts/use-sidebar-preference.js';
13
17
  import enUS from '../../client/locales/en-US.js';
14
18
 
15
19
  // Breadcrumb titles are route data, not keys any namespace owns: the trail translates each through its package's
@@ -87,14 +91,17 @@ function OrderPage(): ReactElement {
87
91
  }
88
92
 
89
93
  function renderLayout(page: ReactElement): HTMLElement {
94
+ const sidebarPreference = createAppSidebarPreferenceStore('/test-app');
90
95
  render(
91
- <MemoryRouter initialEntries={['/orders/42']}>
92
- <Routes>
93
- <Route element={<AppLayout routes={routes} />}>
94
- <Route path='/orders/:id' element={page} />
95
- </Route>
96
- </Routes>
97
- </MemoryRouter>,
96
+ <SidebarPreferenceProvider value={sidebarPreference}>
97
+ <MemoryRouter initialEntries={['/orders/42']}>
98
+ <Routes>
99
+ <Route element={<AppLayout routes={routes} />}>
100
+ <Route path='/orders/:id' element={page} />
101
+ </Route>
102
+ </Routes>
103
+ </MemoryRouter>
104
+ </SidebarPreferenceProvider>,
98
105
  { wrapper: I18n },
99
106
  );
100
107
  return screen.getByRole('banner');
@@ -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 />);
@@ -0,0 +1,127 @@
1
+ import { renderWithApp } from '@nocobase/app-testing/client';
2
+ import type { ColumnDef, PaginationState } from '@tanstack/react-table';
3
+ import { screen, within } from '@testing-library/react';
4
+ import userEvent from '@testing-library/user-event';
5
+ import { useState } from 'react';
6
+ import { describe, it, expect } from 'vitest';
7
+ import {
8
+ DataTable,
9
+ DataTableColumnHeader,
10
+ DataTableViewOptions,
11
+ } from '../../client/components/data-table/index.js';
12
+ import enUS from '../../client/locales/en-US.js';
13
+ import zhCN from '../../client/locales/zh-CN.js';
14
+ const namespace = '@nocobase/datatable-test';
15
+ const namespaces = {
16
+ [namespace]: {
17
+ 'en-US': async () => ({ default: enUS }),
18
+ 'zh-CN': async () => ({ default: zhCN }),
19
+ },
20
+ };
21
+ type Item = { id: string; amount: number };
22
+ const rows: Item[] = Array.from({ length: 12 }, (_, amount) => ({
23
+ id: `row-${amount}`,
24
+ amount,
25
+ }));
26
+ const columns: ColumnDef<Item>[] = [
27
+ { accessorKey: 'id', header: 'ID', enableHiding: false },
28
+ {
29
+ accessorKey: 'amount',
30
+ header: ({ column }) => (
31
+ <DataTableColumnHeader column={column} title='Amount' />
32
+ ),
33
+ },
34
+ ];
35
+ describe('standard DataTable', () => {
36
+ it('uses real TanStack sorting, pagination and column visibility with restoration', async () => {
37
+ const user = userEvent.setup();
38
+ await renderWithApp(
39
+ <DataTable
40
+ columns={columns}
41
+ data={rows}
42
+ getRowId={(row) => row.id}
43
+ showSelectedCount={false}
44
+ toolbar={(table) => (
45
+ <DataTableViewOptions table={table} getColumnLabel={() => 'Amount'} />
46
+ )}
47
+ />,
48
+ { namespace, namespaces },
49
+ );
50
+ expect(screen.getByText('Page 1 of 2')).toBeVisible();
51
+ expect(screen.queryByText('row-10')).not.toBeInTheDocument();
52
+ await user.click(screen.getByRole('button', { name: 'Go to next page' }));
53
+ expect(screen.getByText('row-10')).toBeVisible();
54
+ await user.click(
55
+ screen.getByRole('button', { name: 'Go to previous page' }),
56
+ );
57
+ await user.click(
58
+ screen.getByRole('button', { name: 'Amount', exact: true }),
59
+ );
60
+ await user.click(
61
+ await screen.findByRole('menuitem', { name: 'Desc', exact: true }),
62
+ );
63
+ expect(
64
+ within(screen.getAllByRole('row')[1]!).getByText('row-11'),
65
+ ).toBeVisible();
66
+ await user.click(screen.getByRole('button', { name: 'View', exact: true }));
67
+ await user.click(
68
+ await screen.findByRole('menuitemcheckbox', {
69
+ name: 'Amount',
70
+ exact: true,
71
+ }),
72
+ );
73
+ expect(screen.getAllByRole('columnheader', { hidden: true })).toHaveLength(
74
+ 1,
75
+ );
76
+ await user.click(
77
+ await screen.findByRole('menuitemcheckbox', {
78
+ name: 'Amount',
79
+ exact: true,
80
+ }),
81
+ );
82
+ expect(screen.getByRole('columnheader', { name: 'Amount' })).toBeVisible();
83
+ });
84
+ it('preserves the server page and sort instead of applying client paging or sorting again', async () => {
85
+ const user = userEvent.setup();
86
+ const pageRows = rows.slice(0, 2);
87
+ function ServerPage() {
88
+ const [page, setPage] = useState<PaginationState>({
89
+ pageIndex: 2,
90
+ pageSize: 2,
91
+ });
92
+ return (
93
+ <DataTable
94
+ columns={columns}
95
+ data={pageRows}
96
+ manualPagination
97
+ rowCount={12}
98
+ paginationState={page}
99
+ onPaginationChange={setPage}
100
+ manualSorting
101
+ sortingState={[{ id: 'amount', desc: true }]}
102
+ showSelectedCount={false}
103
+ pageSizeOptions={[2]}
104
+ />
105
+ );
106
+ }
107
+ await renderWithApp(<ServerPage />, { namespace, namespaces });
108
+ expect(screen.getByText('Page 3 of 6')).toBeVisible();
109
+ expect(screen.getByText('row-0')).toBeVisible();
110
+ expect(
111
+ within(screen.getAllByRole('row')[1]!).getByText('row-0'),
112
+ ).toBeVisible();
113
+ await user.click(screen.getByRole('button', { name: 'Go to next page' }));
114
+ expect(screen.getByText('Page 4 of 6')).toBeVisible();
115
+ expect(screen.getByText('row-0')).toBeVisible();
116
+ });
117
+ it('keeps empty-state and paging controls localized in Chinese', async () => {
118
+ await renderWithApp(
119
+ <DataTable columns={columns} data={[]} showSelectedCount={false} />,
120
+ { namespace, namespaces, locale: 'zh-CN' },
121
+ );
122
+ expect(screen.getByText(zhCN.dataTable.noResults)).toBeVisible();
123
+ expect(
124
+ screen.getByRole('button', { name: zhCN.dataTable.nextPage }),
125
+ ).toBeDisabled();
126
+ });
127
+ });
@@ -13,6 +13,10 @@ import { describe, expect, it, vi } from 'vitest';
13
13
 
14
14
  import locales from '../../client/locales/index.js';
15
15
  import { AppLayout } from '../../client/layouts/app-layout.js';
16
+ import {
17
+ createAppSidebarPreferenceStore,
18
+ SidebarPreferenceProvider,
19
+ } from '../../client/layouts/use-sidebar-preference.js';
16
20
  vi.mock('@nocobase/app-plugin-i18n/client', () => ({
17
21
  useSyncServerLocale: () => {},
18
22
  }));
@@ -45,6 +49,7 @@ const overview: AppClientRegisteredRoute = {
45
49
  };
46
50
 
47
51
  async function setup(children: ReactNode) {
52
+ const sidebarPreference = createAppSidebarPreferenceStore('/test-app');
48
53
  vi.stubGlobal('matchMedia', () => ({
49
54
  matches: true,
50
55
  addEventListener: vi.fn(),
@@ -64,7 +69,9 @@ async function setup(children: ReactNode) {
64
69
  render(
65
70
  <I18nProvider runtime={runtime}>
66
71
  <ClientApplicationContext.Provider value={app}>
67
- <MemoryRouter initialEntries={['/']}>{children}</MemoryRouter>
72
+ <SidebarPreferenceProvider value={sidebarPreference}>
73
+ <MemoryRouter initialEntries={['/']}>{children}</MemoryRouter>
74
+ </SidebarPreferenceProvider>
68
75
  </ClientApplicationContext.Provider>
69
76
  </I18nProvider>,
70
77
  );
@@ -81,10 +88,10 @@ describe('shell translations', () => {
81
88
  expect(screen.getByText('AI builds freely.')).toBeVisible();
82
89
  await act(() => runtime.changeLanguage('zh-CN'));
83
90
  expect(screen.getByRole('navigation', { name: '面包屑' })).toBeVisible();
84
- expect(screen.getByText('AI 自由构建。')).toBeVisible();
91
+ expect(screen.getByText('AI 自由构建')).toBeVisible();
85
92
  expect(
86
93
  screen.getByRole('link', { name: 'NocoBase' }).parentElement,
87
- ).toHaveTextContent('NocoBase 保障可靠。');
94
+ ).toHaveTextContent('NocoBase 保障可靠');
88
95
  expect(
89
96
  screen.getByRole('button', { name: '展开或收起导航' }),
90
97
  ).toHaveAttribute('title', '展开或收起导航');
@@ -1,3 +1,7 @@
1
+ import {
2
+ createAppSidebarPreferenceStore,
3
+ SidebarPreferenceProvider,
4
+ } from '../../client/layouts/use-sidebar-preference.js';
1
5
  import type { AppClientRegisteredRoute } from '@nocobase/app-client/plugins';
2
6
  import {
3
7
  act,
@@ -25,7 +29,10 @@ import {
25
29
  AppSidebarProvider,
26
30
  AppSidebarToggle,
27
31
  } from '../../client/layouts/components/app-sidebar.js';
28
- import { NavigationMenu } from '../../client/layouts/components/navigation-menu.js';
32
+ import {
33
+ NavigationMenu,
34
+ type NavigationBadges,
35
+ } from '../../client/layouts/components/navigation-menu.js';
29
36
 
30
37
  // Navigation titles are route data, not keys any namespace owns: the menu translates each through its package's
31
38
  // namespace with the title itself as `defaultValue`, so the runtime is not strict.
@@ -35,11 +42,15 @@ function I18n({ children }: { readonly children: ReactNode }) {
35
42
  return <TestI18nProvider runtime={runtime}>{children}</TestI18nProvider>;
36
43
  }
37
44
 
38
- const preferenceKey = 'nocobase:sidebar:collapsed';
45
+ const sidebarPreference = createAppSidebarPreferenceStore('/main');
46
+
47
+ const preferenceKey = 'nocobase:sidebar:collapsed:app:/main';
39
48
 
40
49
  function setCollapsed(collapsed: boolean) {
41
50
  localStorage.setItem(preferenceKey, String(collapsed));
42
- window.dispatchEvent(new Event('nocobase:sidebar-preference-change'));
51
+ window.dispatchEvent(
52
+ new Event('nocobase:sidebar-preference-change:app:/main'),
53
+ );
43
54
  }
44
55
 
45
56
  function page(name: string): AppClientRegisteredRoute {
@@ -58,19 +69,28 @@ function page(name: string): AppClientRegisteredRoute {
58
69
  const child = page('child');
59
70
  const sibling = page('sibling');
60
71
 
61
- function shell(items: readonly RouteNavigationItem[], selectedKey?: string) {
72
+ function shell(
73
+ items: readonly RouteNavigationItem[],
74
+ selectedKey?: string,
75
+ sections = true,
76
+ badges?: NavigationBadges,
77
+ ) {
62
78
  return (
63
79
  <MemoryRouter>
64
- <AppSidebarProvider>
65
- <AppSidebar label='Navigation'>
66
- <NavigationMenu
67
- items={items}
68
- label='Menu'
69
- selectedKey={selectedKey}
70
- />
71
- </AppSidebar>
72
- <AppSidebarToggle />
73
- </AppSidebarProvider>
80
+ <SidebarPreferenceProvider value={sidebarPreference}>
81
+ <AppSidebarProvider>
82
+ <AppSidebar label='Navigation'>
83
+ <NavigationMenu
84
+ items={items}
85
+ label='Menu'
86
+ selectedKey={selectedKey}
87
+ sections={sections}
88
+ badges={badges}
89
+ />
90
+ </AppSidebar>
91
+ <AppSidebarToggle />
92
+ </AppSidebarProvider>
93
+ </SidebarPreferenceProvider>
74
94
  </MemoryRouter>
75
95
  );
76
96
  }
@@ -99,7 +119,10 @@ describe.each([
99
119
  { route: sibling, children: [] },
100
120
  ],
101
121
  };
102
- const tree = (selectedKey: string | undefined) => shell([item], selectedKey);
122
+ // The collapsible form: a clickable parent always, and a group without a page when sections are off or it is
123
+ // nested. A top-level group without a page is a section, covered below.
124
+ const tree = (selectedKey: string | undefined) =>
125
+ shell([item], selectedKey, false);
103
126
  function toggle() {
104
127
  return screen.getByRole('button', { name: 'Group' });
105
128
  }
@@ -140,6 +163,61 @@ describe.each([
140
163
  });
141
164
  });
142
165
 
166
+ describe('sections', () => {
167
+ const group: RouteNavigationItem = {
168
+ route: { ...page('Group'), componentLoader: undefined },
169
+ children: [
170
+ { route: child, children: [] },
171
+ { route: sibling, children: [] },
172
+ ],
173
+ };
174
+
175
+ it('draws a top-level group without a page as a heading over entries always shown', () => {
176
+ render(shell([{ route: page('Home'), children: [] }, group], 'elsewhere'), {
177
+ wrapper: I18n,
178
+ });
179
+ expect(screen.getByText('Group')).toHaveAttribute(
180
+ 'data-sidebar',
181
+ 'group-label',
182
+ );
183
+ expect(
184
+ screen.queryByRole('button', { name: 'Group' }),
185
+ ).not.toBeInTheDocument();
186
+ expect(screen.getByRole('link', { name: 'child' })).toBeVisible();
187
+ expect(screen.getByRole('link', { name: 'sibling' })).toBeVisible();
188
+ expect(screen.getByRole('link', { name: 'Home' })).toBeVisible();
189
+ });
190
+
191
+ it('keeps a group divided into sections as one collapsible entry', () => {
192
+ const divided: RouteNavigationItem = {
193
+ route: { ...page('Flows'), componentLoader: undefined },
194
+ children: [
195
+ {
196
+ section: true,
197
+ route: { ...page('Business'), componentLoader: undefined },
198
+ children: [{ route: child, children: [] }],
199
+ },
200
+ ],
201
+ };
202
+ render(shell([divided], routeKey(child)), { wrapper: I18n });
203
+ expect(screen.getByRole('button', { name: 'Flows' })).toHaveAttribute(
204
+ 'aria-expanded',
205
+ 'true',
206
+ );
207
+ expect(screen.getByRole('group', { name: 'Business' })).toBeVisible();
208
+ });
209
+
210
+ it('shows a section entries as icons, not a popup, in the collapsed sidebar', () => {
211
+ setCollapsed(true);
212
+ render(shell([group], routeKey(child)), { wrapper: I18n });
213
+ const link = screen.getByRole('link', { name: 'child' });
214
+ expect(link).toHaveAttribute('aria-current', 'page');
215
+ expect(
216
+ screen.queryByRole('button', { name: 'Group' }),
217
+ ).not.toBeInTheDocument();
218
+ });
219
+ });
220
+
143
221
  it('marks the selected page with the selected navigation colors', () => {
144
222
  render(
145
223
  shell([{ route: page('Home'), children: [] }], routeKey(page('Home'))),
@@ -156,11 +234,72 @@ it('marks the selected page with the selected navigation colors', () => {
156
234
  );
157
235
  });
158
236
 
237
+ describe('entry counts', () => {
238
+ const entries: RouteNavigationItem[] = [
239
+ { route: page('inbox'), children: [] },
240
+ { route: page('approvals'), children: [] },
241
+ { route: page('runs'), children: [] },
242
+ { route: page('archive'), children: [] },
243
+ ];
244
+ const badges: NavigationBadges = {
245
+ inbox: 5,
246
+ approvals: 120,
247
+ runs: { count: 2, live: true, hint: '2 running' },
248
+ archive: 0,
249
+ };
250
+
251
+ it('shows a count beside its entry, in the entry name, and nothing at zero', () => {
252
+ render(shell(entries, undefined, true, badges), { wrapper: I18n });
253
+ const inbox = screen.getByRole('link', { name: 'inbox · 5' });
254
+ const badge = inbox.parentElement?.querySelector(
255
+ '[data-slot="sidebar-menu-badge"]',
256
+ );
257
+ expect(badge).toHaveTextContent('5');
258
+ expect(badge).toHaveAttribute('aria-hidden', 'true');
259
+ expect(screen.getByRole('link', { name: 'approvals · 120' })).toBeVisible();
260
+ expect(
261
+ screen
262
+ .getByRole('link', { name: 'approvals · 120' })
263
+ .parentElement?.querySelector('[data-slot="sidebar-menu-badge"]'),
264
+ ).toHaveTextContent('99+');
265
+ const archive = screen.getByRole('link', { name: 'archive' });
266
+ expect(
267
+ archive.parentElement?.querySelector('[data-slot="sidebar-menu-badge"]'),
268
+ ).toBeNull();
269
+ });
270
+
271
+ it('names work under way by its hint', () => {
272
+ render(shell(entries, undefined, true, badges), { wrapper: I18n });
273
+ const runs = screen.getByRole('link', { name: 'runs · 2 running' });
274
+ expect(
275
+ runs.parentElement?.querySelector('[data-slot="sidebar-menu-badge"]'),
276
+ ).toHaveAttribute('title', '2 running');
277
+ });
278
+
279
+ it('marks a counted entry with a dot and its count in the tooltip when collapsed', async () => {
280
+ setCollapsed(true);
281
+ const user = userEvent.setup();
282
+ render(shell(entries, undefined, true, badges), { wrapper: I18n });
283
+ const inbox = screen.getByRole('link', { name: 'inbox · 5' });
284
+ expect(
285
+ inbox.parentElement?.querySelector('[data-slot="navigation-badge-dot"]'),
286
+ ).not.toBeNull();
287
+ expect(
288
+ screen
289
+ .getByRole('link', { name: 'archive' })
290
+ .parentElement?.querySelector('[data-slot="navigation-badge-dot"]'),
291
+ ).toBeNull();
292
+ await user.hover(inbox);
293
+ expect(screen.getByRole('tooltip')).toHaveTextContent('inbox · 5');
294
+ });
295
+ });
296
+
159
297
  describe('collapsed navigation', () => {
160
298
  beforeEach(() => setCollapsed(true));
161
299
 
300
+ // A group's popup belongs to the collapsible form, so these render without sections.
162
301
  function show(item: RouteNavigationItem, selectedKey = routeKey(child)) {
163
- return render(shell([item], selectedKey), { wrapper: I18n });
302
+ return render(shell([item], selectedKey, false), { wrapper: I18n });
164
303
  }
165
304
 
166
305
  it('shows a leaf label immediately on hover and dismisses it on leave', async () => {