Skip to content
Open
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@linode/manager": Upcoming Features
---

Private Image Sharing: Shared with me tab ([#13500](https://github.com/linode/manager/pull/13500))
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
import { formatDate } from 'src/utilities/formatDate';

import { TABLE_CELL_BASE_STYLE } from './constants';
import { getRegionListItem } from './utilities';

import type {
IMAGE_SELECT_TABLE_LINODE_CREATE_PENDO_IDS,
Expand Down Expand Up @@ -76,20 +77,12 @@ export const ImageSelectTableRow = (props: Props) => {
return 'β€”';
};

const getRegionListItem = (imageRegion: ImageRegion) => {
const matchingRegion = regions.find((r) => r.id === imageRegion.region);

return matchingRegion
? `${matchingRegion.label} (${imageRegion.region})`
: imageRegion.region;
};

const FormattedRegionList = () => (
<StyledFormattedRegionList>
{imageRegions.map((region: ImageRegion, idx) => {
return (
<ListItem disablePadding key={`${region.region}-${idx}`}>
{getRegionListItem(region)}
{getRegionListItem(regions, region)}
</ListItem>
);
})}
Expand Down
16 changes: 15 additions & 1 deletion packages/manager/src/components/ImageSelect/utilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { DateTime } from 'luxon';
import { MAX_MONTHS_EOL_FILTER } from 'src/constants';

import type { ImageSelectVariant } from './ImageSelect';
import type { Image, RegionSite } from '@linode/api-v4';
import type { Image, ImageRegion, Region, RegionSite } from '@linode/api-v4';
import type { DisableItemOption } from '@linode/ui';

/**
Expand Down Expand Up @@ -118,3 +118,17 @@ export const getDisabledImages = (options: DisabledImageOptions) => {

return {};
};

/**
* Accepts an array of regions of the Region type and an ImageRegion and returns a string for the matching region to be displayed in the UI
*/
export const getRegionListItem = (
regions: Region[],
imageRegion: ImageRegion
) => {
const matchingRegion = regions.find((r) => r.id === imageRegion.region);

return matchingRegion
? `${matchingRegion.label} (${imageRegion.region})`
: imageRegion.region;
};
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import { getRestrictedResourceText } from 'src/features/Account/utils';
import { usePermissions } from 'src/features/IAM/hooks/usePermissions';

import type { SHARED_WITH_ME_IMAGES_TAB_PENDO_IDS } from '../constants';
import type { Event, Image } from '@linode/api-v4';
import type { Action } from 'src/components/ActionMenu/ActionMenu';

Expand All @@ -20,10 +21,12 @@
event?: Event;
handlers: Handlers;
image: Image;
pendoIDs?: typeof SHARED_WITH_ME_IMAGES_TAB_PENDO_IDS;
sharedImageRow?: boolean;
}

export const ImagesActionMenu = (props: Props) => {
const { handlers, image } = props;
const { handlers, image, sharedImageRow, pendoIDs } = props;

const { id, status } = image;

Expand All @@ -47,21 +50,37 @@
const isAvailable = !isDisabled;

return [
{
disabled: !imagePermissions.update_image || isDisabled,
onClick: () => onEdit?.(image),
title: 'Edit',
tooltip: !imagePermissions.update_image
? getRestrictedResourceText({
action: 'edit',
isSingular: true,
resourceType: 'Images',
})
: isDisabled
? 'Image is not yet available for use.'
: undefined,
},
...(onManageRegions && image.regions && image.regions.length > 0
...(!sharedImageRow
? [
{
disabled: !imagePermissions.update_image || isDisabled,
onClick: () => onEdit?.(image),
title: 'Edit',
tooltip: !imagePermissions.update_image
? getRestrictedResourceText({
action: 'edit',
isSingular: true,
resourceType: 'Images',
})
: isDisabled
? 'Image is not yet available for use.'

Check warning on line 66 in packages/manager/src/features/Images/ImagesLanding/ImagesActionMenu.tsx

View workflow job for this annotation

GitHub Actions / ESLint Review (manager)

[eslint] reported by reviewdog 🐢 Define a constant instead of duplicating this literal 3 times. Raw Output: {"ruleId":"sonarjs/no-duplicate-string","severity":1,"message":"Define a constant instead of duplicating this literal 3 times.","line":66,"column":21,"nodeType":"Literal","endLine":66,"endColumn":58}
: undefined,
},
]
: []),
...(sharedImageRow
? [
{
title: 'View Image Details',
onClick: () => null,
pendoId: pendoIDs?.actionMenu.viewImageDetails,
},
]
: []),
...(!sharedImageRow &&
onManageRegions &&
image.regions &&
image.regions.length > 0
? [
{
disabled: !imagePermissions.replicate_image || isDisabled,
Expand All @@ -80,6 +99,9 @@
{
disabled: !linodeAccountPermissions.create_linode || isDisabled,
onClick: () => onDeploy?.(id),
pendoId: sharedImageRow
? pendoIDs?.actionMenu.deployNewLinode
: undefined,
title: 'Deploy to New Linode',
tooltip: !linodeAccountPermissions.create_linode
? getRestrictedResourceText({
Expand All @@ -94,21 +116,28 @@
{
disabled: isDisabled,
onClick: () => onRebuild?.(image),
pendoId: sharedImageRow
? pendoIDs?.actionMenu.rebuildLinode
: undefined,
title: 'Rebuild an Existing Linode',
tooltip: isDisabled ? 'Image is not yet available for use.' : undefined,
},
{
disabled: !imagePermissions.delete_image,
onClick: () => onDelete?.(image),
title: isAvailable ? 'Delete' : 'Cancel',
tooltip: !imagePermissions.delete_image
? getRestrictedResourceText({
action: 'delete',
isSingular: true,
resourceType: 'Images',
})
: undefined,
},
...(!sharedImageRow
? [
{
disabled: !imagePermissions.delete_image,
onClick: () => onDelete?.(image),
title: isAvailable ? 'Delete' : 'Cancel',
tooltip: !imagePermissions.delete_image
? getRestrictedResourceText({
action: 'delete',
isSingular: true,
resourceType: 'Images',
})
: undefined,
},
]
: []),
];
}, [
status,
Expand All @@ -121,6 +150,8 @@
onDelete,
imagePermissions,
linodeAccountPermissions,
pendoIDs,
sharedImageRow,
]);

return (
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
import userEvent from '@testing-library/user-event';
import React from 'react';

import { imageFactory } from 'src/factories';
import { SHARED_WITH_ME_IMAGES_TAB_PENDO_IDS } from 'src/features/Images/constants';
import {
mockMatchMedia,
renderWithTheme,
wrapWithTableBody,
} from 'src/utilities/testHelpers';

import { SharedImageRow } from './SharedImageRow';

import type { Handlers } from './ImagesActionMenu';

beforeAll(() => mockMatchMedia());

const queryMocks = vi.hoisted(() => ({
usePermissions: vi.fn().mockReturnValue({}),
}));

vi.mock('src/features/IAM/hooks/usePermissions', () => ({
usePermissions: queryMocks.usePermissions,
}));

describe('Shared Image Table Row', () => {
const handlers: Handlers = {
onDelete: vi.fn(),
onDeploy: vi.fn(),
onEdit: vi.fn(),
onManageRegions: vi.fn(),
onRebuild: vi.fn(),
};

beforeEach(() => {
queryMocks.usePermissions.mockReturnValue({
data: {
create_linode: true,
},
});
});

it('should render a shared image row with details', async () => {
const image = imageFactory.build({
image_sharing: {
shared_by: {
sharegroup_id: 1,
sharegroup_label: 'my-share-group',
sharegroup_uuid: 'abc-123',
source_image_id: 42,
},
shared_with: null,
},
regions: [
{ region: 'us-east', status: 'available' },
{ region: 'us-southeast', status: 'available' },
],
size: 300,
});

const { getByLabelText, getByText } = renderWithTheme(
wrapWithTableBody(
<SharedImageRow
handlers={handlers}
image={image}
pendoIDs={SHARED_WITH_ME_IMAGES_TAB_PENDO_IDS}
/>
)
);

expect(getByText(image.label)).toBeVisible();
expect(getByText('my-share-group')).toBeVisible();
expect(getByText(image.id)).toBeVisible();
expect(getByText('2 Regions')).toBeVisible();
expect(getByText('0.29 GB')).toBeVisible(); // 300 / 1024 = 0.292

// Open action menu
const actionMenu = getByLabelText(`Action menu for Image ${image.label}`);
await userEvent.click(actionMenu);

expect(getByText('View Image Details')).toBeVisible();
expect(getByText('Deploy to New Linode')).toBeVisible();
expect(getByText('Rebuild an Existing Linode')).toBeVisible();
});

it('should show a dash for the Share Group column when image_sharing is not set', () => {
const image = imageFactory.build();

const { getByText } = renderWithTheme(
wrapWithTableBody(
<SharedImageRow
handlers={handlers}
image={image}
pendoIDs={SHARED_WITH_ME_IMAGES_TAB_PENDO_IDS}
/>
)
);

expect(getByText('-')).toBeVisible();
});

it('should show a cloud-init icon if the image supports it', () => {
const image = imageFactory.build({
capabilities: ['cloud-init'],
regions: [{ region: 'us-east', status: 'available' }],
});

const { getByLabelText } = renderWithTheme(
wrapWithTableBody(
<SharedImageRow
handlers={handlers}
image={image}
pendoIDs={SHARED_WITH_ME_IMAGES_TAB_PENDO_IDS}
/>
)
);

expect(
getByLabelText('This image supports our Metadata service via cloud-init.')
).toBeVisible();
});

it('should show a dash in "Replicated in" cell if image does not have any regions', () => {
const image = imageFactory.build({ regions: [] });

const { getByText } = renderWithTheme(
wrapWithTableBody(
<SharedImageRow
handlers={handlers}
image={image}
pendoIDs={SHARED_WITH_ME_IMAGES_TAB_PENDO_IDS}
/>
)
);

expect(getByText('β€”')).toBeVisible();
});

it('should not show Edit, Manage Replicas, or Delete actions', async () => {
const image = imageFactory.build({
regions: [{ region: 'us-east', status: 'available' }],
});

const { getByLabelText, queryByText } = renderWithTheme(
wrapWithTableBody(
<SharedImageRow
handlers={handlers}
image={image}
pendoIDs={SHARED_WITH_ME_IMAGES_TAB_PENDO_IDS}
/>
)
);

const actionMenu = getByLabelText(`Action menu for Image ${image.label}`);
await userEvent.click(actionMenu);

expect(queryByText('Edit')).not.toBeInTheDocument();
expect(queryByText('Manage Replicas')).not.toBeInTheDocument();
expect(queryByText('Delete')).not.toBeInTheDocument();
});

it('calls handlers when performing actions', async () => {
const image = imageFactory.build({
regions: [{ region: 'us-east', status: 'available' }],
});

const { getByLabelText, getByText } = renderWithTheme(
wrapWithTableBody(
<SharedImageRow
handlers={handlers}
image={image}
pendoIDs={SHARED_WITH_ME_IMAGES_TAB_PENDO_IDS}
/>
)
);

const actionMenu = getByLabelText(`Action menu for Image ${image.label}`);
await userEvent.click(actionMenu);

await userEvent.click(getByText('Deploy to New Linode'));
expect(handlers.onDeploy).toBeCalledWith(image.id);

await userEvent.click(actionMenu);
await userEvent.click(getByText('Rebuild an Existing Linode'));
expect(handlers.onRebuild).toBeCalledWith(image);
});
});
Loading
Loading