From 50a046221b9d079ab3c67b6632cc77c08ca69406 Mon Sep 17 00:00:00 2001 From: Maxim Stykow Date: Fri, 23 Feb 2024 22:40:17 +0100 Subject: [PATCH 1/2] 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 --- .../AttributionsList/AttributionsList.tsx | 10 +- .../SignalsPanel/SignalsList/SignalsList.tsx | 13 +- src/Frontend/Components/CardList/CardList.tsx | 5 +- .../ConfirmDeletePopup/ConfirmDeletePopup.tsx | 2 +- .../ConfirmReplacePopup.tsx | 2 +- .../ConfirmSavePopup/ConfirmSavePopup.tsx | 2 +- .../Components/GroupedList/GroupedList.tsx | 52 +++-- src/Frontend/Components/List/List.tsx | 57 +++--- src/Frontend/Components/ListCard/ListCard.tsx | 142 ------------- .../ListCard/__tests__/ListCard.test.tsx | 54 ----- .../Components/PackageCard/PackageCard.tsx | 187 +++++++++++++++--- .../PackageCard/PackageCard.util.tsx | 4 +- .../__tests__/PackageCard.test.tsx | 128 +++++------- .../VirtualizedTree/VirtualizedTree.tsx | 4 +- src/Frontend/util/use-virtuoso-refs.ts | 99 ++++++++++ .../__tests__/selecting-attributions.test.ts | 102 ++++++++++ .../__tests__/selecting-signals.test.ts | 89 +++++++++ .../page-objects/AttributionsPanel.ts | 4 + src/e2e-tests/page-objects/SignalsPanel.ts | 4 + 19 files changed, 599 insertions(+), 361 deletions(-) delete mode 100644 src/Frontend/Components/ListCard/ListCard.tsx delete mode 100644 src/Frontend/Components/ListCard/__tests__/ListCard.test.tsx create mode 100644 src/Frontend/util/use-virtuoso-refs.ts create mode 100644 src/e2e-tests/__tests__/selecting-attributions.test.ts create mode 100644 src/e2e-tests/__tests__/selecting-signals.test.ts diff --git a/src/Frontend/Components/AttributionPanels/AttributionsPanel/AttributionsList/AttributionsList.tsx b/src/Frontend/Components/AttributionPanels/AttributionsPanel/AttributionsList/AttributionsList.tsx index 567194f19..61c8bec16 100644 --- a/src/Frontend/Components/AttributionPanels/AttributionsPanel/AttributionsList/AttributionsList.tsx +++ b/src/Frontend/Components/AttributionPanels/AttributionsPanel/AttributionsList/AttributionsList.tsx @@ -10,7 +10,7 @@ import { changeSelectedAttributionOrOpenUnsavedPopup } from '../../../../state/a import { useAppDispatch } from '../../../../state/hooks'; import { useAttributionIdsForReplacement } from '../../../../state/variables/use-attribution-ids-for-replacement'; import { isPackageInfoIncomplete } from '../../../../util/is-important-attribution-information-missing'; -import { List } from '../../../List/List'; +import { List, ListItemContentProps } from '../../../List/List'; import { PackageCard } from '../../../PackageCard/PackageCard'; import { PackagesPanelChildrenProps } from '../../PackagesPanel/PackagesPanel'; @@ -37,7 +37,10 @@ export const AttributionsList: React.FC = ({ /> ); - function renderAttributionCard(attributionId: string) { + function renderAttributionCard( + attributionId: string, + { selected, focused }: ListItemContentProps, + ) { const attribution = attributions?.[attributionId]; if (!attribution) { @@ -54,7 +57,8 @@ export const AttributionsList: React.FC = ({ ); }} cardConfig={{ - selected: attributionId === selectedAttributionId, + selected, + focused, resolved: attributionIdsForReplacement.includes(attributionId), incomplete: isPackageInfoIncomplete(attribution), }} diff --git a/src/Frontend/Components/AttributionPanels/SignalsPanel/SignalsList/SignalsList.tsx b/src/Frontend/Components/AttributionPanels/SignalsPanel/SignalsList/SignalsList.tsx index e42b7d26e..ba5658673 100644 --- a/src/Frontend/Components/AttributionPanels/SignalsPanel/SignalsList/SignalsList.tsx +++ b/src/Frontend/Components/AttributionPanels/SignalsPanel/SignalsList/SignalsList.tsx @@ -14,7 +14,10 @@ import { getResolvedExternalAttributions, } from '../../../../state/selectors/resource-selectors'; import { useAttributionIdsForReplacement } from '../../../../state/variables/use-attribution-ids-for-replacement'; -import { GroupedList } from '../../../GroupedList/GroupedList'; +import { + GroupedList, + GroupedListItemContentProps, +} from '../../../GroupedList/GroupedList'; import { SourceIcon } from '../../../Icons/Icons'; import { PackageCard } from '../../../PackageCard/PackageCard'; import { PackagesPanelChildrenProps } from '../../PackagesPanel/PackagesPanel'; @@ -79,7 +82,10 @@ export const SignalsList: React.FC = ({ /> ); - function renderAttributionCard(attributionId: string) { + function renderAttributionCard( + attributionId: string, + { focused, selected }: GroupedListItemContentProps, + ) { const attribution = attributions?.[attributionId]; if (!attribution) { @@ -96,7 +102,8 @@ export const SignalsList: React.FC = ({ ); }} cardConfig={{ - selected: attributionId === selectedAttributionId, + selected, + focused, resolved: resolvedExternalAttributionIds.has(attributionId), }} packageInfo={attribution} diff --git a/src/Frontend/Components/CardList/CardList.tsx b/src/Frontend/Components/CardList/CardList.tsx index 24c790f00..6feded1b6 100644 --- a/src/Frontend/Components/CardList/CardList.tsx +++ b/src/Frontend/Components/CardList/CardList.tsx @@ -6,13 +6,14 @@ import { styled } from '@mui/material'; import { OpossumColors } from '../../shared-styles'; import { List } from '../List/List'; -import { LIST_CARD_HEIGHT } from '../ListCard/ListCard'; +import { PACKAGE_CARD_HEIGHT } from '../PackageCard/PackageCard'; const MAX_NUMBER_OF_CARDS = 4; export const CardList = styled(List)(({ data }) => { const height = - Math.min(MAX_NUMBER_OF_CARDS, data?.length ?? 0) * (LIST_CARD_HEIGHT + 1) + + Math.min(MAX_NUMBER_OF_CARDS, data?.length ?? 0) * + (PACKAGE_CARD_HEIGHT + 1) + 1; return { diff --git a/src/Frontend/Components/ConfirmDeletePopup/ConfirmDeletePopup.tsx b/src/Frontend/Components/ConfirmDeletePopup/ConfirmDeletePopup.tsx index c3330ba00..f2d11b439 100644 --- a/src/Frontend/Components/ConfirmDeletePopup/ConfirmDeletePopup.tsx +++ b/src/Frontend/Components/ConfirmDeletePopup/ConfirmDeletePopup.tsx @@ -118,7 +118,7 @@ export const ConfirmDeletePopup: React.FC = ({ { + renderItemContent={(attributionId, { index }) => { if (!attributionId || !(attributionId in attributions)) { return null; } diff --git a/src/Frontend/Components/ConfirmReplacePopup/ConfirmReplacePopup.tsx b/src/Frontend/Components/ConfirmReplacePopup/ConfirmReplacePopup.tsx index a7701c701..d3ba63763 100644 --- a/src/Frontend/Components/ConfirmReplacePopup/ConfirmReplacePopup.tsx +++ b/src/Frontend/Components/ConfirmReplacePopup/ConfirmReplacePopup.tsx @@ -98,7 +98,7 @@ export const ConfirmReplacePopup = ({ { + renderItemContent={(attributionId, { index }) => { if (!attributionId || !(attributionId in attributions)) { return null; } diff --git a/src/Frontend/Components/ConfirmSavePopup/ConfirmSavePopup.tsx b/src/Frontend/Components/ConfirmSavePopup/ConfirmSavePopup.tsx index 3b6ef6fd5..daef34325 100644 --- a/src/Frontend/Components/ConfirmSavePopup/ConfirmSavePopup.tsx +++ b/src/Frontend/Components/ConfirmSavePopup/ConfirmSavePopup.tsx @@ -147,7 +147,7 @@ export const ConfirmSavePopup: React.FC = ({ { + renderItemContent={(attributionId, { index }) => { if (!attributionId || !(attributionId in attributions)) { return null; } diff --git a/src/Frontend/Components/GroupedList/GroupedList.tsx b/src/Frontend/Components/GroupedList/GroupedList.tsx index 4eef61248..33654fc1e 100644 --- a/src/Frontend/Components/GroupedList/GroupedList.tsx +++ b/src/Frontend/Components/GroupedList/GroupedList.tsx @@ -10,8 +10,7 @@ import { styled } from '@mui/material'; import MuiBox from '@mui/material/Box'; import MuiTooltip from '@mui/material/Tooltip'; import { SxProps } from '@mui/system'; -import { defer } from 'lodash'; -import { useEffect, useMemo, useRef, useState } from 'react'; +import { useMemo, useState } from 'react'; import { GroupedVirtuoso, GroupedVirtuosoHandle, @@ -20,16 +19,26 @@ import { import { text } from '../../../shared/text'; import { OpossumColors } from '../../shared-styles'; +import { useVirtuosoRefs } from '../../util/use-virtuoso-refs'; import { LoadingMask } from '../LoadingMask/LoadingMask'; import { NoResults } from '../NoResults/NoResults'; import { GroupContainer, StyledLinearProgress } from './GroupedList.style'; -interface GroupedListProps { +export interface GroupedListItemContentProps { + index: number; + selected: boolean; + focused: boolean; +} + +export interface GroupedListProps { className?: string; grouped: Record> | null; loading?: boolean; renderGroupName?: (key: string) => React.ReactNode; - renderItemContent: (datum: string, index: number) => React.ReactNode; + renderItemContent: ( + datum: string, + props: GroupedListItemContentProps, + ) => React.ReactNode; selected?: string; sx?: SxProps; testId?: string; @@ -46,7 +55,6 @@ export function GroupedList({ testId, ...props }: GroupedListProps & Omit, 'selected'>) { - const ref = useRef(null); const [{ startIndex, endIndex }, setRange] = useState<{ startIndex: number; endIndex: number; @@ -63,20 +71,19 @@ export function GroupedList({ ids: flattened, keys: Object.keys(grouped), counts: Object.values(grouped).map((group) => group.length), - selectedIndex: flattened.findIndex((datum) => datum === selected), }; - }, [grouped, selected]); + }, [grouped]); - useEffect(() => { - if (groups?.selectedIndex !== undefined && groups.selectedIndex >= 0) { - defer(() => - ref.current?.scrollIntoView({ - index: groups.selectedIndex, - align: 'center', - }), - ); - } - }, [groups?.selectedIndex]); + const { + ref, + scrollerRef, + focusedIndex, + setIsVirtuosoFocused, + selectedIndex, + } = useVirtuosoRefs({ + data: groups?.ids, + selected, + }); return ( setIsVirtuosoFocused(true)} + onBlur={() => setIsVirtuosoFocused(false)} components={{ EmptyPlaceholder: loading || groups.ids.length ? undefined : () => , }} + scrollerRef={scrollerRef} rangeChanged={setRange} groupCounts={groups?.counts} groupContent={(index) => ( @@ -106,7 +116,13 @@ export function GroupedList({ )} )} - itemContent={(index) => renderItemContent(groups.ids[index], index)} + itemContent={(index) => + renderItemContent(groups.ids[index], { + index, + selected: index === selectedIndex, + focused: index === focusedIndex, + }) + } {...props} /> )} diff --git a/src/Frontend/Components/List/List.tsx b/src/Frontend/Components/List/List.tsx index 0381366e4..7875e6916 100644 --- a/src/Frontend/Components/List/List.tsx +++ b/src/Frontend/Components/List/List.tsx @@ -3,19 +3,27 @@ // // SPDX-License-Identifier: Apache-2.0 import { SxProps } from '@mui/system'; -import { defer } from 'lodash'; -import { useEffect, useMemo, useRef } from 'react'; import { Virtuoso, VirtuosoHandle, VirtuosoProps } from 'react-virtuoso'; +import { useVirtuosoRefs } from '../../util/use-virtuoso-refs'; import { LoadingMask } from '../LoadingMask/LoadingMask'; import { NoResults } from '../NoResults/NoResults'; import { StyledLinearProgress } from './List.style'; -interface ListProps { +export interface ListItemContentProps { + index: number; + selected: boolean; + focused: boolean; +} + +export interface ListProps { className?: string; data: ReadonlyArray | null; loading?: boolean; - renderItemContent: (datum: string, index: number) => React.ReactNode; + renderItemContent: ( + datum: string, + props: ListItemContentProps, + ) => React.ReactNode; selected?: string; sx?: SxProps; testId?: string; @@ -31,26 +39,16 @@ export function List({ testId, ...props }: ListProps & Omit, 'data' | 'selected'>) { - const ref = useRef(null); - - const selectedIndex = useMemo(() => { - if (!data) { - return undefined; - } - - return data.findIndex((datum) => datum === selected); - }, [data, selected]); - - useEffect(() => { - if (selectedIndex !== undefined && selectedIndex >= 0) { - defer(() => - ref.current?.scrollIntoView({ - index: selectedIndex, - align: 'center', - }), - ); - } - }, [selectedIndex]); + const { + focusedIndex, + ref, + scrollerRef, + setIsVirtuosoFocused, + selectedIndex, + } = useVirtuosoRefs({ + data, + selected, + }); return ( setIsVirtuosoFocused(true)} + onBlur={() => setIsVirtuosoFocused(false)} components={{ EmptyPlaceholder: loading || data.length ? undefined : () => , }} + scrollerRef={scrollerRef} data={data} - itemContent={(index) => renderItemContent(data[index], index)} + itemContent={(index) => + renderItemContent(data[index], { + index, + selected: index === selectedIndex, + focused: index === focusedIndex, + }) + } {...props} /> )} diff --git a/src/Frontend/Components/ListCard/ListCard.tsx b/src/Frontend/Components/ListCard/ListCard.tsx deleted file mode 100644 index 0b42f96c2..000000000 --- a/src/Frontend/Components/ListCard/ListCard.tsx +++ /dev/null @@ -1,142 +0,0 @@ -// SPDX-FileCopyrightText: Meta Platforms, Inc. and its affiliates -// SPDX-FileCopyrightText: TNG Technology Consulting GmbH -// -// SPDX-License-Identifier: Apache-2.0 -import MuiBox from '@mui/material/Box'; -import MuiChip from '@mui/material/Chip'; -import MuiTooltip from '@mui/material/Tooltip'; -import MuiTypography from '@mui/material/Typography'; -import { SxProps } from '@mui/system'; - -import { Criticality } from '../../../shared/shared-types'; -import { text } from '../../../shared/text'; -import { OpossumColors } from '../../shared-styles'; -import { maybePluralize } from '../../util/maybe-pluralize'; - -export interface ListCardConfig { - criticality?: Criticality; - excludeFromNotice?: boolean; - firstParty?: boolean; - followUp?: boolean; - incomplete?: boolean; - needsReview?: boolean; - preSelected?: boolean; - preferred?: boolean; - resolved?: boolean; - selected?: boolean; - wasPreferred?: boolean; -} - -export const LIST_CARD_HEIGHT = 40; - -const hoveredSelectedBackgroundColor = OpossumColors.middleBlue; -const hoveredBackgroundColor = OpossumColors.lightestBlueOnHover; - -const classes = { - root: { - flex: 1, - display: 'flex', - alignItems: 'center', - height: LIST_CARD_HEIGHT, - padding: '0 4px', - gap: '4px', - }, - innerRoot: { - flex: 1, - display: 'flex', - alignItems: 'center', - height: LIST_CARD_HEIGHT, - overflow: 'hidden', - gap: '8px', - }, - hover: { - '&:hover': { - cursor: 'pointer', - background: hoveredBackgroundColor, - }, - }, - selected: { - background: OpossumColors.middleBlue, - '&:hover': { - background: hoveredSelectedBackgroundColor, - }, - }, - resolved: { - opacity: 0.5, - backgroundColor: 'white', - }, - iconColumn: { - display: 'grid', - gridTemplateRows: '1fr 1fr', - gridAutoFlow: 'column', - direction: 'rtl', - }, - textLines: { - flex: 1, - overflow: 'hidden', - }, - textLine: { - userSelect: 'none', - textOverflow: 'ellipsis', - overflow: 'hidden', - whiteSpace: 'nowrap', - }, -} satisfies SxProps; - -interface ListCardProps { - text: string; - secondLineText?: string; - count?: number; - cardConfig?: ListCardConfig; - onClick?(): void; - rightIcons?: Array; - leftElement?: React.ReactNode; -} - -export function ListCard(props: ListCardProps) { - return ( - - {props.leftElement} - - {props.count && ( - - - - )} - - {props.text} - {props.secondLineText ? ( - - {props.secondLineText} - - ) : null} - - {props.rightIcons} - - - ); -} diff --git a/src/Frontend/Components/ListCard/__tests__/ListCard.test.tsx b/src/Frontend/Components/ListCard/__tests__/ListCard.test.tsx deleted file mode 100644 index ceb512113..000000000 --- a/src/Frontend/Components/ListCard/__tests__/ListCard.test.tsx +++ /dev/null @@ -1,54 +0,0 @@ -// SPDX-FileCopyrightText: Meta Platforms, Inc. and its affiliates -// SPDX-FileCopyrightText: TNG Technology Consulting GmbH -// -// SPDX-License-Identifier: Apache-2.0 -import { render, screen } from '@testing-library/react'; - -import { Checkbox } from '../../Checkbox/Checkbox'; -import { ListCard } from '../ListCard'; - -describe('The ListCard', () => { - it('renders text without count', () => { - render( - , - ); - - expect(screen.getByText('card text')).toBeInTheDocument(); - expect(screen.getByText('card text of second line')).toBeInTheDocument(); - }); - - it('renders text with count', () => { - render( - , - ); - - expect(screen.getByText('card text')).toBeInTheDocument(); - expect(screen.getByText('card text of second line')).toBeInTheDocument(); - expect(screen.getByText('13')).toBeInTheDocument(); - }); - - it('renders leftElement if provided as input', () => { - const leftElement = ; - render( - , - ); - - expect(screen.getByText('card text')).toBeInTheDocument(); - expect(screen.getByText('card text of second line')).toBeInTheDocument(); - expect(screen.getByRole('checkbox')).toBeInTheDocument(); - }); -}); diff --git a/src/Frontend/Components/PackageCard/PackageCard.tsx b/src/Frontend/Components/PackageCard/PackageCard.tsx index 7c2c176cb..d3f0f79e9 100644 --- a/src/Frontend/Components/PackageCard/PackageCard.tsx +++ b/src/Frontend/Components/PackageCard/PackageCard.tsx @@ -2,17 +2,103 @@ // SPDX-FileCopyrightText: TNG Technology Consulting GmbH // // SPDX-License-Identifier: Apache-2.0 -import { memo, useMemo } from 'react'; +import MuiBox from '@mui/material/Box'; +import MuiChip from '@mui/material/Chip'; +import MuiTooltip from '@mui/material/Tooltip'; +import MuiTypography from '@mui/material/Typography'; +import { SxProps } from '@mui/system'; +import { memo, useEffect, useMemo, useRef } from 'react'; -import { PackageInfo } from '../../../shared/shared-types'; +import { Criticality, PackageInfo } from '../../../shared/shared-types'; +import { text } from '../../../shared/text'; +import { OpossumColors } from '../../shared-styles'; import { getCardLabels } from '../../util/get-card-labels'; +import { maybePluralize } from '../../util/maybe-pluralize'; import { Checkbox } from '../Checkbox/Checkbox'; -import { ListCard, ListCardConfig } from '../ListCard/ListCard'; import { getRightIcons } from './PackageCard.util'; -interface PackageCardProps { +export const PACKAGE_CARD_HEIGHT = 40; + +const hoveredSelectedBackgroundColor = OpossumColors.middleBlue; +const hoveredBackgroundColor = OpossumColors.lightestBlueOnHover; + +const classes = { + root: { + flex: 1, + display: 'flex', + alignItems: 'center', + height: PACKAGE_CARD_HEIGHT, + padding: '0 4px', + gap: '4px', + '&:focus': { + background: hoveredBackgroundColor, + outline: 'none', + }, + }, + innerRoot: { + flex: 1, + display: 'flex', + alignItems: 'center', + height: PACKAGE_CARD_HEIGHT, + overflow: 'hidden', + gap: '8px', + }, + hover: { + '&:hover': { + cursor: 'pointer', + background: hoveredBackgroundColor, + }, + }, + selected: { + background: OpossumColors.middleBlue, + '&:hover': { + background: hoveredSelectedBackgroundColor, + }, + '&:focus': { + background: hoveredSelectedBackgroundColor, + outline: 'none', + }, + }, + resolved: { + opacity: 0.5, + backgroundColor: 'white', + }, + iconColumn: { + display: 'grid', + gridTemplateRows: '1fr 1fr', + gridAutoFlow: 'column', + direction: 'rtl', + }, + textLines: { + flex: 1, + overflow: 'hidden', + }, + textLine: { + userSelect: 'none', + textOverflow: 'ellipsis', + overflow: 'hidden', + whiteSpace: 'nowrap', + }, +} satisfies SxProps; + +export interface PackageCardConfig { + criticality?: Criticality; + excludeFromNotice?: boolean; + firstParty?: boolean; + followUp?: boolean; + incomplete?: boolean; + needsReview?: boolean; + preSelected?: boolean; + preferred?: boolean; + resolved?: boolean; + selected?: boolean; + focused?: boolean; + wasPreferred?: boolean; +} + +export interface PackageCardProps { packageInfo: PackageInfo; - cardConfig?: ListCardConfig; + cardConfig?: PackageCardConfig; onClick?(): void; checkbox?: { checked: boolean; @@ -23,11 +109,12 @@ interface PackageCardProps { export const PackageCard = memo( ({ packageInfo, cardConfig, checkbox, onClick }: PackageCardProps) => { + const ref = useRef(null); const packageLabels = useMemo( () => getCardLabels(packageInfo), [packageInfo], ); - const listCardConfig = useMemo( + const effectiveCardConfig = useMemo( () => ({ criticality: packageInfo.criticality, excludeFromNotice: packageInfo.excludeFromNotice, @@ -42,29 +129,77 @@ export const PackageCard = memo( [cardConfig, packageInfo], ); const rightIcons = useMemo( - () => getRightIcons(listCardConfig), - [listCardConfig], + () => getRightIcons(effectiveCardConfig), + [effectiveCardConfig], ); + useEffect(() => { + if (effectiveCardConfig.focused) { + ref.current?.focus(); + } + }, [effectiveCardConfig.focused]); + return ( - - ) - } - /> + { + if (['Enter', 'Space'].includes(event.code)) { + event.preventDefault(); + onClick?.(); + } + }} + sx={{ + ...classes.root, + ...(onClick && classes.hover), + ...(cardConfig?.resolved && classes.resolved), + ...(cardConfig?.selected && classes.selected), + }} + > + {checkbox && ( + + )} + + {packageInfo.count && ( + + + + )} + + + {packageLabels[0]} + + {!!packageLabels[1] && ( + + {packageLabels[1]} + + )} + + {rightIcons} + + ); }, ); diff --git a/src/Frontend/Components/PackageCard/PackageCard.util.tsx b/src/Frontend/Components/PackageCard/PackageCard.util.tsx index f873e6a03..9547df569 100644 --- a/src/Frontend/Components/PackageCard/PackageCard.util.tsx +++ b/src/Frontend/Components/PackageCard/PackageCard.util.tsx @@ -15,9 +15,9 @@ import { PreSelectedIcon, WasPreferredIcon, } from '../Icons/Icons'; -import { ListCardConfig } from '../ListCard/ListCard'; +import { PackageCardConfig } from './PackageCard'; -export function getRightIcons(cardConfig: ListCardConfig) { +export function getRightIcons(cardConfig: PackageCardConfig) { const rightIcons: Array = []; if (cardConfig.preferred) { diff --git a/src/Frontend/Components/PackageCard/__tests__/PackageCard.test.tsx b/src/Frontend/Components/PackageCard/__tests__/PackageCard.test.tsx index d457b95f2..201c36f35 100644 --- a/src/Frontend/Components/PackageCard/__tests__/PackageCard.test.tsx +++ b/src/Frontend/Components/PackageCard/__tests__/PackageCard.test.tsx @@ -3,105 +3,71 @@ // // SPDX-License-Identifier: Apache-2.0 import { screen } from '@testing-library/react'; -import { noop } from 'lodash'; -import { - Attributions, - Resources, - ResourcesToAttributions, -} from '../../../../shared/shared-types'; -import { loadFromFile } from '../../../state/actions/resource-actions/load-actions'; -import { getParsedInputFileEnrichedWithTestData } from '../../../test-helpers/general-test-helpers'; +import { faker } from '../../../../testing/Faker'; import { renderComponent } from '../../../test-helpers/render'; import { PackageCard } from '../PackageCard'; -let testResources: Resources; -let testAttributionId: string; -let anotherAttributionId: string; -let testAttributions: Attributions; - describe('The PackageCard', () => { - beforeEach(() => { - testResources = { - thirdParty: { - 'package_1.tr.gz': 1, - 'package_2.tr.gz': 1, - 'jQuery.js': 1, - }, - }; - testAttributionId = 'attributionId'; - anotherAttributionId = 'another_id'; - testAttributions = { - [testAttributionId]: { - packageName: 'pkg', - preSelected: true, - id: testAttributionId, - }, - [anotherAttributionId]: { - packageName: 'pkg2', - preSelected: true, - id: anotherAttributionId, - }, - }; - }); - - it('highlights preferred attribution correctly', () => { - const testResourcesToManualAttributions: ResourcesToAttributions = { - 'package_1.tr.gz': [testAttributionId], - }; - + it('renders with preferred icon', () => { renderComponent( , - { - actions: [ - loadFromFile( - getParsedInputFileEnrichedWithTestData({ - resources: testResources, - manualAttributions: testAttributions, - resourcesToManualAttributions: testResourcesToManualAttributions, - }), - ), - ], - }, ); expect(screen.getByTestId('preferred-icon')).toBeInTheDocument(); expect(screen.queryByTestId('was-preferred-icon')).not.toBeInTheDocument(); }); - it('highlights previously preferred attribution correctly', () => { - const testResourcesToManualAttributions: ResourcesToAttributions = { - 'package_1.tr.gz': [testAttributionId], - }; - + it('renders with was-preferred icon', () => { renderComponent( , - { - actions: [ - loadFromFile( - getParsedInputFileEnrichedWithTestData({ - resources: testResources, - manualAttributions: testAttributions, - resourcesToManualAttributions: testResourcesToManualAttributions, - }), - ), - ], - }, ); + + expect(screen.queryByTestId('preferred-icon')).not.toBeInTheDocument(); expect(screen.getByTestId('was-preferred-icon')).toBeInTheDocument(); }); + + it('renders package card with count', () => { + const packageInfo = faker.opossum.packageInfo({ count: 13 }); + + renderComponent( + , + ); + + expect( + screen.getByText( + `${packageInfo.packageName!}, ${packageInfo.packageVersion!}`, + ), + ).toBeInTheDocument(); + expect(screen.getByText(packageInfo.licenseName!)).toBeInTheDocument(); + expect(screen.getByText('13')).toBeInTheDocument(); + }); + + it('renders package card with checkbox', () => { + const packageInfo = faker.opossum.packageInfo(); + + renderComponent( + , + ); + + expect( + screen.getByText( + `${packageInfo.packageName!}, ${packageInfo.packageVersion!}`, + ), + ).toBeInTheDocument(); + expect(screen.getByText(packageInfo.licenseName!)).toBeInTheDocument(); + expect(screen.getByRole('checkbox')).toBeInTheDocument(); + }); }); diff --git a/src/Frontend/Components/VirtualizedTree/VirtualizedTree.tsx b/src/Frontend/Components/VirtualizedTree/VirtualizedTree.tsx index 9b6e29c64..bc3d66ec6 100644 --- a/src/Frontend/Components/VirtualizedTree/VirtualizedTree.tsx +++ b/src/Frontend/Components/VirtualizedTree/VirtualizedTree.tsx @@ -57,14 +57,14 @@ export function VirtualizedTree({ return ( ( + renderItemContent={(nodeId, { selected }) => ( )} diff --git a/src/Frontend/util/use-virtuoso-refs.ts b/src/Frontend/util/use-virtuoso-refs.ts new file mode 100644 index 000000000..1f2780914 --- /dev/null +++ b/src/Frontend/util/use-virtuoso-refs.ts @@ -0,0 +1,99 @@ +// SPDX-FileCopyrightText: Meta Platforms, Inc. and its affiliates +// SPDX-FileCopyrightText: TNG Technology Consulting GmbH +// +// SPDX-License-Identifier: Apache-2.0 +import { defer } from 'lodash'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { VirtuosoHandle } from 'react-virtuoso'; + +export function useVirtuosoRefs({ + data, + selected, +}: { + data: ReadonlyArray | null | undefined; + selected: string | undefined; +}) { + const ref = useRef(null); + const listRef = useRef(); + const [isVirtuosoFocused, setIsVirtuosoFocused] = useState(false); + const [focusedIndex, setFocusedIndex] = useState(); + + const selectedIndex = useMemo(() => { + if (!data) { + return undefined; + } + + return data.findIndex((datum) => datum === selected); + }, [data, selected]); + + useEffect(() => { + if (isVirtuosoFocused) { + setFocusedIndex(selectedIndex); + } + + return () => { + setFocusedIndex(undefined); + }; + }, [isVirtuosoFocused, selectedIndex]); + + useEffect(() => { + if (selectedIndex !== undefined && selectedIndex >= 0) { + defer(() => + ref.current?.scrollIntoView({ + index: selectedIndex, + align: 'center', + }), + ); + } + }, [selectedIndex, ref]); + + const handleKeyDown = useCallback( + (event: Event) => { + if ( + data?.length !== undefined && + focusedIndex !== undefined && + event instanceof KeyboardEvent + ) { + let nextIndex: number | null = null; + + if (event.code === 'ArrowUp') { + nextIndex = Math.max(0, focusedIndex - 1); + } else if (event.code === 'ArrowDown') { + nextIndex = Math.min(data.length - 1, focusedIndex + 1); + } + + if (nextIndex !== null) { + const index = nextIndex; + ref.current?.scrollIntoView({ + index, + behavior: 'auto', + }); + setFocusedIndex(index); + event.preventDefault(); + } + } + }, + [data?.length, focusedIndex], + ); + + const scrollerRef = useCallback( + (ref: Window | HTMLElement | null) => { + if (ref) { + ref.addEventListener('keydown', handleKeyDown); + listRef.current = ref; + } else { + listRef.current?.removeEventListener('keydown', handleKeyDown); + } + }, + [handleKeyDown], + ); + + return { + focusedIndex, + isVirtuosoFocused, + ref, + scrollerRef, + selectedIndex, + setIsVirtuosoFocused, + }; +} diff --git a/src/e2e-tests/__tests__/selecting-attributions.test.ts b/src/e2e-tests/__tests__/selecting-attributions.test.ts new file mode 100644 index 000000000..5184f7a10 --- /dev/null +++ b/src/e2e-tests/__tests__/selecting-attributions.test.ts @@ -0,0 +1,102 @@ +// SPDX-FileCopyrightText: Meta Platforms, Inc. and its affiliates +// SPDX-FileCopyrightText: TNG Technology Consulting GmbH +// +// SPDX-License-Identifier: Apache-2.0 +import { faker, test } from '../utils'; + +const [resourceName1, resourceName2, resourceName3, resourceName4] = + faker.opossum.resourceNames({ count: 4 }); +const [attributionId1, packageInfo1] = faker.opossum.rawAttribution({ + packageName: 'a', +}); +const [attributionId2, packageInfo2] = faker.opossum.rawAttribution({ + packageName: 'b', +}); +const [attributionId3, packageInfo3] = faker.opossum.rawAttribution({ + packageName: 'c', +}); + +test.use({ + data: { + inputData: faker.opossum.inputData({ + resources: faker.opossum.resources({ + [resourceName1]: { + [resourceName2]: { + [resourceName3]: 1, + }, + }, + [resourceName4]: 1, + }), + }), + outputData: faker.opossum.outputData({ + manualAttributions: faker.opossum.rawAttributions({ + [attributionId1]: packageInfo1, + [attributionId2]: packageInfo2, + [attributionId3]: packageInfo3, + }), + resourcesToAttributions: faker.opossum.resourcesToAttributions({ + [faker.opossum.filePath(resourceName1, resourceName2, resourceName3)]: [ + attributionId1, + ], + [faker.opossum.filePath(resourceName4)]: [attributionId2], + [faker.opossum.folderPath(resourceName1, resourceName2)]: [ + attributionId3, + ], + }), + }), + }, +}); + +test('allows selecting and deselecting all attributions in the active tab', async ({ + attributionsPanel, + resourcesTree, + confirmDeletePopup, +}) => { + await resourcesTree.goto(resourceName4); + await attributionsPanel.assert.selectedTabIs('onResource'); + await attributionsPanel.packageCard.assert.checkboxIsUnchecked(packageInfo2); + + await attributionsPanel.selectAllCheckbox.click(); + await attributionsPanel.packageCard.assert.checkboxIsChecked(packageInfo2); + + await attributionsPanel.deleteButton.click(); + await confirmDeletePopup.assert.hasText('the following attribution'); + + await confirmDeletePopup.cancelButton.click(); + await attributionsPanel.tabs.unrelated.click(); + await attributionsPanel.selectAllCheckbox.click(); + await attributionsPanel.packageCard.assert.checkboxIsChecked(packageInfo1); + await attributionsPanel.packageCard.assert.checkboxIsChecked(packageInfo3); + + await attributionsPanel.deleteButton.click(); + await confirmDeletePopup.assert.hasText('the following 2 attributions'); + + await confirmDeletePopup.cancelButton.click(); + await attributionsPanel.selectAllCheckbox.click(); + await attributionsPanel.packageCard.assert.checkboxIsUnchecked(packageInfo1); + await attributionsPanel.packageCard.assert.checkboxIsUnchecked(packageInfo3); +}); + +test('allows navigating through the attributions list by keyboard', async ({ + attributionsPanel, + attributionDetails, + window, +}) => { + await attributionsPanel.packageCard.click(packageInfo1); + await attributionDetails.attributionForm.assert.matchesPackageInfo( + packageInfo1, + ); + + await window.keyboard.press('ArrowDown'); + await window.keyboard.press('ArrowDown'); + await window.keyboard.press('Enter'); + await attributionDetails.attributionForm.assert.matchesPackageInfo( + packageInfo3, + ); + + await window.keyboard.press('ArrowUp'); + await window.keyboard.press('Space'); + await attributionDetails.attributionForm.assert.matchesPackageInfo( + packageInfo2, + ); +}); diff --git a/src/e2e-tests/__tests__/selecting-signals.test.ts b/src/e2e-tests/__tests__/selecting-signals.test.ts new file mode 100644 index 000000000..ac1844133 --- /dev/null +++ b/src/e2e-tests/__tests__/selecting-signals.test.ts @@ -0,0 +1,89 @@ +// SPDX-FileCopyrightText: Meta Platforms, Inc. and its affiliates +// SPDX-FileCopyrightText: TNG Technology Consulting GmbH +// +// SPDX-License-Identifier: Apache-2.0 +import { faker, test } from '../utils'; + +const [resourceName1, resourceName2, resourceName3, resourceName4] = + faker.opossum.resourceNames({ count: 4 }); +const [attributionId1, packageInfo1] = faker.opossum.rawAttribution({ + packageName: 'a', +}); +const [attributionId2, packageInfo2] = faker.opossum.rawAttribution({ + packageName: 'b', +}); +const [attributionId3, packageInfo3] = faker.opossum.rawAttribution({ + packageName: 'c', +}); + +test.use({ + data: { + inputData: faker.opossum.inputData({ + resources: faker.opossum.resources({ + [resourceName1]: { + [resourceName2]: { + [resourceName3]: 1, + }, + }, + [resourceName4]: 1, + }), + externalAttributions: faker.opossum.rawAttributions({ + [attributionId1]: packageInfo1, + [attributionId2]: packageInfo2, + [attributionId3]: packageInfo3, + }), + resourcesToAttributions: faker.opossum.resourcesToAttributions({ + [faker.opossum.filePath(resourceName1, resourceName2, resourceName3)]: [ + attributionId1, + ], + [faker.opossum.filePath(resourceName4)]: [attributionId2], + [faker.opossum.folderPath(resourceName1, resourceName2)]: [ + attributionId3, + ], + }), + }), + }, +}); + +test('allows selecting and deselecting all signals in the active tab', async ({ + signalsPanel, +}) => { + await signalsPanel.assert.selectedTabIs('onChildren'); + await signalsPanel.packageCard.assert.checkboxIsUnchecked(packageInfo1); + await signalsPanel.packageCard.assert.checkboxIsUnchecked(packageInfo2); + await signalsPanel.packageCard.assert.checkboxIsUnchecked(packageInfo3); + + await signalsPanel.selectAllCheckbox.click(); + await signalsPanel.packageCard.assert.checkboxIsChecked(packageInfo1); + await signalsPanel.packageCard.assert.checkboxIsChecked(packageInfo2); + await signalsPanel.packageCard.assert.checkboxIsChecked(packageInfo3); + + await signalsPanel.selectAllCheckbox.click(); + await signalsPanel.packageCard.assert.checkboxIsUnchecked(packageInfo1); + await signalsPanel.packageCard.assert.checkboxIsUnchecked(packageInfo2); + await signalsPanel.packageCard.assert.checkboxIsUnchecked(packageInfo3); +}); + +test('allows navigating through the signals list by keyboard', async ({ + signalsPanel, + attributionDetails, + window, +}) => { + await signalsPanel.packageCard.click(packageInfo1); + await attributionDetails.attributionForm.assert.matchesPackageInfo( + packageInfo1, + ); + + await window.keyboard.press('ArrowDown'); + await window.keyboard.press('ArrowDown'); + await window.keyboard.press('Enter'); + await attributionDetails.attributionForm.assert.matchesPackageInfo( + packageInfo3, + ); + + await window.keyboard.press('ArrowUp'); + await window.keyboard.press('Space'); + await attributionDetails.attributionForm.assert.matchesPackageInfo( + packageInfo2, + ); +}); diff --git a/src/e2e-tests/page-objects/AttributionsPanel.ts b/src/e2e-tests/page-objects/AttributionsPanel.ts index 66fd9c1c0..749e485b0 100644 --- a/src/e2e-tests/page-objects/AttributionsPanel.ts +++ b/src/e2e-tests/page-objects/AttributionsPanel.ts @@ -12,6 +12,7 @@ export class AttributionsPanel { private readonly node: Locator; private readonly header: Locator; readonly packageCard: PackageCard; + readonly selectAllCheckbox: Locator; readonly createButton: Locator; readonly linkButton: Locator; readonly confirmButton: Locator; @@ -47,6 +48,9 @@ export class AttributionsPanel { this.node = window.getByTestId('attributions-panel'); this.header = window.getByTestId('attributions-panel-header'); this.packageCard = new PackageCard(this.node); + this.selectAllCheckbox = this.node.getByRole('checkbox', { + name: 'Select all', + }); this.confirmButton = this.node.getByRole('button', { name: text.packageLists.confirm, exact: true, diff --git a/src/e2e-tests/page-objects/SignalsPanel.ts b/src/e2e-tests/page-objects/SignalsPanel.ts index 371448c33..05a84f53b 100644 --- a/src/e2e-tests/page-objects/SignalsPanel.ts +++ b/src/e2e-tests/page-objects/SignalsPanel.ts @@ -12,6 +12,7 @@ export class SignalsPanel { private readonly node: Locator; private readonly header: Locator; readonly packageCard: PackageCard; + readonly selectAllCheckbox: Locator; readonly linkButton: Locator; readonly deleteButton: Locator; readonly restoreButton: Locator; @@ -42,6 +43,9 @@ export class SignalsPanel { this.node = window.getByTestId('signals-panel'); this.header = window.getByTestId('signals-panel-header'); this.packageCard = new PackageCard(this.node); + this.selectAllCheckbox = this.node.getByRole('checkbox', { + name: 'Select all', + }); this.deleteButton = this.node.getByRole('button', { name: text.packageLists.delete, exact: true, From 91bf5fa6b78f355a51cce690d263a59b2e56cb88 Mon Sep 17 00:00:00 2001 From: Maxim Stykow Date: Tue, 5 Mar 2024 17:31:33 +0100 Subject: [PATCH 2/2] feat: add keyboard navigation for resource tree - keep track of focused ID instead of focused index in Virtuoso - add focus state for tree nodes and define keyboard actions closes #2572 Signed-off-by: Maxim Stykow Signed-off-by: Markus Obendrauf --- .../AttributionsList/AttributionsList.tsx | 2 +- .../SignalsPanel/SignalsList/SignalsList.tsx | 2 +- .../Components/GroupedList/GroupedList.tsx | 6 +- src/Frontend/Components/List/List.tsx | 6 +- .../VirtualizedTree/VirtualizedTree.tsx | 5 +- .../VirtualizedTreeNode.tsx | 36 +++++++- src/Frontend/util/use-virtuoso-refs.ts | 28 +++--- .../__tests__/selecting-resources.test.ts | 85 +++++++++++++++++++ 8 files changed, 149 insertions(+), 21 deletions(-) create mode 100644 src/e2e-tests/__tests__/selecting-resources.test.ts diff --git a/src/Frontend/Components/AttributionPanels/AttributionsPanel/AttributionsList/AttributionsList.tsx b/src/Frontend/Components/AttributionPanels/AttributionsPanel/AttributionsList/AttributionsList.tsx index 61c8bec16..fe76c2fea 100644 --- a/src/Frontend/Components/AttributionPanels/AttributionsPanel/AttributionsList/AttributionsList.tsx +++ b/src/Frontend/Components/AttributionPanels/AttributionsPanel/AttributionsList/AttributionsList.tsx @@ -31,7 +31,7 @@ export const AttributionsList: React.FC = ({ diff --git a/src/Frontend/Components/AttributionPanels/SignalsPanel/SignalsList/SignalsList.tsx b/src/Frontend/Components/AttributionPanels/SignalsPanel/SignalsList/SignalsList.tsx index ba5658673..721c3ecf8 100644 --- a/src/Frontend/Components/AttributionPanels/SignalsPanel/SignalsList/SignalsList.tsx +++ b/src/Frontend/Components/AttributionPanels/SignalsPanel/SignalsList/SignalsList.tsx @@ -69,7 +69,7 @@ export const SignalsList: React.FC = ({ return ( ( <> diff --git a/src/Frontend/Components/GroupedList/GroupedList.tsx b/src/Frontend/Components/GroupedList/GroupedList.tsx index 33654fc1e..f4df112fc 100644 --- a/src/Frontend/Components/GroupedList/GroupedList.tsx +++ b/src/Frontend/Components/GroupedList/GroupedList.tsx @@ -39,7 +39,7 @@ export interface GroupedListProps { datum: string, props: GroupedListItemContentProps, ) => React.ReactNode; - selected?: string; + selectedId?: string; sx?: SxProps; testId?: string; } @@ -50,7 +50,7 @@ export function GroupedList({ loading, renderGroupName, renderItemContent, - selected, + selectedId, sx, testId, ...props @@ -82,7 +82,7 @@ export function GroupedList({ selectedIndex, } = useVirtuosoRefs({ data: groups?.ids, - selected, + selectedId, }); return ( diff --git a/src/Frontend/Components/List/List.tsx b/src/Frontend/Components/List/List.tsx index 7875e6916..440f817b8 100644 --- a/src/Frontend/Components/List/List.tsx +++ b/src/Frontend/Components/List/List.tsx @@ -24,7 +24,7 @@ export interface ListProps { datum: string, props: ListItemContentProps, ) => React.ReactNode; - selected?: string; + selectedId?: string; sx?: SxProps; testId?: string; } @@ -34,7 +34,7 @@ export function List({ data, loading, renderItemContent, - selected, + selectedId, sx, testId, ...props @@ -47,7 +47,7 @@ export function List({ selectedIndex, } = useVirtuosoRefs({ data, - selected, + selectedId, }); return ( diff --git a/src/Frontend/Components/VirtualizedTree/VirtualizedTree.tsx b/src/Frontend/Components/VirtualizedTree/VirtualizedTree.tsx index bc3d66ec6..6112bb830 100644 --- a/src/Frontend/Components/VirtualizedTree/VirtualizedTree.tsx +++ b/src/Frontend/Components/VirtualizedTree/VirtualizedTree.tsx @@ -57,7 +57,7 @@ export function VirtualizedTree({ return ( ( + renderItemContent={(nodeId, { selected, focused }) => ( )} - selected={selectedNodeId} + selectedId={selectedNodeId} testId={testId} sx={{ height: '100%', diff --git a/src/Frontend/Components/VirtualizedTree/VirtualizedTreeNode/VirtualizedTreeNode.tsx b/src/Frontend/Components/VirtualizedTree/VirtualizedTreeNode/VirtualizedTreeNode.tsx index 22b935206..950cba941 100644 --- a/src/Frontend/Components/VirtualizedTree/VirtualizedTreeNode/VirtualizedTreeNode.tsx +++ b/src/Frontend/Components/VirtualizedTree/VirtualizedTreeNode/VirtualizedTreeNode.tsx @@ -5,6 +5,7 @@ import ChevronRightIcon from '@mui/icons-material/ChevronRight'; import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; import MuiBox from '@mui/material/Box'; +import { useEffect, useRef } from 'react'; import { Resources } from '../../../../shared/shared-types'; import { OpossumColors } from '../../../shared-styles'; @@ -26,6 +27,12 @@ const classes = { '&:hover .tree-node-selected-indicator': { display: 'block', }, + '&:focus .tree-node-selected-indicator': { + display: 'block', + }, + '&:focus': { + outline: 'none', + }, }, clickableIcon: { width: '16px', @@ -74,6 +81,7 @@ interface VirtualizedTreeNodeProps extends TreeNode { onToggle: (nodeIdsToExpand: Array) => void; readOnly?: boolean; selected: boolean; + focused: boolean; } export function VirtualizedTreeNode({ @@ -86,6 +94,7 @@ export function VirtualizedTreeNode({ onToggle, readOnly, selected, + focused, }: VirtualizedTreeNodeProps) { const isExpandable = node !== 1 && Object.keys(node).length !== 0; const marginRight = @@ -96,6 +105,14 @@ export function VirtualizedTreeNode({ ? SIMPLE_FOLDER_EXTRA_INDENT : SIMPLE_NODE_EXTRA_INDENT); + const ref = useRef(null); + + useEffect(() => { + if (focused) { + ref.current?.focus(); + } + }, [focused]); + const handleClick = readOnly ? undefined : () => { @@ -106,7 +123,24 @@ export function VirtualizedTreeNode({ }; return ( - + { + if (['Enter', 'Space'].includes(event.code)) { + event.preventDefault(); + handleClick?.(); + } else if (event.code === 'ArrowRight' && !isExpandedNode) { + event.preventDefault(); + onToggle?.([nodeId]); + } else if (event.code === 'ArrowLeft' && isExpandedNode) { + event.preventDefault(); + onToggle?.([nodeId]); + } + }} + > {renderExpandableNodeIcon()} ({ data, - selected, + selectedId, }: { data: ReadonlyArray | null | undefined; - selected: string | undefined; + selectedId: string | undefined; }) { const ref = useRef(null); const listRef = useRef(); const [isVirtuosoFocused, setIsVirtuosoFocused] = useState(false); - const [focusedIndex, setFocusedIndex] = useState(); + const [focusedId, setFocusedId] = useState(); const selectedIndex = useMemo(() => { if (!data) { return undefined; } - return data.findIndex((datum) => datum === selected); - }, [data, selected]); + return data.findIndex((datum) => datum === selectedId); + }, [data, selectedId]); + + const focusedIndex = useMemo(() => { + if (!data) { + return undefined; + } + + return data.findIndex((datum) => datum === focusedId); + }, [data, focusedId]); useEffect(() => { if (isVirtuosoFocused) { - setFocusedIndex(selectedIndex); + setFocusedId(selectedId); } return () => { - setFocusedIndex(undefined); + setFocusedId(undefined); }; - }, [isVirtuosoFocused, selectedIndex]); + }, [isVirtuosoFocused, selectedId]); useEffect(() => { if (selectedIndex !== undefined && selectedIndex >= 0) { @@ -68,12 +76,12 @@ export function useVirtuosoRefs({ index, behavior: 'auto', }); - setFocusedIndex(index); + setFocusedId(data[index]); event.preventDefault(); } } }, - [data?.length, focusedIndex], + [data, focusedIndex], ); const scrollerRef = useCallback( diff --git a/src/e2e-tests/__tests__/selecting-resources.test.ts b/src/e2e-tests/__tests__/selecting-resources.test.ts new file mode 100644 index 000000000..271d50ac3 --- /dev/null +++ b/src/e2e-tests/__tests__/selecting-resources.test.ts @@ -0,0 +1,85 @@ +// SPDX-FileCopyrightText: Meta Platforms, Inc. and its affiliates +// SPDX-FileCopyrightText: TNG Technology Consulting GmbH +// +// SPDX-License-Identifier: Apache-2.0 +import { faker, test } from '../utils'; + +const [resourceName1, resourceName2, resourceName3] = + faker.opossum.resourceNames({ count: 3 }); +const [attributionId1, packageInfo1] = faker.opossum.rawAttribution({ + packageName: 'a', +}); +const [attributionId2, packageInfo2] = faker.opossum.rawAttribution({ + packageName: 'b', +}); +const [attributionId3, packageInfo3] = faker.opossum.rawAttribution({ + packageName: 'c', +}); + +test.use({ + data: { + inputData: faker.opossum.inputData({ + resources: faker.opossum.resources({ + [resourceName1]: { + [resourceName2]: 1, + }, + [resourceName3]: 1, + }), + }), + outputData: faker.opossum.outputData({ + manualAttributions: faker.opossum.rawAttributions({ + [attributionId1]: packageInfo1, + [attributionId2]: packageInfo2, + [attributionId3]: packageInfo3, + }), + resourcesToAttributions: faker.opossum.resourcesToAttributions({ + [faker.opossum.folderPath(resourceName1)]: [attributionId1], + [faker.opossum.filePath(resourceName2)]: [attributionId1], + [faker.opossum.filePath(resourceName3)]: [attributionId3], + }), + }), + }, +}); + +test('allows navigating up and down the resource tree by keyboard', async ({ + resourcesTree, + attributionDetails, + window, +}) => { + await resourcesTree.goto(resourceName1); + await resourcesTree.goto(resourceName2); + await attributionDetails.attributionForm.assert.matchesPackageInfo( + packageInfo1, + ); + + await window.keyboard.press('ArrowDown'); + await window.keyboard.press('Enter'); + await attributionDetails.attributionForm.assert.matchesPackageInfo( + packageInfo3, + ); + + await window.keyboard.press('ArrowUp'); + await window.keyboard.press('ArrowUp'); + await window.keyboard.press('Space'); + await attributionDetails.attributionForm.assert.matchesPackageInfo( + packageInfo1, + ); +}); + +test('allows expanding and collapsing folders in the resource tree by keyboard', async ({ + resourcesTree, + window, +}) => { + await resourcesTree.goto(resourceName3); + await window.keyboard.press('ArrowUp'); + await resourcesTree.assert.resourceIsHidden(resourceName2); + + await window.keyboard.press('ArrowRight'); + await resourcesTree.assert.resourceIsVisible(resourceName2); + + await window.keyboard.press('ArrowLeft'); + await resourcesTree.assert.resourceIsHidden(resourceName2); + + await window.keyboard.press('Enter'); + await resourcesTree.assert.resourceIsVisible(resourceName2); +});