[Dashboard] [Collapsable Panels] Add panel management API (#195513)

Closes https://github.com/elastic/kibana/issues/190445

## Summary

This PR adds the first steps of a panel management API to the
`GridLayout` component:
- A method to delete a panel
- A method to replace a panel
- A method to add a panel with a given size and placement technique
(`'placeAtTop' | 'findTopLeftMostOpenSpace'`)
- Currently, we only support adding a panel to the first row, since this
is all that is necessary for parity with the current Dashboard layout
engine - we can revisit this decision as part of the [row
API](https://github.com/elastic/kibana/issues/195807).
- A method to get panel count
- This might not be necessary for the dashboard (we'll see), but I
needed it for the example plugin to be able to generate suggested panel
IDs. It's possible this will get removed 🤷
- The ability to serialize the grid layout state

I only included the bare minimum here that I know will be necessary for
a dashboard integration, but it's possible I missed some things and so
this API will most likely expand in the future.



https://github.com/user-attachments/assets/28df844c-5c12-40fd-b4f4-8fbd1a8abc20





### Serialization

With respect to serialization, there are still some open questions about
how we want to handle it from the Dashboard side - therefore, in this
PR, I opted to keep the serialization as simple as possible (i.e. both
the input and serialized output take identical forms for the
`GridLayout` component).

Our goal is to keep `kbn-grid-layout` as **generic** as possible so,
while I considered making the serialize method return the form that the
Dashboard expects, I ultimately decided against that; instead, I think
Dashboard should be responsible for taking the grid layout's serialized
form and turning it into a dashboard-specific serialization of a grid
layout and vice-versa for deserializing and sending the initial layout
to the `GridLayout` component.

The dashboard grid layout serialization will be tackled as part of
https://github.com/elastic/kibana/issues/190446, where it's possible my
opinion might change :) This is just a first draft of the
`kbn-grid-layout` API, after all.

### Example Grid Layout

In the grid layout example plugin, I integrated the API by adding some
pretty bare-bones buttons to each panel in order to ensure the API works
as expected - that being said, I didn't worry too much about the design
of these things and so it looks pretty ugly 😆 My next step is
https://github.com/elastic/kibana/issues/190379, where I will have to
integrate the grid layout API with the embeddable actions, at which
point the design will be improved - so this is a very temporary state
🙇

### 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/packages/kbn-i18n/README.md)
- [x] This was checked for [cross-browser
compatibility](https://www.elastic.co/support/matrix#matrix_browsers)


### For maintainers

- [ ] This was checked for breaking API changes and was [labeled
appropriately](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process)

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Hannah Mudge 2024-11-05 11:11:11 -07:00 committed by GitHub
parent b71efcfe7c
commit a91427d71b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 776 additions and 145 deletions

View file

@ -7,53 +7,186 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import React from 'react';
import { cloneDeep } from 'lodash';
import React, { useRef, useState } from 'react';
import ReactDOM from 'react-dom';
import { GridLayout, type GridLayoutData } from '@kbn/grid-layout';
import { AppMountParameters } from '@kbn/core-application-browser';
import { EuiPageTemplate, EuiProvider } from '@elastic/eui';
import { v4 as uuidv4 } from 'uuid';
import {
EuiBadge,
EuiButton,
EuiButtonEmpty,
EuiCallOut,
EuiFlexGroup,
EuiFlexItem,
EuiPageTemplate,
EuiProvider,
EuiSpacer,
} from '@elastic/eui';
import { AppMountParameters } from '@kbn/core-application-browser';
import { CoreStart } from '@kbn/core-lifecycle-browser';
import { GridLayout, GridLayoutData, isLayoutEqual, type GridLayoutApi } from '@kbn/grid-layout';
import { i18n } from '@kbn/i18n';
import { getPanelId } from './get_panel_id';
import {
clearSerializedGridLayout,
getSerializedGridLayout,
setSerializedGridLayout,
} from './serialized_grid_layout';
const DASHBOARD_MARGIN_SIZE = 8;
const DASHBOARD_GRID_HEIGHT = 20;
const DASHBOARD_GRID_COLUMN_COUNT = 48;
const DEFAULT_PANEL_HEIGHT = 15;
const DEFAULT_PANEL_WIDTH = DASHBOARD_GRID_COLUMN_COUNT / 2;
export const GridExample = ({ coreStart }: { coreStart: CoreStart }) => {
const [hasUnsavedChanges, setHasUnsavedChanges] = useState<boolean>(false);
const [layoutKey, setLayoutKey] = useState<string>(uuidv4());
const [gridLayoutApi, setGridLayoutApi] = useState<GridLayoutApi | null>();
const savedLayout = useRef<GridLayoutData>(getSerializedGridLayout());
const currentLayout = useRef<GridLayoutData>(savedLayout.current);
export const GridExample = () => {
return (
<EuiProvider>
<EuiPageTemplate grow={false} offset={0} restrictWidth={false}>
<EuiPageTemplate.Header iconType={'dashboardApp'} pageTitle="Grid Layout Example" />
<EuiPageTemplate.Header
iconType={'dashboardApp'}
pageTitle={i18n.translate('examples.gridExample.pageTitle', {
defaultMessage: 'Grid Layout Example',
})}
/>
<EuiPageTemplate.Section color="subdued">
<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={() => {
clearSerializedGridLayout();
window.location.reload();
}}
>
{i18n.translate('examples.gridExample.resetExampleButton', {
defaultMessage: 'Reset example',
})}
</EuiButton>
</EuiCallOut>
<EuiSpacer size="m" />
<EuiFlexGroup justifyContent="spaceBetween" alignItems="center">
<EuiFlexItem grow={false}>
<EuiButton
onClick={async () => {
const panelId = await getPanelId({
coreStart,
suggestion: `panel${(gridLayoutApi?.getPanelCount() ?? 0) + 1}`,
});
if (panelId)
gridLayoutApi?.addPanel(panelId, {
width: DEFAULT_PANEL_WIDTH,
height: DEFAULT_PANEL_HEIGHT,
});
}}
>
{i18n.translate('examples.gridExample.addPanelButton', {
defaultMessage: 'Add a panel',
})}
</EuiButton>
</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={() => {
currentLayout.current = cloneDeep(savedLayout.current);
setHasUnsavedChanges(false);
setLayoutKey(uuidv4()); // force remount of grid
}}
>
{i18n.translate('examples.gridExample.resetLayoutButton', {
defaultMessage: 'Reset',
})}
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton
onClick={() => {
if (gridLayoutApi) {
const layoutToSave = gridLayoutApi.serializeState();
setSerializedGridLayout(layoutToSave);
savedLayout.current = layoutToSave;
setHasUnsavedChanges(false);
}
}}
>
{i18n.translate('examples.gridExample.saveLayoutButton', {
defaultMessage: 'Save',
})}
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="m" />
<GridLayout
key={layoutKey}
onLayoutChange={(newLayout) => {
currentLayout.current = cloneDeep(newLayout);
setHasUnsavedChanges(!isLayoutEqual(savedLayout.current, newLayout));
}}
ref={setGridLayoutApi}
renderPanelContents={(id) => {
return <div style={{ padding: 8 }}>{id}</div>;
return (
<>
<div style={{ padding: 8 }}>{id}</div>
<EuiButtonEmpty
onClick={() => {
gridLayoutApi?.removePanel(id);
}}
>
{i18n.translate('examples.gridExample.deletePanelButton', {
defaultMessage: 'Delete panel',
})}
</EuiButtonEmpty>
<EuiButtonEmpty
onClick={async () => {
const newPanelId = await getPanelId({
coreStart,
suggestion: `panel${(gridLayoutApi?.getPanelCount() ?? 0) + 1}`,
});
if (newPanelId) gridLayoutApi?.replacePanel(id, newPanelId);
}}
>
{i18n.translate('examples.gridExample.replacePanelButton', {
defaultMessage: 'Replace panel',
})}
</EuiButtonEmpty>
</>
);
}}
getCreationOptions={() => {
const initialLayout: GridLayoutData = [
{
title: 'Large section',
isCollapsed: false,
panels: {
panel1: { column: 0, row: 0, width: 12, height: 6, id: 'panel1' },
panel2: { column: 0, row: 6, width: 8, height: 4, id: 'panel2' },
panel3: { column: 8, row: 6, width: 12, height: 4, id: 'panel3' },
panel4: { column: 0, row: 10, width: 48, height: 4, id: 'panel4' },
panel5: { column: 12, row: 0, width: 36, height: 6, id: 'panel5' },
panel6: { column: 24, row: 6, width: 24, height: 4, id: 'panel6' },
panel7: { column: 20, row: 6, width: 4, height: 2, id: 'panel7' },
panel8: { column: 20, row: 8, width: 4, height: 2, id: 'panel8' },
},
},
{
title: 'Small section',
isCollapsed: false,
panels: { panel9: { column: 0, row: 0, width: 12, height: 16, id: 'panel9' } },
},
{
title: 'Another small section',
isCollapsed: false,
panels: { panel10: { column: 24, row: 0, width: 12, height: 6, id: 'panel10' } },
},
];
return {
gridSettings: { gutterSize: 8, rowHeight: 26, columnCount: 48 },
initialLayout,
gridSettings: {
gutterSize: DASHBOARD_MARGIN_SIZE,
rowHeight: DASHBOARD_GRID_HEIGHT,
columnCount: DASHBOARD_GRID_COLUMN_COUNT,
},
initialLayout: cloneDeep(currentLayout.current),
};
}}
/>
@ -63,8 +196,11 @@ export const GridExample = () => {
);
};
export const renderGridExampleApp = (element: AppMountParameters['element']) => {
ReactDOM.render(<GridExample />, element);
export const renderGridExampleApp = (
element: AppMountParameters['element'],
coreStart: CoreStart
) => {
ReactDOM.render(<GridExample coreStart={coreStart} />, element);
return () => ReactDOM.unmountComponentAtNode(element);
};

View file

@ -0,0 +1,108 @@
/*
* 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,
EuiCallOut,
EuiFieldText,
EuiModal,
EuiModalBody,
EuiModalFooter,
EuiModalHeader,
EuiModalHeaderTitle,
EuiSpacer,
} from '@elastic/eui';
import { CoreStart } from '@kbn/core-lifecycle-browser';
import { toMountPoint } from '@kbn/react-kibana-mount';
import { i18n } from '@kbn/i18n';
const PanelIdModal = ({
suggestion,
onClose,
onSubmit,
}: {
suggestion: string;
onClose: () => void;
onSubmit: (id: string) => void;
}) => {
const [panelId, setPanelId] = useState<string>(suggestion);
return (
<EuiModal onClose={onClose}>
<EuiModalHeader>
<EuiModalHeaderTitle>
{i18n.translate('examples.gridExample.getPanelIdModalTitle', {
defaultMessage: 'Panel ID',
})}
</EuiModalHeaderTitle>
</EuiModalHeader>
<EuiModalBody>
<EuiCallOut
color="warning"
title={i18n.translate('examples.gridExample.getPanelIdWarning', {
defaultMessage: 'Ensure the panel ID is unique, or you may get unexpected behaviour.',
})}
/>
<EuiSpacer size="m" />
<EuiFieldText
placeholder={suggestion}
value={panelId}
onChange={(e) => {
setPanelId(e.target.value ?? '');
}}
/>
</EuiModalBody>
<EuiModalFooter>
<EuiButton
onClick={() => {
onSubmit(panelId);
}}
>
{i18n.translate('examples.gridExample.getPanelIdSubmitButton', {
defaultMessage: 'Submit',
})}
</EuiButton>
</EuiModalFooter>
</EuiModal>
);
};
export const getPanelId = async ({
coreStart,
suggestion,
}: {
coreStart: CoreStart;
suggestion: string;
}): Promise<string | undefined> => {
return new Promise<string | undefined>((resolve) => {
const session = coreStart.overlays.openModal(
toMountPoint(
<PanelIdModal
suggestion={suggestion}
onClose={() => {
resolve(undefined);
session.close();
}}
onSubmit={(newPanelId) => {
resolve(newPanelId);
session.close();
}}
/>,
{
theme: coreStart.theme,
i18n: coreStart.i18n,
}
)
);
});
};

View file

@ -26,8 +26,11 @@ export class GridExamplePlugin
title: gridExampleTitle,
visibleIn: [],
async mount(params: AppMountParameters) {
const { renderGridExampleApp } = await import('./app');
return renderGridExampleApp(params.element);
const [{ renderGridExampleApp }, [coreStart]] = await Promise.all([
import('./app'),
core.getStartServices(),
]);
return renderGridExampleApp(params.element, coreStart);
},
});
developerExamples.register({

View file

@ -0,0 +1,52 @@
/*
* 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 { type GridLayoutData } from '@kbn/grid-layout';
const STATE_SESSION_STORAGE_KEY = 'kibana.examples.gridExample.state';
export function clearSerializedGridLayout() {
sessionStorage.removeItem(STATE_SESSION_STORAGE_KEY);
}
export function getSerializedGridLayout(): GridLayoutData {
const serializedStateJSON = sessionStorage.getItem(STATE_SESSION_STORAGE_KEY);
return serializedStateJSON ? JSON.parse(serializedStateJSON) : initialGridLayout;
}
export function setSerializedGridLayout(layout: GridLayoutData) {
sessionStorage.setItem(STATE_SESSION_STORAGE_KEY, JSON.stringify(layout));
}
const initialGridLayout: GridLayoutData = [
{
title: 'Large section',
isCollapsed: false,
panels: {
panel1: { column: 0, row: 0, width: 12, height: 6, id: 'panel1' },
panel2: { column: 0, row: 6, width: 8, height: 4, id: 'panel2' },
panel3: { column: 8, row: 6, width: 12, height: 4, id: 'panel3' },
panel4: { column: 0, row: 10, width: 48, height: 4, id: 'panel4' },
panel5: { column: 12, row: 0, width: 36, height: 6, id: 'panel5' },
panel6: { column: 24, row: 6, width: 24, height: 4, id: 'panel6' },
panel7: { column: 20, row: 6, width: 4, height: 2, id: 'panel7' },
panel8: { column: 20, row: 8, width: 4, height: 2, id: 'panel8' },
},
},
{
title: 'Small section',
isCollapsed: false,
panels: { panel9: { column: 0, row: 0, width: 12, height: 16, id: 'panel9' } },
},
{
title: 'Another small section',
isCollapsed: false,
panels: { panel10: { column: 24, row: 0, width: 12, height: 6, id: 'panel10' } },
},
];

View file

@ -10,5 +10,8 @@
"@kbn/core-application-browser",
"@kbn/core",
"@kbn/developer-examples-plugin",
"@kbn/core-lifecycle-browser",
"@kbn/react-kibana-mount",
"@kbn/i18n",
]
}

View file

@ -24,7 +24,7 @@ export const GridHeightSmoother = ({
gridLayoutStateManager.interactionEvent$,
]).subscribe(([dimensions, interactionEvent]) => {
if (!smoothHeightRef.current) return;
if (!interactionEvent || interactionEvent.type === 'drop') {
if (!interactionEvent) {
smoothHeightRef.current.style.height = `${dimensions.height}px`;
return;
}

View file

@ -7,82 +7,110 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import React, { useEffect, useState } from 'react';
import { distinctUntilChanged, map, skip } from 'rxjs';
import { v4 as uuidv4 } from 'uuid';
import { cloneDeep } from 'lodash';
import React, { forwardRef, useEffect, useImperativeHandle, useState } from 'react';
import { combineLatest, distinctUntilChanged, filter, map, pairwise, skip } from 'rxjs';
import { GridHeightSmoother } from './grid_height_smoother';
import { GridRow } from './grid_row';
import { GridLayoutData, GridSettings } from './types';
import { GridLayoutApi, GridLayoutData, GridSettings } from './types';
import { useGridLayoutApi } from './use_grid_layout_api';
import { useGridLayoutEvents } from './use_grid_layout_events';
import { useGridLayoutState } from './use_grid_layout_state';
import { isLayoutEqual } from './utils/equality_checks';
export const GridLayout = ({
getCreationOptions,
renderPanelContents,
}: {
interface GridLayoutProps {
getCreationOptions: () => { initialLayout: GridLayoutData; gridSettings: GridSettings };
renderPanelContents: (panelId: string) => React.ReactNode;
}) => {
const { gridLayoutStateManager, setDimensionsRef } = useGridLayoutState({
getCreationOptions,
});
useGridLayoutEvents({ gridLayoutStateManager });
onLayoutChange: (newLayout: GridLayoutData) => void;
}
const [rowCount, setRowCount] = useState<number>(
gridLayoutStateManager.gridLayout$.getValue().length
);
export const GridLayout = forwardRef<GridLayoutApi, GridLayoutProps>(
({ getCreationOptions, renderPanelContents, onLayoutChange }, ref) => {
const { gridLayoutStateManager, setDimensionsRef } = useGridLayoutState({
getCreationOptions,
});
useGridLayoutEvents({ gridLayoutStateManager });
useEffect(() => {
/**
* The only thing that should cause the entire layout to re-render is adding a new row;
* this subscription ensures this by updating the `rowCount` state when it changes.
*/
const rowCountSubscription = gridLayoutStateManager.gridLayout$
.pipe(
skip(1), // we initialized `rowCount` above, so skip the initial emit
map((newLayout) => newLayout.length),
distinctUntilChanged()
)
.subscribe((newRowCount) => {
setRowCount(newRowCount);
});
return () => rowCountSubscription.unsubscribe();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const gridLayoutApi = useGridLayoutApi({ gridLayoutStateManager });
useImperativeHandle(ref, () => gridLayoutApi, [gridLayoutApi]);
return (
<>
<GridHeightSmoother gridLayoutStateManager={gridLayoutStateManager}>
<div
ref={(divElement) => {
setDimensionsRef(divElement);
}}
>
{Array.from({ length: rowCount }, (_, rowIndex) => {
return (
<GridRow
key={uuidv4()}
rowIndex={rowIndex}
renderPanelContents={renderPanelContents}
gridLayoutStateManager={gridLayoutStateManager}
toggleIsCollapsed={() => {
const currentLayout = gridLayoutStateManager.gridLayout$.value;
currentLayout[rowIndex].isCollapsed = !currentLayout[rowIndex].isCollapsed;
gridLayoutStateManager.gridLayout$.next(currentLayout);
}}
setInteractionEvent={(nextInteractionEvent) => {
if (nextInteractionEvent?.type === 'drop') {
gridLayoutStateManager.activePanel$.next(undefined);
}
gridLayoutStateManager.interactionEvent$.next(nextInteractionEvent);
}}
ref={(element) => (gridLayoutStateManager.rowRefs.current[rowIndex] = element)}
/>
);
})}
</div>
</GridHeightSmoother>
</>
);
};
const [rowCount, setRowCount] = useState<number>(
gridLayoutStateManager.gridLayout$.getValue().length
);
useEffect(() => {
/**
* The only thing that should cause the entire layout to re-render is adding a new row;
* this subscription ensures this by updating the `rowCount` state when it changes.
*/
const rowCountSubscription = gridLayoutStateManager.gridLayout$
.pipe(
skip(1), // we initialized `rowCount` above, so skip the initial emit
map((newLayout) => newLayout.length),
distinctUntilChanged()
)
.subscribe((newRowCount) => {
setRowCount(newRowCount);
});
const onLayoutChangeSubscription = combineLatest([
gridLayoutStateManager.gridLayout$,
gridLayoutStateManager.interactionEvent$,
])
.pipe(
// if an interaction event is happening, then ignore any "draft" layout changes
filter(([_, event]) => !Boolean(event)),
// once no interaction event, create pairs of "old" and "new" layouts for comparison
map(([layout]) => layout),
pairwise()
)
.subscribe(([layoutBefore, layoutAfter]) => {
if (!isLayoutEqual(layoutBefore, layoutAfter)) {
onLayoutChange(layoutAfter);
}
});
return () => {
rowCountSubscription.unsubscribe();
onLayoutChangeSubscription.unsubscribe();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<>
<GridHeightSmoother gridLayoutStateManager={gridLayoutStateManager}>
<div
ref={(divElement) => {
setDimensionsRef(divElement);
}}
>
{Array.from({ length: rowCount }, (_, rowIndex) => {
return (
<GridRow
key={rowIndex}
rowIndex={rowIndex}
renderPanelContents={renderPanelContents}
gridLayoutStateManager={gridLayoutStateManager}
toggleIsCollapsed={() => {
const newLayout = cloneDeep(gridLayoutStateManager.gridLayout$.value);
newLayout[rowIndex].isCollapsed = !newLayout[rowIndex].isCollapsed;
gridLayoutStateManager.gridLayout$.next(newLayout);
}}
setInteractionEvent={(nextInteractionEvent) => {
if (!nextInteractionEvent) {
gridLayoutStateManager.activePanel$.next(undefined);
}
gridLayoutStateManager.interactionEvent$.next(nextInteractionEvent);
}}
ref={(element) => (gridLayoutStateManager.rowRefs.current[rowIndex] = element)}
/>
);
})}
</div>
</GridHeightSmoother>
</>
);
}
);

View file

@ -30,7 +30,7 @@ export const GridPanel = forwardRef<
rowIndex: number;
renderPanelContents: (panelId: string) => React.ReactNode;
interactionStart: (
type: PanelInteractionEvent['type'],
type: PanelInteractionEvent['type'] | 'drop',
e: React.MouseEvent<HTMLDivElement, MouseEvent>
) => void;
gridLayoutStateManager: GridLayoutStateManager;
@ -190,6 +190,7 @@ export const GridPanel = forwardRef<
border-bottom: 2px solid ${euiThemeVars.euiColorSuccess};
border-right: 2px solid ${euiThemeVars.euiColorSuccess};
:hover {
opacity: 1;
background-color: ${transparentize(euiThemeVars.euiColorSuccess, 0.05)};
cursor: se-resize;
}

View file

@ -91,7 +91,7 @@ export const GridRow = forwardRef<
)}, ${rowHeight}px)`;
const targetRow = interactionEvent?.targetRowIndex;
if (rowIndex === targetRow && interactionEvent?.type !== 'drop') {
if (rowIndex === targetRow && interactionEvent) {
// apply "targetted row" styles
const gridColor = transparentize(euiThemeVars.euiColorSuccess, 0.2);
rowRef.style.backgroundPosition = `top -${gutterSize / 2}px left -${
@ -122,7 +122,6 @@ export const GridRow = forwardRef<
*/
const rowStateSubscription = gridLayoutStateManager.gridLayout$
.pipe(
skip(1), // we are initializing all row state with a value, so skip the initial emit
map((gridLayout) => {
return {
title: gridLayout[rowIndex].title,
@ -201,18 +200,22 @@ export const GridRow = forwardRef<
if (!panelRef) return;
const panelRect = panelRef.getBoundingClientRect();
setInteractionEvent({
type,
id: panelId,
panelDiv: panelRef,
targetRowIndex: rowIndex,
mouseOffsets: {
top: e.clientY - panelRect.top,
left: e.clientX - panelRect.left,
right: e.clientX - panelRect.right,
bottom: e.clientY - panelRect.bottom,
},
});
if (type === 'drop') {
setInteractionEvent(undefined);
} else {
setInteractionEvent({
type,
id: panelId,
panelDiv: panelRef,
targetRowIndex: rowIndex,
mouseOffsets: {
top: e.clientY - panelRect.top,
left: e.clientX - panelRect.left,
right: e.clientX - panelRect.right,
bottom: e.clientY - panelRect.bottom,
},
});
}
}}
ref={(element) => {
if (!gridLayoutStateManager.panelRefs.current[rowIndex]) {

View file

@ -9,11 +9,13 @@
import { BehaviorSubject } from 'rxjs';
import type { ObservedSize } from 'use-resize-observer/polyfilled';
import { SerializableRecord } from '@kbn/utility-types';
export interface GridCoordinate {
column: number;
row: number;
}
export interface GridRect extends GridCoordinate {
width: number;
height: number;
@ -57,8 +59,9 @@ export interface ActivePanel {
}
export interface GridLayoutStateManager {
gridDimensions$: BehaviorSubject<ObservedSize>;
gridLayout$: BehaviorSubject<GridLayoutData>;
gridDimensions$: BehaviorSubject<ObservedSize>;
runtimeSettings$: BehaviorSubject<RuntimeGridSettings>;
activePanel$: BehaviorSubject<ActivePanel | undefined>;
interactionEvent$: BehaviorSubject<PanelInteractionEvent | undefined>;
@ -74,7 +77,7 @@ export interface PanelInteractionEvent {
/**
* The type of interaction being performed.
*/
type: 'drag' | 'resize' | 'drop';
type: 'drag' | 'resize';
/**
* The id of the panel being interacted with.
@ -102,3 +105,29 @@ export interface PanelInteractionEvent {
bottom: number;
};
}
/**
* The external API provided through the GridLayout component
*/
export interface GridLayoutApi {
addPanel: (panelId: string, placementSettings: PanelPlacementSettings) => void;
removePanel: (panelId: string) => void;
replacePanel: (oldPanelId: string, newPanelId: string) => void;
getPanelCount: () => number;
serializeState: () => GridLayoutData & SerializableRecord;
}
// TODO: Remove from Dashboard plugin as part of https://github.com/elastic/kibana/issues/190446
export enum PanelPlacementStrategy {
/** Place on the very top of the grid layout, add the height of this panel to all other panels. */
placeAtTop = 'placeAtTop',
/** Look for the smallest y and x value where the default panel will fit. */
findTopLeftMostOpenSpace = 'findTopLeftMostOpenSpace',
}
export interface PanelPlacementSettings {
strategy?: PanelPlacementStrategy;
height: number;
width: number;
}

View file

@ -0,0 +1,109 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { useMemo } from 'react';
import { cloneDeep } from 'lodash';
import { SerializableRecord } from '@kbn/utility-types';
import { GridLayoutApi, GridLayoutData, GridLayoutStateManager } from './types';
import { compactGridRow } from './utils/resolve_grid_row';
import { runPanelPlacementStrategy } from './utils/run_panel_placement';
export const useGridLayoutApi = ({
gridLayoutStateManager,
}: {
gridLayoutStateManager: GridLayoutStateManager;
}): GridLayoutApi => {
const api: GridLayoutApi = useMemo(() => {
return {
addPanel: (panelId, placementSettings) => {
const currentLayout = gridLayoutStateManager.gridLayout$.getValue();
const [firstRow, ...rest] = currentLayout; // currently, only adding panels to the first row is supported
const { columnCount: gridColumnCount } = gridLayoutStateManager.runtimeSettings$.getValue();
const nextRow = runPanelPlacementStrategy(
firstRow,
{
id: panelId,
width: placementSettings.width,
height: placementSettings.height,
},
gridColumnCount,
placementSettings?.strategy
);
gridLayoutStateManager.gridLayout$.next([nextRow, ...rest]);
},
removePanel: (panelId) => {
const currentLayout = gridLayoutStateManager.gridLayout$.getValue();
// find the row where the panel exists and delete it from the corresponding panels object
let rowIndex = 0;
let updatedPanels;
for (rowIndex; rowIndex < currentLayout.length; rowIndex++) {
const row = currentLayout[rowIndex];
if (Object.keys(row.panels).includes(panelId)) {
updatedPanels = { ...row.panels }; // prevent mutation of original panel object
delete updatedPanels[panelId];
break;
}
}
// if the panels were updated (i.e. the panel was successfully found and deleted), update the layout
if (updatedPanels) {
const newLayout = cloneDeep(currentLayout);
newLayout[rowIndex] = compactGridRow({
...newLayout[rowIndex],
panels: updatedPanels,
});
gridLayoutStateManager.gridLayout$.next(newLayout);
}
},
replacePanel: (oldPanelId, newPanelId) => {
const currentLayout = gridLayoutStateManager.gridLayout$.getValue();
// find the row where the panel exists and update its ID to trigger a re-render
let rowIndex = 0;
let updatedPanels;
for (rowIndex; rowIndex < currentLayout.length; rowIndex++) {
const row = { ...currentLayout[rowIndex] };
if (Object.keys(row.panels).includes(oldPanelId)) {
updatedPanels = { ...row.panels }; // prevent mutation of original panel object
const oldPanel = updatedPanels[oldPanelId];
delete updatedPanels[oldPanelId];
updatedPanels[newPanelId] = { ...oldPanel, id: newPanelId };
break;
}
}
// if the panels were updated (i.e. the panel was successfully found and replaced), update the layout
if (updatedPanels) {
const newLayout = cloneDeep(currentLayout);
newLayout[rowIndex].panels = updatedPanels;
gridLayoutStateManager.gridLayout$.next(newLayout);
}
},
getPanelCount: () => {
return gridLayoutStateManager.gridLayout$.getValue().reduce((prev, row) => {
return prev + Object.keys(row.panels).length;
}, 0);
},
serializeState: () => {
const currentLayout = gridLayoutStateManager.gridLayout$.getValue();
return cloneDeep(currentLayout) as GridLayoutData & SerializableRecord;
},
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return api;
};

View file

@ -7,21 +7,11 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { useEffect, useRef } from 'react';
import deepEqual from 'fast-deep-equal';
import { resolveGridRow } from './resolve_grid_row';
import { GridLayoutStateManager, GridPanelData } from './types';
export const isGridDataEqual = (a?: GridPanelData, b?: GridPanelData) => {
return (
a?.id === b?.id &&
a?.column === b?.column &&
a?.row === b?.row &&
a?.width === b?.width &&
a?.height === b?.height
);
};
import { useEffect, useRef } from 'react';
import { resolveGridRow } from './utils/resolve_grid_row';
import { GridPanelData, GridLayoutStateManager } from './types';
import { isGridDataEqual } from './utils/equality_checks';
export const useGridLayoutEvents = ({
gridLayoutStateManager,
@ -37,7 +27,7 @@ export const useGridLayoutEvents = ({
useEffect(() => {
const { runtimeSettings$, interactionEvent$, gridLayout$ } = gridLayoutStateManager;
const calculateUserEvent = (e: Event) => {
if (!interactionEvent$.value || interactionEvent$.value.type === 'drop') return;
if (!interactionEvent$.value) return;
e.preventDefault();
e.stopPropagation();

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 { GridLayoutData, GridPanelData } from '../types';
export const isGridDataEqual = (a?: GridPanelData, b?: GridPanelData) => {
return (
a?.id === b?.id &&
a?.column === b?.column &&
a?.row === b?.row &&
a?.width === b?.width &&
a?.height === b?.height
);
};
export const isLayoutEqual = (a: GridLayoutData, b: GridLayoutData) => {
if (a.length !== b.length) return false;
let isEqual = true;
for (let rowIndex = 0; rowIndex < a.length && isEqual; rowIndex++) {
const rowA = a[rowIndex];
const rowB = b[rowIndex];
isEqual =
rowA.title === rowB.title &&
rowA.isCollapsed === rowB.isCollapsed &&
Object.keys(rowA.panels).length === Object.keys(rowB.panels).length;
if (isEqual) {
for (const panelKey of Object.keys(rowA.panels)) {
isEqual = isGridDataEqual(rowA.panels[panelKey], rowB.panels[panelKey]);
if (!isEqual) break;
}
}
}
return isEqual;
};

View file

@ -7,7 +7,7 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { GridPanelData, GridRowData } from './types';
import { GridPanelData, GridRowData } from '../types';
const collides = (panelA: GridPanelData, panelB: GridPanelData) => {
if (panelA.id === panelB.id) return false; // same panel
@ -57,7 +57,7 @@ const getKeysInOrder = (rowData: GridRowData, draggedId?: string): string[] => {
});
};
const compactGridRow = (originalLayout: GridRowData) => {
export const compactGridRow = (originalLayout: GridRowData) => {
const nextRowData = { ...originalLayout, panels: { ...originalLayout.panels } };
// compact all vertical space.
const sortedKeysAfterMove = getKeysInOrder(nextRowData);

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 { i18n } from '@kbn/i18n';
import { GridRowData } from '../..';
import { GridPanelData, PanelPlacementStrategy } from '../types';
import { compactGridRow, resolveGridRow } from './resolve_grid_row';
export const runPanelPlacementStrategy = (
originalRowData: GridRowData,
newPanel: Omit<GridPanelData, 'row' | 'column'>,
columnCount: number,
strategy: PanelPlacementStrategy = PanelPlacementStrategy.findTopLeftMostOpenSpace
): GridRowData => {
const nextRowData = { ...originalRowData, panels: { ...originalRowData.panels } }; // prevent mutation of original row object
switch (strategy) {
case PanelPlacementStrategy.placeAtTop:
// move all other panels down by the height of the new panel to make room for the new panel
Object.keys(nextRowData.panels).forEach((key) => {
const panel = nextRowData.panels[key];
panel.row += newPanel.height;
});
// some panels might need to be pushed back up because they are now floating - so, compact the row
return compactGridRow({
...nextRowData,
// place the new panel at the top left corner, since there is now space
panels: { ...nextRowData.panels, [newPanel.id]: { ...newPanel, row: 0, column: 0 } },
});
case PanelPlacementStrategy.findTopLeftMostOpenSpace:
// find the max row
let maxRow = -1;
const currentPanelsArray = Object.values(nextRowData.panels);
currentPanelsArray.forEach((panel) => {
maxRow = Math.max(panel.row + panel.height, maxRow);
});
// handle case of empty grid by placing the panel at the top left corner
if (maxRow < 0) {
return {
...nextRowData,
panels: { [newPanel.id]: { ...newPanel, row: 0, column: 0 } },
};
}
// find a spot in the grid where the entire panel will fit
const { row, column } = (() => {
// create a 2D array representation of the grid filled with zeros
const grid = new Array(maxRow);
for (let y = 0; y < maxRow; y++) {
grid[y] = new Array(columnCount).fill(0);
}
// fill in the 2D array with ones wherever a panel is
currentPanelsArray.forEach((panel) => {
for (let x = panel.column; x < panel.column + panel.width; x++) {
for (let y = panel.row; y < panel.row + panel.height; y++) {
grid[y][x] = 1;
}
}
});
// now find the first empty spot where there are enough zeros (unoccupied spaces) to fit the whole panel
for (let y = 0; y < maxRow; y++) {
for (let x = 0; x < columnCount; x++) {
if (grid[y][x] === 1) {
// space is filled, so skip this spot
continue;
} else {
for (let h = y; h < Math.min(y + newPanel.height, maxRow); h++) {
for (let w = x; w < Math.min(x + newPanel.width, columnCount); w++) {
const spaceIsEmpty = grid[h][w] === 0;
const fitsPanelWidth = w === x + newPanel.width - 1;
// if the panel is taller than any other panel in the current grid, it can still fit in the space, hence
// we check the minimum of maxY and the panel height.
const fitsPanelHeight = h === Math.min(y + newPanel.height - 1, maxRow - 1);
if (spaceIsEmpty && fitsPanelWidth && fitsPanelHeight) {
// found an empty space where the entire panel will fit
return { column: x, row: y };
} else if (grid[h][w] === 1) {
// x, y is already occupied - break out of the loop and move on to the next starting point
break;
}
}
}
}
}
}
return { column: 0, row: maxRow };
})();
// some panels might need to be pushed down to accomodate the height of the new panel;
// so, resolve the entire row to remove any potential collisions
return resolveGridRow({
...nextRowData,
// place the new panel at the top left corner, since there is now space
panels: { ...nextRowData.panels, [newPanel.id]: { ...newPanel, row, column } },
});
default:
throw new Error(
i18n.translate('kbnGridLayout.panelPlacement.unknownStrategyError', {
defaultMessage: 'Unknown panel placement strategy: {strategy}',
values: { strategy },
})
);
}
};

View file

@ -8,4 +8,12 @@
*/
export { GridLayout } from './grid/grid_layout';
export type { GridLayoutData, GridPanelData, GridRowData, GridSettings } from './grid/types';
export type {
GridLayoutApi,
GridLayoutData,
GridPanelData,
GridRowData,
GridSettings,
} from './grid/types';
export { isLayoutEqual } from './grid/utils/equality_checks';

View file

@ -19,5 +19,6 @@
"kbn_references": [
"@kbn/ui-theme",
"@kbn/i18n",
"@kbn/utility-types",
]
}