Create useSearchStrategy hook (#127801)

* Create useSearchStrategy hook

* Add unit tests

* Fix issues found during code review
This commit is contained in:
Pablo Machado 2022-03-18 09:24:39 +01:00 committed by GitHub
parent 1c7f805509
commit 673e59b9b9
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
4 changed files with 394 additions and 1 deletions

View file

@ -0,0 +1,203 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { useSearchStrategy } from './index';
import { renderHook } from '@testing-library/react-hooks';
import { useObservable } from '@kbn/securitysolution-hook-utils';
import { FactoryQueryTypes } from '../../../../common/search_strategy';
jest.mock('../../../transforms/containers/use_transforms', () => ({
useTransforms: jest.fn(() => ({
getTransformChangesIfTheyExist: null,
})),
}));
const mockAddToastError = jest.fn();
jest.mock('../../hooks/use_app_toasts', () => ({
useAppToasts: jest.fn(() => ({
addError: mockAddToastError,
})),
}));
jest.mock('@kbn/securitysolution-hook-utils');
const useObservableHookResult = {
start: jest.fn(),
error: null,
result: null,
loading: false,
};
const userSearchStrategyProps = {
factoryQueryType: 'testFactoryQueryType' as FactoryQueryTypes,
initialResult: {},
errorMessage: 'testErrorMessage',
};
describe('useSearchStrategy', () => {
it("returns the provided initial result while the query hasn't returned data", () => {
const initialResult = {};
(useObservable as jest.Mock).mockReturnValue(useObservableHookResult);
const { result } = renderHook(() =>
useSearchStrategy<FactoryQueryTypes>({ ...userSearchStrategyProps, initialResult })
);
expect(result.current.result).toBe(initialResult);
});
it('calls start with the given factoryQueryType', () => {
const factoryQueryType = 'fakeQueryType' as FactoryQueryTypes;
const start = jest.fn();
(useObservable as jest.Mock).mockReturnValue({ ...useObservableHookResult, start });
const { result } = renderHook(() =>
useSearchStrategy<FactoryQueryTypes>({
...userSearchStrategyProps,
factoryQueryType,
})
);
result.current.search({});
expect(start).toBeCalledWith(expect.objectContaining({ factoryQueryType }));
});
it('returns inspect', () => {
const dsl = 'testDsl';
(useObservable as jest.Mock).mockReturnValue({
...useObservableHookResult,
result: {
rawResponse: {},
inspect: {
dsl,
},
},
});
const { result } = renderHook(() =>
useSearchStrategy<FactoryQueryTypes>(userSearchStrategyProps)
);
expect(result.current.inspect).toEqual({
dsl,
response: ['{}'],
});
});
it('shows toast error when the API returns error', () => {
const error = 'test error';
const errorMessage = 'error message title';
(useObservable as jest.Mock).mockReturnValue({
...useObservableHookResult,
error,
});
renderHook(() =>
useSearchStrategy<FactoryQueryTypes>({ ...userSearchStrategyProps, errorMessage })
);
expect(mockAddToastError).toBeCalledWith(error, { title: errorMessage });
});
it('start should be called when search is called ', () => {
const start = jest.fn();
const searchParams = {};
(useObservable as jest.Mock).mockReturnValue({ ...useObservableHookResult, start });
const { result } = renderHook(() =>
useSearchStrategy<FactoryQueryTypes>(userSearchStrategyProps)
);
result.current.search(searchParams);
expect(start).toBeCalled();
});
it('refetch should execute the previous search again with the same params', async () => {
const start = jest.fn();
const searchParams = {};
(useObservable as jest.Mock).mockReturnValue({ ...useObservableHookResult, start });
const { result, rerender } = renderHook(() =>
useSearchStrategy<FactoryQueryTypes>(userSearchStrategyProps)
);
result.current.search(searchParams);
rerender();
result.current.refetch();
expect(start).toBeCalledTimes(2);
expect(start.mock.calls[0]).toEqual(start.mock.calls[1]);
});
it('aborts previous search when a subsequent search is triggered', async () => {
const searchParams = {};
const abortFunction = jest.fn();
jest
.spyOn(window, 'AbortController')
.mockReturnValue({ abort: abortFunction, signal: {} as AbortSignal });
(useObservable as jest.Mock).mockReturnValue(useObservableHookResult);
const { result } = renderHook(() =>
useSearchStrategy<FactoryQueryTypes>(userSearchStrategyProps)
);
result.current.search(searchParams);
result.current.search(searchParams);
expect(abortFunction).toBeCalledTimes(2);
});
it('aborts search when component unmounts', async () => {
const searchParams = {};
const abortFunction = jest.fn();
jest
.spyOn(window, 'AbortController')
.mockReturnValue({ abort: abortFunction, signal: {} as AbortSignal });
(useObservable as jest.Mock).mockReturnValue(useObservableHookResult);
const { result, unmount } = renderHook(() =>
useSearchStrategy<FactoryQueryTypes>(userSearchStrategyProps)
);
result.current.search(searchParams);
unmount();
expect(abortFunction).toBeCalledTimes(2);
});
it('calls start with the AbortController signal', () => {
const factoryQueryType = 'fakeQueryType' as FactoryQueryTypes;
const start = jest.fn();
const signal = new AbortController().signal;
jest.spyOn(window, 'AbortController').mockReturnValue({ abort: jest.fn(), signal });
(useObservable as jest.Mock).mockReturnValue({ ...useObservableHookResult, start });
const { result } = renderHook(() =>
useSearchStrategy<FactoryQueryTypes>({
...userSearchStrategyProps,
factoryQueryType,
})
);
result.current.search({});
expect(start).toBeCalledWith(expect.objectContaining({ signal }));
});
});

View file

@ -0,0 +1,175 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { filter } from 'rxjs/operators';
import { noop, omit } from 'lodash/fp';
import { useCallback, useEffect, useRef, useMemo } from 'react';
import { Observable } from 'rxjs';
import { OptionalSignalArgs, useObservable } from '@kbn/securitysolution-hook-utils';
import * as i18n from './translations';
import {
FactoryQueryTypes,
RequestBasicOptions,
StrategyRequestType,
StrategyResponseType,
} from '../../../../common/search_strategy/security_solution';
import { IKibanaSearchResponse } from '../../../../../../../src/plugins/data/common';
import {
DataPublicPluginStart,
isCompleteResponse,
isErrorResponse,
} from '../../../../../../../src/plugins/data/public';
import {
TransformChangesIfTheyExist,
useTransforms,
} from '../../../transforms/containers/use_transforms';
import { getInspectResponse } from '../../../helpers';
import { inputsModel } from '../../store';
import { useKibana } from '../../lib/kibana';
import { useAppToasts } from '../../hooks/use_app_toasts';
type UseSearchStrategyRequestArgs = RequestBasicOptions & {
data: DataPublicPluginStart;
signal: AbortSignal;
factoryQueryType: FactoryQueryTypes;
getTransformChangesIfTheyExist: TransformChangesIfTheyExist;
};
const search = <ResponseType extends IKibanaSearchResponse>({
data,
signal,
factoryQueryType,
defaultIndex,
filterQuery,
timerange,
getTransformChangesIfTheyExist,
...requestProps
}: UseSearchStrategyRequestArgs): Observable<ResponseType> => {
const {
indices: transformIndices,
factoryQueryType: transformFactoryQueryType,
timerange: transformTimerange,
} = getTransformChangesIfTheyExist({
factoryQueryType,
indices: defaultIndex,
filterQuery,
timerange,
});
return data.search.search<RequestBasicOptions, ResponseType>(
{
...requestProps,
factoryQueryType: transformFactoryQueryType,
defaultIndex: transformIndices,
timerange: transformTimerange,
filterQuery,
},
{
strategy: 'securitySolutionSearchStrategy',
abortSignal: signal,
}
);
};
const searchComplete = <ResponseType extends IKibanaSearchResponse>(
props: UseSearchStrategyRequestArgs
): Observable<ResponseType> => {
return search<ResponseType>(props).pipe(
filter((response) => {
return isErrorResponse(response) || isCompleteResponse(response);
})
);
};
const EMPTY_INSPECT = {
dsl: [],
response: [],
};
export const useSearchStrategy = <QueryType extends FactoryQueryTypes>({
factoryQueryType,
initialResult,
errorMessage,
}: {
factoryQueryType: QueryType;
/**
* `result` initial value. It is used until the search strategy returns some data.
*/
initialResult: Omit<StrategyResponseType<QueryType>, 'rawResponse'>;
/**
* Message displayed to the user on a Toast when an erro happens.
*/
errorMessage?: string;
}) => {
const abortCtrl = useRef(new AbortController());
const { getTransformChangesIfTheyExist } = useTransforms();
const refetch = useRef<inputsModel.Refetch>(noop);
const { data } = useKibana().services;
const { addError } = useAppToasts();
const { start, error, result, loading } = useObservable<
[UseSearchStrategyRequestArgs],
StrategyResponseType<QueryType>
>(searchComplete);
useEffect(() => {
if (error != null) {
addError(error, {
title: errorMessage ?? i18n.DEFAULT_ERROR_SEARCH_STRATEGY(factoryQueryType),
});
}
}, [addError, error, errorMessage, factoryQueryType]);
const searchCb = useCallback(
(props: OptionalSignalArgs<StrategyRequestType<QueryType>>) => {
const asyncSearch = () => {
abortCtrl.current = new AbortController();
start({
...props,
data,
factoryQueryType,
getTransformChangesIfTheyExist,
signal: abortCtrl.current.signal,
} as never); // This typescast is required because every StrategyRequestType instance has different fields.
};
abortCtrl.current.abort();
asyncSearch();
refetch.current = asyncSearch;
},
[data, start, factoryQueryType, getTransformChangesIfTheyExist]
);
useEffect(() => {
return () => {
abortCtrl.current.abort();
};
}, []);
const [formatedResult, inspect] = useMemo(
() => [
result
? omit<StrategyResponseType<QueryType>, 'rawResponse'>('rawResponse', result)
: initialResult,
result ? getInspectResponse(result, EMPTY_INSPECT) : EMPTY_INSPECT,
],
[result, initialResult]
);
return {
loading,
result: formatedResult,
error,
search: searchCb,
refetch: refetch.current,
inspect,
};
};

View file

@ -0,0 +1,15 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { i18n } from '@kbn/i18n';
import { FactoryQueryTypes } from '../../../../common/search_strategy';
export const DEFAULT_ERROR_SEARCH_STRATEGY = (factoryQueryType: FactoryQueryTypes) =>
i18n.translate('xpack.securitySolution.searchStrategy.error', {
values: { factoryQueryType },
defaultMessage: `Failed to run search: {factoryQueryType}`,
});

View file

@ -147,7 +147,7 @@ export const manageOldSiemRoutes = async (coreStart: CoreStart) => {
};
export const getInspectResponse = <T extends FactoryQueryTypes>(
response: StrategyResponseType<T> | TimelineEqlResponse,
response: StrategyResponseType<T> | TimelineEqlResponse | undefined,
prevResponse: InspectResponse
): InspectResponse => ({
dsl: response?.inspect?.dsl ?? prevResponse?.dsl ?? [],