@cccsaurora/howler-ui 3.1.0-dev.1609 → 3.1.0-dev.1617
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/api/dossier/groups.d.ts +2 -0
- package/api/dossier/groups.js +4 -0
- package/api/dossier/groups.test.d.ts +1 -0
- package/api/dossier/groups.test.js +26 -0
- package/api/{dossier.d.ts → dossier/index.d.ts} +2 -2
- package/api/{dossier.js → dossier/index.js} +2 -2
- package/components/elements/display/HowlerCard.d.ts +1 -1
- package/components/elements/display/HowlerCard.js +2 -2
- package/components/elements/hit/HitLinks.js +25 -21
- package/components/elements/hit/HitLinks.test.d.ts +1 -0
- package/components/elements/hit/HitLinks.test.js +118 -0
- package/components/elements/hit/related/RelatedIcon.js +2 -1
- package/components/elements/hit/related/RelatedIcon.test.d.ts +1 -0
- package/components/elements/hit/related/RelatedIcon.test.js +79 -0
- package/components/elements/hit/related/RelatedLink.d.ts +5 -0
- package/components/elements/hit/related/RelatedLink.js +23 -6
- package/components/elements/hit/related/RelatedLink.test.d.ts +1 -0
- package/components/elements/hit/related/RelatedLink.test.js +31 -0
- package/components/elements/hit/related/pivots/PivotFlyout.d.ts +25 -0
- package/components/elements/hit/related/pivots/PivotFlyout.js +26 -0
- package/components/elements/hit/related/pivots/PivotFlyoutContent.d.ts +4 -0
- package/components/elements/hit/related/pivots/PivotFlyoutContent.js +42 -0
- package/components/elements/hit/related/pivots/PivotFlyoutContent.test.d.ts +1 -0
- package/components/elements/hit/related/pivots/PivotFlyoutContent.test.js +41 -0
- package/components/elements/hit/related/pivots/PivotFolderMenu.d.ts +4 -0
- package/components/elements/hit/related/pivots/PivotFolderMenu.js +37 -0
- package/components/elements/hit/related/pivots/PivotFolderMenu.test.d.ts +1 -0
- package/components/elements/hit/related/pivots/PivotFolderMenu.test.js +55 -0
- package/components/elements/hit/related/{PivotLink.d.ts → pivots/PivotLink.d.ts} +9 -2
- package/components/elements/hit/related/pivots/PivotLink.js +53 -0
- package/components/elements/hit/related/pivots/PivotLink.test.d.ts +1 -0
- package/components/elements/hit/related/pivots/PivotLink.test.js +117 -0
- package/components/elements/hit/related/pivots/types.d.ts +29 -0
- package/components/elements/hit/related/pivots/utils.d.ts +12 -0
- package/components/elements/hit/related/pivots/utils.js +51 -0
- package/components/elements/hit/related/pivots/utils.test.d.ts +1 -0
- package/components/elements/hit/related/pivots/utils.test.js +30 -0
- package/components/hooks/useMyPreferences.d.ts +1 -1
- package/components/hooks/useMyPreferences.js +20 -2
- package/components/hooks/useMyPreferences.test.d.ts +1 -0
- package/components/hooks/useMyPreferences.test.js +40 -0
- package/components/routes/dossiers/DossierEditor.js +6 -0
- package/components/routes/dossiers/DossierEditor.test.js +30 -1
- package/components/routes/dossiers/PivotForm.js +41 -4
- package/components/routes/dossiers/PivotForm.test.d.ts +1 -0
- package/components/routes/dossiers/PivotForm.test.js +126 -0
- package/components/routes/dossiers/utils.d.ts +44 -0
- package/components/routes/dossiers/utils.js +135 -0
- package/components/routes/dossiers/utils.test.d.ts +1 -0
- package/components/routes/dossiers/utils.test.js +168 -0
- package/locales/en/translation.json +5 -0
- package/locales/fr/translation.json +5 -0
- package/models/entities/generated/Pivot.d.ts +1 -0
- package/package.json +8 -5
- package/plugins/HowlerPlugin.d.ts +1 -1
- package/plugins/clue/components/CluePivot.d.ts +1 -1
- package/plugins/clue/components/CluePivot.js +17 -1
- package/plugins/clue/components/CluePivot.test.d.ts +1 -0
- package/plugins/clue/components/CluePivot.test.js +94 -0
- package/utils/constants.d.ts +1 -0
- package/utils/constants.js +1 -0
- package/components/elements/hit/ResolvePivotUrl.d.ts +0 -4
- package/components/elements/hit/ResolvePivotUrl.js +0 -22
- package/components/elements/hit/related/PivotLink.js +0 -93
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import { hget, joinAllUri } from '@cccsaurora/howler-ui/api';
|
|
2
|
+
import { uri as parentUri } from '@cccsaurora/howler-ui/api/dossier';
|
|
3
|
+
export const uri = () => joinAllUri(parentUri(), 'groups');
|
|
4
|
+
export const get = (prefix) => hget(uri(), new URLSearchParams({ prefix }));
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
const mockHget = vi.hoisted(() => vi.fn());
|
|
3
|
+
vi.mock('api', async () => {
|
|
4
|
+
const urlJoin = (await import('url-join')).default;
|
|
5
|
+
return {
|
|
6
|
+
hget: mockHget,
|
|
7
|
+
joinAllUri: (...parts) => urlJoin(...parts)
|
|
8
|
+
};
|
|
9
|
+
});
|
|
10
|
+
vi.mock('api/dossier', () => ({
|
|
11
|
+
uri: () => '/api/v1/dossier'
|
|
12
|
+
}));
|
|
13
|
+
import { get, uri } from './groups';
|
|
14
|
+
describe('dossier groups API', () => {
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
mockHget.mockReset();
|
|
17
|
+
});
|
|
18
|
+
it('builds the groups endpoint URI', () => {
|
|
19
|
+
expect(uri()).toBe('/api/v1/dossier/groups');
|
|
20
|
+
});
|
|
21
|
+
it('requests groups with the prefix query parameter', async () => {
|
|
22
|
+
mockHget.mockResolvedValue(['network/dns']);
|
|
23
|
+
await expect(get('network')).resolves.toEqual(['network/dns']);
|
|
24
|
+
expect(mockHget).toHaveBeenCalledWith('/api/v1/dossier/groups', new URLSearchParams({ prefix: 'network' }));
|
|
25
|
+
});
|
|
26
|
+
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { hget, type HowlerRefreshParam } from '@cccsaurora/howler-ui/api';
|
|
2
2
|
import type { Dossier } from '@cccsaurora/howler-ui/models/entities/generated/Dossier';
|
|
3
|
-
import * as
|
|
3
|
+
import * as groups from './groups';
|
|
4
4
|
export declare const uri: (id?: string) => string;
|
|
5
5
|
export declare const get: {
|
|
6
6
|
(id: string): ReturnType<typeof hget<Dossier>>;
|
|
@@ -13,4 +13,4 @@ declare const permission: {
|
|
|
13
13
|
put: (id: string, data: import("api/utils/createPermissionsApi").PermissionData) => Promise<Dossier | undefined>;
|
|
14
14
|
delete: (id: string, data: import("api/utils/createPermissionsApi").PermissionData) => Promise<Dossier | undefined>;
|
|
15
15
|
};
|
|
16
|
-
export {
|
|
16
|
+
export { groups, permission };
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// eslint-disable-next-line import/no-cycle
|
|
2
2
|
import { hdelete, hget, hpost, hput, joinAllUri, joinUri, uri as parentUri } from '@cccsaurora/howler-ui/api';
|
|
3
3
|
import createPermissionsApi from '@cccsaurora/howler-ui/api/utils/createPermissionsApi';
|
|
4
|
-
import * as
|
|
4
|
+
import * as groups from './groups';
|
|
5
5
|
export const uri = (id) => {
|
|
6
6
|
return id ? joinAllUri(parentUri(), 'dossier', id) : joinUri(parentUri(), 'dossier');
|
|
7
7
|
};
|
|
@@ -18,4 +18,4 @@ export const del = (id, refresh) => {
|
|
|
18
18
|
return hdelete(uri(id), undefined, undefined, refresh ? new URLSearchParams({ refresh }) : undefined);
|
|
19
19
|
};
|
|
20
20
|
const permission = createPermissionsApi(uri);
|
|
21
|
-
export {
|
|
21
|
+
export { groups, permission };
|
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
import type { CardProps } from '@mui/material';
|
|
2
|
-
declare const _default: import("react").NamedExoticComponent<CardProps
|
|
2
|
+
declare const _default: import("react").NamedExoticComponent<Omit<CardProps, "ref"> & import("react").RefAttributes<HTMLDivElement>>;
|
|
3
3
|
export default _default;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
2
|
import { Card } from '@mui/material';
|
|
3
|
-
import { memo } from 'react';
|
|
4
|
-
const HowlerCard = props => _jsx(Card, { elevation: props.variant !== 'outlined' ? 1 : 0, ...props });
|
|
3
|
+
import { forwardRef, memo } from 'react';
|
|
4
|
+
const HowlerCard = forwardRef((props, ref) => (_jsx(Card, { ref: ref, elevation: props.variant !== 'outlined' ? 1 : 0, ...props })));
|
|
5
5
|
export default memo(HowlerCard);
|
|
@@ -1,37 +1,41 @@
|
|
|
1
|
-
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { Grid, gridClasses } from '@mui/material';
|
|
3
|
-
import { sortBy, uniqBy } from 'lodash-es';
|
|
3
|
+
import { isEmpty, sortBy, uniqBy } from 'lodash-es';
|
|
4
4
|
import { useMemo } from 'react';
|
|
5
5
|
import { useTranslation } from 'react-i18next';
|
|
6
6
|
import HitNotebooks from '@cccsaurora/howler-ui/components/elements/hit/HitNotebooks';
|
|
7
|
-
import
|
|
8
|
-
import PivotLink from '@cccsaurora/howler-ui/components/elements/hit/related/PivotLink';
|
|
7
|
+
import PivotFolderMenu from '@cccsaurora/howler-ui/components/elements/hit/related/pivots/PivotFolderMenu';
|
|
8
|
+
import PivotLink from '@cccsaurora/howler-ui/components/elements/hit/related/pivots/PivotLink';
|
|
9
9
|
import RelatedLink from '@cccsaurora/howler-ui/components/elements/hit/related/RelatedLink';
|
|
10
|
+
import { useMyLocalStorageItem } from '@cccsaurora/howler-ui/components/hooks/useMyLocalStorage';
|
|
11
|
+
import { getDossierPivotKey, pivotForest } from '@cccsaurora/howler-ui/components/routes/dossiers/utils';
|
|
12
|
+
import { StorageKey } from '@cccsaurora/howler-ui/utils/constants';
|
|
10
13
|
const HitLinks = ({ hit, analytic, dossiers = [] }) => {
|
|
11
14
|
const { i18n } = useTranslation();
|
|
15
|
+
const [pivotGroupEnabled] = useMyLocalStorageItem(StorageKey.PIVOT_GROUP, true);
|
|
12
16
|
const displayLinks = useMemo(() => uniqBy(hit?.howler?.links ?? [], 'href').slice(0, 3), [hit?.howler?.links]);
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
17
|
+
// grouped: organize pivots into a forest of groups, each with a single root button
|
|
18
|
+
const forest = useMemo(() => (pivotGroupEnabled ? pivotForest(dossiers, i18n.language) : []), [pivotGroupEnabled, dossiers, i18n.language]);
|
|
19
|
+
const rootPivots = useMemo(() => forest.find(node => isEmpty(node.path))?.pivots ?? [], [forest]);
|
|
20
|
+
// each distinct top-level group is its own tree, represented by a single root button (its own top node)
|
|
21
|
+
const groups = useMemo(() => forest.filter(node => !isEmpty(node.path)), [forest]);
|
|
22
|
+
// ungrouped (legacy): a flat, alphabetically sorted list of every pivot, exactly as it was before grouping
|
|
23
|
+
const flatPivots = useMemo(() => {
|
|
24
|
+
if (pivotGroupEnabled) {
|
|
25
|
+
return [];
|
|
26
|
+
}
|
|
27
|
+
return sortBy(dossiers.flatMap(dossier => (dossier.pivots ?? []).map(pivot => ({ pivot, dossier }))), item => item.pivot.label?.[i18n.language]);
|
|
28
|
+
}, [pivotGroupEnabled, dossiers, i18n.language]);
|
|
24
29
|
const hasNotebooks = (analytic?.notebooks?.length ?? 0) > 0;
|
|
25
|
-
if (displayLinks.length === 0 &&
|
|
30
|
+
if (displayLinks.length === 0 &&
|
|
31
|
+
!(pivotGroupEnabled ? rootPivots.length > 0 || groups.length > 0 : flatPivots.length > 0) &&
|
|
32
|
+
!hasNotebooks) {
|
|
26
33
|
return null;
|
|
27
34
|
}
|
|
28
|
-
return (_jsxs(Grid, { container: true, spacing: 1, pr: 2, sx: { [`& .${gridClasses.root}`]: { display: 'flex' } }, children: [displayLinks
|
|
35
|
+
return (_jsxs(Grid, { container: true, spacing: 1, pr: 2, sx: { [`& .${gridClasses.root}`]: { display: 'flex' } }, children: [pivotGroupEnabled ? (_jsxs(_Fragment, { children: [groups.map(node => (_jsx(Grid, { children: _jsx(PivotFolderMenu, { node: node, hit: hit }) }, node.path))), rootPivots.map(({ pivot, dossier }) => (_jsx(Grid, { children: _jsx(PivotLink, { pivot: pivot, hit: hit, dossier: dossier, compact: true }) }, getDossierPivotKey(dossier, pivot))))] })) : (flatPivots.map(({ pivot, dossier }) => (_jsx(Grid, { children: _jsx(PivotLink, { pivot: pivot, hit: hit, dossier: dossier, compact: true }) }, getDossierPivotKey(dossier, pivot))))), displayLinks
|
|
29
36
|
.filter(link => !!link.href)
|
|
30
37
|
.map(link => {
|
|
31
|
-
|
|
32
|
-
return (_jsx(Grid, { children: _jsx(RelatedLink, { compact: true, title: safeTitle, href: link.href, target: "_blank", rel: "noopener noreferrer" }) }, link.href));
|
|
33
|
-
}), displayPivots.map(({ pivot, dossier, resolvedUrl }) => {
|
|
34
|
-
return (_jsx(Grid, { children: _jsx(PivotLink, { pivot: pivot, hit: hit, dossier: dossier, resolvedUrl: resolvedUrl, compact: true }) }, `${dossier.dossier_id}-${pivot.value}`));
|
|
38
|
+
return (_jsx(Grid, { children: _jsx(RelatedLink, { compact: true, icon: link.icon, title: link.title ?? link.href, href: link.href, target: "_blank", rel: "noopener noreferrer" }) }, link.href));
|
|
35
39
|
}), hasNotebooks && (_jsx(Grid, { children: _jsx(HitNotebooks, { analytic: analytic, hit: hit, compact: true }) }))] }));
|
|
36
40
|
};
|
|
37
41
|
export default HitLinks;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { act, render, screen } from '@testing-library/react';
|
|
3
|
+
import { setupLocalStorageMock } from '@cccsaurora/howler-ui/tests/mocks';
|
|
4
|
+
import { MY_LOCAL_STORAGE_PREFIX, StorageKey } from '@cccsaurora/howler-ui/utils/constants';
|
|
5
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
6
|
+
import HitLinks from './HitLinks';
|
|
7
|
+
vi.mock('react-i18next', () => ({
|
|
8
|
+
useTranslation: () => ({ i18n: { language: 'en' }, t: (key) => key })
|
|
9
|
+
}));
|
|
10
|
+
vi.mock('components/elements/hit/related/pivots/PivotFolderMenu', () => ({
|
|
11
|
+
default: ({ node }) => _jsx("span", { children: `group:${node.path}` })
|
|
12
|
+
}));
|
|
13
|
+
const pivotLifecycle = vi.hoisted(() => ({ mounted: vi.fn(), unmounted: vi.fn() }));
|
|
14
|
+
vi.mock('components/elements/hit/related/pivots/PivotLink', async () => {
|
|
15
|
+
const { useEffect } = await import('react');
|
|
16
|
+
return {
|
|
17
|
+
default: ({ pivot }) => {
|
|
18
|
+
useEffect(() => {
|
|
19
|
+
pivotLifecycle.mounted(pivot.value);
|
|
20
|
+
return () => pivotLifecycle.unmounted(pivot.value);
|
|
21
|
+
}, [pivot.value]);
|
|
22
|
+
return _jsx("span", { children: `pivot:${pivot.value}` });
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
});
|
|
26
|
+
vi.mock('components/elements/hit/related/RelatedLink', () => ({
|
|
27
|
+
default: ({ title, href }) => _jsx("a", { href: href, children: title })
|
|
28
|
+
}));
|
|
29
|
+
const mockLocalStorage = setupLocalStorageMock();
|
|
30
|
+
const pivotGroupStorageKey = `${MY_LOCAL_STORAGE_PREFIX}.${StorageKey.PIVOT_GROUP}`;
|
|
31
|
+
const setPivotGrouping = async (enabled) => {
|
|
32
|
+
await act(async () => {
|
|
33
|
+
mockLocalStorage.setItem(pivotGroupStorageKey, JSON.stringify(enabled));
|
|
34
|
+
window.dispatchEvent(new StorageEvent('storage', {
|
|
35
|
+
key: pivotGroupStorageKey,
|
|
36
|
+
newValue: JSON.stringify(enabled)
|
|
37
|
+
}));
|
|
38
|
+
});
|
|
39
|
+
};
|
|
40
|
+
const dossier = {
|
|
41
|
+
dossier_id: 'dossier-1',
|
|
42
|
+
pivots: [
|
|
43
|
+
{ value: 'root', label: { en: 'Root' } },
|
|
44
|
+
{ value: 'example', group: 'network', label: { en: 'Example' } },
|
|
45
|
+
{ value: 'example-two', group: 'network', label: { en: 'Example Two' } }
|
|
46
|
+
]
|
|
47
|
+
};
|
|
48
|
+
describe('HitLinks pivot grouping', () => {
|
|
49
|
+
beforeEach(() => {
|
|
50
|
+
mockLocalStorage.clear();
|
|
51
|
+
pivotLifecycle.mounted.mockClear();
|
|
52
|
+
pivotLifecycle.unmounted.mockClear();
|
|
53
|
+
});
|
|
54
|
+
it('replaces grouped and flat pivots without accumulating renders', async () => {
|
|
55
|
+
render(_jsx(_Fragment, { children: _jsx(HitLinks, { dossiers: [dossier] }) }));
|
|
56
|
+
expect(screen.getByText('group:network')).toBeInTheDocument();
|
|
57
|
+
expect(screen.getAllByText('pivot:root')).toHaveLength(1);
|
|
58
|
+
expect(screen.queryByText('pivot:example')).not.toBeInTheDocument();
|
|
59
|
+
expect(pivotLifecycle.mounted).toHaveBeenCalledWith('root');
|
|
60
|
+
await setPivotGrouping(false);
|
|
61
|
+
expect(screen.queryByText('group:network')).not.toBeInTheDocument();
|
|
62
|
+
expect(screen.getAllByText('pivot:root')).toHaveLength(1);
|
|
63
|
+
expect(screen.getAllByText('pivot:example')).toHaveLength(1);
|
|
64
|
+
expect(screen.getAllByText('pivot:example-two')).toHaveLength(1);
|
|
65
|
+
expect(pivotLifecycle.unmounted).toHaveBeenCalledWith('root');
|
|
66
|
+
expect(pivotLifecycle.mounted.mock.calls.filter(([value]) => value === 'root')).toHaveLength(2);
|
|
67
|
+
await setPivotGrouping(true);
|
|
68
|
+
expect(screen.getByText('group:network')).toBeInTheDocument();
|
|
69
|
+
expect(screen.getAllByText('pivot:root')).toHaveLength(1);
|
|
70
|
+
expect(screen.queryByText('pivot:example')).not.toBeInTheDocument();
|
|
71
|
+
expect(screen.queryByText('pivot:example-two')).not.toBeInTheDocument();
|
|
72
|
+
await setPivotGrouping(false);
|
|
73
|
+
expect(screen.queryByText('group:network')).not.toBeInTheDocument();
|
|
74
|
+
expect(screen.getAllByText('pivot:root')).toHaveLength(1);
|
|
75
|
+
expect(screen.getAllByText('pivot:example')).toHaveLength(1);
|
|
76
|
+
expect(screen.getAllByText('pivot:example-two')).toHaveLength(1);
|
|
77
|
+
});
|
|
78
|
+
it('does not accumulate pivots that share an action value', async () => {
|
|
79
|
+
const repeatedValueDossier = {
|
|
80
|
+
dossier_id: 'dossier-1',
|
|
81
|
+
pivots: [
|
|
82
|
+
{ value: 'example', group: 'network', label: { en: 'Network Example' } },
|
|
83
|
+
{ value: 'example', group: 'host', label: { en: 'Host Example' } }
|
|
84
|
+
]
|
|
85
|
+
};
|
|
86
|
+
render(_jsx(HitLinks, { dossiers: [repeatedValueDossier] }));
|
|
87
|
+
await setPivotGrouping(false);
|
|
88
|
+
expect(screen.getAllByText('pivot:example')).toHaveLength(2);
|
|
89
|
+
await setPivotGrouping(true);
|
|
90
|
+
await setPivotGrouping(false);
|
|
91
|
+
expect(screen.getAllByText('pivot:example')).toHaveLength(2);
|
|
92
|
+
await setPivotGrouping(true);
|
|
93
|
+
await setPivotGrouping(false);
|
|
94
|
+
expect(screen.getAllByText('pivot:example')).toHaveLength(2);
|
|
95
|
+
});
|
|
96
|
+
it('renders at most three unique external links and omits empty hrefs', () => {
|
|
97
|
+
render(_jsx(HitLinks, { hit: {
|
|
98
|
+
howler: {
|
|
99
|
+
links: [
|
|
100
|
+
{ href: 'https://one.example', title: 'One' },
|
|
101
|
+
{ href: 'https://two.example', title: 'Two' },
|
|
102
|
+
{ href: 'https://three.example', title: 'Three' },
|
|
103
|
+
{ href: '', title: 'Empty' },
|
|
104
|
+
{ href: 'https://four.example', title: 'Four' }
|
|
105
|
+
]
|
|
106
|
+
}
|
|
107
|
+
} }));
|
|
108
|
+
expect(screen.getByRole('link', { name: 'One' })).toBeInTheDocument();
|
|
109
|
+
expect(screen.queryByText('Empty')).not.toBeInTheDocument();
|
|
110
|
+
expect(screen.getByRole('link', { name: 'Two' })).toBeInTheDocument();
|
|
111
|
+
expect(screen.getByRole('link', { name: 'Three' })).toBeInTheDocument();
|
|
112
|
+
expect(screen.queryByRole('link', { name: 'Four' })).not.toBeInTheDocument();
|
|
113
|
+
});
|
|
114
|
+
it('renders nothing when there are no links, pivots, or notebooks', () => {
|
|
115
|
+
const { container } = render(_jsx(HitLinks, {}));
|
|
116
|
+
expect(container).toBeEmptyDOMElement();
|
|
117
|
+
});
|
|
118
|
+
});
|
|
@@ -22,6 +22,7 @@ const RelatedIcon = ({ icon, title, href, compact = false }) => {
|
|
|
22
22
|
if (avatarImage) {
|
|
23
23
|
return (_jsx(Avatar, { variant: "rounded", alt: title ?? href, src: avatarImage, sx: [
|
|
24
24
|
theme => ({
|
|
25
|
+
alignSelf: 'start',
|
|
25
26
|
width: theme.spacing(compact ? 4 : 6),
|
|
26
27
|
height: theme.spacing(compact ? 4 : 6),
|
|
27
28
|
'& img': {
|
|
@@ -31,6 +32,6 @@ const RelatedIcon = ({ icon, title, href, compact = false }) => {
|
|
|
31
32
|
!avatarImage && { backgroundColor: 'transparent' }
|
|
32
33
|
], children: avatarImage }));
|
|
33
34
|
}
|
|
34
|
-
return _jsx(Icon, { fontSize: "1.5rem", icon: icon });
|
|
35
|
+
return _jsx(Icon, { style: { alignSelf: 'start' }, fontSize: "1.5rem", icon: icon });
|
|
35
36
|
};
|
|
36
37
|
export default RelatedIcon;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { render, screen } from '@testing-library/react';
|
|
3
|
+
import { ApiConfigContext } from '@cccsaurora/howler-ui/components/app/providers/ApiConfigProvider';
|
|
4
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
5
|
+
import RelatedIcon from './RelatedIcon';
|
|
6
|
+
const theme = vi.hoisted(() => ({ isDark: false }));
|
|
7
|
+
vi.mock('@iconify/react', () => ({
|
|
8
|
+
Icon: ({ icon, fontSize }) => (_jsx("span", { id: "icon", "data-icon": icon, "data-font-size": fontSize }))
|
|
9
|
+
}));
|
|
10
|
+
vi.mock('@tui/core', () => ({
|
|
11
|
+
useAppTheme: () => theme
|
|
12
|
+
}));
|
|
13
|
+
const renderWithConfig = (children, config = {}) => {
|
|
14
|
+
return render(_jsx(ApiConfigContext.Provider, { value: { config, setConfig: vi.fn(), loaded: true }, children: children }));
|
|
15
|
+
};
|
|
16
|
+
describe('RelatedIcon', () => {
|
|
17
|
+
beforeEach(() => {
|
|
18
|
+
theme.isDark = false;
|
|
19
|
+
});
|
|
20
|
+
it('renders nothing when no icon is provided', () => {
|
|
21
|
+
const { container } = renderWithConfig(_jsx(RelatedIcon, { title: "Example" }));
|
|
22
|
+
expect(container).toBeEmptyDOMElement();
|
|
23
|
+
});
|
|
24
|
+
it('renders a configured application image case-insensitively', () => {
|
|
25
|
+
renderWithConfig(_jsx(RelatedIcon, { icon: "cLuE", title: "Clue" }), {
|
|
26
|
+
configuration: {
|
|
27
|
+
ui: {
|
|
28
|
+
apps: [
|
|
29
|
+
{
|
|
30
|
+
name: 'Clue',
|
|
31
|
+
img_l: 'https://example.test/clue-light.png',
|
|
32
|
+
img_d: 'https://example.test/clue-dark.png',
|
|
33
|
+
alt: 'Clue',
|
|
34
|
+
route: '/clue',
|
|
35
|
+
classification: 'U'
|
|
36
|
+
}
|
|
37
|
+
]
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
expect(screen.getByRole('img')).toHaveAttribute('src', 'https://example.test/clue-light.png');
|
|
42
|
+
expect(screen.getByRole('img')).toHaveAttribute('alt', 'Clue');
|
|
43
|
+
});
|
|
44
|
+
it('uses the dark configured application image when the app theme is dark', () => {
|
|
45
|
+
theme.isDark = true;
|
|
46
|
+
renderWithConfig(_jsx(RelatedIcon, { icon: "Clue", title: "Clue" }), {
|
|
47
|
+
configuration: {
|
|
48
|
+
ui: {
|
|
49
|
+
apps: [
|
|
50
|
+
{
|
|
51
|
+
name: 'Clue',
|
|
52
|
+
img_l: 'https://example.test/clue-light.png',
|
|
53
|
+
img_d: 'https://example.test/clue-dark.png',
|
|
54
|
+
alt: 'Clue',
|
|
55
|
+
route: '/clue',
|
|
56
|
+
classification: 'U'
|
|
57
|
+
}
|
|
58
|
+
]
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
expect(screen.getByRole('img')).toHaveAttribute('src', 'https://example.test/clue-dark.png');
|
|
63
|
+
});
|
|
64
|
+
it('renders a direct HTTP icon as an avatar image', () => {
|
|
65
|
+
renderWithConfig(_jsx(RelatedIcon, { icon: "http://example.test/icon.svg", href: "https://example.test" }));
|
|
66
|
+
expect(screen.getByRole('img')).toHaveAttribute('src', 'http://example.test/icon.svg');
|
|
67
|
+
expect(screen.getByRole('img')).toHaveAttribute('alt', 'https://example.test');
|
|
68
|
+
});
|
|
69
|
+
it('uses the title as the image alt text when no href is provided', () => {
|
|
70
|
+
renderWithConfig(_jsx(RelatedIcon, { icon: "https://example.test/icon.svg", title: "Example", compact: true }));
|
|
71
|
+
expect(screen.getByRole('img')).toHaveAttribute('alt', 'Example');
|
|
72
|
+
expect(screen.getByRole('img').parentElement).toHaveStyle({ width: '32px', height: '32px' });
|
|
73
|
+
});
|
|
74
|
+
it('renders an Iconify icon when no image can be resolved', () => {
|
|
75
|
+
renderWithConfig(_jsx(RelatedIcon, { icon: "mdi:link-variant" }));
|
|
76
|
+
expect(screen.getByTestId('icon')).toHaveAttribute('data-icon', 'mdi:link-variant');
|
|
77
|
+
expect(screen.getByTestId('icon')).toHaveAttribute('data-font-size', '1.5rem');
|
|
78
|
+
});
|
|
79
|
+
});
|
|
@@ -7,5 +7,10 @@ declare const RelatedLink: React.FC<PropsWithChildren<{
|
|
|
7
7
|
target?: string;
|
|
8
8
|
rel?: string;
|
|
9
9
|
tooltip?: React.ReactNode;
|
|
10
|
+
dense?: boolean;
|
|
11
|
+
secondary?: React.ReactNode;
|
|
12
|
+
action?: React.ReactNode;
|
|
13
|
+
menuItem?: boolean;
|
|
14
|
+
onNavigate?: () => void;
|
|
10
15
|
}>>;
|
|
11
16
|
export default RelatedLink;
|
|
@@ -1,18 +1,32 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import { Stack, Tooltip, Typography } from '@mui/material';
|
|
2
|
+
import { ListItemText, Stack, Tooltip, Typography } from '@mui/material';
|
|
3
3
|
import HowlerCard from '@cccsaurora/howler-ui/components/elements/display/HowlerCard';
|
|
4
4
|
import RelatedLinkTooltip from '@cccsaurora/howler-ui/components/elements/hit/RelatedLinkTooltip';
|
|
5
5
|
import React, {} from 'react';
|
|
6
|
-
import { Link } from 'react-router';
|
|
7
6
|
import RelatedIcon from './RelatedIcon';
|
|
8
|
-
const RelatedLink = ({ icon, title, href, target, rel, compact = false, tooltip, children }) => {
|
|
7
|
+
const RelatedLink = ({ icon, title, href, target, rel, compact = false, tooltip, dense = false, secondary, action, menuItem, onNavigate, children }) => {
|
|
9
8
|
const safeTitle = title ?? href ?? '';
|
|
9
|
+
if (dense) {
|
|
10
|
+
return (_jsxs(Stack, { direction: "row", spacing: 1, alignItems: "center", sx: { width: '100%' }, children: [_jsxs(Stack, { component: "a", href: href, target: target, rel: rel, role: menuItem ? 'menuitem' : undefined, onClick: onNavigate, direction: "row", spacing: 1, alignItems: "center", sx: theme => ({
|
|
11
|
+
color: 'text.primary',
|
|
12
|
+
flex: 1,
|
|
13
|
+
minWidth: 0,
|
|
14
|
+
p: 0.5,
|
|
15
|
+
borderRadius: 1,
|
|
16
|
+
textDecoration: 'none',
|
|
17
|
+
display: 'flex',
|
|
18
|
+
alignItems: 'center',
|
|
19
|
+
transition: theme.transitions.create(['background-color']),
|
|
20
|
+
'&:hover': { backgroundColor: 'action.hover', '& .pivot-title': { textDecoration: 'underline' } }
|
|
21
|
+
}), children: [children || _jsx(RelatedIcon, { icon: icon, title: title, href: href, compact: true }), _jsx(ListItemText, { primary: _jsx(Typography, { noWrap: true, className: "pivot-title", children: safeTitle }), secondary: secondary, secondaryTypographyProps: { component: 'div', noWrap: true } })] }), action] }));
|
|
22
|
+
}
|
|
10
23
|
const tooltipContent = tooltip ?? _jsx(RelatedLinkTooltip, { title: safeTitle, href: href ?? '' });
|
|
11
|
-
return (_jsx(Tooltip, { title: tooltipContent, children: _jsx("div", { style: { display: 'flex' }, children: _jsx(HowlerCard, { variant: compact ? 'outlined' : 'elevation',
|
|
24
|
+
return (_jsx(Tooltip, { title: tooltipContent, children: _jsx("div", { style: { display: 'flex' }, children: _jsx(HowlerCard, { variant: compact ? 'outlined' : 'elevation', sx: [
|
|
12
25
|
theme => ({
|
|
13
|
-
cursor: 'pointer',
|
|
14
26
|
backgroundColor: 'transparent',
|
|
15
27
|
transition: theme.transitions.create(['border-color']),
|
|
28
|
+
display: 'flex',
|
|
29
|
+
alignItems: 'center',
|
|
16
30
|
'&:hover': { borderColor: 'primary.main', '& a': { textDecoration: 'underline' } },
|
|
17
31
|
'& > div': {
|
|
18
32
|
height: '100%'
|
|
@@ -20,6 +34,9 @@ const RelatedLink = ({ icon, title, href, target, rel, compact = false, tooltip,
|
|
|
20
34
|
'& a': { textDecoration: 'none', color: 'text.primary' }
|
|
21
35
|
}),
|
|
22
36
|
!compact && { border: 'thin solid', borderColor: 'transparent' }
|
|
23
|
-
], children: _jsxs(Stack, {
|
|
37
|
+
], children: _jsxs(Stack, { component: "a", href: href, target: target, rel: rel, onClick: onNavigate, direction: "row", p: compact ? 0.5 : 1, spacing: 1, alignItems: "center", sx: {
|
|
38
|
+
color: 'text.primary',
|
|
39
|
+
textDecoration: 'none'
|
|
40
|
+
}, children: [children || _jsx(RelatedIcon, { icon: icon, title: title, href: href, compact: compact }), _jsx(Typography, { children: safeTitle })] }) }) }) }));
|
|
24
41
|
};
|
|
25
42
|
export default RelatedLink;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { render, screen } from '@testing-library/react';
|
|
3
|
+
import userEvent from '@testing-library/user-event';
|
|
4
|
+
import { MemoryRouter } from 'react-router';
|
|
5
|
+
import RelatedLink from './RelatedLink';
|
|
6
|
+
describe('RelatedLink', () => {
|
|
7
|
+
it('renders dense links as native links without opening them through the card handler', async () => {
|
|
8
|
+
const user = userEvent.setup();
|
|
9
|
+
const open = vi.spyOn(window, 'open').mockReturnValue(null);
|
|
10
|
+
render(_jsx(RelatedLink, { title: "Example", href: "https://example.test/pivot", target: "_blank", rel: "noopener noreferrer", dense: true, children: _jsx("span", { children: "Icon" }) }));
|
|
11
|
+
const link = screen.getByRole('link', { name: /example/i });
|
|
12
|
+
expect(link).toHaveAttribute('href', 'https://example.test/pivot');
|
|
13
|
+
expect(link).toHaveAttribute('target', '_blank');
|
|
14
|
+
expect(link).toHaveAttribute('rel', 'noopener noreferrer');
|
|
15
|
+
await user.click(link);
|
|
16
|
+
expect(open).not.toHaveBeenCalled();
|
|
17
|
+
open.mockRestore();
|
|
18
|
+
});
|
|
19
|
+
it('renders dense secondary content and a trailing action outside the link', () => {
|
|
20
|
+
render(_jsx(RelatedLink, { title: "Example", href: "https://example.test/pivot", dense: true, secondary: _jsx("span", { children: "Details" }), action: _jsx("button", { type: "button", children: "Settings" }) }));
|
|
21
|
+
expect(screen.getByText('Details')).toBeInTheDocument();
|
|
22
|
+
expect(screen.getByRole('button', { name: 'Settings' })).toBeInTheDocument();
|
|
23
|
+
expect(screen.getByRole('button', { name: 'Settings' }).closest('a')).toBeNull();
|
|
24
|
+
});
|
|
25
|
+
it('renders regular links as a single keyboard-accessible link', () => {
|
|
26
|
+
render(_jsx(MemoryRouter, { children: _jsx(RelatedLink, { title: "Example", href: "https://example.test/pivot", target: "_blank", rel: "noopener noreferrer", children: _jsx("span", { children: "Icon" }) }) }));
|
|
27
|
+
const link = screen.getByRole('link', { name: /example/i });
|
|
28
|
+
expect(link).toHaveAttribute('href', 'https://example.test/pivot');
|
|
29
|
+
expect(link.closest('.MuiCard-root')).not.toBeNull();
|
|
30
|
+
});
|
|
31
|
+
});
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { type PopperProps } from '@mui/material';
|
|
2
|
+
import type { FC, ReactNode } from 'react';
|
|
3
|
+
/** Props for the floating menu container used by pivot navigation. */
|
|
4
|
+
interface PivotFlyoutProps {
|
|
5
|
+
/** Element that anchors the flyout position. */
|
|
6
|
+
anchorEl: HTMLElement | null;
|
|
7
|
+
/** Whether to focus the first menu item when the flyout opens. */
|
|
8
|
+
focusOnOpen: boolean;
|
|
9
|
+
/** Menu content rendered inside the flyout. */
|
|
10
|
+
children: ReactNode;
|
|
11
|
+
/** DOM id assigned to the flyout menu container. */
|
|
12
|
+
id: string;
|
|
13
|
+
/** Position of the flyout relative to its anchor. */
|
|
14
|
+
placement?: PopperProps['placement'];
|
|
15
|
+
/** Closes the flyout. */
|
|
16
|
+
onClose: () => void;
|
|
17
|
+
/** Handles pointer entry into the flyout. */
|
|
18
|
+
onMouseEnter: () => void;
|
|
19
|
+
/** Handles pointer exit from the flyout. */
|
|
20
|
+
onMouseLeave: () => void;
|
|
21
|
+
/** Whether the flyout is visible. */
|
|
22
|
+
open: boolean;
|
|
23
|
+
}
|
|
24
|
+
declare const PivotFlyout: FC<PivotFlyoutProps>;
|
|
25
|
+
export default PivotFlyout;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { ClickAwayListener, Paper, Popper } from '@mui/material';
|
|
3
|
+
import { useEffect, useRef } from 'react';
|
|
4
|
+
const PivotFlyout = ({ anchorEl, focusOnOpen, children, id, placement, onClose, onMouseEnter, onMouseLeave, open }) => {
|
|
5
|
+
const menuRef = useRef(null);
|
|
6
|
+
useEffect(() => {
|
|
7
|
+
if (open && focusOnOpen) {
|
|
8
|
+
menuRef.current?.querySelector('[role="menuitem"]')?.focus();
|
|
9
|
+
}
|
|
10
|
+
}, [focusOnOpen, open]);
|
|
11
|
+
useEffect(() => {
|
|
12
|
+
if (!open && document.activeElement && menuRef.current?.contains(document.activeElement)) {
|
|
13
|
+
anchorEl?.focus();
|
|
14
|
+
}
|
|
15
|
+
}, [anchorEl, open]);
|
|
16
|
+
const onKeyDown = (event) => {
|
|
17
|
+
if (event.key !== 'Escape') {
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
event.preventDefault();
|
|
21
|
+
event.stopPropagation();
|
|
22
|
+
onClose();
|
|
23
|
+
};
|
|
24
|
+
return (_jsx(Popper, { open: open, anchorEl: anchorEl, placement: placement ?? 'right-start', keepMounted: false, sx: { zIndex: theme => theme.zIndex.modal + 1 }, children: _jsx(ClickAwayListener, { onClickAway: () => onClose(), mouseEvent: "onMouseDown", touchEvent: "onTouchStart", children: _jsx(Paper, { elevation: 4, onMouseEnter: onMouseEnter, onMouseLeave: onMouseLeave, onKeyDown: onKeyDown, children: _jsx("div", { id: id, ref: menuRef, children: children }) }) }) }));
|
|
25
|
+
};
|
|
26
|
+
export default PivotFlyout;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { KeyboardArrowRight } from '@mui/icons-material';
|
|
3
|
+
import { MenuItem, MenuList } from '@mui/material';
|
|
4
|
+
import PivotLink from '@cccsaurora/howler-ui/components/elements/hit/related/pivots/PivotLink';
|
|
5
|
+
import { getDossierPivotKey } from '@cccsaurora/howler-ui/components/routes/dossiers/utils';
|
|
6
|
+
import { useId, useState } from 'react';
|
|
7
|
+
import PivotFlyout from './PivotFlyout';
|
|
8
|
+
import { useHoverMenu } from './utils';
|
|
9
|
+
const PivotSubMenuItem = ({ node, hit, onClose, onMenuEnter, onNavigate, onOpen, open }) => {
|
|
10
|
+
const { isOpen, shouldFocusMenu, openMenu, cancelClose, scheduleClose, closeMenu } = useHoverMenu();
|
|
11
|
+
const [rowElement, setRowElement] = useState(null);
|
|
12
|
+
const menuId = useId();
|
|
13
|
+
const openSubmenu = (focusMenu = false) => {
|
|
14
|
+
onOpen();
|
|
15
|
+
openMenu(focusMenu);
|
|
16
|
+
};
|
|
17
|
+
const closeSubmenu = () => {
|
|
18
|
+
closeMenu();
|
|
19
|
+
onClose();
|
|
20
|
+
rowElement?.focus();
|
|
21
|
+
};
|
|
22
|
+
const onKeyDown = (event) => {
|
|
23
|
+
if (event.key === 'Escape' || event.key === 'ArrowLeft') {
|
|
24
|
+
event.preventDefault();
|
|
25
|
+
closeSubmenu();
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
if (event.key === 'ArrowRight') {
|
|
29
|
+
event.preventDefault();
|
|
30
|
+
openSubmenu(true);
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
return (_jsxs(_Fragment, { children: [_jsxs(MenuItem, { ref: setRowElement, "aria-haspopup": "menu", "aria-controls": isOpen && open ? menuId : undefined, "aria-expanded": isOpen && open, onMouseEnter: () => openSubmenu(), onMouseLeave: () => scheduleClose(), onClick: event => openSubmenu(event.detail === 0), onKeyDown: onKeyDown, sx: { justifyContent: 'space-between', gap: 2 }, children: [node.path, _jsx(KeyboardArrowRight, { fontSize: "small" })] }), _jsx(PivotFlyout, { id: menuId, anchorEl: rowElement, focusOnOpen: shouldFocusMenu, open: isOpen && open, onClose: closeSubmenu, onMouseEnter: () => {
|
|
34
|
+
cancelClose();
|
|
35
|
+
onMenuEnter?.();
|
|
36
|
+
}, onMouseLeave: () => scheduleClose(), children: _jsx(PivotFlyoutContent, { pivots: node.pivots ?? [], groups: node.children ?? [], hit: hit, onMenuEnter: cancelClose, onNavigate: onNavigate }) })] }));
|
|
37
|
+
};
|
|
38
|
+
const PivotFlyoutContent = ({ pivots, groups, hit, onMenuEnter, onNavigate }) => {
|
|
39
|
+
const [openGroup, setOpenGroup] = useState();
|
|
40
|
+
return (_jsxs(MenuList, { disablePadding: true, children: [groups.map(group => (_jsx(PivotSubMenuItem, { node: group, hit: hit, open: openGroup === group.path, onOpen: () => setOpenGroup(group.path), onClose: () => setOpenGroup(undefined), onMenuEnter: onMenuEnter, onNavigate: onNavigate }, group.path))), pivots.map(({ pivot, dossier }) => (_jsx(PivotLink, { pivot: pivot, hit: hit, dossier: dossier, variant: "menu-item", onNavigate: onNavigate }, getDossierPivotKey(dossier, pivot))))] }));
|
|
41
|
+
};
|
|
42
|
+
export default PivotFlyoutContent;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { render, screen } from '@testing-library/react';
|
|
3
|
+
import userEvent from '@testing-library/user-event';
|
|
4
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
5
|
+
import PivotFlyoutContent from './PivotFlyoutContent';
|
|
6
|
+
vi.mock('components/elements/hit/related/pivots/PivotLink', () => ({
|
|
7
|
+
default: ({ pivot, onNavigate }) => (_jsx("button", { type: "button", role: "menuitem", onClick: onNavigate, children: pivot.label?.en }))
|
|
8
|
+
}));
|
|
9
|
+
const dossier = { dossier_id: 'dossier-1' };
|
|
10
|
+
const pivot = (value, label) => ({
|
|
11
|
+
format: 'link',
|
|
12
|
+
value,
|
|
13
|
+
label: { en: label, fr: label }
|
|
14
|
+
});
|
|
15
|
+
describe('PivotFlyoutContent', () => {
|
|
16
|
+
it('opens and closes a nested group with keyboard navigation', async () => {
|
|
17
|
+
const user = userEvent.setup();
|
|
18
|
+
const groups = [
|
|
19
|
+
{
|
|
20
|
+
path: 'network',
|
|
21
|
+
pivots: [{ pivot: pivot('dns', 'DNS'), dossier }],
|
|
22
|
+
children: []
|
|
23
|
+
}
|
|
24
|
+
];
|
|
25
|
+
render(_jsx(PivotFlyoutContent, { pivots: [], groups: groups }));
|
|
26
|
+
const group = screen.getByRole('menuitem', { name: 'network' });
|
|
27
|
+
await user.click(group);
|
|
28
|
+
expect(group).toHaveAttribute('aria-expanded', 'true');
|
|
29
|
+
expect(screen.getByRole('menuitem', { name: 'DNS' })).toBeInTheDocument();
|
|
30
|
+
await user.keyboard('{ArrowLeft}');
|
|
31
|
+
expect(group).toHaveAttribute('aria-expanded', 'false');
|
|
32
|
+
expect(screen.queryByRole('menuitem', { name: 'DNS' })).not.toBeInTheDocument();
|
|
33
|
+
});
|
|
34
|
+
it('calls onNavigate when a pivot is selected', async () => {
|
|
35
|
+
const user = userEvent.setup();
|
|
36
|
+
const onNavigate = vi.fn();
|
|
37
|
+
render(_jsx(PivotFlyoutContent, { pivots: [{ pivot: pivot('root', 'Root'), dossier }], groups: [], onNavigate: onNavigate }));
|
|
38
|
+
await user.click(screen.getByRole('menuitem', { name: 'Root' }));
|
|
39
|
+
expect(onNavigate).toHaveBeenCalledOnce();
|
|
40
|
+
});
|
|
41
|
+
});
|