diff --git a/app/javascript/Search/Search.jsx b/app/javascript/Search/Search.jsx index ba2e77ffd..64a82d5fe 100644 --- a/app/javascript/Search/Search.jsx +++ b/app/javascript/Search/Search.jsx @@ -21,6 +21,7 @@ export class Search extends Component { constructor(props) { super(props); this.enableSearchPageChecker = true; + this.syncSearchUrlWithInput = this.syncSearchUrlWithInput.bind(this); } componentWillMount() { @@ -48,9 +49,33 @@ export class Search extends Component { searchPageChecker(); } + /** + * Synchronizes the search input value with the search term defined in the URL. + */ + syncSearchUrlWithInput() { + // TODO: Consolidate search functionality. + // Note that push states for search occur in _search.html.erb + // in initializeSortingTabs(query) + const { searchBoxId } = this.props; + const searchTerm = getInitialSearchTerm(window.location.search); + + // We set the value outside of React state so that there is no flickering of placeholder + // to search term. + const searchBox = document.getElementById(searchBoxId); + searchBox.value = searchTerm; + + // Even though we set the search term directly via the DOM, it still needs to reside + // in component state. + this.setState({ + searchTerm, + }); + } + componentDidMount() { this.registerGlobalKeysListener(); InstantClick.on('change', this.enableSearchPageListener); + + window.addEventListener('popstate', this.syncSearchUrlWithInput); } enableSearchPageListener = () => { @@ -81,8 +106,9 @@ export class Search extends Component { } } - componentDidUnmount() { + componentWillUnmount() { document.removeEventListener('keydown', this.globalKeysListener); + window.removeEventListener('popstate', this.syncSearchUrlWithInput); InstantClick.off('change', this.enableSearchPageListener); } diff --git a/app/javascript/Search/__tests__/Search.test.jsx b/app/javascript/Search/__tests__/Search.test.jsx index 3edf87eaa..09518194d 100644 --- a/app/javascript/Search/__tests__/Search.test.jsx +++ b/app/javascript/Search/__tests__/Search.test.jsx @@ -9,6 +9,7 @@ describe('', () => { global.filterXSS = jest.fn(); global.InstantClick = jest.fn(() => ({ on: jest.fn(), + off: jest.fn(), preload: jest.fn(), display: jest.fn(), }))(); @@ -78,4 +79,34 @@ describe('', () => { expect(searchInput.value).toEqual('hello'); expect(Search.prototype.search).toHaveBeenCalledWith('Enter', 'hello'); }); + + it('should be listening for history state changes', async () => { + // This is an implementation detail, but I want to make sure that this + // listener is registered as it affects the UI. + jest.spyOn(window, 'addEventListener'); + + render(); + + expect(window.addEventListener).toHaveBeenCalledTimes(1); + expect(window.addEventListener).toHaveBeenCalledWith( + 'popstate', + expect.any(Function), + ); + }); + + it('should stop listening for history state changes when the component is destroyed', async () => { + // This is an implementation detail, but I want to make sure that this + // listener is unregistered as it affects the UI. + jest.spyOn(window, 'removeEventListener'); + + const { unmount } = render(); + + unmount(); + + expect(window.removeEventListener).toHaveBeenCalledTimes(1); + expect(window.removeEventListener).toHaveBeenCalledWith( + 'popstate', + expect.any(Function), + ); + }); }); diff --git a/app/javascript/utilities/__tests__/search.test.js b/app/javascript/utilities/__tests__/search.test.js index 827477098..b14f274c8 100644 --- a/app/javascript/utilities/__tests__/search.test.js +++ b/app/javascript/utilities/__tests__/search.test.js @@ -58,6 +58,15 @@ describe('Search utilities', () => { const actual = getInitialSearchTerm(querystring); expect(actual).toEqual(expected); }); + + it(`should return an empty string when the search term is not defined`, () => { + /* eslint-disable-next-line no-global-assign */ + filterXSS = jest.fn(() => undefined); + const querystring = `?q=`; + + const actual = getInitialSearchTerm(querystring); + expect(actual).toEqual(''); + }); }); describe(`When the querystring key 'q' does not exist`, () => { diff --git a/app/javascript/utilities/search/index.js b/app/javascript/utilities/search/index.js index c16ba3ec7..ff873141e 100644 --- a/app/javascript/utilities/search/index.js +++ b/app/javascript/utilities/search/index.js @@ -70,7 +70,7 @@ export function getInitialSearchTerm(querystring) { matches !== null && matches.length === 2 ? decodeURIComponent(matches[1].replace(/\+/g, '%20')) : ''; - const query = filterXSS(rawSearchTerm); + const query = filterXSS(rawSearchTerm) || ''; const divForDecode = document.createElement('div'); divForDecode.innerHTML = query; @@ -86,11 +86,10 @@ export function preloadSearchResults({ const encodedQuery = fixedEncodeURIComponent( searchTerm.replace(/^[ ]+|[ ]+$/g, ''), ); - InstantClick.preload( - `${location.origin}/search?q=${encodedQuery}${getFilterParameters( - location.href, - )}`, - ); + const searchUrl = `${ + location.origin + }/search?q=${encodedQuery}${getFilterParameters(location.href)}`; + InstantClick.preload(searchUrl); } export function createSearchUrl(dataHash) {