refactor(core): refactor search actions and reducer

Convert to TS
Proper search action type
Replace immutable with immer
General cleanup
This commit is contained in:
Vladislav Shkodin 2021-04-06 19:28:15 +03:00 committed by GitHub
parent 3211f94f4a
commit e32ffdf587
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 162 additions and 228 deletions

View File

@ -22,7 +22,7 @@ describe('search', () => {
it('should search entries in all collections using integration', async () => {
const store = mockStore({
collections: fromJS({ posts: { name: 'posts' }, pages: { name: 'pages' } }),
search: fromJS({}),
search: {},
});
selectIntegration.mockReturnValue('search_integration');
@ -46,8 +46,6 @@ describe('search', () => {
expect(actions[1]).toEqual({
type: 'SEARCH_ENTRIES_SUCCESS',
payload: {
searchTerm: 'find me',
searchCollections: ['posts', 'pages'],
entries: response.entries,
page: response.pagination,
},
@ -60,7 +58,7 @@ describe('search', () => {
it('should search entries in a subset of collections using integration', async () => {
const store = mockStore({
collections: fromJS({ posts: { name: 'posts' }, pages: { name: 'pages' } }),
search: fromJS({}),
search: {},
});
selectIntegration.mockReturnValue('search_integration');
@ -84,8 +82,6 @@ describe('search', () => {
expect(actions[1]).toEqual({
type: 'SEARCH_ENTRIES_SUCCESS',
payload: {
searchTerm: 'find me',
searchCollections: ['pages'],
entries: response.entries,
page: response.pagination,
},
@ -98,7 +94,7 @@ describe('search', () => {
it('should search entries in all collections using backend', async () => {
const store = mockStore({
collections: fromJS({ posts: { name: 'posts' }, pages: { name: 'pages' } }),
search: fromJS({}),
search: {},
});
const response = { entries: [{ name: '1' }, { name: '' }], pagination: 1 };
@ -121,8 +117,6 @@ describe('search', () => {
expect(actions[1]).toEqual({
type: 'SEARCH_ENTRIES_SUCCESS',
payload: {
searchTerm: 'find me',
searchCollections: ['posts', 'pages'],
entries: response.entries,
page: response.pagination,
},
@ -138,7 +132,7 @@ describe('search', () => {
it('should search entries in a subset of collections using backend', async () => {
const store = mockStore({
collections: fromJS({ posts: { name: 'posts' }, pages: { name: 'pages' } }),
search: fromJS({}),
search: {},
});
const response = { entries: [{ name: '1' }, { name: '' }], pagination: 1 };
@ -161,8 +155,6 @@ describe('search', () => {
expect(actions[1]).toEqual({
type: 'SEARCH_ENTRIES_SUCCESS',
payload: {
searchTerm: 'find me',
searchCollections: ['pages'],
entries: response.entries,
page: response.pagination,
},
@ -175,7 +167,7 @@ describe('search', () => {
it('should ignore identical search in all collections', async () => {
const store = mockStore({
collections: fromJS({ posts: { name: 'posts' }, pages: { name: 'pages' } }),
search: fromJS({ isFetching: true, term: 'find me', collections: ['posts', 'pages'] }),
search: { isFetching: true, term: 'find me', collections: ['posts', 'pages'] },
});
await store.dispatch(searchEntries('find me'));
@ -187,7 +179,7 @@ describe('search', () => {
it('should ignore identical search in a subset of collections', async () => {
const store = mockStore({
collections: fromJS({ posts: { name: 'posts' }, pages: { name: 'pages' } }),
search: fromJS({ isFetching: true, term: 'find me', collections: ['pages'] }),
search: { isFetching: true, term: 'find me', collections: ['pages'] },
});
await store.dispatch(searchEntries('find me', ['pages']));
@ -199,7 +191,7 @@ describe('search', () => {
it('should not ignore same search term in different search collections', async () => {
const store = mockStore({
collections: fromJS({ posts: { name: 'posts' }, pages: { name: 'pages' } }),
search: fromJS({ isFetching: true, term: 'find me', collections: ['pages'] }),
search: { isFetching: true, term: 'find me', collections: ['pages'] },
});
const backend = { search: jest.fn().mockResolvedValue({}) };
currentBackend.mockReturnValue(backend);

View File

@ -1,11 +1,11 @@
import { ThunkDispatch } from 'redux-thunk';
import { AnyAction } from 'redux';
import { isEqual } from 'lodash';
import { State } from '../types/redux';
import { currentBackend } from '../backend';
import { getIntegrationProvider } from '../integrations';
import { selectIntegration } from '../reducers';
import { EntryValue } from '../valueObjects/Entry';
import { List } from 'immutable';
/*
* Constant Declarations
@ -14,9 +14,9 @@ export const SEARCH_ENTRIES_REQUEST = 'SEARCH_ENTRIES_REQUEST';
export const SEARCH_ENTRIES_SUCCESS = 'SEARCH_ENTRIES_SUCCESS';
export const SEARCH_ENTRIES_FAILURE = 'SEARCH_ENTRIES_FAILURE';
export const QUERY_REQUEST = 'INIT_QUERY';
export const QUERY_SUCCESS = 'QUERY_OK';
export const QUERY_FAILURE = 'QUERY_ERROR';
export const QUERY_REQUEST = 'QUERY_REQUEST';
export const QUERY_SUCCESS = 'QUERY_SUCCESS';
export const QUERY_FAILURE = 'QUERY_FAILURE';
export const SEARCH_CLEAR = 'SEARCH_CLEAR';
@ -28,51 +28,33 @@ export function searchingEntries(searchTerm: string, searchCollections: string[]
return {
type: SEARCH_ENTRIES_REQUEST,
payload: { searchTerm, searchCollections, page },
};
} as const;
}
export function searchSuccess(
searchTerm: string,
searchCollections: string[],
entries: EntryValue[],
page: number,
) {
export function searchSuccess(entries: EntryValue[], page: number) {
return {
type: SEARCH_ENTRIES_SUCCESS,
payload: {
searchTerm,
searchCollections,
entries,
page,
},
};
} as const;
}
export function searchFailure(searchTerm: string, error: Error) {
export function searchFailure(error: Error) {
return {
type: SEARCH_ENTRIES_FAILURE,
payload: {
searchTerm,
error,
},
};
payload: { error },
} as const;
}
export function querying(
namespace: string,
collection: string,
searchFields: string[],
searchTerm: string,
) {
export function querying(searchTerm: string) {
return {
type: QUERY_REQUEST,
payload: {
namespace,
collection,
searchFields,
searchTerm,
},
};
} as const;
}
type SearchResponse = {
@ -85,42 +67,21 @@ type QueryResponse = {
query: string;
};
export function querySuccess(
namespace: string,
collection: string,
searchFields: string[],
searchTerm: string,
response: QueryResponse,
) {
export function querySuccess(namespace: string, hits: EntryValue[]) {
return {
type: QUERY_SUCCESS,
payload: {
namespace,
collection,
searchFields,
searchTerm,
response,
hits,
},
};
} as const;
}
export function queryFailure(
namespace: string,
collection: string,
searchFields: string[],
searchTerm: string,
error: Error,
) {
export function queryFailure(error: Error) {
return {
type: QUERY_FAILURE,
payload: {
namespace,
collection,
searchFields,
searchTerm,
error,
},
};
payload: { error },
} as const;
}
/*
@ -128,7 +89,7 @@ export function queryFailure(
*/
export function clearSearch() {
return { type: SEARCH_CLEAR };
return { type: SEARCH_CLEAR } as const;
}
/*
@ -136,33 +97,29 @@ export function clearSearch() {
*/
// SearchEntries will search for complete entries in all collections.
export function searchEntries(
searchTerm: string,
searchCollections: string[] | null = null,
page = 0,
) {
return (dispatch: ThunkDispatch<State, {}, AnyAction>, getState: () => State) => {
export function searchEntries(searchTerm: string, searchCollections: string[], page = 0) {
return async (dispatch: ThunkDispatch<State, undefined, AnyAction>, getState: () => State) => {
const state = getState();
const { search } = state;
const backend = currentBackend(state.config);
const allCollections = searchCollections || state.collections.keySeq().toArray();
const collections = allCollections.filter(collection =>
selectIntegration(state, collection as string, 'search'),
selectIntegration(state, collection, 'search'),
);
const integration = selectIntegration(state, collections[0] as string, 'search');
const integration = selectIntegration(state, collections[0], 'search');
// avoid duplicate searches
if (
search.get('isFetching') === true &&
search.get('term') === searchTerm &&
search.get('collections') !== null &&
List(allCollections).equals(search.get('collections') as List<string>) &&
search.isFetching &&
search.term === searchTerm &&
isEqual(allCollections, search.collections) &&
// if an integration doesn't exist, 'page' is not used
(search.get('page') === page || !integration)
(search.page === page || !integration)
) {
return;
}
dispatch(searchingEntries(searchTerm, allCollections as string[], page));
dispatch(searchingEntries(searchTerm, allCollections, page));
const searchPromise = integration
? getIntegrationProvider(state.integrations, backend.getToken, integration).search(
@ -178,18 +135,12 @@ export function searchEntries(
searchTerm,
);
return searchPromise.then(
(response: SearchResponse) =>
dispatch(
searchSuccess(
searchTerm,
allCollections as string[],
response.entries,
response.pagination,
),
),
(error: Error) => dispatch(searchFailure(searchTerm, error)),
);
try {
const response: SearchResponse = await searchPromise;
return dispatch(searchSuccess(response.entries, response.pagination));
} catch (error) {
return dispatch(searchFailure(error));
}
};
}
@ -204,7 +155,7 @@ export function query(
limit?: number,
) {
return async (dispatch: ThunkDispatch<State, {}, AnyAction>, getState: () => State) => {
dispatch(querying(namespace, collectionName, searchFields, searchTerm));
dispatch(querying(searchTerm));
const state = getState();
const backend = currentBackend(state.config);
@ -223,9 +174,19 @@ export function query(
try {
const response: QueryResponse = await queryPromise;
return dispatch(querySuccess(namespace, collectionName, searchFields, searchTerm, response));
return dispatch(querySuccess(namespace, response.hits));
} catch (error) {
return dispatch(queryFailure(namespace, collectionName, searchFields, searchTerm, error));
return dispatch(queryFailure(error));
}
};
}
export type SearchAction = ReturnType<
| typeof searchingEntries
| typeof searchSuccess
| typeof searchFailure
| typeof querying
| typeof querySuccess
| typeof queryFailure
| typeof clearSearch
>;

View File

@ -76,8 +76,8 @@ function mapStateToProps(state, ownProps) {
const { searchTerm } = ownProps;
const collections = ownProps.collections.toIndexedSeq();
const collectionNames = ownProps.collections.keySeq().toArray();
const isFetching = state.search.get('isFetching');
const page = state.search.get('page');
const isFetching = state.search.isFetching;
const page = state.search.page;
const entries = selectSearchedEntries(state, collectionNames);
return { isFetching, page, collections, collectionNames, entries, searchTerm };
}

View File

@ -129,7 +129,7 @@ class EditorControl extends React.Component {
processControlRef: PropTypes.func,
controlRef: PropTypes.func,
query: PropTypes.func.isRequired,
queryHits: PropTypes.oneOfType([PropTypes.array, PropTypes.object]),
queryHits: PropTypes.object,
isFetching: PropTypes.bool,
clearSearch: PropTypes.func.isRequired,
clearFieldErrors: PropTypes.func.isRequired,
@ -311,7 +311,7 @@ class EditorControl extends React.Component {
editorControl={ConnectedEditorControl}
query={query}
loadEntry={loadEntry}
queryHits={queryHits}
queryHits={queryHits[this.uniqueFieldId] || []}
clearSearch={clearSearch}
clearFieldErrors={clearFieldErrors}
isFetching={isFetching}
@ -356,8 +356,8 @@ function mapStateToProps(state) {
return {
mediaPaths: state.mediaLibrary.get('controlMedia'),
isFetching: state.search.get('isFetching'),
queryHits: state.search.get('queryHits'),
isFetching: state.search.isFetching,
queryHits: state.search.queryHits,
config: state.config,
entry,
collection,

View File

@ -1,3 +1,4 @@
import { List } from 'immutable';
import auth from './auth';
import config from './config';
import integrations, * as fromIntegrations from './integrations';
@ -50,14 +51,10 @@ export function selectPublishedSlugs(state: State, collection: string) {
}
export function selectSearchedEntries(state: State, availableCollections: string[]) {
const searchItems = state.search.get('entryIds');
// only return search results for actually available collections
return (
searchItems &&
searchItems
.filter(({ collection }) => availableCollections.indexOf(collection) !== -1)
.map(({ collection, slug }) => fromEntries.selectEntry(state.entries, collection, slug))
);
return List(state.search.entryIds)
.filter(entryId => availableCollections.indexOf(entryId!.collection) !== -1)
.map(entryId => fromEntries.selectEntry(state.entries, entryId!.collection, entryId!.slug));
}
export function selectDeployPreview(state: State, collection: string, slug: string) {

View File

@ -1,89 +0,0 @@
import { Map, List } from 'immutable';
import {
SEARCH_ENTRIES_REQUEST,
SEARCH_ENTRIES_SUCCESS,
QUERY_REQUEST,
QUERY_SUCCESS,
SEARCH_CLEAR,
} from 'Actions/search';
let loadedEntries;
let response;
let page;
let searchTerm;
let searchCollections;
const defaultState = Map({
isFetching: false,
term: null,
collections: null,
page: 0,
entryIds: List([]),
queryHits: Map({}),
});
function entries(state = defaultState, action) {
switch (action.type) {
case SEARCH_CLEAR:
return defaultState;
case SEARCH_ENTRIES_REQUEST:
if (action.payload.searchTerm !== state.get('term')) {
return state.withMutations(map => {
map.set('isFetching', true);
map.set('term', action.payload.searchTerm);
map.set('collections', List(action.payload.searchCollections));
map.set('page', action.payload.page);
});
}
return state;
case SEARCH_ENTRIES_SUCCESS:
loadedEntries = action.payload.entries;
page = action.payload.page;
searchTerm = action.payload.searchTerm;
searchCollections = action.payload.searchCollections;
return state.withMutations(map => {
const entryIds = List(
loadedEntries.map(entry => ({ collection: entry.collection, slug: entry.slug })),
);
map.set('isFetching', false);
map.set('fetchID', null);
map.set('page', page);
map.set('term', searchTerm);
map.set('collections', List(searchCollections));
map.set(
'entryIds',
!page || isNaN(page) || page === 0
? entryIds
: map.get('entryIds', List()).concat(entryIds),
);
});
case QUERY_REQUEST:
if (action.payload.searchTerm !== state.get('term')) {
return state.withMutations(map => {
map.set('isFetching', action.payload.namespace ? true : false);
map.set('fetchID', action.payload.namespace);
map.set('term', action.payload.searchTerm);
});
}
return state;
case QUERY_SUCCESS:
searchTerm = action.payload.searchTerm;
response = action.payload.response;
return state.withMutations(map => {
map.set('isFetching', false);
map.set('fetchID', null);
map.set('term', searchTerm);
map.mergeIn(['queryHits'], Map({ [action.payload.namespace]: response.hits }));
});
default:
return state;
}
}
export default entries;

View File

@ -0,0 +1,88 @@
import { produce } from 'immer';
import {
QUERY_FAILURE,
QUERY_REQUEST,
QUERY_SUCCESS,
SEARCH_CLEAR,
SEARCH_ENTRIES_FAILURE,
SEARCH_ENTRIES_REQUEST,
SEARCH_ENTRIES_SUCCESS,
SearchAction,
} from '../actions/search';
import { EntryValue } from '../valueObjects/Entry';
export type Search = {
isFetching: boolean;
term: string;
collections: string[];
page: number;
entryIds: { collection: string; slug: string }[];
queryHits: Record<string, EntryValue[]>;
error: Error | undefined;
};
const defaultState: Search = {
isFetching: false,
term: '',
collections: [],
page: 0,
entryIds: [],
queryHits: {},
error: undefined,
};
const search = produce((state: Search, action: SearchAction) => {
switch (action.type) {
case SEARCH_CLEAR:
return defaultState;
case SEARCH_ENTRIES_REQUEST: {
const { page, searchTerm, searchCollections } = action.payload;
state.isFetching = true;
state.term = searchTerm;
state.collections = searchCollections;
state.page = page;
break;
}
case SEARCH_ENTRIES_SUCCESS: {
const { entries, page } = action.payload;
const entryIds = entries.map(entry => ({ collection: entry.collection, slug: entry.slug }));
state.isFetching = false;
state.page = page;
state.entryIds =
!page || isNaN(page) || page === 0 ? entryIds : state.entryIds.concat(entryIds);
break;
}
case SEARCH_ENTRIES_FAILURE: {
const { error } = action.payload;
state.isFetching = false;
state.error = error;
break;
}
case QUERY_REQUEST: {
const { searchTerm } = action.payload;
state.isFetching = true;
state.term = searchTerm;
break;
}
case QUERY_SUCCESS: {
const { namespace, hits } = action.payload;
state.isFetching = false;
state.queryHits[namespace] = hits;
break;
}
case QUERY_FAILURE: {
const { error } = action.payload;
state.isFetching = false;
state.error = error;
}
}
}, defaultState);
export default search;

View File

@ -7,6 +7,7 @@ import { Auth } from '../reducers/auth';
import { Status } from '../reducers/status';
import { Medias } from '../reducers/medias';
import { Deploys } from '../reducers/deploys';
import { Search } from '../reducers/search';
export type CmsBackendType =
| 'azure'
@ -675,19 +676,6 @@ export type Integrations = StaticallyTypedRecord<{
hooks: { [collectionOrHook: string]: any };
}>;
interface SearchItem {
collection: string;
slug: string;
}
export type Search = StaticallyTypedRecord<{
entryIds?: SearchItem[];
isFetching: boolean;
term: string | null;
collections: List<string> | null;
page: number;
}>;
export type Cursors = StaticallyTypedRecord<{}>;
export interface State {

View File

@ -90,9 +90,8 @@ export default class RelationControl extends React.Component {
forID: PropTypes.string.isRequired,
value: PropTypes.node,
field: ImmutablePropTypes.map,
fetchID: PropTypes.string,
query: PropTypes.func.isRequired,
queryHits: PropTypes.oneOfType([PropTypes.array, PropTypes.object]),
queryHits: PropTypes.array,
classNameWrapper: PropTypes.string.isRequired,
setActiveStyle: PropTypes.func.isRequired,
setInactiveStyle: PropTypes.func.isRequired,
@ -119,7 +118,7 @@ export default class RelationControl extends React.Component {
const metadata = {};
const searchFieldsArray = getSearchFieldArray(field.get('search_fields'));
const { payload } = await query(forID, collection, searchFieldsArray, '', file);
const hits = payload.response?.hits || [];
const hits = payload.hits || [];
const options = this.parseHitOptions(hits);
const initialOptions = initialSearchValues
.map(v => {
@ -221,7 +220,7 @@ export default class RelationControl extends React.Component {
const file = field.get('file');
query(forID, collection, searchFieldsArray, term, file, optionsLength).then(({ payload }) => {
const hits = payload.response?.hits || [];
const hits = payload.hits || [];
const options = this.parseHitOptions(hits);
const uniq = uniqOptions(this.state.initialOptions, options);
callback(uniq);
@ -241,8 +240,7 @@ export default class RelationControl extends React.Component {
const isMultiple = this.isMultiple();
const isClearable = !field.get('required', true) || isMultiple;
const hits = queryHits.get(forID, []);
const queryOptions = this.parseHitOptions(hits);
const queryOptions = this.parseHitOptions(queryHits);
const options = uniqOptions(this.state.initialOptions, queryOptions);
const selectedValue = getSelectedValue({
options,

View File

@ -1,5 +1,5 @@
import React from 'react';
import { fromJS, Map } from 'immutable';
import { fromJS } from 'immutable';
import { render, fireEvent, waitFor } from '@testing-library/react';
import { NetlifyCmsWidgetRelation } from '../';
@ -137,7 +137,7 @@ const numberFieldsHits = [
class RelationController extends React.Component {
state = {
value: this.props.value,
queryHits: Map(),
queryHits: [],
};
mounted = false;
@ -154,9 +154,8 @@ class RelationController extends React.Component {
this.setState({ ...this.state, value });
});
setQueryHits = jest.fn(hits => {
setQueryHits = jest.fn(queryHits => {
if (this.mounted) {
const queryHits = Map().set('relation-field', hits);
this.setState({ ...this.state, queryHits });
}
});
@ -186,7 +185,7 @@ class RelationController extends React.Component {
this.setQueryHits(hits);
return Promise.resolve({ payload: { response: { hits } } });
return Promise.resolve({ payload: { hits } });
});
render() {