mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 09:19:04 -04:00
# 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\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\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\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:
parent
872d103b7e
commit
76b896fbb0
26 changed files with 686 additions and 203 deletions
|
@ -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,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
|
|
@ -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();
|
||||
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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': {
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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 */
|
||||
|
|
|
@ -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';
|
|
@ -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,
|
|
@ -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,
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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';
|
|
@ -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',
|
||||
},
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
|
|
|
@ -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 : (
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
|
|
|
@ -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 };
|
||||
}>;
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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;
|
||||
};
|
|
@ -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;
|
|
@ -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 {
|
||||
|
|
|
@ -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;
|
||||
};
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
};
|
|
@ -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'
|
||||
);
|
||||
};
|
||||
|
|
|
@ -8,3 +8,8 @@
|
|||
*/
|
||||
|
||||
export type UserInteractionEvent = React.UIEvent<HTMLElement> | Event;
|
||||
|
||||
export interface PointerPosition {
|
||||
clientX: number;
|
||||
clientY: number;
|
||||
}
|
||||
|
|
|
@ -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$,
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue