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,