mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 17:59:23 -04:00
Create useSearchStrategy hook (#127801)
* Create useSearchStrategy hook * Add unit tests * Fix issues found during code review
This commit is contained in:
parent
1c7f805509
commit
673e59b9b9
4 changed files with 394 additions and 1 deletions
|
@ -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 }));
|
||||
});
|
||||
});
|
|
@ -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,
|
||||
};
|
||||
};
|
|
@ -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}`,
|
||||
});
|
|
@ -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 ?? [],
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue