Explore: Nested Content Outline (#80688)

* indentation levels

* Highlight parent section when child is selected

* Fix context, add rotation to toggle button

* Merge single child logic; fix styling

* Fix collapsed logic, make elipsis work, reorganize for better readability;

* Add connector

* Tooltip placement

* Update tests so they test components the same way users would interact with them

* Clean up indendation levels

* Support collapsing logic for multiple section; highlight all items in a section of an active child - parent is active only when section is collapsed and child inside of it is active

* Simplify making ellipsis work

* Show tooltip if the text overflows in expanded mode

* The whole button container should have same background when section is expanded in mini view

* Fix a bug where root items were not being sorted by document position

* Update query order when query rows are changed through dragging and dropping

* Fix the issue where chaning the title of a query row would remove the query
pull/85393/head
Haris Rozajac 1 year ago committed by GitHub
parent 73e426b081
commit 649c456eab
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 112
      public/app/features/explore/ContentOutline/ContentOutline.test.tsx
  2. 264
      public/app/features/explore/ContentOutline/ContentOutline.tsx
  3. 149
      public/app/features/explore/ContentOutline/ContentOutlineContext.tsx
  4. 49
      public/app/features/explore/ContentOutline/ContentOutlineItem.tsx
  5. 110
      public/app/features/explore/ContentOutline/ContentOutlineItemButton.tsx
  6. 29
      public/app/features/explore/Explore.tsx
  7. 13
      public/app/features/explore/QueryRows.tsx
  8. 8
      public/app/features/query/components/QueryEditorRows.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('<ContentOutline />', () => {
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);
});
});

@ -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<string | undefined>(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 |
<CustomScrollbar>
<div className={styles.content}>
<ContentOutlineItemButton
title={expanded ? 'Collapse outline' : undefined}
icon={expanded ? 'angle-left' : 'angle-right'}
icon={'arrow-from-right'}
tooltip={contentOutlineExpanded ? 'Collapse outline' : 'Expand outline'}
tooltipPlacement={contentOutlineExpanded ? 'right' : 'bottom'}
onClick={toggle}
tooltip={!expanded ? 'Expand content outline' : undefined}
className={styles.buttonStyles}
aria-expanded={expanded}
className={cx(styles.toggleContentOutlineButton, {
[styles.justifyCenter]: !contentOutlineExpanded && !outlineItemsShouldIndent,
})}
aria-expanded={contentOutlineExpanded}
/>
{outlineItems.map((item) => {
return (
{outlineItems.map((item) => (
<React.Fragment key={item.id}>
<ContentOutlineItemButton
key={item.id}
title={expanded ? item.title : undefined}
className={styles.buttonStyles}
title={contentOutlineExpanded ? item.title : undefined}
contentOutlineExpanded={contentOutlineExpanded}
className={cx(styles.buttonStyles, {
[styles.justifyCenter]: !contentOutlineExpanded,
[styles.sectionHighlighter]: isChildActive(item, activeSectionChildId) && !contentOutlineExpanded,
})}
indentStyle={cx({
[styles.indentRoot]: outlineItemsShouldIndent && item.children?.length === 0,
[styles.sectionHighlighter]:
isChildActive(item, activeSectionChildId) && !contentOutlineExpanded && sectionsExpanded[item.id],
})}
icon={item.icon}
onClick={() => 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}
/>
);
})}
<div id={item.id} data-testid={`section-wrapper-${item.id}`}>
{item.children &&
(!item.mergeSingleChild || item.children.length !== 1) &&
sectionsExpanded[item.id] &&
item.children.map((child, i) => (
<div key={child.id} className={styles.itemWrapper}>
{contentOutlineExpanded && (
<div
className={cx(styles.itemConnector, {
[styles.firstItemConnector]: i === 0,
[styles.lastItemConnector]: i === (item.children?.length || 0) - 1,
})}
/>
)}
<ContentOutlineItemButton
key={child.id}
title={contentOutlineExpanded ? child.title : undefined}
contentOutlineExpanded={contentOutlineExpanded}
icon={contentOutlineExpanded ? undefined : item.icon}
className={cx(styles.buttonStyles, {
[styles.justifyCenter]: !contentOutlineExpanded,
[styles.sectionHighlighter]:
isChildActive(item, activeSectionChildId) && !contentOutlineExpanded,
})}
indentStyle={styles.indentChild}
onClick={() => scrollIntoView(child.ref, child.panelId, child.customTopOffset)}
tooltip={child.title}
isActive={activeSectionChildId === child.id}
/>
</div>
))}
</div>
</React.Fragment>
))}
</div>
</CustomScrollbar>
</PanelContainer>
);
}
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);
}

