diff --git a/packages/manager/.changeset/pr-13558-upcoming-features-1775269042594.md b/packages/manager/.changeset/pr-13558-upcoming-features-1775269042594.md new file mode 100644 index 00000000000..e04813ea9f1 --- /dev/null +++ b/packages/manager/.changeset/pr-13558-upcoming-features-1775269042594.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Private Image Sharing: add View Shared Image Details drawer ([#13558](https://github.com/linode/manager/pull/13558)) diff --git a/packages/manager/src/features/Images/ImagesLanding/ImagesActionMenu.tsx b/packages/manager/src/features/Images/ImagesLanding/ImagesActionMenu.tsx index 33e81f013e0..b4725314e61 100644 --- a/packages/manager/src/features/Images/ImagesLanding/ImagesActionMenu.tsx +++ b/packages/manager/src/features/Images/ImagesLanding/ImagesActionMenu.tsx @@ -15,6 +15,7 @@ export interface Handlers { onEdit?: (image: Image) => void; onManageRegions?: (image: Image) => void; onRebuild?: (image: Image) => void; + onView?: (image: Image) => void; } interface Props { @@ -32,7 +33,8 @@ export const ImagesActionMenu = (props: Props) => { const [isOpen, setIsOpen] = React.useState(false); - const { onDelete, onDeploy, onEdit, onManageRegions, onRebuild } = handlers; + const { onDelete, onDeploy, onEdit, onManageRegions, onRebuild, onView } = + handlers; const { data: imagePermissions, isLoading: isImagePermissionsLoading } = usePermissions( @@ -81,7 +83,7 @@ export const ImagesActionMenu = (props: Props) => { return [ { title: 'View Image Details', - onClick: () => null, + onClick: () => onView?.(image), pendoId: pendoIDs?.actionMenu.viewImageDetails, }, { ...deployAction }, diff --git a/packages/manager/src/features/Images/ImagesLanding/v2/ImageLibrary/ImageLibraryTabs.tsx b/packages/manager/src/features/Images/ImagesLanding/v2/ImageLibrary/ImageLibraryTabs.tsx index 796786111e8..229f29ec3b1 100644 --- a/packages/manager/src/features/Images/ImagesLanding/v2/ImageLibrary/ImageLibraryTabs.tsx +++ b/packages/manager/src/features/Images/ImagesLanding/v2/ImageLibrary/ImageLibraryTabs.tsx @@ -15,8 +15,10 @@ import { DeleteImageDialog } from '../../DeleteImageDialog'; import { EditImageDrawer } from '../../EditImageDrawer'; import { ManageImageReplicasForm } from '../../ImageRegions/ManageImageRegionsForm'; import { RebuildImageDrawer } from '../../RebuildImageDrawer'; +import { VIEW_SHARED_IMAGE_DETAILS_DRAWER_PENDO_IDS } from '../constants'; import { imageLibrarySubTabs as subTabs } from './imageLibraryTabsConfig'; import { ImagesView } from './ImagesView'; +import { ViewImageDrawer } from './ViewImageDrawer'; import type { Handlers as ImageHandlers } from '../../ImagesActionMenu'; import type { Image } from '@linode/api-v4'; @@ -58,6 +60,10 @@ export const ImageLibraryTabs = () => { }); }; + const handleView = (image: Image) => { + actionHandler(image, 'view'); + }; + const handleEdit = (image: Image) => { actionHandler(image, 'edit'); }; @@ -106,6 +112,7 @@ export const ImageLibraryTabs = () => { onEdit: handleEdit, onManageRegions: handleManageRegions, onRebuild: handleRebuild, + onView: handleView, }; const subTabIndex = getSubTabIndex(subTabs, imageTypeParams?.imageType); @@ -149,6 +156,15 @@ export const ImageLibraryTabs = () => { + ({ + useRegionsQuery: vi.fn(), +})); + +vi.mock('@linode/queries', async () => { + const actual = await vi.importActual('@linode/queries'); + return { + ...actual, + useRegionsQuery: queryMocks.useRegionsQuery, + }; +}); + +beforeEach(() => { + queryMocks.useRegionsQuery.mockReturnValue({ data: mockRegions }); +}); + +const onClose = vi.fn(); + +const baseImage = imageFactory.build({ + capabilities: ['distributed-sites'], + created: '2024-01-15T00:00:00', + description: 'A test image description', + id: 'private/123', + image_sharing: { + shared_by: { + sharegroup_id: 1, + sharegroup_label: 'my-share-group', + sharegroup_uuid: 'abc-uuid', + source_image_id: 123, + }, + shared_with: null, + }, + label: 'my-test-image', + regions: [{ region: 'us-east', status: 'available' }], + size: 1500, + total_size: 3000, +}); + +const defaultProps = { + image: baseImage, + imageError: null, + isFetching: false, + isSharedImage: true, + onClose, + open: true, + pendoIDs: {} as typeof VIEW_SHARED_IMAGE_DETAILS_DRAWER_PENDO_IDS, +}; + +describe('ViewImageDrawer', () => { + it('renders the drawer title', () => { + const { getByText } = renderWithTheme( + + ); + + expect(getByText('View shared image details')).toBeVisible(); + }); + + it('renders image label', () => { + const { getByText } = renderWithTheme( + + ); + + expect(getByText('my-test-image')).toBeVisible(); + }); + + it('renders the image ID', () => { + const { getByText } = renderWithTheme( + + ); + + expect(getByText('private/123')).toBeVisible(); + }); + + it('renders the share group label', () => { + const { getByText } = renderWithTheme( + + ); + + expect(getByText(/my-share-group/)).toBeVisible(); + }); + + it('renders original image size and total replica size', () => { + const { getByText } = renderWithTheme( + + ); + + expect(getByText(/1500 MB/)).toBeVisible(); + expect(getByText(/3000 MB/)).toBeVisible(); + }); + + it('renders created date', () => { + const { getByText } = renderWithTheme( + + ); + + expect(getByText('2024-01-15T00:00:00')).toBeVisible(); + }); + + it('renders Encrypted when image has the distributed-sites capability', () => { + const { getByTestId, queryByText } = renderWithTheme( + + ); + + expect(getByTestId('encrypted-indicator')).toBeVisible(); + expect(queryByText('Not Encrypted')).toBeNull(); + }); + + it('renders Not Encrypted when image lacks the distributed-sites capability', () => { + const image = imageFactory.build({ + ...baseImage, + capabilities: [], + }); + + const { getByTestId, queryByText } = renderWithTheme( + + ); + + expect(getByTestId('not-encrypted-indicator')).toBeVisible(); + expect(queryByText('Encrypted')).toBeNull(); + }); + + it('renders the Cloud-Init metadata notice when image has the cloud-init capability', () => { + const image = imageFactory.build({ + ...baseImage, + capabilities: ['cloud-init'], + }); + + const { getByText } = renderWithTheme( + + ); + + expect(getByText('Supports Metadata service via Cloud-Init')).toBeVisible(); + }); + + it('does not render the Cloud-Init metadata notice when image lacks the cloud-init capability', () => { + const { queryByText } = renderWithTheme( + + ); + + expect(queryByText('Supports Metadata service via Cloud-Init')).toBeNull(); + }); + + it('renders the description when present', () => { + const { getByText } = renderWithTheme( + + ); + + expect(getByText('A test image description')).toBeVisible(); + }); + + it('does not render the description section when description is absent', () => { + const image = imageFactory.build({ ...baseImage, description: null }); + + const { queryByText } = renderWithTheme( + + ); + + expect(queryByText('Description')).toBeNull(); + }); + + it('renders the replicated region with flag and label', () => { + queryMocks.useRegionsQuery.mockReturnValue({ + data: [ + regionFactory.build({ + id: 'us-east', + label: 'Newark, NJ', + country: 'us', + }), + ], + }); + + const { getByText } = renderWithTheme( + + ); + + expect(getByText('Newark, NJ')).toBeVisible(); + }); + + it('renders Unknown for unrecognized region', () => { + queryMocks.useRegionsQuery.mockReturnValue({ data: [] }); + + const { getByText } = renderWithTheme( + + ); + + expect(getByText('Unknown')).toBeVisible(); + }); + + it('calls onClose when the Close button is clicked', async () => { + const { getByTestId } = renderWithTheme( + + ); + + getByTestId('cancel').click(); + + expect(onClose).toHaveBeenCalled(); + }); + + it('renders nothing in the drawer body when no image is provided', () => { + const { queryByText } = renderWithTheme( + + ); + + expect(queryByText('my-test-image')).toBeNull(); + expect(queryByText('private/123')).toBeNull(); + }); +}); diff --git a/packages/manager/src/features/Images/ImagesLanding/v2/ImageLibrary/ViewImageDrawer.tsx b/packages/manager/src/features/Images/ImagesLanding/v2/ImageLibrary/ViewImageDrawer.tsx new file mode 100644 index 00000000000..6b0442af77d --- /dev/null +++ b/packages/manager/src/features/Images/ImagesLanding/v2/ImageLibrary/ViewImageDrawer.tsx @@ -0,0 +1,153 @@ +import { useRegionsQuery } from '@linode/queries'; +import { ActionsPanel, Drawer, Stack, styled, Typography } from '@linode/ui'; +import React from 'react'; + +import CloudInitIcon from 'src/assets/icons/cloud-init.svg'; +import Lock from 'src/assets/icons/lock.svg'; +import Unlock from 'src/assets/icons/unlock.svg'; +import { CopyTooltip } from 'src/components/CopyTooltip/CopyTooltip'; +import { Flag } from 'src/components/Flag'; +import { getCountryAndLabelFromImageRegion } from 'src/features/Images/utils'; + +import type { VIEW_SHARED_IMAGE_DETAILS_DRAWER_PENDO_IDS } from '../constants'; +import type { APIError, Image } from '@linode/api-v4'; + +interface Props { + image: Image | undefined; + imageError?: APIError[] | null; + isFetching?: boolean; + isSharedImage?: boolean; + onClose: () => void; + open: boolean; + pendoIDs: typeof VIEW_SHARED_IMAGE_DETAILS_DRAWER_PENDO_IDS; +} + +export const ViewImageDrawer = (props: Props) => { + const { + imageError, + isFetching, + isSharedImage, + onClose, + open, + pendoIDs, + image, + } = props; + + const { data: regions } = useRegionsQuery(); + + return ( + + + + Label: {image?.label} + + + Image ID: {image?.id} + + + {isSharedImage && ( + + Share group:{' '} + {image?.image_sharing?.shared_by?.sharegroup_label} + + )} + + Original image size: {image?.size} MB + + + All replicas: {image?.total_size} MB + + + Created: {image?.created} + + {image?.capabilities?.includes('distributed-sites') ? ( + + + Encrypted + + ) : ( + + + + Not Encrypted + + + )} + {image?.capabilities?.includes('cloud-init') && ( + + + Supports Metadata service via Cloud-Init + + )} + {image?.description && ( + + Description + {image.description} + + )} + Replicated in the following regions:{' '} + {image?.regions.map((region) => { + const countryAndLabelObject = getCountryAndLabelFromImageRegion( + regions ?? [], + region + ); + + const imageCountry = countryAndLabelObject.country ?? 'us'; + const regionLabel = countryAndLabelObject.label ?? 'Unknown'; + + return ( + + + {regionLabel} + + ); + })} + + + + ); +}; + +const StyledLabel = styled('span', { + label: 'StyledLabel', +})(({ theme }) => ({ + font: theme.font.bold, +})); + +const StyledCloudInitIcon = styled(CloudInitIcon, { + label: 'StyledCloudInitIcon', +})(() => ({ + height: 16, + width: 16, +})); + +const StyledCopyIcon = styled(CopyTooltip)(({ theme }) => ({ + '& svg': { + height: 12, + top: 1, + width: 12, + }, + marginLeft: theme.spacingFunction(4), +})); diff --git a/packages/manager/src/features/Images/ImagesLanding/v2/constants.ts b/packages/manager/src/features/Images/ImagesLanding/v2/constants.ts index 1d50213647e..e18b2c1aaad 100644 --- a/packages/manager/src/features/Images/ImagesLanding/v2/constants.ts +++ b/packages/manager/src/features/Images/ImagesLanding/v2/constants.ts @@ -1 +1,8 @@ export const DEFAULT_PAGE_SIZES = [25, 50, 75, 100]; + +// Shared Image drawer Pendo IDs +export const VIEW_SHARED_IMAGE_DETAILS_DRAWER_PENDO_IDS = { + xButton: 'Images Library Shared View-X button', + closeButton: 'Images Library Shared View-Close', + copyImageIdIcon: 'Images Library Shared View-Copy ID', +}; diff --git a/packages/manager/src/features/Images/utils.test.tsx b/packages/manager/src/features/Images/utils.test.tsx index e7ab420a401..8be3bc05bee 100644 --- a/packages/manager/src/features/Images/utils.test.tsx +++ b/packages/manager/src/features/Images/utils.test.tsx @@ -1,10 +1,11 @@ -import { linodeFactory } from '@linode/utilities'; +import { linodeFactory, regionFactory } from '@linode/utilities'; import { renderHook, waitFor } from '@testing-library/react'; import { eventFactory, imageFactory } from 'src/factories'; import { wrapWithTheme } from 'src/utilities/testHelpers'; import { + getCountryAndLabelFromImageRegion, getEventsForImages, getImageLabelForLinode, getImageTypeToImageLibraryType, @@ -121,6 +122,41 @@ describe('getSubTabIndex', () => { }); }); +describe('getCountryAndLabelFromImageRegion', () => { + it('returns the country and label when a matching region is found', () => { + const regions = regionFactory.buildList(1, { + id: 'us-east', + label: 'Newark, NJ', + country: 'us', + }); + const imageRegion = { region: 'us-east', status: 'available' as const }; + + expect(getCountryAndLabelFromImageRegion(regions, imageRegion)).toEqual({ + country: 'us', + label: 'Newark, NJ', + }); + }); + + it('returns undefined for country and label when no matching region is found', () => { + const regions = regionFactory.buildList(1, { id: 'us-west' }); + const imageRegion = { region: 'us-east', status: 'available' as const }; + + expect(getCountryAndLabelFromImageRegion(regions, imageRegion)).toEqual({ + country: undefined, + label: undefined, + }); + }); + + it('returns undefined for country and label when regions array is empty', () => { + const imageRegion = { region: 'us-east', status: 'available' as const }; + + expect(getCountryAndLabelFromImageRegion([], imageRegion)).toEqual({ + country: undefined, + label: undefined, + }); + }); +}); + describe('getImageTypeToImageLibraryType', () => { it('returns "owned-by-me" when image type is "manual"', () => { expect(getImageTypeToImageLibraryType('manual')).toBe('owned-by-me'); diff --git a/packages/manager/src/features/Images/utils.ts b/packages/manager/src/features/Images/utils.ts index c989aa5c21d..2065ec7d32f 100644 --- a/packages/manager/src/features/Images/utils.ts +++ b/packages/manager/src/features/Images/utils.ts @@ -3,7 +3,7 @@ import { useRegionsQuery } from '@linode/queries'; import { DISALLOWED_IMAGE_REGIONS } from 'src/constants'; import { useFlags } from 'src/hooks/useFlags'; -import type { Event, Image, Linode } from '@linode/api-v4'; +import type { Event, Image, ImageRegion, Linode, Region } from '@linode/api-v4'; export type ImageLibraryType = | 'owned-by-me' @@ -118,3 +118,15 @@ export const getImageTypeToImageLibraryType = ( return 'shared-with-me'; } }; + +export const getCountryAndLabelFromImageRegion = ( + regions: Region[], + imageRegion: ImageRegion +) => { + const matchingRegion = regions?.find((r) => r.id === imageRegion.region); + + return { + country: matchingRegion?.country, + label: matchingRegion?.label, + }; +}; diff --git a/packages/manager/src/routes/images/index.ts b/packages/manager/src/routes/images/index.ts index 0e48c5b2c2a..87d824643af 100644 --- a/packages/manager/src/routes/images/index.ts +++ b/packages/manager/src/routes/images/index.ts @@ -45,6 +45,7 @@ const imageActions = { edit: 'edit', 'manage-replicas': 'manage-replicas', rebuild: 'rebuild', + view: 'view', } as const; export type ImageAction = (typeof imageActions)[keyof typeof imageActions]; diff --git a/packages/ui/src/components/Drawer/Drawer.tsx b/packages/ui/src/components/Drawer/Drawer.tsx index c1486bf620f..c727b856bd5 100644 --- a/packages/ui/src/components/Drawer/Drawer.tsx +++ b/packages/ui/src/components/Drawer/Drawer.tsx @@ -35,6 +35,10 @@ export interface DrawerProps extends _DrawerProps { * If true, the drawer will feature a loading spinner for its content. */ isFetching?: boolean; + /** + * Optional Pendo ID for tracking clicks on the X icon that closes the drawer. + */ + pendoId?: string; /** * Title that appears at the top of the drawer */ @@ -72,6 +76,7 @@ export const Drawer = React.forwardRef( isFetching, onClose, open, + pendoId, sx, title, titleSuffix, @@ -204,6 +209,7 @@ export const Drawer = React.forwardRef( onClose?.({}, 'escapeKeyDown')} size="large"