feat(HMS-3386): add blueprints initial layout
This commit is contained in:
parent
0982cbe8db
commit
c2e7c34d6e
10 changed files with 398 additions and 67 deletions
97
src/Components/Blueprints/BlueprintCard.tsx
Normal file
97
src/Components/Blueprints/BlueprintCard.tsx
Normal 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;
|
||||
83
src/Components/Blueprints/BlueprintsSideBar.tsx
Normal file
83
src/Components/Blueprints/BlueprintsSideBar.tsx
Normal 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;
|
||||
|
|
@ -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 />
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
42
src/test/Components/Blueprints/Blueprints.test.js
Normal file
42
src/test/Components/Blueprints/Blueprints.test.js
Normal 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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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' });
|
||||
|
|
|
|||
|
|
@ -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' });
|
||||
|
|
|
|||
|
|
@ -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
37
src/test/fixtures/blueprints.ts
vendored
Normal 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: [],
|
||||
};
|
||||
|
|
@ -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));
|
||||
}),
|
||||
];
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue