Explore: Enable resize of split pane (#58683)

* Move layout to paneleditor, make SplitPaneWrapper more generic

* Read/write the size ratio in local storage

* Add min height to enable scrollbar

* Enable show/hide panel options

* Add new component to explore

* Add styles

* Bring in code from other branch

* Fix update size function, add min size to explore container

* Add window size, save width as a ratio

* Fix tests

* Allow for one child

* Remove children type definition

* Use library methods for min/max size instead of hooks
This commit is contained in:
Kristina 2022-11-17 09:27:07 -06:00 committed by GitHub
parent 0c4aa6d0d8
commit 5cad7089b3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 231 additions and 44 deletions

View File

@ -24,6 +24,7 @@ export interface Props {
className?: string;
isFullscreen?: boolean;
'aria-label'?: string;
buttonOverflowAlignment?: 'left' | 'right';
}
/** @alpha */
@ -42,6 +43,7 @@ export const PageToolbar: FC<Props> = React.memo(
className,
/** main nav-container aria-label **/
'aria-label': ariaLabel,
buttonOverflowAlignment = 'right',
}) => {
const styles = useStyles2(getStyles);
@ -132,7 +134,9 @@ export const PageToolbar: FC<Props> = React.memo(
)}
</nav>
</div>
<ToolbarButtonRow alignment="right">{React.Children.toArray(children).filter(Boolean)}</ToolbarButtonRow>
<ToolbarButtonRow alignment={buttonOverflowAlignment}>
{React.Children.toArray(children).filter(Boolean)}
</ToolbarButtonRow>
</nav>
);
}

View File

