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]);
};