diff --git a/docs/docs/components/react_search_kit.md b/docs/docs/components/react_search_kit.md index 27531e16..62fead51 100644 --- a/docs/docs/components/react_search_kit.md +++ b/docs/docs/components/react_search_kit.md @@ -38,14 +38,30 @@ See the [complete guide](main_concepts.md) for detailed information. - **customHandler** `object`: override entirely the default class `UrlHandlerApi`. -- **searchOnInit** `object` _optional_ +- **searchOnInit** `boolean` _optional_ A boolean to perform a search when the application is mounted. Default `true`. - **appName** `string` _optional_ - A name identifier to distinguish uniquely the application. Default `RSK`. + A name identifier to distinguish uniquely the application. Useful if multiple ReactSearchKit apps are loaded in the same page. Default `RSK`. - **eventListenerEnabled** `boolean` _optional_ If `true` the application listens to the `queryChanged` event else if `false` no listener is registered. When this event is emitted the application triggers a search based on the payload that is passed to the event at the emission time. Default `false`. + +- **initialQueryState** `object` _optional_ + + Set the initial state of your ReactSearchKit application. It will be used to render each component with these default selected values and to perform the first search query, if `searchOnInit` is set to `true`. + The object keys must match the query state fields and the values must be valid values that correspond to the values passed to each parameters. + +- **defaultSortingOnEmptyQueryString** `object` _optional_ + + It is sometimes useful to automatically change the default sorting in case the query string is set or empty. + A typical case is when your app should return by default the most recent items when the user did not search for anything in particular, but it should instead return the best matching items when searching with a particular query string. + You can enabled this behavior by defining in the `initialQueryState` the expected sorting when the query string is set by the user, and an alternative sorting when empty. + + - **sortBy** `string`: the query state `sortBy` value to use on empty query string. + + - **sortOrder** `string`: the query state `sortOrder` value to use on empty query string. + diff --git a/src/demos/cern-videos/App.js b/src/demos/cern-videos/App.js index 318a5283..2b648ac0 100644 --- a/src/demos/cern-videos/App.js +++ b/src/demos/cern-videos/App.js @@ -6,29 +6,29 @@ * under the terms of the MIT License; see LICENSE file for more details. */ +import _truncate from 'lodash/truncate'; import React, { Component } from 'react'; +import { OverridableContext } from 'react-overridable'; import { - Container, - Grid, Accordion, - Menu, Card, + Container, + Grid, Image, Item, + Menu, } from 'semantic-ui-react'; -import _truncate from 'lodash/truncate'; -import { OverridableContext } from 'react-overridable'; +import { InvenioSearchApi } from '../../lib/api/contrib/invenio'; import { - ReactSearchKit, - SearchBar, BucketAggregation, EmptyResults, Error, + ReactSearchKit, ResultsLoader, + SearchBar, withState, } from '../../lib/components'; import { Results } from './Results'; -import { InvenioSearchApi } from '../../lib/api/contrib/invenio'; const OnResults = withState(Results); @@ -43,6 +43,11 @@ const sortValues = [ sortBy: 'oldest', sortOrder: 'asc', }, + { + text: 'Best match', + sortBy: 'bestmatch', + sortOrder: 'asc', + }, ]; const resultsPerPageValues = [ @@ -57,7 +62,7 @@ const resultsPerPageValues = [ ]; const initialState = { - sortBy: 'mostrecent', + sortBy: 'bestmatch', sortOrder: 'asc', layout: 'list', page: 1, @@ -157,6 +162,10 @@ export class App extends Component { searchApi={searchApi} initialQueryState={initialState} urlHandlerApi={{ enabled: false }} + defaultSortingOnEmptyQueryString={{ + sortBy: 'mostrecent', + sortOrder: 'asc', + }} > diff --git a/src/demos/zenodo/App.js b/src/demos/zenodo/App.js index 8ae0a26c..cb271428 100644 --- a/src/demos/zenodo/App.js +++ b/src/demos/zenodo/App.js @@ -6,30 +6,29 @@ * under the terms of the MIT License; see LICENSE file for more details. */ +import _truncate from 'lodash/truncate'; import React, { Component } from 'react'; +import { OverridableContext } from 'react-overridable'; import { - Container, - Grid, Accordion, - Menu, Card, + Container, + Grid, Image, Item, + Menu, } from 'semantic-ui-react'; -import _truncate from 'lodash/truncate'; -import { OverridableContext } from 'react-overridable'; - +import { InvenioSearchApi } from '../../lib/api/contrib/invenio'; import { - ReactSearchKit, - SearchBar, BucketAggregation, EmptyResults, Error, + ReactSearchKit, ResultsLoader, + SearchBar, withState, } from '../../lib/components'; import { Results } from './Results'; -import { InvenioSearchApi } from '../../lib/api/contrib/invenio'; const OnResults = withState(Results); @@ -44,6 +43,11 @@ const sortValues = [ sortBy: 'mostviewed', sortOrder: 'desc', }, + { + text: 'Best match', + sortBy: 'bestmatch', + sortOrder: 'asc', + }, { text: 'Newest', sortBy: 'mostrecent', @@ -80,7 +84,7 @@ const searchApi = new InvenioSearchApi({ }); const initialState = { - sortBy: 'mostrecent', + sortBy: 'bestmatch', sortOrder: 'asc', layout: 'list', page: 1, @@ -168,7 +172,14 @@ export class App extends Component { render() { return ( - + diff --git a/src/lib/api/contrib/invenio/InvenioSearchApi.js b/src/lib/api/contrib/invenio/InvenioSearchApi.js index a40db9b2..7265127e 100644 --- a/src/lib/api/contrib/invenio/InvenioSearchApi.js +++ b/src/lib/api/contrib/invenio/InvenioSearchApi.js @@ -6,14 +6,14 @@ * under the terms of the MIT License; see LICENSE file for more details. */ +import axios from 'axios'; import _get from 'lodash/get'; import _hasIn from 'lodash/hasIn'; import _isEmpty from 'lodash/isEmpty'; -import axios from 'axios'; +import { updateQueryState } from '../../../state/selectors'; +import { INITIAL_QUERY_STATE } from '../../../storeConfig'; import { InvenioRequestSerializer } from './InvenioRequestSerializer'; import { InvenioResponseSerializer } from './InvenioResponseSerializer'; -import { updateQueryState } from '../../../state/selectors'; -import { STORE_KEYS } from '../../../storeConfig'; export class InvenioSearchApi { constructor(config) { @@ -88,7 +88,7 @@ export class InvenioSearchApi { const newQueryState = updateQueryState( stateQuery, response.extras, - STORE_KEYS + INITIAL_QUERY_STATE ); if (!_isEmpty(newQueryState)) { response.newQueryState = newQueryState; diff --git a/src/lib/components/Bootstrap/Bootstrap.js b/src/lib/components/Bootstrap/Bootstrap.js index e740393f..2f2fc4a7 100644 --- a/src/lib/components/Bootstrap/Bootstrap.js +++ b/src/lib/components/Bootstrap/Bootstrap.js @@ -1,13 +1,13 @@ /* * This file is part of React-SearchKit. - * Copyright (C) 2018-2019 CERN. + * Copyright (C) 2018-2020 CERN. * * React-SearchKit is free software; you can redistribute it and/or modify it * under the terms of the MIT License; see LICENSE file for more details. */ -import React, { Component, Fragment } from 'react'; import PropTypes from 'prop-types'; +import React, { Component, Fragment } from 'react'; import Overridable from 'react-overridable'; class Bootstrap extends Component { @@ -28,7 +28,7 @@ class Bootstrap extends Component { this.updateQueryState(payload.searchQuery); } else { console.debug( - `RSK app ${this.appName}: ignore event sent for app ${appReceiverName}...` + `RSK app '${this.appName}': ignoring event sent for app '${appReceiverName}'.` ); } }; diff --git a/src/lib/components/Bootstrap/Bootstrap.test.js b/src/lib/components/Bootstrap/Bootstrap.test.js index 65ff8c27..0cda1cd4 100644 --- a/src/lib/components/Bootstrap/Bootstrap.test.js +++ b/src/lib/components/Bootstrap/Bootstrap.test.js @@ -6,10 +6,10 @@ * under the terms of the MIT License; see LICENSE file for more details. */ -import React from 'react'; import { mount } from 'enzyme'; -import { default as Bootstrap } from './Bootstrap'; +import React from 'react'; import { onQueryChanged } from '../../events'; +import { default as Bootstrap } from './Bootstrap'; describe('test Bootstrap component', () => { it('should update query state when event listener is registered', () => { diff --git a/src/lib/components/BucketAggregation/BucketAggregationValues.js b/src/lib/components/BucketAggregation/BucketAggregationValues.js index 908f69c7..bdfb7cad 100644 --- a/src/lib/components/BucketAggregation/BucketAggregationValues.js +++ b/src/lib/components/BucketAggregation/BucketAggregationValues.js @@ -6,12 +6,11 @@ * under the terms of the MIT License; see LICENSE file for more details. */ -import React, { Component } from 'react'; import PropTypes from 'prop-types'; -import { Checkbox, List } from 'semantic-ui-react'; +import React, { Component } from 'react'; import Overridable from 'react-overridable'; +import { Checkbox, List } from 'semantic-ui-react'; import { buildUID } from '../../util'; -import _get from 'lodash/get'; class BucketAggregationValues extends Component { constructor(props) { @@ -75,7 +74,6 @@ class BucketAggregationValues extends Component { }; const getChildAggCmps = (bucket) => this.getChildAggCmps(bucket, selectedFilters); - let label = null; return ( { return { @@ -19,16 +19,10 @@ jest.mock('../Bootstrap', () => { }); jest.mock('../../store', () => ({ - configureStore: jest.fn(), + createStoreWithConfig: jest.fn(), })); -// const mockUrlHandlerApi = jest.fn(); jest.mock('../../api'); -// , () => { -// return { -// UrlHandlerApi: mockUrlHandlerApi, -// }; -// }); beforeEach(() => { UrlHandlerApi.mockClear(); @@ -40,9 +34,9 @@ const initialQueryState = {}; describe('test ReactSearchKit component', () => { it('should use default configuration', () => { - const rsk = shallow(); + shallow(); - expect(configureStore).toBeCalledWith( + expect(createStoreWithConfig).toBeCalledWith( expect.objectContaining({ searchApi: searchApi, urlHandlerApi: UrlHandlerApi.mock.instances[0], @@ -59,7 +53,7 @@ describe('test ReactSearchKit component', () => { /> ); - expect(configureStore).toBeCalledWith( + expect(createStoreWithConfig).toBeCalledWith( expect.objectContaining({ searchApi: searchApi, urlHandlerApi: null, @@ -73,7 +67,7 @@ describe('test ReactSearchKit component', () => { /> ); - expect(configureStore).toBeCalledWith( + expect(createStoreWithConfig).toBeCalledWith( expect.objectContaining({ searchApi: searchApi, urlHandlerApi: null, @@ -94,7 +88,7 @@ describe('test ReactSearchKit component', () => { /> ); - expect(configureStore).toBeCalledWith( + expect(createStoreWithConfig).toBeCalledWith( expect.objectContaining({ searchApi: searchApi, urlHandlerApi: UrlHandlerApi.mock.instances[0], @@ -113,7 +107,7 @@ describe('test ReactSearchKit component', () => { /> ); - expect(configureStore).toBeCalledWith( + expect(createStoreWithConfig).toBeCalledWith( expect.objectContaining({ searchApi: searchApi, initialQueryState: initialQueryState, @@ -133,7 +127,7 @@ describe('test ReactSearchKit component', () => { /> ); - expect(configureStore).toBeCalledWith( + expect(createStoreWithConfig).toBeCalledWith( expect.objectContaining({ searchApi: searchApi, urlHandlerApi: mockedCustomUrlHandlerApi, @@ -151,7 +145,7 @@ describe('test ReactSearchKit component', () => { /> ); - expect(configureStore).toBeCalledWith( + expect(createStoreWithConfig).toBeCalledWith( expect.objectContaining({ searchApi: searchApi, initialQueryState: { layout: 'grid' }, diff --git a/src/lib/components/Toggle/Toggle.js b/src/lib/components/Toggle/Toggle.js index 4e37d2e3..71665f32 100644 --- a/src/lib/components/Toggle/Toggle.js +++ b/src/lib/components/Toggle/Toggle.js @@ -6,20 +6,18 @@ * under the terms of the MIT License; see LICENSE file for more details. */ +import PropTypes from 'prop-types'; import React, { Component } from 'react'; -import { Checkbox } from 'semantic-ui-react'; import Overridable from 'react-overridable'; -import _get from 'lodash/get'; -import { Card } from 'semantic-ui-react'; -import PropTypes from 'prop-types'; +import { Card, Checkbox } from 'semantic-ui-react'; class ToggleComponent extends Component { - _isChecked = (userSelectionFilters) => { - const isFilterActive = userSelectionFilters.filter( - (filter) => filter[0] === this.props.filterValue[0] - ).length > 0 - return isFilterActive + const isFilterActive = + userSelectionFilters.filter( + (filter) => filter[0] === this.props.filterValue[0] + ).length > 0; + return isFilterActive; }; onToggleClicked = () => { @@ -27,21 +25,17 @@ class ToggleComponent extends Component { }; render() { - const { - userSelectionFilters, - overridableId, - title, - label - } = this.props; + const { userSelectionFilters, overridableId, title, label } = this.props; var isChecked = this._isChecked(userSelectionFilters); - const onToggleClicked = this.onToggleClicked - debugger + const onToggleClicked = this.onToggleClicked; + debugger; return ( + id={('SearchFilters.ToggleComponent', overridableId)} + isChecked={isChecked} + onToggleClicked={onToggleClicked} + {...this.props} + > {title} @@ -56,7 +50,7 @@ class ToggleComponent extends Component { - ) + ); } } @@ -73,4 +67,6 @@ ToggleComponent.defaultProps = { }; export default Overridable.component( - 'SearchFilters.ToggleComponent', ToggleComponent); + 'SearchFilters.ToggleComponent', + ToggleComponent +); diff --git a/src/lib/state/actions/query.js b/src/lib/state/actions/query.js index 260d3348..c1ade164 100644 --- a/src/lib/state/actions/query.js +++ b/src/lib/state/actions/query.js @@ -7,24 +7,25 @@ */ import _cloneDeep from 'lodash/cloneDeep'; +import _isEmpty from 'lodash/isEmpty'; import { + CLEAR_QUERY_SUGGESTIONS, + RESET_QUERY, + RESULTS_FETCH_ERROR, + RESULTS_FETCH_SUCCESS, + RESULTS_LOADING, + RESULTS_UPDATE_LAYOUT, SET_QUERY_COMPONENT_INITIAL_STATE, - SET_QUERY_STRING, + SET_QUERY_FILTERS, + SET_QUERY_PAGINATION_PAGE, + SET_QUERY_PAGINATION_SIZE, SET_QUERY_SORTING, SET_QUERY_SORT_BY, SET_QUERY_SORT_ORDER, SET_QUERY_STATE, - SET_QUERY_PAGINATION_PAGE, - SET_QUERY_PAGINATION_SIZE, - SET_QUERY_FILTERS, + SET_QUERY_STRING, SET_QUERY_SUGGESTIONS, SET_SUGGESTION_STRING, - CLEAR_QUERY_SUGGESTIONS, - RESET_QUERY, - RESULTS_LOADING, - RESULTS_FETCH_SUCCESS, - RESULTS_FETCH_ERROR, - RESULTS_UPDATE_LAYOUT, } from '../types'; export const setInitialState = (initialState) => { @@ -48,6 +49,16 @@ export const onAppInitialized = (searchOnInit) => { }; }; +export const updateQueryState = (queryState) => { + return (dispatch) => { + dispatch({ + type: SET_QUERY_STATE, + payload: queryState, + }); + dispatch(executeQuery()); + }; +}; + export const updateQueryString = (queryString) => { return (dispatch) => { dispatch({ @@ -137,37 +148,104 @@ export const resetQuery = () => { }; }; +/** + * Update URL parameters. + * @param {object} queryState - current query state + * @param {object} appConfig - app config + * @param {boolean} shouldReplaceUrlQueryString - true if should replace the last browser history state + * @param {boolean} shouldUpdateUrlQueryString - true if it should add a new browser history state + */ +const updateURLParameters = ( + queryState, + appConfig, + shouldReplaceUrlQueryString, + shouldUpdateUrlQueryString +) => { + const urlHandlerApi = appConfig.urlHandlerApi; + if (urlHandlerApi) { + if (shouldReplaceUrlQueryString) { + urlHandlerApi.replace(queryState); + } else if (shouldUpdateUrlQueryString) { + urlHandlerApi.set(queryState); + } + } +}; + +/** + * Update query state and URL args with the new query state given by the backend response. + * @param {object} response - API response + * @param {func} dispatch - Redux `dispath` function + * @param {func} getState - function to get the Redux state + * @param {object} appConfig - app config + */ +const updateQueryStateAfterResponse = ( + response, + dispatch, + getState, + appConfig +) => { + dispatch({ + type: SET_QUERY_STATE, + payload: response.newQueryState, + }); + + const urlHandlerApi = appConfig.urlHandlerApi; + if (urlHandlerApi) { + // Replace the URL args with the response new query state + const updatedQueryState = _cloneDeep(getState().query); + urlHandlerApi.replace(updatedQueryState); + } + delete response.newStateQuery; +}; + +const updateQueryStateSorting = (queryState, appState, appConfig) => { + if (_isEmpty(appConfig.defaultSortingOnEmptyQueryString)) { + return; + } + + const userHasChangedSorting = appState.hasUserChangedSorting; + if (userHasChangedSorting === false) { + const isQueryStringEmpty = queryState.queryString === ''; + if (isQueryStringEmpty) { + queryState.sortBy = appConfig.defaultSortingOnEmptyQueryString.sortBy; + queryState.sortOrder = + appConfig.defaultSortingOnEmptyQueryString.sortOrder; + } else { + queryState.sortBy = appState.initialSortBy; + queryState.sortOrder = appState.initialSortOrder; + } + } +}; + +/** + * Execute search API request with current query state and update results state with response. + * @param {object} options - `shouldUpdateUrlQueryString` and `shouldReplaceUrlQueryString` to choose if the + * browser history state should be updated with the current query state. + */ export const executeQuery = ({ shouldUpdateUrlQueryString = true, shouldReplaceUrlQueryString = false, } = {}) => { return async (dispatch, getState, config) => { - let queryState = _cloneDeep(getState().query); + const appState = getState().app; + let queryState = getState().query; + updateQueryStateSorting(queryState, appState, config); + + queryState = _cloneDeep(queryState); const searchApi = config.searchApi; - const urlHandlerApi = config.urlHandlerApi; - if (urlHandlerApi) { - if (shouldReplaceUrlQueryString) { - urlHandlerApi.replace(queryState); - } else if (shouldUpdateUrlQueryString) { - urlHandlerApi.set(queryState); - } - } + updateURLParameters( + queryState, + config, + shouldReplaceUrlQueryString, + shouldUpdateUrlQueryString + ); dispatch({ type: RESULTS_LOADING }); try { const response = await searchApi.search(queryState); if ('newQueryState' in response) { - dispatch({ - type: SET_QUERY_STATE, - payload: response.newQueryState, - }); - if (urlHandlerApi) { - // Get the update state from the store - queryState = _cloneDeep(getState().query); - urlHandlerApi.replace(queryState); - } - delete response.newStateQuery; + updateQueryStateAfterResponse(response, dispatch, getState, config); } dispatch({ @@ -224,13 +302,3 @@ export const clearSuggestions = () => { }); }; }; - -export const updateQueryState = (queryState) => { - return (dispatch) => { - dispatch({ - type: SET_QUERY_STATE, - payload: queryState, - }); - dispatch(executeQuery()); - }; -}; diff --git a/src/lib/state/actions/query.test.js b/src/lib/state/actions/query.test.js index cd57ac66..06820f33 100644 --- a/src/lib/state/actions/query.test.js +++ b/src/lib/state/actions/query.test.js @@ -6,41 +6,62 @@ * under the terms of the MIT License; see LICENSE file for more details. */ +import expect from 'expect'; import configureMockStore from 'redux-mock-store'; import thunk from 'redux-thunk'; -import expect from 'expect'; +import { + executeQuery, + onAppInitialized, + setInitialState, + updateQuerySortBy, + updateQuerySortOrder, + updateQueryString, +} from '.'; +import { createStoreWithConfig } from '../../store'; import { RESULTS_FETCH_SUCCESS, RESULTS_LOADING, SET_QUERY_COMPONENT_INITIAL_STATE, - SET_QUERY_STRING, SET_QUERY_SORT_BY, SET_QUERY_SORT_ORDER, + SET_QUERY_STRING, } from '../types'; -import { - setInitialState, - onAppInitialized, - updateQueryString, - updateQuerySortBy, - updateQuerySortOrder, -} from '.'; -import { UrlHandlerApi } from '../../api'; -const config = { - urlHandlerApi: new UrlHandlerApi(), - searchApi: { - search: query => ({ - aggregations: [], - hits: [], - total: 0, - }), - }, -}; -const middlewares = [thunk.withExtraArgument(config)]; -const mockStore = configureMockStore(middlewares); - -describe('test query actions', () => { - let store; +const urlHandlerApiSet = jest.fn(); +const urlHandlerApiReplace = jest.fn(); +class FakeUrlHandlerApi { + get(queryState) { + return queryState; + } + set(params) { + return urlHandlerApiSet(params); + } + replace(params) { + return urlHandlerApiReplace(params); + } +} + +let store; +afterEach(() => { + store.clearActions(); + urlHandlerApiSet.mockClear(); + urlHandlerApiReplace.mockClear(); +}); + +describe('test query actions to update query state', () => { + const config = { + urlHandlerApi: null, + searchApi: { + search: (query) => ({ + aggregations: [], + hits: [], + total: 0, + }), + }, + }; + const middlewares = [thunk.withExtraArgument(config)]; + const mockStore = configureMockStore(middlewares); + const initialState = { queryString: '', sortBy: null, @@ -54,7 +75,6 @@ describe('test query actions', () => { store = mockStore({ query: initialState, }); - store.clearActions(); }); it('fires a set initial state action', async () => { @@ -130,3 +150,357 @@ describe('test query actions', () => { expect(actions[0]).toEqual(expectedActions[0]); }); }); + +describe('test query actions to execute search', () => { + it('successfully execute search and update result state with no URL update', async () => { + const RESULTS = { + aggregations: [], + hits: [], + total: 1, + }; + const configNoURLhandler = { + urlHandlerApi: null, + searchApi: { + search: (query) => RESULTS, + }, + initialQueryState: {}, + }; + const store = createStoreWithConfig(configNoURLhandler); + const QUERY_STATE = { + queryString: 'text', + }; + store.getState().query = QUERY_STATE; + + await store.dispatch( + executeQuery({ + shouldUpdateUrlQueryString: false, + shouldReplaceUrlQueryString: false, + }) + ); + + const queryState = store.getState().query; + expect(queryState).toMatchObject(QUERY_STATE); + const resultsState = store.getState().results; + expect(resultsState).toMatchObject({ + data: RESULTS, + error: {}, + loading: false, + }); + }); + + it('execute search and update result state with error', async () => { + const configNoURLhandler = { + urlHandlerApi: null, + searchApi: { + search: (query) => { + throw Error('search error'); + }, + }, + initialQueryState: {}, + }; + const store = createStoreWithConfig(configNoURLhandler); + const QUERY_STATE = { + queryString: 'text', + }; + store.getState().query = QUERY_STATE; + + await store.dispatch( + executeQuery({ + shouldUpdateUrlQueryString: false, + shouldReplaceUrlQueryString: false, + }) + ); + + const queryState = store.getState().query; + expect(queryState).toMatchObject(QUERY_STATE); + const resultsState = store.getState().results; + expect(resultsState).toMatchObject({ + data: {}, + error: Error('search error'), + loading: false, + }); + }); +}); + +describe('test execute search with URL handler', () => { + const configWithURLhandler = { + urlHandlerApi: new FakeUrlHandlerApi(), + searchApi: { + search: (query) => ({ + aggregations: [], + hits: [], + total: 1, + }), + }, + }; + + it('execute search and test that it should update URL params', async () => { + const store = createStoreWithConfig(configWithURLhandler); + store.getState().query.queryString = 'text'; + + await store.dispatch( + executeQuery({ + shouldUpdateUrlQueryString: true, + shouldReplaceUrlQueryString: false, + }) + ); + + expect(urlHandlerApiSet).toHaveBeenCalled(); + expect(urlHandlerApiReplace).not.toHaveBeenCalled(); + const call = urlHandlerApiSet.mock.calls[0][0]; + expect(call.queryString).toEqual('text'); + }); + + it('execute search and test that it should replace URL params', async () => { + const store = createStoreWithConfig(configWithURLhandler); + store.getState().query.queryString = 'another text'; + + await store.dispatch( + executeQuery({ + shouldUpdateUrlQueryString: true, + shouldReplaceUrlQueryString: true, + }) + ); + + expect(urlHandlerApiReplace).toHaveBeenCalled(); + expect(urlHandlerApiSet).not.toHaveBeenCalled(); + const call = urlHandlerApiReplace.mock.calls[0][0]; + expect(call.queryString).toEqual('another text'); + + urlHandlerApiReplace.mockClear(); + + // test different combination of params + store.getState().query.queryString = 'another text 2'; + + await store.dispatch( + executeQuery({ + shouldUpdateUrlQueryString: false, + shouldReplaceUrlQueryString: true, + }) + ); + + expect(urlHandlerApiReplace).toHaveBeenCalled(); + expect(urlHandlerApiSet).not.toHaveBeenCalled(); + const call2 = urlHandlerApiReplace.mock.calls[0][0]; + expect(call2.queryString).toEqual('another text 2'); + }); +}); + +describe('test execute search sorting on empty query', () => { + const configNoURLhandler = { + urlHandlerApi: null, + searchApi: { + search: (query) => ({ + aggregations: [], + hits: [], + total: 1, + }), + }, + initialQueryState: { + sortBy: 'bestmatch', + sortOrder: 'desc', + }, + defaultSortingOnEmptyQueryString: { + sortBy: 'mostrecent', + sortOrder: 'asc', + }, + }; + + it('execute search with default sorting when query not empty and user did not change sorting', async () => { + const store = createStoreWithConfig(configNoURLhandler); + store.getState().query.queryString = 'text'; + + await store.dispatch( + executeQuery({ + shouldUpdateUrlQueryString: false, + shouldReplaceUrlQueryString: false, + }) + ); + + expect(store.getState().query).toMatchObject({ + queryString: 'text', + sortBy: 'bestmatch', + sortOrder: 'desc', + }); + }); + + it('execute search with defaultSortingOnEmptyQueryString when query empty and user did not change sorting', async () => { + const store = createStoreWithConfig(configNoURLhandler); + store.getState().query.queryString = ''; + + await store.dispatch( + executeQuery({ + shouldUpdateUrlQueryString: false, + shouldReplaceUrlQueryString: false, + }) + ); + + expect(store.getState().query).toMatchObject({ + queryString: '', + sortBy: 'mostrecent', + sortOrder: 'asc', + }); + }); + + it('execute search with correct sorting depending on query string and when user did not change sorting', async () => { + const store = createStoreWithConfig(configNoURLhandler); + + // 1: query string empty, use defaultSortingOnEmptyQueryString + store.getState().query.queryString = ''; + + await store.dispatch( + executeQuery({ + shouldUpdateUrlQueryString: false, + shouldReplaceUrlQueryString: false, + }) + ); + + expect(store.getState().query).toMatchObject({ + queryString: '', + sortBy: 'mostrecent', + sortOrder: 'asc', + }); + + // 2: user set query string, use default sorting + store.getState().query.queryString = 'text'; + + await store.dispatch( + executeQuery({ + shouldUpdateUrlQueryString: false, + shouldReplaceUrlQueryString: false, + }) + ); + + expect(store.getState().query).toMatchObject({ + queryString: 'text', + sortBy: 'bestmatch', + sortOrder: 'desc', + }); + + // 3: user cleaned empty string, use defaultSortingOnEmptyQueryString + store.getState().query.queryString = ''; + + await store.dispatch( + executeQuery({ + shouldUpdateUrlQueryString: false, + shouldReplaceUrlQueryString: false, + }) + ); + + expect(store.getState().query).toMatchObject({ + queryString: '', + sortBy: 'mostrecent', + sortOrder: 'asc', + }); + }); + + it('execute search with user selection sorting when query empty or not and user did change sorting', async () => { + const store = createStoreWithConfig(configNoURLhandler); + store.getState().app.hasUserChangedSorting = true; + + // 1: user changes sorting and set query string + store.getState().query.queryString = 'text'; + store.getState().query.sortBy = 'mostpopular'; + store.getState().query.sortOrder = 'asc'; + + await store.dispatch( + executeQuery({ + shouldUpdateUrlQueryString: false, + shouldReplaceUrlQueryString: false, + }) + ); + + expect(store.getState().query).toMatchObject({ + queryString: 'text', + sortBy: 'mostpopular', + sortOrder: 'asc', + }); + + // 2: user changes sorting and cleaned query string + store.getState().query.queryString = ''; + store.getState().query.sortBy = 'mostpopular'; + store.getState().query.sortOrder = 'asc'; + + await store.dispatch( + executeQuery({ + shouldUpdateUrlQueryString: false, + shouldReplaceUrlQueryString: false, + }) + ); + + expect(store.getState().query).toMatchObject({ + queryString: '', + sortBy: 'mostpopular', + sortOrder: 'asc', + }); + }); +}); + +describe('test execute search and get new query state in response', () => { + const configNoURLhandler = { + urlHandlerApi: null, + searchApi: { + search: (query) => ({ + aggregations: [], + hits: [], + total: 1, + newQueryState: { + queryString: 'changed text', + sortBy: 'anotherSortBy', + sortOrder: 'anotherSortOrder', + wrong: 'non-existing query state key', + }, + }), + }, + initialQueryState: { + sortBy: 'bestmatch', + sortOrder: 'desc', + }, + }; + + it('execute search and change query state when response contains a new query state', async () => { + const store = createStoreWithConfig(configNoURLhandler); + store.getState().query.queryString = 'text'; + + await store.dispatch( + executeQuery({ + shouldUpdateUrlQueryString: false, + shouldReplaceUrlQueryString: false, + }) + ); + + expect(store.getState().query).toMatchObject({ + queryString: 'changed text', + sortBy: 'anotherSortBy', + sortOrder: 'anotherSortOrder', + }); + }); + + it('execute search and change query state and URL params when response contains a new query state', async () => { + const store = createStoreWithConfig({ + ...configNoURLhandler, + urlHandlerApi: new FakeUrlHandlerApi(), + }); + store.getState().query.queryString = 'text'; + + await store.dispatch( + executeQuery({ + shouldUpdateUrlQueryString: false, + shouldReplaceUrlQueryString: false, + }) + ); + + const EXPECTED_QUERY_STATE = { + queryString: 'changed text', + sortBy: 'anotherSortBy', + sortOrder: 'anotherSortOrder', + }; + expect(store.getState().query).toMatchObject(EXPECTED_QUERY_STATE); + + const newQueryState = { + ...store.getState().query, + ...EXPECTED_QUERY_STATE, + }; + expect(urlHandlerApiReplace).toHaveBeenCalledWith(newQueryState); + }); +}); diff --git a/src/lib/state/reducers/app.js b/src/lib/state/reducers/app.js new file mode 100644 index 00000000..3ce23b4b --- /dev/null +++ b/src/lib/state/reducers/app.js @@ -0,0 +1,35 @@ +/* + * This file is part of React-SearchKit. + * Copyright (C) 2020 CERN. + * + * React-SearchKit is free software; you can redistribute it and/or modify it + * under the terms of the MIT License; see LICENSE file for more details. + */ + +import { + SET_QUERY_SORTING, + SET_QUERY_SORT_BY, + SET_QUERY_SORT_ORDER, +} from '../types'; + +export default (state = {}, action) => { + switch (action.type) { + case SET_QUERY_SORTING: + return { + ...state, + hasUserChangedSorting: true, + }; + case SET_QUERY_SORT_BY: + return { + ...state, + hasUserChangedSorting: true, + }; + case SET_QUERY_SORT_ORDER: + return { + ...state, + hasUserChangedSorting: true, + }; + default: + return state; + } +}; diff --git a/src/lib/state/reducers/index.js b/src/lib/state/reducers/index.js index 540c891f..54a4a0f7 100644 --- a/src/lib/state/reducers/index.js +++ b/src/lib/state/reducers/index.js @@ -1,17 +1,18 @@ /* * This file is part of React-SearchKit. - * Copyright (C) 2018 CERN. + * Copyright (C) 2018-2020 CERN. * * React-SearchKit is free software; you can redistribute it and/or modify it * under the terms of the MIT License; see LICENSE file for more details. */ import { combineReducers } from 'redux'; - +import appReducer from './app'; import queryReducer from './query'; import resultsReducer from './results'; export default combineReducers({ + app: appReducer, query: queryReducer, results: resultsReducer, }); diff --git a/src/lib/state/reducers/query.js b/src/lib/state/reducers/query.js index 8edf1688..7b1f4aa4 100644 --- a/src/lib/state/reducers/query.js +++ b/src/lib/state/reducers/query.js @@ -6,24 +6,24 @@ * under the terms of the MIT License; see LICENSE file for more details. */ +import { INITIAL_QUERY_STATE_KEYS } from '../../storeConfig'; +import { updateQueryFilters, updateQueryState } from '../selectors'; import { + CLEAR_QUERY_SUGGESTIONS, + RESET_QUERY, + RESULTS_UPDATE_LAYOUT, SET_QUERY_COMPONENT_INITIAL_STATE, - SET_QUERY_STRING, + SET_QUERY_FILTERS, + SET_QUERY_PAGINATION_PAGE, + SET_QUERY_PAGINATION_SIZE, SET_QUERY_SORTING, SET_QUERY_SORT_BY, SET_QUERY_SORT_ORDER, - SET_QUERY_PAGINATION_PAGE, - SET_QUERY_PAGINATION_SIZE, - SET_QUERY_FILTERS, - SET_QUERY_SUGGESTIONS, SET_QUERY_STATE, + SET_QUERY_STRING, + SET_QUERY_SUGGESTIONS, SET_SUGGESTION_STRING, - CLEAR_QUERY_SUGGESTIONS, - RESULTS_UPDATE_LAYOUT, - RESET_QUERY, } from '../types'; -import { updateQueryFilters, updateQueryState } from '../selectors'; -import { STORE_KEYS } from '../../storeConfig'; export default (state = {}, action) => { switch (action.type) { @@ -34,18 +34,21 @@ export default (state = {}, action) => { ...state, sortBy: action.payload.sortBy, sortOrder: action.payload.sortOrder, + _sortUserChanged: true, page: 1, }; case SET_QUERY_SORT_BY: return { ...state, sortBy: action.payload, + _sortUserChanged: true, page: 1, }; case SET_QUERY_SORT_ORDER: return { ...state, sortOrder: action.payload, + _sortUserChanged: true, page: 1, }; case SET_QUERY_PAGINATION_PAGE: @@ -89,7 +92,7 @@ export default (state = {}, action) => { case SET_QUERY_STATE: return { ...state, - ...updateQueryState(state, action.payload, STORE_KEYS), + ...updateQueryState(state, action.payload, INITIAL_QUERY_STATE_KEYS), }; case RESULTS_UPDATE_LAYOUT: return { diff --git a/src/lib/store.js b/src/lib/store.js index 8b9a6cc3..3a026b20 100644 --- a/src/lib/store.js +++ b/src/lib/store.js @@ -6,27 +6,31 @@ * under the terms of the MIT License; see LICENSE file for more details. */ -import { createStore, applyMiddleware } from 'redux'; import { connect } from 'react-redux'; +import { applyMiddleware, createStore } from 'redux'; import thunk from 'redux-thunk'; - import rootReducer from './state/reducers'; -import { INITIAL_STORE_STATE } from './storeConfig'; +import { + INITIAL_APP_STATE, + INITIAL_QUERY_STATE, + INITIAL_RESULTS_STATE, +} from './storeConfig'; -export function configureStore(appConfig) { +export function createStoreWithConfig(appConfig) { const initialQueryState = { - ...INITIAL_STORE_STATE, + ...INITIAL_QUERY_STATE, ...appConfig.initialQueryState, }; const initialResultsState = { + ...INITIAL_RESULTS_STATE, loading: appConfig.searchOnInit, - data: { - hits: [], - total: 0, - aggregations: {}, - }, - error: {}, + }; + + const initialAppState = { + ...INITIAL_APP_STATE, + initialSortBy: initialQueryState.sortBy, + initialSortOrder: initialQueryState.sortOrder, }; // configure the initial state @@ -34,6 +38,7 @@ export function configureStore(appConfig) { ? appConfig.urlHandlerApi.get(initialQueryState) : initialQueryState; const preloadedState = { + app: initialAppState, query: preloadedQueryState, results: initialResultsState, }; diff --git a/src/lib/storeConfig.js b/src/lib/storeConfig.js index e8777583..5f478eb2 100644 --- a/src/lib/storeConfig.js +++ b/src/lib/storeConfig.js @@ -6,7 +6,7 @@ * under the terms of the MIT License; see LICENSE file for more details. */ -export const INITIAL_STORE_STATE = { +export const INITIAL_QUERY_STATE = { queryString: '', suggestions: [], sortBy: null, @@ -18,4 +18,20 @@ export const INITIAL_STORE_STATE = { layout: null, }; -export const STORE_KEYS = Object.keys(INITIAL_STORE_STATE); +export const INITIAL_QUERY_STATE_KEYS = Object.keys(INITIAL_QUERY_STATE); + +export const INITIAL_RESULTS_STATE = { + loading: false, + data: { + hits: [], + total: 0, + aggregations: {}, + }, + error: {}, +}; + +export const INITIAL_APP_STATE = { + hasUserChangedSorting: false, + initialSortBy: null, + initialSortOrder: null, +};