feat(HMS-3515): Change blueprint cards to be clickable

This commit is contained in:
Anna Vítová 2024-02-05 10:28:57 +01:00 committed by Lucas Garfield
parent d8c657da6c
commit 45194fa225
6 changed files with 136 additions and 100 deletions

View file

@ -1,4 +1,4 @@
import React, { useState } from 'react';
import React from 'react';
import {
Badge,
@ -7,16 +7,8 @@ import {
CardTitle,
CardBody,
CardFooter,
Dropdown,
DropdownList,
MenuToggle,
MenuToggleElement,
DropdownItem,
Spinner,
} from '@patternfly/react-core';
import { EllipsisVIcon } from '@patternfly/react-icons';
import { DeleteBlueprintModal } from './DeleteBlueprintModal';
import {
BlueprintItem,
@ -36,84 +28,26 @@ const BlueprintCard = ({
selectedBlueprint,
setSelectedBlueprint,
}: blueprintProps) => {
const [isOpen, setIsOpen] = useState<boolean>(false);
const isChecked = blueprint.id === selectedBlueprint;
const onSelect = () => {
setIsOpen(!isOpen);
};
const [showDeleteModal, setShowDeleteModal] = React.useState(false);
const [deleteBlueprint, { isLoading }] = useDeleteBlueprintMutation();
const handleDelete = async () => {
setShowDeleteModal(false);
await deleteBlueprint({ id: blueprint.id });
};
const onDeleteClose = () => {
setShowDeleteModal(false);
};
const onClickHandler = ({
currentTarget: { id: blueprintID },
}: React.ChangeEvent<HTMLInputElement>) => {
setSelectedBlueprint(blueprintID);
};
const headerActions = (
<>
<Dropdown
ouiaId={`blueprint-card-${blueprint.id}-dropdown`}
onSelect={onSelect}
toggle={(toggleRef: React.Ref<MenuToggleElement>) => (
<MenuToggle
ref={toggleRef}
isExpanded={isOpen}
onClick={() => setIsOpen(!isOpen)}
variant="plain"
aria-label="blueprint menu toggle"
>
<EllipsisVIcon aria-hidden="true" />
</MenuToggle>
)}
isOpen={isOpen}
onOpenChange={(isOpen: boolean) => setIsOpen(isOpen)}
>
<DropdownList>
<DropdownItem>Edit details</DropdownItem>
<DropdownItem onClick={() => setShowDeleteModal(true)}>
Delete blueprint
</DropdownItem>
</DropdownList>
</Dropdown>
</>
);
const [, { isLoading }] = useDeleteBlueprintMutation({
fixedCacheKey: 'delete-blueprint',
});
return (
<>
<DeleteBlueprintModal
onDelete={handleDelete}
blueprintName={blueprint?.name}
isOpen={showDeleteModal}
onClose={onDeleteClose}
/>
<Card
ouiaId={`blueprint-card-${blueprint.id}`}
isCompact
isClickable
isSelectable
isSelected={isChecked}
>
<Card ouiaId={`blueprint-card-${blueprint.id}`} isCompact isClickable>
<CardHeader
data-testid={blueprint.id}
selectableActions={{
selectableActionId: blueprint.id,
name: blueprint.name,
variant: 'single',
isChecked: isChecked,
onChange: onClickHandler,
name: 'blueprints',
onClickAction: () => setSelectedBlueprint(blueprint.id),
}}
actions={{ actions: headerActions }}
>
<CardTitle>
{blueprint.name} {isLoading && <Spinner size="md" />}
{isLoading && blueprint.id === selectedBlueprint && (
<Spinner size="md" />
)}
&nbsp;&nbsp;
{blueprint.name}
</CardTitle>
</CardHeader>
<CardBody>{blueprint.description}</CardBody>

View file

@ -3,6 +3,9 @@ import React, { useState, useCallback } from 'react';
import {
Bullseye,
Button,
Card,
CardHeader,
CardTitle,
EmptyState,
EmptyStateActions,
EmptyStateBody,
@ -103,14 +106,23 @@ const BlueprintsSidebar = ({
/>
</StackItem>
<StackItem>
<Button
isBlock
onClick={() => setSelectedBlueprint(undefined)}
variant="link"
<Card
ouiaId={`blueprint-card-all`}
isCompact
isClickable
isDisabled={!selectedBlueprint}
>
Show all images
</Button>
<CardHeader
selectableActions={{
selectableActionId: 'show-all-card',
name: 'blueprints',
variant: 'single',
onClickAction: () => setSelectedBlueprint(undefined),
}}
>
<CardTitle component="a">Clear selection</CardTitle>
</CardHeader>
</Card>
</StackItem>
</>
)}

View file

@ -7,9 +7,11 @@ import {
ModalVariant,
} from '@patternfly/react-core';
import { useGetBlueprintsQuery } from '../../store/imageBuilderApi';
interface DeleteBlueprintModalProps {
onDelete: () => Promise<void>;
blueprintName: string;
selectedBlueprint: string | undefined;
isOpen: boolean;
onClose: () => void;
}
@ -18,10 +20,21 @@ export const DeleteBlueprintModal: React.FunctionComponent<
DeleteBlueprintModalProps
> = ({
onDelete,
blueprintName,
selectedBlueprint,
isOpen,
onClose,
}: DeleteBlueprintModalProps) => {
const { blueprintName } = useGetBlueprintsQuery(
{ search: undefined },
{
selectFromResult: ({ data }) => ({
blueprintName: data?.data?.find(
(blueprint: { id: string | undefined }) =>
blueprint.id === selectedBlueprint
)?.name,
}),
}
);
return (
<Modal
variant={ModalVariant.small}

View file

@ -1,4 +1,4 @@
import React from 'react';
import React, { useState } from 'react';
import {
Button,
@ -7,6 +7,11 @@ import {
TextContent,
Flex,
FlexItem,
Dropdown,
DropdownList,
MenuToggle,
MenuToggleElement,
DropdownItem,
} from '@patternfly/react-core';
import { ExternalLinkAltIcon, HelpIcon } from '@patternfly/react-icons';
// eslint-disable-next-line rulesdir/disallow-fec-relative-imports
@ -17,9 +22,13 @@ import {
} from '@redhat-cloud-services/frontend-components';
import { Link } from 'react-router-dom';
import { useComposeBlueprintMutation } from '../../store/imageBuilderApi';
import {
useComposeBlueprintMutation,
useDeleteBlueprintMutation,
} from '../../store/imageBuilderApi';
import { resolveRelPath } from '../../Utilities/path';
import './ImageBuilderHeader.scss';
import { DeleteBlueprintModal } from '../Blueprints/DeleteBlueprintModal';
type ImageBuilderHeaderPropTypes = {
experimentalFlag?: string | true | undefined;
@ -36,10 +45,34 @@ export const ImageBuilderHeader = ({
const onBuildHandler = async () => {
selectedBlueprint && (await buildBlueprint({ id: selectedBlueprint }));
};
const [isOpen, setIsOpen] = useState<boolean>(false);
const onSelect = () => {
setIsOpen(!isOpen);
};
const [showDeleteModal, setShowDeleteModal] = React.useState(false);
const [deleteBlueprint] = useDeleteBlueprintMutation({
fixedCacheKey: 'delete-blueprint',
});
const handleDelete = async () => {
if (selectedBlueprint) {
setShowDeleteModal(false);
await deleteBlueprint({ id: selectedBlueprint });
}
};
const onDeleteClose = () => {
setShowDeleteModal(false);
};
return (
<>
{/*@ts-ignore*/}
<DeleteBlueprintModal
onDelete={handleDelete}
selectedBlueprint={selectedBlueprint}
isOpen={showDeleteModal}
onClose={onDeleteClose}
/>
<PageHeader>
<Flex>
<FlexItem>
@ -120,6 +153,34 @@ export const ImageBuilderHeader = ({
>
Build images
</Button>
</FlexItem>
<FlexItem>
<Dropdown
ouiaId={`blueprints-dropdown`}
isOpen={isOpen}
onSelect={onSelect}
onOpenChange={(isOpen: boolean) => setIsOpen(isOpen)}
shouldFocusToggleOnSelect
toggle={(toggleRef: React.Ref<MenuToggleElement>) => (
<MenuToggle
ref={toggleRef}
isExpanded={isOpen}
onClick={() => setIsOpen(!isOpen)}
variant="secondary"
aria-label="blueprint menu toggle"
isDisabled={selectedBlueprint === undefined}
>
Blueprint actions
</MenuToggle>
)}
>
<DropdownList>
<DropdownItem>Edit details</DropdownItem>
<DropdownItem onClick={() => setShowDeleteModal(true)}>
Delete blueprint
</DropdownItem>
</DropdownList>
</Dropdown>
</FlexItem>{' '}
</>
)}

View file

@ -28,7 +28,9 @@ jest.mock('@unleash/proxy-client-react', () => ({
describe('Blueprints', () => {
const user = userEvent.setup();
const blueprintNameWithComposes = 'Dark Chocolate';
const blueprintIdWithComposes = '677b010b-e95e-4694-9813-d11d847f1bfc';
const blueprintNameEmptyComposes = 'Milk Chocolate';
const blueprintIdEmptyComposes = '193482e4-4bd0-4898-a8bc-dc8c33ed669f';
test('renders blueprints page', async () => {
renderWithReduxRouter('', {});
@ -50,37 +52,51 @@ describe('Blueprints', () => {
});
test('renders blueprint composes', async () => {
renderWithReduxRouter('', {});
const idMatcher = blueprintIdWithComposes;
const nameMatcher = (_, element) =>
element.getAttribute('name') === blueprintNameWithComposes;
element.getAttribute('name') === 'blueprints';
const blueprintRadioBtn = await screen.findByRole('radio', {
const radioButtons = await screen.findAllByRole('radio', {
name: nameMatcher,
});
await user.click(blueprintRadioBtn);
const elementById = radioButtons.find(
(button) => button.getAttribute('id') === idMatcher
);
await user.click(elementById);
const table = await screen.findByTestId('images-table');
const { findByText } = within(table);
await findByText(blueprintNameWithComposes);
});
test('renders blueprint composes empty state', async () => {
renderWithReduxRouter('', {});
const idMatcher = blueprintIdEmptyComposes;
const nameMatcher = (_, element) =>
element.getAttribute('name') === blueprintNameEmptyComposes;
element.getAttribute('name') === 'blueprints';
const blueprintRadioBtn = await screen.findByRole('radio', {
const radioButtons = await screen.findAllByRole('radio', {
name: nameMatcher,
});
await user.click(blueprintRadioBtn);
const elementById = radioButtons.find(
(button) => button.getAttribute('id') === idMatcher
);
await user.click(elementById);
expect(screen.queryByTestId('images-table')).not.toBeInTheDocument();
});
test('click build image button', async () => {
renderWithReduxRouter('', {});
const idMatcher = blueprintIdWithComposes;
const nameMatcher = (_, element) =>
element.getAttribute('name') === blueprintNameWithComposes;
element.getAttribute('name') === 'blueprints';
const blueprintRadioBtn = await screen.findByRole('radio', {
const radioButtons = await screen.findAllByRole('radio', {
name: nameMatcher,
});
await user.click(blueprintRadioBtn);
const elementById = radioButtons.find(
(button) => button.getAttribute('id') === idMatcher
);
await user.click(elementById);
const buildImageBtn = await screen.findByRole('button', {
name: /Build image/i,
});
@ -99,7 +115,7 @@ describe('Blueprints', () => {
// wait for debounce
await waitFor(() => {
expect(screen.getAllByRole('radio')).toHaveLength(1);
expect(screen.getAllByRole('radio')).toHaveLength(2);
});
});
});

View file

@ -23,7 +23,7 @@ export const mockGetBlueprints: GetBlueprintsApiResponse = {
last_modified_at: '2021-09-09T14:38:00.000Z',
},
{
id: '677b0101-e952-4694-9813-d11d847f1bfc',
id: '193482e4-4bd0-4898-a8bc-dc8c33ed669f',
name: 'Milk Chocolate',
description: '40% Milk Chocolate with salted caramel',
version: 1,