@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.
- package/.agents/rules/components.md +2 -2
- package/.agents/rules/shell.md +5 -1
- package/AGENTS.md +5 -3
- package/Dockerfile.dockerignore +0 -1
- package/README.MD +3 -1
- package/client/app.ts +15 -0
- package/client/components/data-table/column-header.tsx +91 -0
- package/client/components/data-table/data-table.tsx +216 -0
- package/client/components/data-table/index.ts +13 -0
- package/client/components/data-table/pagination.tsx +165 -0
- package/client/components/data-table/view-options.tsx +77 -0
- package/client/components/page-header.tsx +1 -1
- package/client/components/permission-editor.tsx +589 -0
- package/client/components/permission-filter.tsx +455 -0
- package/client/components/route-overlay.tsx +26 -13
- package/client/components/status-badge.tsx +7 -1
- package/client/components/ui/checkbox.tsx +25 -0
- package/client/components/ui/sidebar.tsx +1 -4
- package/client/components/ui/switch.tsx +30 -0
- package/client/components/ui/table.tsx +113 -0
- 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 +6 -6
- 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/layouts/use-sidebar-preference.ts +62 -42
- package/client/lib/navigation-transform.ts +8 -0
- package/client/lib/scrollbar.ts +88 -0
- package/client/locales/system/en-US.ts +11 -0
- package/client/locales/system/settings-en-US.ts +141 -0
- package/client/locales/system/settings-zh-CN.ts +135 -0
- package/client/locales/system/zh-CN.ts +22 -12
- package/client/pages/auth/auth-page.css +52 -0
- package/client/pages/auth/login.tsx +1 -1
- package/client/pages/auth/reset-password.tsx +57 -14
- package/client/pages/auth/shared.tsx +26 -24
- package/client/pages/home/index.tsx +2 -2
- package/client/pages/settings/hooks.tsx +137 -0
- package/client/pages/settings/permission-filters.tsx +119 -0
- package/client/pages/settings/permissions-model.ts +260 -0
- package/client/pages/settings/role-form.tsx +426 -0
- package/client/pages/settings/roles.tsx +163 -0
- package/client/pages/settings/routes.ts +79 -0
- package/client/pages/settings/shared.tsx +119 -0
- package/client/pages/settings/user-detail.tsx +268 -0
- package/client/pages/settings/user-form.tsx +427 -0
- package/client/pages/settings/users.tsx +451 -0
- package/client/routing/app-router.tsx +4 -1
- package/client/routing/route-navigation.ts +10 -1
- package/client/routing/session-preparation.tsx +65 -0
- package/client/styles.css +257 -24
- 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 +26 -25
- package/server/app.ts +2 -0
- package/server/config/notification.ts +4 -7
- package/tests/components/app-layout-routes.test.tsx +14 -7
- package/tests/components/auth-i18n.test.tsx +57 -5
- package/tests/components/data-table.test.tsx +127 -0
- package/tests/components/layout-i18n.test.tsx +10 -3
- package/tests/components/navigation-menu.test.tsx +155 -16
- package/tests/components/permission-editor.test.tsx +477 -0
- package/tests/components/permission-filter-binding.test.tsx +150 -0
- package/tests/components/permission-filter.test.tsx +202 -0
- package/tests/components/settings.test.tsx +294 -0
- package/tests/components/sidebar-permissions.test.tsx +17 -19
- package/tests/components/sidebar-preference.test.tsx +209 -14
- package/tests/components/status-badge.test.tsx +22 -0
- package/tests/components/user-permissions.test.tsx +142 -0
- package/tests/fixtures/theme-tokens.tsx +9 -1
- package/tests/logic/asset-url-build.test.ts +0 -1
- package/tests/logic/client-routes.test.ts +40 -0
- package/tests/logic/client-shell.test.tsx +10 -2
- package/tests/logic/config-example.test.ts +30 -0
- package/tests/logic/dev-client-watch.test.ts +8 -1
- package/tests/logic/route-overlay.test.tsx +42 -0
- package/tests/logic/scrollbar.test.ts +88 -0
- package/tests/logic/session-preparation.test.tsx +223 -0
- package/tests/logic/settings-permissions.test.ts +168 -0
- package/tests/logic/theme-tokens.test.ts +1 -0
- package/vite.config.ts +0 -22
- package/vitest.config.ts +1 -0
- package/client/agent-annotations-host.ts +0 -25
|
@@ -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;
|
|
@@ -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
|
-
<
|
|
92
|
-
<
|
|
93
|
-
<
|
|
94
|
-
<Route
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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:
|
|
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 />);
|
|
@@ -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
|
-
<
|
|
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
|
|
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 {
|
|
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
|
|
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(
|
|
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(
|
|
72
|
+
function shell(
|
|
73
|
+
items: readonly RouteNavigationItem[],
|
|
74
|
+
selectedKey?: string,
|
|
75
|
+
sections = true,
|
|
76
|
+
badges?: NavigationBadges,
|
|
77
|
+
) {
|
|
62
78
|
return (
|
|
63
79
|
<MemoryRouter>
|
|
64
|
-
<
|
|
65
|
-
<
|
|
66
|
-
<
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
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 () => {
|