Blueprints: filter composes by blueprint version

Refs: HMS-3412
This commit is contained in:
Ondrej Ezr 2024-02-06 20:21:29 +01:00 committed by Lucas Garfield
parent 6af38141be
commit 9b5f3631d1
7 changed files with 230 additions and 12 deletions

View file

@ -0,0 +1,72 @@
import React from 'react';
import {
Dropdown,
DropdownItem,
DropdownList,
MenuToggle,
MenuToggleElement,
} from '@patternfly/react-core';
import { FilterIcon } from '@patternfly/react-icons';
import {
versionFilterType,
selectBlueprintVersionFilter,
setBlueprintVersionFilter,
} from '../../store/BlueprintSlice';
import { useAppDispatch, useAppSelector } from '../../store/hooks';
interface blueprintVersionFilterProps {
onFilterChange?: () => void;
}
const BlueprintVersionFilter: React.FC<blueprintVersionFilterProps> = ({
onFilterChange,
}: blueprintVersionFilterProps) => {
const dispatch = useAppDispatch();
const blueprintVersionFilter = useAppSelector(selectBlueprintVersionFilter);
const [isOpen, setIsOpen] = React.useState(false);
const onToggleClick = () => {
setIsOpen(!isOpen);
};
const onSelect = (
_event: React.MouseEvent<Element, MouseEvent> | undefined,
value: versionFilterType
) => {
dispatch(setBlueprintVersionFilter(value));
if (onFilterChange) onFilterChange();
setIsOpen(false);
};
return (
<Dropdown
isOpen={isOpen}
onSelect={onSelect}
onOpenChange={(isOpen: boolean) => setIsOpen(isOpen)}
toggle={(toggleRef: React.Ref<MenuToggleElement>) => (
<MenuToggle
ref={toggleRef}
onClick={onToggleClick}
isExpanded={isOpen}
icon={<FilterIcon />}
>
{blueprintVersionFilter === 'latest' ? 'Newest' : 'All versions'}
</MenuToggle>
)}
shouldFocusToggleOnSelect
>
<DropdownList>
<DropdownItem value={'all'} key="all">
All versions
</DropdownItem>
<DropdownItem value={'latest'} key="newest">
Newest
</DropdownItem>
</DropdownList>
</Dropdown>
);
};
export default BlueprintVersionFilter;

View file

@ -46,6 +46,8 @@ import {
} from '../../constants';
import {
selectBlueprintSearchInput,
selectBlueprintVersionFilter,
selectBlueprintVersionFilterAPI,
selectSelectedBlueprintId,
} from '../../store/BlueprintSlice';
import { useAppSelector } from '../../store/hooks';
@ -70,6 +72,7 @@ const ImagesTable = () => {
const [perPage, setPerPage] = useState(10);
const selectedBlueprintId = useAppSelector(selectSelectedBlueprintId);
const blueprintSearchInput = useAppSelector(selectBlueprintSearchInput);
const blueprintVersionFilter = useAppSelector(selectBlueprintVersionFilter);
const { selectedBlueprintVersion } = useGetBlueprintsQuery(
{ search: blueprintSearchInput },
@ -100,6 +103,7 @@ const ImagesTable = () => {
id: selectedBlueprintId as string,
limit: perPage,
offset: perPage * (page - 1),
blueprintVersion: useAppSelector(selectBlueprintVersionFilterAPI),
},
{ skip: !selectedBlueprintId }
);
@ -163,7 +167,12 @@ const ImagesTable = () => {
);
}
const composes = data?.data;
let composes = data?.data;
if (selectedBlueprintId && blueprintVersionFilter === 'latest') {
composes = composes?.filter((compose) => {
return compose.blueprint_version === selectedBlueprintVersion;
});
}
const itemCount = data?.meta.count || 0;
return (
@ -173,7 +182,7 @@ const ImagesTable = () => {
itemCount={itemCount}
perPage={perPage}
page={page}
onSetPage={onSetPage}
setPage={setPage}
onPerPageSelect={onPerPageSelect}
/>
<Table variant="compact" data-testid="images-table">

View file

@ -21,6 +21,7 @@ import {
import { resolveRelPath } from '../../Utilities/path';
import { useExperimentalFlag } from '../../Utilities/useExperimentalFlag';
import { BlueprintActionsMenu } from '../Blueprints/BlueprintActionsMenu';
import BlueprintVersionFilter from '../Blueprints/BlueprintVersionFilter';
import { BuildImagesButton } from '../Blueprints/BuildImagesButton';
import { DeleteBlueprintModal } from '../Blueprints/DeleteBlueprintModal';
@ -28,7 +29,7 @@ interface imagesTableToolbarProps {
itemCount: number;
perPage: number;
page: number;
onSetPage: (event: React.MouseEvent, page: number) => void;
setPage: (page: number) => void;
onPerPageSelect: (event: React.MouseEvent, perPage: number) => void;
}
@ -36,7 +37,7 @@ const ImagesTableToolbar: React.FC<imagesTableToolbarProps> = ({
itemCount,
perPage,
page,
onSetPage,
setPage,
onPerPageSelect,
}: imagesTableToolbarProps) => {
const experimentalFlag = useExperimentalFlag();
@ -60,7 +61,7 @@ const ImagesTableToolbar: React.FC<imagesTableToolbarProps> = ({
itemCount={itemCount}
perPage={perPage}
page={page}
onSetPage={onSetPage}
onSetPage={(_, page) => setPage(page)}
onPerPageSelect={onPerPageSelect}
widgetId="compose-pagination-top"
data-testid="images-pagination-top"
@ -110,6 +111,11 @@ const ImagesTableToolbar: React.FC<imagesTableToolbarProps> = ({
<ToolbarItem>
<BlueprintActionsMenu setShowDeleteModal={setShowDeleteModal} />
</ToolbarItem>
{selectedBlueprintId && (
<ToolbarItem>
<BlueprintVersionFilter onFilterChange={() => setPage(1)} />
</ToolbarItem>
)}
<ToolbarItem variant="pagination" align={{ default: 'alignRight' }}>
{pagination}
</ToolbarItem>

View file

@ -2,11 +2,14 @@ import { PayloadAction, createSlice } from '@reduxjs/toolkit';
import { RootState } from '.';
export type versionFilterType = 'latest' | 'all';
type blueprintsState = {
selectedBlueprintId: string | undefined;
searchInput?: string;
offset?: number;
limit?: number;
versionFilter?: versionFilterType;
};
const initialState: blueprintsState = {
@ -14,6 +17,7 @@ const initialState: blueprintsState = {
searchInput: undefined,
offset: 0,
limit: 10,
versionFilter: 'all',
};
export const selectSelectedBlueprintId = (state: RootState) =>
@ -22,6 +26,18 @@ export const selectBlueprintSearchInput = (state: RootState) =>
state.blueprints.searchInput;
export const selectOffset = (state: RootState) => state.blueprints.offset;
export const selectLimit = (state: RootState) => state.blueprints.limit;
export const selectBlueprintVersionFilter = (state: RootState) =>
state.blueprints.versionFilter;
export const selectBlueprintVersionFilterAPI = (
state: RootState
): number | undefined => {
const blueprintVersionFilter = state.blueprints.versionFilter;
// We allow only 'latest' filtering, everything else is understood as 'all'
if (blueprintVersionFilter === 'latest') {
return -1;
}
return undefined;
};
export const blueprintsSlice = createSlice({
name: 'blueprints',
@ -42,6 +58,12 @@ export const blueprintsSlice = createSlice({
setBlueprintLimit: (state, action: PayloadAction<number>) => {
state.limit = action.payload;
},
setBlueprintVersionFilter: (
state,
action: PayloadAction<versionFilterType>
) => {
state.versionFilter = action.payload;
},
},
});
@ -50,4 +72,5 @@ export const {
setBlueprintSearchInput,
setBlueprintsOffset,
setBlueprintLimit,
setBlueprintVersionFilter,
} = blueprintsSlice.actions;

View file

@ -32,6 +32,20 @@ jest.mock('@unleash/proxy-client-react', () => ({
),
}));
const selectBlueprintById = async (user, bpId) => {
const nameMatcher = (_, element) =>
element.getAttribute('name') === 'blueprints';
const radioButtons = await screen.findAllByRole('radio', {
name: nameMatcher,
});
const elementById = radioButtons.find(
(button) => button.getAttribute('id') === bpId
);
await user.click(elementById);
return elementById;
};
describe('Blueprints', () => {
const user = userEvent.setup();
const blueprintNameWithComposes = 'Dark Chocolate';
@ -74,7 +88,7 @@ describe('Blueprints', () => {
await user.click(elementById);
const table = await screen.findByTestId('images-table');
const { findAllByText } = within(table);
const images = await findAllByText(blueprintNameWithComposes);
const images = await findAllByText('dark-chocolate-aws');
expect(images).toHaveLength(2);
});
test('renders blueprint composes empty state', async () => {
@ -94,6 +108,7 @@ describe('Blueprints', () => {
const { findByText } = within(table);
await findByText('No images');
});
test('click build image button', async () => {
renderWithReduxRouter('', {});
const idMatcher = blueprintIdWithComposes;
@ -199,4 +214,28 @@ describe('Blueprints', () => {
});
});
});
describe('composes filtering', () => {
test('filter composes by blueprint version', async () => {
renderWithReduxRouter('', {});
await selectBlueprintById(user, blueprintIdWithComposes);
// Wait for the filter appear (right now it's hidden unless a blueprint is selected)
const composesVersionFilter = await screen.findByRole('button', {
name: /All Versions/i,
});
expect(
within(screen.getByTestId('images-table')).getAllByRole('row')
).toHaveLength(4);
await user.click(composesVersionFilter);
const option = await screen.findByRole('menuitem', { name: 'Newest' });
await user.click(option);
expect(
within(screen.getByTestId('images-table')).getAllByRole('row')
).toHaveLength(2);
});
});
});

View file

@ -1,4 +1,4 @@
import { RHEL_9 } from '../../constants';
import { RHEL_8, RHEL_9 } from '../../constants';
import {
GetBlueprintsApiResponse,
CreateBlueprintResponse,
@ -20,7 +20,7 @@ export const mockGetBlueprints: GetBlueprintsApiResponse = {
id: '677b010b-e95e-4694-9813-d11d847f1bfc',
name: 'Dark Chocolate',
description: '70% Dark Chocolate with crunchy cocoa nibs',
version: 1,
version: 2,
last_modified_at: '2021-09-09T14:38:00.000Z',
},
{
@ -75,15 +75,36 @@ export const mockBlueprintComposesOutOfSync: GetBlueprintComposesApiResponse = {
};
export const mockBlueprintComposes: GetBlueprintComposesApiResponse = {
meta: { count: 2 },
meta: { count: 3 },
data: [
{
id: '63e42aaf-b543-41c6-899f-3de1e61838dc',
image_name: 'dark-chocolate-aws',
created_at: '2023-09-08T14:38:00.000Z',
blueprint_version: 2,
request: {
distribution: RHEL_9,
image_requests: [
{
architecture: 'x86_64',
image_type: 'aws',
upload_request: {
type: 'aws',
options: {
share_with_accounts: ['123123123123'],
},
},
},
],
},
},
{
id: '1579d95b-8f1d-4982-8c53-8c2afa4ab04c',
image_name: 'Dark Chocolate',
image_name: 'dark-chocolate-aws',
created_at: '2021-09-08T14:38:00.000Z',
blueprint_version: 1,
request: {
distribution: RHEL_9,
distribution: RHEL_8,
image_requests: [
{
architecture: 'x86_64',

View file

@ -1,6 +1,6 @@
import { PathParams, RestRequest } from 'msw';
import { RHEL_8 } from '../../constants';
import { RHEL_8, RHEL_9 } from '../../constants';
import {
ClonesResponse,
ComposeStatus,
@ -401,6 +401,26 @@ export const mockComposes: ComposesResponseItem[] = [
],
},
},
{
id: '63e42aaf-b543-41c6-899f-3de1e61838dc',
image_name: 'dark-chocolate-v2',
created_at: '2023-09-08T14:38:00.000Z',
request: {
distribution: RHEL_9,
image_requests: [
{
architecture: 'x86_64',
image_type: 'aws',
upload_request: {
type: 'aws',
options: {
share_with_accounts: ['123123123123'],
},
},
},
],
},
},
];
/**
@ -437,6 +457,34 @@ export const mockStatus = (composeId: string): ComposeStatus => {
],
},
},
'63e42aaf-b543-41c6-899f-3de1e61838dc': {
image_status: {
status: 'success',
upload_status: {
options: {
ami: 'ami-0217b81d9be50e44c',
region: 'us-east-1',
},
status: 'success',
type: 'aws',
},
},
request: {
distribution: RHEL_9,
image_requests: [
{
architecture: 'x86_64',
image_type: 'aws',
upload_request: {
type: 'aws',
options: {
share_with_accounts: ['123123123123'],
},
},
},
],
},
},
'c1cfa347-4c37-49b5-8e73-6aa1d1746cfa': {
image_status: {
status: 'failure',