diff --git a/src/components/Commits/CommitDetails/tabs/CommitsPipelineRunTab.tsx b/src/components/Commits/CommitDetails/tabs/CommitsPipelineRunTab.tsx index a074f1d2..ff71fee8 100644 --- a/src/components/Commits/CommitDetails/tabs/CommitsPipelineRunTab.tsx +++ b/src/components/Commits/CommitDetails/tabs/CommitsPipelineRunTab.tsx @@ -1,13 +1,20 @@ import * as React from 'react'; import { useParams } from 'react-router-dom'; import { Bullseye, Spinner, Stack, Title } from '@patternfly/react-core'; +import { PipelineRunLabel } from '../../../../consts/pipelinerun'; import { usePipelineRunsForCommit } from '../../../../hooks/usePipelineRuns'; +import { usePipelineRunsFilter } from '../../../../hooks/usePipelineRunsFilter'; import { usePLRVulnerabilities } from '../../../../hooks/useScanResults'; import { HttpError } from '../../../../k8s/error'; import { RouterParams } from '../../../../routes/utils'; import { Table } from '../../../../shared'; import ErrorEmptyState from '../../../../shared/components/empty-state/ErrorEmptyState'; +import FilteredEmptyState from '../../../../shared/components/empty-state/FilteredEmptyState'; import { PipelineRunKind } from '../../../../types'; +import { statuses } from '../../../../utils/commits-utils'; +import { pipelineRunStatus } from '../../../../utils/pipeline-utils'; +import { pipelineRunTypes } from '../../../../utils/pipelinerun-utils'; +import { createFilterObj } from '../../../Filter/utils/pipelineruns-filter-utils'; import PipelineRunEmptyState from '../../../PipelineRun/PipelineRunEmptyState'; import { PipelineRunListHeaderWithVulnerabilities } from '../../../PipelineRun/PipelineRunListView/PipelineRunListHeader'; import { PipelineRunListRowWithVulnerabilities } from '../../../PipelineRun/PipelineRunListView/PipelineRunListRow'; @@ -16,10 +23,33 @@ import { useWorkspaceInfo } from '../../../Workspace/useWorkspaceInfo'; const CommitsPipelineRunTab: React.FC = () => { const { applicationName, commitName } = useParams(); const { namespace, workspace } = useWorkspaceInfo(); + const { + filterPLRs, + filterState: { nameFilter, typeFilters, statusFilters }, + filterToolbar, + onClearFilters, + } = usePipelineRunsFilter(); const [pipelineRuns, loaded, error, getNextPage, { isFetchingNextPage, hasNextPage }] = usePipelineRunsForCommit(namespace, workspace, applicationName, commitName); - const vulnerabilities = usePLRVulnerabilities(pipelineRuns); + const statusFilterObj = React.useMemo( + () => createFilterObj(pipelineRuns, (plr) => pipelineRunStatus(plr), statuses), + [pipelineRuns], + ); + + const typeFilterObj = React.useMemo( + () => + createFilterObj( + pipelineRuns, + (plr) => plr?.metadata.labels[PipelineRunLabel.PIPELINE_TYPE], + pipelineRunTypes, + ), + [pipelineRuns], + ); + + const filteredPLRs = React.useMemo(() => filterPLRs(pipelineRuns), [filterPLRs, pipelineRuns]); + + const vulnerabilities = usePLRVulnerabilities(nameFilter ? filteredPLRs : pipelineRuns); if (error) { const httpError = HttpError.fromCode(error ? (error as { code: number }).code : 404); @@ -36,6 +66,11 @@ const CommitsPipelineRunTab: React.FC = () => { return ; } + const EmptyMsg = () => ; + const NoDataEmptyMsg = () => ; + + const isFiltered = nameFilter.length > 0 || typeFilters.length > 0 || statusFilters.length > 0; + return ( <> @@ -44,11 +79,19 @@ const CommitsPipelineRunTab: React.FC = () => { <div> <Table key={`${pipelineRuns.length}-${vulnerabilities.fetchedPipelineRuns.length}`} - data={pipelineRuns} + unfilteredData={pipelineRuns} + data={filteredPLRs} aria-label="Pipelinerun List" Header={PipelineRunListHeaderWithVulnerabilities} + Toolbar={ + !isFiltered && pipelineRuns.length === 0 + ? null + : filterToolbar(statusFilterObj, typeFilterObj) + } loaded={isFetchingNextPage || loaded} customData={vulnerabilities} + EmptyMsg={isFiltered ? EmptyMsg : NoDataEmptyMsg} + NoDataEmptyMsg={NoDataEmptyMsg} Row={PipelineRunListRowWithVulnerabilities} getRowProps={(obj: PipelineRunKind) => ({ id: obj.metadata.name, @@ -56,12 +99,12 @@ const CommitsPipelineRunTab: React.FC = () => { isInfiniteLoading infiniteLoaderProps={{ isRowLoaded: (args) => { - return !!pipelineRuns[args.index]; + return !!filteredPLRs[args.index]; }, loadMoreRows: () => { hasNextPage && !isFetchingNextPage && getNextPage?.(); }, - rowCount: hasNextPage ? pipelineRuns.length + 1 : pipelineRuns.length, + rowCount: hasNextPage ? filteredPLRs.length + 1 : filteredPLRs.length, }} /> {isFetchingNextPage ? ( diff --git a/src/components/Filter/PipelineRunsFilterToolbar.tsx b/src/components/Filter/PipelineRunsFilterToolbar.tsx new file mode 100644 index 00000000..9311e518 --- /dev/null +++ b/src/components/Filter/PipelineRunsFilterToolbar.tsx @@ -0,0 +1,145 @@ +import { useState } from 'react'; +import { + SearchInput, + Toolbar, + ToolbarContent, + ToolbarGroup, + ToolbarItem, +} from '@patternfly/react-core'; +import { + Select, + SelectGroup, + SelectOption, + SelectVariant, +} from '@patternfly/react-core/deprecated'; +import { FilterIcon } from '@patternfly/react-icons/dist/esm/icons/filter-icon'; +import { debounce } from 'lodash-es'; + +type PipelineRunsFilterToolbarProps = { + nameFilter: string; + setNameFilter: (name: string) => void; + statusFilters: string[]; + setStatusFilters: (filters: string[]) => void; + statusOptions: { [key: string]: number }; + typeFilters: string[]; + setTypeFilters: (filters: string[]) => void; + typeOptions: { [key: string]: number }; + onLoadName: string; + setOnLoadName: (name: string) => void; + onClearFilters: () => void; +}; + +const PipelineRunsFilterToolbar: React.FC<PipelineRunsFilterToolbarProps> = ({ + nameFilter, + setNameFilter, + statusFilters, + setStatusFilters, + statusOptions, + typeFilters, + setTypeFilters, + typeOptions, + onLoadName, + setOnLoadName, + onClearFilters, +}: PipelineRunsFilterToolbarProps) => { + const [statusFilterExpanded, setStatusFilterExpanded] = useState(false); + const [typeFilterExpanded, setTypeFilterExpanded] = useState(false); + + const onNameInput = debounce((n: string) => { + n.length === 0 && onLoadName.length && setOnLoadName(''); + + setNameFilter(n); + }, 600); + + return ( + <Toolbar data-test="pipelinerun-list-toolbar" clearAllFilters={onClearFilters}> + <ToolbarContent> + <ToolbarGroup align={{ default: 'alignLeft' }}> + <ToolbarItem className="pf-v5-u-ml-0"> + <SearchInput + name="nameInput" + data-test="name-input-filter" + type="search" + aria-label="name filter" + placeholder="Filter by name..." + onChange={(_, n) => onNameInput(n)} + value={nameFilter} + /> + </ToolbarItem> + <ToolbarItem> + <Select + placeholderText="Status" + toggleIcon={<FilterIcon />} + toggleAriaLabel="Status filter menu" + variant={SelectVariant.checkbox} + isOpen={statusFilterExpanded} + onToggle={(_, expanded) => setStatusFilterExpanded(expanded)} + onSelect={(event, selection) => { + const checked = (event.target as HTMLInputElement).checked; + setStatusFilters( + checked + ? [...statusFilters, String(selection)] + : statusFilters.filter((value) => value !== selection), + ); + }} + selections={statusFilters} + isGrouped + > + {[ + <SelectGroup label="Status" key="status"> + {Object.keys(statusOptions).map((filter) => ( + <SelectOption + key={filter} + value={filter} + isChecked={statusFilters.includes(filter)} + itemCount={statusOptions[filter] ?? 0} + > + {filter} + </SelectOption> + ))} + </SelectGroup>, + ]} + </Select> + </ToolbarItem> + <ToolbarItem> + <Select + placeholderText="Type" + toggleIcon={<FilterIcon />} + toggleAriaLabel="Type filter menu" + variant={SelectVariant.checkbox} + isOpen={typeFilterExpanded} + onToggle={(_, expanded) => setTypeFilterExpanded(expanded)} + onSelect={(event, selection) => { + const checked = (event.target as HTMLInputElement).checked; + setTypeFilters( + checked + ? [...typeFilters, String(selection)] + : typeFilters.filter((value) => value !== selection), + ); + }} + selections={typeFilters} + isGrouped + > + {[ + <SelectGroup label="Type" key="type"> + {Object.keys(typeOptions).map((filter) => ( + <SelectOption + key={filter} + value={filter} + isChecked={typeFilters.includes(filter)} + itemCount={typeOptions[filter] ?? 0} + > + {filter} + </SelectOption> + ))} + </SelectGroup>, + ]} + </Select> + </ToolbarItem> + </ToolbarGroup> + </ToolbarContent> + </Toolbar> + ); +}; + +export default PipelineRunsFilterToolbar; diff --git a/src/components/Filter/utils/pipelineruns-filter-utils.ts b/src/components/Filter/utils/pipelineruns-filter-utils.ts new file mode 100644 index 00000000..3e1003d6 --- /dev/null +++ b/src/components/Filter/utils/pipelineruns-filter-utils.ts @@ -0,0 +1,26 @@ +import { PipelineRunKind } from '../../../types'; + +export const createFilterObj = ( + items: PipelineRunKind[], + keyExtractor: (item: PipelineRunKind) => string | undefined, + validKeys: string[], + filterFn?: (item: PipelineRunKind) => boolean, +): { [key: string]: number } => { + return items.reduce((acc, item) => { + if (filterFn && !filterFn(item)) { + return acc; + } + + const key = keyExtractor(item); + + if (validKeys.includes(key)) { + if (acc[key] !== undefined) { + acc[key] = acc[key] + 1; + } else { + acc[key] = 1; + } + } + + return acc; + }, {}); +}; diff --git a/src/components/PipelineRun/PipelineRunListView/PipelineRunsListView.tsx b/src/components/PipelineRun/PipelineRunListView/PipelineRunsListView.tsx index e0a078de..04c13d3e 100644 --- a/src/components/PipelineRun/PipelineRunListView/PipelineRunsListView.tsx +++ b/src/components/PipelineRun/PipelineRunListView/PipelineRunsListView.tsx @@ -1,27 +1,10 @@ import * as React from 'react'; -import { - Bullseye, - SearchInput, - Spinner, - Stack, - Toolbar, - ToolbarContent, - ToolbarGroup, - ToolbarItem, -} from '@patternfly/react-core'; -import { - Select, - SelectGroup, - SelectOption, - SelectVariant, -} from '@patternfly/react-core/deprecated'; -import { FilterIcon } from '@patternfly/react-icons/dist/esm/icons/filter-icon'; -import { debounce } from 'lodash-es'; +import { Bullseye, Spinner, Stack } from '@patternfly/react-core'; import { PipelineRunLabel } from '../../../consts/pipelinerun'; import { useComponents } from '../../../hooks/useComponents'; import { usePipelineRuns } from '../../../hooks/usePipelineRuns'; +import { usePipelineRunsFilter } from '../../../hooks/usePipelineRunsFilter'; import { usePLRVulnerabilities } from '../../../hooks/useScanResults'; -import { useSearchParam } from '../../../hooks/useSearchParam'; import { HttpError } from '../../../k8s/error'; import { Table } from '../../../shared'; import ErrorEmptyState from '../../../shared/components/empty-state/ErrorEmptyState'; @@ -29,6 +12,8 @@ import FilteredEmptyState from '../../../shared/components/empty-state/FilteredE import { PipelineRunKind } from '../../../types'; import { statuses } from '../../../utils/commits-utils'; import { pipelineRunStatus } from '../../../utils/pipeline-utils'; +import { pipelineRunTypes } from '../../../utils/pipelinerun-utils'; +import { createFilterObj } from '../../Filter/utils/pipelineruns-filter-utils'; import { useWorkspaceInfo } from '../../Workspace/useWorkspaceInfo'; import PipelineRunEmptyState from '../PipelineRunEmptyState'; import { PipelineRunListHeaderWithVulnerabilities } from './PipelineRunListHeader'; @@ -47,16 +32,12 @@ const PipelineRunsListView: React.FC<React.PropsWithChildren<PipelineRunsListVie }) => { const { namespace, workspace } = useWorkspaceInfo(); const [components, componentsLoaded] = useComponents(namespace, workspace, applicationName); - const [nameFilter, setNameFilter] = useSearchParam('name', ''); - const [statusFilterExpanded, setStatusFilterExpanded] = React.useState<boolean>(false); - const [statusFiltersParam, setStatusFiltersParam] = useSearchParam('status', ''); - const [onLoadName, setOnLoadName] = React.useState(nameFilter); - React.useEffect(() => { - if (nameFilter) { - setOnLoadName(nameFilter); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + const { + filterPLRs, + filterState: { nameFilter, onLoadName, typeFilters, statusFilters }, + filterToolbar, + onClearFilters, + } = usePipelineRunsFilter(); const [pipelineRuns, loaded, error, getNextPage, { isFetchingNextPage, hasNextPage }] = usePipelineRuns( @@ -85,115 +66,32 @@ const PipelineRunsListView: React.FC<React.PropsWithChildren<PipelineRunsListVie [applicationName, componentName, components, onLoadName], ), ); - const statusFilters = React.useMemo( - () => (statusFiltersParam ? statusFiltersParam.split(',') : []), - [statusFiltersParam], - ); - const setStatusFilters = React.useCallback( - (filters: string[]) => setStatusFiltersParam(filters.join(',')), - [setStatusFiltersParam], + const statusFilterObj = React.useMemo( + () => createFilterObj(pipelineRuns, (plr) => pipelineRunStatus(plr), statuses, customFilter), + [pipelineRuns, customFilter], ); - const statusFilterObj = React.useMemo(() => { - return pipelineRuns.reduce((acc, plr) => { - const stat = pipelineRunStatus(plr); - if (statuses.includes(stat)) { - if (acc[stat] !== undefined) { - acc[stat] = acc[stat] + 1; - } else { - acc[stat] = 1; - } - } - return acc; - }, {}); - }, [pipelineRuns]); + const typeFilterObj = React.useMemo( + () => + createFilterObj( + pipelineRuns, + (plr) => plr?.metadata.labels[PipelineRunLabel.PIPELINE_TYPE], + pipelineRunTypes, + customFilter, + ), + [pipelineRuns, customFilter], + ); const filteredPLRs = React.useMemo( - () => - pipelineRuns - .filter( - (plr) => - (!nameFilter || - plr.metadata.name.indexOf(nameFilter) >= 0 || - plr.metadata.labels?.[PipelineRunLabel.COMPONENT]?.indexOf( - nameFilter.trim().toLowerCase(), - ) >= 0) && - (!statusFilters.length || statusFilters.includes(pipelineRunStatus(plr))), - ) - .filter((plr) => !customFilter || customFilter(plr)), - [customFilter, nameFilter, pipelineRuns, statusFilters], + () => filterPLRs(pipelineRuns).filter((plr) => !customFilter || customFilter(plr)), + [filterPLRs, pipelineRuns, customFilter], ); const vulnerabilities = usePLRVulnerabilities(nameFilter ? filteredPLRs : pipelineRuns); - const onClearFilters = () => { - onLoadName.length && setOnLoadName(''); - setNameFilter(''); - setStatusFilters([]); - }; - const onNameInput = debounce((n: string) => { - n.length === 0 && onLoadName.length && setOnLoadName(''); - - setNameFilter(n); - }, 600); - const EmptyMsg = () => <FilteredEmptyState onClearFilters={onClearFilters} />; const NoDataEmptyMsg = () => <PipelineRunEmptyState applicationName={applicationName} />; - const DataToolbar = ( - <Toolbar data-test="pipelinerun-list-toolbar" clearAllFilters={onClearFilters}> - <ToolbarContent> - <ToolbarGroup align={{ default: 'alignLeft' }}> - <ToolbarItem className="pf-v5-u-ml-0"> - <SearchInput - name="nameInput" - data-test="name-input-filter" - type="search" - aria-label="name filter" - placeholder="Filter by name..." - onChange={(_, n) => onNameInput(n)} - value={nameFilter} - /> - </ToolbarItem> - <ToolbarItem> - <Select - placeholderText="Status" - toggleIcon={<FilterIcon />} - toggleAriaLabel="Status filter menu" - variant={SelectVariant.checkbox} - isOpen={statusFilterExpanded} - onToggle={(_, expanded) => setStatusFilterExpanded(expanded)} - onSelect={(event, selection) => { - const checked = (event.target as HTMLInputElement).checked; - setStatusFilters( - checked - ? [...statusFilters, String(selection)] - : statusFilters.filter((value) => value !== selection), - ); - }} - selections={statusFilters} - isGrouped - > - {[ - <SelectGroup label="Status" key="status"> - {Object.keys(statusFilterObj).map((filter) => ( - <SelectOption - key={filter} - value={filter} - isChecked={statusFilters.includes(filter)} - itemCount={statusFilterObj[filter] ?? 0} - > - {filter} - </SelectOption> - ))} - </SelectGroup>, - ]} - </Select> - </ToolbarItem> - </ToolbarGroup> - </ToolbarContent> - </Toolbar> - ); if (error) { const httpError = HttpError.fromCode(error ? (error as { code: number }).code : 404); @@ -205,14 +103,20 @@ const PipelineRunsListView: React.FC<React.PropsWithChildren<PipelineRunsListVie /> ); } + + const isFiltered = nameFilter.length > 0 || typeFilters.length > 0 || statusFilters.length > 0; + return ( <> <Table data={filteredPLRs} unfilteredData={pipelineRuns} - NoDataEmptyMsg={NoDataEmptyMsg} - EmptyMsg={EmptyMsg} - Toolbar={DataToolbar} + EmptyMsg={isFiltered ? EmptyMsg : NoDataEmptyMsg} + Toolbar={ + !isFiltered && pipelineRuns.length === 0 + ? null + : filterToolbar(statusFilterObj, typeFilterObj) + } aria-label="Pipeline run List" customData={vulnerabilities} Header={PipelineRunListHeaderWithVulnerabilities} diff --git a/src/components/PipelineRun/PipelineRunListView/__tests__/PipelineRunListView.spec.tsx b/src/components/PipelineRun/PipelineRunListView/__tests__/PipelineRunListView.spec.tsx index 50fd57ed..c26d6e2c 100644 --- a/src/components/PipelineRun/PipelineRunListView/__tests__/PipelineRunListView.spec.tsx +++ b/src/components/PipelineRun/PipelineRunListView/__tests__/PipelineRunListView.spec.tsx @@ -1,12 +1,13 @@ import * as React from 'react'; import { Table as PfTable, TableHeader } from '@patternfly/react-table/deprecated'; import { render, screen, fireEvent, waitFor, act } from '@testing-library/react'; +import { PipelineRunLabel, PipelineRunType } from '../../../../consts/pipelinerun'; import { useComponents } from '../../../../hooks/useComponents'; import { usePipelineRuns } from '../../../../hooks/usePipelineRuns'; // import { usePLRVulnerabilities } from '../../../../hooks/useScanResults'; import { useSearchParam } from '../../../../hooks/useSearchParam'; import { useSnapshots } from '../../../../hooks/useSnapshots'; -import { PipelineRunKind } from '../../../../types'; +import { PipelineRunKind, PipelineRunStatus } from '../../../../types'; import { createUseWorkspaceInfoMock } from '../../../../utils/test-utils'; import { mockComponentsData } from '../../../ApplicationDetails/__data__'; import { PipelineRunListRow } from '../PipelineRunListRow'; @@ -115,11 +116,20 @@ const pipelineRuns: PipelineRunKind[] = [ uid: '9c1f121c-1eb6-490f-b2d9-befbfc658df1', labels: { 'appstudio.openshift.io/component': 'sample-component', + [PipelineRunLabel.PIPELINE_TYPE]: PipelineRunType.TEST as string, }, }, spec: { key: 'key1', }, + status: { + conditions: [ + { + status: 'True', + type: 'Succeeded', + }, + ], + } as PipelineRunStatus, }, { kind: 'PipelineRun', @@ -141,6 +151,7 @@ const pipelineRuns: PipelineRunKind[] = [ uid: '9c1f121c-1eb6-490f-b2d9-befbfc658dfb', labels: { 'appstudio.openshift.io/component': 'test-component', + [PipelineRunLabel.PIPELINE_TYPE]: PipelineRunType.BUILD as string, }, }, spec: { @@ -167,6 +178,7 @@ const pipelineRuns: PipelineRunKind[] = [ uid: '9c1f121c-1eb6-490f-b2d9-befbfc658dfc', labels: { 'appstudio.openshift.io/component': 'sample-component', + [PipelineRunLabel.PIPELINE_TYPE]: PipelineRunType.BUILD as string, }, }, spec: { @@ -241,7 +253,7 @@ describe('Pipeline run List', () => { screen.queryByText('Started'); screen.queryByText('Duration'); screen.queryAllByText('Status'); - screen.queryByText('Type'); + screen.queryAllByText('Type'); screen.queryByText('Component'); }); @@ -295,6 +307,88 @@ describe('Pipeline run List', () => { }); }); + it('should render filtered pipelinerun list by status', async () => { + usePipelineRunsMock.mockReturnValue([ + pipelineRuns, + true, + null, + () => {}, + { isFetchingNextPage: false, hasNextPage: false }, + ]); + + const r = render(<PipelineRunsListView applicationName={appName} />); + + const statusFilter = screen.getByRole('button', { + name: /status filter menu/i, + }); + + fireEvent.click(statusFilter); + expect(statusFilter).toHaveAttribute('aria-expanded', 'true'); + + const succeededOption = screen.getByLabelText(/succeeded/i, { + selector: 'input', + }); + + fireEvent.click(succeededOption); + + r.rerender(<PipelineRunsListView applicationName={appName} />); + + expect(succeededOption).toBeChecked(); + + await waitFor(() => { + expect(screen.queryByText('basic-node-js-first')).toBeInTheDocument(); + expect(screen.queryByText('basic-node-js-second')).not.toBeInTheDocument(); + expect(screen.queryByText('basic-node-js-third')).not.toBeInTheDocument(); + }); + + // clean up for other tests + expect(statusFilter).toHaveAttribute('aria-expanded', 'true'); + fireEvent.click(succeededOption); + r.rerender(<PipelineRunsListView applicationName={appName} />); + expect(succeededOption).not.toBeChecked(); + }); + + it('should render filtered pipelinerun list by type', async () => { + usePipelineRunsMock.mockReturnValue([ + pipelineRuns, + true, + null, + () => {}, + { isFetchingNextPage: false, hasNextPage: false }, + ]); + + const r = render(<PipelineRunsListView applicationName={appName} />); + + const typeFilter = screen.getByRole('button', { + name: /type filter menu/i, + }); + + fireEvent.click(typeFilter); + expect(typeFilter).toHaveAttribute('aria-expanded', 'true'); + + const testOption = screen.getByLabelText(/test/i, { + selector: 'input', + }); + + fireEvent.click(testOption); + + r.rerender(<PipelineRunsListView applicationName={appName} />); + + expect(testOption).toBeChecked(); + + await waitFor(() => { + expect(screen.queryByText('basic-node-js-first')).toBeInTheDocument(); + expect(screen.queryByText('basic-node-js-second')).not.toBeInTheDocument(); + expect(screen.queryByText('basic-node-js-third')).not.toBeInTheDocument(); + }); + + // clean up for other tests + expect(typeFilter).toHaveAttribute('aria-expanded', 'true'); + fireEvent.click(testOption); + r.rerender(<PipelineRunsListView applicationName={appName} />); + expect(testOption).not.toBeChecked(); + }); + xit('should clear the filters and render the list again in the table', async () => { usePipelineRunsMock.mockReturnValue([ pipelineRuns, diff --git a/src/components/SnapshotDetails/tabs/SnapshotPipelineRunsList.tsx b/src/components/SnapshotDetails/tabs/SnapshotPipelineRunsList.tsx index 9e52d72b..2c0a0ffe 100644 --- a/src/components/SnapshotDetails/tabs/SnapshotPipelineRunsList.tsx +++ b/src/components/SnapshotDetails/tabs/SnapshotPipelineRunsList.tsx @@ -1,35 +1,19 @@ import * as React from 'react'; -import { - Bullseye, - SearchInput, - Spinner, - Title, - Toolbar, - ToolbarContent, - ToolbarGroup, - ToolbarItem, - debounce, - capitalize, -} from '@patternfly/react-core'; -import { - Select, - SelectVariant, - SelectGroup, - SelectOption, -} from '@patternfly/react-core/deprecated'; -import { FilterIcon } from '@patternfly/react-icons/dist/esm/icons/filter-icon'; -import { PipelineRunLabel, PipelineRunType } from '../../../consts/pipelinerun'; +import { Bullseye, Spinner, Title } from '@patternfly/react-core'; +import { PipelineRunLabel } from '../../../consts/pipelinerun'; +import { usePipelineRunsFilter } from '../../../hooks/usePipelineRunsFilter'; import { usePLRVulnerabilities } from '../../../hooks/useScanResults'; -import { useSearchParam } from '../../../hooks/useSearchParam'; import { Table } from '../../../shared'; import FilteredEmptyState from '../../../shared/components/empty-state/FilteredEmptyState'; import { PipelineRunKind } from '../../../types'; +import { statuses } from '../../../utils/commits-utils'; +import { pipelineRunStatus } from '../../../utils/pipeline-utils'; +import { pipelineRunTypes } from '../../../utils/pipelinerun-utils'; +import { createFilterObj } from '../../Filter/utils/pipelineruns-filter-utils'; import PipelineRunEmptyState from '../../PipelineRun/PipelineRunEmptyState'; import { PipelineRunListHeaderWithVulnerabilities } from '../../PipelineRun/PipelineRunListView/PipelineRunListHeader'; import { PipelineRunListRowWithVulnerabilities } from '../../PipelineRun/PipelineRunListView/PipelineRunListRow'; -const pipelineRunTypes = [PipelineRunType.BUILD as string, PipelineRunType.TEST as string]; - type SnapshotPipelineRunListProps = { snapshotPipelineRuns: PipelineRunKind[]; applicationName: string; @@ -46,65 +30,41 @@ const SnapshotPipelineRunsList: React.FC<React.PropsWithChildren<SnapshotPipelin nextPageProps, customFilter, }) => { - const [nameFilter, setNameFilter] = useSearchParam('name', ''); - const [name, setName] = React.useState(''); - const [typeFilterExpanded, setTypeFilterExpanded] = React.useState<boolean>(false); - const [typeFiltersParam, setTypeFiltersParam] = useSearchParam('type', ''); - const [onLoadName, setOnLoadName] = React.useState(nameFilter); + const { + filterPLRs, + filterState: { nameFilter, typeFilters, statusFilters }, + filterToolbar, + onClearFilters, + } = usePipelineRunsFilter(); - const typeFilters = React.useMemo( - () => (typeFiltersParam ? typeFiltersParam.split(',') : []), - [typeFiltersParam], + const statusFilterObj = React.useMemo( + () => + createFilterObj( + snapshotPipelineRuns, + (plr) => pipelineRunStatus(plr), + statuses, + customFilter, + ), + [snapshotPipelineRuns, customFilter], ); - const setTypeFilters = (filters: string[]) => setTypeFiltersParam(filters.join(',')); - - const statusFilterObj = React.useMemo(() => { - return snapshotPipelineRuns.reduce((acc, plr) => { - const runType = plr?.metadata.labels[PipelineRunLabel.COMMIT_TYPE_LABEL]; - if (pipelineRunTypes.includes(runType)) { - if (acc[runType] !== undefined) { - acc[runType] = acc[runType] + 1; - } else { - acc[runType] = 1; - } - } - return acc; - }, {}); - }, [snapshotPipelineRuns]); - - const onClearFilters = () => { - onLoadName.length && setOnLoadName(''); - setNameFilter(''); - setName(''); - setTypeFilters([]); - }; - const onNameInput = debounce((n: string) => { - n.length === 0 && onLoadName.length && setOnLoadName(''); - - setNameFilter(n); - setName(n); - }, 600); + const typeFilterObj = React.useMemo( + () => + createFilterObj( + snapshotPipelineRuns, + (plr) => plr?.metadata.labels[PipelineRunLabel.COMMIT_TYPE_LABEL], + pipelineRunTypes, + customFilter, + ), + [snapshotPipelineRuns, customFilter], + ); const filteredPLRs = React.useMemo( - () => - snapshotPipelineRuns - .filter((plr) => { - const runType = plr?.metadata.labels[PipelineRunLabel.COMMIT_TYPE_LABEL]; - return ( - (!nameFilter || - plr.metadata.name.indexOf(nameFilter) >= 0 || - plr.metadata.labels?.[PipelineRunLabel.COMPONENT]?.indexOf( - nameFilter.trim().toLowerCase(), - ) >= 0) && - (!typeFilters.length || typeFilters.includes(runType)) - ); - }) - .filter((plr) => !customFilter || customFilter(plr)), - [customFilter, nameFilter, snapshotPipelineRuns, typeFilters], + () => filterPLRs(snapshotPipelineRuns).filter((plr) => !customFilter || customFilter(plr)), + [customFilter, snapshotPipelineRuns, filterPLRs], ); - const vulnerabilities = usePLRVulnerabilities(name ? filteredPLRs : snapshotPipelineRuns); + const vulnerabilities = usePLRVulnerabilities(nameFilter ? filteredPLRs : snapshotPipelineRuns); if (!loaded) { return ( @@ -122,6 +82,10 @@ const SnapshotPipelineRunsList: React.FC<React.PropsWithChildren<SnapshotPipelin return <PipelineRunEmptyState applicationName={applicationName} />; } + const EmptyMsg = () => <FilteredEmptyState onClearFilters={onClearFilters} />; + const NoDataEmptyMsg = () => <PipelineRunEmptyState applicationName={applicationName} />; + const isFiltered = nameFilter.length > 0 || typeFilters.length > 0 || statusFilters.length > 0; + return ( <> <Title @@ -131,85 +95,35 @@ const SnapshotPipelineRunsList: React.FC<React.PropsWithChildren<SnapshotPipelin > Pipeline runs - - - - - onNameInput(n)} - value={nameFilter} - /> - - - - - - - - {filteredPLRs.length > 0 ? ( - ({ - id: obj.metadata.name, - })} - isInfiniteLoading - infiniteLoaderProps={{ - isRowLoaded: (args) => { - return !!filteredPLRs[args.index]; - }, - loadMoreRows: () => { - nextPageProps.hasNextPage && !nextPageProps.isFetchingNextPage && getNextPage?.(); - }, - rowCount: nextPageProps.hasNextPage ? filteredPLRs.length + 1 : filteredPLRs.length, - }} - /> - ) : ( - - )} + +
({ + id: obj.metadata.name, + })} + isInfiniteLoading + infiniteLoaderProps={{ + isRowLoaded: (args) => { + return !!filteredPLRs[args.index]; + }, + loadMoreRows: () => { + nextPageProps.hasNextPage && !nextPageProps.isFetchingNextPage && getNextPage?.(); + }, + rowCount: nextPageProps.hasNextPage ? filteredPLRs.length + 1 : filteredPLRs.length, + }} + /> ); }; diff --git a/src/components/SnapshotDetails/tabs/__tests__/SnapshotPipelineRunsList.spec.tsx b/src/components/SnapshotDetails/tabs/__tests__/SnapshotPipelineRunsList.spec.tsx index 4f7ce83b..d145eddd 100644 --- a/src/components/SnapshotDetails/tabs/__tests__/SnapshotPipelineRunsList.spec.tsx +++ b/src/components/SnapshotDetails/tabs/__tests__/SnapshotPipelineRunsList.spec.tsx @@ -6,6 +6,7 @@ import { useComponents } from '../../../../hooks/useComponents'; import { usePLRVulnerabilities } from '../../../../hooks/useScanResults'; import { useSearchParam } from '../../../../hooks/useSearchParam'; import { useSnapshots } from '../../../../hooks/useSnapshots'; +import { PipelineRunStatus } from '../../../../types'; import { createUseWorkspaceInfoMock } from '../../../../utils/test-utils'; import { mockComponentsData } from '../../../ApplicationDetails/__data__'; import { PipelineRunListRow } from '../../../PipelineRun/PipelineRunListView/PipelineRunListRow'; @@ -99,7 +100,14 @@ const snapShotPLRs = [ }, }, spec: null, - status: null, + status: { + conditions: [ + { + status: 'True', + type: 'Succeeded', + }, + ], + } as PipelineRunStatus, }, { apiVersion: mockPipelineRuns[0].apiVersion, @@ -265,6 +273,183 @@ describe('SnapshotPipelinerunsTab', () => { }); }); + it('should render filtered pipelinerun list by name', async () => { + usePLRVulnerabilitiesMock.mockReturnValue({ + vulnerabilities: {}, + fetchedPipelineRuns: snapShotPLRs.map((plr) => plr.metadata.name), + }); + const r = render( + , + ); + + const filter = screen.getByPlaceholderText('Filter by name...'); + + fireEvent.change(filter, { + target: { value: 'python-sample-942fq' }, + }); + + expect(filter.value).toBe('python-sample-942fq'); + + r.rerender( + , + ); + await waitFor(() => { + expect(screen.queryByText('python-sample-942fq')).toBeInTheDocument(); + expect(screen.queryByText('go-sample-s2f4f')).not.toBeInTheDocument(); + expect(screen.queryByText('go-sample-vvs')).not.toBeInTheDocument(); + }); + + // clean up for next tests + fireEvent.change(filter, { + target: { value: '' }, + }); + r.rerender( + , + ); + expect(filter.value).toBe(''); + }); + + it('should render filtered pipelinerun list by status', async () => { + usePLRVulnerabilitiesMock.mockReturnValue({ + vulnerabilities: {}, + fetchedPipelineRuns: snapShotPLRs.map((plr) => plr.metadata.name), + }); + const r = render( + , + ); + + const statusFilter = screen.getByRole('button', { + name: /status filter menu/i, + }); + fireEvent.click(statusFilter); + expect(statusFilter).toHaveAttribute('aria-expanded', 'true'); + + const succeededOption = screen.getByLabelText(/succeeded/i, { + selector: 'input', + }); + fireEvent.click(succeededOption); + + r.rerender( + , + ); + expect(succeededOption).toBeChecked(); + await waitFor(() => { + expect(screen.queryByText('python-sample-942fq')).toBeInTheDocument(); + expect(screen.queryByText('go-sample-s2f4f')).not.toBeInTheDocument(); + expect(screen.queryByText('go-sample-vvs')).not.toBeInTheDocument(); + }); + + // clean up for other tests + expect(statusFilter).toHaveAttribute('aria-expanded', 'true'); + fireEvent.click(succeededOption); + r.rerender( + , + ); + expect(succeededOption).not.toBeChecked(); + }); + + it('should render filtered pipelinerun list by type', async () => { + usePLRVulnerabilitiesMock.mockReturnValue({ + vulnerabilities: {}, + fetchedPipelineRuns: snapShotPLRs.map((plr) => plr.metadata.name), + }); + const r = render( + , + ); + + const typeFilter = screen.getByRole('button', { + name: /type filter menu/i, + }); + fireEvent.click(typeFilter); + expect(typeFilter).toHaveAttribute('aria-expanded', 'true'); + + const buildOption = screen.getByLabelText(/build/i, { + selector: 'input', + }); + fireEvent.click(buildOption); + r.rerender( + , + ); + expect(buildOption).toBeChecked(); + + r.rerender( + , + ); + await waitFor(() => { + expect(screen.queryByText('python-sample-942fq')).toBeInTheDocument(); + expect(screen.queryByText('go-sample-s2f4f')).not.toBeInTheDocument(); + expect(screen.queryByText('go-sample-vvs')).not.toBeInTheDocument(); + }); + + // clean up for other tests + expect(typeFilter).toHaveAttribute('aria-expanded', 'true'); + fireEvent.click(buildOption); + r.rerender( + , + ); + expect(buildOption).not.toBeChecked(); + }); + it('should clear the filters and render the list again in the table', async () => { const r = render( { + const [nameFilter, setNameFilter, unsetNameFilter] = useSearchParam('name', ''); + const [statusFiltersParam, setStatusFiltersParam, unsetStatusFiltersParam] = useSearchParam( + 'status', + '', + ); + const [typeFiltersParam, setTypeFiltersParam, unsetTypeFiltersParam] = useSearchParam('type', ''); + const [onLoadName, setOnLoadName] = useState(nameFilter); + useEffect(() => { + if (nameFilter) { + setOnLoadName(nameFilter); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const statusFilters = useMemo( + () => (statusFiltersParam ? statusFiltersParam.split(',') : []), + [statusFiltersParam], + ); + + const setStatusFilters = useCallback( + (filters: string[]) => setStatusFiltersParam(filters.join(',')), + [setStatusFiltersParam], + ); + + const typeFilters = useMemo( + () => (typeFiltersParam ? typeFiltersParam.split(',') : []), + [typeFiltersParam], + ); + + const setTypeFilters = useCallback( + (filters: string[]) => setTypeFiltersParam(filters.join(',')), + [setTypeFiltersParam], + ); + + const filterPLRs = useCallback( + (pipelineRuns: PipelineRunKind[]) => + pipelineRuns.filter((plr) => { + const runType = plr?.metadata.labels[PipelineRunLabel.PIPELINE_TYPE]; + return ( + (!nameFilter || + plr.metadata.name.indexOf(nameFilter) >= 0 || + plr.metadata.labels?.[PipelineRunLabel.COMPONENT]?.indexOf( + nameFilter.trim().toLowerCase(), + ) >= 0) && + (!statusFilters.length || statusFilters.includes(pipelineRunStatus(plr))) && + (!typeFilters.length || typeFilters.includes(runType)) + ); + }), + [nameFilter, statusFilters, typeFilters], + ); + + const onClearFilters = useCallback(() => { + onLoadName.length && setOnLoadName(''); + unsetNameFilter(); + unsetStatusFiltersParam(); + unsetTypeFiltersParam(); + }, [onLoadName, setOnLoadName, unsetNameFilter, unsetStatusFiltersParam, unsetTypeFiltersParam]); + + const filterToolbar = useCallback( + (statusOptions, typeOptions) => ( + + ), + [ + nameFilter, + setNameFilter, + statusFilters, + setStatusFilters, + typeFilters, + setTypeFilters, + onLoadName, + setOnLoadName, + onClearFilters, + ], + ); + + return { + filterPLRs, + filterState: { nameFilter, onLoadName, statusFilters, typeFilters }, + filterToolbar, + onClearFilters, + }; +}; diff --git a/src/utils/pipelinerun-utils.ts b/src/utils/pipelinerun-utils.ts index 76ee7263..7e84d8a8 100644 --- a/src/utils/pipelinerun-utils.ts +++ b/src/utils/pipelinerun-utils.ts @@ -1,5 +1,5 @@ import { curry } from 'lodash-es'; -import { PipelineRunLabel } from '../consts/pipelinerun'; +import { PipelineRunLabel, PipelineRunType } from '../consts/pipelinerun'; import { k8sQueryGetResource } from '../k8s'; import { getQueryClient } from '../k8s/query/core'; import { PipelineRunModel, TaskRunModel } from '../models'; @@ -60,3 +60,5 @@ const QueryRun = curry( export const QueryPipelineRun = QueryRun(getPipelineRuns, PipelineRunModel); export const QueryTaskRun = QueryRun(getTaskRuns, TaskRunModel); + +export const pipelineRunTypes = Object.values(PipelineRunType).map((type) => type as string);