grafana/public/app/features/trails/DataTrailsHistory.tsx
2024-05-02 09:55:58 -04:00

333 lines
9.8 KiB
TypeScript

import { css, cx } from '@emotion/css';
import React, { useMemo } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import {
SceneObjectState,
SceneObjectBase,
SceneComponentProps,
SceneVariableValueChangedEvent,
SceneObjectStateChangedEvent,
SceneTimeRange,
sceneUtils,
} from '@grafana/scenes';
import { useStyles2, Tooltip, Stack } from '@grafana/ui';
import { DataTrail, DataTrailState, getTopSceneFor } from './DataTrail';
import { reportExploreMetrics } from './interactions';
import { VAR_FILTERS } from './shared';
import { getTrailFor, isSceneTimeRangeState } from './utils';
export interface DataTrailsHistoryState extends SceneObjectState {
currentStep: number;
steps: DataTrailHistoryStep[];
}
export function isDataTrailsHistoryState(state: SceneObjectState): state is DataTrailsHistoryState {
return 'currentStep' in state && 'steps' in state;
}
export interface DataTrailHistoryStep {
description: string;
type: TrailStepType;
trailState: DataTrailState;
parentIndex: number;
}
export type TrailStepType = 'filters' | 'time' | 'metric' | 'start';
export class DataTrailHistory extends SceneObjectBase<DataTrailsHistoryState> {
public constructor(state: Partial<DataTrailsHistoryState>) {
super({ steps: state.steps ?? [], currentStep: state.currentStep ?? 0 });
this.addActivationHandler(this._onActivate.bind(this));
}
private stepTransitionInProgress = false;
public _onActivate() {
const trail = getTrailFor(this);
if (this.state.steps.length === 0) {
// We always want to ensure in initial 'start' step
this.addTrailStep(trail, 'start');
if (trail.state.metric) {
// But if our current trail has a metric, we want to remove it and the topScene,
// so that the "start" step always displays a metric select screen.
// So we remove the metric and update the topscene for the "start" step
const { metric, ...startState } = trail.state;
startState.topScene = getTopSceneFor(undefined);
this.state.steps[0].trailState = startState;
// But must add a secondary step to represent the selection of the metric
// for this restored trail state
this.addTrailStep(trail, 'metric');
}
}
trail.subscribeToState((newState, oldState) => {
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 });
}
if (newState.metric || oldState.metric) {
this.addTrailStep(trail, 'metric');
}
}
});
trail.subscribeToEvent(SceneVariableValueChangedEvent, (evt) => {
if (evt.payload.state.name === VAR_FILTERS) {
this.addTrailStep(trail, 'filters');
}
});
trail.subscribeToEvent(SceneObjectStateChangedEvent, (evt) => {
if (evt.payload.changedObject instanceof SceneTimeRange) {
const { prevState, newState } = evt.payload;
if (isSceneTimeRangeState(prevState) && isSceneTimeRangeState(newState)) {
if (prevState.from === newState.from && prevState.to === newState.to) {
return;
}
}
this.addTrailStep(trail, 'time');
}
});
}
public addTrailStep(trail: DataTrail, type: TrailStepType) {
if (this.stepTransitionInProgress) {
// Do not add trail steps when step transition is in progress
return;
}
const stepIndex = this.state.steps.length;
const parentIndex = type === 'start' ? -1 : this.state.currentStep;
this.setState({
currentStep: stepIndex,
steps: [
...this.state.steps,
{
description: 'Test',
type,
trailState: sceneUtils.cloneSceneObjectState(trail.state, { history: this }),
parentIndex,
},
],
});
}
public goBackToStep(stepIndex: number) {
if (stepIndex === this.state.currentStep) {
return;
}
const step = this.state.steps[stepIndex];
const type = step.type === 'metric' && step.trailState.metric === undefined ? 'metric-clear' : step.type;
reportExploreMetrics('history_step_clicked', { type, step: stepIndex, numberOfSteps: this.state.steps.length });
this.stepTransitionInProgress = true;
this.setState({ currentStep: stepIndex });
getTrailFor(this).restoreFromHistoryStep(step.trailState);
// The URL will update
this.stepTransitionInProgress = false;
}
renderStepTooltip(step: DataTrailHistoryStep) {
return (
<Stack direction="column">
<div>{step.type}</div>
{step.type === 'metric' && <div>{step.trailState.metric || 'Select new metric'}</div>}
</Stack>
);
}
public static Component = ({ model }: SceneComponentProps<DataTrailHistory>) => {
const { steps, currentStep } = model.useState();
const styles = useStyles2(getStyles);
const { ancestry, alternatePredecessorStyle } = useMemo(() => {
const ancestry = new Set<number>();
let cursor = currentStep;
while (cursor >= 0) {
const step = steps[cursor];
if (!step) {
break;
}
ancestry.add(cursor);
cursor = step.parentIndex;
}
const alternatePredecessorStyle = new Map<number, string>();
ancestry.forEach((index) => {
const parent = steps[index].parentIndex;
if (parent + 1 !== index) {
alternatePredecessorStyle.set(index, createAlternatePredecessorStyle(index, parent));
}
});
return { ancestry, alternatePredecessorStyle };
}, [currentStep, steps]);
return (
<div className={styles.container}>
<div className={styles.heading}>History</div>
{steps.map((step, index) => {
let stepType = step.type;
if (stepType === 'metric' && step.trailState.metric === undefined) {
// If we're resetting the metric, we want it to look like a start node
stepType = 'start';
}
return (
<Tooltip content={() => model.renderStepTooltip(step)} key={index}>
<button
className={cx(
// Base for all steps
styles.step,
// Specifics per step type
styles.stepTypes[stepType],
// 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.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={() => model.goBackToStep(index)}
></button>
</Tooltip>
);
})}
</div>
);
};
}
function getStyles(theme: GrafanaTheme2) {
const visTheme = theme.visualization;
return {
container: css({
display: 'flex',
gap: 10,
alignItems: 'center',
}),
heading: css({}),
step: css({
flexGrow: 0,
cursor: 'pointer',
border: 'none',
boxShadow: 'none',
padding: 0,
margin: 0,
width: 8,
height: 8,
opacity: 0.7,
borderRadius: theme.shape.radius.circle,
background: theme.colors.primary.main,
position: 'relative',
'&:hover': {
opacity: 1,
},
'&: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: -10,
top: 3,
background: theme.colors.primary.border,
pointerEvents: '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: 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',
borderTopLeftRadius: 8,
borderTopRightRadius: 8,
top: -10,
left: 3 - distanceToParent,
background: 'none',
},
});
}