diff --git a/src/actions/__fixtures__/emptyStore.js b/src/actions/__fixtures__/emptyStore.js new file mode 100644 index 00000000..60bd2831 --- /dev/null +++ b/src/actions/__fixtures__/emptyStore.js @@ -0,0 +1,21 @@ +import { aggsState as aggs } from '../../reducers/aggs/aggsSlice'; +import { detailState as detail } from '../../reducers/detail/detailSlice'; +import { filtersState as filters } from '../../reducers/filters/filtersSlice'; +import { mapState as map } from '../../reducers/map/mapSlice'; +import { queryState as query } from '../../reducers/query/querySlice'; +import { resultsState as results } from '../../reducers/results/resultsSlice'; +import { routesState as routes } from '../../reducers/routes/routesSlice'; +import { trendsState as trends } from '../../reducers/trends/trendsSlice'; +import { viewState as view } from '../../reducers/view/viewSlice'; + +export default Object.freeze({ + aggs, + detail, + filters, + map, + query, + results, + routes, + trends, + view, +}); diff --git a/src/actions/__tests__/complaints.spec.js b/src/actions/__tests__/complaints.spec.js index 82b3e9d9..c45b1f27 100644 --- a/src/actions/__tests__/complaints.spec.js +++ b/src/actions/__tests__/complaints.spec.js @@ -1,473 +1,175 @@ -import configureMockStore from 'redux-mock-store'; -import thunk from 'redux-thunk'; -import * as sut from '../complaints'; -import { - complaintsApiCalled, - complaintsApiFailed, - complaintsReceived, -} from '../../reducers/results/resultsSlice'; -import { - statesApiCalled, - statesApiFailed, - statesReceived, -} from '../../reducers/map/mapSlice'; -import { - trendsApiCalled, - trendsApiFailed, - trendsReceived, -} from '../../reducers/trends/trendsSlice'; -import { - aggregationsApiCalled, - aggregationsApiFailed, - aggregationsReceived, -} from '../../reducers/aggs/aggsSlice'; -import { - complaintDetailCalled, - complaintDetailFailed, - complaintDetailReceived, -} from '../../reducers/detail/detailSlice'; - -const middlewares = [thunk]; -const mockStore = configureMockStore(middlewares); - -/** - * - * @param {string} tab - Trends, List, Map view we are testing - * @returns {object} mocked redux store - */ -function setupStore(tab) { - return mockStore({ - aggs: { activeCall: '' }, - map: { activeCall: '' }, - query: { - tab, - }, - trends: { - activeCall: '', - }, - results: { - activeCall: '', - }, - }); -} +import * as sut from '..'; +import * as constants from '../../constants'; +import { initialState, setupStore } from '../../testUtils/setupStore'; describe('action::complaints', () => { - describe('sendHitsQuery', () => { - it('calls the Complaints API', () => { - const store = setupStore('List'); - store.dispatch(sut.sendHitsQuery()); - const expectedActions = [complaintsApiCalled('@@API?foobar')]; + let expectedHitsQS, expectedQS, fixtureStore; - expect(store.getActions()).toEqual(expectedActions); + beforeEach(() => { + expectedHitsQS = + '@@API?date_received_max=2020-05-05&date_received_min=2017-05-05&frm=0&no_aggs=true&searchField=all&size=25&sort=created_date_desc'; + }); + describe('getAggregations', () => { + beforeEach(() => { + fixtureStore = initialState(); + expectedQS = + '@@API?date_received_max=2020-05-05&date_received_min=2017-05-05&field=all&size=0'; }); - it('calls the Map API', () => { - const store = setupStore('Map'); - store.dispatch(sut.sendHitsQuery()); - const expectedActions = [ - statesApiCalled('@@APIgeo/states/?foobar&no_aggs=true'), - ]; + it('executes a chain of actions', function () { + fixtureStore.view.tab = constants.MODE_LIST; - expect(store.getActions()).toEqual(expectedActions); - }); + const store = setupStore(fixtureStore); + const url = expectedQS; - it('calls the Trends API', () => { - const store = setupStore('Trends'); - store.dispatch(sut.sendHitsQuery()); const expectedActions = [ - trendsApiCalled('@@APItrends/?foobar&no_aggs=true'), + sut.aggregationsApiCalled(url), + sut.httpGet(url, sut.aggregationsReceived, sut.aggregationsApiFailed), ]; - - expect(store.getActions()).toEqual(expectedActions); - }); - }); - - describe('getAggregations', () => { - let onSuccess, onFail, store; - - beforeEach(() => { - global.fetch = jest.fn().mockImplementation((url) => { - expect(url).toContain('@@API?foo&size=0'); - /* eslint-disable id-length */ - return { - then: (x) => { - x({ json: () => ({}) }); - return { - then: (x) => { - onSuccess = (data) => x(data); - return { - catch: (y) => { - onFail = y; - }, - }; - }, - }; - }, - }; - }); - /* eslint-enable id-length */ - store = mockStore({ - aggs: { - activeCall: '', - }, - query: { - date_received_min: new Date(2013, 1, 3), - from: 0, - has_narrative: true, - searchText: '', - size: 10, - }, - results: { - activeCall: '', - }, - }); - }); - - it('calls the API', () => { store.dispatch(sut.getAggregations()); - expect(global.fetch).toHaveBeenCalled(); + const { actions } = store.getState().actions; + expect(actions).toEqual(expectedActions); }); it('discards duplicate API calls', () => { - const state = store.getState(); - state.aggs.activeCall = '/?foo=baz'; - store = mockStore(state); + fixtureStore.aggs.activeCall = expectedQS; + const store = setupStore(fixtureStore); + const expectedActions = []; store.dispatch(sut.getAggregations()); - expect(global.fetch).not.toHaveBeenCalled(); - }); - - describe('when the API call is finished', () => { - it('sends a simple action when data is received', () => { - store.dispatch(sut.getAggregations()); - const expectedActions = [ - aggregationsApiCalled('@@API?foo&size=0'), - aggregationsReceived(['123']), - ]; - onSuccess(['123']); - expect(store.getActions()).toEqual(expectedActions); - }); - - it('sends a different simple action when an error occurs', () => { - store.dispatch(sut.getAggregations()); - const expectedActions = [ - aggregationsApiCalled('@@API?foo&size=0'), - aggregationsApiFailed({ error: 'oops' }), - ]; - onFail({ error: 'oops' }); - expect(store.getActions()).toEqual(expectedActions); - }); + const { actions } = store.getState().actions; + expect(actions).toEqual(expectedActions); }); }); describe('getComplaints', () => { - let onSuccess, onFail, store; - + let expectedUrl; beforeEach(() => { - global.fetch = jest.fn().mockImplementation((url) => { - expect(url).toContain('@@API?foo'); - /* eslint-disable id-length */ - return { - then: (x) => { - x({ json: () => ({}) }); - return { - then: (x) => { - onSuccess = (data) => x(data); - return { - catch: (y) => { - onFail = y; - }, - }; - }, - }; - }, - }; - }); - /* eslint-enable id-length */ - store = mockStore({ - query: { - date_received_min: new Date(2013, 1, 3), - from: 0, - has_narrative: true, - searchText: '', - size: 10, - }, - results: { - activeCall: '', - }, - }); + expectedUrl = expectedHitsQS; + fixtureStore.view.tab = constants.MODE_LIST; }); - it('calls the API', () => { + it('executes a chain of actions', function () { + const store = setupStore(fixtureStore); + const expectedActions = [ + sut.complaintsApiCalled(expectedUrl), + sut.httpGet( + expectedUrl, + sut.complaintsReceived, + sut.complaintsApiFailed, + ), + ]; store.dispatch(sut.getComplaints()); - expect(global.fetch).toHaveBeenCalled(); + const { actions } = store.getState().actions; + expect(actions).toEqual(expectedActions); }); it('discards duplicate API calls', () => { - const state = store.getState(); - state.results.activeCall = '@@API' + state.query.queryString; - store = mockStore(state); + fixtureStore.results.activeCall = expectedUrl; + const store = setupStore(fixtureStore); + const expectedActions = []; store.dispatch(sut.getComplaints()); - expect(global.fetch).not.toHaveBeenCalled(); - }); - - describe('when the API call is finished', () => { - it('sends a simple action when data is received', () => { - store.dispatch(sut.getComplaints()); - const expectedActions = [ - complaintsApiCalled('@@API?foo'), - complaintsReceived(['123']), - ]; - onSuccess(['123']); - expect(store.getActions()).toEqual(expectedActions); - }); - - it('sends a different simple action when an error occurs', () => { - store.dispatch(sut.getComplaints()); - const expectedActions = [ - complaintsApiCalled('@@API?foo'), - complaintsApiFailed({ error: 'oops' }), - ]; - onFail({ error: 'oops' }); - expect(store.getActions()).toEqual(expectedActions); - }); + const { actions } = store.getState().actions; + expect(actions).toEqual(expectedActions); }); }); describe('getComplaintDetail', () => { - let onSuccess, onFail; + let expectedUrl; beforeEach(() => { - global.fetch = jest.fn().mockImplementation((url) => { - expect(url).toContain('@@API123'); - /* eslint-disable id-length */ - return { - then: (x) => { - x({ json: () => ({}) }); - return { - then: (x) => { - onSuccess = (data) => x(data); - return { - catch: (y) => { - onFail = y; - }, - }; - }, - }; - }, - }; - }); + fixtureStore.view.tab = constants.MODE_DETAIL; + expectedUrl = '@@API123'; }); - /* eslint-enable id-length */ - it('calls the API', () => { - const store = mockStore({}); - store.dispatch(sut.getComplaintDetail('123')); - expect(global.fetch).toHaveBeenCalled(); - }); - - describe('when the API call is finished', () => { - let store; - beforeEach(() => { - store = mockStore({}); - store.dispatch(sut.getComplaintDetail('123')); - }); - it('sends a simple action when data is received', () => { - const expectedActions = [ - complaintDetailCalled('@@API123'), - complaintDetailReceived({ foo: 'bar' }), - ]; - onSuccess({ foo: 'bar' }); - expect(store.getActions()).toEqual(expectedActions); - }); + it('executes a series of actions', function () { + const store = setupStore(fixtureStore); + const expectedActions = [ + sut.complaintDetailCalled(expectedUrl), + sut.httpGet( + expectedUrl, + sut.complaintDetailReceived, + sut.complaintDetailFailed, + ), + ]; + store.dispatch(sut.getComplaintDetail(123)); + const { actions } = store.getState().actions; + expect(actions).toEqual(expectedActions); + }); - it('sends a different simple action when an error occurs', () => { - const expectedActions = [ - complaintDetailCalled('@@API123'), - complaintDetailFailed({ error: 'oops' }), - ]; - onFail({ error: 'oops' }); - expect(store.getActions()).toEqual(expectedActions); - }); + it('discards duplicate API calls', function () { + fixtureStore.detail.activeCall = expectedUrl; + const store = setupStore(fixtureStore); + const expectedActions = []; + store.dispatch(sut.getComplaintDetail(123)); + const { actions } = store.getState().actions; + expect(actions).toEqual(expectedActions); }); }); describe('getStates', () => { - let onSuccess, onFail, store; + let expectedUrl; beforeEach(() => { - global.fetch = jest.fn().mockImplementation((url) => { - expect(url).toContain('@@APIgeo/states/?foo&no_aggs=true'); - /* eslint-disable id-length */ - return { - then: (x) => { - x({ json: () => ({}) }); - return { - then: (x) => { - onSuccess = (data) => x(data); - return { - catch: (y) => { - onFail = y; - }, - }; - }, - }; - }, - }; - }); - /* eslint-enable id-length */ - store = mockStore({ - query: { - date_received_min: new Date(2013, 1, 3), - from: 0, - has_narrative: true, - queryString: '?foo', - searchText: '', - size: 10, - }, - map: { - activeCall: '', - }, - }); + fixtureStore.view.tab = constants.MODE_MAP; + expectedUrl = + '@@APIgeo/states/?date_received_max=2020-05-05' + + '&date_received_min=2017-05-05&frm=0&no_aggs=true&searchField=all' + + '&size=25&sort=created_date_desc&no_aggs=true'; }); - it('calls the API', () => { + it('executes a series of actions', function () { + const store = setupStore(fixtureStore); + const expectedActions = [ + sut.statesApiCalled(expectedUrl), + sut.httpGet(expectedUrl, sut.statesReceived, sut.statesApiFailed), + ]; store.dispatch(sut.getStates()); - expect(global.fetch).toHaveBeenCalled(); + const { actions } = store.getState().actions; + expect(actions).toEqual(expectedActions); }); - it('discards duplicate API calls', () => { - const state = store.getState(); - state.map.activeCall = - '@@APIgeo/states/' + state.query.queryString + '&no_aggs=true'; - store = mockStore(state); - + it('discards duplicate API calls', function () { + fixtureStore.map.activeCall = expectedUrl; + const store = setupStore(fixtureStore); + const expectedActions = []; store.dispatch(sut.getStates()); - expect(global.fetch).not.toHaveBeenCalled(); - }); - - describe('when the API call is finished', () => { - it('sends a simple action when data is received', () => { - store.dispatch(sut.getStates()); - const expectedActions = [ - statesApiCalled('@@APIgeo/states/?foo&no_aggs=true'), - statesReceived({ data: ['123'] }), - ]; - onSuccess({ data: ['123'] }); - expect(store.getActions()).toEqual(expectedActions); - }); - - it('sends a different simple action when an error occurs', () => { - store.dispatch(sut.getStates()); - const expectedActions = [ - statesApiCalled('@@APIgeo/states/?foo&no_aggs=true'), - statesApiFailed({ error: 'oops' }), - ]; - onFail({ error: 'oops' }); - expect(store.getActions()).toEqual(expectedActions); - }); + const { actions } = store.getState().actions; + expect(actions).toEqual(expectedActions); }); }); describe('getTrends', () => { - let onSuccess, onFail, store; - - /** - * - * @param {Array} company - The companies we are viewing trends for - * @param {string} lens - Aggregate by selected in trends - * @returns {object} mocked redux store - */ - function setupStore(company, lens) { - const mockState = { - query: { - company, - date_received_min: new Date(2013, 1, 3), - from: 0, - has_narrative: true, - queryString: '?foo', - searchText: '', - size: 10, - }, - trends: { - activeCall: '', - lens, - }, - }; - return mockStore(mockState); - } + let expectedUrl; beforeEach(() => { - global.fetch = jest.fn().mockImplementation((url) => { - expect(url).toContain('@@APItrends/?foo&no_aggs=true'); - /* eslint-disable id-length */ - return { - then: (x) => { - x({ json: () => ({}) }); - return { - then: (x) => { - onSuccess = (data) => x(data); - return { - catch: (y) => { - onFail = y; - }, - }; - }, - }; - }, - }; - }); - }); - /* eslint-enable id-length */ - it('calls the API', () => { - store = setupStore(); - store.dispatch(sut.getTrends()); - expect(global.fetch).toHaveBeenCalled(); + fixtureStore.view.tab = constants.MODE_TRENDS; + expectedUrl = + '@@APItrends?date_received_max=2020-05-05' + + '&date_received_min=2017-05-05&frm=0&lens=product&no_aggs=true' + + '&searchField=all&size=25&sort=created_date_desc' + + '&sub_lens=sub_product&trend_depth=5&trend_interval=month&no_aggs=true'; }); - it('discards invalid API calls', () => { - store = setupStore([], 'Company'); - const state = store.getState(); - store = mockStore(state); - + it('executes a series of actions', function () { + const store = setupStore(fixtureStore); + const expectedActions = [ + sut.trendsApiCalled(expectedUrl), + sut.httpGet(expectedUrl, sut.trendsReceived, sut.trendsApiFailed), + ]; store.dispatch(sut.getTrends()); - expect(global.fetch).not.toHaveBeenCalled(); + const { actions } = store.getState().actions; + expect(actions).toEqual(expectedActions); }); - it('discards duplicate API calls', () => { - store = setupStore(); - const state = store.getState(); - state.trends.activeCall = - '@@APItrends/' + state.query.queryString + '&no_aggs=true'; - store = mockStore(state); - + it('discards duplicate API calls', function () { + fixtureStore.trends.activeCall = expectedUrl; + const store = setupStore(fixtureStore); + const expectedActions = []; store.dispatch(sut.getTrends()); - expect(global.fetch).not.toHaveBeenCalled(); - }); - - describe('when the API call is finished', () => { - it('sends a simple action when data is received', () => { - store = setupStore(); - store.dispatch(sut.getTrends()); - const expectedActions = [ - trendsApiCalled('@@APItrends/?foo&no_aggs=true'), - trendsReceived({ data: ['123'] }), - ]; - onSuccess({ data: ['123'] }); - expect(store.getActions()).toEqual(expectedActions); - }); - - it('sends a different simple action when an error occurs', () => { - store = setupStore(); - store.dispatch(sut.getTrends()); - const expectedActions = [ - trendsApiCalled('@@APItrends/?foo&no_aggs=true'), - trendsApiFailed({ name: 'bad', error: 'oops' }), - ]; - onFail({ name: 'bad', error: 'oops' }); - expect(store.getActions()).toEqual(expectedActions); - }); + const { actions } = store.getState().actions; + expect(actions).toEqual(expectedActions); }); }); }); diff --git a/src/actions/complaints.js b/src/actions/complaints.js index 89050f41..5e6d5e74 100644 --- a/src/actions/complaints.js +++ b/src/actions/complaints.js @@ -1,10 +1,5 @@ /* eslint complexity: ["error", 5] */ -import { - API_PLACEHOLDER, - MODE_LIST, - MODE_MAP, - MODE_TRENDS, -} from '../constants'; +import { API_PLACEHOLDER } from '../constants'; import { complaintDetailCalled, complaintDetailReceived, @@ -31,59 +26,7 @@ import { complaintsReceived, } from '../reducers/results/resultsSlice'; import { buildAggregationUri, buildUri } from '../api/url/url'; - -// ---------------------------------------------------------------------------- -// Routing action -/** - * Routes to the correct endpoint based on the state - * - * @returns {Promise} a chain of promises that will update the Redux store - */ -export function sendQuery() { - // eslint-disable-next-line complexity - return (dispatch, getState) => { - const state = getState(); - const viewMode = state.view.tab; - switch (viewMode) { - case MODE_MAP: - case MODE_LIST: - case MODE_TRENDS: - dispatch(getAggregations()); - break; - default: - return; - } - - // Send the right-hand queries - dispatch(sendHitsQuery()); - }; -} - -/** - * Routes to the correct endpoint based on the state - * - * @returns {Promise} a chain of promises that will update the Redux store - */ -export function sendHitsQuery() { - // eslint-disable-next-line complexity - return (dispatch, getState) => { - const state = getState(); - const viewMode = state.view.tab; - switch (viewMode) { - case MODE_MAP: - dispatch(getStates()); - break; - case MODE_TRENDS: - dispatch(getTrends()); - break; - case MODE_LIST: - dispatch(getComplaints()); - break; - default: - break; - } - }; -} +import { httpGet } from './httpRequests/httpRequests'; // ---------------------------------------------------------------------------- // Action Creators @@ -106,10 +49,7 @@ export function getAggregations() { } dispatch(aggregationsApiCalled(uri)); - return fetch(uri) - .then((result) => result.json()) - .then((items) => dispatch(aggregationsReceived(items))) - .catch((error) => dispatch(aggregationsApiFailed(error))); + dispatch(httpGet(uri, aggregationsReceived, aggregationsApiFailed)); }; } @@ -129,12 +69,7 @@ export function getComplaints() { } dispatch(complaintsApiCalled(uri)); - return fetch(uri) - .then((result) => result.json()) - .then((items) => { - dispatch(complaintsReceived(items)); - }) - .catch((error) => dispatch(complaintsApiFailed(error))); + dispatch(httpGet(uri, complaintsReceived, complaintsApiFailed)); }; } @@ -154,10 +89,7 @@ export function getComplaintDetail(id) { } dispatch(complaintDetailCalled(uri)); - fetch(uri) - .then((result) => result.json()) - .then((data) => dispatch(complaintDetailReceived(data))) - .catch((error) => dispatch(complaintDetailFailed(error))); + dispatch(httpGet(uri, complaintDetailReceived, complaintDetailFailed)); }; } @@ -178,10 +110,7 @@ export function getStates() { } dispatch(statesApiCalled(uri)); - return fetch(uri) - .then((result) => result.json()) - .then((items) => dispatch(statesReceived(items))) - .catch((error) => dispatch(statesApiFailed(error))); + dispatch(httpGet(uri, statesReceived, statesApiFailed)); }; } @@ -209,11 +138,6 @@ export function getTrends() { } dispatch(trendsApiCalled(uri)); - return fetch(uri) - .then((result) => result.json()) - .then((items) => { - dispatch(trendsReceived(items)); - }) - .catch((error) => dispatch(trendsApiFailed(error))); + dispatch(httpGet(uri, trendsReceived, trendsApiFailed)); }; } diff --git a/src/actions/httpRequests/httpRequests.js b/src/actions/httpRequests/httpRequests.js new file mode 100644 index 00000000..7bbf2b7e --- /dev/null +++ b/src/actions/httpRequests/httpRequests.js @@ -0,0 +1,30 @@ +import { createAction } from '@reduxjs/toolkit'; + +export const HTTP_GET_REQUEST = 'HTTP_GET_REQUEST'; +export const HTTP_GET_REQUEST_SUCCEEDED = 'HTTP_GET_REQUEST_SUCCEEDED'; +export const HTTP_GET_REQUEST_FAILED = 'HTTP_GET_REQUEST_FAILED'; + +// ---------------------------------------------------------------------------- +// Action Creators +/** + * Builds an action for an HTTP GET + * + * @param {string} url - the URL to call + * @param {string} [onSuccess=HTTP_REQUEST_SUCCEEDED] - the action to dispatch if + * successful + * @param {string} [onFailure=HTTP_REQUEST_FAILED] - the action to dispatch if + * unsuccessful + * @returns {object} a packaged payload to be used by the middleware + */ +export const httpGet = createAction( + HTTP_GET_REQUEST, + function prepare(url, onSuccess, onFailure) { + return { + payload: { + url, + onSuccess: onSuccess || HTTP_GET_REQUEST_SUCCEEDED, + onFailure: onFailure || HTTP_GET_REQUEST_FAILED, + }, + }; + }, +); diff --git a/src/actions/httpRequests/httpRequests.spec.js b/src/actions/httpRequests/httpRequests.spec.js new file mode 100644 index 00000000..8bae05ea --- /dev/null +++ b/src/actions/httpRequests/httpRequests.spec.js @@ -0,0 +1,21 @@ +import * as sut from './httpRequests'; + +const url = 'http://www.example.org'; + +describe('actions:httpRequests', () => { + describe('httpGet', () => { + it('has defaults if the optional actions are not specified', () => { + const expected = { + type: sut.HTTP_GET_REQUEST, + payload: { + url, + onSuccess: sut.HTTP_GET_REQUEST_SUCCEEDED, + onFailure: sut.HTTP_GET_REQUEST_FAILED, + }, + }; + + const actual = sut.httpGet(url); + expect(actual).toEqual(expected); + }); + }); +}); diff --git a/src/actions/index.js b/src/actions/index.js index c7c51609..80a72921 100644 --- a/src/actions/index.js +++ b/src/actions/index.js @@ -1,18 +1,14 @@ -import * as analytics from './analytics'; -import * as complaints from './complaints'; -import * as routes from '../reducers/routes/routesSlice'; - -/** - * Aggregates all the known actions into one importable object - * - * @returns {object} a merged object of all available actions - */ -function combineActions() { - return { - ...analytics, - ...complaints, - ...routes, - }; -} - -export default combineActions(); +export * as Analytics from './analytics'; +export * from './complaints'; +export * from './httpRequests/httpRequests'; +export * from './routes'; +export * from '../reducers/actions/actionsSlice'; +export * from '../reducers/aggs/aggsSlice'; +export * from '../reducers/filters/filtersSlice'; +export * from '../reducers/detail/detailSlice'; +export * from '../reducers/map/mapSlice'; +export * from '../reducers/query/querySlice'; +export * from '../reducers/results/resultsSlice'; +export * from '../reducers/routes/routesSlice'; +export * from '../reducers/trends/trendsSlice'; +export * from '../reducers/view/viewSlice'; diff --git a/src/actions/sendHitsQuery/sendHitsQuery.js b/src/actions/sendHitsQuery/sendHitsQuery.js new file mode 100644 index 00000000..1154b639 --- /dev/null +++ b/src/actions/sendHitsQuery/sendHitsQuery.js @@ -0,0 +1,28 @@ +import * as actions from '../complaints'; +import * as constants from '../../constants'; + +/** + * Routes to the correct endpoint based on the state + * + * @returns {Promise} a chain of promises that will update the Redux store + */ +export function sendHitsQuery() { + // eslint-disable-next-line complexity + return (dispatch, getState) => { + const state = getState(); + const viewMode = state.view.tab; + switch (viewMode) { + case constants.MODE_MAP: + dispatch(actions.getStates()); + break; + case constants.MODE_TRENDS: + dispatch(actions.getTrends()); + break; + case constants.MODE_LIST: + dispatch(actions.getComplaints()); + break; + default: + break; + } + }; +} diff --git a/src/actions/sendHitsQuery/sendHitsQuery.spec.js b/src/actions/sendHitsQuery/sendHitsQuery.spec.js new file mode 100644 index 00000000..aab3e24f --- /dev/null +++ b/src/actions/sendHitsQuery/sendHitsQuery.spec.js @@ -0,0 +1,64 @@ +/* eslint-disable max-nested-callbacks, no-empty-function, camelcase */ +import * as sutComplaints from '../complaints'; +import * as sutHits from './sendHitsQuery'; +import cloneDeep from 'lodash/cloneDeep'; +import * as constants from '../../constants'; +import emptyStore from '../__fixtures__/emptyStore'; + +describe('api::sendHitsQuery', () => { + let fixtureStore; + + beforeEach(function () { + fixtureStore = cloneDeep(emptyStore); + }); + + describe('sendHitsQuery', () => { + let dispatch, getState, spy1, spy2; + beforeEach(() => { + dispatch = jest.fn(); + getState = () => fixtureStore; + }); + + afterEach(() => { + if (spy1 && {}.propertyIsEnumerable.call(spy1, 'mockRestore')) { + spy1.mockRestore(); + } + if (spy2 && {}.propertyIsEnumerable.call(spy2, 'mockRestore')) { + spy2.mockRestore(); + } + }); + + it('executes a specific chain of actions in Map mode', () => { + fixtureStore.view.tab = constants.MODE_MAP; + spy1 = jest + .spyOn(sutComplaints, 'getStates') + .mockImplementation(() => jest.fn()); + + sutHits.sendHitsQuery()(dispatch, getState); + expect(dispatch.mock.calls.length).toEqual(1); + expect(spy1).toHaveBeenCalledTimes(1); + }); + + it('executes a specific chain of actions in List Complaints mode', () => { + fixtureStore.view.tab = constants.MODE_LIST; + spy1 = jest + .spyOn(sutComplaints, 'getComplaints') + .mockImplementation(() => jest.fn()); + + sutHits.sendHitsQuery()(dispatch, getState); + expect(dispatch.mock.calls.length).toEqual(1); + expect(spy1).toHaveBeenCalledTimes(1); + }); + + it('executes a specific set of actions in Trends mode', () => { + fixtureStore.view.tab = constants.MODE_TRENDS; + spy1 = jest + .spyOn(sutComplaints, 'getTrends') + .mockImplementation(() => jest.fn()); + + sutHits.sendHitsQuery()(dispatch, getState); + expect(dispatch.mock.calls.length).toEqual(1); + expect(spy1).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/src/actions/sendQuery/sendQuery.js b/src/actions/sendQuery/sendQuery.js new file mode 100644 index 00000000..b34af52e --- /dev/null +++ b/src/actions/sendQuery/sendQuery.js @@ -0,0 +1,28 @@ +import * as actions from '../complaints'; +import * as constants from '../../constants'; +import { sendHitsQuery } from '../sendHitsQuery/sendHitsQuery'; + +/** + * Routes to the correct endpoint based on the state + * + * @returns {Promise} a chain of promises that will update the Redux store + */ +export function sendQuery() { + // eslint-disable-next-line complexity + return (dispatch, getState) => { + const state = getState(); + const viewMode = state.view.tab; + switch (viewMode) { + case constants.MODE_MAP: + case constants.MODE_LIST: + case constants.MODE_TRENDS: + dispatch(actions.getAggregations()); + break; + default: + return; + } + + // Send the right-hand queries + dispatch(sendHitsQuery()); + }; +} diff --git a/src/actions/sendQuery/sendQuery.spec.js b/src/actions/sendQuery/sendQuery.spec.js new file mode 100644 index 00000000..f48c67e9 --- /dev/null +++ b/src/actions/sendQuery/sendQuery.spec.js @@ -0,0 +1,54 @@ +/* eslint-disable max-nested-callbacks, no-empty-function, camelcase */ + +import * as sutComplaints from '../complaints'; +import * as sutSQ from '../sendQuery/sendQuery'; +import * as sutHits from '../sendHitsQuery/sendHitsQuery'; +import cloneDeep from 'lodash/cloneDeep'; +import * as constants from '../../constants'; +import emptyStore from '../__fixtures__/emptyStore'; + +describe('api::sendQuery', () => { + let fixtureStore; + beforeEach(function () { + fixtureStore = cloneDeep(emptyStore); + }); + + describe('sendQuery', () => { + let dispatch, getState, agSpy, hitSpy; + + beforeEach(function () { + dispatch = jest.fn(); + getState = () => fixtureStore; + agSpy = jest.spyOn(sutComplaints, 'getAggregations'); + hitSpy = jest.spyOn(sutHits, 'sendHitsQuery'); + }); + + afterEach(function () { + agSpy.mockRestore(); + hitSpy.mockRestore(); + }); + + it('ignores unknown view modes', function () { + fixtureStore.view.tab = 'NOTHING'; + sutSQ.sendQuery()(dispatch, getState); + expect(dispatch).not.toHaveBeenCalled(); + }); + + const doubleQueries = [ + constants.MODE_LIST, + constants.MODE_MAP, + constants.MODE_TRENDS, + ]; + + doubleQueries.forEach((mode) => { + it(`executes a set of actions in ${mode} mode`, () => { + fixtureStore.view.tab = mode; + + sutSQ.sendQuery()(dispatch, getState); + expect(dispatch.mock.calls.length).toEqual(2); + expect(agSpy).toHaveBeenCalled(); + expect(hitSpy).toHaveBeenCalled(); + }); + }); + }); +}); diff --git a/src/api/message/message.js b/src/api/message/message.js new file mode 100644 index 00000000..0d8c2d06 --- /dev/null +++ b/src/api/message/message.js @@ -0,0 +1,31 @@ +/** + * Dispatch and override the action to persist query string + * + * @param {object} config - the details of the HTTP Request + * @param {object} response - the current response from an HTTP request + * @param {Function} successAction - the action type of a successful message + * @param {object} store - the current state of all reducers + */ +export function onResponse(config, response, successAction, store) { + const actionPayload = { + data: response.data, + context: config, + }; + + store.dispatch(successAction(actionPayload)); +} + +// /** +// * Reverses the logic applied in the extract*** functions +// * +// * @param {object} params - the parameters returned from an API call +// * @returns {object} a version of the store based on the params +// */ +// export function parseParamsToStore(params) { +// return { +// comparisons: parsers.parseParamsToCompare(params), +// query: parsers.parseParamsToQuery(params), +// trends: parsers.parseParamsToTrends(params), +// viewModel: parsers.parseParamsToViewModel(params), +// }; +// } diff --git a/src/api/message/message.spec.js b/src/api/message/message.spec.js new file mode 100644 index 00000000..b1ef210c --- /dev/null +++ b/src/api/message/message.spec.js @@ -0,0 +1,43 @@ +/* eslint-disable max-nested-callbacks */ +import * as actions from '../../actions/index'; +import * as sut from './message'; + +import { setupStore } from '../../testUtils/setupStore'; +import { act } from '@testing-library/react'; + +describe('message', () => { + describe('onResponse', () => { + let store, parseSpy; + beforeEach(() => { + store = setupStore(); + parseSpy = jest.spyOn(sut, 'parseParamsToStore').mockReturnValue('beta'); + jest.useFakeTimers(); + }); + + afterEach(() => { + parseSpy.mockRestore(); + jest.clearAllTimers(); + }); + + it('passes on actions onResponse', async () => { + const aggReceived = actions.aggregationsReceived; + sut.onResponse( + {}, + { data: { aggregations: {}, hits: { total: { value: 99 } } } }, + aggReceived, + store, + ); + await act(() => jest.runOnlyPendingTimers()); + const expectedActions = store.getState().actions.actions; + expect(expectedActions).toEqual([ + { + type: aggReceived().type, + payload: { + context: {}, + data: { aggregations: {}, hits: { total: { value: 99 } } }, + }, + }, + ]); + }); + }); +}); diff --git a/src/api/params/params.js b/src/api/params/params.js index a2c8c8ca..370dfc5f 100644 --- a/src/api/params/params.js +++ b/src/api/params/params.js @@ -25,6 +25,9 @@ export function extractAggregationParams(state) { set1.date_received_min = query.date_received_min; } + if (query.searchField) { + set1.field = query.searchField; + } if (query.searchText) { set1.search_term = query.searchText; } @@ -105,7 +108,7 @@ export function extractReducerAttributes(reducer, attributes) { export function extractQueryParams(queryState) { const query = queryState; const params = { - searchField: query.searchFields ?? 'all', + searchField: query.searchField ?? 'all', // edge case for doc complaint override in // actions/complaints.js frm: diff --git a/src/api/params/params.spec.js b/src/api/params/params.spec.js index c6c21897..0f7f3a91 100644 --- a/src/api/params/params.spec.js +++ b/src/api/params/params.spec.js @@ -1,23 +1,19 @@ -/* eslint-disable camelcase, no-empty-function, max-nested-callbacks */ - import cloneDeep from 'lodash/cloneDeep'; import * as constants from '../../constants'; -import emptyStore from '../../../actions/__fixtures__/emptyStore'; +import emptyStore from '../../actions/__fixtures__/emptyStore'; import * as sut from './params'; describe('api.v2.params', () => { let fixtureStore, actual; beforeEach(() => { fixtureStore = cloneDeep(emptyStore); - fixtureStore.query.dateRange.datePeriod = ''; - fixtureStore.query.dateRange.from = '2011-07-21'; - fixtureStore.query.dateRange.to = '2018-01-01'; + fixtureStore.query.date_received_min = '2011-07-21'; + fixtureStore.query.date_received_max = '2018-01-01'; fixtureStore.view.tab = constants.MODE_LIST; }); describe('extractAggregationParams', () => { it('handles missing dates', () => { - delete fixtureStore.query.dateRange; actual = sut.extractAggregationParams(fixtureStore); expect(actual).toEqual({ field: 'all', @@ -38,82 +34,6 @@ describe('api.v2.params', () => { }); }); - describe('extractCompareParams', () => { - beforeEach(() => { - fixtureStore.comparisons = { - compareItem: 'Important Item', - selectedCompareType: 'Something', - }; - fixtureStore.viewModel = { - interval: 'month', - }; - }); - - it('gets compare params', () => { - actual = sut.extractCompareParams(fixtureStore); - expect(actual).toEqual({ - compareItem: 'Important Item', - lens: 'sent_to', - selectedCompareType: 'something', - trend_interval: 'month', - }); - }); - }); - - describe('extractGeoParams', () => { - beforeEach(() => { - fixtureStore.almanac = { - almanacId: '123', - almanacLevel: 'County', - }; - - fixtureStore.geo = { - boundingBox: false, - center: { lat: 99, lng: 99 }, - centroidsEnabled: true, - mapType: 'leaflet', - geographicLevel: 'baz', - geoShading: 'shady', - zoom: 7.3, - }; - }); - - it('gets geo params - leaflet', () => { - fixtureStore.geo.boundingBox = { - north: 1, - south: 1, - east: 1, - west: 1, - }; - - actual = sut.extractGeoParams(fixtureStore); - expect(actual).toEqual({ - almanacId: '123', - almanacLevel: 'County', - centroidsEnabled: true, - geographicLevel: 'baz', - geoShading: 'shady', - lat: '99', - lng: '99', - zoom: '7.3', - north: '1', - south: '1', - west: '1', - east: '1', - }); - }); - - it('gets geo params - tile', () => { - fixtureStore.almanac = {}; - fixtureStore.geo.mapType = 'tile'; - - actual = sut.extractGeoParams(fixtureStore); - expect(actual).toEqual({ - geographicLevel: 'baz', - }); - }); - }); - describe('extractReducerAttributes', () => { it('extracts listed attributes', () => { const reducer = { @@ -126,40 +46,6 @@ describe('api.v2.params', () => { }); }); - describe('extractSavedListQueryParams', () => { - beforeEach(() => { - fixtureStore.query = { - from: 0, - _index: 'complaint-crdb', - searchAfter: '', - size: 10, - sort: 'Newest to oldest', - }; - }); - it('gets query params without search after', () => { - actual = sut.extractSavedListQueryParams(fixtureStore); - expect(actual).toEqual({ - frm: 0, - index_name: 'complaint-crdb', - no_aggs: true, - size: 10, - sort: 'created_date_desc', - }); - }); - it('gets query params with search after', () => { - fixtureStore.query.searchAfter = '123_4560-2345'; - actual = sut.extractSavedListQueryParams(fixtureStore); - expect(actual).toEqual({ - frm: 0, - index_name: 'complaint-crdb', - no_aggs: true, - search_after: '123_4560-2345', - size: 10, - sort: 'created_date_desc', - }); - }); - }); - describe('extractTrendsParams', () => { beforeEach(() => { fixtureStore.trends = { @@ -196,50 +82,14 @@ describe('api.v2.params', () => { describe('parseParams', () => { beforeEach(() => { - fixtureStore.comparisons.compareItem = 'Important Item'; fixtureStore.query.searchText = 'foo'; fixtureStore.trends.lens = 'Issue'; fixtureStore.trends.subLens = 'Sub-issue'; }); - describe('parseParamsToCompare', () => { - it('reverses extractCompareParams', () => { - const forward = sut.extractCompareParams(fixtureStore); - const reversed = sut.parseParamsToCompare(forward); - expect(fixtureStore.comparisons).toEqual( - expect.objectContaining(reversed), - ); - }); - - it('extracts compare_company', () => { - const params = { - compare_company: 'Axnm Inc', - selectedCompareType: 'company', - }; - actual = sut.parseParamsToCompare(params); - expect(actual).toEqual({ - compareItem: 'Axnm Inc', - selectedCompareType: 'Company', - }); - }); - }); - describe('parseParamsToQuery', () => { - it('reverses extractQueryParams', () => { - const forward = sut.extractQueryParams(fixtureStore.query); - const reversed = sut.parseParamsToQuery(forward); - expect(fixtureStore.query).toEqual(expect.objectContaining(reversed)); - }); - - it('handles created date sort', () => { - fixtureStore.query.sort = 'Newest to oldest'; - const forward = sut.extractQueryParams(fixtureStore.query); - const reversed = sut.parseParamsToQuery(forward); - expect(fixtureStore.query).toEqual(expect.objectContaining(reversed)); - }); - it('handles bogus searchFieldMap', () => { - fixtureStore.query.searchFields = 'Bogus value'; + fixtureStore.query.searchField = 'Bogus value'; const actual = sut.extractQueryParams(fixtureStore.query); expect(actual.field).toEqual('all'); }); @@ -268,14 +118,6 @@ describe('api.v2.params', () => { }); describe('parseParamsToViewModel', () => { - it('reverses extractCompareParams', () => { - const forward = sut.extractCompareParams(fixtureStore); - const reversed = sut.parseParamsToViewModel(forward); - expect(fixtureStore.viewModel).toEqual( - expect.objectContaining(reversed), - ); - }); - it('reverses extractTrendsParams', () => { const forward = sut.extractTrendsParams(fixtureStore); const reversed = sut.parseParamsToViewModel(forward); diff --git a/src/api/url/url.js b/src/api/url/url.js index fd5c6f37..0274312d 100644 --- a/src/api/url/url.js +++ b/src/api/url/url.js @@ -65,14 +65,3 @@ export function buildUri(state) { export function formatUri(path, params) { return path + '?' + queryString.stringify(params); } - -/** - * Generates a link for each document. - * - * @param {string} indexPath - complaints or tys. - * @param {string} referenceNumber - The complaint id. - * @returns {string} The document url. - */ -export function genDocumentHref(indexPath, referenceNumber = '') { - return '/' + indexPath + '/document?id=' + referenceNumber; -} diff --git a/src/api/url/url.spec.js b/src/api/url/url.spec.js index cb6a8043..e640b9a6 100644 --- a/src/api/url/url.spec.js +++ b/src/api/url/url.spec.js @@ -2,15 +2,15 @@ import cloneDeep from 'lodash/cloneDeep'; import * as constants from '.././../constants'; -import emptyStore from '../../../actions/__fixtures__/emptyStore'; +import emptyStore from '../../actions/__fixtures__/emptyStore'; import * as sut from './url'; describe('api.v2.url', () => { let fixtureStore; beforeEach(() => { fixtureStore = cloneDeep(emptyStore); - fixtureStore.query.dateRange.from = '2011-07-21'; - fixtureStore.query.dateRange.to = '2018-01-01'; + fixtureStore.query.date_received_min = '2011-07-21'; + fixtureStore.query.date_received_max = '2018-01-01'; fixtureStore.view.tab = constants.MODE_LIST; }); @@ -18,68 +18,37 @@ describe('api.v2.url', () => { let expectedQS; beforeEach(() => { expectedQS = - '?census_year=2021&date_received_max=2018-01-01' + - '&date_received_min=2011-07-21&field=all' + - '&index_name=complaint-crdb&size=0'; + '?date_received_max=2018-01-01&date_received_min=2011-07-21' + + '&field=all&size=0'; }); it('supports MODE_MAP', () => { fixtureStore.view.tab = constants.MODE_MAP; const actual = sut.buildAggregationUri(fixtureStore); - expect(actual).toEqual('/api/v2/complaints' + expectedQS); + expect(actual).toEqual(expectedQS); }); }); describe('buildUri', () => { - it('accepts an arbitrary path', () => { - const path = '/foo'; - const actual = sut.buildUri(fixtureStore, path); - expect(actual.substring(0, path.length)).toEqual(path); - }); - - it('does not support unknown modes', () => { - fixtureStore.view.tab = 'woo!'; - const actual = () => { - sut.buildUri(fixtureStore); - }; - expect(actual).toThrow('V2 does not currently support woo!'); - }); - it('works in map mode', () => { fixtureStore.view.tab = constants.MODE_MAP; - const path = '/q/map'; - const actual = sut.buildUri(fixtureStore, path); + const actual = sut.buildUri(fixtureStore); expect(actual).toEqual( - '/q/map?census_year=2021¢roidsEnabled=true' + - '&date_received_max=2018-01-01&date_received_min=2011-07-21' + - '&field=all&frm=0&geoShading=Complaints%20per%201000%20pop.' + - '&geographicLevel=State' + - '&index_name=complaint-crdb' + - '&lat=36.935&lng=-95.45&no_aggs=true&size=10' + - '&sort=relevance_desc&zoom=4', + '?date_received_max=2018-01-01&date_received_min=2011-07-21' + + '&frm=0&no_aggs=true&searchField=all&size=25&sort=created_date_desc', ); }); it('works in trends mode', () => { fixtureStore.view.tab = constants.MODE_TRENDS; - - const path = '/q/trends'; - const actual = sut.buildUri(fixtureStore, path); + const actual = sut.buildUri(fixtureStore); expect(actual).toEqual( - '/q/trends?census_year=2021&date_received_max=2018-01-01' + - '&date_received_min=2011-07-21&field=all&frm=0' + - '&index_name=complaint-crdb' + - '&lens=overview&no_aggs=true&size=10&sort=relevance_desc' + - '&trend_depth=10&trend_interval=month', + '?date_received_max=2018-01-01&date_received_min=2011-07-21' + + '&frm=0&lens=product&no_aggs=true&searchField=all&size=25' + + '&sort=created_date_desc&sub_lens=sub_product&trend_depth=5' + + '&trend_interval=month', ); }); }); - - describe('genDocumentHref', () => { - it('creates urls for documents in with correct indexPath', () => { - const actual = sut.genDocumentHref('complaints', '867-5309'); - expect(actual).toContain('complaints/document?id=867-5309'); - }); - }); }); diff --git a/src/app/store.js b/src/app/store.js index 532710d9..117e6a0e 100644 --- a/src/app/store.js +++ b/src/app/store.js @@ -10,6 +10,8 @@ import routesReducer from '../reducers/routes/routesSlice'; import trendsReducer from '../reducers/trends/trendsSlice'; import viewReducer from '../reducers/view/viewSlice'; import { configureStore } from '@reduxjs/toolkit'; +import { HTTP_GET_REQUEST } from '../actions/httpRequests/httpRequests'; +import httpRequestHandler from '../middleware/httpRequestHandler/httpRequestHandler'; export default configureStore({ devTools: true, @@ -25,5 +27,10 @@ export default configureStore({ view: viewReducer, }, middleware: (getDefaultMiddleware) => - getDefaultMiddleware().concat([queryManager, synchUrl]), + getDefaultMiddleware({ + serializableCheck: { + // Ignore these action types + ignoredActions: [HTTP_GET_REQUEST], + }, + }).concat([queryManager, synchUrl, httpRequestHandler]), }); diff --git a/src/components/Map/MapPanel.js b/src/components/Map/MapPanel.js index 291d5bc1..795b1795 100644 --- a/src/components/Map/MapPanel.js +++ b/src/components/Map/MapPanel.js @@ -52,13 +52,16 @@ export const MapPanel = () => { const dispatch = useDispatch(); const total = useSelector(selectAggsTotal); + const enablePer1000 = useSelector(selectFiltersEnablePer1000); + const mapWarningEnabled = useSelector(selectFiltersMapWarningEnabled); + const activeCall = useSelector(selectMapActiveCall); const results = useSelector(selectMapResults); const hasError = useSelector(selectMapError); - const enablePer1000 = useSelector(selectFiltersEnablePer1000); - const mapWarningEnabled = useSelector(selectFiltersMapWarningEnabled); + const maxDate = useSelector(selectQueryDateReceivedMax); const minDate = useSelector(selectQueryDateReceivedMin); + const expandedRows = useSelector(selectViewExpandedRows); const width = useSelector(selectViewWidth); const hasMobileFilters = width < 750; diff --git a/src/components/Map/MapPanel.spec.js b/src/components/Map/MapPanel.spec.js index 3f06103d..6e2a7404 100644 --- a/src/components/Map/MapPanel.spec.js +++ b/src/components/Map/MapPanel.spec.js @@ -1,5 +1,6 @@ import React from 'react'; import { aggsState } from '../../reducers/aggs/aggsSlice'; +import { filtersState } from '../../reducers/filters/filtersSlice'; import { mapState } from '../../reducers/map/mapSlice'; import { queryState } from '../../reducers/query/querySlice'; import { viewState } from '../../reducers/view/viewSlice'; @@ -16,17 +17,20 @@ import * as viewActions from '../../reducers/filters/filtersSlice'; describe('MapPanel', () => { const renderComponent = ( newAggsState, + newFiltersState, newMapState, newQueryState, newViewState, ) => { merge(newAggsState, aggsState); + merge(newFiltersState, filtersState); merge(newMapState, mapState); merge(newQueryState, queryState); merge(newViewState, viewState); const data = { aggs: newAggsState, + filters: newFiltersState, map: newMapState, query: newQueryState, view: newViewState, @@ -38,7 +42,7 @@ describe('MapPanel', () => { }; it('renders empty state without crashing', () => { - renderComponent({}, {}, {}, {}); + renderComponent({}, {}, {}, {}, {}); expect(screen.getByText(/Showing 0 total complaints/)).toBeInTheDocument(); expect(screen.getByRole('button', { name: /Trends/ })).toBeInTheDocument(); expect(screen.getByRole('button', { name: /List/ })).toBeInTheDocument(); @@ -64,10 +68,15 @@ describe('MapPanel', () => { total: items.length, }; + const filters = { + enablePer1000: false, + has_narrative: true, + mapWarningEnabled: true, + }; + const map = { error: false, results: { - issue: [], product: [], state: [], }, @@ -76,17 +85,11 @@ describe('MapPanel', () => { const query = { date_received_min: new Date('7/10/2017'), date_received_max: new Date('7/10/2020'), - enablePer1000: false, - // this filter is necessary for the reducer validation - has_narrative: true, - mapWarningEnabled: true, - issue: [], - product: [], - tab: MODE_MAP, }; const view = { expandedRows: [], + tab: MODE_MAP, width: 1000, }; @@ -94,7 +97,7 @@ describe('MapPanel', () => { .spyOn(viewActions, 'mapWarningDismissed') .mockReturnValue(jest.fn()); - renderComponent(aggs, map, query, view); + renderComponent(aggs, filters, map, query, view); expect( screen.getByText(/Showing 2 matches out of 100 total complaints/), ).toBeInTheDocument(); @@ -124,10 +127,15 @@ describe('MapPanel', () => { total: items.length, }; + const filters = { + enablePer1000: true, + has_narrative: true, + mapWarningEnabled: false, + }; + const map = { error: true, results: { - issue: [], product: [], state: [], }, @@ -136,21 +144,15 @@ describe('MapPanel', () => { const query = { date_received_min: new Date('7/10/2017'), date_received_max: new Date('7/10/2020'), - enablePer1000: true, - // this filter is necessary for the reducer validation - has_narrative: true, - mapWarningEnabled: false, - issue: [], - product: [], - tab: MODE_MAP, }; const view = { expandedRows: [], + tab: MODE_MAP, width: 1000, }; - renderComponent(aggs, map, query, view); + renderComponent(aggs, filters, map, query, view); expect( screen.getByText(/Showing 2 matches out of 100 total complaints/), ).toBeInTheDocument(); diff --git a/src/components/Search/SearchPanel.js b/src/components/Search/SearchPanel.js index 2ac031a4..25b5a20c 100644 --- a/src/components/Search/SearchPanel.js +++ b/src/components/Search/SearchPanel.js @@ -3,7 +3,7 @@ import { useSelector } from 'react-redux'; import { PillPanel } from './PillPanel'; import { SearchBar } from './SearchBar'; import { selectAggsLastIndexed } from '../../reducers/aggs/selectors'; -import { formatDate } from '../../utils/formatDate'; +import { formatDisplayDate } from '../../utils/formatDate'; export const SearchPanel = () => { const lastIndexed = useSelector(selectAggsLastIndexed); @@ -12,7 +12,7 @@ export const SearchPanel = () => { if (lastIndexed) { lastIndexedMessage = ( - (last updated: {formatDate(lastIndexed)}) + (last updated: {formatDisplayDate(lastIndexed)}) ); } diff --git a/src/middleware/httpRequestHandler/httpRequestHandler.js b/src/middleware/httpRequestHandler/httpRequestHandler.js new file mode 100644 index 00000000..0a1c5690 --- /dev/null +++ b/src/middleware/httpRequestHandler/httpRequestHandler.js @@ -0,0 +1,102 @@ +import { HTTP_GET_REQUEST } from '../../actions/httpRequests/httpRequests'; +import { onResponse } from '../../api/message/message'; + +/** + * Borrowed from https://stackoverflow.com/a/70117817/659014 + * + * @param {Response} res - Response coming from url call. + * @returns {Promise} promise from Fetch API + */ +export const handleResponse = (res) => { + if (res.ok || (res.status >= 400 && res.status < 500)) { + return res + .json() + .then((result) => Promise.resolve(result)) + .catch(() => + Promise.resolve({ + status: res.status, + message: res.statusText, + }), + ); + } + + return Promise.reject(res); +}; + +/** + * This is a compacted version of + * + * function exampleMiddleware(storeAPI) { + * return function wrapDispatch(next) { + * return function handleAction(action) { + * // Do anything here: pass the action onwards with next(action), + * // or restart the pipeline with storeAPI.dispatch(action) + * // Can also use storeAPI.getState() here + * + * return next(action) + * } + * } + * } + * + * Further reading https://redux.js.org/advanced/middleware + * + * @param {object} store - The Redux store. + * @returns {Function} a closure around the Redux middleware function + */ +export const httpRequestHandler = (store) => (next) => async (action) => { + if (![HTTP_GET_REQUEST].includes(action.type)) { + return next(action); + } + + // default config + const config = { + url: action.payload.url, + method: 'GET', + mode: 'cors', + credentials: 'include', + }; + + const responseData = {}; + + return fetch(config.url, config) + .then((response) => { + responseData.status = response.status; + responseData.statusText = response.statusText; + return Promise.resolve(handleResponse(response)); + }) + .then((data) => { + if (data.error || responseData.status >= 400) { + responseData.data = data; + throw Error(responseData.statusText); + } else { + onResponse(config, { data }, action.payload.onSuccess, store); + } + }) + .catch((error) => { + const actionError = {}; + if (responseData.data) { + actionError.status = responseData.status; + actionError.statusText = + responseData.data.error || 'Something went wrong'; + } else { + actionError.status = error.status; + actionError.statusText = error.statusText || 'Something went wrong'; + } + + store.dispatch( + action.payload.onFailure({ + error: { + status: actionError.status, + statusText: actionError.statusText, + }, + context: config, + }), + ); + + if (responseData.status === 403) + // redirect to root / login when unauthorized response + window.location.assign('/'); + }); +}; + +export default httpRequestHandler; diff --git a/src/middleware/httpRequestHandler/httpRequestHandler.spec.js b/src/middleware/httpRequestHandler/httpRequestHandler.spec.js new file mode 100644 index 00000000..1c52ef6b --- /dev/null +++ b/src/middleware/httpRequestHandler/httpRequestHandler.spec.js @@ -0,0 +1,214 @@ +import * as httpActions from '../../actions/httpRequests/httpRequests'; +import httpRequestHandler from './httpRequestHandler'; +import * as api from '../../api/message/message'; +import { act } from '@testing-library/react'; +import { initialState, setupStore } from '../../testUtils/setupStore'; + +/** + * Provide an empty implementation for window.location.assign. + */ +function mockLocationAssign() { + delete window.location; + window.location = { + assign: jest.fn(), + href: 'http://ccdb-website.gov', + }; +} + +const { location } = window; + +describe('redux middleware::httpRequestHandler', () => { + beforeEach(() => { + mockLocationAssign(); + }); + + afterEach(() => { + window.location = location; + jest.resetAllMocks(); + }); + + describe('other ACTIONS', () => { + it('does nothing when not GET OR POST', async () => { + const action = { + type: 'FAKE_ACTION', + payload: 'foo', + }; + + const store = setupStore(initialState(), httpRequestHandler); + store.dispatch(action); + const { actions } = store.getState().actions; + expect(actions).toEqual([ + { + payload: 'foo', + type: 'FAKE_ACTION', + }, + ]); + }); + }); + + describe('HTTP_GET_REQUEST', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.clearAllTimers(); + jest.resetAllMocks(); + }); + + it('fetches successfully data from an API', async () => { + const expectedData = { + data: { foo: 'bar' }, + }; + + const fetchMock = jest.spyOn(global, 'fetch').mockImplementationOnce(() => + Promise.resolve({ + ok: true, + status: 200, + json: () => Promise.resolve({ foo: 'bar' }), + }), + ); + + const successAction = jest.fn(); + const failAction = jest.fn(); + const failSpy = jest.fn(); + const action = { + type: httpActions.HTTP_GET_REQUEST, + payload: { + onSuccess: successAction, + onFailure: failAction, + url: 'http://localhost/foo', + }, + }; + + const config = { + url: action.payload.url, + method: 'GET', + mode: 'cors', + credentials: 'include', + }; + + const onResponseMockGetSuccess = jest + .spyOn(api, 'onResponse') + .mockImplementation(() => jest.fn()); + + const store = setupStore(initialState(), httpRequestHandler); + store.dispatch(action); + expect(fetchMock).toHaveBeenCalledWith(action.payload.url, config); + + // allow any pending promises to be resolved + await act(() => { + jest.runOnlyPendingTimers(); + }); + const { actions } = store.getState().actions; + expect(actions).toEqual([action]); + expect(failAction).not.toHaveBeenCalled(); + expect(failSpy).not.toHaveBeenCalled(); + await expect(onResponseMockGetSuccess).toHaveBeenCalledTimes(1); + expect(onResponseMockGetSuccess).toHaveBeenCalledWith( + config, + expectedData, + successAction, + expect.anything(), + ); + }); + + it('fetches erroneously data from an API', async () => { + const successSpy = jest.fn(); + const failSpy = jest.fn(); + + const subAction = () => { + return () => { + failSpy(); + }; + }; + + const action = { + type: httpActions.HTTP_GET_REQUEST, + payload: { + onSuccess: successSpy, + onFailure: subAction, + url: 'http://localhost/foo', + }, + }; + + const config = { + url: action.payload.url, + method: 'GET', + mode: 'cors', + credentials: 'include', + }; + + const fetchMock = jest.spyOn(global, 'fetch').mockImplementationOnce(() => + Promise.resolve({ + ok: false, + status: 500, + json: () => Promise.resolve({ foo: 'bar' }), + }), + ); + + const store = setupStore(initialState(), httpRequestHandler); + store.dispatch(action); + + expect(fetchMock).toHaveBeenCalledWith(action.payload.url, config); + + // allow any pending promises to be resolved + await act(() => { + jest.runOnlyPendingTimers(); + }); + + expect(failSpy).toHaveBeenCalledTimes(1); + }); + + it('redirect when access denied from an API', async () => { + const successSpy = jest.fn(); + const failSpy = jest.fn(); + + const action = { + type: httpActions.HTTP_GET_REQUEST, + payload: { + onSuccess: successSpy, + onFailure: () => { + return { + type: httpActions.HTTP_GET_REQUEST, + payload: { + onFailure: failSpy, + url: 'http://localhost.com/bar', + }, + }; + }, + url: 'http://localhost/foo', + }, + }; + const config = { + url: action.payload.url, + method: 'GET', + mode: 'cors', + credentials: 'include', + }; + + const fetchMock = jest.spyOn(global, 'fetch').mockImplementation(() => + Promise.resolve({ + ok: false, + status: 403, + json: () => Promise.resolve({ foo: 'denied' }), + }), + ); + + const onResponseMockGetDenied = jest + .spyOn(api, 'onResponse') + .mockImplementation(() => jest.fn()); + + const store = setupStore(initialState(), httpRequestHandler); + store.dispatch(action); + expect(fetchMock).toHaveBeenCalledWith(action.payload.url, config); + // allow any pending promises to be resolved + await act(() => { + jest.runOnlyPendingTimers(); + }); + + expect(onResponseMockGetDenied).toHaveBeenCalledTimes(0); + expect(window.location.assign).toHaveBeenCalledWith('/'); + }); + }); +}); diff --git a/src/middleware/queryManager/queryManager.js b/src/middleware/queryManager/queryManager.js index dfa19003..a134ae9d 100644 --- a/src/middleware/queryManager/queryManager.js +++ b/src/middleware/queryManager/queryManager.js @@ -1,5 +1,6 @@ import * as constants from '../../constants'; -import { sendHitsQuery, sendQuery } from '../../actions/complaints'; +import { sendQuery } from '../../actions/sendQuery/sendQuery'; +import { sendHitsQuery } from '../../actions/sendHitsQuery/sendHitsQuery'; export const queryManager = (store) => (next) => async (action) => { // call the next function diff --git a/src/middleware/synchUrl/synchUrl.js b/src/middleware/synchUrl/synchUrl.js index 600f2308..80bef892 100644 --- a/src/middleware/synchUrl/synchUrl.js +++ b/src/middleware/synchUrl/synchUrl.js @@ -37,7 +37,7 @@ function getQueryAttrs(tab) { 'date_received_min', 'date_received_max', 'searchText', - 'searchFields', + 'searchField', ]; // list view needs these params diff --git a/src/reducers/aggs/aggsSlice.js b/src/reducers/aggs/aggsSlice.js index de632bcd..161bb44f 100644 --- a/src/reducers/aggs/aggsSlice.js +++ b/src/reducers/aggs/aggsSlice.js @@ -59,9 +59,7 @@ export const aggSlice = createSlice({ }, prepare: (data) => { return { - payload: { - data, - }, + payload: data, meta: { requery: REQUERY_NEVER, }, diff --git a/src/reducers/detail/detailSlice.js b/src/reducers/detail/detailSlice.js index bef23201..3e8e52e5 100644 --- a/src/reducers/detail/detailSlice.js +++ b/src/reducers/detail/detailSlice.js @@ -14,7 +14,7 @@ export const detailSlice = createSlice({ state.activeCall = action.payload; }, complaintDetailReceived(state, action) { - state.data = action.payload.hits.hits[0]._source; + state.data = action.payload.data.hits.hits[0]._source; state.activeCall = ''; }, complaintDetailFailed(state, action) { diff --git a/src/reducers/map/mapSlice.js b/src/reducers/map/mapSlice.js index 826a7b17..91dcd55e 100644 --- a/src/reducers/map/mapSlice.js +++ b/src/reducers/map/mapSlice.js @@ -67,11 +67,9 @@ export const mapSlice = createSlice({ results, }; }, - prepare: (items) => { + prepare: (data) => { return { - payload: { - data: items, - }, + payload: data, meta: { persist: PERSIST_SAVE_QUERY_STRING, requery: REQUERY_NEVER, diff --git a/src/reducers/query/querySlice.js b/src/reducers/query/querySlice.js index c5385cb4..e2291c73 100644 --- a/src/reducers/query/querySlice.js +++ b/src/reducers/query/querySlice.js @@ -330,7 +330,7 @@ export const querySlice = createSlice({ .addCase('routes/routeChanged', (state, action) => { const { params } = action.payload; // Set some variables from the URL - const keys = ['dateRange', 'searchFields', 'searchText', 'sort']; + const keys = ['dateRange', 'searchField', 'searchText', 'sort']; keys.forEach((item) => { if (params[item]) { state[item] = enforceValues(params[item], item); diff --git a/src/reducers/results/resultsSlice.js b/src/reducers/results/resultsSlice.js index 3a2dcfb7..2a0da546 100644 --- a/src/reducers/results/resultsSlice.js +++ b/src/reducers/results/resultsSlice.js @@ -26,14 +26,12 @@ export const resultsSlice = createSlice({ state.error = ''; state.items = items; }, - prepare: (items) => { + prepare: (data) => { return { - payload: { - data: items, - meta: { - persist: PERSIST_SAVE_QUERY_STRING, - requery: REQUERY_NEVER, - }, + payload: data, + meta: { + persist: PERSIST_SAVE_QUERY_STRING, + requery: REQUERY_NEVER, }, }; }, diff --git a/src/reducers/trends/trendsSlice.js b/src/reducers/trends/trendsSlice.js index bf4dfa70..335521d4 100644 --- a/src/reducers/trends/trendsSlice.js +++ b/src/reducers/trends/trendsSlice.js @@ -303,11 +303,9 @@ export const trendsSlice = createSlice({ state.total = total; state.subLens = lens === 'Company' ? 'product' : state.subLens; }, - prepare: (items) => { + prepare: (data) => { return { - payload: { - data: items, - }, + payload: data, meta: { persist: PERSIST_SAVE_QUERY_STRING, requery: REQUERY_NEVER, diff --git a/src/testUtils/setupStore.js b/src/testUtils/setupStore.js index 814392d3..437d1cef 100644 --- a/src/testUtils/setupStore.js +++ b/src/testUtils/setupStore.js @@ -12,7 +12,7 @@ import viewModelReducer from '../reducers/view/viewSlice'; import { applyMiddleware, combineReducers } from '@reduxjs/toolkit'; import actionLogger from '../middleware/actionLogger/actionLogger'; import cloneDeep from 'lodash/cloneDeep'; -// import emptyStore from '../actions/__fixtures__/emptyStore'; +import emptyStore from '../actions/__fixtures__/emptyStore'; /** * @@ -21,7 +21,7 @@ import cloneDeep from 'lodash/cloneDeep'; * @returns {object} complete empty redux store */ function initialState() { - return cloneDeep({}); + return cloneDeep(emptyStore); } /** @@ -36,7 +36,7 @@ function setupStore(targetState, additionalMiddlewares) { const preloadedState = targetState ? targetState : initialState(); const rootReducer = combineReducers({ actions: actionsReducer, - aggregations: aggregationsReducer, + aggs: aggregationsReducer, detail: detailReducer, filters: filtersReducer, map: mapReducer,