Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Url state parameter for external alerts checkbox #142344

Merged
merged 5 commits into from
Oct 4, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => ({
Expand All @@ -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 }) => (
<div>
{additionalFilters}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -55,6 +60,8 @@ export type EventsQueryTabBodyComponentProps = QueryTabBodyProps & {
timelineId: TimelineId;
};

const EXTERNAL_ALERTS_URL_PARAM = 'onlyExternalAlerts';

const EventsQueryTabBodyComponent: React.FC<EventsQueryTabBodyComponentProps> = ({
deleteQuery,
endDate,
Expand All @@ -70,14 +77,21 @@ const EventsQueryTabBodyComponent: React.FC<EventsQueryTabBodyComponentProps> =
const { globalFullScreen } = useGlobalFullScreen();
const tGridEnabled = useIsExperimentalFeatureEnabled('tGridEnabled');
const [defaultNumberFormat] = useUiSetting$<string>(DEFAULT_NUMBER_FORMAT);
const [showExternalAlerts, setShowExternalAlerts] = useState(false);
const isEnterprisePlus = useLicense().isEnterprise();
const ACTION_BUTTON_COUNT = isEnterprisePlus ? 5 : 4;
const leadingControlColumns = useMemo(
() => getDefaultControlColumn(ACTION_BUTTON_COUNT),
[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),
Expand Down Expand Up @@ -178,3 +192,43 @@ EventsQueryTabBodyComponent.displayName = 'EventsQueryTabBodyComponent';
export const EventsQueryTabBody = React.memo(EventsQueryTabBodyComponent);

EventsQueryTabBody.displayName = 'EventsQueryTabBody';

const useExternalAlertsInitialUrlState = () => {
const replaceUrlParams = useReplaceUrlParams();

const getInitialUrlParamValue = useGetInitialUrlParamValue<boolean>(EXTERNAL_ALERTS_URL_PARAM);

const { decodedParam: showExternalAlertsInitialUrlState } = useMemo(
() => getInitialUrlParamValue(),
Copy link
Contributor

@jamster10 jamster10 Oct 3, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Made me curious, and not important, but do you know if destructuring allocates memory for the intermediary object the value gets pulled out of?
Would:

const showExternalAlertsInitialUrlState = useMemo(() => getInitialUrlParamState().decodedParam, [•••])

Cause an improvement to memory or memorization? Not that it would make much of a difference here?

[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]);
};
Original file line number Diff line number Diff line change
Expand Up @@ -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) =>
Expand Down Expand Up @@ -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 = <State>(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<State>(param ?? undefined);

return { param, decodedParam };
}, [urlParamKey]);

return getInitialUrlParamValue;
};

export const encodeQueryString = (urlParams: ParsedQuery<string>): 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;
};
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 }) => (
<TestProviders store={store}>{children}</TestProviders>
),
});

mockHistory.replace.mockClear();

act(() => {
store.dispatch(globalUrlParamActions.deregisterUrlParam({ key: urlParamKey }));
});

waitForNextUpdate();

expect(mockHistory.replace).toHaveBeenCalledWith({
search: ``,
});
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -43,13 +38,10 @@ export const useInitializeUrlParam = <State>(
) => {
const dispatch = useDispatch();

const getInitialUrlParamValue = useGetInitialUrlParamValue<State>(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({
Expand All @@ -59,7 +51,7 @@ export const useInitializeUrlParam = <State>(
);

// execute consumer initialization
onInitialize(decodeRisonUrlState<State>(initialValue ?? undefined));
onInitialize(decodedInitialValue);

return () => {
dispatch(globalUrlParamActions.deregisterUrlParam({ key: urlParamKey }));
Expand Down Expand Up @@ -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 };
Expand All @@ -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>): 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]);
};