Skip to content

Commit

Permalink
Add Url state parameter for external alerts checkbox (elastic#142344)
Browse files Browse the repository at this point in the history
* Refactor global_query_string to move reusabel code to helper

* Add Url state parameter for external alerts checkbox

Co-authored-by: Kibana Machine <[email protected]>
  • Loading branch information
2 people authored and WafaaNasr committed Oct 11, 2022
1 parent a236500 commit b97f4bf
Show file tree
Hide file tree
Showing 5 changed files with 178 additions and 51 deletions.
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(),
[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]);
};

0 comments on commit b97f4bf

Please sign in to comment.