Skip to content
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.

Commit 9563b34

Browse files
committedMar 4, 2024··
feat: add keyboard navigation to attributions and signals lists
- merge PackageCard and ListCard components - track keyboard selected item in list and grouped-list - add focus styling to package card closes #971 Signed-off-by: Maxim Stykow <maxim.stykow@tngtech.com>
1 parent 2ec903d commit 9563b34

File tree

19 files changed

+565
-329
lines changed

19 files changed

+565
-329
lines changed
 

‎src/Frontend/Components/AttributionPanels/AttributionsPanel/AttributionsList/AttributionsList.tsx

+7-3
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import { changeSelectedAttributionOrOpenUnsavedPopup } from '../../../../state/a
1010
import { useAppDispatch } from '../../../../state/hooks';
1111
import { useAttributionIdsForReplacement } from '../../../../state/variables/use-attribution-ids-for-replacement';
1212
import { isPackageInfoIncomplete } from '../../../../util/is-important-attribution-information-missing';
13-
import { List } from '../../../List/List';
13+
import { List, ListItemContentProps } from '../../../List/List';
1414
import { PackageCard } from '../../../PackageCard/PackageCard';
1515
import { PackagesPanelChildrenProps } from '../../PackagesPanel/PackagesPanel';
1616

@@ -37,7 +37,10 @@ export const AttributionsList: React.FC<PackagesPanelChildrenProps> = ({
3737
/>
3838
);
3939

40-
function renderAttributionCard(attributionId: string) {
40+
function renderAttributionCard(
41+
attributionId: string,
42+
{ selected, focused }: ListItemContentProps,
43+
) {
4144
const attribution = attributions?.[attributionId];
4245

4346
if (!attribution) {
@@ -54,7 +57,8 @@ export const AttributionsList: React.FC<PackagesPanelChildrenProps> = ({
5457
);
5558
}}
5659
cardConfig={{
57-
selected: attributionId === selectedAttributionId,
60+
selected,
61+
focused,
5862
resolved: attributionIdsForReplacement.includes(attributionId),
5963
incomplete: isPackageInfoIncomplete(attribution),
6064
}}

‎src/Frontend/Components/AttributionPanels/SignalsPanel/SignalsList/SignalsList.tsx

+10-3
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,10 @@ import {
1414
getResolvedExternalAttributions,
1515
} from '../../../../state/selectors/resource-selectors';
1616
import { useAttributionIdsForReplacement } from '../../../../state/variables/use-attribution-ids-for-replacement';
17-
import { GroupedList } from '../../../GroupedList/GroupedList';
17+
import {
18+
GroupedList,
19+
GroupedListItemContentProps,
20+
} from '../../../GroupedList/GroupedList';
1821
import { SourceIcon } from '../../../Icons/Icons';
1922
import { PackageCard } from '../../../PackageCard/PackageCard';
2023
import { PackagesPanelChildrenProps } from '../../PackagesPanel/PackagesPanel';
@@ -79,7 +82,10 @@ export const SignalsList: React.FC<PackagesPanelChildrenProps> = ({
7982
/>
8083
);
8184

82-
function renderAttributionCard(attributionId: string) {
85+
function renderAttributionCard(
86+
attributionId: string,
87+
{ focused, selected }: GroupedListItemContentProps,
88+
) {
8389
const attribution = attributions?.[attributionId];
8490

8591
if (!attribution) {
@@ -96,7 +102,8 @@ export const SignalsList: React.FC<PackagesPanelChildrenProps> = ({
96102
);
97103
}}
98104
cardConfig={{
99-
selected: attributionId === selectedAttributionId,
105+
selected,
106+
focused,
100107
resolved: resolvedExternalAttributionIds.has(attributionId),
101108
}}
102109
packageInfo={attribution}

‎src/Frontend/Components/CardList/CardList.tsx

+3-2
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,14 @@ import { styled } from '@mui/material';
66

77
import { OpossumColors } from '../../shared-styles';
88
import { List } from '../List/List';
9-
import { LIST_CARD_HEIGHT } from '../ListCard/ListCard';
9+
import { PACKAGE_CARD_HEIGHT } from '../PackageCard/PackageCard';
1010

1111
const MAX_NUMBER_OF_CARDS = 4;
1212

1313
export const CardList = styled(List)(({ data }) => {
1414
const height =
15-
Math.min(MAX_NUMBER_OF_CARDS, data?.length ?? 0) * (LIST_CARD_HEIGHT + 1) +
15+
Math.min(MAX_NUMBER_OF_CARDS, data?.length ?? 0) *
16+
(PACKAGE_CARD_HEIGHT + 1) +
1617
1;
1718

1819
return {

‎src/Frontend/Components/ConfirmDeletionPopup/ConfirmDeletionPopup.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,7 @@ export const ConfirmDeletionPopup: React.FC<Props> = ({
124124
</MuiTypography>
125125
<CardList
126126
data={attributionIdsToDelete}
127-
renderItemContent={(attributionId, index) => {
127+
renderItemContent={(attributionId, { index }) => {
128128
if (!attributionId || !(attributionId in attributions)) {
129129
return null;
130130
}

‎src/Frontend/Components/ConfirmSavePopup/ConfirmSavePopup.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -153,7 +153,7 @@ export const ConfirmSavePopup: React.FC<Props> = ({
153153
</MuiTypography>
154154
<CardList
155155
data={attributionIdsToSave}
156-
renderItemContent={(attributionId, index) => {
156+
renderItemContent={(attributionId, { index }) => {
157157
if (!attributionId || !(attributionId in attributions)) {
158158
return null;
159159
}

‎src/Frontend/Components/GroupedList/GroupedList.tsx

+29-5
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import MuiBox from '@mui/material/Box';
1010
import MuiTooltip from '@mui/material/Tooltip';
1111
import { SxProps } from '@mui/system';
1212
import { defer } from 'lodash';
13-
import { useEffect, useMemo, useRef, useState } from 'react';
13+
import { useEffect, useMemo, useState } from 'react';
1414
import {
1515
GroupedVirtuoso,
1616
GroupedVirtuosoHandle,
@@ -19,16 +19,26 @@ import {
1919

2020
import { text } from '../../../shared/text';
2121
import { OpossumColors } from '../../shared-styles';
22+
import { useVirtuosoRefs } from '../../util/use-virtuoso-refs';
2223
import { LoadingMask } from '../LoadingMask/LoadingMask';
2324
import { NoResults } from '../NoResults/NoResults';
2425
import { GroupContainer, StyledLinearProgress } from './GroupedList.style';
2526

27+
export interface GroupedListItemContentProps {
28+
index: number;
29+
selected: boolean;
30+
focused: boolean;
31+
}
32+
2633
export interface GroupedListProps {
2734
className?: string;
2835
grouped: Record<string, ReadonlyArray<string>> | null;
2936
loading?: boolean;
3037
renderGroupName?: (key: string) => React.ReactNode;
31-
renderItemContent: (datum: string, index: number) => React.ReactNode;
38+
renderItemContent: (
39+
datum: string,
40+
props: GroupedListItemContentProps,
41+
) => React.ReactNode;
3242
selected?: string;
3343
sx?: SxProps;
3444
testId?: string;
@@ -45,7 +55,6 @@ export function GroupedList({
4555
testId,
4656
...props
4757
}: GroupedListProps & Omit<GroupedVirtuosoProps<string, unknown>, 'selected'>) {
48-
const ref = useRef<GroupedVirtuosoHandle>(null);
4958
const [{ startIndex, endIndex }, setRange] = useState<{
5059
startIndex: number;
5160
endIndex: number;
@@ -66,6 +75,12 @@ export function GroupedList({
6675
};
6776
}, [grouped, selected]);
6877

78+
const { ref, scrollerRef, focusedIndex, setIsFocused } =
79+
useVirtuosoRefs<GroupedVirtuosoHandle>({
80+
data: groups?.ids,
81+
selectedIndex: groups?.selectedIndex,
82+
});
83+
6984
useEffect(() => {
7085
if (groups?.selectedIndex !== undefined && groups.selectedIndex >= 0) {
7186
defer(() =>
@@ -75,7 +90,7 @@ export function GroupedList({
7590
}),
7691
);
7792
}
78-
}, [groups?.selectedIndex]);
93+
}, [groups?.selectedIndex, ref]);
7994

8095
return (
8196
<LoadingMask
@@ -97,10 +112,13 @@ export function GroupedList({
97112
return (
98113
<GroupedVirtuoso
99114
ref={ref}
115+
onFocus={() => setIsFocused(true)}
116+
onBlur={() => setIsFocused(false)}
100117
components={{
101118
EmptyPlaceholder:
102119
loading || groups.ids.length ? undefined : () => <NoResults />,
103120
}}
121+
scrollerRef={scrollerRef}
104122
rangeChanged={setRange}
105123
groupCounts={groups?.counts}
106124
groupContent={(index) => (
@@ -114,7 +132,13 @@ export function GroupedList({
114132
)}
115133
</GroupContainer>
116134
)}
117-
itemContent={(index) => renderItemContent(groups.ids[index], index)}
135+
itemContent={(index) =>
136+
renderItemContent(groups.ids[index], {
137+
index,
138+
selected: index === groups.selectedIndex,
139+
focused: index === focusedIndex,
140+
})
141+
}
118142
{...props}
119143
/>
120144
);

‎src/Frontend/Components/List/List.tsx

+29-6
Original file line numberDiff line numberDiff line change
@@ -4,18 +4,28 @@
44
// SPDX-License-Identifier: Apache-2.0
55
import { SxProps } from '@mui/system';
66
import { defer } from 'lodash';
7-
import { useEffect, useMemo, useRef } from 'react';
7+
import { useEffect, useMemo } from 'react';
88
import { Virtuoso, VirtuosoHandle, VirtuosoProps } from 'react-virtuoso';
99

10+
import { useVirtuosoRefs } from '../../util/use-virtuoso-refs';
1011
import { LoadingMask } from '../LoadingMask/LoadingMask';
1112
import { NoResults } from '../NoResults/NoResults';
1213
import { StyledLinearProgress } from './List.style';
1314

15+
export interface ListItemContentProps {
16+
index: number;
17+
selected: boolean;
18+
focused: boolean;
19+
}
20+
1421
export interface ListProps {
1522
className?: string;
1623
data: ReadonlyArray<string> | null;
1724
loading?: boolean;
18-
renderItemContent: (datum: string, index: number) => React.ReactNode;
25+
renderItemContent: (
26+
datum: string,
27+
props: ListItemContentProps,
28+
) => React.ReactNode;
1929
selected?: string;
2030
sx?: SxProps;
2131
testId?: string;
@@ -31,8 +41,6 @@ export function List({
3141
testId,
3242
...props
3343
}: ListProps & Omit<VirtuosoProps<string, unknown>, 'data' | 'selected'>) {
34-
const ref = useRef<VirtuosoHandle>(null);
35-
3644
const selectedIndex = useMemo(() => {
3745
if (!data) {
3846
return undefined;
@@ -41,6 +49,12 @@ export function List({
4149
return data.findIndex((datum) => datum === selected);
4250
}, [data, selected]);
4351

52+
const { focusedIndex, ref, scrollerRef, setIsFocused } =
53+
useVirtuosoRefs<VirtuosoHandle>({
54+
data,
55+
selectedIndex,
56+
});
57+
4458
useEffect(() => {
4559
if (selectedIndex !== undefined && selectedIndex >= 0) {
4660
defer(() =>
@@ -50,7 +64,7 @@ export function List({
5064
}),
5165
);
5266
}
53-
}, [selectedIndex]);
67+
}, [selectedIndex, ref]);
5468

5569
return (
5670
<LoadingMask
@@ -72,12 +86,21 @@ export function List({
7286
return (
7387
<Virtuoso
7488
ref={ref}
89+
onFocus={() => setIsFocused(true)}
90+
onBlur={() => setIsFocused(false)}
7591
components={{
7692
EmptyPlaceholder:
7793
loading || data.length ? undefined : () => <NoResults />,
7894
}}
95+
scrollerRef={scrollerRef}
7996
data={data}
80-
itemContent={(index) => renderItemContent(data[index], index)}
97+
itemContent={(index) =>
98+
renderItemContent(data[index], {
99+
index,
100+
selected: index === selectedIndex,
101+
focused: index === focusedIndex,
102+
})
103+
}
81104
{...props}
82105
/>
83106
);

‎src/Frontend/Components/ListCard/ListCard.tsx

-142
This file was deleted.

‎src/Frontend/Components/ListCard/__tests__/ListCard.test.tsx

-54
This file was deleted.

‎src/Frontend/Components/PackageCard/PackageCard.tsx

+161-26
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,103 @@
22
// SPDX-FileCopyrightText: TNG Technology Consulting GmbH <https://www.tngtech.com>
33
//
44
// SPDX-License-Identifier: Apache-2.0
5-
import { memo, useMemo } from 'react';
5+
import MuiBox from '@mui/material/Box';
6+
import MuiChip from '@mui/material/Chip';
7+
import MuiTooltip from '@mui/material/Tooltip';
8+
import MuiTypography from '@mui/material/Typography';
9+
import { SxProps } from '@mui/system';
10+
import { memo, useEffect, useMemo, useRef } from 'react';
611

7-
import { PackageInfo } from '../../../shared/shared-types';
12+
import { Criticality, PackageInfo } from '../../../shared/shared-types';
13+
import { text } from '../../../shared/text';
14+
import { OpossumColors } from '../../shared-styles';
815
import { getCardLabels } from '../../util/get-card-labels';
16+
import { maybePluralize } from '../../util/maybe-pluralize';
917
import { Checkbox } from '../Checkbox/Checkbox';
10-
import { ListCard, ListCardConfig } from '../ListCard/ListCard';
1118
import { getRightIcons } from './PackageCard.util';
1219

13-
interface PackageCardProps {
20+
export const PACKAGE_CARD_HEIGHT = 40;
21+
22+
const hoveredSelectedBackgroundColor = OpossumColors.middleBlue;
23+
const hoveredBackgroundColor = OpossumColors.lightestBlueOnHover;
24+
25+
const classes = {
26+
root: {
27+
flex: 1,
28+
display: 'flex',
29+
alignItems: 'center',
30+
height: PACKAGE_CARD_HEIGHT,
31+
padding: '0 4px',
32+
gap: '4px',
33+
'&:focus': {
34+
background: hoveredBackgroundColor,
35+
outline: 'none',
36+
},
37+
},
38+
innerRoot: {
39+
flex: 1,
40+
display: 'flex',
41+
alignItems: 'center',
42+
height: PACKAGE_CARD_HEIGHT,
43+
overflow: 'hidden',
44+
gap: '8px',
45+
},
46+
hover: {
47+
'&:hover': {
48+
cursor: 'pointer',
49+
background: hoveredBackgroundColor,
50+
},
51+
},
52+
selected: {
53+
background: OpossumColors.middleBlue,
54+
'&:hover': {
55+
background: hoveredSelectedBackgroundColor,
56+
},
57+
'&:focus': {
58+
background: hoveredSelectedBackgroundColor,
59+
outline: 'none',
60+
},
61+
},
62+
resolved: {
63+
opacity: 0.5,
64+
backgroundColor: 'white',
65+
},
66+
iconColumn: {
67+
display: 'grid',
68+
gridTemplateRows: '1fr 1fr',
69+
gridAutoFlow: 'column',
70+
direction: 'rtl',
71+
},
72+
textLines: {
73+
flex: 1,
74+
overflow: 'hidden',
75+
},
76+
textLine: {
77+
userSelect: 'none',
78+
textOverflow: 'ellipsis',
79+
overflow: 'hidden',
80+
whiteSpace: 'nowrap',
81+
},
82+
} satisfies SxProps;
83+
84+
export interface PackageCardConfig {
85+
criticality?: Criticality;
86+
excludeFromNotice?: boolean;
87+
firstParty?: boolean;
88+
followUp?: boolean;
89+
incomplete?: boolean;
90+
needsReview?: boolean;
91+
preSelected?: boolean;
92+
preferred?: boolean;
93+
resolved?: boolean;
94+
selected?: boolean;
95+
focused?: boolean;
96+
wasPreferred?: boolean;
97+
}
98+
99+
export interface PackageCardProps {
14100
packageInfo: PackageInfo;
15-
cardConfig?: ListCardConfig;
101+
cardConfig?: PackageCardConfig;
16102
onClick?(): void;
17103
checkbox?: {
18104
checked: boolean;
@@ -23,11 +109,12 @@ interface PackageCardProps {
23109

24110
export const PackageCard = memo(
25111
({ packageInfo, cardConfig, checkbox, onClick }: PackageCardProps) => {
112+
const ref = useRef<HTMLDivElement>(null);
26113
const packageLabels = useMemo(
27114
() => getCardLabels(packageInfo),
28115
[packageInfo],
29116
);
30-
const listCardConfig = useMemo<ListCardConfig>(
117+
const effectiveCardConfig = useMemo<PackageCardConfig>(
31118
() => ({
32119
criticality: packageInfo.criticality,
33120
excludeFromNotice: packageInfo.excludeFromNotice,
@@ -42,29 +129,77 @@ export const PackageCard = memo(
42129
[cardConfig, packageInfo],
43130
);
44131
const rightIcons = useMemo(
45-
() => getRightIcons(listCardConfig),
46-
[listCardConfig],
132+
() => getRightIcons(effectiveCardConfig),
133+
[effectiveCardConfig],
47134
);
48135

136+
useEffect(() => {
137+
if (effectiveCardConfig.focused) {
138+
ref.current?.focus();
139+
}
140+
}, [effectiveCardConfig.focused]);
141+
49142
return (
50-
<ListCard
51-
text={packageLabels[0]}
52-
secondLineText={packageLabels[1]}
53-
cardConfig={listCardConfig}
54-
count={packageInfo.count}
55-
rightIcons={rightIcons}
56-
onClick={onClick}
57-
leftElement={
58-
checkbox && (
59-
<Checkbox
60-
checked={checkbox.checked}
61-
disabled={checkbox.disabled}
62-
onChange={checkbox?.onChange}
63-
disableRipple
64-
/>
65-
)
66-
}
67-
/>
143+
<MuiBox
144+
ref={ref}
145+
aria-label={`package card ${packageLabels[0]}`}
146+
tabIndex={0}
147+
onKeyDown={(event) => {
148+
if (['Enter', 'Space'].includes(event.code)) {
149+
event.preventDefault();
150+
onClick?.();
151+
}
152+
}}
153+
sx={{
154+
...classes.root,
155+
...(onClick && classes.hover),
156+
...(cardConfig?.resolved && classes.resolved),
157+
...(cardConfig?.selected && classes.selected),
158+
}}
159+
>
160+
{checkbox && (
161+
<Checkbox
162+
checked={checkbox.checked}
163+
disabled={checkbox.disabled}
164+
onChange={checkbox.onChange}
165+
disableRipple
166+
/>
167+
)}
168+
<MuiBox sx={classes.innerRoot} onClick={onClick}>
169+
{packageInfo.count && (
170+
<MuiTooltip
171+
title={maybePluralize(
172+
packageInfo.count,
173+
text.attributionColumn.occurrence,
174+
{
175+
showOne: true,
176+
},
177+
)}
178+
enterDelay={500}
179+
>
180+
<MuiChip
181+
sx={{ minWidth: '24px', userSelect: 'none' }}
182+
label={new Intl.NumberFormat('en-US', {
183+
notation: 'compact',
184+
compactDisplay: 'short',
185+
}).format(packageInfo.count)}
186+
size={'small'}
187+
/>
188+
</MuiTooltip>
189+
)}
190+
<MuiBox sx={classes.textLines}>
191+
<MuiTypography sx={classes.textLine}>
192+
{packageLabels[0]}
193+
</MuiTypography>
194+
{!!packageLabels[1] && (
195+
<MuiTypography sx={classes.textLine}>
196+
{packageLabels[1]}
197+
</MuiTypography>
198+
)}
199+
</MuiBox>
200+
<MuiBox sx={classes.iconColumn}>{rightIcons}</MuiBox>
201+
</MuiBox>
202+
</MuiBox>
68203
);
69204
},
70205
);

‎src/Frontend/Components/PackageCard/PackageCard.util.tsx

+2-2
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,9 @@ import {
1515
PreSelectedIcon,
1616
WasPreferredIcon,
1717
} from '../Icons/Icons';
18-
import { ListCardConfig } from '../ListCard/ListCard';
18+
import { PackageCardConfig } from './PackageCard';
1919

20-
export function getRightIcons(cardConfig: ListCardConfig) {
20+
export function getRightIcons(cardConfig: PackageCardConfig) {
2121
const rightIcons: Array<JSX.Element> = [];
2222

2323
if (cardConfig.preferred) {

‎src/Frontend/Components/PackageCard/__tests__/PackageCard.test.tsx

+47-81
Original file line numberDiff line numberDiff line change
@@ -3,105 +3,71 @@
33
//
44
// SPDX-License-Identifier: Apache-2.0
55
import { screen } from '@testing-library/react';
6-
import { noop } from 'lodash';
76

8-
import {
9-
Attributions,
10-
Resources,
11-
ResourcesToAttributions,
12-
} from '../../../../shared/shared-types';
13-
import { loadFromFile } from '../../../state/actions/resource-actions/load-actions';
14-
import { getParsedInputFileEnrichedWithTestData } from '../../../test-helpers/general-test-helpers';
7+
import { faker } from '../../../../testing/Faker';
158
import { renderComponent } from '../../../test-helpers/render';
169
import { PackageCard } from '../PackageCard';
1710

18-
let testResources: Resources;
19-
let testAttributionId: string;
20-
let anotherAttributionId: string;
21-
let testAttributions: Attributions;
22-
2311
describe('The PackageCard', () => {
24-
beforeEach(() => {
25-
testResources = {
26-
thirdParty: {
27-
'package_1.tr.gz': 1,
28-
'package_2.tr.gz': 1,
29-
'jQuery.js': 1,
30-
},
31-
};
32-
testAttributionId = 'attributionId';
33-
anotherAttributionId = 'another_id';
34-
testAttributions = {
35-
[testAttributionId]: {
36-
packageName: 'pkg',
37-
preSelected: true,
38-
id: testAttributionId,
39-
},
40-
[anotherAttributionId]: {
41-
packageName: 'pkg2',
42-
preSelected: true,
43-
id: anotherAttributionId,
44-
},
45-
};
46-
});
47-
48-
it('highlights preferred attribution correctly', () => {
49-
const testResourcesToManualAttributions: ResourcesToAttributions = {
50-
'package_1.tr.gz': [testAttributionId],
51-
};
52-
12+
it('renders with preferred icon', () => {
5313
renderComponent(
5414
<PackageCard
55-
packageInfo={{
56-
packageName: 'packageName',
57-
id: testAttributionId,
58-
preferred: true,
15+
packageInfo={faker.opossum.packageInfo({
5916
wasPreferred: true,
60-
}}
61-
onClick={noop}
17+
preferred: true,
18+
})}
19+
onClick={jest.fn()}
6220
/>,
63-
{
64-
actions: [
65-
loadFromFile(
66-
getParsedInputFileEnrichedWithTestData({
67-
resources: testResources,
68-
manualAttributions: testAttributions,
69-
resourcesToManualAttributions: testResourcesToManualAttributions,
70-
}),
71-
),
72-
],
73-
},
7421
);
7522
expect(screen.getByTestId('preferred-icon')).toBeInTheDocument();
7623
expect(screen.queryByTestId('was-preferred-icon')).not.toBeInTheDocument();
7724
});
7825

79-
it('highlights previously preferred attribution correctly', () => {
80-
const testResourcesToManualAttributions: ResourcesToAttributions = {
81-
'package_1.tr.gz': [testAttributionId],
82-
};
83-
26+
it('renders with was-preferred icon', () => {
8427
renderComponent(
8528
<PackageCard
86-
packageInfo={{
87-
packageName: 'packageName',
88-
id: testAttributionId,
89-
wasPreferred: true,
90-
}}
91-
onClick={noop}
29+
packageInfo={faker.opossum.packageInfo({ wasPreferred: true })}
30+
onClick={jest.fn()}
9231
/>,
93-
{
94-
actions: [
95-
loadFromFile(
96-
getParsedInputFileEnrichedWithTestData({
97-
resources: testResources,
98-
manualAttributions: testAttributions,
99-
resourcesToManualAttributions: testResourcesToManualAttributions,
100-
}),
101-
),
102-
],
103-
},
10432
);
33+
34+
expect(screen.queryByTestId('preferred-icon')).not.toBeInTheDocument();
10535
expect(screen.getByTestId('was-preferred-icon')).toBeInTheDocument();
10636
});
37+
38+
it('renders package card with count', () => {
39+
const packageInfo = faker.opossum.packageInfo({ count: 13 });
40+
41+
renderComponent(
42+
<PackageCard packageInfo={packageInfo} onClick={jest.fn()} />,
43+
);
44+
45+
expect(
46+
screen.getByText(
47+
`${packageInfo.packageName!}, ${packageInfo.packageVersion!}`,
48+
),
49+
).toBeInTheDocument();
50+
expect(screen.getByText(packageInfo.licenseName!)).toBeInTheDocument();
51+
expect(screen.getByText('13')).toBeInTheDocument();
52+
});
53+
54+
it('renders package card with checkbox', () => {
55+
const packageInfo = faker.opossum.packageInfo();
56+
57+
renderComponent(
58+
<PackageCard
59+
packageInfo={packageInfo}
60+
onClick={jest.fn()}
61+
checkbox={{ checked: true, onChange: jest.fn() }}
62+
/>,
63+
);
64+
65+
expect(
66+
screen.getByText(
67+
`${packageInfo.packageName!}, ${packageInfo.packageVersion!}`,
68+
),
69+
).toBeInTheDocument();
70+
expect(screen.getByText(packageInfo.licenseName!)).toBeInTheDocument();
71+
expect(screen.getByRole('checkbox')).toBeInTheDocument();
72+
});
10773
});

‎src/Frontend/Components/ReplaceAttributionsPopup/ReplaceAttributionsPopup.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@ export const ReplaceAttributionsPopup = ({
103103
<CardList
104104
data={attributionIdsForReplacement}
105105
data-testid={'removed-attributions'}
106-
renderItemContent={(attributionId, index) => {
106+
renderItemContent={(attributionId, { index }) => {
107107
if (!attributionId || !(attributionId in attributions)) {
108108
return null;
109109
}

‎src/Frontend/Components/VirtualizedTree/VirtualizedTree.tsx

+2-2
Original file line numberDiff line numberDiff line change
@@ -57,14 +57,14 @@ export function VirtualizedTree({
5757
return (
5858
<List
5959
data={resourceIds.length ? Object.keys(treeNodes) : []}
60-
renderItemContent={(nodeId) => (
60+
renderItemContent={(nodeId, { selected }) => (
6161
<VirtualizedTreeNode
6262
TreeNodeLabel={TreeNodeLabel}
6363
isExpandedNode={expandedIds.includes(nodeId)}
6464
onToggle={onToggle}
6565
onSelect={onSelect}
6666
readOnly={readOnly}
67-
selected={nodeId === selectedNodeId}
67+
selected={selected}
6868
{...treeNodes[nodeId]}
6969
/>
7070
)}
+73
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
// SPDX-FileCopyrightText: Meta Platforms, Inc. and its affiliates
2+
// SPDX-FileCopyrightText: TNG Technology Consulting GmbH <https://www.tngtech.com>
3+
//
4+
// SPDX-License-Identifier: Apache-2.0
5+
import { useCallback, useEffect, useRef, useState } from 'react';
6+
import { VirtuosoHandle } from 'react-virtuoso';
7+
8+
export function useVirtuosoRefs<T extends VirtuosoHandle>({
9+
data,
10+
selectedIndex,
11+
}: {
12+
data: ReadonlyArray<string> | null | undefined;
13+
selectedIndex: number | undefined;
14+
}) {
15+
const ref = useRef<T>(null);
16+
const listRef = useRef<Window | HTMLElement>();
17+
const [isFocused, setIsFocused] = useState(false);
18+
const [focusedIndex, setFocusedIndex] = useState<number>();
19+
20+
useEffect(() => {
21+
if (isFocused) {
22+
setFocusedIndex(selectedIndex);
23+
}
24+
}, [isFocused, selectedIndex]);
25+
26+
const keyDownCallback = useCallback(
27+
(event: Event) => {
28+
if (
29+
data?.length !== undefined &&
30+
focusedIndex !== undefined &&
31+
event instanceof KeyboardEvent
32+
) {
33+
let nextIndex: number | null = null;
34+
35+
if (event.code === 'ArrowUp') {
36+
nextIndex = Math.max(0, focusedIndex - 1);
37+
} else if (event.code === 'ArrowDown') {
38+
nextIndex = Math.min(data.length - 1, focusedIndex + 1);
39+
}
40+
41+
if (nextIndex !== null) {
42+
const index = nextIndex;
43+
ref.current?.scrollIntoView({
44+
index,
45+
behavior: 'auto',
46+
done: () => setFocusedIndex(index),
47+
});
48+
event.preventDefault();
49+
}
50+
}
51+
},
52+
[data?.length, focusedIndex],
53+
);
54+
55+
const scrollerRef = useCallback(
56+
(ref: Window | HTMLElement | null) => {
57+
if (ref) {
58+
ref.addEventListener('keydown', keyDownCallback);
59+
listRef.current = ref;
60+
} else {
61+
listRef.current?.removeEventListener('keydown', keyDownCallback);
62+
}
63+
},
64+
[keyDownCallback],
65+
);
66+
67+
return {
68+
ref,
69+
scrollerRef,
70+
focusedIndex,
71+
setIsFocused,
72+
};
73+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
// SPDX-FileCopyrightText: Meta Platforms, Inc. and its affiliates
2+
// SPDX-FileCopyrightText: TNG Technology Consulting GmbH <https://www.tngtech.com>
3+
//
4+
// SPDX-License-Identifier: Apache-2.0
5+
import { faker, test } from '../utils';
6+
7+
const [resourceName1, resourceName2, resourceName3, resourceName4] =
8+
faker.opossum.resourceNames({ count: 4 });
9+
const [attributionId1, packageInfo1] = faker.opossum.rawAttribution({
10+
packageName: 'a',
11+
});
12+
const [attributionId2, packageInfo2] = faker.opossum.rawAttribution({
13+
packageName: 'b',
14+
});
15+
const [attributionId3, packageInfo3] = faker.opossum.rawAttribution({
16+
packageName: 'c',
17+
});
18+
19+
test.use({
20+
data: {
21+
inputData: faker.opossum.inputData({
22+
resources: faker.opossum.resources({
23+
[resourceName1]: {
24+
[resourceName2]: {
25+
[resourceName3]: 1,
26+
},
27+
},
28+
[resourceName4]: 1,
29+
}),
30+
}),
31+
outputData: faker.opossum.outputData({
32+
manualAttributions: faker.opossum.rawAttributions({
33+
[attributionId1]: packageInfo1,
34+
[attributionId2]: packageInfo2,
35+
[attributionId3]: packageInfo3,
36+
}),
37+
resourcesToAttributions: faker.opossum.resourcesToAttributions({
38+
[faker.opossum.filePath(resourceName1, resourceName2, resourceName3)]: [
39+
attributionId1,
40+
],
41+
[faker.opossum.filePath(resourceName4)]: [attributionId2],
42+
[faker.opossum.folderPath(resourceName1, resourceName2)]: [
43+
attributionId3,
44+
],
45+
}),
46+
}),
47+
},
48+
});
49+
50+
test('allows selecting and deselecting all attributions in the active tab', async ({
51+
attributionsPanel,
52+
resourcesTree,
53+
confirmDeletionPopup,
54+
}) => {
55+
await resourcesTree.goto(resourceName4);
56+
await attributionsPanel.assert.selectedTabIs('onResource');
57+
await attributionsPanel.packageCard.assert.checkboxIsUnchecked(packageInfo2);
58+
59+
await attributionsPanel.selectAllCheckbox.click();
60+
await attributionsPanel.packageCard.assert.checkboxIsChecked(packageInfo2);
61+
62+
await attributionsPanel.deleteButton.click();
63+
await confirmDeletionPopup.assert.hasText('the following attribution');
64+
65+
await confirmDeletionPopup.cancelButton.click();
66+
await attributionsPanel.tabs.unrelated.click();
67+
await attributionsPanel.selectAllCheckbox.click();
68+
await attributionsPanel.packageCard.assert.checkboxIsChecked(packageInfo1);
69+
await attributionsPanel.packageCard.assert.checkboxIsChecked(packageInfo3);
70+
71+
await attributionsPanel.deleteButton.click();
72+
await confirmDeletionPopup.assert.hasText('the following 2 attributions');
73+
74+
await confirmDeletionPopup.cancelButton.click();
75+
await attributionsPanel.selectAllCheckbox.click();
76+
await attributionsPanel.packageCard.assert.checkboxIsUnchecked(packageInfo1);
77+
await attributionsPanel.packageCard.assert.checkboxIsUnchecked(packageInfo3);
78+
});
79+
80+
test('allows navigating through the attributions list by keyboard', async ({
81+
attributionsPanel,
82+
attributionDetails,
83+
window,
84+
}) => {
85+
await attributionsPanel.packageCard.click(packageInfo1);
86+
await attributionDetails.attributionForm.assert.matchesPackageInfo(
87+
packageInfo1,
88+
);
89+
90+
await window.keyboard.press('ArrowDown');
91+
await window.keyboard.press('ArrowDown');
92+
await window.keyboard.press('Enter');
93+
await attributionDetails.attributionForm.assert.matchesPackageInfo(
94+
packageInfo3,
95+
);
96+
97+
await window.keyboard.press('ArrowUp');
98+
await window.keyboard.press('Space');
99+
await attributionDetails.attributionForm.assert.matchesPackageInfo(
100+
packageInfo2,
101+
);
102+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
// SPDX-FileCopyrightText: Meta Platforms, Inc. and its affiliates
2+
// SPDX-FileCopyrightText: TNG Technology Consulting GmbH <https://www.tngtech.com>
3+
//
4+
// SPDX-License-Identifier: Apache-2.0
5+
import { faker, test } from '../utils';
6+
7+
const [resourceName1, resourceName2, resourceName3, resourceName4] =
8+
faker.opossum.resourceNames({ count: 4 });
9+
const [attributionId1, packageInfo1] = faker.opossum.rawAttribution({
10+
packageName: 'a',
11+
});
12+
const [attributionId2, packageInfo2] = faker.opossum.rawAttribution({
13+
packageName: 'b',
14+
});
15+
const [attributionId3, packageInfo3] = faker.opossum.rawAttribution({
16+
packageName: 'c',
17+
});
18+
19+
test.use({
20+
data: {
21+
inputData: faker.opossum.inputData({
22+
resources: faker.opossum.resources({
23+
[resourceName1]: {
24+
[resourceName2]: {
25+
[resourceName3]: 1,
26+
},
27+
},
28+
[resourceName4]: 1,
29+
}),
30+
externalAttributions: faker.opossum.rawAttributions({
31+
[attributionId1]: packageInfo1,
32+
[attributionId2]: packageInfo2,
33+
[attributionId3]: packageInfo3,
34+
}),
35+
resourcesToAttributions: faker.opossum.resourcesToAttributions({
36+
[faker.opossum.filePath(resourceName1, resourceName2, resourceName3)]: [
37+
attributionId1,
38+
],
39+
[faker.opossum.filePath(resourceName4)]: [attributionId2],
40+
[faker.opossum.folderPath(resourceName1, resourceName2)]: [
41+
attributionId3,
42+
],
43+
}),
44+
}),
45+
},
46+
});
47+
48+
test('allows selecting and deselecting all signals in the active tab', async ({
49+
signalsPanel,
50+
}) => {
51+
await signalsPanel.assert.selectedTabIs('onChildren');
52+
await signalsPanel.packageCard.assert.checkboxIsUnchecked(packageInfo1);
53+
await signalsPanel.packageCard.assert.checkboxIsUnchecked(packageInfo2);
54+
await signalsPanel.packageCard.assert.checkboxIsUnchecked(packageInfo3);
55+
56+
await signalsPanel.selectAllCheckbox.click();
57+
await signalsPanel.packageCard.assert.checkboxIsChecked(packageInfo1);
58+
await signalsPanel.packageCard.assert.checkboxIsChecked(packageInfo2);
59+
await signalsPanel.packageCard.assert.checkboxIsChecked(packageInfo3);
60+
61+
await signalsPanel.selectAllCheckbox.click();
62+
await signalsPanel.packageCard.assert.checkboxIsUnchecked(packageInfo1);
63+
await signalsPanel.packageCard.assert.checkboxIsUnchecked(packageInfo2);
64+
await signalsPanel.packageCard.assert.checkboxIsUnchecked(packageInfo3);
65+
});
66+
67+
test('allows navigating through the signals list by keyboard', async ({
68+
signalsPanel,
69+
attributionDetails,
70+
window,
71+
}) => {
72+
await signalsPanel.packageCard.click(packageInfo1);
73+
await attributionDetails.attributionForm.assert.matchesPackageInfo(
74+
packageInfo1,
75+
);
76+
77+
await window.keyboard.press('ArrowDown');
78+
await window.keyboard.press('ArrowDown');
79+
await window.keyboard.press('Enter');
80+
await attributionDetails.attributionForm.assert.matchesPackageInfo(
81+
packageInfo3,
82+
);
83+
84+
await window.keyboard.press('ArrowUp');
85+
await window.keyboard.press('Space');
86+
await attributionDetails.attributionForm.assert.matchesPackageInfo(
87+
packageInfo2,
88+
);
89+
});

‎src/e2e-tests/page-objects/AttributionsPanel.ts

+4
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ export class AttributionsPanel {
1212
private readonly node: Locator;
1313
private readonly header: Locator;
1414
readonly packageCard: PackageCard;
15+
readonly selectAllCheckbox: Locator;
1516
readonly createButton: Locator;
1617
readonly linkButton: Locator;
1718
readonly confirmButton: Locator;
@@ -47,6 +48,9 @@ export class AttributionsPanel {
4748
this.node = window.getByTestId('attributions-panel');
4849
this.header = window.getByTestId('attributions-panel-header');
4950
this.packageCard = new PackageCard(this.node);
51+
this.selectAllCheckbox = this.node.getByRole('checkbox', {
52+
name: 'Select all',
53+
});
5054
this.confirmButton = this.node.getByRole('button', {
5155
name: text.packageLists.confirm,
5256
exact: true,

‎src/e2e-tests/page-objects/SignalsPanel.ts

+4
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ export class SignalsPanel {
1212
private readonly node: Locator;
1313
private readonly header: Locator;
1414
readonly packageCard: PackageCard;
15+
readonly selectAllCheckbox: Locator;
1516
readonly linkButton: Locator;
1617
readonly deleteButton: Locator;
1718
readonly restoreButton: Locator;
@@ -42,6 +43,9 @@ export class SignalsPanel {
4243
this.node = window.getByTestId('signals-panel');
4344
this.header = window.getByTestId('signals-panel-header');
4445
this.packageCard = new PackageCard(this.node);
46+
this.selectAllCheckbox = this.node.getByRole('checkbox', {
47+
name: 'Select all',
48+
});
4549
this.deleteButton = this.node.getByRole('button', {
4650
name: text.packageLists.delete,
4751
exact: true,

0 commit comments

Comments
 (0)
Please sign in to comment.