From abcd662d3588c44840dc1cac76b9338495c732e3 Mon Sep 17 00:00:00 2001 From: Michal Marcin Date: Tue, 5 Nov 2024 17:25:10 +0100 Subject: [PATCH] feat(KFLUXUI-125): allow sorting pipeline runs by status and type --- .../PipelineRunsListView.tsx | 76 +++++++- .../__tests__/PipelineRunListView.spec.tsx | 104 ++++++++-- .../tabs/SnapshotPipelineRunsList.tsx | 78 +++++++- .../SnapshotPipelineRunsList.spec.tsx | 177 +++++++++++++++++- 4 files changed, 410 insertions(+), 25 deletions(-) diff --git a/src/components/PipelineRunListView/PipelineRunsListView.tsx b/src/components/PipelineRunListView/PipelineRunsListView.tsx index 04fc46d19..550ed6ee7 100644 --- a/src/components/PipelineRunListView/PipelineRunsListView.tsx +++ b/src/components/PipelineRunListView/PipelineRunsListView.tsx @@ -14,7 +14,7 @@ import { } from '@patternfly/react-core/deprecated'; import { FilterIcon } from '@patternfly/react-icons/dist/esm/icons/filter-icon'; import { debounce } from 'lodash-es'; -import { PipelineRunLabel } from '../../consts/pipelinerun'; +import { PipelineRunLabel, PipelineRunType } from '../../consts/pipelinerun'; import { useApplication } from '../../hooks/useApplications'; import { usePipelineRuns } from '../../hooks/usePipelineRuns'; import { usePLRVulnerabilities } from '../../hooks/useScanResults'; @@ -31,6 +31,8 @@ import PipelineRunEmptyState from '../PipelineRunDetailsView/PipelineRunEmptySta import { PipelineRunListHeaderWithVulnerabilities } from './PipelineRunListHeader'; import { PipelineRunListRowWithVulnerabilities } from './PipelineRunListRow'; +const pipelineRunTypes = [PipelineRunType.BUILD as string, PipelineRunType.TEST as string]; + type PipelineRunsListViewProps = { applicationName: string; componentName?: string; @@ -48,6 +50,8 @@ const PipelineRunsListView: React.FC(false); const [statusFiltersParam, setStatusFiltersParam] = useSearchParam('status', ''); + const [typeFilterExpanded, setTypeFilterExpanded] = React.useState(false); + const [typeFiltersParam, setTypeFiltersParam] = useSearchParam('type', ''); const requestQueue = React.useRef([]); const [onLoadName, setOnLoadName] = React.useState(nameFilter); React.useEffect(() => { @@ -102,20 +106,44 @@ const PipelineRunsListView: React.FC (typeFiltersParam ? typeFiltersParam.split(',') : []), + [typeFiltersParam], + ); + + const setTypeFilters = (filters: string[]) => setTypeFiltersParam(filters.join(',')); + + const typeFilterObj = React.useMemo(() => { + return pipelineRuns.reduce((acc, plr) => { + const runType = plr?.metadata.labels[PipelineRunLabel.PIPELINE_TYPE]; + if (pipelineRunTypes.includes(runType)) { + if (acc[runType] !== undefined) { + acc[runType] = acc[runType] + 1; + } else { + acc[runType] = 1; + } + } + return acc; + }, {}); + }, [pipelineRuns]); + const filteredPLRs = React.useMemo( () => pipelineRuns - .filter( - (plr) => + .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))), - ) + (!statusFilters.length || statusFilters.includes(pipelineRunStatus(plr))) && + (!typeFilters.length || typeFilters.includes(runType)) + ); + }) .filter((plr) => !customFilter || customFilter(plr)), - [customFilter, nameFilter, pipelineRuns, statusFilters], + [customFilter, nameFilter, pipelineRuns, statusFilters, typeFilters], ); const vulnerabilities = usePLRVulnerabilities(name ? filteredPLRs : pipelineRuns); @@ -136,6 +164,7 @@ const PipelineRunsListView: React.FC { n.length === 0 && onLoadName.length && setOnLoadName(''); @@ -196,6 +225,41 @@ const PipelineRunsListView: React.FC + + + diff --git a/src/components/PipelineRunListView/__tests__/PipelineRunListView.spec.tsx b/src/components/PipelineRunListView/__tests__/PipelineRunListView.spec.tsx index 0704d7ca7..eb53f5bdd 100644 --- a/src/components/PipelineRunListView/__tests__/PipelineRunListView.spec.tsx +++ b/src/components/PipelineRunListView/__tests__/PipelineRunListView.spec.tsx @@ -1,13 +1,14 @@ import * as React from 'react'; import '@testing-library/jest-dom'; import { Table as PfTable, TableHeader } from '@patternfly/react-table/deprecated'; -import { render, screen, fireEvent, configure, waitFor } from '@testing-library/react'; +import { render, screen, fireEvent, configure, waitFor, cleanup } 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 { mockComponentsData } from '../../ApplicationDetails/__data__'; import { PipelineRunListRow } from '../PipelineRunListRow'; import PipelineRunsListView from '../PipelineRunsListView'; @@ -125,11 +126,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', @@ -151,6 +161,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: { @@ -177,6 +188,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: { @@ -196,6 +208,10 @@ describe('Pipeline run List', () => { mockUseSnapshots.mockReturnValue([[{ metadata: { name: 'snp1' } }], true]); }); + afterEach(() => { + cleanup(); + }); + it('should render spinner if application data is not loaded', () => { usePipelineRunsMock.mockReturnValue([[], false]); render(); @@ -227,7 +243,7 @@ describe('Pipeline run List', () => { screen.queryByText('Started'); screen.queryByText('Duration'); screen.queryAllByText('Status'); - screen.queryByText('Type'); + screen.queryAllByText('Type'); screen.queryByText('Component'); }); @@ -268,30 +284,92 @@ describe('Pipeline run List', () => { }); }); - it('should render filtered pipelinerun list', async () => { + it('should render filtered pipelinerun list by name', async () => { usePipelineRunsMock.mockReturnValue([pipelineRuns, true]); const r = render(); const filter = screen.getByPlaceholderText('Filter by name...'); fireEvent.change(filter, { - target: { value: 'no-match' }, + target: { value: 'basic-node-js-first' }, }); - expect(filter.value).toBe('no-match'); + expect(filter.value).toBe('basic-node-js-first'); r.rerender(); await waitFor(() => { - expect(screen.queryByText('basic-node-js-first')).not.toBeInTheDocument(); + 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(); - expect(screen.queryByText('No results found')).toBeInTheDocument(); - expect( - screen.queryByText( - 'No results match this filter criteria. Clear all filters and try again.', - ), - ).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 () => { + usePipelineRunsMock.mockReturnValue([pipelineRuns, true]); + const r = render(); + + const statusFilter = screen.getByRole('button', { + name: /status filter menu/i, + }); + await fireEvent.click(statusFilter); + expect(statusFilter).toHaveAttribute('aria-expanded', 'true'); + + const succeededOption = screen.getByLabelText(/succeeded/i, { + selector: 'input', + }) as HTMLInputElement; + fireEvent.click(succeededOption); + + r.rerender(); + expect(succeededOption.checked).toBe(true); + 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(); + expect(succeededOption.checked).toBe(false); + }); + + it('should render filtered pipelinerun list by type', async () => { + usePipelineRunsMock.mockReturnValue([pipelineRuns, true]); + const r = render(); + + const typeFilter = screen.getByRole('button', { + name: /type filter menu/i, + }); + await fireEvent.click(typeFilter); + expect(typeFilter).toHaveAttribute('aria-expanded', 'true'); + + const testOption = screen.getByLabelText(/test/i, { + selector: 'input', + }) as HTMLInputElement; + fireEvent.click(testOption); + r.rerender(); + expect(testOption.checked).toBe(true); + + r.rerender(); + 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(); + expect(testOption.checked).toBe(false); }); it('should clear the filters and render the list again in the table', async () => { diff --git a/src/components/SnapshotDetails/tabs/SnapshotPipelineRunsList.tsx b/src/components/SnapshotDetails/tabs/SnapshotPipelineRunsList.tsx index 3b5a64733..13519c0ae 100644 --- a/src/components/SnapshotDetails/tabs/SnapshotPipelineRunsList.tsx +++ b/src/components/SnapshotDetails/tabs/SnapshotPipelineRunsList.tsx @@ -24,6 +24,8 @@ 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 PipelineRunEmptyState from '../../PipelineRunDetailsView/PipelineRunEmptyState'; import { PipelineRunListHeaderWithVulnerabilities } from '../../PipelineRunListView/PipelineRunListHeader'; import { PipelineRunListRowWithVulnerabilities } from '../../PipelineRunListView/PipelineRunListRow'; @@ -48,9 +50,21 @@ const SnapshotPipelineRunsList: React.FC(false); const [typeFiltersParam, setTypeFiltersParam] = useSearchParam('type', ''); + const [statusFilterExpanded, setStatusFilterExpanded] = React.useState(false); + const [statusFiltersParam, setStatusFiltersParam] = useSearchParam('status', ''); const requestQueue = React.useRef([]); const [onLoadName, setOnLoadName] = React.useState(nameFilter); + const statusFilters = React.useMemo( + () => (statusFiltersParam ? statusFiltersParam.split(',') : []), + [statusFiltersParam], + ); + + const setStatusFilters = React.useCallback( + (filters: string[]) => setStatusFiltersParam(filters.join(',')), + [setStatusFiltersParam], + ); + const typeFilters = React.useMemo( () => (typeFiltersParam ? typeFiltersParam.split(',') : []), [typeFiltersParam], @@ -60,7 +74,21 @@ const SnapshotPipelineRunsList: React.FC { return snapshotPipelineRuns.reduce((acc, plr) => { - const runType = plr?.metadata.labels[PipelineRunLabel.COMMIT_TYPE_LABEL]; + const status = pipelineRunStatus(plr); + if (statuses.includes(status)) { + if (acc[status] !== undefined) { + acc[status] = acc[status] + 1; + } else { + acc[status] = 1; + } + } + return acc; + }, {}); + }, [snapshotPipelineRuns]); + + const typeFilterObj = React.useMemo(() => { + return snapshotPipelineRuns.reduce((acc, plr) => { + const runType = plr?.metadata.labels[PipelineRunLabel.PIPELINE_TYPE]; if (pipelineRunTypes.includes(runType)) { if (acc[runType] !== undefined) { acc[runType] = acc[runType] + 1; @@ -76,6 +104,7 @@ const SnapshotPipelineRunsList: React.FC { @@ -89,18 +118,19 @@ const SnapshotPipelineRunsList: React.FC snapshotPipelineRuns .filter((plr) => { - const runType = plr?.metadata.labels[PipelineRunLabel.COMMIT_TYPE_LABEL]; + 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)) ); }) .filter((plr) => !customFilter || customFilter(plr)), - [customFilter, nameFilter, snapshotPipelineRuns, typeFilters], + [customFilter, nameFilter, snapshotPipelineRuns, typeFilters, statusFilters], ); const vulnerabilities = usePLRVulnerabilities(name ? filteredPLRs : snapshotPipelineRuns); @@ -132,6 +162,8 @@ const SnapshotPipelineRunsList: React.FC; } + const EmptyMsg = () => ; + return ( <> </ToolbarItem> + <ToolbarItem> + <Select + placeholderText="Status" + toggleIcon={<FilterIcon />} + toggleAriaLabel="Status filter menu" + variant={SelectVariant.checkbox} + isOpen={statusFilterExpanded} + onToggle={(ev, 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> <ToolbarItem> <Select placeholderText="Type" @@ -177,12 +244,12 @@ const SnapshotPipelineRunsList: React.FC<React.PropsWithChildren<SnapshotPipelin > {[ <SelectGroup label="Type" key="type"> - {Object.keys(statusFilterObj).map((filter) => ( + {Object.keys(typeFilterObj).map((filter) => ( <SelectOption key={filter} value={filter} isChecked={typeFilters.includes(filter)} - itemCount={statusFilterObj[filter] ?? 0} + itemCount={typeFilterObj[filter] ?? 0} > {capitalize(filter)} </SelectOption> @@ -202,6 +269,7 @@ const SnapshotPipelineRunsList: React.FC<React.PropsWithChildren<SnapshotPipelin customData={vulnerabilities} Header={PipelineRunListHeaderWithVulnerabilities} Row={PipelineRunListRowWithVulnerabilities} + EmptyMsg={EmptyMsg} loaded getRowProps={(obj: PipelineRunKind) => ({ id: obj.metadata.name, diff --git a/src/components/SnapshotDetails/tabs/__tests__/SnapshotPipelineRunsList.spec.tsx b/src/components/SnapshotDetails/tabs/__tests__/SnapshotPipelineRunsList.spec.tsx index 4a7c0065f..335159fd2 100644 --- a/src/components/SnapshotDetails/tabs/__tests__/SnapshotPipelineRunsList.spec.tsx +++ b/src/components/SnapshotDetails/tabs/__tests__/SnapshotPipelineRunsList.spec.tsx @@ -2,6 +2,7 @@ import * as React from 'react'; import { Table as PfTable, TableHeader } from '@patternfly/react-table/deprecated'; import '@testing-library/jest-dom'; import { render, screen, configure, fireEvent, waitFor } from '@testing-library/react'; +import { PipelineRunStatus } from 'src/types'; import { mockPipelineRuns } from '../../../../components/Components/__data__/mock-pipeline-run'; import { PipelineRunLabel, PipelineRunType } from '../../../../consts/pipelinerun'; import { useComponents } from '../../../../hooks/useComponents'; @@ -112,7 +113,14 @@ const snapShotPLRs = [ }, }, spec: null, - status: null, + status: { + conditions: [ + { + status: 'True', + type: 'Succeeded', + }, + ], + } as PipelineRunStatus, }, { apiVersion: mockPipelineRuns[0].apiVersion, @@ -270,6 +278,173 @@ describe('SnapshotPipelinerunsTab', () => { }); }); + it('should render filtered pipelinerun list by name', async () => { + usePLRVulnerabilitiesMock.mockReturnValue({ + vulnerabilities: {}, + fetchedPipelineRuns: snapShotPLRs.map((plr) => plr.metadata.name), + }); + const r = render( + <SnapshotPipelineRunsList + applicationName={appName} + getNextPage={null} + snapshotPipelineRuns={snapShotPLRs} + loaded={true} + />, + ); + + const filter = screen.getByPlaceholderText<HTMLInputElement>('Filter by name...'); + + fireEvent.change(filter, { + target: { value: 'python-sample-942fq' }, + }); + + expect(filter.value).toBe('python-sample-942fq'); + + r.rerender( + <SnapshotPipelineRunsList + applicationName={appName} + getNextPage={null} + snapshotPipelineRuns={snapShotPLRs} + loaded={true} + />, + ); + 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( + <SnapshotPipelineRunsList + applicationName={appName} + getNextPage={null} + snapshotPipelineRuns={snapShotPLRs} + loaded={true} + />, + ); + 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( + <SnapshotPipelineRunsList + applicationName={appName} + getNextPage={null} + snapshotPipelineRuns={snapShotPLRs} + loaded={true} + />, + ); + + const statusFilter = screen.getByRole('button', { + name: /status filter menu/i, + }); + await fireEvent.click(statusFilter); + expect(statusFilter).toHaveAttribute('aria-expanded', 'true'); + + const succeededOption = screen.getByLabelText(/succeeded/i, { + selector: 'input', + }) as HTMLInputElement; + fireEvent.click(succeededOption); + + r.rerender( + <SnapshotPipelineRunsList + applicationName={appName} + getNextPage={null} + snapshotPipelineRuns={snapShotPLRs} + loaded={true} + />, + ); + expect(succeededOption.checked).toBe(true); + 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( + <SnapshotPipelineRunsList + applicationName={appName} + getNextPage={null} + snapshotPipelineRuns={snapShotPLRs} + loaded={true} + />, + ); + expect(succeededOption.checked).toBe(false); + }); + + it('should render filtered pipelinerun list by type', async () => { + usePLRVulnerabilitiesMock.mockReturnValue({ + vulnerabilities: {}, + fetchedPipelineRuns: snapShotPLRs.map((plr) => plr.metadata.name), + }); + const r = render( + <SnapshotPipelineRunsList + applicationName={appName} + getNextPage={null} + snapshotPipelineRuns={snapShotPLRs} + loaded={true} + />, + ); + + const typeFilter = screen.getByRole('button', { + name: /type filter menu/i, + }); + await fireEvent.click(typeFilter); + expect(typeFilter).toHaveAttribute('aria-expanded', 'true'); + + const buildOption = screen.getByLabelText(/build/i, { + selector: 'input', + }) as HTMLInputElement; + fireEvent.click(buildOption); + r.rerender( + <SnapshotPipelineRunsList + applicationName={appName} + getNextPage={null} + snapshotPipelineRuns={snapShotPLRs} + loaded={true} + />, + ); + expect(buildOption.checked).toBe(true); + + r.rerender( + <SnapshotPipelineRunsList + applicationName={appName} + getNextPage={null} + snapshotPipelineRuns={snapShotPLRs} + loaded={true} + />, + ); + 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( + <SnapshotPipelineRunsList + applicationName={appName} + getNextPage={null} + snapshotPipelineRuns={snapShotPLRs} + loaded={true} + />, + ); + expect(buildOption.checked).toBe(false); + }); + it('should clear the filters and render the list again in the table', async () => { const r = render( <SnapshotPipelineRunsList