chore: disabled card state + connection lines ONLY when selected

This commit is contained in:
Alex Spencer
2025-12-02 11:10:38 -08:00
parent 8ffd614937
commit b489c4360a
3 changed files with 110 additions and 157 deletions
@@ -18,44 +18,32 @@ export function ConnectionLines({ connections }: ConnectionLinesProps) {
const [positions, setPositions] = useState<Map<string, DOMRect>>(new Map());
const containerRef = useRef<SVGSVGElement>(null);
// Update positions when cards change
useLayoutEffect(() => {
const updatePositions = () => {
const newPositions = new Map<string, DOMRect>();
// Find all cards by their data-card-id attribute
const cards = document.querySelectorAll('[data-card-id]');
cards.forEach((element) => {
const cardId = element.getAttribute('data-card-id');
if (cardId) {
const rect = element.getBoundingClientRect();
newPositions.set(cardId, rect);
newPositions.set(cardId, element.getBoundingClientRect());
}
});
setPositions(newPositions);
};
// Delay to ensure cards are rendered
const timeoutId = setTimeout(updatePositions, 100);
// Update on resize and scroll
window.addEventListener('resize', updatePositions);
const container = containerRef.current?.parentElement;
if (container) {
// Watch for scroll events
const scrollContainer = container.querySelector('[data-scrollcontainer]');
scrollContainer?.addEventListener('scroll', updatePositions);
// Watch for DOM changes
const observer = new MutationObserver(() => {
setTimeout(updatePositions, 50);
});
const observer = new MutationObserver(() => setTimeout(updatePositions, 50));
observer.observe(container, { childList: true, subtree: true });
// Watch for container resize (splitter changes)
const resizeObserver = new ResizeObserver(updatePositions);
resizeObserver.observe(container);
@@ -80,76 +68,42 @@ export function ConnectionLines({ connections }: ConnectionLinesProps) {
return <svg ref={containerRef} className={styles.svg} />;
}
// Group connections by expression (each "to" card gets its own lane)
const lanesByExpression = new Map<string, Set<string>>();
connections.forEach((conn) => {
if (!lanesByExpression.has(conn.to)) {
lanesByExpression.set(conn.to, new Set());
}
const lane = lanesByExpression.get(conn.to)!;
lane.add(conn.from); // Add the referenced query
lane.add(conn.to); // Add the expression itself
const cardIds = new Set<string>();
connections.forEach(({ from, to }) => {
cardIds.add(from);
cardIds.add(to);
});
// Convert to array of lanes
const activeLanes = Array.from(lanesByExpression.values());
const swimlaneX = containerRect.width - 32;
// Find the rightmost edge of all cards
let maxCardRight = 0;
positions.forEach((rect) => {
const cardRight = rect.right - containerRect.left;
if (cardRight > maxCardRight) {
maxCardRight = cardRight;
const cardPositions: number[] = [];
cardIds.forEach((cardId) => {
const cardRect = positions.get(cardId);
if (cardRect) {
cardPositions.push(cardRect.top + cardRect.height / 2 - containerRect.top);
}
});
const laneSpacing = 16; // Spacing between lanes (must match QueryTransformList.tsx)
const baseOffset = 24; // Offset from rightmost card (must match QueryTransformList.tsx)
if (cardPositions.length === 0) {
return <svg ref={containerRef} className={styles.svg} />;
}
const minY = Math.min(...cardPositions);
const maxY = Math.max(...cardPositions);
return (
<svg ref={containerRef} className={styles.svg}>
{activeLanes.map((lane, laneIndex) => {
// Calculate Y positions for cards in this lane
const laneYPositions: number[] = [];
lane.forEach((cardId) => {
<g className={styles.connectionGroup}>
<line x1={swimlaneX} y1={minY} x2={swimlaneX} y2={maxY} className={styles.swimlane} />
{Array.from(cardIds).map((cardId) => {
const cardRect = positions.get(cardId);
if (cardRect) {
laneYPositions.push(cardRect.top + cardRect.height / 2 - containerRect.top);
if (!cardRect) {
return null;
}
});
if (laneYPositions.length === 0) {
return null;
}
// Calculate swimlane X position for this lane (start from rightmost card edge)
const swimlaneX = maxCardRight + baseOffset + laneIndex * laneSpacing;
// Find min/max Y to draw the line only between connected points
const minY = Math.min(...laneYPositions);
const maxY = Math.max(...laneYPositions);
return (
<g key={laneIndex}>
{/* Vertical swimlane line */}
<line x1={swimlaneX} y1={minY} x2={swimlaneX} y2={maxY} className={styles.swimlane} />
{/* Connection points for cards in this lane */}
{Array.from(lane).map((cardId) => {
const cardRect = positions.get(cardId);
if (!cardRect) {
return null;
}
const pointY = cardRect.top + cardRect.height / 2 - containerRect.top;
return <circle key={cardId} cx={swimlaneX} cy={pointY} r={4} className={styles.point} />;
})}
</g>
);
})}
const pointY = cardRect.top + cardRect.height / 2 - containerRect.top;
return <circle key={cardId} cx={swimlaneX} cy={pointY} r={4} className={styles.point} />;
})}
</g>
</svg>
);
}
@@ -165,6 +119,19 @@ const getStyles = (theme: GrafanaTheme2) => ({
zIndex: 10,
overflow: 'visible',
}),
connectionGroup: css({
[theme.transitions.handleMotion('no-preference', 'reduce')]: {
animation: 'fadeIn 0.2s ease-in-out',
'@keyframes fadeIn': {
from: {
opacity: 0,
},
to: {
opacity: 1,
},
},
},
}),
swimlane: css({
stroke: theme.colors.text.maxContrast,
strokeWidth: 2,
@@ -1,4 +1,5 @@
import { css } from '@emotion/css';
import clsx from 'clsx';
import { memo, useMemo } from 'react';
import { DataTransformerConfig, GrafanaTheme2 } from '@grafana/data';
@@ -64,7 +65,7 @@ export const QueryTransformCard = memo(
<div
role="button"
tabIndex={0}
className={`${styles.card} ${isSelected ? styles.cardSelected : ''}`}
className={clsx(styles.card, { [styles.cardSelected]: isSelected, [styles.cardHidden]: isHidden })}
onClick={onClick}
onKeyDown={handleKeyDown}
data-testid={`${type}-card-${index}`}
@@ -84,48 +85,46 @@ export const QueryTransformCard = memo(
<Icon name={icon} className={styles.headerIcon} />
<span className={styles.typeLabel}>{typeLabel}</span>
</div>
<div className={`${styles.actions} ${styles.actionsClass}`}>
<Stack gap={0.5}>
{(type === 'query' || type === 'expression') && onToggleVisibility && (
<IconButton
name={isHidden ? 'eye-slash' : 'eye'}
size="sm"
variant="secondary"
tooltip={
isHidden
? t('dashboard-scene.query-transform-card.show-response', 'Show response')
: t('dashboard-scene.query-transform-card.hide-response', 'Hide response')
}
onClick={(e) => handleAction(e, onToggleVisibility)}
className={styles.actionButton}
/>
)}
{(type === 'query' || type === 'expression') && onDuplicate && (
<IconButton
name="copy"
size="sm"
variant="secondary"
tooltip={t('dashboard-scene.query-transform-card.duplicate', 'Duplicate query')}
onClick={(e) => handleAction(e, onDuplicate)}
className={styles.actionButton}
/>
)}
{onRemove && (
<IconButton
name="trash-alt"
size="sm"
variant="secondary"
tooltip={
type === 'query'
? t('dashboard-scene.query-transform-card.remove-query', 'Remove query')
: t('dashboard-scene.query-transform-card.remove-transform', 'Remove transformation')
}
onClick={(e) => handleAction(e, onRemove)}
className={styles.actionButton}
/>
)}
</Stack>
</div>
<Stack gap={0.5}>
{(type === 'query' || type === 'expression') && onToggleVisibility && (
<IconButton
name={isHidden ? 'eye-slash' : 'eye'}
size="sm"
variant="secondary"
tooltip={
isHidden
? t('dashboard-scene.query-transform-card.show-response', 'Show response')
: t('dashboard-scene.query-transform-card.hide-response', 'Hide response')
}
onClick={(e) => handleAction(e, onToggleVisibility)}
className={styles.actionButton}
/>
)}
{(type === 'query' || type === 'expression') && onDuplicate && (
<IconButton
name="copy"
size="sm"
variant="secondary"
tooltip={t('dashboard-scene.query-transform-card.duplicate', 'Duplicate query')}
onClick={(e) => handleAction(e, onDuplicate)}
className={styles.actionButton}
/>
)}
{onRemove && (
<IconButton
name="trash-alt"
size="sm"
variant="secondary"
tooltip={
type === 'query'
? t('dashboard-scene.query-transform-card.remove-query', 'Remove query')
: t('dashboard-scene.query-transform-card.remove-transform', 'Remove transformation')
}
onClick={(e) => handleAction(e, onRemove)}
className={styles.actionButton}
/>
)}
</Stack>
</div>
{/* Content: Name */}
@@ -145,8 +144,8 @@ export const QueryTransformCard = memo(
QueryTransformCard.displayName = 'QueryTransformCard';
const getStyles = (theme: GrafanaTheme2) => {
const actionsClass = 'actions-container';
const selectedClass = 'card-selected';
const hiddenClass = 'card-hidden';
return {
card: css({
@@ -157,9 +156,6 @@ const getStyles = (theme: GrafanaTheme2) => {
overflow: 'hidden',
background: theme.colors.background.primary,
width: '100%',
[`&:hover .${actionsClass}`]: {
opacity: 1,
},
'&:hover': {
borderColor: theme.colors.border.strong,
},
@@ -171,8 +167,12 @@ const getStyles = (theme: GrafanaTheme2) => {
borderColor: theme.colors.primary.border,
boxShadow: `0 0 0 1px ${theme.colors.primary.border}`,
},
[`&.${hiddenClass}`]: {
opacity: 0.5,
},
}),
cardSelected: selectedClass,
cardHidden: hiddenClass,
headerQuery: css({
display: 'flex',
alignItems: 'center',
@@ -220,11 +220,6 @@ const getStyles = (theme: GrafanaTheme2) => {
textTransform: 'uppercase',
letterSpacing: '0.05em',
}),
actions: css({
opacity: 0,
flexShrink: 0,
}),
actionsClass,
actionButton: css({
'&:hover': {
background: theme.colors.action.hover,
@@ -51,8 +51,7 @@ export const QueryTransformList = memo(
}: QueryTransformListProps) => {
const styles = useStyles2(getStyles);
// Detect connections between items
const connections = useMemo(() => {
const allConnections = useMemo(() => {
const conns: Array<{ from: string; to: string }> = [];
items.forEach((item) => {
@@ -64,19 +63,15 @@ export const QueryTransformList = memo(
const expressionString = expr.expression;
if (expressionType === 'math') {
// Math expressions: parse $A, $B, etc.
const matches = expressionString.matchAll(/\$(\w+)/g);
for (const match of matches) {
const refId = match[1];
conns.push({ from: refId, to: expr.refId });
conns.push({ from: match[1], to: expr.refId });
}
} else if (expressionType === 'reduce' || expressionType === 'resample' || expressionType === 'threshold') {
// Reduce/Resample/Threshold: expression field is a single refId
if (expressionString) {
conns.push({ from: expressionString, to: expr.refId });
}
}
// TODO: Handle 'sql' and 'classic_conditions' types if needed
}
}
});
@@ -84,6 +79,24 @@ export const QueryTransformList = memo(
return conns;
}, [items]);
// Filter connections to only show for selected card
const visibleConnections = useMemo(() => {
if (!selectedId) {
return [];
}
// Find the item to get its refId
const activeItem = items.find((item) => item.id === selectedId);
if (!activeItem || !('refId' in activeItem.data)) {
return [];
}
const activeRefId = activeItem.data.refId;
// Show connections where this card is involved (either as source or destination)
return allConnections.filter((conn) => conn.from === activeRefId || conn.to === activeRefId);
}, [allConnections, selectedId, items]);
const getHandlers = (item: QueryTransformItem) => {
switch (item.type) {
case 'query':
@@ -109,22 +122,11 @@ export const QueryTransformList = memo(
}
};
// Calculate number of lanes for dynamic padding
const lanesByExpression = useMemo(() => {
const lanes = new Map<string, Set<string>>();
connections.forEach((conn) => {
if (!lanes.has(conn.to)) {
lanes.set(conn.to, new Set());
}
});
return lanes.size;
}, [connections]);
return (
<div className={styles.container}>
<ConnectionLines connections={connections} />
<ConnectionLines connections={visibleConnections} />
<ScrollContainer data-scrollcontainer>
<div className={lanesByExpression > 0 ? styles.contentWithConnections(lanesByExpression) : styles.content}>
<div className={styles.content}>
<Stack direction="column" gap={2}>
{items.map((item) => (
<QueryTransformCard
@@ -154,10 +156,6 @@ export const QueryTransformList = memo(
QueryTransformList.displayName = 'QueryTransformList';
const getStyles = (theme: GrafanaTheme2) => {
const laneSpacing = 16; // Must match ConnectionLines.tsx
const baseOffset = 24; // Must match ConnectionLines.tsx
const extraPadding = 8; // Extra breathing room beyond the last lane
return {
container: css({
position: 'relative',
@@ -170,15 +168,8 @@ const getStyles = (theme: GrafanaTheme2) => {
content: css({
position: 'relative',
padding: theme.spacing(2),
paddingRight: theme.spacing(6), // Extra space for the connection line
zIndex: 1,
}),
contentWithConnections: (numLanes: number) =>
css({
position: 'relative',
padding: theme.spacing(2),
// Calculate exact space needed: base offset + (lanes * spacing) + extra padding
paddingRight: baseOffset + numLanes * laneSpacing + extraPadding,
zIndex: 1,
}),
};
};