Skip to content

Commit

Permalink
Blueprints: add Blueprints filtering
Browse files Browse the repository at this point in the history
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
  • Loading branch information
ezr-ondrej committed Jan 31, 2024
1 parent 0e3b5d9 commit 3af6c24
Show file tree
Hide file tree
Showing 4 changed files with 171 additions and 67 deletions.
194 changes: 142 additions & 52 deletions src/Components/Blueprints/BlueprintsSideBar.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import React, { useState, Dispatch, SetStateAction } from 'react';
import React, { useState, useCallback } from 'react';

import {
Bullseye,
Button,
EmptyState,
EmptyStateActions,
Expand All @@ -9,85 +10,174 @@ import {
EmptyStateHeader,
EmptyStateIcon,
SearchInput,
Spinner,
Stack,
StackItem,
} from '@patternfly/react-core';
import { PlusCircleIcon } from '@patternfly/react-icons';
import { PlusCircleIcon, SearchIcon } from '@patternfly/react-icons';
import { SVGIconProps } from '@patternfly/react-icons/dist/esm/createIcon';
import debounce from 'lodash/debounce';

import BlueprintCard from './BlueprintCard';

import { BlueprintItem } from '../../store/imageBuilderApi';
import {
useGetBlueprintsQuery,
BlueprintItem,
} from '../../store/imageBuilderApi';

type blueprintProps = {
blueprints: BlueprintItem[] | undefined;
selectedBlueprint: string | undefined;
setSelectedBlueprint: Dispatch<SetStateAction<string | undefined>>;
setSelectedBlueprint: React.Dispatch<
React.SetStateAction<string | undefined>
>;
};

type blueprintSearchProps = {
filter: string | undefined;
setFilter: React.Dispatch<React.SetStateAction<string>>;
blueprintsTotal: number;
};

type emptyBlueprintStateProps = {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
icon: React.ComponentClass<SVGIconProps, any>;
action: React.ReactNode;
titleText: string;
bodyText: string;
};

const BlueprintsSidebar = ({
blueprints,
selectedBlueprint,
setSelectedBlueprint,
}: blueprintProps) => {
const [blueprintFilter, setBlueprintFilter] = useState('');
const [blueprintsSearchQuery, setBlueprintsSearchQuery] = useState<
string | undefined
>();
const debouncedSearch = useCallback(

Check warning on line 56 in src/Components/Blueprints/BlueprintsSideBar.tsx

View workflow job for this annotation

GitHub Actions / tests (16.x)

React Hook useCallback received a function whose dependencies are unknown. Pass an inline function instead

Check warning on line 56 in src/Components/Blueprints/BlueprintsSideBar.tsx

View workflow job for this annotation

GitHub Actions / tests (18.x)

React Hook useCallback received a function whose dependencies are unknown. Pass an inline function instead
debounce((filter) => {
setBlueprintsSearchQuery(filter.length > 0 ? filter : undefined);
}, 300),
[setBlueprintsSearchQuery]
);
React.useEffect(() => {
return () => {
debouncedSearch.cancel();
};
}, [debouncedSearch]);
const { data: blueprintsData, isLoading } = useGetBlueprintsQuery({
search: blueprintsSearchQuery,
});
const blueprints = blueprintsData?.data;

const onChange = (value: string) => {
setBlueprintFilter(value);
};
const blueprintsTotal = blueprintsData?.meta?.count || 0;

if (isLoading) {
return (
<Bullseye>
<Spinner size="xl" />
</Bullseye>
);
}

const emptyBlueprints = (
<EmptyState variant="sm">
<EmptyStateHeader
if (blueprintsTotal === 0 && blueprintsSearchQuery === undefined) {
return (
<EmptyBlueprintState
icon={PlusCircleIcon}
action={<Button>Create</Button>}
titleText="No blueprints yet"
headingLevel="h4"
icon={<EmptyStateIcon icon={PlusCircleIcon} />}
bodyText="To get started, create a blueprint."
/>
<EmptyStateBody>To get started, create a blueprint.</EmptyStateBody>
<EmptyStateFooter>
<EmptyStateActions>
<Button>Create</Button>
</EmptyStateActions>
</EmptyStateFooter>
</EmptyState>
);

if (blueprints === undefined || blueprints?.length === 0) {
return emptyBlueprints;
);
}

return (
<>
<Stack hasGutter>
<StackItem>
<SearchInput
placeholder="Search by name or description"
value={blueprintFilter}
onChange={(_event, value) => onChange(value)}
onClear={() => onChange('')}
{(blueprintsTotal > 0 || blueprintsSearchQuery !== undefined) && (
<>
<StackItem>
<BlueprintSearch
filter={blueprintsSearchQuery}
setFilter={debouncedSearch}
blueprintsTotal={blueprintsTotal}
/>
</StackItem>
<StackItem>
<Button
isBlock
onClick={() => setSelectedBlueprint(undefined)}
variant="link"
isDisabled={!selectedBlueprint}
>
Show all images
</Button>
</StackItem>
</>
)}
{blueprintsTotal === 0 && (
<EmptyBlueprintState
icon={SearchIcon}
action={
<Button variant="link" onClick={() => debouncedSearch('')}>
Clear all filters
</Button>
}
titleText="No blueprints found"
bodyText="No blueprints match your search criteria. Try a different search."
/>
</StackItem>
<StackItem>
<Button
isBlock
onClick={() => setSelectedBlueprint(undefined)}
variant="link"
isDisabled={!selectedBlueprint}
>
Show all images
</Button>
</StackItem>
{blueprints.map((blueprint: BlueprintItem) => (
<StackItem key={blueprint.id}>
<BlueprintCard
blueprint={blueprint}
selectedBlueprint={selectedBlueprint}
setSelectedBlueprint={setSelectedBlueprint}
/>
</StackItem>
))}
)}
{blueprintsTotal > 0 &&
blueprints?.map((blueprint: BlueprintItem) => (
<StackItem key={blueprint.id}>
<BlueprintCard
blueprint={blueprint}
selectedBlueprint={selectedBlueprint}
setSelectedBlueprint={setSelectedBlueprint}
/>
</StackItem>
))}
</Stack>
</>
);
};

const BlueprintSearch = ({
filter,
setFilter,
blueprintsTotal,
}: blueprintSearchProps) => {
const onChange = (value: string) => {
setFilter(value);
};

return (
<SearchInput
value={filter}
placeholder="Search by name or description"
onChange={(_event, value) => onChange(value)}
onClear={() => onChange('')}
resultsCount={`${blueprintsTotal} blueprints`}
/>
);
};

const EmptyBlueprintState = ({
titleText,
bodyText,
icon,
action,
}: emptyBlueprintStateProps) => (
<EmptyState variant="sm">
<EmptyStateHeader
titleText={titleText}
headingLevel="h4"
icon={<EmptyStateIcon icon={icon} />}
/>
<EmptyStateBody>{bodyText}</EmptyStateBody>
<EmptyStateFooter>
<EmptyStateActions>{action}</EmptyStateActions>
</EmptyStateFooter>
</EmptyState>
);

export default BlueprintsSidebar;
13 changes: 0 additions & 13 deletions src/Components/LandingPage/LandingPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,9 @@ import {
TextContent,
TabAction,
PageSection,
Spinner,
Sidebar,
SidebarContent,
SidebarPanel,
Bullseye,
} from '@patternfly/react-core';
import { ExternalLinkAltIcon, HelpIcon } from '@patternfly/react-icons';
import { useFlag } from '@unleash/proxy-client-react';
Expand All @@ -25,7 +23,6 @@ import './LandingPage.scss';

import Quickstarts from './Quickstarts';

import { useGetBlueprintsQuery } from '../../store/imageBuilderApi';
import { manageEdgeImagesUrlName } from '../../Utilities/edge';
import { resolveRelPath } from '../../Utilities/path';
import BlueprintsSidebar from '../Blueprints/BlueprintsSideBar';
Expand Down Expand Up @@ -56,7 +53,6 @@ export const LandingPage = () => {
const [selectedBlueprint, setSelectedBlueprint] = useState<
string | undefined
>();
const { data: blueprints, isLoading } = useGetBlueprintsQuery({});

const edgeParityFlag = useFlag('edgeParity.image-list');
const experimentalFlag =
Expand All @@ -82,7 +78,6 @@ export const LandingPage = () => {
<Sidebar hasBorder className="pf-v5-u-background-color-100">
<SidebarPanel hasPadding width={{ default: 'width_25' }}>
<BlueprintsSidebar
blueprints={blueprints?.data}
selectedBlueprint={selectedBlueprint}
setSelectedBlueprint={setSelectedBlueprint}
/>
Expand All @@ -99,14 +94,6 @@ export const LandingPage = () => {
? experimentalImageList
: traditionalImageList;

if (isLoading) {
return (
<Bullseye>
<Spinner size="xl" />
</Bullseye>
);
}

return (
<>
<ImageBuilderHeader experimentalFlag={experimentalFlag} />
Expand Down
19 changes: 18 additions & 1 deletion src/test/Components/Blueprints/Blueprints.test.js
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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);
});
});
});
});
12 changes: 11 additions & 1 deletion src/test/mocks/handlers.js
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
Expand Down

0 comments on commit 3af6c24

Please sign in to comment.