import { css, cx } from '@emotion/css'; import { 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 { public constructor(state: Partial) { 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 (
{step.type}
{step.type === 'metric' &&
{step.trailState.metric || 'Select new metric'}
}
); } public static Component = ({ model }: SceneComponentProps) => { const { steps, currentStep } = model.useState(); const styles = useStyles2(getStyles); const { ancestry, alternatePredecessorStyle } = useMemo(() => { const ancestry = new Set(); let cursor = currentStep; while (cursor >= 0) { const step = steps[cursor]; if (!step) { break; } ancestry.add(cursor); cursor = step.parentIndex; } const alternatePredecessorStyle = new Map(); ancestry.forEach((index) => { const parent = steps[index].parentIndex; if (parent + 1 !== index) { alternatePredecessorStyle.set(index, createAlternatePredecessorStyle(index, parent)); } }); return { ancestry, alternatePredecessorStyle }; }, [currentStep, steps]); return (
History
{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 ( model.renderStepTooltip(step)} key={index}> ); })}
); }; } 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', }, }); }