diff --git a/packages/manager/.changeset/pr-12555-changed-1753281074340.md b/packages/manager/.changeset/pr-12555-changed-1753281074340.md new file mode 100644 index 00000000000..581191c6d0a --- /dev/null +++ b/packages/manager/.changeset/pr-12555-changed-1753281074340.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Changed +--- + +Use Search field on Images landing page rather than a classic Text field ([#12555](https://github.com/linode/manager/pull/12555)) diff --git a/packages/manager/cypress/e2e/core/images/search-images.spec.ts b/packages/manager/cypress/e2e/core/images/search-images.spec.ts index 2945baacd87..a64cac73e20 100644 --- a/packages/manager/cypress/e2e/core/images/search-images.spec.ts +++ b/packages/manager/cypress/e2e/core/images/search-images.spec.ts @@ -67,7 +67,7 @@ describe('Search Images', () => { cy.contains(image2.label).should('not.exist'); // Clear search, confirm both images are shown. - cy.findByTestId('clear-images-search').click(); + cy.findByLabelText('Clear').click(); cy.contains(image1.label).should('be.visible'); cy.contains(image2.label).should('be.visible'); diff --git a/packages/manager/src/features/Images/ImagesLanding/ImagesLanding.test.tsx b/packages/manager/src/features/Images/ImagesLanding/ImagesLanding.test.tsx index 2cdd4a23ae6..dc98f2359ac 100644 --- a/packages/manager/src/features/Images/ImagesLanding/ImagesLanding.test.tsx +++ b/packages/manager/src/features/Images/ImagesLanding/ImagesLanding.test.tsx @@ -1,5 +1,5 @@ import { grantsFactory, profileFactory } from '@linode/utilities'; -import { waitFor, waitForElementToBeRemoved } from '@testing-library/react'; +import { waitForElementToBeRemoved } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import * as React from 'react'; @@ -10,33 +10,6 @@ import { mockMatchMedia, renderWithTheme } from 'src/utilities/testHelpers'; import ImagesLanding from './ImagesLanding'; -const queryMocks = vi.hoisted(() => ({ - useParams: vi.fn().mockReturnValue({ action: undefined, imageId: undefined }), - useSearch: vi.fn().mockReturnValue({ query: undefined }), -})); - -vi.mock('@tanstack/react-router', async () => { - const actual = await vi.importActual('@tanstack/react-router'); - return { - ...actual, - useParams: queryMocks.useParams, - useSearch: queryMocks.useSearch, - }; -}); - -const mockHistory = { - push: vi.fn(), - replace: vi.fn(), -}; - -vi.mock('react-router-dom', async () => { - const actual = await vi.importActual('react-router-dom'); - return { - ...actual, - useHistory: vi.fn(() => mockHistory), - }; -}); - beforeAll(() => mockMatchMedia()); const loadingTestId = 'circle-progress'; @@ -55,12 +28,13 @@ describe('Images Landing Table', () => { }) ); - const { getAllByText, queryByTestId } = renderWithTheme(); + const { getAllByText, queryByTestId } = renderWithTheme(, { + initialRoute: '/images', + }); const loadingElement = queryByTestId(loadingTestId); - if (loadingElement) { - await waitForElementToBeRemoved(loadingElement); - } + + await waitForElementToBeRemoved(loadingElement); // Two tables should render getAllByText('Custom Images'); @@ -89,14 +63,11 @@ describe('Images Landing Table', () => { }) ); - const { getByText, queryByTestId } = renderWithTheme(); - - const loadingElement = queryByTestId(loadingTestId); - if (loadingElement) { - await waitForElementToBeRemoved(loadingElement); - } + const { findByText } = renderWithTheme(, { + initialRoute: '/images', + }); - expect(getByText('No Custom Images to display.')).toBeInTheDocument(); + expect(await findByText('No Custom Images to display.')).toBeVisible(); }); it('should render automatic images empty state', async () => { @@ -112,13 +83,11 @@ describe('Images Landing Table', () => { }) ); - const { getByText, queryByTestId } = renderWithTheme(); - const loadingElement = queryByTestId(loadingTestId); - if (loadingElement) { - await waitForElementToBeRemoved(loadingElement); - } + const { findByText } = renderWithTheme(, { + initialRoute: '/images', + }); - expect(getByText('No Recovery Images to display.')).toBeInTheDocument(); + expect(await findByText('No Recovery Images to display.')).toBeVisible(); }); it('should render images landing empty state', async () => { @@ -128,192 +97,145 @@ describe('Images Landing Table', () => { }) ); - const { getByText, queryByTestId } = renderWithTheme(); + const { getByText, queryByTestId } = renderWithTheme(, { + initialRoute: '/images', + }); const loadingElement = queryByTestId(loadingTestId); - if (loadingElement) { - await waitForElementToBeRemoved(loadingElement); - } + await waitForElementToBeRemoved(loadingElement); expect( getByText((text) => text.includes('Store custom Linux images')) - ).toBeInTheDocument(); + ).toBeVisible(); }); it('should allow opening the Edit Image drawer', async () => { - const images = imageFactory.buildList(3, { - regions: [ - { region: 'us-east', status: 'available' }, - { region: 'us-southeast', status: 'pending' }, - ], - }); + const image = imageFactory.build(); + server.use( - http.get('*/images', () => { - return HttpResponse.json(makeResourcePage(images)); + http.get('*/images', ({ request }) => { + const filter = request.headers.get('x-filter'); + + if (filter?.includes('manual')) { + return HttpResponse.json(makeResourcePage([image])); + } + return HttpResponse.json(makeResourcePage([])); }) ); - const { - getAllByLabelText, - getByTestId, - getByText, - queryByTestId, - rerender, - } = renderWithTheme(); - - const loadingElement = queryByTestId(loadingTestId); - if (loadingElement) { - await waitForElementToBeRemoved(loadingElement); - } + const { getByText, findByLabelText, router } = renderWithTheme( + , + { initialRoute: '/images' } + ); - const actionMenu = getAllByLabelText( - `Action menu for Image ${images[0].label}` - )[0]; + const actionMenu = await findByLabelText( + `Action menu for Image ${image.label}` + ); await userEvent.click(actionMenu); await userEvent.click(getByText('Edit')); - queryMocks.useParams.mockReturnValue({ action: 'edit' }); - - rerender(); - - expect(getByTestId(loadingTestId)).toBeInTheDocument(); - - await waitForElementToBeRemoved(getByTestId(loadingTestId)); - - getByText('Edit Image'); + expect(router.state.location.pathname).toBe( + `/images/${encodeURIComponent(image.id)}/edit` + ); }); it('should allow opening the Restore Image drawer', async () => { - const images = imageFactory.buildList(3, { - regions: [ - { region: 'us-east', status: 'available' }, - { region: 'us-southeast', status: 'pending' }, - ], - }); + const image = imageFactory.build(); + server.use( - http.get('*/images', () => { - return HttpResponse.json(makeResourcePage(images)); + http.get('*/images', ({ request }) => { + const filter = request.headers.get('x-filter'); + + if (filter?.includes('manual')) { + return HttpResponse.json(makeResourcePage([image])); + } + return HttpResponse.json(makeResourcePage([])); }) ); - const { - getAllByLabelText, - getByTestId, - getByText, - queryByTestId, - rerender, - } = renderWithTheme(); - - const loadingElement = queryByTestId(loadingTestId); - if (loadingElement) { - await waitForElementToBeRemoved(loadingElement); - } + const { router, getByText, findByLabelText } = renderWithTheme( + , + { initialRoute: '/images' } + ); - const actionMenu = getAllByLabelText( - `Action menu for Image ${images[0].label}` - )[0]; + const actionMenu = await findByLabelText( + `Action menu for Image ${image.label}` + ); await userEvent.click(actionMenu); await userEvent.click(getByText('Rebuild an Existing Linode')); - queryMocks.useParams.mockReturnValue({ action: 'rebuild' }); - - rerender(); - - expect(getByTestId(loadingTestId)).toBeInTheDocument(); - - await waitForElementToBeRemoved(getByTestId(loadingTestId)); - - await waitFor(() => { - getByText('Rebuild an Existing Linode from an Image'); - }); + expect(router.state.location.pathname).toBe( + `/images/${encodeURIComponent(image.id)}/rebuild` + ); }); it('should allow deploying to a new Linode', async () => { - const images = imageFactory.buildList(3, { - regions: [ - { region: 'us-east', status: 'available' }, - { region: 'us-southeast', status: 'pending' }, - ], - }); + const image = imageFactory.build(); + server.use( - http.get('*/images', () => { - return HttpResponse.json(makeResourcePage(images)); + http.get('*/images', ({ request }) => { + const filter = request.headers.get('x-filter'); + + if (filter?.includes('manual')) { + return HttpResponse.json(makeResourcePage([image])); + } + return HttpResponse.json(makeResourcePage([])); }) ); - const { getAllByLabelText, getByText, queryByTestId } = renderWithTheme( - - ); + const { findByLabelText, getByText, queryByTestId, router } = + renderWithTheme(, { initialRoute: '/images' }); const loadingElement = queryByTestId(loadingTestId); - if (loadingElement) { - await waitForElementToBeRemoved(loadingElement); - } + await waitForElementToBeRemoved(loadingElement); - const actionMenu = getAllByLabelText( - `Action menu for Image ${images[0].label}` - )[0]; + const actionMenu = await findByLabelText( + `Action menu for Image ${image.label}` + ); await userEvent.click(actionMenu); await userEvent.click(getByText('Deploy to New Linode')); - expect(mockHistory.push).toBeCalledWith({ - pathname: '/linodes/create/', - search: `?type=Images&imageID=${images[0].id}`, + expect(router.state.location.pathname).toBe('/linodes/create'); + + expect(router.state.location.search).toStrictEqual({ + type: 'Images', + imageID: image.id, }); }); it('should allow deleting an image', async () => { - const images = imageFactory.buildList(3, { - regions: [ - { region: 'us-east', status: 'available' }, - { region: 'us-southeast', status: 'pending' }, - ], - }); + const image = imageFactory.build(); + server.use( - http.get('*/images', () => { - return HttpResponse.json(makeResourcePage(images)); + http.get('*/images', ({ request }) => { + const filter = request.headers.get('x-filter'); + + if (filter?.includes('manual')) { + return HttpResponse.json(makeResourcePage([image])); + } + return HttpResponse.json(makeResourcePage([])); }) ); - const { - getAllByLabelText, - getByTestId, - getByText, - queryByTestId, - rerender, - } = renderWithTheme(); - - const loadingElement = queryByTestId(loadingTestId); - if (loadingElement) { - await waitForElementToBeRemoved(loadingElement); - } + const { router, findByLabelText, getByText } = renderWithTheme( + , + { initialRoute: '/images' } + ); - const actionMenu = getAllByLabelText( - `Action menu for Image ${images[0].label}` - )[0]; + const actionMenu = await findByLabelText( + `Action menu for Image ${image.label}` + ); await userEvent.click(actionMenu); await userEvent.click(getByText('Delete')); - queryMocks.useParams.mockReturnValue({ action: 'delete' }); - - rerender(); - - expect(getByTestId(loadingTestId)).toBeInTheDocument(); - - await waitForElementToBeRemoved(getByTestId(loadingTestId)); - - await waitFor(() => { - getByText('Are you sure you want to delete this Image?'); - }); + expect(router.state.location.pathname).toBe( + `/images/${encodeURIComponent(image.id)}/delete` + ); }); it('disables the create button if the user does not have permission to create images', async () => { - const images = imageFactory.buildList(3, { - regions: [ - { region: 'us-east', status: 'available' }, - { region: 'us-southeast', status: 'pending' }, - ], - }); + const images = imageFactory.buildList(3); + server.use( http.get('*/v4/profile', () => { const profile = profileFactory.build({ restricted: true }); @@ -328,12 +250,12 @@ describe('Images Landing Table', () => { }) ); - const { getByText, queryByTestId } = renderWithTheme(); + const { getByText, queryByTestId } = renderWithTheme(, { + initialRoute: '/images' + }); const loadingElement = queryByTestId(loadingTestId); - if (loadingElement) { - await waitForElementToBeRemoved(loadingElement); - } + await waitForElementToBeRemoved(loadingElement); const createImageButton = getByText('Create Image').closest('button'); @@ -345,13 +267,9 @@ describe('Images Landing Table', () => { }); it('disables the action menu buttons if user does not have permissions to edit images', async () => { - const images = imageFactory.buildList(1, { + const image = imageFactory.build({ id: 'private/99999', label: 'vi-test-image', - regions: [ - { region: 'us-east', status: 'available' }, - { region: 'us-southeast', status: 'pending' }, - ], }); server.use( @@ -374,41 +292,42 @@ describe('Images Landing Table', () => { }); return HttpResponse.json(grants); }), - http.get('*/v4/images', () => { - return HttpResponse.json(makeResourcePage(images)); + http.get('*/images', ({ request }) => { + const filter = request.headers.get('x-filter'); + + if (filter?.includes('manual')) { + return HttpResponse.json(makeResourcePage([image])); + } + return HttpResponse.json(makeResourcePage([])); }) ); - const { findAllByLabelText, getAllByLabelText, queryByTestId } = - renderWithTheme(); - - const loadingElement = queryByTestId(loadingTestId); - if (loadingElement) { - await waitForElementToBeRemoved(loadingElement); - } + const { findByLabelText } = renderWithTheme(, { + initialRoute: '/images', + }); - const actionMenu = getAllByLabelText( - `Action menu for Image ${images[0].label}` - )[0]; + const actionMenu = await findByLabelText( + `Action menu for Image ${image.label}` + ); await userEvent.click(actionMenu); - const disabledEditText = await findAllByLabelText( + const disabledEditText = await findByLabelText( "You don't have permissions to edit this Image. Please contact your account administrator to request the necessary permissions." ); - const disabledDeleteText = await findAllByLabelText( + const disabledDeleteText = await findByLabelText( "You don't have permissions to delete this Image. Please contact your account administrator to request the necessary permissions." ); - const disabledLinodeCreationText = await findAllByLabelText( + const disabledLinodeCreationText = await findByLabelText( "You don't have permissions to create Linodes. Please contact your account administrator to request the necessary permissions." ); - const disabledLinodeRebuildingText = await findAllByLabelText( + const disabledLinodeRebuildingText = await findByLabelText( "You don't have permissions to rebuild Linodes. Please contact your account administrator to request the necessary permissions." ); - expect(disabledEditText.length).toBe(2); - expect(disabledDeleteText.length).toBe(1); - expect(disabledLinodeCreationText.length).toBe(1); - expect(disabledLinodeRebuildingText.length).toBe(1); + expect(disabledEditText).toBeVisible(); + expect(disabledDeleteText).toBeVisible(); + expect(disabledLinodeCreationText).toBeVisible(); + expect(disabledLinodeRebuildingText).toBeVisible(); }); }); diff --git a/packages/manager/src/features/Images/ImagesLanding/ImagesLanding.tsx b/packages/manager/src/features/Images/ImagesLanding/ImagesLanding.tsx index b12dd09bec0..1bde7299a6b 100644 --- a/packages/manager/src/features/Images/ImagesLanding/ImagesLanding.tsx +++ b/packages/manager/src/features/Images/ImagesLanding/ImagesLanding.tsx @@ -8,14 +8,11 @@ import { getAPIFilterFromQuery } from '@linode/search'; import { ActionsPanel, CircleProgress, - CloseIcon, Drawer, ErrorState, - IconButton, - InputAdornment, Notice, Paper, - TextField, + Stack, Typography, } from '@linode/ui'; import { Hidden } from '@linode/ui'; @@ -23,12 +20,10 @@ import { useQueryClient } from '@tanstack/react-query'; import { useNavigate, useParams, useSearch } from '@tanstack/react-router'; import { useSnackbar } from 'notistack'; import React from 'react'; -// eslint-disable-next-line no-restricted-imports -import { useHistory } from 'react-router-dom'; -import { debounce } from 'throttle-debounce'; import { makeStyles } from 'tss-react/mui'; import { ConfirmationDialog } from 'src/components/ConfirmationDialog/ConfirmationDialog'; +import { DebouncedSearchTextField } from 'src/components/DebouncedSearchTextField'; import { DocumentTitleSegment } from 'src/components/DocumentTitle'; import { LandingHeader } from 'src/components/LandingHeader'; import { Link } from 'src/components/Link'; @@ -110,7 +105,6 @@ export const ImagesLanding = () => { }); const search = useSearch({ from: '/images' }); const { query } = search; - const history = useHistory(); const navigate = useNavigate(); const { enqueueSnackbar } = useSnackbar(); const isCreateImageRestricted = useRestrictedGlobalGrantCheck({ @@ -367,25 +361,21 @@ export const ImagesLanding = () => { }; const handleDeployNewLinode = (imageId: string) => { - history.push({ - pathname: `/linodes/create/`, - search: `?type=Images&imageID=${imageId}`, - }); - }; - - const resetSearch = () => { navigate({ - search: (prev) => ({ ...prev, query: undefined }), - to: '/images', + to: '/linodes/create', + search: { + type: 'Images', + imageID: imageId, + }, }); }; - const onSearch = (e: React.ChangeEvent) => { + const onSearch = (query: string) => { navigate({ search: (prev) => ({ ...prev, page: undefined, - query: e.target.value || undefined, + query: query || undefined, }), to: '/images', }); @@ -420,7 +410,7 @@ export const ImagesLanding = () => { const isFetching = manualImagesIsFetching || automaticImagesIsFetching; return ( - + <> { spacingBottom={16} title="Images" /> - - {isFetching && } - - - - - ), - }} - label="Search" - onChange={debounce(400, (e) => { - onSearch(e); - })} - placeholder="Search Images" - value={query ?? ''} - /> - -
- Custom Images - - These are{' '} - - encrypted - {' '} - images you manually uploaded or captured from an existing compute - instance disk. You can deploy an image to a compute instance in any - region. - -
- - - - - Image - - - Status - - - Replicated in - - - Original Image - - - All Replicas - - + + + +
+ Custom Images + + These are{' '} + + encrypted + {' '} + images you manually uploaded or captured from an existing compute + instance disk. You can deploy an image to a compute instance in + any region. + +
+
+ + - Created + Image - - - Image ID - - - - - - {manualImages?.results === 0 && ( - - )} - {manualImagesError && query && ( - - )} - {manualImages?.data.map((manualImage) => ( - - ))} - -
- -
- -
- Recovery Images - - These are images we automatically capture when Linode disks are - deleted. They will be deleted after the indicated expiration date. - -
- - - - - Image - - - Status - - - Size - - + + Status + + + Replicated in + + + Original Image + + + All Replicas + + + + Created + + + + Image ID + + + + + + {manualImages?.results === 0 && ( + + )} + {manualImagesError && query && ( + + )} + {manualImages?.data.map((manualImage) => ( + + ))} + +
+ +
+ +
+ Recovery Images + + These are images we automatically capture when Linode disks are + deleted. They will be deleted after the indicated expiration date. + +
+ + + + + Image + + + Status + - Created + Size - - - Expires - - - - - - {automaticImages?.results === 0 && ( - - )} - {automaticImagesError && query && ( - - )} - {automaticImages?.data.map((automaticImage) => ( - - ))} - -
- + + Created + + + + Expires + + + + + + {automaticImages?.results === 0 && ( + + )} + {automaticImagesError && query && ( + + )} + {automaticImages?.data.map((automaticImage) => ( + + ))} + + + +
+ - - - - - - - handleDeleteImage(selectedImage!), - }} - secondaryButtonProps={{ - 'data-testid': 'cancel', - label: dialogStatus === 'cancel' ? 'Keep Image' : 'Cancel', - onClick: handleCloseDialog, - }} + + - } - entityError={selectedImageError} - isFetching={isFetchingSelectedImage} - onClose={handleCloseDialog} - open={action === 'delete'} - title={ - dialogStatus === 'cancel' - ? 'Cancel Upload' - : `Delete Image ${selectedImage?.label}` - } - > - {dialogState.error && ( - - )} - - {dialogStatus === 'cancel' - ? 'Are you sure you want to cancel this Image upload?' - : 'Are you sure you want to delete this Image?'} - - -
+ + handleDeleteImage(selectedImage!), + }} + secondaryButtonProps={{ + 'data-testid': 'cancel', + label: dialogStatus === 'cancel' ? 'Keep Image' : 'Cancel', + onClick: handleCloseDialog, + }} + /> + } + entityError={selectedImageError} + isFetching={isFetchingSelectedImage} + onClose={handleCloseDialog} + open={action === 'delete'} + title={ + dialogStatus === 'cancel' + ? 'Cancel Upload' + : `Delete Image ${selectedImage?.label}` + } + > + {dialogState.error && ( + + )} + + {dialogStatus === 'cancel' + ? 'Are you sure you want to cancel this Image upload?' + : 'Are you sure you want to delete this Image?'} + + + + ); }; diff --git a/packages/manager/src/utilities/testHelpers.tsx b/packages/manager/src/utilities/testHelpers.tsx index 4e08572950e..0901cd3cd1f 100644 --- a/packages/manager/src/utilities/testHelpers.tsx +++ b/packages/manager/src/utilities/testHelpers.tsx @@ -30,7 +30,7 @@ import { mergeDeepRight } from './mergeDeepRight'; import type { QueryClient } from '@tanstack/react-query'; // TODO: Tanstack Router - replace AnyRouter once migration is complete. import type { AnyRootRoute, AnyRouter } from '@tanstack/react-router'; -import type { MatcherFunction, RenderResult } from '@testing-library/react'; +import type { MatcherFunction } from '@testing-library/react'; import type { DeepPartial } from 'redux'; import type { FlagSet } from 'src/featureFlags'; import type { ApplicationState } from 'src/store'; @@ -106,14 +106,16 @@ export const wrapWithTheme = (ui: any, options: Options = {}) => { path: options.initialRoute ?? '/', }); - const router: AnyRouter = createRouter({ - history: createMemoryHistory({ - initialEntries: (options.MemoryRouter?.initialEntries as string[]) ?? [ - options.initialRoute ?? '/', - ], - }), - routeTree: rootRoute.addChildren([indexRoute]), - }); + const router: AnyRouter = + options.router ?? + createRouter({ + history: createMemoryHistory({ + initialEntries: (options.MemoryRouter?.initialEntries as string[]) ?? [ + options.initialRoute ?? '/', + ], + }), + routeTree: rootRoute.addChildren([indexRoute]), + }); return ( @@ -150,14 +152,29 @@ export const wrapWithTableBody = (ui: any, options: Options = {}) => options ); -export const renderWithTheme = ( - ui: React.ReactNode, - options: Options = {} -): RenderResult => { - const utils = render(wrapWithTheme(ui, options)); +export const renderWithTheme = (ui: React.ReactNode, options: Options = {}) => { + const rootRoute = createRootRoute({}); + const indexRoute = createRoute({ + component: () => ui, + getParentRoute: () => rootRoute, + path: options.initialRoute ?? '/', + }); + + const router: AnyRouter = createRouter({ + history: createMemoryHistory({ + initialEntries: (options.MemoryRouter?.initialEntries as string[]) ?? [ + options.initialRoute ?? '/', + ], + }), + routeTree: rootRoute.addChildren([indexRoute]), + }); + + const utils = render(wrapWithTheme(ui, { ...options, router })); return { ...utils, - rerender: (ui) => utils.rerender(wrapWithTheme(ui, options)), + rerender: (ui: React.ReactNode) => + utils.rerender(wrapWithTheme(ui, options)), + router, }; };