Skip to content

Commit 4ac62db

Browse files
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
1 parent b078966 commit 4ac62db

7 files changed

Lines changed: 321 additions & 38 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@linode/manager": Added
3+
---
4+
5+
Volume & Images landing pages search and filtering ([#10570](https://github.com/linode/manager/pull/10570))
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import { createImage } from '@linode/api-v4/lib/images';
2+
import { createTestLinode } from 'support/util/linodes';
3+
import { ui } from 'support/ui';
4+
5+
import { authenticate } from 'support/api/authentication';
6+
import { randomLabel } from 'support/util/random';
7+
import { cleanUp } from 'support/util/cleanup';
8+
import type { Image, Linode } from '@linode/api-v4';
9+
import { interceptGetLinodeDisks } from 'support/intercepts/linodes';
10+
11+
authenticate();
12+
describe('Search Images', () => {
13+
before(() => {
14+
cleanUp(['linodes', 'images']);
15+
});
16+
17+
/*
18+
* - Confirm that images are API searchable and filtered in the UI.
19+
*/
20+
it('creates two images and make sure they show up in the table and are searchable', () => {
21+
cy.defer(
22+
() =>
23+
createTestLinode(
24+
{ image: 'linode/debian10', region: 'us-east' },
25+
{ waitForDisks: true }
26+
),
27+
'create linode'
28+
).then((linode: Linode) => {
29+
interceptGetLinodeDisks(linode.id).as('getLinodeDisks');
30+
31+
cy.visitWithLogin(`/linodes/${linode.id}/storage`);
32+
cy.wait('@getLinodeDisks').then((xhr) => {
33+
const disks = xhr.response?.body.data;
34+
const disk_id = disks[0].id;
35+
36+
const createTwoImages = async (): Promise<[Image, Image]> => {
37+
return Promise.all([
38+
createImage({
39+
disk_id,
40+
label: randomLabel(),
41+
}),
42+
createImage({
43+
disk_id,
44+
label: randomLabel(),
45+
}),
46+
]);
47+
};
48+
49+
cy.defer(() => createTwoImages(), 'creating images').then(
50+
([image1, image2]) => {
51+
cy.visitWithLogin('/images');
52+
53+
// Confirm that both images are listed on the landing page.
54+
cy.contains(image1.label).should('be.visible');
55+
cy.contains(image2.label).should('be.visible');
56+
57+
// Search for the first image by label, confirm it's the only one shown.
58+
cy.findByPlaceholderText('Search Images').type(image1.label);
59+
expect(cy.contains(image1.label).should('be.visible'));
60+
expect(cy.contains(image2.label).should('not.exist'));
61+
62+
// Clear search, confirm both images are shown.
63+
cy.findByTestId('clear-images-search').click();
64+
cy.contains(image1.label).should('be.visible');
65+
cy.contains(image2.label).should('be.visible');
66+
67+
// Use the main search bar to search and filter images
68+
cy.get('[id="main-search"').type(image2.label);
69+
ui.autocompletePopper.findByTitle(image2.label).click();
70+
71+
// Confirm that only the second image is shown.
72+
cy.contains(image1.label).should('not.exist');
73+
cy.contains(image2.label).should('be.visible');
74+
}
75+
);
76+
});
77+
});
78+
});
79+
});
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { createVolume } from '@linode/api-v4/lib/volumes';
2+
import { Volume } from '@linode/api-v4';
3+
import { ui } from 'support/ui';
4+
5+
import { authenticate } from 'support/api/authentication';
6+
import { randomLabel } from 'support/util/random';
7+
import { cleanUp } from 'support/util/cleanup';
8+
9+
authenticate();
10+
describe('Search Volumes', () => {
11+
before(() => {
12+
cleanUp(['volumes']);
13+
});
14+
15+
/*
16+
* - Confirm that volumes are API searchable and filtered in the UI.
17+
*/
18+
it('creates two volumes and make sure they show up in the table and are searchable', () => {
19+
const createTwoVolumes = async (): Promise<[Volume, Volume]> => {
20+
return Promise.all([
21+
createVolume({
22+
label: randomLabel(),
23+
region: 'us-east',
24+
size: 10,
25+
}),
26+
createVolume({
27+
label: randomLabel(),
28+
region: 'us-east',
29+
size: 10,
30+
}),
31+
]);
32+
};
33+
34+
cy.defer(() => createTwoVolumes(), 'creating volumes').then(
35+
([volume1, volume2]) => {
36+
cy.visitWithLogin('/volumes');
37+
38+
// Confirm that both volumes are listed on the landing page.
39+
cy.findByText(volume1.label).should('be.visible');
40+
cy.findByText(volume2.label).should('be.visible');
41+
42+
// Search for the first volume by label, confirm it's the only one shown.
43+
cy.findByPlaceholderText('Search Volumes').type(volume1.label);
44+
expect(cy.findByText(volume1.label).should('be.visible'));
45+
expect(cy.findByText(volume2.label).should('not.exist'));
46+
47+
// Clear search, confirm both volumes are shown.
48+
cy.findByTestId('clear-volumes-search').click();
49+
cy.findByText(volume1.label).should('be.visible');
50+
cy.findByText(volume2.label).should('be.visible');
51+
52+
// Use the main search bar to search and filter volumes
53+
cy.get('[id="main-search"').type(volume2.label);
54+
ui.autocompletePopper.findByTitle(volume2.label).click();
55+
56+
// Confirm that only the second volume is shown.
57+
cy.findByText(volume1.label).should('not.exist');
58+
cy.findByText(volume2.label).should('be.visible');
59+
}
60+
);
61+
});
62+
});

packages/manager/cypress/support/intercepts/linodes.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,12 @@
22
* @file Cypress intercepts and mocks for Cloud Manager Linode operations.
33
*/
44

5+
import { makeErrorResponse } from 'support/util/errors';
56
import { apiMatcher } from 'support/util/intercepts';
67
import { paginateResponse } from 'support/util/paginate';
78
import { makeResponse } from 'support/util/response';
89

9-
import type { Disk, Linode, LinodeType, Kernel, Volume } from '@linode/api-v4';
10-
import { makeErrorResponse } from 'support/util/errors';
10+
import type { Disk, Kernel, Linode, LinodeType, Volume } from '@linode/api-v4';
1111

1212
/**
1313
* Intercepts POST request to create a Linode.
@@ -210,6 +210,19 @@ export const mockRebootLinodeIntoRescueModeError = (
210210
);
211211
};
212212

213+
/**
214+
* Intercepts GET request to retrieve a Linode's Disks
215+
*
216+
* @param linodeId - ID of Linode for intercepted request.
217+
*
218+
* @returns Cypress chainable.
219+
*/
220+
export const interceptGetLinodeDisks = (
221+
linodeId: number
222+
): Cypress.Chainable<null> => {
223+
return cy.intercept('GET', apiMatcher(`linode/instances/${linodeId}/disks*`));
224+
};
225+
213226
/**
214227
* Intercepts GET request to retrieve a Linode's Disks and mocks response.
215228
*

packages/manager/src/features/Images/ImagesLanding.tsx

Lines changed: 85 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,9 @@
1-
import { Image, ImageStatus } from '@linode/api-v4';
2-
import { APIError } from '@linode/api-v4/lib/types';
3-
import { Theme } from '@mui/material/styles';
1+
import CloseIcon from '@mui/icons-material/Close';
42
import { useQueryClient } from '@tanstack/react-query';
53
import { useSnackbar } from 'notistack';
64
import * as React from 'react';
7-
import { useHistory } from 'react-router-dom';
5+
import { useHistory, useLocation } from 'react-router-dom';
6+
import { debounce } from 'throttle-debounce';
87
import { makeStyles } from 'tss-react/mui';
98

109
import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel';
@@ -13,6 +12,8 @@ import { ConfirmationDialog } from 'src/components/ConfirmationDialog/Confirmati
1312
import { DocumentTitleSegment } from 'src/components/DocumentTitle';
1413
import { ErrorState } from 'src/components/ErrorState/ErrorState';
1514
import { Hidden } from 'src/components/Hidden';
15+
import { IconButton } from 'src/components/IconButton';
16+
import { InputAdornment } from 'src/components/InputAdornment';
1617
import { LandingHeader } from 'src/components/LandingHeader';
1718
import { Notice } from 'src/components/Notice/Notice';
1819
import { PaginationFooter } from 'src/components/PaginationFooter/PaginationFooter';
@@ -23,7 +24,9 @@ import { TableCell } from 'src/components/TableCell';
2324
import { TableHead } from 'src/components/TableHead';
2425
import { TableRow } from 'src/components/TableRow';
2526
import { TableRowEmpty } from 'src/components/TableRowEmpty/TableRowEmpty';
27+
import { TableRowLoading } from 'src/components/TableRowLoading/TableRowLoading';
2628
import { TableSortCell } from 'src/components/TableSortCell';
29+
import { TextField } from 'src/components/TextField';
2730
import { Typography } from 'src/components/Typography';
2831
import { useOrder } from 'src/hooks/useOrder';
2932
import { usePagination } from 'src/hooks/usePagination';
@@ -41,11 +44,17 @@ import { getErrorStringOrDefault } from 'src/utilities/errorUtils';
4144

4245
import { EditImageDrawer } from './EditImageDrawer';
4346
import ImageRow from './ImageRow';
44-
import { Handlers as ImageHandlers } from './ImagesActionMenu';
4547
import { ImagesLandingEmptyState } from './ImagesLandingEmptyState';
4648
import { RebuildImageDrawer } from './RebuildImageDrawer';
4749
import { getEventsForImages } from './utils';
4850

51+
import type { Handlers as ImageHandlers } from './ImagesActionMenu';
52+
import type { Image, ImageStatus } from '@linode/api-v4';
53+
import type { APIError } from '@linode/api-v4/lib/types';
54+
import type { Theme } from '@mui/material/styles';
55+
56+
const searchQueryKey = 'query';
57+
4958
const useStyles = makeStyles()((theme: Theme) => ({
5059
imageTable: {
5160
marginBottom: theme.spacing(3),
@@ -81,6 +90,9 @@ export const ImagesLanding = () => {
8190
const { classes } = useStyles();
8291
const history = useHistory();
8392
const { enqueueSnackbar } = useSnackbar();
93+
const location = useLocation();
94+
const queryParams = new URLSearchParams(location.search);
95+
const imageLabelFromParam = queryParams.get(searchQueryKey) ?? '';
8496

8597
const queryClient = useQueryClient();
8698

@@ -104,9 +116,14 @@ export const ImagesLanding = () => {
104116
['+order_by']: manualImagesOrderBy,
105117
};
106118

119+
if (imageLabelFromParam) {
120+
manualImagesFilter['label'] = { '+contains': imageLabelFromParam };
121+
}
122+
107123
const {
108124
data: manualImages,
109125
error: manualImagesError,
126+
isFetching: manualImagesIsFetching,
110127
isLoading: manualImagesLoading,
111128
} = useImagesQuery(
112129
{
@@ -144,9 +161,14 @@ export const ImagesLanding = () => {
144161
['+order_by']: automaticImagesOrderBy,
145162
};
146163

164+
if (imageLabelFromParam) {
165+
automaticImagesFilter['label'] = { '+contains': imageLabelFromParam };
166+
}
167+
147168
const {
148169
data: automaticImages,
149170
error: automaticImagesError,
171+
isFetching: automaticImagesIsFetching,
150172
isLoading: automaticImagesLoading,
151173
} = useImagesQuery(
152174
{
@@ -310,6 +332,17 @@ export const ImagesLanding = () => {
310332
);
311333
};
312334

335+
const resetSearch = () => {
336+
queryParams.delete(searchQueryKey);
337+
history.push({ search: queryParams.toString() });
338+
};
339+
340+
const onSearch = (e: React.ChangeEvent<HTMLInputElement>) => {
341+
queryParams.delete('page');
342+
queryParams.set(searchQueryKey, e.target.value);
343+
history.push({ search: queryParams.toString() });
344+
};
345+
313346
const handlers: ImageHandlers = {
314347
onCancelFailed: onCancelFailedClick,
315348
onDelete: openDialog,
@@ -350,7 +383,11 @@ export const ImagesLanding = () => {
350383
}
351384

352385
/** Empty States */
353-
if (!manualImages.data.length && !automaticImages.data.length) {
386+
if (
387+
!manualImages.data.length &&
388+
!automaticImages.data.length &&
389+
!imageLabelFromParam
390+
) {
354391
return renderEmpty();
355392
}
356393

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

402+
const isFetching = manualImagesIsFetching || automaticImagesIsFetching;
403+
365404
return (
366405
<React.Fragment>
367406
<DocumentTitleSegment segment="Images" />
@@ -371,6 +410,32 @@ export const ImagesLanding = () => {
371410
onButtonClick={() => history.push('/images/create')}
372411
title="Images"
373412
/>
413+
<TextField
414+
InputProps={{
415+
endAdornment: imageLabelFromParam && (
416+
<InputAdornment position="end">
417+
{isFetching && <CircleProgress size="sm" />}
418+
419+
<IconButton
420+
aria-label="Clear"
421+
data-testid="clear-images-search"
422+
onClick={resetSearch}
423+
size="small"
424+
>
425+
<CloseIcon />
426+
</IconButton>
427+
</InputAdornment>
428+
),
429+
}}
430+
onChange={debounce(400, (e) => {
431+
onSearch(e);
432+
})}
433+
hideLabel
434+
label="Search"
435+
placeholder="Search Images"
436+
sx={{ mb: 2 }}
437+
value={imageLabelFromParam}
438+
/>
374439
<Paper className={classes.imageTable}>
375440
<div className={classes.imageTableHeader}>
376441
<Typography variant="h3">Custom Images</Typography>
@@ -483,16 +548,20 @@ export const ImagesLanding = () => {
483548
</TableRow>
484549
</TableHead>
485550
<TableBody>
486-
{automaticImages.data.length > 0
487-
? automaticImages.data.map((automaticImage) => (
488-
<ImageRow
489-
event={automaticImagesEvents[automaticImage.id]}
490-
handlers={handlers}
491-
image={automaticImage}
492-
key={automaticImage.id}
493-
/>
494-
))
495-
: noAutomaticImages}
551+
{isFetching ? (
552+
<TableRowLoading columns={6} />
553+
) : automaticImages.data.length > 0 ? (
554+
automaticImages.data.map((automaticImage) => (
555+
<ImageRow
556+
event={automaticImagesEvents[automaticImage.id]}
557+
handlers={handlers}
558+
image={automaticImage}
559+
key={automaticImage.id}
560+
/>
561+
))
562+
) : (
563+
noAutomaticImages
564+
)}
496565
</TableBody>
497566
</Table>
498567
<PaginationFooter

0 commit comments

Comments
 (0)