Skip to content
Draft
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
265 changes: 265 additions & 0 deletions packages/manager/src/components/ImageSelect/ImageSelectTable.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,265 @@
import {
useAllTagsQuery,
useImagesQuery,
useProfile,
useRegionsQuery,
} from '@linode/queries';
import { getAPIFilterFromQuery } from '@linode/search';
import {
Autocomplete,
Box,
IconButton,
Notice,
Stack,
TooltipIcon,
} 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 { SHARE_GROUP_COLUMN_HEADER_TOOLTIP } from 'src/features/Images/constants';
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 | string>(null);
const [selectedRegion, setSelectedRegion] = useState<null | string>(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 (
<Stack pt={1} spacing={2}>
{errorText && <Notice text={errorText} variant="error" />}
<Stack alignItems="center" direction="row" flexWrap="wrap" gap={2}>
<Box sx={{ flex: 1, minWidth: 200 }}>
<DebouncedSearchTextField
clearable
debounceTime={250}
errorText={filterError?.message}
hideLabel
isSearching={isFetching}
label="Search"
onSearch={(q) => {
setQuery(q);
pagination.handlePageChange(1);
}}
placeholder="Search images"
value={query}
/>
</Box>
<Box sx={{ flex: 1, minWidth: 150 }}>
<Autocomplete
label=""
noMarginTop
onChange={(_, value) => {
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}
/>
</Box>
<Box sx={{ flex: 1, minWidth: 150 }}>
<Autocomplete
label=""
noMarginTop
onChange={(_, value) => {
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}
/>
</Box>
</Stack>
<Box>
<Table>
<TableHead>
<TableRow>
<TableCell sx={{ paddingLeft: '58px' }}>Image</TableCell>
<TableCell>Replicated in</TableCell>
<TableCell>
<Stack alignItems="center" direction="row">
Share Group
{
<IconButton aria-label="Share group" size="small">
<TooltipIcon
status="info"
sxTooltipIcon={{
padding: '4px',
}}
text={SHARE_GROUP_COLUMN_HEADER_TOOLTIP}
tooltipPosition="right"
/>
</IconButton>
}
</Stack>
</TableCell>
<TableCell>Size</TableCell>
<TableCell>Created</TableCell>
<TableCell>Image ID</TableCell>
</TableRow>
</TableHead>
<TableBody>
{isLoading && (
<TableRowLoading columns={COLUMNS} rows={pagination.pageSize} />
)}
{imagesError && (
<TableRowError
colSpan={COLUMNS}
message={imagesError[0].reason}
/>
)}
{!isLoading && !imagesError && data?.results === 0 && (
<TableRowEmpty colSpan={COLUMNS} />
)}
{!isLoading &&
!imagesError &&
data?.data.map((image) => (
<ImageSelectTableRow
image={image}
key={image.id}
onSelect={() => onSelect(image)}
selected={image.id === selectedImageId}
timezone={profile?.timezone}
/>
))}
</TableBody>
</Table>
<PaginationFooter
count={data?.results ?? 0}
handlePageChange={pagination.handlePageChange}
handleSizeChange={pagination.handlePageSizeChange}
page={pagination.page}
pageSize={pagination.pageSize}
/>
</Box>
</Stack>
);
};

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 } } : {}),
};
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
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, ImageRegion } 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 'β€”';
};

const FormattedRegionList = () => (
<StyledFormattedRegionList>
{regions.map((region: ImageRegion, idx) => {
return (
<ListItem disablePadding key={`${region.region}-${idx}`}>
{region.region}
</ListItem>
);
})}
</StyledFormattedRegionList>
);

return (
<TableRow key={id}>
<TableCell noWrap>
<FormControlLabel
checked={selected}
control={<Radio />}
label={label}
onChange={onSelect}
sx={{ gap: 2 }}
/>
</TableCell>
<TableCell noWrap>
<PlanTextTooltip
displayText={
regions.length > 0
? pluralize('Region', 'Regions', regions.length)
: 'β€”'
}
tooltipText={<FormattedRegionList />}
/>
</TableCell>
<TableCell noWrap>{getShareGroupDisplay()}</TableCell>
<TableCell noWrap>{getSizeDisplay()}</TableCell>
<TableCell noWrap>{formatDate(created, { timezone })}</TableCell>
<TableCell noWrap>{id}</TableCell>
</TableRow>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,6 @@ export const ImagesLanding = () => {
{
...manualImagesFilter,
is_public: false,
type: 'manual',
},
{
// Refetch custom images every 30 seconds.
Expand Down
3 changes: 3 additions & 0 deletions packages/manager/src/features/Images/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.";
Loading
Loading