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 React from 'react';
import { ContentOutline } from './ContentOutline'; import { ContentOutline } from './ContentOutline';
@ -10,13 +11,14 @@ jest.mock('./ContentOutlineContext', () => ({
const scrollIntoViewMock = jest.fn(); const scrollIntoViewMock = jest.fn();
const scrollerMock = document.createElement('div'); const scrollerMock = document.createElement('div');
const setup = () => { const setup = (mergeSingleChild = false) => {
HTMLElement.prototype.scrollIntoView = scrollIntoViewMock; HTMLElement.prototype.scrollIntoView = scrollIntoViewMock;
scrollerMock.scroll = jest.fn(); scrollerMock.scroll = jest.fn();
// Mock useContentOutlineContext with custom outlineItems // Mock useContentOutlineContext with custom outlineItems
const mockUseContentOutlineContext = require('./ContentOutlineContext').useContentOutlineContext; const mockUseContentOutlineContext = require('./ContentOutlineContext').useContentOutlineContext;
mockUseContentOutlineContext.mockReturnValue({ mockUseContentOutlineContext.mockReturnValue({
outlineItems: [ outlineItems: [
{ {
@ -24,12 +26,39 @@ const setup = () => {
icon: 'test-icon', icon: 'test-icon',
title: 'Item 1', title: 'Item 1',
ref: document.createElement('div'), 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', id: 'item-2',
icon: 'test-icon', icon: 'test-icon',
title: 'Item 2', title: 'Item 2',
ref: document.createElement('div'), 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(), register: jest.fn(),
@ -40,31 +69,78 @@ const setup = () => {
}; };
describe('<ContentOutline />', () => { describe('<ContentOutline />', () => {
beforeEach(() => { it('toggles content on button click', async () => {
setup(); setup();
}); let showContentOutlineButton = screen.getByRole('button', { name: 'Expand outline' });
it('toggles content on button click', () => {
let showContentOutlineButton = screen.getByLabelText('Expand content outline');
expect(showContentOutlineButton).toBeInTheDocument(); expect(showContentOutlineButton).toBeInTheDocument();
fireEvent.click(showContentOutlineButton); await userEvent.click(showContentOutlineButton);
const hideContentOutlineButton = screen.getByText('Collapse outline'); const hideContentOutlineButton = screen.getByRole('button', { name: 'Collapse outline' });
expect(hideContentOutlineButton).toBeInTheDocument(); expect(hideContentOutlineButton).toBeInTheDocument();
fireEvent.click(hideContentOutlineButton); await userEvent.click(hideContentOutlineButton);
showContentOutlineButton = screen.getByLabelText('Expand content outline'); showContentOutlineButton = screen.getByRole('button', { name: 'Expand outline' });
expect(showContentOutlineButton).toBeInTheDocument(); expect(showContentOutlineButton).toBeInTheDocument();
}); });
it('scrolls into view on content button click', () => { it('scrolls into view on content button click', async () => {
const itemButtons = screen.getAllByLabelText(/Item/i); 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) => { it('merges a single child item when mergeSingleChild is true', () => {
fireEvent.click(button); setup(true);
const child = screen.queryByRole('button', { name: 'Item 1-1' });
//assert scrollIntoView is called expect(child).not.toBeInTheDocument();
expect(scrollerMock.scroll).toHaveBeenCalled(); });
});
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 React, { useEffect, useRef, useState } from 'react';
import { useToggle, useScroll } from 'react-use'; import { useToggle, useScroll } from 'react-use';
@ -6,41 +6,30 @@ import { GrafanaTheme2 } from '@grafana/data';
import { reportInteraction } from '@grafana/runtime'; import { reportInteraction } from '@grafana/runtime';
import { useStyles2, PanelContainer, CustomScrollbar } from '@grafana/ui'; import { useStyles2, PanelContainer, CustomScrollbar } from '@grafana/ui';
import { useContentOutlineContext } from './ContentOutlineContext'; import { ContentOutlineItemContextProps, useContentOutlineContext } from './ContentOutlineContext';
import { ContentOutlineItemButton } from './ContentOutlineItemButton'; 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 }) { export function ContentOutline({ scroller, panelId }: { scroller: HTMLElement | undefined; panelId: string }) {
const { outlineItems } = useContentOutlineContext(); const [contentOutlineExpanded, toggleContentOutlineExpanded] = useToggle(false);
const [expanded, toggleExpanded] = useToggle(false); const styles = useStyles2(getStyles, contentOutlineExpanded);
const [activeItemId, setActiveItemId] = useState<string | undefined>(outlineItems[0]?.id);
const styles = useStyles2((theme) => getStyles(theme));
const scrollerRef = useRef(scroller || null); const scrollerRef = useRef(scroller || null);
const { y: verticalScroll } = useScroll(scrollerRef); 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 scrollValue = 0;
let el: HTMLElement | null | undefined = ref; let el: HTMLElement | null | undefined = ref;
@ -50,40 +39,65 @@ export function ContentOutline({ scroller, panelId }: { scroller: HTMLElement |
} while (el && el !== scroller); } while (el && el !== scroller);
scroller?.scroll({ scroller?.scroll({
top: scrollValue, top: scrollValue + customOffsetTop,
behavior: 'smooth', behavior: 'smooth',
}); });
reportInteraction('explore_toolbar_contentoutline_clicked', { reportInteraction('explore_toolbar_contentoutline_clicked', {
item: 'select_section', item: 'select_section',
type: buttonTitle, type: itemPanelId,
}); });
}; };
const toggle = () => { const toggle = () => {
toggleExpanded(); toggleContentOutlineExpanded();
reportInteraction('explore_toolbar_contentoutline_clicked', { reportInteraction('explore_toolbar_contentoutline_clicked', {
item: 'outline', 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(() => { useEffect(() => {
const activeItem = outlineItems.find((item) => { let activeItem;
const top = item?.ref?.getBoundingClientRect().top;
for (const item of outlineItems) {
let top = item?.ref?.getBoundingClientRect().top;
if (!top) { // Check item
return false; 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) { if (activeChild) {
return; setActiveSectionChildId(activeChild.id);
} setActiveSectionId(item.id);
break;
}
setActiveItemId(activeItem.id); if (activeItem) {
setActiveSectionId(activeItem.id);
setActiveSectionChildId(undefined);
break;
}
}
}, [outlineItems, verticalScroll]); }, [outlineItems, verticalScroll]);
return ( return (
@ -91,29 +105,163 @@ export function ContentOutline({ scroller, panelId }: { scroller: HTMLElement |
<CustomScrollbar> <CustomScrollbar>
<div className={styles.content}> <div className={styles.content}>
<ContentOutlineItemButton <ContentOutlineItemButton
title={expanded ? 'Collapse outline' : undefined} icon={'arrow-from-right'}
icon={expanded ? 'angle-left' : 'angle-right'} tooltip={contentOutlineExpanded ? 'Collapse outline' : 'Expand outline'}
tooltipPlacement={contentOutlineExpanded ? 'right' : 'bottom'}
onClick={toggle} onClick={toggle}
tooltip={!expanded ? 'Expand content outline' : undefined} className={cx(styles.toggleContentOutlineButton, {
className={styles.buttonStyles} [styles.justifyCenter]: !contentOutlineExpanded && !outlineItemsShouldIndent,
aria-expanded={expanded} })}
aria-expanded={contentOutlineExpanded}
/> />
{outlineItems.map((item) => { {outlineItems.map((item) => (
return ( <React.Fragment key={item.id}>
<ContentOutlineItemButton <ContentOutlineItemButton
key={item.id} key={item.id}
title={expanded ? item.title : undefined} title={contentOutlineExpanded ? item.title : undefined}
className={styles.buttonStyles} 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} icon={item.icon}
onClick={() => scrollIntoView(item.ref, item.title)} onClick={() => scrollIntoView(item.ref, item.panelId)}
tooltip={!expanded ? item.title : undefined} tooltip={item.title}
isActive={activeItemId === item.id} 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> </div>
</CustomScrollbar> </CustomScrollbar>
</PanelContainer> </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 { 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'; import { ContentOutlineItemBaseProps } from './ContentOutlineItem';
export interface ContentOutlineItemContextProps extends ContentOutlineItemBaseProps { export interface ContentOutlineItemContextProps extends ContentOutlineItemBaseProps {
id: string; id: string;
ref: HTMLElement | null; ref: HTMLElement | null;
children?: ContentOutlineItemContextProps[];
} }
type RegisterFunction = ({ title, icon, ref }: Omit<ContentOutlineItemContextProps, 'id'>) => string; type RegisterFunction = (outlineItem: Omit<ContentOutlineItemContextProps, 'id'>) => string;
export interface ContentOutlineContextProps { export interface ContentOutlineContextProps {
outlineItems: ContentOutlineItemContextProps[]; outlineItems: ContentOutlineItemContextProps[];
register: RegisterFunction; register: RegisterFunction;
unregister: (id: string) => void; 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); const ContentOutlineContext = createContext<ContentOutlineContextProps | undefined>(undefined);
export const ContentOutlineContextProvider = ({ children }: { children: ReactNode }) => { export function ContentOutlineContextProvider({ children, refreshDependencies }: ContentOutlineContextProviderProps) {
const [outlineItems, setOutlineItems] = useState<ContentOutlineItemContextProps[]>([]); const [outlineItems, setOutlineItems] = useState<ContentOutlineItemContextProps[]>([]);
const parentlessItemsRef = useRef<ParentlessItems>({});
const register: RegisterFunction = useCallback(({ title, icon, ref }) => { const register: RegisterFunction = useCallback((outlineItem) => {
const id = uniqueId(`${title}-${icon}_`); const id = uniqueId(`${outlineItem.panelId}-${outlineItem.title}-${outlineItem.icon}_`);
setOutlineItems((prevItems) => { setOutlineItems((prevItems) => {
const updatedItems = [...prevItems, { id, title, icon, ref }]; if (outlineItem.level === 'root') {
const mergeSingleChild = checkMergeSingleChild(parentlessItemsRef, outlineItem);
return updatedItems.sort((a, b) => { const updatedItems = [
if (a.ref && b.ref) { ...prevItems,
const diff = a.ref.compareDocumentPosition(b.ref); {
if (diff === Node.DOCUMENT_POSITION_PRECEDING) { ...outlineItem,
return 1; id,
} else if (diff === Node.DOCUMENT_POSITION_FOLLOWING) { children: parentlessItemsRef.current[outlineItem.panelId] || [],
return -1; 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; return id;
}, []); }, []);
const unregister = useCallback((id: string) => { 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 ( return (
<ContentOutlineContext.Provider value={{ outlineItems, register, unregister }}> <ContentOutlineContext.Provider value={{ outlineItems, register, unregister, updateOutlineItems }}>
{children} {children}
</ContentOutlineContext.Provider> </ContentOutlineContext.Provider>
); );
}; }
export function useContentOutlineContext() {
const ctx = useContext(ContentOutlineContext);
if (!ctx) { export function sortElementsByDocumentPosition(a: ContentOutlineItemContextProps, b: ContentOutlineItemContextProps) {
throw new Error('useContentOutlineContext must be used within a ContentOutlineContextProvider'); 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'; import { useContentOutlineContext } from './ContentOutlineContext';
type INDENT_LEVELS = 'root' | 'child';
export interface ContentOutlineItemBaseProps { export interface ContentOutlineItemBaseProps {
panelId: string;
title: string; title: string;
icon: 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 { interface ContentOutlineItemProps extends ContentOutlineItemBaseProps {
@ -12,17 +32,38 @@ interface ContentOutlineItemProps extends ContentOutlineItemBaseProps {
className?: string; className?: string;
} }
export function ContentOutlineItem({ title, icon, children, className }: ContentOutlineItemProps) { export function ContentOutlineItem({
const { register, unregister } = useContentOutlineContext(); panelId,
title,
icon,
customTopOffset,
children,
className,
level = 'root',
mergeSingleChild,
}: ContentOutlineItemProps) {
const { register, unregister } = useContentOutlineContext() ?? {};
const ref = useRef(null); const ref = useRef(null);
useEffect(() => { useEffect(() => {
if (!register || !unregister) {
return;
}
// When the component mounts, register it and get its unique ID. // 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. // When the component unmounts, unregister it using its unique ID.
return () => unregister(id); return () => unregister(id);
}, [title, icon, register, unregister]); }, [panelId, title, icon, customTopOffset, level, mergeSingleChild, register, unregister]);
return ( return (
<div className={className} ref={ref}> <div className={className} ref={ref}>

@ -1,46 +1,90 @@
import { cx, css } from '@emotion/css'; 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 { IconName, isIconName, GrafanaTheme2 } from '@grafana/data';
import { Icon, useStyles2, Tooltip } from '@grafana/ui'; import { Icon, useStyles2, Tooltip } from '@grafana/ui';
import { TooltipPlacement } from '@grafana/ui/src/components/Tooltip';
type CommonProps = { type CommonProps = {
contentOutlineExpanded?: boolean;
title?: string; title?: string;
icon: string; icon?: IconName | React.ReactNode;
tooltip?: string; tooltip?: string;
tooltipPlacement?: TooltipPlacement;
className?: string; className?: string;
indentStyle?: string;
collapsible?: boolean;
collapsed?: boolean;
isActive?: boolean; isActive?: boolean;
sectionId?: string;
toggleCollapsed?: () => void;
}; };
export type ContentOutlineItemButtonProps = CommonProps & ButtonHTMLAttributes<HTMLButtonElement>; export type ContentOutlineItemButtonProps = CommonProps & ButtonHTMLAttributes<HTMLButtonElement>;
export function ContentOutlineItemButton({ export function ContentOutlineItemButton({
contentOutlineExpanded,
title, title,
icon, icon,
tooltip, tooltip,
tooltipPlacement = 'bottom',
className, className,
indentStyle,
collapsible,
collapsed,
isActive, isActive,
sectionId,
toggleCollapsed,
...rest ...rest
}: ContentOutlineItemButtonProps) { }: ContentOutlineItemButtonProps) {
const styles = useStyles2(getStyles); const styles = useStyles2(getStyles);
const buttonStyles = cx(styles.button, className); 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 = ( const body = (
<button <div className={cx(styles.buttonContainer, indentStyle)}>
className={cx(buttonStyles, { {collapsible && (
[styles.active]: isActive, <button
})} className={styles.collapseButton}
aria-label={tooltip} onClick={toggleCollapsed}
{...rest} aria-label="Content outline item collapse button"
> aria-expanded={!collapsed}
{renderIcon(icon)} aria-controls={sectionId}
{title} >
</button> <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 ? ( // if there's a tooltip we want to show it if the text is overflowing
<Tooltip content={tooltip} placement="bottom"> const showTooltip = tooltip && (!contentOutlineExpanded || isOverflowing);
return showTooltip ? (
<Tooltip content={tooltip} placement={tooltipPlacement}>
{body} {body}
</Tooltip> </Tooltip>
) : ( ) : (
@ -48,13 +92,13 @@ export function ContentOutlineItemButton({
); );
} }
function renderIcon(icon: IconName | React.ReactNode) { function OutlineIcon({ icon }: { icon: IconName | React.ReactNode }) {
if (!icon) { if (!icon) {
return null; return null;
} }
if (isIconName(icon)) { if (isIconName(icon)) {
return <Icon name={icon} size={'lg'} />; return <Icon name={icon} size={'lg'} title={icon} />;
} }
return icon; return icon;
@ -62,26 +106,48 @@ function renderIcon(icon: IconName | React.ReactNode) {
const getStyles = (theme: GrafanaTheme2) => { const getStyles = (theme: GrafanaTheme2) => {
return { return {
buttonContainer: css({
position: 'relative',
display: 'flex',
alignItems: 'center',
flexGrow: 1,
gap: theme.spacing(1),
overflow: 'hidden',
width: '100%',
}),
button: css({ button: css({
label: 'content-outline-item-button', label: 'content-outline-item-button',
display: 'flex', display: 'flex',
flexGrow: 1,
alignItems: 'center', alignItems: 'center',
height: theme.spacing(theme.components.height.md), height: theme.spacing(theme.components.height.md),
padding: theme.spacing(0, 1), padding: theme.spacing(0, 1),
gap: theme.spacing(1), gap: theme.spacing(1),
color: theme.colors.text.secondary, color: theme.colors.text.secondary,
width: '100%',
background: 'transparent', background: 'transparent',
border: 'none', border: 'none',
textOverflow: 'ellipsis', }),
overflow: 'hidden', collapseButton: css({
whiteSpace: 'nowrap', 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': { '&:hover': {
color: theme.colors.text.primary, color: theme.colors.text.primary,
background: theme.colors.background.secondary, background: theme.colors.secondary.shade,
textDecoration: 'underline',
}, },
}), }),
textContainer: css({
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}),
active: css({ active: css({
backgroundColor: theme.colors.background.secondary, backgroundColor: theme.colors.background.secondary,
borderTopRightRadius: theme.shape.radius.default, borderTopRightRadius: theme.shape.radius.default,

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

@ -10,6 +10,7 @@ import { useDispatch, useSelector } from 'app/types';
import { getDatasourceSrv } from '../plugins/datasource_srv'; import { getDatasourceSrv } from '../plugins/datasource_srv';
import { QueryEditorRows } from '../query/components/QueryEditorRows'; import { QueryEditorRows } from '../query/components/QueryEditorRows';
import { ContentOutlineItem } from './ContentOutline/ContentOutlineItem';
import { changeQueries, runQueries } from './state/query'; import { changeQueries, runQueries } from './state/query';
import { getExploreItemSelector } from './state/selectors'; import { getExploreItemSelector } from './state/selectors';
@ -88,6 +89,18 @@ export const QueryRows = ({ exploreId }: Props) => {
app={CoreApp.Explore} app={CoreApp.Explore}
history={history} history={history}
eventBus={eventBridge} 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 { DragDropContext, DragStart, Droppable, DropResult } from 'react-beautiful-dnd';
import { import {
@ -34,6 +34,7 @@ export interface Props {
onQueryCopied?: () => void; onQueryCopied?: () => void;
onQueryRemoved?: () => void; onQueryRemoved?: () => void;
onQueryToggled?: (queryStatus?: boolean | undefined) => void; onQueryToggled?: (queryStatus?: boolean | undefined) => void;
queryRowWrapper?: (children: ReactNode, refId: string) => ReactNode;
} }
export class QueryEditorRows extends PureComponent<Props> { export class QueryEditorRows extends PureComponent<Props> {
@ -144,6 +145,7 @@ export class QueryEditorRows extends PureComponent<Props> {
onQueryCopied, onQueryCopied,
onQueryRemoved, onQueryRemoved,
onQueryToggled, onQueryToggled,
queryRowWrapper,
} = this.props; } = this.props;
return ( return (
@ -158,7 +160,7 @@ export class QueryEditorRows extends PureComponent<Props> {
? (settings: DataSourceInstanceSettings) => this.onDataSourceChange(settings, index) ? (settings: DataSourceInstanceSettings) => this.onDataSourceChange(settings, index)
: undefined; : undefined;
return ( const queryEditorRow = (
<QueryEditorRow <QueryEditorRow
id={query.refId} id={query.refId}
index={index} index={index}
@ -180,6 +182,8 @@ export class QueryEditorRows extends PureComponent<Props> {
eventBus={eventBus} eventBus={eventBus}
/> />
); );
return queryRowWrapper ? queryRowWrapper(queryEditorRow, query.refId) : queryEditorRow;
})} })}
{provided.placeholder} {provided.placeholder}
</div> </div>

Loading…
Cancel
Save