mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 17:28:26 -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/drilldowns/url_drilldown @elastic/kibana-app-services
|
||||
src/plugins/url_forwarding @elastic/kibana-visualizations
|
||||
packages/kbn-url-state @elastic/security-threat-hunting-investigations
|
||||
src/plugins/usage_collection @elastic/kibana-core
|
||||
test/plugin_functional/plugins/usage_collection @elastic/kibana-core
|
||||
packages/kbn-user-profile-components @elastic/kibana-security
|
||||
|
|
|
@ -672,6 +672,7 @@
|
|||
"@kbn/upgrade-assistant-plugin": "link:x-pack/plugins/upgrade_assistant",
|
||||
"@kbn/url-drilldown-plugin": "link:x-pack/plugins/drilldowns/url_drilldown",
|
||||
"@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-test-plugin": "link:test/plugin_functional/plugins/usage_collection",
|
||||
"@kbn/user-profile-components": "link:packages/kbn-user-profile-components",
|
||||
|
|
|
@ -7,7 +7,13 @@
|
|||
*/
|
||||
|
||||
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 { FlyoutPanel } from './src/types';
|
||||
|
|
|
@ -6,7 +6,15 @@
|
|||
* 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 { reducer, State } from './reducer';
|
||||
import type { FlyoutPanel } from './types';
|
||||
|
@ -59,19 +67,44 @@ export const ExpandableFlyoutContext = createContext<ExpandableFlyoutContext | u
|
|||
undefined
|
||||
);
|
||||
|
||||
export type ExpandableFlyoutApi = Pick<ExpandableFlyoutContext, 'openFlyout'> & {
|
||||
getState: () => State;
|
||||
};
|
||||
|
||||
export interface ExpandableFlyoutProviderProps {
|
||||
/**
|
||||
* React children
|
||||
*/
|
||||
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.
|
||||
*/
|
||||
export const ExpandableFlyoutProvider = ({ children }: ExpandableFlyoutProviderProps) => {
|
||||
export const ExpandableFlyoutProvider = React.forwardRef<
|
||||
ExpandableFlyoutApi,
|
||||
ExpandableFlyoutProviderProps
|
||||
>(({ children, onChanges = () => {}, onClosePanels = () => {} }, ref) => {
|
||||
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(
|
||||
({
|
||||
right,
|
||||
|
@ -87,40 +120,45 @@ export const ExpandableFlyoutProvider = ({ children }: ExpandableFlyoutProviderP
|
|||
|
||||
const openRightPanel = useCallback(
|
||||
(panel: FlyoutPanel) => dispatch({ type: ActionType.openRightPanel, payload: panel }),
|
||||
[dispatch]
|
||||
[]
|
||||
);
|
||||
|
||||
const openLeftPanel = useCallback(
|
||||
(panel: FlyoutPanel) => dispatch({ type: ActionType.openLeftPanel, payload: panel }),
|
||||
[dispatch]
|
||||
[]
|
||||
);
|
||||
|
||||
const openPreviewPanel = useCallback(
|
||||
(panel: FlyoutPanel) => dispatch({ type: ActionType.openPreviewPanel, payload: panel }),
|
||||
[dispatch]
|
||||
[]
|
||||
);
|
||||
|
||||
const closeRightPanel = useCallback(
|
||||
() => dispatch({ type: ActionType.closeRightPanel }),
|
||||
[dispatch]
|
||||
);
|
||||
const closeRightPanel = useCallback(() => dispatch({ type: ActionType.closeRightPanel }), []);
|
||||
|
||||
const closeLeftPanel = useCallback(
|
||||
() => dispatch({ type: ActionType.closeLeftPanel }),
|
||||
[dispatch]
|
||||
);
|
||||
const closeLeftPanel = useCallback(() => dispatch({ type: ActionType.closeLeftPanel }), []);
|
||||
|
||||
const closePreviewPanel = useCallback(
|
||||
() => dispatch({ type: ActionType.closePreviewPanel }),
|
||||
[dispatch]
|
||||
);
|
||||
const closePreviewPanel = useCallback(() => dispatch({ type: ActionType.closePreviewPanel }), []);
|
||||
|
||||
const previousPreviewPanel = useCallback(
|
||||
() => 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(
|
||||
() => ({
|
||||
|
@ -154,7 +192,7 @@ export const ExpandableFlyoutProvider = ({ children }: ExpandableFlyoutProviderP
|
|||
{children}
|
||||
</ExpandableFlyoutContext.Provider>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* Retrieve context's properties
|
||||
|
|
|
@ -105,5 +105,8 @@ export function reducer(state: State, action: Action) {
|
|||
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-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-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');
|
||||
export const DOCUMENT_DETAILS_FLYOUT_TABLE_TAB_ROW_CELL_COPY_TO_CLIPBOARD =
|
||||
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 { useIsPolicySettingsBarVisible } from '../../../management/pages/policy/view/policy_hooks';
|
||||
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;
|
||||
|
||||
|
@ -75,6 +76,8 @@ export const SecuritySolutionTemplateWrapper: React.FC<Omit<KibanaPageTemplatePr
|
|||
|
||||
const showEmptyState = useShowPagesWithEmptyView() || rest.isEmptyState;
|
||||
|
||||
const [flyoutRef, handleFlyoutChangedOrClosed] = useSyncFlyoutStateWithUrl();
|
||||
|
||||
/*
|
||||
* 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,
|
||||
|
@ -82,7 +85,11 @@ export const SecuritySolutionTemplateWrapper: React.FC<Omit<KibanaPageTemplatePr
|
|||
* between EuiPageTemplate and the security solution pages.
|
||||
*/
|
||||
return (
|
||||
<ExpandableFlyoutProvider>
|
||||
<ExpandableFlyoutProvider
|
||||
onChanges={handleFlyoutChangedOrClosed}
|
||||
onClosePanels={handleFlyoutChangedOrClosed}
|
||||
ref={flyoutRef}
|
||||
>
|
||||
<StyledKibanaPageTemplate
|
||||
$addBottomPadding={addBottomPadding}
|
||||
$isShowingTimelineOverlay={isShowingTimelineOverlay}
|
||||
|
@ -108,7 +115,11 @@ export const SecuritySolutionTemplateWrapper: React.FC<Omit<KibanaPageTemplatePr
|
|||
</EuiThemeProvider>
|
||||
</KibanaPageTemplate.BottomBar>
|
||||
)}
|
||||
<ExpandableFlyout registeredPanels={expandableFlyoutDocumentsPanels} onClose={() => {}} />
|
||||
<ExpandableFlyout
|
||||
registeredPanels={expandableFlyoutDocumentsPanels}
|
||||
onClose={() => {}}
|
||||
handleOnFlyoutClosed={handleFlyoutChangedOrClosed}
|
||||
/>
|
||||
</StyledKibanaPageTemplate>
|
||||
</ExpandableFlyoutProvider>
|
||||
);
|
||||
|
|
|
@ -15,6 +15,9 @@ import { useQueryTimelineByIdOnUrlChange } from './timeline/use_query_timeline_b
|
|||
import { useInitFlyoutFromUrlParam } from './flyout/use_init_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 = () => {
|
||||
useSyncGlobalQueryString();
|
||||
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/core",
|
||||
{ "path": "../../../src/setup_node_env/tsconfig.json" },
|
||||
{
|
||||
"path": "../../../src/setup_node_env/tsconfig.json"
|
||||
},
|
||||
"@kbn/data-plugin",
|
||||
"@kbn/embeddable-plugin",
|
||||
"@kbn/files-plugin",
|
||||
|
@ -153,5 +155,6 @@
|
|||
"@kbn/security-solution-side-nav",
|
||||
"@kbn/core-lifecycle-browser",
|
||||
"@kbn/ecs",
|
||||
"@kbn/url-state"
|
||||
]
|
||||
}
|
||||
|
|
|
@ -5457,6 +5457,10 @@
|
|||
version "0.0.0"
|
||||
uid ""
|
||||
|
||||
"@kbn/url-state@link:packages/kbn-url-state":
|
||||
version "0.0.0"
|
||||
uid ""
|
||||
|
||||
"@kbn/usage-collection-plugin@link:src/plugins/usage_collection":
|
||||
version "0.0.0"
|
||||
uid ""
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue