data-trails: hightlight current node and its ancestry (#78660)

* feat: data-trails: show current node and ancestry
This commit is contained in:
Darren Janeczek 2023-11-28 13:16:22 -05:00 committed by GitHub
parent 01ad2918d6
commit a5377f85ce
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 171 additions and 46 deletions

View File

@ -40,6 +40,14 @@ describe('DataTrail', () => {
expect(trail.state.stepIndex).toBe(0); 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', () => { describe('And metric is selected', () => {
beforeEach(() => { beforeEach(() => {
trail.publishEvent(new MetricSelectedEvent('metric_bucket')); trail.publishEvent(new MetricSelectedEvent('metric_bucket'));
@ -61,9 +69,17 @@ describe('DataTrail', () => {
it('Should set stepIndex to 1', () => { it('Should set stepIndex to 1', () => {
expect(trail.state.stepIndex).toBe(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(() => { beforeEach(() => {
trail.publishEvent(new MetricSelectedEvent('first_metric')); trail.publishEvent(new MetricSelectedEvent('first_metric'));
trail.publishEvent(new MetricSelectedEvent('second_metric')); trail.publishEvent(new MetricSelectedEvent('second_metric'));
@ -79,13 +95,34 @@ describe('DataTrail', () => {
expect(trail.state.stepIndex).toBe(1); 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', () => { it('Should not create another history step', () => {
expect(trail.state.history.state.steps.length).toBe(3); expect(trail.state.history.state.steps.length).toBe(3);
}); });
it('But selecting a new metric should create another history step', () => { describe('But then selecting a new metric', () => {
trail.publishEvent(new MetricSelectedEvent('third_metric')); beforeEach(() => {
expect(trail.state.history.state.steps.length).toBe(4); 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);
});
}); });
}); });
}); });

View File

@ -46,6 +46,7 @@ export interface DataTrailState extends SceneObjectState {
// Indicates which step in the data trail this is // Indicates which step in the data trail this is
stepIndex: number; stepIndex: number;
parentIndex: number; // If there is no parent, this will be -1
} }
export class DataTrail extends SceneObjectBase<DataTrailState> { export class DataTrail extends SceneObjectBase<DataTrailState> {
@ -64,6 +65,7 @@ export class DataTrail extends SceneObjectBase<DataTrailState> {
history: state.history ?? new DataTrailHistory({}), history: state.history ?? new DataTrailHistory({}),
settings: state.settings ?? new DataTrailSettings({}), settings: state.settings ?? new DataTrailSettings({}),
stepIndex: state.stepIndex ?? 0, stepIndex: state.stepIndex ?? 0,
parentIndex: state.parentIndex ?? -1,
...state, ...state,
}); });

View File

@ -1,5 +1,5 @@
import { css, cx } from '@emotion/css'; import { css, cx } from '@emotion/css';
import React from 'react'; import React, { useMemo } from 'react';
import { GrafanaTheme2 } from '@grafana/data'; import { GrafanaTheme2 } from '@grafana/data';
import { import {
@ -18,6 +18,7 @@ import { VAR_FILTERS } from './shared';
import { getTrailFor } from './utils'; import { getTrailFor } from './utils';
export interface DataTrailsHistoryState extends SceneObjectState { export interface DataTrailsHistoryState extends SceneObjectState {
currentStep: number;
steps: DataTrailHistoryStep[]; steps: DataTrailHistoryStep[];
} }
@ -31,7 +32,7 @@ export type TrailStepType = 'filters' | 'time' | 'metric' | 'start';
export class DataTrailHistory extends SceneObjectBase<DataTrailsHistoryState> { export class DataTrailHistory extends SceneObjectBase<DataTrailsHistoryState> {
public constructor(state: Partial<DataTrailsHistoryState>) { public constructor(state: Partial<DataTrailsHistoryState>) {
super({ steps: state.steps ?? [] }); super({ steps: state.steps ?? [], currentStep: state.currentStep ?? 0 });
this.addActivationHandler(this._onActivate.bind(this)); this.addActivationHandler(this._onActivate.bind(this));
} }
@ -44,17 +45,26 @@ export class DataTrailHistory extends SceneObjectBase<DataTrailsHistoryState> {
} }
trail.subscribeToState((newState, oldState) => { 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 (newState.metric !== oldState.metric) {
if (this.state.steps.length === 1) { if (this.state.steps.length === 1) {
// For the first step we want to update the starting state so that it contains data // 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 }); this.state.steps[0].trailState = sceneUtils.cloneSceneObjectState(oldState, { history: this });
} }
// Check if new and old state are at the same step index if (newState.metric) {
// Then we know this isn't a history transition
const isMovingThroughHistory = newState.stepIndex !== oldState.stepIndex;
if (newState.metric && !isMovingThroughHistory) {
this.addTrailStep(trail, 'metric'); this.addTrailStep(trail, 'metric');
} }
} }
@ -74,11 +84,13 @@ export class DataTrailHistory extends SceneObjectBase<DataTrailsHistoryState> {
} }
public addTrailStep(trail: DataTrail, type: TrailStepType) { 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; const stepIndex = this.state.steps.length;
// Update the trail's current step state. It is being given a step index. const parentIndex = type === 'start' ? -1 : trail.state.stepIndex;
trail.setState({ ...trail.state, stepIndex }); trail.setState({ ...trail.state, stepIndex, parentIndex });
this.setState({ this.setState({
currentStep: stepIndex,
steps: [ steps: [
...this.state.steps, ...this.state.steps,
{ {
@ -100,17 +112,52 @@ export class DataTrailHistory extends SceneObjectBase<DataTrailsHistoryState> {
} }
public static Component = ({ model }: SceneComponentProps<DataTrailHistory>) => { public static Component = ({ model }: SceneComponentProps<DataTrailHistory>) => {
const { steps } = model.useState(); const { steps, currentStep } = model.useState();
const styles = useStyles2(getStyles); const styles = useStyles2(getStyles);
const trail = getTrailFor(model); const trail = getTrailFor(model);
const { ancestry, alternatePredecessorStyle } = useMemo(() => {
const ancestry = new Set<number>();
let cursor = currentStep;
while (cursor >= 0) {
ancestry.add(cursor);
cursor = steps[cursor].trailState.parentIndex;
}
const alternatePredecessorStyle = new Map<number, string>();
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 ( return (
<div className={styles.container}> <div className={styles.container}>
<div className={styles.heading}>Trail</div> <div className={styles.heading}>Trail</div>
{steps.map((step, index) => ( {steps.map((step, index) => (
<Tooltip content={() => model.renderStepTooltip(step)} key={index}> <Tooltip content={() => model.renderStepTooltip(step)} key={index}>
<button <button
className={cx(styles.step, styles.stepTypes[step.type])} className={cx(
// Base for all steps
styles.step,
// Specifics per step type
styles.stepTypes[step.type],
// To highlight selected step
model.state.currentStep === index ? styles.stepSelected : '',
// To alter the look of steps with distant non-directly preceding parent
alternatePredecessorStyle.get(index) ?? '',
// To remove direct link for steps that don't have a direct parent
index !== step.trailState.parentIndex + 1 ? styles.stepOmitsDirectLeftLink : '',
// To remove the direct parent link on the start node as well
index === 0 ? styles.stepOmitsDirectLeftLink : '',
// To darken steps that aren't the current step's ancesters
!ancestry.has(index) ? styles.stepIsNotAncestorOfCurrent : ''
)}
onClick={() => trail.goBackToStep(step)} onClick={() => trail.goBackToStep(step)}
></button> ></button>
</Tooltip> </Tooltip>
@ -144,48 +191,87 @@ function getStyles(theme: GrafanaTheme2) {
background: theme.colors.primary.main, background: theme.colors.primary.main,
position: 'relative', position: 'relative',
'&:hover': { '&: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: '""', content: '""',
position: 'absolute', position: 'absolute',
width: 10, width: 10,
height: 2, height: 2,
left: 8, left: -10,
top: 3, top: 3,
background: theme.colors.primary.border, background: theme.colors.primary.border,
pointerEvents: 'none',
}, },
'&:last-child': { }),
'&:after': { stepSelected: css({
display: 'none', '&: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: { stepTypes: {
start: css({ start: generateStepTypeStyle(visTheme.getColorByName('green')),
background: visTheme.getColorByName('green'), filters: generateStepTypeStyle(visTheme.getColorByName('purple')),
'&:after': { metric: generateStepTypeStyle(visTheme.getColorByName('orange')),
background: visTheme.getColorByName('green'), time: generateStepTypeStyle(theme.colors.primary.main),
},
}),
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,
},
}),
}, },
}; };
} }
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',
},
});
}