From 8df8e93ad187b28a81ce3c4472aeb2bfcff486f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafa=C5=82=20Cie=C5=9Blak?= Date: Mon, 17 Apr 2023 13:23:21 +0200 Subject: [PATCH] Connect: Improve focus management in search bar --- .../teleterm/src/ui/Search/SearchBar.test.tsx | 64 ++++++++++++++++++- .../src/ui/Search/SearchContext.test.tsx | 46 +++++++++++++ .../teleterm/src/ui/Search/SearchContext.tsx | 18 +++++- .../teleterm/src/ui/Search/useSearch.test.tsx | 4 ++ 4 files changed, 130 insertions(+), 2 deletions(-) diff --git a/web/packages/teleterm/src/ui/Search/SearchBar.test.tsx b/web/packages/teleterm/src/ui/Search/SearchBar.test.tsx index ca504d0b26282..b71170771c982 100644 --- a/web/packages/teleterm/src/ui/Search/SearchBar.test.tsx +++ b/web/packages/teleterm/src/ui/Search/SearchBar.test.tsx @@ -15,12 +15,13 @@ */ import React from 'react'; -import { render, screen } from 'design/utils/testing'; +import { render, screen, waitFor } from 'design/utils/testing'; import { makeSuccessAttempt } from 'shared/hooks/useAsync'; import { MockAppContext } from 'teleterm/ui/fixtures/mocks'; import { MockAppContextProvider } from 'teleterm/ui/fixtures/MockAppContextProvider'; import { ResourceSearchError } from 'teleterm/ui/services/resources'; +import ModalsHost from 'teleterm/ui/ModalsHost'; import * as pickers from './pickers/pickers'; import * as useActionAttempts from './pickers/useActionAttempts'; @@ -28,6 +29,10 @@ import * as SearchContext from './SearchContext'; import { SearchBarConnected } from './SearchBar'; +beforeEach(() => { + jest.restoreAllMocks(); +}); + it('does not display empty results copy after selecting two filters', () => { const appContext = new MockAppContext(); appContext.workspacesService.setState(draft => { @@ -202,6 +207,63 @@ it('notifies about resource search errors and allows to display details', () => expect(mockedSearchContext.lockOpen).toHaveBeenCalled(); }); +it('maintains focus on the search input after closing a resource search error modal', async () => { + const appContext = new MockAppContext(); + appContext.workspacesService.setState(draft => { + draft.rootClusterUri = '/clusters/foo'; + }); + + const resourceSearchError = new ResourceSearchError( + '/clusters/foo', + 'server', + new Error('whoops') + ); + + const mockActionAttempts = { + filterActionsAttempt: makeSuccessAttempt([]), + resourceActionsAttempt: makeSuccessAttempt([]), + resourceSearchAttempt: makeSuccessAttempt({ + results: [], + errors: [resourceSearchError], + search: '', + }), + }; + jest + .spyOn(useActionAttempts, 'useActionAttempts') + .mockImplementation(() => mockActionAttempts); + + render( + + + + + ); + + screen.getByRole('searchbox').focus(); + expect(screen.getByRole('menu')).toHaveTextContent( + 'Some of the search results are incomplete.' + ); + screen.getByText('Show details').click(); + + const modal = screen.getByTestId('Modal'); + expect(modal).toHaveTextContent('Resource search errors'); + expect(modal).toHaveTextContent('whoops'); + + // Lose focus on the search input. + screen.getByText('Close').focus(); + screen.getByText('Close').click(); + + // Need to await this since some state updates in SearchContext are done after the modal closes. + // Otherwise we'd get a warning about missing `act`. + await waitFor(() => { + expect(modal).not.toBeInTheDocument(); + }); + + expect(screen.getByRole('searchbox')).toHaveFocus(); + // Verify that the search bar wasn't closed. + expect(screen.getByRole('menu')).toBeInTheDocument(); +}); + const getMockedSearchContext = () => ({ inputValue: 'foo', filters: [], diff --git a/web/packages/teleterm/src/ui/Search/SearchContext.test.tsx b/web/packages/teleterm/src/ui/Search/SearchContext.test.tsx index 23208851c3a0c..1d610ec2062a4 100644 --- a/web/packages/teleterm/src/ui/Search/SearchContext.test.tsx +++ b/web/packages/teleterm/src/ui/Search/SearchContext.test.tsx @@ -15,6 +15,8 @@ */ import React from 'react'; +import '@testing-library/jest-dom'; +import { render, screen } from '@testing-library/react'; import { renderHook, act } from '@testing-library/react-hooks'; import { SearchContextProvider, useSearchContext } from './SearchContext'; @@ -40,11 +42,16 @@ describe('lockOpen', () => { finishAction: rejectFailureAction, }, ])('$name', async ({ action, finishAction }) => { + const inputFocus = jest.fn(); const { result } = renderHook(() => useSearchContext(), { wrapper: ({ children }) => ( {children} ), }); + result.current.inputRef.current = { + focus: inputFocus, + } as unknown as HTMLInputElement; + act(() => { result.current.open(); }); @@ -74,5 +81,44 @@ describe('lockOpen', () => { result.current.close(); }); expect(result.current.isOpen).toBe(false); + + // Called the first time during `open`, then again after `lockOpen` finishes. + expect(inputFocus).toHaveBeenCalledTimes(2); + }); +}); + +describe('open', () => { + it('manages the focus properly when called with no arguments', () => { + const SearchInput = () => { + const { inputRef, open, close } = useSearchContext(); + + return ( + <> + +