Dashboards: Add conditional rendering (#100330)

* Dashboards: Add conditional rendering

* Updates

* Fixes

* Code improvements

* Code improvements

* limit condition choices, add delete and clean up ui

* add basic variable condition

* add conditional rendering based on time range interval

* adjust failing test

* remove deprecated pseudo locale file

* extract conditional rendering from behaviour to state property

* clean up behaviour initialisation

* clean up ts errors

* adjust data condition to account for RowItem

* Fix subscribes

* notify change when deleting condition

* fix hidden row item error

* address comments

* subscribe to panel data change in data condition

* Remove loop labels

---------

Co-authored-by: Sergej-Vlasov <sergej.s.vlasov@gmail.com>
Co-authored-by: oscarkilhed <oscar.kilhed@grafana.com>
pull/102054/head^2
Bogdan Matei 4 months ago committed by GitHub
parent 2dca2503b9
commit 0f45f2696e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 12
      packages/grafana-ui/src/components/ElementSelectionContext/ElementSelectionContext.tsx
  2. 1
      packages/grafana-ui/src/components/index.ts
  3. 8
      packages/grafana-ui/src/themes/GlobalStyles/dashboardGrid.ts
  4. 20
      public/app/features/dashboard-scene/conditional-rendering/ConditionHeader.tsx
  5. 45
      public/app/features/dashboard-scene/conditional-rendering/ConditionalRendering.tsx
  6. 61
      public/app/features/dashboard-scene/conditional-rendering/ConditionalRenderingBase.tsx
  7. 139
      public/app/features/dashboard-scene/conditional-rendering/ConditionalRenderingData.tsx
  8. 24
      public/app/features/dashboard-scene/conditional-rendering/ConditionalRenderingEditor.tsx
  9. 159
      public/app/features/dashboard-scene/conditional-rendering/ConditionalRenderingGroup.tsx
  10. 83
      public/app/features/dashboard-scene/conditional-rendering/ConditionalRenderingInterval.tsx
  11. 126
      public/app/features/dashboard-scene/conditional-rendering/ConditionalRenderingVariable.tsx
  12. 25
      public/app/features/dashboard-scene/conditional-rendering/shared.ts
  13. 1
      public/app/features/dashboard-scene/edit-pane/DashboardEditPane.tsx
  14. 4
      public/app/features/dashboard-scene/scene/layout-default/DefaultGridLayoutManager.tsx
  15. 6
      public/app/features/dashboard-scene/scene/layout-default/row-actions/RowActionsRenderer.tsx
  16. 12
      public/app/features/dashboard-scene/scene/layout-responsive-grid/ResponsiveGridItem.tsx
  17. 51
      public/app/features/dashboard-scene/scene/layout-responsive-grid/ResponsiveGridItemEditor.tsx
  18. 16
      public/app/features/dashboard-scene/scene/layout-responsive-grid/ResponsiveGridItemRenderer.tsx
  19. 17
      public/app/features/dashboard-scene/scene/layout-rows/RowItem.tsx
  20. 23
      public/app/features/dashboard-scene/scene/layout-rows/RowItemEditor.tsx
  21. 47
      public/app/features/dashboard-scene/scene/layout-rows/RowItemRenderer.tsx
  22. 11
      public/app/features/dashboard-scene/utils/clone.ts
  23. 45
      public/app/features/dashboard-scene/utils/utils.ts
  24. 50
      public/locales/en-US/grafana.json

@ -9,6 +9,7 @@ export interface ElementSelectionContextState {
/** List of currently selected elements */
selected: ElementSelectionContextItem[];
onSelect: (item: ElementSelectionContextItem, multi?: boolean) => void;
onClear: () => void;
}
export interface ElementSelectionContextItem {
@ -21,6 +22,7 @@ export interface UseElementSelectionResult {
isSelected?: boolean;
isSelectable?: boolean;
onSelect?: (evt: React.PointerEvent) => void;
onClear?: () => void;
}
export function useElementSelection(id: string | undefined): UseElementSelectionResult {
@ -48,5 +50,13 @@ export function useElementSelection(id: string | undefined): UseElementSelection
[context, id]
);
return { isSelected, onSelect, isSelectable: context.enabled };
const onClear = useCallback(() => {
if (!context.enabled) {
return;
}
context.onClear();
}, [context]);
return { isSelected, onSelect, onClear, isSelectable: context.enabled };
}

@ -336,4 +336,5 @@ export {
useElementSelection,
type ElementSelectionContextState,
type ElementSelectionContextItem,
type UseElementSelectionResult,
} from './ElementSelectionContext/ElementSelectionContext';

@ -82,5 +82,13 @@ export function getDashboardGridStyles(theme: GrafanaTheme2) {
backgroundColor: theme.colors.emphasize(theme.colors.background.canvas, 0.08),
},
},
'.dashboard-visible-hidden-element': {
opacity: 0.6,
'&:hover': {
opacity: 1,
},
},
});
}

@ -0,0 +1,20 @@
import { IconButton, Stack, Text } from '@grafana/ui';
import { t } from 'app/core/internationalization';
type Props = {
title: string;
onDelete: () => void;
};
export const ConditionHeader = ({ title, onDelete }: Props) => {
return (
<Stack direction="row" justifyContent="space-between">
<Text variant="h6">{title}</Text>
<IconButton
aria-label={t('dashboard.conditional-rendering.shared.delete-condition', 'Delete Condition')}
name="trash-alt"
onClick={() => onDelete()}
/>
</Stack>
);
};

