mirror of
https://github.com/elastic/kibana.git
synced 2025-06-28 11:05:39 -04:00
[Security Solution] Store expandable flyout state in the url (#154703)
This commit is contained in:
parent
22b8e5b0a6
commit
8a3f5ebbea
22 changed files with 563 additions and 24 deletions
1
.github/CODEOWNERS
vendored
1
.github/CODEOWNERS
vendored
|
@ -683,6 +683,7 @@ src/plugins/unified_search @elastic/kibana-visualizations
|
||||||
x-pack/plugins/upgrade_assistant @elastic/platform-deployment-management
|
x-pack/plugins/upgrade_assistant @elastic/platform-deployment-management
|
||||||
x-pack/plugins/drilldowns/url_drilldown @elastic/kibana-app-services
|
x-pack/plugins/drilldowns/url_drilldown @elastic/kibana-app-services
|
||||||
src/plugins/url_forwarding @elastic/kibana-visualizations
|
src/plugins/url_forwarding @elastic/kibana-visualizations
|
||||||
|
packages/kbn-url-state @elastic/security-threat-hunting-investigations
|
||||||
src/plugins/usage_collection @elastic/kibana-core
|
src/plugins/usage_collection @elastic/kibana-core
|
||||||
test/plugin_functional/plugins/usage_collection @elastic/kibana-core
|
test/plugin_functional/plugins/usage_collection @elastic/kibana-core
|
||||||
packages/kbn-user-profile-components @elastic/kibana-security
|
packages/kbn-user-profile-components @elastic/kibana-security
|
||||||
|
|
|
@ -672,6 +672,7 @@
|
||||||
"@kbn/upgrade-assistant-plugin": "link:x-pack/plugins/upgrade_assistant",
|
"@kbn/upgrade-assistant-plugin": "link:x-pack/plugins/upgrade_assistant",
|
||||||
"@kbn/url-drilldown-plugin": "link:x-pack/plugins/drilldowns/url_drilldown",
|
"@kbn/url-drilldown-plugin": "link:x-pack/plugins/drilldowns/url_drilldown",
|
||||||
"@kbn/url-forwarding-plugin": "link:src/plugins/url_forwarding",
|
"@kbn/url-forwarding-plugin": "link:src/plugins/url_forwarding",
|
||||||
|
"@kbn/url-state": "link:packages/kbn-url-state",
|
||||||
"@kbn/usage-collection-plugin": "link:src/plugins/usage_collection",
|
"@kbn/usage-collection-plugin": "link:src/plugins/usage_collection",
|
||||||
"@kbn/usage-collection-test-plugin": "link:test/plugin_functional/plugins/usage_collection",
|
"@kbn/usage-collection-test-plugin": "link:test/plugin_functional/plugins/usage_collection",
|
||||||
"@kbn/user-profile-components": "link:packages/kbn-user-profile-components",
|
"@kbn/user-profile-components": "link:packages/kbn-user-profile-components",
|
||||||
|
|
|
@ -7,7 +7,13 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export { ExpandableFlyout } from './src';
|
export { ExpandableFlyout } from './src';
|
||||||
export { ExpandableFlyoutProvider, useExpandableFlyoutContext } from './src/context';
|
export {
|
||||||
|
ExpandableFlyoutProvider,
|
||||||
|
useExpandableFlyoutContext,
|
||||||
|
type ExpandableFlyoutContext,
|
||||||
|
} from './src/context';
|
||||||
|
|
||||||
|
export type { ExpandableFlyoutApi } from './src/context';
|
||||||
|
|
||||||
export type { ExpandableFlyoutProps } from './src';
|
export type { ExpandableFlyoutProps } from './src';
|
||||||
export type { FlyoutPanel } from './src/types';
|
export type { FlyoutPanel } from './src/types';
|
||||||
|
|
|
@ -6,7 +6,15 @@
|
||||||
* Side Public License, v 1.
|
* Side Public License, v 1.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { createContext, useCallback, useContext, useMemo, useReducer } from 'react';
|
import React, {
|
||||||
|
createContext,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useImperativeHandle,
|
||||||
|
useMemo,
|
||||||
|
useReducer,
|
||||||
|
} from 'react';
|
||||||
import { ActionType } from './actions';
|
import { ActionType } from './actions';
|
||||||
import { reducer, State } from './reducer';
|
import { reducer, State } from './reducer';
|
||||||
import type { FlyoutPanel } from './types';
|
import type { FlyoutPanel } from './types';
|
||||||
|
@ -59,19 +67,44 @@ export const ExpandableFlyoutContext = createContext<ExpandableFlyoutContext | u
|
||||||
undefined
|
undefined
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export type ExpandableFlyoutApi = Pick<ExpandableFlyoutContext, 'openFlyout'> & {
|
||||||
|
getState: () => State;
|
||||||
|
};
|
||||||
|
|
||||||
export interface ExpandableFlyoutProviderProps {
|
export interface ExpandableFlyoutProviderProps {
|
||||||
/**
|
/**
|
||||||
* React children
|
* React children
|
||||||
*/
|
*/
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
|
/**
|
||||||
|
* Triggered whenever flyout state changes. You can use it to store it's state somewhere for instance.
|
||||||
|
*/
|
||||||
|
onChanges?: (state: State) => void;
|
||||||
|
/**
|
||||||
|
* Triggered whenever flyout is closed. This is independent from the onChanges above.
|
||||||
|
*/
|
||||||
|
onClosePanels?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Wrap your plugin with this context for the ExpandableFlyout React component.
|
* Wrap your plugin with this context for the ExpandableFlyout React component.
|
||||||
*/
|
*/
|
||||||
export const ExpandableFlyoutProvider = ({ children }: ExpandableFlyoutProviderProps) => {
|
export const ExpandableFlyoutProvider = React.forwardRef<
|
||||||
|
ExpandableFlyoutApi,
|
||||||
|
ExpandableFlyoutProviderProps
|
||||||
|
>(({ children, onChanges = () => {}, onClosePanels = () => {} }, ref) => {
|
||||||
const [state, dispatch] = useReducer(reducer, initialState);
|
const [state, dispatch] = useReducer(reducer, initialState);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const closed = !state.right;
|
||||||
|
if (closed) {
|
||||||
|
// manual close is singalled via separate callback
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
onChanges(state);
|
||||||
|
}, [state, onChanges]);
|
||||||
|
|
||||||
const openPanels = useCallback(
|
const openPanels = useCallback(
|
||||||
({
|
({
|
||||||
right,
|
right,
|
||||||
|
@ -87,40 +120,45 @@ export const ExpandableFlyoutProvider = ({ children }: ExpandableFlyoutProviderP
|
||||||
|
|
||||||
const openRightPanel = useCallback(
|
const openRightPanel = useCallback(
|
||||||
(panel: FlyoutPanel) => dispatch({ type: ActionType.openRightPanel, payload: panel }),
|
(panel: FlyoutPanel) => dispatch({ type: ActionType.openRightPanel, payload: panel }),
|
||||||
[dispatch]
|
[]
|
||||||
);
|
);
|
||||||
|
|
||||||
const openLeftPanel = useCallback(
|
const openLeftPanel = useCallback(
|
||||||
(panel: FlyoutPanel) => dispatch({ type: ActionType.openLeftPanel, payload: panel }),
|
(panel: FlyoutPanel) => dispatch({ type: ActionType.openLeftPanel, payload: panel }),
|
||||||
[dispatch]
|
[]
|
||||||
);
|
);
|
||||||
|
|
||||||
const openPreviewPanel = useCallback(
|
const openPreviewPanel = useCallback(
|
||||||
(panel: FlyoutPanel) => dispatch({ type: ActionType.openPreviewPanel, payload: panel }),
|
(panel: FlyoutPanel) => dispatch({ type: ActionType.openPreviewPanel, payload: panel }),
|
||||||
[dispatch]
|
[]
|
||||||
);
|
);
|
||||||
|
|
||||||
const closeRightPanel = useCallback(
|
const closeRightPanel = useCallback(() => dispatch({ type: ActionType.closeRightPanel }), []);
|
||||||
() => dispatch({ type: ActionType.closeRightPanel }),
|
|
||||||
[dispatch]
|
|
||||||
);
|
|
||||||
|
|
||||||
const closeLeftPanel = useCallback(
|
const closeLeftPanel = useCallback(() => dispatch({ type: ActionType.closeLeftPanel }), []);
|
||||||
() => dispatch({ type: ActionType.closeLeftPanel }),
|
|
||||||
[dispatch]
|
|
||||||
);
|
|
||||||
|
|
||||||
const closePreviewPanel = useCallback(
|
const closePreviewPanel = useCallback(() => dispatch({ type: ActionType.closePreviewPanel }), []);
|
||||||
() => dispatch({ type: ActionType.closePreviewPanel }),
|
|
||||||
[dispatch]
|
|
||||||
);
|
|
||||||
|
|
||||||
const previousPreviewPanel = useCallback(
|
const previousPreviewPanel = useCallback(
|
||||||
() => dispatch({ type: ActionType.previousPreviewPanel }),
|
() => dispatch({ type: ActionType.previousPreviewPanel }),
|
||||||
[dispatch]
|
[]
|
||||||
);
|
);
|
||||||
|
|
||||||
const closePanels = useCallback(() => dispatch({ type: ActionType.closeFlyout }), [dispatch]);
|
const closePanels = useCallback(() => {
|
||||||
|
dispatch({ type: ActionType.closeFlyout });
|
||||||
|
onClosePanels();
|
||||||
|
}, [onClosePanels]);
|
||||||
|
|
||||||
|
useImperativeHandle(
|
||||||
|
ref,
|
||||||
|
() => {
|
||||||
|
return {
|
||||||
|
openFlyout: openPanels,
|
||||||
|
getState: () => state,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
[openPanels, state]
|
||||||
|
);
|
||||||
|
|
||||||
const contextValue = useMemo(
|
const contextValue = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
|
@ -154,7 +192,7 @@ export const ExpandableFlyoutProvider = ({ children }: ExpandableFlyoutProviderP
|
||||||
{children}
|
{children}
|
||||||
</ExpandableFlyoutContext.Provider>
|
</ExpandableFlyoutContext.Provider>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Retrieve context's properties
|
* Retrieve context's properties
|
||||||
|
|
|
@ -105,5 +105,8 @@ export function reducer(state: State, action: Action) {
|
||||||
preview: [],
|
preview: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
return state;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
45
packages/kbn-url-state/README.md
Normal file
45
packages/kbn-url-state/README.md
Normal file
|
@ -0,0 +1,45 @@
|
||||||
|
# @kbn/url-state - utils for syncing state to URL
|
||||||
|
|
||||||
|
This package provides:
|
||||||
|
|
||||||
|
- a React hook called `useSyncToUrl` that can be used to synchronize state to the URL. This can be useful when you want to make a portion of state shareable.
|
||||||
|
|
||||||
|
## useSyncToUrl
|
||||||
|
|
||||||
|
The `useSyncToUrl` hook takes three arguments:
|
||||||
|
|
||||||
|
```
|
||||||
|
key (string): The key to use in the URL to store the state.
|
||||||
|
restore (function): A function that is called with the deserialized value from the URL. You should use this function to update your state based on the value from the URL.
|
||||||
|
cleanupOnHistoryNavigation (optional boolean, default: true): If true, the hook will clear the URL state when the user navigates using the browser's history API.
|
||||||
|
```
|
||||||
|
|
||||||
|
### Example usage:
|
||||||
|
|
||||||
|
```
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useSyncToUrl } from '@kbn/url-state';
|
||||||
|
|
||||||
|
function MyComponent() {
|
||||||
|
const [count, setCount] = useState(0);
|
||||||
|
|
||||||
|
useSyncToUrl('count', (value) => {
|
||||||
|
setCount(value);
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleClick = () => {
|
||||||
|
setCount((prevCount) => prevCount + 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<p>Count: {count}</p>
|
||||||
|
<button onClick={handleClick}>Increment</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
In this example, the count state is synced to the URL using the `useSyncToUrl` hook.
|
||||||
|
Whenever the count state changes, the hook will update the URL with the new value.
|
||||||
|
When the user copies the updated url or refreshes the page, `restore` function will be called to update the count state.
|
101
packages/kbn-url-state/index.test.ts
Normal file
101
packages/kbn-url-state/index.test.ts
Normal file
|
@ -0,0 +1,101 @@
|
||||||
|
/*
|
||||||
|
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||||
|
* or more contributor license agreements. Licensed under the Elastic License
|
||||||
|
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||||
|
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||||
|
* Side Public License, v 1.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { renderHook, act } from '@testing-library/react-hooks';
|
||||||
|
import { useSyncToUrl } from '.';
|
||||||
|
import { encode } from '@kbn/rison';
|
||||||
|
|
||||||
|
describe('useSyncToUrl', () => {
|
||||||
|
let originalLocation: Location;
|
||||||
|
let originalHistory: History;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
originalLocation = window.location;
|
||||||
|
originalHistory = window.history;
|
||||||
|
delete (window as any).location;
|
||||||
|
delete (window as any).history;
|
||||||
|
|
||||||
|
window.location = {
|
||||||
|
...originalLocation,
|
||||||
|
search: '',
|
||||||
|
};
|
||||||
|
window.history = {
|
||||||
|
...originalHistory,
|
||||||
|
replaceState: jest.fn(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
window.location = originalLocation;
|
||||||
|
window.history = originalHistory;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should restore the value from the query string on mount', () => {
|
||||||
|
const key = 'testKey';
|
||||||
|
const restoredValue = { test: 'value' };
|
||||||
|
const encodedValue = encode(restoredValue);
|
||||||
|
const restore = jest.fn();
|
||||||
|
|
||||||
|
window.location.search = `?${key}=${encodedValue}`;
|
||||||
|
|
||||||
|
renderHook(() => useSyncToUrl(key, restore));
|
||||||
|
|
||||||
|
expect(restore).toHaveBeenCalledWith(restoredValue);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should sync the value to the query string', () => {
|
||||||
|
const key = 'testKey';
|
||||||
|
const valueToSerialize = { test: 'value' };
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useSyncToUrl(key, jest.fn()));
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current(valueToSerialize);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(window.history.replaceState).toHaveBeenCalledWith(
|
||||||
|
{ path: expect.any(String) },
|
||||||
|
'',
|
||||||
|
'/?testKey=%28test%3Avalue%29'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should clear the value from the query string on unmount', () => {
|
||||||
|
const key = 'testKey';
|
||||||
|
|
||||||
|
const { unmount } = renderHook(() => useSyncToUrl(key, jest.fn()));
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(window.history.replaceState).toHaveBeenCalledWith(
|
||||||
|
{ path: expect.any(String) },
|
||||||
|
'',
|
||||||
|
expect.any(String)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should clear the value from the query string when history back or forward is pressed', () => {
|
||||||
|
const key = 'testKey';
|
||||||
|
const restore = jest.fn();
|
||||||
|
|
||||||
|
renderHook(() => useSyncToUrl(key, restore, true));
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
window.dispatchEvent(new Event('popstate'));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(window.history.replaceState).toHaveBeenCalledTimes(1);
|
||||||
|
expect(window.history.replaceState).toHaveBeenCalledWith(
|
||||||
|
{ path: expect.any(String) },
|
||||||
|
'',
|
||||||
|
'/?'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
9
packages/kbn-url-state/index.ts
Normal file
9
packages/kbn-url-state/index.ts
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
/*
|
||||||
|
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||||
|
* or more contributor license agreements. Licensed under the Elastic License
|
||||||
|
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||||
|
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||||
|
* Side Public License, v 1.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export { useSyncToUrl } from './use_sync_to_url';
|
13
packages/kbn-url-state/jest.config.js
Normal file
13
packages/kbn-url-state/jest.config.js
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
/*
|
||||||
|
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||||
|
* or more contributor license agreements. Licensed under the Elastic License
|
||||||
|
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||||
|
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||||
|
* Side Public License, v 1.
|
||||||
|
*/
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
preset: '@kbn/test',
|
||||||
|
rootDir: '../..',
|
||||||
|
roots: ['<rootDir>/packages/kbn-url-state'],
|
||||||
|
};
|
5
packages/kbn-url-state/kibana.jsonc
Normal file
5
packages/kbn-url-state/kibana.jsonc
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
{
|
||||||
|
"type": "shared-common",
|
||||||
|
"id": "@kbn/url-state",
|
||||||
|
"owner": "@elastic/security-threat-hunting-investigations"
|
||||||
|
}
|
6
packages/kbn-url-state/package.json
Normal file
6
packages/kbn-url-state/package.json
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
{
|
||||||
|
"name": "@kbn/url-state",
|
||||||
|
"private": true,
|
||||||
|
"version": "1.0.0",
|
||||||
|
"license": "SSPL-1.0 OR Elastic License 2.0"
|
||||||
|
}
|
21
packages/kbn-url-state/tsconfig.json
Normal file
21
packages/kbn-url-state/tsconfig.json
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "target/types",
|
||||||
|
"types": [
|
||||||
|
"jest",
|
||||||
|
"node",
|
||||||
|
"react"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"**/*.ts",
|
||||||
|
"**/*.tsx",
|
||||||
|
],
|
||||||
|
"exclude": [
|
||||||
|
"target/**/*"
|
||||||
|
],
|
||||||
|
"kbn_references": [
|
||||||
|
"@kbn/rison",
|
||||||
|
]
|
||||||
|
}
|
87
packages/kbn-url-state/use_sync_to_url.ts
Normal file
87
packages/kbn-url-state/use_sync_to_url.ts
Normal file
|
@ -0,0 +1,87 @@
|
||||||
|
/*
|
||||||
|
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||||
|
* or more contributor license agreements. Licensed under the Elastic License
|
||||||
|
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||||
|
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||||
|
* Side Public License, v 1.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useCallback, useEffect } from 'react';
|
||||||
|
import { encode, decode } from '@kbn/rison';
|
||||||
|
|
||||||
|
// https://developer.mozilla.org/en-US/docs/Web/API/Window/popstate_event
|
||||||
|
const POPSTATE_EVENT = 'popstate' as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sync any object with browser query string using @knb/rison
|
||||||
|
* @param key query string param to use
|
||||||
|
* @param restore use this to handle restored state
|
||||||
|
* @param cleanupOnHistoryNavigation use history events to cleanup state on back / forward naviation. true by default
|
||||||
|
*/
|
||||||
|
export const useSyncToUrl = <TValueToSerialize>(
|
||||||
|
key: string,
|
||||||
|
restore: (data: TValueToSerialize) => void,
|
||||||
|
cleanupOnHistoryNavigation = true
|
||||||
|
) => {
|
||||||
|
useEffect(() => {
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const param = params.get(key);
|
||||||
|
|
||||||
|
if (!param) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const decodedQuery = decode(param);
|
||||||
|
|
||||||
|
if (!decodedQuery) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only restore the value if it is not falsy
|
||||||
|
restore(decodedQuery as unknown as TValueToSerialize);
|
||||||
|
}, [key, restore]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Synces value with the url state, under specified key. If payload is undefined, the value will be removed from the query string althogether.
|
||||||
|
*/
|
||||||
|
const syncValueToQueryString = useCallback(
|
||||||
|
(valueToSerialize?: TValueToSerialize) => {
|
||||||
|
const searchParams = new URLSearchParams(window.location.search);
|
||||||
|
|
||||||
|
if (valueToSerialize) {
|
||||||
|
const serializedPayload = encode(valueToSerialize);
|
||||||
|
searchParams.set(key, serializedPayload);
|
||||||
|
} else {
|
||||||
|
searchParams.delete(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
const newSearch = searchParams.toString();
|
||||||
|
|
||||||
|
// Update query string without unnecessary re-render
|
||||||
|
const newUrl = `${window.location.pathname}?${newSearch}`;
|
||||||
|
window.history.replaceState({ path: newUrl }, '', newUrl);
|
||||||
|
},
|
||||||
|
[key]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Clear remove state from the url on unmount / when history back or forward is pressed
|
||||||
|
useEffect(() => {
|
||||||
|
const clearState = () => {
|
||||||
|
syncValueToQueryString(undefined);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (cleanupOnHistoryNavigation) {
|
||||||
|
window.addEventListener(POPSTATE_EVENT, clearState);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearState();
|
||||||
|
|
||||||
|
if (cleanupOnHistoryNavigation) {
|
||||||
|
window.removeEventListener(POPSTATE_EVENT, clearState);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [cleanupOnHistoryNavigation, syncValueToQueryString]);
|
||||||
|
|
||||||
|
return syncValueToQueryString;
|
||||||
|
};
|
|
@ -1360,6 +1360,8 @@
|
||||||
"@kbn/url-drilldown-plugin/*": ["x-pack/plugins/drilldowns/url_drilldown/*"],
|
"@kbn/url-drilldown-plugin/*": ["x-pack/plugins/drilldowns/url_drilldown/*"],
|
||||||
"@kbn/url-forwarding-plugin": ["src/plugins/url_forwarding"],
|
"@kbn/url-forwarding-plugin": ["src/plugins/url_forwarding"],
|
||||||
"@kbn/url-forwarding-plugin/*": ["src/plugins/url_forwarding/*"],
|
"@kbn/url-forwarding-plugin/*": ["src/plugins/url_forwarding/*"],
|
||||||
|
"@kbn/url-state": ["packages/kbn-url-state"],
|
||||||
|
"@kbn/url-state/*": ["packages/kbn-url-state/*"],
|
||||||
"@kbn/usage-collection-plugin": ["src/plugins/usage_collection"],
|
"@kbn/usage-collection-plugin": ["src/plugins/usage_collection"],
|
||||||
"@kbn/usage-collection-plugin/*": ["src/plugins/usage_collection/*"],
|
"@kbn/usage-collection-plugin/*": ["src/plugins/usage_collection/*"],
|
||||||
"@kbn/usage-collection-test-plugin": ["test/plugin_functional/plugins/usage_collection"],
|
"@kbn/usage-collection-test-plugin": ["test/plugin_functional/plugins/usage_collection"],
|
||||||
|
|
|
@ -0,0 +1,55 @@
|
||||||
|
/*
|
||||||
|
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||||
|
* or more contributor license agreements. Licensed under the Elastic License
|
||||||
|
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||||
|
* 2.0.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { getNewRule } from '../../../objects/rule';
|
||||||
|
import { cleanKibana } from '../../../tasks/common';
|
||||||
|
import { waitForAlertsToPopulate } from '../../../tasks/create_new_rule';
|
||||||
|
import { expandFirstAlertExpandableFlyout } from '../../../tasks/document_expandable_flyout';
|
||||||
|
import { login, visit } from '../../../tasks/login';
|
||||||
|
import { createRule } from '../../../tasks/api_calls/rules';
|
||||||
|
import { ALERTS_URL } from '../../../urls/navigation';
|
||||||
|
import {
|
||||||
|
DOCUMENT_DETAILS_FLYOUT_CLOSE_BUTTON,
|
||||||
|
DOCUMENT_DETAILS_FLYOUT_HEADER_TITLE,
|
||||||
|
} from '../../../screens/document_expandable_flyout';
|
||||||
|
|
||||||
|
// Skipping these for now as the feature is protected behind a feature flag set to false by default
|
||||||
|
// To run the tests locally, add 'securityFlyoutEnabled' in the Cypress config.ts here https://github.com/elastic/kibana/blob/main/x-pack/test/security_solution_cypress/config.ts#L50
|
||||||
|
describe.skip('Expandable flyout state sync', { testIsolation: false }, () => {
|
||||||
|
const rule = getNewRule();
|
||||||
|
|
||||||
|
before(() => {
|
||||||
|
cleanKibana();
|
||||||
|
login();
|
||||||
|
createRule(rule);
|
||||||
|
visit(ALERTS_URL);
|
||||||
|
waitForAlertsToPopulate();
|
||||||
|
expandFirstAlertExpandableFlyout();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should serialize its state to url', () => {
|
||||||
|
cy.url().should('include', 'eventFlyout');
|
||||||
|
cy.get(DOCUMENT_DETAILS_FLYOUT_HEADER_TITLE).should('be.visible').and('have.text', rule.name);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reopen the flyout after browser refresh', () => {
|
||||||
|
cy.reload();
|
||||||
|
|
||||||
|
cy.url().should('include', 'eventFlyout');
|
||||||
|
cy.get(DOCUMENT_DETAILS_FLYOUT_HEADER_TITLE).should('be.visible').and('have.text', rule.name);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should clear the url state when flyout is closed', () => {
|
||||||
|
cy.reload();
|
||||||
|
|
||||||
|
cy.get(DOCUMENT_DETAILS_FLYOUT_HEADER_TITLE).should('be.visible').and('have.text', rule.name);
|
||||||
|
|
||||||
|
cy.get(DOCUMENT_DETAILS_FLYOUT_CLOSE_BUTTON).click();
|
||||||
|
|
||||||
|
cy.url().should('not.include', 'eventFlyout');
|
||||||
|
});
|
||||||
|
});
|
|
@ -335,3 +335,6 @@ export const DOCUMENT_DETAILS_FLYOUT_TABLE_TAB_ROW_CELL_ADD_TO_TIMELINE =
|
||||||
getDataTestSubjectSelector('actionItem-security-detailsFlyout-cellActions-addToTimeline');
|
getDataTestSubjectSelector('actionItem-security-detailsFlyout-cellActions-addToTimeline');
|
||||||
export const DOCUMENT_DETAILS_FLYOUT_TABLE_TAB_ROW_CELL_COPY_TO_CLIPBOARD =
|
export const DOCUMENT_DETAILS_FLYOUT_TABLE_TAB_ROW_CELL_COPY_TO_CLIPBOARD =
|
||||||
getDataTestSubjectSelector('actionItem-security-detailsFlyout-cellActions-copyToClipboard');
|
getDataTestSubjectSelector('actionItem-security-detailsFlyout-cellActions-copyToClipboard');
|
||||||
|
|
||||||
|
export const DOCUMENT_DETAILS_FLYOUT_CLOSE_BUTTON =
|
||||||
|
getDataTestSubjectSelector('euiFlyoutCloseButton');
|
||||||
|
|
|
@ -27,6 +27,7 @@ import { useShowTimeline } from '../../../common/utils/timeline/use_show_timelin
|
||||||
import { useShowPagesWithEmptyView } from '../../../common/utils/empty_view/use_show_pages_with_empty_view';
|
import { useShowPagesWithEmptyView } from '../../../common/utils/empty_view/use_show_pages_with_empty_view';
|
||||||
import { useIsPolicySettingsBarVisible } from '../../../management/pages/policy/view/policy_hooks';
|
import { useIsPolicySettingsBarVisible } from '../../../management/pages/policy/view/policy_hooks';
|
||||||
import { useIsGroupedNavigationEnabled } from '../../../common/components/navigation/helpers';
|
import { useIsGroupedNavigationEnabled } from '../../../common/components/navigation/helpers';
|
||||||
|
import { useSyncFlyoutStateWithUrl } from '../../../flyout/url/use_sync_flyout_state_with_url';
|
||||||
|
|
||||||
const NO_DATA_PAGE_MAX_WIDTH = 950;
|
const NO_DATA_PAGE_MAX_WIDTH = 950;
|
||||||
|
|
||||||
|
@ -75,6 +76,8 @@ export const SecuritySolutionTemplateWrapper: React.FC<Omit<KibanaPageTemplatePr
|
||||||
|
|
||||||
const showEmptyState = useShowPagesWithEmptyView() || rest.isEmptyState;
|
const showEmptyState = useShowPagesWithEmptyView() || rest.isEmptyState;
|
||||||
|
|
||||||
|
const [flyoutRef, handleFlyoutChangedOrClosed] = useSyncFlyoutStateWithUrl();
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* StyledKibanaPageTemplate is a styled EuiPageTemplate. Security solution currently passes the header
|
* StyledKibanaPageTemplate is a styled EuiPageTemplate. Security solution currently passes the header
|
||||||
* and page content as the children of StyledKibanaPageTemplate, as opposed to using the pageHeader prop,
|
* and page content as the children of StyledKibanaPageTemplate, as opposed to using the pageHeader prop,
|
||||||
|
@ -82,7 +85,11 @@ export const SecuritySolutionTemplateWrapper: React.FC<Omit<KibanaPageTemplatePr
|
||||||
* between EuiPageTemplate and the security solution pages.
|
* between EuiPageTemplate and the security solution pages.
|
||||||
*/
|
*/
|
||||||
return (
|
return (
|
||||||
<ExpandableFlyoutProvider>
|
<ExpandableFlyoutProvider
|
||||||
|
onChanges={handleFlyoutChangedOrClosed}
|
||||||
|
onClosePanels={handleFlyoutChangedOrClosed}
|
||||||
|
ref={flyoutRef}
|
||||||
|
>
|
||||||
<StyledKibanaPageTemplate
|
<StyledKibanaPageTemplate
|
||||||
$addBottomPadding={addBottomPadding}
|
$addBottomPadding={addBottomPadding}
|
||||||
$isShowingTimelineOverlay={isShowingTimelineOverlay}
|
$isShowingTimelineOverlay={isShowingTimelineOverlay}
|
||||||
|
@ -108,7 +115,11 @@ export const SecuritySolutionTemplateWrapper: React.FC<Omit<KibanaPageTemplatePr
|
||||||
</EuiThemeProvider>
|
</EuiThemeProvider>
|
||||||
</KibanaPageTemplate.BottomBar>
|
</KibanaPageTemplate.BottomBar>
|
||||||
)}
|
)}
|
||||||
<ExpandableFlyout registeredPanels={expandableFlyoutDocumentsPanels} onClose={() => {}} />
|
<ExpandableFlyout
|
||||||
|
registeredPanels={expandableFlyoutDocumentsPanels}
|
||||||
|
onClose={() => {}}
|
||||||
|
handleOnFlyoutClosed={handleFlyoutChangedOrClosed}
|
||||||
|
/>
|
||||||
</StyledKibanaPageTemplate>
|
</StyledKibanaPageTemplate>
|
||||||
</ExpandableFlyoutProvider>
|
</ExpandableFlyoutProvider>
|
||||||
);
|
);
|
||||||
|
|
|
@ -15,6 +15,9 @@ import { useQueryTimelineByIdOnUrlChange } from './timeline/use_query_timeline_b
|
||||||
import { useInitFlyoutFromUrlParam } from './flyout/use_init_flyout_url_param';
|
import { useInitFlyoutFromUrlParam } from './flyout/use_init_flyout_url_param';
|
||||||
import { useSyncFlyoutUrlParam } from './flyout/use_sync_flyout_url_param';
|
import { useSyncFlyoutUrlParam } from './flyout/use_sync_flyout_url_param';
|
||||||
|
|
||||||
|
// NOTE: the expandable flyout package url state is handled here:
|
||||||
|
// x-pack/plugins/security_solution/public/flyout/url/use_sync_flyout_state_with_url.tsx
|
||||||
|
|
||||||
export const useUrlState = () => {
|
export const useUrlState = () => {
|
||||||
useSyncGlobalQueryString();
|
useSyncGlobalQueryString();
|
||||||
useInitSearchBarFromUrlParams();
|
useInitSearchBarFromUrlParams();
|
||||||
|
|
|
@ -0,0 +1,77 @@
|
||||||
|
/*
|
||||||
|
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||||
|
* or more contributor license agreements. Licensed under the Elastic License
|
||||||
|
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||||
|
* 2.0.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { ExpandableFlyoutApi } from '@kbn/expandable-flyout';
|
||||||
|
import { useSyncToUrl } from '@kbn/url-state';
|
||||||
|
import { renderHook } from '@testing-library/react-hooks';
|
||||||
|
import { useSyncFlyoutStateWithUrl } from './use_sync_flyout_state_with_url';
|
||||||
|
|
||||||
|
jest.mock('@kbn/url-state');
|
||||||
|
|
||||||
|
describe('useSyncFlyoutStateWithUrl', () => {
|
||||||
|
it('should return an array containing flyoutApi ref and handleFlyoutChanges function', () => {
|
||||||
|
const { result } = renderHook(() => useSyncFlyoutStateWithUrl());
|
||||||
|
const [flyoutApi, handleFlyoutChanges] = result.current;
|
||||||
|
|
||||||
|
expect(flyoutApi.current).toBeNull();
|
||||||
|
expect(typeof handleFlyoutChanges).toBe('function');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should open flyout when relevant url state is detected in the query string', () => {
|
||||||
|
jest.useFakeTimers();
|
||||||
|
|
||||||
|
jest.mocked(useSyncToUrl).mockImplementation((_urlKey, callback) => {
|
||||||
|
setTimeout(() => callback({ mocked: { flyout: 'state' } }), 0);
|
||||||
|
return jest.fn();
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useSyncFlyoutStateWithUrl());
|
||||||
|
const [flyoutApi, handleFlyoutChanges] = result.current;
|
||||||
|
|
||||||
|
const flyoutApiMock: ExpandableFlyoutApi = {
|
||||||
|
openFlyout: jest.fn(),
|
||||||
|
getState: () => ({ left: undefined, right: undefined, preview: [] }),
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(typeof handleFlyoutChanges).toBe('function');
|
||||||
|
expect(flyoutApi.current).toBeNull();
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
(flyoutApi as any).current = flyoutApiMock;
|
||||||
|
|
||||||
|
jest.runOnlyPendingTimers();
|
||||||
|
jest.useRealTimers();
|
||||||
|
|
||||||
|
expect(flyoutApiMock.openFlyout).toHaveBeenCalledTimes(1);
|
||||||
|
expect(flyoutApiMock.openFlyout).toHaveBeenCalledWith({ mocked: { flyout: 'state' } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should sync flyout state to url whenever handleFlyoutChanges is called by the consumer', () => {
|
||||||
|
const syncStateToUrl = jest.fn();
|
||||||
|
jest.mocked(useSyncToUrl).mockImplementation((_urlKey, callback) => {
|
||||||
|
setTimeout(() => callback({ mocked: { flyout: 'state' } }), 0);
|
||||||
|
return syncStateToUrl;
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useSyncFlyoutStateWithUrl());
|
||||||
|
const [_flyoutApi, handleFlyoutChanges] = result.current;
|
||||||
|
|
||||||
|
handleFlyoutChanges();
|
||||||
|
|
||||||
|
expect(syncStateToUrl).toHaveBeenCalledTimes(1);
|
||||||
|
expect(syncStateToUrl).toHaveBeenLastCalledWith(undefined);
|
||||||
|
|
||||||
|
handleFlyoutChanges({ left: undefined, right: undefined, preview: [] });
|
||||||
|
|
||||||
|
expect(syncStateToUrl).toHaveBeenLastCalledWith({
|
||||||
|
left: undefined,
|
||||||
|
right: undefined,
|
||||||
|
preview: undefined,
|
||||||
|
});
|
||||||
|
expect(syncStateToUrl).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
});
|
|
@ -0,0 +1,45 @@
|
||||||
|
/*
|
||||||
|
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||||
|
* or more contributor license agreements. Licensed under the Elastic License
|
||||||
|
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||||
|
* 2.0.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useCallback, useRef } from 'react';
|
||||||
|
import type { ExpandableFlyoutApi, ExpandableFlyoutContext } from '@kbn/expandable-flyout';
|
||||||
|
import { useSyncToUrl } from '@kbn/url-state';
|
||||||
|
import last from 'lodash/last';
|
||||||
|
|
||||||
|
const URL_KEY = 'eventFlyout' as const;
|
||||||
|
|
||||||
|
type FlyoutState = Parameters<ExpandableFlyoutApi['openFlyout']>[0];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sync flyout state with the url and open it when relevant url state is detected in the query string
|
||||||
|
* @returns [ref, flyoutChangesHandler]
|
||||||
|
*/
|
||||||
|
export const useSyncFlyoutStateWithUrl = () => {
|
||||||
|
const flyoutApi = useRef<ExpandableFlyoutApi>(null);
|
||||||
|
|
||||||
|
const syncStateToUrl = useSyncToUrl<FlyoutState>(URL_KEY, (data) => {
|
||||||
|
flyoutApi.current?.openFlyout(data);
|
||||||
|
});
|
||||||
|
|
||||||
|
// This should be bound to flyout changed and closed events.
|
||||||
|
// When flyout is closed, url state is cleared
|
||||||
|
const handleFlyoutChanges = useCallback(
|
||||||
|
(state?: ExpandableFlyoutContext['panels']) => {
|
||||||
|
if (!state) {
|
||||||
|
return syncStateToUrl(undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
return syncStateToUrl({
|
||||||
|
...state,
|
||||||
|
preview: last(state.preview),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[syncStateToUrl]
|
||||||
|
);
|
||||||
|
|
||||||
|
return [flyoutApi, handleFlyoutChanges] as const;
|
||||||
|
};
|
|
@ -23,7 +23,9 @@
|
||||||
],
|
],
|
||||||
"kbn_references": [
|
"kbn_references": [
|
||||||
"@kbn/core",
|
"@kbn/core",
|
||||||
{ "path": "../../../src/setup_node_env/tsconfig.json" },
|
{
|
||||||
|
"path": "../../../src/setup_node_env/tsconfig.json"
|
||||||
|
},
|
||||||
"@kbn/data-plugin",
|
"@kbn/data-plugin",
|
||||||
"@kbn/embeddable-plugin",
|
"@kbn/embeddable-plugin",
|
||||||
"@kbn/files-plugin",
|
"@kbn/files-plugin",
|
||||||
|
@ -153,5 +155,6 @@
|
||||||
"@kbn/security-solution-side-nav",
|
"@kbn/security-solution-side-nav",
|
||||||
"@kbn/core-lifecycle-browser",
|
"@kbn/core-lifecycle-browser",
|
||||||
"@kbn/ecs",
|
"@kbn/ecs",
|
||||||
|
"@kbn/url-state"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
@ -5457,6 +5457,10 @@
|
||||||
version "0.0.0"
|
version "0.0.0"
|
||||||
uid ""
|
uid ""
|
||||||
|
|
||||||
|
"@kbn/url-state@link:packages/kbn-url-state":
|
||||||
|
version "0.0.0"
|
||||||
|
uid ""
|
||||||
|
|
||||||
"@kbn/usage-collection-plugin@link:src/plugins/usage_collection":
|
"@kbn/usage-collection-plugin@link:src/plugins/usage_collection":
|
||||||
version "0.0.0"
|
version "0.0.0"
|
||||||
uid ""
|
uid ""
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue