diff --git a/src/Components/Blueprints/BlueprintVersionFilter.tsx b/src/Components/Blueprints/BlueprintVersionFilter.tsx new file mode 100644 index 00000000..aae4d824 --- /dev/null +++ b/src/Components/Blueprints/BlueprintVersionFilter.tsx @@ -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 = ({ + onFilterChange, +}: blueprintVersionFilterProps) => { + const dispatch = useAppDispatch(); + const blueprintVersionFilter = useAppSelector(selectBlueprintVersionFilter); + const [isOpen, setIsOpen] = React.useState(false); + + const onToggleClick = () => { + setIsOpen(!isOpen); + }; + + const onSelect = ( + _event: React.MouseEvent | undefined, + value: versionFilterType + ) => { + dispatch(setBlueprintVersionFilter(value)); + if (onFilterChange) onFilterChange(); + setIsOpen(false); + }; + + return ( + setIsOpen(isOpen)} + toggle={(toggleRef: React.Ref) => ( + } + > + {blueprintVersionFilter === 'latest' ? 'Newest' : 'All versions'} + + )} + shouldFocusToggleOnSelect + > + + + All versions + + + Newest + + + + ); +}; + +export default BlueprintVersionFilter; diff --git a/src/Components/ImagesTable/ImagesTable.tsx b/src/Components/ImagesTable/ImagesTable.tsx index b7d7188c..26524cb1 100644 --- a/src/Components/ImagesTable/ImagesTable.tsx +++ b/src/Components/ImagesTable/ImagesTable.tsx @@ -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} /> diff --git a/src/Components/ImagesTable/ImagesTableToolbar.tsx b/src/Components/ImagesTable/ImagesTableToolbar.tsx index c321343a..4bc2afaf 100644 --- a/src/Components/ImagesTable/ImagesTableToolbar.tsx +++ b/src/Components/ImagesTable/ImagesTableToolbar.tsx @@ -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 = ({ itemCount, perPage, page, - onSetPage, + setPage, onPerPageSelect, }: imagesTableToolbarProps) => { const experimentalFlag = useExperimentalFlag(); @@ -60,7 +61,7 @@ const ImagesTableToolbar: React.FC = ({ 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 = ({ + {selectedBlueprintId && ( + + setPage(1)} /> + + )} {pagination} diff --git a/src/store/BlueprintSlice.ts b/src/store/BlueprintSlice.ts index 23bbd453..8c9c3863 100644 --- a/src/store/BlueprintSlice.ts +++ b/src/store/BlueprintSlice.ts @@ -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) => { state.limit = action.payload; }, + setBlueprintVersionFilter: ( + state, + action: PayloadAction + ) => { + state.versionFilter = action.payload; + }, }, }); @@ -50,4 +72,5 @@ export const { setBlueprintSearchInput, setBlueprintsOffset, setBlueprintLimit, + setBlueprintVersionFilter, } = blueprintsSlice.actions; diff --git a/src/test/Components/Blueprints/Blueprints.test.js b/src/test/Components/Blueprints/Blueprints.test.js index f90f4e75..a47c8a38 100644 --- a/src/test/Components/Blueprints/Blueprints.test.js +++ b/src/test/Components/Blueprints/Blueprints.test.js @@ -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); + }); + }); }); diff --git a/src/test/fixtures/blueprints.ts b/src/test/fixtures/blueprints.ts index 795c2c5e..c2deeb9a 100644 --- a/src/test/fixtures/blueprints.ts +++ b/src/test/fixtures/blueprints.ts @@ -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', diff --git a/src/test/fixtures/composes.ts b/src/test/fixtures/composes.ts index 6d8eaea7..e602d97a 100644 --- a/src/test/fixtures/composes.ts +++ b/src/test/fixtures/composes.ts @@ -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',