Skip to content

Commit

Permalink
[Dashboard] [Collapsable Panels] Add panel management API (elastic#19…
Browse files Browse the repository at this point in the history
…5513)

Closes elastic#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](elastic#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
elastic#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
elastic#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
:bow:

### 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 <[email protected]>
  • Loading branch information
2 people authored and mgadewoll committed Nov 7, 2024
1 parent 01708b4 commit 61259c0
Show file tree
Hide file tree
Showing 17 changed files with 775 additions and 144 deletions.
210 changes: 173 additions & 37 deletions examples/grid_example/public/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 { 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 { EuiPageTemplate, EuiProvider } from '@elastic/eui';
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),
};
}}
/>
Expand All @@ -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);
};
108 changes: 108 additions & 0 deletions examples/grid_example/public/get_panel_id.tsx
Original file line number Diff line number Diff line change
@@ -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,
}
)
);
});
};
7 changes: 5 additions & 2 deletions examples/grid_example/public/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
Loading

0 comments on commit 61259c0

Please sign in to comment.