mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 09:19:04 -04:00
[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:
parent
b71efcfe7c
commit
a91427d71b
17 changed files with 776 additions and 145 deletions
|
@ -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);
|
||||
};
|
||||
|
|
108
examples/grid_example/public/get_panel_id.tsx
Normal file
108
examples/grid_example/public/get_panel_id.tsx
Normal 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,
|
||||
}
|
||||
)
|
||||
);
|
||||
});
|
||||
};
|
|
@ -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({
|
||||
|
|
52
examples/grid_example/public/serialized_grid_layout.ts
Normal file
52
examples/grid_example/public/serialized_grid_layout.ts
Normal 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' } },
|
||||
},
|
||||
];
|
|
@ -10,5 +10,8 @@
|
|||
"@kbn/core-application-browser",
|
||||
"@kbn/core",
|
||||
"@kbn/developer-examples-plugin",
|
||||
"@kbn/core-lifecycle-browser",
|
||||
"@kbn/react-kibana-mount",
|
||||
"@kbn/i18n",
|
||||
]
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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]) {
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
109
packages/kbn-grid-layout/grid/use_grid_layout_api.ts
Normal file
109
packages/kbn-grid-layout/grid/use_grid_layout_api.ts
Normal 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;
|
||||
};
|
|
@ -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();
|
||||
|
||||
|
|
44
packages/kbn-grid-layout/grid/utils/equality_checks.ts
Normal file
44
packages/kbn-grid-layout/grid/utils/equality_checks.ts
Normal 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;
|
||||
};
|
|
@ -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);
|
116
packages/kbn-grid-layout/grid/utils/run_panel_placement.ts
Normal file
116
packages/kbn-grid-layout/grid/utils/run_panel_placement.ts
Normal 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 },
|
||||
})
|
||||
);
|
||||
}
|
||||
};
|
|
@ -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';
|
||||
|
|
|
@ -19,5 +19,6 @@
|
|||
"kbn_references": [
|
||||
"@kbn/ui-theme",
|
||||
"@kbn/i18n",
|
||||
"@kbn/utility-types",
|
||||
]
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue