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

upcoming: [M3-8021] - Manage Image Regions Drawer #10617

Merged
Merged
Show file tree
Hide file tree
Changes from 8 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
15 changes: 7 additions & 8 deletions packages/api-v4/src/images/images.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import type {
Image,
ImageUploadPayload,
UpdateImagePayload,
UpdateImageRegionsPayload,
UploadImageResponse,
} from './types';

Expand Down Expand Up @@ -99,16 +100,14 @@ export const uploadImage = (data: ImageUploadPayload) => {
};

/**
* Selects the regions to which this image will be replicated.
* updateImageRegions
*
* @param imageId { string } ID of the Image to look up.
* @param regions { string[] } ID of regions to replicate to. Must contain at least one valid region.
* Selects the regions to which this image will be replicated.
*/
export const updateImageRegions = (imageId: string, regions: string[]) => {
const data = {
regions,
};

export const updateImageRegions = (
imageId: string,
data: UpdateImageRegionsPayload
) => {
return Request<Image>(
setURL(`${API_ROOT}/images/${encodeURIComponent(imageId)}/regions`),
setMethod('POST'),
bnussman-akamai marked this conversation as resolved.
Show resolved Hide resolved
Expand Down
9 changes: 8 additions & 1 deletion packages/api-v4/src/images/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ export type ImageCapabilities = 'cloud-init' | 'distributed-images';

type ImageType = 'manual' | 'automatic';

type ImageRegionStatus =
export type ImageRegionStatus =
| 'creating'
| 'pending'
| 'available'
Expand Down Expand Up @@ -154,3 +154,10 @@ export interface ImageUploadPayload extends BaseImagePayload {
label: string;
region: string;
}

export interface UpdateImageRegionsPayload {
/**
* An array of region ids
*/
regions: string[];
}
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ export const RegionMultiSelect = React.memo((props: RegionMultiSelectProps) => {
selectedIds,
sortRegionOptions,
width,
onClose,
} = props;

const {
Expand Down Expand Up @@ -171,6 +172,7 @@ export const RegionMultiSelect = React.memo((props: RegionMultiSelectProps) => {
options={regionOptions}
placeholder={placeholder ?? 'Select Regions'}
value={selectedRegions}
onClose={onClose}
/>
</StyledAutocompleteContainer>
{selectedRegions.length > 0 && SelectedRegionsList && (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import { Drawer } from 'src/components/Drawer';
import { Notice } from 'src/components/Notice/Notice';
import { TagsInput } from 'src/components/TagsInput/TagsInput';
import { TextField } from 'src/components/TextField';
import { usePrevious } from 'src/hooks/usePrevious';
import { useUpdateImageMutation } from 'src/queries/images';

import { useImageAndLinodeGrantCheck } from '../utils';
Expand All @@ -18,18 +17,17 @@ import type { APIError, Image, UpdateImagePayload } from '@linode/api-v4';
interface Props {
image: Image | undefined;
onClose: () => void;
open: boolean;
}
export const EditImageDrawer = (props: Props) => {
const { image, onClose } = props;
const { image, onClose, open } = props;

const { canCreateImage } = useImageAndLinodeGrantCheck();

// Prevent content from disappearing when closing drawer
const prevImage = usePrevious(image);
const defaultValues = {
description: image?.description ?? prevImage?.description ?? undefined,
label: image?.label ?? prevImage?.label,
tags: image?.tags ?? prevImage?.tags,
description: image?.description ?? undefined,
label: image?.label,
tags: image?.tags,
};

const {
Expand Down Expand Up @@ -78,12 +76,7 @@ export const EditImageDrawer = (props: Props) => {
});

return (
<Drawer
onClose={onClose}
onExited={reset}
open={!!image}
title="Edit Image"
>
<Drawer onClose={onClose} onExited={reset} open={open} title="Edit Image">
{!canCreateImage && (
<Notice
text="You don't have permissions to edit images. Please contact an account administrator for details."
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import userEvent from '@testing-library/user-event';
import React from 'react';

import { regionFactory } from 'src/factories/regions';
import { makeResourcePage } from 'src/mocks/serverHandlers';
import { HttpResponse, http, server } from 'src/mocks/testServer';
import { renderWithTheme } from 'src/utilities/testHelpers';

import { ImageRegionRow } from './ImageRegionRow';

describe('ImageRegionRow', () => {
it('renders a region label and status', async () => {
const region = regionFactory.build({ id: 'us-east', label: 'Newark, NJ' });

server.use(
http.get('*/v4/regions', () => {
return HttpResponse.json(makeResourcePage([region]));
})
);

const { findByText, getByText } = renderWithTheme(
<ImageRegionRow onRemove={vi.fn()} region="us-east" status="creating" />
);

expect(getByText('creating')).toBeVisible();
expect(await findByText('Newark, NJ')).toBeVisible();
});

it('calls onRemove when the remove button is clicked', async () => {
const onRemove = vi.fn();

const { getByLabelText } = renderWithTheme(
<ImageRegionRow onRemove={onRemove} region="us-east" status="creating" />
);

const removeButton = getByLabelText('Remove us-east');

await userEvent.click(removeButton);

expect(onRemove).toHaveBeenCalled();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import Close from '@mui/icons-material/Close';
import React from 'react';

import { Box } from 'src/components/Box';
import { Flag } from 'src/components/Flag';
import { IconButton } from 'src/components/IconButton';
import { Stack } from 'src/components/Stack';
import { StatusIcon } from 'src/components/StatusIcon/StatusIcon';
import { Typography } from 'src/components/Typography';
import { useRegionsQuery } from 'src/queries/regions/regions';

import type { ImageRegionStatus } from '@linode/api-v4';
import type { Status } from 'src/components/StatusIcon/StatusIcon';

type ExtendedImageRegionStatus = 'unsaved' | ImageRegionStatus;

interface Props {
onRemove: () => void;
region: string;
status: ExtendedImageRegionStatus;
}

export const ImageRegionRow = (props: Props) => {
const { onRemove, region, status } = props;

const { data: regions } = useRegionsQuery();

const actualRegion = regions?.find((r) => r.id === region);
bnussman-akamai marked this conversation as resolved.
Show resolved Hide resolved

return (
<Box alignItems="center" display="flex" justifyContent="space-between">
<Stack alignItems="center" direction="row" gap={1}>
<Flag country={actualRegion?.country ?? 'us'} />
{actualRegion?.label ?? region}
</Stack>
<Stack alignItems="center" direction="row" gap={1}>
<Typography textTransform="capitalize">{status}</Typography>
<StatusIcon
status={IMAGE_REGION_STATUS_TO_STATUS_ICON_STATUS[status]}
/>
<IconButton
aria-label={`Remove ${region}`}
onClick={onRemove}
sx={{ p: 0.5 }}
>
<Close />
</IconButton>
</Stack>
</Box>
);
};

const IMAGE_REGION_STATUS_TO_STATUS_ICON_STATUS: Record<
ExtendedImageRegionStatus,
Status
> = {
available: 'active',
creating: 'other',
pending: 'other',
'pending deletion': 'other',
'pending replication': 'inactive',
replicating: 'other',
timedout: 'inactive',
unsaved: 'inactive',
};
bnussman-akamai marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import userEvent from '@testing-library/user-event';
import React from 'react';

import { imageFactory, regionFactory } from 'src/factories';
import { makeResourcePage } from 'src/mocks/serverHandlers';
import { HttpResponse, http, server } from 'src/mocks/testServer';
import { renderWithTheme } from 'src/utilities/testHelpers';

import { ManageImageRegionsDrawer } from './ManageImageRegionsDrawer';

describe('ManageImageRegionsDrawer', () => {
it('should not render when open is false', () => {
const { container } = renderWithTheme(
<ManageImageRegionsDrawer
image={undefined}
onClose={vi.fn()}
open={false}
/>
);

expect(container).toBeEmptyDOMElement();
});

it('should render a header', () => {
const image = imageFactory.build();
const { getByText } = renderWithTheme(
<ManageImageRegionsDrawer image={image} onClose={vi.fn()} open />
);

expect(getByText(`Manage Regions for ${image.label}`)).toBeVisible();
});

it('should render existing regions and their statuses', async () => {
const region1 = regionFactory.build({ id: 'us-east', label: 'Newark, NJ' });
const region2 = regionFactory.build({ id: 'us-west', label: 'Place, CA' });

const image = imageFactory.build({
regions: [
{
region: 'us-east',
status: 'available',
},
{
region: 'us-west',
status: 'pending replication',
},
],
});

server.use(
http.get('*/v4/regions', () => {
return HttpResponse.json(makeResourcePage([region1, region2]));
})
);

const { findByText } = renderWithTheme(
<ManageImageRegionsDrawer image={image} onClose={vi.fn()} open />
);

await findByText('Newark, NJ');
await findByText('available');
await findByText('Place, CA');
await findByText('pending replication');
});

it('should render a status of "unsaved" when a new region is selected', async () => {
const region1 = regionFactory.build({ id: 'us-east', label: 'Newark, NJ' });
const region2 = regionFactory.build({ id: 'us-west', label: 'Place, CA' });

const image = imageFactory.build({
regions: [
{
region: 'us-east',
status: 'available',
},
],
});

server.use(
http.get('*/v4/regions', () => {
return HttpResponse.json(makeResourcePage([region1, region2]));
})
);

const { findByText, getByLabelText, getByText } = renderWithTheme(
<ManageImageRegionsDrawer image={image} onClose={vi.fn()} open />
);

const regionSelect = getByLabelText('Add Regions');

// Open the Region Select
await userEvent.click(regionSelect);

// Select new region
await userEvent.click(await findByText('us-west', { exact: false }));

// Close the Region Multi-Select to that selections are committed to the list
await userEvent.type(regionSelect, '{escape}');

expect(getByText('Place, CA')).toBeVisible();
expect(getByText('unsaved')).toBeVisible();
});
});
Loading
Loading