diff --git a/src/__tests__/CompareResults/__snapshots__/OverTimeResultsView.test.tsx.snap b/src/__tests__/CompareResults/__snapshots__/OverTimeResultsView.test.tsx.snap index b8ff6f67f..dd047df3b 100644 --- a/src/__tests__/CompareResults/__snapshots__/OverTimeResultsView.test.tsx.snap +++ b/src/__tests__/CompareResults/__snapshots__/OverTimeResultsView.test.tsx.snap @@ -513,7 +513,7 @@ exports[`Results View The table should match snapshot and other elements should role="columnheader" >
+
+ + @@ -390,7 +432,7 @@ exports[`Results Table Should match snapshot 1`] = ` class="selectedRevision_fubtarc MuiBox-root css-0" >
- -
- - + +
+ +
+ +
+ +
@@ -675,7 +759,7 @@ exports[`Results Table Should match snapshot 1`] = ` class="selectedRevision_fubtarc MuiBox-root css-0" >
+
+ +
@@ -3146,7 +3272,7 @@ exports[`Results Table for MannWhitneyResultsItem for mann-whitney-u testVersion class="selectedRevision_fubtarc MuiBox-root css-0" >
- -
- - + +
+ +
+ +
+ +
@@ -3431,7 +3599,7 @@ exports[`Results Table for MannWhitneyResultsItem for mann-whitney-u testVersion class="selectedRevision_fubtarc MuiBox-root css-0" >
+
+ +
@@ -565,7 +607,7 @@ exports[`Compare Over Time renders correctly when there are no results: Initial class="selectedRevision_fubtarc MuiBox-root css-0" >
@@ -1137,43 +1179,21 @@ exports[`Compare Over Time should have an edit mode in Results View: After click class="MuiBox-root css-0" >
- -
- - + +
+ +
+ +
+ +
@@ -1223,7 +1307,7 @@ exports[`Compare Over Time should have an edit mode in Results View: After click class="selectedRevision_fubtarc MuiBox-root css-0" >
- -
- - + +
+ +
+ +
+ +
@@ -2269,7 +2395,7 @@ exports[`Compare Over Time should update base repo, revisions and time-range aft class="selectedRevision_fubtarc MuiBox-root css-0" >
- -
- - + +
+ +
+ +
+ +
@@ -2911,7 +3079,7 @@ exports[`Compare Over Time should update base repo, revisions and time-range aft class="selectedRevision_fubtarc MuiBox-root css-0" >
- -
- - + +
+ +
+ +
+ +
@@ -218,7 +260,7 @@ exports[`Compare With Base renders correctly when there are no results: Initial class="selectedRevision_fubtarc MuiBox-root css-0" >
- -
- - + +
+ +
+ +
+ +
@@ -523,7 +607,7 @@ exports[`Compare With Base renders correctly when there are no results: Initial class="selectedRevision_fubtarc MuiBox-root css-0" >
@@ -911,43 +995,21 @@ exports[`Compare With Base should have an edit mode in Results View: After click class="MuiBox-root css-0" >
- -
- - + +
+ +
+ +
+ +
@@ -1018,7 +1144,7 @@ exports[`Compare With Base should have an edit mode in Results View: After click class="selectedRevision_fubtarc MuiBox-root css-0" >
- -
- - + +
+ +
+ +
+ +
@@ -1425,43 +1593,21 @@ exports[`Compare With Base should have an edit mode in Results View: After click class="MuiBox-root css-0" >
- -
- - + +
+ +
+ +
+ +
@@ -1532,7 +1742,7 @@ exports[`Compare With Base should have an edit mode in Results View: After click class="selectedRevision_fubtarc MuiBox-root css-0" >
- -
- - + +
+ +
+ +
+ +
@@ -1837,7 +2089,7 @@ exports[`Compare With Base should have an edit mode in Results View: After click class="selectedRevision_fubtarc MuiBox-root css-0" >
- -
- - + +
+ +
+ +
+ +
@@ -2210,7 +2504,7 @@ exports[`Compare With Base should have an edit mode in Results View: Initial sta class="selectedRevision_fubtarc MuiBox-root css-0" >
- -
- - + +
+ +
+ +
+ +
@@ -2515,7 +2851,7 @@ exports[`Compare With Base should have an edit mode in Results View: Initial sta class="selectedRevision_fubtarc MuiBox-root css-0" >
- -
- - + +
+ +
+ +
+ +
@@ -2930,43 +3308,21 @@ exports[`Compare With Base should have an edit mode in Results View: after remov class="MuiBox-root css-0" >
- -
- - + +
+ +
+ +
+ +
@@ -3037,7 +3457,7 @@ exports[`Compare With Base should have an edit mode in Results View: after remov class="selectedRevision_fubtarc MuiBox-root css-0" >
- -
- - + +
+ +
+ +
+ +
@@ -3446,7 +3908,7 @@ exports[`Compare With Base should have an edit mode in Results View: after remov class="selectedRevision_fubtarc MuiBox-root css-0" >
- -
- - + +
+ +
+ +
+ +
@@ -4047,43 +4551,21 @@ exports[`Compare With Base should remove the checked revision once X button is c class="MuiBox-root css-0" >
- -
- - + +
+ +
+ +
+ +
@@ -4192,43 +4738,21 @@ exports[`Compare With Base should remove the checked revision once X button is c class="MuiBox-root css-0" >
- -
- - + +
+ +
+ +
+ +
@@ -4616,43 +5204,21 @@ exports[`Compare With Base should remove the checked revision once X button is c class="MuiBox-root css-0" >
- -
- - + +
+ +
+ +
+ +
@@ -4773,7 +5403,7 @@ exports[`Compare With Base should remove the checked revision once X button is c exports[`Compare With Base updates the framework and url when a new one is selected: after awsy is selected 1`] = ` - + + `; exports[`SearchResultsList should match snapshot 1`] = ` @@ -1096,43 +1093,21 @@ exports[`SearchResultsList should match snapshot 1`] = ` class="MuiBox-root css-0" >
- -
- - -
-
-
-
-
-
-
  • -
    - - - - - - - -
    -
    - - - spamspamspam - -
    -
    - - - - michaelpalin@python.com -
    -
    - -
    - Jul 4, 20, 00:00 UTC -
    -
    -
    -
    - - You've got two empty 'alves of coconuts and you're bangin' 'em togetha! - -
    -
  • -
    -
    -
  • -
    - - - - - - - -
    -
    - - - spamspamspam - -
    -
    - - - - grahamchapman@python.com -
    -
    - -
    - Apr 13, 22, 00:00 UTC -
    -
    -
    -
    - - She turned me into a newt! - -
    -
  • -
    -
    -
  • -
    - - - - - - - -
    -
    - - - ed24spamspam - -
    -
    - - - - nathanniy@python.com -
    -
    - -
    - Apr 13, 22, 00:03 UTC -
    -
    -
    -
    - - Bug 123456 Fuzzy She turned me into a newt! - -
    -
  • -
    -
    -
  • -
    - - - - - - - -
    -
    - - - spamspamspam - -
    -
    - - - - grahamchapman@python.com -
    -
    - -
    - Apr 13, 22, 00:00 UTC -
    -
    -
    -
    - - Commit message with no newline at the end - -
    -
  • -
    - + @@ -2035,43 +1281,21 @@ exports[`SearchResultsList should match snapshot 1`] = ` class="MuiBox-root css-0" >
    - -
    - - + +
    + +
    + +
    + +
    @@ -2459,43 +1747,21 @@ exports[`SearchResultsList should match snapshot 1`] = ` class="MuiBox-root css-0" >
    - -
    - - + +
    + +
    + +
    + +
    @@ -2610,5 +1940,810 @@ exports[`SearchResultsList should match snapshot 1`] = ` + `; diff --git a/src/__tests__/Search/__snapshots__/SearchView.test.tsx.snap b/src/__tests__/Search/__snapshots__/SearchView.test.tsx.snap index cd4caa5dc..9ab0e5f28 100644 --- a/src/__tests__/Search/__snapshots__/SearchView.test.tsx.snap +++ b/src/__tests__/Search/__snapshots__/SearchView.test.tsx.snap @@ -299,43 +299,21 @@ exports[`Search View renders correctly when there are no results 1`] = ` class="MuiBox-root css-0" >
    - -
    - - + +
    + +
    + +
    + +
    @@ -444,43 +486,21 @@ exports[`Search View renders correctly when there are no results 1`] = ` class="MuiBox-root css-0" >
    - -
    - - + +
    + +
    + +
    + +
    @@ -868,43 +952,21 @@ exports[`Search View renders correctly when there are no results 1`] = ` class="MuiBox-root css-0" >
    - -
    - - + +
    + +
    + +
    + +
    @@ -1265,43 +1391,21 @@ exports[`With search parameters both search components are populated as expected class="MuiBox-root css-0" >
    - -
    - - + +
    + +
    + +
    + +
    @@ -1351,7 +1519,7 @@ exports[`With search parameters both search components are populated as expected class="selectedRevision_fubtarc MuiBox-root css-0" >
    - -
    - - + +
    + +
    + +
    + +
    @@ -1785,43 +1995,21 @@ exports[`With search parameters both search components are populated as expected class="MuiBox-root css-0" >
    - -
    - - + +
    + +
    + +
    + +
    @@ -1872,7 +2124,7 @@ exports[`With search parameters both search components are populated as expected class="selectedRevision_fubtarc MuiBox-root css-0" >
    - -
    - - + +
    + +
    + +
    + +
    @@ -2402,7 +2696,7 @@ exports[`With search parameters both search components are populated as expected class="selectedRevision_fubtarc MuiBox-root css-0" >
    - -
    - - + +
    + +
    + +
    + +
    @@ -2836,43 +3172,21 @@ exports[`With search parameters both search components are populated as expected class="MuiBox-root css-0" >
    - -
    - - + +
    + +
    + +
    + +
    @@ -2923,7 +3301,7 @@ exports[`With search parameters both search components are populated as expected class="selectedRevision_fubtarc MuiBox-root css-0" >
    - -
    - - + +
    + +
    + +
    + +
    @@ -3453,7 +3873,7 @@ exports[`With search parameters displays the default value for framework if the class="selectedRevision_fubtarc MuiBox-root css-0" >
    - -
    - - + +
    + +
    + +
    + +
    @@ -3887,43 +4349,21 @@ exports[`With search parameters displays the default value for framework if the class="MuiBox-root css-0" >
    - -
    - - + +
    + +
    + +
    + +
    @@ -3974,7 +4478,7 @@ exports[`With search parameters displays the default value for framework if the class="selectedRevision_fubtarc MuiBox-root css-0" >
    - -
    - - + +
    + +
    + +
    + +
    @@ -4653,43 +5199,21 @@ exports[`With search parameters displays the default values if some values are b class="MuiBox-root css-0" >
    - -
    - - + +
    + +
    + +
    + +
    @@ -4798,43 +5386,21 @@ exports[`With search parameters displays the default values if some values are b class="MuiBox-root css-0" >
    - -
    - - + +
    + +
    + +
    + +
    diff --git a/src/__tests__/Search/__snapshots__/SelectedRevision.test.tsx.snap b/src/__tests__/Search/__snapshots__/SelectedRevision.test.tsx.snap index 01651ae65..21fbea79c 100644 --- a/src/__tests__/Search/__snapshots__/SelectedRevision.test.tsx.snap +++ b/src/__tests__/Search/__snapshots__/SelectedRevision.test.tsx.snap @@ -26,7 +26,7 @@ exports[`SelectedRevision should show the selected checked revisions once a resu class="selectedRevision_fubtarc MuiBox-root css-0" >
    { await user.click(screen.getByRole('option', { name: 'autoland' })); expect(baseRepoSelect).toHaveTextContent('autoland'); - const searchInput = screen.getAllByRole('textbox')[0]; + const placeholder = + Strings.components.searchDefault.base.collapsed.base.inputPlaceholder; + + // focus input to show results + const searchInput = screen.getAllByPlaceholderText(placeholder)[1]; await user.click(searchInput); await screen.findAllByText("you've got no arms left!"); @@ -63,9 +67,10 @@ describe('Search View/fetchRecentRevisions', () => { const errorMessages = await screen.findAllByText('No results found'); expect(errorMessages).toHaveLength(3); - const inputs = screen.getAllByRole('textbox'); - expect(inputs[0]).toBeInvalid(); - expect(inputs[1]).toBeInvalid(); + const inputs = screen.queryAllByTestId('autocomplete-option'); + + expect(inputs[0]).toBeUndefined(); + expect(inputs[1]).toBeUndefined(); expect(global.fetch).toHaveFetched( 'https://treeherder.mozilla.org/api/project/try/push/?hide_reviewbot_pushes=true&count=30', undefined, @@ -86,9 +91,9 @@ describe('Search View/fetchRecentRevisions', () => { const errorMessages = await screen.findAllByText(errorMessage); expect(errorMessages).toHaveLength(3); - const inputs = screen.getAllByRole('textbox'); - expect(inputs[0]).toBeInvalid(); - expect(inputs[1]).toBeInvalid(); + const inputs = screen.queryAllByTestId('autocomplete-option'); + expect(inputs[0]).toBeUndefined(); + expect(inputs[1]).toBeUndefined(); expect(global.fetch).toHaveFetched( 'https://treeherder.mozilla.org/api/project/try/push/?hide_reviewbot_pushes=true&count=30', undefined, diff --git a/src/__tests__/Search/fetchRevisionByID.test.tsx b/src/__tests__/Search/fetchRevisionByID.test.tsx index 1f5ba91c7..d5fdac6ad 100644 --- a/src/__tests__/Search/fetchRevisionByID.test.tsx +++ b/src/__tests__/Search/fetchRevisionByID.test.tsx @@ -5,7 +5,10 @@ import { loader } from '../../components/Search/loader'; import SearchView from '../../components/Search/SearchView'; import { Strings } from '../../resources/Strings'; import getTestData from '../utils/fixtures'; -import { screen, renderWithRouter, act } from '../utils/test-utils'; +import { screen, renderWithRouter, act, waitFor } from '../utils/test-utils'; + +const searchRevisionPlaceholder = + Strings.components.searchDefault.base.collapsed.base.inputPlaceholder; async function renderSearchViewComponent() { renderWithRouter(, { @@ -29,7 +32,9 @@ describe('Search View/fetchRevisionByID', () => { await renderSearchViewComponent(); - const searchInput = screen.getAllByRole('textbox')[0]; + const searchInput = screen.getAllByPlaceholderText( + searchRevisionPlaceholder, + )[0]; await user.type(searchInput, 'abcdef123456'); act(() => void jest.runAllTimers()); expect(global.fetch).toHaveFetched( @@ -73,7 +78,9 @@ describe('Search View/fetchRevisionByID', () => { await renderSearchViewComponent(); - const searchInput = screen.getAllByRole('textbox')[0]; + const searchInput = screen.getAllByPlaceholderText( + searchRevisionPlaceholder, + )[0]; await user.type(searchInput, 'abcdef1234567890abcdef1234567890abcdef12'); act(() => void jest.runAllTimers()); @@ -83,7 +90,10 @@ describe('Search View/fetchRevisionByID', () => { ); expect(await screen.findByText('No results found')).toBeInTheDocument(); - expect(searchInput).toBeInvalid(); + + await waitFor(() => { + expect(searchInput).toHaveAttribute('aria-invalid', 'true'); + }); }); it('should update error state if fetchRevisionByID returns an error', async () => { @@ -106,7 +116,9 @@ describe('Search View/fetchRevisionByID', () => { await renderSearchViewComponent(); - const searchInput = screen.getAllByRole('textbox')[0]; + const searchInput = screen.getAllByPlaceholderText( + searchRevisionPlaceholder, + )[0]; await user.type(searchInput, 'abcdef1234567890abcdef1234567890abcdef12'); act(() => void jest.runAllTimers()); @@ -114,8 +126,11 @@ describe('Search View/fetchRevisionByID', () => { 'https://treeherder.mozilla.org/api/project/try/push/?revision=abcdef1234567890abcdef1234567890abcdef12', undefined, ); - expect(await screen.findByText(errorMessage)).toBeInTheDocument(); - expect(searchInput).toBeInvalid(); + const errorMessageNode = await screen.findAllByText(errorMessage); + expect(errorMessageNode[0]).toBeInTheDocument(); + await waitFor(() => { + expect(searchInput).toHaveAttribute('aria-invalid', 'true'); + }); expect(console.error).toHaveBeenCalledWith( 'Error while fetching recent revisions:', new Error(errorMessage), @@ -141,7 +156,10 @@ describe('Search View/fetchRevisionByID', () => { await renderSearchViewComponent(); - const searchInput = screen.getAllByRole('textbox')[0]; + const searchInput = screen.getAllByPlaceholderText( + searchRevisionPlaceholder, + )[0]; + await user.type(searchInput, 'abcdef123456'); act(() => void jest.runAllTimers()); @@ -149,10 +167,15 @@ describe('Search View/fetchRevisionByID', () => { 'https://treeherder.mozilla.org/api/project/try/push/?revision=abcdef123456', undefined, ); - expect( - await screen.findByText('An error has occurred'), - ).toBeInTheDocument(); - expect(searchInput).toBeInvalid(); + const errorMessageNode = await screen.findAllByText( + 'An error has occurred', + ); + expect(errorMessageNode[0]).toBeInTheDocument(); + + await waitFor(() => { + expect(searchInput).toHaveAttribute('aria-invalid', 'true'); + }); + expect(console.error).toHaveBeenCalledWith( 'Error while fetching recent revisions:', new Error(), diff --git a/src/__tests__/Search/fetchRevisionsBySearch.test.tsx b/src/__tests__/Search/fetchRevisionsBySearch.test.tsx index 6d7f6def2..fc7f1d9d7 100644 --- a/src/__tests__/Search/fetchRevisionsBySearch.test.tsx +++ b/src/__tests__/Search/fetchRevisionsBySearch.test.tsx @@ -5,7 +5,10 @@ import { loader } from '../../components/Search/loader'; import SearchView from '../../components/Search/SearchView'; import { Strings } from '../../resources/Strings'; import getTestData from '../utils/fixtures'; -import { screen, act, renderWithRouter } from '../utils/test-utils'; +import { screen, act, renderWithRouter, waitFor } from '../utils/test-utils'; + +const searchRevisionPlaceholder = + Strings.components.searchDefault.base.collapsed.base.inputPlaceholder; async function renderSearchViewComponent() { renderWithRouter(, { @@ -110,7 +113,9 @@ describe('SearchView/fetchRevisions', () => { await renderSearchViewComponent(); - const searchInput = screen.getAllByRole('textbox')[0]; + const searchInput = screen.getAllByPlaceholderText( + searchRevisionPlaceholder, + )[0]; await user.type(searchInput, 'ericidle@python.com'); act(() => void jest.runAllTimers()); @@ -120,7 +125,9 @@ describe('SearchView/fetchRevisions', () => { ); expect(await screen.findByText('No results found')).toBeInTheDocument(); - expect(searchInput).toBeInvalid(); + await waitFor(() => { + expect(searchInput).toHaveAttribute('aria-invalid', 'true'); + }); }); it('should update error state if fetchRevisionsByAuthor returns an error', async () => { @@ -143,7 +150,9 @@ describe('SearchView/fetchRevisions', () => { await renderSearchViewComponent(); - const searchInput = screen.getAllByRole('textbox')[0]; + const searchInput = screen.getAllByPlaceholderText( + searchRevisionPlaceholder, + )[0]; await user.type(searchInput, 'grahamchapman@python.com'); act(() => void jest.runAllTimers()); @@ -151,8 +160,12 @@ describe('SearchView/fetchRevisions', () => { 'https://treeherder.mozilla.org/api/project/try/push/?search=grahamchapman%40python.com', undefined, ); - expect(await screen.findByText(errorMessage)).toBeInTheDocument(); - expect(searchInput).toBeInvalid(); + + const messages = await screen.findAllByText(errorMessage); + expect(messages[0]).toBeInTheDocument(); + await waitFor(() => { + expect(searchInput).toHaveAttribute('aria-invalid', 'true'); + }); expect(console.error).toHaveBeenCalledWith( 'Error while fetching recent revisions:', new Error(errorMessage), @@ -178,7 +191,9 @@ describe('SearchView/fetchRevisions', () => { await renderSearchViewComponent(); - const searchInput = screen.getAllByRole('textbox')[0]; + const searchInput = screen.getAllByPlaceholderText( + searchRevisionPlaceholder, + )[0]; await user.type(searchInput, 'grahamchapman@python.com'); act(() => void jest.runAllTimers()); @@ -186,10 +201,12 @@ describe('SearchView/fetchRevisions', () => { 'https://treeherder.mozilla.org/api/project/try/push/?search=grahamchapman%40python.com', undefined, ); - expect( - await screen.findByText('An error has occurred'), - ).toBeInTheDocument(); - expect(searchInput).toBeInvalid(); + + const messages = await screen.findAllByText('An error has occurred'); + expect(messages[0]).toBeInTheDocument(); + await waitFor(() => { + expect(searchInput).toHaveAttribute('aria-invalid', 'true'); + }); expect(console.error).toHaveBeenCalledWith( 'Error while fetching recent revisions:', new Error(), @@ -209,6 +226,7 @@ describe('SearchView/fetchRevisions', () => { const searchInput = screen.getAllByRole('textbox')[0]; await user.type(searchInput, 'abcdef123456'); + act(() => void jest.runAllTimers()); expect(global.fetch).toHaveFetched( 'https://treeherder.mozilla.org/api/project/try/push/?revision=abcdef123456', diff --git a/src/__tests__/Snackbar.test.tsx b/src/__tests__/Snackbar.test.tsx index 20d323297..be4aab61e 100644 --- a/src/__tests__/Snackbar.test.tsx +++ b/src/__tests__/Snackbar.test.tsx @@ -2,9 +2,6 @@ import fetchMock from '@fetch-mock/jest'; import userEvent from '@testing-library/user-event'; import App from '../components/App'; -import { loader } from '../components/Search/loader'; -import SearchView from '../components/Search/SearchView'; -import { Strings } from '../resources/Strings'; import getTestData from './utils/fixtures'; import { screen, @@ -13,6 +10,12 @@ import { render, waitForElementToBeRemoved, } from './utils/test-utils'; +import { loader } from '../components/Search/loader'; +import SearchView from '../components/Search/SearchView'; +import { Strings } from '../resources/Strings'; + +const searchRevisionPlaceholder = + Strings.components.searchDefault.base.collapsed.base.inputPlaceholder; describe('Snackbar', () => { beforeEach(() => { @@ -32,7 +35,10 @@ describe('Snackbar', () => { render(); // focus input to show results - const searchInput = screen.getAllByRole('textbox')[1]; + // const searchInput = screen.getAllByRole('textbox')[1]; + const searchInput = screen.getAllByPlaceholderText( + searchRevisionPlaceholder, + )[1]; await user.click(searchInput); await user.click(await screen.findByTestId('checkbox-0')); @@ -56,7 +62,10 @@ describe('Snackbar', () => { render(); // focus input to show results - const searchInput = screen.getAllByRole('textbox')[0]; + // const searchInput = screen.getAllByRole('textbox')[0]; + const searchInput = screen.getAllByPlaceholderText( + searchRevisionPlaceholder, + )[0]; await user.click(searchInput); await user.click((await screen.findAllByTestId('checkbox-0'))[0]); @@ -71,12 +80,23 @@ describe('Snackbar', () => { // set delay to null to prevent test time-out due to useFakeTimers const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }); - renderWithRouter(, { - loader, - }); + // eslint-disable-next-line testing-library/no-unnecessary-act + await act(async () => + renderWithRouter( + , + { + loader, + route: '/', + search: '?useFulltextSearch', + }, + ), + ); // focus input to show results - const searchInput = (await screen.findAllByRole('textbox'))[1]; + // const searchInput = (await screen.findAllByRole('textbox'))[1]; + const searchInput = screen.getAllByPlaceholderText( + searchRevisionPlaceholder, + )[1]; await user.click(searchInput); await user.click(await screen.findByTestId('checkbox-0')); diff --git a/src/components/Search/AutocompleteInput.tsx b/src/components/Search/AutocompleteInput.tsx new file mode 100644 index 000000000..b45edeaa2 --- /dev/null +++ b/src/components/Search/AutocompleteInput.tsx @@ -0,0 +1,79 @@ +import SearchIcon from '@mui/icons-material/Search'; +import { InputAdornment, TextField } from '@mui/material'; +import type { AutocompleteRenderInputParams } from '@mui/material/Autocomplete'; +import FormControl from '@mui/material/FormControl'; +import { style } from 'typestyle'; + +import { useAppSelector } from '../../hooks/app'; +import { InputStylesRaw, Spacing } from '../../styles'; + +interface AutocompleteInputProps { + params: AutocompleteRenderInputParams; + searchError: string | null; + inputPlaceholder: string; + searchType: 'base' | 'new'; + compact: boolean; +} + +function AutocompleteInput({ + params, + searchError, + inputPlaceholder, + searchType, + compact, +}: AutocompleteInputProps) { + const mode = useAppSelector((state) => state.theme.mode); + + // Styling from original SearchInput + const inputStyles = { + container: style({ + $nest: { + '.hide': { + visibility: 'hidden', + }, + '.search-text-field': { + width: '100%', + }, + '.MuiInputBase-root': { + ...(mode == 'light' ? InputStylesRaw.Light : InputStylesRaw.Dark), + flexDirection: 'row', + }, + }, + }), + inputAdornment: { + marginLeft: `${Spacing.Small}px`, + marginRight: 0, + }, + }; + + return ( + + + + + ), + }, + htmlInput: { + 'aria-label': inputPlaceholder, + ...params.inputProps, + }, + }} + /> + + ); +} + +export default AutocompleteInput; diff --git a/src/components/Search/AutocompleteOption.tsx b/src/components/Search/AutocompleteOption.tsx new file mode 100644 index 000000000..95f7bcbfc --- /dev/null +++ b/src/components/Search/AutocompleteOption.tsx @@ -0,0 +1,138 @@ +import { HTMLAttributes } from 'react'; + +import AccessTimeOutlinedIcon from '@mui/icons-material/AccessTimeOutlined'; +import MailOutlineOutlinedIcon from '@mui/icons-material/MailOutlineOutlined'; +import Box from '@mui/material/Box'; +import Checkbox from '@mui/material/Checkbox'; +import Radio from '@mui/material/Radio'; +import Typography from '@mui/material/Typography'; +import { style } from 'typestyle'; + +import DateTimeDisplay from './DateTimeDisplay'; +import { Spacing } from '../../styles'; +import type { Changeset } from '../../types/state'; +import { truncateHash, getLatestCommitMessage } from '../../utils/helpers'; + +interface AutocompleteOptionProps { + index: number; + item: Changeset; + isChecked: boolean; + onToggle: (item: Changeset) => void; + listItemComponent?: 'checkbox' | 'radio'; +} + +type AutocompleteOptionWithHTMLProps = AutocompleteOptionProps & + Omit, 'onToggle'>; + +const styles = { + container: style({ + display: 'flex', + alignItems: 'flex-start', + padding: `${Spacing.xSmall}px ${Spacing.Small}px`, + paddingTop: 0, + cursor: 'pointer', + width: '100%', + $nest: { + '&:hover': { + backgroundColor: 'inherit', // Let parent handle hover + }, + '.search-revision-item-icon': { + minWidth: '0', + }, + '.search-revision-item-text': { + flex: 1, + }, + '.MuiListItemText-primary': { + display: 'flex', + justifyContent: 'space-between', + flexWrap: 'wrap', + }, + '.info-caption': { + flexWrap: 'wrap', + $nest: { + svg: { + marginRight: `${Spacing.xSmall}px`, + fontSize: '1rem', + }, + '.item-author': { + marginRight: `${Spacing.xSmall + 1}px`, + }, + }, + }, + }, + }), +}; + +function AutocompleteOption({ + index, + item, + isChecked, + onToggle, + listItemComponent, + ...htmlProps +}: AutocompleteOptionWithHTMLProps) { + const ListItemComponent = listItemComponent === 'radio' ? Radio : Checkbox; + const revisionHash = truncateHash(item.revision); + const commitMessage = getLatestCommitMessage(item); + const itemDate = new Date(item.push_timestamp * 1000); + + const onToggleAction = () => { + onToggle(item); + }; + + return ( +
  • + + + + + + + + {revisionHash} + +
    +
    + + {item.author} +
    + +
    + + +
    +
    +
    + + + {commitMessage} + +
    +
  • + ); +} + +export default AutocompleteOption; diff --git a/src/components/Search/SearchInputAndResults.tsx b/src/components/Search/SearchInputAndResults.tsx index 0d6a0c6b3..2c7eb4171 100644 --- a/src/components/Search/SearchInputAndResults.tsx +++ b/src/components/Search/SearchInputAndResults.tsx @@ -1,11 +1,29 @@ -import { useState, useEffect, useCallback, useRef } from 'react'; +import { + useState, + useEffect, + useCallback, + useRef, + HTMLAttributes, +} from 'react'; +import Autocomplete, { + AutocompleteRenderInputParams, +} from '@mui/material/Autocomplete'; import Box from '@mui/material/Box'; +import { style } from 'typestyle'; -import SearchInput from './SearchInput'; -import SearchResultsList from './SearchResultsList'; +import AutocompleteInput from './AutocompleteInput'; +import AutocompleteOption from './AutocompleteOption'; +import { useAppSelector } from '../../hooks/app'; import { fetchRecentRevisions } from '../../logic/treeherder'; import { Strings } from '../../resources/Strings'; +import { + Colors, + FontsRaw, + Spacing, + captionStylesLight, + captionStylesDark, +} from '../../styles'; import type { Changeset, Repository } from '../../types/state'; import { simpleDebounce } from '../../utils/simple-debounce'; @@ -28,7 +46,8 @@ export default function SearchInputAndResults({ onSearchResultsToggle, listItemComponent, }: Props) { - const [displayDropdown, setDisplayDropdown] = useState(false); + const mode = useAppSelector((state) => state.theme.mode); + const [recentRevisions, setRecentRevisions] = useState( null as null | Changeset[], ); @@ -45,26 +64,89 @@ export default function SearchInputAndResults({ const containerRef = useRef(null as null | HTMLElement); - const handleDocumentMousedown = useCallback( - (e: MouseEvent) => { - if (!displayDropdown) { - return; - } - const target = e.target as HTMLElement; - if (!containerRef.current?.contains(target)) { - // Close the dropdown only if the click is outside the search input or one - // of it's descendants. - setDisplayDropdown(false); - } - }, - [displayDropdown], - ); + // Styling from original SearchResultsList + const sharedSelectStyles = { + borderRadius: '4px', + marginTop: `${Spacing.xSmall}px`, + maxHeight: '285px', + overflow: 'auto', + maxWidth: '100%', + padding: `${Spacing.xSmall}px`, + border: `1px solid ${Colors.BorderDefault}`, + zIndex: 100, + }; - const handleEscKeypress = useCallback((e: KeyboardEvent) => { - if (e.key === 'Escape') { - setDisplayDropdown(false); - } - }, []); + const getListStyles = (theme: string) => { + const backgroundColor = + theme === 'light' ? Colors.Background300 : Colors.Background300Dark; + const hoverColor = + theme === 'light' ? Colors.SecondaryHover : Colors.SecondaryHoverDark; + const activeColor = + theme === 'light' ? Colors.SecondaryActive : Colors.SecondaryActiveDark; + const captionStyle = + theme === 'light' ? captionStylesLight : captionStylesDark; + + return style({ + backgroundColor, + position: 'relative', + ...sharedSelectStyles, + $nest: { + // Autocomplete option highlighting + 'li[aria-selected="true"]': { + backgroundColor: `${hoverColor} !important`, + borderRadius: '4px', + paddingTop: `${Spacing.xSmall}px`, + }, + 'li:hover': { + backgroundColor: `${hoverColor} !important`, + borderRadius: '4px', + paddingTop: `${Spacing.xSmall}px`, + }, + 'li[data-focus="true"]': { + backgroundColor: `${hoverColor} !important`, + borderRadius: '4px', + paddingTop: `${Spacing.xSmall}px`, + }, + 'li.Mui-focused': { + backgroundColor: `${hoverColor} !important`, + borderRadius: '4px', + paddingTop: `${Spacing.xSmall}px`, + }, + // General li styling for all options + li: { + paddingTop: `${Spacing.xSmall}px`, + }, + // Original list item button styles (keeping for compatibility) + '.MuiListItemButton-root': { + padding: `${Spacing.xSmall}px ${Spacing.Small}px`, + $nest: { + '&:hover': { + backgroundColor: hoverColor, + borderRadius: '4px', + }, + '&:active': { + backgroundColor: activeColor, + borderRadius: '4px', + }, + }, + }, + '.item-selected': { + backgroundColor: hoverColor, + borderRadius: '4px', + }, + '.revision-hash': { + ...FontsRaw.BodyDefault, + marginRight: Spacing.Small, + }, + '.info-caption': { + ...captionStyle, + }, + '.MuiTypography-root': { + ...FontsRaw.BodyDefault, + }, + }, + }); + }; const searchRecentRevisions = useCallback( async (searchTerm: string) => { @@ -159,40 +241,64 @@ export default function SearchInputAndResults({ void searchRecentRevisions(lastSearchTermRef.current); }, [repository]); - useEffect(() => { - document.addEventListener('mousedown', handleDocumentMousedown); - return () => { - document.removeEventListener('mousedown', handleDocumentMousedown); - }; - }, [handleDocumentMousedown]); + const renderInput = (params: AutocompleteRenderInputParams) => ( + + ); - useEffect(() => { - document.addEventListener('keydown', handleEscKeypress); - return () => { - document.removeEventListener('keydown', handleEscKeypress); - }; - }, [handleEscKeypress]); + const renderOption = ( + props: HTMLAttributes, + option: Changeset, + ) => ( + rev.id).includes(option.id)} + onToggle={onSearchResultsToggle} + listItemComponent={listItemComponent} + /> + ); return ( - setDisplayDropdown(true)} - compact={compact} - inputPlaceholder={inputPlaceholder} - searchType={searchType} - searchError={searchError} - onChange={onValueChange} + options.revision} + isOptionEqualToValue={(option, value) => option.id === value.id} + multiple={listItemComponent !== 'radio'} + disableCloseOnSelect={listItemComponent !== 'radio'} + filterOptions={(options) => options} + onInputChange={(_, value) => onValueChange(value)} + onChange={(_, value) => { + if (value) { + if (Array.isArray(value)) { + // Handle multiple selection (checkbox mode) + value.forEach((item) => onSearchResultsToggle(item)); + } else { + // Handle single selection (radio mode) + onSearchResultsToggle(value); + } + } + }} + renderInput={renderInput} + renderOption={renderOption} + loading={recentRevisions?.length === 0} + loadingText={'Loading...'} + noOptionsText={searchError || 'No results found'} + slotProps={{ + listbox: { + className: `${getListStyles(mode)} results-list-${mode}`, + }, + }} + data-testid='autocomplete' /> - - {recentRevisions && displayDropdown && ( - - )} ); }