diff --git a/examples/grid_example/public/app.tsx b/examples/grid_example/public/app.tsx index 332649720742a..0e73a76d790fd 100644 --- a/examples/grid_example/public/app.tsx +++ b/examples/grid_example/public/app.tsx @@ -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(false); + + const [layoutKey, setLayoutKey] = useState(uuidv4()); + const [gridLayoutApi, setGridLayoutApi] = useState(); + const savedLayout = useRef(getSerializedGridLayout()); + const currentLayout = useRef(savedLayout.current); -export const GridExample = () => { return ( - + + + { + clearSerializedGridLayout(); + window.location.reload(); + }} + > + {i18n.translate('examples.gridExample.resetExampleButton', { + defaultMessage: 'Reset example', + })} + + + + + + { + 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', + })} + + + + + {hasUnsavedChanges && ( + + + {i18n.translate('examples.gridExample.unsavedChangesBadge', { + defaultMessage: 'Unsaved changes', + })} + + + )} + + { + currentLayout.current = cloneDeep(savedLayout.current); + setHasUnsavedChanges(false); + setLayoutKey(uuidv4()); // force remount of grid + }} + > + {i18n.translate('examples.gridExample.resetLayoutButton', { + defaultMessage: 'Reset', + })} + + + + { + if (gridLayoutApi) { + const layoutToSave = gridLayoutApi.serializeState(); + setSerializedGridLayout(layoutToSave); + savedLayout.current = layoutToSave; + setHasUnsavedChanges(false); + } + }} + > + {i18n.translate('examples.gridExample.saveLayoutButton', { + defaultMessage: 'Save', + })} + + + + + + { + currentLayout.current = cloneDeep(newLayout); + setHasUnsavedChanges(!isLayoutEqual(savedLayout.current, newLayout)); + }} + ref={setGridLayoutApi} renderPanelContents={(id) => { - return
{id}
; + return ( + <> +
{id}
+ { + gridLayoutApi?.removePanel(id); + }} + > + {i18n.translate('examples.gridExample.deletePanelButton', { + defaultMessage: 'Delete panel', + })} + + { + 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', + })} + + + ); }} 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(, element); +export const renderGridExampleApp = ( + element: AppMountParameters['element'], + coreStart: CoreStart +) => { + ReactDOM.render(, element); return () => ReactDOM.unmountComponentAtNode(element); }; diff --git a/examples/grid_example/public/get_panel_id.tsx b/examples/grid_example/public/get_panel_id.tsx new file mode 100644 index 0000000000000..d83d0b232b53a --- /dev/null +++ b/examples/grid_example/public/get_panel_id.tsx @@ -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(suggestion); + + return ( + + + + {i18n.translate('examples.gridExample.getPanelIdModalTitle', { + defaultMessage: 'Panel ID', + })} + + + + + + + + { + setPanelId(e.target.value ?? ''); + }} + /> + + + { + onSubmit(panelId); + }} + > + {i18n.translate('examples.gridExample.getPanelIdSubmitButton', { + defaultMessage: 'Submit', + })} + + + + ); +}; + +export const getPanelId = async ({ + coreStart, + suggestion, +}: { + coreStart: CoreStart; + suggestion: string; +}): Promise => { + return new Promise((resolve) => { + const session = coreStart.overlays.openModal( + toMountPoint( + { + resolve(undefined); + session.close(); + }} + onSubmit={(newPanelId) => { + resolve(newPanelId); + session.close(); + }} + />, + { + theme: coreStart.theme, + i18n: coreStart.i18n, + } + ) + ); + }); +}; diff --git a/examples/grid_example/public/plugin.ts b/examples/grid_example/public/plugin.ts index 0f7d441a1be15..d57b06ac96017 100644 --- a/examples/grid_example/public/plugin.ts +++ b/examples/grid_example/public/plugin.ts @@ -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({ diff --git a/examples/grid_example/public/serialized_grid_layout.ts b/examples/grid_example/public/serialized_grid_layout.ts new file mode 100644 index 0000000000000..2bb20052398f8 --- /dev/null +++ b/examples/grid_example/public/serialized_grid_layout.ts @@ -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' } }, + }, +]; diff --git a/examples/grid_example/tsconfig.json b/examples/grid_example/tsconfig.json index 23be45a74c2f7..ad692e9697b2d 100644 --- a/examples/grid_example/tsconfig.json +++ b/examples/grid_example/tsconfig.json @@ -10,5 +10,8 @@ "@kbn/core-application-browser", "@kbn/core", "@kbn/developer-examples-plugin", + "@kbn/core-lifecycle-browser", + "@kbn/react-kibana-mount", + "@kbn/i18n", ] } diff --git a/packages/kbn-grid-layout/grid/grid_height_smoother.tsx b/packages/kbn-grid-layout/grid/grid_height_smoother.tsx index 7693fac72918a..960fe4f52e735 100644 --- a/packages/kbn-grid-layout/grid/grid_height_smoother.tsx +++ b/packages/kbn-grid-layout/grid/grid_height_smoother.tsx @@ -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; } diff --git a/packages/kbn-grid-layout/grid/grid_layout.tsx b/packages/kbn-grid-layout/grid/grid_layout.tsx index 7f77a476579e9..c3f9521503107 100644 --- a/packages/kbn-grid-layout/grid/grid_layout.tsx +++ b/packages/kbn-grid-layout/grid/grid_layout.tsx @@ -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( - gridLayoutStateManager.gridLayout$.getValue().length - ); +export const GridLayout = forwardRef( + ({ 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 ( - <> - -
{ - setDimensionsRef(divElement); - }} - > - {Array.from({ length: rowCount }, (_, rowIndex) => { - return ( - { - 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)} - /> - ); - })} -
-
- - ); -}; + const [rowCount, setRowCount] = useState( + 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 ( + <> + +
{ + setDimensionsRef(divElement); + }} + > + {Array.from({ length: rowCount }, (_, rowIndex) => { + return ( + { + 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)} + /> + ); + })} +
+
+ + ); + } +); diff --git a/packages/kbn-grid-layout/grid/grid_panel.tsx b/packages/kbn-grid-layout/grid/grid_panel.tsx index 64a4a2faff403..822cb2328c4a5 100644 --- a/packages/kbn-grid-layout/grid/grid_panel.tsx +++ b/packages/kbn-grid-layout/grid/grid_panel.tsx @@ -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 ) => 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; } diff --git a/packages/kbn-grid-layout/grid/grid_row.tsx b/packages/kbn-grid-layout/grid/grid_row.tsx index e797cd570550a..ff97b32efcdbc 100644 --- a/packages/kbn-grid-layout/grid/grid_row.tsx +++ b/packages/kbn-grid-layout/grid/grid_row.tsx @@ -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]) { diff --git a/packages/kbn-grid-layout/grid/types.ts b/packages/kbn-grid-layout/grid/types.ts index 3a88eeb33baba..004669e69b186 100644 --- a/packages/kbn-grid-layout/grid/types.ts +++ b/packages/kbn-grid-layout/grid/types.ts @@ -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; gridLayout$: BehaviorSubject; + + gridDimensions$: BehaviorSubject; runtimeSettings$: BehaviorSubject; activePanel$: BehaviorSubject; interactionEvent$: BehaviorSubject; @@ -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; +} diff --git a/packages/kbn-grid-layout/grid/use_grid_layout_api.ts b/packages/kbn-grid-layout/grid/use_grid_layout_api.ts new file mode 100644 index 0000000000000..1a950ee934174 --- /dev/null +++ b/packages/kbn-grid-layout/grid/use_grid_layout_api.ts @@ -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; +}; diff --git a/packages/kbn-grid-layout/grid/use_grid_layout_events.ts b/packages/kbn-grid-layout/grid/use_grid_layout_events.ts index bd6343b9e5652..22dde2fe68ced 100644 --- a/packages/kbn-grid-layout/grid/use_grid_layout_events.ts +++ b/packages/kbn-grid-layout/grid/use_grid_layout_events.ts @@ -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(); diff --git a/packages/kbn-grid-layout/grid/utils/equality_checks.ts b/packages/kbn-grid-layout/grid/utils/equality_checks.ts new file mode 100644 index 0000000000000..6771baa3a1030 --- /dev/null +++ b/packages/kbn-grid-layout/grid/utils/equality_checks.ts @@ -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; +}; diff --git a/packages/kbn-grid-layout/grid/resolve_grid_row.ts b/packages/kbn-grid-layout/grid/utils/resolve_grid_row.ts similarity index 96% rename from packages/kbn-grid-layout/grid/resolve_grid_row.ts rename to packages/kbn-grid-layout/grid/utils/resolve_grid_row.ts index 4c300336c7617..3037a52c27c69 100644 --- a/packages/kbn-grid-layout/grid/resolve_grid_row.ts +++ b/packages/kbn-grid-layout/grid/utils/resolve_grid_row.ts @@ -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); diff --git a/packages/kbn-grid-layout/grid/utils/run_panel_placement.ts b/packages/kbn-grid-layout/grid/utils/run_panel_placement.ts new file mode 100644 index 0000000000000..69ecddd1f5ffb --- /dev/null +++ b/packages/kbn-grid-layout/grid/utils/run_panel_placement.ts @@ -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, + 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 }, + }) + ); + } +}; diff --git a/packages/kbn-grid-layout/index.ts b/packages/kbn-grid-layout/index.ts index 009b74573e895..924369fe5ab4c 100644 --- a/packages/kbn-grid-layout/index.ts +++ b/packages/kbn-grid-layout/index.ts @@ -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'; diff --git a/packages/kbn-grid-layout/tsconfig.json b/packages/kbn-grid-layout/tsconfig.json index f0dd3232a42d5..14ab38ba76ba9 100644 --- a/packages/kbn-grid-layout/tsconfig.json +++ b/packages/kbn-grid-layout/tsconfig.json @@ -19,5 +19,6 @@ "kbn_references": [ "@kbn/ui-theme", "@kbn/i18n", + "@kbn/utility-types", ] }