2025-04-01 10:38:02 +09:00

326 lines
12 KiB
TypeScript

import { css, cx } from '@emotion/css';
import { Fragment, useEffect, useRef, useState } from 'react';
import { useToggle, useScroll } from 'react-use';
import { GrafanaTheme2, store } from '@grafana/data';
import { reportInteraction } from '@grafana/runtime';
import { useStyles2, PanelContainer, ScrollContainer } from '@grafana/ui';
import { ContentOutlineItemContextProps, useContentOutlineContext } from './ContentOutlineContext';
import { ContentOutlineItemButton } from './ContentOutlineItemButton';
function scrollableChildren(item: ContentOutlineItemContextProps) {
return item.children?.filter((child) => child.type !== 'filter') || [];
}
type SectionsExpanded = Record<string, boolean>;
function shouldBeActive(
item: ContentOutlineItemContextProps,
activeSectionId: string,
activeSectionChildId: string | undefined,
sectionsExpanded: SectionsExpanded
) {
const isAnActiveParent = activeSectionId === item.id;
const isAnActiveChild = activeSectionChildId === item.id;
const isCollapsed = !sectionsExpanded[item.id];
const containsScrollableChildren = scrollableChildren(item).length > 0;
const anyChildActive = isChildActive(item, activeSectionChildId) && !sectionsExpanded[item.id];
if (containsScrollableChildren) {
return isCollapsed && (isAnActiveParent || anyChildActive);
} else {
return isAnActiveParent || isAnActiveChild;
}
}
export const CONTENT_OUTLINE_LOCAL_STORAGE_KEYS = {
visible: 'grafana.explore.contentOutline.visible',
expanded: 'grafana.explore.contentOutline.expanded',
};
export function ContentOutline({ scroller, panelId }: { scroller: HTMLElement | undefined; panelId: string }) {
const [contentOutlineExpanded, toggleContentOutlineExpanded] = useToggle(
store.getBool(CONTENT_OUTLINE_LOCAL_STORAGE_KEYS.expanded, true)
);
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 outlineItemsHaveDeleteButton = outlineItems.some((item) => item.children?.some((child) => child.onRemove));
const [sectionsExpanded, setSectionsExpanded] = useState(() => {
return outlineItems.reduce((acc: { [key: string]: boolean }, item) => {
acc[item.id] = !!item.expanded;
return acc;
}, {});
});
const scrollIntoView = (ref: HTMLElement | null, customOffsetTop = 0) => {
let scrollValue = 0;
let el: HTMLElement | null | undefined = ref;
if (!el) {
return;
}
do {
scrollValue += el?.offsetTop || 0;
el = el?.offsetParent instanceof HTMLElement ? el.offsetParent : undefined;
} while (el && el !== scroller);
scroller?.scroll({
top: scrollValue + customOffsetTop,
behavior: 'smooth',
});
};
const handleItemClicked = (item: ContentOutlineItemContextProps) => {
if (item.level === 'child' && item.type === 'filter') {
const activeParent = outlineItems.find((parent) => {
return parent.children?.find((child) => child.id === item.id);
});
if (activeParent) {
scrollIntoView(activeParent.ref, activeParent.customTopOffset);
}
} else {
scrollIntoView(item.ref, item.customTopOffset);
reportInteraction('explore_toolbar_contentoutline_clicked', {
item: 'select_section',
type: item.panelId,
});
}
};
const toggle = () => {
store.set(CONTENT_OUTLINE_LOCAL_STORAGE_KEYS.expanded, !contentOutlineExpanded);
toggleContentOutlineExpanded();
reportInteraction('explore_toolbar_contentoutline_clicked', {
item: 'outline',
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(() => {
let activeItem;
for (const item of outlineItems) {
let top = item?.ref?.getBoundingClientRect().top;
// Check item
if (top && top >= 0) {
activeItem = item;
}
// Check children
const activeChild = scrollableChildren(item).find((child) => {
const offsetTop = child.customTopOffset || 0;
let childTop = child?.ref?.getBoundingClientRect().top;
return childTop && childTop >= offsetTop;
});
if (activeChild && isCollapsible(item)) {
setActiveSectionChildId(activeChild.id);
setActiveSectionId(item.id);
break;
}
if (activeItem) {
setActiveSectionId(activeItem.id);
setActiveSectionChildId(undefined);
break;
}
}
}, [outlineItems, verticalScroll]);
return (
<PanelContainer className={styles.wrapper} id={panelId}>
<ScrollContainer>
<div className={styles.content}>
<ContentOutlineItemButton
icon={'arrow-from-right'}
tooltip={contentOutlineExpanded ? 'Collapse outline' : 'Expand outline'}
tooltipPlacement={contentOutlineExpanded ? 'right' : 'bottom'}
onClick={toggle}
className={cx(styles.toggleContentOutlineButton, {
[styles.justifyCenter]: !contentOutlineExpanded && !outlineItemsShouldIndent,
})}
aria-expanded={contentOutlineExpanded}
/>
{outlineItems.map((item) => {
return (
<Fragment key={item.id}>
<ContentOutlineItemButton
key={item.id}
title={contentOutlineExpanded ? item.title : undefined}
contentOutlineExpanded={contentOutlineExpanded}
className={cx(styles.buttonStyles, {
[styles.justifyCenter]: !contentOutlineExpanded && !outlineItemsHaveDeleteButton,
[styles.sectionHighlighter]: isChildActive(item, activeSectionChildId) && !contentOutlineExpanded,
})}
indentStyle={cx({
[styles.indentRoot]: !isCollapsible(item) && outlineItemsShouldIndent,
[styles.sectionHighlighter]:
isChildActive(item, activeSectionChildId) && !contentOutlineExpanded && sectionsExpanded[item.id],
})}
icon={item.icon}
onClick={() => handleItemClicked(item)}
tooltip={item.title}
collapsible={isCollapsible(item)}
collapsed={!sectionsExpanded[item.id]}
toggleCollapsed={() => toggleSection(item.id)}
isActive={shouldBeActive(item, activeSectionId, activeSectionChildId, sectionsExpanded)}
sectionId={item.id}
color={item.color}
/>
<div id={item.id} data-testid={`section-wrapper-${item.id}`}>
{item.children &&
isCollapsible(item) &&
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 && !outlineItemsHaveDeleteButton,
[styles.sectionHighlighter]:
isChildActive(item, activeSectionChildId) && !contentOutlineExpanded,
})}
indentStyle={styles.indentChild}
onClick={(e) => {
handleItemClicked(child);
child.onClick?.(e);
}}
tooltip={child.title}
isActive={shouldBeActive(child, activeSectionId, activeSectionChildId, sectionsExpanded)}
extraHighlight={child.highlight}
color={child.color}
onRemove={child.onRemove ? () => child.onRemove?.(child.id) : undefined}
/>
</div>
))}
</div>
</Fragment>
);
})}
</div>
</ScrollContainer>
</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(3),
}),
indentChild: css({
paddingLeft: expanded ? theme.spacing(5) : theme.spacing(2.75),
}),
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: theme.spacing(4.75),
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);
}