diff --git a/public/app/features/explore/ContentOutline/ContentOutline.test.tsx b/public/app/features/explore/ContentOutline/ContentOutline.test.tsx index 88b0945b88a..d28093e331a 100644 --- a/public/app/features/explore/ContentOutline/ContentOutline.test.tsx +++ b/public/app/features/explore/ContentOutline/ContentOutline.test.tsx @@ -1,4 +1,5 @@ -import { render, screen, fireEvent } from '@testing-library/react'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import React from 'react'; import { ContentOutline } from './ContentOutline'; @@ -10,13 +11,14 @@ jest.mock('./ContentOutlineContext', () => ({ const scrollIntoViewMock = jest.fn(); const scrollerMock = document.createElement('div'); -const setup = () => { +const setup = (mergeSingleChild = false) => { HTMLElement.prototype.scrollIntoView = scrollIntoViewMock; scrollerMock.scroll = jest.fn(); // Mock useContentOutlineContext with custom outlineItems const mockUseContentOutlineContext = require('./ContentOutlineContext').useContentOutlineContext; + mockUseContentOutlineContext.mockReturnValue({ outlineItems: [ { @@ -24,12 +26,39 @@ const setup = () => { icon: 'test-icon', title: 'Item 1', ref: document.createElement('div'), + mergeSingleChild, + children: [ + { + id: 'item-1-1', + icon: 'test-icon', + title: 'Item 1-1', + ref: document.createElement('div'), + level: 'child', + }, + ], }, { id: 'item-2', icon: 'test-icon', title: 'Item 2', ref: document.createElement('div'), + mergeSingleChild, + children: [ + { + id: 'item-2-1', + icon: 'test-icon', + title: 'Item 2-1', + ref: document.createElement('div'), + level: 'child', + }, + { + id: 'item-2-2', + icon: 'test-icon', + title: 'Item 2-2', + ref: document.createElement('div'), + level: 'child', + }, + ], }, ], register: jest.fn(), @@ -40,31 +69,78 @@ const setup = () => { }; describe('', () => { - beforeEach(() => { + it('toggles content on button click', async () => { setup(); - }); - - it('toggles content on button click', () => { - let showContentOutlineButton = screen.getByLabelText('Expand content outline'); + let showContentOutlineButton = screen.getByRole('button', { name: 'Expand outline' }); expect(showContentOutlineButton).toBeInTheDocument(); - fireEvent.click(showContentOutlineButton); - const hideContentOutlineButton = screen.getByText('Collapse outline'); + await userEvent.click(showContentOutlineButton); + const hideContentOutlineButton = screen.getByRole('button', { name: 'Collapse outline' }); expect(hideContentOutlineButton).toBeInTheDocument(); - fireEvent.click(hideContentOutlineButton); - showContentOutlineButton = screen.getByLabelText('Expand content outline'); + await userEvent.click(hideContentOutlineButton); + showContentOutlineButton = screen.getByRole('button', { name: 'Expand outline' }); expect(showContentOutlineButton).toBeInTheDocument(); }); - it('scrolls into view on content button click', () => { - const itemButtons = screen.getAllByLabelText(/Item/i); + it('scrolls into view on content button click', async () => { + setup(); + const itemButtons = screen.getAllByRole('button', { name: /Item [0-9]+/ }); + + for (const button of itemButtons) { + await userEvent.click(button); + } + + expect(scrollerMock.scroll).toHaveBeenCalledTimes(itemButtons.length); + }); + + it('doesnt merge a single child item when mergeSingleChild is false', async () => { + setup(); + const expandSectionChevrons = screen.getAllByRole('button', { name: 'Content outline item collapse button' }); + await userEvent.click(expandSectionChevrons[0]); + + const child = screen.getByRole('button', { name: 'Item 1-1' }); + expect(child).toBeInTheDocument(); + }); - itemButtons.forEach((button) => { - fireEvent.click(button); + it('merges a single child item when mergeSingleChild is true', () => { + setup(true); + const child = screen.queryByRole('button', { name: 'Item 1-1' }); - //assert scrollIntoView is called - expect(scrollerMock.scroll).toHaveBeenCalled(); - }); + expect(child).not.toBeInTheDocument(); + }); + + it('displays multiple children', async () => { + setup(); + const expandSectionChevrons = screen.getAllByRole('button', { name: 'Content outline item collapse button' }); + await userEvent.click(expandSectionChevrons[1]); + + const child1 = screen.getByRole('button', { name: 'Item 2-1' }); + const child2 = screen.getByRole('button', { name: 'Item 2-2' }); + expect(child1).toBeInTheDocument(); + expect(child2).toBeInTheDocument(); + }); + + it('if item has multiple children, it displays multiple children even when mergeSingleChild is true', async () => { + setup(true); + const expandSectionChevrons = screen.getAllByRole('button', { name: 'Content outline item collapse button' }); + // since first item has only one child, we will have only one chevron + await userEvent.click(expandSectionChevrons[0]); + + const child1 = screen.getByRole('button', { name: 'Item 2-1' }); + const child2 = screen.getByRole('button', { name: 'Item 2-2' }); + expect(child1).toBeInTheDocument(); + expect(child2).toBeInTheDocument(); + }); + + it('collapse button has same aria-controls as the section content', async () => { + setup(); + const expandSectionChevrons = screen.getAllByRole('button', { name: 'Content outline item collapse button' }); + // chevron for the second item + const button = expandSectionChevrons[1]; + // content for the second item + const sectionContent = screen.getByTestId('section-wrapper-item-2'); + await userEvent.click(button); + expect(button.getAttribute('aria-controls')).toBe(sectionContent.id); }); }); diff --git a/public/app/features/explore/ContentOutline/ContentOutline.tsx b/public/app/features/explore/ContentOutline/ContentOutline.tsx index aa604d6d792..d11b6b7de15 100644 --- a/public/app/features/explore/ContentOutline/ContentOutline.tsx +++ b/public/app/features/explore/ContentOutline/ContentOutline.tsx @@ -1,4 +1,4 @@ -import { css } from '@emotion/css'; +import { css, cx } from '@emotion/css'; import React, { useEffect, useRef, useState } from 'react'; import { useToggle, useScroll } from 'react-use'; @@ -6,41 +6,30 @@ import { GrafanaTheme2 } from '@grafana/data'; import { reportInteraction } from '@grafana/runtime'; import { useStyles2, PanelContainer, CustomScrollbar } from '@grafana/ui'; -import { useContentOutlineContext } from './ContentOutlineContext'; +import { ContentOutlineItemContextProps, 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 { outlineItems } = useContentOutlineContext(); - const [expanded, toggleExpanded] = useToggle(false); - const [activeItemId, setActiveItemId] = useState(outlineItems[0]?.id); - const styles = useStyles2((theme) => getStyles(theme)); + const [contentOutlineExpanded, toggleContentOutlineExpanded] = useToggle(false); + const styles = useStyles2(getStyles, contentOutlineExpanded); const scrollerRef = useRef(scroller || null); const { y: verticalScroll } = useScroll(scrollerRef); + const { outlineItems } = useContentOutlineContext() ?? { outlineItems: [] }; + const [activeSectionId, setActiveSectionId] = useState(outlineItems[0]?.id); + const [activeSectionChildId, setActiveSectionChildId] = useState(outlineItems[0]?.children?.[0]?.id); + + const outlineItemsShouldIndent = outlineItems.some( + (item) => item.children && !(item.mergeSingleChild && item.children?.length === 1) && item.children.length > 0 + ); - const scrollIntoView = (ref: HTMLElement | null, buttonTitle: string) => { + const [sectionsExpanded, setSectionsExpanded] = useState(() => { + return outlineItems.reduce((acc: { [key: string]: boolean }, item) => { + acc[item.id] = false; + return acc; + }, {}); + }); + + const scrollIntoView = (ref: HTMLElement | null, itemPanelId: string, customOffsetTop = 0) => { let scrollValue = 0; let el: HTMLElement | null | undefined = ref; @@ -50,40 +39,65 @@ export function ContentOutline({ scroller, panelId }: { scroller: HTMLElement | } while (el && el !== scroller); scroller?.scroll({ - top: scrollValue, + top: scrollValue + customOffsetTop, behavior: 'smooth', }); reportInteraction('explore_toolbar_contentoutline_clicked', { item: 'select_section', - type: buttonTitle, + type: itemPanelId, }); }; const toggle = () => { - toggleExpanded(); + toggleContentOutlineExpanded(); reportInteraction('explore_toolbar_contentoutline_clicked', { item: 'outline', - type: expanded ? 'minimize' : 'expand', + type: contentOutlineExpanded ? 'minimize' : 'expand', + }); + }; + + const toggleSection = (itemId: string) => { + setSectionsExpanded((prev) => ({ + ...prev, + [itemId]: !prev[itemId], + })); + reportInteraction('explore_toolbar_contentoutline_clicked', { + item: 'section', + type: !sectionsExpanded[itemId] ? 'minimize' : 'expand', }); }; useEffect(() => { - const activeItem = outlineItems.find((item) => { - const top = item?.ref?.getBoundingClientRect().top; + let activeItem; + + for (const item of outlineItems) { + let top = item?.ref?.getBoundingClientRect().top; - if (!top) { - return false; + // Check item + if (top && top >= 0) { + activeItem = item; } - return top >= 0; - }); + // Check children + const activeChild = item.children?.find((child) => { + const offsetTop = child.customTopOffset || 0; + let childTop = child?.ref?.getBoundingClientRect().top; + return childTop && childTop >= offsetTop; + }); - if (!activeItem) { - return; - } + if (activeChild) { + setActiveSectionChildId(activeChild.id); + setActiveSectionId(item.id); + break; + } - setActiveItemId(activeItem.id); + if (activeItem) { + setActiveSectionId(activeItem.id); + setActiveSectionChildId(undefined); + break; + } + } }, [outlineItems, verticalScroll]); return ( @@ -91,29 +105,163 @@ export function ContentOutline({ scroller, panelId }: { scroller: HTMLElement |
- {outlineItems.map((item) => { - return ( + {outlineItems.map((item) => ( + scrollIntoView(item.ref, item.title)} - tooltip={!expanded ? item.title : undefined} - isActive={activeItemId === item.id} + onClick={() => scrollIntoView(item.ref, item.panelId)} + tooltip={item.title} + collapsible={isCollapsible(item)} + collapsed={!sectionsExpanded[item.id]} + toggleCollapsed={() => toggleSection(item.id)} + isActive={ + (isChildActive(item, activeSectionChildId) && !sectionsExpanded[item.id]) || + (activeSectionId === item.id && !sectionsExpanded[item.id]) + } + sectionId={item.id} /> - ); - })} +
+ {item.children && + (!item.mergeSingleChild || item.children.length !== 1) && + sectionsExpanded[item.id] && + item.children.map((child, i) => ( +
+ {contentOutlineExpanded && ( +
+ )} + scrollIntoView(child.ref, child.panelId, child.customTopOffset)} + tooltip={child.title} + isActive={activeSectionChildId === child.id} + /> +
+ ))} +
+ + ))}
); } + +const getStyles = (theme: GrafanaTheme2, expanded: boolean) => { + return { + wrapper: css({ + label: 'wrapper', + position: 'relative', + display: 'flex', + justifyContent: 'center', + marginRight: theme.spacing(1), + height: '100%', + backgroundColor: theme.colors.background.primary, + width: expanded ? '160px' : undefined, + minWidth: expanded ? '160px' : undefined, + }), + content: css({ + label: 'content', + marginLeft: theme.spacing(0.5), + top: 0, + }), + buttonStyles: css({ + display: 'flex', + '&:hover': { + color: theme.colors.text.primary, + textDecoration: 'underline', + }, + }), + toggleContentOutlineButton: css({ + '&:hover': { + color: theme.colors.text.primary, + }, + transform: expanded ? 'rotate(180deg)' : '', + marginRight: expanded ? theme.spacing(0.5) : undefined, + }), + indentRoot: css({ + paddingLeft: theme.spacing(4), + }), + indentChild: css({ + paddingLeft: expanded ? theme.spacing(7) : theme.spacing(4), + }), + itemWrapper: css({ + display: 'flex', + height: theme.spacing(4), + alignItems: 'center', + }), + itemConnector: css({ + position: 'relative', + height: '100%', + width: theme.spacing(1.5), + '&::before': { + borderRight: `1px solid ${theme.colors.border.medium}`, + content: '""', + height: '100%', + left: 48, + position: 'absolute', + transform: 'translateX(50%)', + }, + }), + firstItemConnector: css({ + '&::before': { + top: theme.spacing(1), + height: `calc(100% - ${theme.spacing(1)})`, + }, + }), + lastItemConnector: css({ + '&::before': { + height: `calc(100% - ${theme.spacing(1)})`, + }, + }), + justifyCenter: css({ + justifyContent: 'center', + }), + sectionHighlighter: css({ + backgroundColor: theme.colors.background.secondary, + }), + }; +}; + +function isCollapsible(item: ContentOutlineItemContextProps): boolean { + return !!(item.children && item.children.length > 0 && (!item.mergeSingleChild || item.children.length !== 1)); +} + +function isChildActive(item: ContentOutlineItemContextProps, activeSectionChildId: string | undefined) { + return item.children?.some((child) => child.id === activeSectionChildId); +} diff --git a/public/app/features/explore/ContentOutline/ContentOutlineContext.tsx b/public/app/features/explore/ContentOutline/ContentOutlineContext.tsx index f5a145c6965..565df8c5767 100644 --- a/public/app/features/explore/ContentOutline/ContentOutlineContext.tsx +++ b/public/app/features/explore/ContentOutline/ContentOutlineContext.tsx @@ -1,64 +1,163 @@ import { uniqueId } from 'lodash'; -import React, { useState, useContext, createContext, ReactNode, useCallback } from 'react'; +import React, { useState, useContext, createContext, ReactNode, useCallback, useRef, useEffect } from 'react'; import { ContentOutlineItemBaseProps } from './ContentOutlineItem'; export interface ContentOutlineItemContextProps extends ContentOutlineItemBaseProps { id: string; ref: HTMLElement | null; + children?: ContentOutlineItemContextProps[]; } -type RegisterFunction = ({ title, icon, ref }: Omit) => string; +type RegisterFunction = (outlineItem: Omit) => string; export interface ContentOutlineContextProps { outlineItems: ContentOutlineItemContextProps[]; register: RegisterFunction; unregister: (id: string) => void; + updateOutlineItems: (newItems: ContentOutlineItemContextProps[]) => void; +} + +interface ContentOutlineContextProviderProps { + children: ReactNode; + /** + * used to resort children of an outline item when the dependencies change + * e.g. when the order of query rows changes on drag and drop + */ + refreshDependencies?: unknown[]; +} + +interface ParentlessItems { + [panelId: string]: ContentOutlineItemContextProps[]; } const ContentOutlineContext = createContext(undefined); -export const ContentOutlineContextProvider = ({ children }: { children: ReactNode }) => { +export function ContentOutlineContextProvider({ children, refreshDependencies }: ContentOutlineContextProviderProps) { const [outlineItems, setOutlineItems] = useState([]); + const parentlessItemsRef = useRef({}); - const register: RegisterFunction = useCallback(({ title, icon, ref }) => { - const id = uniqueId(`${title}-${icon}_`); + const register: RegisterFunction = useCallback((outlineItem) => { + const id = uniqueId(`${outlineItem.panelId}-${outlineItem.title}-${outlineItem.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; + if (outlineItem.level === 'root') { + const mergeSingleChild = checkMergeSingleChild(parentlessItemsRef, outlineItem); + const updatedItems = [ + ...prevItems, + { + ...outlineItem, + id, + children: parentlessItemsRef.current[outlineItem.panelId] || [], + mergeSingleChild, + }, + ]; + + return updatedItems.sort(sortElementsByDocumentPosition); + } + + if (outlineItem.level === 'child') { + const parentIndex = prevItems.findIndex( + (item) => item.panelId === outlineItem.panelId && item.level === 'root' + ); + if (parentIndex === -1) { + const parentlessItemSibling = Object.keys(parentlessItemsRef.current).find( + (key) => key === outlineItem.panelId + ); + + if (parentlessItemSibling) { + parentlessItemsRef.current[outlineItem.panelId].push({ + ...outlineItem, + id, + }); + } else { + parentlessItemsRef.current[outlineItem.panelId] = [ + { + ...outlineItem, + id, + }, + ]; } + return [...prevItems]; } - return 0; - }); + + const newItems = [...prevItems]; + const parent = { ...newItems[parentIndex] }; + const childrenUpdated = [...(parent.children || []), { ...outlineItem, id }]; + childrenUpdated.sort(sortElementsByDocumentPosition); + const mergeSingleChild = checkMergeSingleChild(parentlessItemsRef, parent); + + newItems[parentIndex] = { + ...parent, + children: childrenUpdated, + mergeSingleChild, + }; + + return newItems; + } + + return [...prevItems]; }); return id; }, []); const unregister = useCallback((id: string) => { - setOutlineItems((prevItems) => prevItems.filter((item) => item.id !== id)); + setOutlineItems((prevItems) => + prevItems + .filter((item) => item.id !== id) + .map((item) => { + if (item.children) { + item.children = item.children.filter((child) => child.id !== id); + } + return item; + }) + ); + }, []); + + const updateOutlineItems = useCallback((newItems: ContentOutlineItemContextProps[]) => { + setOutlineItems(newItems); }, []); + useEffect(() => { + setOutlineItems((prevItems) => { + const newItems = [...prevItems]; + for (const item of newItems) { + item.children?.sort(sortElementsByDocumentPosition); + } + return newItems; + }); + }, [refreshDependencies]); + return ( - + {children} ); -}; - -export function useContentOutlineContext() { - const ctx = useContext(ContentOutlineContext); +} - if (!ctx) { - throw new Error('useContentOutlineContext must be used within a ContentOutlineContextProvider'); +export function sortElementsByDocumentPosition(a: ContentOutlineItemContextProps, b: ContentOutlineItemContextProps) { + 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 ctx; + return 0; +} + +function checkMergeSingleChild( + parentlessItemsRef: React.MutableRefObject, + outlineItem: Omit +) { + const children = parentlessItemsRef.current[outlineItem.panelId] || []; + const mergeSingleChild = children.length === 1 && outlineItem.mergeSingleChild; + + return mergeSingleChild; +} + +export function useContentOutlineContext() { + return useContext(ContentOutlineContext); } diff --git a/public/app/features/explore/ContentOutline/ContentOutlineItem.tsx b/public/app/features/explore/ContentOutline/ContentOutlineItem.tsx index df591a414b0..7c594436262 100644 --- a/public/app/features/explore/ContentOutline/ContentOutlineItem.tsx +++ b/public/app/features/explore/ContentOutline/ContentOutlineItem.tsx @@ -2,9 +2,29 @@ import React, { useEffect, useRef, ReactNode } from 'react'; import { useContentOutlineContext } from './ContentOutlineContext'; +type INDENT_LEVELS = 'root' | 'child'; + export interface ContentOutlineItemBaseProps { + panelId: string; title: string; icon: string; + /** + * Custom offset from the top of the Explore container when scrolling to this item. + * Items like query row need some offset so the top of the query row is not hidden behind the header. + */ + customTopOffset?: number; + /** + * The level of indentation for this item. + * - `root` is the top level item. + * - `child` is an item that is a child of an item with `root` level. + */ + level?: INDENT_LEVELS; + /** + * Merges a single child of this item with this item. + * e.g. It doesn't make sense to nest a single query row under a queries container + * because user can navigate to the query row by navigating to the queries container. + */ + mergeSingleChild?: boolean; } interface ContentOutlineItemProps extends ContentOutlineItemBaseProps { @@ -12,17 +32,38 @@ interface ContentOutlineItemProps extends ContentOutlineItemBaseProps { className?: string; } -export function ContentOutlineItem({ title, icon, children, className }: ContentOutlineItemProps) { - const { register, unregister } = useContentOutlineContext(); +export function ContentOutlineItem({ + panelId, + title, + icon, + customTopOffset, + children, + className, + level = 'root', + mergeSingleChild, +}: ContentOutlineItemProps) { + const { register, unregister } = useContentOutlineContext() ?? {}; const ref = useRef(null); useEffect(() => { + if (!register || !unregister) { + return; + } + // When the component mounts, register it and get its unique ID. - const id = register({ title: title, icon: icon, ref: ref.current }); + const id = register({ + panelId: panelId, + title: title, + icon: icon, + ref: ref.current, + customTopOffset: customTopOffset, + level: level, + mergeSingleChild, + }); // When the component unmounts, unregister it using its unique ID. return () => unregister(id); - }, [title, icon, register, unregister]); + }, [panelId, title, icon, customTopOffset, level, mergeSingleChild, register, unregister]); return (
diff --git a/public/app/features/explore/ContentOutline/ContentOutlineItemButton.tsx b/public/app/features/explore/ContentOutline/ContentOutlineItemButton.tsx index febd0e6d009..6d47db2766b 100644 --- a/public/app/features/explore/ContentOutline/ContentOutlineItemButton.tsx +++ b/public/app/features/explore/ContentOutline/ContentOutlineItemButton.tsx @@ -1,46 +1,90 @@ import { cx, css } from '@emotion/css'; -import React, { ButtonHTMLAttributes } from 'react'; +import React, { ButtonHTMLAttributes, useEffect, useRef, useState } from 'react'; import { IconName, isIconName, GrafanaTheme2 } from '@grafana/data'; import { Icon, useStyles2, Tooltip } from '@grafana/ui'; +import { TooltipPlacement } from '@grafana/ui/src/components/Tooltip'; type CommonProps = { + contentOutlineExpanded?: boolean; title?: string; - icon: string; + icon?: IconName | React.ReactNode; tooltip?: string; + tooltipPlacement?: TooltipPlacement; className?: string; + indentStyle?: string; + collapsible?: boolean; + collapsed?: boolean; isActive?: boolean; + sectionId?: string; + toggleCollapsed?: () => void; }; export type ContentOutlineItemButtonProps = CommonProps & ButtonHTMLAttributes; export function ContentOutlineItemButton({ + contentOutlineExpanded, title, icon, tooltip, + tooltipPlacement = 'bottom', className, + indentStyle, + collapsible, + collapsed, isActive, + sectionId, + toggleCollapsed, ...rest }: ContentOutlineItemButtonProps) { const styles = useStyles2(getStyles); const buttonStyles = cx(styles.button, className); + const textRef = useRef(null); + const [isOverflowing, setIsOverflowing] = useState(false); + + useEffect(() => { + if (textRef.current) { + setIsOverflowing(textRef.current?.scrollWidth > textRef.current?.clientWidth); + } + }, [title]); + const body = ( - +
+ {collapsible && ( + + )} + +
); - return tooltip ? ( - + // if there's a tooltip we want to show it if the text is overflowing + const showTooltip = tooltip && (!contentOutlineExpanded || isOverflowing); + + return showTooltip ? ( + {body} ) : ( @@ -48,13 +92,13 @@ export function ContentOutlineItemButton({ ); } -function renderIcon(icon: IconName | React.ReactNode) { +function OutlineIcon({ icon }: { icon: IconName | React.ReactNode }) { if (!icon) { return null; } if (isIconName(icon)) { - return ; + return ; } return icon; @@ -62,26 +106,48 @@ function renderIcon(icon: IconName | React.ReactNode) { const getStyles = (theme: GrafanaTheme2) => { return { + buttonContainer: css({ + position: 'relative', + display: 'flex', + alignItems: 'center', + flexGrow: 1, + gap: theme.spacing(1), + overflow: 'hidden', + width: '100%', + }), 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, + width: '100%', background: 'transparent', border: 'none', - textOverflow: 'ellipsis', - overflow: 'hidden', - whiteSpace: 'nowrap', + }), + collapseButton: css({ + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + width: theme.spacing(3), + height: theme.spacing(4), + borderRadius: theme.shape.radius.default, + color: theme.colors.text.secondary, + background: 'transparent', + border: 'none', + '&:hover': { color: theme.colors.text.primary, - background: theme.colors.background.secondary, - textDecoration: 'underline', + background: theme.colors.secondary.shade, }, }), + textContainer: css({ + whiteSpace: 'nowrap', + overflow: 'hidden', + textOverflow: 'ellipsis', + }), active: css({ backgroundColor: theme.colors.background.secondary, borderTopRightRadius: theme.shape.radius.default, diff --git a/public/app/features/explore/Explore.tsx b/public/app/features/explore/Explore.tsx index 4d27d12d865..da1c8540c82 100644 --- a/public/app/features/explore/Explore.tsx +++ b/public/app/features/explore/Explore.tsx @@ -93,9 +93,6 @@ const getStyles = (theme: GrafanaTheme2) => { paddingRight: theme.spacing(2), marginBottom: theme.spacing(2), }), - left: css({ - marginBottom: theme.spacing(2), - }), wrapper: css({ position: 'absolute', top: 0, @@ -370,7 +367,7 @@ export class Explore extends React.PureComponent { return Object.entries(groupedByPlugin).map(([pluginId, frames], index) => { return ( - + { 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 ( - + { gap: theme.spacing(1), }); return ( - + { const { logsSample, timeZone, setSupplementaryQueryEnabled, exploreId, datasourceInstance, queries } = this.props; return ( - + { const datasourceType = datasourceInstance ? datasourceInstance?.type : 'unknown'; return ( - + { renderFlameGraphPanel() { const { queryResponse } = this.props; return ( - + ); @@ -526,7 +523,7 @@ 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 && ( - + { } return ( - + { >
{contentOutlineVisible && ( -
- -
+ )} {
{datasourceInstance ? ( <> - + {correlationsBox} diff --git a/public/app/features/explore/QueryRows.tsx b/public/app/features/explore/QueryRows.tsx index e90a09bb270..6911ca3a49b 100644 --- a/public/app/features/explore/QueryRows.tsx +++ b/public/app/features/explore/QueryRows.tsx @@ -10,6 +10,7 @@ import { useDispatch, useSelector } from 'app/types'; import { getDatasourceSrv } from '../plugins/datasource_srv'; import { QueryEditorRows } from '../query/components/QueryEditorRows'; +import { ContentOutlineItem } from './ContentOutline/ContentOutlineItem'; import { changeQueries, runQueries } from './state/query'; import { getExploreItemSelector } from './state/selectors'; @@ -88,6 +89,18 @@ export const QueryRows = ({ exploreId }: Props) => { app={CoreApp.Explore} history={history} eventBus={eventBridge} + queryRowWrapper={(children, refId) => ( + + {children} + + )} /> ); }; diff --git a/public/app/features/query/components/QueryEditorRows.tsx b/public/app/features/query/components/QueryEditorRows.tsx index 17297eef12a..9399178d42a 100644 --- a/public/app/features/query/components/QueryEditorRows.tsx +++ b/public/app/features/query/components/QueryEditorRows.tsx @@ -1,4 +1,4 @@ -import React, { PureComponent } from 'react'; +import React, { PureComponent, ReactNode } from 'react'; import { DragDropContext, DragStart, Droppable, DropResult } from 'react-beautiful-dnd'; import { @@ -34,6 +34,7 @@ export interface Props { onQueryCopied?: () => void; onQueryRemoved?: () => void; onQueryToggled?: (queryStatus?: boolean | undefined) => void; + queryRowWrapper?: (children: ReactNode, refId: string) => ReactNode; } export class QueryEditorRows extends PureComponent { @@ -144,6 +145,7 @@ export class QueryEditorRows extends PureComponent { onQueryCopied, onQueryRemoved, onQueryToggled, + queryRowWrapper, } = this.props; return ( @@ -158,7 +160,7 @@ export class QueryEditorRows extends PureComponent { ? (settings: DataSourceInstanceSettings) => this.onDataSourceChange(settings, index) : undefined; - return ( + const queryEditorRow = ( { eventBus={eventBus} /> ); + + return queryRowWrapper ? queryRowWrapper(queryEditorRow, query.refId) : queryEditorRow; })} {provided.placeholder}