From 4ec54bc2c39ba43843c693fdb2a4529b6a4703f2 Mon Sep 17 00:00:00 2001 From: Haris Rozajac <58232930+harisrozajac@users.noreply.github.com> Date: Fri, 13 Oct 2023 10:57:13 -0600 Subject: [PATCH] Explore: Content Outline (#74536) * Add images * Basic button functionality; TODO placeholders for dispatching contentOutlineToggle and rendering content outline component * Basic content outline container * Content outline toggles * Remove icon files from explore * Scroll into view v1 * outline that reflect's explore's order of vizs * Update icon name * Add scrollId to PanelChrome; scrolling enabled for Table * Add queries icon * Improve scroll behavior in split view * Add wrapper so the sticky navigation doesn't scroll when on the bottom of the window * Fix the issue with logs gap; center icons * Memoize register and unregister functions; adjust content height * Make displayOrderId optional * Use Node API for finding position of panels in content outline; add tooltip * Dock content outline in expanded mode; at tooltip to toggle button * Handle content outline visibility from Explore and not redux; pass outlineItems as a prop * Fix ContentOutline test * Add interaction tracking * Add padding to fix test * Replace string literals with objects for styles * Update event reporting payloads * Custom content outline button; content outline container improvements * Add aria-expanded to content outline button in ExploreToolbar * Fix vertical and horizontal scrolling * Add aria-controls * Remove unneccessary css since ExploreToolbar is sticky * Update feature toggles; Fix typos * Make content outline button more prominent in split mode; add padding to content outline items; * Diego's UX updates * WIP: some scroll fixes * Fix test and type error * Add id to ContentOutline to differentiate in split mode * No default exports --------- Co-authored-by: Giordano Ricci --- .betterer.results | 6 +- .../feature-toggles/index.md | 1 + .../src/types/featureToggles.gen.ts | 1 + pkg/services/featuremgmt/registry.go | 8 + pkg/services/featuremgmt/toggles_gen.csv | 1 + pkg/services/featuremgmt/toggles_gen.go | 4 + .../ContentOutline/ContentOutline.test.tsx | 70 +++ .../explore/ContentOutline/ContentOutline.tsx | 94 ++++ .../ContentOutline/ContentOutlineContext.tsx | 64 +++ .../ContentOutline/ContentOutlineItem.tsx | 32 ++ .../ContentOutlineItemButton.tsx | 72 +++ public/app/features/explore/Explore.test.tsx | 5 +- public/app/features/explore/Explore.tsx | 450 +++++++++++------- .../features/explore/ExplorePaneContainer.tsx | 17 +- .../app/features/explore/ExploreToolbar.tsx | 41 +- .../app/features/explore/SecondaryActions.tsx | 61 +-- 16 files changed, 694 insertions(+), 233 deletions(-) create mode 100644 public/app/features/explore/ContentOutline/ContentOutline.test.tsx create mode 100644 public/app/features/explore/ContentOutline/ContentOutline.tsx create mode 100644 public/app/features/explore/ContentOutline/ContentOutlineContext.tsx create mode 100644 public/app/features/explore/ContentOutline/ContentOutlineItem.tsx create mode 100644 public/app/features/explore/ContentOutline/ContentOutlineItemButton.tsx diff --git a/.betterer.results b/.betterer.results index c1391a5232a..9fd133181d1 100644 --- a/.betterer.results +++ b/.betterer.results @@ -3770,10 +3770,8 @@ exports[`better eslint`] = { [0, 0, 0, "Do not use any type assertions.", "0"], [0, 0, 0, "Unexpected any. Specify a different type.", "1"] ], - "public/app/features/explore/Explore.tsx:5381": [ - [0, 0, 0, "Styles should be written using objects.", "0"], - [0, 0, 0, "Styles should be written using objects.", "1"], - [0, 0, 0, "Styles should be written using objects.", "2"] + "public/app/features/explore/ContentOutline/ContentOutline.tsx:5381": [ + [0, 0, 0, "Do not use any type assertions.", "0"] ], "public/app/features/explore/ExploreDrawer.tsx:5381": [ [0, 0, 0, "Styles should be written using objects.", "0"], diff --git a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md index 74ceff706af..58fcbc9abb8 100644 --- a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md +++ b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md @@ -24,6 +24,7 @@ Some features are enabled by default. You can disable these feature by setting t | `disableEnvelopeEncryption` | Disable envelope encryption (emergency only) | | | `publicDashboards` | Enables public access to dashboards | Yes | | `featureHighlights` | Highlight Grafana Enterprise features | | +| `exploreContentOutline` | Content outline sidebar | Yes | | `dataConnectionsConsole` | Enables a new top-level page called Connections. This page is an experiment that provides a better experience when you install and configure data sources and other plugins. | Yes | | `cloudWatchCrossAccountQuerying` | Enables cross-account querying in CloudWatch datasources | Yes | | `redshiftAsyncQueryDataSupport` | Enable async query data support for Redshift | Yes | diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index 1d5983471d1..ca3cd2bfa6c 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -30,6 +30,7 @@ export interface FeatureToggles { migrationLocking?: boolean; storage?: boolean; correlations?: boolean; + exploreContentOutline?: boolean; datasourceQueryMultiStatus?: boolean; traceToMetrics?: boolean; newDBLibrary?: boolean; diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index 56367e24157..35aa19375e2 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -86,6 +86,14 @@ var ( Stage: FeatureStagePublicPreview, Owner: grafanaExploreSquad, }, + { + Name: "exploreContentOutline", + Description: "Content outline sidebar", + Stage: FeatureStageGeneralAvailability, + Owner: grafanaExploreSquad, + Expression: "true", // enabled by default + FrontendOnly: true, + }, { Name: "datasourceQueryMultiStatus", Description: "Introduce HTTP 207 Multi Status for api/ds/query", diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index 407e77bf051..18a914b2301 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -11,6 +11,7 @@ featureHighlights,GA,@grafana/grafana-as-code,false,false,false,false migrationLocking,preview,@grafana/backend-platform,false,false,false,false storage,experimental,@grafana/grafana-app-platform-squad,false,false,false,false correlations,preview,@grafana/explore-squad,false,false,false,false +exploreContentOutline,GA,@grafana/explore-squad,false,false,false,true datasourceQueryMultiStatus,experimental,@grafana/plugins-platform-backend,false,false,false,false traceToMetrics,experimental,@grafana/observability-traces-and-profiling,false,false,false,true newDBLibrary,preview,@grafana/backend-platform,false,false,false,false diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index 2c89102fbb5..2cee40d875f 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -55,6 +55,10 @@ const ( // Correlations page FlagCorrelations = "correlations" + // FlagExploreContentOutline + // Content outline sidebar + FlagExploreContentOutline = "exploreContentOutline" + // FlagDatasourceQueryMultiStatus // Introduce HTTP 207 Multi Status for api/ds/query FlagDatasourceQueryMultiStatus = "datasourceQueryMultiStatus" diff --git a/public/app/features/explore/ContentOutline/ContentOutline.test.tsx b/public/app/features/explore/ContentOutline/ContentOutline.test.tsx new file mode 100644 index 00000000000..88b0945b88a --- /dev/null +++ b/public/app/features/explore/ContentOutline/ContentOutline.test.tsx @@ -0,0 +1,70 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import React from 'react'; + +import { ContentOutline } from './ContentOutline'; + +jest.mock('./ContentOutlineContext', () => ({ + useContentOutlineContext: jest.fn(), +})); + +const scrollIntoViewMock = jest.fn(); +const scrollerMock = document.createElement('div'); + +const setup = () => { + HTMLElement.prototype.scrollIntoView = scrollIntoViewMock; + + scrollerMock.scroll = jest.fn(); + + // Mock useContentOutlineContext with custom outlineItems + const mockUseContentOutlineContext = require('./ContentOutlineContext').useContentOutlineContext; + mockUseContentOutlineContext.mockReturnValue({ + outlineItems: [ + { + id: 'item-1', + icon: 'test-icon', + title: 'Item 1', + ref: document.createElement('div'), + }, + { + id: 'item-2', + icon: 'test-icon', + title: 'Item 2', + ref: document.createElement('div'), + }, + ], + register: jest.fn(), + unregister: jest.fn(), + }); + + return render(); +}; + +describe('', () => { + beforeEach(() => { + setup(); + }); + + it('toggles content on button click', () => { + let showContentOutlineButton = screen.getByLabelText('Expand content outline'); + expect(showContentOutlineButton).toBeInTheDocument(); + + fireEvent.click(showContentOutlineButton); + const hideContentOutlineButton = screen.getByText('Collapse outline'); + expect(hideContentOutlineButton).toBeInTheDocument(); + + fireEvent.click(hideContentOutlineButton); + showContentOutlineButton = screen.getByLabelText('Expand content outline'); + expect(showContentOutlineButton).toBeInTheDocument(); + }); + + it('scrolls into view on content button click', () => { + const itemButtons = screen.getAllByLabelText(/Item/i); + + itemButtons.forEach((button) => { + fireEvent.click(button); + + //assert scrollIntoView is called + expect(scrollerMock.scroll).toHaveBeenCalled(); + }); + }); +}); diff --git a/public/app/features/explore/ContentOutline/ContentOutline.tsx b/public/app/features/explore/ContentOutline/ContentOutline.tsx new file mode 100644 index 00000000000..01b9be893ae --- /dev/null +++ b/public/app/features/explore/ContentOutline/ContentOutline.tsx @@ -0,0 +1,94 @@ +import { css } from '@emotion/css'; +import React from 'react'; +import { useToggle } from 'react-use'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { reportInteraction } from '@grafana/runtime'; +import { useStyles2, PanelContainer, CustomScrollbar } from '@grafana/ui'; + +import { useContentOutlineContext } from './ContentOutlineContext'; +import { ContentOutlineItemButton } from './ContentOutlineItemButton'; + +const getStyles = (theme: GrafanaTheme2) => { + return { + wrapper: css({ + label: 'wrapper', + position: 'relative', + display: 'flex', + justifyContent: 'center', + marginRight: theme.spacing(1), + height: '100%', + backgroundColor: theme.colors.background.primary, + }), + content: css({ + label: 'content', + top: 0, + }), + buttonStyles: css({ + textAlign: 'left', + width: '100%', + padding: theme.spacing(0, 1.5), + }), + }; +}; + +export function ContentOutline({ scroller, panelId }: { scroller: HTMLElement | undefined; panelId: string }) { + const [expanded, toggleExpanded] = useToggle(false); + const styles = useStyles2((theme) => getStyles(theme)); + const { outlineItems } = useContentOutlineContext(); + + const scrollIntoView = (ref: HTMLElement | null, buttonTitle: string) => { + let scrollValue = 0; + let el: HTMLElement | null | undefined = ref; + + do { + scrollValue += el?.offsetTop || 0; + el = el?.offsetParent as HTMLElement; + } while (el && el !== scroller); + + scroller?.scroll({ + top: scrollValue, + behavior: 'smooth', + }); + reportInteraction('explore_toolbar_contentoutline_clicked', { + item: 'select_section', + type: buttonTitle, + }); + }; + + const toggle = () => { + toggleExpanded(); + reportInteraction('explore_toolbar_contentoutline_clicked', { + item: 'outline', + type: expanded ? 'minimize' : 'expand', + }); + }; + + return ( + + +
+ + + {outlineItems.map((item) => ( + scrollIntoView(item.ref, item.title)} + tooltip={!expanded ? item.title : undefined} + /> + ))} +
+
+
+ ); +} diff --git a/public/app/features/explore/ContentOutline/ContentOutlineContext.tsx b/public/app/features/explore/ContentOutline/ContentOutlineContext.tsx new file mode 100644 index 00000000000..5ca319431e1 --- /dev/null +++ b/public/app/features/explore/ContentOutline/ContentOutlineContext.tsx @@ -0,0 +1,64 @@ +import { uniqueId } from 'lodash'; +import React, { useState, useContext, createContext, ReactNode, useCallback } from 'react'; + +import { ContentOutlineItemBaseProps } from './ContentOutlineItem'; + +export interface ContentOutlineItemContextProps extends ContentOutlineItemBaseProps { + id: string; + ref: HTMLElement | null; +} + +type RegisterFunction = ({ title, icon, ref }: Omit) => string; + +interface ContentOutlineContextProps { + outlineItems: ContentOutlineItemContextProps[]; + register: RegisterFunction; + unregister: (id: string) => void; +} + +const ContentOutlineContext = createContext(undefined); + +export const ContentOutlineContextProvider = ({ children }: { children: ReactNode }) => { + const [outlineItems, setOutlineItems] = useState([]); + + const register: RegisterFunction = useCallback(({ title, icon, ref }) => { + const id = uniqueId(`${title}-${icon}_`); + + setOutlineItems((prevItems) => { + const updatedItems = [...prevItems, { id, title, icon, ref }]; + + return updatedItems.sort((a, b) => { + if (a.ref && b.ref) { + const diff = a.ref.compareDocumentPosition(b.ref); + if (diff === Node.DOCUMENT_POSITION_PRECEDING) { + return 1; + } else if (diff === Node.DOCUMENT_POSITION_FOLLOWING) { + return -1; + } + } + return 0; + }); + }); + + return id; + }, []); + + const unregister = useCallback((id: string) => { + setOutlineItems((prevItems) => prevItems.filter((item) => item.id !== id)); + }, []); + + return ( + + {children} + + ); +}; + +export function useContentOutlineContext() { + const ctx = useContext(ContentOutlineContext); + + if (!ctx) { + throw new Error('useContentOutlineContext must be used within a ContentOutlineContextProvider'); + } + return ctx; +} diff --git a/public/app/features/explore/ContentOutline/ContentOutlineItem.tsx b/public/app/features/explore/ContentOutline/ContentOutlineItem.tsx new file mode 100644 index 00000000000..df591a414b0 --- /dev/null +++ b/public/app/features/explore/ContentOutline/ContentOutlineItem.tsx @@ -0,0 +1,32 @@ +import React, { useEffect, useRef, ReactNode } from 'react'; + +import { useContentOutlineContext } from './ContentOutlineContext'; + +export interface ContentOutlineItemBaseProps { + title: string; + icon: string; +} + +interface ContentOutlineItemProps extends ContentOutlineItemBaseProps { + children: ReactNode; + className?: string; +} + +export function ContentOutlineItem({ title, icon, children, className }: ContentOutlineItemProps) { + const { register, unregister } = useContentOutlineContext(); + const ref = useRef(null); + + useEffect(() => { + // When the component mounts, register it and get its unique ID. + const id = register({ title: title, icon: icon, ref: ref.current }); + + // When the component unmounts, unregister it using its unique ID. + return () => unregister(id); + }, [title, icon, register, unregister]); + + return ( +
+ {children} +
+ ); +} diff --git a/public/app/features/explore/ContentOutline/ContentOutlineItemButton.tsx b/public/app/features/explore/ContentOutline/ContentOutlineItemButton.tsx new file mode 100644 index 00000000000..7f887a76899 --- /dev/null +++ b/public/app/features/explore/ContentOutline/ContentOutlineItemButton.tsx @@ -0,0 +1,72 @@ +import { cx, css } from '@emotion/css'; +import React, { ButtonHTMLAttributes } from 'react'; + +import { IconName, isIconName, GrafanaTheme2 } from '@grafana/data'; +import { Icon, useStyles2, Tooltip } from '@grafana/ui'; + +type CommonProps = { + title?: string; + icon: string; + tooltip?: string; + className?: string; +}; + +export type ContentOutlineItemButtonProps = CommonProps & ButtonHTMLAttributes; + +export function ContentOutlineItemButton({ title, icon, tooltip, className, ...rest }: ContentOutlineItemButtonProps) { + const styles = useStyles2(getStyles); + + const buttonStyles = cx(styles.button, className); + + const body = ( + + ); + + return tooltip ? ( + + {body} + + ) : ( + body + ); +} + +function renderIcon(icon: IconName | React.ReactNode) { + if (!icon) { + return null; + } + + if (isIconName(icon)) { + return ; + } + + return icon; +} + +const getStyles = (theme: GrafanaTheme2) => { + return { + button: css({ + label: 'content-outline-item-button', + display: 'flex', + flexGrow: 1, + alignItems: 'center', + height: theme.spacing(theme.components.height.md), + padding: theme.spacing(0, 1), + gap: theme.spacing(1), + color: theme.colors.text.secondary, + background: 'transparent', + border: 'none', + textOverflow: 'ellipsis', + overflow: 'hidden', + whiteSpace: 'nowrap', + '&:hover': { + color: theme.colors.text.primary, + background: theme.colors.background.secondary, + textDecoration: 'underline', + }, + }), + }; +}; diff --git a/public/app/features/explore/Explore.test.tsx b/public/app/features/explore/Explore.test.tsx index f76ed10569f..68de610f7c4 100644 --- a/public/app/features/explore/Explore.test.tsx +++ b/public/app/features/explore/Explore.test.tsx @@ -8,6 +8,7 @@ import { selectors } from '@grafana/e2e-selectors'; import { getPluginLinkExtensions } from '@grafana/runtime'; import { configureStore } from 'app/store/configureStore'; +import { ContentOutlineContextProvider } from './ContentOutline/ContentOutlineContext'; import { Explore, Props } from './Explore'; import { initialExploreState } from './state/main'; import { scanStopAction } from './state/query'; @@ -141,7 +142,9 @@ const setup = (overrideProps?: Partial) => { return render( - + + + ); }; diff --git a/public/app/features/explore/Explore.tsx b/public/app/features/explore/Explore.tsx index 2720d06d2c6..1fca13ba665 100644 --- a/public/app/features/explore/Explore.tsx +++ b/public/app/features/explore/Explore.tsx @@ -35,6 +35,9 @@ import { StoreState } from 'app/types'; import { getTimeZone } from '../profile/state/selectors'; +import { ContentOutline } from './ContentOutline/ContentOutline'; +import { ContentOutlineContextProvider } from './ContentOutline/ContentOutlineContext'; +import { ContentOutlineItem } from './ContentOutline/ContentOutlineItem'; import { CorrelationHelper } from './CorrelationHelper'; import { CustomContainer } from './CustomContainer'; import ExploreQueryInspector from './ExploreQueryInspector'; @@ -69,30 +72,37 @@ import { updateTimeRange } from './state/time'; const getStyles = (theme: GrafanaTheme2) => { return { - exploreMain: css` - label: exploreMain; + exploreMain: css({ + label: 'exploreMain', // Is needed for some transition animations to work. - position: relative; - margin-top: 21px; - display: flex; - flex-direction: column; - gap: ${theme.spacing(1)}; - `, - queryContainer: css` - label: queryContainer; - // Need to override normal css class and don't want to count on ordering of the classes in html. - height: auto !important; - flex: unset !important; - display: unset !important; - padding: ${theme.spacing(1)}; - `, - exploreContainer: css` - display: flex; - flex: 1 1 auto; - flex-direction: column; - padding: ${theme.spacing(2)}; - padding-top: 0; - `, + position: 'relative', + marginTop: '21px', + display: 'flex', + flexDirection: 'column', + gap: theme.spacing(1), + }), + queryContainer: css({ + label: 'queryContainer', + padding: theme.spacing(1), + }), + exploreContainer: css({ + label: 'exploreContainer', + display: 'flex', + flexDirection: 'column', + paddingRight: theme.spacing(2), + marginBottom: theme.spacing(2), + }), + left: css({ + marginBottom: theme.spacing(2), + }), + wrapper: css({ + position: 'absolute', + top: 0, + left: theme.spacing(2), + right: 0, + bottom: 0, + display: 'flex', + }), }; }; @@ -109,6 +119,7 @@ enum ExploreDrawer { interface ExploreState { openDrawer?: ExploreDrawer; + contentOutlineVisible: boolean; } export type Props = ExploreProps & ConnectedProps; @@ -137,6 +148,7 @@ export type Props = ExploreProps & ConnectedProps; * The result viewers determine some of the query options sent to the datasource, e.g., * `format`, to indicate eventual transformations by the datasources' result transformers. */ + export class Explore extends React.PureComponent { scrollElement: HTMLDivElement | undefined; topOfViewRef = createRef(); @@ -148,6 +160,7 @@ export class Explore extends React.PureComponent { super(props); this.state = { openDrawer: undefined, + contentOutlineVisible: false, }; this.graphEventBus = props.eventBus.newScopedBus('graph', { onlyLocal: false }); this.logsEventBus = props.eventBus.newScopedBus('logs', { onlyLocal: false }); @@ -174,6 +187,18 @@ export class Explore extends React.PureComponent { } }; + onContentOutlineToogle = () => { + this.setState((state) => { + reportInteraction('explore_toolbar_contentoutline_clicked', { + item: 'outline', + type: state.contentOutlineVisible ? 'close' : 'open', + }); + return { + contentOutlineVisible: !state.contentOutlineVisible, + }; + }); + }; + /** * Used by Logs details. * Returns true if all queries have the filter, otherwise false. @@ -318,18 +343,20 @@ export class Explore extends React.PureComponent { return Object.entries(groupedByPlugin).map(([pluginId, frames], index) => { return ( - + + + ); }); } @@ -338,68 +365,82 @@ export class Explore extends React.PureComponent { const { graphResult, absoluteRange, timeZone, queryResponse, showFlameGraph } = this.props; return ( - + + + ); } renderTablePanel(width: number) { const { exploreId, timeZone } = this.props; return ( - + + + ); } renderRawPrometheus(width: number) { const { exploreId, datasourceInstance, timeZone } = this.props; return ( - + + + ); } renderLogsPanel(width: number) { const { exploreId, syncedTimes, theme, queryResponse } = this.props; const spacing = parseInt(theme.spacing(2).slice(0, -2), 10); + // Need to make ContentOutlineItem a flex container so the gap works + const logsContentOutlineWrapper = css({ + display: 'flex', + flexDirection: 'column', + gap: theme.spacing(1), + }); return ( - + + + ); } @@ -407,17 +448,19 @@ export class Explore extends React.PureComponent { const { logsSample, timeZone, setSupplementaryQueryEnabled, exploreId, datasourceInstance, queries } = this.props; return ( - - setSupplementaryQueryEnabled(exploreId, enabled, SupplementaryQueryType.LogsSample) - } - /> + + + setSupplementaryQueryEnabled(exploreId, enabled, SupplementaryQueryType.LogsSample) + } + /> + ); } @@ -426,19 +469,25 @@ export class Explore extends React.PureComponent { const datasourceType = datasourceInstance ? datasourceInstance?.type : 'unknown'; return ( - + + + ); } renderFlameGraphPanel() { const { queryResponse } = this.props; - return ; + return ( + + + + ); } renderTraceViewPanel() { @@ -448,14 +497,16 @@ export class Explore extends React.PureComponent { return ( // If there is no data (like 404) we show a separate error so no need to show anything here dataFrames.length && ( - + + + ) ); } @@ -481,7 +532,7 @@ export class Explore extends React.PureComponent { correlationEditorDetails, correlationEditorHelperData, } = this.props; - const { openDrawer } = this.state; + const { openDrawer, contentOutlineVisible } = this.state; const styles = getStyles(theme); const showPanels = queryResponse && queryResponse.state !== LoadingState.NotStarted; const showRichHistory = openDrawer === ExploreDrawer.RichHistory; @@ -508,85 +559,118 @@ export class Explore extends React.PureComponent { } return ( - <> - - (this.scrollElement = scrollElement || undefined)} + + +
- {datasourceInstance ? ( -
- - {correlationsBox} - - - - - - {({ width }) => { - if (width === 0) { - return null; - } +
+ {contentOutlineVisible && ( +
+ +
+ )} + (this.scrollElement = scrollElement || undefined)} + hideHorizontalTrack + > +
+ {datasourceInstance ? ( + <> + + + {correlationsBox} + + + + + + + {({ width }) => { + if (width === 0) { + return null; + } - return ( -
- - {showPanels && ( - <> - {showMetrics && graphResult && ( - {this.renderGraphPanel(width)} - )} - {showRawPrometheus && ( - {this.renderRawPrometheus(width)} - )} - {showTable && {this.renderTablePanel(width)}} - {showLogs && {this.renderLogsPanel(width)}} - {showNodeGraph && {this.renderNodeGraphPanel()}} - {showFlameGraph && {this.renderFlameGraphPanel()}} - {showTrace && {this.renderTraceViewPanel()}} - {showLogsSample && {this.renderLogsSamplePanel()}} - {showCustom && {this.renderCustom(width)}} - {showNoData && {this.renderNoData()}} - - )} - {showRichHistory && ( - - )} - {showQueryInspector && ( - - )} - -
- ); - }} -
-
- ) : ( - this.renderEmptyState(styles.exploreContainer) - )} -
- + return ( +
+ + {showPanels && ( + <> + {showMetrics && graphResult && ( + {this.renderGraphPanel(width)} + )} + {showRawPrometheus && ( + {this.renderRawPrometheus(width)} + )} + {showTable && {this.renderTablePanel(width)}} + {showLogs && {this.renderLogsPanel(width)}} + {showNodeGraph && ( + {this.renderNodeGraphPanel()} + )} + {showFlameGraph && ( + {this.renderFlameGraphPanel()} + )} + {showTrace && {this.renderTraceViewPanel()}} + {showLogsSample && ( + {this.renderLogsSamplePanel()} + )} + {showCustom && {this.renderCustom(width)}} + {showNoData && {this.renderNoData()}} + + )} + {showRichHistory && ( + + )} + {showQueryInspector && ( + + )} + +
+ ); + }} + + + ) : ( + this.renderEmptyState(styles.exploreContainer) + )} +
+ +
+
+
); } } diff --git a/public/app/features/explore/ExplorePaneContainer.tsx b/public/app/features/explore/ExplorePaneContainer.tsx index ddee6ba8cdd..fb1574a081b 100644 --- a/public/app/features/explore/ExplorePaneContainer.tsx +++ b/public/app/features/explore/ExplorePaneContainer.tsx @@ -4,7 +4,7 @@ import { connect } from 'react-redux'; import { EventBusSrv, GrafanaTheme2 } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; -import { useStyles2 } from '@grafana/ui'; +import { useStyles2, CustomScrollbar } from '@grafana/ui'; import { stopQueryState } from 'app/core/utils/explore'; import { StoreState, useSelector } from 'app/types'; @@ -14,14 +14,11 @@ import { getExploreItemSelector } from './state/selectors'; const getStyles = (theme: GrafanaTheme2) => { return { explore: css` + label: explorePaneContainer; display: flex; - flex: 1 1 auto; flex-direction: column; - overflow: hidden; min-width: 600px; - & + & { - border-left: 1px dotted ${theme.colors.border.medium}; - } + height: 100%; `, }; }; @@ -51,9 +48,11 @@ function ExplorePaneContainerUnconnected({ exploreId }: Props) { }, []); return ( -
- -
+ +
+ +
+
); } diff --git a/public/app/features/explore/ExploreToolbar.tsx b/public/app/features/explore/ExploreToolbar.tsx index 84f0fc35bbb..897ddc71782 100644 --- a/public/app/features/explore/ExploreToolbar.tsx +++ b/public/app/features/explore/ExploreToolbar.tsx @@ -4,7 +4,7 @@ import React, { RefObject, useMemo } from 'react'; import { shallowEqual } from 'react-redux'; import { DataSourceInstanceSettings, RawTimeRange, GrafanaTheme2 } from '@grafana/data'; -import { reportInteraction } from '@grafana/runtime'; +import { reportInteraction, config } from '@grafana/runtime'; import { defaultIntervals, PageToolbar, @@ -43,25 +43,39 @@ import { isLeftPaneSelector, isSplit, selectCorrelationDetails, selectPanesEntri import { syncTimes, changeRefreshInterval } from './state/time'; import { LiveTailControls } from './useLiveTailControls'; -const getStyles = (theme: GrafanaTheme2) => ({ +const getStyles = (theme: GrafanaTheme2, splitted: Boolean) => ({ rotateIcon: css({ '> div > svg': { transform: 'rotate(180deg)', }, }), + toolbarButton: css({ + display: 'flex', + justifyContent: 'center', + marginRight: theme.spacing(0.5), + width: splitted && theme.spacing(6), + }), }); interface Props { exploreId: string; onChangeTime: (range: RawTimeRange, changedByScanner?: boolean) => void; + onContentOutlineToogle: () => void; + isContentOutlineOpen: boolean; topOfViewRef?: RefObject; } -export function ExploreToolbar({ exploreId, topOfViewRef, onChangeTime }: Props) { +export function ExploreToolbar({ + exploreId, + topOfViewRef, + onChangeTime, + onContentOutlineToogle, + isContentOutlineOpen, +}: Props) { const dispatch = useDispatch(); - const styles = useStyles2(getStyles); - const splitted = useSelector(isSplit); + const styles = useStyles2(getStyles, splitted); + const timeZone = useSelector((state: StoreState) => getTimeZone(state.user)); const fiscalYearStartMonth = useSelector((state: StoreState) => getFiscalYearStartMonth(state.user)); const { refreshInterval, datasourceInstance, range, isLive, isPaused, syncedTimes } = useSelector( @@ -217,6 +231,21 @@ export function ExploreToolbar({ exploreId, topOfViewRef, onChangeTime }: Props) + Outline + + ), , - ]} + ].filter(Boolean)} forceShowLeftItems > {[ diff --git a/public/app/features/explore/SecondaryActions.tsx b/public/app/features/explore/SecondaryActions.tsx index f0f4d4fbb3e..b7ee9065608 100644 --- a/public/app/features/explore/SecondaryActions.tsx +++ b/public/app/features/explore/SecondaryActions.tsx @@ -3,7 +3,7 @@ import React from 'react'; import { GrafanaTheme2 } from '@grafana/data'; import { Components } from '@grafana/e2e-selectors'; -import { HorizontalGroup, ToolbarButton, useTheme2 } from '@grafana/ui'; +import { ToolbarButton, useTheme2 } from '@grafana/ui'; type Props = { addQueryRowButtonDisabled?: boolean; @@ -20,6 +20,9 @@ type Props = { const getStyles = (theme: GrafanaTheme2) => { return { containerMargin: css` + display: flex; + flex-wrap: wrap; + gap: ${theme.spacing(1)}; margin-top: ${theme.spacing(2)}; `, }; @@ -30,38 +33,36 @@ export function SecondaryActions(props: Props) { const styles = getStyles(theme); return (
- - {!props.addQueryRowButtonHidden && ( - - Add query - - )} - {!props.richHistoryRowButtonHidden && ( - - Query history - - )} + {!props.addQueryRowButtonHidden && ( - Query inspector + Add query - + )} + {!props.richHistoryRowButtonHidden && ( + + Query history + + )} + + Query inspector +
); }