From 4ac62dbf0a2c0b4365e69ba1f4ce9da30e878efb Mon Sep 17 00:00:00 2001 From: Alban Bailly <130582365+abailly-akamai@users.noreply.github.com> Date: Mon, 17 Jun 2024 13:38:35 -0400 Subject: [PATCH] feat: [M3-8229] - Volume & Images search and filtering (#10570) * Initial commit: search implementation * Cleaner logic * improve code and expand to images * e2e coverage * cleanup * type fixes post rebase * feedback @bnussman-akamai * Added changeset: Volume & Images landing pages search and filtering * feedback @bnussman-akamai * more changes from feedback * cleanup * fix empty state * moar cleanup * moar cleanup * code readability --- .../pr-10570-added-1718308378096.md | 5 + .../e2e/core/images/search-images.spec.ts | 79 ++++++++++++++ .../e2e/core/volumes/search-volumes.spec.ts | 62 +++++++++++ .../cypress/support/intercepts/linodes.ts | 17 ++- .../src/features/Images/ImagesLanding.tsx | 101 +++++++++++++++--- .../src/features/Volumes/VolumesLanding.tsx | 64 ++++++++++- .../src/store/selectors/getSearchEntities.ts | 31 +++--- 7 files changed, 321 insertions(+), 38 deletions(-) create mode 100644 packages/manager/.changeset/pr-10570-added-1718308378096.md create mode 100644 packages/manager/cypress/e2e/core/images/search-images.spec.ts create mode 100644 packages/manager/cypress/e2e/core/volumes/search-volumes.spec.ts diff --git a/packages/manager/.changeset/pr-10570-added-1718308378096.md b/packages/manager/.changeset/pr-10570-added-1718308378096.md new file mode 100644 index 00000000000..d8077d9974b --- /dev/null +++ b/packages/manager/.changeset/pr-10570-added-1718308378096.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Added +--- + +Volume & Images landing pages search and filtering ([#10570](https://github.com/linode/manager/pull/10570)) diff --git a/packages/manager/cypress/e2e/core/images/search-images.spec.ts b/packages/manager/cypress/e2e/core/images/search-images.spec.ts new file mode 100644 index 00000000000..9620a2312e7 --- /dev/null +++ b/packages/manager/cypress/e2e/core/images/search-images.spec.ts @@ -0,0 +1,79 @@ +import { createImage } from '@linode/api-v4/lib/images'; +import { createTestLinode } from 'support/util/linodes'; +import { ui } from 'support/ui'; + +import { authenticate } from 'support/api/authentication'; +import { randomLabel } from 'support/util/random'; +import { cleanUp } from 'support/util/cleanup'; +import type { Image, Linode } from '@linode/api-v4'; +import { interceptGetLinodeDisks } from 'support/intercepts/linodes'; + +authenticate(); +describe('Search Images', () => { + before(() => { + cleanUp(['linodes', 'images']); + }); + + /* + * - Confirm that images are API searchable and filtered in the UI. + */ + it('creates two images and make sure they show up in the table and are searchable', () => { + cy.defer( + () => + createTestLinode( + { image: 'linode/debian10', region: 'us-east' }, + { waitForDisks: true } + ), + 'create linode' + ).then((linode: Linode) => { + interceptGetLinodeDisks(linode.id).as('getLinodeDisks'); + + cy.visitWithLogin(`/linodes/${linode.id}/storage`); + cy.wait('@getLinodeDisks').then((xhr) => { + const disks = xhr.response?.body.data; + const disk_id = disks[0].id; + + const createTwoImages = async (): Promise<[Image, Image]> => { + return Promise.all([ + createImage({ + disk_id, + label: randomLabel(), + }), + createImage({ + disk_id, + label: randomLabel(), + }), + ]); + }; + + cy.defer(() => createTwoImages(), 'creating images').then( + ([image1, image2]) => { + cy.visitWithLogin('/images'); + + // Confirm that both images are listed on the landing page. + cy.contains(image1.label).should('be.visible'); + cy.contains(image2.label).should('be.visible'); + + // Search for the first image by label, confirm it's the only one shown. + cy.findByPlaceholderText('Search Images').type(image1.label); + expect(cy.contains(image1.label).should('be.visible')); + expect(cy.contains(image2.label).should('not.exist')); + + // Clear search, confirm both images are shown. + cy.findByTestId('clear-images-search').click(); + cy.contains(image1.label).should('be.visible'); + cy.contains(image2.label).should('be.visible'); + + // Use the main search bar to search and filter images + cy.get('[id="main-search"').type(image2.label); + ui.autocompletePopper.findByTitle(image2.label).click(); + + // Confirm that only the second image is shown. + cy.contains(image1.label).should('not.exist'); + cy.contains(image2.label).should('be.visible'); + } + ); + }); + }); + }); +}); diff --git a/packages/manager/cypress/e2e/core/volumes/search-volumes.spec.ts b/packages/manager/cypress/e2e/core/volumes/search-volumes.spec.ts new file mode 100644 index 00000000000..e6fc05b38b0 --- /dev/null +++ b/packages/manager/cypress/e2e/core/volumes/search-volumes.spec.ts @@ -0,0 +1,62 @@ +import { createVolume } from '@linode/api-v4/lib/volumes'; +import { Volume } from '@linode/api-v4'; +import { ui } from 'support/ui'; + +import { authenticate } from 'support/api/authentication'; +import { randomLabel } from 'support/util/random'; +import { cleanUp } from 'support/util/cleanup'; + +authenticate(); +describe('Search Volumes', () => { + before(() => { + cleanUp(['volumes']); + }); + + /* + * - Confirm that volumes are API searchable and filtered in the UI. + */ + it('creates two volumes and make sure they show up in the table and are searchable', () => { + const createTwoVolumes = async (): Promise<[Volume, Volume]> => { + return Promise.all([ + createVolume({ + label: randomLabel(), + region: 'us-east', + size: 10, + }), + createVolume({ + label: randomLabel(), + region: 'us-east', + size: 10, + }), + ]); + }; + + cy.defer(() => createTwoVolumes(), 'creating volumes').then( + ([volume1, volume2]) => { + cy.visitWithLogin('/volumes'); + + // Confirm that both volumes are listed on the landing page. + cy.findByText(volume1.label).should('be.visible'); + cy.findByText(volume2.label).should('be.visible'); + + // Search for the first volume by label, confirm it's the only one shown. + cy.findByPlaceholderText('Search Volumes').type(volume1.label); + expect(cy.findByText(volume1.label).should('be.visible')); + expect(cy.findByText(volume2.label).should('not.exist')); + + // Clear search, confirm both volumes are shown. + cy.findByTestId('clear-volumes-search').click(); + cy.findByText(volume1.label).should('be.visible'); + cy.findByText(volume2.label).should('be.visible'); + + // Use the main search bar to search and filter volumes + cy.get('[id="main-search"').type(volume2.label); + ui.autocompletePopper.findByTitle(volume2.label).click(); + + // Confirm that only the second volume is shown. + cy.findByText(volume1.label).should('not.exist'); + cy.findByText(volume2.label).should('be.visible'); + } + ); + }); +}); diff --git a/packages/manager/cypress/support/intercepts/linodes.ts b/packages/manager/cypress/support/intercepts/linodes.ts index 932e1bc0bec..2a4a898068c 100644 --- a/packages/manager/cypress/support/intercepts/linodes.ts +++ b/packages/manager/cypress/support/intercepts/linodes.ts @@ -2,12 +2,12 @@ * @file Cypress intercepts and mocks for Cloud Manager Linode operations. */ +import { makeErrorResponse } from 'support/util/errors'; import { apiMatcher } from 'support/util/intercepts'; import { paginateResponse } from 'support/util/paginate'; import { makeResponse } from 'support/util/response'; -import type { Disk, Linode, LinodeType, Kernel, Volume } from '@linode/api-v4'; -import { makeErrorResponse } from 'support/util/errors'; +import type { Disk, Kernel, Linode, LinodeType, Volume } from '@linode/api-v4'; /** * Intercepts POST request to create a Linode. @@ -210,6 +210,19 @@ export const mockRebootLinodeIntoRescueModeError = ( ); }; +/** + * Intercepts GET request to retrieve a Linode's Disks + * + * @param linodeId - ID of Linode for intercepted request. + * + * @returns Cypress chainable. + */ +export const interceptGetLinodeDisks = ( + linodeId: number +): Cypress.Chainable => { + return cy.intercept('GET', apiMatcher(`linode/instances/${linodeId}/disks*`)); +}; + /** * Intercepts GET request to retrieve a Linode's Disks and mocks response. * diff --git a/packages/manager/src/features/Images/ImagesLanding.tsx b/packages/manager/src/features/Images/ImagesLanding.tsx index 2e27506b3bf..37a58e4152a 100644 --- a/packages/manager/src/features/Images/ImagesLanding.tsx +++ b/packages/manager/src/features/Images/ImagesLanding.tsx @@ -1,10 +1,9 @@ -import { Image, ImageStatus } from '@linode/api-v4'; -import { APIError } from '@linode/api-v4/lib/types'; -import { Theme } from '@mui/material/styles'; +import CloseIcon from '@mui/icons-material/Close'; import { useQueryClient } from '@tanstack/react-query'; import { useSnackbar } from 'notistack'; import * as React from 'react'; -import { useHistory } from 'react-router-dom'; +import { useHistory, useLocation } from 'react-router-dom'; +import { debounce } from 'throttle-debounce'; import { makeStyles } from 'tss-react/mui'; import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; @@ -13,6 +12,8 @@ import { ConfirmationDialog } from 'src/components/ConfirmationDialog/Confirmati import { DocumentTitleSegment } from 'src/components/DocumentTitle'; import { ErrorState } from 'src/components/ErrorState/ErrorState'; import { Hidden } from 'src/components/Hidden'; +import { IconButton } from 'src/components/IconButton'; +import { InputAdornment } from 'src/components/InputAdornment'; import { LandingHeader } from 'src/components/LandingHeader'; import { Notice } from 'src/components/Notice/Notice'; import { PaginationFooter } from 'src/components/PaginationFooter/PaginationFooter'; @@ -23,7 +24,9 @@ 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 { TableRowLoading } from 'src/components/TableRowLoading/TableRowLoading'; import { TableSortCell } from 'src/components/TableSortCell'; +import { TextField } from 'src/components/TextField'; import { Typography } from 'src/components/Typography'; import { useOrder } from 'src/hooks/useOrder'; import { usePagination } from 'src/hooks/usePagination'; @@ -41,11 +44,17 @@ import { getErrorStringOrDefault } from 'src/utilities/errorUtils'; import { EditImageDrawer } from './EditImageDrawer'; import ImageRow from './ImageRow'; -import { Handlers as ImageHandlers } from './ImagesActionMenu'; import { ImagesLandingEmptyState } from './ImagesLandingEmptyState'; import { RebuildImageDrawer } from './RebuildImageDrawer'; import { getEventsForImages } from './utils'; +import type { Handlers as ImageHandlers } from './ImagesActionMenu'; +import type { Image, ImageStatus } from '@linode/api-v4'; +import type { APIError } from '@linode/api-v4/lib/types'; +import type { Theme } from '@mui/material/styles'; + +const searchQueryKey = 'query'; + const useStyles = makeStyles()((theme: Theme) => ({ imageTable: { marginBottom: theme.spacing(3), @@ -81,6 +90,9 @@ export const ImagesLanding = () => { const { classes } = useStyles(); const history = useHistory(); const { enqueueSnackbar } = useSnackbar(); + const location = useLocation(); + const queryParams = new URLSearchParams(location.search); + const imageLabelFromParam = queryParams.get(searchQueryKey) ?? ''; const queryClient = useQueryClient(); @@ -104,9 +116,14 @@ export const ImagesLanding = () => { ['+order_by']: manualImagesOrderBy, }; + if (imageLabelFromParam) { + manualImagesFilter['label'] = { '+contains': imageLabelFromParam }; + } + const { data: manualImages, error: manualImagesError, + isFetching: manualImagesIsFetching, isLoading: manualImagesLoading, } = useImagesQuery( { @@ -144,9 +161,14 @@ export const ImagesLanding = () => { ['+order_by']: automaticImagesOrderBy, }; + if (imageLabelFromParam) { + automaticImagesFilter['label'] = { '+contains': imageLabelFromParam }; + } + const { data: automaticImages, error: automaticImagesError, + isFetching: automaticImagesIsFetching, isLoading: automaticImagesLoading, } = useImagesQuery( { @@ -310,6 +332,17 @@ export const ImagesLanding = () => { ); }; + const resetSearch = () => { + queryParams.delete(searchQueryKey); + history.push({ search: queryParams.toString() }); + }; + + const onSearch = (e: React.ChangeEvent) => { + queryParams.delete('page'); + queryParams.set(searchQueryKey, e.target.value); + history.push({ search: queryParams.toString() }); + }; + const handlers: ImageHandlers = { onCancelFailed: onCancelFailedClick, onDelete: openDialog, @@ -350,7 +383,11 @@ export const ImagesLanding = () => { } /** Empty States */ - if (!manualImages.data.length && !automaticImages.data.length) { + if ( + !manualImages.data.length && + !automaticImages.data.length && + !imageLabelFromParam + ) { return renderEmpty(); } @@ -362,6 +399,8 @@ export const ImagesLanding = () => { ); + const isFetching = manualImagesIsFetching || automaticImagesIsFetching; + return ( @@ -371,6 +410,32 @@ export const ImagesLanding = () => { onButtonClick={() => history.push('/images/create')} title="Images" /> + + {isFetching && } + + + + + + ), + }} + onChange={debounce(400, (e) => { + onSearch(e); + })} + hideLabel + label="Search" + placeholder="Search Images" + sx={{ mb: 2 }} + value={imageLabelFromParam} + />
Custom Images @@ -483,16 +548,20 @@ export const ImagesLanding = () => { - {automaticImages.data.length > 0 - ? automaticImages.data.map((automaticImage) => ( - - )) - : noAutomaticImages} + {isFetching ? ( + + ) : automaticImages.data.length > 0 ? ( + automaticImages.data.map((automaticImage) => ( + + )) + ) : ( + noAutomaticImages + )} { const history = useHistory(); - const location = useLocation<{ volume: Volume | undefined }>(); - const pagination = usePagination(1, preferenceKey); + const queryParams = new URLSearchParams(location.search); + const volumeLabelFromParam = queryParams.get(searchQueryKey) ?? ''; const { handleOrderChange, order, orderBy } = useOrder( { @@ -52,14 +59,17 @@ export const VolumesLanding = () => { ['+order_by']: orderBy, }; - const { data: volumes, error, isLoading } = useVolumesQuery( + if (volumeLabelFromParam) { + filter['label'] = { '+contains': volumeLabelFromParam }; + } + + const { data: volumes, error, isFetching, isLoading } = useVolumesQuery( { page: pagination.page, page_size: pagination.pageSize, }, filter ); - const [selectedVolumeId, setSelectedVolumeId] = React.useState(); const [isDetailsDrawerOpen, setIsDetailsDrawerOpen] = React.useState( Boolean(location.state?.volume) @@ -114,6 +124,17 @@ export const VolumesLanding = () => { setIsUpgradeDialogOpen(true); }; + const resetSearch = () => { + queryParams.delete(searchQueryKey); + history.push({ search: queryParams.toString() }); + }; + + const onSearch = (e: React.ChangeEvent) => { + queryParams.delete('page'); + queryParams.set(searchQueryKey, e.target.value); + history.push({ search: queryParams.toString() }); + }; + if (isLoading) { return ; } @@ -128,7 +149,7 @@ export const VolumesLanding = () => { ); } - if (volumes?.results === 0) { + if (volumes?.results === 0 && !volumeLabelFromParam) { return ; } @@ -136,11 +157,41 @@ export const VolumesLanding = () => { <> history.push('/volumes/create')} title="Volumes" /> + + {isFetching && } + + + + + + ), + }} + onChange={debounce(400, (e) => { + onSearch(e); + })} + hideLabel + label="Search" + placeholder="Search Volumes" + sx={{ mb: 2 }} + value={volumeLabelFromParam} + /> @@ -174,6 +225,9 @@ export const VolumesLanding = () => { + {volumes?.data.length === 0 && ( + + )} {volumes?.data.map((volume) => ( { const { ipv4, ipv6 } = linode; return ipv4.concat([ipv6 || '']); @@ -65,7 +67,7 @@ export const volumeToSearchableItem = (volume: Volume): SearchableItem => ({ created: volume.created, description: volume.size + ' GB', icon: 'volume', - path: `/volumes/${volume.id}`, + path: `/volumes?query=${volume.label}`, region: volume.region, tags: volume.tags, }, @@ -83,10 +85,9 @@ export const imageToSearchableItem = (image: Image): SearchableItem => ({ data: { created: image.created, description: image.description || '', - /* TODO: Update this with the Images icon! */ - icon: 'volume', + icon: 'image', /* TODO: Choose a real location for this to link to */ - path: `/images`, + path: `/images?query=${image.label}`, tags: [], }, entityType: 'image',