From 3e1865513d3e59cb93c073a6c6663aabf65cc5a6 Mon Sep 17 00:00:00 2001 From: Davis McPhee Date: Wed, 27 Sep 2023 21:52:25 -0300 Subject: [PATCH] [Discover] Add resize support to the Discover field list sidebar (#167066) ## Summary This PR adds resize support to the Discover field list sidebar, which is persisted to a user's local storage similar to the resizable chart height. Additionally it migrates the resizable layout code from Unified Histogram to a new package called `kbn-resizable-layout` so it can be shared between Discover and Unified Histogram, as well as act as a new platform component that other teams can consume to create their own resizable layouts. ![resize](https://github.com/elastic/kibana/assets/25592674/71b9a0ae-1795-43c8-acb0-e75fe46e2a8a) Resolves #9531. ### Checklist - [ ] ~Any text added follows [EUI's writing guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses sentence case text and includes [i18n support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md)~ - [ ] ~[Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html) was added for features that require explanation or tutorials~ - [x] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios - [x] Any UI touched in this PR is usable by keyboard only (learn more about [keyboard accessibility](https://webaim.org/techniques/keyboard/)) - [ ] ~Any UI touched in this PR does not create any new axe failures (run axe in browser: [FF](https://addons.mozilla.org/en-US/firefox/addon/axe-devtools/), [Chrome](https://chrome.google.com/webstore/detail/axe-web-accessibility-tes/lhdoppojpmngadmnindnejefpokejbdd?hl=en-US))~ - [ ] ~If a plugin configuration key changed, check if it needs to be allowlisted in the cloud and added to the [docker list](https://github.com/elastic/kibana/blob/main/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker)~ - [x] This renders correctly on smaller devices using a responsive layout. (You can test this [in your browser](https://www.browserstack.com/guide/responsive-testing-on-local-server)) - [x] This was checked for [cross-browser compatibility](https://www.elastic.co/support/matrix#matrix_browsers) ### For maintainers - [ ] This was checked for breaking API changes and was [labeled appropriately](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process) --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> --- .github/CODEOWNERS | 2 + .../resizable_layout_examples/kibana.jsonc | 11 + .../public/application.tsx | 161 ++++++++++++ .../resizable_layout_examples/public/index.ts | 13 + .../public/plugin.tsx | 44 ++++ .../public/resizable_layout_examples.png | Bin 0 -> 30405 bytes .../resizable_layout_examples/tsconfig.json | 15 ++ package.json | 2 + packages/kbn-resizable-layout/README.md | 85 ++++++ packages/kbn-resizable-layout/index.ts | 14 + .../kbn-resizable-layout/jest.config.js | 6 +- packages/kbn-resizable-layout/kibana.jsonc | 6 + packages/kbn-resizable-layout/package.json | 6 + .../src/panels_resizable.test.tsx | 246 ++++++++++++++++++ .../src/panels_resizable.tsx | 228 ++++++++++++++++ .../src/panels_static.test.tsx | 71 +++++ .../src/panels_static.tsx | 28 +- .../src/resizable_layout.test.tsx | 106 ++++++++ .../src/resizable_layout.tsx | 130 +++++++++ .../kbn-resizable-layout/src/utils.test.ts | 40 +++ packages/kbn-resizable-layout/src/utils.ts | 22 ++ packages/kbn-resizable-layout/tsconfig.json | 11 + packages/kbn-resizable-layout/types.ts | 33 +++ .../field_list_sidebar.scss | 9 +- .../field_list_sidebar.tsx | 7 + .../field_list_sidebar_container.tsx | 10 +- .../layout/discover_histogram_layout.test.tsx | 2 +- .../layout/discover_histogram_layout.tsx | 8 +- .../components/layout/discover_layout.scss | 14 + .../layout/discover_layout.test.tsx | 5 + .../components/layout/discover_layout.tsx | 173 +++++++----- .../layout/discover_resizable_layout.test.tsx | 169 ++++++++++++ .../layout/discover_resizable_layout.tsx | 80 ++++++ .../discover_sidebar_responsive.test.tsx | 80 +++--- .../sidebar/discover_sidebar_responsive.tsx | 10 +- src/plugins/discover/tsconfig.json | 3 +- .../public/container/container.test.tsx | 4 +- .../public/container/container.tsx | 2 +- .../public/layout/layout.test.tsx | 56 ++-- .../public/layout/layout.tsx | 55 ++-- .../public/panels/panels.test.tsx | 95 ------- .../public/panels/panels.tsx | 59 ----- .../public/panels/panels_fixed.test.tsx | 44 ---- .../public/panels/panels_resizable.test.tsx | 197 -------------- .../public/panels/panels_resizable.tsx | 197 -------------- src/plugins/unified_histogram/tsconfig.json | 1 + .../apps/discover/group1/_discover.ts | 31 ++- .../management/data_views/_scripted_fields.ts | 4 - .../_scripted_fields_classic_table.ts | 4 - tsconfig.base.json | 4 + yarn.lock | 8 + 51 files changed, 1822 insertions(+), 789 deletions(-) create mode 100644 examples/resizable_layout_examples/kibana.jsonc create mode 100644 examples/resizable_layout_examples/public/application.tsx create mode 100644 examples/resizable_layout_examples/public/index.ts create mode 100644 examples/resizable_layout_examples/public/plugin.tsx create mode 100644 examples/resizable_layout_examples/public/resizable_layout_examples.png create mode 100644 examples/resizable_layout_examples/tsconfig.json create mode 100644 packages/kbn-resizable-layout/README.md create mode 100644 packages/kbn-resizable-layout/index.ts rename src/plugins/unified_histogram/public/panels/index.ts => packages/kbn-resizable-layout/jest.config.js (74%) create mode 100644 packages/kbn-resizable-layout/kibana.jsonc create mode 100644 packages/kbn-resizable-layout/package.json create mode 100644 packages/kbn-resizable-layout/src/panels_resizable.test.tsx create mode 100644 packages/kbn-resizable-layout/src/panels_resizable.tsx create mode 100644 packages/kbn-resizable-layout/src/panels_static.test.tsx rename src/plugins/unified_histogram/public/panels/panels_fixed.tsx => packages/kbn-resizable-layout/src/panels_static.tsx (68%) create mode 100644 packages/kbn-resizable-layout/src/resizable_layout.test.tsx create mode 100644 packages/kbn-resizable-layout/src/resizable_layout.tsx create mode 100644 packages/kbn-resizable-layout/src/utils.test.ts create mode 100644 packages/kbn-resizable-layout/src/utils.ts create mode 100644 packages/kbn-resizable-layout/tsconfig.json create mode 100644 packages/kbn-resizable-layout/types.ts create mode 100644 src/plugins/discover/public/application/main/components/layout/discover_resizable_layout.test.tsx create mode 100644 src/plugins/discover/public/application/main/components/layout/discover_resizable_layout.tsx delete mode 100644 src/plugins/unified_histogram/public/panels/panels.test.tsx delete mode 100644 src/plugins/unified_histogram/public/panels/panels.tsx delete mode 100644 src/plugins/unified_histogram/public/panels/panels_fixed.test.tsx delete mode 100644 src/plugins/unified_histogram/public/panels/panels_resizable.test.tsx delete mode 100644 src/plugins/unified_histogram/public/panels/panels_resizable.tsx 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 0000000000000000000000000000000000000000..b89eca91d838e7a41ba2f0fe29935f76e7dc9de0 GIT binary patch literal 30405 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 b-;$I&Q_$Xyac6G7d+DmdHGRxw*N6WPKvZ*O literal 0 HcmV?d00001 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 ""