From 8cb36177312e562a39e99702776f59293cd6a658 Mon Sep 17 00:00:00 2001 From: Hannah Mudge Date: Mon, 24 Feb 2025 11:29:00 -0700 Subject: [PATCH] [kbn-grid-layout] Add ability to create, edit, and delete rows (#209193) Closes https://github.com/elastic/kibana/issues/204849 ## Summary This PR adds the ability to create, edit, and delete sections / rows to `kbn-grid-layout`: https://github.com/user-attachments/assets/4831b289-2c71-42fb-851d-0925560e233a Note that sections are still statically placed - dragging rows around will be added in a follow-up PR, because it's a larger undertaking. Since this feature is not available to users yet, it is okay to implement this in stages like this. ### Checklist - [x] Any text added follows [EUI's writing guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses sentence case text and includes [i18n support](https://github.com/elastic/kibana/blob/main/src/platform/packages/shared/kbn-i18n/README.md) - [x] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios - [x] The PR description includes the appropriate Release Notes section, and the correct `release_note:*` label is applied per the [guidelines](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process) ### Identify risks Collapsible sections are not available on Dashboard yet and so there is no user-facing risk to this PR. --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: Marta Bondyra <4283304+mbondyra@users.noreply.github.com> --- .../components/add_button.tsx | 12 + examples/grid_example/public/app.tsx | 215 +++++++---------- .../public/grid_layout_options.tsx | 116 +++++++++ examples/grid_example/public/types.ts | 18 ++ .../public/use_mock_dashboard_api.tsx | 16 +- .../kbn-grid-layout/grid/grid_layout.test.tsx | 3 +- .../kbn-grid-layout/grid/grid_layout.tsx | 3 + .../grid/grid_panel/grid_panel.test.tsx | 4 +- .../grid/grid_row/delete_grid_row_modal.tsx | 102 ++++++++ .../grid/grid_row/grid_row.test.tsx | 14 +- .../grid/grid_row/grid_row.tsx | 75 +++--- .../grid/grid_row/grid_row_header.test.tsx | 148 ++++++++++++ .../grid/grid_row/grid_row_header.tsx | 226 +++++++++++++++--- .../grid/grid_row/grid_row_title.tsx | 187 +++++++++++++++ .../kbn-grid-layout/grid/test_utils/mocks.tsx | 36 +-- .../grid/use_grid_layout_state.ts | 3 +- .../grid/utils/row_management.ts | 44 ++++ 17 files changed, 1004 insertions(+), 218 deletions(-) create mode 100644 examples/grid_example/public/grid_layout_options.tsx create mode 100644 src/platform/packages/private/kbn-grid-layout/grid/grid_row/delete_grid_row_modal.tsx create mode 100644 src/platform/packages/private/kbn-grid-layout/grid/grid_row/grid_row_header.test.tsx create mode 100644 src/platform/packages/private/kbn-grid-layout/grid/grid_row/grid_row_title.tsx create mode 100644 src/platform/packages/private/kbn-grid-layout/grid/utils/row_management.ts diff --git a/examples/embeddable_examples/public/app/presentation_container_example/components/add_button.tsx b/examples/embeddable_examples/public/app/presentation_container_example/components/add_button.tsx index 8bfa21696451b..72deeccc11fd0 100644 --- a/examples/embeddable_examples/public/app/presentation_container_example/components/add_button.tsx +++ b/examples/embeddable_examples/public/app/presentation_container_example/components/add_button.tsx @@ -10,11 +10,22 @@ import React, { ReactElement, useEffect, useState } from 'react'; import { EuiButton, EuiContextMenuItem, EuiContextMenuPanel, EuiPopover } from '@elastic/eui'; import { ADD_PANEL_TRIGGER, UiActionsStart } from '@kbn/ui-actions-plugin/public'; +import { + PublishingSubject, + ViewMode, + apiPublishesViewMode, + useStateFromPublishingSubject, +} from '@kbn/presentation-publishing'; +import { of } from 'rxjs'; export function AddButton({ pageApi, uiActions }: { pageApi: unknown; uiActions: UiActionsStart }) { const [isPopoverOpen, setIsPopoverOpen] = useState(false); const [items, setItems] = useState([]); + const viewMode = useStateFromPublishingSubject( + apiPublishesViewMode(pageApi) ? pageApi?.viewMode$ : (of('edit') as PublishingSubject) + ); + useEffect(() => { let cancelled = false; @@ -59,6 +70,7 @@ export function AddButton({ pageApi, uiActions }: { pageApi: unknown; uiActions: onClick={() => { setIsPopoverOpen(!isPopoverOpen); }} + disabled={viewMode !== 'edit'} > Add panel diff --git a/examples/grid_example/public/app.tsx b/examples/grid_example/public/app.tsx index 6cc463c0b8a6b..4ea2e4ed7bf42 100644 --- a/examples/grid_example/public/app.tsx +++ b/examples/grid_example/public/app.tsx @@ -10,20 +10,16 @@ import deepEqual from 'fast-deep-equal'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import ReactDOM from 'react-dom'; -import { combineLatest, debounceTime } from 'rxjs'; +import { Subject, combineLatest, debounceTime, map, skip, take } from 'rxjs'; import { EuiBadge, EuiButton, EuiButtonEmpty, - EuiButtonGroup, EuiCallOut, EuiFlexGroup, EuiFlexItem, - EuiFormRow, EuiPageTemplate, - EuiPopover, - EuiRange, EuiSpacer, transparentize, useEuiTheme, @@ -33,12 +29,13 @@ import { AppMountParameters } from '@kbn/core-application-browser'; import { CoreStart } from '@kbn/core-lifecycle-browser'; import { AddEmbeddableButton } from '@kbn/embeddable-examples-plugin/public'; import { ReactEmbeddableRenderer } from '@kbn/embeddable-plugin/public'; -import { GridLayout, GridLayoutData } from '@kbn/grid-layout'; +import { GridLayout, GridLayoutData, GridSettings } from '@kbn/grid-layout'; import { i18n } from '@kbn/i18n'; import { useBatchedPublishingSubjects } from '@kbn/presentation-publishing'; import { KibanaRenderContextProvider } from '@kbn/react-kibana-context-render'; import { UiActionsStart } from '@kbn/ui-actions-plugin/public'; +import { GridLayoutOptions } from './grid_layout_options'; import { clearSerializedDashboardState, getSerializedDashboardState, @@ -66,39 +63,54 @@ export const GridExample = ({ const [currentLayout, setCurrentLayout] = useState( dashboardInputToGridLayout(savedState.current) ); - const [isSettingsPopoverOpen, setIsSettingsPopoverOpen] = useState(false); - const [gutterSize, setGutterSize] = useState(DASHBOARD_MARGIN_SIZE); - const [rowHeight, setRowHeight] = useState(DASHBOARD_GRID_HEIGHT); + const [gridSettings, setGridSettings] = useState({ + gutterSize: DASHBOARD_MARGIN_SIZE, + rowHeight: DASHBOARD_GRID_HEIGHT, + columnCount: DASHBOARD_GRID_COLUMN_COUNT, + }); const mockDashboardApi = useMockDashboardApi({ savedState: savedState.current }); const [viewMode, expandedPanelId] = useBatchedPublishingSubjects( mockDashboardApi.viewMode$, mockDashboardApi.expandedPanelId$ ); + const layoutUpdated$ = useMemo(() => new Subject(), []); useEffect(() => { combineLatest([mockDashboardApi.panels$, mockDashboardApi.rows$]) - .pipe(debounceTime(0)) // debounce to avoid subscribe being called twice when both panels$ and rows$ publish - .subscribe(([panels, rows]) => { - const panelIds = Object.keys(panels); - let panelsAreEqual = true; - for (const panelId of panelIds) { - if (!panelsAreEqual) break; - const currentPanel = panels[panelId]; - const savedPanel = savedState.current.panels[panelId]; - panelsAreEqual = deepEqual( - { row: 0, ...currentPanel.gridData }, - { row: 0, ...savedPanel.gridData } - ); - } - - const hasChanges = !(panelsAreEqual && deepEqual(rows, savedState.current.rows)); + .pipe( + debounceTime(0), // debounce to avoid subscribe being called twice when both panels$ and rows$ publish + map(([panels, rows]) => { + const panelIds = Object.keys(panels); + let panelsAreEqual = true; + for (const panelId of panelIds) { + if (!panelsAreEqual) break; + const currentPanel = panels[panelId]; + const savedPanel = savedState.current.panels[panelId]; + panelsAreEqual = deepEqual( + { row: 0, ...currentPanel.gridData }, + { row: 0, ...savedPanel.gridData } + ); + } + const hasChanges = !(panelsAreEqual && deepEqual(rows, savedState.current.rows)); + return { hasChanges, updatedLayout: dashboardInputToGridLayout({ panels, rows }) }; + }) + ) + .subscribe(({ hasChanges, updatedLayout }) => { setHasUnsavedChanges(hasChanges); - setCurrentLayout(dashboardInputToGridLayout({ panels, rows })); + setCurrentLayout(updatedLayout); }); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + /** + * On layout update, emit `layoutUpdated$` so that side effects to layout updates can + * happen (such as scrolling to the bottom of the screen after adding a new section) + */ + useEffect(() => { + layoutUpdated$.next(); + }, [currentLayout, layoutUpdated$]); + const renderPanelContents = useCallback( (id: string, setDragHandles?: (refs: Array) => void) => { const currentPanels = mockDashboardApi.panels$.getValue(); @@ -122,6 +134,41 @@ export const GridExample = ({ [mockDashboardApi] ); + const onLayoutChange = useCallback( + (newLayout: GridLayoutData) => { + const { panels, rows } = gridLayoutToDashboardPanelMap( + mockDashboardApi.panels$.getValue(), + newLayout + ); + mockDashboardApi.panels$.next(panels); + mockDashboardApi.rows$.next(rows); + }, + [mockDashboardApi.panels$, mockDashboardApi.rows$] + ); + + const addNewSection = useCallback(() => { + mockDashboardApi.rows$.next([ + ...mockDashboardApi.rows$.getValue(), + { + title: i18n.translate('examples.gridExample.defaultSectionTitle', { + defaultMessage: 'New collapsible section', + }), + collapsed: false, + }, + ]); + + // scroll to bottom after row is added + layoutUpdated$.pipe(skip(1), take(1)).subscribe(() => { + window.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' }); + }); + }, [mockDashboardApi.rows$, layoutUpdated$]); + + const resetUnsavedChanges = useCallback(() => { + const { panels, rows } = savedState.current; + mockDashboardApi.panels$.next(panels); + mockDashboardApi.rows$.next(rows); + }, [mockDashboardApi.panels$, mockDashboardApi.rows$]); + const customLayoutCss = useMemo(() => { const gridColor = transparentize(euiTheme.colors.backgroundFilledAccentSecondary, 0.2); return css` @@ -196,89 +243,22 @@ export const GridExample = ({ - {' '} + - setIsSettingsPopoverOpen(!isSettingsPopoverOpen)} - > - {i18n.translate('examples.gridExample.settingsPopover.title', { - defaultMessage: 'Layout settings', - })} - - } - isOpen={isSettingsPopoverOpen} - closePopover={() => setIsSettingsPopoverOpen(false)} - > - <> - - { - mockDashboardApi.viewMode$.next(id); - }} - /> - - - setGutterSize(parseInt(e.currentTarget.value, 10))} - showLabels - showValue - /> - - - setRowHeight(parseInt(e.currentTarget.value, 10))} - showLabels - showValue - /> - - - + + {i18n.translate('examples.gridExample.addRowButton', { + defaultMessage: 'Add collapsible section', + })} + + + + @@ -294,13 +274,7 @@ export const GridExample = ({ )} - { - const { panels, rows } = savedState.current; - mockDashboardApi.panels$.next(panels); - mockDashboardApi.rows$.next(rows); - }} - > + {i18n.translate('examples.gridExample.resetLayoutButton', { defaultMessage: 'Reset', })} @@ -332,21 +306,10 @@ export const GridExample = ({ accessMode={viewMode === 'view' ? 'VIEW' : 'EDIT'} expandedPanelId={expandedPanelId} layout={currentLayout} - gridSettings={{ - gutterSize, - rowHeight, - columnCount: DASHBOARD_GRID_COLUMN_COUNT, - }} + gridSettings={gridSettings} useCustomDragHandle={true} renderPanelContents={renderPanelContents} - onLayoutChange={(newLayout) => { - const { panels, rows } = gridLayoutToDashboardPanelMap( - mockDashboardApi.panels$.getValue(), - newLayout - ); - mockDashboardApi.panels$.next(panels); - mockDashboardApi.rows$.next(rows); - }} + onLayoutChange={onLayoutChange} css={customLayoutCss} /> diff --git a/examples/grid_example/public/grid_layout_options.tsx b/examples/grid_example/public/grid_layout_options.tsx new file mode 100644 index 0000000000000..eccbf77185837 --- /dev/null +++ b/examples/grid_example/public/grid_layout_options.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 React, { useState } from 'react'; + +import { EuiButton, EuiButtonGroup, EuiFormRow, EuiPopover, EuiRange } from '@elastic/eui'; +import { GridSettings } from '@kbn/grid-layout'; +import { i18n } from '@kbn/i18n'; +import { ViewMode } from '@kbn/presentation-publishing'; +import { MockDashboardApi } from './types'; + +export const GridLayoutOptions = ({ + viewMode, + mockDashboardApi, + gridSettings, + setGridSettings, +}: { + viewMode: ViewMode; + mockDashboardApi: MockDashboardApi; + gridSettings: GridSettings; + setGridSettings: (settings: GridSettings) => void; +}) => { + const [isSettingsPopoverOpen, setIsSettingsPopoverOpen] = useState(false); + + return ( + setIsSettingsPopoverOpen(!isSettingsPopoverOpen)} + > + {i18n.translate('examples.gridExample.settingsPopover.title', { + defaultMessage: 'Layout settings', + })} + + } + isOpen={isSettingsPopoverOpen} + closePopover={() => setIsSettingsPopoverOpen(false)} + > + <> + + { + mockDashboardApi.setViewMode(id as ViewMode); + }} + /> + + + + setGridSettings({ ...gridSettings, gutterSize: parseInt(e.currentTarget.value, 10) }) + } + showLabels + showValue + /> + + + + setGridSettings({ ...gridSettings, rowHeight: parseInt(e.currentTarget.value, 10) }) + } + showLabels + showValue + /> + + + + ); +}; diff --git a/examples/grid_example/public/types.ts b/examples/grid_example/public/types.ts index 141ba96ed2a75..705b652e3d6bf 100644 --- a/examples/grid_example/public/types.ts +++ b/examples/grid_example/public/types.ts @@ -7,6 +7,15 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ +import { + CanAddNewPanel, + CanExpandPanels, + HasSerializedChildState, + PresentationContainer, +} from '@kbn/presentation-containers'; +import { PublishesWritableViewMode } from '@kbn/presentation-publishing'; +import { BehaviorSubject } from 'rxjs'; + export interface DashboardGridData { w: number; h: number; @@ -32,3 +41,12 @@ export interface MockSerializedDashboardState { panels: MockedDashboardPanelMap; rows: MockedDashboardRowMap; } + +export type MockDashboardApi = PresentationContainer & + CanAddNewPanel & + HasSerializedChildState & + PublishesWritableViewMode & + CanExpandPanels & { + panels$: BehaviorSubject; + rows$: BehaviorSubject; + }; diff --git a/examples/grid_example/public/use_mock_dashboard_api.tsx b/examples/grid_example/public/use_mock_dashboard_api.tsx index 5268c65184b6b..ec91fc762cabd 100644 --- a/examples/grid_example/public/use_mock_dashboard_api.tsx +++ b/examples/grid_example/public/use_mock_dashboard_api.tsx @@ -15,7 +15,9 @@ import { v4 } from 'uuid'; import { TimeRange } from '@kbn/es-query'; import { PanelPackage } from '@kbn/presentation-containers'; +import { ViewMode } from '@kbn/presentation-publishing'; import { + MockDashboardApi, MockSerializedDashboardState, MockedDashboardPanelMap, MockedDashboardRowMap, @@ -29,10 +31,11 @@ export const useMockDashboardApi = ({ savedState, }: { savedState: MockSerializedDashboardState; -}) => { +}): MockDashboardApi => { const mockDashboardApi = useMemo(() => { const panels$ = new BehaviorSubject(savedState.panels); const expandedPanelId$ = new BehaviorSubject(undefined); + const viewMode$ = new BehaviorSubject('edit'); return { getSerializedStateForChild: (id: string) => { @@ -48,8 +51,12 @@ export const useMockDashboardApi = ({ }), filters$: new BehaviorSubject([]), query$: new BehaviorSubject(''), - viewMode$: new BehaviorSubject('edit'), + viewMode$, + setViewMode: (viewMode: ViewMode) => viewMode$.next(viewMode), panels$, + getPanelCount: () => { + return Object.keys(panels$.getValue()).length; + }, rows$: new BehaviorSubject(savedState.rows), expandedPanelId$, expandPanel: (id: string) => { @@ -64,7 +71,7 @@ export const useMockDashboardApi = ({ delete panels[id]; // the grid layout component will handle compacting, if necessary mockDashboardApi.panels$.next(panels); }, - replacePanel: (id: string, newPanel: PanelPackage) => { + replacePanel: async (id: string, newPanel: PanelPackage): Promise => { const currentPanels = mockDashboardApi.panels$.getValue(); const otherPanels = { ...currentPanels }; const oldPanel = currentPanels[id]; @@ -75,8 +82,9 @@ export const useMockDashboardApi = ({ explicitInput: { ...newPanel.initialState, id: newId }, }; mockDashboardApi.panels$.next(otherPanels); + return newId; }, - addNewPanel: async (panelPackage: PanelPackage) => { + addNewPanel: async (panelPackage: PanelPackage): Promise => { // we are only implementing "place at top" here, for demo purposes const currentPanels = mockDashboardApi.panels$.getValue(); const otherPanels = { ...currentPanels }; diff --git a/src/platform/packages/private/kbn-grid-layout/grid/grid_layout.test.tsx b/src/platform/packages/private/kbn-grid-layout/grid/grid_layout.test.tsx index 213b580393737..fa54d83019129 100644 --- a/src/platform/packages/private/kbn-grid-layout/grid/grid_layout.test.tsx +++ b/src/platform/packages/private/kbn-grid-layout/grid/grid_layout.test.tsx @@ -21,6 +21,7 @@ import { touchMoveTo, touchStart, } from './test_utils/events'; +import { EuiThemeProvider } from '@elastic/eui'; const onLayoutChange = jest.fn(); @@ -34,7 +35,7 @@ const renderGridLayout = (propsOverrides: Partial = {}) => { ...propsOverrides, } as GridLayoutProps; - const { rerender, ...rtlRest } = render(); + const { rerender, ...rtlRest } = render(, { wrapper: EuiThemeProvider }); return { ...rtlRest, diff --git a/src/platform/packages/private/kbn-grid-layout/grid/grid_layout.tsx b/src/platform/packages/private/kbn-grid-layout/grid/grid_layout.tsx index c7eac78627113..6790f054e0bb7 100644 --- a/src/platform/packages/private/kbn-grid-layout/grid/grid_layout.tsx +++ b/src/platform/packages/private/kbn-grid-layout/grid/grid_layout.tsx @@ -198,6 +198,9 @@ const styles = { '& .kbnGridRowContainer:has(.kbnGridPanel--expanded)': { '.kbnGridRowHeader': { height: '0px', // used instead of 'display: none' due to a11y concerns + padding: '0px', + display: 'block', + overflow: 'hidden', }, '.kbnGridRow': { display: 'block !important', // overwrite grid display diff --git a/src/platform/packages/private/kbn-grid-layout/grid/grid_panel/grid_panel.test.tsx b/src/platform/packages/private/kbn-grid-layout/grid/grid_panel/grid_panel.test.tsx index b3c22bad19331..5df0b7d831e5e 100644 --- a/src/platform/packages/private/kbn-grid-layout/grid/grid_panel/grid_panel.test.tsx +++ b/src/platform/packages/private/kbn-grid-layout/grid/grid_panel/grid_panel.test.tsx @@ -10,7 +10,7 @@ import React from 'react'; import { render, screen } from '@testing-library/react'; import { GridPanel, type GridPanelProps } from './grid_panel'; -import { gridLayoutStateManagerMock, mockRenderPanelContents } from '../test_utils/mocks'; +import { getGridLayoutStateManagerMock, mockRenderPanelContents } from '../test_utils/mocks'; import { GridLayoutContext, type GridLayoutContextType } from '../use_grid_layout_context'; describe('GridPanel', () => { @@ -20,7 +20,7 @@ describe('GridPanel', () => { }) => { const contextValue = { renderPanelContents: mockRenderPanelContents, - gridLayoutStateManager: gridLayoutStateManagerMock, + gridLayoutStateManager: getGridLayoutStateManagerMock(), ...(overrides?.contextOverrides ?? {}), } as GridLayoutContextType; const panelProps = { diff --git a/src/platform/packages/private/kbn-grid-layout/grid/grid_row/delete_grid_row_modal.tsx b/src/platform/packages/private/kbn-grid-layout/grid/grid_row/delete_grid_row_modal.tsx new file mode 100644 index 0000000000000..9aba4f4230ad9 --- /dev/null +++ b/src/platform/packages/private/kbn-grid-layout/grid/grid_row/delete_grid_row_modal.tsx @@ -0,0 +1,102 @@ +/* + * 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 { + EuiButton, + EuiButtonEmpty, + EuiModal, + EuiModalBody, + EuiModalFooter, + EuiModalHeader, + EuiModalHeaderTitle, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + +import { deleteRow, movePanelsToRow } from '../utils/row_management'; +import { useGridLayoutContext } from '../use_grid_layout_context'; + +export const DeleteGridRowModal = ({ + rowIndex, + setDeleteModalVisible, +}: { + rowIndex: number; + setDeleteModalVisible: (visible: boolean) => void; +}) => { + const { gridLayoutStateManager } = useGridLayoutContext(); + + return ( + { + setDeleteModalVisible(false); + }} + > + + + {i18n.translate('kbnGridLayout.deleteGridRowModal.title', { + defaultMessage: 'Delete section', + })} + + + + {i18n.translate('kbnGridLayout.deleteGridRowModal.body', { + defaultMessage: + 'Choose to remove the section, including its contents, or only the section.', + })} + + + { + setDeleteModalVisible(false); + }} + > + {i18n.translate('kbnGridLayout.deleteGridRowModal.cancelButton', { + defaultMessage: 'Cancel', + })} + + { + setDeleteModalVisible(false); + let newLayout = movePanelsToRow( + gridLayoutStateManager.gridLayout$.getValue(), + rowIndex, + 0 + ); + newLayout = deleteRow(newLayout, rowIndex); + gridLayoutStateManager.gridLayout$.next(newLayout); + }} + color="danger" + > + {i18n.translate('kbnGridLayout.deleteGridRowModal.confirmDeleteSection', { + defaultMessage: 'Delete section only', + })} + + { + setDeleteModalVisible(false); + const newLayout = deleteRow(gridLayoutStateManager.gridLayout$.getValue(), rowIndex); + gridLayoutStateManager.gridLayout$.next(newLayout); + }} + fill + color="danger" + > + {i18n.translate('kbnGridLayout.deleteGridRowModal.confirmDeleteAllPanels', { + defaultMessage: + 'Delete section and {panelCount} {panelCount, plural, one {panel} other {panels}}', + values: { + panelCount: Object.keys( + gridLayoutStateManager.gridLayout$.getValue()[rowIndex].panels + ).length, + }, + })} + + + + ); +}; diff --git a/src/platform/packages/private/kbn-grid-layout/grid/grid_row/grid_row.test.tsx b/src/platform/packages/private/kbn-grid-layout/grid/grid_row/grid_row.test.tsx index ad30f118780c4..523453d8dd1e4 100644 --- a/src/platform/packages/private/kbn-grid-layout/grid/grid_row/grid_row.test.tsx +++ b/src/platform/packages/private/kbn-grid-layout/grid/grid_row/grid_row.test.tsx @@ -7,12 +7,15 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ import React from 'react'; + +import { EuiThemeProvider } from '@elastic/eui'; import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { GridRow, type GridRowProps } from './grid_row'; -import { gridLayoutStateManagerMock, mockRenderPanelContents } from '../test_utils/mocks'; + +import { getGridLayoutStateManagerMock, mockRenderPanelContents } from '../test_utils/mocks'; import { getSampleLayout } from '../test_utils/sample_layout'; import { GridLayoutContext, type GridLayoutContextType } from '../use_grid_layout_context'; +import { GridRow, GridRowProps } from './grid_row'; describe('GridRow', () => { const renderGridRow = ( @@ -24,13 +27,14 @@ describe('GridRow', () => { value={ { renderPanelContents: mockRenderPanelContents, - gridLayoutStateManager: gridLayoutStateManagerMock, + gridLayoutStateManager: getGridLayoutStateManagerMock(), ...contextOverrides, } as GridLayoutContextType } > - + , + { wrapper: EuiThemeProvider } ); }; @@ -45,11 +49,13 @@ describe('GridRow', () => { it('does not show the panels in a row that is collapsed', async () => { renderGridRow({ rowIndex: 1 }); + expect(screen.getByTestId('kbnGridRowTitle-1').ariaExpanded).toBe('true'); expect(screen.getAllByText(/panel content/)).toHaveLength(1); const collapseButton = screen.getByRole('button', { name: /toggle collapse/i }); await userEvent.click(collapseButton); + expect(screen.getByTestId('kbnGridRowTitle-1').ariaExpanded).toBe('false'); expect(screen.queryAllByText(/panel content/)).toHaveLength(0); }); }); diff --git a/src/platform/packages/private/kbn-grid-layout/grid/grid_row/grid_row.tsx b/src/platform/packages/private/kbn-grid-layout/grid/grid_row/grid_row.tsx index 1d1f7c6d93682..5df877fcb13d9 100644 --- a/src/platform/packages/private/kbn-grid-layout/grid/grid_row/grid_row.tsx +++ b/src/platform/packages/private/kbn-grid-layout/grid/grid_row/grid_row.tsx @@ -7,9 +7,10 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ +import classNames from 'classnames'; import { cloneDeep } from 'lodash'; -import React, { useEffect, useState } from 'react'; -import { combineLatest, distinctUntilChanged, map, pairwise, skip } from 'rxjs'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { combineLatest, map, pairwise, skip } from 'rxjs'; import { css } from '@emotion/react'; @@ -25,13 +26,12 @@ export interface GridRowProps { export const GridRow = React.memo(({ rowIndex }: GridRowProps) => { const { gridLayoutStateManager } = useGridLayoutContext(); - + const collapseButtonRef = useRef(null); const currentRow = gridLayoutStateManager.gridLayout$.value[rowIndex]; const [panelIdsInOrder, setPanelIdsInOrder] = useState(() => getKeysInOrder(currentRow.panels) ); - const [rowTitle, setRowTitle] = useState(currentRow.title); const [isCollapsed, setIsCollapsed] = useState(currentRow.isCollapsed); useEffect( @@ -42,7 +42,6 @@ export const GridRow = React.memo(({ rowIndex }: GridRowProps) => { .subscribe((interactionEvent) => { const rowRef = gridLayoutStateManager.rowRefs.current[rowIndex]; if (!rowRef) return; - const targetRow = interactionEvent?.targetRowIndex; if (rowIndex === targetRow && interactionEvent) { rowRef.classList.add('kbnGridRow--targeted'); @@ -53,7 +52,6 @@ export const GridRow = React.memo(({ rowIndex }: GridRowProps) => { /** * This subscription ensures that the row will re-render when one of the following changes: - * - Title * - Collapsed state * - Panel IDs (adding/removing/replacing, but not reordering) */ @@ -65,17 +63,16 @@ export const GridRow = React.memo(({ rowIndex }: GridRowProps) => { map(([proposedGridLayout, gridLayout]) => { const displayedGridLayout = proposedGridLayout ?? gridLayout; return { - title: displayedGridLayout[rowIndex].title, - isCollapsed: displayedGridLayout[rowIndex].isCollapsed, - panelIds: Object.keys(displayedGridLayout[rowIndex].panels), + isCollapsed: displayedGridLayout[rowIndex]?.isCollapsed ?? false, + panelIds: Object.keys(displayedGridLayout[rowIndex]?.panels ?? {}), }; }), pairwise() ) .subscribe(([oldRowData, newRowData]) => { - if (oldRowData.title !== newRowData.title) setRowTitle(newRowData.title); - if (oldRowData.isCollapsed !== newRowData.isCollapsed) + if (oldRowData.isCollapsed !== newRowData.isCollapsed) { setIsCollapsed(newRowData.isCollapsed); + } if ( oldRowData.panelIds.length !== newRowData.panelIds.length || !( @@ -86,7 +83,7 @@ export const GridRow = React.memo(({ rowIndex }: GridRowProps) => { setPanelIdsInOrder( getKeysInOrder( (gridLayoutStateManager.proposedGridLayout$.getValue() ?? - gridLayoutStateManager.gridLayout$.getValue())[rowIndex].panels + gridLayoutStateManager.gridLayout$.getValue())[rowIndex]?.panels ?? {} ) ); } @@ -98,54 +95,62 @@ export const GridRow = React.memo(({ rowIndex }: GridRowProps) => { * reasons (screen readers and focus management). */ const gridLayoutSubscription = gridLayoutStateManager.gridLayout$.subscribe((gridLayout) => { + if (!gridLayout[rowIndex]) return; const newPanelIdsInOrder = getKeysInOrder(gridLayout[rowIndex].panels); if (panelIdsInOrder.join() !== newPanelIdsInOrder.join()) { setPanelIdsInOrder(newPanelIdsInOrder); } }); - const columnCountSubscription = gridLayoutStateManager.runtimeSettings$ - .pipe( - map(({ columnCount }) => columnCount), - distinctUntilChanged() - ) - .subscribe((columnCount) => { - const rowRef = gridLayoutStateManager.rowRefs.current[rowIndex]; - if (!rowRef) return; - rowRef.style.setProperty('--kbnGridRowColumnCount', `${columnCount}`); - }); - return () => { interactionStyleSubscription.unsubscribe(); gridLayoutSubscription.unsubscribe(); rowStateSubscription.unsubscribe(); - columnCountSubscription.unsubscribe(); }; }, // eslint-disable-next-line react-hooks/exhaustive-deps [rowIndex] ); + const toggleIsCollapsed = useCallback(() => { + const newLayout = cloneDeep(gridLayoutStateManager.gridLayout$.value); + newLayout[rowIndex].isCollapsed = !newLayout[rowIndex].isCollapsed; + gridLayoutStateManager.gridLayout$.next(newLayout); + }, [rowIndex, gridLayoutStateManager.gridLayout$]); + + useEffect(() => { + /** + * Set `aria-expanded` without passing the expanded state as a prop to `GridRowHeader` in order + * to prevent `GridRowHeader` from rerendering when this state changes + */ + if (!collapseButtonRef.current) return; + collapseButtonRef.current.ariaExpanded = `${!isCollapsed}`; + }, [isCollapsed]); + return ( -
+
{rowIndex !== 0 && ( { - const newLayout = cloneDeep(gridLayoutStateManager.gridLayout$.value); - newLayout[rowIndex].isCollapsed = !newLayout[rowIndex].isCollapsed; - gridLayoutStateManager.gridLayout$.next(newLayout); - }} - rowTitle={rowTitle} + rowIndex={rowIndex} + toggleIsCollapsed={toggleIsCollapsed} + collapseButtonRef={collapseButtonRef} /> )} {!isCollapsed && (
(gridLayoutStateManager.rowRefs.current[rowIndex] = element) } css={[styles.fullHeight, styles.grid]} + role="region" + aria-labelledby={`kbnGridRowTile-${rowIndex}`} > {/* render the panels **in order** for accessibility, using the memoized panel components */} {panelIdsInOrder.map((panelId) => ( @@ -169,10 +174,10 @@ const styles = { gap: 'calc(var(--kbnGridGutterSize) * 1px)', gridAutoRows: 'calc(var(--kbnGridRowHeight) * 1px)', gridTemplateColumns: `repeat( - var(--kbnGridRowColumnCount), + var(--kbnGridColumnCount), calc( - (100% - (var(--kbnGridGutterSize) * (var(--kbnGridRowColumnCount) - 1) * 1px)) / - var(--kbnGridRowColumnCount) + (100% - (var(--kbnGridGutterSize) * (var(--kbnGridColumnCount) - 1) * 1px)) / + var(--kbnGridColumnCount) ) )`, }), diff --git a/src/platform/packages/private/kbn-grid-layout/grid/grid_row/grid_row_header.test.tsx b/src/platform/packages/private/kbn-grid-layout/grid/grid_row/grid_row_header.test.tsx new file mode 100644 index 0000000000000..e5d855c12fd7f --- /dev/null +++ b/src/platform/packages/private/kbn-grid-layout/grid/grid_row/grid_row_header.test.tsx @@ -0,0 +1,148 @@ +/* + * 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 { cloneDeep } from 'lodash'; +import React from 'react'; + +import { EuiThemeProvider } from '@elastic/eui'; +import { RenderResult, act, render, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { getGridLayoutStateManagerMock, mockRenderPanelContents } from '../test_utils/mocks'; +import { GridLayoutStateManager } from '../types'; +import { GridRowHeader, GridRowHeaderProps } from './grid_row_header'; +import { GridLayoutContext, GridLayoutContextType } from '../use_grid_layout_context'; + +const toggleIsCollapsed = jest + .fn() + .mockImplementation((rowIndex: number, gridLayoutStateManager: GridLayoutStateManager) => { + const newLayout = cloneDeep(gridLayoutStateManager.gridLayout$.value); + newLayout[rowIndex].isCollapsed = !newLayout[rowIndex].isCollapsed; + gridLayoutStateManager.gridLayout$.next(newLayout); + }); + +describe('GridRowHeader', () => { + const renderGridRowHeader = ( + propsOverrides: Partial = {}, + contextOverrides: Partial = {} + ) => { + const stateManagerMock = getGridLayoutStateManagerMock(); + return { + component: render( + + toggleIsCollapsed(0, stateManagerMock)} + collapseButtonRef={React.createRef()} + {...propsOverrides} + /> + , + { wrapper: EuiThemeProvider } + ), + gridLayoutStateManager: stateManagerMock, + }; + }; + + beforeEach(() => { + toggleIsCollapsed.mockClear(); + }); + + it('renders the panel count', async () => { + const { component, gridLayoutStateManager } = renderGridRowHeader(); + const initialCount = component.getByTestId('kbnGridRowHeader-0--panelCount'); + expect(initialCount.textContent).toBe('(8 panels)'); + + act(() => { + const currentRow = gridLayoutStateManager.gridLayout$.getValue()[0]; + gridLayoutStateManager.gridLayout$.next([ + { + ...currentRow, + panels: { + panel1: currentRow.panels.panel1, + }, + }, + ]); + }); + + await waitFor(() => { + const updatedCount = component.getByTestId('kbnGridRowHeader-0--panelCount'); + expect(updatedCount.textContent).toBe('(1 panel)'); + }); + }); + + it('clicking title calls `toggleIsCollapsed`', async () => { + const { component, gridLayoutStateManager } = renderGridRowHeader(); + const title = component.getByTestId('kbnGridRowTitle-0'); + + expect(toggleIsCollapsed).toBeCalledTimes(0); + expect(gridLayoutStateManager.gridLayout$.getValue()[0].isCollapsed).toBe(false); + await userEvent.click(title); + expect(toggleIsCollapsed).toBeCalledTimes(1); + expect(gridLayoutStateManager.gridLayout$.getValue()[0].isCollapsed).toBe(true); + }); + + describe('title editor', () => { + const setTitle = async (component: RenderResult) => { + const input = component.getByTestId('euiInlineEditModeInput'); + expect(input.getAttribute('value')).toBe('Large section'); + await userEvent.click(input); + await userEvent.keyboard(' 123'); + expect(input.getAttribute('value')).toBe('Large section 123'); + }; + + it('clicking on edit icon triggers inline title editor and does not toggle collapsed', async () => { + const { component, gridLayoutStateManager } = renderGridRowHeader(); + const editIcon = component.getByTestId('kbnGridRowTitle-0--edit'); + + expect(component.queryByTestId('kbnGridRowTitle-0--editor')).not.toBeInTheDocument(); + expect(gridLayoutStateManager.gridLayout$.getValue()[0].isCollapsed).toBe(false); + await userEvent.click(editIcon); + expect(component.getByTestId('kbnGridRowTitle-0--editor')).toBeInTheDocument(); + expect(toggleIsCollapsed).toBeCalledTimes(0); + expect(gridLayoutStateManager.gridLayout$.getValue()[0].isCollapsed).toBe(false); + }); + + it('can update the title', async () => { + const { component, gridLayoutStateManager } = renderGridRowHeader(); + expect(component.getByTestId('kbnGridRowTitle-0').textContent).toBe('Large section'); + expect(gridLayoutStateManager.gridLayout$.getValue()[0].title).toBe('Large section'); + + const editIcon = component.getByTestId('kbnGridRowTitle-0--edit'); + await userEvent.click(editIcon); + await setTitle(component); + const saveButton = component.getByTestId('euiInlineEditModeSaveButton'); + await userEvent.click(saveButton); + + expect(component.queryByTestId('kbnGridRowTitle-0--editor')).not.toBeInTheDocument(); + expect(component.getByTestId('kbnGridRowTitle-0').textContent).toBe('Large section 123'); + expect(gridLayoutStateManager.gridLayout$.getValue()[0].title).toBe('Large section 123'); + }); + + it('clicking on cancel closes the inline title editor without updating title', async () => { + const { component, gridLayoutStateManager } = renderGridRowHeader(); + const editIcon = component.getByTestId('kbnGridRowTitle-0--edit'); + await userEvent.click(editIcon); + + await setTitle(component); + const cancelButton = component.getByTestId('euiInlineEditModeCancelButton'); + await userEvent.click(cancelButton); + + expect(component.queryByTestId('kbnGridRowTitle-0--editor')).not.toBeInTheDocument(); + expect(component.getByTestId('kbnGridRowTitle-0').textContent).toBe('Large section'); + expect(gridLayoutStateManager.gridLayout$.getValue()[0].title).toBe('Large section'); + }); + }); +}); diff --git a/src/platform/packages/private/kbn-grid-layout/grid/grid_row/grid_row_header.tsx b/src/platform/packages/private/kbn-grid-layout/grid/grid_row/grid_row_header.tsx index bcdca2b91ea87..391a48aa4e2c0 100644 --- a/src/platform/packages/private/kbn-grid-layout/grid/grid_row/grid_row_header.tsx +++ b/src/platform/packages/private/kbn-grid-layout/grid/grid_row/grid_row_header.tsx @@ -6,40 +6,212 @@ * 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 { EuiButtonIcon, EuiFlexGroup, EuiSpacer, EuiTitle } from '@elastic/eui'; +import React, { useCallback, useEffect, useState } from 'react'; +import { distinctUntilChanged, map } from 'rxjs'; + +import { + EuiButtonIcon, + EuiFlexGroup, + EuiFlexItem, + EuiText, + UseEuiTheme, + euiCanAnimate, +} from '@elastic/eui'; +import { css } from '@emotion/react'; import { i18n } from '@kbn/i18n'; +import { useGridLayoutContext } from '../use_grid_layout_context'; +import { deleteRow } from '../utils/row_management'; +import { DeleteGridRowModal } from './delete_grid_row_modal'; +import { GridRowTitle } from './grid_row_title'; + +export interface GridRowHeaderProps { + rowIndex: number; + toggleIsCollapsed: () => void; + collapseButtonRef: React.MutableRefObject; +} + export const GridRowHeader = React.memo( - ({ - isCollapsed, - toggleIsCollapsed, - rowTitle, - }: { - isCollapsed: boolean; - toggleIsCollapsed: () => void; - rowTitle?: string; - }) => { + ({ rowIndex, toggleIsCollapsed, collapseButtonRef }: GridRowHeaderProps) => { + const { gridLayoutStateManager } = useGridLayoutContext(); + + const [editTitleOpen, setEditTitleOpen] = useState(false); + const [deleteModalVisible, setDeleteModalVisible] = useState(false); + const [readOnly, setReadOnly] = useState( + gridLayoutStateManager.accessMode$.getValue() === 'VIEW' + ); + const [panelCount, setPanelCount] = useState( + Object.keys(gridLayoutStateManager.gridLayout$.getValue()[rowIndex].panels).length + ); + + useEffect(() => { + /** + * This subscription is responsible for controlling whether or not the section title is + * editable and hiding all other "edit mode" actions (delete section, move section, etc) + */ + const accessModeSubscription = gridLayoutStateManager.accessMode$ + .pipe(distinctUntilChanged()) + .subscribe((accessMode) => { + setReadOnly(accessMode === 'VIEW'); + }); + + /** + * This subscription is responsible for keeping the panel count in sync + */ + const panelCountSubscription = gridLayoutStateManager.gridLayout$ + .pipe( + map((layout) => Object.keys(layout[rowIndex]?.panels ?? {}).length), + distinctUntilChanged() + ) + .subscribe((count) => { + setPanelCount(count); + }); + + return () => { + accessModeSubscription.unsubscribe(); + panelCountSubscription.unsubscribe(); + }; + }, [gridLayoutStateManager, rowIndex]); + + const confirmDeleteRow = useCallback(() => { + /** + * Memoization of this callback does not need to be dependant on the React panel count + * state, so just grab the panel count via gridLayoutStateManager instead + */ + const count = Object.keys( + gridLayoutStateManager.gridLayout$.getValue()[rowIndex].panels + ).length; + if (!Boolean(count)) { + const newLayout = deleteRow(gridLayoutStateManager.gridLayout$.getValue(), rowIndex); + gridLayoutStateManager.gridLayout$.next(newLayout); + } else { + setDeleteModalVisible(true); + } + }, [gridLayoutStateManager.gridLayout$, rowIndex]); + return ( -
- - - + + - -

{rowTitle}

-
+ { + /** + * Add actions at the end of the header section when the layout is editable + the section title + * is not in edit mode + */ + !editTitleOpen && ( + <> + + + {i18n.translate('kbnGridLayout.rowHeader.panelCount', { + defaultMessage: + '({panelCount} {panelCount, plural, one {panel} other {panels}})', + values: { + panelCount, + }, + })} + + + {!readOnly && ( + <> + + + + + {/* + This was added as a placeholder to get the desired UI here; however, since the + functionality will be implemented in https://github.com/elastic/kibana/issues/190381 + and this button doesn't do anything yet, I'm choosing to hide it for now. I am keeping + the `FlexItem` wrapper so that the UI still looks correct. + */} + {/* */} + + + )} + + ) + }
- -
+ {deleteModalVisible && ( + + )} + ); } ); -GridRowHeader.displayName = 'KbnGridLayoutRowHeader'; +const styles = { + hiddenOnCollapsed: css({ + display: 'none', + '.kbnGridRowContainer--collapsed &': { + display: 'block', + }, + }), + floatToRight: css({ + marginLeft: 'auto', + }), + headerStyles: ({ euiTheme }: UseEuiTheme) => + css({ + height: `calc(${euiTheme.size.xl} + (2 * ${euiTheme.size.s}))`, + padding: `${euiTheme.size.s} 0px`, + borderBottom: '1px solid transparent', // prevents layout shift + '.kbnGridRowContainer--collapsed &': { + borderBottom: euiTheme.border.thin, + }, + '.kbnGridLayout--deleteRowIcon': { + marginLeft: euiTheme.size.xs, + }, + '.kbnGridLayout--panelCount': { + textWrapMode: 'nowrap', // prevent panel count from wrapping + }, + // these styles hide the delete + move actions by default and only show them on hover + [`.kbnGridLayout--deleteRowIcon, + .kbnGridLayout--moveRowIcon`]: { + opacity: '0', + [`${euiCanAnimate}`]: { + transition: `opacity ${euiTheme.animation.extraFast} ease-in`, + }, + }, + [`&:hover .kbnGridLayout--deleteRowIcon, + &:hover .kbnGridLayout--moveRowIcon, + &:has(:focus-visible) .kbnGridLayout--deleteRowIcon, + &:has(:focus-visible) .kbnGridLayout--moveRowIcon`]: { + opacity: 1, + }, + }), +}; + +GridRowHeader.displayName = 'GridRowHeader'; diff --git a/src/platform/packages/private/kbn-grid-layout/grid/grid_row/grid_row_title.tsx b/src/platform/packages/private/kbn-grid-layout/grid/grid_row/grid_row_title.tsx new file mode 100644 index 0000000000000..b4a7e9bb6d210 --- /dev/null +++ b/src/platform/packages/private/kbn-grid-layout/grid/grid_row/grid_row_title.tsx @@ -0,0 +1,187 @@ +/* + * 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 { cloneDeep } from 'lodash'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { distinctUntilChanged, map } from 'rxjs'; + +import { + EuiButtonEmpty, + EuiButtonIcon, + EuiFlexItem, + EuiInlineEditTitle, + EuiTitle, + UseEuiTheme, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { css } from '@emotion/react'; + +import { useGridLayoutContext } from '../use_grid_layout_context'; + +export const GridRowTitle = React.memo( + ({ + readOnly, + rowIndex, + editTitleOpen, + setEditTitleOpen, + toggleIsCollapsed, + collapseButtonRef, + }: { + readOnly: boolean; + rowIndex: number; + editTitleOpen: boolean; + setEditTitleOpen: (value: boolean) => void; + toggleIsCollapsed: () => void; + collapseButtonRef: React.MutableRefObject; + }) => { + const { gridLayoutStateManager } = useGridLayoutContext(); + + const inputRef = useRef(null); + const currentRow = gridLayoutStateManager.gridLayout$.getValue()[rowIndex]; + const [rowTitle, setRowTitle] = useState(currentRow.title); + + useEffect(() => { + /** + * This subscription ensures that this component will re-render when the title changes + */ + const titleSubscription = gridLayoutStateManager.gridLayout$ + .pipe( + map((gridLayout) => gridLayout[rowIndex]?.title ?? ''), + distinctUntilChanged() + ) + .subscribe((title) => { + setRowTitle(title); + }); + + return () => { + titleSubscription.unsubscribe(); + }; + }, [rowIndex, gridLayoutStateManager]); + + useEffect(() => { + /** + * Set focus on title input when edit mode is open + */ + if (editTitleOpen && inputRef.current) { + inputRef.current.focus(); + } + }, [editTitleOpen]); + + const updateTitle = useCallback( + (title: string) => { + const newLayout = cloneDeep(gridLayoutStateManager.gridLayout$.getValue()); + newLayout[rowIndex].title = title; + gridLayoutStateManager.gridLayout$.next(newLayout); + setEditTitleOpen(false); + }, + [rowIndex, setEditTitleOpen, gridLayoutStateManager.gridLayout$] + ); + + return ( + <> + + + {editTitleOpen ? null : ( + +

{rowTitle}

+
+ )} +
+
+ {!readOnly && editTitleOpen ? ( + + {/* @ts-ignore - EUI typing issue that will be resolved with https://github.com/elastic/eui/pull/8307 */} + setEditTitleOpen(false)} + startWithEditOpen + editModeProps={{ + inputProps: { inputRef }, + }} + inputAriaLabel={i18n.translate('kbnGridLayout.row.editTitleAriaLabel', { + defaultMessage: 'Edit section title', + })} + data-test-subj={`kbnGridRowTitle-${rowIndex}--editor`} + /> + + ) : ( + <> + {!readOnly && ( + + setEditTitleOpen(true)} + color="text" + aria-label={i18n.translate('kbnGridLayout.row.editRowTitle', { + defaultMessage: 'Edit section title', + })} + data-test-subj={`kbnGridRowTitle-${rowIndex}--edit`} + /> + + )} + + )} + + ); + } +); + +const styles = { + titleButton: ({ euiTheme }: UseEuiTheme) => + css({ + minWidth: 0, + button: { + '&:focus': { + backgroundColor: 'unset', + }, + h2: { + overflow: 'hidden', + textOverflow: 'ellipsis', + }, + svg: { + transition: `transform ${euiTheme.animation.fast} ease`, + transform: 'rotate(0deg)', + '.kbnGridRowContainer--collapsed &': { + transform: 'rotate(-90deg) !important', + }, + }, + }, + }), + editTitleInput: css({ + // if field-sizing is supported, grow width to text; otherwise, fill available space + '@supports (field-sizing: content)': { + minWidth: 0, + '.euiFlexItem:has(input)': { + flexGrow: 0, + maxWidth: 'calc(100% - 80px)', // don't extend past parent + }, + input: { + fieldSizing: 'content', + }, + }, + }), +}; + +GridRowTitle.displayName = 'GridRowTitle'; diff --git a/src/platform/packages/private/kbn-grid-layout/grid/test_utils/mocks.tsx b/src/platform/packages/private/kbn-grid-layout/grid/test_utils/mocks.tsx index 5efb50d9d5f04..b5190166bc94f 100644 --- a/src/platform/packages/private/kbn-grid-layout/grid/test_utils/mocks.tsx +++ b/src/platform/packages/private/kbn-grid-layout/grid/test_utils/mocks.tsx @@ -29,26 +29,26 @@ export const gridSettings = { rowHeight: DASHBOARD_GRID_HEIGHT, columnCount: DASHBOARD_GRID_COLUMN_COUNT, }; - export const mockRenderPanelContents = jest.fn((panelId) => ( )); -const runtimeSettings$ = new BehaviorSubject({ - ...gridSettings, - columnPixelWidth: 0, -}); - -export const gridLayoutStateManagerMock: GridLayoutStateManager = { - expandedPanelId$: new BehaviorSubject(undefined), - isMobileView$: new BehaviorSubject(false), - gridLayout$: new BehaviorSubject(getSampleLayout()), - proposedGridLayout$: new BehaviorSubject(undefined), - runtimeSettings$, - panelRefs: { current: [] }, - rowRefs: { current: [] }, - accessMode$: new BehaviorSubject('EDIT'), - interactionEvent$: new BehaviorSubject(undefined), - activePanel$: new BehaviorSubject(undefined), - gridDimensions$: new BehaviorSubject({ width: 600, height: 900 }), +export const getGridLayoutStateManagerMock = (overrides?: Partial) => { + return { + expandedPanelId$: new BehaviorSubject(undefined), + isMobileView$: new BehaviorSubject(false), + gridLayout$: new BehaviorSubject(getSampleLayout()), + proposedGridLayout$: new BehaviorSubject(undefined), + runtimeSettings$: new BehaviorSubject({ + ...gridSettings, + columnPixelWidth: 0, + }), + panelRefs: { current: [] }, + rowRefs: { current: [] }, + accessMode$: new BehaviorSubject('EDIT'), + interactionEvent$: new BehaviorSubject(undefined), + activePanel$: new BehaviorSubject(undefined), + gridDimensions$: new BehaviorSubject({ width: 600, height: 900 }), + ...overrides, + }; }; diff --git a/src/platform/packages/private/kbn-grid-layout/grid/use_grid_layout_state.ts b/src/platform/packages/private/kbn-grid-layout/grid/use_grid_layout_state.ts index 0294c82590d27..3bdd80ba1a1a3 100644 --- a/src/platform/packages/private/kbn-grid-layout/grid/use_grid_layout_state.ts +++ b/src/platform/packages/private/kbn-grid-layout/grid/use_grid_layout_state.ts @@ -146,11 +146,12 @@ export const useGridLayoutState = ({ */ const cssVariableSubscription = gridLayoutStateManager.runtimeSettings$ .pipe(distinctUntilChanged(deepEqual)) - .subscribe(({ gutterSize, columnPixelWidth, rowHeight }) => { + .subscribe(({ gutterSize, columnPixelWidth, rowHeight, columnCount }) => { if (!layoutRef.current) return; layoutRef.current.style.setProperty('--kbnGridGutterSize', `${gutterSize}`); layoutRef.current.style.setProperty('--kbnGridRowHeight', `${rowHeight}`); layoutRef.current.style.setProperty('--kbnGridColumnWidth', `${columnPixelWidth}`); + layoutRef.current.style.setProperty('--kbnGridColumnCount', `${columnCount}`); }); return () => { diff --git a/src/platform/packages/private/kbn-grid-layout/grid/utils/row_management.ts b/src/platform/packages/private/kbn-grid-layout/grid/utils/row_management.ts new file mode 100644 index 0000000000000..e81be386a3c56 --- /dev/null +++ b/src/platform/packages/private/kbn-grid-layout/grid/utils/row_management.ts @@ -0,0 +1,44 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", 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 { cloneDeep } from 'lodash'; + +import { GridLayoutData } from '../types'; +import { resolveGridRow } from './resolve_grid_row'; + +/** + * Move the panels in the `startingRow` to the bottom of the `newRow` and resolve the resulting layout + * @param layout Starting layout + * @param startingRow The source row for the panels + * @param newRow The destination row for the panels + * @returns Updated layout with panels moved from `startingRow` to `newRow` + */ +export const movePanelsToRow = (layout: GridLayoutData, startingRow: number, newRow: number) => { + const newLayout = cloneDeep(layout); + const panelsToMove = newLayout[startingRow].panels; + const maxRow = Math.max( + ...Object.values(newLayout[newRow].panels).map(({ row, height }) => row + height) + ); + Object.keys(panelsToMove).forEach((index) => (panelsToMove[index].row += maxRow)); + newLayout[newRow].panels = { ...newLayout[newRow].panels, ...panelsToMove }; + newLayout[newRow] = resolveGridRow(newLayout[newRow]); + newLayout[startingRow] = { ...newLayout[startingRow], panels: {} }; + return newLayout; +}; + +/** + * Deletes an entire row from the layout, including all of its panels + * @param layout Starting layout + * @param rowIndex The row to be deleted + * @returns Updated layout with the row at `rowIndex` deleted + */ +export const deleteRow = (layout: GridLayoutData, rowIndex: number) => { + const newLayout = cloneDeep(layout); + newLayout.splice(rowIndex, 1); + return newLayout; +};