diff --git a/packages/grafana-ui/src/components/PageLayout/PageToolbar.tsx b/packages/grafana-ui/src/components/PageLayout/PageToolbar.tsx index 83e1f292643..b31470ae630 100644 --- a/packages/grafana-ui/src/components/PageLayout/PageToolbar.tsx +++ b/packages/grafana-ui/src/components/PageLayout/PageToolbar.tsx @@ -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 = 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 = React.memo( )} - {React.Children.toArray(children).filter(Boolean)} + + {React.Children.toArray(children).filter(Boolean)} + ); } diff --git a/public/app/core/components/SplitPaneWrapper/SplitPaneWrapper.tsx b/public/app/core/components/SplitPaneWrapper/SplitPaneWrapper.tsx index 51d1786d274..69816af915f 100644 --- a/public/app/core/components/SplitPaneWrapper/SplitPaneWrapper.tsx +++ b/public/app/core/components/SplitPaneWrapper/SplitPaneWrapper.tsx @@ -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 { }; 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 { ? 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 = [ + {childrenArr[0]}, + {childrenArr[1] || undefined}, + ]; + return ( this.onDragStarted()} onDragFinished={(size) => this.onDragFinished(size)} + paneStyle={paneStyle} pane2Style={secondaryPaneStyle} > - {this.props.children} + {childrenFragments} ); } } -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: ''; diff --git a/public/app/features/explore/ExplorePaneContainer.tsx b/public/app/features/explore/ExplorePaneContainer.tsx index 15f9cafcc91..68c248eddc7 100644 --- a/public/app/features/explore/ExplorePaneContainer.tsx +++ b/public/app/features/explore/ExplorePaneContainer.tsx @@ -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 { }; 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 ( -
+
{initialized && }
); diff --git a/public/app/features/explore/ExploreToolbar.tsx b/public/app/features/explore/ExploreToolbar.tsx index b746c6f28f3..d5a1df7687d 100644 --- a/public/app/features/explore/ExploreToolbar.tsx +++ b/public/app/features/explore/ExploreToolbar.tsx @@ -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 { 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 ? ( { Split ) : ( - - Close - + + + + Close + + ), showExploreToDashboard && ( @@ -285,7 +308,7 @@ class UnConnectedExploreToolbar extends PureComponent { } 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); diff --git a/public/app/features/explore/Wrapper.test.tsx b/public/app/features/explore/Wrapper.test.tsx index 5ae2f7a1ea5..8117a1fb533 100644 --- a/public/app/features/explore/Wrapper.test.tsx +++ b/public/app/features/explore/Wrapper.test.tsx @@ -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({ datasourceUid: 'elastic', query: { expr: 'error' } }) as any); + store.dispatch(mainState.splitOpen({ 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({ datasourceUid: 'elastic', query: { expr: 'error' } }) as any); + store.dispatch(mainState.splitOpen({ datasourceUid: 'elastic', query: { expr: 'error' } }) as any); await waitFor(() => expect(document.title).toEqual('Explore - loki | elastic - Grafana')); }); }); diff --git a/public/app/features/explore/Wrapper.tsx b/public/app/features/explore/Wrapper.tsx index ccaf3907a03..f14b5ab9175 100644 --- a/public/app/features/explore/Wrapper.tsx +++ b/public/app/features/explore/Wrapper.tsx @@ -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 (
- - - - {hasSplit && ( + { + if (size) { + updateSplitSize(size); + } + }} + > - + - )} + {hasSplit && ( + + + + )} +
); diff --git a/public/app/features/explore/state/main.test.ts b/public/app/features/explore/state/main.test.ts index 3a5d5839784..1802d78c06f 100644 --- a/public/app/features/explore/state/main.test.ts +++ b/public/app/features/explore/state/main.test.ts @@ -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); }); diff --git a/public/app/features/explore/state/main.ts b/public/app/features/explore/state/main.ts index 3989cdbf359..1dde5fd5a7c 100644 --- a/public/app/features/explore/state/main.ts +++ b/public/app/features/explore/state/main.ts @@ -40,6 +40,16 @@ export const richHistorySearchFiltersUpdatedAction = createAction<{ export const saveCorrelationsAction = createAction('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, }; } diff --git a/public/app/types/explore.ts b/public/app/types/explore.ts index 439b39dac2c..7891519890a 100644 --- a/public/app/types/explore.ts +++ b/public/app/types/explore.ts @@ -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;