From de521a7ae7422ca96e8ba8b819e09db7e0caaf30 Mon Sep 17 00:00:00 2001 From: Ondrej Ezr Date: Sat, 27 Jan 2024 01:53:58 +0100 Subject: [PATCH] Blueprints: add Blueprints filtering Enables Blueprints filtering. Given this is a server side filtering, it also ads Debounce hook. This hook enables delay the API request to save server roundtrips. Refs HMS-3389 --- .../Blueprints/BlueprintsSideBar.tsx | 124 ++++++++++++------ src/Components/LandingPage/LandingPage.tsx | 20 ++- .../Components/Blueprints/Blueprints.test.js | 19 ++- src/test/mocks/handlers.js | 12 +- 4 files changed, 130 insertions(+), 45 deletions(-) diff --git a/src/Components/Blueprints/BlueprintsSideBar.tsx b/src/Components/Blueprints/BlueprintsSideBar.tsx index ec5f562618..b3063b6b59 100644 --- a/src/Components/Blueprints/BlueprintsSideBar.tsx +++ b/src/Components/Blueprints/BlueprintsSideBar.tsx @@ -1,4 +1,4 @@ -import React, { useState, Dispatch, SetStateAction } from 'react'; +import React, { Dispatch, SetStateAction } from 'react'; import { Button, @@ -12,71 +12,82 @@ import { Stack, StackItem, } from '@patternfly/react-core'; -import { PlusCircleIcon } from '@patternfly/react-icons'; +import { PlusCircleIcon, SearchIcon } from '@patternfly/react-icons'; import BlueprintCard from './BlueprintCard'; import { BlueprintItem } from '../../store/imageBuilderApi'; +import { SVGIconProps } from '@patternfly/react-icons/dist/esm/createIcon'; type blueprintProps = { blueprints: BlueprintItem[] | undefined; selectedBlueprint: string | undefined; setSelectedBlueprint: Dispatch>; + filter: string | undefined; + setFilter: Dispatch>; +}; + +type blueprintSearchProps = { + filter: string | undefined; + setFilter: Dispatch>; +}; + +type emptyBlueprintStateProps = { + icon: React.ComponentClass; + action: React.ReactNode; + titleText: string; + bodyText: string; }; const BlueprintsSidebar = ({ blueprints, selectedBlueprint, setSelectedBlueprint, + filter, + setFilter, }: blueprintProps) => { - const [blueprintFilter, setBlueprintFilter] = useState(''); + const hasAnyBlueprints = (blueprints?.length || 0) > 0; - const onChange = (value: string) => { - setBlueprintFilter(value); - }; - - const emptyBlueprints = ( - - Create} titleText="No blueprints yet" - headingLevel="h4" - icon={} + bodyText="To get started, create a blueprint." /> - To get started, create a blueprint. - - - - - - - ); - - if (blueprints === undefined || blueprints?.length === 0) { - return emptyBlueprints; + ); } return ( <> - - onChange(value)} - onClear={() => onChange('')} + {(hasAnyBlueprints || filter !== undefined) && ( + <> + + + + + + + + )} + {!hasAnyBlueprints && ( + setFilter('')}>Clear all filters} + titleText="No blueprints found" + bodyText="No blueprints match your search criteria. Try a different search." /> - - - - - {blueprints.map((blueprint: BlueprintItem) => ( + )} + {hasAnyBlueprints && blueprints?.map((blueprint: BlueprintItem) => ( { + const onChange = (value: string) => { + setFilter(value); + }; + + return ( + onChange(value)} + onClear={() => onChange('')} + /> + ); +}; + +const EmptyBlueprintState = ({titleText, bodyText, icon, action }: emptyBlueprintStateProps) => ( + + } + /> + To get started, create a blueprint. + + + {action} + + + +); + export default BlueprintsSidebar; diff --git a/src/Components/LandingPage/LandingPage.tsx b/src/Components/LandingPage/LandingPage.tsx index 5cc473224a..07004ea041 100644 --- a/src/Components/LandingPage/LandingPage.tsx +++ b/src/Components/LandingPage/LandingPage.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useState } from 'react'; - +import debounce from 'lodash/debounce'; import { Button, Label, @@ -25,6 +25,7 @@ import './LandingPage.scss'; import Quickstarts from './Quickstarts'; +import useDebouncedSearch from '../../Hooks/useDebouncedSearch'; import { useGetBlueprintsQuery } from '../../store/imageBuilderApi'; import { manageEdgeImagesUrlName } from '../../Utilities/edge'; import { resolveRelPath } from '../../Utilities/path'; @@ -32,6 +33,7 @@ import BlueprintsSidebar from '../Blueprints/BlueprintsSideBar'; import EdgeImagesTable from '../edge/ImagesTable'; import ImagesTable from '../ImagesTable/ImagesTable'; import { ImageBuilderHeader } from '../sharedComponents/ImageBuilderHeader'; +import { set } from 'lodash'; export const LandingPage = () => { const { pathname } = useLocation(); @@ -56,7 +58,19 @@ export const LandingPage = () => { const [selectedBlueprint, setSelectedBlueprint] = useState< string | undefined >(); - const { data: blueprints, isLoading } = useGetBlueprintsQuery({}); + const [blueprintsSearchQuery, setBlueprintsSearchQuery] = useState(); + const debouncedSearch = React.useCallback( + debounce((filter) => { + setBlueprintsSearchQuery(filter.length > 0 ? filter : undefined); + }, 300), [setBlueprintsSearchQuery]); + React.useEffect(() => { + return () => { + debouncedSearch.cancel(); + }; + }, [debouncedSearch]); + const { data: blueprints, isLoading } = useGetBlueprintsQuery({ + search: blueprintsSearchQuery, + }); const edgeParityFlag = useFlag('edgeParity.image-list'); const experimentalFlag = @@ -85,6 +99,8 @@ export const LandingPage = () => { blueprints={blueprints?.data} selectedBlueprint={selectedBlueprint} setSelectedBlueprint={setSelectedBlueprint} + filter={blueprintsSearchQuery} + setFilter={debouncedSearch} /> diff --git a/src/test/Components/Blueprints/Blueprints.test.js b/src/test/Components/Blueprints/Blueprints.test.js index 6e2452e81b..d1dc3354f2 100644 --- a/src/test/Components/Blueprints/Blueprints.test.js +++ b/src/test/Components/Blueprints/Blueprints.test.js @@ -1,4 +1,4 @@ -import { screen, within } from '@testing-library/react'; +import { screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { rest } from 'msw'; @@ -71,4 +71,21 @@ describe('Blueprints', () => { await user.click(blueprintRadioBtn); expect(screen.queryByTestId('images-table')).not.toBeInTheDocument(); }); + + describe('filtering', () => { + test('filter blueprints', async () => { + renderWithReduxRouter('', {}); + + const searchInput = await screen.findByPlaceholderText( + 'Search by name or description' + ); + searchInput.focus(); + await user.keyboard('Milk'); + + // wait for debounce + await waitFor(() => { + expect(screen.getAllByRole('radio')).toHaveLength(1); + }); + }); + }); }); diff --git a/src/test/mocks/handlers.js b/src/test/mocks/handlers.js index 6f2312319b..eddeba792c 100644 --- a/src/test/mocks/handlers.js +++ b/src/test/mocks/handlers.js @@ -113,7 +113,17 @@ export const handlers = [ } ), rest.get(`${IMAGE_BUILDER_API}/experimental/blueprints`, (req, res, ctx) => { - return res(ctx.status(200), ctx.json(mockGetBlueprints)); + const search = req.url.searchParams.get('search'); + const resp = mockGetBlueprints; + if (search) { + const regexp = new RegExp(search + '*'); + resp.data = mockGetBlueprints.data.filter(({ name }) => + regexp.test(name) + ); + resp.meta.count = resp.data.length; + } + + return res(ctx.status(200), ctx.json(resp)); }), rest.get( `${IMAGE_BUILDER_API}/experimental/blueprints/:id/composes`,