@ -0,0 +1,45 @@
import { SceneComponentProps, SceneObjectBase, SceneObjectState } from '@grafana/scenes';
import { ConditionalRenderingGroup } from './ConditionalRenderingGroup';
export interface ConditionalRenderingState extends SceneObjectState {
rootGroup: ConditionalRenderingGroup;
}
export class ConditionalRendering extends SceneObjectBase<ConditionalRenderingState> {
public static Component = ConditionalRenderingRenderer;
public constructor(state: ConditionalRenderingState) {
super(state);
this.addActivationHandler(() => this._activationHandler());
}
private _activationHandler() {
// This ensures that all children are activated when conditional rendering is activated
// We need this in order to allow children to subscribe to variable changes etc.
this.forEachChild((child) => {
if (!child.isActive) {
this._subs.add(child.activate());
}
});
}
public evaluate(): boolean {
return this.state.rootGroup.evaluate();
}
public notifyChange() {
this.parent?.forceRender();
}
public static createEmpty(): ConditionalRendering {
return new ConditionalRendering({ rootGroup: ConditionalRenderingGroup.createEmpty() });
}
}
function ConditionalRenderingRenderer({ model }: SceneComponentProps<ConditionalRendering>) {
const { rootGroup } = model.useState();
return <rootGroup.Component model={rootGroup} />;
}

@ -0,0 +1,61 @@
import { ReactNode } from 'react';
import { SceneComponentProps, sceneGraph, SceneObjectBase, SceneObjectState } from '@grafana/scenes';
import { ConditionalRendering } from './ConditionalRendering';
import { ConditionalRenderingGroup } from './ConditionalRenderingGroup';
import { ConditionValues } from './shared';
export interface ConditionalRenderingBaseState<V = ConditionValues> extends SceneObjectState {
value: V;
}
export abstract class ConditionalRenderingBase<
S extends ConditionalRenderingBaseState<ConditionValues>,
> extends SceneObjectBase<S> {
public static Component = ConditionalRenderingBaseRenderer;
public constructor(state: S) {
super(state);
this.addActivationHandler(() => this._baseActivationHandler());
}
private _baseActivationHandler() {
// Similarly to the ConditionalRendering activation handler,
// this ensures that all children are activated when conditional rendering is activated
// We need this in order to allow children to subscribe to variable changes etc.
this.forEachChild((child) => {
if (!child.isActive) {
this._subs.add(child.activate());
}
});
}
public abstract readonly title: string;
public abstract evaluate(): boolean;
public abstract render(): ReactNode;
public abstract onDelete(): void;
public getConditionalLogicRoot(): ConditionalRendering {
return sceneGraph.getAncestor(this, ConditionalRendering);
}
public getRootGroup(): ConditionalRenderingGroup {
return this.getConditionalLogicRoot().state.rootGroup;
}
public setStateAndNotify(state: Partial<S>) {
this.setState(state);
this.getConditionalLogicRoot().notifyChange();
}
}
function ConditionalRenderingBaseRenderer({
model,
}: SceneComponentProps<ConditionalRenderingBase<ConditionalRenderingBaseState>>) {
return model.render();
}

@ -0,0 +1,139 @@
import { ReactNode, useMemo } from 'react';
import { PanelData, SelectableValue } from '@grafana/data';
import { SceneComponentProps, SceneDataProvider, sceneGraph } from '@grafana/scenes';
import { RadioButtonGroup, Stack } from '@grafana/ui';
import { t } from 'app/core/internationalization';
import { ResponsiveGridItem } from '../scene/layout-responsive-grid/ResponsiveGridItem';
import { RowItem } from '../scene/layout-rows/RowItem';
import { ConditionHeader } from './ConditionHeader';
import { ConditionalRenderingBase, ConditionalRenderingBaseState } from './ConditionalRenderingBase';
import { handleDeleteNonGroupCondition } from './shared';
export type DataConditionValue = boolean;
type ConditionalRenderingDataState = ConditionalRenderingBaseState<DataConditionValue>;
export class ConditionalRenderingData extends ConditionalRenderingBase<ConditionalRenderingDataState> {
public get title(): string {
return t('dashboard.conditional-rendering.data.label', 'Data');
}
public constructor(state: ConditionalRenderingDataState) {
super(state);
this.addActivationHandler(() => this._activationHandler());
}
private _activationHandler() {
let panelDataProviders: SceneDataProvider[] = [];
const item = this.getConditionalLogicRoot().parent;
if (item instanceof ResponsiveGridItem) {
const panelData = sceneGraph.getData(item.state.body);
if (panelData) {
panelDataProviders.push(panelData);
}
}
// extract multiple panel data from RowItem
if (item instanceof RowItem) {
const panels = item.getLayout().getVizPanels();
for (const panel of panels) {
const panelData = sceneGraph.getData(panel);
if (panelData) {
panelDataProviders.push(panelData);
}
}
}
panelDataProviders.forEach((d) => {
this._subs.add(
d.subscribeToState(() => {
this.getConditionalLogicRoot().notifyChange();
})
);
});
}
public evaluate(): boolean {
const { value } = this.state;
// enable/disable condition
if (!value) {
return true;
}
let data: PanelData[] = [];
// get ResponsiveGridItem or RowItem
const item = this.getConditionalLogicRoot().parent;
// extract single panel data from ResponsiveGridItem
if (item instanceof ResponsiveGridItem) {
const panelData = sceneGraph.getData(item.state.body).state.data;
if (panelData) {
data.push(panelData);
}
}
// extract multiple panel data from RowItem
if (item instanceof RowItem) {
const panels = item.getLayout().getVizPanels();
for (const panel of panels) {
const panelData = sceneGraph.getData(panel).state.data;
if (panelData) {
data.push(panelData);
}
}
}
// early return if no panel data
if (!data.length) {
return false;
}
for (let panelDataIdx = 0; panelDataIdx < data.length; panelDataIdx++) {
const series = data[panelDataIdx]?.series ?? [];
for (let seriesIdx = 0; seriesIdx < series.length; seriesIdx++) {
if (series[seriesIdx].length > 0) {
return true;
}
}
}
return false;
}
public render(): ReactNode {
return <ConditionalRenderingDataRenderer model={this} />;
}
public onDelete() {
handleDeleteNonGroupCondition(this);
}
}
function ConditionalRenderingDataRenderer({ model }: SceneComponentProps<ConditionalRenderingData>) {
const { value } = model.useState();
const enableConditionOptions: Array<SelectableValue<true | false>> = useMemo(
() => [
{ label: t('dashboard.conditional-rendering.data.enable', 'Enable'), value: true },
{ label: t('dashboard.conditional-rendering.data.disable', 'Disable'), value: false },
],
[]
);
return (
<Stack direction="column">
<ConditionHeader title={model.title} onDelete={() => model.onDelete()} />
<RadioButtonGroup
fullWidth
options={enableConditionOptions}
value={value}
onChange={(value) => model.setStateAndNotify({ value: value })}
/>
</Stack>
);
}

