[8.x] [kbn-grid-layout] Add ability to create, edit, and delete rows (#209193) (#212307)

# Backport

This will backport the following commits from `main` to `8.x`:
- [[kbn-grid-layout] Add ability to create, edit, and delete rows
(#209193)](https://github.com/elastic/kibana/pull/209193)

<!--- Backport version: 9.6.6 -->

### Questions ?
Please refer to the [Backport tool
documentation](https://github.com/sorenlouv/backport)

<!--BACKPORT [{"author":{"name":"Hannah
Mudge","email":"Heenawter@users.noreply.github.com"},"sourceCommit":{"committedDate":"2025-02-24T18:29:00Z","message":"[kbn-grid-layout]
Add ability to create, edit, and delete rows (#209193)\n\nCloses
https://github.com/elastic/kibana/issues/204849\n\n## Summary\n\nThis PR
adds the ability to create, edit, and delete sections / rows
to\n`kbn-grid-layout`:\n\n\n\nhttps://github.com/user-attachments/assets/4831b289-2c71-42fb-851d-0925560e233a\n\n\n\nNote
that sections are still statically placed - dragging rows around\nwill
be added in a follow-up PR, because it's a larger undertaking.\nSince
this feature is not available to users yet, it is okay to\nimplement
this in stages like this.\n\n### Checklist\n\n- [x] Any text added
follows [EUI's
writing\nguidelines](https://elastic.github.io/eui/#/guidelines/writing),
uses\nsentence case text and includes
[i18n\nsupport](https://github.com/elastic/kibana/blob/main/src/platform/packages/shared/kbn-i18n/README.md)\n-
[x] [Unit or
functional\ntests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)\nwere
updated or added to match the most common scenarios\n- [x] The PR
description includes the appropriate Release Notes section,\nand the
correct `release_note:*` label is applied per
the\n[guidelines](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process)\n\n###
Identify risks\n\nCollapsible sections are not available on Dashboard
yet and so there is\nno user-facing risk to this
PR.\n\n---------\n\nCo-authored-by: kibanamachine
<42973632+kibanamachine@users.noreply.github.com>\nCo-authored-by: Marta
Bondyra
<4283304+mbondyra@users.noreply.github.com>","sha":"e587187ffcf14bb92d4d30cacbdc13d9380e4025","branchLabelMapping":{"^v9.1.0$":"main","^v8.19.0$":"8.x","^v(\\d+).(\\d+).\\d+$":"$1.$2"}},"sourcePullRequest":{"labels":["Team:Presentation","loe:large","release_note:skip","impact:high","Project:Collapsable
Panels","backport:version","v9.1.0","v8.19.0"],"title":"[kbn-grid-layout]
Add ability to create, edit, and delete
rows","number":209193,"url":"https://github.com/elastic/kibana/pull/209193","mergeCommit":{"message":"[kbn-grid-layout]
Add ability to create, edit, and delete rows (#209193)\n\nCloses
https://github.com/elastic/kibana/issues/204849\n\n## Summary\n\nThis PR
adds the ability to create, edit, and delete sections / rows
to\n`kbn-grid-layout`:\n\n\n\nhttps://github.com/user-attachments/assets/4831b289-2c71-42fb-851d-0925560e233a\n\n\n\nNote
that sections are still statically placed - dragging rows around\nwill
be added in a follow-up PR, because it's a larger undertaking.\nSince
this feature is not available to users yet, it is okay to\nimplement
this in stages like this.\n\n### Checklist\n\n- [x] Any text added
follows [EUI's
writing\nguidelines](https://elastic.github.io/eui/#/guidelines/writing),
uses\nsentence case text and includes
[i18n\nsupport](https://github.com/elastic/kibana/blob/main/src/platform/packages/shared/kbn-i18n/README.md)\n-
[x] [Unit or
functional\ntests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)\nwere
updated or added to match the most common scenarios\n- [x] The PR
description includes the appropriate Release Notes section,\nand the
correct `release_note:*` label is applied per
the\n[guidelines](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process)\n\n###
Identify risks\n\nCollapsible sections are not available on Dashboard
yet and so there is\nno user-facing risk to this
PR.\n\n---------\n\nCo-authored-by: kibanamachine
<42973632+kibanamachine@users.noreply.github.com>\nCo-authored-by: Marta
Bondyra
<4283304+mbondyra@users.noreply.github.com>","sha":"e587187ffcf14bb92d4d30cacbdc13d9380e4025"}},"sourceBranch":"main","suggestedTargetBranches":["8.x"],"targetPullRequestStates":[{"branch":"main","label":"v9.1.0","branchLabelMappingKey":"^v9.1.0$","isSourceBranch":true,"state":"MERGED","url":"https://github.com/elastic/kibana/pull/209193","number":209193,"mergeCommit":{"message":"[kbn-grid-layout]
Add ability to create, edit, and delete rows (#209193)\n\nCloses
https://github.com/elastic/kibana/issues/204849\n\n## Summary\n\nThis PR
adds the ability to create, edit, and delete sections / rows
to\n`kbn-grid-layout`:\n\n\n\nhttps://github.com/user-attachments/assets/4831b289-2c71-42fb-851d-0925560e233a\n\n\n\nNote
that sections are still statically placed - dragging rows around\nwill
be added in a follow-up PR, because it's a larger undertaking.\nSince
this feature is not available to users yet, it is okay to\nimplement
this in stages like this.\n\n### Checklist\n\n- [x] Any text added
follows [EUI's
writing\nguidelines](https://elastic.github.io/eui/#/guidelines/writing),
uses\nsentence case text and includes
[i18n\nsupport](https://github.com/elastic/kibana/blob/main/src/platform/packages/shared/kbn-i18n/README.md)\n-
[x] [Unit or
functional\ntests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)\nwere
updated or added to match the most common scenarios\n- [x] The PR
description includes the appropriate Release Notes section,\nand the
correct `release_note:*` label is applied per
the\n[guidelines](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process)\n\n###
Identify risks\n\nCollapsible sections are not available on Dashboard
yet and so there is\nno user-facing risk to this
PR.\n\n---------\n\nCo-authored-by: kibanamachine
<42973632+kibanamachine@users.noreply.github.com>\nCo-authored-by: Marta
Bondyra
<4283304+mbondyra@users.noreply.github.com>","sha":"e587187ffcf14bb92d4d30cacbdc13d9380e4025"}},{"branch":"8.x","label":"v8.19.0","branchLabelMappingKey":"^v8.19.0$","isSourceBranch":false,"state":"NOT_CREATED"}]}]
BACKPORT-->

Co-authored-by: Hannah Mudge <Heenawter@users.noreply.github.com>
This commit is contained in:
Kibana Machine 2025-02-25 09:16:26 +11:00 committed by GitHub
parent e1318bd6fc
commit bc829efd2e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 1004 additions and 218 deletions

View file

@ -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<ReactElement[]>([]);
const viewMode = useStateFromPublishingSubject(
apiPublishesViewMode(pageApi) ? pageApi?.viewMode$ : (of('edit') as PublishingSubject<ViewMode>)
);
useEffect(() => {
let cancelled = false;
@ -59,6 +70,7 @@ export function AddButton({ pageApi, uiActions }: { pageApi: unknown; uiActions:
onClick={() => {
setIsPopoverOpen(!isPopoverOpen);
}}
disabled={viewMode !== 'edit'}
>
Add panel
</EuiButton>

View file

@ -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<GridLayoutData>(
dashboardInputToGridLayout(savedState.current)
);
const [isSettingsPopoverOpen, setIsSettingsPopoverOpen] = useState(false);
const [gutterSize, setGutterSize] = useState<number>(DASHBOARD_MARGIN_SIZE);
const [rowHeight, setRowHeight] = useState<number>(DASHBOARD_GRID_HEIGHT);
const [gridSettings, setGridSettings] = useState<GridSettings>({
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<void>(), []);
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<HTMLElement | null>) => 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 = ({
<EuiFlexItem grow={false}>
<EuiFlexGroup gutterSize="s" alignItems="center">
<EuiFlexItem grow={false}>
<AddEmbeddableButton pageApi={mockDashboardApi} uiActions={uiActions} />{' '}
<AddEmbeddableButton pageApi={mockDashboardApi} uiActions={uiActions} />
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiPopover
button={
<EuiButton
iconType="arrowDown"
iconSide="right"
onClick={() => setIsSettingsPopoverOpen(!isSettingsPopoverOpen)}
>
{i18n.translate('examples.gridExample.settingsPopover.title', {
defaultMessage: 'Layout settings',
})}
</EuiButton>
}
isOpen={isSettingsPopoverOpen}
closePopover={() => setIsSettingsPopoverOpen(false)}
>
<>
<EuiFormRow
label={i18n.translate('examples.gridExample.settingsPopover.viewMode', {
defaultMessage: 'View mode',
})}
>
<EuiButtonGroup
legend={i18n.translate('examples.gridExample.layoutOptionsLegend', {
defaultMessage: 'Layout options',
})}
options={[
{
id: 'view',
label: i18n.translate('examples.gridExample.viewOption', {
defaultMessage: 'View',
}),
toolTipContent:
'The layout adjusts when the window is resized. Panel interactivity, such as moving and resizing within the grid, is disabled.',
},
{
id: 'edit',
label: i18n.translate('examples.gridExample.editOption', {
defaultMessage: 'Edit',
}),
toolTipContent:
'The layout does not adjust when the window is resized.',
},
]}
idSelected={viewMode}
onChange={(id) => {
mockDashboardApi.viewMode$.next(id);
}}
/>
</EuiFormRow>
<EuiFormRow
label={i18n.translate('examples.gridExample.settingsPopover.gutterSize', {
defaultMessage: 'Gutter size',
})}
>
<EuiRange
min={1}
max={30}
value={gutterSize}
onChange={(e) => setGutterSize(parseInt(e.currentTarget.value, 10))}
showLabels
showValue
/>
</EuiFormRow>
<EuiFormRow
label={i18n.translate('examples.gridExample.settingsPopover.rowHeight', {
defaultMessage: 'Row height',
})}
>
<EuiRange
min={5}
max={30}
step={5}
value={rowHeight}
onChange={(e) => setRowHeight(parseInt(e.currentTarget.value, 10))}
showLabels
showValue
/>
</EuiFormRow>
</>
</EuiPopover>
<EuiButton onClick={addNewSection} disabled={viewMode !== 'edit'}>
{i18n.translate('examples.gridExample.addRowButton', {
defaultMessage: 'Add collapsible section',
})}
</EuiButton>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<GridLayoutOptions
mockDashboardApi={mockDashboardApi}
gridSettings={gridSettings}
setGridSettings={setGridSettings}
viewMode={viewMode}
/>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
@ -294,13 +274,7 @@ export const GridExample = ({
</EuiFlexItem>
)}
<EuiFlexItem grow={false}>
<EuiButtonEmpty
onClick={() => {
const { panels, rows } = savedState.current;
mockDashboardApi.panels$.next(panels);
mockDashboardApi.rows$.next(rows);
}}
>
<EuiButtonEmpty onClick={resetUnsavedChanges}>
{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}
/>
</EuiPageTemplate.Section>

View file

@ -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 (
<EuiPopover
button={
<EuiButton
iconType="arrowDown"
iconSide="right"
onClick={() => setIsSettingsPopoverOpen(!isSettingsPopoverOpen)}
>
{i18n.translate('examples.gridExample.settingsPopover.title', {
defaultMessage: 'Layout settings',
})}
</EuiButton>
}
isOpen={isSettingsPopoverOpen}
closePopover={() => setIsSettingsPopoverOpen(false)}
>
<>
<EuiFormRow
label={i18n.translate('examples.gridExample.settingsPopover.viewMode', {
defaultMessage: 'View mode',
})}
>
<EuiButtonGroup
legend={i18n.translate('examples.gridExample.layoutOptionsLegend', {
defaultMessage: 'Layout options',
})}
options={[
{
id: 'view',
label: i18n.translate('examples.gridExample.viewOption', {
defaultMessage: 'View',
}),
toolTipContent:
'The layout adjusts when the window is resized. Panel interactivity, such as moving and resizing within the grid, is disabled.',
},
{
id: 'edit',
label: i18n.translate('examples.gridExample.editOption', {
defaultMessage: 'Edit',
}),
toolTipContent: 'The layout does not adjust when the window is resized.',
},
]}
idSelected={viewMode}
onChange={(id) => {
mockDashboardApi.setViewMode(id as ViewMode);
}}
/>
</EuiFormRow>
<EuiFormRow
label={i18n.translate('examples.gridExample.settingsPopover.gutterSize', {
defaultMessage: 'Gutter size',
})}
>
<EuiRange
min={1}
max={30}
value={gridSettings.gutterSize}
onChange={(e) =>
setGridSettings({ ...gridSettings, gutterSize: parseInt(e.currentTarget.value, 10) })
}
showLabels
showValue
/>
</EuiFormRow>
<EuiFormRow
label={i18n.translate('examples.gridExample.settingsPopover.rowHeight', {
defaultMessage: 'Row height',
})}
>
<EuiRange
min={5}
max={30}
step={5}
value={gridSettings.rowHeight}
onChange={(e) =>
setGridSettings({ ...gridSettings, rowHeight: parseInt(e.currentTarget.value, 10) })
}
showLabels
showValue
/>
</EuiFormRow>
</>
</EuiPopover>
);
};

View file

@ -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<MockedDashboardPanelMap>;
rows$: BehaviorSubject<MockedDashboardRowMap>;
};

View file

@ -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<MockedDashboardPanelMap>(savedState.panels);
const expandedPanelId$ = new BehaviorSubject<string | undefined>(undefined);
const viewMode$ = new BehaviorSubject<ViewMode>('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<MockedDashboardRowMap>(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<string> => {
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<undefined> => {
// we are only implementing "place at top" here, for demo purposes
const currentPanels = mockDashboardApi.panels$.getValue();
const otherPanels = { ...currentPanels };

View file

@ -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<GridLayoutProps> = {}) => {
...propsOverrides,
} as GridLayoutProps;
const { rerender, ...rtlRest } = render(<GridLayout {...props} />);
const { rerender, ...rtlRest } = render(<GridLayout {...props} />, { wrapper: EuiThemeProvider });
return {
...rtlRest,

View file

@ -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

View file

@ -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 = {

View file

@ -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 (
<EuiModal
onClose={() => {
setDeleteModalVisible(false);
}}
>
<EuiModalHeader>
<EuiModalHeaderTitle>
{i18n.translate('kbnGridLayout.deleteGridRowModal.title', {
defaultMessage: 'Delete section',
})}
</EuiModalHeaderTitle>
</EuiModalHeader>
<EuiModalBody>
{i18n.translate('kbnGridLayout.deleteGridRowModal.body', {
defaultMessage:
'Choose to remove the section, including its contents, or only the section.',
})}
</EuiModalBody>
<EuiModalFooter>
<EuiButtonEmpty
onClick={() => {
setDeleteModalVisible(false);
}}
>
{i18n.translate('kbnGridLayout.deleteGridRowModal.cancelButton', {
defaultMessage: 'Cancel',
})}
</EuiButtonEmpty>
<EuiButton
onClick={() => {
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',
})}
</EuiButton>
<EuiButton
onClick={() => {
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,
},
})}
</EuiButton>
</EuiModalFooter>
</EuiModal>
);
};

View file

@ -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
}
>
<GridRow rowIndex={0} {...propsOverrides} />
</GridLayoutContext.Provider>
</GridLayoutContext.Provider>,
{ 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);
});
});

View file

@ -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<HTMLButtonElement | null>(null);
const currentRow = gridLayoutStateManager.gridLayout$.value[rowIndex];
const [panelIdsInOrder, setPanelIdsInOrder] = useState<string[]>(() =>
getKeysInOrder(currentRow.panels)
);
const [rowTitle, setRowTitle] = useState<string>(currentRow.title);
const [isCollapsed, setIsCollapsed] = useState<boolean>(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 (
<div css={styles.fullHeight} className="kbnGridRowContainer">
<div
css={styles.fullHeight}
className={classNames('kbnGridRowContainer', {
'kbnGridRowContainer--collapsed': isCollapsed,
})}
>
{rowIndex !== 0 && (
<GridRowHeader
isCollapsed={isCollapsed}
toggleIsCollapsed={() => {
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 && (
<div
id={`kbnGridRow-${rowIndex}`}
className={'kbnGridRow'}
ref={(element: HTMLDivElement | null) =>
(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)
)
)`,
}),

View file

@ -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<GridRowHeaderProps> = {},
contextOverrides: Partial<GridLayoutContextType> = {}
) => {
const stateManagerMock = getGridLayoutStateManagerMock();
return {
component: render(
<GridLayoutContext.Provider
value={
{
renderPanelContents: mockRenderPanelContents,
gridLayoutStateManager: stateManagerMock,
...contextOverrides,
} as GridLayoutContextType
}
>
<GridRowHeader
rowIndex={0}
toggleIsCollapsed={() => toggleIsCollapsed(0, stateManagerMock)}
collapseButtonRef={React.createRef()}
{...propsOverrides}
/>
</GridLayoutContext.Provider>,
{ 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');
});
});
});

View file

@ -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<HTMLButtonElement | null>;
}
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<boolean>(false);
const [deleteModalVisible, setDeleteModalVisible] = useState<boolean>(false);
const [readOnly, setReadOnly] = useState<boolean>(
gridLayoutStateManager.accessMode$.getValue() === 'VIEW'
);
const [panelCount, setPanelCount] = useState<number>(
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 (
<div className="kbnGridRowHeader">
<EuiSpacer size="s" />
<EuiFlexGroup gutterSize="s">
<EuiButtonIcon
color="text"
aria-label={i18n.translate('kbnGridLayout.row.toggleCollapse', {
defaultMessage: 'Toggle collapse',
})}
iconType={isCollapsed ? 'arrowRight' : 'arrowDown'}
onClick={toggleIsCollapsed}
<>
<EuiFlexGroup
gutterSize="xs"
responsive={false}
alignItems="center"
css={styles.headerStyles}
className="kbnGridRowHeader"
data-test-subj={`kbnGridRowHeader-${rowIndex}`}
>
<GridRowTitle
rowIndex={rowIndex}
readOnly={readOnly}
toggleIsCollapsed={toggleIsCollapsed}
editTitleOpen={editTitleOpen}
setEditTitleOpen={setEditTitleOpen}
collapseButtonRef={collapseButtonRef}
/>
<EuiTitle size="xs">
<h2>{rowTitle}</h2>
</EuiTitle>
{
/**
* Add actions at the end of the header section when the layout is editable + the section title
* is not in edit mode
*/
!editTitleOpen && (
<>
<EuiFlexItem grow={false} css={styles.hiddenOnCollapsed}>
<EuiText
color="subdued"
size="s"
data-test-subj={`kbnGridRowHeader-${rowIndex}--panelCount`}
className={'kbnGridLayout--panelCount'}
>
{i18n.translate('kbnGridLayout.rowHeader.panelCount', {
defaultMessage:
'({panelCount} {panelCount, plural, one {panel} other {panels}})',
values: {
panelCount,
},
})}
</EuiText>
</EuiFlexItem>
{!readOnly && (
<>
<EuiFlexItem grow={false}>
<EuiButtonIcon
iconType="trash"
color="danger"
className="kbnGridLayout--deleteRowIcon"
onClick={confirmDeleteRow}
aria-label={i18n.translate('kbnGridLayout.row.deleteRow', {
defaultMessage: 'Delete section',
})}
/>
</EuiFlexItem>
<EuiFlexItem grow={false} css={[styles.hiddenOnCollapsed, styles.floatToRight]}>
{/*
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.
*/}
{/* <EuiButtonIcon
iconType="move"
color="text"
className="kbnGridLayout--moveRowIcon"
aria-label={i18n.translate('kbnGridLayout.row.moveRow', {
defaultMessage: 'Move section',
})}
/> */}
</EuiFlexItem>
</>
)}
</>
)
}
</EuiFlexGroup>
<EuiSpacer size="s" />
</div>
{deleteModalVisible && (
<DeleteGridRowModal rowIndex={rowIndex} setDeleteModalVisible={setDeleteModalVisible} />
)}
</>
);
}
);
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';

View file

@ -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<HTMLButtonElement | null>;
}) => {
const { gridLayoutStateManager } = useGridLayoutContext();
const inputRef = useRef<HTMLInputElement | null>(null);
const currentRow = gridLayoutStateManager.gridLayout$.getValue()[rowIndex];
const [rowTitle, setRowTitle] = useState<string>(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 (
<>
<EuiFlexItem grow={false} css={styles.titleButton}>
<EuiButtonEmpty
buttonRef={collapseButtonRef}
color="text"
aria-label={i18n.translate('kbnGridLayout.row.toggleCollapse', {
defaultMessage: 'Toggle collapse',
})}
iconType={'arrowDown'}
onClick={toggleIsCollapsed}
size="m"
id={`kbnGridRowTitle-${rowIndex}`}
aria-controls={`kbnGridRow-${rowIndex}`}
data-test-subj={`kbnGridRowTitle-${rowIndex}`}
textProps={false}
flush="both"
>
{editTitleOpen ? null : (
<EuiTitle size="xs">
<h2>{rowTitle}</h2>
</EuiTitle>
)}
</EuiButtonEmpty>
</EuiFlexItem>
{!readOnly && editTitleOpen ? (
<EuiFlexItem grow={true} css={styles.editTitleInput}>
{/* @ts-ignore - EUI typing issue that will be resolved with https://github.com/elastic/eui/pull/8307 */}
<EuiInlineEditTitle
size="xs"
heading="h2"
defaultValue={rowTitle}
onSave={updateTitle}
onCancel={() => setEditTitleOpen(false)}
startWithEditOpen
editModeProps={{
inputProps: { inputRef },
}}
inputAriaLabel={i18n.translate('kbnGridLayout.row.editTitleAriaLabel', {
defaultMessage: 'Edit section title',
})}
data-test-subj={`kbnGridRowTitle-${rowIndex}--editor`}
/>
</EuiFlexItem>
) : (
<>
{!readOnly && (
<EuiFlexItem grow={false}>
<EuiButtonIcon
iconType="pencil"
onClick={() => setEditTitleOpen(true)}
color="text"
aria-label={i18n.translate('kbnGridLayout.row.editRowTitle', {
defaultMessage: 'Edit section title',
})}
data-test-subj={`kbnGridRowTitle-${rowIndex}--edit`}
/>
</EuiFlexItem>
)}
</>
)}
</>
);
}
);
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';

View file

@ -29,26 +29,26 @@ export const gridSettings = {
rowHeight: DASHBOARD_GRID_HEIGHT,
columnCount: DASHBOARD_GRID_COLUMN_COUNT,
};
export const mockRenderPanelContents = jest.fn((panelId) => (
<button aria-label={`panelId:${panelId}`}>panel content {panelId}</button>
));
const runtimeSettings$ = new BehaviorSubject<RuntimeGridSettings>({
...gridSettings,
columnPixelWidth: 0,
});
export const gridLayoutStateManagerMock: GridLayoutStateManager = {
expandedPanelId$: new BehaviorSubject<string | undefined>(undefined),
isMobileView$: new BehaviorSubject<boolean>(false),
gridLayout$: new BehaviorSubject<GridLayoutData>(getSampleLayout()),
proposedGridLayout$: new BehaviorSubject<GridLayoutData | undefined>(undefined),
runtimeSettings$,
panelRefs: { current: [] },
rowRefs: { current: [] },
accessMode$: new BehaviorSubject<GridAccessMode>('EDIT'),
interactionEvent$: new BehaviorSubject<PanelInteractionEvent | undefined>(undefined),
activePanel$: new BehaviorSubject<ActivePanel | undefined>(undefined),
gridDimensions$: new BehaviorSubject<ObservedSize>({ width: 600, height: 900 }),
export const getGridLayoutStateManagerMock = (overrides?: Partial<GridLayoutStateManager>) => {
return {
expandedPanelId$: new BehaviorSubject<string | undefined>(undefined),
isMobileView$: new BehaviorSubject<boolean>(false),
gridLayout$: new BehaviorSubject<GridLayoutData>(getSampleLayout()),
proposedGridLayout$: new BehaviorSubject<GridLayoutData | undefined>(undefined),
runtimeSettings$: new BehaviorSubject<RuntimeGridSettings>({
...gridSettings,
columnPixelWidth: 0,
}),
panelRefs: { current: [] },
rowRefs: { current: [] },
accessMode$: new BehaviorSubject<GridAccessMode>('EDIT'),
interactionEvent$: new BehaviorSubject<PanelInteractionEvent | undefined>(undefined),
activePanel$: new BehaviorSubject<ActivePanel | undefined>(undefined),
gridDimensions$: new BehaviorSubject<ObservedSize>({ width: 600, height: 900 }),
...overrides,
};
};

View file

@ -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 () => {

View file

@ -0,0 +1,44 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", 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;
};