feat(HMS-3386): add blueprints initial layout

This commit is contained in:
Amir 2024-01-18 18:28:20 +02:00 committed by Lucas Garfield
parent 0982cbe8db
commit bae6435fd9
10 changed files with 398 additions and 67 deletions

View file

@ -0,0 +1,97 @@
import React, { useState } from 'react';
import {
Badge,
Card,
CardHeader,
CardTitle,
CardBody,
CardFooter,
Dropdown,
DropdownList,
MenuToggle,
MenuToggleElement,
DropdownItem,
} from '@patternfly/react-core';
import { EllipsisVIcon } from '@patternfly/react-icons';
import { BlueprintItem } from '../../store/imageBuilderApi';
type blueprintProps = {
blueprint: BlueprintItem;
selectedBlueprint: string;
setSelectedBlueprint: React.Dispatch<React.SetStateAction<string>>;
};
const BlueprintCard = ({
blueprint,
selectedBlueprint,
setSelectedBlueprint,
}: blueprintProps) => {
const [isOpen, setIsOpen] = useState<boolean>(false);
const isChecked = blueprint.id === selectedBlueprint;
const onSelect = () => {
setIsOpen(!isOpen);
};
const onClickHandler = () => {
setSelectedBlueprint(blueprint.id);
};
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>Delete blueprint</DropdownItem>
</DropdownList>
</Dropdown>
</>
);
return (
<Card
ouiaId={`blueprint-card-${blueprint.id}`}
isCompact
isClickable
isSelectable
isSelected={isChecked}
>
<CardHeader
selectableActions={{
selectableActionId: blueprint.id,
selectableActionAriaLabelledby: 'blueprint radio select',
name: blueprint.name,
variant: 'single',
isChecked: isChecked,
onChange: onClickHandler,
}}
actions={{ actions: headerActions }}
>
<CardTitle>{blueprint.name}</CardTitle>
</CardHeader>
<CardBody>{blueprint.description}</CardBody>
<CardFooter>
Version <Badge isRead>{blueprint.version}</Badge>
</CardFooter>
</Card>
);
};
export default BlueprintCard;

View file

@ -0,0 +1,83 @@
import React, { useState, Dispatch, SetStateAction } from 'react';
import {
Button,
EmptyState,
EmptyStateActions,
EmptyStateBody,
EmptyStateFooter,
EmptyStateHeader,
EmptyStateIcon,
SearchInput,
Stack,
StackItem,
} from '@patternfly/react-core';
import { PlusCircleIcon } from '@patternfly/react-icons';
import BlueprintCard from './BlueprintCard';
import { BlueprintItem } from '../../store/imageBuilderApi';
type blueprintProps = {
blueprints: BlueprintItem[] | undefined;
selectedBlueprint: string;
setSelectedBlueprint: Dispatch<SetStateAction<string>>;
};
const BlueprintsSidebar = ({
blueprints,
selectedBlueprint,
setSelectedBlueprint,
}: blueprintProps) => {
const [blueprintFilter, setBlueprintFilter] = useState('');
const onChange = (value: string) => {
setBlueprintFilter(value);
};
const emptyBlueprints = (
<EmptyState variant="sm">
<EmptyStateHeader
titleText="No blueprints yet"
headingLevel="h4"
icon={<EmptyStateIcon icon={PlusCircleIcon} />}
/>
<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('')}
/>
</StackItem>
{blueprints.map((blueprint: BlueprintItem) => (
<StackItem key={blueprint.id}>
<BlueprintCard
blueprint={blueprint}
selectedBlueprint={selectedBlueprint}
setSelectedBlueprint={setSelectedBlueprint}
/>
</StackItem>
))}
</Stack>
</>
);
};
export default BlueprintsSidebar;

View file

