mirror of
https://github.com/elastic/kibana.git
synced 2025-06-27 10:40:07 -04:00
[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.

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>
This commit is contained in:
parent
272219ca49
commit
3e1865513d
51 changed files with 1822 additions and 789 deletions
2
.github/CODEOWNERS
vendored
2
.github/CODEOWNERS
vendored
|
@ -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
|
||||
|
|
11
examples/resizable_layout_examples/kibana.jsonc
Normal file
11
examples/resizable_layout_examples/kibana.jsonc
Normal file
|
@ -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"]
|
||||
}
|
||||
}
|
161
examples/resizable_layout_examples/public/application.tsx
Normal file
161
examples/resizable_layout_examples/public/application.tsx
Normal file
|
@ -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<HTMLDivElement | null>(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 (
|
||||
<div ref={setContainer} css={fullWidthAndHeightCss}>
|
||||
<InPortal node={fixedPanelNode}>
|
||||
<div css={fixedPanelCss}>{fixedPanelContent}</div>
|
||||
</InPortal>
|
||||
<InPortal node={flexPanelNode}>
|
||||
<div css={flexPanelCss}>{flexPanelContent}</div>
|
||||
</InPortal>
|
||||
<ResizableLayout
|
||||
mode={layoutMode}
|
||||
direction={layoutDirection}
|
||||
container={container}
|
||||
fixedPanelSize={fixedPanelSize}
|
||||
minFixedPanelSize={minFixedPanelSize}
|
||||
minFlexPanelSize={minFlexPanelSize}
|
||||
fixedPanel={<OutPortal node={fixedPanelNode} />}
|
||||
flexPanel={<OutPortal node={flexPanelNode} />}
|
||||
onFixedPanelSizeChange={setFixedPanelSize}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const renderApp = ({ element, theme$ }: AppMountParameters) => {
|
||||
ReactDOM.render(
|
||||
<I18nProvider>
|
||||
<CoreThemeProvider theme$={theme$}>
|
||||
<div
|
||||
css={css`
|
||||
height: calc(100vh - var(--euiFixedHeadersOffset, 0));
|
||||
`}
|
||||
>
|
||||
<ResizableSection
|
||||
direction={ResizableLayoutDirection.Horizontal}
|
||||
initialFixedPanelSize={500}
|
||||
minFixedPanelSize={300}
|
||||
minFlexPanelSize={500}
|
||||
fixedPanelColor="#16E0BD"
|
||||
flexPanelColor="#89A6FB"
|
||||
fixedPanelContent={
|
||||
<ResizableSection
|
||||
direction={ResizableLayoutDirection.Vertical}
|
||||
initialFixedPanelSize={200}
|
||||
minFixedPanelSize={100}
|
||||
minFlexPanelSize={200}
|
||||
fixedPanelColor="#E3655B"
|
||||
flexPanelColor="#FDCA40"
|
||||
fixedPanelContent="Sidebar Header"
|
||||
flexPanelContent="Sidebar Body"
|
||||
/>
|
||||
}
|
||||
flexPanelContent={
|
||||
<ResizableSection
|
||||
direction={ResizableLayoutDirection.Vertical}
|
||||
initialFixedPanelSize={300}
|
||||
minFixedPanelSize={200}
|
||||
minFlexPanelSize={300}
|
||||
fixedPanelColor="#FFA0AC"
|
||||
flexPanelColor="#F6F740"
|
||||
fixedPanelContent="Main Body Header"
|
||||
flexPanelContent={
|
||||
<ResizableSection
|
||||
direction={ResizableLayoutDirection.Horizontal}
|
||||
initialFixedPanelSize={400}
|
||||
minFixedPanelSize={200}
|
||||
minFlexPanelSize={200}
|
||||
fixedPanelColor="#78C3FB"
|
||||
flexPanelColor="#EF709D"
|
||||
fixedPanelContent="Main Body Left"
|
||||
flexPanelContent="Main Body Right"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</CoreThemeProvider>
|
||||
</I18nProvider>,
|
||||
element
|
||||
);
|
||||
|
||||
return () => {
|
||||
ReactDOM.unmountComponentAtNode(element);
|
||||
};
|
||||
};
|
13
examples/resizable_layout_examples/public/index.ts
Normal file
13
examples/resizable_layout_examples/public/index.ts
Normal file
|
@ -0,0 +1,13 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { ResizableLayoutExamplesPlugin } from './plugin';
|
||||
|
||||
export function plugin() {
|
||||
return new ResizableLayoutExamplesPlugin();
|
||||
}
|
44
examples/resizable_layout_examples/public/plugin.tsx
Normal file
44
examples/resizable_layout_examples/public/plugin.tsx
Normal file
|
@ -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() {}
|
||||
}
|
Binary file not shown.
After Width: | Height: | Size: 30 KiB |
15
examples/resizable_layout_examples/tsconfig.json
Normal file
15
examples/resizable_layout_examples/tsconfig.json
Normal file
|
@ -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/**/*"]
|
||||
}
|
|
@ -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",
|
||||
|
|
85
packages/kbn-resizable-layout/README.md
Normal file
85
packages/kbn-resizable-layout/README.md
Normal file
|
@ -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<HTMLDivElement | null>(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 (
|
||||
<div ref={setContainer} css={fullWidthAndHeightCss}>
|
||||
<InPortal node={fixedPanelNode}>
|
||||
<div css={fixedPanelCss}>
|
||||
This is the fixed width panel. It will remain the same size when resizing the window until
|
||||
the flexible panel reaches its minimum size.
|
||||
</div>
|
||||
</InPortal>
|
||||
<InPortal node={flexPanelNode}>
|
||||
<div css={flexPanelCss}>
|
||||
This is the flexible width panel. It will resize as the window resizes until it reaches
|
||||
its minimum size.
|
||||
</div>
|
||||
</InPortal>
|
||||
<ResizableLayout
|
||||
mode={layoutMode}
|
||||
direction={layoutDirection}
|
||||
container={container}
|
||||
fixedPanelSize={fixedPanelSize}
|
||||
minFixedPanelSize={300}
|
||||
minFlexPanelSize={500}
|
||||
fixedPanel={<OutPortal node={fixedPanelNode} />}
|
||||
flexPanel={<OutPortal node={flexPanelNode} />}
|
||||
onFixedPanelSizeChange={setFixedPanelSize}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
```
|
14
packages/kbn-resizable-layout/index.ts
Normal file
14
packages/kbn-resizable-layout/index.ts
Normal file
|
@ -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')));
|
|
@ -6,4 +6,8 @@
|
|||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
export { Panels, PANELS_MODE } from './panels';
|
||||
module.exports = {
|
||||
preset: '@kbn/test',
|
||||
rootDir: '../..',
|
||||
roots: ['<rootDir>/packages/kbn-resizable-layout'],
|
||||
};
|
6
packages/kbn-resizable-layout/kibana.jsonc
Normal file
6
packages/kbn-resizable-layout/kibana.jsonc
Normal file
|
@ -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"
|
||||
}
|
6
packages/kbn-resizable-layout/package.json
Normal file
6
packages/kbn-resizable-layout/package.json
Normal file
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"name": "@kbn/resizable-layout",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"license": "SSPL-1.0 OR Elastic License 2.0"
|
||||
}
|
246
packages/kbn-resizable-layout/src/panels_resizable.test.tsx
Normal file
246
packages/kbn-resizable-layout/src/panels_resizable.test.tsx
Normal file
|
@ -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 (
|
||||
<PanelsResizable
|
||||
className={className}
|
||||
direction={direction}
|
||||
container={container}
|
||||
fixedPanelSize={fixedPanelSize ?? initialFixedPanelSize}
|
||||
minFixedPanelSize={minFixedPanelSize}
|
||||
minFlexPanelSize={minFlexPanelSize}
|
||||
panelSizes={panelSizes}
|
||||
fixedPanel={fixedPanel}
|
||||
flexPanel={flexPanel}
|
||||
onFixedPanelSizeChange={onFixedPanelSizeChange}
|
||||
setPanelSizes={setPanelSizes}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return mount(<PanelsWrapper />, 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 = <div data-test-subj="fixedPanel" />;
|
||||
const flexPanel = <div data-test-subj="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);
|
||||
});
|
||||
});
|
228
packages/kbn-resizable-layout/src/panels_resizable.tsx
Normal file
228
packages/kbn-resizable-layout/src/panels_resizable.tsx
Normal file
|
@ -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 (
|
||||
<EuiResizableContainer
|
||||
className={className}
|
||||
direction={direction}
|
||||
onPanelWidthChange={onPanelSizeChange}
|
||||
onResizeStart={onResizeStart}
|
||||
onResizeEnd={onResizeEnd}
|
||||
data-test-subj={`${dataTestSubj}ResizableContainer`}
|
||||
css={css`
|
||||
height: 100%;
|
||||
`}
|
||||
>
|
||||
{(EuiResizablePanel, EuiResizableButton) => (
|
||||
<>
|
||||
<EuiResizablePanel
|
||||
id={fixedPanelId}
|
||||
minSize={`${minFixedPanelSize}px`}
|
||||
size={panelSizes.fixedPanelSizePct}
|
||||
paddingSize="none"
|
||||
data-test-subj={`${dataTestSubj}ResizablePanelFixed`}
|
||||
>
|
||||
{fixedPanel}
|
||||
</EuiResizablePanel>
|
||||
<EuiResizableButton
|
||||
className={resizeButtonClassName}
|
||||
css={
|
||||
resizeWithPortalsHackIsResizing ? resizeWithPortalsHackButtonCss : defaultButtonCss
|
||||
}
|
||||
data-test-subj={`${dataTestSubj}ResizableButton`}
|
||||
/>
|
||||
<EuiResizablePanel
|
||||
minSize={`${minFlexPanelSize}px`}
|
||||
size={panelSizes.flexPanelSizePct}
|
||||
paddingSize="none"
|
||||
data-test-subj={`${dataTestSubj}ResizablePanelFlex`}
|
||||
>
|
||||
{flexPanel}
|
||||
</EuiResizablePanel>
|
||||
{resizeWithPortalsHackIsResizing ? <div css={resizeWithPortalsHackOverlayCss} /> : <></>}
|
||||
</>
|
||||
)}
|
||||
</EuiResizableContainer>
|
||||
);
|
||||
};
|
71
packages/kbn-resizable-layout/src/panels_static.test.tsx
Normal file
71
packages/kbn-resizable-layout/src/panels_static.test.tsx
Normal file
|
@ -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(
|
||||
<PanelsStatic
|
||||
direction={direction}
|
||||
hideFixedPanel={hideFixedPanel}
|
||||
fixedPanel={fixedPanel}
|
||||
flexPanel={flexPanel}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
it('should render both panels when hideFixedPanel is false', () => {
|
||||
const fixedPanel = <div data-test-subj="fixedPanel" />;
|
||||
const flexPanel = <div data-test-subj="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 = <div data-test-subj="fixedPanel" />;
|
||||
const flexPanel = <div data-test-subj="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');
|
||||
});
|
||||
});
|
|
@ -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 (
|
||||
<EuiFlexGroup
|
||||
className={className}
|
||||
direction="column"
|
||||
direction={direction === ResizableLayoutDirection.Vertical ? 'column' : 'row'}
|
||||
alignItems="stretch"
|
||||
gutterSize="none"
|
||||
responsive={false}
|
||||
css={css`
|
||||
height: 100%;
|
||||
`}
|
||||
>
|
||||
{!hideTopPanel && <EuiFlexItem grow={false}>{topPanel}</EuiFlexItem>}
|
||||
<EuiFlexItem css={mainPanelCss}>{mainPanel}</EuiFlexItem>
|
||||
{!hideFixedPanel && <EuiFlexItem grow={false}>{fixedPanel}</EuiFlexItem>}
|
||||
<EuiFlexItem css={flexPanelCss}>{flexPanel}</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
);
|
||||
};
|
106
packages/kbn-resizable-layout/src/resizable_layout.test.tsx
Normal file
106
packages/kbn-resizable-layout/src/resizable_layout.test.tsx
Normal file
|
@ -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(
|
||||
<ResizableLayout
|
||||
mode={mode}
|
||||
direction={ResizableLayoutDirection.Vertical}
|
||||
container={container}
|
||||
fixedPanelSize={initialFixedPanelSize}
|
||||
minFixedPanelSize={minFixedPanelSize}
|
||||
minFlexPanelSize={minFlexPanelSize}
|
||||
fixedPanel={fixedPanel}
|
||||
flexPanel={flexPanel}
|
||||
onFixedPanelSizeChange={jest.fn()}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
it('should show PanelsFixed when mode is ResizableLayoutMode.Single', () => {
|
||||
const fixedPanel = <div data-test-subj="fixedPanel" />;
|
||||
const flexPanel = <div data-test-subj="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 = <div data-test-subj="fixedPanel" />;
|
||||
const flexPanel = <div data-test-subj="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 = <div data-test-subj="fixedPanel" />;
|
||||
const flexPanel = <div data-test-subj="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 = <div data-test-subj="fixedPanel" />;
|
||||
const flexPanel = <div data-test-subj="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 = <div data-test-subj="fixedPanel" />;
|
||||
const flexPanel = <div data-test-subj="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);
|
||||
});
|
||||
});
|
130
packages/kbn-resizable-layout/src/resizable_layout.tsx
Normal file
130
packages/kbn-resizable-layout/src/resizable_layout.tsx
Normal file
|
@ -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) ? (
|
||||
<PanelsStatic
|
||||
direction={direction}
|
||||
hideFixedPanel={mode === ResizableLayoutMode.Single}
|
||||
{...panelsProps}
|
||||
/>
|
||||
) : (
|
||||
<PanelsResizable
|
||||
direction={direction}
|
||||
container={container}
|
||||
fixedPanelSize={fixedPanelSize}
|
||||
minFixedPanelSize={minFixedPanelSize}
|
||||
minFlexPanelSize={minFlexPanelSize}
|
||||
panelSizes={panelSizes}
|
||||
resizeButtonClassName={resizeButtonClassName}
|
||||
data-test-subj={dataTestSubj}
|
||||
onFixedPanelSizeChange={onFixedPanelSizeChange}
|
||||
setPanelSizes={setPanelSizes}
|
||||
{...panelsProps}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
export default ResizableLayout;
|
40
packages/kbn-resizable-layout/src/utils.test.ts
Normal file
40
packages/kbn-resizable-layout/src/utils.test.ts
Normal file
|
@ -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);
|
||||
});
|
||||
});
|
22
packages/kbn-resizable-layout/src/utils.ts
Normal file
22
packages/kbn-resizable-layout/src/utils.ts
Normal file
|
@ -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);
|
11
packages/kbn-resizable-layout/tsconfig.json
Normal file
11
packages/kbn-resizable-layout/tsconfig.json
Normal file
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "target/types"
|
||||
},
|
||||
"include": ["**/*.ts", "**/*.tsx"],
|
||||
"exclude": ["target/**/*"],
|
||||
"kbn_references": [
|
||||
"@kbn/shared-ux-utility",
|
||||
]
|
||||
}
|
33
packages/kbn-resizable-layout/types.ts
Normal file
33
packages/kbn-resizable-layout/types.ts
Normal file
|
@ -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',
|
||||
}
|
|
@ -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;
|
||||
|
|
|
@ -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<UnifiedFieldListSidebarP
|
|||
trackUiMetric,
|
||||
showFieldList = true,
|
||||
compressed = true,
|
||||
fullWidth,
|
||||
isAffectedByGlobalFilter,
|
||||
prepend,
|
||||
onAddFieldToWorkspace,
|
||||
|
@ -306,6 +312,7 @@ export const UnifiedFieldListSidebarComponent: React.FC<UnifiedFieldListSidebarP
|
|||
const pageSidebarProps: Partial<EuiPageSidebarProps> = {
|
||||
className: classnames('unifiedFieldListSidebar', {
|
||||
'unifiedFieldListSidebar--collapsed': isSidebarCollapsed,
|
||||
['unifiedFieldListSidebar--fullWidth']: fullWidth,
|
||||
}),
|
||||
'aria-label': i18n.translate(
|
||||
'unifiedFieldList.fieldListSidebar.indexAndFieldsSectionAriaLabel',
|
||||
|
|
|
@ -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<boolean>;
|
||||
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<boolean>(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) {
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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<HTMLDivElement>;
|
||||
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 ? (
|
||||
<ResetSearchButton resetSavedSearch={stateContainer.actions.undoSavedSearchChanges} />
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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());
|
||||
|
|
|
@ -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<HTMLDivElement>(null);
|
||||
const [sidebarContainer, setSidebarContainer] = useState<HTMLDivElement | null>(null);
|
||||
const [mainContainer, setMainContainer] = useState<HTMLDivElement | null>(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' && <LoadingSpinner />}
|
||||
|
@ -221,14 +224,18 @@ export function DiscoverLayout({ stateContainer }: DiscoverLayoutProps) {
|
|||
currentColumns,
|
||||
dataView,
|
||||
isPlainRecord,
|
||||
mainContainer,
|
||||
onAddFilter,
|
||||
onDropFieldToTable,
|
||||
onFieldEdited,
|
||||
resultState,
|
||||
stateContainer,
|
||||
viewMode,
|
||||
onDropFieldToTable,
|
||||
]);
|
||||
|
||||
const [unifiedFieldListSidebarContainerApi, setUnifiedFieldListSidebarContainerApi] =
|
||||
useState<UnifiedFieldListSidebarContainerApi | null>(null);
|
||||
|
||||
return (
|
||||
<EuiPage
|
||||
className="dscPage"
|
||||
|
@ -265,13 +272,29 @@ export function DiscoverLayout({ stateContainer }: DiscoverLayoutProps) {
|
|||
onFieldEdited={onFieldEdited}
|
||||
/>
|
||||
<EuiPageBody className="dscPageBody" aria-describedby="savedSearchTitle">
|
||||
<div
|
||||
ref={setSidebarContainer}
|
||||
css={css`
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
`}
|
||||
>
|
||||
<SavedSearchURLConflictCallout
|
||||
savedSearch={savedSearch}
|
||||
spaces={spaces}
|
||||
history={history}
|
||||
/>
|
||||
<EuiFlexGroup className="dscPageBody__contents" gutterSize="none">
|
||||
<EuiFlexItem grow={false}>
|
||||
<DiscoverResizableLayout
|
||||
container={sidebarContainer}
|
||||
unifiedFieldListSidebarContainerApi={unifiedFieldListSidebarContainerApi}
|
||||
sidebarPanel={
|
||||
<EuiFlexGroup
|
||||
gutterSize="none"
|
||||
css={css`
|
||||
height: 100%;
|
||||
`}
|
||||
>
|
||||
<EuiFlexItem>
|
||||
<SidebarMemoized
|
||||
documents$={stateContainer.dataState.data$.documents$}
|
||||
onAddField={onAddColumn}
|
||||
|
@ -284,6 +307,8 @@ export function DiscoverLayout({ stateContainer }: DiscoverLayoutProps) {
|
|||
onFieldEdited={onFieldEdited}
|
||||
onDataViewCreated={stateContainer.actions.onDataViewCreated}
|
||||
availableFields$={stateContainer.dataState.data$.availableFields$}
|
||||
unifiedFieldListSidebarContainerApi={unifiedFieldListSidebarContainerApi}
|
||||
setUnifiedFieldListSidebarContainerApi={setUnifiedFieldListSidebarContainerApi}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
<EuiHideFor sizes={['xs', 's']}>
|
||||
|
@ -294,13 +319,19 @@ export function DiscoverLayout({ stateContainer }: DiscoverLayoutProps) {
|
|||
`}
|
||||
/>
|
||||
</EuiHideFor>
|
||||
<EuiFlexItem className="dscPageContent__wrapper">
|
||||
</EuiFlexGroup>
|
||||
}
|
||||
mainPanel={
|
||||
<div className="dscPageContent__wrapper">
|
||||
{resultState === 'none' ? (
|
||||
dataState.error ? (
|
||||
<ErrorCallout
|
||||
title={i18n.translate('discover.noResults.searchExamples.noResultsErrorTitle', {
|
||||
title={i18n.translate(
|
||||
'discover.noResults.searchExamples.noResultsErrorTitle',
|
||||
{
|
||||
defaultMessage: 'Unable to retrieve search results',
|
||||
})}
|
||||
}
|
||||
)}
|
||||
error={dataState.error}
|
||||
data-test-subj="discoverNoResultsError"
|
||||
/>
|
||||
|
@ -317,7 +348,7 @@ export function DiscoverLayout({ stateContainer }: DiscoverLayoutProps) {
|
|||
) : (
|
||||
<EuiPanel
|
||||
role="main"
|
||||
panelRef={resizeRef}
|
||||
panelRef={setMainContainer}
|
||||
paddingSize="none"
|
||||
borderRadius="none"
|
||||
hasShadow={false}
|
||||
|
@ -330,8 +361,10 @@ export function DiscoverLayout({ stateContainer }: DiscoverLayoutProps) {
|
|||
{mainDisplay}
|
||||
</EuiPanel>
|
||||
)}
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</EuiPageBody>
|
||||
</EuiPage>
|
||||
);
|
||||
|
|
|
@ -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(
|
||||
<DiscoverResizableLayout
|
||||
container={null}
|
||||
unifiedFieldListSidebarContainerApi={null}
|
||||
sidebarPanel={<div data-test-subj="sidebarPanel" />}
|
||||
mainPanel={<div data-test-subj="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(
|
||||
<DiscoverResizableLayout
|
||||
container={null}
|
||||
unifiedFieldListSidebarContainerApi={null}
|
||||
sidebarPanel={<div data-test-subj="sidebarPanel" />}
|
||||
mainPanel={<div data-test-subj="mainPanel" />}
|
||||
/>
|
||||
);
|
||||
expect(wrapper.find(ResizableLayout).prop('fixedPanelSize')).toBe(304);
|
||||
});
|
||||
|
||||
it('should use the stored sidebar width from local storage', () => {
|
||||
mockSidebarWidth = 400;
|
||||
const wrapper = mount(
|
||||
<DiscoverResizableLayout
|
||||
container={null}
|
||||
unifiedFieldListSidebarContainerApi={null}
|
||||
sidebarPanel={<div data-test-subj="sidebarPanel" />}
|
||||
mainPanel={<div data-test-subj="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(
|
||||
<DiscoverResizableLayout
|
||||
container={null}
|
||||
unifiedFieldListSidebarContainerApi={
|
||||
{ isSidebarCollapsed$: of(false) } as UnifiedFieldListSidebarContainerApi
|
||||
}
|
||||
sidebarPanel={<div data-test-subj="sidebarPanel" />}
|
||||
mainPanel={<div data-test-subj="mainPanel" />}
|
||||
/>
|
||||
);
|
||||
expect(wrapper.find(ResizableLayout).prop('mode')).toBe(ResizableLayoutMode.Resizable);
|
||||
});
|
||||
|
||||
it('should pass mode ResizableLayoutMode.Static when mobile', () => {
|
||||
mockIsMobile = true;
|
||||
const wrapper = mount(
|
||||
<DiscoverResizableLayout
|
||||
container={null}
|
||||
unifiedFieldListSidebarContainerApi={
|
||||
{ isSidebarCollapsed$: of(false) } as UnifiedFieldListSidebarContainerApi
|
||||
}
|
||||
sidebarPanel={<div data-test-subj="sidebarPanel" />}
|
||||
mainPanel={<div data-test-subj="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(
|
||||
<DiscoverResizableLayout
|
||||
container={null}
|
||||
unifiedFieldListSidebarContainerApi={
|
||||
{ isSidebarCollapsed$: of(true) } as UnifiedFieldListSidebarContainerApi
|
||||
}
|
||||
sidebarPanel={<div data-test-subj="sidebarPanel" />}
|
||||
mainPanel={<div data-test-subj="mainPanel" />}
|
||||
/>
|
||||
);
|
||||
expect(wrapper.find(ResizableLayout).prop('mode')).toBe(ResizableLayoutMode.Static);
|
||||
});
|
||||
|
||||
it('should pass direction ResizableLayoutDirection.Horizontal when not mobile', () => {
|
||||
mockIsMobile = false;
|
||||
const wrapper = mount(
|
||||
<DiscoverResizableLayout
|
||||
container={null}
|
||||
unifiedFieldListSidebarContainerApi={
|
||||
{ isSidebarCollapsed$: of(false) } as UnifiedFieldListSidebarContainerApi
|
||||
}
|
||||
sidebarPanel={<div data-test-subj="sidebarPanel" />}
|
||||
mainPanel={<div data-test-subj="mainPanel" />}
|
||||
/>
|
||||
);
|
||||
expect(wrapper.find(ResizableLayout).prop('direction')).toBe(
|
||||
ResizableLayoutDirection.Horizontal
|
||||
);
|
||||
});
|
||||
|
||||
it('should pass direction ResizableLayoutDirection.Vertical when mobile', () => {
|
||||
mockIsMobile = true;
|
||||
const wrapper = mount(
|
||||
<DiscoverResizableLayout
|
||||
container={null}
|
||||
unifiedFieldListSidebarContainerApi={
|
||||
{ isSidebarCollapsed$: of(false) } as UnifiedFieldListSidebarContainerApi
|
||||
}
|
||||
sidebarPanel={<div data-test-subj="sidebarPanel" />}
|
||||
mainPanel={<div data-test-subj="mainPanel" />}
|
||||
/>
|
||||
);
|
||||
expect(wrapper.find(ResizableLayout).prop('direction')).toBe(ResizableLayoutDirection.Vertical);
|
||||
});
|
||||
});
|
|
@ -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 (
|
||||
<>
|
||||
<InPortal node={sidebarPanelNode}>{sidebarPanel}</InPortal>
|
||||
<InPortal node={mainPanelNode}>{mainPanel}</InPortal>
|
||||
<ResizableLayout
|
||||
className="dscPageBody__contents"
|
||||
mode={layoutMode}
|
||||
direction={layoutDirection}
|
||||
container={container}
|
||||
fixedPanelSize={sidebarWidth ?? defaultSidebarWidth}
|
||||
minFixedPanelSize={minSidebarWidth}
|
||||
minFlexPanelSize={minMainPanelWidth}
|
||||
fixedPanel={<OutPortal node={sidebarPanelNode} />}
|
||||
flexPanel={<OutPortal node={mainPanelNode} />}
|
||||
resizeButtonClassName="dscSidebarResizeButton"
|
||||
data-test-subj="discoverLayout"
|
||||
onFixedPanelSizeChange={setSidebarWidth}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -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<UnifiedFieldListSidebarContainerApi | null>(null);
|
||||
return (
|
||||
<DiscoverSidebarResponsive
|
||||
{...props}
|
||||
unifiedFieldListSidebarContainerApi={api}
|
||||
setUnifiedFieldListSidebarContainerApi={setApi}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
comp = mountWithIntl(
|
||||
<KibanaContextProvider services={mockedServices}>
|
||||
<DiscoverAppStateProvider value={appState}>
|
||||
<DiscoverSidebarResponsive {...props} />
|
||||
<SidebarWrapper />
|
||||
</DiscoverAppStateProvider>
|
||||
</KibanaContextProvider>
|
||||
);
|
||||
// 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);
|
||||
});
|
||||
|
|
|
@ -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<DataView | null | undefined>(selectedDataView);
|
||||
const showFieldList = sidebarState.status !== DiscoverSidebarReducerStatus.INITIAL;
|
||||
const [unifiedFieldListSidebarContainerApi, setUnifiedFieldListSidebarContainerApi] =
|
||||
useState<UnifiedFieldListSidebarContainerApi | null>(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}
|
||||
|
|
|
@ -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/**/*"
|
||||
|
|
|
@ -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)));
|
||||
|
|
|
@ -53,7 +53,7 @@ export type UnifiedHistogramContainerProps = {
|
|||
| 'timeRange'
|
||||
| 'relativeTimeRange'
|
||||
| 'columns'
|
||||
| 'resizeRef'
|
||||
| 'container'
|
||||
| 'appendHitsCounter'
|
||||
| 'children'
|
||||
| 'onBrushEnd'
|
||||
|
|
|
@ -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<Omit<UnifiedHistogramLayoutProps, 'hits' | 'chart'>> & {
|
||||
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 () => {
|
||||
|
|
|
@ -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<unknown>
|
|||
*/
|
||||
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<HTMLDivElement>;
|
||||
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 = ({
|
|||
/>
|
||||
</InPortal>
|
||||
<InPortal node={mainPanelNode}>{children}</InPortal>
|
||||
<Panels
|
||||
<ResizableLayout
|
||||
className={className}
|
||||
mode={panelsMode}
|
||||
resizeRef={resizeRef}
|
||||
topPanelHeight={currentTopPanelHeight}
|
||||
minTopPanelHeight={defaultTopPanelHeight}
|
||||
minMainPanelHeight={minMainPanelHeight}
|
||||
topPanel={<OutPortal node={topPanelNode} />}
|
||||
mainPanel={<OutPortal node={mainPanelNode} />}
|
||||
onTopPanelHeightChange={onTopPanelHeightChange}
|
||||
direction={ResizableLayoutDirection.Vertical}
|
||||
container={container}
|
||||
fixedPanelSize={currentTopPanelHeight}
|
||||
minFixedPanelSize={defaultTopPanelHeight}
|
||||
minFlexPanelSize={minMainPanelHeight}
|
||||
fixedPanel={<OutPortal node={topPanelNode} />}
|
||||
flexPanel={<OutPortal node={mainPanelNode} />}
|
||||
data-test-subj="unifiedHistogram"
|
||||
onFixedPanelSizeChange={onTopPanelHeightChange}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
|
|
@ -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<HTMLDivElement>;
|
||||
initialTopPanelHeight?: number;
|
||||
minTopPanelHeight?: number;
|
||||
minMainPanelHeight?: number;
|
||||
mainPanel?: ReactElement;
|
||||
topPanel?: ReactElement;
|
||||
}) => {
|
||||
return mount(
|
||||
<Panels
|
||||
mode={mode}
|
||||
resizeRef={resizeRef}
|
||||
topPanelHeight={initialTopPanelHeight}
|
||||
minTopPanelHeight={minTopPanelHeight}
|
||||
minMainPanelHeight={minMainPanelHeight}
|
||||
topPanel={topPanel}
|
||||
mainPanel={mainPanel}
|
||||
onTopPanelHeightChange={jest.fn()}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
it('should show PanelsFixed when mode is PANELS_MODE.SINGLE', () => {
|
||||
const topPanel = <div data-test-subj="topPanel" />;
|
||||
const mainPanel = <div data-test-subj="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 = <div data-test-subj="topPanel" />;
|
||||
const mainPanel = <div data-test-subj="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 = <div data-test-subj="topPanel" />;
|
||||
const mainPanel = <div data-test-subj="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 = <div data-test-subj="topPanel" />;
|
||||
const mainPanel = <div data-test-subj="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 = <div data-test-subj="topPanel" />;
|
||||
const mainPanel = <div data-test-subj="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);
|
||||
});
|
||||
});
|
|
@ -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<HTMLDivElement>;
|
||||
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) ? (
|
||||
<PanelsFixed hideTopPanel={mode === PANELS_MODE.SINGLE} {...panelsProps} />
|
||||
) : (
|
||||
<PanelsResizable
|
||||
resizeRef={resizeRef}
|
||||
topPanelHeight={topPanelHeight}
|
||||
minTopPanelHeight={minTopPanelHeight}
|
||||
minMainPanelHeight={minMainPanelHeight}
|
||||
onTopPanelHeightChange={onTopPanelHeightChange}
|
||||
{...panelsProps}
|
||||
/>
|
||||
);
|
||||
};
|
|
@ -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(
|
||||
<PanelsFixed hideTopPanel={hideTopPanel} topPanel={topPanel} mainPanel={mainPanel} />
|
||||
);
|
||||
};
|
||||
|
||||
it('should render both panels when hideTopPanel is false', () => {
|
||||
const topPanel = <div data-test-subj="topPanel" />;
|
||||
const mainPanel = <div data-test-subj="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 = <div data-test-subj="topPanel" />;
|
||||
const mainPanel = <div data-test-subj="mainPanel" />;
|
||||
const component = mountComponent({ hideTopPanel: true, topPanel, mainPanel });
|
||||
expect(component.contains(topPanel)).toBe(false);
|
||||
expect(component.contains(mainPanel)).toBe(true);
|
||||
});
|
||||
});
|
|
@ -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<HTMLDivElement>;
|
||||
initialTopPanelHeight?: number;
|
||||
minTopPanelHeight?: number;
|
||||
minMainPanelHeight?: number;
|
||||
topPanel?: ReactElement;
|
||||
mainPanel?: ReactElement;
|
||||
attachTo?: HTMLElement;
|
||||
onTopPanelHeightChange?: (topPanelHeight: number) => void;
|
||||
}) => {
|
||||
return mount(
|
||||
<PanelsResizable
|
||||
className={className}
|
||||
resizeRef={resizeRef}
|
||||
topPanelHeight={initialTopPanelHeight}
|
||||
minTopPanelHeight={minTopPanelHeight}
|
||||
minMainPanelHeight={minMainPanelHeight}
|
||||
topPanel={topPanel}
|
||||
mainPanel={mainPanel}
|
||||
onTopPanelHeightChange={onTopPanelHeightChange}
|
||||
/>,
|
||||
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 = <div data-test-subj="topPanel" />;
|
||||
const mainPanel = <div data-test-subj="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();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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<HTMLDivElement>;
|
||||
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 (
|
||||
<EuiResizableContainer
|
||||
className={className}
|
||||
direction="vertical"
|
||||
onPanelWidthChange={onPanelSizeChange}
|
||||
onResizeStart={onResizeStart}
|
||||
onResizeEnd={onResizeEnd}
|
||||
data-test-subj="unifiedHistogramResizableContainer"
|
||||
>
|
||||
{(EuiResizablePanel, EuiResizableButton) => (
|
||||
<>
|
||||
<EuiResizablePanel
|
||||
id={topPanelId}
|
||||
minSize={`${minTopPanelHeight}px`}
|
||||
size={panelSizes.topPanelSize}
|
||||
paddingSize="none"
|
||||
data-test-subj="unifiedHistogramResizablePanelTop"
|
||||
>
|
||||
{topPanel}
|
||||
</EuiResizablePanel>
|
||||
<EuiResizableButton
|
||||
css={resizeWithPortalsHackButtonCss}
|
||||
data-test-subj="unifiedHistogramResizableButton"
|
||||
/>
|
||||
<EuiResizablePanel
|
||||
minSize={`${minMainPanelHeight}px`}
|
||||
size={panelSizes.mainPanelSize}
|
||||
paddingSize="none"
|
||||
data-test-subj="unifiedHistogramResizablePanelMain"
|
||||
>
|
||||
{mainPanel}
|
||||
</EuiResizablePanel>
|
||||
{resizeWithPortalsHackIsResizing ? <div css={resizeWithPortalsHackOverlayCss} /> : <></>}
|
||||
</>
|
||||
)}
|
||||
</EuiResizableContainer>
|
||||
);
|
||||
};
|
|
@ -25,6 +25,7 @@
|
|||
"@kbn/kibana-utils-plugin",
|
||||
"@kbn/visualizations-plugin",
|
||||
"@kbn/discover-utils",
|
||||
"@kbn/resizable-layout",
|
||||
],
|
||||
"exclude": [
|
||||
"target/**/*",
|
||||
|
|
|
@ -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', () => {
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
|
|
|
@ -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"],
|
||||
|
|
|
@ -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 ""
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue