mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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
This commit is contained in:
parent
73e426b081
commit
649c456eab
@ -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]+/ });
|
||||
|
||||
itemButtons.forEach((button) => {
|
||||
fireEvent.click(button);
|
||||
for (const button of itemButtons) {
|
||||
await userEvent.click(button);
|
||||
}
|
||||
|
||||
//assert scrollIntoView is called
|
||||
expect(scrollerMock.scroll).toHaveBeenCalled();
|
||||
});
|
||||
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();
|
||||
});
|
||||
|
||||
it('merges a single child item when mergeSingleChild is true', () => {
|
||||
setup(true);
|
||||
const child = screen.queryByRole('button', { name: 'Item 1-1' });
|
||||
|
||||
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 scrollIntoView = (ref: HTMLElement | null, buttonTitle: string) => {
|
||||
const outlineItemsShouldIndent = outlineItems.some(
|
||||
(item) => item.children && !(item.mergeSingleChild && item.children?.length === 1) && item.children.length > 0
|
||||
);
|
||||
|
||||
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;
|
||||
|
||||
if (!top) {
|
||||
return false;
|
||||
for (const item of outlineItems) {
|
||||
let top = item?.ref?.getBoundingClientRect().top;
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
if (activeItem) {
|
||||
setActiveSectionId(activeItem.id);
|
||||
setActiveSectionChildId(undefined);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
setActiveItemId(activeItem.id);
|
||||
}, [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 }];
|
||||
if (outlineItem.level === 'root') {
|
||||
const mergeSingleChild = checkMergeSingleChild(parentlessItemsRef, outlineItem);
|
||||
const updatedItems = [
|
||||
...prevItems,
|
||||
{
|
||||
...outlineItem,
|
||||
id,
|
||||
children: parentlessItemsRef.current[outlineItem.panelId] || [],
|
||||
mergeSingleChild,
|
||||
},
|
||||
];
|
||||
|
||||
return updatedItems.sort((a, b) => {
|
||||
if (a.ref && b.ref) {
|
||||
const diff = a.ref.compareDocumentPosition(b.ref);
|
||||
if (diff === Node.DOCUMENT_POSITION_PRECEDING) {
|
||||
return 1;
|
||||
} else if (diff === Node.DOCUMENT_POSITION_FOLLOWING) {
|
||||
return -1;
|
||||
return 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 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 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() {
|
||||
const ctx = useContext(ContentOutlineContext);
|
||||
|
||||
if (!ctx) {
|
||||
throw new Error('useContentOutlineContext must be used within a ContentOutlineContextProvider');
|
||||
}
|
||||
return ctx;
|
||||
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…
Reference in New Issue
Block a user