@ -0,0 +1,24 @@
import { t } from 'app/core/internationalization';
import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor';
import { OptionsPaneItemDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneItemDescriptor';
import { ConditionalRendering } from './ConditionalRendering';
export function useConditionalRenderingEditor(
conditionalRendering?: ConditionalRendering
): OptionsPaneCategoryDescriptor | null {
if (!conditionalRendering) {
return null;
}
return new OptionsPaneCategoryDescriptor({
title: t('dashboard.conditional-rendering.title', 'Conditional rendering options'),
id: 'conditional-rendering-options',
isOpenDefault: true,
}).addItem(
new OptionsPaneItemDescriptor({
title: t('dashboard.conditional-rendering.title', 'Conditional rendering options'),
render: () => <conditionalRendering.Component model={conditionalRendering} />,
})
);
}

@ -0,0 +1,159 @@
import { css } from '@emotion/css';
import { Fragment, ReactNode, useMemo } from 'react';
import { GrafanaTheme2, SelectableValue } from '@grafana/data';
import { SceneComponentProps } from '@grafana/scenes';
import { Divider, Dropdown, Field, Menu, RadioButtonGroup, Stack, ToolbarButton, useStyles2 } from '@grafana/ui';
import { t, Trans } from 'app/core/internationalization';
import { ConditionHeader } from './ConditionHeader';
import { ConditionalRenderingBase, ConditionalRenderingBaseState } from './ConditionalRenderingBase';
import { ConditionalRenderingData } from './ConditionalRenderingData';
import { ConditionalRenderingInterval } from './ConditionalRenderingInterval';
import { ConditionalRenderingVariable } from './ConditionalRenderingVariable';
import { ConditionalRenderingConditions } from './shared';
export type GroupConditionValue = ConditionalRenderingConditions[];
export interface ConditionalRenderingGroupState extends ConditionalRenderingBaseState<GroupConditionValue> {
condition: 'and' | 'or';
}
export class ConditionalRenderingGroup extends ConditionalRenderingBase<ConditionalRenderingGroupState> {
public get title(): string {
return t('dashboard.conditional-rendering.group.label', 'Group');
}
public evaluate(): boolean {
if (this.state.value.length === 0) {
return true;
}
if (this.state.condition === 'and') {
return this.state.value.every((entry) => entry.evaluate());
}
return this.state.value.some((entry) => entry.evaluate());
}
public render(): ReactNode {
return <ConditionalRenderingGroupRenderer model={this} />;
}
public changeCondition(condition: 'and' | 'or') {
this.setStateAndNotify({ condition });
}
public addItem(item: ConditionalRenderingConditions) {
// We don't use `setStateAndNotify` here because
// We need to set a parent and activate the new condition before notifying the root
this.setState({ value: [...this.state.value, item] });
if (this.isActive && !item.isActive) {
item.activate();
}
this.getConditionalLogicRoot().notifyChange();
}
public static createEmpty(): ConditionalRenderingGroup {
return new ConditionalRenderingGroup({ condition: 'and', value: [] });
}
public onDelete() {
const rootGroup = this.getRootGroup();
if (this === rootGroup) {
this.getConditionalLogicRoot().setState({ rootGroup: ConditionalRenderingGroup.createEmpty() });
} else {
rootGroup.setState({ value: rootGroup.state.value.filter((condition) => condition !== this) });
}
this.getConditionalLogicRoot().notifyChange();
}
}
function ConditionalRenderingGroupRenderer({ model }: SceneComponentProps<ConditionalRenderingGroup>) {
const styles = useStyles2(getStyles);
const { condition, value } = model.useState();
const conditionsOptions: Array<SelectableValue<'and' | 'or'>> = useMemo(
() => [
{ label: t('dashboard.conditional-rendering.group.condition.meet-all', 'Meet all'), value: 'and' },
{ label: t('dashboard.conditional-rendering.group.condition.meet-any', 'Meet any'), value: 'or' },
],
[]
);
return (
<Stack direction="column">
<ConditionHeader title={model.title} onDelete={() => model.onDelete()} />
<Field label={t('dashboard.conditional-rendering.group.condition.label', 'Evaluate conditions')}>
<RadioButtonGroup
fullWidth
options={conditionsOptions}
value={condition}
onChange={(value) => model.changeCondition(value!)}
/>
</Field>
<Divider spacing={1} />
{value.map((entry) => (
<Fragment key={entry!.state.key}>
{/* @ts-expect-error */}
<entry.Component model={entry} />
<div className={styles.entryDivider}>
<Divider spacing={1} />
<p className={styles.entryDividerText}> {condition}</p>
<Divider spacing={1} />
</div>
</Fragment>
))}
<div className={styles.addButtonContainer}>
<Dropdown
overlay={
<Menu>
<Menu.Item
label={t('dashboard.conditional-rendering.group.add.data', 'Data')}
onClick={() => model.addItem(new ConditionalRenderingData({ value: true }))}
/>
<Menu.Item
label={t('dashboard.conditional-rendering.group.add.interval', 'Interval')}
onClick={() => model.addItem(new ConditionalRenderingInterval({ value: '7d' }))}
/>
<Menu.Item
label={t('dashboard.conditional-rendering.group.add.variable', 'Variable value')}
onClick={() =>
model.addItem(new ConditionalRenderingVariable({ value: { name: '', operator: '=', value: '' } }))
}
/>
</Menu>
}
>
<ToolbarButton icon="plus" iconSize="xs" variant="canvas">
<Trans i18nKey="dashboard.conditional-rendering.group.add.button">Add condition based on</Trans>
</ToolbarButton>
</Dropdown>
</div>
</Stack>
);
}
const getStyles = (theme: GrafanaTheme2) => ({
entryDivider: css({
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
}),
entryDividerText: css({
margin: 0,
padding: theme.spacing(0, 2),
textTransform: 'capitalize',
}),
addButtonContainer: css({
display: 'flex',
flexDirection: 'row',
justifyContent: 'center',
alignItems: 'center',
}),
});

@ -0,0 +1,83 @@
import { ReactNode, useState } from 'react';
import { rangeUtil } from '@grafana/data';
import { SceneComponentProps, sceneGraph } from '@grafana/scenes';
import { Field, Input, Stack } from '@grafana/ui';
import { t } from 'app/core/internationalization';
import { ConditionHeader } from './ConditionHeader';
import { ConditionalRenderingBase, ConditionalRenderingBaseState } from './ConditionalRenderingBase';
import { handleDeleteNonGroupCondition } from './shared';
export type IntervalConditionValue = string;
type ConditionalRenderingIntervalState = ConditionalRenderingBaseState<IntervalConditionValue>;
export class ConditionalRenderingInterval extends ConditionalRenderingBase<ConditionalRenderingIntervalState> {
public get title(): string {
return t('dashboard.conditional-rendering.interval.label', 'Time range interval');
}
public constructor(state: ConditionalRenderingIntervalState) {
super(state);
this.addActivationHandler(() => this._activationHandler());
}
private _activationHandler() {
this._subs.add(
sceneGraph.getTimeRange(this).subscribeToState(() => {
this.getConditionalLogicRoot().notifyChange();
})
);
}
public evaluate(): boolean {
try {
const interval = rangeUtil.intervalToSeconds(this.state.value);
const timeRange = sceneGraph.getTimeRange(this);
if (timeRange.state.value.to.unix() - timeRange.state.value.from.unix() <= interval) {
return true;
}
} catch {
return false;
}
return false;
}
public render(): ReactNode {
return <ConditionalRenderingIntervalRenderer model={this} />;
}
public onDelete() {
handleDeleteNonGroupCondition(this);
}
}
function ConditionalRenderingIntervalRenderer({ model }: SceneComponentProps<ConditionalRenderingInterval>) {
const { value } = model.useState();
const [isValid, setIsValid] = useState(validateIntervalRegex.test(value));
return (
<Stack direction="column">
<ConditionHeader title={model.title} onDelete={() => model.onDelete()} />
<Field
invalid={!isValid}
error={t('dashboard.conditional-rendering.interval.invalid-message', 'Invalid interval')}
label={t('dashboard.conditional-rendering.interval.input-label', 'Value')}
>
<Input
value={value}
onChange={(e) => {
setIsValid(validateIntervalRegex.test(e.currentTarget.value));
model.setStateAndNotify({ value: e.currentTarget.value });
}}
/>
</Field>
</Stack>
);
}
export const validateIntervalRegex = /^(-?\d+(?:\.\d+)?)(ms|[Mwdhmsy])?$/;

@ -0,0 +1,126 @@
import { css } from '@emotion/css';
import { ReactNode, useMemo } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { SceneComponentProps, sceneGraph, VariableDependencyConfig } from '@grafana/scenes';
import { Combobox, ComboboxOption, Field, Input, Stack, useStyles2 } from '@grafana/ui';
import { t } from 'app/core/internationalization';
import { ConditionHeader } from './ConditionHeader';
import { ConditionalRenderingBase, ConditionalRenderingBaseState } from './ConditionalRenderingBase';
import { handleDeleteNonGroupCondition } from './shared';
export type VariableConditionValue = {
name: string;
operator: '=' | '!=';
value: string;
};
type ConditionalRenderingVariableState = ConditionalRenderingBaseState<VariableConditionValue>;
export class ConditionalRenderingVariable extends ConditionalRenderingBase<ConditionalRenderingVariableState> {
public get title(): string {
return t('dashboard.conditional-rendering.variable.label', 'Variable');
}
protected _variableDependency = new VariableDependencyConfig(this, {
onAnyVariableChanged: (v) => {
if (v.state.name === this.state.value.name) {
this.getConditionalLogicRoot().notifyChange();
}
},
});
public evaluate(): boolean {
if (!this.state.value.name) {
return true;
}
const variable = sceneGraph.getVariables(this).state.variables.find((v) => v.state.name === this.state.value.name);
// name is defined but no variable found - return false
if (!variable) {
return false;
}
const value = variable.getValue();
let hit = Array.isArray(value) ? value.includes(this.state.value.value) : value === this.state.value.value;
if (this.state.value.operator === '!=') {
hit = !hit;
}
return hit;
}
public render(): ReactNode {
return <ConditionalRenderingVariableRenderer model={this} />;
}
public onDelete() {
handleDeleteNonGroupCondition(this);
}
}
function ConditionalRenderingVariableRenderer({ model }: SceneComponentProps<ConditionalRenderingVariable>) {
const variables = useMemo(() => sceneGraph.getVariables(model), [model]);
const variableNames = useMemo(
() => variables.state.variables.map((v) => ({ value: v.state.name, label: v.state.label ?? v.state.name })),
[variables.state.variables]
);
const operatorOptions: Array<ComboboxOption<'=' | '!='>> = useMemo(
() => [
{ value: '=', description: t('dashboard.conditional-rendering.variable.operator.equals', 'Equals') },
{ value: '!=', description: t('dashboard.conditional-rendering.variable.operator.not-equal', 'Not equal') },
],
[]
);
const { value } = model.useState();
const styles = useStyles2(getStyles);
return (
<Stack direction="column">
<ConditionHeader title={model.title} onDelete={() => model.onDelete()} />
<Stack direction="column">
<Stack direction="row" gap={0.5} grow={1}>
<Field
label={t('dashboard.conditional-rendering.variable.select-variable', 'Select variable')}
className={styles.variableNameSelect}
>
<Combobox
options={variableNames}
value={value.name}
onChange={(option) => model.setStateAndNotify({ value: { ...value, name: option.value } })}
/>
</Field>
<Field
label={t('dashboard.conditional-rendering.variable.select-operator', 'Operator')}
className={styles.operatorSelect}
>
<Combobox
options={operatorOptions}
value={value.operator}
onChange={(option) => model.setStateAndNotify({ value: { ...value, operator: option.value } })}
/>
</Field>
</Stack>
<Field label={t('dashboard.conditional-rendering.variable.value-input', 'Value')}>
<Input
value={value.value}
onChange={(e) => model.setStateAndNotify({ value: { ...value, value: e.currentTarget.value } })}
/>
</Field>
</Stack>
</Stack>
);
}
const getStyles = (theme: GrafanaTheme2) => ({
variableNameSelect: css({
flexGrow: 1,
}),
operatorSelect: css({
width: theme.spacing(12),
}),
});

@ -0,0 +1,25 @@
import { ConditionalRenderingData, DataConditionValue } from './ConditionalRenderingData';
import { ConditionalRenderingGroup, GroupConditionValue } from './ConditionalRenderingGroup';
import { ConditionalRenderingInterval, IntervalConditionValue } from './ConditionalRenderingInterval';
import { ConditionalRenderingVariable, VariableConditionValue } from './ConditionalRenderingVariable';
export type ConditionValues =
| DataConditionValue
| VariableConditionValue
| GroupConditionValue
| IntervalConditionValue;
export type ConditionalRenderingConditions =
| ConditionalRenderingData
| ConditionalRenderingVariable
| ConditionalRenderingInterval
| ConditionalRenderingGroup;
type NonGroupConditions = Exclude<ConditionalRenderingConditions, ConditionalRenderingGroup>;
export const handleDeleteNonGroupCondition = (model: NonGroupConditions) => {
if (model.parent instanceof ConditionalRenderingGroup) {
model.parent.setState({ value: model.parent.state.value.filter((condition) => condition !== model) });
model.getConditionalLogicRoot().notifyChange();
}
};

@ -39,6 +39,7 @@ export class DashboardEditPane extends SceneObjectBase<DashboardEditPaneState> {
enabled: false,
selected: [],
onSelect: (item, multi) => this.selectElement(item, multi),
onClear: () => this.clearSelection(),
},
});

