Skip to content

Commit

Permalink
feat: [M3-8229] - Volume & Images search and filtering (#10570)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
abailly-akamai authored Jun 17, 2024
1 parent b078966 commit 4ac62db
Show file tree
Hide file tree
Showing 7 changed files with 321 additions and 38 deletions.
5 changes: 5 additions & 0 deletions packages/manager/.changeset/pr-10570-added-1718308378096.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@linode/manager": Added
---

Volume & Images landing pages search and filtering ([#10570](https://github.com/linode/manager/pull/10570))
79 changes: 79 additions & 0 deletions packages/manager/cypress/e2e/core/images/search-images.spec.ts
Original file line number Diff line number Diff line change
@@ -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');
}
);
});
});
});
});
62 changes: 62 additions & 0 deletions packages/manager/cypress/e2e/core/volumes/search-volumes.spec.ts
Original file line number Diff line number Diff line change
@@ -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');
}
);
});
});
17 changes: 15 additions & 2 deletions packages/manager/cypress/support/intercepts/linodes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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<null> => {
return cy.intercept('GET', apiMatcher(`linode/instances/${linodeId}/disks*`));
};

/**
* Intercepts GET request to retrieve a Linode's Disks and mocks response.
*
Expand Down
101 changes: 85 additions & 16 deletions packages/manager/src/features/Images/ImagesLanding.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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';
Expand All @@ -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';
Expand All @@ -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),
Expand Down Expand Up @@ -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();

Expand All @@ -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(
{
Expand Down Expand Up @@ -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(
{
Expand Down Expand Up @@ -310,6 +332,17 @@ export const ImagesLanding = () => {
);
};

const resetSearch = () => {
queryParams.delete(searchQueryKey);
history.push({ search: queryParams.toString() });
};

const onSearch = (e: React.ChangeEvent<HTMLInputElement>) => {
queryParams.delete('page');
queryParams.set(searchQueryKey, e.target.value);
history.push({ search: queryParams.toString() });
};

const handlers: ImageHandlers = {
onCancelFailed: onCancelFailedClick,
onDelete: openDialog,
Expand Down Expand Up @@ -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();
}

Expand All @@ -362,6 +399,8 @@ export const ImagesLanding = () => {
<TableRowEmpty colSpan={6} message={`No Recovery Images to display.`} />
);

const isFetching = manualImagesIsFetching || automaticImagesIsFetching;

return (
<React.Fragment>
<DocumentTitleSegment segment="Images" />
Expand All @@ -371,6 +410,32 @@ export const ImagesLanding = () => {
onButtonClick={() => history.push('/images/create')}
title="Images"
/>
<TextField
InputProps={{
endAdornment: imageLabelFromParam && (
<InputAdornment position="end">
{isFetching && <CircleProgress size="sm" />}

<IconButton
aria-label="Clear"
data-testid="clear-images-search"
onClick={resetSearch}
size="small"
>
<CloseIcon />
</IconButton>
</InputAdornment>
),
}}
onChange={debounce(400, (e) => {
onSearch(e);
})}
hideLabel
label="Search"
placeholder="Search Images"
sx={{ mb: 2 }}
value={imageLabelFromParam}
/>
<Paper className={classes.imageTable}>
<div className={classes.imageTableHeader}>
<Typography variant="h3">Custom Images</Typography>
Expand Down Expand Up @@ -483,16 +548,20 @@ export const ImagesLanding = () => {
</TableRow>
</TableHead>
<TableBody>
{automaticImages.data.length > 0
? automaticImages.data.map((automaticImage) => (
<ImageRow
event={automaticImagesEvents[automaticImage.id]}
handlers={handlers}
image={automaticImage}
key={automaticImage.id}
/>
))
: noAutomaticImages}
{isFetching ? (
<TableRowLoading columns={6} />
) : automaticImages.data.length > 0 ? (
automaticImages.data.map((automaticImage) => (
<ImageRow
event={automaticImagesEvents[automaticImage.id]}
handlers={handlers}
image={automaticImage}
key={automaticImage.id}
/>
))
) : (
noAutomaticImages
)}
</TableBody>
</Table>
<PaginationFooter
Expand Down
Loading

0 comments on commit 4ac62db

Please sign in to comment.