diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index af6ce61ca4b5..be401c26dc0d 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -581,6 +581,8 @@ packages/kbn-repo-source-classifier-cli @elastic/kibana-operations packages/kbn-reporting/common @elastic/appex-sharedux x-pack/examples/reporting_example @elastic/appex-sharedux x-pack/plugins/reporting @elastic/appex-sharedux +packages/kbn-resizable-layout @elastic/kibana-data-discovery +examples/resizable_layout_examples @elastic/kibana-data-discovery x-pack/test/plugin_functional/plugins/resolver_test @elastic/security-solution examples/response_stream @elastic/ml-ui packages/kbn-rison @elastic/kibana-operations diff --git a/examples/resizable_layout_examples/kibana.jsonc b/examples/resizable_layout_examples/kibana.jsonc new file mode 100644 index 000000000000..6c6e3e6360cb --- /dev/null +++ b/examples/resizable_layout_examples/kibana.jsonc @@ -0,0 +1,11 @@ +{ + "type": "plugin", + "id": "@kbn/resizable-layout-examples-plugin", + "owner": "@elastic/kibana-data-discovery", + "plugin": { + "id": "resizableLayoutExamples", + "server": false, + "browser": true, + "requiredPlugins": ["developerExamples"] + } +} diff --git a/examples/resizable_layout_examples/public/application.tsx b/examples/resizable_layout_examples/public/application.tsx new file mode 100644 index 000000000000..350530be022f --- /dev/null +++ b/examples/resizable_layout_examples/public/application.tsx @@ -0,0 +1,161 @@ +/* + * 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 { CoreThemeProvider } from '@kbn/core-theme-browser-internal'; +import type { AppMountParameters } from '@kbn/core/public'; +import { I18nProvider } from '@kbn/i18n-react'; +import React, { ReactNode, useState } from 'react'; +import ReactDOM from 'react-dom'; +import { useIsWithinBreakpoints } from '@elastic/eui'; +import { css } from '@emotion/react'; +import { + ResizableLayout, + ResizableLayoutDirection, + ResizableLayoutMode, +} from '@kbn/resizable-layout'; +import { createHtmlPortalNode, InPortal, OutPortal } from 'react-reverse-portal'; + +const ResizableSection = ({ + direction, + initialFixedPanelSize, + minFixedPanelSize, + minFlexPanelSize, + fixedPanelColor, + flexPanelColor, + fixedPanelContent, + flexPanelContent, +}: { + direction: ResizableLayoutDirection; + initialFixedPanelSize: number; + minFixedPanelSize: number; + minFlexPanelSize: number; + fixedPanelColor: string; + flexPanelColor: string; + fixedPanelContent: ReactNode; + flexPanelContent: ReactNode; +}) => { + const [fixedPanelSize, setFixedPanelSize] = useState(initialFixedPanelSize); + const [container, setContainer] = useState(null); + const [fixedPanelNode] = useState(() => + createHtmlPortalNode({ attributes: { class: 'eui-fullHeight' } }) + ); + const [flexPanelNode] = useState(() => + createHtmlPortalNode({ attributes: { class: 'eui-fullHeight' } }) + ); + + const isMobile = useIsWithinBreakpoints(['xs', 's']); + const layoutMode = isMobile ? ResizableLayoutMode.Static : ResizableLayoutMode.Resizable; + const layoutDirection = isMobile ? ResizableLayoutDirection.Vertical : direction; + + const fullWidthAndHeightCss = css` + position: relative; + width: 100%; + height: 100%; + `; + const panelBaseCss = css` + ${fullWidthAndHeightCss} + padding: 20px; + font-size: 20px; + display: flex; + align-items: center; + justify-content: center; + `; + const fixedPanelCss = css` + ${panelBaseCss} + background-color: ${fixedPanelColor}; + `; + const flexPanelCss = css` + ${panelBaseCss} + background-color: ${flexPanelColor}; + `; + + return ( +
+ +
{fixedPanelContent}
+
+ +
{flexPanelContent}
+
+ } + flexPanel={} + onFixedPanelSizeChange={setFixedPanelSize} + /> +
+ ); +}; + +export const renderApp = ({ element, theme$ }: AppMountParameters) => { + ReactDOM.render( + + +
+ + } + flexPanelContent={ + + } + /> + } + /> +
+
+
, + element + ); + + return () => { + ReactDOM.unmountComponentAtNode(element); + }; +}; diff --git a/examples/resizable_layout_examples/public/index.ts b/examples/resizable_layout_examples/public/index.ts new file mode 100644 index 000000000000..26123cbb5961 --- /dev/null +++ b/examples/resizable_layout_examples/public/index.ts @@ -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. + */ + +import { ResizableLayoutExamplesPlugin } from './plugin'; + +export function plugin() { + return new ResizableLayoutExamplesPlugin(); +} diff --git a/examples/resizable_layout_examples/public/plugin.tsx b/examples/resizable_layout_examples/public/plugin.tsx new file mode 100644 index 000000000000..b1f3bcbb2e26 --- /dev/null +++ b/examples/resizable_layout_examples/public/plugin.tsx @@ -0,0 +1,44 @@ +/* + * 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 { AppMountParameters, AppNavLinkStatus, CoreSetup, Plugin } from '@kbn/core/public'; +import type { DeveloperExamplesSetup } from '@kbn/developer-examples-plugin/public'; +import image from './resizable_layout_examples.png'; + +export interface ResizableLayoutExamplesSetupPlugins { + developerExamples: DeveloperExamplesSetup; +} + +const PLUGIN_ID = 'resizableLayoutExamples'; +const PLUGIN_NAME = 'Resizable Layout Examples'; + +export class ResizableLayoutExamplesPlugin implements Plugin { + setup(core: CoreSetup, plugins: ResizableLayoutExamplesSetupPlugins) { + core.application.register({ + id: PLUGIN_ID, + title: PLUGIN_NAME, + navLinkStatus: AppNavLinkStatus.hidden, + mount: async (params: AppMountParameters) => { + // Load application bundle + const { renderApp } = await import('./application'); + // Render the application + return renderApp(params); + }, + }); + + plugins.developerExamples.register({ + appId: PLUGIN_ID, + title: PLUGIN_NAME, + description: + 'A component for creating resizable layouts containing a fixed width panel and a flexible panel, with support for horizontal and vertical layouts.', + image, + }); + } + + start() {} +} diff --git a/examples/resizable_layout_examples/public/resizable_layout_examples.png b/examples/resizable_layout_examples/public/resizable_layout_examples.png new file mode 100644 index 000000000000..b89eca91d838 Binary files /dev/null and b/examples/resizable_layout_examples/public/resizable_layout_examples.png differ diff --git a/examples/resizable_layout_examples/tsconfig.json b/examples/resizable_layout_examples/tsconfig.json new file mode 100644 index 000000000000..e998e2c117f4 --- /dev/null +++ b/examples/resizable_layout_examples/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "target/types" + }, + "include": ["common/**/*", "public/**/*", "server/**/*", "../../typings/**/*"], + "kbn_references": [ + "@kbn/resizable-layout", + "@kbn/core-theme-browser-internal", + "@kbn/core", + "@kbn/i18n-react", + "@kbn/developer-examples-plugin", + ], + "exclude": ["target/**/*"] +} diff --git a/package.json b/package.json index 6f6f4352380e..3b7f8c030fc8 100644 --- a/package.json +++ b/package.json @@ -588,6 +588,8 @@ "@kbn/reporting-common": "link:packages/kbn-reporting/common", "@kbn/reporting-example-plugin": "link:x-pack/examples/reporting_example", "@kbn/reporting-plugin": "link:x-pack/plugins/reporting", + "@kbn/resizable-layout": "link:packages/kbn-resizable-layout", + "@kbn/resizable-layout-examples-plugin": "link:examples/resizable_layout_examples", "@kbn/resolver-test-plugin": "link:x-pack/test/plugin_functional/plugins/resolver_test", "@kbn/response-stream-plugin": "link:examples/response_stream", "@kbn/rison": "link:packages/kbn-rison", diff --git a/packages/kbn-resizable-layout/README.md b/packages/kbn-resizable-layout/README.md new file mode 100644 index 000000000000..41e94071325f --- /dev/null +++ b/packages/kbn-resizable-layout/README.md @@ -0,0 +1,85 @@ +# @kbn/resizable-layout + +A component for creating resizable layouts containing a fixed width panel and a flexible panel, with support for horizontal and vertical layouts. + +## Example + +> [!NOTE] +> For advanced usage see [the example plugin](/examples/resizable_layout_examples/public/application.tsx). + +```tsx +import { useIsWithinBreakpoints } from '@elastic/eui'; +import { css } from '@emotion/react'; +import { + ResizableLayout, + ResizableLayoutDirection, + ResizableLayoutMode, +} from '@kbn/resizable-layout'; +import React, { useRef, useState } from 'react'; +// Using react-reverse-portal is recommended for complex/heavy layouts to prevent +// re-mounting panel components when the layout switches from resizable to static +import { createHtmlPortalNode, InPortal, OutPortal } from 'react-reverse-portal'; + +export const ResizablePage = () => { + const [fixedPanelSize, setFixedPanelSize] = useState(500); + const [container, setContainer] = useState(null); + const [fixedPanelNode] = useState(() => + createHtmlPortalNode({ attributes: { class: 'eui-fullHeight' } }) + ); + const [flexPanelNode] = useState(() => + createHtmlPortalNode({ attributes: { class: 'eui-fullHeight' } }) + ); + + const isMobile = useIsWithinBreakpoints(['xs']); + const layoutMode = isMobile ? ResizableLayoutMode.Static : ResizableLayoutMode.Resizable; + const layoutDirection = isMobile + ? ResizableLayoutDirection.Vertical + : ResizableLayoutDirection.Horizontal; + + const fullWidthAndHeightCss = css` + position: relative; + width: 100%; + height: 100%; + `; + const panelBaseCss = css` + ${fullWidthAndHeightCss} + padding: 20px; + `; + const fixedPanelCss = css` + ${panelBaseCss} + background-color: rgb(255 0 0 / 30%); + `; + const flexPanelCss = css` + ${panelBaseCss} + background-color: rgb(0 0 255 / 30%); + `; + + return ( +
+ +
+ This is the fixed width panel. It will remain the same size when resizing the window until + the flexible panel reaches its minimum size. +
+
+ +
+ This is the flexible width panel. It will resize as the window resizes until it reaches + its minimum size. +
+
+ } + flexPanel={} + onFixedPanelSizeChange={setFixedPanelSize} + /> +
+ ); +}; +``` diff --git a/packages/kbn-resizable-layout/index.ts b/packages/kbn-resizable-layout/index.ts new file mode 100644 index 000000000000..d3106dbf4f32 --- /dev/null +++ b/packages/kbn-resizable-layout/index.ts @@ -0,0 +1,14 @@ +/* + * 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 { withSuspense } from '@kbn/shared-ux-utility'; +import { lazy } from 'react'; + +export { ResizableLayoutMode, ResizableLayoutDirection } from './types'; +export type { ResizableLayoutProps } from './src/resizable_layout'; +export const ResizableLayout = withSuspense(lazy(() => import('./src/resizable_layout'))); diff --git a/src/plugins/unified_histogram/public/panels/index.ts b/packages/kbn-resizable-layout/jest.config.js similarity index 74% rename from src/plugins/unified_histogram/public/panels/index.ts rename to packages/kbn-resizable-layout/jest.config.js index ba3e73cb5a35..7909efadcc41 100644 --- a/src/plugins/unified_histogram/public/panels/index.ts +++ b/packages/kbn-resizable-layout/jest.config.js @@ -6,4 +6,8 @@ * Side Public License, v 1. */ -export { Panels, PANELS_MODE } from './panels'; +module.exports = { + preset: '@kbn/test', + rootDir: '../..', + roots: ['/packages/kbn-resizable-layout'], +}; diff --git a/packages/kbn-resizable-layout/kibana.jsonc b/packages/kbn-resizable-layout/kibana.jsonc new file mode 100644 index 000000000000..b578e1b774dc --- /dev/null +++ b/packages/kbn-resizable-layout/kibana.jsonc @@ -0,0 +1,6 @@ +{ + "type": "shared-common", + "id": "@kbn/resizable-layout", + "description": "A component for creating resizable layouts containing a fixed width panel and a flexible panel, with support for horizontal and vertical layouts.", + "owner": "@elastic/kibana-data-discovery" +} diff --git a/packages/kbn-resizable-layout/package.json b/packages/kbn-resizable-layout/package.json new file mode 100644 index 000000000000..4f925688a84b --- /dev/null +++ b/packages/kbn-resizable-layout/package.json @@ -0,0 +1,6 @@ +{ + "name": "@kbn/resizable-layout", + "private": true, + "version": "1.0.0", + "license": "SSPL-1.0 OR Elastic License 2.0" +} \ No newline at end of file diff --git a/packages/kbn-resizable-layout/src/panels_resizable.test.tsx b/packages/kbn-resizable-layout/src/panels_resizable.test.tsx new file mode 100644 index 000000000000..3ea2ccc87aae --- /dev/null +++ b/packages/kbn-resizable-layout/src/panels_resizable.test.tsx @@ -0,0 +1,246 @@ +/* + * 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 type { ReactWrapper } from 'enzyme'; +import { mount } from 'enzyme'; +import { ReactElement, useState } from 'react'; +import React from 'react'; +import { PanelsResizable } from './panels_resizable'; +import { act } from 'react-dom/test-utils'; + +const containerHeight = 1000; +const containerWidth = 500; +const fixedPanelId = 'fixedPanel'; + +jest.mock('@elastic/eui', () => ({ + ...jest.requireActual('@elastic/eui'), + useResizeObserver: jest.fn(), + useGeneratedHtmlId: jest.fn(() => fixedPanelId), +})); + +import * as eui from '@elastic/eui'; +import { waitFor } from '@testing-library/dom'; +import { ResizableLayoutDirection } from '../types'; + +describe('Panels resizable', () => { + const mountComponent = ({ + className = '', + direction = ResizableLayoutDirection.Vertical, + container = null, + initialFixedPanelSize = 0, + minFixedPanelSize = 0, + minFlexPanelSize = 0, + fixedPanel = <>, + flexPanel = <>, + attachTo, + onFixedPanelSizeChange = jest.fn(), + }: { + className?: string; + direction?: ResizableLayoutDirection; + container?: HTMLElement | null; + initialFixedPanelSize?: number; + minFixedPanelSize?: number; + minFlexPanelSize?: number; + fixedPanel?: ReactElement; + flexPanel?: ReactElement; + attachTo?: HTMLElement; + onFixedPanelSizeChange?: (fixedPanelSize: number) => void; + }) => { + const PanelsWrapper = ({ fixedPanelSize }: { fixedPanelSize?: number }) => { + const [panelSizes, setPanelSizes] = useState({ + fixedPanelSizePct: 50, + flexPanelSizePct: 50, + }); + + return ( + + ); + }; + + return mount(, attachTo ? { attachTo } : undefined); + }; + + const expectCorrectPanelSizes = ( + component: ReactWrapper, + currentContainerSize: number, + fixedPanelSize: number + ) => { + const fixedPanelSizePct = (fixedPanelSize / currentContainerSize) * 100; + expect( + component.find('[data-test-subj="resizableLayoutResizablePanelFixed"]').at(0).prop('size') + ).toBe(fixedPanelSizePct); + expect( + component.find('[data-test-subj="resizableLayoutResizablePanelFlex"]').at(0).prop('size') + ).toBe(100 - fixedPanelSizePct); + }; + + const forceRender = (component: ReactWrapper) => { + component.setProps({}).update(); + }; + + beforeEach(() => { + jest + .spyOn(eui, 'useResizeObserver') + .mockReturnValue({ height: containerHeight, width: containerWidth }); + }); + + it('should render both panels', () => { + const fixedPanel =
; + const flexPanel =
; + const component = mountComponent({ fixedPanel, flexPanel }); + expect(component.contains(fixedPanel)).toBe(true); + expect(component.contains(flexPanel)).toBe(true); + }); + + it('should set the initial sizes of both panels', () => { + const initialFixedPanelSize = 200; + const component = mountComponent({ initialFixedPanelSize }); + expectCorrectPanelSizes(component, containerHeight, initialFixedPanelSize); + }); + + it('should set the correct sizes of both panels when the panels are resized', () => { + const initialFixedPanelSize = 200; + const onFixedPanelSizeChange = jest.fn((fixedPanelSize) => { + component.setProps({ fixedPanelSize }).update(); + }); + const component = mountComponent({ initialFixedPanelSize, onFixedPanelSizeChange }); + expectCorrectPanelSizes(component, containerHeight, initialFixedPanelSize); + const newFixedPanelSizePct = 30; + const onPanelSizeChange = component + .find('[data-test-subj="resizableLayoutResizableContainer"]') + .at(0) + .prop('onPanelWidthChange') as Function; + act(() => { + onPanelSizeChange({ [fixedPanelId]: newFixedPanelSizePct }); + }); + forceRender(component); + const newFixedPanelSize = (newFixedPanelSizePct / 100) * containerHeight; + expect(onFixedPanelSizeChange).toHaveBeenCalledWith(newFixedPanelSize); + expectCorrectPanelSizes(component, containerHeight, newFixedPanelSize); + }); + + it('should maintain the size of the fixed panel and resize the flex panel when the container size changes', () => { + const initialFixedPanelSize = 200; + const component = mountComponent({ initialFixedPanelSize }); + expectCorrectPanelSizes(component, containerHeight, initialFixedPanelSize); + const newContainerSize = 2000; + jest.spyOn(eui, 'useResizeObserver').mockReturnValue({ height: newContainerSize, width: 0 }); + forceRender(component); + expectCorrectPanelSizes(component, newContainerSize, initialFixedPanelSize); + }); + + it('should resize the fixed panel once the flex panel is at its minimum size', () => { + const initialFixedPanelSize = 500; + const minFixedPanelSize = 100; + const minFlexPanelSize = 100; + const component = mountComponent({ + initialFixedPanelSize, + minFixedPanelSize, + minFlexPanelSize, + }); + expectCorrectPanelSizes(component, containerHeight, initialFixedPanelSize); + const newContainerSize = 400; + jest.spyOn(eui, 'useResizeObserver').mockReturnValue({ height: newContainerSize, width: 0 }); + forceRender(component); + expectCorrectPanelSizes(component, newContainerSize, newContainerSize - minFlexPanelSize); + jest.spyOn(eui, 'useResizeObserver').mockReturnValue({ height: containerHeight, width: 0 }); + forceRender(component); + expectCorrectPanelSizes(component, containerHeight, initialFixedPanelSize); + }); + + it('should maintain the minimum sizes of both panels when the container is too small to fit them', () => { + const initialFixedPanelSize = 500; + const minFixedPanelSize = 100; + const minFlexPanelSize = 150; + const component = mountComponent({ + initialFixedPanelSize, + minFixedPanelSize, + minFlexPanelSize, + }); + expectCorrectPanelSizes(component, containerHeight, initialFixedPanelSize); + const newContainerSize = 200; + jest.spyOn(eui, 'useResizeObserver').mockReturnValue({ height: newContainerSize, width: 0 }); + forceRender(component); + expect( + component.find('[data-test-subj="resizableLayoutResizablePanelFixed"]').at(0).prop('size') + ).toBe((minFixedPanelSize / newContainerSize) * 100); + expect( + component.find('[data-test-subj="resizableLayoutResizablePanelFlex"]').at(0).prop('size') + ).toBe((minFlexPanelSize / newContainerSize) * 100); + jest.spyOn(eui, 'useResizeObserver').mockReturnValue({ height: containerHeight, width: 0 }); + forceRender(component); + expectCorrectPanelSizes(component, containerHeight, initialFixedPanelSize); + }); + + it('should blur the resize button after a resize', async () => { + const attachTo = document.createElement('div'); + document.body.appendChild(attachTo); + const component = mountComponent({ attachTo }); + const getContainer = () => + component.find('[data-test-subj="resizableLayoutResizableContainer"]').at(0); + const resizeButton = component.find('button[data-test-subj="resizableLayoutResizableButton"]'); + act(() => { + const onResizeStart = getContainer().prop('onResizeStart') as Function; + onResizeStart('pointer'); + }); + (resizeButton.getDOMNode() as HTMLElement).focus(); + forceRender(component); + act(() => { + const onResizeEnd = getContainer().prop('onResizeEnd') as Function; + onResizeEnd(); + }); + expect(resizeButton.getDOMNode()).toHaveFocus(); + await waitFor(() => { + expect(resizeButton.getDOMNode()).not.toHaveFocus(); + }); + }); + + it('should pass direction "vertical" to EuiResizableContainer when direction is ResizableLayoutDirection.Vertical', () => { + const component = mountComponent({ direction: ResizableLayoutDirection.Vertical }); + expect( + component.find('[data-test-subj="resizableLayoutResizableContainer"]').at(0).prop('direction') + ).toBe('vertical'); + }); + + it('should pass direction "horizontal" to EuiResizableContainer when direction is ResizableLayoutDirection.Horizontal', () => { + const component = mountComponent({ direction: ResizableLayoutDirection.Horizontal }); + expect( + component.find('[data-test-subj="resizableLayoutResizableContainer"]').at(0).prop('direction') + ).toBe('horizontal'); + }); + + it('should use containerHeight when direction is ResizableLayoutDirection.Vertical', () => { + const initialFixedPanelSize = 200; + const component = mountComponent({ + direction: ResizableLayoutDirection.Vertical, + initialFixedPanelSize, + }); + expectCorrectPanelSizes(component, containerHeight, initialFixedPanelSize); + }); + + it('should use containerWidth when direction is ResizableLayoutDirection.Horizontal', () => { + const initialFixedPanelSize = 200; + const component = mountComponent({ + direction: ResizableLayoutDirection.Horizontal, + initialFixedPanelSize, + }); + expectCorrectPanelSizes(component, containerWidth, initialFixedPanelSize); + }); +}); diff --git a/packages/kbn-resizable-layout/src/panels_resizable.tsx b/packages/kbn-resizable-layout/src/panels_resizable.tsx new file mode 100644 index 000000000000..968e5203047f --- /dev/null +++ b/packages/kbn-resizable-layout/src/panels_resizable.tsx @@ -0,0 +1,228 @@ +/* + * 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 { EuiResizableContainer, useGeneratedHtmlId, useResizeObserver } from '@elastic/eui'; +import type { ResizeTrigger } from '@elastic/eui/src/components/resizable_container/types'; +import { css } from '@emotion/react'; +import { isEqual, round } from 'lodash'; +import type { ReactElement } from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; +import { ResizableLayoutDirection } from '../types'; +import { getContainerSize, percentToPixels, pixelsToPercent } from './utils'; + +export const PanelsResizable = ({ + className, + direction, + container, + fixedPanelSize, + minFixedPanelSize, + minFlexPanelSize, + panelSizes, + fixedPanel, + flexPanel, + resizeButtonClassName, + ['data-test-subj']: dataTestSubj = 'resizableLayout', + onFixedPanelSizeChange, + setPanelSizes, +}: { + className?: string; + direction: ResizableLayoutDirection; + container: HTMLElement | null; + fixedPanelSize: number; + minFixedPanelSize: number; + minFlexPanelSize: number; + panelSizes: { + fixedPanelSizePct: number; + flexPanelSizePct: number; + }; + fixedPanel: ReactElement; + flexPanel: ReactElement; + resizeButtonClassName?: string; + ['data-test-subj']?: string; + onFixedPanelSizeChange?: (fixedPanelSize: number) => void; + setPanelSizes: (panelSizes: { fixedPanelSizePct: number; flexPanelSizePct: number }) => void; +}) => { + const fixedPanelId = useGeneratedHtmlId({ prefix: 'fixedPanel' }); + const { height: containerHeight, width: containerWidth } = useResizeObserver(container); + const containerSize = getContainerSize(direction, containerWidth, containerHeight); + + // EuiResizableContainer doesn't work properly when used with react-reverse-portal and + // will cancel the resize. To work around this we keep track of when resizes start and + // end to toggle the rendering of a transparent overlay which prevents the cancellation. + // EUI issue: https://github.com/elastic/eui/issues/6199 + const [resizeWithPortalsHackIsResizing, setResizeWithPortalsHackIsResizing] = useState(false); + const enableResizeWithPortalsHack = useCallback( + () => setResizeWithPortalsHackIsResizing(true), + [] + ); + const disableResizeWithPortalsHack = useCallback( + () => setResizeWithPortalsHackIsResizing(false), + [] + ); + const defaultButtonCss = css` + z-index: 3; + `; + const resizeWithPortalsHackButtonCss = css` + z-index: 4; + `; + const resizeWithPortalsHackOverlayCss = css` + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + z-index: 3; + `; + + // We convert the top panel size from a percentage of the container size + // to a pixel value and emit the change to the parent component. We also convert + // the pixel value back to a percentage before updating the panel sizes to avoid + // rounding issues with the isEqual check in the effect below. + const onPanelSizeChange = useCallback( + ({ [fixedPanelId]: currentFixedPanelSize }: { [key: string]: number }) => { + const newFixedPanelSizePx = percentToPixels(containerSize, currentFixedPanelSize); + const newFixedPanelSizePct = pixelsToPercent(containerSize, newFixedPanelSizePx); + + setPanelSizes({ + fixedPanelSizePct: round(newFixedPanelSizePct, 4), + flexPanelSizePct: round(100 - newFixedPanelSizePct, 4), + }); + + onFixedPanelSizeChange?.(newFixedPanelSizePx); + }, + [fixedPanelId, containerSize, setPanelSizes, onFixedPanelSizeChange] + ); + + // This effect will update the panel sizes based on the top panel size whenever + // it or the container size changes. This allows us to keep the size of the + // top panel fixed when the window is resized. + useEffect(() => { + if (!containerSize) { + return; + } + + let fixedPanelSizePct: number; + let flexPanelSizePct: number; + + // If the container size is less than the minimum main content size + // plus the current top panel size, then we need to make some adjustments. + if (containerSize < minFlexPanelSize + fixedPanelSize) { + const newFixedPanelSize = containerSize - minFlexPanelSize; + + // Try to make the top panel size fit within the container, but if it + // doesn't then just use the minimum sizes. + if (newFixedPanelSize < minFixedPanelSize) { + fixedPanelSizePct = pixelsToPercent(containerSize, minFixedPanelSize); + flexPanelSizePct = pixelsToPercent(containerSize, minFlexPanelSize); + } else { + fixedPanelSizePct = pixelsToPercent(containerSize, newFixedPanelSize); + flexPanelSizePct = 100 - fixedPanelSizePct; + } + } else { + fixedPanelSizePct = pixelsToPercent(containerSize, fixedPanelSize); + flexPanelSizePct = 100 - fixedPanelSizePct; + } + + const newPanelSizes = { + fixedPanelSizePct: round(fixedPanelSizePct, 4), + flexPanelSizePct: round(flexPanelSizePct, 4), + }; + + // Skip updating the panel sizes if they haven't changed + // since onPanelSizeChange will also trigger this effect. + if (!isEqual(panelSizes, newPanelSizes)) { + setPanelSizes(newPanelSizes); + } + }, [ + containerSize, + fixedPanelSize, + minFixedPanelSize, + minFlexPanelSize, + panelSizes, + setPanelSizes, + ]); + + const onResizeStart = useCallback( + (trigger: ResizeTrigger) => { + if (trigger !== 'pointer') { + return; + } + + enableResizeWithPortalsHack(); + }, + [enableResizeWithPortalsHack] + ); + + const onResizeEnd = useCallback(() => { + if (!resizeWithPortalsHackIsResizing) { + return; + } + + // We don't want the resize button to retain focus after the resize is complete, + // but EuiResizableContainer will force focus it onClick. To work around this we + // use setTimeout to wait until after onClick has been called before blurring. + if (document.activeElement instanceof HTMLElement) { + const button = document.activeElement; + setTimeout(() => { + button.blur(); + }); + } + + disableResizeWithPortalsHack(); + }, [disableResizeWithPortalsHack, resizeWithPortalsHackIsResizing]); + + // Don't render EuiResizableContainer until we have have valid + // panel sizes or it can cause the resize functionality to break. + if (!panelSizes.fixedPanelSizePct && !panelSizes.flexPanelSizePct) { + return null; + } + + return ( + + {(EuiResizablePanel, EuiResizableButton) => ( + <> + + {fixedPanel} + + + + {flexPanel} + + {resizeWithPortalsHackIsResizing ?
: <>} + + )} + + ); +}; diff --git a/packages/kbn-resizable-layout/src/panels_static.test.tsx b/packages/kbn-resizable-layout/src/panels_static.test.tsx new file mode 100644 index 000000000000..7b33c5d2f12d --- /dev/null +++ b/packages/kbn-resizable-layout/src/panels_static.test.tsx @@ -0,0 +1,71 @@ +/* + * 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 { EuiFlexGroup } from '@elastic/eui'; +import { mount } from 'enzyme'; +import type { ReactElement } from 'react'; +import React from 'react'; +import { ResizableLayoutDirection } from '../types'; +import { PanelsStatic } from './panels_static'; + +describe('Panels static', () => { + const mountComponent = ({ + direction = ResizableLayoutDirection.Vertical, + hideFixedPanel = false, + fixedPanel = <>, + flexPanel = <>, + }: { + direction?: ResizableLayoutDirection; + hideFixedPanel?: boolean; + fixedPanel: ReactElement; + flexPanel: ReactElement; + }) => { + return mount( + + ); + }; + + it('should render both panels when hideFixedPanel is false', () => { + const fixedPanel =
; + const flexPanel =
; + const component = mountComponent({ fixedPanel, flexPanel }); + expect(component.contains(fixedPanel)).toBe(true); + expect(component.contains(flexPanel)).toBe(true); + }); + + it('should render only flex panel when hideFixedPanel is true', () => { + const fixedPanel =
; + const flexPanel =
; + const component = mountComponent({ hideFixedPanel: true, fixedPanel, flexPanel }); + expect(component.contains(fixedPanel)).toBe(false); + expect(component.contains(flexPanel)).toBe(true); + }); + + it('should pass direction "column" to EuiFlexGroup when direction is ResizableLayoutDirection.Vertical', () => { + const component = mountComponent({ + direction: ResizableLayoutDirection.Vertical, + fixedPanel: <>, + flexPanel: <>, + }); + expect(component.find(EuiFlexGroup).prop('direction')).toBe('column'); + }); + + it('should pass direction "row" to EuiFlexGroup when direction is ResizableLayoutDirection.Horizontal', () => { + const component = mountComponent({ + direction: ResizableLayoutDirection.Horizontal, + fixedPanel: <>, + flexPanel: <>, + }); + expect(component.find(EuiFlexGroup).prop('direction')).toBe('row'); + }); +}); diff --git a/src/plugins/unified_histogram/public/panels/panels_fixed.tsx b/packages/kbn-resizable-layout/src/panels_static.tsx similarity index 68% rename from src/plugins/unified_histogram/public/panels/panels_fixed.tsx rename to packages/kbn-resizable-layout/src/panels_static.tsx index 1b7d8bf9bf68..7ddc18fc6ce0 100644 --- a/src/plugins/unified_histogram/public/panels/panels_fixed.tsx +++ b/packages/kbn-resizable-layout/src/panels_static.tsx @@ -10,37 +10,43 @@ import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import { css } from '@emotion/react'; import type { ReactElement } from 'react'; import React from 'react'; +import { ResizableLayoutDirection } from '../types'; -export const PanelsFixed = ({ +export const PanelsStatic = ({ className, - hideTopPanel, - topPanel, - mainPanel, + direction, + hideFixedPanel, + fixedPanel, + flexPanel, }: { className?: string; - hideTopPanel?: boolean; - topPanel: ReactElement; - mainPanel: ReactElement; + direction: ResizableLayoutDirection; + hideFixedPanel?: boolean; + fixedPanel: ReactElement; + flexPanel: ReactElement; }) => { // By default a flex item has overflow: visible, min-height: auto, and min-width: auto. // This can cause the item to overflow the flexbox parent when its content is too large. // Setting the overflow to something other than visible (e.g. auto) resets the min-height // and min-width to 0 and makes the item respect the flexbox parent's size. // https://stackoverflow.com/questions/36247140/why-dont-flex-items-shrink-past-content-size - const mainPanelCss = css` + const flexPanelCss = css` overflow: auto; `; return ( - {!hideTopPanel && {topPanel}} - {mainPanel} + {!hideFixedPanel && {fixedPanel}} + {flexPanel} ); }; diff --git a/packages/kbn-resizable-layout/src/resizable_layout.test.tsx b/packages/kbn-resizable-layout/src/resizable_layout.test.tsx new file mode 100644 index 000000000000..dbd3186bc2c3 --- /dev/null +++ b/packages/kbn-resizable-layout/src/resizable_layout.test.tsx @@ -0,0 +1,106 @@ +/* + * 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 { mount } from 'enzyme'; +import type { ReactElement } from 'react'; +import React from 'react'; +import ResizableLayout from './resizable_layout'; +import { PanelsResizable } from './panels_resizable'; +import { PanelsStatic } from './panels_static'; +import { ResizableLayoutDirection, ResizableLayoutMode } from '../types'; + +jest.mock('@elastic/eui', () => ({ + ...jest.requireActual('@elastic/eui'), + useResizeObserver: jest.fn(() => ({ width: 1000, height: 1000 })), +})); + +describe('ResizableLayout component', () => { + const mountComponent = ({ + mode = ResizableLayoutMode.Resizable, + container = null, + initialFixedPanelSize = 200, + minFixedPanelSize = 100, + minFlexPanelSize = 100, + fixedPanel = <>, + flexPanel = <>, + }: { + mode?: ResizableLayoutMode; + container?: HTMLElement | null; + initialFixedPanelSize?: number; + minFixedPanelSize?: number; + minFlexPanelSize?: number; + flexPanel?: ReactElement; + fixedPanel?: ReactElement; + }) => { + return mount( + + ); + }; + + it('should show PanelsFixed when mode is ResizableLayoutMode.Single', () => { + const fixedPanel =
; + const flexPanel =
; + const component = mountComponent({ mode: ResizableLayoutMode.Single, fixedPanel, flexPanel }); + expect(component.find(PanelsStatic).exists()).toBe(true); + expect(component.find(PanelsResizable).exists()).toBe(false); + expect(component.contains(fixedPanel)).toBe(false); + expect(component.contains(flexPanel)).toBe(true); + }); + + it('should show PanelsFixed when mode is ResizableLayoutMode.Static', () => { + const fixedPanel =
; + const flexPanel =
; + const component = mountComponent({ mode: ResizableLayoutMode.Static, fixedPanel, flexPanel }); + expect(component.find(PanelsStatic).exists()).toBe(true); + expect(component.find(PanelsResizable).exists()).toBe(false); + expect(component.contains(fixedPanel)).toBe(true); + expect(component.contains(flexPanel)).toBe(true); + }); + + it('should show PanelsResizable when mode is ResizableLayoutMode.Resizable', () => { + const fixedPanel =
; + const flexPanel =
; + const component = mountComponent({ + mode: ResizableLayoutMode.Resizable, + fixedPanel, + flexPanel, + }); + expect(component.find(PanelsStatic).exists()).toBe(false); + expect(component.find(PanelsResizable).exists()).toBe(true); + expect(component.contains(fixedPanel)).toBe(true); + expect(component.contains(flexPanel)).toBe(true); + }); + + it('should pass true for hideFixedPanel when mode is ResizableLayoutMode.Single', () => { + const fixedPanel =
; + const flexPanel =
; + const component = mountComponent({ mode: ResizableLayoutMode.Single, fixedPanel, flexPanel }); + expect(component.find(PanelsStatic).prop('hideFixedPanel')).toBe(true); + expect(component.contains(fixedPanel)).toBe(false); + expect(component.contains(flexPanel)).toBe(true); + }); + + it('should pass false for hideFixedPanel when mode is ResizableLayoutMode.Static', () => { + const fixedPanel =
; + const flexPanel =
; + const component = mountComponent({ mode: ResizableLayoutMode.Static, fixedPanel, flexPanel }); + expect(component.find(PanelsStatic).prop('hideFixedPanel')).toBe(false); + expect(component.contains(fixedPanel)).toBe(true); + expect(component.contains(flexPanel)).toBe(true); + }); +}); diff --git a/packages/kbn-resizable-layout/src/resizable_layout.tsx b/packages/kbn-resizable-layout/src/resizable_layout.tsx new file mode 100644 index 000000000000..435d69cdcc86 --- /dev/null +++ b/packages/kbn-resizable-layout/src/resizable_layout.tsx @@ -0,0 +1,130 @@ +/* + * 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 { ReactElement, useState } from 'react'; +import React from 'react'; +import { round } from 'lodash'; +import { PanelsResizable } from './panels_resizable'; +import { PanelsStatic } from './panels_static'; +import { ResizableLayoutDirection, ResizableLayoutMode } from '../types'; +import { getContainerSize, pixelsToPercent } from './utils'; + +export interface ResizableLayoutProps { + /** + * Class name for the layout container + */ + className?: string; + /** + * The current layout mode + */ + mode: ResizableLayoutMode; + /** + * The current layout direction + */ + direction: ResizableLayoutDirection; + /** + * The parent container element, used to calculate the layout size + */ + container: HTMLElement | null; + /** + * Current size of the fixed panel in pixels + */ + fixedPanelSize: number; + /** + * Minimum size of the fixed panel in pixels + */ + minFixedPanelSize: number; + /** + * Minimum size of the flex panel in pixels + */ + minFlexPanelSize: number; + /** + * The fixed panel + */ + fixedPanel: ReactElement; + /** + * The flex panel + */ + flexPanel: ReactElement; + /** + * Class name for the resize button + */ + resizeButtonClassName?: string; + /** + * Test subject for the layout container + */ + ['data-test-subj']?: string; + /** + * Callback when the fixed panel size changes, receives the new size in pixels + */ + onFixedPanelSizeChange?: (fixedPanelSize: number) => void; +} + +const staticModes = [ResizableLayoutMode.Single, ResizableLayoutMode.Static]; + +const ResizableLayout = ({ + className, + mode, + direction, + container, + fixedPanelSize, + minFixedPanelSize, + minFlexPanelSize, + fixedPanel, + flexPanel, + resizeButtonClassName, + ['data-test-subj']: dataTestSubj, + onFixedPanelSizeChange, +}: ResizableLayoutProps) => { + const panelsProps = { className, fixedPanel, flexPanel }; + const [panelSizes, setPanelSizes] = useState(() => { + if (!container) { + return { fixedPanelSizePct: 0, flexPanelSizePct: 0 }; + } + + const { width, height } = container.getBoundingClientRect(); + const initialContainerSize = getContainerSize(direction, width, height); + + if (!initialContainerSize) { + return { fixedPanelSizePct: 0, flexPanelSizePct: 0 }; + } + + const fixedPanelSizePct = pixelsToPercent(initialContainerSize, fixedPanelSize); + const flexPanelSizePct = 100 - fixedPanelSizePct; + + return { + fixedPanelSizePct: round(fixedPanelSizePct, 4), + flexPanelSizePct: round(flexPanelSizePct, 4), + }; + }); + + return staticModes.includes(mode) ? ( + + ) : ( + + ); +}; + +// eslint-disable-next-line import/no-default-export +export default ResizableLayout; diff --git a/packages/kbn-resizable-layout/src/utils.test.ts b/packages/kbn-resizable-layout/src/utils.test.ts new file mode 100644 index 000000000000..31d7cf6d7a16 --- /dev/null +++ b/packages/kbn-resizable-layout/src/utils.test.ts @@ -0,0 +1,40 @@ +/* + * 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 { ResizableLayoutDirection } from '../types'; +import { getContainerSize, percentToPixels, pixelsToPercent } from './utils'; + +describe('getContainerSize', () => { + it('should return the width when direction is horizontal', () => { + expect(getContainerSize(ResizableLayoutDirection.Horizontal, 100, 200)).toBe(100); + }); + + it('should return the height when direction is vertical', () => { + expect(getContainerSize(ResizableLayoutDirection.Vertical, 100, 200)).toBe(200); + }); +}); + +describe('percentToPixels', () => { + it('should convert percentage to pixels', () => { + expect(percentToPixels(250, 50)).toBe(125); + }); +}); + +describe('pixelsToPercent', () => { + it('should convert pixels to percentage', () => { + expect(pixelsToPercent(250, 125)).toBe(50); + }); + + it('should clamp percentage to 0 when pixels is negative', () => { + expect(pixelsToPercent(250, -125)).toBe(0); + }); + + it('should clamp percentage to 100 when pixels is greater than container size', () => { + expect(pixelsToPercent(250, 500)).toBe(100); + }); +}); diff --git a/packages/kbn-resizable-layout/src/utils.ts b/packages/kbn-resizable-layout/src/utils.ts new file mode 100644 index 000000000000..b0f6078b88a2 --- /dev/null +++ b/packages/kbn-resizable-layout/src/utils.ts @@ -0,0 +1,22 @@ +/* + * 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 { clamp } from 'lodash'; +import { ResizableLayoutDirection } from '../types'; + +export const percentToPixels = (containerSize: number, percentage: number) => + Math.round(containerSize * (percentage / 100)); + +export const pixelsToPercent = (containerSize: number, pixels: number) => + clamp((pixels / containerSize) * 100, 0, 100); + +export const getContainerSize = ( + direction: ResizableLayoutDirection, + width: number, + height: number +) => (direction === ResizableLayoutDirection.Vertical ? height : width); diff --git a/packages/kbn-resizable-layout/tsconfig.json b/packages/kbn-resizable-layout/tsconfig.json new file mode 100644 index 000000000000..28cd6625b538 --- /dev/null +++ b/packages/kbn-resizable-layout/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "target/types" + }, + "include": ["**/*.ts", "**/*.tsx"], + "exclude": ["target/**/*"], + "kbn_references": [ + "@kbn/shared-ux-utility", + ] +} diff --git a/packages/kbn-resizable-layout/types.ts b/packages/kbn-resizable-layout/types.ts new file mode 100644 index 000000000000..3d5564311480 --- /dev/null +++ b/packages/kbn-resizable-layout/types.ts @@ -0,0 +1,33 @@ +/* + * 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 enum ResizableLayoutMode { + /** + * Single panel mode -- hides the fixed panel + */ + Single = 'single', + /** + * Static mode -- prevents resizing + */ + Static = 'static', + /** + * Resizable mode -- allows resizing + */ + Resizable = 'resizable', +} + +export enum ResizableLayoutDirection { + /** + * Horizontal layout -- panels are side by side + */ + Horizontal = 'horizontal', + /** + * Vertical layout -- panels are stacked + */ + Vertical = 'vertical', +} diff --git a/packages/kbn-unified-field-list/src/containers/unified_field_list_sidebar/field_list_sidebar.scss b/packages/kbn-unified-field-list/src/containers/unified_field_list_sidebar/field_list_sidebar.scss index b646d60ec3b0..48fb44f1663e 100644 --- a/packages/kbn-unified-field-list/src/containers/unified_field_list_sidebar/field_list_sidebar.scss +++ b/packages/kbn-unified-field-list/src/containers/unified_field_list_sidebar/field_list_sidebar.scss @@ -3,7 +3,6 @@ margin: 0 !important; flex-grow: 1; padding: 0; - width: $euiSize * 19; height: 100%; &--collapsed { @@ -11,6 +10,14 @@ padding: $euiSizeS $euiSizeS 0; } + &.unifiedFieldListSidebar--fullWidth { + min-width: 0 !important; + } + + &:not(.unifiedFieldListSidebar--fullWidth) { + width: $euiSize * 19; + } + @include euiBreakpoint('xs', 's') { width: 100%; padding: $euiSize; diff --git a/packages/kbn-unified-field-list/src/containers/unified_field_list_sidebar/field_list_sidebar.tsx b/packages/kbn-unified-field-list/src/containers/unified_field_list_sidebar/field_list_sidebar.tsx index fb90e2b36d39..4bc54069336b 100644 --- a/packages/kbn-unified-field-list/src/containers/unified_field_list_sidebar/field_list_sidebar.tsx +++ b/packages/kbn-unified-field-list/src/containers/unified_field_list_sidebar/field_list_sidebar.tsx @@ -59,6 +59,11 @@ export type UnifiedFieldListSidebarCustomizableProps = Pick< */ showFieldList?: boolean; + /** + * Make the field list full width + */ + fullWidth?: boolean; + /** * Compressed view */ @@ -145,6 +150,7 @@ export const UnifiedFieldListSidebarComponent: React.FC = { className: classnames('unifiedFieldListSidebar', { 'unifiedFieldListSidebar--collapsed': isSidebarCollapsed, + ['unifiedFieldListSidebar--fullWidth']: fullWidth, }), 'aria-label': i18n.translate( 'unifiedFieldList.fieldListSidebar.indexAndFieldsSectionAriaLabel', diff --git a/packages/kbn-unified-field-list/src/containers/unified_field_list_sidebar/field_list_sidebar_container.tsx b/packages/kbn-unified-field-list/src/containers/unified_field_list_sidebar/field_list_sidebar_container.tsx index 520a64f8d69b..32dd400f2d6f 100644 --- a/packages/kbn-unified-field-list/src/containers/unified_field_list_sidebar/field_list_sidebar_container.tsx +++ b/packages/kbn-unified-field-list/src/containers/unified_field_list_sidebar/field_list_sidebar_container.tsx @@ -30,6 +30,7 @@ import { EuiShowFor, EuiTitle, } from '@elastic/eui'; +import { BehaviorSubject, Observable } from 'rxjs'; import { useExistingFieldsFetcher, type ExistingFieldsFetcher, @@ -49,6 +50,7 @@ import type { } from '../../types'; export interface UnifiedFieldListSidebarContainerApi { + isSidebarCollapsed$: Observable; refetchFieldsExistenceInfo: ExistingFieldsFetcher['refetchFieldsExistenceInfo']; closeFieldListFlyout: () => void; // no user permission or missing dataViewFieldEditor service will result in `undefined` API methods @@ -121,6 +123,7 @@ const UnifiedFieldListSidebarContainer = forwardRef< const { data, dataViewFieldEditor } = services; const [isFieldListFlyoutVisible, setIsFieldListFlyoutVisible] = useState(false); const { isSidebarCollapsed, onToggleSidebar } = useSidebarToggle({ stateService }); + const [isSidebarCollapsed$] = useState(() => new BehaviorSubject(isSidebarCollapsed)); const canEditDataView = Boolean(dataViewFieldEditor?.userPermissions.editIndexPattern()) || @@ -222,16 +225,21 @@ const UnifiedFieldListSidebarContainer = forwardRef< }; }, []); + useEffect(() => { + isSidebarCollapsed$.next(isSidebarCollapsed); + }, [isSidebarCollapsed, isSidebarCollapsed$]); + useImperativeHandle( componentRef, () => ({ + isSidebarCollapsed$, refetchFieldsExistenceInfo, closeFieldListFlyout, createField: editField, editField, deleteField, }), - [refetchFieldsExistenceInfo, closeFieldListFlyout, editField, deleteField] + [isSidebarCollapsed$, refetchFieldsExistenceInfo, closeFieldListFlyout, editField, deleteField] ); if (!dataView) { diff --git a/src/plugins/discover/public/application/main/components/layout/discover_histogram_layout.test.tsx b/src/plugins/discover/public/application/main/components/layout/discover_histogram_layout.test.tsx index d7066306ee8d..832a37577fc8 100644 --- a/src/plugins/discover/public/application/main/components/layout/discover_histogram_layout.test.tsx +++ b/src/plugins/discover/public/application/main/components/layout/discover_histogram_layout.test.tsx @@ -122,7 +122,7 @@ const mountComponent = async ({ columns: [], viewMode: VIEW_MODE.DOCUMENT_LEVEL, onAddFilter: jest.fn(), - resizeRef: { current: null }, + container: null, }; stateContainer.searchSessionManager = createSearchSessionMock(session).searchSessionManager; diff --git a/src/plugins/discover/public/application/main/components/layout/discover_histogram_layout.tsx b/src/plugins/discover/public/application/main/components/layout/discover_histogram_layout.tsx index 42ae4e2c18a5..54447ebe06b0 100644 --- a/src/plugins/discover/public/application/main/components/layout/discover_histogram_layout.tsx +++ b/src/plugins/discover/public/application/main/components/layout/discover_histogram_layout.tsx @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import React, { RefObject } from 'react'; +import React from 'react'; import { UnifiedHistogramContainer } from '@kbn/unified-histogram-plugin/public'; import { css } from '@emotion/react'; import useObservable from 'react-use/lib/useObservable'; @@ -17,7 +17,7 @@ import { ResetSearchButton } from './reset_search_button'; import { useAppStateSelector } from '../../services/discover_app_state_container'; export interface DiscoverHistogramLayoutProps extends DiscoverMainContentProps { - resizeRef: RefObject; + container: HTMLElement | null; } const histogramLayoutCss = css` @@ -28,7 +28,7 @@ export const DiscoverHistogramLayout = ({ isPlainRecord, dataView, stateContainer, - resizeRef, + container, ...mainContentProps }: DiscoverHistogramLayoutProps) => { const { dataState } = stateContainer; @@ -53,7 +53,7 @@ export const DiscoverHistogramLayout = ({ {...unifiedHistogramProps} searchSessionId={searchSessionId} requestAdapter={dataState.inspectorAdapters.requests} - resizeRef={resizeRef} + container={container} appendHitsCounter={ savedSearch.id ? ( diff --git a/src/plugins/discover/public/application/main/components/layout/discover_layout.scss b/src/plugins/discover/public/application/main/components/layout/discover_layout.scss index 8f8f5b8ec883..88da97d6f533 100644 --- a/src/plugins/discover/public/application/main/components/layout/discover_layout.scss +++ b/src/plugins/discover/public/application/main/components/layout/discover_layout.scss @@ -29,10 +29,24 @@ discover-app { .dscPageBody__contents { overflow: hidden; + height: 100%; +} + +.dscSidebarResizeButton { + background-color: transparent !important; + + &:not(:hover):not(:focus) { + &:before, &:after { + width: 0; + } + } } .dscPageContent__wrapper { overflow: hidden; // Ensures horizontal scroll of table + display: flex; + flex-direction: column; + height: 100%; } .dscPageContent { diff --git a/src/plugins/discover/public/application/main/components/layout/discover_layout.test.tsx b/src/plugins/discover/public/application/main/components/layout/discover_layout.test.tsx index ac0906911dde..d4cfa7e049a4 100644 --- a/src/plugins/discover/public/application/main/components/layout/discover_layout.test.tsx +++ b/src/plugins/discover/public/application/main/components/layout/discover_layout.test.tsx @@ -41,6 +41,11 @@ import { act } from 'react-dom/test-utils'; import { ErrorCallout } from '../../../../components/common/error_callout'; import * as localStorageModule from 'react-use/lib/useLocalStorage'; +jest.mock('@elastic/eui', () => ({ + ...jest.requireActual('@elastic/eui'), + useResizeObserver: jest.fn(() => ({ width: 1000, height: 1000 })), +})); + jest.spyOn(localStorageModule, 'default'); setHeaderActionMenuMounter(jest.fn()); diff --git a/src/plugins/discover/public/application/main/components/layout/discover_layout.tsx b/src/plugins/discover/public/application/main/components/layout/discover_layout.tsx index 3402bfbce1bc..4d5655c012e1 100644 --- a/src/plugins/discover/public/application/main/components/layout/discover_layout.tsx +++ b/src/plugins/discover/public/application/main/components/layout/discover_layout.tsx @@ -6,7 +6,7 @@ * Side Public License, v 1. */ import './discover_layout.scss'; -import React, { useCallback, useEffect, useMemo, useRef } from 'react'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { EuiFlexGroup, EuiFlexItem, @@ -31,6 +31,7 @@ import { } from '@kbn/discover-utils'; import { popularizeField, useColumns } from '@kbn/unified-data-table'; import { DocViewFilterFn } from '@kbn/unified-doc-viewer/types'; +import type { UnifiedFieldListSidebarContainerApi } from '@kbn/unified-field-list'; import { useSavedSearchInitial } from '../../services/discover_state_provider'; import { DiscoverStateContainer } from '../../services/discover_state'; import { VIEW_MODE } from '../../../../../common/constants'; @@ -52,6 +53,7 @@ import { SavedSearchURLConflictCallout } from '../../../../components/saved_sear import { DiscoverHistogramLayout } from './discover_histogram_layout'; import { ErrorCallout } from '../../../../components/common/error_callout'; import { addLog } from '../../../../utils/add_log'; +import { DiscoverResizableLayout } from './discover_resizable_layout'; const SidebarMemoized = React.memo(DiscoverSidebarResponsive); const TopNavMemoized = React.memo(DiscoverTopNav); @@ -182,7 +184,8 @@ export function DiscoverLayout({ stateContainer }: DiscoverLayoutProps) { } }, [dataState.error, isPlainRecord]); - const resizeRef = useRef(null); + const [sidebarContainer, setSidebarContainer] = useState(null); + const [mainContainer, setMainContainer] = useState(null); const [{ dragging }] = useDragDropContext(); const draggingFieldName = dragging?.id; @@ -211,7 +214,7 @@ export function DiscoverLayout({ stateContainer }: DiscoverLayoutProps) { viewMode={viewMode} onAddFilter={onAddFilter as DocViewFilterFn} onFieldEdited={onFieldEdited} - resizeRef={resizeRef} + container={mainContainer} onDropFieldToTable={onDropFieldToTable} /> {resultState === 'loading' && } @@ -221,14 +224,18 @@ export function DiscoverLayout({ stateContainer }: DiscoverLayoutProps) { currentColumns, dataView, isPlainRecord, + mainContainer, onAddFilter, + onDropFieldToTable, onFieldEdited, resultState, stateContainer, viewMode, - onDropFieldToTable, ]); + const [unifiedFieldListSidebarContainerApi, setUnifiedFieldListSidebarContainerApi] = + useState(null); + return ( - - - - - - - - - - {resultState === 'none' ? ( - dataState.error ? ( - - ) : ( - - ) - ) : ( - + + - {mainDisplay} - - )} - - + + + + + + + + } + mainPanel={ +
+ {resultState === 'none' ? ( + dataState.error ? ( + + ) : ( + + ) + ) : ( + + {mainDisplay} + + )} +
+ } + /> +
); diff --git a/src/plugins/discover/public/application/main/components/layout/discover_resizable_layout.test.tsx b/src/plugins/discover/public/application/main/components/layout/discover_resizable_layout.test.tsx new file mode 100644 index 000000000000..26aacd894830 --- /dev/null +++ b/src/plugins/discover/public/application/main/components/layout/discover_resizable_layout.test.tsx @@ -0,0 +1,169 @@ +/* + * 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 { + ResizableLayout, + ResizableLayoutDirection, + ResizableLayoutMode, +} from '@kbn/resizable-layout'; +import { findTestSubject } from '@kbn/test-jest-helpers'; +import type { UnifiedFieldListSidebarContainerApi } from '@kbn/unified-field-list'; +import { mount } from 'enzyme'; +import { isEqual as mockIsEqual } from 'lodash'; +import React from 'react'; +import { of } from 'rxjs'; +import { DiscoverResizableLayout, SIDEBAR_WIDTH_KEY } from './discover_resizable_layout'; + +const mockSidebarKey = SIDEBAR_WIDTH_KEY; +let mockSidebarWidth: number | undefined; + +jest.mock('react-use/lib/useLocalStorage', () => { + return jest.fn((key: string, initialValue: number) => { + if (key !== mockSidebarKey) { + throw new Error(`Unexpected key: ${key}`); + } + return [mockSidebarWidth ?? initialValue, jest.fn()]; + }); +}); + +let mockIsMobile = false; + +jest.mock('@elastic/eui', () => { + const original = jest.requireActual('@elastic/eui'); + return { + ...original, + useIsWithinBreakpoints: jest.fn((breakpoints: string[]) => { + if (!mockIsEqual(breakpoints, ['xs', 's'])) { + throw new Error(`Unexpected breakpoints: ${breakpoints}`); + } + return mockIsMobile; + }), + }; +}); + +describe('DiscoverResizableLayout', () => { + beforeEach(() => { + mockSidebarWidth = undefined; + mockIsMobile = false; + }); + + it('should render sidebarPanel and mainPanel', () => { + const wrapper = mount( + } + mainPanel={
} + /> + ); + expect(findTestSubject(wrapper, 'sidebarPanel')).toHaveLength(1); + expect(findTestSubject(wrapper, 'mainPanel')).toHaveLength(1); + }); + + it('should use the default sidebar width when no value is stored in local storage', () => { + const wrapper = mount( + } + mainPanel={
} + /> + ); + expect(wrapper.find(ResizableLayout).prop('fixedPanelSize')).toBe(304); + }); + + it('should use the stored sidebar width from local storage', () => { + mockSidebarWidth = 400; + const wrapper = mount( + } + mainPanel={
} + /> + ); + expect(wrapper.find(ResizableLayout).prop('fixedPanelSize')).toBe(400); + }); + + it('should pass mode ResizableLayoutMode.Resizable when not mobile and sidebar is not collapsed', () => { + mockIsMobile = false; + const wrapper = mount( + } + mainPanel={
} + /> + ); + expect(wrapper.find(ResizableLayout).prop('mode')).toBe(ResizableLayoutMode.Resizable); + }); + + it('should pass mode ResizableLayoutMode.Static when mobile', () => { + mockIsMobile = true; + const wrapper = mount( + } + mainPanel={
} + /> + ); + expect(wrapper.find(ResizableLayout).prop('mode')).toBe(ResizableLayoutMode.Static); + }); + + it('should pass mode ResizableLayoutMode.Static when not mobile and sidebar is collapsed', () => { + mockIsMobile = false; + const wrapper = mount( + } + mainPanel={
} + /> + ); + expect(wrapper.find(ResizableLayout).prop('mode')).toBe(ResizableLayoutMode.Static); + }); + + it('should pass direction ResizableLayoutDirection.Horizontal when not mobile', () => { + mockIsMobile = false; + const wrapper = mount( + } + mainPanel={
} + /> + ); + expect(wrapper.find(ResizableLayout).prop('direction')).toBe( + ResizableLayoutDirection.Horizontal + ); + }); + + it('should pass direction ResizableLayoutDirection.Vertical when mobile', () => { + mockIsMobile = true; + const wrapper = mount( + } + mainPanel={
} + /> + ); + expect(wrapper.find(ResizableLayout).prop('direction')).toBe(ResizableLayoutDirection.Vertical); + }); +}); diff --git a/src/plugins/discover/public/application/main/components/layout/discover_resizable_layout.tsx b/src/plugins/discover/public/application/main/components/layout/discover_resizable_layout.tsx new file mode 100644 index 000000000000..32491a38d86f --- /dev/null +++ b/src/plugins/discover/public/application/main/components/layout/discover_resizable_layout.tsx @@ -0,0 +1,80 @@ +/* + * 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 { useEuiTheme, useIsWithinBreakpoints } from '@elastic/eui'; +import { + ResizableLayout, + ResizableLayoutDirection, + ResizableLayoutMode, +} from '@kbn/resizable-layout'; +import type { UnifiedFieldListSidebarContainerApi } from '@kbn/unified-field-list'; +import React, { ReactNode, useState } from 'react'; +import { createHtmlPortalNode, InPortal, OutPortal } from 'react-reverse-portal'; +import useLocalStorage from 'react-use/lib/useLocalStorage'; +import useObservable from 'react-use/lib/useObservable'; +import { of } from 'rxjs'; + +export const SIDEBAR_WIDTH_KEY = 'discover:sidebarWidth'; + +export const DiscoverResizableLayout = ({ + container, + unifiedFieldListSidebarContainerApi, + sidebarPanel, + mainPanel, +}: { + container: HTMLElement | null; + unifiedFieldListSidebarContainerApi: UnifiedFieldListSidebarContainerApi | null; + sidebarPanel: ReactNode; + mainPanel: ReactNode; +}) => { + const [sidebarPanelNode] = useState(() => + createHtmlPortalNode({ attributes: { class: 'eui-fullHeight' } }) + ); + const [mainPanelNode] = useState(() => + createHtmlPortalNode({ attributes: { class: 'eui-fullHeight' } }) + ); + + const { euiTheme } = useEuiTheme(); + const minSidebarWidth = euiTheme.base * 13; + const defaultSidebarWidth = euiTheme.base * 19; + const minMainPanelWidth = euiTheme.base * 30; + + const [sidebarWidth, setSidebarWidth] = useLocalStorage(SIDEBAR_WIDTH_KEY, defaultSidebarWidth); + const isSidebarCollapsed = useObservable( + unifiedFieldListSidebarContainerApi?.isSidebarCollapsed$ ?? of(true), + true + ); + + const isMobile = useIsWithinBreakpoints(['xs', 's']); + const layoutMode = + isMobile || isSidebarCollapsed ? ResizableLayoutMode.Static : ResizableLayoutMode.Resizable; + const layoutDirection = isMobile + ? ResizableLayoutDirection.Vertical + : ResizableLayoutDirection.Horizontal; + + return ( + <> + {sidebarPanel} + {mainPanel} + } + flexPanel={} + resizeButtonClassName="dscSidebarResizeButton" + data-test-subj="discoverLayout" + onFixedPanelSizeChange={setSidebarWidth} + /> + + ); +}; diff --git a/src/plugins/discover/public/application/main/components/sidebar/discover_sidebar_responsive.test.tsx b/src/plugins/discover/public/application/main/components/sidebar/discover_sidebar_responsive.test.tsx index bd2f2c7639e4..723c19b5b3d9 100644 --- a/src/plugins/discover/public/application/main/components/sidebar/discover_sidebar_responsive.test.tsx +++ b/src/plugins/discover/public/application/main/components/sidebar/discover_sidebar_responsive.test.tsx @@ -13,7 +13,7 @@ import { EuiProgress } from '@elastic/eui'; import { getDataTableRecords, realHits } from '../../../../__fixtures__/real_hits'; import { act } from 'react-dom/test-utils'; import { mountWithIntl } from '@kbn/test-jest-helpers'; -import React from 'react'; +import React, { useState } from 'react'; import { DiscoverSidebarResponsive, DiscoverSidebarResponsiveProps, @@ -37,6 +37,7 @@ import { buildDataTableRecord } from '@kbn/discover-utils'; import type { DataTableRecord } from '@kbn/discover-utils/types'; import type { DiscoverCustomizationId } from '../../../../customizations/customization_service'; import type { SearchBarCustomization } from '../../../../customizations'; +import type { UnifiedFieldListSidebarContainerApi } from '@kbn/unified-field-list'; const mockSearchBarCustomization: SearchBarCustomization = { id: 'search_bar', @@ -168,6 +169,8 @@ function getCompProps(options?: { hits?: DataTableRecord[] }): DiscoverSidebarRe trackUiMetric: jest.fn(), onFieldEdited: jest.fn(), onDataViewCreated: jest.fn(), + unifiedFieldListSidebarContainerApi: null, + setUnifiedFieldListSidebarContainerApi: jest.fn(), }; } @@ -199,19 +202,30 @@ async function mountComponent( mockedServices.data.query.getState = jest.fn().mockImplementation(() => appState.getState()); await act(async () => { - comp = await mountWithIntl( + const SidebarWrapper = () => { + const [api, setApi] = useState(null); + return ( + + ); + }; + + comp = mountWithIntl( - + ); // wait for lazy modules await new Promise((resolve) => setTimeout(resolve, 0)); - await comp.update(); + comp.update(); }); - await comp!.update(); + comp!.update(); return comp!; } @@ -251,7 +265,7 @@ describe('discover responsive sidebar', function () { await act(async () => { // wait for lazy modules await new Promise((resolve) => setTimeout(resolve, 0)); - await compLoadingExistence.update(); + compLoadingExistence.update(); }); expect( @@ -273,11 +287,11 @@ describe('discover responsive sidebar', function () { indexPatternTitle: 'test-loaded', existingFieldNames: Object.keys(mockfieldCounts), }); - await compLoadingExistence.update(); + compLoadingExistence.update(); }); await act(async () => { - await compLoadingExistence.update(); + compLoadingExistence.update(); }); expect( @@ -419,11 +433,11 @@ describe('discover responsive sidebar', function () { const availableFields = findTestSubject(comp, 'fieldListGroupedAvailableFields'); await act(async () => { const button = findTestSubject(availableFields, 'field-extension-showDetails'); - await button.simulate('click'); - await comp.update(); + button.simulate('click'); + comp.update(); }); - await comp.update(); + comp.update(); findTestSubject(comp, 'plus-extension-gif').simulate('click'); expect(props.onAddFilter).toHaveBeenCalled(); }); @@ -432,11 +446,11 @@ describe('discover responsive sidebar', function () { const availableFields = findTestSubject(comp, 'fieldListGroupedAvailableFields'); await act(async () => { const button = findTestSubject(availableFields, 'field-extension-showDetails'); - await button.simulate('click'); - await comp.update(); + button.simulate('click'); + comp.update(); }); - await comp.update(); + comp.update(); findTestSubject(comp, 'discoverFieldListPanelAddExistFilter-extension').simulate('click'); expect(props.onAddFilter).toHaveBeenCalledWith('_exists_', 'extension', '+'); }); @@ -450,7 +464,7 @@ describe('discover responsive sidebar', function () { ); await act(async () => { - await findTestSubject(comp, 'fieldListFiltersFieldSearch').simulate('change', { + findTestSubject(comp, 'fieldListFiltersFieldSearch').simulate('change', { target: { value: 'bytes' }, }); }); @@ -471,16 +485,16 @@ describe('discover responsive sidebar', function () { ); await act(async () => { - await findTestSubject(comp, 'fieldListFiltersFieldTypeFilterToggle').simulate('click'); + findTestSubject(comp, 'fieldListFiltersFieldTypeFilterToggle').simulate('click'); }); - await comp.update(); + comp.update(); await act(async () => { - await findTestSubject(comp, 'typeFilter-number').simulate('click'); + findTestSubject(comp, 'typeFilter-number').simulate('click'); }); - await comp.update(); + comp.update(); expect(findTestSubject(comp, 'fieldListGroupedAvailableFields-count').text()).toBe('2'); expect(findTestSubject(comp, 'fieldListGrouped__ariaDescription').text()).toBe( @@ -519,7 +533,7 @@ describe('discover responsive sidebar', function () { await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)); - await compInTextBasedMode.update(); + compInTextBasedMode.update(); }); expect(findTestSubject(compInTextBasedMode, 'indexPattern-add-field_btn').length).toBe(0); @@ -619,7 +633,7 @@ describe('discover responsive sidebar', function () { ); const addFieldButton = findTestSubject(comp, 'dataView-add-field_btn'); expect(addFieldButton.length).toBe(1); - await addFieldButton.simulate('click'); + addFieldButton.simulate('click'); expect(services.dataViewFieldEditor.openEditor).toHaveBeenCalledTimes(1); }); @@ -630,10 +644,10 @@ describe('discover responsive sidebar', function () { await act(async () => { findTestSubject(availableFields, 'field-bytes').simulate('click'); }); - await comp.update(); + comp.update(); const editFieldButton = findTestSubject(comp, 'discoverFieldListPanelEdit-bytes'); expect(editFieldButton.length).toBe(1); - await editFieldButton.simulate('click'); + editFieldButton.simulate('click'); expect(services.dataViewFieldEditor.openEditor).toHaveBeenCalledTimes(1); }); @@ -662,12 +676,12 @@ describe('discover responsive sidebar', function () { // open flyout await act(async () => { compWithPicker.find('.unifiedFieldListSidebar__mobileButton').last().simulate('click'); - await compWithPicker.update(); + compWithPicker.update(); }); - await compWithPicker.update(); + compWithPicker.update(); // open data view picker - await findTestSubject(compWithPicker, 'dataView-switch-link').simulate('click'); + findTestSubject(compWithPicker, 'dataView-switch-link').simulate('click'); expect(findTestSubject(compWithPicker, 'changeDataViewPopover').length).toBe(1); // check "Add a field" const addFieldButtonInDataViewPicker = findTestSubject( @@ -678,7 +692,7 @@ describe('discover responsive sidebar', function () { // click "Create a data view" const createDataViewButton = findTestSubject(compWithPicker, 'dataview-create-new'); expect(createDataViewButton.length).toBe(1); - await createDataViewButton.simulate('click'); + createDataViewButton.simulate('click'); expect(services.dataViewEditor.openEditor).toHaveBeenCalled(); }); @@ -697,10 +711,10 @@ describe('discover responsive sidebar', function () { .find('.unifiedFieldListSidebar__mobileButton') .last() .simulate('click'); - await compWithPickerInViewerMode.update(); + compWithPickerInViewerMode.update(); }); - await compWithPickerInViewerMode.update(); + compWithPickerInViewerMode.update(); // open data view picker findTestSubject(compWithPickerInViewerMode, 'dataView-switch-link').simulate('click'); expect(findTestSubject(compWithPickerInViewerMode, 'changeDataViewPopover').length).toBe(1); @@ -724,10 +738,10 @@ describe('discover responsive sidebar', function () { await act(async () => { comp.find('.unifiedFieldListSidebar__mobileButton').last().simulate('click'); - await comp.update(); + comp.update(); }); - await comp.update(); + comp.update(); expect(comp.find('[data-test-subj="custom-data-view-picker"]').exists()).toBe(false); }); @@ -741,10 +755,10 @@ describe('discover responsive sidebar', function () { await act(async () => { comp.find('.unifiedFieldListSidebar__mobileButton').last().simulate('click'); - await comp.update(); + comp.update(); }); - await comp.update(); + comp.update(); expect(comp.find('[data-test-subj="custom-data-view-picker"]').exists()).toBe(true); }); diff --git a/src/plugins/discover/public/application/main/components/sidebar/discover_sidebar_responsive.tsx b/src/plugins/discover/public/application/main/components/sidebar/discover_sidebar_responsive.tsx index f8352850cb4d..3177adefdf49 100644 --- a/src/plugins/discover/public/application/main/components/sidebar/discover_sidebar_responsive.tsx +++ b/src/plugins/discover/public/application/main/components/sidebar/discover_sidebar_responsive.tsx @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import React, { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useReducer, useRef } from 'react'; import { UiCounterMetricType } from '@kbn/analytics'; import { i18n } from '@kbn/i18n'; import type { DataView, DataViewField } from '@kbn/data-views-plugin/public'; @@ -133,6 +133,9 @@ export interface DiscoverSidebarResponsiveProps { * For customization and testing purposes */ fieldListVariant?: UnifiedFieldListSidebarContainerProps['variant']; + + unifiedFieldListSidebarContainerApi: UnifiedFieldListSidebarContainerApi | null; + setUnifiedFieldListSidebarContainerApi: (api: UnifiedFieldListSidebarContainerApi) => void; } /** @@ -153,6 +156,8 @@ export function DiscoverSidebarResponsive(props: DiscoverSidebarResponsiveProps) onChangeDataView, onAddField, onRemoveField, + unifiedFieldListSidebarContainerApi, + setUnifiedFieldListSidebarContainerApi, } = props; const [sidebarState, dispatchSidebarStateAction] = useReducer( discoverSidebarReducer, @@ -161,8 +166,6 @@ export function DiscoverSidebarResponsive(props: DiscoverSidebarResponsiveProps) ); const selectedDataViewRef = useRef(selectedDataView); const showFieldList = sidebarState.status !== DiscoverSidebarReducerStatus.INITIAL; - const [unifiedFieldListSidebarContainerApi, setUnifiedFieldListSidebarContainerApi] = - useState(null); useEffect(() => { const subscription = props.documents$.subscribe((documentState) => { @@ -385,6 +388,7 @@ export function DiscoverSidebarResponsive(props: DiscoverSidebarResponsiveProps) allFields={sidebarState.allFields} showFieldList={showFieldList} workspaceSelectedFieldNames={columns} + fullWidth onAddFieldToWorkspace={onAddFieldToWorkspace} onRemoveFieldFromWorkspace={onRemoveFieldFromWorkspace} onAddFilter={onAddFilter} diff --git a/src/plugins/discover/tsconfig.json b/src/plugins/discover/tsconfig.json index cb84d95d69f7..4c24cbb6140c 100644 --- a/src/plugins/discover/tsconfig.json +++ b/src/plugins/discover/tsconfig.json @@ -73,7 +73,8 @@ "@kbn/unified-data-table", "@kbn/no-data-page-plugin", "@kbn/rule-data-utils", - "@kbn/global-search-plugin" + "@kbn/global-search-plugin", + "@kbn/resizable-layout" ], "exclude": [ "target/**/*" diff --git a/src/plugins/unified_histogram/public/container/container.test.tsx b/src/plugins/unified_histogram/public/container/container.test.tsx index ea6ef95db55f..de62cf599761 100644 --- a/src/plugins/unified_histogram/public/container/container.test.tsx +++ b/src/plugins/unified_histogram/public/container/container.test.tsx @@ -33,7 +33,7 @@ describe('UnifiedHistogramContainer', () => { requestAdapter={new RequestAdapter()} searchSessionId={'123'} timeRange={{ from: 'now-15m', to: 'now' }} - resizeRef={{ current: null }} + container={null} /> ); expect(component.update().isEmptyRender()).toBe(true); @@ -62,7 +62,7 @@ describe('UnifiedHistogramContainer', () => { requestAdapter={new RequestAdapter()} searchSessionId={'123'} timeRange={{ from: 'now-15m', to: 'now' }} - resizeRef={{ current: null }} + container={null} /> ); await act(() => new Promise((resolve) => setTimeout(resolve, 0))); diff --git a/src/plugins/unified_histogram/public/container/container.tsx b/src/plugins/unified_histogram/public/container/container.tsx index 74ecf5837c02..c65f1e2b43c0 100644 --- a/src/plugins/unified_histogram/public/container/container.tsx +++ b/src/plugins/unified_histogram/public/container/container.tsx @@ -53,7 +53,7 @@ export type UnifiedHistogramContainerProps = { | 'timeRange' | 'relativeTimeRange' | 'columns' - | 'resizeRef' + | 'container' | 'appendHitsCounter' | 'children' | 'onBrushEnd' diff --git a/src/plugins/unified_histogram/public/layout/layout.test.tsx b/src/plugins/unified_histogram/public/layout/layout.test.tsx index f75fa0b1d4b9..a12c8cf46430 100644 --- a/src/plugins/unified_histogram/public/layout/layout.test.tsx +++ b/src/plugins/unified_histogram/public/layout/layout.test.tsx @@ -13,7 +13,6 @@ import React from 'react'; import { act } from 'react-dom/test-utils'; import { of } from 'rxjs'; import { Chart } from '../chart'; -import { Panels, PANELS_MODE } from '../panels'; import { UnifiedHistogramChartContext, UnifiedHistogramFetchStatus, @@ -22,6 +21,7 @@ import { import { dataViewWithTimefieldMock } from '../__mocks__/data_view_with_timefield'; import { unifiedHistogramServicesMock } from '../__mocks__/services'; import { UnifiedHistogramLayout, UnifiedHistogramLayoutProps } from './layout'; +import { ResizableLayout, ResizableLayoutMode } from '@kbn/resizable-layout'; let mockBreakpoint = 'l'; @@ -50,7 +50,7 @@ describe('Layout', () => { services = unifiedHistogramServicesMock, hits = createHits(), chart = createChart(), - resizeRef = { current: null }, + container = null, ...rest }: Partial> & { hits?: UnifiedHistogramHitsContext | null; @@ -65,7 +65,7 @@ describe('Layout', () => { services={services} hits={hits ?? undefined} chart={chart ?? undefined} - resizeRef={resizeRef} + container={container} dataView={dataViewWithTimefieldMock} query={{ language: 'kuery', @@ -95,66 +95,66 @@ describe('Layout', () => { }); describe('PANELS_MODE', () => { - it('should set the panels mode to PANELS_MODE.RESIZABLE when viewing on medium screens and above', async () => { + it('should set the layout mode to ResizableLayoutMode.Resizable when viewing on medium screens and above', async () => { const component = await mountComponent(); setBreakpoint(component, 'm'); - expect(component.find(Panels).prop('mode')).toBe(PANELS_MODE.RESIZABLE); + expect(component.find(ResizableLayout).prop('mode')).toBe(ResizableLayoutMode.Resizable); }); - it('should set the panels mode to PANELS_MODE.FIXED when viewing on small screens and below', async () => { + it('should set the layout mode to ResizableLayoutMode.Static when viewing on small screens and below', async () => { const component = await mountComponent(); setBreakpoint(component, 's'); - expect(component.find(Panels).prop('mode')).toBe(PANELS_MODE.FIXED); + expect(component.find(ResizableLayout).prop('mode')).toBe(ResizableLayoutMode.Static); }); - it('should set the panels mode to PANELS_MODE.FIXED if chart.hidden is true', async () => { + it('should set the layout mode to ResizableLayoutMode.Static if chart.hidden is true', async () => { const component = await mountComponent({ chart: { ...createChart(), hidden: true, }, }); - expect(component.find(Panels).prop('mode')).toBe(PANELS_MODE.FIXED); + expect(component.find(ResizableLayout).prop('mode')).toBe(ResizableLayoutMode.Static); }); - it('should set the panels mode to PANELS_MODE.FIXED if chart is undefined', async () => { + it('should set the layout mode to ResizableLayoutMode.Static if chart is undefined', async () => { const component = await mountComponent({ chart: null }); - expect(component.find(Panels).prop('mode')).toBe(PANELS_MODE.FIXED); + expect(component.find(ResizableLayout).prop('mode')).toBe(ResizableLayoutMode.Static); }); - it('should set the panels mode to PANELS_MODE.SINGLE if chart and hits are undefined', async () => { + it('should set the layout mode to ResizableLayoutMode.Single if chart and hits are undefined', async () => { const component = await mountComponent({ chart: null, hits: null }); - expect(component.find(Panels).prop('mode')).toBe(PANELS_MODE.SINGLE); + expect(component.find(ResizableLayout).prop('mode')).toBe(ResizableLayoutMode.Single); }); - it('should set a fixed height for Chart when panels mode is PANELS_MODE.FIXED and chart.hidden is false', async () => { + it('should set a fixed height for Chart when layout mode is ResizableLayoutMode.Static and chart.hidden is false', async () => { const component = await mountComponent(); setBreakpoint(component, 's'); - const expectedHeight = component.find(Panels).prop('topPanelHeight'); + const expectedHeight = component.find(ResizableLayout).prop('fixedPanelSize'); expect(component.find(Chart).find('div.euiFlexGroup').first().getDOMNode()).toHaveStyle({ height: `${expectedHeight}px`, }); }); - it('should not set a fixed height for Chart when panels mode is PANELS_MODE.FIXED and chart.hidden is true', async () => { + it('should not set a fixed height for Chart when layout mode is ResizableLayoutMode.Static and chart.hidden is true', async () => { const component = await mountComponent({ chart: { ...createChart(), hidden: true } }); setBreakpoint(component, 's'); - const expectedHeight = component.find(Panels).prop('topPanelHeight'); + const expectedHeight = component.find(ResizableLayout).prop('fixedPanelSize'); expect(component.find(Chart).find('div.euiFlexGroup').first().getDOMNode()).not.toHaveStyle({ height: `${expectedHeight}px`, }); }); - it('should not set a fixed height for Chart when panels mode is PANELS_MODE.FIXED and chart is undefined', async () => { + it('should not set a fixed height for Chart when layout mode is ResizableLayoutMode.Static and chart is undefined', async () => { const component = await mountComponent({ chart: null }); setBreakpoint(component, 's'); - const expectedHeight = component.find(Panels).prop('topPanelHeight'); + const expectedHeight = component.find(ResizableLayout).prop('fixedPanelSize'); expect(component.find(Chart).find('div.euiFlexGroup').first().getDOMNode()).not.toHaveStyle({ height: `${expectedHeight}px`, }); }); - it('should pass undefined for onResetChartHeight to Chart when panels mode is PANELS_MODE.FIXED', async () => { + it('should pass undefined for onResetChartHeight to Chart when layout mode is ResizableLayoutMode.Static', async () => { const component = await mountComponent({ topPanelHeight: 123 }); expect(component.find(Chart).prop('onResetChartHeight')).toBeDefined(); setBreakpoint(component, 's'); @@ -163,28 +163,28 @@ describe('Layout', () => { }); describe('topPanelHeight', () => { - it('should pass a default topPanelHeight to Panels when the topPanelHeight prop is undefined', async () => { + it('should pass a default fixedPanelSize to ResizableLayout when the topPanelHeight prop is undefined', async () => { const component = await mountComponent({ topPanelHeight: undefined }); - expect(component.find(Panels).prop('topPanelHeight')).toBeGreaterThan(0); + expect(component.find(ResizableLayout).prop('fixedPanelSize')).toBeGreaterThan(0); }); - it('should reset the topPanelHeight to the default when onResetChartHeight is called on Chart', async () => { + it('should reset the fixedPanelSize to the default when onResetChartHeight is called on Chart', async () => { const component: ReactWrapper = await mountComponent({ onTopPanelHeightChange: jest.fn((topPanelHeight) => { component.setProps({ topPanelHeight }); }), }); - const defaultTopPanelHeight = component.find(Panels).prop('topPanelHeight'); + const defaultTopPanelHeight = component.find(ResizableLayout).prop('fixedPanelSize'); const newTopPanelHeight = 123; - expect(component.find(Panels).prop('topPanelHeight')).not.toBe(newTopPanelHeight); + expect(component.find(ResizableLayout).prop('fixedPanelSize')).not.toBe(newTopPanelHeight); act(() => { - component.find(Panels).prop('onTopPanelHeightChange')!(newTopPanelHeight); + component.find(ResizableLayout).prop('onFixedPanelSizeChange')!(newTopPanelHeight); }); - expect(component.find(Panels).prop('topPanelHeight')).toBe(newTopPanelHeight); + expect(component.find(ResizableLayout).prop('fixedPanelSize')).toBe(newTopPanelHeight); act(() => { component.find(Chart).prop('onResetChartHeight')!(); }); - expect(component.find(Panels).prop('topPanelHeight')).toBe(defaultTopPanelHeight); + expect(component.find(ResizableLayout).prop('fixedPanelSize')).toBe(defaultTopPanelHeight); }); it('should pass undefined for onResetChartHeight to Chart when the chart is the default height', async () => { diff --git a/src/plugins/unified_histogram/public/layout/layout.tsx b/src/plugins/unified_histogram/public/layout/layout.tsx index 014495427f30..d923ea3031a5 100644 --- a/src/plugins/unified_histogram/public/layout/layout.tsx +++ b/src/plugins/unified_histogram/public/layout/layout.tsx @@ -7,8 +7,7 @@ */ import { EuiSpacer, useEuiTheme, useIsWithinBreakpoints } from '@elastic/eui'; -import { PropsWithChildren, ReactElement, RefObject } from 'react'; -import React, { useMemo } from 'react'; +import React, { PropsWithChildren, ReactElement, useMemo, useState } from 'react'; import { Observable } from 'rxjs'; import { createHtmlPortalNode, InPortal, OutPortal } from 'react-reverse-portal'; import { css } from '@emotion/css'; @@ -21,9 +20,13 @@ import type { LensSuggestionsApi, Suggestion, } from '@kbn/lens-plugin/public'; -import { AggregateQuery, Filter, Query, TimeRange } from '@kbn/es-query'; +import type { AggregateQuery, Filter, Query, TimeRange } from '@kbn/es-query'; +import { + ResizableLayout, + ResizableLayoutMode, + ResizableLayoutDirection, +} from '@kbn/resizable-layout'; import { Chart } from '../chart'; -import { Panels, PANELS_MODE } from '../panels'; import type { UnifiedHistogramChartContext, UnifiedHistogramServices, @@ -96,9 +99,9 @@ export interface UnifiedHistogramLayoutProps extends PropsWithChildren */ breakdown?: UnifiedHistogramBreakdownContext; /** - * Ref to the element wrapping the layout which will be used for resize calculations + * The parent container element, used to calculate the layout size */ - resizeRef: RefObject; + container: HTMLElement | null; /** * Current top panel height -- leave undefined to use the default */ @@ -192,7 +195,7 @@ export const UnifiedHistogramLayout = ({ lensEmbeddableOutput$, chart: originalChart, breakdown, - resizeRef, + container, topPanelHeight, appendHitsCounter, disableAutoFetching, @@ -226,14 +229,11 @@ export const UnifiedHistogramLayout = ({ }); const chart = suggestionUnsupported ? undefined : originalChart; - const topPanelNode = useMemo( - () => createHtmlPortalNode({ attributes: { class: 'eui-fullHeight' } }), - [] + const [topPanelNode] = useState(() => + createHtmlPortalNode({ attributes: { class: 'eui-fullHeight' } }) ); - - const mainPanelNode = useMemo( - () => createHtmlPortalNode({ attributes: { class: 'eui-fullHeight' } }), - [] + const [mainPanelNode] = useState(() => + createHtmlPortalNode({ attributes: { class: 'eui-fullHeight' } }) ); const isMobile = useIsWithinBreakpoints(['xs', 's']); @@ -252,14 +252,15 @@ export const UnifiedHistogramLayout = ({ const panelsMode = chart || hits ? showFixedPanels - ? PANELS_MODE.FIXED - : PANELS_MODE.RESIZABLE - : PANELS_MODE.SINGLE; + ? ResizableLayoutMode.Static + : ResizableLayoutMode.Resizable + : ResizableLayoutMode.Single; const currentTopPanelHeight = topPanelHeight ?? defaultTopPanelHeight; const onResetChartHeight = useMemo(() => { - return currentTopPanelHeight !== defaultTopPanelHeight && panelsMode === PANELS_MODE.RESIZABLE + return currentTopPanelHeight !== defaultTopPanelHeight && + panelsMode === ResizableLayoutMode.Resizable ? () => onTopPanelHeightChange?.(undefined) : undefined; }, [currentTopPanelHeight, defaultTopPanelHeight, onTopPanelHeightChange, panelsMode]); @@ -305,16 +306,18 @@ export const UnifiedHistogramLayout = ({ /> {children} - } - mainPanel={} - onTopPanelHeightChange={onTopPanelHeightChange} + direction={ResizableLayoutDirection.Vertical} + container={container} + fixedPanelSize={currentTopPanelHeight} + minFixedPanelSize={defaultTopPanelHeight} + minFlexPanelSize={minMainPanelHeight} + fixedPanel={} + flexPanel={} + data-test-subj="unifiedHistogram" + onFixedPanelSizeChange={onTopPanelHeightChange} /> ); diff --git a/src/plugins/unified_histogram/public/panels/panels.test.tsx b/src/plugins/unified_histogram/public/panels/panels.test.tsx deleted file mode 100644 index e0e2de24b408..000000000000 --- a/src/plugins/unified_histogram/public/panels/panels.test.tsx +++ /dev/null @@ -1,95 +0,0 @@ -/* - * 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 { mount } from 'enzyme'; -import type { ReactElement, RefObject } from 'react'; -import React from 'react'; -import { Panels, PANELS_MODE } from './panels'; -import { PanelsResizable } from './panels_resizable'; -import { PanelsFixed } from './panels_fixed'; - -describe('Panels component', () => { - const mountComponent = ({ - mode = PANELS_MODE.RESIZABLE, - resizeRef = { current: null }, - initialTopPanelHeight = 200, - minTopPanelHeight = 100, - minMainPanelHeight = 100, - topPanel = <>, - mainPanel = <>, - }: { - mode?: PANELS_MODE; - resizeRef?: RefObject; - initialTopPanelHeight?: number; - minTopPanelHeight?: number; - minMainPanelHeight?: number; - mainPanel?: ReactElement; - topPanel?: ReactElement; - }) => { - return mount( - - ); - }; - - it('should show PanelsFixed when mode is PANELS_MODE.SINGLE', () => { - const topPanel =
; - const mainPanel =
; - const component = mountComponent({ mode: PANELS_MODE.SINGLE, topPanel, mainPanel }); - expect(component.find(PanelsFixed).exists()).toBe(true); - expect(component.find(PanelsResizable).exists()).toBe(false); - expect(component.contains(topPanel)).toBe(false); - expect(component.contains(mainPanel)).toBe(true); - }); - - it('should show PanelsFixed when mode is PANELS_MODE.FIXED', () => { - const topPanel =
; - const mainPanel =
; - const component = mountComponent({ mode: PANELS_MODE.FIXED, topPanel, mainPanel }); - expect(component.find(PanelsFixed).exists()).toBe(true); - expect(component.find(PanelsResizable).exists()).toBe(false); - expect(component.contains(topPanel)).toBe(true); - expect(component.contains(mainPanel)).toBe(true); - }); - - it('should show PanelsResizable when mode is PANELS_MODE.RESIZABLE', () => { - const topPanel =
; - const mainPanel =
; - const component = mountComponent({ mode: PANELS_MODE.RESIZABLE, topPanel, mainPanel }); - expect(component.find(PanelsFixed).exists()).toBe(false); - expect(component.find(PanelsResizable).exists()).toBe(true); - expect(component.contains(topPanel)).toBe(true); - expect(component.contains(mainPanel)).toBe(true); - }); - - it('should pass true for hideTopPanel when mode is PANELS_MODE.SINGLE', () => { - const topPanel =
; - const mainPanel =
; - const component = mountComponent({ mode: PANELS_MODE.SINGLE, topPanel, mainPanel }); - expect(component.find(PanelsFixed).prop('hideTopPanel')).toBe(true); - expect(component.contains(topPanel)).toBe(false); - expect(component.contains(mainPanel)).toBe(true); - }); - - it('should pass false for hideTopPanel when mode is PANELS_MODE.FIXED', () => { - const topPanel =
; - const mainPanel =
; - const component = mountComponent({ mode: PANELS_MODE.FIXED, topPanel, mainPanel }); - expect(component.find(PanelsFixed).prop('hideTopPanel')).toBe(false); - expect(component.contains(topPanel)).toBe(true); - expect(component.contains(mainPanel)).toBe(true); - }); -}); diff --git a/src/plugins/unified_histogram/public/panels/panels.tsx b/src/plugins/unified_histogram/public/panels/panels.tsx deleted file mode 100644 index 609219ab2866..000000000000 --- a/src/plugins/unified_histogram/public/panels/panels.tsx +++ /dev/null @@ -1,59 +0,0 @@ -/* - * 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 type { ReactElement, RefObject } from 'react'; -import React from 'react'; -import { PanelsResizable } from './panels_resizable'; -import { PanelsFixed } from './panels_fixed'; - -export enum PANELS_MODE { - SINGLE = 'single', - FIXED = 'fixed', - RESIZABLE = 'resizable', -} - -export interface PanelsProps { - className?: string; - mode: PANELS_MODE; - resizeRef: RefObject; - topPanelHeight: number; - minTopPanelHeight: number; - minMainPanelHeight: number; - topPanel: ReactElement; - mainPanel: ReactElement; - onTopPanelHeightChange?: (topPanelHeight: number) => void; -} - -const fixedModes = [PANELS_MODE.SINGLE, PANELS_MODE.FIXED]; - -export const Panels = ({ - className, - mode, - resizeRef, - topPanelHeight, - minTopPanelHeight, - minMainPanelHeight, - topPanel, - mainPanel, - onTopPanelHeightChange, -}: PanelsProps) => { - const panelsProps = { className, topPanel, mainPanel }; - - return fixedModes.includes(mode) ? ( - - ) : ( - - ); -}; diff --git a/src/plugins/unified_histogram/public/panels/panels_fixed.test.tsx b/src/plugins/unified_histogram/public/panels/panels_fixed.test.tsx deleted file mode 100644 index e803d0445b1e..000000000000 --- a/src/plugins/unified_histogram/public/panels/panels_fixed.test.tsx +++ /dev/null @@ -1,44 +0,0 @@ -/* - * 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 { mount } from 'enzyme'; -import type { ReactElement } from 'react'; -import React from 'react'; -import { PanelsFixed } from './panels_fixed'; - -describe('Panels fixed', () => { - const mountComponent = ({ - hideTopPanel = false, - topPanel = <>, - mainPanel = <>, - }: { - hideTopPanel?: boolean; - topPanel: ReactElement; - mainPanel: ReactElement; - }) => { - return mount( - - ); - }; - - it('should render both panels when hideTopPanel is false', () => { - const topPanel =
; - const mainPanel =
; - const component = mountComponent({ topPanel, mainPanel }); - expect(component.contains(topPanel)).toBe(true); - expect(component.contains(mainPanel)).toBe(true); - }); - - it('should render only main panel when hideTopPanel is true', () => { - const topPanel =
; - const mainPanel =
; - const component = mountComponent({ hideTopPanel: true, topPanel, mainPanel }); - expect(component.contains(topPanel)).toBe(false); - expect(component.contains(mainPanel)).toBe(true); - }); -}); diff --git a/src/plugins/unified_histogram/public/panels/panels_resizable.test.tsx b/src/plugins/unified_histogram/public/panels/panels_resizable.test.tsx deleted file mode 100644 index add0281cfc0f..000000000000 --- a/src/plugins/unified_histogram/public/panels/panels_resizable.test.tsx +++ /dev/null @@ -1,197 +0,0 @@ -/* - * 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 type { ReactWrapper } from 'enzyme'; -import { mount } from 'enzyme'; -import type { ReactElement, RefObject } from 'react'; -import React from 'react'; -import { PanelsResizable } from './panels_resizable'; -import { act } from 'react-dom/test-utils'; - -const containerHeight = 1000; -const topPanelId = 'topPanel'; - -jest.mock('@elastic/eui', () => ({ - ...jest.requireActual('@elastic/eui'), - useResizeObserver: jest.fn(), - useGeneratedHtmlId: jest.fn(() => topPanelId), -})); - -import * as eui from '@elastic/eui'; -import { waitFor } from '@testing-library/dom'; - -describe('Panels resizable', () => { - const mountComponent = ({ - className = '', - resizeRef = { current: null }, - initialTopPanelHeight = 0, - minTopPanelHeight = 0, - minMainPanelHeight = 0, - topPanel = <>, - mainPanel = <>, - attachTo, - onTopPanelHeightChange = jest.fn(), - }: { - className?: string; - resizeRef?: RefObject; - initialTopPanelHeight?: number; - minTopPanelHeight?: number; - minMainPanelHeight?: number; - topPanel?: ReactElement; - mainPanel?: ReactElement; - attachTo?: HTMLElement; - onTopPanelHeightChange?: (topPanelHeight: number) => void; - }) => { - return mount( - , - attachTo ? { attachTo } : undefined - ); - }; - - const expectCorrectPanelSizes = ( - component: ReactWrapper, - currentContainerHeight: number, - topPanelHeight: number - ) => { - const topPanelSize = (topPanelHeight / currentContainerHeight) * 100; - expect( - component.find('[data-test-subj="unifiedHistogramResizablePanelTop"]').at(0).prop('size') - ).toBe(topPanelSize); - expect( - component.find('[data-test-subj="unifiedHistogramResizablePanelMain"]').at(0).prop('size') - ).toBe(100 - topPanelSize); - }; - - const forceRender = (component: ReactWrapper) => { - component.setProps({}).update(); - }; - - beforeEach(() => { - jest.spyOn(eui, 'useResizeObserver').mockReturnValue({ height: containerHeight, width: 0 }); - }); - - it('should render both panels', () => { - const topPanel =
; - const mainPanel =
; - const component = mountComponent({ topPanel, mainPanel }); - expect(component.contains(topPanel)).toBe(true); - expect(component.contains(mainPanel)).toBe(true); - }); - - it('should set the initial heights of both panels', () => { - const initialTopPanelHeight = 200; - const component = mountComponent({ initialTopPanelHeight }); - expectCorrectPanelSizes(component, containerHeight, initialTopPanelHeight); - }); - - it('should set the correct heights of both panels when the panels are resized', () => { - const initialTopPanelHeight = 200; - const onTopPanelHeightChange = jest.fn((topPanelHeight) => { - component.setProps({ topPanelHeight }).update(); - }); - const component = mountComponent({ initialTopPanelHeight, onTopPanelHeightChange }); - expectCorrectPanelSizes(component, containerHeight, initialTopPanelHeight); - const newTopPanelSize = 30; - const onPanelSizeChange = component - .find('[data-test-subj="unifiedHistogramResizableContainer"]') - .at(0) - .prop('onPanelWidthChange') as Function; - act(() => { - onPanelSizeChange({ [topPanelId]: newTopPanelSize }); - }); - forceRender(component); - const newTopPanelHeight = (newTopPanelSize / 100) * containerHeight; - expect(onTopPanelHeightChange).toHaveBeenCalledWith(newTopPanelHeight); - expectCorrectPanelSizes(component, containerHeight, newTopPanelHeight); - }); - - it('should maintain the height of the top panel and resize the main panel when the container height changes', () => { - const initialTopPanelHeight = 200; - const component = mountComponent({ initialTopPanelHeight }); - expectCorrectPanelSizes(component, containerHeight, initialTopPanelHeight); - const newContainerHeight = 2000; - jest.spyOn(eui, 'useResizeObserver').mockReturnValue({ height: newContainerHeight, width: 0 }); - forceRender(component); - expectCorrectPanelSizes(component, newContainerHeight, initialTopPanelHeight); - }); - - it('should resize the top panel once the main panel is at its minimum height', () => { - const initialTopPanelHeight = 500; - const minTopPanelHeight = 100; - const minMainPanelHeight = 100; - const component = mountComponent({ - initialTopPanelHeight, - minTopPanelHeight, - minMainPanelHeight, - }); - expectCorrectPanelSizes(component, containerHeight, initialTopPanelHeight); - const newContainerHeight = 400; - jest.spyOn(eui, 'useResizeObserver').mockReturnValue({ height: newContainerHeight, width: 0 }); - forceRender(component); - expectCorrectPanelSizes(component, newContainerHeight, newContainerHeight - minMainPanelHeight); - jest.spyOn(eui, 'useResizeObserver').mockReturnValue({ height: containerHeight, width: 0 }); - forceRender(component); - expectCorrectPanelSizes(component, containerHeight, initialTopPanelHeight); - }); - - it('should maintain the minimum heights of both panels when the container is too small to fit them', () => { - const initialTopPanelHeight = 500; - const minTopPanelHeight = 100; - const minMainPanelHeight = 150; - const component = mountComponent({ - initialTopPanelHeight, - minTopPanelHeight, - minMainPanelHeight, - }); - expectCorrectPanelSizes(component, containerHeight, initialTopPanelHeight); - const newContainerHeight = 200; - jest.spyOn(eui, 'useResizeObserver').mockReturnValue({ height: newContainerHeight, width: 0 }); - forceRender(component); - expect( - component.find('[data-test-subj="unifiedHistogramResizablePanelTop"]').at(0).prop('size') - ).toBe((minTopPanelHeight / newContainerHeight) * 100); - expect( - component.find('[data-test-subj="unifiedHistogramResizablePanelMain"]').at(0).prop('size') - ).toBe((minMainPanelHeight / newContainerHeight) * 100); - jest.spyOn(eui, 'useResizeObserver').mockReturnValue({ height: containerHeight, width: 0 }); - forceRender(component); - expectCorrectPanelSizes(component, containerHeight, initialTopPanelHeight); - }); - - it('should blur the resize button after a resize', async () => { - const attachTo = document.createElement('div'); - document.body.appendChild(attachTo); - const component = mountComponent({ attachTo }); - const getContainer = () => - component.find('[data-test-subj="unifiedHistogramResizableContainer"]').at(0); - const resizeButton = component.find('button[data-test-subj="unifiedHistogramResizableButton"]'); - act(() => { - const onResizeStart = getContainer().prop('onResizeStart') as Function; - onResizeStart('pointer'); - }); - (resizeButton.getDOMNode() as HTMLElement).focus(); - forceRender(component); - act(() => { - const onResizeEnd = getContainer().prop('onResizeEnd') as Function; - onResizeEnd(); - }); - expect(resizeButton.getDOMNode()).toHaveFocus(); - await waitFor(() => { - expect(resizeButton.getDOMNode()).not.toHaveFocus(); - }); - }); -}); diff --git a/src/plugins/unified_histogram/public/panels/panels_resizable.tsx b/src/plugins/unified_histogram/public/panels/panels_resizable.tsx deleted file mode 100644 index 9f8fd5338a38..000000000000 --- a/src/plugins/unified_histogram/public/panels/panels_resizable.tsx +++ /dev/null @@ -1,197 +0,0 @@ -/* - * 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 { EuiResizableContainer, useGeneratedHtmlId, useResizeObserver } from '@elastic/eui'; -import type { ResizeTrigger } from '@elastic/eui/src/components/resizable_container/types'; -import { css } from '@emotion/react'; -import { isEqual, round } from 'lodash'; -import type { ReactElement, RefObject } from 'react'; -import React, { useCallback, useEffect, useState } from 'react'; - -const percentToPixels = (containerHeight: number, percentage: number) => - Math.round(containerHeight * (percentage / 100)); - -const pixelsToPercent = (containerHeight: number, pixels: number) => - (pixels / containerHeight) * 100; - -export const PanelsResizable = ({ - className, - resizeRef, - topPanelHeight, - minTopPanelHeight, - minMainPanelHeight, - topPanel, - mainPanel, - onTopPanelHeightChange, -}: { - className?: string; - resizeRef: RefObject; - topPanelHeight: number; - minTopPanelHeight: number; - minMainPanelHeight: number; - topPanel: ReactElement; - mainPanel: ReactElement; - onTopPanelHeightChange?: (topPanelHeight: number) => void; -}) => { - const topPanelId = useGeneratedHtmlId({ prefix: 'topPanel' }); - const { height: containerHeight } = useResizeObserver(resizeRef.current); - const [panelSizes, setPanelSizes] = useState({ topPanelSize: 0, mainPanelSize: 0 }); - - // EuiResizableContainer doesn't work properly when used with react-reverse-portal and - // will cancel the resize. To work around this we keep track of when resizes start and - // end to toggle the rendering of a transparent overlay which prevents the cancellation. - // EUI issue: https://github.com/elastic/eui/issues/6199 - const [resizeWithPortalsHackIsResizing, setResizeWithPortalsHackIsResizing] = useState(false); - const enableResizeWithPortalsHack = useCallback( - () => setResizeWithPortalsHackIsResizing(true), - [] - ); - const disableResizeWithPortalsHack = useCallback( - () => setResizeWithPortalsHackIsResizing(false), - [] - ); - const resizeWithPortalsHackButtonCss = css` - z-index: 3; - `; - const resizeWithPortalsHackOverlayCss = css` - position: absolute; - top: 0; - left: 0; - width: 100%; - height: 100%; - z-index: 2; - `; - - // We convert the top panel height from a percentage of the container height - // to a pixel value and emit the change to the parent component. We also convert - // the pixel value back to a percentage before updating the panel sizes to avoid - // rounding issues with the isEqual check in the effect below. - const onPanelSizeChange = useCallback( - ({ [topPanelId]: topPanelSize }: { [key: string]: number }) => { - const newTopPanelHeight = percentToPixels(containerHeight, topPanelSize); - const newTopPanelSize = pixelsToPercent(containerHeight, newTopPanelHeight); - - setPanelSizes({ - topPanelSize: round(newTopPanelSize, 4), - mainPanelSize: round(100 - newTopPanelSize, 4), - }); - - onTopPanelHeightChange?.(newTopPanelHeight); - }, - [containerHeight, onTopPanelHeightChange, topPanelId] - ); - - // This effect will update the panel sizes based on the top panel height whenever - // it or the container height changes. This allows us to keep the height of the - // top panel fixed when the window is resized. - useEffect(() => { - if (!containerHeight) { - return; - } - - let topPanelSize: number; - let mainPanelSize: number; - - // If the container height is less than the minimum main content height - // plus the current top panel height, then we need to make some adjustments. - if (containerHeight < minMainPanelHeight + topPanelHeight) { - const newTopPanelHeight = containerHeight - minMainPanelHeight; - - // Try to make the top panel height fit within the container, but if it - // doesn't then just use the minimum heights. - if (newTopPanelHeight < minTopPanelHeight) { - topPanelSize = pixelsToPercent(containerHeight, minTopPanelHeight); - mainPanelSize = pixelsToPercent(containerHeight, minMainPanelHeight); - } else { - topPanelSize = pixelsToPercent(containerHeight, newTopPanelHeight); - mainPanelSize = 100 - topPanelSize; - } - } else { - topPanelSize = pixelsToPercent(containerHeight, topPanelHeight); - mainPanelSize = 100 - topPanelSize; - } - - const newPanelSizes = { - topPanelSize: round(topPanelSize, 4), - mainPanelSize: round(mainPanelSize, 4), - }; - - // Skip updating the panel sizes if they haven't changed - // since onPanelSizeChange will also trigger this effect. - if (!isEqual(panelSizes, newPanelSizes)) { - setPanelSizes(newPanelSizes); - } - }, [containerHeight, minMainPanelHeight, minTopPanelHeight, panelSizes, topPanelHeight]); - - const onResizeStart = useCallback( - (trigger: ResizeTrigger) => { - if (trigger !== 'pointer') { - return; - } - - enableResizeWithPortalsHack(); - }, - [enableResizeWithPortalsHack] - ); - - const onResizeEnd = useCallback(() => { - if (!resizeWithPortalsHackIsResizing) { - return; - } - - // We don't want the resize button to retain focus after the resize is complete, - // but EuiResizableContainer will force focus it onClick. To work around this we - // use setTimeout to wait until after onClick has been called before blurring. - if (document.activeElement instanceof HTMLElement) { - const button = document.activeElement; - setTimeout(() => { - button.blur(); - }); - } - - disableResizeWithPortalsHack(); - }, [disableResizeWithPortalsHack, resizeWithPortalsHackIsResizing]); - - return ( - - {(EuiResizablePanel, EuiResizableButton) => ( - <> - - {topPanel} - - - - {mainPanel} - - {resizeWithPortalsHackIsResizing ?
: <>} - - )} - - ); -}; diff --git a/src/plugins/unified_histogram/tsconfig.json b/src/plugins/unified_histogram/tsconfig.json index 0dc5b1fe9d52..b8337379679c 100644 --- a/src/plugins/unified_histogram/tsconfig.json +++ b/src/plugins/unified_histogram/tsconfig.json @@ -25,6 +25,7 @@ "@kbn/kibana-utils-plugin", "@kbn/visualizations-plugin", "@kbn/discover-utils", + "@kbn/resizable-layout", ], "exclude": [ "target/**/*", diff --git a/test/functional/apps/discover/group1/_discover.ts b/test/functional/apps/discover/group1/_discover.ts index 286a30a6bb22..2933dd02f152 100644 --- a/test/functional/apps/discover/group1/_discover.ts +++ b/test/functional/apps/discover/group1/_discover.ts @@ -31,8 +31,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { defaultIndex: 'logstash-*', }; - // FLAKY: https://github.com/elastic/kibana/issues/146223 - describe.skip('discover test', function describeIndexTests() { + describe('discover test', function describeIndexTests() { before(async function () { log.debug('load kibana index with default index pattern'); await kibanaServer.importExport.load('test/functional/fixtures/kbn_archiver/discover'); @@ -112,7 +111,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { ); }); - it('should show correct initial chart interval of Auto', async function () { + // FLAKY: https://github.com/elastic/kibana/issues/146223 + it.skip('should show correct initial chart interval of Auto', async function () { await PageObjects.timePicker.setDefaultAbsoluteRange(); await PageObjects.discover.waitUntilSearchingHasFinished(); const actualInterval = await PageObjects.discover.getChartInterval(); @@ -127,6 +127,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); it('should reload the saved search with persisted query to show the initial hit count', async function () { + await PageObjects.timePicker.setDefaultAbsoluteRange(); + await PageObjects.discover.waitUntilSearchingHasFinished(); // apply query some changes await queryBar.setQuery('test'); await queryBar.submitQuery(); @@ -298,10 +300,10 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); describe('resizable layout panels', () => { - it('should allow resizing the layout panels', async () => { + it('should allow resizing the histogram layout panels', async () => { const resizeDistance = 100; - const topPanel = await testSubjects.find('unifiedHistogramResizablePanelTop'); - const mainPanel = await testSubjects.find('unifiedHistogramResizablePanelMain'); + const topPanel = await testSubjects.find('unifiedHistogramResizablePanelFixed'); + const mainPanel = await testSubjects.find('unifiedHistogramResizablePanelFlex'); const resizeButton = await testSubjects.find('unifiedHistogramResizableButton'); const topPanelSize = (await topPanel.getPosition()).height; const mainPanelSize = (await mainPanel.getPosition()).height; @@ -314,6 +316,23 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { expect(newTopPanelSize).to.be(topPanelSize + resizeDistance); expect(newMainPanelSize).to.be(mainPanelSize - resizeDistance); }); + + it('should allow resizing the sidebar layout panels', async () => { + const resizeDistance = 100; + const leftPanel = await testSubjects.find('discoverLayoutResizablePanelFixed'); + const mainPanel = await testSubjects.find('discoverLayoutResizablePanelFlex'); + const resizeButton = await testSubjects.find('discoverLayoutResizableButton'); + const leftPanelSize = (await leftPanel.getPosition()).width; + const mainPanelSize = (await mainPanel.getPosition()).width; + await browser.dragAndDrop( + { location: resizeButton }, + { location: { x: resizeDistance, y: 0 } } + ); + const newLeftPanelSize = (await leftPanel.getPosition()).width; + const newMainPanelSize = (await mainPanel.getPosition()).width; + expect(newLeftPanelSize).to.be(leftPanelSize + resizeDistance); + expect(newMainPanelSize).to.be(mainPanelSize - resizeDistance); + }); }); describe('URL state', () => { diff --git a/test/functional/apps/management/data_views/_scripted_fields.ts b/test/functional/apps/management/data_views/_scripted_fields.ts index 5bbbc85488d2..e49eb1131050 100644 --- a/test/functional/apps/management/data_views/_scripted_fields.ts +++ b/test/functional/apps/management/data_views/_scripted_fields.ts @@ -155,7 +155,6 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { it('should see scripted field value in Discover', async function () { await PageObjects.common.navigateToApp('discover'); - await PageObjects.unifiedFieldList.clickFieldListItem(scriptedPainlessFieldName); await retry.try(async function () { await PageObjects.unifiedFieldList.clickFieldListItemAdd(scriptedPainlessFieldName); }); @@ -261,7 +260,6 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { it('should see scripted field value in Discover', async function () { await PageObjects.common.navigateToApp('discover'); - await PageObjects.unifiedFieldList.clickFieldListItem(scriptedPainlessFieldName2); await retry.try(async function () { await PageObjects.unifiedFieldList.clickFieldListItemAdd(scriptedPainlessFieldName2); }); @@ -366,7 +364,6 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { it('should see scripted field value in Discover', async function () { await PageObjects.common.navigateToApp('discover'); - await PageObjects.unifiedFieldList.clickFieldListItem(scriptedPainlessFieldName2); await retry.try(async function () { await PageObjects.unifiedFieldList.clickFieldListItemAdd(scriptedPainlessFieldName2); }); @@ -464,7 +461,6 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { it('should see scripted field value in Discover', async function () { await PageObjects.common.navigateToApp('discover'); - await PageObjects.unifiedFieldList.clickFieldListItem(scriptedPainlessFieldName2); await retry.try(async function () { await PageObjects.unifiedFieldList.clickFieldListItemAdd(scriptedPainlessFieldName2); }); diff --git a/test/functional/apps/management/data_views/_scripted_fields_classic_table.ts b/test/functional/apps/management/data_views/_scripted_fields_classic_table.ts index a2fbcb43cfe5..0ca095811c0b 100644 --- a/test/functional/apps/management/data_views/_scripted_fields_classic_table.ts +++ b/test/functional/apps/management/data_views/_scripted_fields_classic_table.ts @@ -143,7 +143,6 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.common.navigateToApp('discover'); await PageObjects.timePicker.setAbsoluteRange(fromTime, toTime); - await PageObjects.unifiedFieldList.clickFieldListItem(scriptedPainlessFieldName); await retry.try(async function () { await PageObjects.unifiedFieldList.clickFieldListItemAdd(scriptedPainlessFieldName); }); @@ -233,7 +232,6 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.common.navigateToApp('discover'); await PageObjects.timePicker.setAbsoluteRange(fromTime, toTime); - await PageObjects.unifiedFieldList.clickFieldListItem(scriptedPainlessFieldName2); await retry.try(async function () { await PageObjects.unifiedFieldList.clickFieldListItemAdd(scriptedPainlessFieldName2); }); @@ -322,7 +320,6 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.common.navigateToApp('discover'); await PageObjects.timePicker.setAbsoluteRange(fromTime, toTime); - await PageObjects.unifiedFieldList.clickFieldListItem(scriptedPainlessFieldName2); await retry.try(async function () { await PageObjects.unifiedFieldList.clickFieldListItemAdd(scriptedPainlessFieldName2); }); @@ -412,7 +409,6 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.common.navigateToApp('discover'); await PageObjects.timePicker.setAbsoluteRange(fromTime, toTime); - await PageObjects.unifiedFieldList.clickFieldListItem(scriptedPainlessFieldName2); await retry.try(async function () { await PageObjects.unifiedFieldList.clickFieldListItemAdd(scriptedPainlessFieldName2); }); diff --git a/tsconfig.base.json b/tsconfig.base.json index b43c2b3a9184..030b5c9bbed4 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -1156,6 +1156,10 @@ "@kbn/reporting-example-plugin/*": ["x-pack/examples/reporting_example/*"], "@kbn/reporting-plugin": ["x-pack/plugins/reporting"], "@kbn/reporting-plugin/*": ["x-pack/plugins/reporting/*"], + "@kbn/resizable-layout": ["packages/kbn-resizable-layout"], + "@kbn/resizable-layout/*": ["packages/kbn-resizable-layout/*"], + "@kbn/resizable-layout-examples-plugin": ["examples/resizable_layout_examples"], + "@kbn/resizable-layout-examples-plugin/*": ["examples/resizable_layout_examples/*"], "@kbn/resolver-test-plugin": ["x-pack/test/plugin_functional/plugins/resolver_test"], "@kbn/resolver-test-plugin/*": ["x-pack/test/plugin_functional/plugins/resolver_test/*"], "@kbn/response-stream-plugin": ["examples/response_stream"], diff --git a/yarn.lock b/yarn.lock index 861e3d293a17..41769ceb9a6e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5255,6 +5255,14 @@ version "0.0.0" uid "" +"@kbn/resizable-layout-examples-plugin@link:examples/resizable_layout_examples": + version "0.0.0" + uid "" + +"@kbn/resizable-layout@link:packages/kbn-resizable-layout": + version "0.0.0" + uid "" + "@kbn/resolver-test-plugin@link:x-pack/test/plugin_functional/plugins/resolver_test": version "0.0.0" uid ""