Skip to content

Commit

Permalink
[kbn-grid-layout] Add ability to create, edit, and delete rows (#209193)
Browse files Browse the repository at this point in the history
Closes #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>
  • Loading branch information
3 people authored Feb 24, 2025
1 parent 4a8928d commit e587187
Show file tree
Hide file tree
Showing 17 changed files with 1,004 additions and 218 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -59,6 +70,7 @@ export function AddButton({ pageApi, uiActions }: { pageApi: unknown; uiActions:
onClick={() => {
setIsPopoverOpen(!isPopoverOpen);
}}
disabled={viewMode !== 'edit'}
>
Add panel
</EuiButton>
Expand Down
215 changes: 89 additions & 126 deletions examples/grid_example/public/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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();
Expand All @@ -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`
Expand Down Expand Up @@ -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>
Expand All @@ -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',
})}
Expand Down Expand Up @@ -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>
Expand Down
Loading

0 comments on commit e587187

Please sign in to comment.