[Security Solution] Store expandable flyout state in the url (#154703)

This commit is contained in:
Luke 2023-04-21 22:45:37 +02:00 committed by GitHub
parent 22b8e5b0a6
commit 8a3f5ebbea
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
22 changed files with 563 additions and 24 deletions

1
.github/CODEOWNERS vendored
View file

@ -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

View file

@ -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",

View file

@ -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';

View file

@ -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

View file

@ -105,5 +105,8 @@ export function reducer(state: State, action: Action) {
preview: [],
};
}
default:
return state;
}
}

View 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.

View 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) },
'',
'/?'
);
});
});

View 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';

View 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'],
};

View file

@ -0,0 +1,5 @@
{
"type": "shared-common",
"id": "@kbn/url-state",
"owner": "@elastic/security-threat-hunting-investigations"
}

View file

@ -0,0 +1,6 @@
{
"name": "@kbn/url-state",
"private": true,
"version": "1.0.0",
"license": "SSPL-1.0 OR Elastic License 2.0"
}

View 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",
]
}

View 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;
};

View file

@ -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"],

View file

@ -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');
});
});

View file

@ -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');

View file

@ -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>
);

View file

@ -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();

View file

@ -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);
});
});

View file

@ -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;
};

View file

@ -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"
]
}

View file

@ -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 ""