@ -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<ContentOutlineItemContextProps, 'id'>) => string;
type RegisterFunction = (outlineItem: Omit<ContentOutlineItemContextProps, 'id'>) => 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<ContentOutlineContextProps | undefined>(undefined);
export const ContentOutlineContextProvider = ({ children }: { children: ReactNode }) => {
export function ContentOutlineContextProvider({ children, refreshDependencies }: ContentOutlineContextProviderProps) {
const [outlineItems, setOutlineItems] = useState<ContentOutlineItemContextProps[]>([]);
const parentlessItemsRef = useRef<ParentlessItems>({});
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 (
<ContentOutlineContext.Provider value={{ outlineItems, register, unregister }}>
<ContentOutlineContext.Provider value={{ outlineItems, register, unregister, updateOutlineItems }}>
{children}
</ContentOutlineContext.Provider>
);
};
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<ParentlessItems>,
outlineItem: Omit<ContentOutlineItemContextProps, 'id'>
) {
const children = parentlessItemsRef.current[outlineItem.panelId] || [];
const mergeSingleChild = children.length === 1 && outlineItem.mergeSingleChild;
return mergeSingleChild;
}
export function useContentOutlineContext() {
return useContext(ContentOutlineContext);
}

@ -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 (
<div className={className} ref={ref}>

@ -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<HTMLButtonElement>;
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<HTMLElement>(null);
const [isOverflowing, setIsOverflowing] = useState(false);
useEffect(() => {
if (textRef.current) {
setIsOverflowing(textRef.current?.scrollWidth > textRef.current?.clientWidth);
}
}, [title]);
const body = (
<button
className={cx(buttonStyles, {
[styles.active]: isActive,
})}
aria-label={tooltip}
{...rest}
>
{renderIcon(icon)}
{title}
</button>
<div className={cx(styles.buttonContainer, indentStyle)}>
{collapsible && (
<button
className={styles.collapseButton}
onClick={toggleCollapsed}
aria-label="Content outline item collapse button"
aria-expanded={!collapsed}
aria-controls={sectionId}
>
<OutlineIcon icon={collapsed ? 'angle-right' : 'angle-down'} />
</button>
)}
<button
className={cx(buttonStyles, {
[styles.active]: isActive,
})}
aria-label={tooltip}
{...rest}
>
<OutlineIcon icon={icon} />
{title && (
<span className={styles.textContainer} ref={textRef}>
{title}
</span>
)}
</button>
</div>
);
return tooltip ? (
<Tooltip content={tooltip} placement="bottom">
// if there's a tooltip we want to show it if the text is overflowing
const showTooltip = tooltip && (!contentOutlineExpanded || isOverflowing);
return showTooltip ? (
<Tooltip content={tooltip} placement={tooltipPlacement}>
{body}
</Tooltip>
) : (
@ -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 <Icon name={icon} size={'lg'} />;
return <Icon name={icon} size={'lg'} title={icon} />;
}
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,

@ -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<Props, ExploreState> {
return Object.entries(groupedByPlugin).map(([pluginId, frames], index) => {
return (
<ContentOutlineItem title={pluginId} icon="plug" key={index}>
<ContentOutlineItem panelId={pluginId} title={pluginId} icon="plug" key={index}>
<CustomContainer
key={index}
timeZone={timeZone}
@ -392,7 +389,7 @@ export class Explore extends React.PureComponent<Props, ExploreState> {
const { graphResult, absoluteRange, timeZone, queryResponse, showFlameGraph } = this.props;
return (
<ContentOutlineItem title="Graph" icon="graph-bar">
<ContentOutlineItem panelId="Graph" title="Graph" icon="graph-bar">
<GraphContainer
data={graphResult!}
height={showFlameGraph ? 180 : 400}
@ -412,7 +409,7 @@ export class Explore extends React.PureComponent<Props, ExploreState> {
renderTablePanel(width: number) {
const { exploreId, timeZone } = this.props;
return (
<ContentOutlineItem title="Table" icon="table">
<ContentOutlineItem panelId="Table" title="Table" icon="table">
<TableContainer
ariaLabel={selectors.pages.Explore.General.table}
width={width}
@ -428,7 +425,7 @@ export class Explore extends React.PureComponent<Props, ExploreState> {
renderRawPrometheus(width: number) {
const { exploreId, datasourceInstance, timeZone } = this.props;
return (
<ContentOutlineItem title="Raw Prometheus" icon="gf-prometheus">
<ContentOutlineItem panelId="Raw Prometheus" title="Raw Prometheus" icon="gf-prometheus">
<RawPrometheusContainer
showRawPrometheus={true}
ariaLabel={selectors.pages.Explore.General.table}
@ -452,7 +449,7 @@ export class Explore extends React.PureComponent<Props, ExploreState> {
gap: theme.spacing(1),
});
return (
<ContentOutlineItem title="Logs" icon="gf-logs" className={logsContentOutlineWrapper}>
<ContentOutlineItem panelId="Logs" title="Logs" icon="gf-logs" className={logsContentOutlineWrapper}>
<LogsContainer
exploreId={exploreId}
loadingState={queryResponse.state}
@ -477,7 +474,7 @@ export class Explore extends React.PureComponent<Props, ExploreState> {
const { logsSample, timeZone, setSupplementaryQueryEnabled, exploreId, datasourceInstance, queries } = this.props;
return (
<ContentOutlineItem title="Logs Sample" icon="gf-logs">
<ContentOutlineItem panelId="Logs Sample" title="Logs Sample" icon="gf-logs">
<LogsSamplePanel
queryResponse={logsSample.data}
timeZone={timeZone}
@ -498,7 +495,7 @@ export class Explore extends React.PureComponent<Props, ExploreState> {
const datasourceType = datasourceInstance ? datasourceInstance?.type : 'unknown';
return (
<ContentOutlineItem title="Node Graph" icon="code-branch">
<ContentOutlineItem panelId="Node Graph" title="Node Graph" icon="code-branch">
<NodeGraphContainer
dataFrames={this.memoizedGetNodeGraphDataFrames(queryResponse.series)}
exploreId={exploreId}
@ -513,7 +510,7 @@ export class Explore extends React.PureComponent<Props, ExploreState> {
renderFlameGraphPanel() {
const { queryResponse } = this.props;
return (
<ContentOutlineItem title="Flame Graph" icon="fire">
<ContentOutlineItem panelId="Flame Graph" title="Flame Graph" icon="fire">
<FlameGraphExploreContainer dataFrames={queryResponse.flameGraphFrames} />
</ContentOutlineItem>
);
@ -526,7 +523,7 @@ export class Explore extends React.PureComponent<Props, ExploreState> {
return (
// If there is no data (like 404) we show a separate error so no need to show anything here
dataFrames.length && (
<ContentOutlineItem title="Traces" icon="file-alt">
<ContentOutlineItem panelId="Traces" title="Traces" icon="file-alt">
<TraceViewContainer
exploreId={exploreId}
dataFrames={dataFrames}
@ -586,7 +583,7 @@ export class Explore extends React.PureComponent<Props, ExploreState> {
}
return (
<ContentOutlineContextProvider>
<ContentOutlineContextProvider refreshDependencies={this.props.queries}>
<ExploreToolbar
exploreId={exploreId}
onChangeTime={this.onChangeTime}
@ -602,9 +599,7 @@ export class Explore extends React.PureComponent<Props, ExploreState> {
>
<div className={styles.wrapper}>
{contentOutlineVisible && (
<div className={styles.left}>
<ContentOutline scroller={this.scrollElement} panelId={`content-outline-container-${exploreId}`} />
</div>
<ContentOutline scroller={this.scrollElement} panelId={`content-outline-container-${exploreId}`} />
)}
<CustomScrollbar
testId={selectors.pages.Explore.General.scrollView}
@ -614,7 +609,7 @@ export class Explore extends React.PureComponent<Props, ExploreState> {
<div className={styles.exploreContainer}>
{datasourceInstance ? (
<>
<ContentOutlineItem title="Queries" icon="arrow">
<ContentOutlineItem panelId="Queries" title="Queries" icon="arrow" mergeSingleChild={true}>
<PanelContainer className={styles.queryContainer}>
{correlationsBox}
<QueryRows exploreId={exploreId} />

@ -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) => (
<ContentOutlineItem
title={refId}
icon="arrow"
key={refId}
panelId="Queries"
customTopOffset={-10}
level="child"
>
{children}
</ContentOutlineItem>
)}
/>
);
};

@ -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<Props> {
@ -144,6 +145,7 @@ export class QueryEditorRows extends PureComponent<Props> {
onQueryCopied,
onQueryRemoved,
onQueryToggled,
queryRowWrapper,
} = this.props;
return (
@ -158,7 +160,7 @@ export class QueryEditorRows extends PureComponent<Props> {
? (settings: DataSourceInstanceSettings) => this.onDataSourceChange(settings, index)
: undefined;
return (
const queryEditorRow = (
<QueryEditorRow
id={query.refId}
index={index}
@ -180,6 +182,8 @@ export class QueryEditorRows extends PureComponent<Props> {
eventBus={eventBus}
/>
);
return queryRowWrapper ? queryRowWrapper(queryEditorRow, query.refId) : queryEditorRow;
})}
{provided.placeholder}
</div>

Loading…
Cancel
Save