kibana/examples/grid_example/public/app.tsx
Hannah Mudge 1b25685667
[kbn-grid-layout] Flatten grid layout (#218900)
Closes https://github.com/elastic/kibana/issues/216096

## Summary

This PR accomplishes two main things:
1. It flattens out how grid elements are rendered, which means that
embeddables no longer re-mount when dragged between sections and
2. It allows panels and sections to be "intermixed" on a **single**
level (i.e. you can only drop a section header between panels if they
are **not** in a section)

Since this was a **major** rewrite of the grid layout logic, I also took
some time to clean up the code - this includes removing
`proposedGridLayout$` (since this added two sources of truth, which was
causing issues with the DOM becoming out-of-sync with the layout object;
however, this also caused
https://github.com/elastic/kibana/issues/220309) and unifying on the use
of "section" rather than "row" (since it was confusing that we were
using "row" for both the grid row number and the section ID).


https://github.com/user-attachments/assets/c5d9aa97-5b14-4f4c-aacf-74055c7d9c33

> [!NOTE]
> Reminder that, since collapsible sections aren't available in
Dashboard yet, you must test this PR in the `grid` example app (by
running Kibana with `yarn start --run-examples`).


### 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)

---------

Co-authored-by: mbondyra <marta.bondyra@elastic.co>
Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
Co-authored-by: Marta Bondyra <4283304+mbondyra@users.noreply.github.com>
2025-05-21 08:01:13 -06:00

361 lines
13 KiB
TypeScript

/*
* 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 deepEqual from 'fast-deep-equal';
import { cloneDeep } from 'lodash';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import ReactDOM from 'react-dom';
import { Subject, combineLatest, debounceTime, map, take } from 'rxjs';
import { v4 as uuidv4 } from 'uuid';
import {
EuiBadge,
EuiButton,
EuiButtonEmpty,
EuiCallOut,
EuiFlexGroup,
EuiFlexItem,
EuiPageTemplate,
EuiSpacer,
UseEuiTheme,
transparentize,
} from '@elastic/eui';
import { css } from '@emotion/react';
import { AppMountParameters } from '@kbn/core-application-browser';
import { CoreStart } from '@kbn/core-lifecycle-browser';
import { AddEmbeddableButton } from '@kbn/embeddable-examples-plugin/public';
import { EmbeddableRenderer } from '@kbn/embeddable-plugin/public';
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,
setSerializedGridLayout,
} from './serialized_grid_layout';
import { MockSerializedDashboardState } from './types';
import { useLayoutStyles } from './use_layout_styles';
import { useMockDashboardApi } from './use_mock_dashboard_api';
import { dashboardInputToGridLayout, gridLayoutToDashboardPanelMap } from './utils';
const DASHBOARD_MARGIN_SIZE = 8;
const DASHBOARD_GRID_HEIGHT = 20;
const DASHBOARD_GRID_COLUMN_COUNT = 48;
const DASHBOARD_DRAG_TOP_OFFSET = 150;
export const GridExample = ({
coreStart,
uiActions,
}: {
coreStart: CoreStart;
uiActions: UiActionsStart;
}) => {
const layoutStyles = useLayoutStyles();
const savedState = useRef<MockSerializedDashboardState>(getSerializedDashboardState());
const [hasUnsavedChanges, setHasUnsavedChanges] = useState<boolean>(false);
const [currentLayout, setCurrentLayout] = useState<GridLayoutData>(
dashboardInputToGridLayout(savedState.current)
);
const [gridSettings, setGridSettings] = useState<GridSettings>({
gutterSize: DASHBOARD_MARGIN_SIZE,
rowHeight: DASHBOARD_GRID_HEIGHT,
columnCount: DASHBOARD_GRID_COLUMN_COUNT,
keyboardDragTopLimit: DASHBOARD_DRAG_TOP_OFFSET,
});
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.sections$])
.pipe(
debounceTime(0), // debounce to avoid subscribe being called twice when both panels$ and rows$ publish
map(([panels, sections]) => {
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(currentPanel?.gridData, savedPanel?.gridData);
}
const hasChanges = !(panelsAreEqual && deepEqual(sections, savedState.current.sections));
return { hasChanges, updatedLayout: dashboardInputToGridLayout({ panels, sections }) };
})
)
.subscribe(({ hasChanges, updatedLayout }) => {
setHasUnsavedChanges(hasChanges);
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();
return (
<EmbeddableRenderer
key={id}
maybeId={id}
type={currentPanels[id].type}
getParentApi={() => mockDashboardApi}
panelProps={{
showBadges: true,
showBorder: true,
showNotifications: true,
showShadow: false,
setDragHandles,
}}
/>
);
},
[mockDashboardApi]
);
const onLayoutChange = useCallback(
(newLayout: GridLayoutData) => {
const { panels, sections } = gridLayoutToDashboardPanelMap(
mockDashboardApi.panels$.getValue(),
newLayout
);
mockDashboardApi.panels$.next(panels);
mockDashboardApi.sections$.next(sections);
},
[mockDashboardApi.panels$, mockDashboardApi.sections$]
);
const addNewSection = useCallback(() => {
const rows = cloneDeep(mockDashboardApi.sections$.getValue());
const id = uuidv4();
const maxY = Math.max(
...Object.values({
...mockDashboardApi.sections$.getValue(),
...mockDashboardApi.panels$.getValue(),
}).map((widget) => ('gridData' in widget ? widget.gridData.y + widget.gridData.h : widget.y))
);
rows[id] = {
id,
y: maxY + 1,
title: i18n.translate('examples.gridExample.defaultSectionTitle', {
defaultMessage: 'New collapsible section',
}),
collapsed: false,
};
mockDashboardApi.sections$.next(rows);
// scroll to bottom after row is added
layoutUpdated$.pipe(take(1)).subscribe(() => {
window.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' });
});
}, [mockDashboardApi.sections$, mockDashboardApi.panels$, layoutUpdated$]);
const resetUnsavedChanges = useCallback(() => {
const { panels, sections: rows } = savedState.current;
mockDashboardApi.panels$.next(panels);
mockDashboardApi.sections$.next(rows);
}, [mockDashboardApi.panels$, mockDashboardApi.sections$]);
return (
<KibanaRenderContextProvider {...coreStart}>
<EuiPageTemplate grow={false} offset={0} restrictWidth={false}>
<EuiPageTemplate.Header
iconType={'dashboardApp'}
pageTitle={i18n.translate('examples.gridExample.pageTitle', {
defaultMessage: 'Grid Layout Example',
})}
/>
<EuiPageTemplate.Section
color="subdued"
contentProps={{
css: { flexGrow: 1, display: 'flex', flexDirection: 'column' },
}}
>
<EuiCallOut
title={i18n.translate('examples.gridExample.sessionStorageCallout', {
defaultMessage:
'This example uses session storage to persist saved state and unsaved changes',
})}
>
<EuiButton
color="accent"
size="s"
onClick={() => {
clearSerializedDashboardState();
window.location.reload();
}}
>
{i18n.translate('examples.gridExample.resetExampleButton', {
defaultMessage: 'Reset example',
})}
</EuiButton>
</EuiCallOut>
<EuiSpacer size="m" />
<EuiFlexGroup justifyContent="spaceBetween" alignItems="center">
<EuiFlexItem grow={false}>
<EuiFlexGroup gutterSize="s" alignItems="center">
<EuiFlexItem grow={false}>
<AddEmbeddableButton pageApi={mockDashboardApi} uiActions={uiActions} />
</EuiFlexItem>
<EuiFlexItem grow={false}>
<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>
<EuiFlexItem grow={false}>
<EuiFlexGroup gutterSize="xs" alignItems="center">
{hasUnsavedChanges && (
<EuiFlexItem grow={false}>
<EuiBadge color="warning">
{i18n.translate('examples.gridExample.unsavedChangesBadge', {
defaultMessage: 'Unsaved changes',
})}
</EuiBadge>
</EuiFlexItem>
)}
<EuiFlexItem grow={false}>
<EuiButtonEmpty onClick={resetUnsavedChanges}>
{i18n.translate('examples.gridExample.resetLayoutButton', {
defaultMessage: 'Reset',
})}
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton
onClick={() => {
const newSavedState = {
panels: mockDashboardApi.panels$.getValue(),
sections: mockDashboardApi.sections$.getValue(),
};
savedState.current = newSavedState;
setHasUnsavedChanges(false);
setSerializedGridLayout(newSavedState);
}}
>
{i18n.translate('examples.gridExample.saveLayoutButton', {
defaultMessage: 'Save',
})}
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="m" />
<GridLayout
accessMode={viewMode === 'view' ? 'VIEW' : 'EDIT'}
expandedPanelId={expandedPanelId}
layout={currentLayout}
gridSettings={gridSettings}
renderPanelContents={renderPanelContents}
onLayoutChange={onLayoutChange}
css={[layoutStyles, customLayoutStyles]}
useCustomDragHandle={true}
/>
</EuiPageTemplate.Section>
</EuiPageTemplate>
</KibanaRenderContextProvider>
);
};
export const renderGridExampleApp = (
element: AppMountParameters['element'],
deps: { uiActions: UiActionsStart; coreStart: CoreStart }
) => {
ReactDOM.render(<GridExample {...deps} />, element);
return () => ReactDOM.unmountComponentAtNode(element);
};
const customLayoutStyles = ({ euiTheme }: UseEuiTheme) => {
return css({
// removes the extra padding that EuiPageTemplate adds in order to make it look more similar to Dashboard
marginLeft: `-${euiTheme.size.l}`,
marginRight: `-${euiTheme.size.l}`,
// styling for what the grid row header looks like when being dragged
'.kbnGridSectionHeader--active': {
backgroundColor: euiTheme.colors.backgroundBasePlain,
outline: `${euiTheme.border.width.thick} solid
${euiTheme.colors.vis.euiColorVis0}`,
borderRadius: `${euiTheme.border.radius.medium} ${euiTheme.border.radius.medium}`,
paddingLeft: '8px',
// hide accordian arrow + panel count text when row is being dragged
'& .kbnGridSectionTitle--button svg, & .kbnGridLayout--panelCount': {
display: 'none',
},
},
// styles for the area where the row will be dropped
'.kbnGridSection--dragPreview': {
backgroundColor: transparentize(euiTheme.colors.vis.euiColorVis0, 0.2),
borderRadius: `${euiTheme.border.radius.medium} ${euiTheme.border.radius.medium}`,
},
'.kbnGridSectionFooter': {
height: `${euiTheme.size.s}`,
display: `block`,
borderTop: `${euiTheme.border.thin}`,
'&--targeted': {
borderTop: `${euiTheme.border.width.thick} solid ${transparentize(
euiTheme.colors.vis.euiColorVis0,
0.5
)}`,
},
},
// hide border when section is being dragged
'&:has(.kbnGridSectionHeader--active) .kbnGridSectionHeader--active + .kbnGridSectionFooter': {
borderTop: `none`,
},
'.kbnGridSection--blocked': {
zIndex: 1,
backgroundColor: `${transparentize(euiTheme.colors.backgroundBaseSubdued, 0.5)}`,
// the oulines of panels extend past 100% by 1px on each side, so adjust for that
marginLeft: '-1px',
marginTop: '-1px',
width: `calc(100% + 2px)`,
height: `calc(100% + 2px)`,
},
'&:has(.kbnGridSection--blocked) .kbnGridSection--dragHandle': {
cursor: 'not-allowed !important',
},
});
};