[8.x] [kbn-grid-layout] Allow rows to be reordered (#213166) (#215217)

# Backport

This will backport the following commits from `main` to `8.x`:
- [[kbn-grid-layout] Allow rows to be reordered
(#213166)](https://github.com/elastic/kibana/pull/213166)

<!--- Backport version: 9.6.6 -->

### Questions ?
Please refer to the [Backport tool
documentation](https://github.com/sorenlouv/backport)

<!--BACKPORT [{"author":{"name":"Hannah
Mudge","email":"Heenawter@users.noreply.github.com"},"sourceCommit":{"committedDate":"2025-03-19T17:12:35Z","message":"[kbn-grid-layout]
Allow rows to be reordered (#213166)\n\nCloses
https://github.com/elastic/kibana/issues/190381\n\n## Summary\n\nThis PR
adds the ability to drag and drop rows by their headers in order\nto
reorder
them:\n\n![Mar-12-2025\n16-07-04](https://github.com/user-attachments/assets/de6afb8e-f009-4c00-b1dc-4804769e54eb)\n\nIt
can be a bit confusing dragging section headers around when
other\nsections are expanded - it is easy to lose track of them,
especially\nwhen the expanded sections are very large. I experimented
with\nauto-collapsing all sections on drag, but this felt
extremely\ndisorienting because you instantly lost all of your context -
so, to\nimprove the UI here, I added a \"scroll to\" effect on drop like
so:\n\n\n\nhttps://github.com/user-attachments/assets/0b519783-a4f5-4590-9a1c-580df66a2f66\n\nReminder
that, to test this feature, you need to run Kibana with\nexamples via
`yarn start --run-examples` and navigate to the grid\nexamples app via
`Analytics > Developer examples > Grid Example`.\n\n### Checklist\n\n-
[x] [Unit or
functional\ntests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)\nwere
updated or added to match the most common scenarios\n- [x] The PR
description includes the appropriate Release Notes section,\nand the
correct `release_note:*` label is applied per
the\n[guidelines](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process)\n\n###
Identify risks\n\nCollapsible sections are not available on Dashboard
yet and so there is\nno user-facing risk to this
PR.","sha":"05db9e9597ad874e6db5a6fd203a089752007b79","branchLabelMapping":{"^v9.1.0$":"main","^v8.19.0$":"8.x","^v(\\d+).(\\d+).\\d+$":"$1.$2"}},"sourcePullRequest":{"labels":["Team:Presentation","loe:large","release_note:skip","impact:high","Project:Collapsable
Panels","backport:version","v9.1.0","v8.19.0"],"title":"[kbn-grid-layout]
Allow rows to be
reordered","number":213166,"url":"https://github.com/elastic/kibana/pull/213166","mergeCommit":{"message":"[kbn-grid-layout]
Allow rows to be reordered (#213166)\n\nCloses
https://github.com/elastic/kibana/issues/190381\n\n## Summary\n\nThis PR
adds the ability to drag and drop rows by their headers in order\nto
reorder
them:\n\n![Mar-12-2025\n16-07-04](https://github.com/user-attachments/assets/de6afb8e-f009-4c00-b1dc-4804769e54eb)\n\nIt
can be a bit confusing dragging section headers around when
other\nsections are expanded - it is easy to lose track of them,
especially\nwhen the expanded sections are very large. I experimented
with\nauto-collapsing all sections on drag, but this felt
extremely\ndisorienting because you instantly lost all of your context -
so, to\nimprove the UI here, I added a \"scroll to\" effect on drop like
so:\n\n\n\nhttps://github.com/user-attachments/assets/0b519783-a4f5-4590-9a1c-580df66a2f66\n\nReminder
that, to test this feature, you need to run Kibana with\nexamples via
`yarn start --run-examples` and navigate to the grid\nexamples app via
`Analytics > Developer examples > Grid Example`.\n\n### Checklist\n\n-
[x] [Unit or
functional\ntests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)\nwere
updated or added to match the most common scenarios\n- [x] The PR
description includes the appropriate Release Notes section,\nand the
correct `release_note:*` label is applied per
the\n[guidelines](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process)\n\n###
Identify risks\n\nCollapsible sections are not available on Dashboard
yet and so there is\nno user-facing risk to this
PR.","sha":"05db9e9597ad874e6db5a6fd203a089752007b79"}},"sourceBranch":"main","suggestedTargetBranches":["8.x"],"targetPullRequestStates":[{"branch":"main","label":"v9.1.0","branchLabelMappingKey":"^v9.1.0$","isSourceBranch":true,"state":"MERGED","url":"https://github.com/elastic/kibana/pull/213166","number":213166,"mergeCommit":{"message":"[kbn-grid-layout]
Allow rows to be reordered (#213166)\n\nCloses
https://github.com/elastic/kibana/issues/190381\n\n## Summary\n\nThis PR
adds the ability to drag and drop rows by their headers in order\nto
reorder
them:\n\n![Mar-12-2025\n16-07-04](https://github.com/user-attachments/assets/de6afb8e-f009-4c00-b1dc-4804769e54eb)\n\nIt
can be a bit confusing dragging section headers around when
other\nsections are expanded - it is easy to lose track of them,
especially\nwhen the expanded sections are very large. I experimented
with\nauto-collapsing all sections on drag, but this felt
extremely\ndisorienting because you instantly lost all of your context -
so, to\nimprove the UI here, I added a \"scroll to\" effect on drop like
so:\n\n\n\nhttps://github.com/user-attachments/assets/0b519783-a4f5-4590-9a1c-580df66a2f66\n\nReminder
that, to test this feature, you need to run Kibana with\nexamples via
`yarn start --run-examples` and navigate to the grid\nexamples app via
`Analytics > Developer examples > Grid Example`.\n\n### Checklist\n\n-
[x] [Unit or
functional\ntests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)\nwere
updated or added to match the most common scenarios\n- [x] The PR
description includes the appropriate Release Notes section,\nand the
correct `release_note:*` label is applied per
the\n[guidelines](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process)\n\n###
Identify risks\n\nCollapsible sections are not available on Dashboard
yet and so there is\nno user-facing risk to this
PR.","sha":"05db9e9597ad874e6db5a6fd203a089752007b79"}},{"branch":"8.x","label":"v8.19.0","branchLabelMappingKey":"^v8.19.0$","isSourceBranch":false,"state":"NOT_CREATED"}]}]
BACKPORT-->

Co-authored-by: Hannah Mudge <Heenawter@users.noreply.github.com>
This commit is contained in:
Kibana Machine 2025-03-19 20:04:09 +01:00 committed by GitHub
parent 872d103b7e
commit 76b896fbb0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 686 additions and 203 deletions

View file

@ -23,8 +23,8 @@ import {
EuiFlexItem,
EuiPageTemplate,
EuiSpacer,
UseEuiTheme,
transparentize,
useEuiTheme,
} from '@elastic/eui';
import { css } from '@emotion/react';
import { AppMountParameters } from '@kbn/core-application-browser';
@ -58,8 +58,6 @@ export const GridExample = ({
coreStart: CoreStart;
uiActions: UiActionsStart;
}) => {
const { euiTheme } = useEuiTheme();
const savedState = useRef<MockSerializedDashboardState>(getSerializedDashboardState());
const [hasUnsavedChanges, setHasUnsavedChanges] = useState<boolean>(false);
const [currentLayout, setCurrentLayout] = useState<GridLayoutData>(
@ -90,8 +88,8 @@ export const GridExample = ({
const currentPanel = panels[panelId];
const savedPanel = savedState.current.panels[panelId];
panelsAreEqual = deepEqual(
{ row: 'first', ...currentPanel.gridData },
{ row: 'first', ...savedPanel.gridData }
{ row: 'first', ...currentPanel?.gridData },
{ row: 'first', ...savedPanel?.gridData }
);
}
const hasChanges = !(panelsAreEqual && deepEqual(rows, savedState.current.rows));
@ -173,41 +171,6 @@ export const GridExample = ({
mockDashboardApi.rows$.next(rows);
}, [mockDashboardApi.panels$, mockDashboardApi.rows$]);
const customLayoutCss = useMemo(() => {
const gridColor = transparentize(euiTheme.colors.backgroundFilledAccentSecondary, 0.2);
return css`
.kbnGridRow--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: linear-gradient(to right, ${gridColor} 1px, transparent 1px),
linear-gradient(to bottom, ${gridColor} 1px, transparent 1px);
background-color: ${transparentize(euiTheme.colors.backgroundFilledAccentSecondary, 0.1)};
}
.kbnGridPanel--dragPreview {
border-radius: ${euiTheme.border.radius};
background-color: ${transparentize(euiTheme.colors.backgroundFilledAccentSecondary, 0.2)};
transition: opacity 100ms linear;
}
.kbnGridPanel--resizeHandle {
opacity: 0;
transition: opacity 0.2s, border 0.2s;
border-radius: 7px 0 7px 0;
border-bottom: 2px solid ${euiTheme.colors.accentSecondary};
border-right: 2px solid ${euiTheme.colors.accentSecondary};
&:hover,
&:focus {
outline-style: none !important;
opacity: 1;
background-color: ${transparentize(euiTheme.colors.accentSecondary, 0.05)};
}
}
`;
}, [euiTheme]);
return (
<KibanaRenderContextProvider {...coreStart}>
<EuiPageTemplate grow={false} offset={0} restrictWidth={false}>
@ -314,7 +277,7 @@ export const GridExample = ({
useCustomDragHandle={true}
renderPanelContents={renderPanelContents}
onLayoutChange={onLayoutChange}
css={customLayoutCss}
css={layoutStyles}
/>
</EuiPageTemplate.Section>
</EuiPageTemplate>
@ -330,3 +293,50 @@ export const renderGridExampleApp = (
return () => ReactDOM.unmountComponentAtNode(element);
};
const layoutStyles = ({ euiTheme }: UseEuiTheme) => {
const gridColor = transparentize(euiTheme.colors.backgroundFilledAccentSecondary, 0.2);
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)}`,
},
},
// styling for what the grid row header looks like when being dragged
'.kbnGridRowHeader--active': {
backgroundColor: euiTheme.colors.backgroundBasePlain,
border: `1px solid ${euiTheme.border.color}`,
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': {
display: 'none',
},
},
// styles for the area where the row will be dropped
'.kbnGridPanel--rowDragPreview': {
backgroundColor: euiTheme.components.dragDropDraggingBackground,
},
});
};

View file

@ -117,40 +117,66 @@ describe('GridLayout', () => {
expect(onLayoutChange).toBeCalledTimes(1);
});
it(`'renderPanelContents' is not called during dragging`, () => {
renderGridLayout();
describe('dragging rows', () => {
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();
});
// assert that renderPanelContents has been called ONLY ONCE for each of 10 panels on initial render
expect(mockRenderPanelContents).toHaveBeenCalledTimes(expectedInitPanelIdsInOrder.length);
jest.clearAllMocks();
it('row gets active when dragged', () => {
renderGridLayout();
expect(screen.getByTestId('kbnGridRowHeader-second')).not.toHaveClass(
'kbnGridRowHeader--active'
);
const panelHandle = getPanelHandle('panel1');
mouseStartDragging(panelHandle);
mouseMoveTo({ clientX: 256, clientY: 128 });
const rowHandle = screen.getByTestId(`kbnGridRowHeader-second--dragHandle`);
mouseStartDragging(rowHandle);
mouseMoveTo({ clientX: 256, clientY: 128 });
expect(screen.getByTestId('kbnGridRowHeader-second')).toHaveClass('kbnGridRowHeader--active');
// assert that renderPanelContents has not been called during dragging
expect(mockRenderPanelContents).toHaveBeenCalledTimes(0);
mouseDrop(panelHandle);
// assert that renderPanelContents has not been called after reordering
expect(mockRenderPanelContents).toHaveBeenCalledTimes(0);
mouseDrop(rowHandle);
expect(screen.getByTestId('kbnGridRowHeader-second')).not.toHaveClass(
'kbnGridRowHeader--active'
);
});
});
it('panel gets active when dragged', () => {
renderGridLayout();
const panelHandle = getPanelHandle('panel1');
expect(screen.getByLabelText('panelId:panel1').closest('div')).toHaveClass('kbnGridPanel', {
exact: true,
describe('dragging panels', () => {
it(`'renderPanelContents' is not called during dragging`, () => {
renderGridLayout();
// assert that renderPanelContents has been called ONLY ONCE for each of 10 panels on initial render
expect(mockRenderPanelContents).toHaveBeenCalledTimes(expectedInitPanelIdsInOrder.length);
jest.clearAllMocks();
const panelHandle = getPanelHandle('panel1');
mouseStartDragging(panelHandle);
mouseMoveTo({ clientX: 256, clientY: 128 });
// assert that renderPanelContents has not been called during dragging
expect(mockRenderPanelContents).toHaveBeenCalledTimes(0);
mouseDrop(panelHandle);
// assert that renderPanelContents has not beesn called after reordering
expect(mockRenderPanelContents).toHaveBeenCalledTimes(0);
});
mouseStartDragging(panelHandle);
mouseMoveTo({ clientX: 256, clientY: 128 });
expect(screen.getByLabelText('panelId:panel1').closest('div')).toHaveClass(
'kbnGridPanel kbnGridPanel--active',
{ exact: true }
);
mouseDrop(panelHandle);
expect(screen.getByLabelText('panelId:panel1').closest('div')).toHaveClass('kbnGridPanel', {
exact: true,
it('panel gets active when dragged', () => {
renderGridLayout();
const panelHandle = getPanelHandle('panel1');
expect(screen.getByLabelText('panelId:panel1').closest('div')).toHaveClass('kbnGridPanel', {
exact: true,
});
mouseStartDragging(panelHandle);
mouseMoveTo({ clientX: 256, clientY: 128 });
expect(screen.getByLabelText('panelId:panel1').closest('div')).toHaveClass(
'kbnGridPanel kbnGridPanel--active',
{ exact: true }
);
mouseDrop(panelHandle);
expect(screen.getByLabelText('panelId:panel1').closest('div')).toHaveClass('kbnGridPanel', {
exact: true,
});
});
});
@ -163,6 +189,7 @@ describe('GridLayout', () => {
await assertTabThroughPanel('panel2');
await assertTabThroughPanel('panel3');
});
it('on initializing', () => {
renderGridLayout();
expect(getAllThePanelIds()).toEqual(expectedInitPanelIdsInOrder);
@ -191,6 +218,7 @@ describe('GridLayout', () => {
'panel10',
]);
});
it('after reordering some panels via touch events', async () => {
renderGridLayout();

View file

@ -11,7 +11,7 @@ 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 } from 'rxjs';
import { combineLatest, pairwise, map, distinctUntilChanged } from 'rxjs';
import { css } from '@emotion/react';
@ -90,6 +90,23 @@ export const GridLayout = ({
}
});
/**
* This subscription ensures that rows get re-rendered when their orders change
*/
const rowOrderSubscription = combineLatest([
gridLayoutStateManager.proposedGridLayout$,
gridLayoutStateManager.gridLayout$,
])
.pipe(
map(([proposedGridLayout, gridLayout]) =>
getRowKeysInOrder(proposedGridLayout ?? gridLayout)
),
distinctUntilChanged(deepEqual)
)
.subscribe((rowKeys) => {
setRowIdsInOrder(rowKeys);
});
/**
* This subscription adds and/or removes the necessary class names related to styling for
* mobile view and a static (non-interactable) grid layout
@ -115,6 +132,7 @@ export const GridLayout = ({
return () => {
onLayoutChangeSubscription.unsubscribe();
rowOrderSubscription.unsubscribe();
gridLayoutClassSubscription.unsubscribe();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
@ -160,7 +178,7 @@ const styles = {
padding: 'calc(var(--kbnGridGutterSize) * 1px)',
}),
hasActivePanel: css({
'&:has(.kbnGridPanel--active)': {
'&:has(.kbnGridPanel--active), &:has(.kbnGridRowHeader--active)': {
// disable pointer events and user select on drag + resize
userSelect: 'none',
pointerEvents: 'none',

View file

@ -47,7 +47,7 @@ const styles = ({ euiTheme }: UseEuiTheme) =>
border: `1px solid ${euiTheme.border.color}`,
borderBottom: 'none',
backgroundColor: euiTheme.colors.backgroundBasePlain,
borderRadius: `${euiTheme.border.radius} ${euiTheme.border.radius} 0 0`,
borderRadius: `${euiTheme.border.radius.medium} ${euiTheme.border.radius.medium} 0 0`,
transition: `${euiTheme.animation.slow} opacity`,
touchAction: 'none',
'.kbnGridPanel:hover &, .kbnGridPanel:focus-within &, &:active, &:focus': {

View file

@ -9,9 +9,9 @@
import { useCallback, useEffect, useRef } from 'react';
import { useGridLayoutEvents } from '../../use_grid_layout_events';
import { UserInteractionEvent } from '../../use_grid_layout_events/types';
import { useGridLayoutContext } from '../../use_grid_layout_context';
import { useGridLayoutPanelEvents } from '../../use_grid_layout_events';
import { UserInteractionEvent } from '../../use_grid_layout_events/types';
export interface DragHandleApi {
startDrag: (e: UserInteractionEvent) => void;
@ -27,7 +27,7 @@ export const useDragHandleApi = ({
}): DragHandleApi => {
const { useCustomDragHandle } = useGridLayoutContext();
const startInteraction = useGridLayoutEvents({
const startInteraction = useGridLayoutPanelEvents({
interactionType: 'drag',
panelId,
rowId,

View file

@ -16,7 +16,7 @@ import { css } from '@emotion/react';
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 './resize_handle';
import { ResizeHandle } from './grid_panel_resize_handle';
export interface GridPanelProps {
panelId: string;
@ -50,6 +50,14 @@ export const GridPanel = React.memo(({ panelId, rowId }: GridPanelProps) => {
`;
}, [gridLayoutStateManager, rowId, panelId]);
useEffect(() => {
return () => {
// remove reference on unmount
delete gridLayoutStateManager.panelRefs.current[rowId][panelId];
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(
() => {
/** Update the styles of the panel via a subscription to prevent re-renders */

View file

@ -11,9 +11,9 @@ import React, { useEffect, useRef } from 'react';
import { combineLatest, skip } from 'rxjs';
import { css } from '@emotion/react';
import { useGridLayoutContext } from './use_grid_layout_context';
import { useGridLayoutContext } from '../use_grid_layout_context';
export const DragPreview = React.memo(({ rowId }: { rowId: string }) => {
export const GridPanelDragPreview = React.memo(({ rowId }: { rowId: string }) => {
const { gridLayoutStateManager } = useGridLayoutContext();
const dragPreviewRef = useRef<HTMLDivElement | null>(null);
@ -54,4 +54,4 @@ export const DragPreview = React.memo(({ rowId }: { rowId: string }) => {
const styles = css({ display: 'none', pointerEvents: 'none' });
DragPreview.displayName = 'KbnGridLayoutDragPreview';
GridPanelDragPreview.displayName = 'KbnGridLayoutPanelDragPreview';

View file

@ -13,10 +13,10 @@ import type { UseEuiTheme } from '@elastic/eui';
import { css } from '@emotion/react';
import { i18n } from '@kbn/i18n';
import { useGridLayoutEvents } from '../use_grid_layout_events';
import { useGridLayoutPanelEvents } from '../use_grid_layout_events';
export const ResizeHandle = React.memo(({ rowId, panelId }: { rowId: string; panelId: string }) => {
const startInteraction = useGridLayoutEvents({
const startInteraction = useGridLayoutPanelEvents({
interactionType: 'resize',
panelId,
rowId,

View file

@ -19,8 +19,8 @@ import {
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { deleteRow, movePanelsToRow } from '../utils/row_management';
import { useGridLayoutContext } from '../use_grid_layout_context';
import { deleteRow, movePanelsToRow } from '../utils/row_management';
export const DeleteGridRowModal = ({
rowId,

View file

@ -14,11 +14,11 @@ import { combineLatest, map, pairwise, skip } from 'rxjs';
import { css } from '@emotion/react';
import { DragPreview } from '../drag_preview';
import { GridPanelDragPreview } from '../grid_panel/grid_panel_drag_preview';
import { GridPanel } from '../grid_panel';
import { useGridLayoutContext } from '../use_grid_layout_context';
import { getPanelKeysInOrder } from '../utils/resolve_grid_row';
import { GridRowHeader } from './grid_row_header';
import { getPanelKeysInOrder } from '../utils/resolve_grid_row';
export interface GridRowProps {
rowId: string;
@ -150,13 +150,13 @@ export const GridRow = React.memo(({ rowId }: GridRowProps) => {
}
css={[styles.fullHeight, styles.grid]}
role="region"
aria-labelledby={`kbnGridRowTile-${rowId}`}
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} />
))}
<DragPreview rowId={rowId} />
<GridPanelDragPreview rowId={rowId} />
</div>
)}
</div>

View file

@ -0,0 +1,43 @@
/*
* 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

@ -6,8 +6,9 @@
* 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 } from 'rxjs';
import { distinctUntilChanged, map, pairwise } from 'rxjs';
import {
EuiButtonIcon,
@ -21,8 +22,10 @@ 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 {
@ -34,7 +37,11 @@ export interface GridRowHeaderProps {
export const GridRowHeader = React.memo(
({ rowId, toggleIsCollapsed, collapseButtonRef }: GridRowHeaderProps) => {
const { gridLayoutStateManager } = useGridLayoutContext();
const startInteraction = 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>(
@ -44,6 +51,14 @@ export const GridRowHeader = React.memo(
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
@ -67,9 +82,50 @@ export const GridRowHeader = React.memo(
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]);
@ -94,12 +150,15 @@ export const GridRowHeader = React.memo(
responsive={false}
alignItems="center"
css={styles.headerStyles}
className="kbnGridRowHeader"
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}
readOnly={readOnly || isActive}
toggleIsCollapsed={toggleIsCollapsed}
editTitleOpen={editTitleOpen}
setEditTitleOpen={setEditTitleOpen}
@ -112,7 +171,7 @@ export const GridRowHeader = React.memo(
*/
!editTitleOpen && (
<>
<EuiFlexItem grow={false} css={styles.hiddenOnCollapsed}>
<EuiFlexItem grow={false} css={styles.visibleOnlyWhenCollapsed}>
<EuiText
color="subdued"
size="s"
@ -130,32 +189,34 @@ export const GridRowHeader = React.memo(
</EuiFlexItem>
{!readOnly && (
<>
<EuiFlexItem grow={false}>
{!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="trash"
color="danger"
className="kbnGridLayout--deleteRowIcon"
onClick={confirmDeleteRow}
aria-label={i18n.translate('kbnGridLayout.row.deleteRow', {
defaultMessage: 'Delete section',
})}
/>
</EuiFlexItem>
<EuiFlexItem grow={false} css={[styles.hiddenOnCollapsed, styles.floatToRight]}>
{/*
This was added as a placeholder to get the desired UI here; however, since the
functionality will be implemented in https://github.com/elastic/kibana/issues/190381
and this button doesn't do anything yet, I'm choosing to hide it for now. I am keeping
the `FlexItem` wrapper so that the UI still looks correct.
*/}
{/* <EuiButtonIcon
iconType="move"
color="text"
className="kbnGridLayout--moveRowIcon"
aria-label={i18n.translate('kbnGridLayout.row.moveRow', {
defaultMessage: 'Move section',
})}
/> */}
onMouseDown={startInteraction}
onTouchStart={startInteraction}
data-test-subj={`kbnGridRowHeader-${rowId}--dragHandle`}
/>
</EuiFlexItem>
</>
)}
@ -163,6 +224,7 @@ export const GridRowHeader = React.memo(
)
}
</EuiFlexGroup>
{isActive && <GridRowDragPreview rowId={rowId} />}
{deleteModalVisible && (
<DeleteGridRowModal rowId={rowId} setDeleteModalVisible={setDeleteModalVisible} />
)}
@ -172,7 +234,7 @@ export const GridRowHeader = React.memo(
);
const styles = {
hiddenOnCollapsed: css({
visibleOnlyWhenCollapsed: css({
display: 'none',
'.kbnGridRowContainer--collapsed &': {
display: 'block',
@ -185,8 +247,8 @@ const styles = {
css({
height: `calc(${euiTheme.size.xl} + (2 * ${euiTheme.size.s}))`,
padding: `${euiTheme.size.s} 0px`,
borderBottom: '1px solid transparent', // prevents layout shift
'.kbnGridRowContainer--collapsed &': {
border: '1px solid transparent', // prevents layout shift
'.kbnGridRowContainer--collapsed &:not(.kbnGridRowHeader--active)': {
borderBottom: euiTheme.border.thin,
},
'.kbnGridLayout--deleteRowIcon': {
@ -195,6 +257,15 @@ const styles = {
'.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`]: {
@ -209,6 +280,16 @@ const styles = {
&: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',
},
},
}),
};

View file

@ -98,6 +98,7 @@ export const GridRowTitle = React.memo(
aria-controls={`kbnGridRow-${rowId}`}
data-test-subj={`kbnGridRowTitle-${rowId}`}
textProps={false}
className={'kbnGridRowTitle--button'}
flush="both"
>
{editTitleOpen ? null : (

View file

@ -8,19 +8,23 @@
*/
import { fireEvent } from '@testing-library/react';
import { PointerPosition } from '../use_grid_layout_events/types';
class TouchEventFake extends Event {
constructor(public touches: Array<{ clientX: number; clientY: number }>) {
constructor(public touches: PointerPosition[]) {
super('touchmove');
this.touches = [{ clientX: 256, clientY: 128 }];
}
}
export const mouseStartDragging = (handle: HTMLElement, options = { clientX: 0, clientY: 0 }) => {
export const mouseStartDragging = (
handle: HTMLElement,
options: PointerPosition = { clientX: 0, clientY: 0 }
) => {
fireEvent.mouseDown(handle, options);
};
export const mouseMoveTo = (options = { clientX: 256, clientY: 128 }) => {
export const mouseMoveTo = (options: PointerPosition = { clientX: 256, clientY: 128 }) => {
fireEvent.mouseMove(document, options);
};
@ -29,14 +33,14 @@ export const mouseDrop = (handle: HTMLElement) => {
};
export const touchStart = (
handle: HTMLElement,
options = { touches: [{ clientX: 0, clientY: 0 }] }
options: { touches: PointerPosition[] } = { touches: [{ clientX: 0, clientY: 0 }] }
) => {
fireEvent.touchStart(handle, options);
};
export const touchMoveTo = (
handle: HTMLElement,
options = { touches: [{ clientX: 256, clientY: 128 }] }
options: { touches: PointerPosition[] } = { touches: [{ clientX: 256, clientY: 128 }] }
) => {
const realTouchEvent = window.TouchEvent;
// @ts-expect-error

View file

@ -12,6 +12,7 @@ import { BehaviorSubject } from 'rxjs';
import { ObservedSize } from 'use-resize-observer/polyfilled';
import {
ActivePanel,
ActiveRowEvent,
GridAccessMode,
GridLayoutData,
GridLayoutStateManager,
@ -43,11 +44,13 @@ export const getGridLayoutStateManagerMock = (overrides?: Partial<GridLayoutStat
...gridSettings,
columnPixelWidth: 0,
}),
panelRefs: { current: [] },
rowRefs: { current: [] },
panelRefs: { current: {} },
rowRefs: { 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),
gridDimensions$: new BehaviorSubject<ObservedSize>({ width: 600, height: 900 }),
...overrides,
};

View file

@ -60,6 +60,18 @@ export interface ActivePanel {
};
}
export interface ActiveRowEvent {
id: string;
startingPosition: {
top: number;
left: number;
};
translate: {
top: number;
left: number;
};
}
export interface GridLayoutStateManager {
gridLayout$: BehaviorSubject<GridLayoutData>;
proposedGridLayout$: BehaviorSubject<GridLayoutData | undefined>; // temporary state for layout during drag and drop operations
@ -69,9 +81,11 @@ export interface GridLayoutStateManager {
gridDimensions$: BehaviorSubject<ObservedSize>;
runtimeSettings$: BehaviorSubject<RuntimeGridSettings>;
activePanel$: BehaviorSubject<ActivePanel | undefined>;
activeRowEvent$: BehaviorSubject<ActiveRowEvent | undefined>;
interactionEvent$: BehaviorSubject<PanelInteractionEvent | undefined>;
rowRefs: React.MutableRefObject<{ [rowId: string]: HTMLDivElement | null }>;
headerRefs: React.MutableRefObject<{ [rowId: string]: HTMLDivElement | null }>;
panelRefs: React.MutableRefObject<{
[rowId: string]: { [panelId: string]: HTMLDivElement | null };
}>;

View file

@ -7,81 +7,5 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { useCallback, useRef } from 'react';
import { GridPanelData, GridLayoutStateManager, PanelInteractionEvent } from '../types';
import {
getPointerPosition,
isMouseEvent,
isTouchEvent,
startMouseInteraction,
startTouchInteraction,
} from './sensors';
import { commitAction, moveAction, startAction } from './state_manager_actions';
import { UserInteractionEvent } from './types';
import { useGridLayoutContext } from '../use_grid_layout_context';
/*
* This hook sets up and manages drag/resize interaction logic for grid panels.
* 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 or touch).
*/
export const useGridLayoutEvents = ({
interactionType,
rowId,
panelId,
}: {
interactionType: PanelInteractionEvent['type'];
rowId: string;
panelId: string;
}) => {
const { gridLayoutStateManager } = useGridLayoutContext();
const lastRequestedPanelPosition = useRef<GridPanelData | undefined>(undefined);
const pointerPixel = useRef<{ clientX: number; clientY: number }>({ clientX: 0, clientY: 0 });
const startInteraction = useCallback(
(e: UserInteractionEvent) => {
if (!isLayoutInteractive(gridLayoutStateManager)) return;
const onStart = () => startAction(e, gridLayoutStateManager, interactionType, rowId, panelId);
const onMove = (ev: UserInteractionEvent) => {
if (isMouseEvent(ev) || isTouchEvent(ev)) {
pointerPixel.current = getPointerPosition(ev);
}
moveAction(gridLayoutStateManager, pointerPixel.current, lastRequestedPanelPosition);
};
const onEnd = () => commitAction(gridLayoutStateManager);
if (isMouseEvent(e)) {
e.stopPropagation();
startMouseInteraction({
e,
onStart,
onMove,
onEnd,
});
} else if (isTouchEvent(e)) {
startTouchInteraction({
e,
onStart,
onMove,
onEnd,
});
}
},
[gridLayoutStateManager, rowId, panelId, interactionType]
);
return startInteraction;
};
const isLayoutInteractive = (gridLayoutStateManager: GridLayoutStateManager) => {
return (
gridLayoutStateManager.expandedPanelId$.value === undefined &&
gridLayoutStateManager.accessMode$.getValue() === 'EDIT'
);
};
export { useGridLayoutPanelEvents } from './panel_events';
export { useGridLayoutRowEvents } from './row_events';

View file

@ -0,0 +1,80 @@
/*
* 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 { useCallback, useRef } from 'react';
import { GridPanelData, PanelInteractionEvent } from '../types';
import { useGridLayoutContext } from '../use_grid_layout_context';
import { commitAction, moveAction, startAction } from './panel_state_manager_actions';
import {
getPointerPosition,
isLayoutInteractive,
isMouseEvent,
isTouchEvent,
startMouseInteraction,
startTouchInteraction,
} from './sensors';
import { PointerPosition, UserInteractionEvent } from './types';
/*
* This hook sets up and manages drag/resize interaction logic for grid panels.
* 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 or touch).
*/
export const useGridLayoutPanelEvents = ({
interactionType,
rowId,
panelId,
}: {
interactionType: PanelInteractionEvent['type'];
rowId: string;
panelId: string;
}) => {
const { gridLayoutStateManager } = useGridLayoutContext();
const lastRequestedPanelPosition = useRef<GridPanelData | undefined>(undefined);
const pointerPixel = useRef<PointerPosition>({ clientX: 0, clientY: 0 });
const startInteraction = useCallback(
(e: UserInteractionEvent) => {
if (!isLayoutInteractive(gridLayoutStateManager)) return;
const onStart = () => startAction(e, gridLayoutStateManager, interactionType, rowId, panelId);
const onMove = (ev: UserInteractionEvent) => {
if (isMouseEvent(ev) || isTouchEvent(ev)) {
pointerPixel.current = getPointerPosition(ev);
}
moveAction(gridLayoutStateManager, pointerPixel.current, lastRequestedPanelPosition);
};
const onEnd = () => commitAction(gridLayoutStateManager);
if (isMouseEvent(e)) {
e.stopPropagation();
startMouseInteraction({
e,
onStart,
onMove,
onEnd,
});
} else if (isTouchEvent(e)) {
startTouchInteraction({
e,
onStart,
onMove,
onEnd,
});
}
},
[gridLayoutStateManager, rowId, panelId, interactionType]
);
return startInteraction;
};

View file

@ -14,7 +14,7 @@ import { GridLayoutStateManager, GridPanelData } from '../types';
import { getDragPreviewRect, getPointerOffsets, getResizePreviewRect } from './pointer_event_utils';
import { resolveGridRow } from '../utils/resolve_grid_row';
import { isGridDataEqual } from '../utils/equality_checks';
import { UserInteractionEvent } from './types';
import { PointerPosition, UserInteractionEvent } from './types';
export const startAction = (
e: UserInteractionEvent,
@ -56,7 +56,7 @@ export const commitAction = ({
export const moveAction = (
gridLayoutStateManager: GridLayoutStateManager,
pointerPixel: { clientX: number; clientY: number },
pointerPixel: PointerPosition,
lastRequestedPanelPosition: MutableRefObject<GridPanelData | undefined>
) => {
const {
@ -164,7 +164,6 @@ export const moveAction = (
) {
lastRequestedPanelPosition.current = { ...requestedPanelData };
// remove the panel from the row it's currently in.
const nextLayout = cloneDeep(currentLayout);
Object.entries(nextLayout).forEach(([rowId, row]) => {
const { [interactionEvent.id]: interactingPanel, ...otherPanels } = row.panels;

View file

@ -9,14 +9,14 @@
import { PanelInteractionEvent } from '../types';
import { getPointerPosition } from './sensors';
import { UserInteractionEvent } from './types';
import { PointerPosition, UserInteractionEvent } from './types';
// Calculates the preview rect coordinates for a resized panel
export const getResizePreviewRect = ({
interactionEvent,
pointerPixel,
}: {
pointerPixel: { clientX: number; clientY: number };
pointerPixel: PointerPosition;
interactionEvent: PanelInteractionEvent;
}) => {
const panelRect = interactionEvent.panelDiv.getBoundingClientRect();
@ -34,7 +34,7 @@ export const getDragPreviewRect = ({
pointerPixel,
interactionEvent,
}: {
pointerPixel: { clientX: number; clientY: number };
pointerPixel: PointerPosition;
interactionEvent: PanelInteractionEvent;
}) => {
return {

View file

@ -0,0 +1,72 @@
/*
* 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 { useCallback, useRef } from 'react';
import { useGridLayoutContext } from '../use_grid_layout_context';
import { commitAction, moveAction, startAction } from './row_state_manager_actions';
import {
getPointerPosition,
isLayoutInteractive,
isMouseEvent,
isTouchEvent,
startMouseInteraction,
startTouchInteraction,
} from './sensors';
import { PointerPosition, UserInteractionEvent } from './types';
/*
* This hook sets up and manages interaction logic for dragging grid rows.
* 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 or touch).
*/
export const useGridLayoutRowEvents = ({ rowId }: { rowId: string }) => {
const { gridLayoutStateManager } = useGridLayoutContext();
const pointerPixel = useRef<PointerPosition>({ clientX: 0, clientY: 0 });
const startingPointer = useRef<PointerPosition>({ clientX: 0, clientY: 0 });
const startInteraction = useCallback(
(e: UserInteractionEvent) => {
if (!isLayoutInteractive(gridLayoutStateManager)) return;
const onStart = () => startAction(e, gridLayoutStateManager, rowId, startingPointer);
const onMove = (ev: UserInteractionEvent) => {
if (isMouseEvent(ev) || isTouchEvent(ev)) {
pointerPixel.current = getPointerPosition(ev);
}
moveAction(gridLayoutStateManager, startingPointer.current, pointerPixel.current);
};
const onEnd = () => commitAction(gridLayoutStateManager);
if (isMouseEvent(e)) {
e.stopPropagation();
startMouseInteraction({
e,
onStart,
onMove,
onEnd,
});
} else if (isTouchEvent(e)) {
startTouchInteraction({
e,
onStart,
onMove,
onEnd,
});
}
},
[gridLayoutStateManager, rowId]
);
return startInteraction;
};

View file

@ -0,0 +1,78 @@
/*
* 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 './row_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,
},
});
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,
},
});
});
});
});

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 deepEqual from 'fast-deep-equal';
import { cloneDeep, pick } from 'lodash';
import { MutableRefObject } from 'react';
import { GridLayoutStateManager } from '../types';
import { getRowKeysInOrder } from '../utils/resolve_grid_row';
import { getPointerPosition } from './sensors';
import { PointerPosition, UserInteractionEvent } from './types';
export const startAction = (
e: UserInteractionEvent,
gridLayoutStateManager: GridLayoutStateManager,
rowId: string,
startingPointer: MutableRefObject<PointerPosition>
) => {
const headerRef = gridLayoutStateManager.headerRefs.current[rowId];
if (!headerRef) return;
const startingPosition = pick(headerRef.getBoundingClientRect(), ['top', 'left']);
startingPointer.current = getPointerPosition(e);
gridLayoutStateManager.activeRowEvent$.next({
id: rowId,
startingPosition,
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 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

@ -7,6 +7,7 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { GridLayoutStateManager } from '../../types';
import { UserInteractionEvent } from '../types';
import { isMouseEvent } from './mouse';
import { isTouchEvent } from './touch';
@ -20,3 +21,10 @@ export function getPointerPosition(e: UserInteractionEvent) {
}
return isTouchEvent(e) ? e.touches[0] : e;
}
export const isLayoutInteractive = (gridLayoutStateManager: GridLayoutStateManager) => {
return (
gridLayoutStateManager.expandedPanelId$.value === undefined &&
gridLayoutStateManager.accessMode$.getValue() === 'EDIT'
);
};

View file

@ -8,3 +8,8 @@
*/
export type UserInteractionEvent = React.UIEvent<HTMLElement> | Event;
export interface PointerPosition {
clientX: number;
clientY: number;
}

View file

@ -15,6 +15,7 @@ import useResizeObserver, { type ObservedSize } from 'use-resize-observer/polyfi
import { useEuiTheme } from '@elastic/eui';
import {
ActivePanel,
ActiveRowEvent,
GridAccessMode,
GridLayoutData,
GridLayoutStateManager,
@ -42,6 +43,7 @@ export const useGridLayoutState = ({
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 { euiTheme } = useEuiTheme();
@ -92,13 +94,16 @@ export const useGridLayoutState = ({
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);
return {
rowRefs,
headerRefs,
panelRefs,
proposedGridLayout$,
gridLayout$,
activePanel$,
activeRowEvent$,
accessMode$,
gridDimensions$,
runtimeSettings$,