Skip to content

Commit

Permalink
feat(KFLUXUI-125): allow sorting pipeline runs by status and type
Browse files Browse the repository at this point in the history
  • Loading branch information
marcin-michal committed Nov 14, 2024
1 parent ba451ac commit 0a8d8c8
Show file tree
Hide file tree
Showing 4 changed files with 410 additions and 25 deletions.
76 changes: 70 additions & 6 deletions src/components/PipelineRunListView/PipelineRunsListView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 { useComponents } from '../../hooks/useComponents';
import { usePipelineRuns } from '../../hooks/usePipelineRuns';
import { usePLRVulnerabilities } from '../../hooks/useScanResults';
Expand All @@ -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;
Expand All @@ -48,6 +50,8 @@ const PipelineRunsListView: React.FC<React.PropsWithChildren<PipelineRunsListVie
const [name, setName] = React.useState('');
const [statusFilterExpanded, setStatusFilterExpanded] = React.useState<boolean>(false);
const [statusFiltersParam, setStatusFiltersParam] = useSearchParam('status', '');
const [typeFilterExpanded, setTypeFilterExpanded] = React.useState<boolean>(false);
const [typeFiltersParam, setTypeFiltersParam] = useSearchParam('type', '');
const requestQueue = React.useRef<Function[]>([]);
const [onLoadName, setOnLoadName] = React.useState(nameFilter);
React.useEffect(() => {
Expand Down Expand Up @@ -104,20 +108,44 @@ const PipelineRunsListView: React.FC<React.PropsWithChildren<PipelineRunsListVie
}, {});
}, [pipelineRuns]);

const typeFilters = React.useMemo(
() => (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);
Expand All @@ -138,6 +166,7 @@ const PipelineRunsListView: React.FC<React.PropsWithChildren<PipelineRunsListVie
setNameFilter('');
setName('');
setStatusFilters([]);
setTypeFilters([]);
};
const onNameInput = debounce((n: string) => {
n.length === 0 && onLoadName.length && setOnLoadName('');
Expand Down Expand Up @@ -198,6 +227,41 @@ const PipelineRunsListView: React.FC<React.PropsWithChildren<PipelineRunsListVie
]}
</Select>
</ToolbarItem>
<ToolbarItem>
<Select
placeholderText="Type"
toggleIcon={<FilterIcon />}
toggleAriaLabel="Type filter menu"
variant={SelectVariant.checkbox}
isOpen={typeFilterExpanded}
onToggle={(ev, 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(typeFilterObj).map((filter) => (
<SelectOption
key={filter}
value={filter}
isChecked={typeFilters.includes(filter)}
itemCount={typeFilterObj[filter] ?? 0}
>
{filter}
</SelectOption>
))}
</SelectGroup>,
]}
</Select>
</ToolbarItem>
</ToolbarGroup>
</ToolbarContent>
</Toolbar>
Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -121,11 +122,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',
Expand All @@ -147,6 +157,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: {
Expand All @@ -173,6 +184,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: {
Expand All @@ -192,6 +204,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(<PipelineRunsListView applicationName={appName} />);
Expand Down Expand Up @@ -223,7 +239,7 @@ describe('Pipeline run List', () => {
screen.queryByText('Started');
screen.queryByText('Duration');
screen.queryAllByText('Status');
screen.queryByText('Type');
screen.queryAllByText('Type');
screen.queryByText('Component');
});

Expand Down Expand Up @@ -264,30 +280,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(<PipelineRunsListView applicationName={appName} />);

const filter = screen.getByPlaceholderText<HTMLInputElement>('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(<PipelineRunsListView applicationName={appName} />);
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(<PipelineRunsListView applicationName={appName} />);
expect(filter.value).toBe('');
});

it('should render filtered pipelinerun list by status', async () => {
usePipelineRunsMock.mockReturnValue([pipelineRuns, true]);
const r = render(<PipelineRunsListView applicationName={appName} />);

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(<PipelineRunsListView applicationName={appName} />);
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(<PipelineRunsListView applicationName={appName} />);
expect(succeededOption.checked).toBe(false);
});

it('should render filtered pipelinerun list by type', async () => {
usePipelineRunsMock.mockReturnValue([pipelineRuns, true]);
const r = render(<PipelineRunsListView applicationName={appName} />);

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(<PipelineRunsListView applicationName={appName} />);
expect(testOption.checked).toBe(true);

r.rerender(<PipelineRunsListView applicationName={appName} />);
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.checked).toBe(false);
});

it('should clear the filters and render the list again in the table', async () => {
Expand Down
Loading

0 comments on commit 0a8d8c8

Please sign in to comment.