diff --git a/packages/grafana-ui/src/components/ElementSelectionContext/ElementSelectionContext.tsx b/packages/grafana-ui/src/components/ElementSelectionContext/ElementSelectionContext.tsx index 2e6b1b9fd46..8ba48a7eeda 100644 --- a/packages/grafana-ui/src/components/ElementSelectionContext/ElementSelectionContext.tsx +++ b/packages/grafana-ui/src/components/ElementSelectionContext/ElementSelectionContext.tsx @@ -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 }; } diff --git a/packages/grafana-ui/src/components/index.ts b/packages/grafana-ui/src/components/index.ts index cdfe02e32b6..af030e6a0fb 100644 --- a/packages/grafana-ui/src/components/index.ts +++ b/packages/grafana-ui/src/components/index.ts @@ -336,4 +336,5 @@ export { useElementSelection, type ElementSelectionContextState, type ElementSelectionContextItem, + type UseElementSelectionResult, } from './ElementSelectionContext/ElementSelectionContext'; diff --git a/packages/grafana-ui/src/themes/GlobalStyles/dashboardGrid.ts b/packages/grafana-ui/src/themes/GlobalStyles/dashboardGrid.ts index 6a87fad092c..144dbfa3b79 100644 --- a/packages/grafana-ui/src/themes/GlobalStyles/dashboardGrid.ts +++ b/packages/grafana-ui/src/themes/GlobalStyles/dashboardGrid.ts @@ -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, + }, + }, }); } diff --git a/public/app/features/dashboard-scene/conditional-rendering/ConditionHeader.tsx b/public/app/features/dashboard-scene/conditional-rendering/ConditionHeader.tsx new file mode 100644 index 00000000000..0112e96334e --- /dev/null +++ b/public/app/features/dashboard-scene/conditional-rendering/ConditionHeader.tsx @@ -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 ( + + {title} + onDelete()} + /> + + ); +}; diff --git a/public/app/features/dashboard-scene/conditional-rendering/ConditionalRendering.tsx b/public/app/features/dashboard-scene/conditional-rendering/ConditionalRendering.tsx new file mode 100644 index 00000000000..50770dcce9a --- /dev/null +++ b/public/app/features/dashboard-scene/conditional-rendering/ConditionalRendering.tsx @@ -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 { + 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) { + const { rootGroup } = model.useState(); + + return ; +} diff --git a/public/app/features/dashboard-scene/conditional-rendering/ConditionalRenderingBase.tsx b/public/app/features/dashboard-scene/conditional-rendering/ConditionalRenderingBase.tsx new file mode 100644 index 00000000000..6fbbd1a83af --- /dev/null +++ b/public/app/features/dashboard-scene/conditional-rendering/ConditionalRenderingBase.tsx @@ -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 extends SceneObjectState { + value: V; +} + +export abstract class ConditionalRenderingBase< + S extends ConditionalRenderingBaseState, +> extends SceneObjectBase { + 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) { + this.setState(state); + this.getConditionalLogicRoot().notifyChange(); + } +} + +function ConditionalRenderingBaseRenderer({ + model, +}: SceneComponentProps>) { + return model.render(); +} diff --git a/public/app/features/dashboard-scene/conditional-rendering/ConditionalRenderingData.tsx b/public/app/features/dashboard-scene/conditional-rendering/ConditionalRenderingData.tsx new file mode 100644 index 00000000000..e95b5f45b18 --- /dev/null +++ b/public/app/features/dashboard-scene/conditional-rendering/ConditionalRenderingData.tsx @@ -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; + +export class ConditionalRenderingData extends ConditionalRenderingBase { + 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 ; + } + + public onDelete() { + handleDeleteNonGroupCondition(this); + } +} + +function ConditionalRenderingDataRenderer({ model }: SceneComponentProps) { + const { value } = model.useState(); + + const enableConditionOptions: Array> = useMemo( + () => [ + { label: t('dashboard.conditional-rendering.data.enable', 'Enable'), value: true }, + { label: t('dashboard.conditional-rendering.data.disable', 'Disable'), value: false }, + ], + [] + ); + + return ( + + model.onDelete()} /> + model.setStateAndNotify({ value: value })} + /> + + ); +} diff --git a/public/app/features/dashboard-scene/conditional-rendering/ConditionalRenderingEditor.tsx b/public/app/features/dashboard-scene/conditional-rendering/ConditionalRenderingEditor.tsx new file mode 100644 index 00000000000..510f3c50d0d --- /dev/null +++ b/public/app/features/dashboard-scene/conditional-rendering/ConditionalRenderingEditor.tsx @@ -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: () => , + }) + ); +} diff --git a/public/app/features/dashboard-scene/conditional-rendering/ConditionalRenderingGroup.tsx b/public/app/features/dashboard-scene/conditional-rendering/ConditionalRenderingGroup.tsx new file mode 100644 index 00000000000..45f4edd92ca --- /dev/null +++ b/public/app/features/dashboard-scene/conditional-rendering/ConditionalRenderingGroup.tsx @@ -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 { + condition: 'and' | 'or'; +} + +export class ConditionalRenderingGroup extends ConditionalRenderingBase { + 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 ; + } + + 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) { + const styles = useStyles2(getStyles); + const { condition, value } = model.useState(); + + const conditionsOptions: Array> = 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 ( + + model.onDelete()} /> + + model.changeCondition(value!)} + /> + + + + + {value.map((entry) => ( + + {/* @ts-expect-error */} + + +
+ +

{condition}

+ +
+
+ ))} + +
+ + model.addItem(new ConditionalRenderingData({ value: true }))} + /> + model.addItem(new ConditionalRenderingInterval({ value: '7d' }))} + /> + + model.addItem(new ConditionalRenderingVariable({ value: { name: '', operator: '=', value: '' } })) + } + /> + + } + > + + Add condition based on + + +
+
+ ); +} + +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', + }), +}); diff --git a/public/app/features/dashboard-scene/conditional-rendering/ConditionalRenderingInterval.tsx b/public/app/features/dashboard-scene/conditional-rendering/ConditionalRenderingInterval.tsx new file mode 100644 index 00000000000..8454fa627f5 --- /dev/null +++ b/public/app/features/dashboard-scene/conditional-rendering/ConditionalRenderingInterval.tsx @@ -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; + +export class ConditionalRenderingInterval extends ConditionalRenderingBase { + 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 ; + } + + public onDelete() { + handleDeleteNonGroupCondition(this); + } +} + +function ConditionalRenderingIntervalRenderer({ model }: SceneComponentProps) { + const { value } = model.useState(); + const [isValid, setIsValid] = useState(validateIntervalRegex.test(value)); + + return ( + + model.onDelete()} /> + + { + setIsValid(validateIntervalRegex.test(e.currentTarget.value)); + model.setStateAndNotify({ value: e.currentTarget.value }); + }} + /> + + + ); +} + +export const validateIntervalRegex = /^(-?\d+(?:\.\d+)?)(ms|[Mwdhmsy])?$/; diff --git a/public/app/features/dashboard-scene/conditional-rendering/ConditionalRenderingVariable.tsx b/public/app/features/dashboard-scene/conditional-rendering/ConditionalRenderingVariable.tsx new file mode 100644 index 00000000000..5899dfd1797 --- /dev/null +++ b/public/app/features/dashboard-scene/conditional-rendering/ConditionalRenderingVariable.tsx @@ -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; + +export class ConditionalRenderingVariable extends ConditionalRenderingBase { + 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 ; + } + + public onDelete() { + handleDeleteNonGroupCondition(this); + } +} + +function ConditionalRenderingVariableRenderer({ model }: SceneComponentProps) { + 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 + model.onDelete()} /> + + + + model.setStateAndNotify({ value: { ...value, name: option.value } })} + /> + + + model.setStateAndNotify({ value: { ...value, operator: option.value } })} + /> + + + + model.setStateAndNotify({ value: { ...value, value: e.currentTarget.value } })} + /> + + + + ); +} + +const getStyles = (theme: GrafanaTheme2) => ({ + variableNameSelect: css({ + flexGrow: 1, + }), + operatorSelect: css({ + width: theme.spacing(12), + }), +}); diff --git a/public/app/features/dashboard-scene/conditional-rendering/shared.ts b/public/app/features/dashboard-scene/conditional-rendering/shared.ts new file mode 100644 index 00000000000..2a88ffc62b6 --- /dev/null +++ b/public/app/features/dashboard-scene/conditional-rendering/shared.ts @@ -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; + +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(); + } +}; diff --git a/public/app/features/dashboard-scene/edit-pane/DashboardEditPane.tsx b/public/app/features/dashboard-scene/edit-pane/DashboardEditPane.tsx index 072d80ba367..7fec9b3106b 100644 --- a/public/app/features/dashboard-scene/edit-pane/DashboardEditPane.tsx +++ b/public/app/features/dashboard-scene/edit-pane/DashboardEditPane.tsx @@ -39,6 +39,7 @@ export class DashboardEditPane extends SceneObjectBase { enabled: false, selected: [], onSelect: (item, multi) => this.selectElement(item, multi), + onClear: () => this.clearSelection(), }, }); diff --git a/public/app/features/dashboard-scene/scene/layout-default/DefaultGridLayoutManager.tsx b/public/app/features/dashboard-scene/scene/layout-default/DefaultGridLayoutManager.tsx index c30558f3221..652d62dd757 100644 --- a/public/app/features/dashboard-scene/scene/layout-default/DefaultGridLayoutManager.tsx +++ b/public/app/features/dashboard-scene/scene/layout-default/DefaultGridLayoutManager.tsx @@ -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) { 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) { diff --git a/public/app/features/dashboard-scene/scene/layout-default/row-actions/RowActionsRenderer.tsx b/public/app/features/dashboard-scene/scene/layout-default/row-actions/RowActionsRenderer.tsx index 2d7dbfabca3..64f4787cba7 100644 --- a/public/app/features/dashboard-scene/scene/layout-default/row-actions/RowActionsRenderer.tsx +++ b/public/app/features/dashboard-scene/scene/layout-default/row-actions/RowActionsRenderer.tsx @@ -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) { - 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( diff --git a/public/app/features/dashboard-scene/scene/layout-responsive-grid/ResponsiveGridItem.tsx b/public/app/features/dashboard-scene/scene/layout-responsive-grid/ResponsiveGridItem.tsx index e7b9010a3d5..29997c4c2ed 100644 --- a/public/app/features/dashboard-scene/scene/layout-responsive-grid/ResponsiveGridItem.tsx +++ b/public/app/features/dashboard-scene/scene/layout-responsive-grid/ResponsiveGridItem.tsx @@ -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 implements DashboardLayoutItem { @@ -40,7 +42,7 @@ export class ResponsiveGridItem extends SceneObjectBase 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 if (this.state.variableName) { this.performRepeat(); } + + const deactivate = this.state.conditionalRendering?.activate(); + + return () => { + if (deactivate) { + deactivate(); + } + }; } public getOptions(): OptionsPaneCategoryDescriptor { diff --git a/public/app/features/dashboard-scene/scene/layout-responsive-grid/ResponsiveGridItemEditor.tsx b/public/app/features/dashboard-scene/scene/layout-responsive-grid/ResponsiveGridItemEditor.tsx index 47be0b1272a..4e7b4f80dc9 100644 --- a/public/app/features/dashboard-scene/scene/layout-responsive-grid/ResponsiveGridItemEditor.tsx +++ b/public/app/features/dashboard-scene/scene/layout-responsive-grid/ResponsiveGridItemEditor.tsx @@ -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: () => , - }) - ); - - 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: () => , - }) - ); - - return category; -} - -function GridItemNoDataToggle({ item }: { item: ResponsiveGridItem }) { - const { hideWhenNoData } = item.useState(); - - return item.toggleHideWhenNoData()} />; -} - -function RepeatByOption({ item }: { item: ResponsiveGridItem }) { - const { variableName } = item.useState(); - - return ( - item.setRepeatByVariable(value)} - /> - ); + return useConditionalRenderingEditor(model.state.conditionalRendering)!; } diff --git a/public/app/features/dashboard-scene/scene/layout-responsive-grid/ResponsiveGridItemRenderer.tsx b/public/app/features/dashboard-scene/scene/layout-responsive-grid/ResponsiveGridItemRenderer.tsx index 9a039becb97..15570072389 100644 --- a/public/app/features/dashboard-scene/scene/layout-responsive-grid/ResponsiveGridItemRenderer.tsx +++ b/public/app/features/dashboard-scene/scene/layout-responsive-grid/ResponsiveGridItemRenderer.tsx @@ -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) { 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) => ( -
+
))} ) : ( -
+
); diff --git a/public/app/features/dashboard-scene/scene/layout-rows/RowItem.tsx b/public/app/features/dashboard-scene/scene/layout-rows/RowItem.tsx index b5119359850..a518ac891f4 100644 --- a/public/app/features/dashboard-scene/scene/layout-rows/RowItem.tsx +++ b/public/app/features/dashboard-scene/scene/layout-rows/RowItem.tsx @@ -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 { diff --git a/public/app/features/dashboard-scene/scene/layout-rows/RowItemEditor.tsx b/public/app/features/dashboard-scene/scene/layout-rows/RowItemEditor.tsx index b7fe35e580a..3467b7bbcc7 100644 --- a/public/app/features/dashboard-scene/scene/layout-rows/RowItemEditor.tsx +++ b/public/app/features/dashboard-scene/scene/layout-rows/RowItemEditor.tsx @@ -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: () => , + render: () => , }) ) .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 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); diff --git a/public/app/features/dashboard-scene/scene/layout-rows/RowItemRenderer.tsx b/public/app/features/dashboard-scene/scene/layout-rows/RowItemRenderer.tsx index e7af3b4f570..e69e6d8d407 100644 --- a/public/app/features/dashboard-scene/scene/layout-rows/RowItemRenderer.tsx +++ b/public/app/features/dashboard-scene/scene/layout-rows/RowItemRenderer.tsx @@ -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) { - 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(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 (
) { 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)) && (
@@ -58,11 +73,11 @@ export function RowItemRenderer({ model }: SceneComponentProps) { ? 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!)} > - {titleInterpolated} + {title} {!isClone && isEditing && } diff --git a/public/app/features/dashboard-scene/utils/clone.ts b/public/app/features/dashboard-scene/utils/clone.ts index 05d9459e0e2..f084b2e0182 100644 --- a/public/app/features/dashboard-scene/utils/clone.ts +++ b/public/app/features/dashboard-scene/utils/clone.ts @@ -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!); +} diff --git a/public/app/features/dashboard-scene/utils/utils.ts b/public/app/features/dashboard-scene/utils/utils.ts index f22210dd3d2..33321ca235b 100644 --- a/public/app/features/dashboard-scene/utils/utils.ts +++ b/public/app/features/dashboard-scene/utils/utils.ts @@ -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(scene: SceneObject): string { + const { title } = scene.useState(); + + if (!title) { + return ''; + } + + return sceneGraph.interpolate(scene, title, undefined, 'text'); +} diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index 95e6fe9744d..3d1bb21e1d3 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -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",