From 7290824e74ea3bdf2d35b502e40556787ecac729 Mon Sep 17 00:00:00 2001 From: Devon Thomson Date: Thu, 15 Aug 2024 17:51:43 -0400 Subject: [PATCH] [Dashboard] New layout engine (#174132) Introduces a new performant and simple drag & drop layout engine for Kibana which uses HTML5 and CSS and and has **no external dependencies**. --- .github/CODEOWNERS | 2 + examples/grid_example/README.md | 3 + examples/grid_example/kibana.jsonc | 13 ++ examples/grid_example/public/app.tsx | 69 ++++++ examples/grid_example/public/index.ts | 11 + examples/grid_example/public/plugin.ts | 42 ++++ examples/grid_example/tsconfig.json | 14 ++ package.json | 2 + packages/kbn-grid-layout/README.md | 3 + packages/kbn-grid-layout/grid/grid_layout.tsx | 92 ++++++++ packages/kbn-grid-layout/grid/grid_panel.tsx | 158 ++++++++++++++ packages/kbn-grid-layout/grid/grid_row.tsx | 124 +++++++++++ .../kbn-grid-layout/grid/resolve_grid_row.ts | 107 +++++++++ packages/kbn-grid-layout/grid/types.ts | 97 +++++++++ .../grid/use_grid_layout_events.ts | 205 ++++++++++++++++++ .../grid/use_grid_layout_state.ts | 94 ++++++++ packages/kbn-grid-layout/index.ts | 10 + packages/kbn-grid-layout/jest.config.js | 13 ++ packages/kbn-grid-layout/kibana.jsonc | 5 + packages/kbn-grid-layout/package.json | 6 + packages/kbn-grid-layout/tsconfig.json | 23 ++ tsconfig.base.json | 4 + yarn.lock | 8 + 23 files changed, 1105 insertions(+) create mode 100644 examples/grid_example/README.md create mode 100644 examples/grid_example/kibana.jsonc create mode 100644 examples/grid_example/public/app.tsx create mode 100644 examples/grid_example/public/index.ts create mode 100644 examples/grid_example/public/plugin.ts create mode 100644 examples/grid_example/tsconfig.json create mode 100644 packages/kbn-grid-layout/README.md create mode 100644 packages/kbn-grid-layout/grid/grid_layout.tsx create mode 100644 packages/kbn-grid-layout/grid/grid_panel.tsx create mode 100644 packages/kbn-grid-layout/grid/grid_row.tsx create mode 100644 packages/kbn-grid-layout/grid/resolve_grid_row.ts create mode 100644 packages/kbn-grid-layout/grid/types.ts create mode 100644 packages/kbn-grid-layout/grid/use_grid_layout_events.ts create mode 100644 packages/kbn-grid-layout/grid/use_grid_layout_state.ts create mode 100644 packages/kbn-grid-layout/index.ts create mode 100644 packages/kbn-grid-layout/jest.config.js create mode 100644 packages/kbn-grid-layout/kibana.jsonc create mode 100644 packages/kbn-grid-layout/package.json create mode 100644 packages/kbn-grid-layout/tsconfig.json diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 388254c88f7ae..c3cab77ee6951 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -478,6 +478,8 @@ x-pack/plugins/global_search @elastic/appex-sharedux x-pack/plugins/global_search_providers @elastic/appex-sharedux x-pack/test/plugin_functional/plugins/global_search_test @elastic/kibana-core x-pack/plugins/graph @elastic/kibana-visualizations +examples/grid_example @elastic/kibana-presentation +packages/kbn-grid-layout @elastic/kibana-presentation x-pack/plugins/grokdebugger @elastic/kibana-management packages/kbn-grouping @elastic/response-ops packages/kbn-guided-onboarding @elastic/appex-sharedux diff --git a/examples/grid_example/README.md b/examples/grid_example/README.md new file mode 100644 index 0000000000000..91b0ea05b79de --- /dev/null +++ b/examples/grid_example/README.md @@ -0,0 +1,3 @@ +# Grid Example + +This plugin is a playground and learning tool that demonstrates the Dashboard layout engine. diff --git a/examples/grid_example/kibana.jsonc b/examples/grid_example/kibana.jsonc new file mode 100644 index 0000000000000..ecaae63268e7a --- /dev/null +++ b/examples/grid_example/kibana.jsonc @@ -0,0 +1,13 @@ +{ + "type": "plugin", + "id": "@kbn/grid-example-plugin", + "owner": "@elastic/kibana-presentation", + "description": "Temporary example app used to build out the new Dashboard layout system", + "plugin": { + "id": "gridExample", + "server": false, + "browser": true, + "requiredPlugins": ["developerExamples"], + "requiredBundles": [] + } +} diff --git a/examples/grid_example/public/app.tsx b/examples/grid_example/public/app.tsx new file mode 100644 index 0000000000000..8c26ecf8f1e2a --- /dev/null +++ b/examples/grid_example/public/app.tsx @@ -0,0 +1,69 @@ +/* + * 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 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 or the Server + * Side Public License, v 1. + */ + +import React 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'; + +export const GridExample = () => { + return ( + + + + + { + return
{id}
; + }} + 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: 6, 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, + }; + }} + /> +
+
+
+ ); +}; + +export const renderGridExampleApp = (element: AppMountParameters['element']) => { + ReactDOM.render(, element); + + return () => ReactDOM.unmountComponentAtNode(element); +}; diff --git a/examples/grid_example/public/index.ts b/examples/grid_example/public/index.ts new file mode 100644 index 0000000000000..b1c5644c055d4 --- /dev/null +++ b/examples/grid_example/public/index.ts @@ -0,0 +1,11 @@ +/* + * 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 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 or the Server + * Side Public License, v 1. + */ + +import { GridExamplePlugin } from './plugin'; + +export const plugin = () => new GridExamplePlugin(); diff --git a/examples/grid_example/public/plugin.ts b/examples/grid_example/public/plugin.ts new file mode 100644 index 0000000000000..a4245bab8fd76 --- /dev/null +++ b/examples/grid_example/public/plugin.ts @@ -0,0 +1,42 @@ +/* + * 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 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 or the Server + * Side Public License, v 1. + */ + +import { AppMountParameters, CoreSetup, CoreStart, Plugin } from '@kbn/core/public'; +import { DeveloperExamplesSetup } from '@kbn/developer-examples-plugin/public'; + +export const GRID_EXAMPLE_APP_ID = 'gridExample'; +const gridExampleTitle = 'Grid Example'; + +interface GridExamplePluginSetupDependencies { + developerExamples: DeveloperExamplesSetup; +} + +export class GridExamplePlugin + implements Plugin +{ + public setup(core: CoreSetup<{}>, { developerExamples }: GridExamplePluginSetupDependencies) { + core.application.register({ + id: GRID_EXAMPLE_APP_ID, + title: gridExampleTitle, + visibleIn: [], + async mount(params: AppMountParameters) { + const { renderGridExampleApp } = await import('./app'); + return renderGridExampleApp(params.element); + }, + }); + developerExamples.register({ + appId: GRID_EXAMPLE_APP_ID, + title: gridExampleTitle, + description: `A playground and learning tool that demonstrates the Dashboard layout engine.`, + }); + } + + public start(core: CoreStart, deps: {}) {} + + public stop() {} +} diff --git a/examples/grid_example/tsconfig.json b/examples/grid_example/tsconfig.json new file mode 100644 index 0000000000000..23be45a74c2f7 --- /dev/null +++ b/examples/grid_example/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "target/types" + }, + "include": ["index.ts", "public/**/*.ts", "public/**/*.tsx", "../../typings/**/*"], + "exclude": ["target/**/*"], + "kbn_references": [ + "@kbn/grid-layout", + "@kbn/core-application-browser", + "@kbn/core", + "@kbn/developer-examples-plugin", + ] +} diff --git a/package.json b/package.json index 0c7859a951633..21d5af414369b 100644 --- a/package.json +++ b/package.json @@ -524,6 +524,8 @@ "@kbn/global-search-providers-plugin": "link:x-pack/plugins/global_search_providers", "@kbn/global-search-test-plugin": "link:x-pack/test/plugin_functional/plugins/global_search_test", "@kbn/graph-plugin": "link:x-pack/plugins/graph", + "@kbn/grid-example-plugin": "link:examples/grid_example", + "@kbn/grid-layout": "link:packages/kbn-grid-layout", "@kbn/grokdebugger-plugin": "link:x-pack/plugins/grokdebugger", "@kbn/grouping": "link:packages/kbn-grouping", "@kbn/guided-onboarding": "link:packages/kbn-guided-onboarding", diff --git a/packages/kbn-grid-layout/README.md b/packages/kbn-grid-layout/README.md new file mode 100644 index 0000000000000..873eb55b1210b --- /dev/null +++ b/packages/kbn-grid-layout/README.md @@ -0,0 +1,3 @@ +# @kbn/grid-layout + +Contains a simple drag and drop layout engine for Kibana Dashboards. diff --git a/packages/kbn-grid-layout/grid/grid_layout.tsx b/packages/kbn-grid-layout/grid/grid_layout.tsx new file mode 100644 index 0000000000000..68650035f7b44 --- /dev/null +++ b/packages/kbn-grid-layout/grid/grid_layout.tsx @@ -0,0 +1,92 @@ +/* + * 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 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 or the Server + * Side Public License, v 1. + */ + +import { EuiPortal, transparentize } from '@elastic/eui'; +import { css } from '@emotion/react'; +import { useBatchedPublishingSubjects } from '@kbn/presentation-publishing'; +import { euiThemeVars } from '@kbn/ui-theme'; +import React from 'react'; +import { GridRow } from './grid_row'; +import { GridLayoutData, GridSettings } from './types'; +import { useGridLayoutEvents } from './use_grid_layout_events'; +import { useGridLayoutState } from './use_grid_layout_state'; + +export const GridLayout = ({ + getCreationOptions, + renderPanelContents, +}: { + getCreationOptions: () => { initialLayout: GridLayoutData; gridSettings: GridSettings }; + renderPanelContents: (panelId: string) => React.ReactNode; +}) => { + const { gridLayoutStateManager, gridSizeRef } = useGridLayoutState({ + getCreationOptions, + }); + useGridLayoutEvents({ gridLayoutStateManager }); + + const [gridLayout, runtimeSettings, interactionEvent] = useBatchedPublishingSubjects( + gridLayoutStateManager.gridLayout$, + gridLayoutStateManager.runtimeSettings$, + gridLayoutStateManager.interactionEvent$ + ); + + return ( +
+ {gridLayout.map((rowData, rowIndex) => { + return ( + { + const currentLayout = gridLayoutStateManager.gridLayout$.value; + currentLayout[rowIndex].isCollapsed = !currentLayout[rowIndex].isCollapsed; + gridLayoutStateManager.gridLayout$.next(currentLayout); + }} + setInteractionEvent={(nextInteractionEvent) => { + if (!nextInteractionEvent) { + gridLayoutStateManager.hideDragPreview(); + } + 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 new file mode 100644 index 0000000000000..a0f53cef8869c --- /dev/null +++ b/packages/kbn-grid-layout/grid/grid_panel.tsx @@ -0,0 +1,158 @@ +/* + * 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 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 or the Server + * Side Public License, v 1. + */ + +import { + EuiIcon, + EuiPanel, + euiFullHeight, + transparentize, + useEuiOverflowScroll, +} from '@elastic/eui'; +import { css } from '@emotion/react'; +import { euiThemeVars } from '@kbn/ui-theme'; +import React, { useCallback, useRef } from 'react'; +import { GridPanelData, PanelInteractionEvent } from './types'; + +export const GridPanel = ({ + activePanelId, + panelData, + renderPanelContents, + setInteractionEvent, +}: { + panelData: GridPanelData; + activePanelId: string | undefined; + renderPanelContents: (panelId: string) => React.ReactNode; + setInteractionEvent: (interactionData?: Omit) => void; +}) => { + const panelRef = useRef(null); + const ghostRef = useRef(null); + const thisPanelActive = activePanelId === panelData.id; + + const interactionStart = useCallback( + (type: 'drag' | 'resize', e: React.DragEvent) => { + if (!panelRef.current || !ghostRef.current) return; + e.dataTransfer.effectAllowed = 'move'; + e.dataTransfer.dropEffect = 'move'; + e.dataTransfer.setDragImage(ghostRef.current, 0, 0); + const panelRect = panelRef.current.getBoundingClientRect(); + setInteractionEvent({ + type, + id: panelData.id, + panelDiv: panelRef.current, + mouseOffsets: { + top: e.clientY - panelRect.top, + left: e.clientX - panelRect.left, + right: e.clientX - panelRect.right, + bottom: e.clientY - panelRect.bottom, + }, + }); + }, + [panelData.id, setInteractionEvent] + ); + + return ( +
+ + {/* Hidden dragging ghost */} +
+ {/* drag handle */} +
) => interactionStart('drag', e)} + > + +
+ {/* Resize handle */} +
interactionStart('resize', e)} + css={css` + right: 0; + bottom: 0; + opacity: 0; + margin: -2px; + position: absolute; + width: ${euiThemeVars.euiSizeL}; + height: ${euiThemeVars.euiSizeL}; + transition: opacity 0.2s, border 0.2s; + border-radius: 7px 0 7px 0; + border-bottom: 2px solid ${euiThemeVars.euiColorSuccess}; + border-right: 2px solid ${euiThemeVars.euiColorSuccess}; + :hover { + background-color: ${transparentize(euiThemeVars.euiColorSuccess, 0.05)}; + cursor: se-resize; + } + `} + /> +
+ {renderPanelContents(panelData.id)} +
+ +
+ ); +}; diff --git a/packages/kbn-grid-layout/grid/grid_row.tsx b/packages/kbn-grid-layout/grid/grid_row.tsx new file mode 100644 index 0000000000000..3f2676a1db6ba --- /dev/null +++ b/packages/kbn-grid-layout/grid/grid_row.tsx @@ -0,0 +1,124 @@ +/* + * 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 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 or the Server + * Side Public License, v 1. + */ + +import { EuiButtonIcon, EuiFlexGroup, EuiSpacer, EuiTitle, transparentize } from '@elastic/eui'; +import { css } from '@emotion/react'; +import { euiThemeVars } from '@kbn/ui-theme'; +import React, { forwardRef, useMemo } from 'react'; +import { GridPanel } from './grid_panel'; +import { GridRowData, PanelInteractionEvent, RuntimeGridSettings } from './types'; + +const gridColor = transparentize(euiThemeVars.euiColorSuccess, 0.2); +const getGridBackgroundCSS = (settings: RuntimeGridSettings) => { + const { gutterSize, columnPixelWidth, rowHeight } = settings; + return css` + background-position: top -${gutterSize / 2}px left -${gutterSize / 2}px; + background-size: ${columnPixelWidth + gutterSize}px ${rowHeight + gutterSize}px; + background-image: linear-gradient(to right, ${gridColor} 1px, transparent 1px), + linear-gradient(to bottom, ${gridColor} 1px, transparent 1px); + `; +}; + +export const GridRow = forwardRef< + HTMLDivElement, + { + rowIndex: number; + rowData: GridRowData; + toggleIsCollapsed: () => void; + activePanelId: string | undefined; + targetRowIndex: number | undefined; + runtimeSettings: RuntimeGridSettings; + renderPanelContents: (panelId: string) => React.ReactNode; + setInteractionEvent: (interactionData?: PanelInteractionEvent) => void; + } +>( + ( + { + rowData, + rowIndex, + activePanelId, + targetRowIndex, + runtimeSettings, + toggleIsCollapsed, + renderPanelContents, + setInteractionEvent, + }, + gridRef + ) => { + const { gutterSize, columnCount, rowHeight } = runtimeSettings; + const isGridTargeted = activePanelId && targetRowIndex === rowIndex; + + // calculate row count based on the number of rows needed to fit all panels + const rowCount = useMemo(() => { + const maxRow = Object.values(rowData.panels).reduce((acc, panel) => { + return Math.max(acc, panel.row + panel.height); + }, 0); + return maxRow || 1; + }, [rowData]); + + return ( + <> + {rowIndex !== 0 && ( + <> + + + + +

{rowData.title}

+
+
+ + + )} + {!rowData.isCollapsed && ( +
+ {Object.values(rowData.panels).map((panelData) => ( + { + if (partialInteractionEvent) { + setInteractionEvent({ + ...partialInteractionEvent, + targetRowIndex: rowIndex, + }); + return; + } + setInteractionEvent(); + }} + /> + ))} +
+ )} + + ); + } +); diff --git a/packages/kbn-grid-layout/grid/resolve_grid_row.ts b/packages/kbn-grid-layout/grid/resolve_grid_row.ts new file mode 100644 index 0000000000000..1fb7d43dc35d2 --- /dev/null +++ b/packages/kbn-grid-layout/grid/resolve_grid_row.ts @@ -0,0 +1,107 @@ +/* + * 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 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 or the Server + * Side Public License, v 1. + */ + +import { GridPanelData, GridRowData } from './types'; + +const collides = (panelA: GridPanelData, panelB: GridPanelData) => { + if (panelA.id === panelB.id) return false; // same panel + if (panelA.column + panelA.width <= panelB.column) return false; // panel a is left of panel b + if (panelA.column >= panelB.column + panelB.width) return false; // panel a is right of panel b + if (panelA.row + panelA.height <= panelB.row) return false; // panel a is above panel b + if (panelA.row >= panelB.row + panelB.height) return false; // panel a is below panel b + return true; // boxes overlap +}; + +const getAllCollisionsWithPanel = ( + panelToCheck: GridPanelData, + gridLayout: GridRowData, + keysInOrder: string[] +): GridPanelData[] => { + const collidingPanels: GridPanelData[] = []; + for (const key of keysInOrder) { + const comparePanel = gridLayout.panels[key]; + if (comparePanel.id === panelToCheck.id) continue; + if (collides(panelToCheck, comparePanel)) { + collidingPanels.push(comparePanel); + } + } + return collidingPanels; +}; + +const getKeysInOrder = (rowData: GridRowData, draggedId?: string): string[] => { + const panelKeys = Object.keys(rowData.panels); + return panelKeys.sort((panelKeyA, panelKeyB) => { + const panelA = rowData.panels[panelKeyA]; + const panelB = rowData.panels[panelKeyB]; + + // sort by row first + if (panelA.row > panelB.row) return 1; + if (panelA.row < panelB.row) return -1; + + // if rows are the same. Is either panel being dragged? + if (panelA.id === draggedId) return -1; + if (panelB.id === draggedId) return 1; + + // if rows are the same and neither panel is being dragged, sort by column + if (panelA.column > panelB.column) return 1; + if (panelA.column < panelB.column) return -1; + + // fall back + return 1; + }); +}; + +const compactGridRow = (originalLayout: GridRowData) => { + const nextRowData = { ...originalLayout, panels: { ...originalLayout.panels } }; + // compact all vertical space. + const sortedKeysAfterMove = getKeysInOrder(nextRowData); + for (const panelKey of sortedKeysAfterMove) { + const panel = nextRowData.panels[panelKey]; + // try moving panel up one row at a time until it collides + while (panel.row > 0) { + const collisions = getAllCollisionsWithPanel( + { ...panel, row: panel.row - 1 }, + nextRowData, + sortedKeysAfterMove + ); + if (collisions.length !== 0) break; + panel.row -= 1; + } + } + return nextRowData; +}; + +export const resolveGridRow = ( + originalRowData: GridRowData, + dragRequest?: GridPanelData +): GridRowData => { + const nextRowData = { ...originalRowData, panels: { ...originalRowData.panels } }; + + // Apply drag request + if (dragRequest) { + nextRowData.panels[dragRequest.id] = dragRequest; + } + // return nextRowData; + + // push all panels down if they collide with another panel + const sortedKeys = getKeysInOrder(nextRowData, dragRequest?.id); + + for (const key of sortedKeys) { + const panel = nextRowData.panels[key]; + const collisions = getAllCollisionsWithPanel(panel, nextRowData, sortedKeys); + + for (const collision of collisions) { + const rowOverlap = panel.row + panel.height - collision.row; + if (rowOverlap > 0) { + collision.row += rowOverlap; + } + } + } + const compactedGrid = compactGridRow(nextRowData); + return compactedGrid; +}; diff --git a/packages/kbn-grid-layout/grid/types.ts b/packages/kbn-grid-layout/grid/types.ts new file mode 100644 index 0000000000000..e3119f6e1cfd2 --- /dev/null +++ b/packages/kbn-grid-layout/grid/types.ts @@ -0,0 +1,97 @@ +/* + * 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 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 or the Server + * Side Public License, v 1. + */ + +import { BehaviorSubject } from 'rxjs'; +export interface GridCoordinate { + column: number; + row: number; +} + +export interface GridRect extends GridCoordinate { + width: number; + height: number; +} + +export interface GridPanelData extends GridRect { + id: string; +} + +export interface GridRowData { + title: string; + isCollapsed: boolean; + panels: { + [key: string]: GridPanelData; + }; +} + +export type GridLayoutData = GridRowData[]; + +export interface GridSettings { + gutterSize: number; + rowHeight: number; + columnCount: number; +} + +/** + * The runtime settings for the grid, including the pixel width of each column + * which is calculated on the fly based on the grid settings and the width of + * the containing element. + */ +export type RuntimeGridSettings = GridSettings & { columnPixelWidth: number }; + +export interface GridLayoutStateManager { + hideDragPreview: () => void; + updatePreviewElement: (rect: { + top: number; + left: number; + bottom: number; + right: number; + }) => void; + + gridLayout$: BehaviorSubject; + runtimeSettings$: BehaviorSubject; + rowRefs: React.MutableRefObject>; + dragPreviewRef: React.MutableRefObject; + interactionEvent$: BehaviorSubject; +} + +/** + * The information required to start a panel interaction. + */ +export interface PanelInteractionEvent { + /** + * The type of interaction being performed. + */ + type: 'drag' | 'resize'; + + /** + * The id of the panel being interacted with. + */ + id: string; + + /** + * The index of the grid row this panel interaction is targeting. + */ + targetRowIndex: number; + + /** + * The pixel rect of the panel being interacted with. + */ + panelDiv: HTMLDivElement; + + /** + * The pixel offsets from where the mouse was at drag start to the + * edges of the panel + */ + mouseOffsets: { + top: number; + left: number; + right: number; + bottom: number; + }; +} diff --git a/packages/kbn-grid-layout/grid/use_grid_layout_events.ts b/packages/kbn-grid-layout/grid/use_grid_layout_events.ts new file mode 100644 index 0000000000000..c8c1a505fc13a --- /dev/null +++ b/packages/kbn-grid-layout/grid/use_grid_layout_events.ts @@ -0,0 +1,205 @@ +/* + * 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 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 or the Server + * Side Public License, v 1. + */ + +import { useEffect, useRef } from 'react'; +import { resolveGridRow } from './resolve_grid_row'; +import { GridPanelData, GridLayoutStateManager } 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 useGridLayoutEvents = ({ + gridLayoutStateManager, +}: { + gridLayoutStateManager: GridLayoutStateManager; +}) => { + const dragEnterCount = useRef(0); + const lastRequestedPanelPosition = useRef(undefined); + + // ----------------------------------------------------------------------------------------- + // Set up drag events + // ----------------------------------------------------------------------------------------- + useEffect(() => { + const { runtimeSettings$, interactionEvent$, gridLayout$ } = gridLayoutStateManager; + const dragOver = (e: MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + + const gridRowElements = gridLayoutStateManager.rowRefs.current; + const previewElement = gridLayoutStateManager.dragPreviewRef.current; + + const interactionEvent = interactionEvent$.value; + const isResize = interactionEvent?.type === 'resize'; + + const currentLayout = gridLayout$.value; + const currentGridData = (() => { + if (!interactionEvent) return; + for (const row of currentLayout) { + if (row.panels[interactionEvent.id]) return row.panels[interactionEvent.id]; + } + })(); + + if ( + !runtimeSettings$.value || + !interactionEvent || + !previewElement || + !gridRowElements || + !currentGridData + ) { + return; + } + + const mouseTargetPixel = { x: e.clientX, y: e.clientY }; + const panelRect = interactionEvent.panelDiv.getBoundingClientRect(); + const previewRect = { + left: isResize ? panelRect.left : mouseTargetPixel.x - interactionEvent.mouseOffsets.left, + top: isResize ? panelRect.top : mouseTargetPixel.y - interactionEvent.mouseOffsets.top, + bottom: mouseTargetPixel.y - interactionEvent.mouseOffsets.bottom, + right: mouseTargetPixel.x - interactionEvent.mouseOffsets.right, + }; + gridLayoutStateManager.updatePreviewElement(previewRect); + + // find the grid that the preview rect is over + const previewBottom = + previewRect.top + gridLayoutStateManager.runtimeSettings$.value.rowHeight; + const lastRowIndex = interactionEvent?.targetRowIndex; + const targetRowIndex = (() => { + if (isResize) return lastRowIndex; + + let highestOverlap = -Infinity; + let highestOverlapRowIndex = -1; + gridRowElements.forEach((row, index) => { + if (!row) return; + const rowRect = row.getBoundingClientRect(); + const overlap = + Math.min(previewBottom, rowRect.bottom) - Math.max(previewRect.top, rowRect.top); + if (overlap > highestOverlap) { + highestOverlap = overlap; + highestOverlapRowIndex = index; + } + }); + return highestOverlapRowIndex; + })(); + const hasChangedGridRow = targetRowIndex !== lastRowIndex; + + // re-render when the target row changes + if (hasChangedGridRow) { + interactionEvent$.next({ + ...interactionEvent, + targetRowIndex, + }); + } + + // calculate the requested grid position + const { columnCount, gutterSize, rowHeight, columnPixelWidth } = runtimeSettings$.value; + const targetedGridRow = gridRowElements[targetRowIndex]; + const targetedGridLeft = targetedGridRow?.getBoundingClientRect().left ?? 0; + const targetedGridTop = targetedGridRow?.getBoundingClientRect().top ?? 0; + + const maxColumn = isResize ? columnCount : columnCount - currentGridData.width; + + const localXCoordinate = isResize + ? previewRect.right - targetedGridLeft + : previewRect.left - targetedGridLeft; + const localYCoordinate = isResize + ? previewRect.bottom - targetedGridTop + : previewRect.top - targetedGridTop; + + const targetColumn = Math.min( + Math.max(Math.round(localXCoordinate / (columnPixelWidth + gutterSize)), 0), + maxColumn + ); + const targetRow = Math.max(Math.round(localYCoordinate / (rowHeight + gutterSize)), 0); + const requestedGridData = { ...currentGridData }; + if (isResize) { + requestedGridData.width = Math.max(targetColumn - requestedGridData.column, 1); + requestedGridData.height = Math.max(targetRow - requestedGridData.row, 1); + } else { + requestedGridData.column = targetColumn; + requestedGridData.row = targetRow; + } + + // resolve the new grid layout + if ( + hasChangedGridRow || + !isGridDataEqual(requestedGridData, lastRequestedPanelPosition.current) + ) { + lastRequestedPanelPosition.current = { ...requestedGridData }; + + // remove the panel from the row it's currently in. + const nextLayout = currentLayout.map((row, rowIndex) => { + const { [interactionEvent.id]: interactingPanel, ...otherPanels } = row.panels; + return { ...row, panels: { ...otherPanels } }; + }); + + // resolve destination grid + const destinationGrid = nextLayout[targetRowIndex]; + const resolvedDestinationGrid = resolveGridRow(destinationGrid, requestedGridData); + nextLayout[targetRowIndex] = resolvedDestinationGrid; + + // resolve origin grid + if (hasChangedGridRow) { + const originGrid = nextLayout[lastRowIndex]; + const resolvedOriginGrid = resolveGridRow(originGrid); + nextLayout[lastRowIndex] = resolvedOriginGrid; + } + gridLayout$.next(nextLayout); + } + }; + + const onDrop = (e: MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + if (!interactionEvent$.value) return; + + interactionEvent$.next(undefined); + gridLayoutStateManager.hideDragPreview(); + dragEnterCount.current = 0; + }; + + const onDragEnter = (e: MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + if (!interactionEvent$.value) return; + + dragEnterCount.current++; + }; + + const onDragLeave = (e: MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + if (!interactionEvent$.value) return; + + dragEnterCount.current--; + if (dragEnterCount.current === 0) { + interactionEvent$.next(undefined); + gridLayoutStateManager.hideDragPreview(); + dragEnterCount.current = 0; + } + }; + + window.addEventListener('drop', onDrop); + window.addEventListener('dragover', dragOver); + window.addEventListener('dragenter', onDragEnter); + window.addEventListener('dragleave', onDragLeave); + return () => { + window.removeEventListener('drop', dragOver); + window.removeEventListener('dragover', dragOver); + window.removeEventListener('dragenter', onDragEnter); + window.removeEventListener('dragleave', onDragLeave); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); +}; diff --git a/packages/kbn-grid-layout/grid/use_grid_layout_state.ts b/packages/kbn-grid-layout/grid/use_grid_layout_state.ts new file mode 100644 index 0000000000000..bad259b428892 --- /dev/null +++ b/packages/kbn-grid-layout/grid/use_grid_layout_state.ts @@ -0,0 +1,94 @@ +/* + * 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 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 or the Server + * Side Public License, v 1. + */ + +import { debounce } from 'lodash'; +import { useMemo, useRef } from 'react'; +import { BehaviorSubject } from 'rxjs'; +import useResizeObserver from 'use-resize-observer/polyfilled'; +import { + GridLayoutData, + GridLayoutStateManager, + GridSettings, + PanelInteractionEvent, + RuntimeGridSettings, +} from './types'; + +export const useGridLayoutState = ({ + getCreationOptions, +}: { + getCreationOptions: () => { initialLayout: GridLayoutData; gridSettings: GridSettings }; +}): { + gridLayoutStateManager: GridLayoutStateManager; + gridSizeRef: (instance: HTMLDivElement | null) => void; +} => { + const rowRefs = useRef>([]); + const dragPreviewRef = useRef(null); + + const { gridLayoutStateManager, onWidthChange } = useMemo(() => { + const { initialLayout, gridSettings } = getCreationOptions(); + const gridLayout$ = new BehaviorSubject(initialLayout); + const interactionEvent$ = new BehaviorSubject(undefined); + const runtimeSettings$ = new BehaviorSubject({ + ...gridSettings, + columnPixelWidth: 0, + }); + + // debounce width changes to avoid re-rendering too frequently when the browser is resizing + const widthChange = debounce((elementWidth: number) => { + const columnPixelWidth = + (elementWidth - gridSettings.gutterSize * (gridSettings.columnCount - 1)) / + gridSettings.columnCount; + runtimeSettings$.next({ ...gridSettings, columnPixelWidth }); + }, 250); + + return { + gridLayoutStateManager: { + rowRefs, + gridLayout$, + dragPreviewRef, + runtimeSettings$, + interactionEvent$, + updatePreviewElement: (previewRect: { + top: number; + bottom: number; + left: number; + right: number; + }) => { + if (!dragPreviewRef.current) return; + dragPreviewRef.current.style.opacity = '1'; + dragPreviewRef.current.style.left = `${previewRect.left}px`; + dragPreviewRef.current.style.top = `${previewRect.top}px`; + dragPreviewRef.current.style.width = `${Math.max( + previewRect.right - previewRect.left, + runtimeSettings$.value.columnPixelWidth + )}px`; + dragPreviewRef.current.style.height = `${Math.max( + previewRect.bottom - previewRect.top, + runtimeSettings$.value.rowHeight + )}px`; + }, + hideDragPreview: () => { + if (!dragPreviewRef.current) return; + dragPreviewRef.current.style.opacity = '0'; + }, + }, + onWidthChange: widthChange, + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const { ref: gridSizeRef } = useResizeObserver({ + onResize: (dimensions) => { + if (dimensions.width) { + onWidthChange(dimensions.width); + } + }, + }); + + return { gridLayoutStateManager, gridSizeRef }; +}; diff --git a/packages/kbn-grid-layout/index.ts b/packages/kbn-grid-layout/index.ts new file mode 100644 index 0000000000000..4ebe1e7575527 --- /dev/null +++ b/packages/kbn-grid-layout/index.ts @@ -0,0 +1,10 @@ +/* + * 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 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 or the Server + * Side Public License, v 1. + */ + +export { GridLayout } from './grid/grid_layout'; +export type { GridLayoutData, GridPanelData, GridRowData, GridSettings } from './grid/types'; diff --git a/packages/kbn-grid-layout/jest.config.js b/packages/kbn-grid-layout/jest.config.js new file mode 100644 index 0000000000000..b4c6123bdccce --- /dev/null +++ b/packages/kbn-grid-layout/jest.config.js @@ -0,0 +1,13 @@ +/* + * 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 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 or the Server + * Side Public License, v 1. + */ + +module.exports = { + preset: '@kbn/test', + rootDir: '../..', + roots: ['/packages/kbn-grid-layout'], +}; diff --git a/packages/kbn-grid-layout/kibana.jsonc b/packages/kbn-grid-layout/kibana.jsonc new file mode 100644 index 0000000000000..8063456ea0b5f --- /dev/null +++ b/packages/kbn-grid-layout/kibana.jsonc @@ -0,0 +1,5 @@ +{ + "type": "shared-browser", + "id": "@kbn/grid-layout", + "owner": "@elastic/kibana-presentation" +} diff --git a/packages/kbn-grid-layout/package.json b/packages/kbn-grid-layout/package.json new file mode 100644 index 0000000000000..102eef2fa4d93 --- /dev/null +++ b/packages/kbn-grid-layout/package.json @@ -0,0 +1,6 @@ +{ + "name": "@kbn/grid-layout", + "private": true, + "version": "1.0.0", + "license": "SSPL-1.0 OR Elastic License 2.0" +} \ No newline at end of file diff --git a/packages/kbn-grid-layout/tsconfig.json b/packages/kbn-grid-layout/tsconfig.json new file mode 100644 index 0000000000000..3aff3ea3aa354 --- /dev/null +++ b/packages/kbn-grid-layout/tsconfig.json @@ -0,0 +1,23 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "target/types", + "types": [ + "jest", + "node", + "react", + "@emotion/react/types/css-prop" + ] + }, + "include": [ + "**/*.ts", + "**/*.tsx", + ], + "exclude": [ + "target/**/*" + ], + "kbn_references": [ + "@kbn/presentation-publishing", + "@kbn/ui-theme", + ] +} diff --git a/tsconfig.base.json b/tsconfig.base.json index ebc0883991d5a..d063f6a880441 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -950,6 +950,10 @@ "@kbn/global-search-test-plugin/*": ["x-pack/test/plugin_functional/plugins/global_search_test/*"], "@kbn/graph-plugin": ["x-pack/plugins/graph"], "@kbn/graph-plugin/*": ["x-pack/plugins/graph/*"], + "@kbn/grid-example-plugin": ["examples/grid_example"], + "@kbn/grid-example-plugin/*": ["examples/grid_example/*"], + "@kbn/grid-layout": ["packages/kbn-grid-layout"], + "@kbn/grid-layout/*": ["packages/kbn-grid-layout/*"], "@kbn/grokdebugger-plugin": ["x-pack/plugins/grokdebugger"], "@kbn/grokdebugger-plugin/*": ["x-pack/plugins/grokdebugger/*"], "@kbn/grouping": ["packages/kbn-grouping"], diff --git a/yarn.lock b/yarn.lock index 71f3fb54f369e..8a669f67827c9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5191,6 +5191,14 @@ version "0.0.0" uid "" +"@kbn/grid-example-plugin@link:examples/grid_example": + version "0.0.0" + uid "" + +"@kbn/grid-layout@link:packages/kbn-grid-layout": + version "0.0.0" + uid "" + "@kbn/grokdebugger-plugin@link:x-pack/plugins/grokdebugger": version "0.0.0" uid ""