@ -25,7 +25,7 @@ import {
NEW_PANEL_WIDTH,
getVizPanelKeyForPanelId,
getGridItemKeyForPanelId,
getDashboardSceneFor,
useDashboard,
} from '../../utils/utils';
import { DashboardLayoutManager } from '../types/DashboardLayoutManager';
import { LayoutRegistryItem } from '../types/LayoutRegistryItem';
@ -442,7 +442,7 @@ export class DefaultGridLayoutManager
function DefaultGridLayoutManagerRenderer({ model }: SceneComponentProps<DefaultGridLayoutManager>) {
const { children } = useSceneObjectState(model.state.grid, { shouldActivateOrKeepAlive: true });
const dashboard = getDashboardSceneFor(model);
const dashboard = useDashboard(model);
// If we are top level layout and have no children, show empty state
if (model.parent === dashboard && children.length === 0) {

@ -8,7 +8,7 @@ import { t } from 'app/core/internationalization';
import { SHARED_DASHBOARD_QUERY } from 'app/plugins/datasource/dashboard/constants';
import { MIXED_DATASOURCE_NAME } from 'app/plugins/datasource/mixed/MixedDataSource';
import { getDashboardSceneFor, getQueryRunnerFor } from '../../../utils/utils';
import { getQueryRunnerFor, useDashboard, useDashboardState } from '../../../utils/utils';
import { DashboardGridItem } from '../DashboardGridItem';
import { RowRepeaterBehavior } from '../RowRepeaterBehavior';
@ -16,10 +16,10 @@ import { RowActions } from './RowActions';
import { RowOptionsButton } from './RowOptionsButton';
export function RowActionsRenderer({ model }: SceneComponentProps<RowActions>) {
const dashboard = getDashboardSceneFor(model);
const row = model.getParent();
const { title, children } = row.useState();
const { meta, isEditing } = dashboard.useState();
const dashboard = useDashboard(model);
const { meta, isEditing } = useDashboardState(model);
const styles = useStyles2(getStyles);
const isUsingDashboardDS = useMemo(

@ -15,6 +15,7 @@ import {
} from '@grafana/scenes';
import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor';
import { ConditionalRendering } from '../../conditional-rendering/ConditionalRendering';
import { getCloneKey } from '../../utils/clone';
import { getMultiVariableValues } from '../../utils/utils';
import { DashboardLayoutItem } from '../types/DashboardLayoutItem';
@ -28,6 +29,7 @@ export interface ResponsiveGridItemState extends SceneObjectState {
hideWhenNoData?: boolean;
repeatedPanels?: VizPanel[];
variableName?: string;
conditionalRendering?: ConditionalRendering;
}
export class ResponsiveGridItem extends SceneObjectBase<ResponsiveGridItemState> implements DashboardLayoutItem {
@ -40,7 +42,7 @@ export class ResponsiveGridItem extends SceneObjectBase<ResponsiveGridItemState>
public readonly isDashboardLayoutItem = true;
public constructor(state: ResponsiveGridItemState) {
super(state);
super({ ...state, conditionalRendering: state?.conditionalRendering ?? ConditionalRendering.createEmpty() });
this.addActivationHandler(() => this._activationHandler());
}
@ -48,6 +50,14 @@ export class ResponsiveGridItem extends SceneObjectBase<ResponsiveGridItemState>
if (this.state.variableName) {
this.performRepeat();
}
const deactivate = this.state.conditionalRendering?.activate();
return () => {
if (deactivate) {
deactivate();
}
};
}
public getOptions(): OptionsPaneCategoryDescriptor {

@ -1,54 +1,9 @@
import { Switch } from '@grafana/ui';
import { t } from 'app/core/internationalization';
import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor';
import { OptionsPaneItemDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneItemDescriptor';
import { RepeatRowSelect2 } from 'app/features/dashboard/components/RepeatRowSelect/RepeatRowSelect';
import { useConditionalRenderingEditor } from '../../conditional-rendering/ConditionalRenderingEditor';
import { ResponsiveGridItem } from './ResponsiveGridItem';
export function getOptions(model: ResponsiveGridItem): OptionsPaneCategoryDescriptor {
const category = new OptionsPaneCategoryDescriptor({
title: t('dashboard.responsive-layout.item-options.title', 'Layout options'),
id: 'layout-options',
isOpenDefault: false,
});
category.addItem(
new OptionsPaneItemDescriptor({
title: t('dashboard.responsive-layout.item-options.hide-no-data', 'Hide when no data'),
render: () => <GridItemNoDataToggle item={model} />,
})
);
category.addItem(
new OptionsPaneItemDescriptor({
title: t('dashboard.responsive-layout.item-options.repeat.variable.title', 'Repeat by variable'),
description: t(
'dashboard.responsive-layout.item-options.repeat.variable.description',
'Repeat this panel for each value in the selected variable. This is not visible while in edit mode. You need to go back to dashboard and then update the variable or reload the dashboard.'
),
render: () => <RepeatByOption item={model} />,
})
);
return category;
}
function GridItemNoDataToggle({ item }: { item: ResponsiveGridItem }) {
const { hideWhenNoData } = item.useState();
return <Switch value={hideWhenNoData} id="hide-when-no-data" onChange={() => item.toggleHideWhenNoData()} />;
}
function RepeatByOption({ item }: { item: ResponsiveGridItem }) {
const { variableName } = item.useState();
return (
<RepeatRowSelect2
id="repeat-by-variable-select"
sceneContext={item}
repeat={variableName}
onChange={(value?: string) => item.setRepeatByVariable(value)}
/>
);
return useConditionalRenderingEditor(model.state.conditionalRendering)!;
}

@ -3,22 +3,34 @@ import { css, cx } from '@emotion/css';
import { SceneComponentProps } from '@grafana/scenes';
import { useStyles2 } from '@grafana/ui';
import { useDashboardState, useIsConditionallyHidden } from '../../utils/utils';
import { ResponsiveGridItem } from './ResponsiveGridItem';
export function ResponsiveGridItemRenderer({ model }: SceneComponentProps<ResponsiveGridItem>) {
const { body } = model.useState();
const style = useStyles2(getStyles);
const { showHiddenElements } = useDashboardState(model);
const isConditionallyHidden = useIsConditionallyHidden(model);
if (isConditionallyHidden && !showHiddenElements) {
return null;
}
const isHiddenButVisibleElement = showHiddenElements && isConditionallyHidden;
return model.state.repeatedPanels ? (
<>
{model.state.repeatedPanels.map((item) => (
<div className={cx(style.wrapper)} key={item.state.key}>
<div
className={cx(style.wrapper, isHiddenButVisibleElement && 'dashboard-visible-hidden-element')}
key={item.state.key}
>
<item.Component model={item} />
</div>
))}
</>
) : (
<div className={cx(style.wrapper)}>
<div className={cx(style.wrapper, isHiddenButVisibleElement && 'dashboard-visible-hidden-element')}>
<body.Component model={body} />
</div>
);

@ -1,7 +1,8 @@
import { SceneObjectState, SceneObjectBase, sceneGraph, VariableDependencyConfig, SceneObject } from '@grafana/scenes';
import { sceneGraph, SceneObject, SceneObjectBase, SceneObjectState, VariableDependencyConfig } from '@grafana/scenes';
import { t } from 'app/core/internationalization';
import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor';
import { ConditionalRendering } from '../../conditional-rendering/ConditionalRendering';
import { getDefaultVizPanel } from '../../utils/utils';
import { ResponsiveGridLayoutManager } from '../layout-responsive-grid/ResponsiveGridLayoutManager';
import { BulkActionElement } from '../types/BulkActionElement';
@ -21,6 +22,7 @@ export interface RowItemState extends SceneObjectState {
isCollapsed?: boolean;
isHeaderHidden?: boolean;
height?: 'expand' | 'min';
conditionalRendering?: ConditionalRendering;
}
export class RowItem
@ -40,7 +42,20 @@ export class RowItem
...state,
title: state?.title ?? t('dashboard.rows-layout.row.new', 'New row'),
layout: state?.layout ?? ResponsiveGridLayoutManager.createEmpty(),
conditionalRendering: state?.conditionalRendering ?? ConditionalRendering.createEmpty(),
});
this.addActivationHandler(() => this._activationHandler());
}
private _activationHandler() {
const deactivate = this.state.conditionalRendering?.activate();
return () => {
if (deactivate) {
deactivate();
}
};
}
public getEditableElementInfo(): EditableDashboardElementInfo {

@ -10,8 +10,8 @@ import { RepeatRowSelect2 } from 'app/features/dashboard/components/RepeatRowSel
import { SHARED_DASHBOARD_QUERY } from 'app/plugins/datasource/dashboard/constants';
import { MIXED_DATASOURCE_NAME } from 'app/plugins/datasource/mixed/MixedDataSource';
import { getDashboardSceneFor, getQueryRunnerFor } from '../../utils/utils';
import { DashboardScene } from '../DashboardScene';
import { useConditionalRenderingEditor } from '../../conditional-rendering/ConditionalRenderingEditor';
import { getQueryRunnerFor, useDashboard } from '../../utils/utils';
import { DashboardLayoutSelector } from '../layouts-shared/DashboardLayoutSelector';
import { useEditPaneInputAutoFocus } from '../layouts-shared/utils';
@ -20,8 +20,6 @@ import { RowItem } from './RowItem';
export function getEditOptions(model: RowItem): OptionsPaneCategoryDescriptor[] {
const { layout } = model.useState();
const rowOptions = useMemo(() => {
const dashboard = getDashboardSceneFor(model);
const editPaneHeaderOptions = new OptionsPaneCategoryDescriptor({ title: '', id: 'row-options' })
.addItem(
new OptionsPaneItemDescriptor({
@ -52,7 +50,7 @@ export function getEditOptions(model: RowItem): OptionsPaneCategoryDescriptor[]
.addItem(
new OptionsPaneItemDescriptor({
title: t('dashboard.rows-layout.option.repeat', 'Repeat for'),
render: () => <RowRepeatSelect row={model} dashboard={dashboard} />,
render: () => <RowRepeatSelect row={model} />,
})
)
.addItem(
@ -65,7 +63,17 @@ export function getEditOptions(model: RowItem): OptionsPaneCategoryDescriptor[]
return editPaneHeaderOptions;
}, [layout, model]);
return [rowOptions];
const conditionalRenderingOptions = useMemo(() => {
return useConditionalRenderingEditor(model.state.conditionalRendering);
}, [model]);
const editOptions = [rowOptions];
if (conditionalRenderingOptions) {
editOptions.push(conditionalRenderingOptions);
}
return editOptions;
}
function RowTitleInput({ row }: { row: RowItem }) {
@ -99,8 +107,9 @@ function RowHeightSelect({ row }: { row: RowItem }) {
return <RadioButtonGroup options={options} value={height} onChange={(option) => row.onChangeHeight(option)} />;
}
function RowRepeatSelect({ row, dashboard }: { row: RowItem; dashboard: DashboardScene }) {
function RowRepeatSelect({ row }: { row: RowItem }) {
const { layout } = row.useState();
const dashboard = useDashboard(row);
const isAnyPanelUsingDashboardDS = layout.getVizPanels().some((vizPanel) => {
const runner = getQueryRunnerFor(vizPanel);

@ -1,35 +1,46 @@
import { css, cx } from '@emotion/css';
import { useCallback, useMemo, useRef, useState } from 'react';
import { useCallback, useState } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { SceneComponentProps, sceneGraph } from '@grafana/scenes';
import { clearButtonStyles, Icon, useElementSelection, useStyles2 } from '@grafana/ui';
import { SceneComponentProps } from '@grafana/scenes';
import { clearButtonStyles, Icon, useStyles2 } from '@grafana/ui';
import { t } from 'app/core/internationalization';
import { isClonedKey } from '../../utils/clone';
import { getDashboardSceneFor } from '../../utils/utils';
import { useIsClone } from '../../utils/clone';
import {
useDashboardState,
useElementSelectionScene,
useInterpolatedTitle,
useIsConditionallyHidden,
} from '../../utils/utils';
import { RowItem } from './RowItem';
import { RowItemMenu } from './RowItemMenu';
export function RowItemRenderer({ model }: SceneComponentProps<RowItem>) {
const { layout, title, isCollapsed, height = 'min', isHeaderHidden, key } = model.useState();
const isClone = useMemo(() => isClonedKey(key!), [key]);
const dashboard = getDashboardSceneFor(model);
const { isEditing, showHiddenElements } = dashboard.useState();
const { layout, isCollapsed, height = 'min', isHeaderHidden } = model.useState();
const isClone = useIsClone(model);
const { isEditing, showHiddenElements } = useDashboardState(model);
const isConditionallyHidden = useIsConditionallyHidden(model);
const { isSelected, onSelect, isSelectable } = useElementSelectionScene(model);
const title = useInterpolatedTitle(model);
const styles = useStyles2(getStyles);
const clearStyles = useStyles2(clearButtonStyles);
const titleInterpolated = sceneGraph.interpolate(model, title, undefined, 'text');
const ref = useRef<HTMLDivElement>(null);
const shouldGrow = !isCollapsed && height === 'expand';
const { isSelected, isSelectable, onSelect } = useElementSelection(key);
const isHiddenButVisibleElement = showHiddenElements && isConditionallyHidden;
const isHiddenButVisibleHeader = showHiddenElements && isHeaderHidden;
// Highlight the full row when hovering over header
const [selectableHighlight, setSelectableHighlight] = useState(false);
const onHeaderEnter = useCallback(() => setSelectableHighlight(true), []);
const onHeaderLeave = useCallback(() => setSelectableHighlight(false), []);
if (isConditionallyHidden && !showHiddenElements) {
return null;
}
return (
<div
className={cx(
@ -38,15 +49,19 @@ export function RowItemRenderer({ model }: SceneComponentProps<RowItem>) {
isEditing && isCollapsed && styles.wrapperEditingCollapsed,
isCollapsed && styles.wrapperCollapsed,
shouldGrow && styles.wrapperGrow,
isHiddenButVisibleElement && 'dashboard-visible-hidden-element',
!isClone && isSelected && 'dashboard-selected-element',
!isClone && !isSelected && selectableHighlight && 'dashboard-selectable-element'
)}
ref={ref}
onPointerDown={onSelect}
>
{(!isHeaderHidden || (isEditing && showHiddenElements)) && (
<div
className={cx(styles.rowHeader, 'dashboard-row-header')}
className={cx(
isHiddenButVisibleHeader && 'dashboard-visible-hidden-element',
styles.rowHeader,
'dashboard-row-header'
)}
onMouseEnter={isSelectable ? onHeaderEnter : undefined}
onMouseLeave={isSelectable ? onHeaderLeave : undefined}
>
@ -58,11 +73,11 @@ export function RowItemRenderer({ model }: SceneComponentProps<RowItem>) {
? t('dashboard.rows-layout.row.expand', 'Expand row')
: t('dashboard.rows-layout.row.collapse', 'Collapse row')
}
data-testid={selectors.components.DashboardRow.title(titleInterpolated!)}
data-testid={selectors.components.DashboardRow.title(title!)}
>
<Icon name={isCollapsed ? 'angle-right' : 'angle-down'} />
<span className={styles.rowTitle} role="heading">
{titleInterpolated}
{title}
</span>
</button>
{!isClone && isEditing && <RowItemMenu model={model} />}

@ -1,3 +1,5 @@
import { SceneObject } from '@grafana/scenes';
const CLONE_KEY = '-clone-';
const CLONE_SEPARATOR = '/';
@ -71,3 +73,12 @@ export function joinCloneKeys(...keys: string[]): string {
export function containsCloneKey(key: string): boolean {
return key.includes(CLONE_KEY);
}
/**
* Useful hook for checking of a scene is a clone
* @param scene
*/
export function useIsClone(scene: SceneObject): boolean {
const { key } = scene.useState();
return isClonedKey(key!);
}

@ -7,18 +7,22 @@ import {
SceneDataTransformer,
sceneGraph,
SceneObject,
SceneObjectState,
SceneQueryRunner,
VizPanel,
VizPanelMenu,
} from '@grafana/scenes';
import { useElementSelection, UseElementSelectionResult } from '@grafana/ui';
import { initialIntervalVariableModelState } from 'app/features/variables/interval/reducer';
import { DashboardDatasourceBehaviour } from '../scene/DashboardDatasourceBehaviour';
import { DashboardScene } from '../scene/DashboardScene';
import { DashboardScene, DashboardSceneState } from '../scene/DashboardScene';
import { LibraryPanelBehavior } from '../scene/LibraryPanelBehavior';
import { VizPanelLinks, VizPanelLinksMenu } from '../scene/PanelLinks';
import { panelMenuBehavior } from '../scene/PanelMenuBehavior';
import { DashboardGridItem } from '../scene/layout-default/DashboardGridItem';
import { ResponsiveGridItem } from '../scene/layout-responsive-grid/ResponsiveGridItem';
import { RowItem } from '../scene/layout-rows/RowItem';
import { setDashboardPanelContext } from '../scene/setDashboardPanelContext';
import { DashboardLayoutManager, isDashboardLayoutManager } from '../scene/types/DashboardLayoutManager';
@ -437,3 +441,42 @@ export function getLayoutManagerFor(sceneObject: SceneObject): DashboardLayoutMa
export function getGridItemKeyForPanelId(panelId: number): string {
return `grid-item-${panelId}`;
}
export function useDashboard(scene: SceneObject): DashboardScene {
return getDashboardSceneFor(scene);
}
export function useDashboardState(
scene: SceneObject
): DashboardSceneState & { isEditing: boolean; showHiddenElements: boolean } {
const dashboard = useDashboard(scene);
const state = dashboard.useState();
return {
...state,
isEditing: !!state.isEditing,
showHiddenElements: !!(state.isEditing && state.showHiddenElements),
};
}
export function useIsConditionallyHidden(scene: RowItem | ResponsiveGridItem): boolean {
const { conditionalRendering } = scene.useState();
return !(conditionalRendering?.evaluate() ?? true);
}
export function useElementSelectionScene(scene: SceneObject): UseElementSelectionResult {
const { key } = scene.useState();
return useElementSelection(key);
}
export function useInterpolatedTitle<T extends SceneObjectState & { title?: string }>(scene: SceneObject<T>): string {
const { title } = scene.useState();
if (!title) {
return '';
}
return sceneGraph.interpolate(scene, title, undefined, 'text');
}

@ -1020,6 +1020,46 @@
"redirect-link": "List in Grafana Alerting",
"subtitle": "Alert rules related to this dashboard"
},
"conditional-rendering": {
"data": {
"disable": "Disable",
"enable": "Enable",
"label": "Data"
},
"group": {
"add": {
"button": "Add condition based on",
"data": "Data",
"interval": "Interval",
"variable": "Variable value"
},
"condition": {
"label": "Evaluate conditions",
"meet-all": "Meet all",
"meet-any": "Meet any"
},
"label": "Group"
},
"interval": {
"input-label": "Value",
"invalid-message": "Invalid interval",
"label": "Time range interval"
},
"shared": {
"delete-condition": "Delete Condition"
},
"title": "Conditional rendering options",
"variable": {
"label": "Variable",
"operator": {
"equals": "Equals",
"not-equal": "Not equal"
},
"select-operator": "Operator",
"select-variable": "Select variable",
"value-input": "Value"
}
},
"default-layout": {
"description": "Manually size and position panels",
"item-options": {
@ -1194,16 +1234,6 @@
},
"responsive-layout": {
"description": "Automatically positions panels into a grid.",
"item-options": {
"hide-no-data": "Hide when no data",
"repeat": {
"variable": {
"description": "Repeat this panel for each value in the selected variable. This is not visible while in edit mode. You need to go back to dashboard and then update the variable or reload the dashboard.",
"title": "Repeat by variable"
}
},
"title": "Layout options"
},
"name": "Auto",
"options": {
"columns": "Columns",

Loading…
Cancel
Save