diff --git a/x-pack/plugins/security_solution/public/common/components/events_tab/events_query_tab_body.test.tsx b/x-pack/plugins/security_solution/public/common/components/events_tab/events_query_tab_body.test.tsx index 0b13363a653a0..cb12aaa7f0e79 100644 --- a/x-pack/plugins/security_solution/public/common/components/events_tab/events_query_tab_body.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/events_tab/events_query_tab_body.test.tsx @@ -15,6 +15,7 @@ import { EventsQueryTabBody, ALERTS_EVENTS_HISTOGRAM_ID } from './events_query_t import { useGlobalFullScreen } from '../../containers/use_full_screen'; import * as tGridActions from '@kbn/timelines-plugin/public/store/t_grid/actions'; import { licenseService } from '../../hooks/use_license'; +import { mockHistory } from '../../mock/router'; const mockGetDefaultControlColumn = jest.fn(); jest.mock('../../../timelines/components/timeline/body/control_columns', () => ({ @@ -39,6 +40,11 @@ jest.mock('../../lib/kibana', () => { }; }); +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useHistory: () => mockHistory, +})); + const FakeStatefulEventsViewer = ({ additionalFilters }: { additionalFilters: JSX.Element }) => (
{additionalFilters} diff --git a/x-pack/plugins/security_solution/public/common/components/events_tab/events_query_tab_body.tsx b/x-pack/plugins/security_solution/public/common/components/events_tab/events_query_tab_body.tsx index db663c1f0fc6d..07c9995ddbd9e 100644 --- a/x-pack/plugins/security_solution/public/common/components/events_tab/events_query_tab_body.tsx +++ b/x-pack/plugins/security_solution/public/common/components/events_tab/events_query_tab_body.tsx @@ -42,6 +42,11 @@ import { useLicense } from '../../hooks/use_license'; import { useUiSetting$ } from '../../lib/kibana'; import { defaultAlertsFilters } from '../events_viewer/external_alerts_filter'; +import { + useGetInitialUrlParamValue, + useReplaceUrlParams, +} from '../../utils/global_query_string/helpers'; + export const ALERTS_EVENTS_HISTOGRAM_ID = 'alertsOrEventsHistogramQuery'; type QueryTabBodyProps = UserQueryTabBodyProps | HostQueryTabBodyProps | NetworkQueryTabBodyProps; @@ -55,6 +60,8 @@ export type EventsQueryTabBodyComponentProps = QueryTabBodyProps & { timelineId: TimelineId; }; +const EXTERNAL_ALERTS_URL_PARAM = 'onlyExternalAlerts'; + const EventsQueryTabBodyComponent: React.FC = ({ deleteQuery, endDate, @@ -70,7 +77,6 @@ const EventsQueryTabBodyComponent: React.FC = const { globalFullScreen } = useGlobalFullScreen(); const tGridEnabled = useIsExperimentalFeatureEnabled('tGridEnabled'); const [defaultNumberFormat] = useUiSetting$(DEFAULT_NUMBER_FORMAT); - const [showExternalAlerts, setShowExternalAlerts] = useState(false); const isEnterprisePlus = useLicense().isEnterprise(); const ACTION_BUTTON_COUNT = isEnterprisePlus ? 5 : 4; const leadingControlColumns = useMemo( @@ -78,6 +84,14 @@ const EventsQueryTabBodyComponent: React.FC = [ACTION_BUTTON_COUNT] ); + const showExternalAlertsInitialUrlState = useExternalAlertsInitialUrlState(); + + const [showExternalAlerts, setShowExternalAlerts] = useState( + showExternalAlertsInitialUrlState ?? false + ); + + useSyncExternalAlertsUrlState(showExternalAlerts); + const toggleExternalAlerts = useCallback(() => setShowExternalAlerts((s) => !s), []); const getHistogramSubtitle = useMemo( () => getSubtitleFunction(defaultNumberFormat, showExternalAlerts), @@ -178,3 +192,43 @@ EventsQueryTabBodyComponent.displayName = 'EventsQueryTabBodyComponent'; export const EventsQueryTabBody = React.memo(EventsQueryTabBodyComponent); EventsQueryTabBody.displayName = 'EventsQueryTabBody'; + +const useExternalAlertsInitialUrlState = () => { + const replaceUrlParams = useReplaceUrlParams(); + + const getInitialUrlParamValue = useGetInitialUrlParamValue(EXTERNAL_ALERTS_URL_PARAM); + + const { decodedParam: showExternalAlertsInitialUrlState } = useMemo( + () => getInitialUrlParamValue(), + [getInitialUrlParamValue] + ); + + useEffect(() => { + // Only called on component unmount + return () => { + replaceUrlParams([ + { + key: EXTERNAL_ALERTS_URL_PARAM, + value: null, + }, + ]); + }; + }, [replaceUrlParams]); + + return showExternalAlertsInitialUrlState; +}; + +/** + * Update URL state when showExternalAlerts value changes + */ +const useSyncExternalAlertsUrlState = (showExternalAlerts: boolean) => { + const replaceUrlParams = useReplaceUrlParams(); + useEffect(() => { + replaceUrlParams([ + { + key: EXTERNAL_ALERTS_URL_PARAM, + value: showExternalAlerts ? 'true' : null, + }, + ]); + }, [showExternalAlerts, replaceUrlParams]); +}; diff --git a/x-pack/plugins/security_solution/public/common/utils/global_query_string/helpers.ts b/x-pack/plugins/security_solution/public/common/utils/global_query_string/helpers.ts index 63684f1985049..a5f7e93146750 100644 --- a/x-pack/plugins/security_solution/public/common/utils/global_query_string/helpers.ts +++ b/x-pack/plugins/security_solution/public/common/utils/global_query_string/helpers.ts @@ -5,8 +5,12 @@ * 2.0. */ -import { parse } from 'query-string'; import { decode, encode } from 'rison-node'; +import type { ParsedQuery } from 'query-string'; +import { parse, stringify } from 'query-string'; +import { url } from '@kbn/kibana-utils-plugin/public'; +import { useHistory } from 'react-router-dom'; +import { useCallback } from 'react'; import { SecurityPageName } from '../../../app/types'; export const isDetectionsPages = (pageName: string) => @@ -40,3 +44,59 @@ export const getParamFromQueryString = ( return Array.isArray(queryParam) ? queryParam[0] : queryParam; }; + +/** + * + * Gets the value of the URL param from the query string. + * It doesn't update when the URL changes. + * + */ +export const useGetInitialUrlParamValue = (urlParamKey: string) => { + // window.location.search provides the most updated representation of the url search. + // It also guarantees that we don't overwrite URL param managed outside react-router. + const getInitialUrlParamValue = useCallback(() => { + const param = getParamFromQueryString( + getQueryStringFromLocation(window.location.search), + urlParamKey + ); + + const decodedParam = decodeRisonUrlState(param ?? undefined); + + return { param, decodedParam }; + }, [urlParamKey]); + + return getInitialUrlParamValue; +}; + +export const encodeQueryString = (urlParams: ParsedQuery): string => + stringify(url.encodeQuery(urlParams), { sort: false, encode: false }); + +export const useReplaceUrlParams = () => { + const history = useHistory(); + + const replaceUrlParams = useCallback( + (params: Array<{ key: string; value: string | null }>) => { + // window.location.search provides the most updated representation of the url search. + // It prevents unnecessary re-renders which useLocation would create because 'replaceUrlParams' does update the location. + // window.location.search also guarantees that we don't overwrite URL param managed outside react-router. + const search = window.location.search; + const urlParams = parse(search, { sort: false }); + + params.forEach(({ key, value }) => { + if (value == null || value === '') { + delete urlParams[key]; + } else { + urlParams[key] = value; + } + }); + + const newSearch = encodeQueryString(urlParams); + + if (getQueryStringFromLocation(search) !== newSearch) { + history.replace({ search: newSearch }); + } + }, + [history] + ); + return replaceUrlParams; +}; diff --git a/x-pack/plugins/security_solution/public/common/utils/global_query_string/index.test.tsx b/x-pack/plugins/security_solution/public/common/utils/global_query_string/index.test.tsx index a400ab24e06dd..dede5125775c4 100644 --- a/x-pack/plugins/security_solution/public/common/utils/global_query_string/index.test.tsx +++ b/x-pack/plugins/security_solution/public/common/utils/global_query_string/index.test.tsx @@ -6,7 +6,7 @@ */ import React from 'react'; -import { renderHook } from '@testing-library/react-hooks'; +import { act, renderHook } from '@testing-library/react-hooks'; import { useInitializeUrlParam, useGlobalQueryString, @@ -296,5 +296,33 @@ describe('global query string', () => { expect(mockHistory.replace).not.toHaveBeenCalledWith(); }); + + it('deletes unregistered URL params', async () => { + const urlParamKey = 'testKey'; + const value = '123'; + window.location.search = `?${urlParamKey}=${value}`; + const globalUrlParam = { + [urlParamKey]: value, + }; + const store = makeStore(globalUrlParam); + + const { waitForNextUpdate } = renderHook(() => useSyncGlobalQueryString(), { + wrapper: ({ children }: { children: React.ReactElement }) => ( + {children} + ), + }); + + mockHistory.replace.mockClear(); + + act(() => { + store.dispatch(globalUrlParamActions.deregisterUrlParam({ key: urlParamKey })); + }); + + waitForNextUpdate(); + + expect(mockHistory.replace).toHaveBeenCalledWith({ + search: ``, + }); + }); }); }); diff --git a/x-pack/plugins/security_solution/public/common/utils/global_query_string/index.ts b/x-pack/plugins/security_solution/public/common/utils/global_query_string/index.ts index 09588c7298c09..96834d39fd644 100644 --- a/x-pack/plugins/security_solution/public/common/utils/global_query_string/index.ts +++ b/x-pack/plugins/security_solution/public/common/utils/global_query_string/index.ts @@ -5,20 +5,15 @@ * 2.0. */ -import type * as H from 'history'; -import type { ParsedQuery } from 'query-string'; -import { parse, stringify } from 'query-string'; import { useCallback, useEffect, useMemo } from 'react'; - -import { url } from '@kbn/kibana-utils-plugin/public'; -import { isEmpty, pickBy } from 'lodash/fp'; -import { useHistory } from 'react-router-dom'; +import { difference, isEmpty, pickBy } from 'lodash/fp'; import { useDispatch } from 'react-redux'; +import usePrevious from 'react-use/lib/usePrevious'; import { - decodeRisonUrlState, + encodeQueryString, encodeRisonUrlState, - getParamFromQueryString, - getQueryStringFromLocation, + useGetInitialUrlParamValue, + useReplaceUrlParams, } from './helpers'; import { useShallowEqualSelector } from '../../hooks/use_selector'; import { globalUrlParamActions, globalUrlParamSelectors } from '../../store/global_url_param'; @@ -43,13 +38,10 @@ export const useInitializeUrlParam = ( ) => { const dispatch = useDispatch(); + const getInitialUrlParamValue = useGetInitialUrlParamValue(urlParamKey); + useEffect(() => { - // window.location.search provides the most updated representation of the url search. - // It also guarantees that we don't overwrite URL param managed outside react-router. - const initialValue = getParamFromQueryString( - getQueryStringFromLocation(window.location.search), - urlParamKey - ); + const { param: initialValue, decodedParam: decodedInitialValue } = getInitialUrlParamValue(); dispatch( globalUrlParamActions.registerUrlParam({ @@ -59,7 +51,7 @@ export const useInitializeUrlParam = ( ); // execute consumer initialization - onInitialize(decodeRisonUrlState(initialValue ?? undefined)); + onInitialize(decodedInitialValue); return () => { dispatch(globalUrlParamActions.deregisterUrlParam({ key: urlParamKey })); @@ -103,9 +95,16 @@ export const useGlobalQueryString = (): string => { * - It updates the URL when globalUrlParam store updates. */ export const useSyncGlobalQueryString = () => { - const history = useHistory(); const [{ pageName }] = useRouteSpy(); const globalUrlParam = useShallowEqualSelector(globalUrlParamSelectors.selectGlobalUrlParam); + const previousGlobalUrlParams = usePrevious(globalUrlParam); + const replaceUrlParams = useReplaceUrlParams(); + + // Url params that got deleted from GlobalUrlParams + const unregisteredKeys = useMemo( + () => difference(Object.keys(previousGlobalUrlParams ?? {}), Object.keys(globalUrlParam)), + [previousGlobalUrlParams, globalUrlParam] + ); useEffect(() => { const linkInfo = getLinkInfo(pageName) ?? { skipUrlState: true }; @@ -114,36 +113,16 @@ export const useSyncGlobalQueryString = () => { value: linkInfo.skipUrlState ? null : value, })); - if (params.length > 0) { - // window.location.search provides the most updated representation of the url search. - // It prevents unnecessary re-renders which useLocation would create because 'replaceUrlParams' does update the location. - // window.location.search also guarantees that we don't overwrite URL param managed outside react-router. - replaceUrlParams(params, history, window.location.search); - } - }, [globalUrlParam, pageName, history]); -}; - -const encodeQueryString = (urlParams: ParsedQuery): string => - stringify(url.encodeQuery(urlParams), { sort: false, encode: false }); - -const replaceUrlParams = ( - params: Array<{ key: string; value: string | null }>, - history: H.History, - search: string -) => { - const urlParams = parse(search, { sort: false }); + // Delete unregistered Url params + unregisteredKeys.forEach((key) => { + params.push({ + key, + value: null, + }); + }); - params.forEach(({ key, value }) => { - if (value == null || value === '') { - delete urlParams[key]; - } else { - urlParams[key] = value; + if (params.length > 0) { + replaceUrlParams(params); } - }); - - const newSearch = encodeQueryString(urlParams); - - if (getQueryStringFromLocation(search) !== newSearch) { - history.replace({ search: newSearch }); - } + }, [globalUrlParam, pageName, unregisteredKeys, replaceUrlParams]); };