chore: disabled card state + connection lines ONLY when selected
This commit is contained in:
@@ -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,
|
||||
|
||||
+47
-52
@@ -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,
|
||||
|
||||
+23
-32
@@ -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,
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user