[Security Solution] Global query string functionality improvements (#147218)

Addresses: https://github.com/elastic/kibana/issues/140263

This PR was inspired by https://github.com/elastic/kibana/pull/146649 and while working on persistent rules table state https://github.com/elastic/kibana/pull/145111.

## Summary

It includes improvements for url search parameters manipulation functionality. In particular `useGetInitialUrlParamValue` and `useReplaceUrlParams` hooks.

The main idea is to isolate the encoding layer ([rison](https://github.com/Nanonid/rison)) as an implementation detail so `useGetInitialUrlParamValue` and `useReplaceUrlParams` consumers can just provide types they wish and the hooks serialize/desirealize the data into appropriate format under the hood.

On top of that after `@kbn/rison` was added in https://github.com/elastic/kibana/pull/146649 it's possible to use this package directly to encode and decode parameters whenever necessary.

The improvements include
- store unserialized url parameters state in the redux store
- encode and decode data by using functions from `@kbn/rison` directly
- let `useReplaceUrlParams` accept an updater object

### Checklist

- [x] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios
This commit is contained in:
Maxim Palenov 2023-01-05 18:02:12 +01:00 committed by GitHub
parent f5b4977b51
commit 4a93da0ba3
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
25 changed files with 157 additions and 192 deletions

View file

@ -21,7 +21,7 @@ describe('encoding', () => {
});
it('throws if it received undefined', () => {
expect(() => Rison.encode(undefined)).toThrowErrorMatchingInlineSnapshot(
`"unable to encode value into rison, expected a primative value array or object"`
`"unable to encode value into rison, expected a primitive value array or object"`
);
});
it('encodes a complex object', () => {

View file

@ -29,7 +29,7 @@ export function encode(obj: any) {
const rison = encodeUnknown(obj);
if (rison === undefined) {
throw new Error(
'unable to encode value into rison, expected a primative value array or object'
'unable to encode value into rison, expected a primitive value array or object'
);
}
return rison;
@ -42,6 +42,17 @@ export function decode(rison: string): RisonValue {
return Rison.decode(rison);
}
/**
* safely parse a rison string into a javascript structure, never throws
*/
export function safeDecode(rison: string): RisonValue {
try {
return decode(rison);
} catch {
return null;
}
}
/**
* rison-encode a javascript array without surrounding parens
*/

View file

@ -5,16 +5,14 @@
* 2.0.
*/
import rison from '@kbn/rison';
import type { FilterItemObj } from '../../public/common/components/filter_group/types';
export const formatPageFilterSearchParam = (filters: FilterItemObj[]) => {
const modifiedFilters = filters.map((filter) => ({
return filters.map((filter) => ({
title: filter.title ?? filter.fieldName,
selectedOptions: filter.selectedOptions ?? [],
fieldName: filter.fieldName,
existsSelected: filter.existsSelected ?? false,
exclude: filter.exclude ?? false,
}));
return rison.encode(modifiedFilters);
};

View file

@ -5,6 +5,7 @@
* 2.0.
*/
import { encode } from '@kbn/rison';
import { getNewRule } from '../../objects/rule';
import {
CONTROL_FRAMES,
@ -83,7 +84,7 @@ describe('Detections : Page Filters', () => {
cy.url().then((url) => {
const currURL = new URL(url);
currURL.searchParams.set('pageFilters', formatPageFilterSearchParam(NEW_FILTERS));
currURL.searchParams.set('pageFilters', encode(formatPageFilterSearchParam(NEW_FILTERS)));
cy.visit(currURL.toString());
waitForAlerts();
assertFilterControlsWithFilterObject(NEW_FILTERS);
@ -104,7 +105,7 @@ describe('Detections : Page Filters', () => {
cy.url().then((url) => {
const currURL = new URL(url);
currURL.searchParams.set('pageFilters', pageFilterUrlString);
currURL.searchParams.set('pageFilters', encode(pageFilterUrlString));
cy.visit(currURL.toString());
waitForAlerts();

View file

@ -26,6 +26,7 @@
{
"path": "../tsconfig.json",
"force": true
}
},
"@kbn/rison"
]
}

View file

@ -210,7 +210,7 @@ const useExternalAlertsInitialUrlState = () => {
const getInitialUrlParamValue = useGetInitialUrlParamValue<boolean>(EXTERNAL_ALERTS_URL_PARAM);
const { decodedParam: showExternalAlertsInitialUrlState } = useMemo(
const showExternalAlertsInitialUrlState = useMemo(
() => getInitialUrlParamValue(),
[getInitialUrlParamValue]
);
@ -218,12 +218,9 @@ const useExternalAlertsInitialUrlState = () => {
useEffect(() => {
// Only called on component unmount
return () => {
replaceUrlParams([
{
key: EXTERNAL_ALERTS_URL_PARAM,
value: null,
},
]);
replaceUrlParams({
[EXTERNAL_ALERTS_URL_PARAM]: null,
});
};
}, [replaceUrlParams]);
@ -236,11 +233,8 @@ const useExternalAlertsInitialUrlState = () => {
const useSyncExternalAlertsUrlState = (showExternalAlerts: boolean) => {
const replaceUrlParams = useReplaceUrlParams();
useEffect(() => {
replaceUrlParams([
{
key: EXTERNAL_ALERTS_URL_PARAM,
value: showExternalAlerts ? 'true' : null,
},
]);
replaceUrlParams({
[EXTERNAL_ALERTS_URL_PARAM]: showExternalAlerts ? true : null,
});
}, [showExternalAlerts, replaceUrlParams]);
};

View file

@ -7,8 +7,8 @@
import type { Plugin } from 'unified';
import type { RemarkTokenizer } from '@elastic/eui';
import { safeDecode } from '@kbn/rison';
import { parse } from 'query-string';
import { decodeRisonUrlState } from '../../../../utils/global_query_string/helpers';
import { ID, PREFIX } from './constants';
import * as i18n from './translations';
@ -73,9 +73,14 @@ export const TimelineParser: Plugin = function () {
try {
const timelineSearch = timelineUrl.split('?');
const parseTimelineUrlSearch = parse(timelineSearch[1]) as { timeline: string };
const { id: timelineId = '', graphEventId = '' } = decodeRisonUrlState(
parseTimelineUrlSearch.timeline ?? ''
) ?? { id: null, graphEventId: '' };
const decodedTimeline = safeDecode(parseTimelineUrlSearch.timeline ?? '') as {
id?: string;
graphEventId?: string;
} | null;
const { id: timelineId = '', graphEventId = '' } = decodedTimeline ?? {
id: null,
graphEventId: '',
};
if (!timelineId) {
this.file.info(i18n.NO_TIMELINE_ID_FOUND, {

View file

@ -7,7 +7,6 @@
import { queryTimelineById } from '../../../timelines/components/open_timeline/helpers';
import { useQueryTimelineByIdOnUrlChange } from './use_query_timeline_by_id_on_url_change';
import * as urlHelpers from '../../utils/global_query_string/helpers';
import { renderHook } from '@testing-library/react-hooks';
import { timelineDefaults } from '../../../timelines/store/timeline/defaults';
@ -91,15 +90,11 @@ describe('queryTimelineByIdOnUrlChange', () => {
describe('when decode rison fails', () => {
it('should not call queryTimelineById', () => {
jest.spyOn(urlHelpers, 'decodeRisonUrlState').mockImplementationOnce(() => {
throw new Error('Unable to decode');
});
mockUseLocation.mockReturnValue({ search: oldTimelineRisonSearchString });
const { rerender } = renderHook(() => useQueryTimelineByIdOnUrlChange());
mockUseLocation.mockReturnValue({ search: newTimelineRisonSearchString });
jest.clearAllMocks();
mockUseLocation.mockReturnValue({ search: '?foo=bar' });
rerender();
expect(queryTimelineById).not.toBeCalled();

View file

@ -10,6 +10,7 @@ import { useEffect, useMemo } from 'react';
import { useLocation } from 'react-router-dom';
import usePrevious from 'react-use/lib/usePrevious';
import { useDispatch } from 'react-redux';
import { safeDecode } from '@kbn/rison';
import type { TimelineUrl } from '../../../timelines/store/timeline/model';
import { timelineActions, timelineSelectors } from '../../../timelines/store/timeline';
import { TimelineId, TimelineTabs } from '../../../../common/types';
@ -20,7 +21,6 @@ import {
queryTimelineById,
} from '../../../timelines/components/open_timeline/helpers';
import {
decodeRisonUrlState,
getParamFromQueryString,
getQueryStringFromLocation,
} from '../../utils/global_query_string/helpers';
@ -51,29 +51,14 @@ export const useQueryTimelineByIdOnUrlChange = () => {
urlKey: URL_PARAM_KEY.timeline,
search: oldSearch ?? '',
});
const newUrlStateString = getQueryStringKeyValue({ urlKey: URL_PARAM_KEY.timeline, search });
if (oldUrlStateString != null && newUrlStateString != null) {
let newTimeline = null;
let oldTimeline = null;
try {
newTimeline = decodeRisonUrlState<TimelineUrl>(newUrlStateString);
} catch (error) {
// do nothing as timeline is defaulted to null
}
try {
oldTimeline = decodeRisonUrlState<TimelineUrl>(oldUrlStateString);
} catch (error) {
// do nothing as timeline is defaulted to null
}
return [oldTimeline, newTimeline];
}
return [null, null];
return oldUrlStateString != null && newUrlStateString != null
? [
safeDecode(oldUrlStateString) as TimelineUrl | null,
safeDecode(newUrlStateString) as TimelineUrl | null,
]
: [null, null];
}, [oldSearch, search]);
const oldId = previousTimeline?.id;

View file

@ -9,7 +9,6 @@ import { renderHook } from '@testing-library/react-hooks';
import { useDeepEqualSelector } from './use_selector';
import { useKibana } from '../lib/kibana';
import { useResolveConflict } from './use_resolve_conflict';
import * as urlHelpers from '../utils/global_query_string/helpers';
jest.mock('react-router-dom', () => {
const original = jest.requireActual('react-router-dom');
@ -127,9 +126,6 @@ describe('useResolveConflict', () => {
describe('rison is unable to be decoded', () => {
it('should use timeline values from redux to create the otherObjectPath', async () => {
jest.spyOn(urlHelpers, 'decodeRisonUrlState').mockImplementation(() => {
throw new Error('Unable to decode');
});
(useLocation as jest.Mock).mockReturnValue({
pathname: 'my/cool/path',
search: '?foo=bar',

View file

@ -8,12 +8,12 @@
import React, { useCallback, useMemo } from 'react';
import { useLocation } from 'react-router-dom';
import { EuiSpacer } from '@elastic/eui';
import { safeDecode, encode } from '@kbn/rison';
import { useDeepEqualSelector } from './use_selector';
import { TimelineId } from '../../../common/types/timeline';
import { timelineSelectors } from '../../timelines/store/timeline';
import type { TimelineUrl } from '../../timelines/store/timeline/model';
import { timelineDefaults } from '../../timelines/store/timeline/defaults';
import { decodeRisonUrlState, encodeRisonUrlState } from '../utils/global_query_string/helpers';
import { useKibana } from '../lib/kibana';
import { URL_PARAM_KEY } from './use_url_state';
@ -53,12 +53,9 @@ export const useResolveConflict = () => {
activeTab,
graphEventId,
};
let timelineSearch: TimelineUrl = currentTimelineState;
try {
timelineSearch = decodeRisonUrlState(timelineRison) ?? currentTimelineState;
} catch (error) {
// do nothing as it's already defaulted on line 77
}
const timelineSearch =
(safeDecode(timelineRison ?? '') as TimelineUrl | null) ?? currentTimelineState;
// We have resolved to one object, but another object has a legacy URL alias associated with this ID/page. We should display a
// callout with a warning for the user, and provide a way for them to navigate to the other object.
const currentObjectId = timelineSearch?.id;
@ -68,7 +65,7 @@ export const useResolveConflict = () => {
...timelineSearch,
id: newSavedObjectId,
};
const newTimelineRison = encodeRisonUrlState(newTimelineSearch);
const newTimelineRison = encode(newTimelineSearch);
searchQuery.set(URL_PARAM_KEY.timeline, newTimelineRison);
const newPath = `${pathname}?${searchQuery.toString()}${window.location.hash}`;

View file

@ -10,7 +10,6 @@ import { renderHook } from '@testing-library/react-hooks';
import { useDeepEqualSelector } from './use_selector';
import { useKibana } from '../lib/kibana';
import { useResolveRedirect } from './use_resolve_redirect';
import * as urlHelpers from '../utils/global_query_string/helpers';
jest.mock('react-router-dom', () => {
const original = jest.requireActual('react-router-dom');
@ -101,9 +100,6 @@ describe('useResolveRedirect', () => {
describe('rison is unable to be decoded', () => {
it('should use timeline values from redux to create the redirect path', async () => {
jest.spyOn(urlHelpers, 'decodeRisonUrlState').mockImplementation(() => {
throw new Error('Unable to decode');
});
(useLocation as jest.Mock).mockReturnValue({
pathname: 'my/cool/path',
search: '?foo=bar',

View file

@ -7,11 +7,11 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useLocation } from 'react-router-dom';
import { safeDecode, encode } from '@kbn/rison';
import { useDeepEqualSelector } from './use_selector';
import { TimelineId } from '../../../common/types/timeline';
import { timelineSelectors } from '../../timelines/store/timeline';
import { timelineDefaults } from '../../timelines/store/timeline/defaults';
import { decodeRisonUrlState, encodeRisonUrlState } from '../utils/global_query_string/helpers';
import { useKibana } from '../lib/kibana';
import type { TimelineUrl } from '../../timelines/store/timeline/model';
import { URL_PARAM_KEY } from './use_url_state';
@ -42,12 +42,8 @@ export const useResolveRedirect = () => {
activeTab,
graphEventId,
};
let timelineSearch: TimelineUrl = currentTimelineState;
try {
timelineSearch = decodeRisonUrlState(timelineRison) ?? currentTimelineState;
} catch (error) {
// do nothing as it's already defaulted on line 77
}
const timelineSearch =
(safeDecode(timelineRison ?? '') as TimelineUrl | null) ?? currentTimelineState;
if (
hasRedirected ||
@ -64,7 +60,7 @@ export const useResolveRedirect = () => {
...timelineSearch,
id: newObjectId,
};
const newTimelineRison = encodeRisonUrlState(newTimelineSearch);
const newTimelineRison = encode(newTimelineSearch);
searchQuery.set(URL_PARAM_KEY.timeline, newTimelineRison);
const newPath = `${pathname}?${searchQuery.toString()}`;
spaces.ui.redirectLegacyUrl({

View file

@ -5,16 +5,17 @@
* 2.0.
*/
import type { RisonValue } from '@kbn/rison';
import actionCreatorFactory from 'typescript-fsa';
const actionCreator = actionCreatorFactory('x-pack/security_solution/local/global_url_param');
export const registerUrlParam = actionCreator<{ key: string; initialValue: string | null }>(
export const registerUrlParam = actionCreator<{ key: string; initialValue: RisonValue | null }>(
'REGISTER_URL_PARAM'
);
export const deregisterUrlParam = actionCreator<{ key: string }>('DEREGISTER_URL_PARAM');
export const updateUrlParam = actionCreator<{ key: string; value: string | null }>(
export const updateUrlParam = actionCreator<{ key: string; value: RisonValue | null }>(
'UPDATE_URL_PARAM'
);

View file

@ -5,10 +5,12 @@
* 2.0.
*/
import type { RisonValue } from '@kbn/rison';
import deepEqual from 'fast-deep-equal';
import { reducerWithInitialState } from 'typescript-fsa-reducers';
import { registerUrlParam, updateUrlParam, deregisterUrlParam } from './actions';
export type GlobalUrlParam = Record<string, string | null>;
export type GlobalUrlParam = Record<string, RisonValue | null>;
export const initialGlobalUrlParam: GlobalUrlParam = {};
@ -34,8 +36,7 @@ export const globalUrlParamReducer = reducerWithInitialState(initialGlobalUrlPar
return nextState;
})
.case(updateUrlParam, (state, { key, value }) => {
// Only update the URL after the query param is registered and if the current value is different than the previous value
if (state[key] === undefined || state[key] === value) {
if (state[key] === undefined || deepEqual(state[key], value)) {
return state;
}

View file

@ -5,7 +5,8 @@
* 2.0.
*/
import { decode, encode } from '@kbn/rison';
import type { RisonValue } from '@kbn/rison';
import { safeDecode, encode } from '@kbn/rison';
import type { ParsedQuery } from 'query-string';
import { parse, stringify } from 'query-string';
import { url } from '@kbn/kibana-utils-plugin/public';
@ -19,20 +20,6 @@ export const isDetectionsPages = (pageName: string) =>
pageName === SecurityPageName.rulesCreate ||
pageName === SecurityPageName.exceptions;
export const decodeRisonUrlState = <T>(value: string | undefined): T | null => {
try {
return value ? (decode(value) as unknown as T) : null;
} catch (error) {
if (error instanceof Error && error.message.startsWith('rison decoder error')) {
return null;
}
throw error;
}
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const encodeRisonUrlState = (state: any) => encode(state);
export const getQueryStringFromLocation = (search: string) => search.substring(1);
export const getParamFromQueryString = (
@ -51,18 +38,19 @@ export const getParamFromQueryString = (
* It doesn't update when the URL changes.
*
*/
export const useGetInitialUrlParamValue = <State>(urlParamKey: string) => {
export const useGetInitialUrlParamValue = <State extends RisonValue>(
urlParamKey: string
): (() => State | null) => {
// 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(
const getInitialUrlParamValue = useCallback((): State | null => {
const rawParamValue = getParamFromQueryString(
getQueryStringFromLocation(window.location.search),
urlParamKey
);
const paramValue = safeDecode(rawParamValue ?? '') as State | null;
const decodedParam = decodeRisonUrlState<State>(param ?? undefined);
return { param, decodedParam };
return paramValue;
}, [urlParamKey]);
return getInitialUrlParamValue;
@ -71,22 +59,30 @@ export const useGetInitialUrlParamValue = <State>(urlParamKey: string) => {
export const encodeQueryString = (urlParams: ParsedQuery<string>): string =>
stringify(url.encodeQuery(urlParams), { sort: false, encode: false });
export const useReplaceUrlParams = () => {
export const useReplaceUrlParams = (): ((params: Record<string, RisonValue | null>) => void) => {
const history = useHistory();
const replaceUrlParams = useCallback(
(params: Array<{ key: string; value: string | null }>) => {
(params: Record<string, RisonValue | null>): void => {
// 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 }) => {
Object.keys(params).forEach((key) => {
const value = params[key];
if (value == null || value === '') {
delete urlParams[key];
} else {
urlParams[key] = value;
return;
}
try {
urlParams[key] = encode(value);
} catch {
// eslint-disable-next-line no-console
console.error('Unable to encode url param value');
}
});

View file

@ -130,7 +130,7 @@ describe('global query string', () => {
expect(mockDispatch).toBeCalledWith(
globalUrlParamActions.registerUrlParam({
key: urlParamKey,
initialValue: initialValue.toString(),
initialValue,
})
);
});
@ -140,7 +140,6 @@ describe('global query string', () => {
it('dispatch updateUrlParam action', () => {
const urlParamKey = 'testKey';
const value = { test: 123 };
const encodedVaue = '(test:123)';
const globalUrlParam = {
[urlParamKey]: 'oldValue',
@ -156,7 +155,7 @@ describe('global query string', () => {
expect(mockDispatch).toBeCalledWith(
globalUrlParamActions.updateUrlParam({
key: urlParamKey,
value: encodedVaue,
value,
})
);
});
@ -186,10 +185,12 @@ describe('global query string', () => {
{
...mockGlobalState,
globalUrlParam: {
testNumber: '123',
testObject: '(test:321)',
testNumber: 123,
testObject: { testKey: 321 },
testEmptyObject: {},
testEmptyArray: [],
testNull: null,
testEmpty: '',
testEmptyString: '',
},
},
SUB_PLUGINS_REDUCER,
@ -202,7 +203,7 @@ describe('global query string', () => {
const { result } = renderHook(() => useGlobalQueryString(), { wrapper });
expect(result.current).toEqual('testNumber=123&testObject=(test:321)');
expect(result.current).toEqual(`testNumber=123&testObject=(testKey:321)`);
});
});
@ -218,7 +219,7 @@ describe('global query string', () => {
renderHook(() => useSyncGlobalQueryString(), { wrapper: makeWrapper(globalUrlParam) });
expect(mockHistory.replace).toHaveBeenCalledWith({
search: `firstKey=111&${urlParamKey}=${value}&lastKey=999`,
search: `firstKey=111&${urlParamKey}='${value}'&lastKey=999`,
});
});
@ -236,7 +237,7 @@ describe('global query string', () => {
renderHook(() => useSyncGlobalQueryString(), { wrapper: makeWrapper(globalUrlParam) });
expect(mockHistory.replace).toHaveBeenCalledWith({
search: `${urlParamKey1}=${value1}&${urlParamKey2}=${value2}`,
search: `${urlParamKey1}='${value1}'&${urlParamKey2}='${value2}'`,
});
});

View file

@ -9,12 +9,8 @@ import { useCallback, useEffect, useMemo } from 'react';
import { difference, isEmpty, pickBy } from 'lodash/fp';
import { useDispatch } from 'react-redux';
import usePrevious from 'react-use/lib/usePrevious';
import {
encodeQueryString,
encodeRisonUrlState,
useGetInitialUrlParamValue,
useReplaceUrlParams,
} from './helpers';
import { encode } from '@kbn/rison';
import { encodeQueryString, useGetInitialUrlParamValue, useReplaceUrlParams } from './helpers';
import { useShallowEqualSelector } from '../../hooks/use_selector';
import { globalUrlParamActions, globalUrlParamSelectors } from '../../store/global_url_param';
import { useRouteSpy } from '../route/use_route_spy';
@ -29,7 +25,7 @@ import { getLinkInfo } from '../../links';
* @param urlParamKey Must not change.
* @param onInitialize Called once when initializing. It must not change.
*/
export const useInitializeUrlParam = <State>(
export const useInitializeUrlParam = <State extends {}>(
urlParamKey: string,
/**
* @param state Decoded URL param value.
@ -38,20 +34,20 @@ export const useInitializeUrlParam = <State>(
) => {
const dispatch = useDispatch();
const getInitialUrlParamValue = useGetInitialUrlParamValue<State>(urlParamKey);
const getInitialUrlParamValue = useGetInitialUrlParamValue(urlParamKey);
useEffect(() => {
const { param: initialValue, decodedParam: decodedInitialValue } = getInitialUrlParamValue();
const value = getInitialUrlParamValue();
dispatch(
globalUrlParamActions.registerUrlParam({
key: urlParamKey,
initialValue: initialValue ?? null,
initialValue: value,
})
);
// execute consumer initialization
onInitialize(decodedInitialValue);
onInitialize(value as State);
return () => {
dispatch(globalUrlParamActions.deregisterUrlParam({ key: urlParamKey }));
@ -65,13 +61,12 @@ export const useInitializeUrlParam = <State>(
*
* Make sure to call `useInitializeUrlParam` before calling this function.
*/
export const useUpdateUrlParam = <State>(urlParamKey: string) => {
export const useUpdateUrlParam = <State extends {}>(urlParamKey: string) => {
const dispatch = useDispatch();
const updateUrlParam = useCallback(
(value: State | null) => {
const encodedValue = value !== null ? encodeRisonUrlState(value) : null;
dispatch(globalUrlParamActions.updateUrlParam({ key: urlParamKey, value: encodedValue }));
dispatch(globalUrlParamActions.updateUrlParam({ key: urlParamKey, value }));
},
[dispatch, urlParamKey]
);
@ -81,11 +76,29 @@ export const useUpdateUrlParam = <State>(urlParamKey: string) => {
export const useGlobalQueryString = (): string => {
const globalUrlParam = useShallowEqualSelector(globalUrlParamSelectors.selectGlobalUrlParam);
const globalQueryString = useMemo(() => {
const encodedGlobalUrlParam: Record<string, string> = {};
const globalQueryString = useMemo(
() => encodeQueryString(pickBy((value) => !isEmpty(value), globalUrlParam)),
[globalUrlParam]
);
if (!globalUrlParam) {
return '';
}
Object.keys(globalUrlParam).forEach((paramName) => {
const value = globalUrlParam[paramName];
if (!value || (typeof value === 'object' && isEmpty(value))) {
return;
}
try {
encodedGlobalUrlParam[paramName] = encode(value);
} catch {
// Just ignore parameters which unable to encode
}
});
return encodeQueryString(pickBy((value) => !isEmpty(value), encodedGlobalUrlParam));
}, [globalUrlParam]);
return globalQueryString;
};
@ -100,29 +113,29 @@ export const useSyncGlobalQueryString = () => {
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 };
const params = Object.entries(globalUrlParam).map(([key, value]) => ({
key,
value: linkInfo.skipUrlState ? null : value,
}));
const paramsToUpdate = { ...globalUrlParam };
if (linkInfo.skipUrlState) {
Object.keys(paramsToUpdate).forEach((key) => {
paramsToUpdate[key] = null;
});
}
// Url params that got deleted from GlobalUrlParams
const unregisteredKeys = difference(
Object.keys(previousGlobalUrlParams ?? {}),
Object.keys(globalUrlParam)
);
// Delete unregistered Url params
unregisteredKeys.forEach((key) => {
params.push({
key,
value: null,
});
paramsToUpdate[key] = null;
});
if (params.length > 0) {
replaceUrlParams(params);
if (Object.keys(paramsToUpdate).length > 0) {
replaceUrlParams(paramsToUpdate);
}
}, [globalUrlParam, pageName, unregisteredKeys, replaceUrlParams]);
}, [previousGlobalUrlParams, globalUrlParam, pageName, replaceUrlParams]);
};

View file

@ -19,9 +19,7 @@ export function mockRulesTablePersistedState({
urlState: RulesTableUrlSavedState | null;
storageState: RulesTableStorageSavedState | null;
}): void {
(useGetInitialUrlParamValue as jest.Mock).mockReturnValue(
jest.fn().mockReturnValue({ decodedParam: urlState })
);
(useGetInitialUrlParamValue as jest.Mock).mockReturnValue(jest.fn().mockReturnValue(urlState));
(useKibana as jest.Mock).mockReturnValue({
services: { sessionStorage: { get: jest.fn().mockReturnValue(storageState) } },
});

View file

@ -62,7 +62,7 @@ export function useInitializeRulesTableSavedState(): void {
} = useKibana();
useEffect(() => {
const { decodedParam: urlState } = getUrlParam();
const urlState = getUrlParam();
const storageState = readStorageState(sessionStorage);
if (!urlState && !storageState) {

View file

@ -55,12 +55,9 @@ describe('useSyncRulesTableSavedState', () => {
renderHook(() => useSyncRulesTableSavedState());
expect(replaceUrlParams).toHaveBeenCalledWith([
{
key: URL_PARAM_KEY.rulesTable,
value: expectedUrlState,
},
]);
expect(replaceUrlParams).toHaveBeenCalledWith({
[URL_PARAM_KEY.rulesTable]: expectedUrlState,
});
};
const expectStateToSyncWithStorage = (
@ -96,7 +93,7 @@ describe('useSyncRulesTableSavedState', () => {
renderHook(() => useSyncRulesTableSavedState());
expect(replaceUrlParams).toHaveBeenCalledWith([{ key: URL_PARAM_KEY.rulesTable, value: null }]);
expect(replaceUrlParams).toHaveBeenCalledWith({ [URL_PARAM_KEY.rulesTable]: null });
expect(setStorage).not.toHaveBeenCalled();
expect(removeStorage).toHaveBeenCalledWith(RULES_TABLE_STATE_STORAGE_KEY);
});
@ -136,9 +133,7 @@ describe('useSyncRulesTableSavedState', () => {
renderHook(() => useSyncRulesTableSavedState());
expect(replaceUrlParams).toHaveBeenCalledWith([
{ key: URL_PARAM_KEY.rulesTable, value: expectedUrlState },
]);
expect(replaceUrlParams).toHaveBeenCalledWith({ [URL_PARAM_KEY.rulesTable]: expectedUrlState });
expect(setStorage).toHaveBeenCalledWith(RULES_TABLE_STATE_STORAGE_KEY, expectedStorageState);
});

View file

@ -6,10 +6,7 @@
*/
import { useEffect } from 'react';
import {
encodeRisonUrlState,
useReplaceUrlParams,
} from '../../../../../common/utils/global_query_string/helpers';
import { useReplaceUrlParams } from '../../../../../common/utils/global_query_string/helpers';
import { useKibana } from '../../../../../common/lib/kibana';
import { URL_PARAM_KEY } from '../../../../../common/hooks/use_url_state';
import { RULES_TABLE_STATE_STORAGE_KEY } from '../constants';
@ -76,7 +73,7 @@ export function useSyncRulesTableSavedState(): void {
const hasStorageStateToSave = Object.keys(storageStateToSave).length > 0;
if (!hasUrlStateToSave) {
replaceUrlParams([{ key: URL_PARAM_KEY.rulesTable, value: null }]);
replaceUrlParams({ [URL_PARAM_KEY.rulesTable]: null });
}
if (!hasStorageStateToSave) {
@ -87,9 +84,7 @@ export function useSyncRulesTableSavedState(): void {
return;
}
replaceUrlParams([
{ key: URL_PARAM_KEY.rulesTable, value: encodeRisonUrlState(urlStateToSave) },
]);
replaceUrlParams({ [URL_PARAM_KEY.rulesTable]: urlStateToSave });
sessionStorage.set(RULES_TABLE_STATE_STORAGE_KEY, storageStateToSave);
}, [replaceUrlParams, sessionStorage, state]);
}

View file

@ -100,9 +100,7 @@ describe('useRuleFromTimeline', () => {
jest.clearAllMocks();
appToastsMock = useAppToastsMock.create();
(useAppToasts as jest.Mock).mockReturnValue(appToastsMock);
(useGetInitialUrlParamValue as jest.Mock).mockReturnValue(() => ({
decodedParam: timelineId,
}));
(useGetInitialUrlParamValue as jest.Mock).mockReturnValue(() => timelineId);
(resolveTimeline as jest.Mock).mockResolvedValue(selectedTimeline);
});
@ -139,9 +137,7 @@ describe('useRuleFromTimeline', () => {
});
});
it('if no timeline id in URL, loading: false and query not set', async () => {
(useGetInitialUrlParamValue as jest.Mock).mockReturnValue(() => ({
decodedParam: undefined,
}));
(useGetInitialUrlParamValue as jest.Mock).mockReturnValue(() => undefined);
const { result } = renderHook(() => useRuleFromTimeline(setRuleQuery));
expect(result.current.loading).toEqual(false);
@ -227,9 +223,7 @@ describe('useRuleFromTimeline', () => {
});
it('Sets rule from timeline query via callback', async () => {
(useGetInitialUrlParamValue as jest.Mock).mockReturnValue(() => ({
decodedParam: undefined,
}));
(useGetInitialUrlParamValue as jest.Mock).mockReturnValue(() => undefined);
const { result } = renderHook(() => useRuleFromTimeline(setRuleQuery));
expect(result.current.loading).toEqual(false);
await act(async () => {
@ -280,9 +274,7 @@ describe('useRuleFromTimeline', () => {
});
it('Handles error when query is malformed', async () => {
(useGetInitialUrlParamValue as jest.Mock).mockReturnValue(() => ({
decodedParam: undefined,
}));
(useGetInitialUrlParamValue as jest.Mock).mockReturnValue(() => undefined);
const { result } = renderHook(() => useRuleFromTimeline(setRuleQuery));
expect(result.current.loading).toEqual(false);
const tl = {

View file

@ -181,9 +181,7 @@ export const useRuleFromTimeline = (setRuleQuery: SetRuleQuery): RuleFromTimelin
// start handle set rule from timeline id
const getInitialUrlParamValue = useGetInitialUrlParamValue<string>(RULE_FROM_TIMELINE_URL_PARAM);
const { decodedParam: timelineIdFromUrl } = useMemo(getInitialUrlParamValue, [
getInitialUrlParamValue,
]);
const timelineIdFromUrl = useMemo(getInitialUrlParamValue, [getInitialUrlParamValue]);
const getTimelineById = useCallback(
(timelineId: string) => {

View file

@ -7,9 +7,9 @@
import React, { useEffect, useState, useCallback, useMemo } from 'react';
import { useDispatch } from 'react-redux';
import { encode } from '@kbn/rison';
import { RULE_FROM_TIMELINE_URL_PARAM } from '../../../detections/containers/detection_engine/rules/use_rule_from_timeline';
import { encodeRisonUrlState } from '../../../common/utils/global_query_string/helpers';
import { useNavigation } from '../../../common/lib/kibana';
import { SecurityPageName } from '../../../../common/constants';
import { useShallowEqualSelector } from '../../../common/hooks/use_selector';
@ -283,7 +283,7 @@ export const StatefulOpenTimelineComponent = React.memo<OpenTimelineOwnProps>(
(savedObjectId) =>
navigateTo({
deepLinkId: SecurityPageName.rulesCreate,
path: `?${RULE_FROM_TIMELINE_URL_PARAM}=${encodeRisonUrlState(savedObjectId)}`,
path: `?${RULE_FROM_TIMELINE_URL_PARAM}=${encode(savedObjectId)}`,
}),
[navigateTo]
);