From 69644343de75cdece65365ecabe0f865290426f9 Mon Sep 17 00:00:00 2001 From: Dajahi Wiley Date: Tue, 24 Feb 2026 18:06:50 -0500 Subject: [PATCH 1/9] Core functionality in place --- .../ImageSelect/ImageSelectTable.tsx | 241 ++++++++++++++++++ .../ImageSelect/ImageSelectTableRow.tsx | 74 ++++++ .../Images/ImagesLanding/ImagesLanding.tsx | 1 - .../Linodes/LinodeCreate/Tabs/Images.tsx | 33 ++- 4 files changed, 339 insertions(+), 10 deletions(-) create mode 100644 packages/manager/src/components/ImageSelect/ImageSelectTable.tsx create mode 100644 packages/manager/src/components/ImageSelect/ImageSelectTableRow.tsx diff --git a/packages/manager/src/components/ImageSelect/ImageSelectTable.tsx b/packages/manager/src/components/ImageSelect/ImageSelectTable.tsx new file mode 100644 index 00000000000..bc7798a7bf8 --- /dev/null +++ b/packages/manager/src/components/ImageSelect/ImageSelectTable.tsx @@ -0,0 +1,241 @@ +import { + useAllTagsQuery, + useImagesQuery, + useProfile, + useRegionsQuery, +} from '@linode/queries'; +import { getAPIFilterFromQuery } from '@linode/search'; +import { Autocomplete, Box, Notice, Stack } from '@linode/ui'; +import React, { useState } from 'react'; + +import { DebouncedSearchTextField } from 'src/components/DebouncedSearchTextField'; +import { PaginationFooter } from 'src/components/PaginationFooter/PaginationFooter'; +import { Table } from 'src/components/Table'; +import { TableBody } from 'src/components/TableBody'; +import { TableCell } from 'src/components/TableCell'; +import { TableHead } from 'src/components/TableHead'; +import { TableRow } from 'src/components/TableRow'; +import { TableRowEmpty } from 'src/components/TableRowEmpty/TableRowEmpty'; +import { TableRowError } from 'src/components/TableRowError/TableRowError'; +import { TableRowLoading } from 'src/components/TableRowLoading/TableRowLoading'; +import { usePaginationV2 } from 'src/hooks/usePaginationV2'; + +import { ImageSelectTableRow } from './ImageSelectTableRow'; + +import type { Filter, Image } from '@linode/api-v4'; +import type { LinkProps } from '@tanstack/react-router'; + +interface Props { + /** + * The route this table is rendered on. Used to persist pagination and + * sort state in the URL. + */ + currentRoute: LinkProps['to']; + /** + * Error message to display above the table, e.g. from form validation. + */ + errorText?: string; + /** + * Callback fired when the user selects an image row. + */ + onSelect: (image: Image) => void; + /** + * The ID of the currently selected image. + */ + selectedImageId?: null | string; +} + +type OptionType = { label: string; value: string }; + +const COLUMNS = 6; +const PREFERENCE_KEY = 'image-select-table'; + +export const ImageSelectTable = (props: Props) => { + const { currentRoute, errorText, onSelect, selectedImageId } = props; + + const [query, setQuery] = useState(''); + const [selectedTag, setSelectedTag] = useState(null); + const [selectedRegion, setSelectedRegion] = useState(null); + + const { data: profile } = useProfile(); + const { data: tags } = useAllTagsQuery(); + const { data: regions } = useRegionsQuery(); + + const pagination = usePaginationV2({ + currentRoute, + initialPage: 1, + preferenceKey: PREFERENCE_KEY, + }); + + const { filter: searchFilter, error: filterError } = getAPIFilterFromQuery( + query, + { + filterShapeOverrides: { + '+contains': { + field: 'region', + filter: (value) => ({ regions: { region: value } }), + }, + '+eq': { + field: 'region', + filter: (value) => ({ regions: { region: value } }), + }, + }, + searchableFieldsWithoutOperator: ['label', 'tags'], + } + ); + + const combinedFilter = buildImageFilter({ + searchFilter, + selectedRegion, + selectedTag, + }); + + const { + data, + error: imagesError, + isFetching, + isLoading, + } = useImagesQuery( + { + page: pagination.page, + page_size: pagination.pageSize, + }, + { + ...combinedFilter, + is_public: false, + type: 'manual', + } + ); + + const tagOptions = + tags?.map((tag) => ({ label: tag.label, value: tag.label })) ?? []; + + const regionOptions = + regions?.map((r) => ({ label: r.label, value: r.id })) ?? []; + + const selectedTagOption = + tagOptions.find((t) => t.value === selectedTag) ?? null; + + const selectedRegionOption = + regionOptions.find((r) => r.value === selectedRegion) ?? null; + + return ( + + {errorText && } + + + { + setQuery(q); + pagination.handlePageChange(1); + }} + placeholder="Search images" + value={query} + /> + + + { + setSelectedTag((value as null | OptionType)?.value ?? null); + pagination.handlePageChange(1); + }} + options={tagOptions} + placeholder="Filter by tag" + sx={{ paddingBottom: '9px' }} // to align with search field + value={selectedTagOption} + /> + + + { + setSelectedRegion((value as null | OptionType)?.value ?? null); + pagination.handlePageChange(1); + }} + options={regionOptions} + placeholder="Filter by region" + sx={{ paddingBottom: '9px' }} // to align with search field + value={selectedRegionOption} + /> + + + + + + + Image + Replicated in + Share Group + Size + Created + Image ID + + + + {isLoading && ( + + )} + {imagesError && ( + + )} + {!isLoading && !imagesError && data?.results === 0 && ( + + )} + {!isLoading && + !imagesError && + data?.data.map((image) => ( + onSelect(image)} + selected={image.id === selectedImageId} + timezone={profile?.timezone} + /> + ))} + +
+ +
+
+ ); +}; + +interface BuildImageFilterParams { + searchFilter: Filter; + selectedRegion: null | string; + selectedTag: null | string; +} + +/** + * Merges the search filter with optional tag and region dropdown filters + * into a single API filter object. + */ +const buildImageFilter = ({ + searchFilter, + selectedRegion, + selectedTag, +}: BuildImageFilterParams) => { + return { + ...searchFilter, + ...(selectedTag ? { tags: { '+contains': selectedTag } } : {}), + ...(selectedRegion ? { regions: { region: selectedRegion } } : {}), + }; +}; diff --git a/packages/manager/src/components/ImageSelect/ImageSelectTableRow.tsx b/packages/manager/src/components/ImageSelect/ImageSelectTableRow.tsx new file mode 100644 index 00000000000..86600725f4a --- /dev/null +++ b/packages/manager/src/components/ImageSelect/ImageSelectTableRow.tsx @@ -0,0 +1,74 @@ +import { FormControlLabel, Radio } from '@linode/ui'; +import { convertStorageUnit, pluralize } from '@linode/utilities'; +import React from 'react'; + +import { TableCell } from 'src/components/TableCell'; +import { TableRow } from 'src/components/TableRow'; +import { formatDate } from 'src/utilities/formatDate'; + +import type { Image } from '@linode/api-v4'; + +interface Props { + image: Image; + onSelect: () => void; + selected: boolean; + timezone?: string; +} + +export const ImageSelectTableRow = (props: Props) => { + const { image, onSelect, selected, timezone } = props; + + const { created, id, image_sharing, label, regions, size, status } = image; + + const getSizeDisplay = () => { + if (status === 'available') { + const sizeInGB = convertStorageUnit('MB', size, 'GB'); + const formatted = Intl.NumberFormat('en-US', { + maximumFractionDigits: 2, + minimumFractionDigits: 0, + }).format(sizeInGB); + return `${formatted} GB`; + } + return 'Pending'; + }; + + const getShareGroupDisplay = () => { + if (image_sharing?.shared_by?.sharegroup_label) { + return image_sharing.shared_by.sharegroup_label; + } + if ( + image_sharing?.shared_with?.sharegroup_count !== null && + image_sharing?.shared_with?.sharegroup_count !== undefined + ) { + return pluralize( + 'Share Group', + 'Share Groups', + image_sharing.shared_with.sharegroup_count + ); + } + return '—'; + }; + + return ( + + + } + label={label} + onChange={onSelect} + sx={{ gap: 2 }} + /> + + + {regions?.length > 0 + ? pluralize('Region', 'Regions', regions.length) + : '—'} + + {getShareGroupDisplay()} + {getSizeDisplay()} + {formatDate(created, { timezone })} + {id} + + ); +}; diff --git a/packages/manager/src/features/Images/ImagesLanding/ImagesLanding.tsx b/packages/manager/src/features/Images/ImagesLanding/ImagesLanding.tsx index d597cc237f4..5bbf48f3f81 100644 --- a/packages/manager/src/features/Images/ImagesLanding/ImagesLanding.tsx +++ b/packages/manager/src/features/Images/ImagesLanding/ImagesLanding.tsx @@ -156,7 +156,6 @@ export const ImagesLanding = () => { { ...manualImagesFilter, is_public: false, - type: 'manual', }, { // Refetch custom images every 30 seconds. diff --git a/packages/manager/src/features/Linodes/LinodeCreate/Tabs/Images.tsx b/packages/manager/src/features/Linodes/LinodeCreate/Tabs/Images.tsx index 717cdc85696..cdcea8cf586 100644 --- a/packages/manager/src/features/Linodes/LinodeCreate/Tabs/Images.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreate/Tabs/Images.tsx @@ -6,10 +6,12 @@ import { useController, useFormContext, useWatch } from 'react-hook-form'; import ComputeIcon from 'src/assets/icons/entityIcons/compute.svg'; import { ImageSelect } from 'src/components/ImageSelect/ImageSelect'; +import { ImageSelectTable } from 'src/components/ImageSelect/ImageSelectTable'; import { getAPIFilterForImageSelect } from 'src/components/ImageSelect/utilities'; import { Link } from 'src/components/Link'; import { Placeholder } from 'src/components/Placeholder/Placeholder'; import { usePermissions } from 'src/features/IAM/hooks/usePermissions'; +import { useIsPrivateImageSharingEnabled } from 'src/features/Images/utils'; import { Region } from '../Region'; import { getGeneratedLinodeLabel } from '../utilities'; @@ -26,10 +28,12 @@ export const Images = () => { getValues, setValue, } = useFormContext(); + const { field, fieldState } = useController({ control, name: 'image', }); + const queryClient = useQueryClient(); const { data: permissions } = usePermissions('account', ['create_linode']); @@ -40,6 +44,8 @@ export const Images = () => { const selectedRegion = regions?.find((r) => r.id === regionId); + const { isPrivateImageSharingEnabled } = useIsPrivateImageSharingEnabled(); + const onChange = async (image: Image | null) => { field.onChange(image?.id ?? null); @@ -88,17 +94,26 @@ export const Images = () => { Choose an Image - - - + ) : ( + + + + )} ); From 61709e2dd0dbc385572136d5ec3f6077885b9888 Mon Sep 17 00:00:00 2001 From: Dajahi Wiley Date: Wed, 25 Feb 2026 17:37:58 -0500 Subject: [PATCH 2/9] Additional progress --- .../ImageSelect/ImageSelectTable.tsx | 28 +++++++++++++++-- .../ImageSelect/ImageSelectTableRow.tsx | 31 ++++++++++++++++--- .../manager/src/features/Images/constants.ts | 3 ++ 3 files changed, 55 insertions(+), 7 deletions(-) diff --git a/packages/manager/src/components/ImageSelect/ImageSelectTable.tsx b/packages/manager/src/components/ImageSelect/ImageSelectTable.tsx index bc7798a7bf8..848b4947331 100644 --- a/packages/manager/src/components/ImageSelect/ImageSelectTable.tsx +++ b/packages/manager/src/components/ImageSelect/ImageSelectTable.tsx @@ -5,7 +5,14 @@ import { useRegionsQuery, } from '@linode/queries'; import { getAPIFilterFromQuery } from '@linode/search'; -import { Autocomplete, Box, Notice, Stack } from '@linode/ui'; +import { + Autocomplete, + Box, + IconButton, + Notice, + Stack, + TooltipIcon, +} from '@linode/ui'; import React, { useState } from 'react'; import { DebouncedSearchTextField } from 'src/components/DebouncedSearchTextField'; @@ -18,6 +25,7 @@ import { TableRow } from 'src/components/TableRow'; import { TableRowEmpty } from 'src/components/TableRowEmpty/TableRowEmpty'; import { TableRowError } from 'src/components/TableRowError/TableRowError'; import { TableRowLoading } from 'src/components/TableRowLoading/TableRowLoading'; +import { SHARE_GROUP_COLUMN_HEADER_TOOLTIP } from 'src/features/Images/constants'; import { usePaginationV2 } from 'src/hooks/usePaginationV2'; import { ImageSelectTableRow } from './ImageSelectTableRow'; @@ -174,7 +182,23 @@ export const ImageSelectTable = (props: Props) => { Image Replicated in - Share Group + + + Share Group + { + + + + } + + Size Created Image ID diff --git a/packages/manager/src/components/ImageSelect/ImageSelectTableRow.tsx b/packages/manager/src/components/ImageSelect/ImageSelectTableRow.tsx index 86600725f4a..e32c771beaf 100644 --- a/packages/manager/src/components/ImageSelect/ImageSelectTableRow.tsx +++ b/packages/manager/src/components/ImageSelect/ImageSelectTableRow.tsx @@ -1,12 +1,16 @@ -import { FormControlLabel, Radio } from '@linode/ui'; +import { FormControlLabel, ListItem, Radio } from '@linode/ui'; import { convertStorageUnit, pluralize } from '@linode/utilities'; import React from 'react'; import { TableCell } from 'src/components/TableCell'; import { TableRow } from 'src/components/TableRow'; +import { + PlanTextTooltip, + StyledFormattedRegionList, +} from 'src/features/components/PlansPanel/PlansAvailabilityNotice.styles'; import { formatDate } from 'src/utilities/formatDate'; -import type { Image } from '@linode/api-v4'; +import type { Image, ImageRegion } from '@linode/api-v4'; interface Props { image: Image; @@ -49,6 +53,18 @@ export const ImageSelectTableRow = (props: Props) => { return '—'; }; + const FormattedRegionList = () => ( + + {regions.map((region: ImageRegion, idx) => { + return ( + + {region.region} + + ); + })} + + ); + return ( @@ -61,9 +77,14 @@ export const ImageSelectTableRow = (props: Props) => { /> - {regions?.length > 0 - ? pluralize('Region', 'Regions', regions.length) - : '—'} + 0 + ? pluralize('Region', 'Regions', regions.length) + : '—' + } + tooltipText={} + /> {getShareGroupDisplay()} {getSizeDisplay()} diff --git a/packages/manager/src/features/Images/constants.ts b/packages/manager/src/features/Images/constants.ts index ac206464950..88ae9abc38a 100644 --- a/packages/manager/src/features/Images/constants.ts +++ b/packages/manager/src/features/Images/constants.ts @@ -6,3 +6,6 @@ export const MANUAL_IMAGES_DEFAULT_ORDER = 'asc'; export const MANUAL_IMAGES_DEFAULT_ORDER_BY = 'label'; export const AUTOMATIC_IMAGES_DEFAULT_ORDER = 'asc'; export const AUTOMATIC_IMAGES_DEFAULT_ORDER_BY = 'label'; + +export const SHARE_GROUP_COLUMN_HEADER_TOOLTIP = + "Displays the share group for images shared with you; your custom images don't display a group name."; From aa4fe0429a30a0cd26246f70435aef9dbe684db9 Mon Sep 17 00:00:00 2001 From: Dajahi Wiley Date: Thu, 26 Feb 2026 14:22:19 -0500 Subject: [PATCH 3/9] Use CDS table elements and pagination footer --- .../ImageSelect/ImageSelectTable.tsx | 139 ++++++++++++------ .../ImageSelect/ImageSelectTableRow.tsx | 33 +++-- .../src/components/ImageSelect/constants.ts | 7 + 3 files changed, 127 insertions(+), 52 deletions(-) create mode 100644 packages/manager/src/components/ImageSelect/constants.ts diff --git a/packages/manager/src/components/ImageSelect/ImageSelectTable.tsx b/packages/manager/src/components/ImageSelect/ImageSelectTable.tsx index 848b4947331..e8bcf62d137 100644 --- a/packages/manager/src/components/ImageSelect/ImageSelectTable.tsx +++ b/packages/manager/src/components/ImageSelect/ImageSelectTable.tsx @@ -1,6 +1,6 @@ import { + useAllImagesQuery, useAllTagsQuery, - useImagesQuery, useProfile, useRegionsQuery, } from '@linode/queries'; @@ -12,22 +12,32 @@ import { Notice, Stack, TooltipIcon, + useTheme, } from '@linode/ui'; +import useMediaQuery from '@mui/material/useMediaQuery'; +import { Pagination } from 'akamai-cds-react-components/Pagination'; +import { + Table, + TableBody, + TableHead, + TableHeaderCell, + TableRow, +} from 'akamai-cds-react-components/Table'; import React, { useState } from 'react'; import { DebouncedSearchTextField } from 'src/components/DebouncedSearchTextField'; -import { PaginationFooter } from 'src/components/PaginationFooter/PaginationFooter'; -import { Table } from 'src/components/Table'; -import { TableBody } from 'src/components/TableBody'; -import { TableCell } from 'src/components/TableCell'; -import { TableHead } from 'src/components/TableHead'; -import { TableRow } from 'src/components/TableRow'; import { TableRowEmpty } from 'src/components/TableRowEmpty/TableRowEmpty'; import { TableRowError } from 'src/components/TableRowError/TableRowError'; import { TableRowLoading } from 'src/components/TableRowLoading/TableRowLoading'; import { SHARE_GROUP_COLUMN_HEADER_TOOLTIP } from 'src/features/Images/constants'; import { usePaginationV2 } from 'src/hooks/usePaginationV2'; +import { + DEFAULT_CLIENT_SIDE_PAGE_SIZE, + IMAGE_SELECT_TABLE_COLUMNS_COUNT, + IMAGE_SELECT_TABLE_PREFERENCE_KEY, + TABLE_CELL_BASE_STYLE, +} from './constants'; import { ImageSelectTableRow } from './ImageSelectTableRow'; import type { Filter, Image } from '@linode/api-v4'; @@ -55,26 +65,19 @@ interface Props { type OptionType = { label: string; value: string }; -const COLUMNS = 6; -const PREFERENCE_KEY = 'image-select-table'; - export const ImageSelectTable = (props: Props) => { const { currentRoute, errorText, onSelect, selectedImageId } = props; + const theme = useTheme(); const [query, setQuery] = useState(''); const [selectedTag, setSelectedTag] = useState(null); const [selectedRegion, setSelectedRegion] = useState(null); + const matchesSmDown = useMediaQuery(theme.breakpoints.down('md')); const { data: profile } = useProfile(); const { data: tags } = useAllTagsQuery(); const { data: regions } = useRegionsQuery(); - const pagination = usePaginationV2({ - currentRoute, - initialPage: 1, - preferenceKey: PREFERENCE_KEY, - }); - const { filter: searchFilter, error: filterError } = getAPIFilterFromQuery( query, { @@ -99,15 +102,12 @@ export const ImageSelectTable = (props: Props) => { }); const { - data, + data: imagesData, error: imagesError, isFetching, isLoading, - } = useImagesQuery( - { - page: pagination.page, - page_size: pagination.pageSize, - }, + } = useAllImagesQuery( + {}, { ...combinedFilter, is_public: false, @@ -115,6 +115,14 @@ export const ImageSelectTable = (props: Props) => { } ); + const pagination = usePaginationV2({ + clientSidePaginationData: imagesData, + currentRoute, + defaultPageSize: DEFAULT_CLIENT_SIDE_PAGE_SIZE, + initialPage: 1, + preferenceKey: IMAGE_SELECT_TABLE_PREFERENCE_KEY, + }); + const tagOptions = tags?.map((tag) => ({ label: tag.label, value: tag.label })) ?? []; @@ -127,11 +135,25 @@ export const ImageSelectTable = (props: Props) => { const selectedRegionOption = regionOptions.find((r) => r.value === selectedRegion) ?? null; + const handlePageChange = (event: CustomEvent<{ page: number }>) => { + pagination.handlePageChange(Number(event.detail)); + }; + + const handlePageSizeChange = (event: CustomEvent<{ pageSize: number }>) => { + const newSize = event.detail.pageSize; + pagination.handlePageSizeChange(newSize); + }; + return ( {errorText && } - - + + { value={query} /> - + { value={selectedTagOption} /> - + { - - Image - Replicated in - + + + Image + + Replicated in + Share Group { @@ -198,28 +235,43 @@ export const ImageSelectTable = (props: Props) => { } - - Size - Created - Image ID + + + Size + + + Created + + + Image ID + {isLoading && ( - + )} {imagesError && ( )} - {!isLoading && !imagesError && data?.results === 0 && ( - + {!isLoading && !imagesError && imagesData?.length === 0 && ( + )} {!isLoading && !imagesError && - data?.data.map((image) => ( + pagination.paginatedData.map((image) => ( { ))}
-
diff --git a/packages/manager/src/components/ImageSelect/ImageSelectTableRow.tsx b/packages/manager/src/components/ImageSelect/ImageSelectTableRow.tsx index e32c771beaf..d88dc513a7c 100644 --- a/packages/manager/src/components/ImageSelect/ImageSelectTableRow.tsx +++ b/packages/manager/src/components/ImageSelect/ImageSelectTableRow.tsx @@ -1,15 +1,16 @@ import { FormControlLabel, ListItem, Radio } from '@linode/ui'; import { convertStorageUnit, pluralize } from '@linode/utilities'; +import { TableCell, TableRow } from 'akamai-cds-react-components/Table'; import React from 'react'; -import { TableCell } from 'src/components/TableCell'; -import { TableRow } from 'src/components/TableRow'; import { PlanTextTooltip, StyledFormattedRegionList, } from 'src/features/components/PlansPanel/PlansAvailabilityNotice.styles'; import { formatDate } from 'src/utilities/formatDate'; +import { TABLE_CELL_BASE_STYLE } from './constants'; + import type { Image, ImageRegion } from '@linode/api-v4'; interface Props { @@ -66,8 +67,8 @@ export const ImageSelectTableRow = (props: Props) => { ); return ( - - + + } @@ -76,7 +77,13 @@ export const ImageSelectTableRow = (props: Props) => { sx={{ gap: 2 }} /> - + 0 @@ -86,10 +93,18 @@ export const ImageSelectTableRow = (props: Props) => { tooltipText={} /> - {getShareGroupDisplay()} - {getSizeDisplay()} - {formatDate(created, { timezone })} - {id} + + {getShareGroupDisplay()} + + + {getSizeDisplay()} + + + {formatDate(created, { timezone })} + + + {id} + ); }; diff --git a/packages/manager/src/components/ImageSelect/constants.ts b/packages/manager/src/components/ImageSelect/constants.ts new file mode 100644 index 00000000000..ff8f7b072b2 --- /dev/null +++ b/packages/manager/src/components/ImageSelect/constants.ts @@ -0,0 +1,7 @@ +export const TABLE_CELL_BASE_STYLE = { + boxSizing: 'border-box' as const, +}; + +export const IMAGE_SELECT_TABLE_COLUMNS_COUNT = 6; +export const IMAGE_SELECT_TABLE_PREFERENCE_KEY = 'image-select-table'; +export const DEFAULT_CLIENT_SIDE_PAGE_SIZE = 10; From 72ec8e3a60713a9bb9959c77e982109b82a62c37 Mon Sep 17 00:00:00 2001 From: Dajahi Wiley Date: Mon, 2 Mar 2026 16:33:29 -0500 Subject: [PATCH 4/9] Tablet/mobile UI support --- .../ImageSelect/ImageSelectTable.tsx | 54 +++++------ .../ImageSelect/ImageSelectTableRow.tsx | 92 ++++++++++++++----- 2 files changed, 96 insertions(+), 50 deletions(-) diff --git a/packages/manager/src/components/ImageSelect/ImageSelectTable.tsx b/packages/manager/src/components/ImageSelect/ImageSelectTable.tsx index e8bcf62d137..f335d2920e7 100644 --- a/packages/manager/src/components/ImageSelect/ImageSelectTable.tsx +++ b/packages/manager/src/components/ImageSelect/ImageSelectTable.tsx @@ -8,7 +8,7 @@ import { getAPIFilterFromQuery } from '@linode/search'; import { Autocomplete, Box, - IconButton, + Hidden, Notice, Stack, TooltipIcon, @@ -216,31 +216,33 @@ export const ImageSelectTable = (props: Props) => { > Image - Replicated in - - - Share Group - { - - - - } - - - - Size - + + Replicated in + + + + + Share Group + + + + + + + Size + + diff --git a/packages/manager/src/components/ImageSelect/ImageSelectTableRow.tsx b/packages/manager/src/components/ImageSelect/ImageSelectTableRow.tsx index d88dc513a7c..163700e0903 100644 --- a/packages/manager/src/components/ImageSelect/ImageSelectTableRow.tsx +++ b/packages/manager/src/components/ImageSelect/ImageSelectTableRow.tsx @@ -1,8 +1,16 @@ -import { FormControlLabel, ListItem, Radio } from '@linode/ui'; +import { + FormControlLabel, + Hidden, + ListItem, + Radio, + TooltipIcon, +} from '@linode/ui'; import { convertStorageUnit, pluralize } from '@linode/utilities'; +import useMediaQuery from '@mui/material/useMediaQuery'; import { TableCell, TableRow } from 'akamai-cds-react-components/Table'; import React from 'react'; +import CloudInitIcon from 'src/assets/icons/cloud-init.svg'; import { PlanTextTooltip, StyledFormattedRegionList, @@ -12,6 +20,7 @@ import { formatDate } from 'src/utilities/formatDate'; import { TABLE_CELL_BASE_STYLE } from './constants'; import type { Image, ImageRegion } from '@linode/api-v4'; +import type { Theme } from '@linode/ui'; interface Props { image: Image; @@ -23,7 +32,21 @@ interface Props { export const ImageSelectTableRow = (props: Props) => { const { image, onSelect, selected, timezone } = props; - const { created, id, image_sharing, label, regions, size, status } = image; + const { + capabilities, + created, + id, + image_sharing, + label, + regions, + size, + status, + type, + } = image; + + const matchesLgDown = useMediaQuery((theme: Theme) => + theme.breakpoints.down('lg') + ); const getSizeDisplay = () => { if (status === 'available') { @@ -76,29 +99,50 @@ export const ImageSelectTableRow = (props: Props) => { onChange={onSelect} sx={{ gap: 2 }} /> + {type === 'manual' && capabilities.includes('cloud-init') && ( + } + sxTooltipIcon={{ + padding: 0, + }} + text="This image supports our Metadata service via cloud-init." + /> + )} - - 0 - ? pluralize('Region', 'Regions', regions.length) - : '—' - } - tooltipText={} - /> - - - {getShareGroupDisplay()} - - - {getSizeDisplay()} - + + + 0 + ? pluralize('Region', 'Regions', regions.length) + : '—' + } + tooltipText={} + /> + + + + + {getShareGroupDisplay()} + + + + + {getSizeDisplay()} + + {formatDate(created, { timezone })} From 326513eb7ecbdc9a0f2360c1dcdc7c162bc51e7d Mon Sep 17 00:00:00 2001 From: Dajahi Wiley Date: Tue, 3 Mar 2026 15:33:42 -0500 Subject: [PATCH 5/9] Properly formatted popover for replication regions --- .../ImageSelect/ImageSelectTable.tsx | 1 + .../ImageSelect/ImageSelectTableRow.tsx | 23 +++++++++++++------ 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/packages/manager/src/components/ImageSelect/ImageSelectTable.tsx b/packages/manager/src/components/ImageSelect/ImageSelectTable.tsx index f335d2920e7..95ea277f01f 100644 --- a/packages/manager/src/components/ImageSelect/ImageSelectTable.tsx +++ b/packages/manager/src/components/ImageSelect/ImageSelectTable.tsx @@ -278,6 +278,7 @@ export const ImageSelectTable = (props: Props) => { image={image} key={image.id} onSelect={() => onSelect(image)} + regions={regions ?? []} selected={image.id === selectedImageId} timezone={profile?.timezone} /> diff --git a/packages/manager/src/components/ImageSelect/ImageSelectTableRow.tsx b/packages/manager/src/components/ImageSelect/ImageSelectTableRow.tsx index 163700e0903..b5ac36915bc 100644 --- a/packages/manager/src/components/ImageSelect/ImageSelectTableRow.tsx +++ b/packages/manager/src/components/ImageSelect/ImageSelectTableRow.tsx @@ -19,18 +19,19 @@ import { formatDate } from 'src/utilities/formatDate'; import { TABLE_CELL_BASE_STYLE } from './constants'; -import type { Image, ImageRegion } from '@linode/api-v4'; +import type { Image, ImageRegion, Region } from '@linode/api-v4'; import type { Theme } from '@linode/ui'; interface Props { image: Image; onSelect: () => void; + regions: Region[]; selected: boolean; timezone?: string; } export const ImageSelectTableRow = (props: Props) => { - const { image, onSelect, selected, timezone } = props; + const { image, onSelect, regions, selected, timezone } = props; const { capabilities, @@ -38,7 +39,7 @@ export const ImageSelectTableRow = (props: Props) => { id, image_sharing, label, - regions, + regions: imageRegions, size, status, type, @@ -77,12 +78,20 @@ 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 = () => ( - {regions.map((region: ImageRegion, idx) => { + {imageRegions.map((region: ImageRegion, idx) => { return ( - {region.region} + {getRegionListItem(region)} ); })} @@ -119,8 +128,8 @@ export const ImageSelectTableRow = (props: Props) => { > 0 - ? pluralize('Region', 'Regions', regions.length) + imageRegions.length > 0 + ? pluralize('Region', 'Regions', imageRegions.length) : '—' } tooltipText={} From d7a0d838c3b0052baac581a37b6c8806ee3c0d69 Mon Sep 17 00:00:00 2001 From: Dajahi Wiley Date: Tue, 3 Mar 2026 17:27:15 -0500 Subject: [PATCH 6/9] Pendo tags, unit tests --- .../ImageSelect/ImageSelectTable.tsx | 13 +++- .../ImageSelect/ImageSelectTableRow.tsx | 21 +++--- .../src/components/ImageSelect/constants.ts | 9 +++ .../Linodes/LinodeCreate/Tabs/Images.test.tsx | 67 ++++++++++++++++++- .../Linodes/LinodeCreate/Tabs/Images.tsx | 2 + 5 files changed, 98 insertions(+), 14 deletions(-) diff --git a/packages/manager/src/components/ImageSelect/ImageSelectTable.tsx b/packages/manager/src/components/ImageSelect/ImageSelectTable.tsx index 95ea277f01f..29a71799bab 100644 --- a/packages/manager/src/components/ImageSelect/ImageSelectTable.tsx +++ b/packages/manager/src/components/ImageSelect/ImageSelectTable.tsx @@ -35,6 +35,7 @@ import { usePaginationV2 } from 'src/hooks/usePaginationV2'; import { DEFAULT_CLIENT_SIDE_PAGE_SIZE, IMAGE_SELECT_TABLE_COLUMNS_COUNT, + IMAGE_SELECT_TABLE_PENDO_IDS, IMAGE_SELECT_TABLE_PREFERENCE_KEY, TABLE_CELL_BASE_STYLE, } from './constants'; @@ -57,6 +58,10 @@ interface Props { * Callback fired when the user selects an image row. */ onSelect: (image: Image) => void; + /** + * An object containing Pendo IDs for elements in this component. + */ + pendoIDs: typeof IMAGE_SELECT_TABLE_PENDO_IDS; /** * The ID of the currently selected image. */ @@ -66,7 +71,8 @@ interface Props { type OptionType = { label: string; value: string }; export const ImageSelectTable = (props: Props) => { - const { currentRoute, errorText, onSelect, selectedImageId } = props; + const { currentRoute, errorText, onSelect, pendoIDs, selectedImageId } = + props; const theme = useTheme(); const [query, setQuery] = useState(''); @@ -156,6 +162,7 @@ export const ImageSelectTable = (props: Props) => { { { @@ -185,6 +193,7 @@ export const ImageSelectTable = (props: Props) => { { @@ -226,6 +235,7 @@ export const ImageSelectTable = (props: Props) => { Share Group { image={image} key={image.id} onSelect={() => onSelect(image)} + pendoIDs={pendoIDs} regions={regions ?? []} selected={image.id === selectedImageId} timezone={profile?.timezone} diff --git a/packages/manager/src/components/ImageSelect/ImageSelectTableRow.tsx b/packages/manager/src/components/ImageSelect/ImageSelectTableRow.tsx index b5ac36915bc..c77f5e7b140 100644 --- a/packages/manager/src/components/ImageSelect/ImageSelectTableRow.tsx +++ b/packages/manager/src/components/ImageSelect/ImageSelectTableRow.tsx @@ -17,7 +17,10 @@ import { } from 'src/features/components/PlansPanel/PlansAvailabilityNotice.styles'; import { formatDate } from 'src/utilities/formatDate'; -import { TABLE_CELL_BASE_STYLE } from './constants'; +import { + IMAGE_SELECT_TABLE_PENDO_IDS, + TABLE_CELL_BASE_STYLE, +} from './constants'; import type { Image, ImageRegion, Region } from '@linode/api-v4'; import type { Theme } from '@linode/ui'; @@ -25,13 +28,14 @@ import type { Theme } from '@linode/ui'; interface Props { image: Image; onSelect: () => void; + pendoIDs: typeof IMAGE_SELECT_TABLE_PENDO_IDS; regions: Region[]; selected: boolean; timezone?: string; } export const ImageSelectTableRow = (props: Props) => { - const { image, onSelect, regions, selected, timezone } = props; + const { image, onSelect, pendoIDs, regions, selected, timezone } = props; const { capabilities, @@ -65,16 +69,7 @@ export const ImageSelectTableRow = (props: Props) => { if (image_sharing?.shared_by?.sharegroup_label) { return image_sharing.shared_by.sharegroup_label; } - if ( - image_sharing?.shared_with?.sharegroup_count !== null && - image_sharing?.shared_with?.sharegroup_count !== undefined - ) { - return pluralize( - 'Share Group', - 'Share Groups', - image_sharing.shared_with.sharegroup_count - ); - } + return '—'; }; @@ -110,6 +105,7 @@ export const ImageSelectTableRow = (props: Props) => { /> {type === 'manual' && capabilities.includes('cloud-init') && ( } sxTooltipIcon={{ padding: 0, @@ -127,6 +123,7 @@ export const ImageSelectTableRow = (props: Props) => { }} > 0 ? pluralize('Region', 'Regions', imageRegions.length) diff --git a/packages/manager/src/components/ImageSelect/constants.ts b/packages/manager/src/components/ImageSelect/constants.ts index ff8f7b072b2..eac94f48139 100644 --- a/packages/manager/src/components/ImageSelect/constants.ts +++ b/packages/manager/src/components/ImageSelect/constants.ts @@ -5,3 +5,12 @@ export const TABLE_CELL_BASE_STYLE = { export const IMAGE_SELECT_TABLE_COLUMNS_COUNT = 6; export const IMAGE_SELECT_TABLE_PREFERENCE_KEY = 'image-select-table'; export const DEFAULT_CLIENT_SIDE_PAGE_SIZE = 10; + +export const IMAGE_SELECT_TABLE_PENDO_IDS = { + searchImagesBar: 'Linodes Create Images-Search click', + tagFilterSelect: 'Linodes Create Images-Filter by Tag click', + regionFilterSelect: 'Linodes Create Images-Filter by Region click', + metadataSupportedIcon: 'Linodes Create Images-Metadata Supported icon', + replicatedRegionPopover: 'Linodes Create Images-Replicated in', + shareGroupInfoIcon: 'Linodes Create Images-Share Group info icon', +}; diff --git a/packages/manager/src/features/Linodes/LinodeCreate/Tabs/Images.test.tsx b/packages/manager/src/features/Linodes/LinodeCreate/Tabs/Images.test.tsx index e3d3f068610..569a9f1b4db 100644 --- a/packages/manager/src/features/Linodes/LinodeCreate/Tabs/Images.test.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreate/Tabs/Images.test.tsx @@ -1,10 +1,16 @@ import React from 'react'; -import { renderWithThemeAndHookFormContext } from 'src/utilities/testHelpers'; +import { + renderWithThemeAndHookFormContext, + resizeScreenSize, +} from 'src/utilities/testHelpers'; import { Images } from './Images'; const queryMocks = vi.hoisted(() => ({ + useIsPrivateImageSharingEnabled: vi.fn(() => ({ + isPrivateImageSharingEnabled: false, + })), useNavigate: vi.fn(), useParams: vi.fn(), useSearch: vi.fn(), @@ -29,11 +35,22 @@ vi.mock('src/features/IAM/hooks/usePermissions', () => ({ usePermissions: queryMocks.userPermissions, })); +vi.mock('src/features/Images/utils', async () => { + const actual = await vi.importActual('src/features/Images/utils'); + return { + ...actual, + useIsPrivateImageSharingEnabled: queryMocks.useIsPrivateImageSharingEnabled, + }; +}); + describe('Images', () => { beforeEach(() => { queryMocks.useNavigate.mockReturnValue(vi.fn()); queryMocks.useSearch.mockReturnValue({}); queryMocks.useParams.mockReturnValue({}); + queryMocks.useIsPrivateImageSharingEnabled.mockReturnValue({ + isPrivateImageSharingEnabled: false, + }); }); it('renders a header', () => { @@ -73,4 +90,52 @@ describe('Images', () => { expect(getByPlaceholderText('Choose an image')).toBeVisible(); expect(getByPlaceholderText('Choose an image')).toBeEnabled(); }); + + describe('when isPrivateImageSharingEnabled is true', () => { + beforeEach(() => { + queryMocks.useIsPrivateImageSharingEnabled.mockReturnValue({ + isPrivateImageSharingEnabled: true, + }); + // Mock matchMedia at a width wider than MUI's `lg` breakpoint (1200px) + // so that columns wrapped in are not hidden. + resizeScreenSize(1280); + }); + + it('renders the search images field', () => { + const { getByPlaceholderText } = renderWithThemeAndHookFormContext({ + component: , + }); + + expect(getByPlaceholderText('Search images')).toBeVisible(); + }); + + it('renders the filter by tag field', () => { + const { getByPlaceholderText } = renderWithThemeAndHookFormContext({ + component: , + }); + + expect(getByPlaceholderText('Filter by tag')).toBeVisible(); + }); + + it('renders the filter by region field', () => { + const { getByPlaceholderText } = renderWithThemeAndHookFormContext({ + component: , + }); + + expect(getByPlaceholderText('Filter by region')).toBeVisible(); + }); + + it('renders the table column headers', () => { + const { getByText } = renderWithThemeAndHookFormContext({ + component: , + }); + + expect(getByText('Image')).toBeVisible(); + expect(getByText('Replicated in')).toBeVisible(); + expect(getByText('Share Group')).toBeVisible(); + expect(getByText('Size')).toBeVisible(); + expect(getByText('Created')).toBeVisible(); + expect(getByText('Image ID')).toBeVisible(); + }); + }); }); diff --git a/packages/manager/src/features/Linodes/LinodeCreate/Tabs/Images.tsx b/packages/manager/src/features/Linodes/LinodeCreate/Tabs/Images.tsx index cdcea8cf586..9c2836f0547 100644 --- a/packages/manager/src/features/Linodes/LinodeCreate/Tabs/Images.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreate/Tabs/Images.tsx @@ -5,6 +5,7 @@ import React from 'react'; import { useController, useFormContext, useWatch } from 'react-hook-form'; import ComputeIcon from 'src/assets/icons/entityIcons/compute.svg'; +import { IMAGE_SELECT_TABLE_PENDO_IDS } from 'src/components/ImageSelect/constants'; import { ImageSelect } from 'src/components/ImageSelect/ImageSelect'; import { ImageSelectTable } from 'src/components/ImageSelect/ImageSelectTable'; import { getAPIFilterForImageSelect } from 'src/components/ImageSelect/utilities'; @@ -99,6 +100,7 @@ export const Images = () => { currentRoute={'/linodes/create/images'} errorText={fieldState.error?.message} onSelect={onChange} + pendoIDs={IMAGE_SELECT_TABLE_PENDO_IDS} selectedImageId={field.value} /> ) : ( From 1e639f8bd928ca40b01eae815b19a7a36239340a Mon Sep 17 00:00:00 2001 From: Dajahi Wiley Date: Tue, 3 Mar 2026 18:16:23 -0500 Subject: [PATCH 7/9] Table cleanup, undo ImagesLanding.tsx change --- .../ImageSelect/ImageSelectTable.tsx | 32 +++++++++---------- .../src/components/ImageSelect/constants.ts | 1 - .../Images/ImagesLanding/ImagesLanding.tsx | 1 + 3 files changed, 17 insertions(+), 17 deletions(-) diff --git a/packages/manager/src/components/ImageSelect/ImageSelectTable.tsx b/packages/manager/src/components/ImageSelect/ImageSelectTable.tsx index 29a71799bab..2c071b67e4f 100644 --- a/packages/manager/src/components/ImageSelect/ImageSelectTable.tsx +++ b/packages/manager/src/components/ImageSelect/ImageSelectTable.tsx @@ -8,6 +8,8 @@ import { getAPIFilterFromQuery } from '@linode/search'; import { Autocomplete, Box, + CircleProgress, + ErrorState, Hidden, Notice, Stack, @@ -19,6 +21,7 @@ import { Pagination } from 'akamai-cds-react-components/Pagination'; import { Table, TableBody, + TableCell, TableHead, TableHeaderCell, TableRow, @@ -26,21 +29,17 @@ import { import React, { useState } from 'react'; import { DebouncedSearchTextField } from 'src/components/DebouncedSearchTextField'; -import { TableRowEmpty } from 'src/components/TableRowEmpty/TableRowEmpty'; -import { TableRowError } from 'src/components/TableRowError/TableRowError'; -import { TableRowLoading } from 'src/components/TableRowLoading/TableRowLoading'; import { SHARE_GROUP_COLUMN_HEADER_TOOLTIP } from 'src/features/Images/constants'; import { usePaginationV2 } from 'src/hooks/usePaginationV2'; import { DEFAULT_CLIENT_SIDE_PAGE_SIZE, - IMAGE_SELECT_TABLE_COLUMNS_COUNT, - IMAGE_SELECT_TABLE_PENDO_IDS, IMAGE_SELECT_TABLE_PREFERENCE_KEY, TABLE_CELL_BASE_STYLE, } from './constants'; import { ImageSelectTableRow } from './ImageSelectTableRow'; +import type { IMAGE_SELECT_TABLE_PENDO_IDS } from './constants'; import type { Filter, Image } from '@linode/api-v4'; import type { LinkProps } from '@tanstack/react-router'; @@ -266,20 +265,21 @@ export const ImageSelectTable = (props: Props) => { - {isLoading && ( - - )} + {isLoading && } {imagesError && ( - + )} {!isLoading && !imagesError && imagesData?.length === 0 && ( - + + + No items to display. + + )} {!isLoading && !imagesError && diff --git a/packages/manager/src/components/ImageSelect/constants.ts b/packages/manager/src/components/ImageSelect/constants.ts index eac94f48139..473fac3ef6b 100644 --- a/packages/manager/src/components/ImageSelect/constants.ts +++ b/packages/manager/src/components/ImageSelect/constants.ts @@ -2,7 +2,6 @@ export const TABLE_CELL_BASE_STYLE = { boxSizing: 'border-box' as const, }; -export const IMAGE_SELECT_TABLE_COLUMNS_COUNT = 6; export const IMAGE_SELECT_TABLE_PREFERENCE_KEY = 'image-select-table'; export const DEFAULT_CLIENT_SIDE_PAGE_SIZE = 10; diff --git a/packages/manager/src/features/Images/ImagesLanding/ImagesLanding.tsx b/packages/manager/src/features/Images/ImagesLanding/ImagesLanding.tsx index 5bbf48f3f81..d597cc237f4 100644 --- a/packages/manager/src/features/Images/ImagesLanding/ImagesLanding.tsx +++ b/packages/manager/src/features/Images/ImagesLanding/ImagesLanding.tsx @@ -156,6 +156,7 @@ export const ImagesLanding = () => { { ...manualImagesFilter, is_public: false, + type: 'manual', }, { // Refetch custom images every 30 seconds. From a0132df506b10b53864a047d07d85c88688e7a14 Mon Sep 17 00:00:00 2001 From: Dajahi Wiley Date: Tue, 3 Mar 2026 18:34:49 -0500 Subject: [PATCH 8/9] Added changeset: Add new Image Select table and use it in Linode Create > Images tab --- .../.changeset/pr-13435-upcoming-features-1772580889092.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 packages/manager/.changeset/pr-13435-upcoming-features-1772580889092.md diff --git a/packages/manager/.changeset/pr-13435-upcoming-features-1772580889092.md b/packages/manager/.changeset/pr-13435-upcoming-features-1772580889092.md new file mode 100644 index 00000000000..105a8c6351a --- /dev/null +++ b/packages/manager/.changeset/pr-13435-upcoming-features-1772580889092.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Add new Image Select table and use it in Linode Create > Images tab ([#13435](https://github.com/linode/manager/pull/13435)) From ffb740326545a0b755c603020ec952f2692e48fb Mon Sep 17 00:00:00 2001 From: Dajahi Wiley Date: Wed, 4 Mar 2026 16:59:29 -0500 Subject: [PATCH 9/9] Feedback: labels for accessibility but use prop to hide --- .../src/components/ImageSelect/ImageSelectTable.tsx | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/packages/manager/src/components/ImageSelect/ImageSelectTable.tsx b/packages/manager/src/components/ImageSelect/ImageSelectTable.tsx index 2c071b67e4f..0e27105f5c1 100644 --- a/packages/manager/src/components/ImageSelect/ImageSelectTable.tsx +++ b/packages/manager/src/components/ImageSelect/ImageSelectTable.tsx @@ -178,7 +178,7 @@ export const ImageSelectTable = (props: Props) => { { setSelectedTag((value as null | OptionType)?.value ?? null); @@ -186,14 +186,16 @@ export const ImageSelectTable = (props: Props) => { }} options={tagOptions} placeholder="Filter by tag" - sx={{ paddingBottom: '9px' }} // to align with search field + textFieldProps={{ + hideLabel: true, + }} value={selectedTagOption} /> { setSelectedRegion((value as null | OptionType)?.value ?? null); @@ -201,7 +203,9 @@ export const ImageSelectTable = (props: Props) => { }} options={regionOptions} placeholder="Filter by region" - sx={{ paddingBottom: '9px' }} // to align with search field + textFieldProps={{ + hideLabel: true, + }} value={selectedRegionOption} />