Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(KFLUXUI-125): allow sorting pipeline runs by status and type #1020

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 { useApplication } from '../../hooks/useApplications';
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 @@ -102,20 +106,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 @@ -136,6 +164,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 @@ -196,6 +225,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 @@ -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',
Expand All @@ -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: {
Expand All @@ -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: {
Expand All @@ -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(<PipelineRunsListView applicationName={appName} />);
Expand Down Expand Up @@ -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');
});

Expand Down Expand Up @@ -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(<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