From 69644343de75cdece65365ecabe0f865290426f9 Mon Sep 17 00:00:00 2001 From: Dajahi Wiley Date: Tue, 24 Feb 2026 18:06:50 -0500 Subject: [PATCH 1/2] 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/2] 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.";