From a5377f85ce8ffce04c1135db96df5f8f4df20e40 Mon Sep 17 00:00:00 2001 From: Darren Janeczek <38694490+darrenjaneczek@users.noreply.github.com> Date: Tue, 28 Nov 2023 13:16:22 -0500 Subject: [PATCH] data-trails: hightlight current node and its ancestry (#78660) * feat: data-trails: show current node and ancestry --- public/app/features/trails/DataTrail.test.tsx | 45 ++++- public/app/features/trails/DataTrail.tsx | 2 + .../app/features/trails/DataTrailsHistory.tsx | 170 +++++++++++++----- 3 files changed, 171 insertions(+), 46 deletions(-) diff --git a/public/app/features/trails/DataTrail.test.tsx b/public/app/features/trails/DataTrail.test.tsx index a8c9c9af5ad..5bb6ae2dc13 100644 --- a/public/app/features/trails/DataTrail.test.tsx +++ b/public/app/features/trails/DataTrail.test.tsx @@ -40,6 +40,14 @@ describe('DataTrail', () => { expect(trail.state.stepIndex).toBe(0); }); + it('Should set parentIndex to -1', () => { + expect(trail.state.parentIndex).toBe(-1); + }); + + it('Should set history currentStep to 0', () => { + expect(trail.state.history.state.currentStep).toBe(0); + }); + describe('And metric is selected', () => { beforeEach(() => { trail.publishEvent(new MetricSelectedEvent('metric_bucket')); @@ -61,9 +69,17 @@ describe('DataTrail', () => { it('Should set stepIndex to 1', () => { expect(trail.state.stepIndex).toBe(1); }); + + it('Should set history currentStep to 1', () => { + expect(trail.state.history.state.currentStep).toBe(1); + }); + + it('Should set parentIndex to 0', () => { + expect(trail.state.parentIndex).toBe(0); + }); }); - describe('When going back to history step', () => { + describe('When going back to history step 1', () => { beforeEach(() => { trail.publishEvent(new MetricSelectedEvent('first_metric')); trail.publishEvent(new MetricSelectedEvent('second_metric')); @@ -79,13 +95,34 @@ describe('DataTrail', () => { expect(trail.state.stepIndex).toBe(1); }); + it('Should set history currentStep to 1', () => { + expect(trail.state.history.state.currentStep).toBe(1); + }); + it('Should not create another history step', () => { expect(trail.state.history.state.steps.length).toBe(3); }); - it('But selecting a new metric should create another history step', () => { - trail.publishEvent(new MetricSelectedEvent('third_metric')); - expect(trail.state.history.state.steps.length).toBe(4); + describe('But then selecting a new metric', () => { + beforeEach(() => { + trail.publishEvent(new MetricSelectedEvent('third_metric')); + }); + + it('Should create another history step', () => { + expect(trail.state.history.state.steps.length).toBe(4); + }); + + it('Should set stepIndex to 3', () => { + expect(trail.state.stepIndex).toBe(3); + }); + + it('Should set history currentStep to 1', () => { + expect(trail.state.history.state.currentStep).toBe(3); + }); + + it('Should set parentIndex to 1', () => { + expect(trail.state.parentIndex).toBe(1); + }); }); }); }); diff --git a/public/app/features/trails/DataTrail.tsx b/public/app/features/trails/DataTrail.tsx index 2e87bad909d..367cf34c1b5 100644 --- a/public/app/features/trails/DataTrail.tsx +++ b/public/app/features/trails/DataTrail.tsx @@ -46,6 +46,7 @@ export interface DataTrailState extends SceneObjectState { // Indicates which step in the data trail this is stepIndex: number; + parentIndex: number; // If there is no parent, this will be -1 } export class DataTrail extends SceneObjectBase { @@ -64,6 +65,7 @@ export class DataTrail extends SceneObjectBase { history: state.history ?? new DataTrailHistory({}), settings: state.settings ?? new DataTrailSettings({}), stepIndex: state.stepIndex ?? 0, + parentIndex: state.parentIndex ?? -1, ...state, }); diff --git a/public/app/features/trails/DataTrailsHistory.tsx b/public/app/features/trails/DataTrailsHistory.tsx index 7b427137fc4..e37cab2f545 100644 --- a/public/app/features/trails/DataTrailsHistory.tsx +++ b/public/app/features/trails/DataTrailsHistory.tsx @@ -1,5 +1,5 @@ import { css, cx } from '@emotion/css'; -import React from 'react'; +import React, { useMemo } from 'react'; import { GrafanaTheme2 } from '@grafana/data'; import { @@ -18,6 +18,7 @@ import { VAR_FILTERS } from './shared'; import { getTrailFor } from './utils'; export interface DataTrailsHistoryState extends SceneObjectState { + currentStep: number; steps: DataTrailHistoryStep[]; } @@ -31,7 +32,7 @@ export type TrailStepType = 'filters' | 'time' | 'metric' | 'start'; export class DataTrailHistory extends SceneObjectBase { public constructor(state: Partial) { - super({ steps: state.steps ?? [] }); + super({ steps: state.steps ?? [], currentStep: state.currentStep ?? 0 }); this.addActivationHandler(this._onActivate.bind(this)); } @@ -44,17 +45,26 @@ export class DataTrailHistory extends SceneObjectBase { } trail.subscribeToState((newState, oldState) => { + // Check if new and old state are at the same step index + // Then we know this is a history transition + const isMovingThroughHistory = newState.stepIndex !== oldState.stepIndex; + + if (isMovingThroughHistory) { + this.setState({ + ...this.state, + currentStep: newState.stepIndex, + }); + + return; + } + if (newState.metric !== oldState.metric) { if (this.state.steps.length === 1) { // For the first step we want to update the starting state so that it contains data this.state.steps[0].trailState = sceneUtils.cloneSceneObjectState(oldState, { history: this }); } - // Check if new and old state are at the same step index - // Then we know this isn't a history transition - const isMovingThroughHistory = newState.stepIndex !== oldState.stepIndex; - - if (newState.metric && !isMovingThroughHistory) { + if (newState.metric) { this.addTrailStep(trail, 'metric'); } } @@ -74,11 +84,13 @@ export class DataTrailHistory extends SceneObjectBase { } public addTrailStep(trail: DataTrail, type: TrailStepType) { + // Update the trail's new current step state, and note its parent step. const stepIndex = this.state.steps.length; - // Update the trail's current step state. It is being given a step index. - trail.setState({ ...trail.state, stepIndex }); + const parentIndex = type === 'start' ? -1 : trail.state.stepIndex; + trail.setState({ ...trail.state, stepIndex, parentIndex }); this.setState({ + currentStep: stepIndex, steps: [ ...this.state.steps, { @@ -100,17 +112,52 @@ export class DataTrailHistory extends SceneObjectBase { } public static Component = ({ model }: SceneComponentProps) => { - const { steps } = model.useState(); + const { steps, currentStep } = model.useState(); const styles = useStyles2(getStyles); const trail = getTrailFor(model); + const { ancestry, alternatePredecessorStyle } = useMemo(() => { + const ancestry = new Set(); + let cursor = currentStep; + while (cursor >= 0) { + ancestry.add(cursor); + cursor = steps[cursor].trailState.parentIndex; + } + + const alternatePredecessorStyle = new Map(); + + ancestry.forEach((index) => { + const parent = steps[index].trailState.parentIndex; + if (parent + 1 !== index) { + alternatePredecessorStyle.set(index, createAlternatePredecessorStyle(index, parent)); + } + }); + + return { ancestry, alternatePredecessorStyle }; + }, [currentStep, steps]); + return (
Trail
{steps.map((step, index) => ( model.renderStepTooltip(step)} key={index}> @@ -144,48 +191,87 @@ function getStyles(theme: GrafanaTheme2) { background: theme.colors.primary.main, position: 'relative', '&:hover': { - transform: 'scale(1.1)', + opacity: 1, }, - '&:after': { + '&:hover:before': { + // We only want the node to hover, not its connection to its parent + opacity: 0.7, + }, + '&:before': { content: '""', position: 'absolute', width: 10, height: 2, - left: 8, + left: -10, top: 3, background: theme.colors.primary.border, + pointerEvents: 'none', }, - '&:last-child': { - '&:after': { - display: 'none', - }, + }), + stepSelected: css({ + '&:after': { + content: '""', + borderStyle: `solid`, + borderWidth: 2, + borderRadius: '50%', + position: 'absolute', + width: 16, + height: 16, + left: -4, + top: -4, + boxShadow: `0px 0px 0px 2px inset ${theme.colors.background.canvas}`, + }, + }), + stepOmitsDirectLeftLink: css({ + '&:before': { + background: 'none', + }, + }), + stepIsNotAncestorOfCurrent: css({ + opacity: 0.2, + '&:hover:before': { + opacity: 0.2, }, }), stepTypes: { - start: css({ - background: visTheme.getColorByName('green'), - '&:after': { - background: visTheme.getColorByName('green'), - }, - }), - filters: css({ - background: visTheme.getColorByName('purple'), - '&:after': { - background: visTheme.getColorByName('purple'), - }, - }), - metric: css({ - background: visTheme.getColorByName('orange'), - '&:after': { - background: visTheme.getColorByName('orange'), - }, - }), - time: css({ - background: theme.colors.primary.main, - '&:after': { - background: theme.colors.primary.main, - }, - }), + start: generateStepTypeStyle(visTheme.getColorByName('green')), + filters: generateStepTypeStyle(visTheme.getColorByName('purple')), + metric: generateStepTypeStyle(visTheme.getColorByName('orange')), + time: generateStepTypeStyle(theme.colors.primary.main), }, }; } + +function generateStepTypeStyle(color: string) { + return css({ + background: color, + '&:before': { + background: color, + borderColor: color, + }, + '&:after': { + borderColor: color, + }, + }); +} + +function createAlternatePredecessorStyle(index: number, parent: number) { + const difference = index - parent; + + const NODE_DISTANCE = 18; + const distanceToParent = difference * NODE_DISTANCE; + + return css({ + '&:before': { + content: '""', + width: distanceToParent + 2, + height: 10, + borderStyle: 'solid', + borderWidth: 2, + borderBottom: 'none', + top: -10, + left: 3 - distanceToParent, + background: 'none', + }, + }); +}