@ -9,9 +9,11 @@ interface Props {
splitOrientation?: Split;
paneSize: number;
splitVisible?: boolean;
minSize?: number;
maxSize?: number;
primary?: 'first' | 'second';
onDragFinished?: (size?: number) => void;
paneStyle?: React.CSSProperties;
secondaryPaneStyle?: React.CSSProperties;
}
@ -49,9 +51,27 @@ export class SplitPaneWrapper extends PureComponent<Props> {
};
render() {
const { paneSize, splitOrientation, maxSize, primary, secondaryPaneStyle } = this.props;
const {
children,
paneSize,
splitOrientation,
maxSize,
minSize,
primary,
paneStyle,
secondaryPaneStyle,
splitVisible = true,
} = this.props;
let childrenArr = [];
if (Array.isArray(children)) {
childrenArr = children;
} else {
childrenArr.push(children);
}
// Limit options pane width to 90% of screen.
const styles = getStyles(config.theme2);
const styles = getStyles(config.theme2, splitVisible);
// Need to handle when width is relative. ie a percentage of the viewport
const paneSizePx =
@ -59,29 +79,38 @@ export class SplitPaneWrapper extends PureComponent<Props> {
? paneSize * (splitOrientation === 'horizontal' ? window.innerHeight : window.innerWidth)
: paneSize;
// the react split pane library always wants 2 children. This logic ensures that happens, even if one child is passed in
const childrenFragments = [
<React.Fragment key="leftPane">{childrenArr[0]}</React.Fragment>,
<React.Fragment key="rightPane">{childrenArr[1] || undefined}</React.Fragment>,
];
return (
<SplitPane
split={splitOrientation}
minSize={minSize}
maxSize={maxSize}
size={paneSizePx}
primary={primary}
size={splitVisible ? paneSizePx : 0}
primary={splitVisible ? primary : 'second'}
resizerClassName={splitOrientation === 'horizontal' ? styles.resizerH : styles.resizerV}
onDragStarted={() => this.onDragStarted()}
onDragFinished={(size) => this.onDragFinished(size)}
paneStyle={paneStyle}
pane2Style={secondaryPaneStyle}
>
{this.props.children}
{childrenFragments}
</SplitPane>
);
}
}
const getStyles = (theme: GrafanaTheme2) => {
const getStyles = (theme: GrafanaTheme2, hasSplit: boolean) => {
const handleColor = theme.v1.palette.blue95;
const paneSpacing = theme.spacing(2);
const resizer = css`
position: relative;
display: ${hasSplit ? 'block' : 'none'};
&::before {
content: '';

View File

@ -1,4 +1,4 @@
import { css, cx } from '@emotion/css';
import { css } from '@emotion/css';
import memoizeOne from 'memoize-one';
import React from 'react';
import { connect, ConnectedProps } from 'react-redux';
@ -36,20 +36,18 @@ const getStyles = (theme: GrafanaTheme2) => {
display: flex;
flex: 1 1 auto;
flex-direction: column;
overflow: scroll;
min-width: 600px;
& + & {
border-left: 1px dotted ${theme.colors.border.medium};
}
`,
exploreSplit: css`
width: 50%;
`,
};
};
interface OwnProps extends Themeable2 {
exploreId: ExploreId;
urlQuery: string;
split: boolean;
eventBus: EventBus;
}
@ -144,11 +142,10 @@ class ExplorePaneContainerUnconnected extends React.PureComponent<Props> {
};
render() {
const { theme, split, exploreId, initialized, eventBus } = this.props;
const { theme, exploreId, initialized, eventBus } = this.props;
const styles = getStyles(theme);
const exploreClass = cx(styles.explore, split && styles.exploreSplit);
return (
<div className={exploreClass} ref={this.getRef} data-testid={selectors.pages.Explore.General.container}>
<div className={styles.explore} ref={this.getRef} data-testid={selectors.pages.Explore.General.container}>
{initialized && <Explore exploreId={exploreId} eventBus={eventBus} />}
</div>
);

View File

@ -26,7 +26,7 @@ import { getFiscalYearStartMonth, getTimeZone } from '../profile/state/selectors
import { ExploreTimeControls } from './ExploreTimeControls';
import { LiveTailButton } from './LiveTailButton';
import { changeDatasource } from './state/datasource';
import { splitClose, splitOpen } from './state/main';
import { splitClose, splitOpen, maximizePaneAction, evenPaneResizeAction } from './state/main';
import { cancelQueries, runQueries } from './state/query';
import { isSplit } from './state/selectors';
import { syncTimes, changeRefreshInterval } from './state/time';
@ -133,13 +133,24 @@ class UnConnectedExploreToolbar extends PureComponent<Props> {
isPaused,
hasLiveOption,
containerWidth,
largerExploreId,
} = this.props;
const showSmallTimePicker = splitted || containerWidth < 1210;
const isLargerExploreId = largerExploreId === exploreId;
const showExploreToDashboard =
contextSrv.hasAccess(AccessControlAction.DashboardsCreate, contextSrv.isEditor) ||
contextSrv.hasAccess(AccessControlAction.DashboardsWrite, contextSrv.isEditor);
const onClickResize = () => {
if (isLargerExploreId) {
this.props.evenPaneResizeAction();
} else {
this.props.maximizePaneAction({ exploreId: exploreId });
}
};
return [
!splitted ? (
<ToolbarButton
@ -152,9 +163,21 @@ class UnConnectedExploreToolbar extends PureComponent<Props> {
Split
</ToolbarButton>
) : (
<ToolbarButton key="split" tooltip="Close split pane" onClick={this.onCloseSplitView} icon="times">
Close
</ToolbarButton>
<React.Fragment key="splitActions">
<ToolbarButton
tooltip={`${isLargerExploreId ? 'Narrow' : 'Widen'} pane`}
disabled={isLive}
onClick={onClickResize}
icon={
(exploreId === 'left' && isLargerExploreId) || (exploreId === 'right' && !isLargerExploreId)
? 'angle-left'
: 'angle-right'
}
/>
<ToolbarButton tooltip="Close split pane" onClick={this.onCloseSplitView} icon="times">
Close
</ToolbarButton>
</React.Fragment>
),
showExploreToDashboard && (
@ -285,7 +308,7 @@ class UnConnectedExploreToolbar extends PureComponent<Props> {
}
const mapStateToProps = (state: StoreState, { exploreId }: OwnProps) => {
const { syncedTimes } = state.explore;
const { syncedTimes, largerExploreId } = state.explore;
const exploreItem = state.explore[exploreId]!;
const { datasourceInstance, datasourceMissing, range, refreshInterval, loading, isLive, isPaused, containerWidth } =
exploreItem;
@ -307,6 +330,7 @@ const mapStateToProps = (state: StoreState, { exploreId }: OwnProps) => {
isPaused,
syncedTimes,
containerWidth,
largerExploreId,
};
};
@ -320,6 +344,8 @@ const mapDispatchToProps = {
syncTimes,
onChangeTimeZone: updateTimeZoneForSession,
onChangeFiscalYearStartMonth: updateFiscalYearStartMonthForSession,
maximizePaneAction,
evenPaneResizeAction,
};
const connector = connect(mapStateToProps, mapDispatchToProps);

View File

@ -8,7 +8,7 @@ import { locationService, config } from '@grafana/runtime';
import { changeDatasource } from './spec/helper/interactions';
import { makeLogsQueryResponse, makeMetricsQueryResponse } from './spec/helper/query';
import { setupExplore, tearDown, waitForExplore } from './spec/helper/setup';
import { splitOpen } from './state/main';
import * as mainState from './state/main';
import * as queryState from './state/query';
jest.mock('app/core/core', () => {
@ -154,7 +154,7 @@ describe('Wrapper', () => {
});
});
describe('Handles open/close splits in UI and URL', () => {
describe('Handles open/close splits and related events in UI and URL', () => {
it('opens the split pane when split button is clicked', async () => {
setupExplore();
// Wait for rendering the editor
@ -226,8 +226,8 @@ describe('Wrapper', () => {
await userEvent.click(closeButtons[1]);
await waitFor(() => {
const logsPanels = screen.queryAllByLabelText(/Close split pane/i);
expect(logsPanels.length).toBe(0);
const postCloseButtons = screen.queryAllByLabelText(/Close split pane/i);
expect(postCloseButtons.length).toBe(0);
});
});
@ -261,12 +261,35 @@ describe('Wrapper', () => {
// to work
await screen.findByText(`loki Editor input: { label="value"}`);
store.dispatch(splitOpen<any>({ datasourceUid: 'elastic', query: { expr: 'error' } }) as any);
store.dispatch(mainState.splitOpen<any>({ datasourceUid: 'elastic', query: { expr: 'error' } }) as any);
// Editor renders the new query
await screen.findByText(`elastic Editor input: error`);
await screen.findByText(`loki Editor input: { label="value"}`);
});
it('handles split size events and sets relevant variables', async () => {
setupExplore();
const splitButton = await screen.findByText(/split/i);
fireEvent.click(splitButton);
await waitForExplore(undefined, true);
let widenButton = await screen.findAllByLabelText('Widen pane');
let narrowButton = await screen.queryAllByLabelText('Narrow pane');
const panes = screen.getAllByRole('main');
expect(widenButton.length).toBe(2);
expect(narrowButton.length).toBe(0);
expect(Number.parseInt(getComputedStyle(panes[0]).width, 10)).toBe(1000);
expect(Number.parseInt(getComputedStyle(panes[1]).width, 10)).toBe(1000);
const resizer = screen.getByRole('presentation');
fireEvent.mouseDown(resizer, { buttons: 1 });
fireEvent.mouseMove(resizer, { clientX: -700, buttons: 1 });
fireEvent.mouseUp(resizer);
widenButton = await screen.findAllByLabelText('Widen pane');
narrowButton = await screen.queryAllByLabelText('Narrow pane');
expect(widenButton.length).toBe(1);
expect(narrowButton.length).toBe(1);
// the autosizer is mocked so there is no actual resize here
});
});
describe('Handles document title changes', () => {
@ -295,7 +318,7 @@ describe('Wrapper', () => {
// to work
await screen.findByText(`loki Editor input: { label="value"}`);
store.dispatch(splitOpen<any>({ datasourceUid: 'elastic', query: { expr: 'error' } }) as any);
store.dispatch(mainState.splitOpen<any>({ datasourceUid: 'elastic', query: { expr: 'error' } }) as any);
await waitFor(() => expect(document.title).toEqual('Explore - loki | elastic - Grafana'));
});
});

View File

@ -1,8 +1,11 @@
import { css } from '@emotion/css';
import React, { useEffect, useRef } from 'react';
import { inRange } from 'lodash';
import React, { useEffect, useRef, useState } from 'react';
import { useWindowSize } from 'react-use';
import { locationService } from '@grafana/runtime';
import { ErrorBoundaryAlert, usePanelContext } from '@grafana/ui';
import { SplitPaneWrapper } from 'app/core/components/SplitPaneWrapper/SplitPaneWrapper';
import { useGrafana } from 'app/core/context/GrafanaContext';
import { useAppNotification } from 'app/core/copy/appNotification';
import { useNavModel } from 'app/core/hooks/useNavModel';
@ -16,7 +19,7 @@ import { useCorrelations } from '../correlations/useCorrelations';
import { ExploreActions } from './ExploreActions';
import { ExplorePaneContainer } from './ExplorePaneContainer';
import { lastSavedUrl, resetExploreAction, saveCorrelationsAction } from './state/main';
import { lastSavedUrl, saveCorrelationsAction, resetExploreAction, splitSizeUpdateAction } from './state/main';
const styles = {
pageScrollbarWrapper: css`
@ -40,6 +43,10 @@ function Wrapper(props: GrafanaRouteComponentProps<{}, ExploreQueryParams>) {
const { warning } = useAppNotification();
const panelCtx = usePanelContext();
const eventBus = useRef(panelCtx.eventBus.newScopedBus('explore', { onlyLocal: false }));
const [rightPaneWidthRatio, setRightPaneWidthRatio] = useState(0.5);
const { width: windowWidth } = useWindowSize();
const minWidth = 200;
const exploreState = useSelector((state) => state.explore);
useEffect(() => {
//This is needed for breadcrumbs and topnav.
@ -97,30 +104,65 @@ function Wrapper(props: GrafanaRouteComponentProps<{}, ExploreQueryParams>) {
// eslint-disable-next-line react-hooks/exhaustive-deps -- dispatch is stable, doesn't need to be in the deps array
}, []);
const updateSplitSize = (size: number) => {
const evenSplitWidth = windowWidth / 2;
const areBothSimilar = inRange(size, evenSplitWidth - 100, evenSplitWidth + 100);
if (areBothSimilar) {
dispatch(splitSizeUpdateAction({ largerExploreId: undefined }));
} else {
dispatch(
splitSizeUpdateAction({
largerExploreId: size > evenSplitWidth ? ExploreId.right : ExploreId.left,
})
);
}
setRightPaneWidthRatio(size / windowWidth);
};
const hasSplit = Boolean(queryParams.left) && Boolean(queryParams.right);
let widthCalc = 0;
if (hasSplit) {
if (!exploreState.evenSplitPanes && exploreState.maxedExploreId) {
widthCalc = exploreState.maxedExploreId === ExploreId.right ? windowWidth - minWidth : minWidth;
} else if (exploreState.evenSplitPanes) {
widthCalc = Math.floor(windowWidth / 2);
} else if (rightPaneWidthRatio !== undefined) {
widthCalc = windowWidth * rightPaneWidthRatio;
}
}
return (
<div className={styles.pageScrollbarWrapper}>
<ExploreActions exploreIdLeft={ExploreId.left} exploreIdRight={ExploreId.right} />
<div className={styles.exploreWrapper}>
<ErrorBoundaryAlert style="page">
<ExplorePaneContainer
split={hasSplit}
exploreId={ExploreId.left}
urlQuery={queryParams.left}
eventBus={eventBus.current}
/>
</ErrorBoundaryAlert>
{hasSplit && (
<SplitPaneWrapper
splitOrientation="vertical"
paneSize={widthCalc}
minSize={minWidth}
maxSize={minWidth * -1}
primary="second"
splitVisible={hasSplit}
paneStyle={{ overflow: 'auto', display: 'flex', flexDirection: 'column', overflowY: 'scroll' }}
onDragFinished={(size) => {
if (size) {
updateSplitSize(size);
}
}}
>
<ErrorBoundaryAlert style="page">
<ExplorePaneContainer
split={hasSplit}
exploreId={ExploreId.right}
urlQuery={queryParams.right}
eventBus={eventBus.current}
/>
<ExplorePaneContainer exploreId={ExploreId.left} urlQuery={queryParams.left} eventBus={eventBus.current} />
</ErrorBoundaryAlert>
)}
{hasSplit && (
<ErrorBoundaryAlert style="page">
<ExplorePaneContainer
exploreId={ExploreId.right}
urlQuery={queryParams.right}
eventBus={eventBus.current}
/>
</ErrorBoundaryAlert>
)}
</SplitPaneWrapper>
</div>
</div>
);

View File

@ -139,7 +139,10 @@ describe('Explore reducer', () => {
.givenReducer(exploreReducer, initialState)
.whenActionIsDispatched(splitCloseAction({ itemId: ExploreId.left }))
.thenStateShouldEqual({
evenSplitPanes: true,
largerExploreId: undefined,
left: rightItemMock,
maxedExploreId: undefined,
right: undefined,
} as unknown as ExploreState);
});
@ -162,7 +165,10 @@ describe('Explore reducer', () => {
.givenReducer(exploreReducer, initialState)
.whenActionIsDispatched(splitCloseAction({ itemId: ExploreId.right }))
.thenStateShouldEqual({
evenSplitPanes: true,
largerExploreId: undefined,
left: leftItemMock,
maxedExploreId: undefined,
right: undefined,
} as unknown as ExploreState);
});

View File

@ -40,6 +40,16 @@ export const richHistorySearchFiltersUpdatedAction = createAction<{
export const saveCorrelationsAction = createAction<CorrelationData[]>('explore/saveCorrelationsAction');
export const splitSizeUpdateAction = createAction<{
largerExploreId?: ExploreId;
}>('explore/splitSizeUpdateAction');
export const maximizePaneAction = createAction<{
exploreId?: ExploreId;
}>('explore/maximizePaneAction');
export const evenPaneResizeAction = createAction('explore/evenPaneResizeAction');
/**
* Resets state for explore.
*/
@ -163,6 +173,9 @@ export const initialExploreState: ExploreState = {
richHistoryStorageFull: false,
richHistoryLimitExceededWarningShown: false,
richHistoryMigrationFailed: false,
largerExploreId: undefined,
maxedExploreId: undefined,
evenSplitPanes: true,
};
/**
@ -179,6 +192,38 @@ export const exploreReducer = (state = initialExploreState, action: AnyAction):
return {
...state,
...targetSplit,
largerExploreId: undefined,
maxedExploreId: undefined,
evenSplitPanes: true,
};
}
if (splitSizeUpdateAction.match(action)) {
const { largerExploreId } = action.payload;
return {
...state,
largerExploreId,
maxedExploreId: undefined,
evenSplitPanes: largerExploreId === undefined,
};
}
if (maximizePaneAction.match(action)) {
const { exploreId } = action.payload;
return {
...state,
largerExploreId: exploreId,
maxedExploreId: exploreId,
evenSplitPanes: false,
};
}
if (evenPaneResizeAction.match(action)) {
return {
...state,
largerExploreId: undefined,
maxedExploreId: undefined,
evenSplitPanes: true,
};
}

View File

@ -69,6 +69,21 @@ export interface ExploreState {
* True if a warning message about failed rich history has been shown already in this session.
*/
richHistoryMigrationFailed: boolean;
/**
* On a split manual resize, we calculate which pane is larger, or if they are roughly the same size. If undefined, it is not split or they are roughly the same size
*/
largerExploreId?: ExploreId;
/**
* If a maximize pane button is pressed, this indicates which side was maximized. Will be undefined if not split or if it is manually resized
*/
maxedExploreId?: ExploreId;
/**
* If a minimize pane button is pressed, it will do an even split of panes. Will be undefined if split or on a manual resize
*/
evenSplitPanes?: boolean;
}
export const EXPLORE_GRAPH_STYLES = ['lines', 'bars', 'points', 'stacked_lines', 'stacked_bars'] as const;