mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 17:28:26 -04:00
[kbn-grid-layout] Add useCustomDragHandle
prop (#210463)
This PR **might** resolve https://github.com/elastic/kibana/issues/207011 but we will need time for the telemetry metrics to settle before we know for sure. ## Summary This PR removes the conditional rendering of the default drag handle in `kbn-grid-layout`, which has two benefits: 1. It removes the double render of `GridPanel` that was caused by relying on the `dragHandleCount` to be updated in order to determine whether the default drag handle should be rendered 2. The default drag handle no longer "flashes" when Dashboards are loading and waiting for `dragHandleCount` to update - **Before:** https://github.com/user-attachments/assets/30a032fc-4df3-42ce-9494-dd7f69637c03 - **After:** https://github.com/user-attachments/assets/db447911-cbe2-40dd-9a07-405d1e35a75d Instead, the consumer of `kbn-grid-layout` is responsible for setting the `useCustomDragHandle` prop to `true` when they want to use a drag handle other than the default one. When adding the `useCustomDragHandle` prop, I got annoyed that I had to pass this prop all the way down to `grid_panel` - so I decided to swap to using React context in this PR, as well. The API for the grid layout component will most likely continue to grow, so this should make it easier to manage the props. ### Checklist - [x] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios - [x] The PR description includes the appropriate Release Notes section, and the correct `release_note:*` label is applied per the [guidelines](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process)
This commit is contained in:
parent
84fdbcba62
commit
200922a512
17 changed files with 557 additions and 501 deletions
|
@ -337,6 +337,7 @@ export const GridExample = ({
|
|||
rowHeight,
|
||||
columnCount: DASHBOARD_GRID_COLUMN_COUNT,
|
||||
}}
|
||||
useCustomDragHandle={true}
|
||||
renderPanelContents={renderPanelContents}
|
||||
onLayoutChange={(newLayout) => {
|
||||
const { panels, rows } = gridLayoutToDashboardPanelMap(
|
||||
|
|
|
@ -11,53 +11,46 @@ import React, { useEffect, useRef } from 'react';
|
|||
import { combineLatest, skip } from 'rxjs';
|
||||
|
||||
import { css } from '@emotion/react';
|
||||
import { useGridLayoutContext } from './use_grid_layout_context';
|
||||
|
||||
import { GridLayoutStateManager } from './types';
|
||||
export const DragPreview = React.memo(({ rowIndex }: { rowIndex: number }) => {
|
||||
const { gridLayoutStateManager } = useGridLayoutContext();
|
||||
|
||||
export const DragPreview = React.memo(
|
||||
({
|
||||
rowIndex,
|
||||
gridLayoutStateManager,
|
||||
}: {
|
||||
rowIndex: number;
|
||||
gridLayoutStateManager: GridLayoutStateManager;
|
||||
}) => {
|
||||
const dragPreviewRef = useRef<HTMLDivElement | null>(null);
|
||||
const dragPreviewRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(
|
||||
() => {
|
||||
/** Update the styles of the drag preview via a subscription to prevent re-renders */
|
||||
const styleSubscription = combineLatest([
|
||||
gridLayoutStateManager.activePanel$,
|
||||
gridLayoutStateManager.proposedGridLayout$,
|
||||
])
|
||||
.pipe(skip(1)) // skip the first emit because the drag preview is only rendered after a user action
|
||||
.subscribe(([activePanel, proposedGridLayout]) => {
|
||||
if (!dragPreviewRef.current) return;
|
||||
useEffect(
|
||||
() => {
|
||||
/** Update the styles of the drag preview via a subscription to prevent re-renders */
|
||||
const styleSubscription = combineLatest([
|
||||
gridLayoutStateManager.activePanel$,
|
||||
gridLayoutStateManager.proposedGridLayout$,
|
||||
])
|
||||
.pipe(skip(1)) // skip the first emit because the drag preview is only rendered after a user action
|
||||
.subscribe(([activePanel, proposedGridLayout]) => {
|
||||
if (!dragPreviewRef.current) return;
|
||||
|
||||
if (!activePanel || !proposedGridLayout?.[rowIndex].panels[activePanel.id]) {
|
||||
dragPreviewRef.current.style.display = 'none';
|
||||
} else {
|
||||
const panel = proposedGridLayout[rowIndex].panels[activePanel.id];
|
||||
dragPreviewRef.current.style.display = 'block';
|
||||
dragPreviewRef.current.style.gridColumnStart = `${panel.column + 1}`;
|
||||
dragPreviewRef.current.style.gridColumnEnd = `${panel.column + 1 + panel.width}`;
|
||||
dragPreviewRef.current.style.gridRowStart = `${panel.row + 1}`;
|
||||
dragPreviewRef.current.style.gridRowEnd = `${panel.row + 1 + panel.height}`;
|
||||
}
|
||||
});
|
||||
if (!activePanel || !proposedGridLayout?.[rowIndex].panels[activePanel.id]) {
|
||||
dragPreviewRef.current.style.display = 'none';
|
||||
} else {
|
||||
const panel = proposedGridLayout[rowIndex].panels[activePanel.id];
|
||||
dragPreviewRef.current.style.display = 'block';
|
||||
dragPreviewRef.current.style.gridColumnStart = `${panel.column + 1}`;
|
||||
dragPreviewRef.current.style.gridColumnEnd = `${panel.column + 1 + panel.width}`;
|
||||
dragPreviewRef.current.style.gridRowStart = `${panel.row + 1}`;
|
||||
dragPreviewRef.current.style.gridRowEnd = `${panel.row + 1 + panel.height}`;
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
styleSubscription.unsubscribe();
|
||||
};
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[]
|
||||
);
|
||||
return () => {
|
||||
styleSubscription.unsubscribe();
|
||||
};
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[]
|
||||
);
|
||||
|
||||
return <div ref={dragPreviewRef} className={'kbnGridPanel--dragPreview'} css={styles} />;
|
||||
}
|
||||
);
|
||||
return <div ref={dragPreviewRef} className={'kbnGridPanel--dragPreview'} css={styles} />;
|
||||
});
|
||||
|
||||
const styles = css({ display: 'none', pointerEvents: 'none' });
|
||||
|
||||
|
|
|
@ -8,15 +8,14 @@
|
|||
*/
|
||||
|
||||
import { css } from '@emotion/react';
|
||||
import React, { PropsWithChildren, useEffect, useRef } from 'react';
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { combineLatest } from 'rxjs';
|
||||
import { GridLayoutStateManager } from './types';
|
||||
import { useGridLayoutContext } from './use_grid_layout_context';
|
||||
|
||||
export const GridHeightSmoother = React.memo(
|
||||
({
|
||||
children,
|
||||
gridLayoutStateManager,
|
||||
}: PropsWithChildren<{ gridLayoutStateManager: GridLayoutStateManager }>) => {
|
||||
({ children }: { children: React.ReactNode | undefined }) => {
|
||||
const { gridLayoutStateManager } = useGridLayoutContext();
|
||||
|
||||
// set the parent div size directly to smooth out height changes.
|
||||
const smoothHeightRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
|
|
|
@ -25,20 +25,23 @@ import {
|
|||
const onLayoutChange = jest.fn();
|
||||
|
||||
const renderGridLayout = (propsOverrides: Partial<GridLayoutProps> = {}) => {
|
||||
const defaultProps: GridLayoutProps = {
|
||||
const props = {
|
||||
accessMode: 'EDIT',
|
||||
layout: getSampleLayout(),
|
||||
gridSettings,
|
||||
renderPanelContents: mockRenderPanelContents,
|
||||
onLayoutChange,
|
||||
};
|
||||
...propsOverrides,
|
||||
} as GridLayoutProps;
|
||||
|
||||
const { rerender, ...rtlRest } = render(<GridLayout {...defaultProps} {...propsOverrides} />);
|
||||
const { rerender, ...rtlRest } = render(<GridLayout {...props} />);
|
||||
|
||||
return {
|
||||
...rtlRest,
|
||||
rerender: (overrides: Partial<GridLayoutProps>) =>
|
||||
rerender(<GridLayout {...defaultProps} {...overrides} />),
|
||||
rerender: (overrides: Partial<GridLayoutProps>) => {
|
||||
const newProps = { ...props, ...overrides } as GridLayoutProps;
|
||||
return rerender(<GridLayout {...newProps} />);
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
@ -9,30 +9,27 @@
|
|||
|
||||
import classNames from 'classnames';
|
||||
import { cloneDeep } from 'lodash';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { combineLatest, distinctUntilChanged, map, pairwise, skip } from 'rxjs';
|
||||
|
||||
import { css } from '@emotion/react';
|
||||
|
||||
import { GridHeightSmoother } from './grid_height_smoother';
|
||||
import { GridRow } from './grid_row';
|
||||
import { GridAccessMode, GridLayoutData, GridSettings } from './types';
|
||||
import { GridAccessMode, GridLayoutData, GridSettings, UseCustomDragHandle } from './types';
|
||||
import { GridLayoutContext, GridLayoutContextType } from './use_grid_layout_context';
|
||||
import { useGridLayoutState } from './use_grid_layout_state';
|
||||
import { isLayoutEqual } from './utils/equality_checks';
|
||||
import { resolveGridRow } from './utils/resolve_grid_row';
|
||||
|
||||
export interface GridLayoutProps {
|
||||
export type GridLayoutProps = {
|
||||
layout: GridLayoutData;
|
||||
gridSettings: GridSettings;
|
||||
renderPanelContents: (
|
||||
panelId: string,
|
||||
setDragHandles?: (refs: Array<HTMLElement | null>) => void
|
||||
) => React.ReactNode;
|
||||
onLayoutChange: (newLayout: GridLayoutData) => void;
|
||||
expandedPanelId?: string;
|
||||
accessMode?: GridAccessMode;
|
||||
className?: string; // this makes it so that custom CSS can be passed via Emotion
|
||||
}
|
||||
} & UseCustomDragHandle;
|
||||
|
||||
export const GridLayout = ({
|
||||
layout,
|
||||
|
@ -42,6 +39,7 @@ export const GridLayout = ({
|
|||
expandedPanelId,
|
||||
accessMode = 'EDIT',
|
||||
className,
|
||||
useCustomDragHandle = false,
|
||||
}: GridLayoutProps) => {
|
||||
const layoutRef = useRef<HTMLDivElement | null>(null);
|
||||
const { gridLayoutStateManager, setDimensionsRef } = useGridLayoutState({
|
||||
|
@ -134,33 +132,38 @@ export const GridLayout = ({
|
|||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const memoizedContext = useMemo(
|
||||
() =>
|
||||
({
|
||||
renderPanelContents,
|
||||
useCustomDragHandle,
|
||||
gridLayoutStateManager,
|
||||
} as GridLayoutContextType),
|
||||
[renderPanelContents, useCustomDragHandle, gridLayoutStateManager]
|
||||
);
|
||||
|
||||
return (
|
||||
<GridHeightSmoother gridLayoutStateManager={gridLayoutStateManager}>
|
||||
<div
|
||||
ref={(divElement) => {
|
||||
layoutRef.current = divElement;
|
||||
setDimensionsRef(divElement);
|
||||
}}
|
||||
className={classNames('kbnGrid', className)}
|
||||
css={[
|
||||
styles.layoutPadding,
|
||||
styles.hasActivePanel,
|
||||
styles.singleColumn,
|
||||
styles.hasExpandedPanel,
|
||||
]}
|
||||
>
|
||||
{Array.from({ length: rowCount }, (_, rowIndex) => {
|
||||
return (
|
||||
<GridRow
|
||||
key={rowIndex}
|
||||
rowIndex={rowIndex}
|
||||
renderPanelContents={renderPanelContents}
|
||||
gridLayoutStateManager={gridLayoutStateManager}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</GridHeightSmoother>
|
||||
<GridLayoutContext.Provider value={memoizedContext}>
|
||||
<GridHeightSmoother>
|
||||
<div
|
||||
ref={(divElement) => {
|
||||
layoutRef.current = divElement;
|
||||
setDimensionsRef(divElement);
|
||||
}}
|
||||
className={classNames('kbnGrid', className)}
|
||||
css={[
|
||||
styles.layoutPadding,
|
||||
styles.hasActivePanel,
|
||||
styles.singleColumn,
|
||||
styles.hasExpandedPanel,
|
||||
]}
|
||||
>
|
||||
{Array.from({ length: rowCount }, (_, rowIndex) => {
|
||||
return <GridRow key={rowIndex} rowIndex={rowIndex} />;
|
||||
})}
|
||||
</div>
|
||||
</GridHeightSmoother>
|
||||
</GridLayoutContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -10,18 +10,19 @@ import React from 'react';
|
|||
import { EuiIcon, type UseEuiTheme } from '@elastic/eui';
|
||||
import { css } from '@emotion/react';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { UserInteractionEvent } from '../../use_grid_layout_events/types';
|
||||
import { DragHandleApi } from './use_drag_handle_api';
|
||||
|
||||
export const DefaultDragHandle = React.memo(
|
||||
({ onDragStart }: { onDragStart: (e: UserInteractionEvent) => void }) => {
|
||||
({ dragHandleApi }: { dragHandleApi: DragHandleApi }) => {
|
||||
return (
|
||||
<button
|
||||
onMouseDown={onDragStart}
|
||||
onTouchStart={onDragStart}
|
||||
onMouseDown={dragHandleApi.startDrag}
|
||||
onTouchStart={dragHandleApi.startDrag}
|
||||
aria-label={i18n.translate('kbnGridLayout.dragHandle.ariaLabel', {
|
||||
defaultMessage: 'Drag to move',
|
||||
})}
|
||||
className="kbnGridPanel__dragHandle"
|
||||
data-test-subj="kbnGridPanel--dragHandle"
|
||||
css={styles}
|
||||
>
|
||||
<EuiIcon type="grabOmnidirectional" />
|
||||
|
|
|
@ -1,73 +0,0 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the "Elastic License
|
||||
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
|
||||
* Public License v 1"; you may not use this file except in compliance with, at
|
||||
* your election, the "Elastic License 2.0", the "GNU Affero General Public
|
||||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
import React, { useCallback, useEffect, useImperativeHandle, useRef, useState } from 'react';
|
||||
|
||||
import { GridLayoutStateManager } from '../../types';
|
||||
import { useGridLayoutEvents } from '../../use_grid_layout_events';
|
||||
import { DefaultDragHandle } from './default_drag_handle';
|
||||
|
||||
export interface DragHandleApi {
|
||||
setDragHandles: (refs: Array<HTMLElement | null>) => void;
|
||||
}
|
||||
|
||||
export const DragHandle = React.memo(
|
||||
React.forwardRef<
|
||||
DragHandleApi,
|
||||
{
|
||||
gridLayoutStateManager: GridLayoutStateManager;
|
||||
panelId: string;
|
||||
rowIndex: number;
|
||||
}
|
||||
>(({ gridLayoutStateManager, panelId, rowIndex }, ref) => {
|
||||
const startInteraction = useGridLayoutEvents({
|
||||
interactionType: 'drag',
|
||||
gridLayoutStateManager,
|
||||
panelId,
|
||||
rowIndex,
|
||||
});
|
||||
|
||||
const [dragHandleCount, setDragHandleCount] = useState<number>(0);
|
||||
const removeEventListenersRef = useRef<(() => void) | null>(null);
|
||||
|
||||
const setDragHandles = useCallback(
|
||||
(dragHandles: Array<HTMLElement | null>) => {
|
||||
setDragHandleCount(dragHandles.length);
|
||||
for (const handle of dragHandles) {
|
||||
if (handle === null) return;
|
||||
handle.addEventListener('mousedown', startInteraction, { passive: true });
|
||||
handle.addEventListener('touchstart', startInteraction, { passive: true });
|
||||
handle.style.touchAction = 'none';
|
||||
}
|
||||
removeEventListenersRef.current = () => {
|
||||
for (const handle of dragHandles) {
|
||||
if (handle === null) return;
|
||||
handle.removeEventListener('mousedown', startInteraction);
|
||||
handle.removeEventListener('touchstart', startInteraction);
|
||||
}
|
||||
};
|
||||
},
|
||||
[startInteraction]
|
||||
);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
// on unmount, remove all drag handle event listeners
|
||||
removeEventListenersRef.current?.();
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
useImperativeHandle(ref, () => ({ setDragHandles }), [setDragHandles]);
|
||||
|
||||
return Boolean(dragHandleCount) ? null : <DefaultDragHandle onDragStart={startInteraction} />;
|
||||
})
|
||||
);
|
||||
|
||||
DragHandle.displayName = 'KbnGridLayoutDragHandle';
|
|
@ -0,0 +1,69 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the "Elastic License
|
||||
* 2.0", 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, 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';
|
||||
|
||||
export interface DragHandleApi {
|
||||
startDrag: (e: UserInteractionEvent) => void;
|
||||
setDragHandles?: (refs: Array<HTMLElement | null>) => void;
|
||||
}
|
||||
|
||||
export const useDragHandleApi = ({
|
||||
panelId,
|
||||
rowIndex,
|
||||
}: {
|
||||
panelId: string;
|
||||
rowIndex: number;
|
||||
}): DragHandleApi => {
|
||||
const { useCustomDragHandle } = useGridLayoutContext();
|
||||
|
||||
const startInteraction = useGridLayoutEvents({
|
||||
interactionType: 'drag',
|
||||
panelId,
|
||||
rowIndex,
|
||||
});
|
||||
|
||||
const removeEventListenersRef = useRef<(() => void) | null>(null);
|
||||
|
||||
const setDragHandles = useCallback(
|
||||
(dragHandles: Array<HTMLElement | null>) => {
|
||||
for (const handle of dragHandles) {
|
||||
if (handle === null) return;
|
||||
handle.addEventListener('mousedown', startInteraction, { passive: true });
|
||||
handle.addEventListener('touchstart', startInteraction, { passive: true });
|
||||
handle.style.touchAction = 'none';
|
||||
}
|
||||
removeEventListenersRef.current = () => {
|
||||
for (const handle of dragHandles) {
|
||||
if (handle === null) return;
|
||||
handle.removeEventListener('mousedown', startInteraction);
|
||||
handle.removeEventListener('touchstart', startInteraction);
|
||||
}
|
||||
};
|
||||
},
|
||||
[startInteraction]
|
||||
);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
// on unmount, remove all drag handle event listeners
|
||||
removeEventListenersRef.current?.();
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
return {
|
||||
startDrag: startInteraction,
|
||||
setDragHandles: useCustomDragHandle ? setDragHandles : undefined,
|
||||
};
|
||||
};
|
|
@ -9,20 +9,51 @@
|
|||
|
||||
import React from 'react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { GridPanel, GridPanelProps } from './grid_panel';
|
||||
import { GridPanel, type GridPanelProps } from './grid_panel';
|
||||
import { gridLayoutStateManagerMock, mockRenderPanelContents } from '../test_utils/mocks';
|
||||
import { GridLayoutContext, type GridLayoutContextType } from '../use_grid_layout_context';
|
||||
|
||||
describe('GridPanel', () => {
|
||||
const renderGridPanel = (propsOverrides: Partial<GridPanelProps> = {}) => {
|
||||
return render(
|
||||
<GridPanel
|
||||
panelId="panel1"
|
||||
rowIndex={0}
|
||||
renderPanelContents={mockRenderPanelContents}
|
||||
gridLayoutStateManager={gridLayoutStateManagerMock}
|
||||
{...propsOverrides}
|
||||
/>
|
||||
const renderGridPanel = (overrides?: {
|
||||
propsOverrides?: Partial<GridPanelProps>;
|
||||
contextOverrides?: Partial<GridLayoutContextType>;
|
||||
}) => {
|
||||
const contextValue = {
|
||||
renderPanelContents: mockRenderPanelContents,
|
||||
gridLayoutStateManager: gridLayoutStateManagerMock,
|
||||
...(overrides?.contextOverrides ?? {}),
|
||||
} as GridLayoutContextType;
|
||||
const panelProps = {
|
||||
panelId: 'panel1',
|
||||
rowIndex: 0,
|
||||
...(overrides?.propsOverrides ?? {}),
|
||||
};
|
||||
const { rerender, ...rtlRest } = render(
|
||||
<GridLayoutContext.Provider value={contextValue}>
|
||||
<GridPanel {...panelProps} />
|
||||
</GridLayoutContext.Provider>
|
||||
);
|
||||
|
||||
return {
|
||||
...rtlRest,
|
||||
rerender: (newOverrides?: {
|
||||
propsOverrides?: Partial<GridPanelProps>;
|
||||
contextOverrides?: Partial<GridLayoutContextType>;
|
||||
}) => {
|
||||
return rerender(
|
||||
<GridLayoutContext.Provider
|
||||
value={
|
||||
{
|
||||
...contextValue,
|
||||
...(newOverrides?.contextOverrides ?? {}),
|
||||
} as GridLayoutContextType
|
||||
}
|
||||
>
|
||||
<GridPanel {...panelProps} {...(newOverrides?.propsOverrides ?? {})} />
|
||||
</GridLayoutContext.Provider>
|
||||
);
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
|
@ -32,6 +63,22 @@ describe('GridPanel', () => {
|
|||
it('renders panel contents', () => {
|
||||
renderGridPanel();
|
||||
expect(screen.getByText('panel content panel1')).toBeInTheDocument();
|
||||
expect(mockRenderPanelContents).toHaveBeenCalledWith('panel1', expect.any(Function));
|
||||
expect(mockRenderPanelContents).toHaveBeenCalledWith('panel1', undefined);
|
||||
});
|
||||
|
||||
describe('use custom drag handle', () => {
|
||||
it('renders default drag handle when `useCustomDragHandle` is false | undefined', () => {
|
||||
const panel = renderGridPanel();
|
||||
expect(panel.queryByTestId('kbnGridPanel--dragHandle')).toBeInTheDocument();
|
||||
|
||||
panel.rerender({ contextOverrides: { useCustomDragHandle: false } });
|
||||
expect(panel.queryByTestId('kbnGridPanel--dragHandle')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render default drag handle when `useCustomDragHandle` is true and calls setDragHandles', () => {
|
||||
const panel = renderGridPanel({ contextOverrides: { useCustomDragHandle: true } });
|
||||
expect(panel.queryByTestId('kbnGridPanel--dragHandle')).not.toBeInTheDocument();
|
||||
expect(mockRenderPanelContents).toHaveBeenCalledWith('panel1', expect.any(Function));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -7,185 +7,172 @@
|
|||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import React, { useEffect, useMemo } from 'react';
|
||||
import { combineLatest, skip } from 'rxjs';
|
||||
|
||||
import { useEuiTheme } from '@elastic/eui';
|
||||
import { css } from '@emotion/react';
|
||||
|
||||
import { GridLayoutStateManager } from '../types';
|
||||
import { DragHandle, DragHandleApi } from './drag_handle';
|
||||
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';
|
||||
|
||||
export interface GridPanelProps {
|
||||
panelId: string;
|
||||
rowIndex: number;
|
||||
renderPanelContents: (
|
||||
panelId: string,
|
||||
setDragHandles?: (refs: Array<HTMLElement | null>) => void
|
||||
) => React.ReactNode;
|
||||
gridLayoutStateManager: GridLayoutStateManager;
|
||||
}
|
||||
|
||||
export const GridPanel = React.memo(
|
||||
({ panelId, rowIndex, renderPanelContents, gridLayoutStateManager }: GridPanelProps) => {
|
||||
const [dragHandleApi, setDragHandleApi] = useState<DragHandleApi | null>(null);
|
||||
const { euiTheme } = useEuiTheme();
|
||||
export const GridPanel = React.memo(({ panelId, rowIndex }: GridPanelProps) => {
|
||||
const { gridLayoutStateManager, useCustomDragHandle, renderPanelContents } =
|
||||
useGridLayoutContext();
|
||||
|
||||
/** Set initial styles based on state at mount to prevent styles from "blipping" */
|
||||
const initialStyles = useMemo(() => {
|
||||
const initialPanel = (gridLayoutStateManager.proposedGridLayout$.getValue() ??
|
||||
gridLayoutStateManager.gridLayout$.getValue())[rowIndex].panels[panelId];
|
||||
return css`
|
||||
position: relative;
|
||||
height: calc(
|
||||
1px *
|
||||
(
|
||||
${initialPanel.height} * (var(--kbnGridRowHeight) + var(--kbnGridGutterSize)) -
|
||||
var(--kbnGridGutterSize)
|
||||
)
|
||||
);
|
||||
grid-column-start: ${initialPanel.column + 1};
|
||||
grid-column-end: ${initialPanel.column + 1 + initialPanel.width};
|
||||
grid-row-start: ${initialPanel.row + 1};
|
||||
grid-row-end: ${initialPanel.row + 1 + initialPanel.height};
|
||||
`;
|
||||
}, [gridLayoutStateManager, rowIndex, panelId]);
|
||||
const { euiTheme } = useEuiTheme();
|
||||
const dragHandleApi = useDragHandleApi({ panelId, rowIndex });
|
||||
|
||||
useEffect(
|
||||
() => {
|
||||
/** Update the styles of the panel via a subscription to prevent re-renders */
|
||||
const activePanelStyleSubscription = combineLatest([
|
||||
gridLayoutStateManager.activePanel$,
|
||||
gridLayoutStateManager.gridLayout$,
|
||||
gridLayoutStateManager.proposedGridLayout$,
|
||||
])
|
||||
.pipe(skip(1)) // skip the first emit because the `initialStyles` will take care of it
|
||||
.subscribe(([activePanel, gridLayout, proposedGridLayout]) => {
|
||||
const ref = gridLayoutStateManager.panelRefs.current[rowIndex][panelId];
|
||||
const panel = (proposedGridLayout ?? gridLayout)[rowIndex].panels[panelId];
|
||||
if (!ref || !panel) return;
|
||||
/** Set initial styles based on state at mount to prevent styles from "blipping" */
|
||||
const initialStyles = useMemo(() => {
|
||||
const initialPanel = (gridLayoutStateManager.proposedGridLayout$.getValue() ??
|
||||
gridLayoutStateManager.gridLayout$.getValue())[rowIndex].panels[panelId];
|
||||
return css`
|
||||
position: relative;
|
||||
height: calc(
|
||||
1px *
|
||||
(
|
||||
${initialPanel.height} * (var(--kbnGridRowHeight) + var(--kbnGridGutterSize)) -
|
||||
var(--kbnGridGutterSize)
|
||||
)
|
||||
);
|
||||
grid-column-start: ${initialPanel.column + 1};
|
||||
grid-column-end: ${initialPanel.column + 1 + initialPanel.width};
|
||||
grid-row-start: ${initialPanel.row + 1};
|
||||
grid-row-end: ${initialPanel.row + 1 + initialPanel.height};
|
||||
`;
|
||||
}, [gridLayoutStateManager, rowIndex, panelId]);
|
||||
|
||||
const currentInteractionEvent = gridLayoutStateManager.interactionEvent$.getValue();
|
||||
useEffect(
|
||||
() => {
|
||||
/** Update the styles of the panel via a subscription to prevent re-renders */
|
||||
const activePanelStyleSubscription = combineLatest([
|
||||
gridLayoutStateManager.activePanel$,
|
||||
gridLayoutStateManager.gridLayout$,
|
||||
gridLayoutStateManager.proposedGridLayout$,
|
||||
])
|
||||
.pipe(skip(1)) // skip the first emit because the `initialStyles` will take care of it
|
||||
.subscribe(([activePanel, gridLayout, proposedGridLayout]) => {
|
||||
const ref = gridLayoutStateManager.panelRefs.current[rowIndex][panelId];
|
||||
const panel = (proposedGridLayout ?? gridLayout)[rowIndex].panels[panelId];
|
||||
if (!ref || !panel) return;
|
||||
|
||||
if (panelId === activePanel?.id) {
|
||||
ref.classList.add('kbnGridPanel--active');
|
||||
const currentInteractionEvent = gridLayoutStateManager.interactionEvent$.getValue();
|
||||
|
||||
// if the current panel is active, give it fixed positioning depending on the interaction event
|
||||
const { position: draggingPosition } = activePanel;
|
||||
const runtimeSettings = gridLayoutStateManager.runtimeSettings$.getValue();
|
||||
if (panelId === activePanel?.id) {
|
||||
ref.classList.add('kbnGridPanel--active');
|
||||
|
||||
ref.style.zIndex = `${euiTheme.levels.modal}`;
|
||||
if (currentInteractionEvent?.type === 'resize') {
|
||||
// if the current panel is being resized, ensure it is not shrunk past the size of a single cell
|
||||
ref.style.width = `${Math.max(
|
||||
draggingPosition.right - draggingPosition.left,
|
||||
runtimeSettings.columnPixelWidth
|
||||
)}px`;
|
||||
ref.style.height = `${Math.max(
|
||||
draggingPosition.bottom - draggingPosition.top,
|
||||
runtimeSettings.rowHeight
|
||||
)}px`;
|
||||
// if the current panel is active, give it fixed positioning depending on the interaction event
|
||||
const { position: draggingPosition } = activePanel;
|
||||
const runtimeSettings = gridLayoutStateManager.runtimeSettings$.getValue();
|
||||
|
||||
// undo any "lock to grid" styles **except** for the top left corner, which stays locked
|
||||
ref.style.gridColumnStart = `${panel.column + 1}`;
|
||||
ref.style.gridRowStart = `${panel.row + 1}`;
|
||||
ref.style.gridColumnEnd = `auto`;
|
||||
ref.style.gridRowEnd = `auto`;
|
||||
} else {
|
||||
// if the current panel is being dragged, render it with a fixed position + size
|
||||
ref.style.position = 'fixed';
|
||||
ref.style.zIndex = `${euiTheme.levels.modal}`;
|
||||
if (currentInteractionEvent?.type === 'resize') {
|
||||
// if the current panel is being resized, ensure it is not shrunk past the size of a single cell
|
||||
ref.style.width = `${Math.max(
|
||||
draggingPosition.right - draggingPosition.left,
|
||||
runtimeSettings.columnPixelWidth
|
||||
)}px`;
|
||||
ref.style.height = `${Math.max(
|
||||
draggingPosition.bottom - draggingPosition.top,
|
||||
runtimeSettings.rowHeight
|
||||
)}px`;
|
||||
|
||||
ref.style.left = `${draggingPosition.left}px`;
|
||||
ref.style.top = `${draggingPosition.top}px`;
|
||||
ref.style.width = `${draggingPosition.right - draggingPosition.left}px`;
|
||||
ref.style.height = `${draggingPosition.bottom - draggingPosition.top}px`;
|
||||
|
||||
// undo any "lock to grid" styles
|
||||
ref.style.gridArea = `auto`; // shortcut to set all grid styles to `auto`
|
||||
}
|
||||
} else {
|
||||
ref.classList.remove('kbnGridPanel--active');
|
||||
|
||||
ref.style.zIndex = `auto`;
|
||||
|
||||
// if the panel is not being dragged and/or resized, undo any fixed position styles
|
||||
ref.style.position = '';
|
||||
ref.style.left = ``;
|
||||
ref.style.top = ``;
|
||||
ref.style.width = ``;
|
||||
// setting the height is necessary for mobile mode
|
||||
ref.style.height = `calc(1px * (${panel.height} * (var(--kbnGridRowHeight) + var(--kbnGridGutterSize)) - var(--kbnGridGutterSize)))`;
|
||||
|
||||
// and render the panel locked to the grid
|
||||
// undo any "lock to grid" styles **except** for the top left corner, which stays locked
|
||||
ref.style.gridColumnStart = `${panel.column + 1}`;
|
||||
ref.style.gridColumnEnd = `${panel.column + 1 + panel.width}`;
|
||||
ref.style.gridRowStart = `${panel.row + 1}`;
|
||||
ref.style.gridRowEnd = `${panel.row + 1 + panel.height}`;
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* This subscription adds and/or removes the necessary class name for expanded panel styling
|
||||
*/
|
||||
const expandedPanelSubscription = gridLayoutStateManager.expandedPanelId$.subscribe(
|
||||
(expandedPanelId) => {
|
||||
const ref = gridLayoutStateManager.panelRefs.current[rowIndex][panelId];
|
||||
const gridLayout = gridLayoutStateManager.gridLayout$.getValue();
|
||||
const panel = gridLayout[rowIndex].panels[panelId];
|
||||
if (!ref || !panel) return;
|
||||
|
||||
if (expandedPanelId && expandedPanelId === panelId) {
|
||||
ref.classList.add('kbnGridPanel--expanded');
|
||||
ref.style.gridColumnEnd = `auto`;
|
||||
ref.style.gridRowEnd = `auto`;
|
||||
} else {
|
||||
ref.classList.remove('kbnGridPanel--expanded');
|
||||
// if the current panel is being dragged, render it with a fixed position + size
|
||||
ref.style.position = 'fixed';
|
||||
|
||||
ref.style.left = `${draggingPosition.left}px`;
|
||||
ref.style.top = `${draggingPosition.top}px`;
|
||||
ref.style.width = `${draggingPosition.right - draggingPosition.left}px`;
|
||||
ref.style.height = `${draggingPosition.bottom - draggingPosition.top}px`;
|
||||
|
||||
// undo any "lock to grid" styles
|
||||
ref.style.gridArea = `auto`; // shortcut to set all grid styles to `auto`
|
||||
}
|
||||
} else {
|
||||
ref.classList.remove('kbnGridPanel--active');
|
||||
|
||||
ref.style.zIndex = `auto`;
|
||||
|
||||
// if the panel is not being dragged and/or resized, undo any fixed position styles
|
||||
ref.style.position = '';
|
||||
ref.style.left = ``;
|
||||
ref.style.top = ``;
|
||||
ref.style.width = ``;
|
||||
// setting the height is necessary for mobile mode
|
||||
ref.style.height = `calc(1px * (${panel.height} * (var(--kbnGridRowHeight) + var(--kbnGridGutterSize)) - var(--kbnGridGutterSize)))`;
|
||||
|
||||
// and render the panel locked to the grid
|
||||
ref.style.gridColumnStart = `${panel.column + 1}`;
|
||||
ref.style.gridColumnEnd = `${panel.column + 1 + panel.width}`;
|
||||
ref.style.gridRowStart = `${panel.row + 1}`;
|
||||
ref.style.gridRowEnd = `${panel.row + 1 + panel.height}`;
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
return () => {
|
||||
expandedPanelSubscription.unsubscribe();
|
||||
activePanelStyleSubscription.unsubscribe();
|
||||
};
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[]
|
||||
);
|
||||
/**
|
||||
* This subscription adds and/or removes the necessary class name for expanded panel styling
|
||||
*/
|
||||
const expandedPanelSubscription = gridLayoutStateManager.expandedPanelId$.subscribe(
|
||||
(expandedPanelId) => {
|
||||
const ref = gridLayoutStateManager.panelRefs.current[rowIndex][panelId];
|
||||
const gridLayout = gridLayoutStateManager.gridLayout$.getValue();
|
||||
const panel = gridLayout[rowIndex].panels[panelId];
|
||||
if (!ref || !panel) return;
|
||||
|
||||
/**
|
||||
* Memoize panel contents to prevent unnecessary re-renders
|
||||
*/
|
||||
const panelContents = useMemo(() => {
|
||||
if (!dragHandleApi) return <></>; // delays the rendering of the panel until after dragHandleApi is defined
|
||||
return renderPanelContents(panelId, dragHandleApi.setDragHandles);
|
||||
}, [panelId, renderPanelContents, dragHandleApi]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={(element) => {
|
||||
if (!gridLayoutStateManager.panelRefs.current[rowIndex]) {
|
||||
gridLayoutStateManager.panelRefs.current[rowIndex] = {};
|
||||
if (expandedPanelId && expandedPanelId === panelId) {
|
||||
ref.classList.add('kbnGridPanel--expanded');
|
||||
} else {
|
||||
ref.classList.remove('kbnGridPanel--expanded');
|
||||
}
|
||||
gridLayoutStateManager.panelRefs.current[rowIndex][panelId] = element;
|
||||
}}
|
||||
css={initialStyles}
|
||||
className="kbnGridPanel"
|
||||
>
|
||||
<DragHandle
|
||||
ref={setDragHandleApi}
|
||||
gridLayoutStateManager={gridLayoutStateManager}
|
||||
panelId={panelId}
|
||||
rowIndex={rowIndex}
|
||||
/>
|
||||
{panelContents}
|
||||
<ResizeHandle
|
||||
gridLayoutStateManager={gridLayoutStateManager}
|
||||
panelId={panelId}
|
||||
rowIndex={rowIndex}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
return () => {
|
||||
expandedPanelSubscription.unsubscribe();
|
||||
activePanelStyleSubscription.unsubscribe();
|
||||
};
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[]
|
||||
);
|
||||
|
||||
/**
|
||||
* Memoize panel contents to prevent unnecessary re-renders
|
||||
*/
|
||||
const panelContents = useMemo(() => {
|
||||
return renderPanelContents(panelId, dragHandleApi.setDragHandles);
|
||||
}, [panelId, renderPanelContents, dragHandleApi]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={(element) => {
|
||||
if (!gridLayoutStateManager.panelRefs.current[rowIndex]) {
|
||||
gridLayoutStateManager.panelRefs.current[rowIndex] = {};
|
||||
}
|
||||
gridLayoutStateManager.panelRefs.current[rowIndex][panelId] = element;
|
||||
}}
|
||||
css={initialStyles}
|
||||
className="kbnGridPanel"
|
||||
>
|
||||
{!useCustomDragHandle && <DefaultDragHandle dragHandleApi={dragHandleApi} />}
|
||||
{panelContents}
|
||||
<ResizeHandle panelId={panelId} rowIndex={rowIndex} />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
GridPanel.displayName = 'KbnGridLayoutPanel';
|
||||
|
|
|
@ -13,22 +13,12 @@ import type { UseEuiTheme } from '@elastic/eui';
|
|||
import { css } from '@emotion/react';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
|
||||
import { GridLayoutStateManager } from '../types';
|
||||
import { useGridLayoutEvents } from '../use_grid_layout_events';
|
||||
|
||||
export const ResizeHandle = React.memo(
|
||||
({
|
||||
gridLayoutStateManager,
|
||||
rowIndex,
|
||||
panelId,
|
||||
}: {
|
||||
gridLayoutStateManager: GridLayoutStateManager;
|
||||
rowIndex: number;
|
||||
panelId: string;
|
||||
}) => {
|
||||
({ rowIndex, panelId }: { rowIndex: number; panelId: string }) => {
|
||||
const startInteraction = useGridLayoutEvents({
|
||||
interactionType: 'resize',
|
||||
gridLayoutStateManager,
|
||||
panelId,
|
||||
rowIndex,
|
||||
});
|
||||
|
|
|
@ -9,19 +9,28 @@
|
|||
import React from 'react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { GridRow, GridRowProps } from './grid_row';
|
||||
import { GridRow, type GridRowProps } from './grid_row';
|
||||
import { gridLayoutStateManagerMock, mockRenderPanelContents } from '../test_utils/mocks';
|
||||
import { getSampleLayout } from '../test_utils/sample_layout';
|
||||
import { GridLayoutContext, type GridLayoutContextType } from '../use_grid_layout_context';
|
||||
|
||||
describe('GridRow', () => {
|
||||
const renderGridRow = (propsOverrides: Partial<GridRowProps> = {}) => {
|
||||
const renderGridRow = (
|
||||
propsOverrides: Partial<GridRowProps> = {},
|
||||
contextOverrides: Partial<GridLayoutContextType> = {}
|
||||
) => {
|
||||
return render(
|
||||
<GridRow
|
||||
rowIndex={0}
|
||||
renderPanelContents={mockRenderPanelContents}
|
||||
gridLayoutStateManager={gridLayoutStateManagerMock}
|
||||
{...propsOverrides}
|
||||
/>
|
||||
<GridLayoutContext.Provider
|
||||
value={
|
||||
{
|
||||
renderPanelContents: mockRenderPanelContents,
|
||||
gridLayoutStateManager: gridLayoutStateManagerMock,
|
||||
...contextOverrides,
|
||||
} as GridLayoutContextType
|
||||
}
|
||||
>
|
||||
<GridRow rowIndex={0} {...propsOverrides} />
|
||||
</GridLayoutContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -15,161 +15,148 @@ import { css } from '@emotion/react';
|
|||
|
||||
import { DragPreview } from '../drag_preview';
|
||||
import { GridPanel } from '../grid_panel';
|
||||
import { GridLayoutStateManager } from '../types';
|
||||
import { useGridLayoutContext } from '../use_grid_layout_context';
|
||||
import { getKeysInOrder } from '../utils/resolve_grid_row';
|
||||
import { GridRowHeader } from './grid_row_header';
|
||||
|
||||
export interface GridRowProps {
|
||||
rowIndex: number;
|
||||
renderPanelContents: (
|
||||
panelId: string,
|
||||
setDragHandles?: (refs: Array<HTMLElement | null>) => void
|
||||
) => React.ReactNode;
|
||||
gridLayoutStateManager: GridLayoutStateManager;
|
||||
}
|
||||
|
||||
export const GridRow = React.memo(
|
||||
({ rowIndex, renderPanelContents, gridLayoutStateManager }: GridRowProps) => {
|
||||
const currentRow = gridLayoutStateManager.gridLayout$.value[rowIndex];
|
||||
export const GridRow = React.memo(({ rowIndex }: GridRowProps) => {
|
||||
const { gridLayoutStateManager } = useGridLayoutContext();
|
||||
|
||||
const [panelIdsInOrder, setPanelIdsInOrder] = useState<string[]>(() =>
|
||||
getKeysInOrder(currentRow.panels)
|
||||
);
|
||||
const [rowTitle, setRowTitle] = useState<string>(currentRow.title);
|
||||
const [isCollapsed, setIsCollapsed] = useState<boolean>(currentRow.isCollapsed);
|
||||
const currentRow = gridLayoutStateManager.gridLayout$.value[rowIndex];
|
||||
|
||||
useEffect(
|
||||
() => {
|
||||
/** Update the styles of the grid row via a subscription to prevent re-renders */
|
||||
const interactionStyleSubscription = gridLayoutStateManager.interactionEvent$
|
||||
.pipe(skip(1)) // skip the first emit because the `initialStyles` will take care of it
|
||||
.subscribe((interactionEvent) => {
|
||||
const rowRef = gridLayoutStateManager.rowRefs.current[rowIndex];
|
||||
if (!rowRef) return;
|
||||
const [panelIdsInOrder, setPanelIdsInOrder] = useState<string[]>(() =>
|
||||
getKeysInOrder(currentRow.panels)
|
||||
);
|
||||
const [rowTitle, setRowTitle] = useState<string>(currentRow.title);
|
||||
const [isCollapsed, setIsCollapsed] = useState<boolean>(currentRow.isCollapsed);
|
||||
|
||||
const targetRow = interactionEvent?.targetRowIndex;
|
||||
if (rowIndex === targetRow && interactionEvent) {
|
||||
rowRef.classList.add('kbnGridRow--targeted');
|
||||
} else {
|
||||
rowRef.classList.remove('kbnGridRow--targeted');
|
||||
}
|
||||
});
|
||||
useEffect(
|
||||
() => {
|
||||
/** Update the styles of the grid row via a subscription to prevent re-renders */
|
||||
const interactionStyleSubscription = gridLayoutStateManager.interactionEvent$
|
||||
.pipe(skip(1)) // skip the first emit because the `initialStyles` will take care of it
|
||||
.subscribe((interactionEvent) => {
|
||||
const rowRef = gridLayoutStateManager.rowRefs.current[rowIndex];
|
||||
if (!rowRef) return;
|
||||
|
||||
/**
|
||||
* This subscription ensures that the row will re-render when one of the following changes:
|
||||
* - Title
|
||||
* - Collapsed state
|
||||
* - Panel IDs (adding/removing/replacing, but not reordering)
|
||||
*/
|
||||
const rowStateSubscription = combineLatest([
|
||||
gridLayoutStateManager.proposedGridLayout$,
|
||||
gridLayoutStateManager.gridLayout$,
|
||||
])
|
||||
.pipe(
|
||||
map(([proposedGridLayout, gridLayout]) => {
|
||||
const displayedGridLayout = proposedGridLayout ?? gridLayout;
|
||||
return {
|
||||
title: displayedGridLayout[rowIndex].title,
|
||||
isCollapsed: displayedGridLayout[rowIndex].isCollapsed,
|
||||
panelIds: Object.keys(displayedGridLayout[rowIndex].panels),
|
||||
};
|
||||
}),
|
||||
pairwise()
|
||||
)
|
||||
.subscribe(([oldRowData, newRowData]) => {
|
||||
if (oldRowData.title !== newRowData.title) setRowTitle(newRowData.title);
|
||||
if (oldRowData.isCollapsed !== newRowData.isCollapsed)
|
||||
setIsCollapsed(newRowData.isCollapsed);
|
||||
if (
|
||||
oldRowData.panelIds.length !== newRowData.panelIds.length ||
|
||||
!(
|
||||
oldRowData.panelIds.every((p) => newRowData.panelIds.includes(p)) &&
|
||||
newRowData.panelIds.every((p) => oldRowData.panelIds.includes(p))
|
||||
)
|
||||
) {
|
||||
setPanelIdsInOrder(
|
||||
getKeysInOrder(
|
||||
(gridLayoutStateManager.proposedGridLayout$.getValue() ??
|
||||
gridLayoutStateManager.gridLayout$.getValue())[rowIndex].panels
|
||||
)
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Ensure the row re-renders to reflect the new panel order after a drag-and-drop interaction, since
|
||||
* the order of rendered panels need to be aligned with how they are displayed in the grid for accessibility
|
||||
* reasons (screen readers and focus management).
|
||||
*/
|
||||
const gridLayoutSubscription = gridLayoutStateManager.gridLayout$.subscribe(
|
||||
(gridLayout) => {
|
||||
const newPanelIdsInOrder = getKeysInOrder(gridLayout[rowIndex].panels);
|
||||
if (panelIdsInOrder.join() !== newPanelIdsInOrder.join()) {
|
||||
setPanelIdsInOrder(newPanelIdsInOrder);
|
||||
}
|
||||
const targetRow = interactionEvent?.targetRowIndex;
|
||||
if (rowIndex === targetRow && interactionEvent) {
|
||||
rowRef.classList.add('kbnGridRow--targeted');
|
||||
} else {
|
||||
rowRef.classList.remove('kbnGridRow--targeted');
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
const columnCountSubscription = gridLayoutStateManager.runtimeSettings$
|
||||
.pipe(
|
||||
map(({ columnCount }) => columnCount),
|
||||
distinctUntilChanged()
|
||||
)
|
||||
.subscribe((columnCount) => {
|
||||
const rowRef = gridLayoutStateManager.rowRefs.current[rowIndex];
|
||||
if (!rowRef) return;
|
||||
rowRef.style.setProperty('--kbnGridRowColumnCount', `${columnCount}`);
|
||||
});
|
||||
/**
|
||||
* This subscription ensures that the row will re-render when one of the following changes:
|
||||
* - Title
|
||||
* - Collapsed state
|
||||
* - Panel IDs (adding/removing/replacing, but not reordering)
|
||||
*/
|
||||
const rowStateSubscription = combineLatest([
|
||||
gridLayoutStateManager.proposedGridLayout$,
|
||||
gridLayoutStateManager.gridLayout$,
|
||||
])
|
||||
.pipe(
|
||||
map(([proposedGridLayout, gridLayout]) => {
|
||||
const displayedGridLayout = proposedGridLayout ?? gridLayout;
|
||||
return {
|
||||
title: displayedGridLayout[rowIndex].title,
|
||||
isCollapsed: displayedGridLayout[rowIndex].isCollapsed,
|
||||
panelIds: Object.keys(displayedGridLayout[rowIndex].panels),
|
||||
};
|
||||
}),
|
||||
pairwise()
|
||||
)
|
||||
.subscribe(([oldRowData, newRowData]) => {
|
||||
if (oldRowData.title !== newRowData.title) setRowTitle(newRowData.title);
|
||||
if (oldRowData.isCollapsed !== newRowData.isCollapsed)
|
||||
setIsCollapsed(newRowData.isCollapsed);
|
||||
if (
|
||||
oldRowData.panelIds.length !== newRowData.panelIds.length ||
|
||||
!(
|
||||
oldRowData.panelIds.every((p) => newRowData.panelIds.includes(p)) &&
|
||||
newRowData.panelIds.every((p) => oldRowData.panelIds.includes(p))
|
||||
)
|
||||
) {
|
||||
setPanelIdsInOrder(
|
||||
getKeysInOrder(
|
||||
(gridLayoutStateManager.proposedGridLayout$.getValue() ??
|
||||
gridLayoutStateManager.gridLayout$.getValue())[rowIndex].panels
|
||||
)
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
interactionStyleSubscription.unsubscribe();
|
||||
gridLayoutSubscription.unsubscribe();
|
||||
rowStateSubscription.unsubscribe();
|
||||
columnCountSubscription.unsubscribe();
|
||||
};
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[rowIndex]
|
||||
);
|
||||
/**
|
||||
* Ensure the row re-renders to reflect the new panel order after a drag-and-drop interaction, since
|
||||
* the order of rendered panels need to be aligned with how they are displayed in the grid for accessibility
|
||||
* reasons (screen readers and focus management).
|
||||
*/
|
||||
const gridLayoutSubscription = gridLayoutStateManager.gridLayout$.subscribe((gridLayout) => {
|
||||
const newPanelIdsInOrder = getKeysInOrder(gridLayout[rowIndex].panels);
|
||||
if (panelIdsInOrder.join() !== newPanelIdsInOrder.join()) {
|
||||
setPanelIdsInOrder(newPanelIdsInOrder);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<div css={styles.fullHeight} className="kbnGridRowContainer">
|
||||
{rowIndex !== 0 && (
|
||||
<GridRowHeader
|
||||
isCollapsed={isCollapsed}
|
||||
toggleIsCollapsed={() => {
|
||||
const newLayout = cloneDeep(gridLayoutStateManager.gridLayout$.value);
|
||||
newLayout[rowIndex].isCollapsed = !newLayout[rowIndex].isCollapsed;
|
||||
gridLayoutStateManager.gridLayout$.next(newLayout);
|
||||
}}
|
||||
rowTitle={rowTitle}
|
||||
/>
|
||||
)}
|
||||
{!isCollapsed && (
|
||||
<div
|
||||
className={'kbnGridRow'}
|
||||
ref={(element: HTMLDivElement | null) =>
|
||||
(gridLayoutStateManager.rowRefs.current[rowIndex] = element)
|
||||
}
|
||||
css={[styles.fullHeight, styles.grid]}
|
||||
>
|
||||
{/* render the panels **in order** for accessibility, using the memoized panel components */}
|
||||
{panelIdsInOrder.map((panelId) => (
|
||||
<GridPanel
|
||||
key={panelId}
|
||||
panelId={panelId}
|
||||
rowIndex={rowIndex}
|
||||
gridLayoutStateManager={gridLayoutStateManager}
|
||||
renderPanelContents={renderPanelContents}
|
||||
/>
|
||||
))}
|
||||
<DragPreview rowIndex={rowIndex} gridLayoutStateManager={gridLayoutStateManager} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
const columnCountSubscription = gridLayoutStateManager.runtimeSettings$
|
||||
.pipe(
|
||||
map(({ columnCount }) => columnCount),
|
||||
distinctUntilChanged()
|
||||
)
|
||||
.subscribe((columnCount) => {
|
||||
const rowRef = gridLayoutStateManager.rowRefs.current[rowIndex];
|
||||
if (!rowRef) return;
|
||||
rowRef.style.setProperty('--kbnGridRowColumnCount', `${columnCount}`);
|
||||
});
|
||||
|
||||
return () => {
|
||||
interactionStyleSubscription.unsubscribe();
|
||||
gridLayoutSubscription.unsubscribe();
|
||||
rowStateSubscription.unsubscribe();
|
||||
columnCountSubscription.unsubscribe();
|
||||
};
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[rowIndex]
|
||||
);
|
||||
|
||||
return (
|
||||
<div css={styles.fullHeight} className="kbnGridRowContainer">
|
||||
{rowIndex !== 0 && (
|
||||
<GridRowHeader
|
||||
isCollapsed={isCollapsed}
|
||||
toggleIsCollapsed={() => {
|
||||
const newLayout = cloneDeep(gridLayoutStateManager.gridLayout$.value);
|
||||
newLayout[rowIndex].isCollapsed = !newLayout[rowIndex].isCollapsed;
|
||||
gridLayoutStateManager.gridLayout$.next(newLayout);
|
||||
}}
|
||||
rowTitle={rowTitle}
|
||||
/>
|
||||
)}
|
||||
{!isCollapsed && (
|
||||
<div
|
||||
className={'kbnGridRow'}
|
||||
ref={(element: HTMLDivElement | null) =>
|
||||
(gridLayoutStateManager.rowRefs.current[rowIndex] = element)
|
||||
}
|
||||
css={[styles.fullHeight, styles.grid]}
|
||||
>
|
||||
{/* render the panels **in order** for accessibility, using the memoized panel components */}
|
||||
{panelIdsInOrder.map((panelId) => (
|
||||
<GridPanel key={panelId} panelId={panelId} rowIndex={rowIndex} />
|
||||
))}
|
||||
<DragPreview rowIndex={rowIndex} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
const styles = {
|
||||
fullHeight: css({
|
||||
|
|
|
@ -107,18 +107,24 @@ export interface PanelInteractionEvent {
|
|||
};
|
||||
}
|
||||
|
||||
// TODO: Remove from Dashboard plugin as part of https://github.com/elastic/kibana/issues/190446
|
||||
export enum PanelPlacementStrategy {
|
||||
/** Place on the very top of the grid layout, add the height of this panel to all other panels. */
|
||||
placeAtTop = 'placeAtTop',
|
||||
/** Look for the smallest y and x value where the default panel will fit. */
|
||||
findTopLeftMostOpenSpace = 'findTopLeftMostOpenSpace',
|
||||
}
|
||||
|
||||
export interface PanelPlacementSettings {
|
||||
strategy?: PanelPlacementStrategy;
|
||||
height: number;
|
||||
width: number;
|
||||
}
|
||||
/**
|
||||
* This type is used to conditionally change the type of `renderPanelContents` depending
|
||||
* on the value of `useCustomDragHandle`
|
||||
*/
|
||||
export type UseCustomDragHandle =
|
||||
| {
|
||||
useCustomDragHandle: true;
|
||||
renderPanelContents: (
|
||||
panelId: string,
|
||||
setDragHandles: (refs: Array<HTMLElement | null>) => void
|
||||
) => React.ReactNode;
|
||||
}
|
||||
| {
|
||||
useCustomDragHandle?: false;
|
||||
renderPanelContents: (panelId: string) => React.ReactNode;
|
||||
};
|
||||
|
||||
/**
|
||||
* Controls whether the resize + drag handles are visible and functioning
|
||||
*/
|
||||
export type GridAccessMode = 'VIEW' | 'EDIT';
|
||||
|
|
|
@ -0,0 +1,32 @@
|
|||
/*
|
||||
* 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 { createContext, useContext } from 'react';
|
||||
import { GridLayoutStateManager } from './types';
|
||||
|
||||
export interface GridLayoutContextType<UseCustomDragHandle extends boolean = boolean> {
|
||||
gridLayoutStateManager: GridLayoutStateManager;
|
||||
useCustomDragHandle: UseCustomDragHandle;
|
||||
renderPanelContents: (
|
||||
panelId: string,
|
||||
setDragHandles: UseCustomDragHandle extends true
|
||||
? (refs: Array<HTMLElement | null>) => void
|
||||
: undefined
|
||||
) => React.ReactNode;
|
||||
}
|
||||
|
||||
export const GridLayoutContext = createContext<GridLayoutContextType | undefined>(undefined);
|
||||
|
||||
export const useGridLayoutContext = (): GridLayoutContextType => {
|
||||
const context = useContext<GridLayoutContextType | undefined>(GridLayoutContext);
|
||||
if (!context) {
|
||||
throw new Error('useGridLayoutContext must be used inside GridLayoutContext');
|
||||
}
|
||||
return context;
|
||||
};
|
|
@ -18,6 +18,7 @@ import {
|
|||
} 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.
|
||||
|
@ -28,15 +29,15 @@ import { UserInteractionEvent } from './types';
|
|||
|
||||
export const useGridLayoutEvents = ({
|
||||
interactionType,
|
||||
gridLayoutStateManager,
|
||||
rowIndex,
|
||||
panelId,
|
||||
}: {
|
||||
interactionType: PanelInteractionEvent['type'];
|
||||
gridLayoutStateManager: GridLayoutStateManager;
|
||||
rowIndex: number;
|
||||
panelId: string;
|
||||
}) => {
|
||||
const { gridLayoutStateManager } = useGridLayoutContext();
|
||||
|
||||
const lastRequestedPanelPosition = useRef<GridPanelData | undefined>(undefined);
|
||||
const pointerPixel = useRef<{ clientX: number; clientY: number }>({ clientX: 0, clientY: 0 });
|
||||
|
||||
|
|
|
@ -97,7 +97,7 @@ export const DashboardGrid = ({
|
|||
);
|
||||
|
||||
const renderPanelContents = useCallback(
|
||||
(id: string, setDragHandles?: (refs: Array<HTMLElement | null>) => void) => {
|
||||
(id: string, setDragHandles: (refs: Array<HTMLElement | null>) => void) => {
|
||||
const currentPanels = dashboardApi.panels$.getValue();
|
||||
if (!currentPanels[id]) return;
|
||||
|
||||
|
@ -132,6 +132,7 @@ export const DashboardGrid = ({
|
|||
rowHeight: DASHBOARD_GRID_HEIGHT,
|
||||
columnCount: DASHBOARD_GRID_COLUMN_COUNT,
|
||||
}}
|
||||
useCustomDragHandle={true}
|
||||
renderPanelContents={renderPanelContents}
|
||||
onLayoutChange={onLayoutChange}
|
||||
expandedPanelId={expandedPanelId}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue