From bcc42d5aa085ddd28910d974fac22532e50492ce Mon Sep 17 00:00:00 2001 From: Philippe Oberti Date: Thu, 19 Sep 2024 01:02:36 +0200 Subject: [PATCH] [kbn-expandable-flyout] - add support for resizable flyout (#192906) --- .../src/components/container.test.tsx | 180 +++++++++ .../src/components/container.tsx | 241 ++++++++++++ .../src/components/left_section.tsx | 24 +- .../src/components/preview_section.test.tsx | 11 +- .../src/components/preview_section.tsx | 28 +- .../components/resizable_container.test.tsx | 51 +++ .../src/components/resizable_container.tsx | 116 ++++++ .../src/components/right_section.tsx | 23 +- .../src/components/settings_menu.test.tsx | 362 ++++++++++++------ .../src/components/settings_menu.tsx | 55 ++- .../src/components/test_ids.ts | 15 + .../kbn-expandable-flyout/src/constants.ts | 3 + .../src/hooks/use_expandable_flyout_state.ts | 4 +- .../use_initialize_from_local_storage.test.ts | 55 ++- .../use_initialize_from_local_storage.ts | 52 ++- .../src/hooks/use_sections.test.tsx | 134 +++++++ .../src/hooks/use_sections.ts | 86 +++++ .../src/hooks/use_sections_sizes.test.ts | 251 ------------ .../src/hooks/use_sections_sizes.ts | 114 ------ .../src/hooks/use_window_size.test.ts | 18 - .../src/hooks/use_window_size.ts | 26 -- .../src/hooks/use_window_width.test.ts | 150 ++++++++ .../src/hooks/use_window_width.ts | 84 ++++ .../src/index.stories.tsx | 83 +++- .../kbn-expandable-flyout/src/index.test.tsx | 148 +------ packages/kbn-expandable-flyout/src/index.tsx | 125 +----- .../src/provider.test.tsx | 14 +- .../src/store/actions.ts | 63 +++ .../src/store/middlewares.test.ts | 205 ++++++++-- .../src/store/middlewares.ts | 106 ++++- .../src/store/reducers.test.ts | 238 ++++++++++++ .../src/store/reducers.ts | 34 ++ .../kbn-expandable-flyout/src/store/redux.ts | 20 +- .../kbn-expandable-flyout/src/store/state.ts | 64 ++++ .../src/test/provider.tsx | 12 +- 35 files changed, 2288 insertions(+), 907 deletions(-) create mode 100644 packages/kbn-expandable-flyout/src/components/container.test.tsx create mode 100644 packages/kbn-expandable-flyout/src/components/container.tsx create mode 100644 packages/kbn-expandable-flyout/src/components/resizable_container.test.tsx create mode 100644 packages/kbn-expandable-flyout/src/components/resizable_container.tsx create mode 100644 packages/kbn-expandable-flyout/src/hooks/use_sections.test.tsx create mode 100644 packages/kbn-expandable-flyout/src/hooks/use_sections.ts delete mode 100644 packages/kbn-expandable-flyout/src/hooks/use_sections_sizes.test.ts delete mode 100644 packages/kbn-expandable-flyout/src/hooks/use_sections_sizes.ts delete mode 100644 packages/kbn-expandable-flyout/src/hooks/use_window_size.test.ts delete mode 100644 packages/kbn-expandable-flyout/src/hooks/use_window_size.ts create mode 100644 packages/kbn-expandable-flyout/src/hooks/use_window_width.test.ts create mode 100644 packages/kbn-expandable-flyout/src/hooks/use_window_width.ts diff --git a/packages/kbn-expandable-flyout/src/components/container.test.tsx b/packages/kbn-expandable-flyout/src/components/container.test.tsx new file mode 100644 index 0000000000000..fa27d81fa4437 --- /dev/null +++ b/packages/kbn-expandable-flyout/src/components/container.test.tsx @@ -0,0 +1,180 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import React from 'react'; +import { render } from '@testing-library/react'; + +import { Panel } from '../types'; +import { + LEFT_SECTION_TEST_ID, + PREVIEW_SECTION_TEST_ID, + SETTINGS_MENU_BUTTON_TEST_ID, + RIGHT_SECTION_TEST_ID, +} from './test_ids'; +import { initialUiState, type State } from '../store/state'; +import { TestProvider } from '../test/provider'; +import { REDUX_ID_FOR_MEMORY_STORAGE } from '../constants'; +import { Container } from './container'; + +const id = REDUX_ID_FOR_MEMORY_STORAGE; +const registeredPanels: Panel[] = [ + { + key: 'key', + component: () =>
{'component'}
, + }, +]; + +describe('Container', () => { + it(`shouldn't render flyout if no panels`, () => { + const state: State = { + panels: { + byId: {}, + }, + ui: initialUiState, + }; + + const result = render( + + + + ); + + expect(result.asFragment()).toMatchInlineSnapshot(``); + }); + + it('should render collapsed flyout (right section)', () => { + const state: State = { + panels: { + byId: { + [id]: { + right: { + id: 'key', + }, + left: undefined, + preview: undefined, + }, + }, + }, + ui: initialUiState, + }; + + const { getByTestId } = render( + + + + ); + + expect(getByTestId(RIGHT_SECTION_TEST_ID)).toBeInTheDocument(); + }); + + it('should render expanded flyout (right and left sections)', () => { + const state: State = { + panels: { + byId: { + [id]: { + right: { + id: 'key', + }, + left: { + id: 'key', + }, + preview: undefined, + }, + }, + }, + ui: initialUiState, + }; + + const { getByTestId } = render( + + + + ); + + expect(getByTestId(LEFT_SECTION_TEST_ID)).toBeInTheDocument(); + }); + + it('should render preview section', () => { + const state: State = { + panels: { + byId: { + [id]: { + right: undefined, + left: undefined, + preview: [ + { + id: 'key', + }, + ], + }, + }, + }, + ui: initialUiState, + }; + + const { getByTestId } = render( + + + + ); + + expect(getByTestId(PREVIEW_SECTION_TEST_ID)).toBeInTheDocument(); + }); + + it('should not render flyout when right has value but does not matches registered panels', () => { + const state: State = { + panels: { + byId: { + [id]: { + right: { + id: 'key1', + }, + left: undefined, + preview: undefined, + }, + }, + }, + ui: initialUiState, + }; + + const { queryByTestId } = render( + + + + ); + + expect(queryByTestId('my-test-flyout')).toBeNull(); + expect(queryByTestId(RIGHT_SECTION_TEST_ID)).toBeNull(); + }); + + it('should render the menu to change display options', () => { + const state: State = { + panels: { + byId: { + [id]: { + right: { + id: 'key', + }, + left: undefined, + preview: undefined, + }, + }, + }, + ui: initialUiState, + }; + + const { getByTestId } = render( + + + + ); + + expect(getByTestId(SETTINGS_MENU_BUTTON_TEST_ID)).toBeInTheDocument(); + }); +}); diff --git a/packages/kbn-expandable-flyout/src/components/container.tsx b/packages/kbn-expandable-flyout/src/components/container.tsx new file mode 100644 index 0000000000000..9d858d08be23c --- /dev/null +++ b/packages/kbn-expandable-flyout/src/components/container.tsx @@ -0,0 +1,241 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import React, { memo, useCallback, useMemo } from 'react'; +import { Interpolation, Theme } from '@emotion/react'; +import { EuiFlyoutProps, EuiFlyoutResizable } from '@elastic/eui'; +import { EuiFlyoutResizableProps } from '@elastic/eui/src/components/flyout/flyout_resizable'; +import { changeUserCollapsedWidthAction, changeUserExpandedWidthAction } from '../store/actions'; +import { + selectDefaultWidths, + selectPushVsOverlay, + selectUserFlyoutWidths, + useDispatch, + useSelector, +} from '../store/redux'; +import { RightSection } from './right_section'; +import { useSections } from '../hooks/use_sections'; +import { useExpandableFlyoutState } from '../hooks/use_expandable_flyout_state'; +import { useExpandableFlyoutApi } from '../hooks/use_expandable_flyout_api'; +import type { FlyoutPanelProps, Panel } from '../types'; +import { SettingsMenu } from './settings_menu'; +import { PreviewSection } from './preview_section'; +import { ResizableContainer } from './resizable_container'; + +const COLLAPSED_FLYOUT_MIN_WIDTH = 380; +const EXPANDED_FLYOUT_MIN_WIDTH = 740; + +export interface ContainerProps extends Omit { + /** + * List of all registered panels available for render + */ + registeredPanels: Panel[]; + /** + * Allows for custom styles to be passed to the EuiFlyout component + */ + customStyles?: Interpolation; + /** + * Callback function to let application's code the flyout is closed + */ + onClose?: EuiFlyoutProps['onClose']; + /** + * Set of properties that drive a settings menu + */ + flyoutCustomProps?: { + /** + * Hide the gear icon and settings menu if true + */ + hideSettings?: boolean; + /** + * Control if the option to render in overlay or push mode is enabled or not + */ + pushVsOverlay?: { + /** + * Disables the option + */ + disabled: boolean; + /** + * Tooltip to display + */ + tooltip: string; + }; + /** + * Control if the option to resize the flyout is enabled or not + */ + resize?: { + /** + * Disables the option + */ + disabled: boolean; + /** + * Tooltip to display + */ + tooltip: string; + }; + }; + /** + * Optional data test subject string + */ + 'data-test-subj'?: string; +} + +/** + * Expandable flyout UI React component. + * Displays 3 sections (right, left, preview) depending on the panels in the context. + * + * The behavior expects that the left and preview sections should only be displayed is a right section + * is already rendered. + */ +export const Container: React.FC = memo( + ({ customStyles, registeredPanels, flyoutCustomProps, ...flyoutProps }) => { + const dispatch = useDispatch(); + + const { left, right, preview } = useExpandableFlyoutState(); + const { closeFlyout } = useExpandableFlyoutApi(); + + // for flyout where the push vs overlay option is disable in the UI we fall back to overlay mode + const type = useSelector(selectPushVsOverlay); + const flyoutType = flyoutCustomProps?.pushVsOverlay?.disabled ? 'overlay' : type; + + const flyoutWidths = useSelector(selectUserFlyoutWidths); + const defaultWidths = useSelector(selectDefaultWidths); + + // retrieves the sections to be displayed + const { + leftSection, + rightSection, + previewSection, + mostRecentPreview, + mostRecentPreviewBanner, + } = useSections({ + registeredPanels, + }); + + // calculates what needs to be rendered + const showLeft = useMemo(() => leftSection != null && left != null, [leftSection, left]); + const showRight = useMemo(() => rightSection != null && right != null, [rightSection, right]); + const showPreview = useMemo( + () => previewSection != null && preview != null, + [previewSection, preview] + ); + + const showCollapsed = useMemo(() => !showLeft && showRight, [showLeft, showRight]); + const showExpanded = useMemo(() => showLeft && showRight, [showLeft, showRight]); + + const leftComponent = useMemo( + () => (leftSection ? leftSection.component({ ...(left as FlyoutPanelProps) }) : null), + [leftSection, left] + ); + const rightComponent = useMemo( + () => (rightSection ? rightSection.component({ ...(right as FlyoutPanelProps) }) : null), + [rightSection, right] + ); + + const previewComponent = useMemo( + () => + previewSection + ? previewSection.component({ + ...(mostRecentPreview as FlyoutPanelProps), + }) + : null, + [previewSection, mostRecentPreview] + ); + + // we want to set a minimum flyout width different when in collapsed and expanded mode + const minFlyoutWidth = useMemo( + () => (showExpanded ? EXPANDED_FLYOUT_MIN_WIDTH : COLLAPSED_FLYOUT_MIN_WIDTH), + [showExpanded] + ); + + const flyoutWidth = useMemo(() => { + if (showCollapsed) { + return flyoutWidths.collapsedWidth || defaultWidths.rightWidth; + } + if (showExpanded) { + return flyoutWidths.expandedWidth || defaultWidths.rightWidth + defaultWidths.leftWidth; + } + }, [ + showCollapsed, + showExpanded, + flyoutWidths.collapsedWidth, + flyoutWidths.expandedWidth, + defaultWidths.rightWidth, + defaultWidths.leftWidth, + ]); + + // callback function called when user changes the flyout's width + const onResize = useCallback( + (width: number) => { + if (showExpanded) { + dispatch( + changeUserExpandedWidthAction({ + width, + savedToLocalStorage: true, + }) + ); + } else if (showCollapsed) { + dispatch( + changeUserCollapsedWidthAction({ + width, + savedToLocalStorage: true, + }) + ); + } + }, + [dispatch, showCollapsed, showExpanded] + ); + + // don't need to render if the windowWidth is 0 or if nothing needs to be rendered + if (!showExpanded && !showCollapsed && !showPreview) { + return null; + } + + return ( + // @ts-ignore // TODO figure out why it's throwing a 'Types of property ref are incompatible' error + { + closeFlyout(); + if (flyoutProps.onClose) { + flyoutProps.onClose(e); + } + }} + css={customStyles} + onResize={onResize} + minWidth={minFlyoutWidth} + > + {showCollapsed && } + + {showExpanded && ( + + )} + + {showPreview && ( + + )} + + {!flyoutCustomProps?.hideSettings && } + + ); + } +); + +Container.displayName = 'Container'; diff --git a/packages/kbn-expandable-flyout/src/components/left_section.tsx b/packages/kbn-expandable-flyout/src/components/left_section.tsx index c0bd285e9b162..591062116a971 100644 --- a/packages/kbn-expandable-flyout/src/components/left_section.tsx +++ b/packages/kbn-expandable-flyout/src/components/left_section.tsx @@ -8,7 +8,7 @@ */ import { EuiFlexItem } from '@elastic/eui'; -import React, { memo, useMemo } from 'react'; +import React, { memo } from 'react'; import { LEFT_SECTION_TEST_ID } from './test_ids'; interface LeftSectionProps { @@ -16,27 +16,15 @@ interface LeftSectionProps { * Component to be rendered */ component: React.ReactElement; - /** - * Width used when rendering the panel - */ - width: number; } /** * Left section of the expanded flyout rendering a panel */ -export const LeftSection: React.FC = memo( - ({ component, width }: LeftSectionProps) => { - const style = useMemo( - () => ({ height: '100%', width: `${width}px` }), - [width] - ); - return ( - - {component} - - ); - } -); +export const LeftSection: React.FC = memo(({ component }: LeftSectionProps) => ( + + {component} + +)); LeftSection.displayName = 'LeftSection'; diff --git a/packages/kbn-expandable-flyout/src/components/preview_section.test.tsx b/packages/kbn-expandable-flyout/src/components/preview_section.test.tsx index 9916f2e784dfa..6476ac91c0031 100644 --- a/packages/kbn-expandable-flyout/src/components/preview_section.test.tsx +++ b/packages/kbn-expandable-flyout/src/components/preview_section.test.tsx @@ -16,7 +16,7 @@ import { PREVIEW_SECTION_TEST_ID, } from './test_ids'; import { TestProvider } from '../test/provider'; -import { State } from '../store/state'; +import { initialUiState, State } from '../store/state'; describe('PreviewSection', () => { const context: State = { @@ -33,18 +33,15 @@ describe('PreviewSection', () => { }, }, }, - ui: { - pushVsOverlay: 'overlay', - }, + ui: initialUiState, }; const component =
{'component'}
; - const left = 500; it('should render back button and close button in header', () => { const { getByTestId } = render( - + ); @@ -62,7 +59,7 @@ describe('PreviewSection', () => { const { getByTestId, getByText } = render( - + ); diff --git a/packages/kbn-expandable-flyout/src/components/preview_section.tsx b/packages/kbn-expandable-flyout/src/components/preview_section.tsx index f461c8c3710bf..d759e5500534b 100644 --- a/packages/kbn-expandable-flyout/src/components/preview_section.tsx +++ b/packages/kbn-expandable-flyout/src/components/preview_section.tsx @@ -17,9 +17,10 @@ import { EuiSplitPanel, transparentize, } from '@elastic/eui'; -import React, { memo } from 'react'; +import React, { memo, useMemo } from 'react'; import { css } from '@emotion/react'; import { has } from 'lodash'; +import { selectDefaultWidths, selectUserSectionWidths, useSelector } from '../store/redux'; import { PREVIEW_SECTION_BACK_BUTTON_TEST_ID, PREVIEW_SECTION_CLOSE_BUTTON_TEST_ID, @@ -66,14 +67,14 @@ interface PreviewSectionProps { * Component to be rendered */ component: React.ReactElement; - /** - * Left position used when rendering the panel - */ - leftPosition: number; /** * Preview banner shown at the top of preview panel */ banner?: PreviewBanner; + /** + * Flag to indicate whether the preview section is expanded, use to calculate the width of the section + */ + showExpanded: boolean; } /** @@ -81,11 +82,20 @@ interface PreviewSectionProps { * Will display a back and close button in the header for the previous and close feature respectively. */ export const PreviewSection: React.FC = memo( - ({ component, leftPosition, banner }: PreviewSectionProps) => { + ({ component, banner, showExpanded }: PreviewSectionProps) => { const { euiTheme } = useEuiTheme(); const { closePreviewPanel, previousPreviewPanel } = useExpandableFlyoutApi(); - const left = leftPosition + 4; + const { rightPercentage } = useSelector(selectUserSectionWidths); + const defaultPercentages = useSelector(selectDefaultWidths); + + // Calculate the width of the preview section based on the following + // - if only the right section is visible, then we use 100% of the width (minus some padding) + // - if both the right and left sections are visible, we use the width of the right section (minus the same padding) + const width = useMemo(() => { + const percentage = rightPercentage ? rightPercentage : defaultPercentages.rightPercentage; + return showExpanded ? `calc(${percentage}% - 8px)` : `calc(100% - 8px)`; + }, [defaultPercentages.rightPercentage, rightPercentage, showExpanded]); const closeButton = ( @@ -122,14 +132,14 @@ export const PreviewSection: React.FC = memo( top: 8px; bottom: 8px; right: 4px; - left: ${left}px; + width: ${width}; z-index: 1000; `} > {'left component'}; +const rightComponent =
{'right component'}
; + +describe('ResizableContainer', () => { + it('should render left and right component as well as resize button', () => { + const state = { + ...initialState, + ui: { + ...initialState.ui, + userSectionWidths: { + leftPercentage: 50, + rightPercentage: 50, + }, + }, + }; + + const { getByTestId } = render( + + + + ); + + expect(getByTestId(RESIZABLE_LEFT_SECTION_TEST_ID)).toBeInTheDocument(); + expect(getByTestId(RESIZABLE_BUTTON_TEST_ID)).toBeInTheDocument(); + expect(getByTestId(RESIZABLE_RIGHT_SECTION_TEST_ID)).toBeInTheDocument(); + }); +}); diff --git a/packages/kbn-expandable-flyout/src/components/resizable_container.tsx b/packages/kbn-expandable-flyout/src/components/resizable_container.tsx new file mode 100644 index 0000000000000..c7da40167a7fd --- /dev/null +++ b/packages/kbn-expandable-flyout/src/components/resizable_container.tsx @@ -0,0 +1,116 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { EuiResizableContainer } from '@elastic/eui'; +import React, { memo, useCallback, useMemo } from 'react'; +import { css } from '@emotion/react'; +import { changeUserSectionWidthsAction } from '../store/actions'; +import { + selectDefaultWidths, + selectUserSectionWidths, + useDispatch, + useSelector, +} from '../store/redux'; +import { + RESIZABLE_BUTTON_TEST_ID, + RESIZABLE_LEFT_SECTION_TEST_ID, + RESIZABLE_RIGHT_SECTION_TEST_ID, +} from './test_ids'; +import { LeftSection } from './left_section'; +import { RightSection } from './right_section'; + +const RIGHT_SECTION_MIN_WIDTH = '380px'; +const LEFT_SECTION_MIN_WIDTH = '380px'; +const LEFT_PANEL_ID = 'left'; +const RIGHT_PANEL_ID = 'right'; + +interface ResizableContainerProps { + /** + * The component to render on the left side of the flyout + */ + leftComponent: React.ReactElement; + /** + * The component to render on the right side of the flyout + */ + rightComponent: React.ReactElement; + /** + * If the preview section is shown we disable the resize button + */ + showPreview: boolean; +} + +/** + * Component that renders the left and right section when the flyout is in expanded mode. + * It allows the resizing of the sections, saving the percentages in local storage. + */ +export const ResizableContainer: React.FC = memo( + ({ leftComponent, rightComponent, showPreview }: ResizableContainerProps) => { + const dispatch = useDispatch(); + + const { leftPercentage, rightPercentage } = useSelector(selectUserSectionWidths); + const defaultPercentages = useSelector(selectDefaultWidths); + + const initialLeftPercentage = useMemo( + () => leftPercentage || defaultPercentages.leftPercentage, + [defaultPercentages.leftPercentage, leftPercentage] + ); + const initialRightPercentage = useMemo( + () => rightPercentage || defaultPercentages.rightPercentage, + [defaultPercentages.rightPercentage, rightPercentage] + ); + + const onWidthChange = useCallback( + (newSizes) => + dispatch( + changeUserSectionWidthsAction({ + ...newSizes, + savedToLocalStorage: true, + }) + ), + [dispatch] + ); + + return ( + + {(EuiResizablePanel, EuiResizableButton) => ( + <> + + + + + + + + + )} + + ); + } +); + +ResizableContainer.displayName = 'ResizableContainer'; diff --git a/packages/kbn-expandable-flyout/src/components/right_section.tsx b/packages/kbn-expandable-flyout/src/components/right_section.tsx index 73931e44ad5fe..ab6598b9f8e3a 100644 --- a/packages/kbn-expandable-flyout/src/components/right_section.tsx +++ b/packages/kbn-expandable-flyout/src/components/right_section.tsx @@ -8,7 +8,7 @@ */ import { EuiFlexItem } from '@elastic/eui'; -import React, { memo, useMemo } from 'react'; +import React, { memo } from 'react'; import { RIGHT_SECTION_TEST_ID } from './test_ids'; interface RightSectionProps { @@ -16,28 +16,17 @@ interface RightSectionProps { * Component to be rendered */ component: React.ReactElement; - /** - * Width used when rendering the panel - */ - width: number; } /** * Right section of the expanded flyout rendering a panel */ export const RightSection: React.FC = memo( - ({ component, width }: RightSectionProps) => { - const style = useMemo( - () => ({ height: '100%', width: `${width}px` }), - [width] - ); - - return ( - - {component} - - ); - } + ({ component }: RightSectionProps) => ( + + {component} + + ) ); RightSection.displayName = 'RightSection'; diff --git a/packages/kbn-expandable-flyout/src/components/settings_menu.test.tsx b/packages/kbn-expandable-flyout/src/components/settings_menu.test.tsx index 9ef33a649671d..f9a6991f55b52 100644 --- a/packages/kbn-expandable-flyout/src/components/settings_menu.test.tsx +++ b/packages/kbn-expandable-flyout/src/components/settings_menu.test.tsx @@ -13,6 +13,9 @@ import { render } from '@testing-library/react'; import { SettingsMenu } from './settings_menu'; import { SETTINGS_MENU_BUTTON_TEST_ID, + SETTINGS_MENU_FLYOUT_RESIZE_BUTTON_TEST_ID, + SETTINGS_MENU_FLYOUT_RESIZE_INFORMATION_ICON_TEST_ID, + SETTINGS_MENU_FLYOUT_RESIZE_TITLE_TEST_ID, SETTINGS_MENU_FLYOUT_TYPE_BUTTON_GROUP_OVERLAY_TEST_ID, SETTINGS_MENU_FLYOUT_TYPE_BUTTON_GROUP_PUSH_TEST_ID, SETTINGS_MENU_FLYOUT_TYPE_BUTTON_GROUP_TEST_ID, @@ -21,8 +24,14 @@ import { } from './test_ids'; import { TestProvider } from '../test/provider'; import { localStorageMock } from '../../__mocks__'; -import { EXPANDABLE_FLYOUT_LOCAL_STORAGE, PUSH_VS_OVERLAY_LOCAL_STORAGE } from '../constants'; -import { initialPanelsState } from '../store/state'; +import { + USER_COLLAPSED_WIDTH_LOCAL_STORAGE, + EXPANDABLE_FLYOUT_LOCAL_STORAGE, + USER_EXPANDED_WIDTH_LOCAL_STORAGE, + USER_SECTION_WIDTHS_LOCAL_STORAGE, + PUSH_VS_OVERLAY_LOCAL_STORAGE, +} from '../constants'; +import { initialPanelsState, initialUiState } from '../store/state'; describe('SettingsMenu', () => { beforeEach(() => { @@ -31,144 +40,251 @@ describe('SettingsMenu', () => { }); }); - it('should render the flyout type button group', () => { - const flyoutCustomProps = { - hideSettings: false, - pushVsOverlay: { - disabled: false, - tooltip: '', - }, - }; - - const { getByTestId, queryByTestId } = render( - - - - ); - - getByTestId(SETTINGS_MENU_BUTTON_TEST_ID).click(); - - expect(getByTestId(SETTINGS_MENU_FLYOUT_TYPE_TITLE_TEST_ID)).toBeInTheDocument(); - expect( - queryByTestId(SETTINGS_MENU_FLYOUT_TYPE_INFORMATION_ICON_TEST_ID) - ).not.toBeInTheDocument(); - expect(getByTestId(SETTINGS_MENU_FLYOUT_TYPE_BUTTON_GROUP_TEST_ID)).toBeInTheDocument(); - }); + describe('push vs overlay', () => { + it('should render the flyout type button group', () => { + const flyoutCustomProps = { + hideSettings: false, + pushVsOverlay: { + disabled: false, + tooltip: '', + }, + }; - it('should have the type selected if option is enabled', () => { - const state = { - panels: initialPanelsState, - ui: { - pushVsOverlay: 'push' as const, - }, - }; - const flyoutCustomProps = { - hideSettings: false, - pushVsOverlay: { - disabled: false, - tooltip: '', - }, - }; - - const { getByTestId } = render( - - - - ); - - getByTestId(SETTINGS_MENU_BUTTON_TEST_ID).click(); - - expect(getByTestId(SETTINGS_MENU_FLYOUT_TYPE_BUTTON_GROUP_PUSH_TEST_ID)).toHaveClass( - 'euiButtonGroupButton-isSelected' - ); - expect(getByTestId(SETTINGS_MENU_FLYOUT_TYPE_BUTTON_GROUP_OVERLAY_TEST_ID)).not.toHaveClass( - 'euiButtonGroupButton-isSelected' - ); - }); + const { getByTestId, queryByTestId } = render( + + + + ); + + getByTestId(SETTINGS_MENU_BUTTON_TEST_ID).click(); + + expect(getByTestId(SETTINGS_MENU_FLYOUT_TYPE_TITLE_TEST_ID)).toBeInTheDocument(); + expect( + queryByTestId(SETTINGS_MENU_FLYOUT_TYPE_INFORMATION_ICON_TEST_ID) + ).not.toBeInTheDocument(); + expect(getByTestId(SETTINGS_MENU_FLYOUT_TYPE_BUTTON_GROUP_TEST_ID)).toBeInTheDocument(); + }); - it('should select correct the flyout type', () => { - const flyoutCustomProps = { - hideSettings: false, - pushVsOverlay: { - disabled: false, - tooltip: '', - }, - }; + it('should have the type selected if option is enabled', () => { + const state = { + panels: initialPanelsState, + ui: { + ...initialUiState, + pushVsOverlay: 'push' as const, + }, + }; + const flyoutCustomProps = { + hideSettings: false, + pushVsOverlay: { + disabled: false, + tooltip: '', + }, + }; - const { getByTestId } = render( - - - - ); + const { getByTestId } = render( + + + + ); - expect(localStorage.getItem(EXPANDABLE_FLYOUT_LOCAL_STORAGE)).toEqual(null); + getByTestId(SETTINGS_MENU_BUTTON_TEST_ID).click(); - getByTestId(SETTINGS_MENU_BUTTON_TEST_ID).click(); - getByTestId(SETTINGS_MENU_FLYOUT_TYPE_BUTTON_GROUP_PUSH_TEST_ID).click(); + expect(getByTestId(SETTINGS_MENU_FLYOUT_TYPE_BUTTON_GROUP_PUSH_TEST_ID)).toHaveClass( + 'euiButtonGroupButton-isSelected' + ); + expect(getByTestId(SETTINGS_MENU_FLYOUT_TYPE_BUTTON_GROUP_OVERLAY_TEST_ID)).not.toHaveClass( + 'euiButtonGroupButton-isSelected' + ); + }); - expect(localStorage.getItem(EXPANDABLE_FLYOUT_LOCAL_STORAGE)).toEqual( - JSON.stringify({ [PUSH_VS_OVERLAY_LOCAL_STORAGE]: 'push' }) - ); + it('should select correct the flyout type', () => { + const flyoutCustomProps = { + hideSettings: false, + pushVsOverlay: { + disabled: false, + tooltip: '', + }, + }; - getByTestId(SETTINGS_MENU_FLYOUT_TYPE_BUTTON_GROUP_OVERLAY_TEST_ID).click(); + const { getByTestId } = render( + + + + ); - expect(localStorage.getItem(EXPANDABLE_FLYOUT_LOCAL_STORAGE)).toEqual( - JSON.stringify({ [PUSH_VS_OVERLAY_LOCAL_STORAGE]: 'overlay' }) - ); - }); + expect(localStorage.getItem(EXPANDABLE_FLYOUT_LOCAL_STORAGE)).toEqual(null); - it('should render the the flyout type button group disabled', () => { - const flyoutCustomProps = { - hideSettings: false, - pushVsOverlay: { - disabled: true, - tooltip: 'This option is disabled', - }, - }; + getByTestId(SETTINGS_MENU_BUTTON_TEST_ID).click(); + getByTestId(SETTINGS_MENU_FLYOUT_TYPE_BUTTON_GROUP_PUSH_TEST_ID).click(); - const { getByTestId } = render( - - - - ); + expect(localStorage.getItem(EXPANDABLE_FLYOUT_LOCAL_STORAGE)).toEqual( + JSON.stringify({ [PUSH_VS_OVERLAY_LOCAL_STORAGE]: 'push' }) + ); - expect(localStorage.getItem(EXPANDABLE_FLYOUT_LOCAL_STORAGE)).toEqual(null); + getByTestId(SETTINGS_MENU_FLYOUT_TYPE_BUTTON_GROUP_OVERLAY_TEST_ID).click(); + + expect(localStorage.getItem(EXPANDABLE_FLYOUT_LOCAL_STORAGE)).toEqual( + JSON.stringify({ [PUSH_VS_OVERLAY_LOCAL_STORAGE]: 'overlay' }) + ); + }); - getByTestId(SETTINGS_MENU_BUTTON_TEST_ID).click(); - expect(getByTestId(SETTINGS_MENU_FLYOUT_TYPE_BUTTON_GROUP_TEST_ID)).toHaveAttribute('disabled'); + it('should render the the flyout type button group disabled', () => { + const flyoutCustomProps = { + hideSettings: false, + pushVsOverlay: { + disabled: true, + tooltip: 'This option is disabled', + }, + }; - expect(getByTestId(SETTINGS_MENU_FLYOUT_TYPE_INFORMATION_ICON_TEST_ID)).toBeInTheDocument(); + const { getByTestId } = render( + + + + ); - expect(getByTestId(SETTINGS_MENU_FLYOUT_TYPE_BUTTON_GROUP_OVERLAY_TEST_ID)).toHaveClass( - 'euiButtonGroupButton-isSelected' - ); - expect(getByTestId(SETTINGS_MENU_FLYOUT_TYPE_BUTTON_GROUP_PUSH_TEST_ID)).not.toHaveClass( - 'euiButtonGroupButton-isSelected' - ); + expect(localStorage.getItem(EXPANDABLE_FLYOUT_LOCAL_STORAGE)).toEqual(null); - getByTestId(SETTINGS_MENU_FLYOUT_TYPE_BUTTON_GROUP_PUSH_TEST_ID).click(); + getByTestId(SETTINGS_MENU_BUTTON_TEST_ID).click(); + expect(getByTestId(SETTINGS_MENU_FLYOUT_TYPE_BUTTON_GROUP_TEST_ID)).toHaveAttribute( + 'disabled' + ); - expect(localStorage.getItem(EXPANDABLE_FLYOUT_LOCAL_STORAGE)).toEqual(null); + expect(getByTestId(SETTINGS_MENU_FLYOUT_TYPE_INFORMATION_ICON_TEST_ID)).toBeInTheDocument(); + + expect(getByTestId(SETTINGS_MENU_FLYOUT_TYPE_BUTTON_GROUP_OVERLAY_TEST_ID)).toHaveClass( + 'euiButtonGroupButton-isSelected' + ); + expect(getByTestId(SETTINGS_MENU_FLYOUT_TYPE_BUTTON_GROUP_PUSH_TEST_ID)).not.toHaveClass( + 'euiButtonGroupButton-isSelected' + ); + + getByTestId(SETTINGS_MENU_FLYOUT_TYPE_BUTTON_GROUP_PUSH_TEST_ID).click(); + + expect(localStorage.getItem(EXPANDABLE_FLYOUT_LOCAL_STORAGE)).toEqual(null); + }); + + it('should not render the information icon if the tooltip is empty', () => { + const flyoutCustomProps = { + hideSettings: false, + pushVsOverlay: { + disabled: true, + tooltip: '', + }, + }; + + const { getByTestId, queryByTestId } = render( + + + + ); + + getByTestId(SETTINGS_MENU_BUTTON_TEST_ID).click(); + + expect( + queryByTestId(SETTINGS_MENU_FLYOUT_TYPE_INFORMATION_ICON_TEST_ID) + ).not.toBeInTheDocument(); + }); }); - it('should not render the information icon if the tooltip is empty', () => { - const flyoutCustomProps = { - hideSettings: false, - pushVsOverlay: { - disabled: true, - tooltip: '', - }, - }; - - const { getByTestId, queryByTestId } = render( - - - - ); - - getByTestId(SETTINGS_MENU_BUTTON_TEST_ID).click(); - expect( - queryByTestId(SETTINGS_MENU_FLYOUT_TYPE_INFORMATION_ICON_TEST_ID) - ).not.toBeInTheDocument(); + describe('resize', () => { + it('should render the flyout resize button', () => { + const flyoutCustomProps = { + hideSettings: false, + resize: { + disabled: false, + tooltip: '', + }, + }; + const { getByTestId, queryByTestId } = render( + + + + ); + + getByTestId(SETTINGS_MENU_BUTTON_TEST_ID).click(); + + expect(getByTestId(SETTINGS_MENU_FLYOUT_RESIZE_TITLE_TEST_ID)).toBeInTheDocument(); + expect( + queryByTestId(SETTINGS_MENU_FLYOUT_RESIZE_INFORMATION_ICON_TEST_ID) + ).not.toBeInTheDocument(); + expect(getByTestId(SETTINGS_MENU_FLYOUT_RESIZE_BUTTON_TEST_ID)).toBeInTheDocument(); + }); + + it('should reset correctly when clicked', () => { + const flyoutCustomProps = { + hideSettings: false, + resize: { + disabled: false, + tooltip: '', + }, + }; + + localStorage.setItem( + EXPANDABLE_FLYOUT_LOCAL_STORAGE, + JSON.stringify({ + [USER_COLLAPSED_WIDTH_LOCAL_STORAGE]: '250', + [USER_EXPANDED_WIDTH_LOCAL_STORAGE]: '500', + [USER_SECTION_WIDTHS_LOCAL_STORAGE]: { left: 50, right: 50 }, + }) + ); + + const { getByTestId } = render( + + + + ); + + getByTestId(SETTINGS_MENU_BUTTON_TEST_ID).click(); + getByTestId(SETTINGS_MENU_FLYOUT_RESIZE_BUTTON_TEST_ID).click(); + + const expandableFlyout = localStorage.getItem(EXPANDABLE_FLYOUT_LOCAL_STORAGE); + expect(expandableFlyout).not.toBe(null); + + expect(expandableFlyout).not.toHaveProperty(USER_COLLAPSED_WIDTH_LOCAL_STORAGE); + expect(expandableFlyout).not.toHaveProperty(USER_EXPANDED_WIDTH_LOCAL_STORAGE); + expect(expandableFlyout).not.toHaveProperty(USER_SECTION_WIDTHS_LOCAL_STORAGE); + }); + + it('should render the the flyout resize button disabled', () => { + const flyoutCustomProps = { + hideSettings: false, + resize: { + disabled: true, + tooltip: 'This option is disabled', + }, + }; + + const { getByTestId } = render( + + + + ); + + getByTestId(SETTINGS_MENU_BUTTON_TEST_ID).click(); + expect(getByTestId(SETTINGS_MENU_FLYOUT_RESIZE_BUTTON_TEST_ID)).toHaveAttribute('disabled'); + expect(getByTestId(SETTINGS_MENU_FLYOUT_RESIZE_INFORMATION_ICON_TEST_ID)).toBeInTheDocument(); + }); + + it('should not render the information icon if the tooltip is empty', () => { + const flyoutCustomProps = { + hideSettings: false, + resize: { + disabled: true, + tooltip: '', + }, + }; + + const { getByTestId, queryByTestId } = render( + + + + ); + + getByTestId(SETTINGS_MENU_BUTTON_TEST_ID).click(); + expect( + queryByTestId(SETTINGS_MENU_FLYOUT_RESIZE_INFORMATION_ICON_TEST_ID) + ).not.toBeInTheDocument(); + }); }); }); diff --git a/packages/kbn-expandable-flyout/src/components/settings_menu.tsx b/packages/kbn-expandable-flyout/src/components/settings_menu.tsx index 7229921bfdd39..e632f8bb0172c 100644 --- a/packages/kbn-expandable-flyout/src/components/settings_menu.tsx +++ b/packages/kbn-expandable-flyout/src/components/settings_menu.tsx @@ -8,6 +8,7 @@ */ import { + EuiButtonEmpty, EuiButtonGroup, EuiButtonIcon, EuiContextMenu, @@ -22,9 +23,12 @@ import { import { css } from '@emotion/css'; import React, { memo, useCallback, useState } from 'react'; import { i18n } from '@kbn/i18n'; -import { changePushVsOverlayAction } from '../store/actions'; +import { changePushVsOverlayAction, resetAllUserChangedWidthsAction } from '../store/actions'; import { SETTINGS_MENU_BUTTON_TEST_ID, + SETTINGS_MENU_FLYOUT_RESIZE_BUTTON_TEST_ID, + SETTINGS_MENU_FLYOUT_RESIZE_INFORMATION_ICON_TEST_ID, + SETTINGS_MENU_FLYOUT_RESIZE_TITLE_TEST_ID, SETTINGS_MENU_FLYOUT_TYPE_BUTTON_GROUP_OVERLAY_TEST_ID, SETTINGS_MENU_FLYOUT_TYPE_BUTTON_GROUP_PUSH_TEST_ID, SETTINGS_MENU_FLYOUT_TYPE_BUTTON_GROUP_TEST_ID, @@ -60,6 +64,12 @@ const FLYOUT_TYPE_OVERLAY_TOOLTIP = i18n.translate('expandableFlyout.settingsMen const FLYOUT_TYPE_PUSH_TOOLTIP = i18n.translate('expandableFlyout.settingsMenu.pushTooltip', { defaultMessage: 'Displays the flyout next to the page', }); +const FLYOUT_RESIZE_TITLE = i18n.translate('expandableFlyout.renderMenu.flyoutResizeTitle', { + defaultMessage: 'Flyout size', +}); +const FLYOUT_RESIZE_BUTTON = i18n.translate('expandableFlyout.renderMenu.flyoutResizeButton', { + defaultMessage: 'Reset size', +}); export interface FlyoutCustomProps { /** @@ -79,6 +89,19 @@ export interface FlyoutCustomProps { */ tooltip: string; }; + /** + * Control if the option to resize the flyout is enabled or not + */ + resize?: { + /** + * Disables the option + */ + disabled: boolean; + /** + * Tooltip to display + */ + tooltip: string; + }; } export interface SettingsMenuProps { @@ -119,6 +142,11 @@ export const SettingsMenu: React.FC = memo( [dispatch, flyoutCustomProps?.pushVsOverlay?.disabled] ); + const resetSizeOnClick = useCallback(() => { + dispatch(resetAllUserChangedWidthsAction()); + setPopover(false); + }, [dispatch]); + const panels = [ { id: 0, @@ -133,9 +161,6 @@ export const SettingsMenu: React.FC = memo( )} @@ -163,6 +188,28 @@ export const SettingsMenu: React.FC = memo( isDisabled={flyoutCustomProps?.pushVsOverlay?.disabled} data-test-subj={SETTINGS_MENU_FLYOUT_TYPE_BUTTON_GROUP_TEST_ID} /> + + +

+ {FLYOUT_RESIZE_TITLE}{' '} + {flyoutCustomProps?.resize?.tooltip && ( + + + + )} +

+
+ + + {FLYOUT_RESIZE_BUTTON} + ), }, diff --git a/packages/kbn-expandable-flyout/src/components/test_ids.ts b/packages/kbn-expandable-flyout/src/components/test_ids.ts index 498342f1a227d..22d2e00ed66c7 100644 --- a/packages/kbn-expandable-flyout/src/components/test_ids.ts +++ b/packages/kbn-expandable-flyout/src/components/test_ids.ts @@ -7,6 +7,8 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ +export const FLYOUT_TEST_ID = 'resizableFlyout'; + export const RIGHT_SECTION_TEST_ID = 'rightSection'; export const LEFT_SECTION_TEST_ID = 'leftSection'; @@ -33,3 +35,16 @@ export const SETTINGS_MENU_FLYOUT_TYPE_BUTTON_GROUP_OVERLAY_TEST_ID = export const SETTINGS_MENU_FLYOUT_TYPE_BUTTON_GROUP_PUSH_TEST_ID = 'settingsMenuFlyoutTypeButtonGroupPushOption'; + +export const SETTINGS_MENU_FLYOUT_RESIZE_TITLE_TEST_ID = 'settingsMenuFlyoutSizeTitle'; + +export const SETTINGS_MENU_FLYOUT_RESIZE_INFORMATION_ICON_TEST_ID = + 'settingsMenuFlyoutSizeInformationIcon'; + +export const SETTINGS_MENU_FLYOUT_RESIZE_BUTTON_TEST_ID = 'settingsMenuFlyoutSizeButton'; + +export const RESIZABLE_LEFT_SECTION_TEST_ID = 'resizableLeftSection'; + +export const RESIZABLE_RIGHT_SECTION_TEST_ID = 'resizableRightSection'; + +export const RESIZABLE_BUTTON_TEST_ID = 'resizableButton'; diff --git a/packages/kbn-expandable-flyout/src/constants.ts b/packages/kbn-expandable-flyout/src/constants.ts index 7ec81a9de4b67..dfabd845a3f20 100644 --- a/packages/kbn-expandable-flyout/src/constants.ts +++ b/packages/kbn-expandable-flyout/src/constants.ts @@ -14,3 +14,6 @@ export const REDUX_ID_FOR_MEMORY_STORAGE = 'memory'; export const EXPANDABLE_FLYOUT_LOCAL_STORAGE = 'expandableFlyout.ui'; export const PUSH_VS_OVERLAY_LOCAL_STORAGE = 'pushVsOverlay'; +export const USER_COLLAPSED_WIDTH_LOCAL_STORAGE = 'collapsedWidth'; +export const USER_EXPANDED_WIDTH_LOCAL_STORAGE = 'expandedWidth'; +export const USER_SECTION_WIDTHS_LOCAL_STORAGE = 'sectionWidths'; diff --git a/packages/kbn-expandable-flyout/src/hooks/use_expandable_flyout_state.ts b/packages/kbn-expandable-flyout/src/hooks/use_expandable_flyout_state.ts index 49cac7d97a895..88a94f66d54ae 100644 --- a/packages/kbn-expandable-flyout/src/hooks/use_expandable_flyout_state.ts +++ b/packages/kbn-expandable-flyout/src/hooks/use_expandable_flyout_state.ts @@ -7,6 +7,7 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ +import { useMemo } from 'react'; import { REDUX_ID_FOR_MEMORY_STORAGE } from '../constants'; import { useExpandableFlyoutContext } from '../context'; import { selectPanelsById, useSelector } from '../store/redux'; @@ -17,6 +18,7 @@ import { selectPanelsById, useSelector } from '../store/redux'; export const useExpandableFlyoutState = () => { const { urlKey } = useExpandableFlyoutContext(); // if no urlKey is provided, we are in memory storage mode and use the reserved word 'memory' - const id = urlKey || REDUX_ID_FOR_MEMORY_STORAGE; + const id = useMemo(() => urlKey || REDUX_ID_FOR_MEMORY_STORAGE, [urlKey]); + return useSelector(selectPanelsById(id)); }; diff --git a/packages/kbn-expandable-flyout/src/hooks/use_initialize_from_local_storage.test.ts b/packages/kbn-expandable-flyout/src/hooks/use_initialize_from_local_storage.test.ts index 70cc4f31f2636..26b3daf8161db 100644 --- a/packages/kbn-expandable-flyout/src/hooks/use_initialize_from_local_storage.test.ts +++ b/packages/kbn-expandable-flyout/src/hooks/use_initialize_from_local_storage.test.ts @@ -10,9 +10,20 @@ import { renderHook } from '@testing-library/react-hooks'; import { useInitializeFromLocalStorage } from './use_initialize_from_local_storage'; import { localStorageMock } from '../../__mocks__'; -import { EXPANDABLE_FLYOUT_LOCAL_STORAGE, PUSH_VS_OVERLAY_LOCAL_STORAGE } from '../constants'; +import { + USER_COLLAPSED_WIDTH_LOCAL_STORAGE, + EXPANDABLE_FLYOUT_LOCAL_STORAGE, + USER_EXPANDED_WIDTH_LOCAL_STORAGE, + USER_SECTION_WIDTHS_LOCAL_STORAGE, + PUSH_VS_OVERLAY_LOCAL_STORAGE, +} from '../constants'; import { useDispatch } from '../store/redux'; -import { changePushVsOverlayAction } from '../store/actions'; +import { + changeUserCollapsedWidthAction, + changeUserExpandedWidthAction, + changeUserSectionWidthsAction, + changePushVsOverlayAction, +} from '../store/actions'; jest.mock('../store/redux'); @@ -25,7 +36,7 @@ describe('useInitializeFromLocalStorage', () => { // if this test fails, it's very likely because the data format of the values saved in local storage // has changed and we might need to run a migration - it('should retrieve push/overlay value from local storage', () => { + it('should retrieve values from local storage', () => { const mockUseDispatch = jest.fn(); (useDispatch as jest.Mock).mockImplementation(() => mockUseDispatch); @@ -33,6 +44,9 @@ describe('useInitializeFromLocalStorage', () => { EXPANDABLE_FLYOUT_LOCAL_STORAGE, JSON.stringify({ [PUSH_VS_OVERLAY_LOCAL_STORAGE]: 'push', + [USER_COLLAPSED_WIDTH_LOCAL_STORAGE]: 250, + [USER_EXPANDED_WIDTH_LOCAL_STORAGE]: 500, + [USER_SECTION_WIDTHS_LOCAL_STORAGE]: { left: 50, right: 50 }, }) ); @@ -44,6 +58,25 @@ describe('useInitializeFromLocalStorage', () => { savedToLocalStorage: false, }) ); + expect(mockUseDispatch).toHaveBeenCalledWith( + changeUserCollapsedWidthAction({ + width: 250, + savedToLocalStorage: false, + }) + ); + expect(mockUseDispatch).toHaveBeenCalledWith( + changeUserExpandedWidthAction({ + width: 500, + savedToLocalStorage: false, + }) + ); + expect(mockUseDispatch).toHaveBeenCalledWith( + changeUserSectionWidthsAction({ + right: 50, + left: 50, + savedToLocalStorage: false, + }) + ); }); it('should not dispatch action if expandable flyout key is not present in local storage', () => { @@ -51,9 +84,12 @@ describe('useInitializeFromLocalStorage', () => { (useDispatch as jest.Mock).mockImplementation(() => mockUseDispatch); localStorage.setItem( - EXPANDABLE_FLYOUT_LOCAL_STORAGE, + 'wrong_top_level_key', JSON.stringify({ - wrong_key: 'push', + [PUSH_VS_OVERLAY_LOCAL_STORAGE]: 'push', + [USER_COLLAPSED_WIDTH_LOCAL_STORAGE]: 250, + [USER_EXPANDED_WIDTH_LOCAL_STORAGE]: 500, + [USER_SECTION_WIDTHS_LOCAL_STORAGE]: { left: 50, right: 50 }, }) ); @@ -62,10 +98,17 @@ describe('useInitializeFromLocalStorage', () => { expect(mockUseDispatch).not.toHaveBeenCalled(); }); - it('should not dispatch action if expandable flyout key is present in local storage but not push/overlay', () => { + it('should not dispatch action if expandable flyout key is present in local storage but no has no properties', () => { const mockUseDispatch = jest.fn(); (useDispatch as jest.Mock).mockImplementation(() => mockUseDispatch); + localStorage.setItem( + EXPANDABLE_FLYOUT_LOCAL_STORAGE, + JSON.stringify({ + wrong_key: 'push', + }) + ); + renderHook(() => useInitializeFromLocalStorage()); expect(mockUseDispatch).not.toHaveBeenCalled(); diff --git a/packages/kbn-expandable-flyout/src/hooks/use_initialize_from_local_storage.ts b/packages/kbn-expandable-flyout/src/hooks/use_initialize_from_local_storage.ts index 7af92a726a394..9c88fe29e75d7 100644 --- a/packages/kbn-expandable-flyout/src/hooks/use_initialize_from_local_storage.ts +++ b/packages/kbn-expandable-flyout/src/hooks/use_initialize_from_local_storage.ts @@ -7,12 +7,27 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import { EXPANDABLE_FLYOUT_LOCAL_STORAGE, PUSH_VS_OVERLAY_LOCAL_STORAGE } from '../constants'; +import { + USER_COLLAPSED_WIDTH_LOCAL_STORAGE, + EXPANDABLE_FLYOUT_LOCAL_STORAGE, + USER_EXPANDED_WIDTH_LOCAL_STORAGE, + USER_SECTION_WIDTHS_LOCAL_STORAGE, + PUSH_VS_OVERLAY_LOCAL_STORAGE, +} from '../constants'; import { useDispatch } from '../store/redux'; -import { changePushVsOverlayAction } from '../store/actions'; +import { + changeUserCollapsedWidthAction, + changeUserExpandedWidthAction, + changeUserSectionWidthsAction, + changePushVsOverlayAction, +} from '../store/actions'; /** - * Hook to initialize the push vs overlay redux state from local storage + * Hook to initialize all the values in redux state from local storage + * - push vs overlay + * - user's custom collapsed width + * - user's custom expanded width + * - user's custom section percentages */ export const useInitializeFromLocalStorage = () => { const dispatch = useDispatch(); @@ -29,4 +44,35 @@ export const useInitializeFromLocalStorage = () => { }) ); } + + const userCollapsedFlyoutWidth = JSON.parse(expandableFlyout)[USER_COLLAPSED_WIDTH_LOCAL_STORAGE]; + if (userCollapsedFlyoutWidth) { + dispatch( + changeUserCollapsedWidthAction({ + width: parseInt(userCollapsedFlyoutWidth, 10), + savedToLocalStorage: false, + }) + ); + } + + const userExpandedFlyoutWidth = JSON.parse(expandableFlyout)[USER_EXPANDED_WIDTH_LOCAL_STORAGE]; + if (userExpandedFlyoutWidth) { + dispatch( + changeUserExpandedWidthAction({ + width: parseInt(userExpandedFlyoutWidth, 10), + savedToLocalStorage: false, + }) + ); + } + + const userSectionWidths = JSON.parse(expandableFlyout)[USER_SECTION_WIDTHS_LOCAL_STORAGE]; + if (userSectionWidths) { + dispatch( + changeUserSectionWidthsAction({ + right: userSectionWidths.right, + left: userSectionWidths.left, + savedToLocalStorage: false, + }) + ); + } }; diff --git a/packages/kbn-expandable-flyout/src/hooks/use_sections.test.tsx b/packages/kbn-expandable-flyout/src/hooks/use_sections.test.tsx new file mode 100644 index 0000000000000..4526f128affd3 --- /dev/null +++ b/packages/kbn-expandable-flyout/src/hooks/use_sections.test.tsx @@ -0,0 +1,134 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import React from 'react'; +import { renderHook } from '@testing-library/react-hooks'; +import type { RenderHookResult } from '@testing-library/react-hooks'; +import type { UseSectionsParams, UseSectionsResult } from './use_sections'; +import { useSections } from './use_sections'; +import { useExpandableFlyoutState } from '../..'; + +jest.mock('../..'); + +describe('useSections', () => { + let hookResult: RenderHookResult; + + it('should return undefined for all values if no registeredPanels', () => { + (useExpandableFlyoutState as jest.Mock).mockReturnValue({ + left: undefined, + right: undefined, + preview: undefined, + }); + + const initialProps: UseSectionsParams = { + registeredPanels: [], + }; + hookResult = renderHook((props: UseSectionsParams) => useSections(props), { + initialProps, + }); + + expect(hookResult.result.current).toEqual({ + leftSection: undefined, + rightSection: undefined, + previewSection: undefined, + mostRecentPreviewBanner: undefined, + mostRecentPreview: undefined, + }); + }); + + it('should return all sections', () => { + (useExpandableFlyoutState as jest.Mock).mockReturnValue({ + left: { id: 'left' }, + right: { id: 'right' }, + preview: [{ id: 'preview' }], + }); + + const initialProps: UseSectionsParams = { + registeredPanels: [ + { + key: 'right', + component: () =>
{'component'}
, + }, + { + key: 'left', + component: () =>
{'component'}
, + }, + { + key: 'preview', + component: () =>
{'component'}
, + }, + ], + }; + hookResult = renderHook((props: UseSectionsParams) => useSections(props), { + initialProps, + }); + + expect(hookResult.result.current.rightSection?.key).toEqual('right'); + expect(hookResult.result.current.rightSection?.component).toBeDefined(); + + expect(hookResult.result.current.leftSection?.key).toEqual('left'); + expect(hookResult.result.current.leftSection?.component).toBeDefined(); + + expect(hookResult.result.current.previewSection?.key).toEqual('preview'); + expect(hookResult.result.current.previewSection?.component).toBeDefined(); + + expect(hookResult.result.current.mostRecentPreviewBanner).toEqual(undefined); + expect(hookResult.result.current.mostRecentPreview).toEqual({ id: 'preview' }); + }); + + it('should return preview banner', () => { + (useExpandableFlyoutState as jest.Mock).mockReturnValue({ + preview: [ + { + id: 'preview', + params: { + banner: { + title: 'title', + backgroundColor: 'primary', + textColor: 'red', + }, + }, + }, + ], + }); + + const initialProps: UseSectionsParams = { + registeredPanels: [ + { + key: 'preview', + component: () =>
{'component'}
, + }, + ], + }; + hookResult = renderHook((props: UseSectionsParams) => useSections(props), { + initialProps, + }); + + expect(hookResult.result.current.mostRecentPreviewBanner).toEqual({ + title: 'title', + backgroundColor: 'primary', + textColor: 'red', + }); + }); + + it('should return most recent preview', () => { + (useExpandableFlyoutState as jest.Mock).mockReturnValue({ + preview: [{ id: 'preview1' }, { id: 'preview2' }, { id: 'preview3' }], + }); + + const initialProps: UseSectionsParams = { + registeredPanels: [], + }; + hookResult = renderHook((props: UseSectionsParams) => useSections(props), { + initialProps, + }); + + expect(hookResult.result.current.mostRecentPreview).toEqual({ id: 'preview3' }); + }); +}); diff --git a/packages/kbn-expandable-flyout/src/hooks/use_sections.ts b/packages/kbn-expandable-flyout/src/hooks/use_sections.ts new file mode 100644 index 0000000000000..5267030790e38 --- /dev/null +++ b/packages/kbn-expandable-flyout/src/hooks/use_sections.ts @@ -0,0 +1,86 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { useMemo } from 'react'; +import { isPreviewBanner, PreviewBanner } from '../components/preview_section'; +import { FlyoutPanelProps, useExpandableFlyoutState } from '../..'; +import { Panel } from '../types'; + +export interface UseSectionsParams { + /** + * List of all registered panels available for render + */ + registeredPanels: Panel[]; +} + +export interface UseSectionsResult { + /** + * The left section to be displayed in the flyout. + */ + leftSection: Panel | undefined; + /** + * The right section to be displayed in the flyout. + */ + rightSection: Panel | undefined; + /** + * The preview section to be displayed in the flyout. + */ + previewSection: Panel | undefined; + /** + * The most recent preview information to be displayed in the preview section. + */ + mostRecentPreview: FlyoutPanelProps | undefined; + /** + * The preview banner to be displayed in preview section. + */ + mostRecentPreviewBanner: PreviewBanner | undefined; +} + +/** + * Hook that retrieves the left, right, and preview sections to be displayed in the flyout. + */ +export const useSections = ({ registeredPanels }: UseSectionsParams): UseSectionsResult => { + const { left, preview, right } = useExpandableFlyoutState(); + + const rightSection = useMemo( + () => registeredPanels.find((panel) => panel.key === right?.id), + [right, registeredPanels] + ); + const leftSection = useMemo( + () => registeredPanels.find((panel) => panel.key === left?.id), + [left, registeredPanels] + ); + // retrieve the last preview panel (most recent) + const mostRecentPreview = useMemo( + () => (preview ? preview[preview.length - 1] : undefined), + [preview] + ); + const previewSection = useMemo( + () => registeredPanels.find((panel) => panel.key === mostRecentPreview?.id), + [mostRecentPreview, registeredPanels] + ); + const mostRecentPreviewBanner = useMemo( + () => + isPreviewBanner(mostRecentPreview?.params?.banner) + ? mostRecentPreview?.params?.banner + : undefined, + [mostRecentPreview?.params?.banner] + ); + + return useMemo( + () => ({ + leftSection, + rightSection, + previewSection, + mostRecentPreviewBanner, + mostRecentPreview, + }), + [leftSection, rightSection, previewSection, mostRecentPreviewBanner, mostRecentPreview] + ); +}; diff --git a/packages/kbn-expandable-flyout/src/hooks/use_sections_sizes.test.ts b/packages/kbn-expandable-flyout/src/hooks/use_sections_sizes.test.ts deleted file mode 100644 index f1e9c4e3bf072..0000000000000 --- a/packages/kbn-expandable-flyout/src/hooks/use_sections_sizes.test.ts +++ /dev/null @@ -1,251 +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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public - * License v3.0 only", or the "Server Side Public License, v 1". - */ - -import { renderHook } from '@testing-library/react-hooks'; -import type { RenderHookResult } from '@testing-library/react-hooks'; -import type { UserSectionsSizesParams, UserSectionsSizesResult } from './use_sections_sizes'; -import { useSectionSizes } from './use_sections_sizes'; - -describe('useSectionSizes', () => { - let hookResult: RenderHookResult; - - describe('Right section', () => { - it('should return 0 for right section if it is hidden', () => { - const initialProps = { - windowWidth: 350, - showRight: false, - showLeft: false, - showPreview: false, - }; - hookResult = renderHook((props: UserSectionsSizesParams) => useSectionSizes(props), { - initialProps, - }); - - expect(hookResult.result.current).toEqual({ - rightSectionWidth: 0, - leftSectionWidth: 0, - flyoutWidth: '0px', - previewSectionLeft: 0, - }); - }); - - it('should return the window width for right section size for tiny screen', () => { - const initialProps = { - windowWidth: 350, - showRight: true, - showLeft: false, - showPreview: false, - }; - hookResult = renderHook((props: UserSectionsSizesParams) => useSectionSizes(props), { - initialProps, - }); - - expect(hookResult.result.current).toEqual({ - rightSectionWidth: 350, - leftSectionWidth: 0, - flyoutWidth: '350px', - previewSectionLeft: 0, - }); - }); - - it('should return 380 for right section size for medium screen', () => { - const initialProps = { - windowWidth: 600, - showRight: true, - showLeft: false, - showPreview: false, - }; - hookResult = renderHook((props: UserSectionsSizesParams) => useSectionSizes(props), { - initialProps, - }); - - expect(hookResult.result.current).toEqual({ - rightSectionWidth: 380, - leftSectionWidth: 0, - flyoutWidth: '380px', - previewSectionLeft: 0, - }); - }); - - it('should return 500 for right section size for large screen', () => { - const initialProps = { - windowWidth: 1300, - showRight: true, - showLeft: false, - showPreview: false, - }; - hookResult = renderHook((props: UserSectionsSizesParams) => useSectionSizes(props), { - initialProps, - }); - - expect(hookResult.result.current.rightSectionWidth).toBeGreaterThan(420); - expect(hookResult.result.current.rightSectionWidth).toBeLessThan(750); - expect(hookResult.result.current.leftSectionWidth).toEqual(0); - expect(hookResult.result.current.flyoutWidth).toEqual( - `${hookResult.result.current.rightSectionWidth}px` - ); - expect(hookResult.result.current.previewSectionLeft).toEqual(0); - }); - - it('should return 750 for right section size for very large screen', () => { - const initialProps = { - windowWidth: 2500, - showRight: true, - showLeft: false, - showPreview: false, - }; - hookResult = renderHook((props: UserSectionsSizesParams) => useSectionSizes(props), { - initialProps, - }); - - expect(hookResult.result.current).toEqual({ - rightSectionWidth: 750, - leftSectionWidth: 0, - flyoutWidth: '750px', - previewSectionLeft: 0, - }); - }); - }); - - describe('Left section', () => { - it('should return 0 for left section if it is hidden', () => { - const initialProps = { - windowWidth: 500, - showRight: true, - showLeft: false, - showPreview: false, - }; - hookResult = renderHook((props: UserSectionsSizesParams) => useSectionSizes(props), { - initialProps, - }); - - expect(hookResult.result.current).toEqual({ - rightSectionWidth: 380, - leftSectionWidth: 0, - flyoutWidth: '380px', - previewSectionLeft: 0, - }); - }); - - it('should return the remaining for left section', () => { - const initialProps = { - windowWidth: 500, - showRight: true, - showLeft: true, - showPreview: false, - }; - hookResult = renderHook((props: UserSectionsSizesParams) => useSectionSizes(props), { - initialProps, - }); - - expect(hookResult.result.current).toEqual({ - rightSectionWidth: 380, - leftSectionWidth: 72, - flyoutWidth: '452px', - previewSectionLeft: 0, - }); - }); - - it('should return 80% of remaining for left section', () => { - const initialProps = { - windowWidth: 2500, - showRight: true, - showLeft: true, - showPreview: false, - }; - hookResult = renderHook((props: UserSectionsSizesParams) => useSectionSizes(props), { - initialProps, - }); - - expect(hookResult.result.current.rightSectionWidth).toEqual(750); - expect(hookResult.result.current.leftSectionWidth).toEqual((2500 - 750) * 0.8); - expect(hookResult.result.current.flyoutWidth).toEqual( - `${ - hookResult.result.current.rightSectionWidth + hookResult.result.current.leftSectionWidth - }px` - ); - expect(hookResult.result.current.previewSectionLeft).toEqual(0); - }); - - it('should return max out at 1500px for really big screens', () => { - const initialProps = { - windowWidth: 2700, - showRight: true, - showLeft: true, - showPreview: false, - }; - hookResult = renderHook((props: UserSectionsSizesParams) => useSectionSizes(props), { - initialProps, - }); - - expect(hookResult.result.current.rightSectionWidth).toEqual(750); - expect(hookResult.result.current.leftSectionWidth).toEqual(1500); - expect(hookResult.result.current.flyoutWidth).toEqual( - `${ - hookResult.result.current.rightSectionWidth + hookResult.result.current.leftSectionWidth - }px` - ); - expect(hookResult.result.current.previewSectionLeft).toEqual(0); - }); - }); - - describe('Preview section', () => { - it('should return the 0 for preview section if it is hidden', () => { - const initialProps = { - windowWidth: 600, - showRight: true, - showLeft: false, - showPreview: false, - }; - hookResult = renderHook((props: UserSectionsSizesParams) => useSectionSizes(props), { - initialProps, - }); - - expect(hookResult.result.current).toEqual({ - rightSectionWidth: 380, - leftSectionWidth: 0, - flyoutWidth: '380px', - previewSectionLeft: 0, - }); - }); - - it('should return the 0 for preview section when left section is hidden', () => { - const initialProps = { - windowWidth: 600, - showRight: true, - showLeft: false, - showPreview: true, - }; - hookResult = renderHook((props: UserSectionsSizesParams) => useSectionSizes(props), { - initialProps, - }); - - expect(hookResult.result.current).toEqual({ - rightSectionWidth: 380, - leftSectionWidth: 0, - flyoutWidth: '380px', - previewSectionLeft: 0, - }); - }); - - it('should return for preview section when left section is visible', () => { - const initialProps = { windowWidth: 600, showRight: true, showLeft: true, showPreview: true }; - hookResult = renderHook((props: UserSectionsSizesParams) => useSectionSizes(props), { - initialProps, - }); - - expect(hookResult.result.current).toEqual({ - rightSectionWidth: 380, - leftSectionWidth: 172, - flyoutWidth: '552px', - previewSectionLeft: 172, - }); - }); - }); -}); diff --git a/packages/kbn-expandable-flyout/src/hooks/use_sections_sizes.ts b/packages/kbn-expandable-flyout/src/hooks/use_sections_sizes.ts deleted file mode 100644 index b255010b06967..0000000000000 --- a/packages/kbn-expandable-flyout/src/hooks/use_sections_sizes.ts +++ /dev/null @@ -1,114 +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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public - * License v3.0 only", or the "Server Side Public License, v 1". - */ - -const RIGHT_SECTION_MIN_WIDTH = 380; -const MIN_RESOLUTION_BREAKPOINT = 992; -const RIGHT_SECTION_MAX_WIDTH = 750; -const MAX_RESOLUTION_BREAKPOINT = 1920; - -const LEFT_SECTION_MAX_WIDTH = 1500; - -const FULL_WIDTH_BREAKPOINT = 1600; -const FULL_WIDTH_PADDING = 48; - -export interface UserSectionsSizesParams { - /** - * The width of the browser window - */ - windowWidth: number; - /** - * True if the right section is visible, false otherwise - */ - showRight: boolean; - /** - * True if the left section is visible, false otherwise - */ - showLeft: boolean; - /** - * True if the preview section is visible, false otherwise - */ - showPreview: boolean; -} - -export interface UserSectionsSizesResult { - /** - * Width of the right section in pixels - */ - rightSectionWidth: number; - /** - * Width of the left section in pixels - */ - leftSectionWidth: number; - /** - * Width of the flyout in pixels - */ - flyoutWidth: string; - /** - * Left position of the preview section in pixels - */ - previewSectionLeft: number; -} - -/** - * Hook that calculate the different width for the sections of the flyout and the flyout itself - */ -export const useSectionSizes = ({ - windowWidth, - showRight, - showLeft, - showPreview, -}: UserSectionsSizesParams): UserSectionsSizesResult => { - let rightSectionWidth: number = 0; - if (showRight) { - if (windowWidth < MIN_RESOLUTION_BREAKPOINT) { - // the right section's width will grow from 380px (at 992px resolution) while handling tiny screens by not going smaller than the window width - rightSectionWidth = Math.min(RIGHT_SECTION_MIN_WIDTH, windowWidth); - } else { - const ratioWidth = - (RIGHT_SECTION_MAX_WIDTH - RIGHT_SECTION_MIN_WIDTH) * - ((windowWidth - MIN_RESOLUTION_BREAKPOINT) / - (MAX_RESOLUTION_BREAKPOINT - MIN_RESOLUTION_BREAKPOINT)); - - // the right section's width will grow to 750px (at 1920px resolution) and will never go bigger than 750px in higher resolutions - rightSectionWidth = Math.min(RIGHT_SECTION_MIN_WIDTH + ratioWidth, RIGHT_SECTION_MAX_WIDTH); - } - } - - let leftSectionWidth: number = 0; - if (showLeft) { - // the left section's width will be nearly the remaining space for resolution lower than 1600px - if (windowWidth <= FULL_WIDTH_BREAKPOINT) { - leftSectionWidth = windowWidth - rightSectionWidth - FULL_WIDTH_PADDING; - } else { - // the left section's width will be taking 80% of the remaining space for resolution higher than 1600px, while never going bigger than 1500px - leftSectionWidth = Math.min( - ((windowWidth - rightSectionWidth) * 80) / 100, - LEFT_SECTION_MAX_WIDTH - ); - } - } - - const flyoutWidth: string = - showRight && showLeft ? `${rightSectionWidth + leftSectionWidth}px` : `${rightSectionWidth}px`; - - // preview section's width should only be similar to the right section. - // Though because the preview is rendered with an absolute position in the flyout, we calculate its left position instead of the width - let previewSectionLeft: number = 0; - if (showPreview) { - // the preview section starts where the left section ends - previewSectionLeft = leftSectionWidth; - } - - return { - rightSectionWidth, - leftSectionWidth, - flyoutWidth, - previewSectionLeft, - }; -}; diff --git a/packages/kbn-expandable-flyout/src/hooks/use_window_size.test.ts b/packages/kbn-expandable-flyout/src/hooks/use_window_size.test.ts deleted file mode 100644 index e53268466497d..0000000000000 --- a/packages/kbn-expandable-flyout/src/hooks/use_window_size.test.ts +++ /dev/null @@ -1,18 +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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public - * License v3.0 only", or the "Server Side Public License, v 1". - */ - -import { renderHook } from '@testing-library/react-hooks'; -import { useWindowSize } from './use_window_size'; - -describe('useWindowSize', () => { - it('should return the window size', () => { - const hookResult = renderHook(() => useWindowSize()); - expect(hookResult.result.current).toEqual(1024); - }); -}); diff --git a/packages/kbn-expandable-flyout/src/hooks/use_window_size.ts b/packages/kbn-expandable-flyout/src/hooks/use_window_size.ts deleted file mode 100644 index 268e70b8f6d6c..0000000000000 --- a/packages/kbn-expandable-flyout/src/hooks/use_window_size.ts +++ /dev/null @@ -1,26 +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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public - * License v3.0 only", or the "Server Side Public License, v 1". - */ - -import { useLayoutEffect, useState } from 'react'; - -/** - * Hook that returns the browser window width - */ -export const useWindowSize = (): number => { - const [width, setWidth] = useState(0); - useLayoutEffect(() => { - function updateSize() { - setWidth(window.innerWidth); - } - window.addEventListener('resize', updateSize); - updateSize(); - return () => window.removeEventListener('resize', updateSize); - }, []); - return width; -}; diff --git a/packages/kbn-expandable-flyout/src/hooks/use_window_width.test.ts b/packages/kbn-expandable-flyout/src/hooks/use_window_width.test.ts new file mode 100644 index 0000000000000..72ab9148743db --- /dev/null +++ b/packages/kbn-expandable-flyout/src/hooks/use_window_width.test.ts @@ -0,0 +1,150 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { renderHook } from '@testing-library/react-hooks'; +import { + FULL_WIDTH_PADDING, + MAX_RESOLUTION_BREAKPOINT, + MIN_RESOLUTION_BREAKPOINT, + RIGHT_SECTION_MAX_WIDTH, + RIGHT_SECTION_MIN_WIDTH, + useWindowWidth, +} from './use_window_width'; +import { useDispatch } from '../store/redux'; +import { setDefaultWidthsAction } from '../store/actions'; + +jest.mock('../store/redux'); + +describe('useWindowWidth', () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + + it('should return the window size and dispatch setDefaultWidthsAction', () => { + global.innerWidth = 1024; + + const mockUseDispatch = jest.fn(); + (useDispatch as jest.Mock).mockImplementation(() => mockUseDispatch); + + const hookResult = renderHook(() => useWindowWidth()); + + expect(hookResult.result.current).toEqual(1024); + expect(mockUseDispatch).toHaveBeenCalled(); + }); + + it('should not dispatch action if window.innerWidth is 0', () => { + global.innerWidth = 0; + + const mockUseDispatch = jest.fn(); + (useDispatch as jest.Mock).mockImplementation(() => mockUseDispatch); + + const hookResult = renderHook(() => useWindowWidth()); + + expect(hookResult.result.current).toEqual(0); + expect(mockUseDispatch).not.toHaveBeenCalled(); + }); + + it('should handle very small screens', () => { + global.innerWidth = 300; + + const mockUseDispatch = jest.fn(); + (useDispatch as jest.Mock).mockImplementation(() => mockUseDispatch); + + const hookResult = renderHook(() => useWindowWidth()); + + expect(hookResult.result.current).toEqual(300); + expect(mockUseDispatch).toHaveBeenCalledWith( + setDefaultWidthsAction({ + left: -48, + right: 300, + preview: 300, + }) + ); + }); + + it('should handle small screens', () => { + global.innerWidth = 500; + + const mockUseDispatch = jest.fn(); + (useDispatch as jest.Mock).mockImplementation(() => mockUseDispatch); + + const hookResult = renderHook(() => useWindowWidth()); + + expect(hookResult.result.current).toEqual(500); + expect(mockUseDispatch).toHaveBeenCalledWith( + setDefaultWidthsAction({ + left: 72, + right: 380, + preview: 380, + }) + ); + }); + + it('should handle medium screens', () => { + global.innerWidth = 1300; + + const mockUseDispatch = jest.fn(); + (useDispatch as jest.Mock).mockImplementation(() => mockUseDispatch); + + const hookResult = renderHook(() => useWindowWidth()); + + const right = + RIGHT_SECTION_MIN_WIDTH + + (RIGHT_SECTION_MAX_WIDTH - RIGHT_SECTION_MIN_WIDTH) * + ((1300 - MIN_RESOLUTION_BREAKPOINT) / + (MAX_RESOLUTION_BREAKPOINT - MIN_RESOLUTION_BREAKPOINT)); + const left = 1300 - right - FULL_WIDTH_PADDING; + const preview = right; + + expect(hookResult.result.current).toEqual(1300); + expect(mockUseDispatch).toHaveBeenCalledWith( + setDefaultWidthsAction({ + left, + right, + preview, + }) + ); + }); + + it('should handle large screens', () => { + global.innerWidth = 2500; + + const mockUseDispatch = jest.fn(); + (useDispatch as jest.Mock).mockImplementation(() => mockUseDispatch); + + const hookResult = renderHook(() => useWindowWidth()); + + expect(hookResult.result.current).toEqual(2500); + expect(mockUseDispatch).toHaveBeenCalledWith( + setDefaultWidthsAction({ + left: 1400, + right: 750, + preview: 750, + }) + ); + }); + + it('should handle very large screens', () => { + global.innerWidth = 3800; + + const mockUseDispatch = jest.fn(); + (useDispatch as jest.Mock).mockImplementation(() => mockUseDispatch); + + const hookResult = renderHook(() => useWindowWidth()); + + expect(hookResult.result.current).toEqual(3800); + expect(mockUseDispatch).toHaveBeenCalledWith( + setDefaultWidthsAction({ + left: 1500, + right: 750, + preview: 750, + }) + ); + }); +}); diff --git a/packages/kbn-expandable-flyout/src/hooks/use_window_width.ts b/packages/kbn-expandable-flyout/src/hooks/use_window_width.ts new file mode 100644 index 0000000000000..3df9eef08a4f8 --- /dev/null +++ b/packages/kbn-expandable-flyout/src/hooks/use_window_width.ts @@ -0,0 +1,84 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { useLayoutEffect, useState } from 'react'; +import { useDispatch } from '../store/redux'; +import { setDefaultWidthsAction } from '../store/actions'; + +export const RIGHT_SECTION_MIN_WIDTH = 380; +export const MIN_RESOLUTION_BREAKPOINT = 992; +export const RIGHT_SECTION_MAX_WIDTH = 750; +export const MAX_RESOLUTION_BREAKPOINT = 1920; + +const LEFT_SECTION_MAX_WIDTH = 1500; + +const FULL_WIDTH_BREAKPOINT = 1600; +export const FULL_WIDTH_PADDING = 48; + +/** + * Hook that returns the browser window width + */ +export const useWindowWidth = (): number => { + const dispatch = useDispatch(); + + const [width, setWidth] = useState(0); + + useLayoutEffect(() => { + function updateSize() { + setWidth(window.innerWidth); + + const windowWidth = window.innerWidth; + if (windowWidth !== 0) { + let rightSectionWidth: number; + if (windowWidth < MIN_RESOLUTION_BREAKPOINT) { + // the right section's width will grow from 380px (at 992px resolution) while handling tiny screens by not going smaller than the window width + rightSectionWidth = Math.min(RIGHT_SECTION_MIN_WIDTH, windowWidth); + } else { + const ratioWidth = + (RIGHT_SECTION_MAX_WIDTH - RIGHT_SECTION_MIN_WIDTH) * + ((windowWidth - MIN_RESOLUTION_BREAKPOINT) / + (MAX_RESOLUTION_BREAKPOINT - MIN_RESOLUTION_BREAKPOINT)); + + // the right section's width will grow to 750px (at 1920px resolution) and will never go bigger than 750px in higher resolutions + rightSectionWidth = Math.min( + RIGHT_SECTION_MIN_WIDTH + ratioWidth, + RIGHT_SECTION_MAX_WIDTH + ); + } + + let leftSectionWidth: number; + // the left section's width will be nearly the remaining space for resolution lower than 1600px + if (windowWidth <= FULL_WIDTH_BREAKPOINT) { + leftSectionWidth = windowWidth - rightSectionWidth - FULL_WIDTH_PADDING; + } else { + // the left section's width will be taking 80% of the remaining space for resolution higher than 1600px, while never going bigger than 1500px + leftSectionWidth = Math.min( + ((windowWidth - rightSectionWidth) * 80) / 100, + LEFT_SECTION_MAX_WIDTH + ); + } + + const previewSectionWidth: number = rightSectionWidth; + + dispatch( + setDefaultWidthsAction({ + right: rightSectionWidth, + left: leftSectionWidth, + preview: previewSectionWidth, + }) + ); + } + } + window.addEventListener('resize', updateSize); + updateSize(); + return () => window.removeEventListener('resize', updateSize); + }, [dispatch]); + + return width; +}; diff --git a/packages/kbn-expandable-flyout/src/index.stories.tsx b/packages/kbn-expandable-flyout/src/index.stories.tsx index 6e6e7207d8f15..1e8e08d96c073 100644 --- a/packages/kbn-expandable-flyout/src/index.stories.tsx +++ b/packages/kbn-expandable-flyout/src/index.stories.tsx @@ -21,7 +21,7 @@ import { } from '@elastic/eui'; import { ExpandableFlyout } from '.'; import { TestProvider } from './test/provider'; -import { State } from './store/state'; +import { initialUiState, State } from './store/state'; export default { component: ExpandableFlyout, @@ -114,9 +114,7 @@ export const Right: Story = () => { }, }, }, - ui: { - pushVsOverlay: 'overlay', - }, + ui: initialUiState, }; return ( @@ -144,9 +142,7 @@ export const Left: Story = () => { }, }, }, - ui: { - pushVsOverlay: 'overlay', - }, + ui: initialUiState, }; return ( @@ -178,9 +174,7 @@ export const Preview: Story = () => { }, }, }, - ui: { - pushVsOverlay: 'overlay', - }, + ui: initialUiState, }; return ( @@ -215,9 +209,7 @@ export const MultiplePreviews: Story = () => { }, }, }, - ui: { - pushVsOverlay: 'overlay', - }, + ui: initialUiState, }; return ( @@ -230,7 +222,7 @@ export const MultiplePreviews: Story = () => { ); }; -export const CollapsedPushVsOverlay: Story = () => { +export const CollapsedPushMode: Story = () => { const state: State = { panels: { byId: { @@ -244,6 +236,7 @@ export const CollapsedPushVsOverlay: Story = () => { }, }, ui: { + ...initialUiState, pushVsOverlay: 'push', }, }; @@ -255,7 +248,7 @@ export const CollapsedPushVsOverlay: Story = () => { ); }; -export const ExpandedPushVsOverlay: Story = () => { +export const ExpandedPushMode: Story = () => { const state: State = { panels: { byId: { @@ -271,6 +264,7 @@ export const ExpandedPushVsOverlay: Story = () => { }, }, ui: { + ...initialUiState, pushVsOverlay: 'push', }, }; @@ -297,9 +291,7 @@ export const DisableTypeSelection: Story = () => { }, }, }, - ui: { - pushVsOverlay: 'overlay', - }, + ui: initialUiState, }; return ( @@ -313,3 +305,58 @@ export const DisableTypeSelection: Story = () => { ); }; + +export const ResetWidths: Story = () => { + const state: State = { + panels: { + byId: { + memory: { + right: { + id: 'right', + }, + left: { + id: 'left', + }, + preview: undefined, + }, + }, + }, + ui: initialUiState, + }; + + return ( + + + + ); +}; + +export const DisableResizeWidthSelection: Story = () => { + const state: State = { + panels: { + byId: { + memory: { + right: { + id: 'right', + }, + left: { + id: 'left', + }, + preview: undefined, + }, + }, + }, + ui: initialUiState, + }; + + return ( + + + + ); +}; diff --git a/packages/kbn-expandable-flyout/src/index.test.tsx b/packages/kbn-expandable-flyout/src/index.test.tsx index f465a16501761..8ee4ff32a9821 100644 --- a/packages/kbn-expandable-flyout/src/index.test.tsx +++ b/packages/kbn-expandable-flyout/src/index.test.tsx @@ -12,17 +12,13 @@ import { render } from '@testing-library/react'; import { Panel } from './types'; import { ExpandableFlyout } from '.'; -import { - LEFT_SECTION_TEST_ID, - PREVIEW_SECTION_TEST_ID, - SETTINGS_MENU_BUTTON_TEST_ID, - RIGHT_SECTION_TEST_ID, -} from './components/test_ids'; -import { type State } from './store/state'; +import { useWindowWidth } from './hooks/use_window_width'; import { TestProvider } from './test/provider'; import { REDUX_ID_FOR_MEMORY_STORAGE } from './constants'; +import { initialUiState } from './store/state'; + +jest.mock('./hooks/use_window_width'); -const id = REDUX_ID_FOR_MEMORY_STORAGE; const registeredPanels: Panel[] = [ { key: 'key', @@ -31,18 +27,11 @@ const registeredPanels: Panel[] = [ ]; describe('ExpandableFlyout', () => { - it(`shouldn't render flyout if no panels`, () => { - const state: State = { - panels: { - byId: {}, - }, - ui: { - pushVsOverlay: 'overlay', - }, - }; + it(`should not render flyout if window width is 0`, () => { + (useWindowWidth as jest.Mock).mockReturnValue(0); const result = render( - + ); @@ -50,122 +39,13 @@ describe('ExpandableFlyout', () => { expect(result.asFragment()).toMatchInlineSnapshot(``); }); - it('should render right section', () => { - const state = { - panels: { - byId: { - [id]: { - right: { - id: 'key', - }, - left: undefined, - preview: undefined, - }, - }, - }, - ui: { - pushVsOverlay: 'overlay' as const, - }, - }; - - const { getByTestId } = render( - - - - ); - - expect(getByTestId(RIGHT_SECTION_TEST_ID)).toBeInTheDocument(); - }); - - it('should render left section', () => { - const state = { - panels: { - byId: { - [id]: { - right: undefined, - left: { - id: 'key', - }, - preview: undefined, - }, - }, - }, - ui: { - pushVsOverlay: 'overlay' as const, - }, - }; - - const { getByTestId } = render( - - - - ); - - expect(getByTestId(LEFT_SECTION_TEST_ID)).toBeInTheDocument(); - }); - - it('should render preview section', () => { - const state = { - panels: { - byId: { - [id]: { - right: undefined, - left: undefined, - preview: [ - { - id: 'key', - }, - ], - }, - }, - }, - ui: { - pushVsOverlay: 'overlay' as const, - }, - }; - - const { getByTestId } = render( - - - - ); - - expect(getByTestId(PREVIEW_SECTION_TEST_ID)).toBeInTheDocument(); - }); + it(`should render flyout`, () => { + (useWindowWidth as jest.Mock).mockReturnValue(1000); - it('should not render flyout when right has value but does not matches registered panels', () => { const state = { panels: { byId: { - [id]: { - right: { - id: 'key1', - }, - left: undefined, - preview: undefined, - }, - }, - }, - ui: { - pushVsOverlay: 'overlay' as const, - }, - }; - - const { queryByTestId } = render( - - - - ); - - expect(queryByTestId('my-test-flyout')).toBeNull(); - expect(queryByTestId(RIGHT_SECTION_TEST_ID)).toBeNull(); - }); - - it('should render the menu to change display options', () => { - const state = { - panels: { - byId: { - [id]: { + [REDUX_ID_FOR_MEMORY_STORAGE]: { right: { id: 'key', }, @@ -174,17 +54,15 @@ describe('ExpandableFlyout', () => { }, }, }, - ui: { - pushVsOverlay: 'overlay' as const, - }, + ui: initialUiState, }; const { getByTestId } = render( - + ); - expect(getByTestId(SETTINGS_MENU_BUTTON_TEST_ID)).toBeInTheDocument(); + expect(getByTestId('TEST')).toBeInTheDocument(); }); }); diff --git a/packages/kbn-expandable-flyout/src/index.tsx b/packages/kbn-expandable-flyout/src/index.tsx index 4904661b2da88..25425a75e2ba9 100644 --- a/packages/kbn-expandable-flyout/src/index.tsx +++ b/packages/kbn-expandable-flyout/src/index.tsx @@ -10,23 +10,14 @@ import React, { useMemo } from 'react'; import type { Interpolation, Theme } from '@emotion/react'; import { EuiFlyoutProps } from '@elastic/eui'; -import { EuiFlexGroup, EuiFlyout } from '@elastic/eui'; +import { EuiFlyoutResizableProps } from '@elastic/eui/src/components/flyout/flyout_resizable'; +import { Container } from './components/container'; +import { useWindowWidth } from './hooks/use_window_width'; import { useInitializeFromLocalStorage } from './hooks/use_initialize_from_local_storage'; -import { FlyoutCustomProps, SettingsMenu } from './components/settings_menu'; -import { useSectionSizes } from './hooks/use_sections_sizes'; -import { useWindowSize } from './hooks/use_window_size'; -import { useExpandableFlyoutState } from './hooks/use_expandable_flyout_state'; -import { useExpandableFlyoutApi } from './hooks/use_expandable_flyout_api'; -import { PreviewSection } from './components/preview_section'; -import { RightSection } from './components/right_section'; -import type { FlyoutPanelProps, Panel } from './types'; -import { LeftSection } from './components/left_section'; -import { isPreviewBanner } from './components/preview_section'; -import { selectPushVsOverlay, useSelector } from './store/redux'; +import { FlyoutCustomProps } from './components/settings_menu'; +import type { Panel } from './types'; -const flyoutInnerStyles = { height: '100%' }; - -export interface ExpandableFlyoutProps extends Omit { +export interface ExpandableFlyoutProps extends Omit { /** * List of all registered panels available for render */ @@ -43,6 +34,10 @@ export interface ExpandableFlyoutProps extends Omit { * Set of properties that drive a settings menu */ flyoutCustomProps?: FlyoutCustomProps; + /** + * Optional data test subject string to be used on the EuiFlyoutResizable component + */ + 'data-test-subj'?: string; } /** @@ -52,108 +47,18 @@ export interface ExpandableFlyoutProps extends Omit { * The behavior expects that the left and preview sections should only be displayed is a right section * is already rendered. */ -export const ExpandableFlyout: React.FC = ({ - customStyles, - registeredPanels, - flyoutCustomProps, - ...flyoutProps -}) => { - const windowWidth = useWindowSize(); +export const ExpandableFlyout: React.FC = ({ ...props }) => { + const windowWidth = useWindowWidth(); useInitializeFromLocalStorage(); - // for flyout where the push vs overlay option is disable in the UI we fall back to overlay mode - const type = useSelector(selectPushVsOverlay); - const flyoutType = flyoutCustomProps?.pushVsOverlay?.disabled ? 'overlay' : type; - - const { left, right, preview } = useExpandableFlyoutState(); - const { closeFlyout } = useExpandableFlyoutApi(); - - const leftSection = useMemo( - () => registeredPanels.find((panel) => panel.key === left?.id), - [left, registeredPanels] - ); - - const rightSection = useMemo( - () => registeredPanels.find((panel) => panel.key === right?.id), - [right, registeredPanels] - ); - - // retrieve the last preview panel (most recent) - const mostRecentPreview = preview ? preview[preview.length - 1] : undefined; - const previewBanner = isPreviewBanner(mostRecentPreview?.params?.banner) - ? mostRecentPreview?.params?.banner - : undefined; + const container = useMemo(() => , [props]); - const previewSection = useMemo( - () => registeredPanels.find((panel) => panel.key === mostRecentPreview?.id), - [mostRecentPreview, registeredPanels] - ); - - const showRight = rightSection != null && right != null; - const showLeft = leftSection != null && left != null; - const showPreview = previewSection != null && preview != null; - - const { rightSectionWidth, leftSectionWidth, flyoutWidth, previewSectionLeft } = useSectionSizes({ - windowWidth, - showRight, - showLeft, - showPreview, - }); - - const hideFlyout = !(left && leftSection) && !(right && rightSection) && !preview?.length; - - if (hideFlyout) { + if (windowWidth === 0) { return null; } - return ( - { - closeFlyout(); - if (flyoutProps.onClose) { - flyoutProps.onClose(e); - } - }} - css={customStyles} - > - - {showLeft ? ( - - ) : null} - {showRight ? ( - - ) : null} - - - {showPreview ? ( - - ) : null} - - {!flyoutCustomProps?.hideSettings && } - - ); + return <>{container}; }; ExpandableFlyout.displayName = 'ExpandableFlyout'; diff --git a/packages/kbn-expandable-flyout/src/provider.test.tsx b/packages/kbn-expandable-flyout/src/provider.test.tsx index bdd4183c53276..7d7e6f8ab10c0 100644 --- a/packages/kbn-expandable-flyout/src/provider.test.tsx +++ b/packages/kbn-expandable-flyout/src/provider.test.tsx @@ -12,7 +12,7 @@ import { render } from '@testing-library/react'; import { TestProvider } from './test/provider'; import { UrlSynchronizer } from './provider'; import * as actions from './store/actions'; -import { State } from './store/state'; +import { initialUiState, State } from './store/state'; import { of } from 'rxjs'; const mockGet = jest.fn(); @@ -38,9 +38,7 @@ describe('UrlSynchronizer', () => { }, needsSync: true, }, - ui: { - pushVsOverlay: 'overlay', - }, + ui: initialUiState, }; render( @@ -64,9 +62,7 @@ describe('UrlSynchronizer', () => { byId: {}, needsSync: true, }, - ui: { - pushVsOverlay: 'overlay', - }, + ui: initialUiState, }; render( @@ -101,9 +97,7 @@ describe('UrlSynchronizer', () => { }, needsSync: true, }, - ui: { - pushVsOverlay: 'overlay', - }, + ui: initialUiState, }; render( diff --git a/packages/kbn-expandable-flyout/src/store/actions.ts b/packages/kbn-expandable-flyout/src/store/actions.ts index 2886118369b0e..49e28befa3456 100644 --- a/packages/kbn-expandable-flyout/src/store/actions.ts +++ b/packages/kbn-expandable-flyout/src/store/actions.ts @@ -23,6 +23,15 @@ export enum ActionType { urlChanged = 'urlChanged', changePushVsOverlay = 'change_push_overlay', + + setDefaultWidths = 'set_default_widths', + + changeUserCollapsedWidth = 'change_user_collapsed_width', + changeUserExpandedWidth = 'change_user_expanded_width', + + changeUserSectionWidths = 'change_user_section_widths', + + resetAllUserWidths = 'reset_all_user_widths', } export const openPanelsAction = createAction<{ @@ -134,3 +143,57 @@ export const changePushVsOverlayAction = createAction<{ */ savedToLocalStorage: boolean; }>(ActionType.changePushVsOverlay); + +export const setDefaultWidthsAction = createAction<{ + /** + * Default width for the right section + */ + right: number; + /** + * Default width for the left section + */ + left: number; + /** + * Default width for the preview section + */ + preview: number; +}>(ActionType.setDefaultWidths); + +export const changeUserCollapsedWidthAction = createAction<{ + /** + * Width of the collapsed flyout + */ + width: number; + /** + * Used in the redux middleware to decide if the value needs to be saved to local storage. + */ + savedToLocalStorage: boolean; +}>(ActionType.changeUserCollapsedWidth); + +export const changeUserExpandedWidthAction = createAction<{ + /** + * Width of the expanded flyout + */ + width: number; + /** + * Used in the redux middleware to decide if the value needs to be saved to local storage. + */ + savedToLocalStorage: boolean; +}>(ActionType.changeUserExpandedWidth); + +export const changeUserSectionWidthsAction = createAction<{ + /** + * Width of the left section + */ + left: number; + /** + * Width of the right section + */ + right: number; + /** + * Used in the redux middleware to decide if the value needs to be saved to local storage. + */ + savedToLocalStorage: boolean; +}>(ActionType.changeUserSectionWidths); + +export const resetAllUserChangedWidthsAction = createAction(ActionType.resetAllUserWidths); diff --git a/packages/kbn-expandable-flyout/src/store/middlewares.test.ts b/packages/kbn-expandable-flyout/src/store/middlewares.test.ts index ccbb5d5443db7..680a5619b0b64 100644 --- a/packages/kbn-expandable-flyout/src/store/middlewares.test.ts +++ b/packages/kbn-expandable-flyout/src/store/middlewares.test.ts @@ -8,10 +8,27 @@ */ import { localStorageMock } from '../../__mocks__'; -import { EXPANDABLE_FLYOUT_LOCAL_STORAGE, PUSH_VS_OVERLAY_LOCAL_STORAGE } from '../constants'; -import { savePushVsOverlayToLocalStorageMiddleware } from './middlewares'; -import { createAction, type MiddlewareAPI } from '@reduxjs/toolkit'; -import { changePushVsOverlayAction } from './actions'; +import { + EXPANDABLE_FLYOUT_LOCAL_STORAGE, + PUSH_VS_OVERLAY_LOCAL_STORAGE, + USER_COLLAPSED_WIDTH_LOCAL_STORAGE, + USER_EXPANDED_WIDTH_LOCAL_STORAGE, + USER_SECTION_WIDTHS_LOCAL_STORAGE, +} from '../constants'; +import { + clearAllUserWidthsFromLocalStorageMiddleware, + savePushVsOverlayToLocalStorageMiddleware, + saveUserFlyoutWidthsToLocalStorageMiddleware, + saveUserSectionWidthsToLocalStorageMiddleware, +} from './middlewares'; +import { createAction } from '@reduxjs/toolkit'; +import { + changeUserCollapsedWidthAction, + changeUserExpandedWidthAction, + changeUserSectionWidthsAction, + changePushVsOverlayAction, + resetAllUserChangedWidthsAction, +} from './actions'; const noTypeAction = createAction<{ type: 'no_type'; @@ -20,40 +37,182 @@ const randomAction = createAction<{ type: 'random_type'; }>('random_action'); -describe('pushVsOverlayMiddleware', () => { +describe('middlewares', () => { beforeEach(() => { Object.defineProperty(window, 'localStorage', { value: localStorageMock(), }); }); - it('should ignore action without type', () => { - savePushVsOverlayToLocalStorageMiddleware({} as MiddlewareAPI)(jest.fn)(noTypeAction); + describe('savePushVsOverlayToLocalStorageMiddleware', () => { + it('should ignore action without type', () => { + savePushVsOverlayToLocalStorageMiddleware()(jest.fn)(noTypeAction); - expect(localStorage.getItem(EXPANDABLE_FLYOUT_LOCAL_STORAGE)).toEqual(null); + expect(localStorage.getItem(EXPANDABLE_FLYOUT_LOCAL_STORAGE)).toEqual(null); + }); + + it('should ignore action of types other than changePushVsOverlayAction', () => { + savePushVsOverlayToLocalStorageMiddleware()(jest.fn)(randomAction); + + expect(localStorage.getItem(EXPANDABLE_FLYOUT_LOCAL_STORAGE)).toEqual(null); + }); + + it('should save value to local storage if action is of type changePushVsOverlayAction', () => { + savePushVsOverlayToLocalStorageMiddleware()(jest.fn)( + changePushVsOverlayAction({ type: 'push', savedToLocalStorage: true }) + ); + + expect(localStorage.getItem(EXPANDABLE_FLYOUT_LOCAL_STORAGE)).toEqual( + JSON.stringify({ [PUSH_VS_OVERLAY_LOCAL_STORAGE]: 'push' }) + ); + }); + + it('should not save value to local storage if savedToLocalStorage is false', () => { + savePushVsOverlayToLocalStorageMiddleware()(jest.fn)( + changePushVsOverlayAction({ type: 'push', savedToLocalStorage: false }) + ); + expect(localStorage.getItem(EXPANDABLE_FLYOUT_LOCAL_STORAGE)).toEqual(null); + }); }); - it('should ignore action of types other than changePushVsOverlayAction', () => { - savePushVsOverlayToLocalStorageMiddleware({} as MiddlewareAPI)(jest.fn)(randomAction); + describe('saveUserFlyoutWidthsToLocalStorageMiddleware', () => { + it('should ignore action without type', () => { + saveUserFlyoutWidthsToLocalStorageMiddleware()(jest.fn)(noTypeAction); + + expect(localStorage.getItem(EXPANDABLE_FLYOUT_LOCAL_STORAGE)).toEqual(null); + }); + + it('should ignore action of other types', () => { + saveUserFlyoutWidthsToLocalStorageMiddleware()(jest.fn)(randomAction); - expect(localStorage.getItem(EXPANDABLE_FLYOUT_LOCAL_STORAGE)).toEqual(null); + expect(localStorage.getItem(EXPANDABLE_FLYOUT_LOCAL_STORAGE)).toEqual(null); + }); + + it('should save collapsed value to local storage if action is of type changeUserCollapsedWidthAction', () => { + saveUserFlyoutWidthsToLocalStorageMiddleware()(jest.fn)( + changeUserCollapsedWidthAction({ width: 250, savedToLocalStorage: true }) + ); + + const expandableFlyout = localStorage.getItem(EXPANDABLE_FLYOUT_LOCAL_STORAGE); + expect(expandableFlyout).not.toBe(null); + + if (expandableFlyout) { + expect(JSON.parse(expandableFlyout)[USER_COLLAPSED_WIDTH_LOCAL_STORAGE]).toEqual(250); + } + }); + + it('should save expanded value to local storage if action is of type changeUserExpandedWidthAction', () => { + saveUserFlyoutWidthsToLocalStorageMiddleware()(jest.fn)( + changeUserExpandedWidthAction({ width: 500, savedToLocalStorage: true }) + ); + + const expandableFlyout = localStorage.getItem(EXPANDABLE_FLYOUT_LOCAL_STORAGE); + expect(expandableFlyout).not.toBe(null); + + if (expandableFlyout) { + expect(JSON.parse(expandableFlyout)[USER_EXPANDED_WIDTH_LOCAL_STORAGE]).toEqual(500); + } + }); + + it('should not save collapsed value to local storage if savedToLocalStorage is false', () => { + saveUserFlyoutWidthsToLocalStorageMiddleware()(jest.fn)( + changeUserCollapsedWidthAction({ width: 250, savedToLocalStorage: false }) + ); + + expect(localStorage.getItem(EXPANDABLE_FLYOUT_LOCAL_STORAGE)).toEqual(null); + }); + + it('should not save expanded value to local storage if savedToLocalStorage is false', () => { + saveUserFlyoutWidthsToLocalStorageMiddleware()(jest.fn)( + changeUserExpandedWidthAction({ width: 500, savedToLocalStorage: false }) + ); + + expect(localStorage.getItem(EXPANDABLE_FLYOUT_LOCAL_STORAGE)).toEqual(null); + }); }); - it('should save value to local storage if action is of type changePushVsOverlayAction', () => { - savePushVsOverlayToLocalStorageMiddleware({} as MiddlewareAPI)(jest.fn)( - changePushVsOverlayAction({ type: 'push', savedToLocalStorage: true }) - ); + describe('saveUserSectionWidthsToLocalStorageMiddleware', () => { + it('should ignore action without type', () => { + saveUserSectionWidthsToLocalStorageMiddleware()(jest.fn)(noTypeAction); + + expect(localStorage.getItem(EXPANDABLE_FLYOUT_LOCAL_STORAGE)).toEqual(null); + }); + + it('should ignore action of other types ', () => { + saveUserSectionWidthsToLocalStorageMiddleware()(jest.fn)(randomAction); + + expect(localStorage.getItem(EXPANDABLE_FLYOUT_LOCAL_STORAGE)).toEqual(null); + }); + + it('should save section width values to local storage if action is of type changeUserSectionWidthsAction', () => { + saveUserSectionWidthsToLocalStorageMiddleware()(jest.fn)( + changeUserSectionWidthsAction({ + left: 500, + right: 500, + savedToLocalStorage: true, + }) + ); + + const expandableFlyout = localStorage.getItem(EXPANDABLE_FLYOUT_LOCAL_STORAGE); + expect(expandableFlyout).not.toBe(null); + + if (expandableFlyout) { + expect(JSON.parse(expandableFlyout)[USER_SECTION_WIDTHS_LOCAL_STORAGE]).toEqual({ + left: 500, + right: 500, + }); + } + }); - expect(localStorage.getItem(EXPANDABLE_FLYOUT_LOCAL_STORAGE)).toEqual( - JSON.stringify({ [PUSH_VS_OVERLAY_LOCAL_STORAGE]: 'push' }) - ); + it('should not save section width values to local storage if savedToLocalStorage is false', () => { + saveUserSectionWidthsToLocalStorageMiddleware()(jest.fn)( + changeUserSectionWidthsAction({ + left: 500, + right: 500, + savedToLocalStorage: false, + }) + ); + + expect(localStorage.getItem(EXPANDABLE_FLYOUT_LOCAL_STORAGE)).toEqual(null); + }); }); - it('should not save value to local storage if savedToLocalStorage is false', () => { - savePushVsOverlayToLocalStorageMiddleware({} as MiddlewareAPI)(jest.fn)( - changePushVsOverlayAction({ type: 'push', savedToLocalStorage: false }) - ); + describe('clearAllUserWidthsFromLocalStorageMiddleware', () => { + it('should ignore action without type', () => { + clearAllUserWidthsFromLocalStorageMiddleware()(jest.fn)(noTypeAction); + + expect(localStorage.getItem(EXPANDABLE_FLYOUT_LOCAL_STORAGE)).toEqual(null); + }); + + it('should ignore action of other types ', () => { + clearAllUserWidthsFromLocalStorageMiddleware()(jest.fn)(randomAction); - expect(localStorage.getItem(EXPANDABLE_FLYOUT_LOCAL_STORAGE)).toEqual(null); + expect(localStorage.getItem(EXPANDABLE_FLYOUT_LOCAL_STORAGE)).toEqual(null); + }); + + it('should clear width values from local storage if action is of type resetUserCollapsedWidthAction', () => { + localStorage.setItem( + EXPANDABLE_FLYOUT_LOCAL_STORAGE, + JSON.stringify({ + [PUSH_VS_OVERLAY_LOCAL_STORAGE]: 'push', + [USER_COLLAPSED_WIDTH_LOCAL_STORAGE]: 250, + [USER_EXPANDED_WIDTH_LOCAL_STORAGE]: 500, + [USER_SECTION_WIDTHS_LOCAL_STORAGE]: { left: 50, right: 50 }, + }) + ); + + clearAllUserWidthsFromLocalStorageMiddleware()(jest.fn)(resetAllUserChangedWidthsAction()); + + const expandableFlyout = localStorage.getItem(EXPANDABLE_FLYOUT_LOCAL_STORAGE); + expect(expandableFlyout).not.toBe(null); + + if (expandableFlyout) { + const parsed = JSON.parse(expandableFlyout); + expect(parsed[PUSH_VS_OVERLAY_LOCAL_STORAGE]).toEqual('push'); + expect(expandableFlyout).not.toHaveProperty(USER_COLLAPSED_WIDTH_LOCAL_STORAGE); + expect(expandableFlyout).not.toHaveProperty(USER_EXPANDED_WIDTH_LOCAL_STORAGE); + expect(expandableFlyout).not.toHaveProperty(USER_SECTION_WIDTHS_LOCAL_STORAGE); + } + }); }); }); diff --git a/packages/kbn-expandable-flyout/src/store/middlewares.ts b/packages/kbn-expandable-flyout/src/store/middlewares.ts index c9e04ea2846d7..4fb5354535caf 100644 --- a/packages/kbn-expandable-flyout/src/store/middlewares.ts +++ b/packages/kbn-expandable-flyout/src/store/middlewares.ts @@ -7,26 +7,112 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import type { Action, Dispatch, MiddlewareAPI } from '@reduxjs/toolkit'; -import { changePushVsOverlayAction } from './actions'; -import { EXPANDABLE_FLYOUT_LOCAL_STORAGE, PUSH_VS_OVERLAY_LOCAL_STORAGE } from '../constants'; +import type { Action, Dispatch } from '@reduxjs/toolkit'; +import { + changeUserCollapsedWidthAction, + changeUserExpandedWidthAction, + changeUserSectionWidthsAction, + changePushVsOverlayAction, + resetAllUserChangedWidthsAction, +} from './actions'; +import { + USER_COLLAPSED_WIDTH_LOCAL_STORAGE, + EXPANDABLE_FLYOUT_LOCAL_STORAGE, + USER_SECTION_WIDTHS_LOCAL_STORAGE, + PUSH_VS_OVERLAY_LOCAL_STORAGE, + USER_EXPANDED_WIDTH_LOCAL_STORAGE, +} from '../constants'; /** * Middleware to save the push vs overlay state to local storage */ export const savePushVsOverlayToLocalStorageMiddleware = - (store: MiddlewareAPI) => (next: Dispatch) => (action: Action) => { + () => (next: Dispatch) => (action: Action) => { if (!action.type) { return next(action); } if (changePushVsOverlayAction.match(action) && action.payload.savedToLocalStorage) { - localStorage.setItem( - EXPANDABLE_FLYOUT_LOCAL_STORAGE, - JSON.stringify({ - [PUSH_VS_OVERLAY_LOCAL_STORAGE]: action.payload.type, - }) - ); + const currentStringValue = localStorage.getItem(EXPANDABLE_FLYOUT_LOCAL_STORAGE); + const currentJsonValue = currentStringValue ? JSON.parse(currentStringValue) : {}; + + currentJsonValue[PUSH_VS_OVERLAY_LOCAL_STORAGE] = action.payload.type; + + localStorage.setItem(EXPANDABLE_FLYOUT_LOCAL_STORAGE, JSON.stringify(currentJsonValue)); + } + + return next(action); + }; + +/** + * Middleware to save the user collapsed and expanded flyout widths to local storage + */ +export const saveUserFlyoutWidthsToLocalStorageMiddleware = + () => (next: Dispatch) => (action: Action) => { + if (!action.type) { + return next(action); + } + + const currentStringValue = localStorage.getItem(EXPANDABLE_FLYOUT_LOCAL_STORAGE); + const currentJsonValue = currentStringValue ? JSON.parse(currentStringValue) : {}; + + if (changeUserCollapsedWidthAction.match(action) && action.payload.savedToLocalStorage) { + currentJsonValue[USER_COLLAPSED_WIDTH_LOCAL_STORAGE] = action.payload.width; + + localStorage.setItem(EXPANDABLE_FLYOUT_LOCAL_STORAGE, JSON.stringify(currentJsonValue)); + } + + if (changeUserExpandedWidthAction.match(action) && action.payload.savedToLocalStorage) { + currentJsonValue[USER_EXPANDED_WIDTH_LOCAL_STORAGE] = action.payload.width; + + localStorage.setItem(EXPANDABLE_FLYOUT_LOCAL_STORAGE, JSON.stringify(currentJsonValue)); + } + + return next(action); + }; + +/** + * Middleware to save the user left and right section widths to local storage + */ +export const saveUserSectionWidthsToLocalStorageMiddleware = + () => (next: Dispatch) => (action: Action) => { + if (!action.type) { + return next(action); + } + + if (changeUserSectionWidthsAction.match(action) && action.payload.savedToLocalStorage) { + const currentStringValue = localStorage.getItem(EXPANDABLE_FLYOUT_LOCAL_STORAGE); + const currentJsonValue = currentStringValue ? JSON.parse(currentStringValue) : {}; + + currentJsonValue[USER_SECTION_WIDTHS_LOCAL_STORAGE] = { + left: action.payload.left, + right: action.payload.right, + }; + + localStorage.setItem(EXPANDABLE_FLYOUT_LOCAL_STORAGE, JSON.stringify(currentJsonValue)); + } + + return next(action); + }; + +/** + * Middleware to save the user left and right section widths to local storage + */ +export const clearAllUserWidthsFromLocalStorageMiddleware = + () => (next: Dispatch) => (action: Action) => { + if (!action.type) { + return next(action); + } + + if (resetAllUserChangedWidthsAction.match(action)) { + const currentStringValue = localStorage.getItem(EXPANDABLE_FLYOUT_LOCAL_STORAGE); + const currentJsonValue = currentStringValue ? JSON.parse(currentStringValue) : {}; + + delete currentJsonValue[USER_COLLAPSED_WIDTH_LOCAL_STORAGE]; + delete currentJsonValue[USER_EXPANDED_WIDTH_LOCAL_STORAGE]; + delete currentJsonValue[USER_SECTION_WIDTHS_LOCAL_STORAGE]; + + localStorage.setItem(EXPANDABLE_FLYOUT_LOCAL_STORAGE, JSON.stringify(currentJsonValue)); } return next(action); diff --git a/packages/kbn-expandable-flyout/src/store/reducers.test.ts b/packages/kbn-expandable-flyout/src/store/reducers.test.ts index 78caea13bd2d3..1a887333daca8 100644 --- a/packages/kbn-expandable-flyout/src/store/reducers.test.ts +++ b/packages/kbn-expandable-flyout/src/store/reducers.test.ts @@ -12,6 +12,9 @@ import { panelsReducer, uiReducer } from './reducers'; import { initialPanelsState, PanelsState, initialUiState, UiState } from './state'; import { changePushVsOverlayAction, + changeUserCollapsedWidthAction, + changeUserExpandedWidthAction, + changeUserSectionWidthsAction, closeLeftPanelAction, closePanelsAction, closePreviewPanelAction, @@ -21,6 +24,8 @@ import { openPreviewPanelAction, openRightPanelAction, previousPreviewPanelAction, + resetAllUserChangedWidthsAction, + setDefaultWidthsAction, } from './actions'; const id1 = 'id1'; @@ -794,12 +799,14 @@ describe('uiReducer', () => { const newState: UiState = uiReducer(state, action); expect(newState).toEqual({ + ...state, pushVsOverlay: 'push', }); }); it('should override value if id already exists', () => { const state: UiState = { + ...initialUiState, pushVsOverlay: 'push', }; const action = changePushVsOverlayAction({ @@ -809,8 +816,239 @@ describe('uiReducer', () => { const newState: UiState = uiReducer(state, action); expect(newState).toEqual({ + ...state, pushVsOverlay: 'overlay', }); }); }); + + describe('should handle setDefaultWidthsAction action', () => { + it('should set value state is empty', () => { + const state: UiState = initialUiState; + const action = setDefaultWidthsAction({ + right: 200, + left: 600, + preview: 200, + }); + const newState: UiState = uiReducer(state, action); + + expect(newState).toEqual({ + ...state, + defaultWidths: { + rightWidth: 200, + leftWidth: 600, + previewWidth: 200, + rightPercentage: 25, + leftPercentage: 75, + previewPercentage: 25, + }, + }); + }); + + it('should override value if state not empty', () => { + const state: UiState = { + ...initialUiState, + defaultWidths: { + rightWidth: 200, + leftWidth: 600, + previewWidth: 200, + rightPercentage: 25, + leftPercentage: 75, + previewPercentage: 25, + }, + }; + const action = setDefaultWidthsAction({ + right: 500, + left: 500, + preview: 500, + }); + const newState: UiState = uiReducer(state, action); + + expect(newState).toEqual({ + ...state, + defaultWidths: { + rightWidth: 500, + leftWidth: 500, + previewWidth: 500, + rightPercentage: 50, + leftPercentage: 50, + previewPercentage: 50, + }, + }); + }); + }); + + describe('should handle changeUserCollapsedWidthAction action', () => { + it('should set value state is empty', () => { + const state: UiState = initialUiState; + const action = changeUserCollapsedWidthAction({ + width: 200, + savedToLocalStorage: false, + }); + const newState: UiState = uiReducer(state, action); + + expect(newState).toEqual({ + ...state, + userFlyoutWidths: { + collapsedWidth: 200, + }, + }); + }); + + it('should override value if state not empty', () => { + const state: UiState = { + ...initialUiState, + userFlyoutWidths: { + collapsedWidth: 200, + expandedWidth: 500, + }, + }; + const action = changeUserCollapsedWidthAction({ + width: 250, + savedToLocalStorage: false, + }); + const newState: UiState = uiReducer(state, action); + + expect(newState).toEqual({ + ...state, + userFlyoutWidths: { + collapsedWidth: 250, + expandedWidth: 500, + }, + }); + }); + }); + + describe('should handle changeUserExpandedWidthAction action', () => { + it('should set value state is empty', () => { + const state: UiState = initialUiState; + const action = changeUserExpandedWidthAction({ + width: 500, + savedToLocalStorage: false, + }); + const newState: UiState = uiReducer(state, action); + + expect(newState).toEqual({ + ...state, + userFlyoutWidths: { + expandedWidth: 500, + }, + }); + }); + + it('should override value if state not empty', () => { + const state: UiState = { + ...initialUiState, + userFlyoutWidths: { + collapsedWidth: 200, + expandedWidth: 500, + }, + }; + const action = changeUserExpandedWidthAction({ + width: 1000, + savedToLocalStorage: false, + }); + const newState: UiState = uiReducer(state, action); + + expect(newState).toEqual({ + ...state, + userFlyoutWidths: { + collapsedWidth: 200, + expandedWidth: 1000, + }, + }); + }); + }); + + describe('should handle changeUserSectionWidthsAction action', () => { + it('should set value state is empty', () => { + const state: UiState = initialUiState; + const action = changeUserSectionWidthsAction({ + right: 50, + left: 50, + savedToLocalStorage: false, + }); + const newState: UiState = uiReducer(state, action); + + expect(newState).toEqual({ + ...state, + userSectionWidths: { + leftPercentage: 50, + rightPercentage: 50, + }, + }); + }); + + it('should override value if state not empty', () => { + const state: UiState = { + ...initialUiState, + userSectionWidths: { + leftPercentage: 50, + rightPercentage: 50, + }, + }; + const action = changeUserSectionWidthsAction({ + right: 30, + left: 70, + savedToLocalStorage: false, + }); + const newState: UiState = uiReducer(state, action); + + expect(newState).toEqual({ + ...state, + userSectionWidths: { + leftPercentage: 70, + rightPercentage: 30, + }, + }); + }); + }); + + describe('should handle resetAllUserChangedWidthsAction action', () => { + it('should set value state is empty', () => { + const state: UiState = initialUiState; + const action = resetAllUserChangedWidthsAction(); + const newState: UiState = uiReducer(state, action); + + expect(newState).toEqual({ + ...state, + userSectionWidths: { + leftPercentage: undefined, + rightPercentage: undefined, + }, + userFlyoutWidths: { + collapsedWidth: undefined, + expandedWidth: undefined, + }, + }); + }); + + it('should override value if state not empty', () => { + const state: UiState = { + ...initialUiState, + userFlyoutWidths: { + collapsedWidth: 200, + expandedWidth: 500, + }, + userSectionWidths: { + leftPercentage: 50, + rightPercentage: 50, + }, + }; + const action = resetAllUserChangedWidthsAction(); + const newState: UiState = uiReducer(state, action); + + expect(newState).toEqual({ + ...state, + userSectionWidths: { + leftPercentage: undefined, + rightPercentage: undefined, + }, + userFlyoutWidths: { + collapsedWidth: undefined, + expandedWidth: undefined, + }, + }); + }); + }); }); diff --git a/packages/kbn-expandable-flyout/src/store/reducers.ts b/packages/kbn-expandable-flyout/src/store/reducers.ts index 54918f5c6d7bb..b14aa0b1b703b 100644 --- a/packages/kbn-expandable-flyout/src/store/reducers.ts +++ b/packages/kbn-expandable-flyout/src/store/reducers.ts @@ -21,6 +21,11 @@ import { openPreviewPanelAction, urlChangedAction, changePushVsOverlayAction, + setDefaultWidthsAction, + changeUserCollapsedWidthAction, + changeUserExpandedWidthAction, + changeUserSectionWidthsAction, + resetAllUserChangedWidthsAction, } from './actions'; import { initialPanelsState, initialUiState } from './state'; @@ -155,4 +160,33 @@ export const uiReducer = createReducer(initialUiState, (builder) => { builder.addCase(changePushVsOverlayAction, (state, { payload: { type } }) => { state.pushVsOverlay = type; }); + + builder.addCase(setDefaultWidthsAction, (state, { payload: { right, left, preview } }) => { + state.defaultWidths.rightWidth = right; + state.defaultWidths.leftWidth = left; + state.defaultWidths.previewWidth = preview; + state.defaultWidths.rightPercentage = (right / (right + left)) * 100; + state.defaultWidths.leftPercentage = (left / (right + left)) * 100; + state.defaultWidths.previewPercentage = (right / (right + left)) * 100; + }); + + builder.addCase(changeUserCollapsedWidthAction, (state, { payload: { width } }) => { + state.userFlyoutWidths.collapsedWidth = width; + }); + + builder.addCase(changeUserExpandedWidthAction, (state, { payload: { width } }) => { + state.userFlyoutWidths.expandedWidth = width; + }); + + builder.addCase(changeUserSectionWidthsAction, (state, { payload: { right, left } }) => { + state.userSectionWidths.leftPercentage = left; + state.userSectionWidths.rightPercentage = right; + }); + + builder.addCase(resetAllUserChangedWidthsAction, (state) => { + state.userFlyoutWidths.collapsedWidth = undefined; + state.userFlyoutWidths.expandedWidth = undefined; + state.userSectionWidths.leftPercentage = undefined; + state.userSectionWidths.rightPercentage = undefined; + }); }); diff --git a/packages/kbn-expandable-flyout/src/store/redux.ts b/packages/kbn-expandable-flyout/src/store/redux.ts index 9951334a247f3..d68b4a0295769 100644 --- a/packages/kbn-expandable-flyout/src/store/redux.ts +++ b/packages/kbn-expandable-flyout/src/store/redux.ts @@ -13,7 +13,12 @@ import { configureStore } from '@reduxjs/toolkit'; import { createSelector } from 'reselect'; import { panelsReducer, uiReducer } from './reducers'; import { initialState, State } from './state'; -import { savePushVsOverlayToLocalStorageMiddleware } from './middlewares'; +import { + savePushVsOverlayToLocalStorageMiddleware, + saveUserSectionWidthsToLocalStorageMiddleware, + saveUserFlyoutWidthsToLocalStorageMiddleware, + clearAllUserWidthsFromLocalStorageMiddleware, +} from './middlewares'; export const store = configureStore({ reducer: { @@ -21,7 +26,12 @@ export const store = configureStore({ ui: uiReducer, }, devTools: process.env.NODE_ENV !== 'production', - middleware: [savePushVsOverlayToLocalStorageMiddleware], + middleware: [ + savePushVsOverlayToLocalStorageMiddleware, + saveUserSectionWidthsToLocalStorageMiddleware, + saveUserFlyoutWidthsToLocalStorageMiddleware, + clearAllUserWidthsFromLocalStorageMiddleware, + ], }); export const Context = createContext>({ @@ -41,3 +51,9 @@ export const selectNeedsSync = () => createSelector(panelsSelector, (state) => s const uiSelector = createSelector(stateSelector, (state) => state.ui); export const selectPushVsOverlay = createSelector(uiSelector, (state) => state.pushVsOverlay); +export const selectDefaultWidths = createSelector(uiSelector, (state) => state.defaultWidths); +export const selectUserFlyoutWidths = createSelector(uiSelector, (state) => state.userFlyoutWidths); +export const selectUserSectionWidths = createSelector( + uiSelector, + (state) => state.userSectionWidths +); diff --git a/packages/kbn-expandable-flyout/src/store/state.ts b/packages/kbn-expandable-flyout/src/store/state.ts index a794d0db34d28..e158f61aaccd5 100644 --- a/packages/kbn-expandable-flyout/src/store/state.ts +++ b/packages/kbn-expandable-flyout/src/store/state.ts @@ -44,15 +44,79 @@ export const initialPanelsState: PanelsState = { needsSync: false, }; +export interface DefaultWidthsState { + /** + * Default width for the right section (calculated from the window width) + */ + rightWidth: number; + /** + * Default width for the left section (calculated from the window width) + */ + leftWidth: number; + /** + * Default width for the preview section (calculated from the window width) + */ + previewWidth: number; + /** + * Value of the right width in percentage (of the flyout total width) + */ + rightPercentage: number; + /** + * Value of the left width in percentage (of the flyout total width) + */ + leftPercentage: number; + /** + * Value of the preview width in percentage (of the flyout total width) + */ + previewPercentage: number; +} + +export interface UserFlyoutWidthsState { + /** + * Width of the collapsed flyout + */ + collapsedWidth?: number; + /** + * Width of the expanded flyout + */ + expandedWidth?: number; +} + +export interface UserSectionWidthsState { + /** + * Percentage for the left section + */ + leftPercentage: number | undefined; + /** + * Percentage for the right section + */ + rightPercentage: number | undefined; +} + export interface UiState { /** * Push vs overlay information */ pushVsOverlay: 'push' | 'overlay'; + /** + * Default widths for the flyout + */ + defaultWidths: DefaultWidthsState; + /** + * User resized widths for the flyout + */ + userFlyoutWidths: UserFlyoutWidthsState; + /** + * User resized left and right section widths for the flyout + */ + userSectionWidths: UserSectionWidthsState; } export const initialUiState: UiState = { pushVsOverlay: 'overlay', + defaultWidths: {} as DefaultWidthsState, + userFlyoutWidths: {}, + userSectionWidths: {} as UserSectionWidthsState, }; export interface State { diff --git a/packages/kbn-expandable-flyout/src/test/provider.tsx b/packages/kbn-expandable-flyout/src/test/provider.tsx index 0dc2656e15c7e..81de83720afd7 100644 --- a/packages/kbn-expandable-flyout/src/test/provider.tsx +++ b/packages/kbn-expandable-flyout/src/test/provider.tsx @@ -11,7 +11,11 @@ import { Provider as ReduxProvider } from 'react-redux'; import { configureStore } from '@reduxjs/toolkit'; import React, { FC, PropsWithChildren } from 'react'; import { I18nProvider } from '@kbn/i18n-react'; -import { savePushVsOverlayToLocalStorageMiddleware } from '../store/middlewares'; +import { + savePushVsOverlayToLocalStorageMiddleware, + saveUserSectionWidthsToLocalStorageMiddleware, + saveUserFlyoutWidthsToLocalStorageMiddleware, +} from '../store/middlewares'; import { ExpandableFlyoutContextProvider } from '../context'; import { panelsReducer, uiReducer } from '../store/reducers'; import { Context } from '../store/redux'; @@ -34,7 +38,11 @@ export const TestProvider: FC> = ({ }, devTools: false, preloadedState: state, - middleware: [savePushVsOverlayToLocalStorageMiddleware], + middleware: [ + savePushVsOverlayToLocalStorageMiddleware, + saveUserSectionWidthsToLocalStorageMiddleware, + saveUserFlyoutWidthsToLocalStorageMiddleware, + ], }); return (