[kbn-grid-layout] Flatten grid layout (#218900)

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

## Summary

This PR accomplishes two main things:
1. It flattens out how grid elements are rendered, which means that
embeddables no longer re-mount when dragged between sections and
2. It allows panels and sections to be "intermixed" on a **single**
level (i.e. you can only drop a section header between panels if they
are **not** in a section)

Since this was a **major** rewrite of the grid layout logic, I also took
some time to clean up the code - this includes removing
`proposedGridLayout$` (since this added two sources of truth, which was
causing issues with the DOM becoming out-of-sync with the layout object;
however, this also caused
https://github.com/elastic/kibana/issues/220309) and unifying on the use
of "section" rather than "row" (since it was confusing that we were
using "row" for both the grid row number and the section ID).


https://github.com/user-attachments/assets/c5d9aa97-5b14-4f4c-aacf-74055c7d9c33

> [!NOTE]
> Reminder that, since collapsible sections aren't available in
Dashboard yet, you must test this PR in the `grid` example app (by
running Kibana with `yarn start --run-examples`).


### 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/src/platform/packages/shared/kbn-i18n/README.md)
- [x] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios
- [x] The PR description includes the appropriate Release Notes section,
and the correct `release_note:*` label is applied per the
[guidelines](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process)

---------

Co-authored-by: mbondyra <marta.bondyra@elastic.co>
Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
Co-authored-by: Marta Bondyra <4283304+mbondyra@users.noreply.github.com>
This commit is contained in:
Hannah Mudge 2025-05-21 08:01:13 -06:00 committed by GitHub
parent 088cd43f87
commit 1b25685667
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
66 changed files with 3402 additions and 2258 deletions

View file

@ -11,7 +11,7 @@ import deepEqual from 'fast-deep-equal';
import { cloneDeep } from 'lodash';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import ReactDOM from 'react-dom';
import { Subject, combineLatest, debounceTime, map, skip, take } from 'rxjs';
import { Subject, combineLatest, debounceTime, map, take } from 'rxjs';
import { v4 as uuidv4 } from 'uuid';
import {
@ -44,6 +44,7 @@ import {
setSerializedGridLayout,
} from './serialized_grid_layout';
import { MockSerializedDashboardState } from './types';
import { useLayoutStyles } from './use_layout_styles';
import { useMockDashboardApi } from './use_mock_dashboard_api';
import { dashboardInputToGridLayout, gridLayoutToDashboardPanelMap } from './utils';
@ -59,6 +60,7 @@ export const GridExample = ({
coreStart: CoreStart;
uiActions: UiActionsStart;
}) => {
const layoutStyles = useLayoutStyles();
const savedState = useRef<MockSerializedDashboardState>(getSerializedDashboardState());
const [hasUnsavedChanges, setHasUnsavedChanges] = useState<boolean>(false);
const [currentLayout, setCurrentLayout] = useState<GridLayoutData>(
@ -79,23 +81,20 @@ export const GridExample = ({
const layoutUpdated$ = useMemo(() => new Subject<void>(), []);
useEffect(() => {
combineLatest([mockDashboardApi.panels$, mockDashboardApi.rows$])
combineLatest([mockDashboardApi.panels$, mockDashboardApi.sections$])
.pipe(
debounceTime(0), // debounce to avoid subscribe being called twice when both panels$ and rows$ publish
map(([panels, rows]) => {
map(([panels, sections]) => {
const panelIds = Object.keys(panels);
let panelsAreEqual = true;
for (const panelId of panelIds) {
if (!panelsAreEqual) break;
const currentPanel = panels[panelId];
const savedPanel = savedState.current.panels[panelId];
panelsAreEqual = deepEqual(
{ row: 'first', ...currentPanel?.gridData },
{ row: 'first', ...savedPanel?.gridData }
);
panelsAreEqual = deepEqual(currentPanel?.gridData, savedPanel?.gridData);
}
const hasChanges = !(panelsAreEqual && deepEqual(rows, savedState.current.rows));
return { hasChanges, updatedLayout: dashboardInputToGridLayout({ panels, rows }) };
const hasChanges = !(panelsAreEqual && deepEqual(sections, savedState.current.sections));
return { hasChanges, updatedLayout: dashboardInputToGridLayout({ panels, sections }) };
})
)
.subscribe(({ hasChanges, updatedLayout }) => {
@ -138,40 +137,47 @@ export const GridExample = ({
const onLayoutChange = useCallback(
(newLayout: GridLayoutData) => {
const { panels, rows } = gridLayoutToDashboardPanelMap(
const { panels, sections } = gridLayoutToDashboardPanelMap(
mockDashboardApi.panels$.getValue(),
newLayout
);
mockDashboardApi.panels$.next(panels);
mockDashboardApi.rows$.next(rows);
mockDashboardApi.sections$.next(sections);
},
[mockDashboardApi.panels$, mockDashboardApi.rows$]
[mockDashboardApi.panels$, mockDashboardApi.sections$]
);
const addNewSection = useCallback(() => {
const rows = cloneDeep(mockDashboardApi.rows$.getValue());
const rows = cloneDeep(mockDashboardApi.sections$.getValue());
const id = uuidv4();
const maxY = Math.max(
...Object.values({
...mockDashboardApi.sections$.getValue(),
...mockDashboardApi.panels$.getValue(),
}).map((widget) => ('gridData' in widget ? widget.gridData.y + widget.gridData.h : widget.y))
);
rows[id] = {
id,
order: Object.keys(rows).length,
y: maxY + 1,
title: i18n.translate('examples.gridExample.defaultSectionTitle', {
defaultMessage: 'New collapsible section',
}),
collapsed: false,
};
mockDashboardApi.rows$.next(rows);
mockDashboardApi.sections$.next(rows);
// scroll to bottom after row is added
layoutUpdated$.pipe(skip(1), take(1)).subscribe(() => {
layoutUpdated$.pipe(take(1)).subscribe(() => {
window.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' });
});
}, [mockDashboardApi.rows$, layoutUpdated$]);
}, [mockDashboardApi.sections$, mockDashboardApi.panels$, layoutUpdated$]);
const resetUnsavedChanges = useCallback(() => {
const { panels, rows } = savedState.current;
const { panels, sections: rows } = savedState.current;
mockDashboardApi.panels$.next(panels);
mockDashboardApi.rows$.next(rows);
}, [mockDashboardApi.panels$, mockDashboardApi.rows$]);
mockDashboardApi.sections$.next(rows);
}, [mockDashboardApi.panels$, mockDashboardApi.sections$]);
return (
<KibanaRenderContextProvider {...coreStart}>
@ -254,7 +260,7 @@ export const GridExample = ({
onClick={() => {
const newSavedState = {
panels: mockDashboardApi.panels$.getValue(),
rows: mockDashboardApi.rows$.getValue(),
sections: mockDashboardApi.sections$.getValue(),
};
savedState.current = newSavedState;
setHasUnsavedChanges(false);
@ -276,10 +282,10 @@ export const GridExample = ({
expandedPanelId={expandedPanelId}
layout={currentLayout}
gridSettings={gridSettings}
useCustomDragHandle={true}
renderPanelContents={renderPanelContents}
onLayoutChange={onLayoutChange}
css={layoutStyles}
css={[layoutStyles, customLayoutStyles]}
useCustomDragHandle={true}
/>
</EuiPageTemplate.Section>
</EuiPageTemplate>
@ -296,49 +302,60 @@ export const renderGridExampleApp = (
return () => ReactDOM.unmountComponentAtNode(element);
};
const layoutStyles = ({ euiTheme }: UseEuiTheme) => {
const gridColor = transparentize(euiTheme.colors.backgroundFilledAccentSecondary, 0.2);
const customLayoutStyles = ({ euiTheme }: UseEuiTheme) => {
return css({
// background for grid row that is being targetted
'.kbnGridRow--targeted': {
backgroundPosition: `top calc((var(--kbnGridGutterSize) / 2) * -1px) left calc((var(--kbnGridGutterSize) / 2) * -1px)`,
backgroundSize: `calc((var(--kbnGridColumnWidth) + var(--kbnGridGutterSize)) * 1px) calc((var(--kbnGridRowHeight) + var(--kbnGridGutterSize)) * 1px)`,
backgroundImage: `linear-gradient(to right, ${gridColor} 1px, transparent 1px), linear-gradient(to bottom, ${gridColor} 1px, transparent 1px)`,
backgroundColor: `${transparentize(euiTheme.colors.backgroundFilledAccentSecondary, 0.1)}`,
},
// styling for the "locked to grid" preview for what the panel will look like when dropped / resized
'.kbnGridPanel--dragPreview': {
borderRadius: `${euiTheme.border.radius}`,
backgroundColor: `${transparentize(euiTheme.colors.backgroundFilledAccentSecondary, 0.2)}`,
transition: `opacity 100ms linear`,
},
// styling for panel resize handle
'.kbnGridPanel--resizeHandle': {
opacity: '0',
transition: `opacity 0.2s, border 0.2s`,
borderRadius: `7px 0 7px 0`,
borderBottom: `2px solid ${euiTheme.colors.accentSecondary}`,
borderRight: `2px solid ${euiTheme.colors.accentSecondary}`,
'&:hover, &:focus': {
outlineStyle: `none !important`,
opacity: 1,
backgroundColor: `${transparentize(euiTheme.colors.accentSecondary, 0.05)}`,
},
},
// removes the extra padding that EuiPageTemplate adds in order to make it look more similar to Dashboard
marginLeft: `-${euiTheme.size.l}`,
marginRight: `-${euiTheme.size.l}`,
// styling for what the grid row header looks like when being dragged
'.kbnGridRowHeader--active': {
'.kbnGridSectionHeader--active': {
backgroundColor: euiTheme.colors.backgroundBasePlain,
border: `1px solid ${euiTheme.border.color}`,
outline: `${euiTheme.border.width.thick} solid
${euiTheme.colors.vis.euiColorVis0}`,
borderRadius: `${euiTheme.border.radius.medium} ${euiTheme.border.radius.medium}`,
paddingLeft: '8px',
// hide accordian arrow + panel count text when row is being dragged
'& .kbnGridRowTitle--button svg, & .kbnGridLayout--panelCount': {
'& .kbnGridSectionTitle--button svg, & .kbnGridLayout--panelCount': {
display: 'none',
},
},
// styles for the area where the row will be dropped
'.kbnGridPanel--rowDragPreview': {
backgroundColor: euiTheme.components.dragDropDraggingBackground,
'.kbnGridSection--dragPreview': {
backgroundColor: transparentize(euiTheme.colors.vis.euiColorVis0, 0.2),
borderRadius: `${euiTheme.border.radius.medium} ${euiTheme.border.radius.medium}`,
},
'.kbnGridSectionFooter': {
height: `${euiTheme.size.s}`,
display: `block`,
borderTop: `${euiTheme.border.thin}`,
'&--targeted': {
borderTop: `${euiTheme.border.width.thick} solid ${transparentize(
euiTheme.colors.vis.euiColorVis0,
0.5
)}`,
},
},
// hide border when section is being dragged
'&:has(.kbnGridSectionHeader--active) .kbnGridSectionHeader--active + .kbnGridSectionFooter': {
borderTop: `none`,
},
'.kbnGridSection--blocked': {
zIndex: 1,
backgroundColor: `${transparentize(euiTheme.colors.backgroundBaseSubdued, 0.5)}`,
// the oulines of panels extend past 100% by 1px on each side, so adjust for that
marginLeft: '-1px',
marginTop: '-1px',
width: `calc(100% + 2px)`,
height: `calc(100% + 2px)`,
},
'&:has(.kbnGridSection--blocked) .kbnGridSection--dragHandle': {
cursor: 'not-allowed !important',
},
});
};

View file

@ -1004,7 +1004,7 @@
"w": 48,
"h": 17,
"i": "4",
"row": "second"
"section": "second"
},
"explicitInput": {
"id": "4",
@ -1035,7 +1035,7 @@
"w": 18,
"h": 8,
"i": "05da0d2b-0145-4068-b21c-00be3184d465",
"row": "second"
"section": "second"
},
"explicitInput": {
"id": "05da0d2b-0145-4068-b21c-00be3184d465",
@ -1073,7 +1073,7 @@
"w": 18,
"h": 16,
"i": "b7da9075-4742-47e3-b4f8-fc9ba82de74c",
"row": "second"
"section": "second"
},
"explicitInput": {
"id": "b7da9075-4742-47e3-b4f8-fc9ba82de74c",
@ -1111,7 +1111,7 @@
"w": 12,
"h": 16,
"i": "5c409557-644d-4c05-a284-ffe54bb28db0",
"row": "second"
"section": "second"
},
"explicitInput": {
"id": "5c409557-644d-4c05-a284-ffe54bb28db0",
@ -1234,7 +1234,7 @@
"w": 6,
"h": 8,
"i": "af4b5c07-506e-44c2-b2bb-2113d0c5b274",
"row": "second"
"section": "second"
},
"explicitInput": {
"id": "af4b5c07-506e-44c2-b2bb-2113d0c5b274",
@ -1400,7 +1400,7 @@
"w": 6,
"h": 8,
"i": "d42c4870-c028-4d8a-abd0-0effbc190ce3",
"row": "second"
"section": "second"
},
"explicitInput": {
"id": "d42c4870-c028-4d8a-abd0-0effbc190ce3",
@ -1520,7 +1520,7 @@
"w": 6,
"h": 8,
"i": "4092d42c-f93b-4c71-a6db-8f12abf12791",
"row": "second"
"section": "second"
},
"explicitInput": {
"id": "4092d42c-f93b-4c71-a6db-8f12abf12791",
@ -1641,7 +1641,7 @@
"w": 30,
"h": 15,
"i": "15",
"row": "third"
"section": "third"
},
"explicitInput": {
"id": "15",
@ -1887,7 +1887,7 @@
"w": 18,
"h": 8,
"i": "4e64d6d7-4f92-4d5e-abbb-13796604db30",
"row": "third"
"section": "third"
},
"explicitInput": {
"id": "4e64d6d7-4f92-4d5e-abbb-13796604db30v",
@ -1925,7 +1925,7 @@
"w": 6,
"h": 7,
"i": "ddce4ad8-6a82-44f0-9995-57f46f153f50",
"row": "third"
"section": "third"
},
"explicitInput": {
"id": "ddce4ad8-6a82-44f0-9995-57f46f153f50",
@ -2120,7 +2120,7 @@
"w": 6,
"h": 7,
"i": "a2884704-db3b-4b92-a19a-cdfe668dec39",
"row": "third"
"section": "third"
},
"explicitInput": {
"id": "a2884704-db3b-4b92-a19a-cdfe668dec39",
@ -2315,7 +2315,7 @@
"w": 6,
"h": 7,
"i": "529eec49-10e2-4a40-9c77-5c81f4eb3943",
"row": "third"
"section": "third"
},
"explicitInput": {
"id": "529eec49-10e2-4a40-9c77-5c81f4eb3943",
@ -2510,7 +2510,7 @@
"w": 48,
"h": 12,
"i": "1d5f0b3f-d9d2-4b26-997b-83bc5ca3090b",
"row": "third"
"section": "third"
},
"explicitInput": {
"id": "1d5f0b3f-d9d2-4b26-997b-83bc5ca3090b",
@ -2905,7 +2905,7 @@
"w": 48,
"h": 15,
"i": "9f79ecca-123f-4098-a658-6b0e998da003",
"row": "fourth"
"section": "fourth"
},
"explicitInput": {
"id": "9f79ecca-123f-4098-a658-6b0e998da003",
@ -2922,7 +2922,7 @@
"w": 24,
"h": 9,
"i": "7",
"row": "fourth"
"section": "fourth"
},
"explicitInput": {
"id": "7",
@ -3161,7 +3161,7 @@
"w": 24,
"h": 11,
"i": "10",
"row": "fourth"
"section": "fourth"
},
"explicitInput": {
"id": "10",
@ -3346,7 +3346,7 @@
"w": 24,
"h": 22,
"i": "23",
"row": "fourth"
"section": "fourth"
},
"explicitInput": {
"id": "23",
@ -3371,7 +3371,7 @@
"w": 24,
"h": 22,
"i": "31",
"row": "fourth"
"section": "fourth"
},
"explicitInput": {
"id": "31",
@ -3388,7 +3388,7 @@
"w": 24,
"h": 8,
"i": "6afc61f7-e2d5-45a3-9e7a-281160ad3eb9",
"row": "fourth"
"section": "fourth"
},
"explicitInput": {
"id": "6afc61f7-e2d5-45a3-9e7a-281160ad3eb9",
@ -3420,7 +3420,7 @@
"w": 8,
"h": 8,
"i": "392b4936-f753-47bc-a98d-a4e41a0a4cd4",
"row": "fourth"
"section": "fourth"
},
"explicitInput": {
"id": "392b4936-f753-47bc-a98d-a4e41a0a4cd4",
@ -3485,7 +3485,7 @@
"w": 8,
"h": 4,
"i": "9271deff-5a61-4665-83fc-f9fdc6bf0c0b",
"row": "fourth"
"section": "fourth"
},
"explicitInput": {
"id": "9271deff-5a61-4665-83fc-f9fdc6bf0c0b",
@ -3613,7 +3613,7 @@
"w": 8,
"h": 4,
"i": "aa591c29-1a31-4ee1-a71d-b829c06fd162",
"row": "fourth"
"section": "fourth"
},
"explicitInput": {
"id": "aa591c29-1a31-4ee1-a71d-b829c06fd162",
@ -3777,7 +3777,7 @@
"w": 8,
"h": 4,
"i": "b766e3b8-4544-46ed-99e6-9ecc4847e2a2",
"row": "fourth"
"section": "fourth"
},
"explicitInput": {
"id": "b766e3b8-4544-46ed-99e6-9ecc4847e2a2",
@ -3905,7 +3905,7 @@
"w": 8,
"h": 4,
"i": "2e33ade5-96e5-40b4-b460-493e5d4fa834",
"row": "fourth"
"section": "fourth"
},
"explicitInput": {
"id": "2e33ade5-96e5-40b4-b460-493e5d4fa834",
@ -4069,7 +4069,7 @@
"w": 24,
"h": 8,
"i": "086ac2e9-dd16-4b45-92b8-1e43ff7e3f65",
"row": "fourth"
"section": "fourth"
},
"explicitInput": {
"id": "086ac2e9-dd16-4b45-92b8-1e43ff7e3f65",
@ -4190,7 +4190,7 @@
"w": 24,
"h": 28,
"i": "fb86b32f-fb7a-45cf-9511-f366fef51bbd",
"row": "fourth"
"section": "fourth"
},
"explicitInput": {
"id": "fb86b32f-fb7a-45cf-9511-f366fef51bbd",
@ -4500,7 +4500,7 @@
"w": 24,
"h": 11,
"i": "0cc42484-16f7-42ec-b38c-9bf8be69cde7",
"row": "fourth"
"section": "fourth"
},
"explicitInput": {
"id": "0cc42484-16f7-42ec-b38c-9bf8be69cde7",
@ -4643,7 +4643,7 @@
"w": 12,
"h": 11,
"i": "5d53db36-2d5a-4adc-af7b-cec4c1a294e0",
"row": "fourth"
"section": "fourth"
},
"explicitInput": {
"id": "5d53db36-2d5a-4adc-af7b-cec4c1a294e0",
@ -4773,7 +4773,7 @@
"w": 12,
"h": 11,
"i": "ecd89a7c-9124-4472-bdc6-9bdbd70d45d5",
"row": "fourth"
"section": "fourth"
},
"explicitInput": {
"id": "ecd89a7c-9124-4472-bdc6-9bdbd70d45d5",

View file

@ -28,10 +28,9 @@ export function setSerializedGridLayout(state: MockSerializedDashboardState) {
const initialState: MockSerializedDashboardState = {
panels: logsPanels,
rows: {
first: { id: 'first', order: 0, title: 'Request Sizes', collapsed: false },
second: { id: 'second', order: 1, title: 'Visitors', collapsed: false },
third: { id: 'third', order: 2, title: 'Response Codes', collapsed: false },
fourth: { id: 'fourth', order: 3, title: 'Entire Flights Dashboard', collapsed: true },
sections: {
second: { id: 'second', y: 24, title: 'Visitors', collapsed: true },
third: { id: 'third', y: 25, title: 'Response Codes', collapsed: false },
fourth: { id: 'fourth', y: 26, title: 'Entire Flights Dashboard', collapsed: true },
},
};

View file

@ -26,7 +26,7 @@ export interface DashboardGridData {
interface DashboardPanelState {
type: string;
gridData: DashboardGridData & { row?: string };
gridData: DashboardGridData & { section?: string };
explicitInput: Partial<any> & { id: string };
version?: string;
}
@ -35,13 +35,13 @@ export interface MockedDashboardPanelMap {
[key: string]: DashboardPanelState;
}
export interface MockedDashboardRowMap {
[id: string]: { id: string; order: number; title: string; collapsed: boolean };
export interface MockedDashboardSectionMap {
[id: string]: { id: string; y: number; title: string; collapsed: boolean };
}
export interface MockSerializedDashboardState {
panels: MockedDashboardPanelMap;
rows: MockedDashboardRowMap;
sections: MockedDashboardSectionMap;
}
export type MockDashboardApi = PresentationContainer &
@ -50,5 +50,5 @@ export type MockDashboardApi = PresentationContainer &
PublishesWritableViewMode &
CanExpandPanels & {
panels$: BehaviorSubject<MockedDashboardPanelMap>;
rows$: BehaviorSubject<MockedDashboardRowMap>;
sections$: BehaviorSubject<MockedDashboardSectionMap>;
};

View file

@ -0,0 +1,102 @@
/*
* 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 { transparentize, useEuiTheme } from '@elastic/eui';
import { css } from '@emotion/react';
import { useMemo } from 'react';
/**
* This file was copy pasted from `src/platform/plugins/shared/dashboard/public/dashboard_renderer/grid/use_layout_styles.tsx`
* so that the styles in the example app could match those used in Dashboard
*/
export const useLayoutStyles = () => {
const { euiTheme } = useEuiTheme();
const layoutStyles = useMemo(() => {
const getRadialGradient = (position: string) => {
return `radial-gradient(
circle at ${position},
${euiTheme.colors.accentSecondary} 1px,
transparent 1px
)`;
};
/**
* TODO: We are currently using `euiTheme.colors.vis.euiColorVis0` for grid layout styles because it
* is the best choice available; however, once https://github.com/elastic/platform-ux-team/issues/586
* is resolved, we should swap these out for the drag-specific colour tokens
*/
return css`
--dashboardActivePanelBorderStyle: ${euiTheme.border.width.thick} solid
${euiTheme.colors.vis.euiColorVis0};
--dashboardHoverActionsActivePanelBoxShadow--singleWrapper: 0 0 0
${euiTheme.border.width.thin} ${euiTheme.colors.vis.euiColorVis0};
--dashboardHoverActionsActivePanelBoxShadow: -${euiTheme.border.width.thin} 0 ${euiTheme.colors.vis.euiColorVis0},
${euiTheme.border.width.thin} 0 ${euiTheme.colors.vis.euiColorVis0},
0 -${euiTheme.border.width.thin} ${euiTheme.colors.vis.euiColorVis0};
.kbnGridSection--targeted {
background-position: top calc((var(--kbnGridGutterSize) / 2) * -1px) left
calc((var(--kbnGridGutterSize) / 2) * -1px);
background-size: calc((var(--kbnGridColumnWidth) + var(--kbnGridGutterSize)) * 1px)
calc((var(--kbnGridRowHeight) + var(--kbnGridGutterSize)) * 1px);
background-image: ${getRadialGradient('top left')}, ${getRadialGradient('top right')},
${getRadialGradient('bottom left')}, ${getRadialGradient('bottom right')};
background-origin: content-box;
}
.kbnGridPanel--dragPreview {
background-color: ${transparentize(euiTheme.colors.vis.euiColorVis0, 0.2)};
}
.kbnGridPanel--resizeHandle {
z-index: ${euiTheme.levels.maskBelowHeader};
// applying mask via ::after allows for focus borders to show
&:after {
display: block;
width: 100%;
height: 100%;
content: '';
mask-repeat: no-repeat;
mask-position: bottom ${euiTheme.size.s} right ${euiTheme.size.s};
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='8' viewBox='0 0 8 8' fill='none'%3E%3Cg clip-path='url(%23clip0_472_172810)'%3E%3Ccircle cx='7' cy='1' r='1' fill='%23000000'/%3E%3C/g%3E%3Cg clip-path='url(%23clip1_472_172810)'%3E%3Ccircle cx='4' cy='4' r='1' fill='%23000000'/%3E%3Ccircle cx='7' cy='4' r='1' fill='%23000000'/%3E%3C/g%3E%3Cg clip-path='url(%23clip2_472_172810)'%3E%3Ccircle cx='1' cy='7' r='1' fill='%23000000'/%3E%3Ccircle cx='4' cy='7' r='1' fill='%23000000'/%3E%3Ccircle cx='7' cy='7' r='1' fill='%23000000'/%3E%3C/g%3E%3C/svg%3E");
background-color: ${euiTheme.colors.borderBaseFormsControl};
}
&:hover,
&:focus-visible {
&:after {
background-color: ${euiTheme.colors.vis.euiColorVis0};
}
}
}
.kbnGridPanel--active {
// overwrite the border style on panels + hover actions for active panels
--hoverActionsBorderStyle: var(--dashboardActivePanelBorderStyle);
--hoverActionsBoxShadowStyle: var(--dashboardHoverActionsActivePanelBoxShadow);
--hoverActionsSingleWrapperBoxShadowStyle: var(
--dashboardHoverActionsActivePanelBoxShadow--singleWrapper
);
// prevent the hover actions transition when active to prevent "blip" on resize
.embPanel__hoverActions {
transition: none;
}
}
`;
}, [euiTheme]);
return layoutStyles;
};

View file

@ -20,7 +20,7 @@ import {
MockDashboardApi,
MockSerializedDashboardState,
MockedDashboardPanelMap,
MockedDashboardRowMap,
MockedDashboardSectionMap,
} from './types';
const DASHBOARD_GRID_COLUMN_COUNT = 48;
@ -57,7 +57,7 @@ export const useMockDashboardApi = ({
getPanelCount: () => {
return Object.keys(panels$.getValue()).length;
},
rows$: new BehaviorSubject<MockedDashboardRowMap>(savedState.rows),
sections$: new BehaviorSubject<MockedDashboardSectionMap>(savedState.sections),
expandedPanelId$,
expandPanel: (id: string) => {
if (expandedPanelId$.getValue()) {
@ -99,7 +99,6 @@ export const useMockDashboardApi = ({
[newId]: {
type: panelPackage.panelType,
gridData: {
row: 'first',
x: 0,
y: 0,
w: DEFAULT_PANEL_WIDTH,

View file

@ -7,19 +7,18 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { GridLayoutData } from '@kbn/grid-layout';
import { MockedDashboardPanelMap, MockedDashboardRowMap } from './types';
import { GridLayoutData, GridSectionData } from '@kbn/grid-layout';
import { MockedDashboardPanelMap, MockedDashboardSectionMap } from './types';
export const gridLayoutToDashboardPanelMap = (
panelState: MockedDashboardPanelMap,
layout: GridLayoutData
): { panels: MockedDashboardPanelMap; rows: MockedDashboardRowMap } => {
): { panels: MockedDashboardPanelMap; sections: MockedDashboardSectionMap } => {
const panels: MockedDashboardPanelMap = {};
const rows: MockedDashboardRowMap = {};
Object.entries(layout).forEach(([rowId, row]) => {
const { panels: rowPanels, isCollapsed, ...rest } = row; // drop panels
rows[rowId] = { ...rest, collapsed: isCollapsed };
Object.values(rowPanels).forEach((panelGridData) => {
const sections: MockedDashboardSectionMap = {};
Object.entries(layout).forEach(([widgetId, widget]) => {
if (widget.type === 'panel') {
const panelGridData = widget;
panels[panelGridData.id] = {
...panelState[panelGridData.id],
gridData: {
@ -28,26 +27,44 @@ export const gridLayoutToDashboardPanelMap = (
x: panelGridData.column,
w: panelGridData.width,
h: panelGridData.height,
row: rowId,
},
};
});
} else {
const { panels: rowPanels, type, isCollapsed, row, ...rest } = widget; // drop panels and type
sections[widgetId] = { ...rest, y: row, collapsed: isCollapsed };
Object.values(rowPanels).forEach((panelGridData) => {
panels[panelGridData.id] = {
...panelState[panelGridData.id],
gridData: {
i: panelGridData.id,
y: panelGridData.row,
x: panelGridData.column,
w: panelGridData.width,
h: panelGridData.height,
section: widgetId,
},
};
});
}
});
return { panels, rows };
return { panels, sections };
};
export const dashboardInputToGridLayout = ({
panels,
rows,
sections,
}: {
panels: MockedDashboardPanelMap;
rows: MockedDashboardRowMap;
sections: MockedDashboardSectionMap;
}): GridLayoutData => {
const layout: GridLayoutData = {};
Object.values(rows).forEach((row) => {
const { collapsed, ...rest } = row;
Object.values(sections).forEach((row) => {
const { collapsed, y, ...rest } = row;
layout[row.id] = {
...rest,
type: 'section',
row: y,
panels: {},
isCollapsed: collapsed,
};
@ -55,13 +72,18 @@ export const dashboardInputToGridLayout = ({
Object.keys(panels).forEach((panelId) => {
const gridData = panels[panelId].gridData;
layout[gridData.row ?? 'first'].panels[panelId] = {
const panelData = {
id: panelId,
row: gridData.y,
column: gridData.x,
width: gridData.w,
height: gridData.h,
};
if (gridData.section) {
(layout[gridData.section] as GridSectionData).panels[panelId] = panelData;
} else {
layout[panelId] = { type: 'panel', ...panelData };
}
});
return layout;

View file

@ -27,11 +27,11 @@ export const GridHeightSmoother = React.memo(
*/
const interactionStyleSubscription = combineLatest([
gridLayoutStateManager.gridDimensions$,
gridLayoutStateManager.interactionEvent$,
]).subscribe(([dimensions, interactionEvent]) => {
gridLayoutStateManager.activePanelEvent$,
]).subscribe(([dimensions, activePanel]) => {
if (!smoothHeightRef.current || gridLayoutStateManager.expandedPanelId$.getValue()) return;
if (!interactionEvent) {
if (!activePanel) {
smoothHeightRef.current.style.minHeight = `${dimensions.height}px`;
return;
}

View file

@ -22,6 +22,7 @@ import {
touchStart,
} from './test_utils/events';
import { EuiThemeProvider } from '@elastic/eui';
import { GridLayoutData } from './types';
const onLayoutChange = jest.fn();
@ -95,48 +96,45 @@ describe('GridLayout', () => {
expect(onLayoutChange).not.toBeCalled();
// if layout **has** changed, call `onLayoutChange`
const newLayout = cloneDeep(layout);
newLayout.first = {
...newLayout.first,
panels: {
...newLayout.first.panels,
panel1: {
id: 'panel1',
row: 100,
column: 0,
width: 12,
height: 6,
},
const newLayout: GridLayoutData = {
...cloneDeep(layout),
panel1: {
id: 'panel1',
type: 'panel',
row: 100,
column: 0,
width: 12,
height: 6,
},
};
layoutComponent.rerender({
layout: newLayout,
});
expect(onLayoutChange).toBeCalledTimes(1);
});
describe('dragging rows', () => {
describe('dragging sections', () => {
beforeAll(() => {
// scroll into view is not mocked by RTL so we need to add this to prevent these tests from throwing
Element.prototype.scrollIntoView = jest.fn();
});
it('row gets active when dragged', () => {
it('section gets active when dragged', () => {
renderGridLayout();
expect(screen.getByTestId('kbnGridRowHeader-second')).not.toHaveClass(
'kbnGridRowHeader--active'
expect(screen.getByTestId('kbnGridSectionHeader-second')).not.toHaveClass(
'kbnGridSectionHeader--active'
);
const rowHandle = screen.getByTestId(`kbnGridRowHeader-second--dragHandle`);
mouseStartDragging(rowHandle);
const sectionHandle = screen.getByTestId(`kbnGridSectionHeader-second--dragHandle`);
mouseStartDragging(sectionHandle);
mouseMoveTo({ clientX: 256, clientY: 128 });
expect(screen.getByTestId('kbnGridRowHeader-second')).toHaveClass('kbnGridRowHeader--active');
expect(screen.getByTestId('kbnGridSectionHeader-second')).toHaveClass(
'kbnGridSectionHeader--active'
);
mouseDrop(rowHandle);
expect(screen.getByTestId('kbnGridRowHeader-second')).not.toHaveClass(
'kbnGridRowHeader--active'
mouseDrop(sectionHandle);
expect(screen.getByTestId('kbnGridSectionHeader-second')).not.toHaveClass(
'kbnGridSectionHeader--active'
);
});
});
@ -164,24 +162,17 @@ describe('GridLayout', () => {
it('panel gets active when dragged', () => {
renderGridLayout();
const panelHandle = getPanelHandle('panel1');
expect(screen.getByLabelText('panelId:panel1').closest('div')).toHaveClass(
'kbnGridPanel css-c5ixg-initialStyles',
{
exact: true,
}
expect(screen.getByLabelText('panelId:panel1').closest('div')).not.toHaveClass(
'kbnGridPanel--active'
);
mouseStartDragging(panelHandle);
mouseMoveTo({ clientX: 256, clientY: 128 });
expect(screen.getByLabelText('panelId:panel1').closest('div')).toHaveClass(
'kbnGridPanel css-c5ixg-initialStyles kbnGridPanel--active',
{ exact: true }
'kbnGridPanel--active'
);
mouseDrop(panelHandle);
expect(screen.getByLabelText('panelId:panel1').closest('div')).toHaveClass(
'kbnGridPanel css-c5ixg-initialStyles',
{
exact: true,
}
expect(screen.getByLabelText('panelId:panel1').closest('div')).not.toHaveClass(
'kbnGridPanel--active'
);
});
});
@ -208,7 +199,8 @@ describe('GridLayout', () => {
mouseStartDragging(panelHandle);
mouseMoveTo({ clientX: 256, clientY: 128 });
expect(getAllThePanelIds()).toEqual(expectedInitPanelIdsInOrder); // the panels shouldn't be reordered till we mouseDrop
// TODO: Uncomment this line when https://github.com/elastic/kibana/issues/220309 is resolved
// expect(getAllThePanelIds()).toEqual(expectedInitPanelIdsInOrder); // the panels shouldn't be reordered till we mouseDrop
mouseDrop(panelHandle);
expect(getAllThePanelIds()).toEqual([
@ -231,7 +223,8 @@ describe('GridLayout', () => {
const panelHandle = getPanelHandle('panel1');
touchStart(panelHandle);
touchMoveTo(panelHandle, { touches: [{ clientX: 256, clientY: 128 }] });
expect(getAllThePanelIds()).toEqual(expectedInitPanelIdsInOrder); // the panels shouldn't be reordered till we mouseDrop
// TODO: Uncomment this line when https://github.com/elastic/kibana/issues/220309 is resolved
// expect(getAllThePanelIds()).toEqual(expectedInitPanelIdsInOrder); // the panels shouldn't be reordered till we mouseDrop
touchEnd(panelHandle);
expect(getAllThePanelIds()).toEqual([
@ -251,7 +244,7 @@ describe('GridLayout', () => {
it('after removing a panel', async () => {
const { rerender } = renderGridLayout();
const sampleLayoutWithoutPanel1 = cloneDeep(getSampleLayout());
delete sampleLayoutWithoutPanel1.first.panels.panel1;
delete sampleLayoutWithoutPanel1.panel1;
rerender({ layout: sampleLayoutWithoutPanel1 });
expect(getAllThePanelIds()).toEqual([
@ -270,9 +263,9 @@ describe('GridLayout', () => {
it('after replacing a panel id', async () => {
const { rerender } = renderGridLayout();
const modifiedLayout = cloneDeep(getSampleLayout());
const newPanel = { ...modifiedLayout.first.panels.panel1, id: 'panel11' };
delete modifiedLayout.first.panels.panel1;
modifiedLayout.first.panels.panel11 = newPanel;
const newPanel = { ...modifiedLayout.panel1, id: 'panel11' };
delete modifiedLayout.panel1;
modifiedLayout.panel11 = newPanel;
rerender({ layout: modifiedLayout });

View file

@ -8,20 +8,30 @@
*/
import classNames from 'classnames';
import deepEqual from 'fast-deep-equal';
import { cloneDeep } from 'lodash';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { combineLatest, pairwise, map, distinctUntilChanged } from 'rxjs';
import { combineLatest } from 'rxjs';
import { cloneDeep } from 'lodash';
import { css } from '@emotion/react';
import { GridHeightSmoother } from './grid_height_smoother';
import { GridRow } from './grid_row';
import { GridPanel, GridPanelDragPreview } from './grid_panel';
import {
GridSectionDragPreview,
GridSectionFooter,
GridSectionHeader,
GridSectionWrapper,
} from './grid_section';
import { GridAccessMode, GridLayoutData, GridSettings, UseCustomDragHandle } from './types';
import { GridLayoutContext, GridLayoutContextType } from './use_grid_layout_context';
import { useGridLayoutState } from './use_grid_layout_state';
import { isLayoutEqual } from './utils/equality_checks';
import { getRowKeysInOrder, resolveGridRow } from './utils/resolve_grid_row';
import {
getPanelKeysInOrder,
getSectionsInOrder,
resolveGridSection,
} from './utils/resolve_grid_section';
import { getOrderedLayout } from './utils/conversions';
import { isOrderedLayoutEqual } from './utils/equality_checks';
export type GridLayoutProps = {
layout: GridLayoutData;
@ -32,6 +42,11 @@ export type GridLayoutProps = {
className?: string; // this makes it so that custom CSS can be passed via Emotion
} & UseCustomDragHandle;
type GridLayoutElementsInOrder = Array<{
type: 'header' | 'footer' | 'panel' | 'wrapper';
id: string;
}>;
export const GridLayout = ({
layout,
gridSettings,
@ -50,46 +65,36 @@ export const GridLayout = ({
expandedPanelId,
accessMode,
});
const [elementsInOrder, setElementsInOrder] = useState<GridLayoutElementsInOrder>([]);
const [rowIdsInOrder, setRowIdsInOrder] = useState<string[]>(getRowKeysInOrder(layout));
/**
* Update the `gridLayout$` behaviour subject in response to the `layout` prop changing
*/
useEffect(() => {
if (!isLayoutEqual(layout, gridLayoutStateManager.gridLayout$.getValue())) {
const newLayout = cloneDeep(layout);
const orderedLayout = getOrderedLayout(layout);
if (!isOrderedLayoutEqual(orderedLayout, gridLayoutStateManager.gridLayout$.getValue())) {
const newLayout = cloneDeep(orderedLayout);
/**
* the layout sent in as a prop is not guaranteed to be valid (i.e it may have floating panels) -
* so, we need to loop through each row and ensure it is compacted
*/
Object.entries(newLayout).forEach(([rowId, row]) => {
newLayout[rowId] = resolveGridRow(row);
Object.entries(newLayout).forEach(([sectionId, row]) => {
newLayout[sectionId].panels = resolveGridSection(row.panels);
});
gridLayoutStateManager.gridLayout$.next(newLayout);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [layout]);
/**
* Set up subscriptions
*/
useEffect(() => {
/**
* This subscription calls the passed `onLayoutChange` callback when the layout changes;
* if the row IDs have changed, it also sets `rowIdsInOrder` to trigger a re-render
* This subscription calls the passed `onLayoutChange` callback when the layout changes
*/
const onLayoutChangeSubscription = gridLayoutStateManager.gridLayout$
.pipe(pairwise())
.subscribe(([layoutBefore, layoutAfter]) => {
if (!isLayoutEqual(layoutBefore, layoutAfter)) {
onLayoutChange(layoutAfter);
if (!deepEqual(Object.keys(layoutBefore), Object.keys(layoutAfter))) {
setRowIdsInOrder(getRowKeysInOrder(layoutAfter));
}
}
});
const onLayoutChangeSubscription = gridLayoutStateManager.layoutUpdated$.subscribe(
(newLayout) => {
onLayoutChange(newLayout);
}
);
return () => {
onLayoutChangeSubscription.unsubscribe();
};
@ -98,22 +103,57 @@ export const GridLayout = ({
useEffect(() => {
/**
* This subscription ensures that rows get re-rendered when their orders change
* This subscription sets the rendered elements and the `gridTemplateString`,
* which defines the grid layout structure as follows:
* - Each grid section has two named grid lines: `start-<sectionId>` and `end-<sectionId>`,
* marking the start and end of the section. Headers and footers are positioned relative to these lines.
* - Grid rows are named `gridRow-<sectionId>`, and panels are positioned relative to these lines.
*/
const rowOrderSubscription = combineLatest([
gridLayoutStateManager.proposedGridLayout$,
gridLayoutStateManager.gridLayout$,
])
.pipe(
map(([proposedGridLayout, gridLayout]) =>
getRowKeysInOrder(proposedGridLayout ?? gridLayout)
),
distinctUntilChanged(deepEqual)
)
.subscribe((rowKeys) => {
setRowIdsInOrder(rowKeys);
const renderSubscription = gridLayoutStateManager.gridLayout$.subscribe((sections) => {
const currentElementsInOrder: GridLayoutElementsInOrder = [];
let gridTemplateString = '';
getSectionsInOrder(sections).forEach((section) => {
const { id } = section;
/** Header */
if (!section.isMainSection) {
currentElementsInOrder.push({ type: 'header', id });
gridTemplateString += `auto `;
}
/** Panels */
gridTemplateString += `[start-${id}] `;
if (Object.keys(section.panels).length && (section.isMainSection || !section.isCollapsed)) {
let maxRow = 0;
getPanelKeysInOrder(section.panels).forEach((panelId) => {
const panel = section.panels[panelId];
maxRow = Math.max(maxRow, panel.row + panel.height);
currentElementsInOrder.push({
type: 'panel',
id: panel.id,
});
});
gridTemplateString += `repeat(${maxRow}, [gridRow-${id}] calc(var(--kbnGridRowHeight) * 1px)) `;
currentElementsInOrder.push({
type: 'wrapper',
id,
});
}
gridTemplateString += `[end-${section.id}] `;
/** Footer */
if (!section.isMainSection) {
currentElementsInOrder.push({ type: 'footer', id });
gridTemplateString += `auto `;
}
});
setElementsInOrder(currentElementsInOrder);
gridTemplateString = gridTemplateString.replaceAll('] [', ' ');
if (layoutRef.current) layoutRef.current.style.gridTemplateRows = gridTemplateString;
});
/**
* This subscription adds and/or removes the necessary class names related to styling for
* mobile view and a static (non-interactable) grid layout
@ -138,7 +178,7 @@ export const GridLayout = ({
});
return () => {
rowOrderSubscription.unsubscribe();
renderSubscription.unsubscribe();
gridLayoutClassSubscription.unsubscribe();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
@ -163,16 +203,22 @@ export const GridLayout = ({
setDimensionsRef(divElement);
}}
className={classNames('kbnGrid', className)}
css={[
styles.layoutPadding,
styles.hasActivePanel,
styles.singleColumn,
styles.hasExpandedPanel,
]}
css={[styles.layout, styles.hasActivePanel, styles.singleColumn, styles.hasExpandedPanel]}
>
{rowIdsInOrder.map((rowId) => (
<GridRow key={rowId} rowId={rowId} />
))}
{elementsInOrder.map((element) => {
switch (element.type) {
case 'header':
return <GridSectionHeader key={element.id} sectionId={element.id} />;
case 'panel':
return <GridPanel key={element.id} panelId={element.id} />;
case 'wrapper':
return <GridSectionWrapper key={`${element.id}--wrapper`} sectionId={element.id} />;
case 'footer':
return <GridSectionFooter key={`${element.id}--footer`} sectionId={element.id} />;
}
})}
<GridPanelDragPreview />
<GridSectionDragPreview />
</div>
</GridHeightSmoother>
</GridLayoutContext.Provider>
@ -180,11 +226,21 @@ export const GridLayout = ({
};
const styles = {
layoutPadding: css({
layout: css({
display: 'grid',
gap: 'calc(var(--kbnGridGutterSize) * 1px)',
padding: 'calc(var(--kbnGridGutterSize) * 1px)',
gridAutoRows: 'calc(var(--kbnGridRowHeight) * 1px)',
gridTemplateColumns: `repeat(
var(--kbnGridColumnCount),
calc(
(100% - (var(--kbnGridGutterSize) * (var(--kbnGridColumnCount) - 1) * 1px)) /
var(--kbnGridColumnCount)
)
)`,
}),
hasActivePanel: css({
'&:has(.kbnGridPanel--active), &:has(.kbnGridRowHeader--active)': {
'&:has(.kbnGridPanel--active), &:has(.kbnGridSectionHeader--active)': {
// disable pointer events and user select on drag + resize
userSelect: 'none',
pointerEvents: 'none',
@ -192,50 +248,40 @@ const styles = {
}),
singleColumn: css({
'&.kbnGrid--mobileView': {
'.kbnGridRow': {
gridTemplateColumns: '100%',
gridTemplateRows: 'auto',
gridAutoFlow: 'row',
gridAutoRows: 'auto',
},
'.kbnGridPanel': {
gridTemplateColumns: '100%',
gridTemplateRows: 'auto !important',
gridAutoFlow: 'row',
gridAutoRows: 'auto',
'.kbnGridPanel, .kbnGridSectionHeader': {
gridArea: 'unset !important',
},
},
}),
hasExpandedPanel: css({
'&:has(.kbnGridPanel--expanded)': {
':has(.kbnGridPanel--expanded)': {
height: '100%',
// targets the grid row container that contains the expanded panel
'& .kbnGridRowContainer:has(.kbnGridPanel--expanded)': {
'.kbnGridRowHeader': {
height: '0px', // used instead of 'display: none' due to a11y concerns
padding: '0px',
display: 'block',
overflow: 'hidden',
},
'.kbnGridRow': {
display: 'block !important', // overwrite grid display
height: '100%',
'.kbnGridPanel': {
'&.kbnGridPanel--expanded': {
height: '100% !important',
},
// hide the non-expanded panels
'&:not(.kbnGridPanel--expanded)': {
position: 'absolute',
top: '-9999px',
left: '-9999px',
visibility: 'hidden', // remove hidden panels and their contents from tab order for a11y
},
},
},
display: 'block',
paddingBottom: 'calc(var(--kbnGridGutterSize) * 1px) !important',
'.kbnGridSectionHeader, .kbnGridSectionFooter': {
height: '0px', // better than 'display: none' for a11y header may hold info relevant to the expanded panel
padding: '0px',
display: 'block',
overflow: 'hidden',
},
// targets the grid row containers that **do not** contain the expanded panel
'& .kbnGridRowContainer:not(:has(.kbnGridPanel--expanded))': {
position: 'absolute',
top: '-9999px',
left: '-9999px',
'.kbnGridSectionFooter': {
visibility: 'hidden',
},
'.kbnGridPanel': {
'&.kbnGridPanel--expanded': {
height: '100% !important',
},
// hide the non-expanded panels
'&:not(.kbnGridPanel--expanded)': {
position: 'absolute',
top: '-9999px',
left: '-9999px',
visibility: 'hidden', // remove hidden panels and their contents from tab order for a11y
},
},
},
}),

View file

@ -20,23 +20,29 @@ export interface DragHandleApi {
export const useDragHandleApi = ({
panelId,
rowId,
sectionId,
}: {
panelId: string;
rowId: string;
sectionId?: string;
}): DragHandleApi => {
const { useCustomDragHandle } = useGridLayoutContext();
const { startDrag } = useGridLayoutPanelEvents({
const startDrag = useGridLayoutPanelEvents({
interactionType: 'drag',
panelId,
rowId,
sectionId,
});
const removeEventListenersRef = useRef<(() => void) | null>(null);
const setDragHandles = useCallback(
(dragHandles: Array<HTMLElement | null>) => {
/**
* if new `startDrag` reference (which happens when, for example, panels change sections),
* then clean up the old event listeners
*/
removeEventListenersRef.current?.();
for (const handle of dragHandles) {
if (handle === null) return;
handle.addEventListener('mousedown', startDrag, { passive: true });

View file

@ -26,7 +26,7 @@ describe('GridPanel', () => {
} as GridLayoutContextType;
const panelProps = {
panelId: 'panel1',
rowId: 'first',
sectionId: 'first',
...(overrides?.propsOverrides ?? {}),
};
const { rerender, ...rtlRest } = render(

View file

@ -7,8 +7,8 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import React, { useEffect, useMemo } from 'react';
import { combineLatest, skip } from 'rxjs';
import React, { useEffect, useMemo, useState } from 'react';
import { combineLatest, distinctUntilChanged, filter, map, pairwise, skip, startWith } from 'rxjs';
import { useEuiTheme } from '@elastic/eui';
import { css } from '@emotion/react';
@ -17,23 +17,23 @@ import { useGridLayoutContext } from '../use_grid_layout_context';
import { DefaultDragHandle } from './drag_handle/default_drag_handle';
import { useDragHandleApi } from './drag_handle/use_drag_handle_api';
import { ResizeHandle } from './grid_panel_resize_handle';
import { useGridPanelState } from './use_panel_grid_data';
export interface GridPanelProps {
panelId: string;
rowId: string;
}
export const GridPanel = React.memo(({ panelId, rowId }: GridPanelProps) => {
export const GridPanel = React.memo(({ panelId }: GridPanelProps) => {
const { euiTheme } = useEuiTheme();
const { gridLayoutStateManager, useCustomDragHandle, renderPanelContents } =
useGridLayoutContext();
const panel$ = useGridPanelState({ panelId });
const [sectionId, setSectionId] = useState<string | undefined>(panel$.getValue()?.sectionId);
const dragHandleApi = useDragHandleApi({ panelId, sectionId });
const { euiTheme } = useEuiTheme();
const dragHandleApi = useDragHandleApi({ panelId, rowId });
/** Set initial styles based on state at mount to prevent styles from "blipping" */
const initialStyles = useMemo(() => {
const initialPanel = (gridLayoutStateManager.proposedGridLayout$.getValue() ??
gridLayoutStateManager.gridLayout$.getValue())[rowId].panels[panelId];
const initialPanel = panel$.getValue();
if (!initialPanel) return;
return css`
position: relative;
height: calc(
@ -45,123 +45,131 @@ export const GridPanel = React.memo(({ panelId, rowId }: GridPanelProps) => {
);
grid-column-start: ${initialPanel.column + 1};
grid-column-end: ${initialPanel.column + 1 + initialPanel.width};
grid-row-start: ${initialPanel.row + 1};
grid-row-end: ${initialPanel.row + 1 + initialPanel.height};
grid-row-start: ${`gridRow-${initialPanel.sectionId}`} ${initialPanel.row + 1};
grid-row-end: span ${initialPanel.height};
.kbnGridPanel--dragHandle,
.kbnGridPanel--resizeHandle {
touch-action: none; // prevent scrolling on touch devices
scroll-margin-top: ${gridLayoutStateManager.runtimeSettings$.value.keyboardDragTopLimit}px;
}
`;
}, [gridLayoutStateManager, rowId, panelId]);
}, [panel$, gridLayoutStateManager.runtimeSettings$]);
useEffect(() => {
return () => {
// remove reference on unmount
delete gridLayoutStateManager.panelRefs.current[rowId][panelId];
delete gridLayoutStateManager.panelRefs.current[panelId];
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
}, [panelId, gridLayoutStateManager]);
useEffect(
() => {
/** Update the styles of the panel via a subscription to prevent re-renders */
const activePanelStyleSubscription = combineLatest([
gridLayoutStateManager.activePanel$,
gridLayoutStateManager.gridLayout$,
gridLayoutStateManager.proposedGridLayout$,
])
.pipe(skip(1)) // skip the first emit because the `initialStyles` will take care of it
.subscribe(([activePanel, gridLayout, proposedGridLayout]) => {
const ref = gridLayoutStateManager.panelRefs.current[rowId][panelId];
const panel = (proposedGridLayout ?? gridLayout)[rowId]?.panels[panelId];
if (!ref || !panel) return;
useEffect(() => {
/** Update the styles of the panel as it is dragged via a subscription to prevent re-renders */
const activePanelStyleSubscription = combineLatest([
gridLayoutStateManager.activePanelEvent$.pipe(
// filter out the first active panel event to allow "onClick" events through
pairwise(),
filter(([before]) => before !== undefined),
map(([, after]) => after),
startWith(undefined)
),
panel$,
])
.pipe(skip(1))
.subscribe(([activePanel, currentPanel]) => {
if (!currentPanel) return;
const ref = gridLayoutStateManager.panelRefs.current[currentPanel?.id];
const isPanelActive = activePanel && activePanel.id === currentPanel?.id;
if (!ref) return;
const currentInteractionEvent = gridLayoutStateManager.interactionEvent$.getValue();
if (isPanelActive) {
// allow click events through before triggering active status
ref.classList.add('kbnGridPanel--active');
if (panelId === activePanel?.id) {
ref.classList.add('kbnGridPanel--active');
// if the current panel is active, give it fixed positioning depending on the interaction event
const { position: draggingPosition } = activePanel;
const runtimeSettings = gridLayoutStateManager.runtimeSettings$.getValue();
// if the current panel is active, give it fixed positioning depending on the interaction event
const { position: draggingPosition } = activePanel;
const runtimeSettings = gridLayoutStateManager.runtimeSettings$.getValue();
ref.style.zIndex = `${euiTheme.levels.modal}`;
if (activePanel.type === 'resize') {
// if the current panel is being resized, ensure it is not shrunk past the size of a single cell
ref.style.width = `${Math.max(
draggingPosition.right - draggingPosition.left,
runtimeSettings.columnPixelWidth
)}px`;
ref.style.height = `${Math.max(
draggingPosition.bottom - draggingPosition.top,
runtimeSettings.rowHeight
)}px`;
ref.style.zIndex = `${euiTheme.levels.modal}`;
if (currentInteractionEvent?.type === 'resize') {
// if the current panel is being resized, ensure it is not shrunk past the size of a single cell
ref.style.width = `${Math.max(
draggingPosition.right - draggingPosition.left,
runtimeSettings.columnPixelWidth
)}px`;
ref.style.height = `${Math.max(
draggingPosition.bottom - draggingPosition.top,
runtimeSettings.rowHeight
)}px`;
// undo any "lock to grid" styles **except** for the top left corner, which stays locked
ref.style.gridColumnStart = `${panel.column + 1}`;
ref.style.gridRowStart = `${panel.row + 1}`;
ref.style.gridColumnEnd = `auto`;
ref.style.gridRowEnd = `auto`;
} else {
// if the current panel is being dragged, render it with a fixed position + size
ref.style.position = 'fixed';
ref.style.left = `${draggingPosition.left}px`;
ref.style.top = `${draggingPosition.top}px`;
ref.style.width = `${draggingPosition.right - draggingPosition.left}px`;
ref.style.height = `${draggingPosition.bottom - draggingPosition.top}px`;
// undo any "lock to grid" styles
ref.style.gridArea = `auto`; // shortcut to set all grid styles to `auto`
}
// undo any "lock to grid" styles **except** for the top left corner, which stays locked
ref.style.gridColumnStart = `${currentPanel.column + 1}`;
ref.style.gridRowStart = `${`gridRow-${currentPanel.sectionId}`} ${
currentPanel.row + 1
}`;
ref.style.gridColumnEnd = `auto`;
ref.style.gridRowEnd = `auto`;
} else {
ref.classList.remove('kbnGridPanel--active');
// if the current panel is being dragged, render it with a fixed position + size
ref.style.position = 'fixed';
ref.style.zIndex = `auto`;
// if the panel is not being dragged and/or resized, undo any fixed position styles
ref.style.position = '';
ref.style.left = ``;
ref.style.top = ``;
ref.style.width = ``;
// setting the height is necessary for mobile mode
ref.style.height = `calc(1px * (${panel.height} * (var(--kbnGridRowHeight) + var(--kbnGridGutterSize)) - var(--kbnGridGutterSize)))`;
// and render the panel locked to the grid
ref.style.gridColumnStart = `${panel.column + 1}`;
ref.style.gridColumnEnd = `${panel.column + 1 + panel.width}`;
ref.style.gridRowStart = `${panel.row + 1}`;
ref.style.gridRowEnd = `${panel.row + 1 + panel.height}`;
ref.style.left = `${draggingPosition.left}px`;
ref.style.top = `${draggingPosition.top}px`;
ref.style.width = `${draggingPosition.right - draggingPosition.left}px`;
ref.style.height = `${draggingPosition.bottom - draggingPosition.top}px`;
}
});
} else {
ref.classList.remove('kbnGridPanel--active');
ref.style.zIndex = `auto`;
/**
* This subscription adds and/or removes the necessary class name for expanded panel styling
*/
const expandedPanelSubscription = gridLayoutStateManager.expandedPanelId$.subscribe(
(expandedPanelId) => {
const ref = gridLayoutStateManager.panelRefs.current[rowId][panelId];
const gridLayout = gridLayoutStateManager.gridLayout$.getValue();
const panel = gridLayout[rowId].panels[panelId];
if (!ref || !panel) return;
// if the panel is not being dragged and/or resized, undo any fixed position styles
ref.style.position = '';
ref.style.left = ``;
ref.style.top = ``;
ref.style.width = ``;
// setting the height is necessary for mobile mode
ref.style.height = `calc(1px * (${currentPanel.height} * (var(--kbnGridRowHeight) + var(--kbnGridGutterSize)) - var(--kbnGridGutterSize)))`;
if (expandedPanelId && expandedPanelId === panelId) {
ref.classList.add('kbnGridPanel--expanded');
} else {
ref.classList.remove('kbnGridPanel--expanded');
}
// and render the panel locked to the grid
ref.style.gridColumnStart = `${currentPanel.column + 1}`;
ref.style.gridColumnEnd = `${currentPanel.column + 1 + currentPanel.width}`;
ref.style.gridRowStart = `${`gridRow-${currentPanel.sectionId}`} ${currentPanel.row + 1}`;
ref.style.gridRowEnd = `span ${currentPanel.height}`;
}
);
});
return () => {
expandedPanelSubscription.unsubscribe();
activePanelStyleSubscription.unsubscribe();
};
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[]
);
/**
* This subscription adds and/or removes the necessary class name for expanded panel styling
*/
const expandedPanelSubscription = gridLayoutStateManager.expandedPanelId$.subscribe(
(expandedPanelId) => {
const panel = panel$.getValue();
if (!panel) return;
const ref = gridLayoutStateManager.panelRefs.current[panel.id];
if (!ref) return;
if (expandedPanelId && expandedPanelId === panel.id) {
ref.classList.add('kbnGridPanel--expanded');
} else {
ref.classList.remove('kbnGridPanel--expanded');
}
}
);
const sectionIdSubscription = panel$
.pipe(
skip(1),
map((panel) => panel?.sectionId),
distinctUntilChanged()
)
.subscribe((currentSection) => {
setSectionId(currentSection);
});
return () => {
activePanelStyleSubscription.unsubscribe();
expandedPanelSubscription.unsubscribe();
sectionIdSubscription.unsubscribe();
};
}, [panel$, gridLayoutStateManager, euiTheme.levels.modal]);
/**
* Memoize panel contents to prevent unnecessary re-renders
@ -173,17 +181,14 @@ export const GridPanel = React.memo(({ panelId, rowId }: GridPanelProps) => {
return (
<div
ref={(element) => {
if (!gridLayoutStateManager.panelRefs.current[rowId]) {
gridLayoutStateManager.panelRefs.current[rowId] = {};
}
gridLayoutStateManager.panelRefs.current[rowId][panelId] = element;
gridLayoutStateManager.panelRefs.current[panelId] = element;
}}
css={initialStyles}
className="kbnGridPanel"
>
{!useCustomDragHandle && <DefaultDragHandle dragHandleApi={dragHandleApi} />}
{panelContents}
<ResizeHandle panelId={panelId} rowId={rowId} />
<ResizeHandle panelId={panelId} sectionId={sectionId} />
</div>
);
});

View file

@ -8,12 +8,12 @@
*/
import React, { useEffect, useRef } from 'react';
import { combineLatest, skip } from 'rxjs';
import { skip } from 'rxjs';
import { css } from '@emotion/react';
import { useGridLayoutContext } from '../use_grid_layout_context';
export const GridPanelDragPreview = React.memo(({ rowId }: { rowId: string }) => {
export const GridPanelDragPreview = React.memo(() => {
const { gridLayoutStateManager } = useGridLayoutContext();
const dragPreviewRef = useRef<HTMLDivElement | null>(null);
@ -21,23 +21,23 @@ export const GridPanelDragPreview = React.memo(({ rowId }: { rowId: string }) =>
useEffect(
() => {
/** Update the styles of the drag preview via a subscription to prevent re-renders */
const styleSubscription = combineLatest([
gridLayoutStateManager.activePanel$,
gridLayoutStateManager.proposedGridLayout$,
])
const styleSubscription = gridLayoutStateManager.activePanelEvent$
.pipe(skip(1)) // skip the first emit because the drag preview is only rendered after a user action
.subscribe(([activePanel, proposedGridLayout]) => {
.subscribe((activePanel) => {
if (!dragPreviewRef.current) return;
if (!activePanel || !proposedGridLayout?.[rowId].panels[activePanel.id]) {
const gridLayout = gridLayoutStateManager.gridLayout$.getValue();
const sectionId = activePanel?.targetSection;
if (!activePanel || !sectionId || !gridLayout[sectionId]?.panels[activePanel.id]) {
dragPreviewRef.current.style.display = 'none';
} else {
const panel = proposedGridLayout[rowId].panels[activePanel.id];
const panel = gridLayout[sectionId].panels[activePanel.id];
dragPreviewRef.current.style.display = 'block';
dragPreviewRef.current.style.gridColumnStart = `${panel.column + 1}`;
dragPreviewRef.current.style.gridColumnEnd = `${panel.column + 1 + panel.width}`;
dragPreviewRef.current.style.gridRowStart = `${panel.row + 1}`;
dragPreviewRef.current.style.gridRowEnd = `${panel.row + 1 + panel.height}`;
dragPreviewRef.current.style.gridRowStart = `${`gridRow-${sectionId}`} ${
panel.row + 1
}`;
dragPreviewRef.current.style.gridRowEnd = `span ${panel.height}`;
}
});

View file

@ -15,26 +15,28 @@ import { i18n } from '@kbn/i18n';
import { useGridLayoutPanelEvents } from '../use_grid_layout_events';
export const ResizeHandle = React.memo(({ rowId, panelId }: { rowId: string; panelId: string }) => {
const { startDrag } = useGridLayoutPanelEvents({
interactionType: 'resize',
panelId,
rowId,
});
export const ResizeHandle = React.memo(
({ sectionId, panelId }: { sectionId?: string; panelId: string }) => {
const startDrag = useGridLayoutPanelEvents({
interactionType: 'resize',
panelId,
sectionId,
});
return (
<button
css={styles}
onMouseDown={startDrag}
onTouchStart={startDrag}
onKeyDown={startDrag}
className="kbnGridPanel--resizeHandle"
aria-label={i18n.translate('kbnGridLayout.resizeHandle.ariaLabel', {
defaultMessage: 'Resize panel',
})}
/>
);
});
return (
<button
css={styles}
onMouseDown={startDrag}
onTouchStart={startDrag}
onKeyDown={startDrag}
className="kbnGridPanel--resizeHandle"
aria-label={i18n.translate('kbnGridLayout.resizeHandle.ariaLabel', {
defaultMessage: 'Resize panel',
})}
/>
);
}
);
const styles = ({ euiTheme }: UseEuiTheme) =>
css({

View file

@ -8,3 +8,5 @@
*/
export { GridPanel } from './grid_panel';
export { GridPanelDragPreview } from './grid_panel_drag_preview';
export type { GridPanelData, ActivePanelEvent } from './types';

View file

@ -0,0 +1,65 @@
/*
* 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".
*/
interface GridCoordinate {
column: number;
row: number;
}
interface GridRect extends GridCoordinate {
width: number;
height: number;
}
export interface GridPanelData extends GridRect {
id: string;
}
export interface ActivePanelEvent {
/**
* 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.
*/
targetSection: string;
/**
* 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
*/
sensorOffsets: {
top: number;
left: number;
right: number;
bottom: number;
};
sensorType: 'mouse' | 'touch' | 'keyboard';
/**
* This position of the fixed position panel
*/
position: {
top: number;
left: number;
bottom: number;
right: number;
};
}

View file

@ -0,0 +1,76 @@
/*
* 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 { useEffect, useMemo, useRef } from 'react';
import { BehaviorSubject, distinctUntilChanged, filter, map } from 'rxjs';
import type { OrderedLayout } from '../types';
import { useGridLayoutContext } from '../use_grid_layout_context';
import { isGridDataEqual } from '../utils/equality_checks';
import type { GridPanelData } from './types';
export const useGridPanelState = ({
panelId,
}: {
panelId: string;
}): BehaviorSubject<(GridPanelData & { sectionId: string }) | undefined> => {
const { gridLayoutStateManager } = useGridLayoutContext();
const cleanupCallback = useRef<null | (() => void)>();
const panel$ = useMemo(() => {
const panelSubject = new BehaviorSubject(
getPanelState(gridLayoutStateManager.gridLayout$.getValue(), panelId)
);
const subscription = gridLayoutStateManager.gridLayout$
.pipe(
map((layout) => getPanelState(layout, panelId)),
// filter out undefined panels
filter(nonNullable),
distinctUntilChanged(
(panelA, panelB) =>
isGridDataEqual(panelA, panelB) && panelA.sectionId === panelB.sectionId
)
)
.subscribe((panel) => {
panelSubject.next(panel);
});
cleanupCallback.current = () => {
subscription.unsubscribe();
};
return panelSubject;
}, [gridLayoutStateManager.gridLayout$, panelId]);
useEffect(() => {
return () => {
if (cleanupCallback.current) cleanupCallback.current();
};
}, []);
return panel$;
};
const getPanelState = (
layout: OrderedLayout,
panelId: string
): (GridPanelData & { sectionId: string }) | undefined => {
for (const section of Object.values(layout)) {
const panel = section.panels[panelId];
if (panel) {
return { ...panel, sectionId: section.id };
}
}
return undefined;
};
function nonNullable<T>(value: T): value is NonNullable<T> {
return value !== null && value !== undefined;
}

View file

@ -1,61 +0,0 @@
/*
* 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 from 'react';
import { EuiThemeProvider } from '@elastic/eui';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { getGridLayoutStateManagerMock, mockRenderPanelContents } from '../test_utils/mocks';
import { getSampleLayout } from '../test_utils/sample_layout';
import { GridLayoutContext, type GridLayoutContextType } from '../use_grid_layout_context';
import { GridRow, GridRowProps } from './grid_row';
describe('GridRow', () => {
const renderGridRow = (
propsOverrides: Partial<GridRowProps> = {},
contextOverrides: Partial<GridLayoutContextType> = {}
) => {
return render(
<GridLayoutContext.Provider
value={
{
renderPanelContents: mockRenderPanelContents,
gridLayoutStateManager: getGridLayoutStateManagerMock(),
...contextOverrides,
} as GridLayoutContextType
}
>
<GridRow rowId={'first'} {...propsOverrides} />
</GridLayoutContext.Provider>,
{ wrapper: EuiThemeProvider }
);
};
it('renders all the panels in a row', () => {
renderGridRow();
const firstRowPanels = Object.values(getSampleLayout().first.panels);
firstRowPanels.forEach((panel) => {
expect(screen.getByLabelText(`panelId:${panel.id}`)).toBeInTheDocument();
});
});
it('does not show the panels in a row that is collapsed', async () => {
renderGridRow({ rowId: 'second' });
expect(screen.getByTestId('kbnGridRowTitle-second').ariaExpanded).toBe('true');
expect(screen.getAllByText(/panel content/)).toHaveLength(1);
const collapseButton = screen.getByRole('button', { name: /toggle collapse/i });
await userEvent.click(collapseButton);
expect(screen.getByTestId('kbnGridRowTitle-second').ariaExpanded).toBe('false');
expect(screen.queryAllByText(/panel content/)).toHaveLength(0);
});
});

View file

@ -1,186 +0,0 @@
/*
* 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 classNames from 'classnames';
import { cloneDeep } from 'lodash';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { combineLatest, map, pairwise, skip } from 'rxjs';
import { css } from '@emotion/react';
import { GridPanelDragPreview } from '../grid_panel/grid_panel_drag_preview';
import { GridPanel } from '../grid_panel';
import { useGridLayoutContext } from '../use_grid_layout_context';
import { GridRowHeader } from './grid_row_header';
import { getPanelKeysInOrder } from '../utils/resolve_grid_row';
export interface GridRowProps {
rowId: string;
}
export const GridRow = React.memo(({ rowId }: GridRowProps) => {
const { gridLayoutStateManager } = useGridLayoutContext();
const collapseButtonRef = useRef<HTMLButtonElement | null>(null);
const currentRow = gridLayoutStateManager.gridLayout$.value[rowId];
const [panelIdsInOrder, setPanelIdsInOrder] = useState<string[]>(() =>
getPanelKeysInOrder(currentRow.panels)
);
const [isCollapsed, setIsCollapsed] = useState<boolean>(currentRow.isCollapsed);
useEffect(
() => {
/** Update the styles of the grid row via a subscription to prevent re-renders */
const interactionStyleSubscription = gridLayoutStateManager.interactionEvent$
.pipe(skip(1)) // skip the first emit because the `initialStyles` will take care of it
.subscribe((interactionEvent) => {
const rowRef = gridLayoutStateManager.rowRefs.current[rowId];
if (!rowRef) return;
const targetRow = interactionEvent?.targetRow;
if (rowId === targetRow && interactionEvent) {
rowRef.classList.add('kbnGridRow--targeted');
} else {
rowRef.classList.remove('kbnGridRow--targeted');
}
});
/**
* This subscription ensures that the row will re-render when one of the following changes:
* - Collapsed state
* - Panel IDs (adding/removing/replacing, but not reordering)
*/
const rowStateSubscription = combineLatest([
gridLayoutStateManager.proposedGridLayout$,
gridLayoutStateManager.gridLayout$,
])
.pipe(
map(([proposedGridLayout, gridLayout]) => {
const displayedGridLayout = proposedGridLayout ?? gridLayout;
return {
isCollapsed: displayedGridLayout[rowId]?.isCollapsed ?? false,
panelIds: Object.keys(displayedGridLayout[rowId]?.panels ?? {}),
};
}),
pairwise()
)
.subscribe(([oldRowData, newRowData]) => {
if (oldRowData.isCollapsed !== newRowData.isCollapsed) {
setIsCollapsed(newRowData.isCollapsed);
}
if (
oldRowData.panelIds.length !== newRowData.panelIds.length ||
!(
oldRowData.panelIds.every((p) => newRowData.panelIds.includes(p)) &&
newRowData.panelIds.every((p) => oldRowData.panelIds.includes(p))
)
) {
setPanelIdsInOrder(
getPanelKeysInOrder(
(gridLayoutStateManager.proposedGridLayout$.getValue() ??
gridLayoutStateManager.gridLayout$.getValue())[rowId]?.panels ?? {}
)
);
}
});
/**
* Ensure the row re-renders to reflect the new panel order after a drag-and-drop interaction, since
* the order of rendered panels need to be aligned with how they are displayed in the grid for accessibility
* reasons (screen readers and focus management).
*/
const gridLayoutSubscription = gridLayoutStateManager.gridLayout$.subscribe((gridLayout) => {
if (!gridLayout[rowId]) return;
const newPanelIdsInOrder = getPanelKeysInOrder(gridLayout[rowId].panels);
if (panelIdsInOrder.join() !== newPanelIdsInOrder.join()) {
setPanelIdsInOrder(newPanelIdsInOrder);
}
});
return () => {
interactionStyleSubscription.unsubscribe();
gridLayoutSubscription.unsubscribe();
rowStateSubscription.unsubscribe();
};
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[rowId]
);
const toggleIsCollapsed = useCallback(() => {
const newLayout = cloneDeep(gridLayoutStateManager.gridLayout$.value);
newLayout[rowId].isCollapsed = !newLayout[rowId].isCollapsed;
gridLayoutStateManager.gridLayout$.next(newLayout);
}, [rowId, gridLayoutStateManager.gridLayout$]);
useEffect(() => {
/**
* Set `aria-expanded` without passing the expanded state as a prop to `GridRowHeader` in order
* to prevent `GridRowHeader` from rerendering when this state changes
*/
if (!collapseButtonRef.current) return;
collapseButtonRef.current.ariaExpanded = `${!isCollapsed}`;
}, [isCollapsed]);
return (
<div
css={styles.fullHeight}
className={classNames('kbnGridRowContainer', {
'kbnGridRowContainer--collapsed': isCollapsed,
})}
>
{currentRow.order !== 0 && (
<GridRowHeader
rowId={rowId}
toggleIsCollapsed={toggleIsCollapsed}
collapseButtonRef={collapseButtonRef}
/>
)}
{!isCollapsed && (
<div
id={`kbnGridRow-${rowId}`}
className={'kbnGridRow'}
ref={(element: HTMLDivElement | null) =>
(gridLayoutStateManager.rowRefs.current[rowId] = element)
}
css={[styles.fullHeight, styles.grid]}
role="region"
aria-labelledby={`kbnGridRowTitle-${rowId}`}
>
{/* render the panels **in order** for accessibility, using the memoized panel components */}
{panelIdsInOrder.map((panelId) => (
<GridPanel key={panelId} panelId={panelId} rowId={rowId} />
))}
<GridPanelDragPreview rowId={rowId} />
</div>
)}
</div>
);
});
const styles = {
fullHeight: css({
height: '100%',
}),
grid: css({
position: 'relative',
justifyItems: 'stretch',
display: 'grid',
gap: 'calc(var(--kbnGridGutterSize) * 1px)',
gridAutoRows: 'calc(var(--kbnGridRowHeight) * 1px)',
gridTemplateColumns: `repeat(
var(--kbnGridColumnCount),
calc(
(100% - (var(--kbnGridGutterSize) * (var(--kbnGridColumnCount) - 1) * 1px)) /
var(--kbnGridColumnCount)
)
)`,
}),
};
GridRow.displayName = 'KbnGridLayoutRow';

View file

@ -1,43 +0,0 @@
/*
* 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, { useEffect } from 'react';
import { UseEuiTheme } from '@elastic/eui';
import { css } from '@emotion/react';
import { useGridLayoutContext } from '../use_grid_layout_context';
export const GridRowDragPreview = React.memo(({ rowId }: { rowId: string }) => {
const { gridLayoutStateManager } = useGridLayoutContext();
useEffect(
() => {
return () => {
// when drag preview unmounts, this means the header was dropped - so, scroll to it
const headerRef = gridLayoutStateManager.headerRefs.current[rowId];
headerRef?.scrollIntoView({ behavior: 'smooth', block: 'center' });
};
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[]
);
return <div className={'kbnGridPanel--rowDragPreview'} css={styles} />;
});
const styles = ({ euiTheme }: UseEuiTheme) =>
css({
width: '100%',
height: euiTheme.size.xl,
margin: `${euiTheme.size.s} 0px`,
position: 'relative',
});
GridRowDragPreview.displayName = 'KbnGridLayoutRowDragPreview';

View file

@ -1,148 +0,0 @@
/*
* 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 { cloneDeep } from 'lodash';
import React from 'react';
import { EuiThemeProvider } from '@elastic/eui';
import { RenderResult, act, render, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { getGridLayoutStateManagerMock, mockRenderPanelContents } from '../test_utils/mocks';
import { GridLayoutStateManager } from '../types';
import { GridRowHeader, GridRowHeaderProps } from './grid_row_header';
import { GridLayoutContext, GridLayoutContextType } from '../use_grid_layout_context';
const toggleIsCollapsed = jest
.fn()
.mockImplementation((rowId: string, gridLayoutStateManager: GridLayoutStateManager) => {
const newLayout = cloneDeep(gridLayoutStateManager.gridLayout$.value);
newLayout[rowId].isCollapsed = !newLayout[rowId].isCollapsed;
gridLayoutStateManager.gridLayout$.next(newLayout);
});
describe('GridRowHeader', () => {
const renderGridRowHeader = (
propsOverrides: Partial<GridRowHeaderProps> = {},
contextOverrides: Partial<GridLayoutContextType> = {}
) => {
const stateManagerMock = getGridLayoutStateManagerMock();
return {
component: render(
<GridLayoutContext.Provider
value={
{
renderPanelContents: mockRenderPanelContents,
gridLayoutStateManager: stateManagerMock,
...contextOverrides,
} as GridLayoutContextType
}
>
<GridRowHeader
rowId={'first'}
toggleIsCollapsed={() => toggleIsCollapsed('first', stateManagerMock)}
collapseButtonRef={React.createRef()}
{...propsOverrides}
/>
</GridLayoutContext.Provider>,
{ wrapper: EuiThemeProvider }
),
gridLayoutStateManager: stateManagerMock,
};
};
beforeEach(() => {
toggleIsCollapsed.mockClear();
});
it('renders the panel count', async () => {
const { component, gridLayoutStateManager } = renderGridRowHeader();
const initialCount = component.getByTestId('kbnGridRowHeader-first--panelCount');
expect(initialCount.textContent).toBe('(8 panels)');
act(() => {
const currentRow = gridLayoutStateManager.gridLayout$.getValue().first;
gridLayoutStateManager.gridLayout$.next({
first: {
...currentRow,
panels: {
panel1: currentRow.panels.panel1,
},
},
});
});
await waitFor(() => {
const updatedCount = component.getByTestId('kbnGridRowHeader-first--panelCount');
expect(updatedCount.textContent).toBe('(1 panel)');
});
});
it('clicking title calls `toggleIsCollapsed`', async () => {
const { component, gridLayoutStateManager } = renderGridRowHeader();
const title = component.getByTestId('kbnGridRowTitle-first');
expect(toggleIsCollapsed).toBeCalledTimes(0);
expect(gridLayoutStateManager.gridLayout$.getValue().first.isCollapsed).toBe(false);
await userEvent.click(title);
expect(toggleIsCollapsed).toBeCalledTimes(1);
expect(gridLayoutStateManager.gridLayout$.getValue().first.isCollapsed).toBe(true);
});
describe('title editor', () => {
const setTitle = async (component: RenderResult) => {
const input = component.getByTestId('euiInlineEditModeInput');
expect(input.getAttribute('value')).toBe('Large section');
await userEvent.click(input);
await userEvent.keyboard(' 123');
expect(input.getAttribute('value')).toBe('Large section 123');
};
it('clicking on edit icon triggers inline title editor and does not toggle collapsed', async () => {
const { component, gridLayoutStateManager } = renderGridRowHeader();
const editIcon = component.getByTestId('kbnGridRowTitle-first--edit');
expect(component.queryByTestId('kbnGridRowTitle-first--editor')).not.toBeInTheDocument();
expect(gridLayoutStateManager.gridLayout$.getValue().first.isCollapsed).toBe(false);
await userEvent.click(editIcon);
expect(component.getByTestId('kbnGridRowTitle-first--editor')).toBeInTheDocument();
expect(toggleIsCollapsed).toBeCalledTimes(0);
expect(gridLayoutStateManager.gridLayout$.getValue().first.isCollapsed).toBe(false);
});
it('can update the title', async () => {
const { component, gridLayoutStateManager } = renderGridRowHeader();
expect(component.getByTestId('kbnGridRowTitle-first').textContent).toBe('Large section');
expect(gridLayoutStateManager.gridLayout$.getValue().first.title).toBe('Large section');
const editIcon = component.getByTestId('kbnGridRowTitle-first--edit');
await userEvent.click(editIcon);
await setTitle(component);
const saveButton = component.getByTestId('euiInlineEditModeSaveButton');
await userEvent.click(saveButton);
expect(component.queryByTestId('kbnGridRowTitle-first--editor')).not.toBeInTheDocument();
expect(component.getByTestId('kbnGridRowTitle-first').textContent).toBe('Large section 123');
expect(gridLayoutStateManager.gridLayout$.getValue().first.title).toBe('Large section 123');
});
it('clicking on cancel closes the inline title editor without updating title', async () => {
const { component, gridLayoutStateManager } = renderGridRowHeader();
const editIcon = component.getByTestId('kbnGridRowTitle-first--edit');
await userEvent.click(editIcon);
await setTitle(component);
const cancelButton = component.getByTestId('euiInlineEditModeCancelButton');
await userEvent.click(cancelButton);
expect(component.queryByTestId('kbnGridRowTitle-first--editor')).not.toBeInTheDocument();
expect(component.getByTestId('kbnGridRowTitle-first').textContent).toBe('Large section');
expect(gridLayoutStateManager.gridLayout$.getValue().first.title).toBe('Large section');
});
});
});

View file

@ -1,296 +0,0 @@
/*
* 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 classNames from 'classnames';
import React, { useCallback, useEffect, useState } from 'react';
import { distinctUntilChanged, map, pairwise } from 'rxjs';
import {
EuiButtonIcon,
EuiFlexGroup,
EuiFlexItem,
EuiText,
UseEuiTheme,
euiCanAnimate,
} from '@elastic/eui';
import { css } from '@emotion/react';
import { i18n } from '@kbn/i18n';
import { useGridLayoutContext } from '../use_grid_layout_context';
import { useGridLayoutRowEvents } from '../use_grid_layout_events';
import { deleteRow } from '../utils/row_management';
import { DeleteGridRowModal } from './delete_grid_row_modal';
import { GridRowDragPreview } from './grid_row_drag_preview';
import { GridRowTitle } from './grid_row_title';
export interface GridRowHeaderProps {
rowId: string;
toggleIsCollapsed: () => void;
collapseButtonRef: React.MutableRefObject<HTMLButtonElement | null>;
}
export const GridRowHeader = React.memo(
({ rowId, toggleIsCollapsed, collapseButtonRef }: GridRowHeaderProps) => {
const { gridLayoutStateManager } = useGridLayoutContext();
const { startDrag, onBlur } = useGridLayoutRowEvents({ rowId });
const [isActive, setIsActive] = useState<boolean>(false);
const [editTitleOpen, setEditTitleOpen] = useState<boolean>(false);
const [deleteModalVisible, setDeleteModalVisible] = useState<boolean>(false);
const [readOnly, setReadOnly] = useState<boolean>(
gridLayoutStateManager.accessMode$.getValue() === 'VIEW'
);
const [panelCount, setPanelCount] = useState<number>(
Object.keys(gridLayoutStateManager.gridLayout$.getValue()[rowId].panels).length
);
useEffect(() => {
return () => {
// remove reference on unmount
delete gridLayoutStateManager.headerRefs.current[rowId];
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
/**
* This subscription is responsible for controlling whether or not the section title is
* editable and hiding all other "edit mode" actions (delete section, move section, etc)
*/
const accessModeSubscription = gridLayoutStateManager.accessMode$
.pipe(distinctUntilChanged())
.subscribe((accessMode) => {
setReadOnly(accessMode === 'VIEW');
});
/**
* This subscription is responsible for keeping the panel count in sync
*/
const panelCountSubscription = gridLayoutStateManager.gridLayout$
.pipe(
map((layout) => Object.keys(layout[rowId]?.panels ?? {}).length),
distinctUntilChanged()
)
.subscribe((count) => {
setPanelCount(count);
});
/**
* This subscription is responsible for handling the drag + drop styles for
* re-ordering grid rows
*/
const dragRowStyleSubscription = gridLayoutStateManager.activeRowEvent$
.pipe(
pairwise(),
map(([before, after]) => {
if (!before && after) {
return { type: 'init', activeRowEvent: after };
} else if (before && after) {
return { type: 'update', activeRowEvent: after };
} else {
return { type: 'finish', activeRowEvent: before };
}
})
)
.subscribe(({ type, activeRowEvent }) => {
const headerRef = gridLayoutStateManager.headerRefs.current[rowId];
if (!headerRef || activeRowEvent?.id !== rowId) return;
if (type === 'init') {
setIsActive(true);
const width = headerRef.getBoundingClientRect().width;
headerRef.style.position = 'fixed';
headerRef.style.width = `${width}px`;
headerRef.style.top = `${activeRowEvent.startingPosition.top}px`;
headerRef.style.left = `${activeRowEvent.startingPosition.left}px`;
} else if (type === 'update') {
headerRef.style.transform = `translate(${activeRowEvent.translate.left}px, ${activeRowEvent.translate.top}px)`;
} else {
setIsActive(false);
headerRef.style.position = 'relative';
headerRef.style.width = ``;
headerRef.style.top = ``;
headerRef.style.left = ``;
headerRef.style.transform = ``;
}
});
return () => {
accessModeSubscription.unsubscribe();
panelCountSubscription.unsubscribe();
dragRowStyleSubscription.unsubscribe();
};
}, [gridLayoutStateManager, rowId]);
const confirmDeleteRow = useCallback(() => {
/**
* Memoization of this callback does not need to be dependant on the React panel count
* state, so just grab the panel count via gridLayoutStateManager instead
*/
const count = Object.keys(gridLayoutStateManager.gridLayout$.getValue()[rowId].panels).length;
if (!Boolean(count)) {
const newLayout = deleteRow(gridLayoutStateManager.gridLayout$.getValue(), rowId);
gridLayoutStateManager.gridLayout$.next(newLayout);
} else {
setDeleteModalVisible(true);
}
}, [gridLayoutStateManager.gridLayout$, rowId]);
return (
<>
<EuiFlexGroup
gutterSize="xs"
responsive={false}
alignItems="center"
css={styles.headerStyles}
className={classNames('kbnGridRowHeader', { 'kbnGridRowHeader--active': isActive })}
data-test-subj={`kbnGridRowHeader-${rowId}`}
ref={(element: HTMLDivElement | null) =>
(gridLayoutStateManager.headerRefs.current[rowId] = element)
}
>
<GridRowTitle
rowId={rowId}
readOnly={readOnly || isActive}
toggleIsCollapsed={toggleIsCollapsed}
editTitleOpen={editTitleOpen}
setEditTitleOpen={setEditTitleOpen}
collapseButtonRef={collapseButtonRef}
/>
{
/**
* Add actions at the end of the header section when the layout is editable + the section title
* is not in edit mode
*/
!editTitleOpen && (
<>
<EuiFlexItem grow={false} css={styles.visibleOnlyWhenCollapsed}>
<EuiText
color="subdued"
size="s"
data-test-subj={`kbnGridRowHeader-${rowId}--panelCount`}
className={'kbnGridLayout--panelCount'}
>
{i18n.translate('kbnGridLayout.rowHeader.panelCount', {
defaultMessage:
'({panelCount} {panelCount, plural, one {panel} other {panels}})',
values: {
panelCount,
},
})}
</EuiText>
</EuiFlexItem>
{!readOnly && (
<>
{!isActive && (
<EuiFlexItem grow={false}>
<EuiButtonIcon
iconType="trash"
color="danger"
className="kbnGridLayout--deleteRowIcon"
onClick={confirmDeleteRow}
aria-label={i18n.translate('kbnGridLayout.row.deleteRow', {
defaultMessage: 'Delete section',
})}
/>
</EuiFlexItem>
)}
<EuiFlexItem
grow={false}
css={[styles.floatToRight, styles.visibleOnlyWhenCollapsed]}
>
<EuiButtonIcon
iconType="move"
color="text"
className="kbnGridLayout--moveRowIcon"
aria-label={i18n.translate('kbnGridLayout.row.moveRow', {
defaultMessage: 'Move section',
})}
onMouseDown={startDrag}
onTouchStart={startDrag}
onKeyDown={startDrag}
onBlur={onBlur}
data-test-subj={`kbnGridRowHeader-${rowId}--dragHandle`}
/>
</EuiFlexItem>
</>
)}
</>
)
}
</EuiFlexGroup>
{isActive && <GridRowDragPreview rowId={rowId} />}
{deleteModalVisible && (
<DeleteGridRowModal rowId={rowId} setDeleteModalVisible={setDeleteModalVisible} />
)}
</>
);
}
);
const styles = {
visibleOnlyWhenCollapsed: css({
display: 'none',
'.kbnGridRowContainer--collapsed &': {
display: 'block',
},
}),
floatToRight: css({
marginLeft: 'auto',
}),
headerStyles: ({ euiTheme }: UseEuiTheme) =>
css({
height: `calc(${euiTheme.size.xl} + (2 * ${euiTheme.size.s}))`,
padding: `${euiTheme.size.s} 0px`,
border: '1px solid transparent', // prevents layout shift
'.kbnGridRowContainer--collapsed &:not(.kbnGridRowHeader--active)': {
borderBottom: euiTheme.border.thin,
},
'.kbnGridLayout--deleteRowIcon': {
marginLeft: euiTheme.size.xs,
},
'.kbnGridLayout--panelCount': {
textWrapMode: 'nowrap', // prevent panel count from wrapping
},
'.kbnGridLayout--moveRowIcon': {
cursor: 'move',
touchAction: 'none',
'&:active, &:hover, &:focus': {
transform: 'none !important', // prevent "bump up" that EUI adds on hover
backgroundColor: 'transparent',
},
},
// these styles hide the delete + move actions by default and only show them on hover
[`.kbnGridLayout--deleteRowIcon,
.kbnGridLayout--moveRowIcon`]: {
opacity: '0',
[`${euiCanAnimate}`]: {
transition: `opacity ${euiTheme.animation.extraFast} ease-in`,
},
},
[`&:hover .kbnGridLayout--deleteRowIcon,
&:hover .kbnGridLayout--moveRowIcon,
&:has(:focus-visible) .kbnGridLayout--deleteRowIcon,
&:has(:focus-visible) .kbnGridLayout--moveRowIcon`]: {
opacity: 1,
},
// these styles ensure that dragged rows are rendered **above** everything else + the move icon stays visible
'&.kbnGridRowHeader--active': {
zIndex: euiTheme.levels.modal,
'.kbnGridLayout--moveRowIcon': {
cursor: 'move',
opacity: 1,
pointerEvents: 'auto',
},
},
}),
};
GridRowHeader.displayName = 'GridRowHeader';

View file

@ -6,6 +6,7 @@
* 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 { cloneDeep } from 'lodash';
import React from 'react';
import {
@ -20,13 +21,14 @@ import {
import { i18n } from '@kbn/i18n';
import { useGridLayoutContext } from '../use_grid_layout_context';
import { deleteRow, movePanelsToRow } from '../utils/row_management';
import { deleteSection, isCollapsibleSection, resolveSections } from '../utils/section_management';
import { MainSection } from './types';
export const DeleteGridRowModal = ({
rowId,
export const DeleteGridSectionModal = ({
sectionId,
setDeleteModalVisible,
}: {
rowId: string;
sectionId: string;
setDeleteModalVisible: (visible: boolean) => void;
}) => {
const { gridLayoutStateManager } = useGridLayoutContext();
@ -39,13 +41,13 @@ export const DeleteGridRowModal = ({
>
<EuiModalHeader>
<EuiModalHeaderTitle>
{i18n.translate('kbnGridLayout.deleteGridRowModal.title', {
{i18n.translate('kbnGridLayout.deleteGridSectionModal.title', {
defaultMessage: 'Delete section',
})}
</EuiModalHeaderTitle>
</EuiModalHeader>
<EuiModalBody>
{i18n.translate('kbnGridLayout.deleteGridRowModal.body', {
{i18n.translate('kbnGridLayout.deleteGridSectionModal.body', {
defaultMessage:
'Choose to remove the section, including its contents, or only the section.',
})}
@ -56,7 +58,7 @@ export const DeleteGridRowModal = ({
setDeleteModalVisible(false);
}}
>
{i18n.translate('kbnGridLayout.deleteGridRowModal.cancelButton', {
{i18n.translate('kbnGridLayout.deleteGridSectionModal.cancelButton', {
defaultMessage: 'Cancel',
})}
</EuiButtonEmpty>
@ -64,33 +66,45 @@ export const DeleteGridRowModal = ({
onClick={() => {
setDeleteModalVisible(false);
const layout = gridLayoutStateManager.gridLayout$.getValue();
const firstRowId = Object.values(layout).find(({ order }) => order === 0)?.id;
if (!firstRowId) return;
let newLayout = movePanelsToRow(layout, rowId, firstRowId);
newLayout = deleteRow(newLayout, rowId);
gridLayoutStateManager.gridLayout$.next(newLayout);
const section = layout[sectionId];
if (!isCollapsibleSection(section)) return; // main sections are not user deletable
// convert collapsible section to main section so that panels remain in place
const newLayout = cloneDeep(layout);
const { title, isCollapsed, ...baseSectionProps } = section;
const sectionAsMain: MainSection = {
...baseSectionProps,
isMainSection: true,
};
newLayout[sectionId] = sectionAsMain;
gridLayoutStateManager.gridLayout$.next(resolveSections(newLayout));
}}
color="danger"
>
{i18n.translate('kbnGridLayout.deleteGridRowModal.confirmDeleteSection', {
{i18n.translate('kbnGridLayout.deleteGridSectionModal.confirmDeleteSection', {
defaultMessage: 'Delete section only',
})}
</EuiButton>
<EuiButton
onClick={() => {
setDeleteModalVisible(false);
const newLayout = deleteRow(gridLayoutStateManager.gridLayout$.getValue(), rowId);
const newLayout = deleteSection(
gridLayoutStateManager.gridLayout$.getValue(),
sectionId
);
gridLayoutStateManager.gridLayout$.next(newLayout);
}}
fill
color="danger"
>
{i18n.translate('kbnGridLayout.deleteGridRowModal.confirmDeleteAllPanels', {
{i18n.translate('kbnGridLayout.deleteGridSectionModal.confirmDeleteAllPanels', {
defaultMessage:
'Delete section and {panelCount} {panelCount, plural, one {panel} other {panels}}',
values: {
panelCount: Object.keys(gridLayoutStateManager.gridLayout$.getValue()[rowId].panels)
.length,
panelCount: Object.keys(
gridLayoutStateManager.gridLayout$.getValue()[sectionId].panels
).length,
},
})}
</EuiButton>

View file

@ -0,0 +1,59 @@
/*
* 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, { useEffect, useRef } from 'react';
import { skip } from 'rxjs';
import { UseEuiTheme } from '@elastic/eui';
import { css } from '@emotion/react';
import { useGridLayoutContext } from '../use_grid_layout_context';
export const GridSectionDragPreview = React.memo(() => {
const { gridLayoutStateManager } = useGridLayoutContext();
const dragPreviewRef = useRef<HTMLDivElement | null>(null);
useEffect(
() => {
/** Update the styles of the drag preview via a subscription to prevent re-renders */
const styleSubscription = gridLayoutStateManager.activeSectionEvent$
.pipe(skip(1)) // skip the first emit because the drag preview is only rendered after a user action
.subscribe((activeRow) => {
if (!dragPreviewRef.current) return;
const sectionId = activeRow?.id;
if (!activeRow || !sectionId) {
dragPreviewRef.current.style.display = 'none';
} else {
dragPreviewRef.current.style.display = 'block';
dragPreviewRef.current.style.gridColumnStart = `1`;
dragPreviewRef.current.style.gridColumnEnd = `-1`;
dragPreviewRef.current.style.gridRowEnd = `start-${sectionId}`;
dragPreviewRef.current.style.gridRowStart = `span 1`;
}
});
return () => {
styleSubscription.unsubscribe();
};
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[]
);
return <div ref={dragPreviewRef} className={'kbnGridSection--dragPreview'} css={styles} />;
});
const styles = ({ euiTheme }: UseEuiTheme) =>
css({
display: 'none',
minHeight: euiTheme.size.xl,
position: 'relative',
});
GridSectionDragPreview.displayName = 'KbnGridLayoutSectionDragPreview';

View file

@ -0,0 +1,58 @@
/*
* 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, { useEffect, useMemo, useRef } from 'react';
import { skip } from 'rxjs';
import { css } from '@emotion/react';
import { useGridLayoutContext } from '../use_grid_layout_context';
export interface GridSectionProps {
sectionId: string;
}
export const GridSectionFooter = React.memo(({ sectionId }: GridSectionProps) => {
const { gridLayoutStateManager } = useGridLayoutContext();
const ref = useRef<HTMLDivElement | null>(null);
const styles = useMemo(
() =>
css({
gridColumnStart: 1,
gridColumnEnd: -1,
gridRowEnd: `span 1`,
gridRowStart: `end-${sectionId}`,
}),
[sectionId]
);
useEffect(() => {
/** Update the styles of the drag preview via a subscription to prevent re-renders */
const styleSubscription = gridLayoutStateManager.activePanelEvent$
.pipe(skip(1)) // skip the first emit because the drag preview is only rendered after a user action
.subscribe((activePanel) => {
if (!ref.current) return;
const isTargeted = sectionId === activePanel?.targetSection;
if (isTargeted) {
ref.current.classList.add('kbnGridSectionFooter--targeted');
} else {
ref.current.classList.remove('kbnGridSectionFooter--targeted');
}
});
return () => {
styleSubscription.unsubscribe();
};
}, [sectionId, gridLayoutStateManager.activePanelEvent$]);
return <div ref={ref} className={'kbnGridSectionFooter'} css={styles} />;
});
GridSectionFooter.displayName = 'KbnGridLayoutSectionFooter';

View file

@ -0,0 +1,143 @@
/*
* 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 from 'react';
import { EuiThemeProvider } from '@elastic/eui';
import { RenderResult, act, render, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { getGridLayoutStateManagerMock, mockRenderPanelContents } from '../test_utils/mocks';
import { GridLayoutContext, GridLayoutContextType } from '../use_grid_layout_context';
import { GridSectionHeader, GridSectionHeaderProps } from './grid_section_header';
import { CollapsibleSection } from './types';
describe('GridSectionHeader', () => {
const renderGridSectionHeader = (
propsOverrides: Partial<GridSectionHeaderProps> = {},
contextOverrides: Partial<GridLayoutContextType> = {}
) => {
const stateManagerMock = getGridLayoutStateManagerMock();
return {
component: render(
<GridLayoutContext.Provider
value={
{
renderPanelContents: mockRenderPanelContents,
gridLayoutStateManager: stateManagerMock,
...contextOverrides,
} as GridLayoutContextType
}
>
<GridSectionHeader sectionId={'second'} {...propsOverrides} />
</GridLayoutContext.Provider>,
{ wrapper: EuiThemeProvider }
),
gridLayoutStateManager: stateManagerMock,
};
};
it('renders the panel count', async () => {
const { component, gridLayoutStateManager } = renderGridSectionHeader();
const initialCount = component.getByTestId('kbnGridSectionHeader-second--panelCount');
expect(initialCount.textContent).toBe('(1 panel)');
const currentLayout = gridLayoutStateManager.gridLayout$.getValue();
act(() => {
const currentRow = currentLayout.second;
gridLayoutStateManager.gridLayout$.next({
...currentLayout,
second: {
...currentRow,
panels: currentLayout['main-0'].panels,
},
});
});
await waitFor(() => {
const updatedCount = component.getByTestId('kbnGridSectionHeader-second--panelCount');
expect(updatedCount.textContent).toBe('(8 panels)');
});
});
it('clicking title toggles collapsed state`', async () => {
const { component, gridLayoutStateManager } = renderGridSectionHeader();
const title = component.getByTestId('kbnGridSectionTitle-second');
expect(
(gridLayoutStateManager.gridLayout$.getValue().second as CollapsibleSection).isCollapsed
).toBe(false);
await userEvent.click(title);
expect(
(gridLayoutStateManager.gridLayout$.getValue().second as CollapsibleSection).isCollapsed
).toBe(true);
});
describe('title editor', () => {
const setTitle = async (component: RenderResult) => {
const input = component.getByTestId('euiInlineEditModeInput');
expect(input.getAttribute('value')).toBe('Small section');
await userEvent.click(input);
await userEvent.keyboard(' 123');
expect(input.getAttribute('value')).toBe('Small section 123');
};
it('clicking on edit icon triggers inline title editor and does not toggle collapsed', async () => {
const { component, gridLayoutStateManager } = renderGridSectionHeader();
const editIcon = component.getByTestId('kbnGridSectionTitle-second--edit');
expect(component.queryByTestId('kbnGridSectionTitle-second--editor')).not.toBeInTheDocument();
expect(
(gridLayoutStateManager.gridLayout$.getValue().second as CollapsibleSection).isCollapsed
).toBe(false);
await userEvent.click(editIcon);
expect(component.getByTestId('kbnGridSectionTitle-second--editor')).toBeInTheDocument();
expect(
(gridLayoutStateManager.gridLayout$.getValue().second as CollapsibleSection).isCollapsed
).toBe(false);
});
it('can update the title', async () => {
const { component, gridLayoutStateManager } = renderGridSectionHeader();
expect(component.getByTestId('kbnGridSectionTitle-second').textContent).toBe('Small section');
expect(
(gridLayoutStateManager.gridLayout$.getValue().second as CollapsibleSection).title
).toBe('Small section');
const editIcon = component.getByTestId('kbnGridSectionTitle-second--edit');
await userEvent.click(editIcon);
await setTitle(component);
const saveButton = component.getByTestId('euiInlineEditModeSaveButton');
await userEvent.click(saveButton);
expect(component.queryByTestId('kbnGridSectionTitle-second--editor')).not.toBeInTheDocument();
expect(component.getByTestId('kbnGridSectionTitle-second').textContent).toBe(
'Small section 123'
);
expect(
(gridLayoutStateManager.gridLayout$.getValue().second as CollapsibleSection).title
).toBe('Small section 123');
});
it('clicking on cancel closes the inline title editor without updating title', async () => {
const { component, gridLayoutStateManager } = renderGridSectionHeader();
const editIcon = component.getByTestId('kbnGridSectionTitle-second--edit');
await userEvent.click(editIcon);
await setTitle(component);
const cancelButton = component.getByTestId('euiInlineEditModeCancelButton');
await userEvent.click(cancelButton);
expect(component.queryByTestId('kbnGridSectionTitle-second--editor')).not.toBeInTheDocument();
expect(component.getByTestId('kbnGridSectionTitle-second').textContent).toBe('Small section');
expect(
(gridLayoutStateManager.gridLayout$.getValue().second as CollapsibleSection).title
).toBe('Small section');
});
});
});

View file

@ -0,0 +1,338 @@
/*
* 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 classNames from 'classnames';
import { cloneDeep } from 'lodash';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { distinctUntilChanged, map, pairwise } from 'rxjs';
import {
EuiButtonIcon,
EuiFlexGroup,
EuiFlexItem,
EuiText,
UseEuiTheme,
euiCanAnimate,
} from '@elastic/eui';
import { css } from '@emotion/react';
import { i18n } from '@kbn/i18n';
import { useGridLayoutContext } from '../use_grid_layout_context';
import { useGridLayoutSectionEvents } from '../use_grid_layout_events';
import { deleteSection } from '../utils/section_management';
import { DeleteGridSectionModal } from './delete_grid_section_modal';
import { GridSectionTitle } from './grid_section_title';
import { CollapsibleSection } from './types';
export interface GridSectionHeaderProps {
sectionId: string;
}
export const GridSectionHeader = React.memo(({ sectionId }: GridSectionHeaderProps) => {
const collapseButtonRef = useRef<HTMLButtonElement | null>(null);
const { gridLayoutStateManager } = useGridLayoutContext();
const startDrag = useGridLayoutSectionEvents({ sectionId });
const [isActive, setIsActive] = useState<boolean>(false);
const [editTitleOpen, setEditTitleOpen] = useState<boolean>(false);
const [deleteModalVisible, setDeleteModalVisible] = useState<boolean>(false);
const [readOnly, setReadOnly] = useState<boolean>(
gridLayoutStateManager.accessMode$.getValue() === 'VIEW'
);
const [panelCount, setPanelCount] = useState<number>(
Object.keys(gridLayoutStateManager.gridLayout$.getValue()[sectionId]?.panels ?? {}).length
);
useEffect(() => {
return () => {
// remove reference on unmount
delete gridLayoutStateManager.headerRefs.current[sectionId];
};
}, [sectionId, gridLayoutStateManager]);
useEffect(() => {
/**
* This subscription is responsible for controlling whether or not the section title is
* editable and hiding all other "edit mode" actions (delete section, move section, etc)
*/
const accessModeSubscription = gridLayoutStateManager.accessMode$
.pipe(distinctUntilChanged())
.subscribe((accessMode) => {
setReadOnly(accessMode === 'VIEW');
});
/**
* This subscription is responsible for keeping the panel count in sync
*/
const panelCountSubscription = gridLayoutStateManager.gridLayout$
.pipe(
map((layout) => Object.keys(layout[sectionId]?.panels ?? {}).length),
distinctUntilChanged()
)
.subscribe((count) => {
setPanelCount(count);
});
/**
* This subscription is responsible for handling the drag + drop styles for
* re-ordering grid rows
*/
const dragRowStyleSubscription = gridLayoutStateManager.activeSectionEvent$
.pipe(
pairwise(),
map(([before, after]) => {
if (!before && after) {
return { type: 'init', activeSectionEvent: after };
} else if (before && after) {
return { type: 'update', activeSectionEvent: after };
} else {
return { type: 'finish', activeSectionEvent: before };
}
})
)
.subscribe(({ type, activeSectionEvent }) => {
const headerRef = gridLayoutStateManager.headerRefs.current[sectionId];
if (!headerRef || activeSectionEvent?.id !== sectionId) return;
if (type === 'init') {
setIsActive(true);
const width = headerRef.getBoundingClientRect().width;
headerRef.style.position = 'fixed';
headerRef.style.width = `${width}px`;
headerRef.style.top = `${activeSectionEvent.startingPosition.top}px`;
headerRef.style.left = `${activeSectionEvent.startingPosition.left}px`;
} else if (type === 'update') {
headerRef.style.transform = `translate(${activeSectionEvent.translate.left}px, ${activeSectionEvent.translate.top}px)`;
} else {
setIsActive(false);
headerRef.style.position = ``;
headerRef.style.width = ``;
headerRef.style.top = ``;
headerRef.style.left = ``;
headerRef.style.transform = ``;
}
});
/**
* This subscription is responsible for setting the collapsed state class name
*/
const collapsedStateSubscription = gridLayoutStateManager.gridLayout$
.pipe(
map((gridLayout) => {
const row = gridLayout[sectionId];
return row && (row.isMainSection || row.isCollapsed);
})
)
.subscribe((collapsed) => {
const headerRef = gridLayoutStateManager.headerRefs.current[sectionId];
if (!headerRef) return;
if (collapsed) {
headerRef.classList.add('kbnGridSectionHeader--collapsed');
} else {
headerRef.classList.remove('kbnGridSectionHeader--collapsed');
}
});
return () => {
accessModeSubscription.unsubscribe();
panelCountSubscription.unsubscribe();
dragRowStyleSubscription.unsubscribe();
collapsedStateSubscription.unsubscribe();
};
}, [gridLayoutStateManager, sectionId]);
const confirmDeleteSection = useCallback(() => {
/**
* Memoization of this callback does not need to be dependant on the React panel count
* state, so just grab the panel count via gridLayoutStateManager instead
*/
const count = Object.keys(
gridLayoutStateManager.gridLayout$.getValue()[sectionId].panels
).length;
if (!Boolean(count)) {
const newLayout = deleteSection(gridLayoutStateManager.gridLayout$.getValue(), sectionId);
gridLayoutStateManager.gridLayout$.next(newLayout);
} else {
setDeleteModalVisible(true);
}
}, [gridLayoutStateManager, sectionId]);
/**
* Callback for collapsing and/or expanding the section when the title button is clicked
*/
const toggleIsCollapsed = useCallback(() => {
const newLayout = cloneDeep(gridLayoutStateManager.gridLayout$.value);
const section = newLayout[sectionId];
if (section.isMainSection) return;
section.isCollapsed = !section.isCollapsed;
gridLayoutStateManager.gridLayout$.next(newLayout);
}, [gridLayoutStateManager, sectionId]);
return (
<>
<EuiFlexGroup
gutterSize="xs"
responsive={false}
alignItems="center"
css={(theme) => styles.headerStyles(theme, sectionId)}
className={classNames('kbnGridSectionHeader', {
'kbnGridSectionHeader--active': isActive,
// sets the collapsed state on mount
'kbnGridSectionHeader--collapsed': (
gridLayoutStateManager.gridLayout$.getValue()[sectionId] as
| CollapsibleSection
| undefined
)?.isCollapsed,
})}
data-test-subj={`kbnGridSectionHeader-${sectionId}`}
ref={(element: HTMLDivElement | null) => {
gridLayoutStateManager.headerRefs.current[sectionId] = element;
}}
>
<GridSectionTitle
sectionId={sectionId}
readOnly={readOnly || isActive}
toggleIsCollapsed={toggleIsCollapsed}
editTitleOpen={editTitleOpen}
setEditTitleOpen={setEditTitleOpen}
collapseButtonRef={collapseButtonRef}
/>
{
/**
* Add actions at the end of the header section when the layout is editable + the section title
* is not in edit mode
*/
!editTitleOpen && (
<>
<EuiFlexItem grow={false} css={styles.visibleOnlyWhenCollapsed}>
<EuiText
color="subdued"
size="s"
data-test-subj={`kbnGridSectionHeader-${sectionId}--panelCount`}
className={'kbnGridLayout--panelCount'}
>
{i18n.translate('kbnGridLayout.section.panelCount', {
defaultMessage:
'({panelCount} {panelCount, plural, one {panel} other {panels}})',
values: {
panelCount,
},
})}
</EuiText>
</EuiFlexItem>
{!readOnly && (
<>
{!isActive && (
<EuiFlexItem grow={false}>
<EuiButtonIcon
iconType="trash"
color="danger"
className="kbnGridLayout--deleteSectionIcon"
onClick={confirmDeleteSection}
aria-label={i18n.translate('kbnGridLayout.section.deleteSection', {
defaultMessage: 'Delete section',
})}
/>
</EuiFlexItem>
)}
<EuiFlexItem
grow={false}
css={[styles.floatToRight, styles.visibleOnlyWhenCollapsed]}
>
<EuiButtonIcon
iconType="move"
color="text"
className="kbnGridSection--dragHandle"
aria-label={i18n.translate('kbnGridLayout.section.moveRow', {
defaultMessage: 'Move section',
})}
onMouseDown={startDrag}
onTouchStart={startDrag}
onKeyDown={startDrag}
data-test-subj={`kbnGridSectionHeader-${sectionId}--dragHandle`}
/>
</EuiFlexItem>
</>
)}
</>
)
}
</EuiFlexGroup>
{deleteModalVisible && (
<DeleteGridSectionModal
sectionId={sectionId}
setDeleteModalVisible={setDeleteModalVisible}
/>
)}
</>
);
});
const styles = {
visibleOnlyWhenCollapsed: css({
display: 'none',
'.kbnGridSectionHeader--collapsed &': {
display: 'block',
},
}),
floatToRight: css({
marginLeft: 'auto',
}),
headerStyles: ({ euiTheme }: UseEuiTheme, sectionId: string) =>
css({
gridColumnStart: 1,
gridColumnEnd: -1,
gridRowStart: `span 1`,
gridRowEnd: `start-${sectionId}`,
height: `${euiTheme.size.xl}`,
'.kbnGridLayout--deleteSectionIcon': {
marginLeft: euiTheme.size.xs,
},
'.kbnGridLayout--panelCount': {
textWrapMode: 'nowrap', // prevent panel count from wrapping
},
'.kbnGridSection--dragHandle': {
cursor: 'move',
touchAction: 'none',
'&:active, &:hover, &:focus': {
transform: 'none !important', // prevent "bump up" that EUI adds on hover
backgroundColor: 'transparent',
},
},
// these styles hide the delete + move actions by default and only show them on hover
[`.kbnGridLayout--deleteSectionIcon,
.kbnGridSection--dragHandle`]: {
opacity: '0',
[`${euiCanAnimate}`]: {
transition: `opacity ${euiTheme.animation.extraFast} ease-in`,
},
},
[`&:hover .kbnGridLayout--deleteSectionIcon,
&:hover .kbnGridSection--dragHandle,
&:has(:focus-visible) .kbnGridLayout--deleteSectionIcon,
&:has(:focus-visible) .kbnGridSection--dragHandle`]: {
opacity: 1,
},
// these styles ensure that dragged sections are rendered **above** everything else + the move icon stays visible
'&.kbnGridSectionHeader--active': {
zIndex: euiTheme.levels.modal,
'.kbnGridSection--dragHandle': {
cursor: 'move',
opacity: 1,
pointerEvents: 'auto',
},
},
}),
};
GridSectionHeader.displayName = 'GridSectionHeader';

View file

@ -18,22 +18,23 @@ import {
EuiTitle,
UseEuiTheme,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { css } from '@emotion/react';
import { i18n } from '@kbn/i18n';
import { useGridLayoutContext } from '../use_grid_layout_context';
import type { CollapsibleSection } from './types';
export const GridRowTitle = React.memo(
export const GridSectionTitle = React.memo(
({
readOnly,
rowId,
sectionId,
editTitleOpen,
setEditTitleOpen,
toggleIsCollapsed,
collapseButtonRef,
}: {
readOnly: boolean;
rowId: string;
sectionId: string;
editTitleOpen: boolean;
setEditTitleOpen: (value: boolean) => void;
toggleIsCollapsed: () => void;
@ -42,8 +43,15 @@ export const GridRowTitle = React.memo(
const { gridLayoutStateManager } = useGridLayoutContext();
const inputRef = useRef<HTMLInputElement | null>(null);
const currentRow = gridLayoutStateManager.gridLayout$.getValue()[rowId];
const [rowTitle, setRowTitle] = useState<string>(currentRow.title);
/**
* This element should never be rendered for "main" sections, so casting to CollapsibleSection
* is safe; however, if this somehow **did** happen for a main section, we would fall back to
* an empty string for the title (which should be fine)
*/
const currentSection = gridLayoutStateManager.gridLayout$.value[sectionId] as
| CollapsibleSection
| undefined;
const [sectionTitle, setSectionTitle] = useState<string>(currentSection?.title ?? '');
useEffect(() => {
/**
@ -51,17 +59,21 @@ export const GridRowTitle = React.memo(
*/
const titleSubscription = gridLayoutStateManager.gridLayout$
.pipe(
map((gridLayout) => gridLayout[rowId]?.title ?? ''),
map((gridLayout) => {
const section = gridLayout[sectionId];
if (!section || section.isMainSection) return ''; // main sections cannot have titles
return section.title;
}),
distinctUntilChanged()
)
.subscribe((title) => {
setRowTitle(title);
setSectionTitle(title);
});
return () => {
titleSubscription.unsubscribe();
};
}, [rowId, gridLayoutStateManager]);
}, [sectionId, gridLayoutStateManager]);
useEffect(() => {
/**
@ -75,11 +87,13 @@ export const GridRowTitle = React.memo(
const updateTitle = useCallback(
(title: string) => {
const newLayout = cloneDeep(gridLayoutStateManager.gridLayout$.getValue());
newLayout[rowId].title = title;
const section = newLayout[sectionId];
if (section.isMainSection) return; // main sections cannot have titles
section.title = title;
gridLayoutStateManager.gridLayout$.next(newLayout);
setEditTitleOpen(false);
},
[rowId, setEditTitleOpen, gridLayoutStateManager.gridLayout$]
[sectionId, setEditTitleOpen, gridLayoutStateManager.gridLayout$]
);
return (
@ -88,22 +102,22 @@ export const GridRowTitle = React.memo(
<EuiButtonEmpty
buttonRef={collapseButtonRef}
color="text"
aria-label={i18n.translate('kbnGridLayout.row.toggleCollapse', {
aria-label={i18n.translate('kbnGridLayout.section.toggleCollapse', {
defaultMessage: 'Toggle collapse',
})}
iconType={'arrowDown'}
onClick={toggleIsCollapsed}
size="m"
id={`kbnGridRowTitle-${rowId}`}
aria-controls={`kbnGridRow-${rowId}`}
data-test-subj={`kbnGridRowTitle-${rowId}`}
id={`kbnGridSectionTitle-${sectionId}`}
aria-controls={`kbnGridSection-${sectionId}`}
data-test-subj={`kbnGridSectionTitle-${sectionId}`}
textProps={false}
className={'kbnGridRowTitle--button'}
className={'kbnGridSectionTitle--button'}
flush="both"
>
{editTitleOpen ? null : (
<EuiTitle size="xs">
<h2>{rowTitle}</h2>
<h2>{sectionTitle}</h2>
</EuiTitle>
)}
</EuiButtonEmpty>
@ -114,17 +128,17 @@ export const GridRowTitle = React.memo(
<EuiInlineEditTitle
size="xs"
heading="h2"
defaultValue={rowTitle}
defaultValue={sectionTitle}
onSave={updateTitle}
onCancel={() => setEditTitleOpen(false)}
startWithEditOpen
editModeProps={{
inputProps: { inputRef },
}}
inputAriaLabel={i18n.translate('kbnGridLayout.row.editTitleAriaLabel', {
inputAriaLabel={i18n.translate('kbnGridLayout.section.editTitleAriaLabel', {
defaultMessage: 'Edit section title',
})}
data-test-subj={`kbnGridRowTitle-${rowId}--editor`}
data-test-subj={`kbnGridSectionTitle-${sectionId}--editor`}
/>
</EuiFlexItem>
) : (
@ -135,10 +149,10 @@ export const GridRowTitle = React.memo(
iconType="pencil"
onClick={() => setEditTitleOpen(true)}
color="text"
aria-label={i18n.translate('kbnGridLayout.row.editRowTitle', {
aria-label={i18n.translate('kbnGridLayout.section.editTitleAriaLabel', {
defaultMessage: 'Edit section title',
})}
data-test-subj={`kbnGridRowTitle-${rowId}--edit`}
data-test-subj={`kbnGridSectionTitle-${sectionId}--edit`}
/>
</EuiFlexItem>
)}
@ -164,7 +178,7 @@ const styles = {
svg: {
transition: `transform ${euiTheme.animation.fast} ease`,
transform: 'rotate(0deg)',
'.kbnGridRowContainer--collapsed &': {
'.kbnGridSectionHeader--collapsed &': {
transform: 'rotate(-90deg) !important',
},
},
@ -185,4 +199,4 @@ const styles = {
}),
};
GridRowTitle.displayName = 'GridRowTitle';
GridSectionTitle.displayName = 'GridSectionTitle';

View file

@ -0,0 +1,93 @@
/*
* 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, { useEffect, useMemo } from 'react';
import { css } from '@emotion/react';
import { useGridLayoutContext } from '../use_grid_layout_context';
export interface GridSectionProps {
sectionId: string;
}
/**
* This component "wraps" all the panels in a given section and it is used to:
* 1. Apply styling to a targeted section via the `kbnGridSection--targeted` class name
* 2. Apply styling to sections where dropping is blocked via the `kbnGridSection--blocked` class name
* 3. The ref to this component is used to figure out which section is being targeted
*/
export const GridSectionWrapper = React.memo(({ sectionId }: GridSectionProps) => {
const { gridLayoutStateManager } = useGridLayoutContext();
const styles = useMemo(() => {
return css({
gridColumn: `1 / -1`,
gridRowStart: `start-${sectionId}`,
gridRowEnd: `end-${sectionId}`,
});
}, [sectionId]);
useEffect(() => {
return () => {
// remove reference on unmount
delete gridLayoutStateManager.sectionRefs.current[sectionId];
};
}, [sectionId, gridLayoutStateManager]);
useEffect(
() => {
/** Update the styles of the grid row via a subscription to prevent re-renders */
const panelInteractionStyleSubscription = gridLayoutStateManager.activePanelEvent$.subscribe(
(activePanel) => {
const rowRef = gridLayoutStateManager.sectionRefs.current[sectionId];
if (!rowRef) return;
const targetSection = activePanel?.targetSection;
if (sectionId === targetSection && activePanel) {
rowRef.classList.add('kbnGridSection--targeted');
} else {
rowRef.classList.remove('kbnGridSection--targeted');
}
}
);
const sectionInteractionStyleSubscription =
gridLayoutStateManager.activeSectionEvent$.subscribe((activeSection) => {
const rowRef = gridLayoutStateManager.sectionRefs.current[sectionId];
if (!rowRef) return;
const targetSection = activeSection?.targetSection;
const layout = gridLayoutStateManager.gridLayout$.getValue();
if (sectionId === targetSection && !layout[sectionId].isMainSection) {
rowRef.classList.add('kbnGridSection--blocked');
} else {
rowRef.classList.remove('kbnGridSection--blocked');
}
});
return () => {
panelInteractionStyleSubscription.unsubscribe();
sectionInteractionStyleSubscription.unsubscribe();
};
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[sectionId]
);
return (
<span
css={styles}
ref={(rowRef: HTMLDivElement | null) => {
gridLayoutStateManager.sectionRefs.current[sectionId] = rowRef;
}}
className={'kbnGridSection'}
/>
);
});
GridSectionWrapper.displayName = 'KbnGridLayoutSectionwWrapper';

View file

@ -7,4 +7,9 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
export { GridRow } from './grid_row';
export { GridSectionHeader } from './grid_section_header';
export { GridSectionWrapper } from './grid_section_wrapper';
export { GridSectionFooter } from './grid_section_footer';
export { GridSectionDragPreview } from './grid_section_drag_preview';
export type { GridSectionData, MainSection, CollapsibleSection, ActiveSectionEvent } from './types';

View file

@ -0,0 +1,53 @@
/*
* 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 { GridPanelData } from '../grid_panel';
export interface GridSectionData {
id: string;
row: number; // position of section in main grid
title: string;
isCollapsed: boolean;
panels: {
[key: string]: GridPanelData;
};
}
export interface ActiveSectionEvent {
id: string;
targetSection?: string;
sensorType: 'mouse' | 'touch' | 'keyboard';
startingPosition: {
top: number;
left: number;
};
translate: {
top: number;
left: number;
};
}
/**
* MainSections are rendered without headers, which means they are non-collapsible and don't have
* titles; these are "runtime" sections that do not get translated to the output GridLayoutData, since
* all "widgets" of type `panel` get sent into these "fake" sections
*/
export type MainSection = Omit<GridSectionData, 'row' | 'isCollapsed' | 'title'> & {
order: number;
isMainSection: true;
};
/**
* Collapsible sections correspond to the `section` widget type in `GridLayoutData` - they are
* collapsible, have titles, can be re-ordered, etc.
*/
export type CollapsibleSection = Omit<GridSectionData, 'row'> & {
order: number;
isMainSection: false;
};

View file

@ -60,50 +60,36 @@ describe('Keyboard navigation', () => {
renderGridLayout();
const panelHandle = getPanelHandle('panel1');
panelHandle.focus();
expect(screen.getByLabelText('panelId:panel1').closest('div')).toHaveClass(
'kbnGridPanel css-c5ixg-initialStyles',
{
exact: true,
}
expect(screen.getByLabelText('panelId:panel1').closest('div')).not.toHaveClass(
'kbnGridPanel--active'
);
await pressEnter();
await pressKey('[ArrowDown]');
expect(panelHandle).toHaveFocus(); // focus is not lost during interaction
expect(screen.getByLabelText('panelId:panel1').closest('div')).toHaveClass(
'kbnGridPanel kbnGridPanel--active css-c5ixg-initialStyles',
{ exact: true }
'kbnGridPanel--active'
);
await pressEnter();
expect(screen.getByLabelText('panelId:panel1').closest('div')).toHaveClass(
'kbnGridPanel css-c5ixg-initialStyles',
{
exact: true,
}
expect(screen.getByLabelText('panelId:panel1').closest('div')).not.toHaveClass(
'kbnGridPanel--active'
);
});
it('should show the panel active when during interaction for resize handle', async () => {
renderGridLayout();
const panelHandle = getPanelHandle('panel5', 'resize');
panelHandle.focus();
expect(screen.getByLabelText('panelId:panel5').closest('div')).toHaveClass(
'kbnGridPanel css-1l7q1xe-initialStyles',
{
exact: true,
}
expect(screen.getByLabelText('panelId:panel5').closest('div')).not.toHaveClass(
'kbnGridPanel--active'
);
await pressEnter();
await pressKey('[ArrowDown]');
expect(panelHandle).toHaveFocus(); // focus is not lost during interaction
expect(screen.getByLabelText('panelId:panel5').closest('div')).toHaveClass(
'kbnGridPanel css-1l7q1xe-initialStyles kbnGridPanel--active',
{ exact: true }
'kbnGridPanel--active'
);
await pressKey('{Escape}');
expect(screen.getByLabelText('panelId:panel5').closest('div')).toHaveClass(
'kbnGridPanel css-1l7q1xe-initialStyles',
{
exact: true,
}
expect(screen.getByLabelText('panelId:panel5').closest('div')).not.toHaveClass(
'kbnGridPanel--active'
);
expect(panelHandle).toHaveFocus(); // focus is not lost during interaction
});

View file

@ -9,17 +9,18 @@
import React from 'react';
import { BehaviorSubject } from 'rxjs';
import { ObservedSize } from 'use-resize-observer/polyfilled';
import {
ActivePanel,
ActiveRowEvent,
import type { ObservedSize } from 'use-resize-observer/polyfilled';
import type { ActivePanelEvent } from '../grid_panel';
import type { ActiveSectionEvent } from '../grid_section';
import type {
GridAccessMode,
GridLayoutData,
GridLayoutStateManager,
PanelInteractionEvent,
OrderedLayout,
RuntimeGridSettings,
} from '../types';
import { getSampleLayout } from './sample_layout';
import { getSampleOrderedLayout } from './sample_layout';
const DASHBOARD_MARGIN_SIZE = 8;
const DASHBOARD_GRID_HEIGHT = 20;
@ -39,7 +40,7 @@ export const getGridLayoutStateManagerMock = (overrides?: Partial<GridLayoutStat
layoutRef: { current: {} },
expandedPanelId$: new BehaviorSubject<string | undefined>(undefined),
isMobileView$: new BehaviorSubject<boolean>(false),
gridLayout$: new BehaviorSubject<GridLayoutData>(getSampleLayout()),
gridLayout$: new BehaviorSubject<OrderedLayout>(getSampleOrderedLayout()),
proposedGridLayout$: new BehaviorSubject<GridLayoutData | undefined>(undefined),
runtimeSettings$: new BehaviorSubject<RuntimeGridSettings>({
...gridSettings,
@ -47,12 +48,11 @@ export const getGridLayoutStateManagerMock = (overrides?: Partial<GridLayoutStat
keyboardDragTopLimit: 0,
}),
panelRefs: { current: {} },
rowRefs: { current: {} },
sectionRefs: { current: {} },
headerRefs: { current: {} },
accessMode$: new BehaviorSubject<GridAccessMode>('EDIT'),
interactionEvent$: new BehaviorSubject<PanelInteractionEvent | undefined>(undefined),
activePanel$: new BehaviorSubject<ActivePanel | undefined>(undefined),
activeRowEvent$: new BehaviorSubject<ActiveRowEvent | undefined>(undefined),
activePanelEvent$: new BehaviorSubject<ActivePanelEvent | undefined>(undefined),
activeSectionEvent$: new BehaviorSubject<ActiveSectionEvent | undefined>(undefined),
gridDimensions$: new BehaviorSubject<ObservedSize>({ width: 600, height: 900 }),
...overrides,
} as GridLayoutStateManager;

View file

@ -7,14 +7,112 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { GridLayoutData } from '../types';
import { GridLayoutData, OrderedLayout } from '../types';
export const getSampleLayout = (): GridLayoutData => ({
first: {
title: 'Large section',
panel1: {
id: 'panel1',
row: 0,
column: 0,
width: 12,
height: 6,
type: 'panel',
},
panel2: {
id: 'panel2',
row: 6,
column: 0,
width: 8,
height: 4,
type: 'panel',
},
panel3: {
id: 'panel3',
row: 6,
column: 8,
width: 12,
height: 4,
type: 'panel',
},
panel4: {
id: 'panel4',
row: 10,
column: 0,
width: 48,
height: 4,
type: 'panel',
},
panel5: {
id: 'panel5',
row: 0,
column: 12,
width: 36,
height: 6,
type: 'panel',
},
panel6: {
id: 'panel6',
row: 6,
column: 24,
width: 24,
height: 4,
type: 'panel',
},
panel7: {
id: 'panel7',
row: 6,
column: 20,
width: 4,
height: 2,
type: 'panel',
},
panel8: {
id: 'panel8',
row: 8,
column: 20,
width: 4,
height: 2,
type: 'panel',
},
second: {
title: 'Small section',
isCollapsed: false,
id: 'first',
id: 'second',
type: 'section',
row: 14,
panels: {
panel9: {
id: 'panel9',
row: 0,
column: 0,
width: 12,
height: 16,
},
},
},
third: {
title: 'Another small section',
isCollapsed: false,
id: 'third',
type: 'section',
row: 15,
panels: {
panel10: {
id: 'panel10',
row: 0,
column: 24,
width: 12,
height: 6,
},
},
},
});
export const getSampleOrderedLayout = (): OrderedLayout => ({
'main-0': {
id: 'main-0',
order: 0,
isMainSection: true,
panels: {
panel1: {
id: 'panel1',
@ -78,6 +176,7 @@ export const getSampleLayout = (): GridLayoutData => ({
title: 'Small section',
isCollapsed: false,
id: 'second',
isMainSection: false,
order: 1,
panels: {
panel9: {
@ -93,6 +192,7 @@ export const getSampleLayout = (): GridLayoutData => ({
title: 'Another small section',
isCollapsed: false,
id: 'third',
isMainSection: false,
order: 2,
panels: {
panel10: {

View file

@ -7,36 +7,19 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { BehaviorSubject } from 'rxjs';
import { BehaviorSubject, Observable } from 'rxjs';
import type { ObservedSize } from 'use-resize-observer/polyfilled';
import type { ActivePanelEvent, GridPanelData } from './grid_panel';
import type {
ActiveSectionEvent,
CollapsibleSection,
GridSectionData,
MainSection,
} from './grid_section';
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 {
id: string;
order: number;
title: string;
isCollapsed: boolean;
panels: {
[key: string]: GridPanelData;
};
}
export interface GridLayoutData {
[rowId: string]: GridRowData;
}
/**
* The settings for how the grid should be rendered
*/
export interface GridSettings {
gutterSize: number;
rowHeight: number;
@ -51,84 +34,44 @@ export interface GridSettings {
*/
export type RuntimeGridSettings = GridSettings & { columnPixelWidth: number };
export interface ActivePanel {
id: string;
position: {
top: number;
left: number;
bottom: number;
right: number;
};
/**
* A grid layout can be a mix of panels and sections, and we call these "widgets" as a general term
*/
export type GridLayoutWidget =
| (GridPanelData & { type: 'panel' })
| (GridSectionData & { type: 'section' });
export interface GridLayoutData {
[key: string]: GridLayoutWidget;
}
export interface ActiveRowEvent {
id: string;
sensorType: 'mouse' | 'touch' | 'keyboard';
startingPosition: {
top: number;
left: number;
};
translate: {
top: number;
left: number;
};
/**
* This represents `GridLayoutData` where every panel exists in an ordered section;
* i.e. panels and sections are no longer mixed on the same level
*/
export interface OrderedLayout {
[key: string]: MainSection | CollapsibleSection;
}
/**
* The GridLayoutStateManager is used for all state management
*/
export interface GridLayoutStateManager {
gridLayout$: BehaviorSubject<GridLayoutData>;
proposedGridLayout$: BehaviorSubject<GridLayoutData | undefined>; // temporary state for layout during drag and drop operations
gridLayout$: BehaviorSubject<OrderedLayout>;
expandedPanelId$: BehaviorSubject<string | undefined>;
isMobileView$: BehaviorSubject<boolean>;
accessMode$: BehaviorSubject<GridAccessMode>;
gridDimensions$: BehaviorSubject<ObservedSize>;
runtimeSettings$: BehaviorSubject<RuntimeGridSettings>;
activePanel$: BehaviorSubject<ActivePanel | undefined>;
activeRowEvent$: BehaviorSubject<ActiveRowEvent | undefined>;
interactionEvent$: BehaviorSubject<PanelInteractionEvent | undefined>;
activePanelEvent$: BehaviorSubject<ActivePanelEvent | undefined>;
activeSectionEvent$: BehaviorSubject<ActiveSectionEvent | undefined>;
layoutUpdated$: Observable<GridLayoutData>;
layoutRef: React.MutableRefObject<HTMLDivElement | null>;
rowRefs: React.MutableRefObject<{ [rowId: string]: HTMLDivElement | null }>;
headerRefs: React.MutableRefObject<{ [rowId: string]: HTMLDivElement | null }>;
panelRefs: React.MutableRefObject<{
[rowId: string]: { [panelId: string]: HTMLDivElement | null };
}>;
}
/**
* 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.
*/
targetRow: string;
/**
* 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
*/
sensorOffsets: {
top: number;
left: number;
right: number;
bottom: number;
};
sensorType: 'mouse' | 'touch' | 'keyboard';
sectionRefs: React.MutableRefObject<{ [sectionId: string]: HTMLDivElement | null }>;
headerRefs: React.MutableRefObject<{ [sectionId: string]: HTMLDivElement | null }>;
panelRefs: React.MutableRefObject<{ [panelId: string]: HTMLDivElement | null }>;
}
/**

View file

@ -8,4 +8,4 @@
*/
export { useGridLayoutPanelEvents } from './panel/events';
export { useGridLayoutRowEvents } from './row/events';
export { useGridLayoutSectionEvents } from './section/events';

View file

@ -8,24 +8,25 @@
*/
import { useCallback, useRef } from 'react';
import { GridPanelData, PanelInteractionEvent } from '../../types';
import type { ActivePanelEvent, GridPanelData } from '../../grid_panel';
import { useGridLayoutContext } from '../../use_grid_layout_context';
import { commitAction, moveAction, startAction, cancelAction } from './state_manager_actions';
import {
getSensorPosition,
isKeyboardEvent,
isMouseEvent,
isTouchEvent,
startKeyboardInteraction,
startMouseInteraction,
startTouchInteraction,
startKeyboardInteraction,
isKeyboardEvent,
} from '../sensors';
import { UserInteractionEvent } from '../types';
import { getNextKeyboardPositionForPanel } from './utils';
import {
hasPanelInteractionStartedWithKeyboard,
isLayoutInteractive,
} from '../state_manager_selectors';
import type { UserInteractionEvent } from '../types';
import { cancelAction, commitAction, moveAction, startAction } from './state_manager_actions';
import { getNextKeyboardPositionForPanel } from './utils';
/*
* This hook sets up and manages drag/resize interaction logic for grid panels.
* It initializes event handlers to start, move, and commit the interaction,
@ -34,11 +35,11 @@ import {
*/
export const useGridLayoutPanelEvents = ({
interactionType,
rowId,
sectionId,
panelId,
}: {
interactionType: PanelInteractionEvent['type'];
rowId: string;
interactionType: ActivePanelEvent['type'];
sectionId?: string;
panelId: string;
}) => {
const { gridLayoutStateManager } = useGridLayoutContext();
@ -47,9 +48,10 @@ export const useGridLayoutPanelEvents = ({
const onStart = useCallback(
(ev: UserInteractionEvent) => {
startAction(ev, gridLayoutStateManager, interactionType, rowId, panelId);
if (!sectionId) return;
startAction(ev, interactionType, gridLayoutStateManager, sectionId, panelId);
},
[gridLayoutStateManager, interactionType, rowId, panelId]
[gridLayoutStateManager, interactionType, sectionId, panelId]
);
const onEnd = useCallback(() => {
@ -58,13 +60,13 @@ export const useGridLayoutPanelEvents = ({
const onBlur = useCallback(() => {
const {
interactionEvent$: { value: { id, type, targetRow } = {} },
activePanelEvent$: { value: { id, targetSection } = {} },
} = gridLayoutStateManager;
// make sure the user hasn't started another interaction in the meantime
if (id === panelId && rowId === targetRow && type === interactionType) {
if (id === panelId && sectionId === targetSection) {
commitAction(gridLayoutStateManager);
}
}, [gridLayoutStateManager, panelId, rowId, interactionType]);
}, [gridLayoutStateManager, panelId, sectionId]);
const onCancel = useCallback(() => {
cancelAction(gridLayoutStateManager);
@ -96,7 +98,9 @@ export const useGridLayoutPanelEvents = ({
const startInteraction = useCallback(
(e: UserInteractionEvent) => {
if (!isLayoutInteractive(gridLayoutStateManager)) return;
if (!isLayoutInteractive(gridLayoutStateManager)) {
return;
}
if (isMouseEvent(e)) {
startMouseInteraction({
e,
@ -112,21 +116,19 @@ export const useGridLayoutPanelEvents = ({
onEnd,
});
} else if (isKeyboardEvent(e)) {
const isEventActive = gridLayoutStateManager.interactionEvent$.value !== undefined;
if (gridLayoutStateManager.activePanelEvent$.getValue()) return; // interaction has already happened, so don't start again
startKeyboardInteraction({
e,
isEventActive,
onStart,
onMove,
onEnd,
onBlur,
onCancel,
shouldScrollToEnd: interactionType === 'resize',
});
}
},
[gridLayoutStateManager, interactionType, onStart, onMove, onEnd, onBlur, onCancel]
[gridLayoutStateManager, onStart, onMove, onEnd, onBlur, onCancel]
);
return { startDrag: startInteraction };
return startInteraction;
};

View file

@ -8,134 +8,146 @@
*/
import { cloneDeep } from 'lodash';
import deepEqual from 'fast-deep-equal';
import { MutableRefObject } from 'react';
import { GridLayoutStateManager, GridPanelData } from '../../types';
import { getDragPreviewRect, getSensorOffsets, getResizePreviewRect } from './utils';
import { resolveGridRow } from '../../utils/resolve_grid_row';
import { isGridDataEqual } from '../../utils/equality_checks';
import { PointerPosition, UserInteractionEvent } from '../types';
import { getSensorType, isKeyboardEvent } from '../sensors';
import type { ActivePanelEvent, GridPanelData } from '../../grid_panel';
import type { GridLayoutStateManager, OrderedLayout } from '../../types';
import type { GridLayoutContextType } from '../../use_grid_layout_context';
import { isGridDataEqual, isOrderedLayoutEqual } from '../../utils/equality_checks';
import { resolveGridSection } from '../../utils/resolve_grid_section';
import { resolveSections } from '../../utils/section_management';
import { getSensorType } from '../sensors';
import type { PointerPosition, UserInteractionEvent } from '../types';
import { getDragPreviewRect, getResizePreviewRect, getSensorOffsets } from './utils';
let startingLayout: OrderedLayout | undefined;
export const startAction = (
e: UserInteractionEvent,
type: ActivePanelEvent['type'],
gridLayoutStateManager: GridLayoutStateManager,
type: 'drag' | 'resize',
rowId: string,
sectionId: string,
panelId: string
) => {
const panelRef = gridLayoutStateManager.panelRefs.current[rowId][panelId];
const panelRef = gridLayoutStateManager.panelRefs.current[panelId];
if (!panelRef) return;
startingLayout = gridLayoutStateManager.gridLayout$.getValue();
const panelRect = panelRef.getBoundingClientRect();
gridLayoutStateManager.interactionEvent$.next({
gridLayoutStateManager.activePanelEvent$.next({
type,
id: panelId,
panelDiv: panelRef,
targetRow: rowId,
targetSection: sectionId,
sensorType: getSensorType(e),
position: panelRect,
sensorOffsets: getSensorOffsets(e, panelRect),
});
gridLayoutStateManager.proposedGridLayout$.next(gridLayoutStateManager.gridLayout$.value);
};
export const moveAction = (
e: UserInteractionEvent,
gridLayoutStateManager: GridLayoutStateManager,
gridLayoutStateManager: GridLayoutContextType['gridLayoutStateManager'],
pointerPixel: PointerPosition,
lastRequestedPanelPosition: MutableRefObject<GridPanelData | undefined>
) => {
const {
runtimeSettings$: { value: runtimeSettings },
interactionEvent$,
proposedGridLayout$,
activePanel$,
rowRefs: { current: gridRowElements },
activePanelEvent$,
gridLayout$,
layoutRef: { current: gridLayoutElement },
headerRefs: { current: gridHeaderElements },
sectionRefs: { current: gridSectionElements },
} = gridLayoutStateManager;
const interactionEvent = interactionEvent$.value;
if (!interactionEvent || !runtimeSettings || !gridRowElements) {
const activePanel = activePanelEvent$.value;
const currentLayout = gridLayout$.value;
if (!activePanel || !runtimeSettings || !gridSectionElements || !currentLayout) {
// if no interaction event return early
return;
}
const currentLayout = proposedGridLayout$.value;
const currentPanelData = currentLayout?.[interactionEvent.targetRow].panels[interactionEvent.id];
const currentPanelData = currentLayout[activePanel.targetSection].panels[activePanel.id];
if (!currentPanelData) {
return;
}
const isResize = interactionEvent.type === 'resize';
const { columnCount, gutterSize, rowHeight, columnPixelWidth } = runtimeSettings;
const isResize = activePanel.type === 'resize';
const previewRect = (() => {
if (isResize) {
const layoutRef = gridLayoutStateManager.layoutRef.current;
const maxRight = layoutRef
? layoutRef.getBoundingClientRect().right - runtimeSettings.gutterSize
: window.innerWidth;
return getResizePreviewRect({ interactionEvent, pointerPixel, maxRight });
const maxRight = layoutRef ? layoutRef.getBoundingClientRect().right : window.innerWidth;
return getResizePreviewRect({ activePanel, pointerPixel, maxRight });
} else {
return getDragPreviewRect({ interactionEvent, pointerPixel });
return getDragPreviewRect({ activePanel, pointerPixel });
}
})();
activePanel$.next({ id: interactionEvent.id, position: previewRect });
const { columnCount, gutterSize, rowHeight, columnPixelWidth } = runtimeSettings;
// find the grid that the preview rect is over
const lastRowId = interactionEvent.targetRow;
const targetRowId = (() => {
// TODO: temporary blocking of moving with keyboard between sections till we have a better way to handle keyboard events between rows
if (isResize || isKeyboardEvent(e)) return lastRowId;
const previewBottom = previewRect.top + rowHeight;
const lastSectionId = activePanel.targetSection;
let previousSection;
let targetSectionId: string | undefined = (() => {
if (isResize) return lastSectionId;
// early return - target the first "main" section if the panel is dragged above the layout element
if (previewRect.top < (gridLayoutElement?.getBoundingClientRect().top ?? 0)) {
return `main-0`;
}
const previewBottom = previewRect.top + rowHeight;
let highestOverlap = -Infinity;
let highestOverlapRowId = '';
Object.entries(gridRowElements).forEach(([id, row]) => {
if (!row) return;
const rowRect = row.getBoundingClientRect();
let highestOverlapSectionId = '';
Object.keys(currentLayout).forEach((sectionId) => {
const section = currentLayout[sectionId];
const sectionElement =
!section.isMainSection && (section.isCollapsed || Object.keys(section.panels).length === 0)
? gridHeaderElements[sectionId]
: gridSectionElements[sectionId];
if (!sectionElement) return;
const rowRect = sectionElement.getBoundingClientRect();
const overlap =
Math.min(previewBottom, rowRect.bottom) - Math.max(previewRect.top, rowRect.top);
if (overlap > highestOverlap) {
highestOverlap = overlap;
highestOverlapRowId = id;
highestOverlapSectionId = sectionId;
}
});
return highestOverlapRowId;
const section = currentLayout[highestOverlapSectionId];
if (!section.isMainSection && section.isCollapsed) {
previousSection = highestOverlapSectionId;
// skip past collapsed section
return undefined;
}
return highestOverlapSectionId;
})();
const hasChangedGridRow = targetRowId !== lastRowId;
// re-render when the target row changes
if (hasChangedGridRow) {
interactionEvent$.next({
...interactionEvent,
targetRow: targetRowId,
});
}
// calculate the requested grid position
const targetedGridRow = gridRowElements[targetRowId];
const targetedGridLeft = targetedGridRow?.getBoundingClientRect().left ?? 0;
const targetedGridTop = targetedGridRow?.getBoundingClientRect().top ?? 0;
const maxColumn = isResize ? columnCount : columnCount - currentPanelData.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 gridLayoutRect = gridLayoutElement?.getBoundingClientRect();
const targetColumn = (() => {
const targetedGridLeft = gridLayoutRect?.left ?? 0;
const localXCoordinate = isResize
? previewRect.right - targetedGridLeft
: previewRect.left - targetedGridLeft;
const maxColumn = isResize ? columnCount : columnCount - currentPanelData.width;
return Math.min(
Math.max(Math.round(localXCoordinate / (columnPixelWidth + gutterSize)), 0),
maxColumn
);
})();
const targetRow = (() => {
if (targetSectionId) {
// this section already exists, so use the wrapper element to figure out target row
const targetedGridSection = gridSectionElements[targetSectionId];
const targetedGridSectionRect = targetedGridSection?.getBoundingClientRect();
const targetedGridTop = targetedGridSectionRect?.top ?? 0;
const localYCoordinate = isResize
? previewRect.bottom - targetedGridTop
: previewRect.top - targetedGridTop;
return Math.max(Math.round(localYCoordinate / (rowHeight + gutterSize)), 0);
} else {
// this section doesn't exist yet, so target the first row of that section
return 0;
}
})();
const requestedPanelData = { ...currentPanelData };
if (isResize) {
@ -145,57 +157,104 @@ export const moveAction = (
requestedPanelData.column = targetColumn;
requestedPanelData.row = targetRow;
}
const hasChangedGridSection = targetSectionId !== lastSectionId;
// resolve the new grid layout
if (
hasChangedGridRow ||
hasChangedGridSection ||
!isGridDataEqual(requestedPanelData, lastRequestedPanelPosition.current)
) {
lastRequestedPanelPosition.current = { ...requestedPanelData };
const nextLayout = cloneDeep(currentLayout);
Object.entries(nextLayout).forEach(([rowId, row]) => {
const { [interactionEvent.id]: interactingPanel, ...otherPanels } = row.panels;
nextLayout[rowId] = { ...row, panels: { ...otherPanels } };
});
let nextLayout = cloneDeep(currentLayout) ?? {};
if (!targetSectionId || !nextLayout[targetSectionId]) {
// section doesn't exist, so add it
const { order: nextOrder } =
targetSectionId === 'main-0' ? { order: -1 } : nextLayout[previousSection!];
// push other sections down
Object.keys(nextLayout).forEach((sectionId) => {
if (nextLayout[sectionId].order > nextOrder) {
nextLayout[sectionId].order += 1;
}
});
// add the new section, which may be renamed by `resolveSections` to `main-<order>`
targetSectionId = targetSectionId ?? `main-new`;
nextLayout[targetSectionId] = {
id: targetSectionId,
isMainSection: true,
panels: {},
order: nextOrder + 1,
};
requestedPanelData.row = 0;
}
// remove the panel from where it started so that we can apply the drag request
delete nextLayout[lastSectionId].panels[activePanel.id];
// resolve destination grid
const destinationGrid = nextLayout[targetRowId];
const resolvedDestinationGrid = resolveGridRow(destinationGrid, requestedPanelData);
nextLayout[targetRowId] = resolvedDestinationGrid;
const destinationGrid = nextLayout[targetSectionId];
const resolvedDestinationGrid = resolveGridSection(destinationGrid.panels, requestedPanelData);
nextLayout[targetSectionId].panels = resolvedDestinationGrid;
// resolve origin grid
if (hasChangedGridRow) {
const originGrid = nextLayout[lastRowId];
const resolvedOriginGrid = resolveGridRow(originGrid);
nextLayout[lastRowId] = resolvedOriginGrid;
if (hasChangedGridSection) {
const originGrid = nextLayout[lastSectionId];
const resolvedOriginGrid = resolveGridSection(originGrid.panels);
nextLayout[lastSectionId].panels = resolvedOriginGrid;
}
if (!deepEqual(currentLayout, nextLayout)) {
proposedGridLayout$.next(nextLayout);
// resolve sections to remove empty main sections + ensure orders are valid
nextLayout = resolveSections(nextLayout);
if (!nextLayout[targetSectionId]) {
// resolving the sections possibly removed + renamed sections, so reset target section
const { order } = nextLayout[previousSection!];
targetSectionId = `main-${order + 1}`;
}
if (currentLayout && !isOrderedLayoutEqual(currentLayout, nextLayout)) {
gridLayout$.next(nextLayout);
}
}
// re-render the active panel
activePanelEvent$.next({
...activePanel,
id: activePanel.id,
position: previewRect,
targetSection: targetSectionId!,
});
};
export const commitAction = ({
activePanel$,
interactionEvent$,
gridLayout$,
proposedGridLayout$,
activePanelEvent$: activePanelEvent$,
panelRefs,
}: GridLayoutStateManager) => {
activePanel$.next(undefined);
interactionEvent$.next(undefined);
if (proposedGridLayout$.value && !deepEqual(proposedGridLayout$.value, gridLayout$.getValue())) {
gridLayout$.next(cloneDeep(proposedGridLayout$.value));
}
proposedGridLayout$.next(undefined);
const event = activePanelEvent$.getValue();
activePanelEvent$.next(undefined);
if (!event) return;
panelRefs.current[event.id]?.scrollIntoView({
behavior: 'smooth',
block: 'center',
});
};
export const cancelAction = ({
activePanel$,
interactionEvent$,
proposedGridLayout$,
activePanelEvent$: activePanelEvent$,
gridLayout$,
panelRefs,
}: GridLayoutStateManager) => {
activePanel$.next(undefined);
interactionEvent$.next(undefined);
proposedGridLayout$.next(undefined);
const event = activePanelEvent$.getValue();
activePanelEvent$.next(undefined);
if (startingLayout) {
gridLayout$.next(startingLayout);
}
if (!event) return;
panelRefs.current[event.id]?.scrollIntoView({
behavior: 'smooth',
block: 'center',
});
};

View file

@ -8,45 +8,47 @@
*/
import { euiThemeVars } from '@kbn/ui-theme';
import type { GridLayoutStateManager, PanelInteractionEvent } from '../../types';
import type { UserInteractionEvent, PointerPosition } from '../types';
import { KeyboardCode, type UserKeyboardEvent } from '../sensors/keyboard/types';
import { getSensorPosition, isKeyboardEvent, isMouseEvent, isTouchEvent } from '../sensors';
import type { ActivePanelEvent } from '../../grid_panel';
import type { GridLayoutStateManager } from '../../types';
import { updateClientY } from '../keyboard_utils';
import { getSensorPosition, isKeyboardEvent, isMouseEvent, isTouchEvent } from '../sensors';
import { KeyboardCode, type UserKeyboardEvent } from '../sensors/keyboard/types';
import type { PointerPosition, UserInteractionEvent } from '../types';
// Calculates the preview rect coordinates for a resized panel
export const getResizePreviewRect = ({
interactionEvent,
activePanel,
pointerPixel,
maxRight,
}: {
pointerPixel: PointerPosition;
interactionEvent: PanelInteractionEvent;
activePanel: ActivePanelEvent;
maxRight: number;
}) => {
const panelRect = interactionEvent.panelDiv.getBoundingClientRect();
const panelRect = activePanel.panelDiv.getBoundingClientRect();
return {
left: panelRect.left,
top: panelRect.top,
bottom: pointerPixel.clientY - interactionEvent.sensorOffsets.bottom,
right: Math.min(pointerPixel.clientX - interactionEvent.sensorOffsets.right, maxRight),
bottom: pointerPixel.clientY - activePanel.sensorOffsets.bottom,
right: Math.min(pointerPixel.clientX - activePanel.sensorOffsets.right, maxRight),
};
};
// Calculates the preview rect coordinates for a dragged panel
export const getDragPreviewRect = ({
pointerPixel,
interactionEvent,
activePanel,
}: {
pointerPixel: PointerPosition;
interactionEvent: PanelInteractionEvent;
activePanel: ActivePanelEvent;
}) => {
return {
left: pointerPixel.clientX - interactionEvent.sensorOffsets.left,
top: pointerPixel.clientY - interactionEvent.sensorOffsets.top,
bottom: pointerPixel.clientY - interactionEvent.sensorOffsets.bottom,
right: pointerPixel.clientX - interactionEvent.sensorOffsets.right,
left: pointerPixel.clientX - activePanel.sensorOffsets.left,
top: pointerPixel.clientY - activePanel.sensorOffsets.top,
bottom: pointerPixel.clientY - activePanel.sensorOffsets.bottom,
right: pointerPixel.clientX - activePanel.sensorOffsets.right,
};
};
@ -73,15 +75,14 @@ export const getNextKeyboardPositionForPanel = (
handlePosition: { clientX: number; clientY: number }
) => {
const {
interactionEvent$: { value: interactionEvent },
activePanel$: { value: activePanel },
activePanelEvent$: { value: activePanel },
runtimeSettings$: {
value: { columnPixelWidth, rowHeight, gutterSize, keyboardDragTopLimit },
},
} = gridLayoutStateManager;
const { type } = interactionEvent || {};
const panelPosition = activePanel?.position || interactionEvent?.panelDiv.getBoundingClientRect();
const { type } = activePanel ?? {};
const panelPosition = activePanel?.position ?? activePanel?.panelDiv.getBoundingClientRect();
if (!panelPosition) return handlePosition;

View file

@ -1,80 +0,0 @@
/*
* 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 { getGridLayoutStateManagerMock } from '../../test_utils/mocks';
import { getRowKeysInOrder } from '../../utils/resolve_grid_row';
import { moveAction } from './state_manager_actions';
describe('row state manager actions', () => {
const gridLayoutStateManager = getGridLayoutStateManagerMock();
describe('move action', () => {
beforeAll(() => {
gridLayoutStateManager.activeRowEvent$.next({
id: 'second',
startingPosition: {
top: 100,
left: 100,
},
translate: {
top: 0,
left: 0,
},
sensorType: 'mouse',
});
gridLayoutStateManager.rowRefs.current = {
first: {} as any as HTMLDivElement,
second: {} as any as HTMLDivElement,
third: {} as any as HTMLDivElement,
};
gridLayoutStateManager.headerRefs.current = {
second: {} as any as HTMLDivElement,
third: {} as any as HTMLDivElement,
};
});
it('adjusts row order based on positioning of row refs', () => {
const currentRowOrder = getRowKeysInOrder(gridLayoutStateManager.gridLayout$.getValue());
expect(currentRowOrder).toEqual(['first', 'second', 'third']);
gridLayoutStateManager.rowRefs.current = {
second: {
getBoundingClientRect: jest.fn().mockReturnValue({ top: 100, height: 100 }),
} as any as HTMLDivElement,
third: {
getBoundingClientRect: jest.fn().mockReturnValue({ top: 25, height: 100 }),
} as any as HTMLDivElement,
};
moveAction(gridLayoutStateManager, { clientX: 0, clientY: 0 }, { clientX: 0, clientY: 0 });
const newRowOrder = getRowKeysInOrder(gridLayoutStateManager.proposedGridLayout$.getValue()!);
expect(newRowOrder).toEqual(['first', 'third', 'second']);
});
it('calculates translate based on old and new mouse position', () => {
moveAction(
gridLayoutStateManager,
{ clientX: 20, clientY: 150 },
{ clientX: 100, clientY: 10 }
);
expect(gridLayoutStateManager.activeRowEvent$.getValue()).toEqual({
id: 'second',
startingPosition: {
top: 100,
left: 100,
},
translate: {
top: -140,
left: 80,
},
sensorType: 'mouse',
});
});
});
});

View file

@ -1,105 +0,0 @@
/*
* 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 deepEqual from 'fast-deep-equal';
import { cloneDeep, pick } from 'lodash';
import { GridLayoutStateManager } from '../../types';
import { getRowKeysInOrder } from '../../utils/resolve_grid_row';
import { getSensorType } from '../sensors';
import { PointerPosition, UserInteractionEvent } from '../types';
export const startAction = (
e: UserInteractionEvent,
gridLayoutStateManager: GridLayoutStateManager,
rowId: string
) => {
const headerRef = gridLayoutStateManager.headerRefs.current[rowId];
if (!headerRef) return;
const startingPosition = pick(headerRef.getBoundingClientRect(), ['top', 'left']);
gridLayoutStateManager.activeRowEvent$.next({
id: rowId,
startingPosition,
sensorType: getSensorType(e),
translate: {
top: 0,
left: 0,
},
});
};
export const commitAction = ({
activeRowEvent$,
proposedGridLayout$,
gridLayout$,
}: GridLayoutStateManager) => {
activeRowEvent$.next(undefined);
const proposedGridLayoutValue = proposedGridLayout$.getValue();
if (proposedGridLayoutValue && !deepEqual(proposedGridLayoutValue, gridLayout$.getValue())) {
gridLayout$.next(cloneDeep(proposedGridLayoutValue));
}
proposedGridLayout$.next(undefined);
};
export const cancelAction = ({ activeRowEvent$, proposedGridLayout$ }: GridLayoutStateManager) => {
activeRowEvent$.next(undefined);
proposedGridLayout$.next(undefined);
};
export const moveAction = (
gridLayoutStateManager: GridLayoutStateManager,
startingPointer: PointerPosition,
currentPointer: PointerPosition
) => {
const currentActiveRowEvent = gridLayoutStateManager.activeRowEvent$.getValue();
if (!currentActiveRowEvent) return;
const currentLayout =
gridLayoutStateManager.proposedGridLayout$.getValue() ??
gridLayoutStateManager.gridLayout$.getValue();
const currentRowOrder = getRowKeysInOrder(currentLayout);
currentRowOrder.shift(); // drop first row since nothing can go above it
const updatedRowOrder = Object.keys(gridLayoutStateManager.headerRefs.current).sort(
(idA, idB) => {
// if expanded, get dimensions of row; otherwise, use the header
const rowRefA = currentLayout[idA].isCollapsed
? gridLayoutStateManager.headerRefs.current[idA]
: gridLayoutStateManager.rowRefs.current[idA];
const rowRefB = currentLayout[idB].isCollapsed
? gridLayoutStateManager.headerRefs.current[idB]
: gridLayoutStateManager.rowRefs.current[idB];
if (!rowRefA || !rowRefB) return 0;
// switch the order when the dragged row goes beyond the mid point of the row it's compared against
const { top: topA, height: heightA } = rowRefA.getBoundingClientRect();
const { top: topB, height: heightB } = rowRefB.getBoundingClientRect();
const midA = topA + heightA / 2;
const midB = topB + heightB / 2;
return midA - midB;
}
);
if (!deepEqual(currentRowOrder, updatedRowOrder)) {
const updatedLayout = cloneDeep(currentLayout);
updatedRowOrder.forEach((id, index) => {
updatedLayout[id].order = index + 1;
});
gridLayoutStateManager.proposedGridLayout$.next(updatedLayout);
}
gridLayoutStateManager.activeRowEvent$.next({
...currentActiveRowEvent,
translate: {
top: currentPointer.clientY - startingPointer.clientY,
left: currentPointer.clientX - startingPointer.clientX,
},
});
};

View file

@ -10,7 +10,6 @@
import { useCallback, useRef } from 'react';
import { useGridLayoutContext } from '../../use_grid_layout_context';
import { cancelAction, commitAction, moveAction, startAction } from './state_manager_actions';
import {
getSensorPosition,
isKeyboardEvent,
@ -20,20 +19,21 @@ import {
startMouseInteraction,
startTouchInteraction,
} from '../sensors';
import { PointerPosition, UserInteractionEvent } from '../types';
import {
hasRowInteractionStartedWithKeyboard,
isLayoutInteractive,
} from '../state_manager_selectors';
import { PointerPosition, UserInteractionEvent } from '../types';
import { cancelAction, commitAction, moveAction, startAction } from './state_manager_actions';
import { getNextKeyboardPosition } from './utils';
/*
* This hook sets up and manages interaction logic for dragging grid rows.
* This hook sets up and manages interaction logic for dragging grid sections.
* It initializes event handlers to start, move, and commit the interaction,
* ensuring responsive updates to the panel's position and grid layout state.
* The interaction behavior is dynamic and adapts to the input type (mouse, touch, or keyboard).
*/
export const useGridLayoutRowEvents = ({ rowId }: { rowId: string }) => {
export const useGridLayoutSectionEvents = ({ sectionId }: { sectionId: string }) => {
const { gridLayoutStateManager } = useGridLayoutContext();
const startingPointer = useRef<PointerPosition>({ clientX: 0, clientY: 0 });
@ -44,10 +44,12 @@ export const useGridLayoutRowEvents = ({ rowId }: { rowId: string }) => {
);
const startInteraction = useCallback(
(e: UserInteractionEvent) => {
if (!isLayoutInteractive(gridLayoutStateManager)) return;
if (!isLayoutInteractive(gridLayoutStateManager)) {
return;
}
const onStart = () => {
startingPointer.current = getSensorPosition(e);
startAction(e, gridLayoutStateManager, rowId);
startAction(e, gridLayoutStateManager, sectionId);
};
const onMove = (ev: UserInteractionEvent) => {
@ -60,8 +62,8 @@ export const useGridLayoutRowEvents = ({ rowId }: { rowId: string }) => {
const pointerPixel = getNextKeyboardPosition(
ev,
gridLayoutStateManager,
getSensorPosition(e),
rowId
getSensorPosition(ev),
sectionId
);
moveAction(gridLayoutStateManager, startingPointer.current, pointerPixel);
}
@ -83,19 +85,19 @@ export const useGridLayoutRowEvents = ({ rowId }: { rowId: string }) => {
onEnd,
});
} else if (isKeyboardEvent(e)) {
const isEventActive = gridLayoutStateManager.activeRowEvent$.value !== undefined;
if (gridLayoutStateManager.activeSectionEvent$.getValue()) return; // interaction has already happened, so don't start again
startKeyboardInteraction({
e,
isEventActive,
onStart,
onMove,
onEnd,
onBlur: onEnd,
onCancel,
});
}
},
[gridLayoutStateManager, rowId, onEnd, onCancel]
[gridLayoutStateManager, sectionId, onEnd, onCancel]
);
return { startDrag: startInteraction, onBlur: onEnd };
return startInteraction;
};

View file

@ -0,0 +1,174 @@
/*
* 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 { CollapsibleSection } from '../../grid_section';
import { getGridLayoutStateManagerMock } from '../../test_utils/mocks';
import { getSampleOrderedLayout } from '../../test_utils/sample_layout';
import { getSectionsInOrder } from '../../utils/resolve_grid_section';
import { moveAction } from './state_manager_actions';
describe('row state manager actions', () => {
const gridLayoutStateManager = getGridLayoutStateManagerMock();
describe('move action', () => {
beforeAll(() => {
gridLayoutStateManager.gridLayout$.next({
...gridLayoutStateManager.gridLayout$.getValue(),
second: {
...gridLayoutStateManager.gridLayout$.getValue().second,
isCollapsed: true,
} as CollapsibleSection,
});
gridLayoutStateManager.activeSectionEvent$.next({
id: 'second',
startingPosition: {
top: 100,
left: 100,
},
translate: {
top: 0,
left: 0,
},
sensorType: 'mouse',
targetSection: undefined,
});
gridLayoutStateManager.sectionRefs.current = {
'main-0': {
getBoundingClientRect: jest.fn().mockReturnValue({ top: 0, height: 100, bottom: 100 }),
} as any as HTMLDivElement,
third: {
getBoundingClientRect: jest.fn().mockReturnValue({ top: 200, height: 100, bottom: 300 }),
} as any as HTMLDivElement,
};
gridLayoutStateManager.headerRefs.current = {
second: {
getBoundingClientRect: jest.fn().mockReturnValue({ top: 100, height: 50, bottom: 150 }),
} as any as HTMLDivElement,
third: {
getBoundingClientRect: jest.fn().mockReturnValue({ top: 150, height: 50, bottom: 200 }),
} as any as HTMLDivElement,
};
});
it('calculates translate based on old and new mouse position', () => {
moveAction(
gridLayoutStateManager,
{ clientX: 20, clientY: 150 },
{ clientX: 100, clientY: 10 }
);
expect(gridLayoutStateManager.activeSectionEvent$.getValue()).toEqual(
expect.objectContaining({
id: 'second',
startingPosition: {
top: 100,
left: 100,
},
translate: {
top: -140,
left: 80,
},
})
);
gridLayoutStateManager.gridLayout$.next(getSampleOrderedLayout());
});
describe('re-ordering sections', () => {
beforeAll(() => {
const currentRowOrder = getSectionsInOrder(
gridLayoutStateManager.gridLayout$.getValue()
).map(({ id }) => id);
expect(currentRowOrder).toEqual(['main-0', 'second', 'third']);
});
it('no target section id', () => {
// "move" the second section up so that it overlaps with nothing
gridLayoutStateManager.headerRefs.current.second = {
getBoundingClientRect: jest.fn().mockReturnValue({ top: -100, height: 50, bottom: -50 }),
} as any as HTMLDivElement;
moveAction(gridLayoutStateManager, { clientX: 0, clientY: 0 }, { clientX: 0, clientY: 0 });
// second section gets dropped at the top
const newRowOrder = getSectionsInOrder(gridLayoutStateManager.gridLayout$.getValue()).map(
({ id }) => id
);
expect(newRowOrder).toEqual(['second', 'main-0', 'third']);
});
it('targeting a non-main section', () => {
// "move" the second section so that it overlaps the third section
gridLayoutStateManager.headerRefs.current.second = {
getBoundingClientRect: jest.fn().mockReturnValue({ top: 260, height: 50, bottom: 310 }),
} as any as HTMLDivElement;
moveAction(gridLayoutStateManager, { clientX: 0, clientY: 0 }, { clientX: 0, clientY: 0 });
// second section gets dropped after the third section
const newRowOrder = getSectionsInOrder(gridLayoutStateManager.gridLayout$.getValue()).map(
({ id }) => id
);
expect(newRowOrder).toEqual(['main-0', 'third', 'second']);
});
it('targeting a main section', () => {
// "move" the second section so that it overlaps the first main section
gridLayoutStateManager.headerRefs.current.second = {
getBoundingClientRect: jest.fn().mockReturnValue({ top: 50, height: 50, bottom: 100 }),
} as any as HTMLDivElement;
moveAction(gridLayoutStateManager, { clientX: 0, clientY: 0 }, { clientX: 0, clientY: 0 });
// second section gets dropped between panels and creates a new section
const newRowOrder = getSectionsInOrder(gridLayoutStateManager.gridLayout$.getValue()).map(
({ id }) => id
);
expect(newRowOrder).toEqual(['main-0', 'second', 'main-2', 'third']);
expect(gridLayoutStateManager.gridLayout$.getValue()).toEqual({
'main-0': expect.objectContaining({
order: 0,
panels: {
panel1: expect.objectContaining({
row: 0,
}),
panel5: expect.objectContaining({
row: 0,
}),
},
}),
second: expect.objectContaining({
order: 1,
}),
'main-2': expect.objectContaining({
order: 2,
panels: {
panel2: expect.objectContaining({
row: 0,
}),
panel3: expect.objectContaining({
row: 0,
}),
panel4: expect.objectContaining({
row: 4,
}),
panel6: expect.objectContaining({
row: 0,
}),
panel7: expect.objectContaining({
row: 0,
}),
panel8: expect.objectContaining({
row: 2,
}),
},
}),
third: expect.objectContaining({
order: 3,
}),
});
});
});
});
});

View file

@ -0,0 +1,212 @@
/*
* 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 deepEqual from 'fast-deep-equal';
import { pick } from 'lodash';
import type { GridSectionData } from '../../grid_section';
import type { GridLayoutStateManager, OrderedLayout } from '../../types';
import { getPanelKeysInOrder, getSectionsInOrder } from '../../utils/resolve_grid_section';
import { resolveSections } from '../../utils/section_management';
import { getSensorType } from '../sensors';
import type { PointerPosition, UserInteractionEvent } from '../types';
let startingLayout: OrderedLayout | undefined;
export const startAction = (
e: UserInteractionEvent,
gridLayoutStateManager: GridLayoutStateManager,
sectionId: string
) => {
const headerRef = gridLayoutStateManager.headerRefs.current[sectionId];
if (!headerRef) return;
startingLayout = gridLayoutStateManager.gridLayout$.getValue();
const startingPosition = pick(headerRef.getBoundingClientRect(), ['top', 'left']);
gridLayoutStateManager.activeSectionEvent$.next({
id: sectionId,
startingPosition,
sensorType: getSensorType(e),
translate: {
top: 0,
left: 0,
},
});
};
export const commitAction = ({ activeSectionEvent$, headerRefs }: GridLayoutStateManager) => {
const event = activeSectionEvent$.getValue();
activeSectionEvent$.next(undefined);
if (!event) return;
headerRefs.current[event.id]?.scrollIntoView({
behavior: 'smooth',
block: 'center',
});
};
export const cancelAction = ({
activeSectionEvent$,
gridLayout$,
headerRefs,
}: GridLayoutStateManager) => {
const event = activeSectionEvent$.getValue();
activeSectionEvent$.next(undefined);
if (startingLayout) {
gridLayout$.next(startingLayout);
}
if (!event) return;
headerRefs.current[event.id]?.scrollIntoView({
behavior: 'smooth',
block: 'center',
});
};
export const moveAction = (
gridLayoutStateManager: GridLayoutStateManager,
startingPointer: PointerPosition,
currentPointer: PointerPosition
) => {
const currentActiveSectionEvent = gridLayoutStateManager.activeSectionEvent$.getValue();
if (!currentActiveSectionEvent) return;
const {
runtimeSettings$: { value: runtimeSettings },
headerRefs: { current: gridHeaderElements },
sectionRefs: { current: gridSectionElements },
} = gridLayoutStateManager;
const currentLayout = gridLayoutStateManager.gridLayout$.getValue();
// check which section ID is being targeted
const activeRowRect = gridHeaderElements[
currentActiveSectionEvent.id
]?.getBoundingClientRect() ?? {
top: 0,
bottom: 0,
};
const targetSectionId: string | undefined = (() => {
let currentTargetSection;
Object.entries(gridSectionElements).forEach(([id, section]) => {
const { top, bottom } = section?.getBoundingClientRect() ?? { top: 0, bottom: 0 };
if (activeRowRect.bottom >= top && activeRowRect.top <= bottom) {
currentTargetSection = id;
}
});
return currentTargetSection;
})();
if (!targetSectionId || !currentLayout[targetSectionId].isMainSection) {
// when not targetting an existing main section, then simply re-order the columns based on their positions in the DOM
const sortedRows = Object.entries({ ...gridHeaderElements, ...gridSectionElements })
.map(([id, row]) => {
// by spreading in this way, we use the grid wrapper elements for expanded sections and the headers for collapsed sections
const { top, height } = row?.getBoundingClientRect() ?? { top: 0, height: 0 };
return { id, middle: top + height / 2 };
})
.sort(({ middle: middleA }, { middle: middleB }) => middleA - middleB);
const ordersAreEqual = sortedRows.every(
(section, index) => currentLayout[section.id].order === index
);
if (!ordersAreEqual) {
const orderedLayout: OrderedLayout = {};
sortedRows.forEach((row, index) => {
orderedLayout[row.id] = {
...currentLayout[row.id],
order: index,
};
});
gridLayoutStateManager.gridLayout$.next(orderedLayout);
}
} else {
// when a main section is being targeted, allow the header to be dropped between panels
const { gutterSize, rowHeight } = runtimeSettings;
const targetRow = (() => {
const targetedGridSection = gridSectionElements[targetSectionId];
const targetedGridSectionRect = targetedGridSection?.getBoundingClientRect();
const targetedGridTop = targetedGridSectionRect?.top ?? 0;
const localYCoordinate = activeRowRect.top - targetedGridTop;
return Math.max(Math.round(localYCoordinate / (rowHeight + gutterSize)), 0);
})();
// rebuild layout by splittng the targeted sectionId into 2
let order = 0;
const firstSectionOrder = currentLayout[targetSectionId].order;
const splitLayout: OrderedLayout = {};
getSectionsInOrder(currentLayout).forEach((section) => {
const { id } = section;
if (id === currentActiveSectionEvent.id) return;
if (section.order < firstSectionOrder) {
splitLayout[id] = section;
} else if (section.order === firstSectionOrder) {
// split this section into 2 - one main section above the dragged section, and one below
const topSectionPanels: GridSectionData['panels'] = {};
const bottomSectionPanels: GridSectionData['panels'] = {};
let startingRow: number;
getPanelKeysInOrder(section.panels).forEach((panelId) => {
const panel = section.panels[panelId];
if (panel.row < targetRow) {
topSectionPanels[panel.id] = panel;
} else {
if (startingRow === undefined) {
startingRow = panel.row;
}
bottomSectionPanels[panel.id] = { ...panel, row: panel.row - startingRow };
}
});
if (Object.keys(topSectionPanels).length > 0) {
splitLayout[`main-${order}`] = {
id: `main-${order}`,
isMainSection: true,
order,
panels: topSectionPanels,
};
order++;
}
splitLayout[currentActiveSectionEvent.id] = {
...currentLayout[currentActiveSectionEvent.id],
order,
};
order++;
if (Object.keys(bottomSectionPanels).length > 0) {
splitLayout[`main-${order}`] = {
id: `main-${order}`,
isMainSection: true,
order,
panels: bottomSectionPanels,
};
}
} else {
// push each other section down
const sectionId = section.isMainSection ? `main-${order}` : id;
splitLayout[sectionId] = { ...section, id: sectionId, order };
}
order++;
});
const finalLayout = resolveSections(splitLayout);
if (!deepEqual(currentLayout, finalLayout)) {
gridLayoutStateManager.gridLayout$.next(finalLayout);
}
}
// update the dragged element
gridLayoutStateManager.activeSectionEvent$.next({
...currentActiveSectionEvent,
targetSection: targetSectionId,
translate: {
top: currentPointer.clientY - startingPointer.clientY,
left: currentPointer.clientX - startingPointer.clientX,
},
});
};

View file

@ -15,7 +15,7 @@ export const getNextKeyboardPosition = (
ev: UserKeyboardEvent,
gridLayoutStateManager: GridLayoutStateManager,
handlePosition: { clientX: number; clientY: number },
rowId: string
sectionId: string
) => {
const {
headerRefs: { current: headerRefs },
@ -24,8 +24,8 @@ export const getNextKeyboardPosition = (
},
} = gridLayoutStateManager;
const headerRef = headerRefs[rowId];
const headerRefHeight = (headerRef?.getBoundingClientRect().height || 48) * 0.5;
const headerRef = headerRefs[sectionId];
const headerRefHeight = (headerRef?.getBoundingClientRect().height ?? 48) * 0.5;
const stepY = headerRefHeight;
switch (ev.code) {

View file

@ -29,70 +29,67 @@ const preventDefault = (e: Event) => e.preventDefault();
const disableScroll = () => window.addEventListener('wheel', preventDefault, { passive: false });
const enableScroll = () => window.removeEventListener('wheel', preventDefault);
const scrollToActiveElement = (shouldScrollToEnd: boolean) => {
document.activeElement?.scrollIntoView({
behavior: 'smooth',
block: shouldScrollToEnd ? 'end' : 'start',
});
};
const handleStart = (e: UserKeyboardEvent, onStart: EventHandler, onBlur?: EventHandler) => {
e.stopPropagation();
e.preventDefault();
onStart(e);
disableScroll();
const handleBlur = (blurEvent: Event) => {
onBlur?.(blurEvent as UserInteractionEvent);
enableScroll();
};
e.target?.addEventListener('blur', handleBlur, { once: true });
};
const handleMove = (e: UserKeyboardEvent, onMove: EventHandler) => {
e.stopPropagation();
e.preventDefault();
onMove(e);
};
const handleEnd = (e: UserKeyboardEvent, onEnd: EventHandler, shouldScrollToEnd: boolean) => {
e.preventDefault();
enableScroll();
onEnd(e);
scrollToActiveElement(shouldScrollToEnd);
};
const handleCancel = (e: UserKeyboardEvent, onCancel: EventHandler, shouldScrollToEnd: boolean) => {
enableScroll();
onCancel(e);
scrollToActiveElement(shouldScrollToEnd);
};
export const startKeyboardInteraction = ({
e,
isEventActive,
onStart,
onMove,
onEnd,
onCancel,
onBlur,
shouldScrollToEnd = false,
}: {
e: UserKeyboardEvent;
isEventActive: boolean;
shouldScrollToEnd?: boolean;
onMove: EventHandler;
onStart: EventHandler;
onEnd: EventHandler;
onCancel: EventHandler;
onBlur?: EventHandler;
}) => {
if (!isEventActive) {
if (isStartKey(e)) handleStart(e, onStart, onBlur);
return;
const handleStart = (ev: UserKeyboardEvent) => {
ev.stopPropagation();
ev.preventDefault();
onStart(ev);
disableScroll();
const handleBlur = (blurEvent: Event) => {
onBlur?.(blurEvent);
enableScroll();
};
document.addEventListener('keydown', handleKeyPress);
/**
* TODO: Blur is firing on re-render, so use `mousedown` instead
* This should be fixed by https://github.com/elastic/kibana/issues/220309
*/
// ev.target?.addEventListener('blur', handleBlur, { once: true });
document.addEventListener('mousedown', handleBlur, { once: true });
};
const handleMove = (ev: UserKeyboardEvent) => {
ev.stopPropagation();
ev.preventDefault();
onMove(ev);
};
const handleEnd = (ev: UserKeyboardEvent) => {
document.removeEventListener('keydown', handleKeyPress);
ev.preventDefault();
enableScroll();
onEnd(ev);
};
const handleCancel = (ev: UserKeyboardEvent) => {
document.removeEventListener('keydown', handleKeyPress);
enableScroll();
onCancel(ev);
};
const handleKeyPress = (ev: UserKeyboardEvent) => {
if (isMoveKey(ev)) handleMove(ev);
else if (isEndKey(ev)) handleEnd(ev);
else if (isCancelKey(ev)) handleCancel(ev);
};
if (isStartKey(e)) {
handleStart(e);
}
if (isMoveKey(e)) handleMove(e, onMove);
if (isEndKey(e)) handleEnd(e, onEnd, shouldScrollToEnd);
if (isCancelKey(e)) handleCancel(e, onCancel, shouldScrollToEnd);
};

View file

@ -17,7 +17,7 @@ export const isLayoutInteractive = (gridLayoutStateManager: GridLayoutStateManag
};
export const hasPanelInteractionStartedWithKeyboard = (manager: GridLayoutStateManager) =>
manager.interactionEvent$.value?.sensorType === 'keyboard';
manager.activePanelEvent$.value?.sensorType === 'keyboard';
export const hasRowInteractionStartedWithKeyboard = (manager: GridLayoutStateManager) =>
manager.activeRowEvent$.value?.sensorType === 'keyboard';
manager.activeSectionEvent$.value?.sensorType === 'keyboard';

View file

@ -7,25 +7,36 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { useEuiTheme } from '@elastic/eui';
import deepEqual from 'fast-deep-equal';
import { cloneDeep, pick } from 'lodash';
import { pick } from 'lodash';
import { useEffect, useMemo, useRef } from 'react';
import { BehaviorSubject, combineLatest, debounceTime, distinctUntilChanged } from 'rxjs';
import {
BehaviorSubject,
Observable,
combineLatest,
debounceTime,
distinctUntilChanged,
filter,
map,
merge,
} from 'rxjs';
import useResizeObserver, { type ObservedSize } from 'use-resize-observer/polyfilled';
import {
ActivePanel,
ActiveRowEvent,
import { useEuiTheme } from '@elastic/eui';
import type { ActivePanelEvent } from './grid_panel';
import type { ActiveSectionEvent } from './grid_section';
import type {
GridAccessMode,
GridLayoutData,
GridLayoutStateManager,
GridSettings,
PanelInteractionEvent,
OrderedLayout,
RuntimeGridSettings,
} from './types';
import { getGridLayout, getOrderedLayout } from './utils/conversions';
import { isLayoutEqual } from './utils/equality_checks';
import { shouldShowMobileView } from './utils/mobile_view';
import { resolveGridRow } from './utils/resolve_grid_row';
export const useGridLayoutState = ({
layout,
@ -43,9 +54,9 @@ export const useGridLayoutState = ({
gridLayoutStateManager: GridLayoutStateManager;
setDimensionsRef: (instance: HTMLDivElement | null) => void;
} => {
const rowRefs = useRef<{ [rowId: string]: HTMLDivElement | null }>({});
const headerRefs = useRef<{ [rowId: string]: HTMLDivElement | null }>({});
const panelRefs = useRef<{ [rowId: string]: { [panelId: string]: HTMLDivElement | null } }>({});
const sectionRefs = useRef<{ [sectionId: string]: HTMLDivElement | null }>({});
const headerRefs = useRef<{ [sectionId: string]: HTMLDivElement | null }>({});
const panelRefs = useRef<{ [panelId: string]: HTMLDivElement | null }>({});
const { euiTheme } = useEuiTheme();
const expandedPanelId$ = useMemo(
@ -85,35 +96,41 @@ export const useGridLayoutState = ({
}, [gridSettings, runtimeSettings$]);
const gridLayoutStateManager = useMemo(() => {
const resolvedLayout = cloneDeep(layout);
Object.values(resolvedLayout).forEach((row) => {
resolvedLayout[row.id] = resolveGridRow(row);
});
const gridLayout$ = new BehaviorSubject<GridLayoutData>(resolvedLayout);
const proposedGridLayout$ = new BehaviorSubject<GridLayoutData | undefined>(undefined);
const orderedLayout = getOrderedLayout(layout);
const gridLayout$ = new BehaviorSubject<OrderedLayout>(orderedLayout);
const gridDimensions$ = new BehaviorSubject<ObservedSize>({ width: 0, height: 0 });
const interactionEvent$ = new BehaviorSubject<PanelInteractionEvent | undefined>(undefined);
const activePanel$ = new BehaviorSubject<ActivePanel | undefined>(undefined);
const activeRowEvent$ = new BehaviorSubject<ActiveRowEvent | undefined>(undefined);
const activePanelEvent$ = new BehaviorSubject<ActivePanelEvent | undefined>(undefined);
const activeSectionEvent$ = new BehaviorSubject<ActiveSectionEvent | undefined>(undefined);
const layoutUpdated$: Observable<GridLayoutData> = combineLatest([
gridLayout$,
merge(activePanelEvent$, activeSectionEvent$),
]).pipe(
// if an interaction event is happening, then ignore any "draft" layout changes
filter(([_, event]) => !Boolean(event)),
// once no interaction event, convert to the grid data format
map(([newLayout]) => getGridLayout(newLayout)),
// only emit if the layout has changed
distinctUntilChanged(isLayoutEqual)
);
return {
layoutRef,
rowRefs,
sectionRefs,
headerRefs,
panelRefs,
proposedGridLayout$,
gridLayout$,
activePanel$,
activeRowEvent$,
activePanelEvent$,
activeSectionEvent$,
accessMode$,
gridDimensions$,
runtimeSettings$,
interactionEvent$,
expandedPanelId$,
isMobileView$: new BehaviorSubject<boolean>(
shouldShowMobileView(accessMode, euiTheme.breakpoint.m)
),
layoutUpdated$,
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);

View file

@ -0,0 +1,352 @@
/*
* 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 { getSampleLayout, getSampleOrderedLayout } from '../test_utils/sample_layout';
import { GridLayoutData, OrderedLayout } from '../types';
import { getGridLayout, getOrderedLayout } from './conversions';
describe('conversions', () => {
describe('getGridLayout', () => {
it('should convert an ordered layout to a grid layout', () => {
const orderedLayout = getSampleOrderedLayout();
const result = getGridLayout(orderedLayout);
expect(result).toEqual(getSampleLayout());
});
it('should handle empty ordered layout', () => {
const orderedLayout: OrderedLayout = {};
const result = getGridLayout(orderedLayout);
expect(result).toEqual({});
});
it('should handle a single panel in getGridLayout', () => {
const orderedLayout: OrderedLayout = {
'main-0': {
id: 'main-0',
panels: {
panel1: { id: 'panel1', row: 0, column: 0, height: 2, width: 3 },
},
order: 0,
isMainSection: true,
},
};
const expectedGridLayout: GridLayoutData = {
panel1: { id: 'panel1', row: 0, column: 0, height: 2, width: 3, type: 'panel' },
};
const result = getGridLayout(orderedLayout);
expect(result).toEqual(expectedGridLayout);
});
it('should handle ordered layout with overlapping panels', () => {
const orderedLayout: OrderedLayout = {
'main-0': {
id: 'main-0',
panels: {
panel1: { id: 'panel1', row: 0, column: 0, height: 2, width: 3 },
panel2: { id: 'panel2', row: 1, column: 0, height: 2, width: 3 }, // 0verlaps with panel1
},
order: 0,
isMainSection: true,
},
};
const result = getGridLayout(orderedLayout);
expect(result).toEqual({
panel1: { id: 'panel1', row: 0, column: 0, height: 2, width: 3, type: 'panel' },
panel2: { id: 'panel2', row: 2, column: 0, height: 2, width: 3, type: 'panel' }, // pushed down
});
});
it('should handle ordered layout with invalid orders', () => {
const orderedLayout: OrderedLayout = {
'main-0': {
id: 'main-0',
panels: {
panel1: { id: 'panel1', row: 0, column: 0, height: 2, width: 3 },
},
order: 0,
isMainSection: true,
},
section2: {
id: 'section2',
order: 1,
isMainSection: false,
isCollapsed: false,
title: 'Some section',
panels: {},
},
section1: {
id: 'section1',
order: 100,
isMainSection: false,
isCollapsed: true,
title: 'Some floating section',
panels: {},
},
};
const result = getGridLayout(orderedLayout);
expect(result).toEqual({
panel1: { id: 'panel1', row: 0, column: 0, height: 2, width: 3, type: 'panel' },
section2: {
id: 'section2',
row: 2,
isCollapsed: false,
title: 'Some section',
panels: {},
type: 'section',
},
section1: {
id: 'section1',
row: 3,
isCollapsed: true,
title: 'Some floating section',
panels: {},
type: 'section',
},
});
});
it('should handle ordered layout with multiple main sections', () => {
const orderedLayout: OrderedLayout = {
'main-0': {
id: 'main-0',
panels: {
panel1: { id: 'panel1', row: 0, column: 0, height: 2, width: 3 },
},
order: 0,
isMainSection: true,
},
section1: {
id: 'section1',
order: 1,
isMainSection: false,
isCollapsed: false,
title: 'Some section',
panels: {},
},
'main-2': {
id: 'main-2',
panels: {
panel2: { id: 'panel2', row: 0, column: 0, height: 2, width: 3 },
},
order: 2,
isMainSection: true,
},
section2: {
id: 'section2',
order: 3,
isMainSection: false,
isCollapsed: true,
title: 'Some other section',
panels: {
panel2a: { id: 'panel2a', row: 0, column: 0, height: 2, width: 3 },
},
},
'main-4': {
id: 'main-4',
panels: {
panel3: { id: 'panel3', row: 0, column: 0, height: 2, width: 3 },
},
order: 4,
isMainSection: true,
},
};
const result = getGridLayout(orderedLayout);
expect(result).toEqual({
panel1: { id: 'panel1', row: 0, column: 0, height: 2, width: 3, type: 'panel' },
section1: {
id: 'section1',
row: 2,
isCollapsed: false,
title: 'Some section',
panels: {},
type: 'section',
},
panel2: { id: 'panel2', row: 3, column: 0, height: 2, width: 3, type: 'panel' },
section2: {
id: 'section2',
row: 5,
isCollapsed: true,
title: 'Some other section',
panels: { panel2a: { id: 'panel2a', row: 0, column: 0, height: 2, width: 3 } },
type: 'section',
},
panel3: { id: 'panel3', row: 6, column: 0, height: 2, width: 3, type: 'panel' },
});
});
});
describe('getOrderedLayout', () => {
it('should convert a grid layout to an ordered layout', () => {
const gridLayout = getSampleLayout();
const result = getOrderedLayout(gridLayout);
expect(result).toEqual(getSampleOrderedLayout());
});
it('should handle empty grid layout', () => {
const gridLayout: GridLayoutData = {};
const result = getOrderedLayout(gridLayout);
expect(result).toEqual({});
});
it('should handle a single panel in getOrderedLayout', () => {
const gridLayout: GridLayoutData = {
panel1: { id: 'panel1', row: 0, column: 0, height: 2, width: 3, type: 'panel' },
};
const expectedOrderedLayout: OrderedLayout = {
'main-0': {
id: 'main-0',
panels: {
panel1: { id: 'panel1', row: 0, column: 0, height: 2, width: 3 },
},
order: 0,
isMainSection: true,
},
};
const result = getOrderedLayout(gridLayout);
expect(result).toEqual(expectedOrderedLayout);
});
it('should handle grid layout with overlapping sections and panels', () => {
const gridLayout: GridLayoutData = {
panel1: { id: 'panel1', row: 0, column: 0, height: 2, width: 3, type: 'panel' },
panel2: { id: 'panel2', row: 1, column: 0, height: 2, width: 3, type: 'panel' }, // overlaps with panel1
};
const result = getOrderedLayout(gridLayout);
expect(result).toEqual({
'main-0': {
id: 'main-0',
panels: {
panel1: { id: 'panel1', row: 0, column: 0, height: 2, width: 3 },
panel2: { id: 'panel2', row: 2, column: 0, height: 2, width: 3 }, // pushed down
},
order: 0,
isMainSection: true,
},
});
});
it('should handle grid layout with floating sections', () => {
const gridLayout: GridLayoutData = {
panel1: { id: 'panel1', row: 0, column: 0, height: 2, width: 3, type: 'panel' },
section1: {
id: 'section1',
row: 100,
isCollapsed: true,
title: 'Some floating section',
panels: {},
type: 'section',
},
section2: {
id: 'section2',
row: 2,
isCollapsed: false,
title: 'Some section',
panels: {},
type: 'section',
},
};
const result = getOrderedLayout(gridLayout);
expect(result).toEqual({
'main-0': {
id: 'main-0',
panels: {
panel1: { id: 'panel1', row: 0, column: 0, height: 2, width: 3 },
},
order: 0,
isMainSection: true,
},
section2: {
id: 'section2',
order: 1,
isMainSection: false,
isCollapsed: false,
title: 'Some section',
panels: {},
},
section1: {
id: 'section1',
order: 2,
isMainSection: false,
isCollapsed: true,
title: 'Some floating section',
panels: {},
},
});
});
it('should handle grid layout with sections mixed between sections', () => {
const gridLayout: GridLayoutData = {
panel1: { id: 'panel1', row: 0, column: 0, height: 2, width: 3, type: 'panel' },
section1: {
id: 'section1',
row: 2,
isCollapsed: false,
title: 'Some section',
panels: {},
type: 'section',
},
panel2: { id: 'panel2', row: 3, column: 0, height: 2, width: 3, type: 'panel' },
section2: {
id: 'section2',
row: 5,
isCollapsed: true,
title: 'Some other section',
panels: { panel2a: { id: 'panel2a', row: 0, column: 0, height: 2, width: 3 } },
type: 'section',
},
panel3: { id: 'panel3', row: 6, column: 0, height: 2, width: 3, type: 'panel' },
};
const result = getOrderedLayout(gridLayout);
expect(result).toEqual({
'main-0': {
id: 'main-0',
panels: {
panel1: { id: 'panel1', row: 0, column: 0, height: 2, width: 3 },
},
order: 0,
isMainSection: true,
},
section1: {
id: 'section1',
order: 1,
isMainSection: false,
isCollapsed: false,
title: 'Some section',
panels: {},
},
'main-2': {
id: 'main-2',
panels: {
panel2: { id: 'panel2', row: 0, column: 0, height: 2, width: 3 },
},
order: 2,
isMainSection: true,
},
section2: {
id: 'section2',
order: 3,
isMainSection: false,
isCollapsed: true,
title: 'Some other section',
panels: {
panel2a: { id: 'panel2a', row: 0, column: 0, height: 2, width: 3 },
},
},
'main-4': {
id: 'main-4',
panels: {
panel3: { id: 'panel3', row: 0, column: 0, height: 2, width: 3 },
},
order: 4,
isMainSection: true,
},
});
});
});
});

View file

@ -0,0 +1,98 @@
/*
* 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 { cloneDeep } from 'lodash';
import type { GridPanelData } from '../grid_panel';
import type { GridSectionData } from '../grid_section';
import type { GridLayoutData, OrderedLayout } from '../types';
import { getLayoutInOrder, getSectionsInOrder, resolveGridSection } from './resolve_grid_section';
export const getGridLayout = (layout: OrderedLayout): GridLayoutData => {
let gridLayout: GridLayoutData = {};
let mainRow = 0;
getSectionsInOrder(layout).forEach((section) => {
const panels: { [key: string]: GridPanelData & { type?: 'panel' } } = cloneDeep(
resolveGridSection(section.panels)
);
if (section.isMainSection) {
const panelValues = Object.values(panels);
const maxRow =
panelValues.length > 0
? Math.max(...panelValues.map(({ row, height }) => row + height))
: 0;
panelValues.forEach((panel: GridPanelData & { type?: 'panel' }) => {
panel.row += mainRow;
panel.type = 'panel';
});
gridLayout = { ...gridLayout, ...panels } as GridLayoutData;
mainRow += maxRow;
} else {
const { order, isMainSection, ...rest } = section;
gridLayout[section.id] = {
...rest,
type: 'section',
row: mainRow,
};
mainRow++;
}
});
return gridLayout;
};
export const getOrderedLayout = (layout: GridLayoutData): OrderedLayout => {
const widgets = cloneDeep(getLayoutInOrder(layout));
const orderedLayout: OrderedLayout = {};
let order = 0;
let mainRow = 0;
for (let i = 0; i < widgets.length; i++) {
const { type, id } = widgets[i];
if (type === 'panel') {
orderedLayout[`main-${order}`] = {
id: `main-${order}`,
panels: {},
order,
isMainSection: true,
};
const startingRow = (layout[widgets[i].id] as GridPanelData).row;
let maxRow = -Infinity;
while (i < widgets.length && widgets[i].type === 'panel') {
const { type: drop, ...panel } = cloneDeep(layout[widgets[i].id]) as GridPanelData & {
type: 'panel';
}; // drop type
panel.row -= startingRow;
maxRow = Math.max(maxRow, panel.row + panel.height);
orderedLayout[`main-${order}`].panels[panel.id] = panel;
i++;
}
orderedLayout[`main-${order}`].panels = resolveGridSection(
orderedLayout[`main-${order}`].panels
);
i--;
mainRow += maxRow;
} else {
const sectionId = id;
const {
type: drop,
row,
...section
} = cloneDeep(layout[sectionId]) as GridSectionData & { type: 'section' }; // drop type and row
orderedLayout[sectionId] = {
...section,
order,
isMainSection: false,
panels: resolveGridSection(section.panels),
};
mainRow++;
}
order++;
}
return orderedLayout;
};

View file

@ -7,8 +7,8 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import deepEqual from 'fast-deep-equal';
import { GridLayoutData, GridPanelData } from '../types';
import type { GridPanelData } from '../grid_panel';
import type { GridLayoutData, OrderedLayout } from '../types';
export const isGridDataEqual = (a?: GridPanelData, b?: GridPanelData) => {
return (
@ -20,29 +20,66 @@ export const isGridDataEqual = (a?: GridPanelData, b?: GridPanelData) => {
);
};
export const isLayoutEqual = (a: GridLayoutData, b: GridLayoutData) => {
if (!deepEqual(Object.keys(a), Object.keys(b))) return false;
export const isOrderedSectionEqual = (a?: OrderedLayout[string], b?: OrderedLayout[string]) => {
if (!a || !b) {
return a === b; // early return for if one grid section is undefined
}
let isEqual =
a.id === b.id &&
a.order === b.order &&
Object.keys(a.panels).length === Object.keys(b.panels).length;
if (a.isMainSection && b.isMainSection) {
isEqual = isEqual && a.order === b.order;
} else if (!(a.isMainSection || b.isMainSection)) {
isEqual = isEqual && a.isCollapsed === b.isCollapsed && a.title === b.title;
} else {
return false;
}
for (const panelKey of Object.keys(a.panels)) {
if (!isEqual) break;
isEqual = isGridDataEqual(a.panels[panelKey], b.panels[panelKey]);
}
return isEqual;
};
export const isOrderedLayoutEqual = (a: OrderedLayout, b: OrderedLayout) => {
if (Object.keys(a).length !== Object.keys(b).length) return false;
let isEqual = true;
const keys = Object.keys(a); // keys of A are equal to keys of b
const sections = Object.keys(a); // keys of A are equal to keys of B
for (const sectionId of sections) {
const sectionA = a[sectionId];
const sectionB = b[sectionId];
isEqual = isOrderedSectionEqual(sectionA, sectionB);
if (!isEqual) break;
}
return isEqual;
};
export const isLayoutEqual = (a: GridLayoutData, b: GridLayoutData) => {
if (Object.keys(a).length !== Object.keys(b).length) return false;
let isEqual = true;
const keys = Object.keys(a); // keys of A are equal to keys of B
for (const key of keys) {
const rowA = a[key];
const rowB = b[key];
const widgetA = a[key];
const widgetB = b[key];
isEqual =
rowA.order === rowB.order &&
rowA.title === rowB.title &&
rowA.isCollapsed === rowB.isCollapsed &&
Object.keys(rowA.panels).length === Object.keys(rowB.panels).length;
if (widgetA.type === 'panel' && widgetB.type === 'panel') {
isEqual = isGridDataEqual(widgetA, widgetB);
} else if (widgetA.type === 'section' && widgetB.type === 'section') {
isEqual =
widgetA.row === widgetB.row &&
widgetA.title === widgetB.title &&
widgetA.isCollapsed === widgetB.isCollapsed &&
Object.keys(widgetA.panels).length === Object.keys(widgetB.panels).length;
if (isEqual) {
for (const panelKey of Object.keys(rowA.panels)) {
isEqual = isGridDataEqual(rowA.panels[panelKey], rowB.panels[panelKey]);
for (const panelKey of Object.keys(widgetA.panels)) {
if (!isEqual) break;
isEqual = isGridDataEqual(widgetA.panels[panelKey], widgetB.panels[panelKey]);
}
} else {
isEqual = widgetA.row === widgetB.row;
}
if (!isEqual) break;
}
return isEqual;
};

View file

@ -1,177 +0,0 @@
/*
* 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 { resolveGridRow } from './resolve_grid_row';
describe('resolve grid row', () => {
test('does nothing if grid row has no collisions', () => {
const gridRow = {
order: 0,
id: 'first',
title: 'Test',
isCollapsed: false,
panels: {
panel1: { id: 'panel1', row: 3, column: 0, height: 1, width: 7 },
panel2: { id: 'panel2', row: 4, column: 0, height: 1, width: 7 },
panel3: { id: 'panel3', row: 5, column: 0, height: 1, width: 7 },
panel4: { id: 'panel4', row: 0, column: 6, height: 3, width: 1 },
},
};
const result = resolveGridRow(gridRow);
expect(result).toEqual(gridRow);
});
test('resolves grid row if it has collisions without drag event', () => {
const result = resolveGridRow({
order: 0,
id: 'first',
title: 'Test',
isCollapsed: false,
panels: {
panel1: { id: 'panel1', row: 0, column: 0, height: 3, width: 4 },
panel2: { id: 'panel2', row: 3, column: 0, height: 2, width: 2 },
panel3: { id: 'panel3', row: 3, column: 2, height: 2, width: 2 },
panel4: { id: 'panel4', row: 0, column: 3, height: 5, width: 4 },
},
});
expect(result).toEqual({
order: 0,
id: 'first',
title: 'Test',
isCollapsed: false,
panels: {
panel1: { id: 'panel1', row: 0, column: 0, height: 3, width: 4 },
panel2: { id: 'panel2', row: 3, column: 0, height: 2, width: 2 },
panel3: { id: 'panel3', row: 8, column: 2, height: 2, width: 2 }, // pushed down
panel4: { id: 'panel4', row: 3, column: 3, height: 5, width: 4 }, // pushed down
},
});
});
test('drag causes no collision', () => {
const result = resolveGridRow(
{
order: 0,
id: 'first',
title: 'Test',
isCollapsed: false,
panels: {
panel1: { id: 'panel1', row: 0, column: 0, height: 1, width: 7 },
panel2: { id: 'panel2', row: 1, column: 0, height: 1, width: 7 },
panel3: { id: 'panel3', row: 2, column: 0, height: 1, width: 7 },
},
},
{ id: 'panel4', row: 0, column: 7, height: 3, width: 1 }
);
expect(result).toEqual({
order: 0,
id: 'first',
title: 'Test',
isCollapsed: false,
panels: {
panel1: { id: 'panel1', row: 0, column: 0, height: 1, width: 7 },
panel2: { id: 'panel2', row: 1, column: 0, height: 1, width: 7 },
panel3: { id: 'panel3', row: 2, column: 0, height: 1, width: 7 },
panel4: { id: 'panel4', row: 0, column: 7, height: 3, width: 1 },
},
});
});
test('drag causes collision with one panel that pushes down others', () => {
const result = resolveGridRow(
{
order: 0,
id: 'first',
title: 'Test',
isCollapsed: false,
panels: {
panel1: { id: 'panel1', row: 0, column: 0, height: 1, width: 7 },
panel2: { id: 'panel2', row: 1, column: 0, height: 1, width: 7 },
panel3: { id: 'panel3', row: 2, column: 0, height: 1, width: 8 },
panel4: { id: 'panel4', row: 3, column: 4, height: 3, width: 4 },
},
},
{ id: 'panel5', row: 2, column: 0, height: 3, width: 3 }
);
expect(result).toEqual({
order: 0,
id: 'first',
title: 'Test',
isCollapsed: false,
panels: {
panel1: { id: 'panel1', row: 0, column: 0, height: 1, width: 7 },
panel2: { id: 'panel2', row: 1, column: 0, height: 1, width: 7 },
panel3: { id: 'panel3', row: 5, column: 0, height: 1, width: 8 }, // pushed down
panel4: { id: 'panel4', row: 6, column: 4, height: 3, width: 4 }, // pushed down
panel5: { id: 'panel5', row: 2, column: 0, height: 3, width: 3 },
},
});
});
test('drag causes collision with multiple panels', () => {
const result = resolveGridRow(
{
order: 0,
id: 'first',
title: 'Test',
isCollapsed: false,
panels: {
panel1: { id: 'panel1', row: 0, column: 0, height: 3, width: 4 },
panel2: { id: 'panel2', row: 3, column: 0, height: 2, width: 2 },
panel3: { id: 'panel3', row: 3, column: 2, height: 2, width: 2 },
},
},
{ id: 'panel4', row: 0, column: 3, height: 5, width: 4 }
);
expect(result).toEqual({
order: 0,
id: 'first',
title: 'Test',
isCollapsed: false,
panels: {
panel1: { id: 'panel1', row: 5, column: 0, height: 3, width: 4 }, // pushed down
panel2: { id: 'panel2', row: 8, column: 0, height: 2, width: 2 }, // pushed down
panel3: { id: 'panel3', row: 8, column: 2, height: 2, width: 2 }, // pushed down
panel4: { id: 'panel4', row: 0, column: 3, height: 5, width: 4 },
},
});
});
test('drag causes collision with every panel', () => {
const result = resolveGridRow(
{
order: 0,
id: 'first',
title: 'Test',
isCollapsed: false,
panels: {
panel1: { id: 'panel1', row: 0, column: 0, height: 1, width: 7 },
panel2: { id: 'panel2', row: 1, column: 0, height: 1, width: 7 },
panel3: { id: 'panel3', row: 2, column: 0, height: 1, width: 7 },
},
},
{ id: 'panel4', row: 0, column: 6, height: 3, width: 1 }
);
expect(result).toEqual({
order: 0,
id: 'first',
title: 'Test',
isCollapsed: false,
panels: {
panel1: { id: 'panel1', row: 3, column: 0, height: 1, width: 7 },
panel2: { id: 'panel2', row: 4, column: 0, height: 1, width: 7 },
panel3: { id: 'panel3', row: 5, column: 0, height: 1, width: 7 },
panel4: { id: 'panel4', row: 0, column: 6, height: 3, width: 1 },
},
});
});
});

View file

@ -1,146 +0,0 @@
/*
* 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, 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 getFirstCollision = (gridLayout: GridRowData, keysInOrder: string[]): string | undefined => {
for (const panelA of keysInOrder) {
for (const panelB of keysInOrder) {
if (panelA === panelB) continue;
if (collides(gridLayout.panels[panelA], gridLayout.panels[panelB])) {
return panelA;
}
}
}
return undefined;
};
export const getRowKeysInOrder = (rows: GridLayoutData): string[] => {
return Object.values(rows)
.sort(({ order: orderA }, { order: orderB }) => orderA - orderB)
.map(({ id }) => id);
};
export const getPanelKeysInOrder = (
panels: GridRowData['panels'],
draggedId?: string
): string[] => {
const panelKeys = Object.keys(panels);
return panelKeys.sort((panelKeyA, panelKeyB) => {
const panelA = panels[panelKeyA];
const panelB = 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 = getPanelKeysInOrder(nextRowData.panels);
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 => {
let nextRowData = { ...originalRowData, panels: { ...originalRowData.panels } };
// apply drag request
if (dragRequest) {
nextRowData.panels[dragRequest.id] = dragRequest;
}
// get keys in order from top to bottom, left to right, with priority on the dragged item if it exists
const sortedKeys = getPanelKeysInOrder(nextRowData.panels, dragRequest?.id);
// while the layout has at least one collision, try to resolve them in order
let collision = getFirstCollision(nextRowData, sortedKeys);
while (collision !== undefined) {
nextRowData = resolvePanelCollisions(nextRowData, nextRowData.panels[collision], sortedKeys);
collision = getFirstCollision(nextRowData, sortedKeys);
}
return compactGridRow(nextRowData); // compact the grid to close any gaps
};
/**
* for each panel that collides with `panelToResolve`, push the colliding panel down by a single row and
* recursively handle any collisions that result from that move
*/
function resolvePanelCollisions(
rowData: GridRowData,
panelToResolve: GridPanelData,
keysInOrder: string[]
): GridRowData {
const collisions = getAllCollisionsWithPanel(panelToResolve, rowData, keysInOrder);
for (const collision of collisions) {
if (collision.id === panelToResolve.id) continue;
rowData.panels[collision.id].row++;
rowData = resolvePanelCollisions(
rowData,
rowData.panels[collision.id],
/**
* when recursively resolving any collisions that result from moving this colliding panel down,
* ignore if `collision` is still colliding with `panelToResolve` to prevent an infinite loop
*/
keysInOrder.filter((key) => key !== panelToResolve.id)
);
}
return rowData;
}

View file

@ -0,0 +1,110 @@
/*
* 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 { resolveGridSection } from './resolve_grid_section';
describe('resolve grid section', () => {
test('does nothing if grid section has no collisions', () => {
const panels = {
panel1: { id: 'panel1', row: 3, column: 0, height: 1, width: 7 },
panel2: { id: 'panel2', row: 4, column: 0, height: 1, width: 7 },
panel3: { id: 'panel3', row: 5, column: 0, height: 1, width: 7 },
panel4: { id: 'panel4', row: 0, column: 6, height: 3, width: 1 },
};
const result = resolveGridSection(panels);
expect(result).toEqual(panels);
});
test('resolves grid section if it has collisions without drag event', () => {
const result = resolveGridSection({
panel1: { id: 'panel1', row: 0, column: 0, height: 3, width: 4 },
panel2: { id: 'panel2', row: 3, column: 0, height: 2, width: 2 },
panel3: { id: 'panel3', row: 3, column: 2, height: 2, width: 2 },
panel4: { id: 'panel4', row: 0, column: 3, height: 5, width: 4 },
});
expect(result).toEqual({
panel1: { id: 'panel1', row: 0, column: 0, height: 3, width: 4 },
panel2: { id: 'panel2', row: 3, column: 0, height: 2, width: 2 },
panel3: { id: 'panel3', row: 8, column: 2, height: 2, width: 2 }, // pushed down
panel4: { id: 'panel4', row: 3, column: 3, height: 5, width: 4 }, // pushed down
});
});
test('drag causes no collision', () => {
const result = resolveGridSection(
{
panel1: { id: 'panel1', row: 0, column: 0, height: 1, width: 7 },
panel2: { id: 'panel2', row: 1, column: 0, height: 1, width: 7 },
panel3: { id: 'panel3', row: 2, column: 0, height: 1, width: 7 },
},
{ id: 'panel4', row: 0, column: 7, height: 3, width: 1 }
);
expect(result).toEqual({
panel1: { id: 'panel1', row: 0, column: 0, height: 1, width: 7 },
panel2: { id: 'panel2', row: 1, column: 0, height: 1, width: 7 },
panel3: { id: 'panel3', row: 2, column: 0, height: 1, width: 7 },
panel4: { id: 'panel4', row: 0, column: 7, height: 3, width: 1 },
});
});
test('drag causes collision with one panel that pushes down others', () => {
const result = resolveGridSection(
{
panel1: { id: 'panel1', row: 0, column: 0, height: 1, width: 7 },
panel2: { id: 'panel2', row: 1, column: 0, height: 1, width: 7 },
panel3: { id: 'panel3', row: 2, column: 0, height: 1, width: 8 },
panel4: { id: 'panel4', row: 3, column: 4, height: 3, width: 4 },
},
{ id: 'panel5', row: 2, column: 0, height: 3, width: 3 }
);
expect(result).toEqual({
panel1: { id: 'panel1', row: 0, column: 0, height: 1, width: 7 },
panel2: { id: 'panel2', row: 1, column: 0, height: 1, width: 7 },
panel3: { id: 'panel3', row: 5, column: 0, height: 1, width: 8 }, // pushed down
panel4: { id: 'panel4', row: 6, column: 4, height: 3, width: 4 }, // pushed down
panel5: { id: 'panel5', row: 2, column: 0, height: 3, width: 3 },
});
});
test('drag causes collision with multiple panels', () => {
const result = resolveGridSection(
{
panel1: { id: 'panel1', row: 0, column: 0, height: 3, width: 4 },
panel2: { id: 'panel2', row: 3, column: 0, height: 2, width: 2 },
panel3: { id: 'panel3', row: 3, column: 2, height: 2, width: 2 },
},
{ id: 'panel4', row: 0, column: 3, height: 5, width: 4 }
);
expect(result).toEqual({
panel1: { id: 'panel1', row: 5, column: 0, height: 3, width: 4 }, // pushed down
panel2: { id: 'panel2', row: 8, column: 0, height: 2, width: 2 }, // pushed down
panel3: { id: 'panel3', row: 8, column: 2, height: 2, width: 2 }, // pushed down
panel4: { id: 'panel4', row: 0, column: 3, height: 5, width: 4 },
});
});
test('drag causes collision with every panel', () => {
const result = resolveGridSection(
{
panel1: { id: 'panel1', row: 0, column: 0, height: 1, width: 7 },
panel2: { id: 'panel2', row: 1, column: 0, height: 1, width: 7 },
panel3: { id: 'panel3', row: 2, column: 0, height: 1, width: 7 },
},
{ id: 'panel4', row: 0, column: 6, height: 3, width: 1 }
);
expect(result).toEqual({
panel1: { id: 'panel1', row: 3, column: 0, height: 1, width: 7 },
panel2: { id: 'panel2', row: 4, column: 0, height: 1, width: 7 },
panel3: { id: 'panel3', row: 5, column: 0, height: 1, width: 7 },
panel4: { id: 'panel4', row: 0, column: 6, height: 3, width: 1 },
});
});
});

View file

@ -0,0 +1,192 @@
/*
* 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 { GridPanelData } from '../grid_panel';
import type { GridSectionData } from '../grid_section';
import type { GridLayoutData, GridLayoutWidget, OrderedLayout } 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: GridSectionData['panels'],
keysInOrder: string[]
): GridPanelData[] => {
const collidingPanels: GridPanelData[] = [];
for (const key of keysInOrder) {
const comparePanel = gridLayout[key];
if (comparePanel.id === panelToCheck.id) continue;
if (collides(panelToCheck, comparePanel)) {
collidingPanels.push(comparePanel);
}
}
return collidingPanels;
};
const getFirstCollision = (
gridLayout: GridSectionData['panels'],
keysInOrder: string[]
): string | undefined => {
for (const panelA of keysInOrder) {
for (const panelB of keysInOrder) {
if (panelA === panelB) continue;
if (collides(gridLayout[panelA], gridLayout[panelB])) {
return panelA;
}
}
}
return undefined;
};
export const getLayoutInOrder = (
layout: GridLayoutData,
draggedId?: string
): Array<{ type: 'panel' | 'section'; id: string }> => {
const widgetIds = Object.keys(layout);
const idsInorder = widgetIds.sort((widgetKeyA, widgetKeyB) => {
const widgetA = layout[widgetKeyA];
const widgetB = layout[widgetKeyB];
if (widgetA.type === 'panel' && widgetB.type === 'panel') {
return comparePanel(widgetA, widgetB, draggedId);
} else if (widgetA.type !== widgetB.type) {
if (widgetA.type === 'panel') {
// widgetB is a section
const [panel, section] = [widgetA as GridPanelData, widgetB as GridSectionData];
return panel.row - section.row;
} else {
// widgetA is a section
const [panel, section] = [widgetB as GridPanelData, widgetA as GridSectionData];
return section.row - panel.row;
}
} else {
return compareRow(widgetA, widgetB);
}
});
return idsInorder.map((id) => ({ id, type: layout[id].type }));
};
export const getPanelKeysInOrder = (
panels: GridSectionData['panels'],
draggedId?: string
): string[] => {
const panelKeys = Object.keys(panels);
return panelKeys.sort((panelKeyA, panelKeyB) => {
const panelA = panels[panelKeyA];
const panelB = panels[panelKeyB];
return comparePanel(panelA, panelB, draggedId);
});
};
export const getSectionsInOrder = (layout: OrderedLayout) => {
return Object.values(layout).sort(({ order: orderA }, { order: orderB }) => {
return orderA - orderB;
});
};
const compareRow = (widgetA: GridLayoutWidget, widgetB: GridLayoutWidget) => {
if (widgetA.row > widgetB.row) return 1;
return -1;
};
const comparePanel = (panelA: GridPanelData, panelB: GridPanelData, draggedId?: string) => {
// 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 compactGridSection = (originalLayout: GridSectionData['panels']) => {
const nextSectionData = { ...originalLayout };
// compact all vertical space.
const sortedKeysAfterMove = getPanelKeysInOrder(nextSectionData);
for (const panelKey of sortedKeysAfterMove) {
const panel = nextSectionData[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 },
nextSectionData,
sortedKeysAfterMove
);
if (collisions.length !== 0) break;
panel.row -= 1;
}
}
return nextSectionData;
};
export const resolveGridSection = (
originalSectionData: GridSectionData['panels'],
dragRequest?: GridPanelData
): GridSectionData['panels'] => {
let nextSectionData = { ...originalSectionData };
// apply drag request
if (dragRequest) {
nextSectionData[dragRequest.id] = dragRequest;
}
// get keys in order from top to bottom, left to right, with priority on the dragged item if it exists
const sortedKeys = getPanelKeysInOrder(nextSectionData, dragRequest?.id);
// while the layout has at least one collision, try to resolve them in order
let collision = getFirstCollision(nextSectionData, sortedKeys);
while (collision !== undefined) {
nextSectionData = resolvePanelCollisions(
nextSectionData,
nextSectionData[collision],
sortedKeys
);
collision = getFirstCollision(nextSectionData, sortedKeys);
}
return compactGridSection(nextSectionData); // compact the grid to close any gaps
};
/**
* for each panel that collides with `panelToResolve`, push the colliding panel down by a single row and
* recursively handle any collisions that result from that move
*/
function resolvePanelCollisions(
rowData: GridSectionData['panels'],
panelToResolve: GridPanelData,
keysInOrder: string[]
): GridSectionData['panels'] {
const collisions = getAllCollisionsWithPanel(panelToResolve, rowData, keysInOrder);
for (const collision of collisions) {
if (collision.id === panelToResolve.id) continue;
rowData[collision.id].row++;
rowData = resolvePanelCollisions(
rowData,
rowData[collision.id],
/**
* when recursively resolving any collisions that result from moving this colliding panel down,
* ignore if `collision` is still colliding with `panelToResolve` to prevent an infinite loop
*/
keysInOrder.filter((key) => key !== panelToResolve.id)
);
}
return rowData;
}

View file

@ -1,92 +0,0 @@
/*
* 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 { omit } from 'lodash';
import { getSampleLayout } from '../test_utils/sample_layout';
import { GridLayoutData } from '../types';
import { deleteRow, movePanelsToRow } from './row_management';
describe('row management', () => {
describe('move panels to row', () => {
const checkPanelCountsAfterMove = (
originalLayout: GridLayoutData,
newLayout: GridLayoutData,
startingRow: string,
newRow: string
) => {
// panels are removed from the starting row
expect(newLayout[startingRow].panels).toEqual({});
// and added to the new row
expect(Object.keys(newLayout[newRow].panels).length).toEqual(
Object.keys(originalLayout[newRow].panels).length +
Object.keys(originalLayout[startingRow].panels).length
);
};
it('move panels from one row to another populated row', () => {
const originalLayout = getSampleLayout();
const newLayout = movePanelsToRow(originalLayout, 'third', 'first');
checkPanelCountsAfterMove(originalLayout, newLayout, 'third', 'first');
// existing panels in new row do not move
Object.values(originalLayout.first.panels).forEach((panel) => {
expect(panel).toEqual(newLayout.first.panels[panel.id]); // deep equal
});
// only the new panel's row is different, since no compaction was necessary
const newPanel = newLayout.first.panels.panel10;
expect(newPanel.row).toBe(14);
expect(omit(newPanel, 'row')).toEqual(omit(originalLayout.third.panels.panel10, 'row'));
});
it('move panels from one row to another empty row', () => {
const originalLayout = {
first: {
title: 'Large section',
isCollapsed: false,
id: 'first',
order: 0,
panels: {},
},
second: {
title: 'Another section',
isCollapsed: false,
id: 'second',
order: 1,
panels: getSampleLayout().first.panels,
},
};
const newLayout = movePanelsToRow(originalLayout, 'second', 'first');
checkPanelCountsAfterMove(originalLayout, newLayout, 'second', 'first');
// if no panels in new row, then just send all panels to new row with no changes
Object.values(originalLayout.second.panels).forEach((panel) => {
expect(panel).toEqual(newLayout.first.panels[panel.id]); // deep equal
});
});
});
describe('delete row', () => {
it('delete existing row', () => {
const originalLayout = getSampleLayout();
const newLayout = deleteRow(originalLayout, 'first');
// modification happens by value and not by reference
expect(originalLayout.first).toBeDefined();
expect(newLayout.first).not.toBeDefined();
});
it('delete non-existant row', () => {
const originalLayout = getSampleLayout();
expect(() => {
const newLayout = deleteRow(originalLayout, 'fake');
expect(newLayout.fake).not.toBeDefined();
}).not.toThrow();
});
});
});

View file

@ -1,46 +0,0 @@
/*
* 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 { cloneDeep } from 'lodash';
import { GridLayoutData } from '../types';
import { resolveGridRow } from './resolve_grid_row';
/**
* Move the panels in the `startingRow` to the bottom of the `newRow` and resolve the resulting layout
* @param layout Starting layout
* @param startingRow The source row for the panels
* @param newRow The destination row for the panels
* @returns Updated layout with panels moved from `startingRow` to `newRow`
*/
export const movePanelsToRow = (layout: GridLayoutData, startingRow: string, newRow: string) => {
const newLayout = cloneDeep(layout);
const panelsToMove = newLayout[startingRow].panels;
const startingPanels = Object.values(newLayout[newRow].panels);
const maxRow =
startingPanels.length > 0
? Math.max(...startingPanels.map(({ row, height }) => row + height))
: 0;
Object.keys(panelsToMove).forEach((index) => (panelsToMove[index].row += maxRow));
newLayout[newRow].panels = { ...newLayout[newRow].panels, ...panelsToMove };
newLayout[newRow] = resolveGridRow(newLayout[newRow]);
newLayout[startingRow] = { ...newLayout[startingRow], panels: {} };
return newLayout;
};
/**
* Deletes an entire row from the layout, including all of its panels
* @param layout Starting layout
* @param rowIndex The row to be deleted
* @returns Updated layout with the row at `rowIndex` deleted
*/
export const deleteRow = (layout: GridLayoutData, rowId: string) => {
const newLayout = cloneDeep(layout);
delete newLayout[rowId];
return newLayout;
};

View file

@ -0,0 +1,61 @@
/*
* 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 { omit } from 'lodash';
import { getSampleOrderedLayout } from '../test_utils/sample_layout';
import { combinePanels, deleteSection } from './section_management';
describe('section management', () => {
describe('combine panels', () => {
it('move panels from one row to another populated row', () => {
const originalLayout = getSampleOrderedLayout();
const combined = combinePanels(originalLayout.third.panels, originalLayout['main-0'].panels);
expect(Object.keys(combined).length).toEqual(
Object.keys(originalLayout.third.panels).length +
Object.keys(originalLayout['main-0'].panels).length
);
// only the new panel's row is different, since no compaction was necessary
const newPanel = combined.panel10;
expect(newPanel.row).toBe(14);
expect(omit(newPanel, 'row')).toEqual(omit(originalLayout.third.panels.panel10, 'row'));
});
it('move panels from one row to another empty row', () => {
const originalLayout = getSampleOrderedLayout();
const combined = combinePanels({}, originalLayout.second.panels);
expect(Object.keys(combined).length).toEqual(
Object.keys(originalLayout.second.panels).length
);
// if no panels in new row, then just send all panels to new row with no changes
Object.values(originalLayout.second.panels).forEach((panel) => {
expect(panel).toEqual(combined[panel.id]); // deep equal
});
});
});
describe('delete row', () => {
it('delete existing row', () => {
const originalLayout = getSampleOrderedLayout();
const newLayout = deleteSection(originalLayout, 'second');
// modification happens by value and not by reference
expect(originalLayout.second).toBeDefined();
expect(newLayout.second).not.toBeDefined();
});
it('delete non-existant row', () => {
const originalLayout = getSampleOrderedLayout();
expect(() => {
const newLayout = deleteSection(originalLayout, 'fake');
expect(newLayout.fake).not.toBeDefined();
}).not.toThrow();
});
});
});

View file

@ -0,0 +1,87 @@
/*
* 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 { cloneDeep } from 'lodash';
import type { CollapsibleSection, GridSectionData, MainSection } from '../grid_section';
import type { OrderedLayout } from '../types';
import { getSectionsInOrder, resolveGridSection } from './resolve_grid_section';
/**
* Move the panels in the `startingSection` to the bottom of the `newSection` and resolve the resulting panels
* @param startingSectionPanels The source section for the panels
* @param newSectionPanels The destination section for the panels
* @returns Combined panel list
*/
export const combinePanels = (
startingSectionPanels: GridSectionData['panels'],
newSectionPanels: GridSectionData['panels']
): GridSectionData['panels'] => {
const panelsToMove = cloneDeep(startingSectionPanels);
const startingPanels = Object.values(newSectionPanels);
const maxRow =
startingPanels.length > 0
? Math.max(...startingPanels.map(({ row, height }) => row + height))
: 0;
Object.keys(panelsToMove).forEach((index) => (panelsToMove[index].row += maxRow));
const resolvedPanels = resolveGridSection({ ...newSectionPanels, ...panelsToMove });
return resolvedPanels;
};
/**
* Deletes an entire section from the layout, including all of its panels
* @param layout Starting layout
* @param sectionId The section to be deleted
* @returns Updated layout with the section at `sectionId` deleted and orders adjusted
*/
export const deleteSection = (layout: OrderedLayout, sectionId: string) => {
const newLayout = cloneDeep(layout);
delete newLayout[sectionId];
return resolveSections(newLayout);
};
/**
* Combine sequential main layouts and redefine section orders to keep layout consistent + valid
* @param layout Starting layout
* @returns Updated layout with `main` sections combined + section orders resolved
*/
export const resolveSections = (layout: OrderedLayout) => {
const sortedSections = getSectionsInOrder(layout);
const resolvedLayout: OrderedLayout = {};
let order = 0;
for (let i = 0; i < sortedSections.length; i++) {
const firstSection = sortedSections[i];
if (firstSection.isMainSection && Object.keys(firstSection.panels).length === 0) {
// do not include empty main sections
continue;
}
if (firstSection.isMainSection) {
let combinedPanels: GridSectionData['panels'] = { ...firstSection.panels };
while (i + 1 < sortedSections.length) {
const secondSection = sortedSections[i + 1];
if (!secondSection.isMainSection) break;
combinedPanels = combinePanels(secondSection.panels, combinedPanels);
i++;
}
resolvedLayout[`main-${order}`] = {
...firstSection,
order,
panels: combinedPanels,
id: `main-${order}`,
};
} else {
resolvedLayout[firstSection.id] = { ...firstSection, order };
}
order++;
}
return resolvedLayout;
};
export const isCollapsibleSection = (
section: CollapsibleSection | MainSection
): section is CollapsibleSection => !section.isMainSection;

View file

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

View file

@ -14,7 +14,6 @@ import { GridLayout, type GridLayoutData } from '@kbn/grid-layout';
import { useBatchedPublishingSubjects } from '@kbn/presentation-publishing';
import classNames from 'classnames';
import React, { useCallback, useMemo, useRef } from 'react';
import { v4 as uuidv4 } from 'uuid';
import { DASHBOARD_GRID_COLUMN_COUNT } from '../../../common/content_management/constants';
import { arePanelLayoutsEqual } from '../../dashboard_api/are_panel_layouts_equal';
import { DashboardLayout } from '../../dashboard_api/types';
@ -39,7 +38,6 @@ export const DashboardGrid = ({
const layoutStyles = useLayoutStyles();
const panelRefs = useRef<{ [panelId: string]: React.Ref<HTMLDivElement> }>({});
const { euiTheme } = useEuiTheme();
const firstRowId = useRef(uuidv4());
const [expandedPanelId, layout, useMargins, viewMode] = useBatchedPublishingSubjects(
dashboardApi.expandedPanelId$,
@ -51,22 +49,17 @@ export const DashboardGrid = ({
const appFixedViewport = useAppFixedViewport();
const currentLayout: GridLayoutData = useMemo(() => {
const singleRow: GridLayoutData[string] = {
id: firstRowId.current,
order: 0,
title: '', // we only support a single section currently, and it does not have a title
isCollapsed: false,
panels: {},
};
const singleRow: GridLayoutData = {};
Object.keys(layout).forEach((panelId) => {
const gridData = layout[panelId].gridData;
singleRow.panels[panelId] = {
singleRow[panelId] = {
id: panelId,
row: gridData.y,
column: gridData.x,
width: gridData.w,
height: gridData.h,
type: 'panel',
};
// update `data-grid-row` attribute for all panels because it is used for some styling
const panelRef = panelRefs.current[panelId];
@ -75,7 +68,7 @@ export const DashboardGrid = ({
}
});
return { [firstRowId.current]: singleRow };
return singleRow;
}, [layout]);
const onLayoutChange = useCallback(
@ -83,21 +76,25 @@ export const DashboardGrid = ({
if (viewMode !== 'edit') return;
const currentPanels = dashboardInternalApi.layout$.getValue();
const updatedPanels: DashboardLayout = Object.values(
newLayout[firstRowId.current].panels
).reduce((updatedPanelsAcc, panelLayout) => {
updatedPanelsAcc[panelLayout.id] = {
...currentPanels[panelLayout.id],
gridData: {
i: panelLayout.id,
y: panelLayout.row,
x: panelLayout.column,
w: panelLayout.width,
h: panelLayout.height,
},
};
return updatedPanelsAcc;
}, {} as DashboardLayout);
const updatedPanels: DashboardLayout = Object.values(newLayout).reduce(
(updatedPanelsAcc, widget) => {
if (widget.type === 'section') {
return updatedPanelsAcc; // sections currently aren't supported
}
updatedPanelsAcc[widget.id] = {
...currentPanels[widget.id],
gridData: {
i: widget.id,
y: widget.row,
x: widget.column,
w: widget.width,
h: widget.height,
},
};
return updatedPanelsAcc;
},
{} as DashboardLayout
);
if (!arePanelLayoutsEqual(currentPanels, updatedPanels)) {
dashboardInternalApi.layout$.next(updatedPanels);
}

View file

@ -40,24 +40,14 @@ export const useLayoutStyles = () => {
${euiTheme.border.width.thin} 0 ${euiTheme.colors.vis.euiColorVis0},
0 -${euiTheme.border.width.thin} ${euiTheme.colors.vis.euiColorVis0};
&.kbnGrid {
// remove margin top + bottom on grid in favour of padding in row
padding-bottom: 0px;
}
.kbnGridRow {
// use padding in grid row so that dotted grid is not cut off
padding-bottom: calc(var(--kbnGridGutterSize) * 1px);
&--targeted {
background-position: top calc((var(--kbnGridGutterSize) / 2) * -1px) left
calc((var(--kbnGridGutterSize) / 2) * -1px);
background-size: calc((var(--kbnGridColumnWidth) + var(--kbnGridGutterSize)) * 1px)
calc((var(--kbnGridRowHeight) + var(--kbnGridGutterSize)) * 1px);
background-image: ${getRadialGradient('top left')}, ${getRadialGradient('top right')},
${getRadialGradient('bottom left')}, ${getRadialGradient('bottom right')};
background-origin: content-box;
}
.kbnGridSection--targeted {
background-position: top calc((var(--kbnGridGutterSize) / 2) * -1px) left
calc((var(--kbnGridGutterSize) / 2) * -1px);
background-size: calc((var(--kbnGridColumnWidth) + var(--kbnGridGutterSize)) * 1px)
calc((var(--kbnGridRowHeight) + var(--kbnGridGutterSize)) * 1px);
background-image: ${getRadialGradient('top left')}, ${getRadialGradient('top right')},
${getRadialGradient('bottom left')}, ${getRadialGradient('bottom right')};
background-origin: content-box;
}
.kbnGridPanel--dragPreview {

View file

@ -5738,7 +5738,7 @@
"kbnConfig.deprecations.unusedSettingMessage": "Vous navez plus besoin de configurer \"{fullPath}\".",
"kbnGridLayout.dragHandle.ariaLabel": "Faire glisser pour déplacer",
"kbnGridLayout.resizeHandle.ariaLabel": "Redimensionner le panneau",
"kbnGridLayout.row.toggleCollapse": "Basculer vers la réduction",
"kbnGridLayout.section.toggleCollapse": "Basculer vers la réduction",
"kibana_utils.history.savedObjectIsMissingNotificationMessage": "L'objet enregistré est manquant.",
"kibana_utils.stateManagement.stateHash.unableToRestoreUrlErrorMessage": "Impossible de restaurer complètement l'URL. Assurez-vous d'utiliser la fonctionnalité de partage.",
"kibana_utils.stateManagement.stateHash.unableToStoreHistoryInSessionErrorMessage": "Kibana n'est pas en mesure de stocker des éléments d'historique dans votre session, car le stockage est arrivé à saturation et il ne semble pas y avoir d'éléments pouvant être supprimés sans risque. Ce problème peut généralement être corrigé en passant à un nouvel onglet, mais il peut être causé par un problème plus important. Si ce message s'affiche régulièrement, veuillez nous en faire part sur {gitHubIssuesUrl}.",

View file

@ -5732,7 +5732,7 @@
"kbnConfig.deprecations.unusedSettingMessage": "\"{fullPath}\"を構成する必要はありません。",
"kbnGridLayout.dragHandle.ariaLabel": "ドラッグして移動します",
"kbnGridLayout.resizeHandle.ariaLabel": "パネルのサイズを変更",
"kbnGridLayout.row.toggleCollapse": "折りたたみを切り替える",
"kbnGridLayout.section.toggleCollapse": "折りたたみを切り替える",
"kibana_utils.history.savedObjectIsMissingNotificationMessage": "保存されたオブジェクトがありません",
"kibana_utils.stateManagement.stateHash.unableToRestoreUrlErrorMessage": "URL を完全に復元できません。共有機能を使用していることを確認してください。",
"kibana_utils.stateManagement.stateHash.unableToStoreHistoryInSessionErrorMessage": "セッションがいっぱいで安全に削除できるアイテムが見つからないため、Kibana は履歴アイテムを保存できません。これは大抵新規タブに移動することで解決されますが、より大きな問題が原因である可能性もあります。このメッセージが定期的に表示される場合は、{gitHubIssuesUrl} で問題を報告してください。",

View file

@ -5742,7 +5742,7 @@
"kbnConfig.deprecations.unusedSettingMessage": "您不再需要配置“{fullPath}”。",
"kbnGridLayout.dragHandle.ariaLabel": "拖动以移动",
"kbnGridLayout.resizeHandle.ariaLabel": "调整面板大小",
"kbnGridLayout.row.toggleCollapse": "切换折叠",
"kbnGridLayout.section.toggleCollapse": "切换折叠",
"kibana_utils.history.savedObjectIsMissingNotificationMessage": "已保存对象缺失",
"kibana_utils.stateManagement.stateHash.unableToRestoreUrlErrorMessage": "无法完全还原 URL请确保使用共享功能。",
"kibana_utils.stateManagement.stateHash.unableToStoreHistoryInSessionErrorMessage": "Kibana 无法将历史记录项存储在您的会话中,因为其已满,另外,似乎没有任何可安全删除的项目。通常,这可以通过移到全新的选项卡来解决,但这种情况可能是由更大的问题造成。如果您定期看到这个消息,请在 {gitHubIssuesUrl} 报告问题。",