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

Blueprints: add Blueprints filtering #1607

Merged
merged 1 commit into from
Feb 2, 2024
Merged
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
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 @@
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);
});
});
});
});
18 changes: 17 additions & 1 deletion src/test/mocks/handlers.js
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,23 @@ 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 = Object.assign({}, mockGetBlueprints);
if (search) {
let regexp;
try {
regexp = new RegExp(search);
} catch (e) {
const sanitized = search.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
regexp = new RegExp(sanitized);
}
resp.data = mockGetBlueprints.data.filter(({ name }) => {
return 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
Loading