@ -10,6 +10,12 @@ import {
Text,
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';
@ -19,8 +25,10 @@ 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';
import EdgeImagesTable from '../edge/ImagesTable';
import ImagesTable from '../ImagesTable/ImagesTable';
import { ImageBuilderHeader } from '../sharedComponents/ImageBuilderHeader';
@ -45,15 +53,58 @@ export const LandingPage = () => {
}
setActiveTabKey(tabIndex);
};
const [selectedBlueprint, setSelectedBlueprint] = useState<string>('');
const { data: blueprints, isLoading } = useGetBlueprintsQuery({});
const edgeParityFlag = useFlag('edgeParity.image-list');
const traditionalImageList = (
<section className="pf-l-page__main-section pf-c-page__main-section">
<Quickstarts />
const experimentalFlag =
useFlag('image-builder.new-wizard.enabled') || process.env.EXPERIMENTAL;
<ImagesTable />
</section>
const traditionalImageList = (
<>
<PageSection>
<Quickstarts />
</PageSection>
<PageSection>
<ImagesTable />
</PageSection>
</>
);
const experimentalImageList = (
<>
<PageSection>
<Quickstarts />
</PageSection>
<PageSection>
<Sidebar hasBorder className="pf-v5-u-background-color-100">
<SidebarPanel hasPadding width={{ default: 'width_25' }}>
<BlueprintsSidebar
blueprints={blueprints?.data}
selectedBlueprint={selectedBlueprint}
setSelectedBlueprint={setSelectedBlueprint}
/>
</SidebarPanel>
<SidebarContent>
<ImagesTable />
</SidebarContent>
</Sidebar>
</PageSection>
</>
);
const imageList = experimentalFlag
? experimentalImageList
: traditionalImageList;
if (isLoading) {
return (
<Bullseye>
<Spinner size="xl" />
</Bullseye>
);
}
return (
<>
<ImageBuilderHeader />
@ -99,7 +150,7 @@ export const LandingPage = () => {
/>
}
>
{traditionalImageList}
{imageList}
</Tab>
<Tab
eventKey={1}
@ -147,7 +198,7 @@ export const LandingPage = () => {
</Tab>
</Tabs>
) : (
traditionalImageList
imageList
)}
<Outlet />
</>

View file

@ -1,6 +1,13 @@
import React from 'react';
import { Button, Popover, Text, TextContent } from '@patternfly/react-core';
import {
Button,
Popover,
Text,
TextContent,
Flex,
FlexItem,
} from '@patternfly/react-core';
import { ExternalLinkAltIcon, HelpIcon } from '@patternfly/react-icons';
// eslint-disable-next-line rulesdir/disallow-fec-relative-imports
import {
@ -16,62 +23,72 @@ export const ImageBuilderHeader = () => {
<>
{/*@ts-ignore*/}
<PageHeader>
<PageHeaderTitle className="title" title="Image Builder" />
<Popover
minWidth="35rem"
headerContent={'About image builder'}
bodyContent={
<TextContent>
<Text>
Image builder is a tool for creating deployment-ready customized
system images: installation disks, virtual machines, cloud
vendor-specific images, and others. By using image builder, you
can make these images faster than manual procedures because it
eliminates the specific configurations required for each output
type.
</Text>
<Text>
<Button
component="a"
target="_blank"
variant="link"
icon={<ExternalLinkAltIcon />}
iconPosition="right"
isInline
href={
'https://access.redhat.com/documentation/en-us/red_hat_enterprise_linux/8/html/creating_customized_rhel_images_using_the_image_builder_service'
}
>
Image builder for RPM-DNF documentation
</Button>
</Text>
<Text>
<Button
component="a"
target="_blank"
variant="link"
icon={<ExternalLinkAltIcon />}
iconPosition="right"
isInline
href={
'https://access.redhat.com/documentation/en-us/edge_management/2022/html/create_rhel_for_edge_images_and_configure_automated_management/index'
}
>
Image builder for OSTree documentation
</Button>
</Text>
</TextContent>
}
>
<Button
variant="plain"
aria-label="About image builder"
className="pf-u-pl-sm header-button"
>
<HelpIcon />
</Button>
</Popover>
<OpenSourceBadge repositoriesURL="https://www.osbuild.org/guides/image-builder-service/architecture.html" />
<Flex>
<FlexItem>
<PageHeaderTitle className="title" title="Images" />
<Popover
minWidth="35rem"
headerContent={'About image builder'}
bodyContent={
<TextContent>
<Text>
Image builder is a tool for creating deployment-ready
customized system images: installation disks, virtual
machines, cloud vendor-specific images, and others. By using
image builder, you can make these images faster than manual
procedures because it eliminates the specific configurations
required for each output type.
</Text>
<Text>
<Button
component="a"
target="_blank"
variant="link"
icon={<ExternalLinkAltIcon />}
iconPosition="right"
isInline
href={
'https://access.redhat.com/documentation/en-us/red_hat_enterprise_linux/8/html/creating_customized_rhel_images_using_the_image_builder_service'
}
>
Image builder for RPM-DNF documentation
</Button>
</Text>
<Text>
<Button
component="a"
target="_blank"
variant="link"
icon={<ExternalLinkAltIcon />}
iconPosition="right"
isInline
href={
'https://access.redhat.com/documentation/en-us/edge_management/2022/html/create_rhel_for_edge_images_and_configure_automated_management/index'
}
>
Image builder for OSTree documentation
</Button>
</Text>
</TextContent>
}
>
<Button
variant="plain"
aria-label="About image builder"
className="pf-u-pl-sm header-button"
>
<HelpIcon />
</Button>
</Popover>
<OpenSourceBadge repositoriesURL="https://www.osbuild.org/guides/image-builder-service/architecture.html" />
</FlexItem>
<FlexItem align={{ default: 'alignRight' }}>
<Button>New blueprint</Button>
</FlexItem>
<FlexItem>
<Button isDisabled>Build images</Button>
</FlexItem>
</Flex>
</PageHeader>
</>
);

View file

@ -0,0 +1,42 @@
import { screen } from '@testing-library/react';
import { rest } from 'msw';
import { IMAGE_BUILDER_API } from '../../../constants';
import { emptyGetBlueprints } from '../../fixtures/blueprints';
import { server } from '../../mocks/server';
import { renderWithReduxRouter } from '../../testUtils';
jest.mock('@redhat-cloud-services/frontend-components/useChrome', () => ({
useChrome: () => ({
isBeta: () => false,
isProd: () => true,
getEnvironment: () => 'prod',
}),
}));
jest.mock('@unleash/proxy-client-react', () => ({
useUnleashContext: () => jest.fn(),
useFlag: jest.fn((flag) =>
flag === 'image-builder.new-wizard.enabled' ? true : false
),
}));
describe('Blueprints', () => {
test('renders blueprints page', async () => {
renderWithReduxRouter('', {});
await screen.findByText('Dark Chocolate');
});
test('renders blueprint empty state', async () => {
server.use(
rest.get(
`${IMAGE_BUILDER_API}/experimental/blueprints`,
(req, res, ctx) => {
return res(ctx.status(200), ctx.json(emptyGetBlueprints));
}
)
);
renderWithReduxRouter('', {});
await screen.findByText('No blueprints yet');
});
});

View file

@ -114,7 +114,7 @@ describe('Create Image Wizard', () => {
test('renders component', async () => {
renderCustomRoutesWithReduxRouter('imagewizard', {}, routes);
// check heading
await screen.findByRole('heading', { name: /Image Builder/ });
await screen.findByRole('heading', { name: /Images/ });
await screen.findByRole('button', { name: 'Image output' });
await screen.findByRole('button', { name: 'Register' });

View file

@ -115,7 +115,7 @@ describe('Create Image Wizard', () => {
test('renders component', async () => {
renderCustomRoutesWithReduxRouter('imagewizard', {}, routes);
// check heading
await screen.findByRole('heading', { name: /Image Builder/ });
await screen.findByRole('heading', { name: /Images/ });
await screen.findByRole('button', { name: 'Image output' });
await screen.findByRole('button', { name: 'Register' });

View file

@ -24,7 +24,7 @@ describe('Landing Page', () => {
renderWithReduxRouter('', {});
// check heading
await screen.findByRole('heading', { name: /Image Builder/i });
await screen.findByRole('heading', { name: /Images/i });
});
test('renders EmptyState child component', async () => {

37
src/test/fixtures/blueprints.ts vendored Normal file
View file

@ -0,0 +1,37 @@
import {
GetBlueprintsApiResponse,
CreateBlueprintResponse,
} from '../../store/imageBuilderApi';
export const mockBlueprintsCreation: CreateBlueprintResponse[] = [
{
id: '677b010b-e95e-4694-9813-d11d847f1bfc',
},
];
export const mockGetBlueprints: GetBlueprintsApiResponse = {
links: { first: 'first', last: 'last' },
meta: { count: 2 },
data: [
{
id: '677b010b-e95e-4694-9813-d11d847f1bfc',
name: 'Dark Chocolate',
description: '70% Dark Chocolate with crunchy cocoa nibs',
version: 1,
last_modified_at: '2021-09-09T14:38:00.000Z',
},
{
id: '677b0101-e952-4694-9813-d11d847f1bfc',
name: 'Milk Chocolate',
description: '40% Milk Chocolate with salted caramel',
version: 1,
last_modified_at: '2021-09-08T14:38:00.000Z',
},
],
};
export const emptyGetBlueprints: GetBlueprintsApiResponse = {
links: { first: 'first', last: 'last' },
meta: { count: 0 },
data: [],
};

View file

@ -11,6 +11,7 @@ import {
mockActivationKeysResults,
} from '../fixtures/activationKeys';
import { mockArchitecturesByDistro } from '../fixtures/architectures';
import { mockGetBlueprints } from '../fixtures/blueprints';
import {
composesEndpoint,
mockClones,
@ -107,4 +108,7 @@ export const handlers = [
return res(ctx.status(200), ctx.json(oscapCustomizations(profile)));
}
),
rest.get(`${IMAGE_BUILDER_API}/experimental/blueprints`, (req, res, ctx) => {
return res(ctx.status(200), ctx.json(mockGetBlueprints));
}),
];