Variable options edit pane (#103852)

* Variable options in edit pane

* Variable options in edit pane

* Progress

* Variables, fixes blur events

* Update

* Update

* Update

* Progress

* Update

* Update
This commit is contained in:
Torkel Ödegaard
2025-04-15 10:38:24 +02:00
committed by GitHub
parent 8e63782e5b
commit daa5a48ef1
12 changed files with 283 additions and 76 deletions
+2 -1
View File
@@ -1606,7 +1606,8 @@ exports[`better eslint`] = {
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]
],
"public/app/features/dashboard-scene/settings/variables/utils.ts:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]
[0, 0, 0, "Do not use any type assertions.", "0"],
[0, 0, 0, "Unexpected any. Specify a different type.", "1"]
],
"public/app/features/dashboard-scene/settings/version-history/RevertDashboardModal.tsx:5381": [
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"]
+2 -2
View File
@@ -275,8 +275,8 @@
"@grafana/prometheus": "workspace:*",
"@grafana/runtime": "workspace:*",
"@grafana/saga-icons": "workspace:*",
"@grafana/scenes": "^6.7.0",
"@grafana/scenes-react": "^6.7.0",
"@grafana/scenes": "^6.8.1",
"@grafana/scenes-react": "^6.8.1",
"@grafana/schema": "workspace:*",
"@grafana/sql": "workspace:*",
"@grafana/ui": "workspace:*",
@@ -132,13 +132,7 @@ export class DashboardEditPane extends SceneObjectBase<DashboardEditPaneState> {
const { selection, contextItems: selected } = elementSelection.getStateWithValue(id, obj, !!multi);
this.setState({
selection: new ElementSelection(selection),
selectionContext: {
...this.state.selectionContext,
selected,
},
});
this.updateSelection(new ElementSelection(selection), selected);
}
private removeMultiSelectedObject(id: string) {
@@ -153,13 +147,17 @@ export class DashboardEditPane extends SceneObjectBase<DashboardEditPaneState> {
return;
}
this.setState({
selection: new ElementSelection([...entries]),
selectionContext: {
...this.state.selectionContext,
selected,
},
});
this.updateSelection(new ElementSelection([...entries]), selected);
}
private updateSelection(selection: ElementSelection | undefined, selected: ElementSelectionContextItem[]) {
// onBlur events are not fired on unmount and some edit pane inputs have important onBlur events
// This make sure they fire before unmounting
if (document.activeElement instanceof HTMLElement) {
document.activeElement.blur();
}
this.setState({ selection, selectionContext: { ...this.state.selectionContext, selected } });
}
public clearSelection() {
@@ -167,13 +165,7 @@ export class DashboardEditPane extends SceneObjectBase<DashboardEditPaneState> {
return;
}
this.setState({
selection: undefined,
selectionContext: {
...this.state.selectionContext,
selected: [],
},
});
this.updateSelection(undefined, []);
}
private newObjectAddedToCanvas(obj: SceneObject) {
@@ -33,6 +33,17 @@ export function VariableValueSelectWrapper({ variable }: VariableSelectProps) {
}
const onPointerDown = (evt: React.PointerEvent) => {
if (!isSelectable) {
return;
}
// Ignore click if it's inside the value control
if (evt.target instanceof Element && !evt.target.closest(`label`)) {
// Prevent clearing selection when clicking inside value
evt.stopPropagation();
return;
}
if (isSelectable && onSelect) {
evt.stopPropagation();
onSelect(evt);
@@ -7,7 +7,7 @@ import {
ValidateAndUpdateResult,
VariableDependencyConfig,
VariableValueOption,
renderSelectForVariable,
MultiOrSingleValueSelect,
sceneGraph,
VariableGetOptionsArgs,
} from '@grafana/scenes';
@@ -64,7 +64,7 @@ export class SnapshotVariable extends MultiValueVariable<SnapshotVariableState>
}
public static Component = ({ model }: SceneComponentProps<MultiValueVariable<SnapshotVariableState>>) => {
return renderSelectForVariable(model);
return <MultiOrSingleValueSelect model={model} />;
};
// we will always preserve the current value and text for snapshots
private _updateValueGivenNewOptions(options: VariableValueOption[]) {
@@ -2,8 +2,8 @@ import { FormEvent, useMemo, useState } from 'react';
import { VariableHide } from '@grafana/data';
import { locationService } from '@grafana/runtime';
import { SceneVariable, SceneVariableSet } from '@grafana/scenes';
import { Combobox, Input, TextArea, Stack, Button, Field } from '@grafana/ui';
import { MultiValueVariable, SceneVariable, SceneVariableSet } from '@grafana/scenes';
import { Input, TextArea, Button, Field, Box } from '@grafana/ui';
import { t, Trans } from 'app/core/internationalization';
import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor';
import { OptionsPaneItemDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneItemDescriptor';
@@ -13,7 +13,9 @@ import { useEditPaneInputAutoFocus } from '../../scene/layouts-shared/utils';
import { BulkActionElement } from '../../scene/types/BulkActionElement';
import { EditableDashboardElement, EditableDashboardElementInfo } from '../../scene/types/EditableDashboardElement';
import { VariableHideSelect } from '../../settings/variables/components/VariableHideSelect';
import { getVariableTypeSelectOptions, validateVariableName } from '../../settings/variables/utils';
import { getEditableVariableDefinition, validateVariableName } from '../../settings/variables/utils';
import { useVariableSelectionOptionsCategory } from './useVariableSelectionOptionsCategory';
export class VariableEditableElement implements EditableDashboardElement, BulkActionElement {
public readonly isEditableDashboardElement = true;
@@ -22,8 +24,10 @@ export class VariableEditableElement implements EditableDashboardElement, BulkAc
public constructor(public variable: SceneVariable) {}
public getEditableElementInfo(): EditableDashboardElementInfo {
const variableEditorDef = getEditableVariableDefinition(this.variable.state.type);
return {
typeName: t('dashboard.edit-pane.elements.variable', 'Variable'),
typeName: t('dashboard.edit-pane.elements.variable', '{{type}} variable', { type: variableEditorDef.name }),
icon: 'dollar-alt',
instanceName: this.variable.state.name,
isHidden: this.variable.state.hide === VariableHide.hideVariable,
@@ -33,8 +37,8 @@ export class VariableEditableElement implements EditableDashboardElement, BulkAc
public useEditPaneOptions(isNewElement: boolean): OptionsPaneCategoryDescriptor[] {
const variable = this.variable;
const options = useMemo(() => {
return new OptionsPaneCategoryDescriptor({ title: '', id: 'panel-options' })
const basicOptions = useMemo(() => {
return new OptionsPaneCategoryDescriptor({ title: '', id: 'variable-options' })
.addItem(
new OptionsPaneItemDescriptor({
title: '',
@@ -44,17 +48,14 @@ export class VariableEditableElement implements EditableDashboardElement, BulkAc
)
.addItem(
new OptionsPaneItemDescriptor({
title: t('dashboard-scene.variable-editor-form.label', 'Label'),
description: t(
'dashboard-scene.variable-editor-form.description-optional-display-name',
'Optional display name'
),
title: t('dashboard.edit-pane.variable.label', 'Label'),
description: t('dashboard.edit-pane.variable.label-description', 'Optional display name'),
render: () => <VariableLabelInput variable={variable} />,
})
)
.addItem(
new OptionsPaneItemDescriptor({
title: t('dashboard-scene.variable-editor-form.description', 'Description'),
title: t('dashboard.edit-pane.variable.description', 'Description'),
render: () => <VariableDescriptionTextArea variable={variable} />,
})
)
@@ -64,16 +65,18 @@ export class VariableEditableElement implements EditableDashboardElement, BulkAc
skipField: true,
render: () => <VariableHideInput variable={variable} />,
})
)
.addItem(
new OptionsPaneItemDescriptor({
title: t('dashboard-scene.variable-editor-form.type', 'Type'),
render: () => <VariableTypeSelect variable={variable} />,
})
);
}, [variable, isNewElement]);
return [options];
const categories = [basicOptions];
const typeCategory = useVariableTypeCategory(variable);
categories.push(typeCategory);
if (variable instanceof MultiValueVariable) {
categories.push(useVariableSelectionOptionsCategory(variable));
}
return categories;
}
public onDelete() {
@@ -126,7 +129,7 @@ function VariableNameInput({ variable, isNewElement }: { variable: SceneVariable
};
return (
<Field label={t('dashboard-scene.variable-editor-form.name', 'Name')} invalid={!!nameError} error={nameError}>
<Field label={t('dashboard.edit-pane.variable.name', 'Name')} invalid={!!nameError} error={nameError}>
<Input ref={ref} value={name} onChange={onChange} required onBlur={onBlur} />
</Field>
);
@@ -144,7 +147,7 @@ function VariableDescriptionTextArea({ variable }: VariableInputProps) {
<TextArea
id="description-text-area"
value={description ?? ''}
placeholder={t('dashboard-scene.variable-editor-form.placeholder-descriptive-text', 'Descriptive text')}
placeholder={t('dashboard.edit-pane.variable.description-placeholder', 'Descriptive text')}
onChange={(e) => variable.setState({ description: e.currentTarget.value })}
/>
);
@@ -160,9 +163,37 @@ function VariableHideInput({ variable }: VariableInputProps) {
return <VariableHideSelect hide={hide} type={variable.state.type} onChange={onChange} />;
}
function VariableTypeSelect({ variable }: VariableInputProps) {
const options = useMemo(() => getVariableTypeSelectOptions().map((o) => ({ value: o.value!, label: o.label })), []);
function useVariableTypeCategory(variable: SceneVariable) {
return useMemo(() => {
const variableEditorDef = getEditableVariableDefinition(variable.state.type);
const categoryName = t('dashboard.edit-pane.variable.type-category', '{{type}} options', {
type: variableEditorDef.name,
});
const category = new OptionsPaneCategoryDescriptor({
title: categoryName,
id: 'variable-type',
isOpenDefault: true,
});
if (variableEditorDef.getOptions) {
const options = variableEditorDef.getOptions(variable);
options.forEach((option) => category.addItem(option));
} else {
category.addItem(
new OptionsPaneItemDescriptor({
title: '',
skipField: true,
render: () => <OpenOldVariableEditButton variable={variable} />,
})
);
}
return category;
}, [variable]);
}
function OpenOldVariableEditButton({ variable }: VariableInputProps) {
const onOpenVariableEdior = () => {
const set = variable.parent!;
if (!(set instanceof SceneVariableSet)) {
@@ -174,18 +205,18 @@ function VariableTypeSelect({ variable }: VariableInputProps) {
};
return (
<Stack gap={2} direction={'column'}>
<Combobox value={variable.state.type} options={options} disabled={true} onChange={() => {}} />
<Box display={'flex'} direction={'column'} paddingBottom={1}>
<Button
tooltip={t(
'dashboard-scene.variable-editor-form.open-editor-tooltip',
'dashboard.edit-pane.variable.open-editor-tooltip',
'For more variable options open variable editor'
)}
onClick={onOpenVariableEdior}
size="sm"
fullWidth
>
<Trans i18nKey="dashboard-scene.variable-editor-form.open-editor">Open variable editor</Trans>
<Trans i18nKey="dashboard.edit-pane.variable.open-editor">Open variable editor</Trans>
</Button>
</Stack>
</Box>
);
}
@@ -1,7 +1,11 @@
import { FormEvent } from 'react';
import { lastValueFrom } from 'rxjs';
import { selectors } from '@grafana/e2e-selectors';
import { Trans } from 'app/core/internationalization';
import { CustomVariable, SceneVariable } from '@grafana/scenes';
import { TextArea } from '@grafana/ui';
import { t, Trans } from 'app/core/internationalization';
import { OptionsPaneItemDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneItemDescriptor';
import { VariableLegend } from '../components/VariableLegend';
import { VariableTextAreaField } from '../components/VariableTextAreaField';
@@ -67,3 +71,39 @@ export function CustomVariableForm({
</>
);
}
export function getCustomVariableOptions(variable: SceneVariable): OptionsPaneItemDescriptor[] {
if (!(variable instanceof CustomVariable)) {
return [];
}
return [
new OptionsPaneItemDescriptor({
title: t('dashboard.edit-pane.variable.custom-options.values', 'Values separated by comma'),
render: () => <ValuesTextField variable={variable} />,
}),
];
}
function ValuesTextField({ variable }: { variable: CustomVariable }) {
const { query } = variable.useState();
const onBlur = async (event: FormEvent<HTMLTextAreaElement>) => {
variable.setState({ query: event.currentTarget.value });
await lastValueFrom(variable.validateAndUpdate!());
};
return (
<TextArea
rows={2}
defaultValue={query}
onBlur={onBlur}
placeholder={t(
'dashboard.edit-pane.variable.custom-options.values-placeholder',
'1, 10, mykey : myvalue, myvalue, escaped\,value'
)}
required
data-testid={selectors.pages.Dashboard.Settings.Variables.Edit.CustomVariable.customValueInput}
/>
);
}
@@ -56,7 +56,7 @@ export function SelectionOptionsForm({
name="Include All option"
description={t(
'dashboard-scene.selection-options-form.description-enables-option-include-variables',
'Enables an option to include all variables'
'Enables an option to include all values'
)}
onChange={onIncludeAllChange}
testId={selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsIncludeAllSwitch}
@@ -0,0 +1,101 @@
import { useCallback, useMemo, useRef } from 'react';
import { MultiValueVariable, SceneVariableValueChangedEvent } from '@grafana/scenes';
import { Input, 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';
export function useVariableSelectionOptionsCategory(variable: MultiValueVariable): OptionsPaneCategoryDescriptor {
return useMemo(() => {
return new OptionsPaneCategoryDescriptor({
title: t('dashboard.edit-pane.variable.selection-options.category', 'Selection options'),
id: 'selection-options-category',
isOpenDefault: true,
})
.addItem(
new OptionsPaneItemDescriptor({
title: t('dashboard.edit-pane.variable.selection-options.multi-value', 'Multi-value'),
render: () => <MultiValueSwitch variable={variable} />,
})
)
.addItem(
new OptionsPaneItemDescriptor({
title: t('dashboard.edit-pane.variable.selection-options.include-all', 'Include All option'),
description: t(
'dashboard.edit-pane.variable.selection-options.include-all-description',
'Enables an option to include all values'
),
render: () => <IncludeAllSwitch variable={variable} />,
})
)
.addItem(
new OptionsPaneItemDescriptor({
title: t('dashboard.edit-pane.variable.selection-options.custom-all-value', 'Custom all value'),
description: t(
'dashboard.edit-pane.variable.selection-options.custom-all-value-description',
'A wildcard regex or other value to represent All'
),
useShowIf: () => {
return variable.useState().includeAll ?? false;
},
render: () => <CustomAllValueInput variable={variable} />,
})
)
.addItem(
new OptionsPaneItemDescriptor({
title: t('dashboard.edit-pane.variable.selection-options.allow-custom-values', 'Allow custom values'),
description: t(
'dashboard.edit-pane.variable.selection-options.allow-custom-values-description',
'Enables users to enter values'
),
render: () => <AllowCustomSwitch variable={variable} />,
})
);
}, [variable]);
}
function MultiValueSwitch({ variable }: { variable: MultiValueVariable }) {
const { isMulti } = variable.useState();
return <Switch value={isMulti} onChange={(evt) => variable.setState({ isMulti: evt.currentTarget.checked })} />;
}
function IncludeAllSwitch({ variable }: { variable: MultiValueVariable }) {
const { includeAll } = variable.useState();
return <Switch value={includeAll} onChange={(evt) => variable.setState({ includeAll: evt.currentTarget.checked })} />;
}
function AllowCustomSwitch({ variable }: { variable: MultiValueVariable }) {
const { allowCustomValue } = variable.useState();
return (
<Switch
value={allowCustomValue}
onChange={(evt) => variable.setState({ allowCustomValue: evt.currentTarget.checked })}
/>
);
}
function CustomAllValueInput({ variable }: { variable: MultiValueVariable }) {
const { allValue } = variable.useState();
const ref = useRef<HTMLInputElement>(null);
const onInputBlur = useCallback(
(evt: React.FocusEvent<HTMLInputElement>) => {
const newValue = evt.currentTarget.value;
if (newValue === variable.state.allValue) {
return;
}
variable.setState({ allValue: newValue });
if (variable.hasAllValue()) {
variable.publishEvent(new SceneVariableValueChangedEvent(variable), true);
}
},
[variable]
);
return <Input ref={ref} defaultValue={allValue ?? ''} onBlur={onInputBlur} />;
}
@@ -19,9 +19,11 @@ import {
SceneVariableSet,
} from '@grafana/scenes';
import { VariableHide, VariableType } from '@grafana/schema';
import { OptionsPaneItemDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneItemDescriptor';
import { getIntervalsQueryFromNewIntervalModel } from '../../utils/utils';
import { getCustomVariableOptions } from './components/CustomVariableForm';
import { AdHocFiltersVariableEditor } from './editors/AdHocFiltersVariableEditor';
import { ConstantVariableEditor } from './editors/ConstantVariableEditor';
import { CustomVariableEditor } from './editors/CustomVariableEditor';
@@ -35,6 +37,7 @@ interface EditableVariableConfig {
name: string;
description: string;
editor: React.ComponentType<any>;
getOptions?: (variable: SceneVariable) => OptionsPaneItemDescriptor[];
}
//exclude system variable type and snapshot variable type
@@ -49,6 +52,7 @@ export const EDITABLE_VARIABLES: Record<EditableVariableType, EditableVariableCo
name: 'Custom',
description: 'Define variable values manually',
editor: CustomVariableEditor,
getOptions: getCustomVariableOptions,
},
query: {
name: 'Query',
@@ -87,6 +91,15 @@ export const EDITABLE_VARIABLES: Record<EditableVariableType, EditableVariableCo
},
};
export function getEditableVariableDefinition(type: string): EditableVariableConfig {
const editableVariable = EDITABLE_VARIABLES[type as EditableVariableType];
if (!editableVariable) {
throw new Error(`Variable type ${type} not found`);
}
return editableVariable;
}
export const EDITABLE_VARIABLES_SELECT_ORDER: EditableVariableType[] = [
'query',
'custom',
+27 -9
View File
@@ -2836,7 +2836,7 @@
"rows": "Rows",
"tab": "Tab",
"tabs": "Tabs",
"variable": "Variable",
"variable": "{{type}} variable",
"variable-set": "Variables"
},
"open": "Open options pane",
@@ -2846,6 +2846,30 @@
"title": "Row header"
}
},
"variable": {
"custom-options": {
"values": "Values separated by comma",
"values-placeholder": "1, 10, mykey : myvalue, myvalue, escaped,value"
},
"description": "Description",
"description-placeholder": "Descriptive text",
"label": "Label",
"label-description": "Optional display name",
"name": "Name",
"open-editor": "Open variable editor",
"open-editor-tooltip": "For more variable options open variable editor",
"selection-options": {
"allow-custom-values": "Allow custom values",
"allow-custom-values-description": "Enables users to enter values",
"category": "Selection options",
"custom-all-value": "Custom all value",
"custom-all-value-description": "A wildcard regex or other value to represent All",
"include-all": "Include All option",
"include-all-description": "Enables an option to include all values",
"multi-value": "Multi-value"
},
"type-category": "{{type}} options"
},
"variables": {
"add-variable": "Add variable",
"select-variable": "Select"
@@ -3674,7 +3698,7 @@
},
"selection-options-form": {
"description-enables-multiple-values-selected": "Enables multiple values to be selected at the same time",
"description-enables-option-include-variables": "Enables an option to include all variables",
"description-enables-option-include-variables": "Enables an option to include all values",
"description-enables-users-custom-values": "Enables users to add custom values to the list"
},
"share-button": {
@@ -3710,19 +3734,13 @@
"aria-label-variable-editor-form": "Variable editor form",
"back-to-list": "Back to list",
"delete": "Delete",
"description": "Description",
"description-optional-display-name": "Optional display name",
"description-template-variable-characters": "The name of the template variable. (Max. 50 characters)",
"general": "General",
"label": "Label",
"name": "Name",
"open-editor": "Open variable editor",
"open-editor-tooltip": "For more variable options open variable editor",
"placeholder-descriptive-text": "Descriptive text",
"placeholder-label-name": "Label name",
"placeholder-variable-name": "Variable name",
"text-running-query": "Running query...",
"type": "Type"
"text-running-query": "Running query..."
},
"variable-editor-list": {
"definition": "Definition",
+11 -11
View File
@@ -3465,11 +3465,11 @@ __metadata:
languageName: unknown
linkType: soft
"@grafana/scenes-react@npm:^6.7.0":
version: 6.7.0
resolution: "@grafana/scenes-react@npm:6.7.0"
"@grafana/scenes-react@npm:^6.8.1":
version: 6.8.1
resolution: "@grafana/scenes-react@npm:6.8.1"
dependencies:
"@grafana/scenes": "npm:6.7.0"
"@grafana/scenes": "npm:6.8.1"
lru-cache: "npm:^10.2.2"
react-use: "npm:^17.4.0"
peerDependencies:
@@ -3481,13 +3481,13 @@ __metadata:
react: ^18.0.0
react-dom: ^18.0.0
react-router-dom: ^6.28.0
checksum: 10/3561d1d0a9a2c07d8e5b560ff8cc03de017d29c73f1451f03f0d56a0cfb71e71bfe1c1e657840d9afb2fe5ed7e101532cbc843322b1d9a54ee2870a05acf77fb
checksum: 10/798595f91971beb5ba34989e7f6aabe8208c28011e36ca2da0f451bc112f760318a14c93d336ef08c99601ef386f25b70568e12c62c6e4a1f258bc93490dc111
languageName: node
linkType: hard
"@grafana/scenes@npm:6.7.0, @grafana/scenes@npm:^6.7.0":
version: 6.7.0
resolution: "@grafana/scenes@npm:6.7.0"
"@grafana/scenes@npm:6.8.1, @grafana/scenes@npm:^6.8.1":
version: 6.8.1
resolution: "@grafana/scenes@npm:6.8.1"
dependencies:
"@floating-ui/react": "npm:^0.26.16"
"@leeoniya/ufuzzy": "npm:^1.0.16"
@@ -3505,7 +3505,7 @@ __metadata:
react: ^18.0.0
react-dom: ^18.0.0
react-router-dom: ^6.28.0
checksum: 10/5b0a7f49b11d88a10c67f697c7951d730d6e3bf2d7ca6354134ccf099009feaa269ac7aa559147c3565a925935db2cf5c9d645c0e194d9cd02a99566d7b7299a
checksum: 10/e465eb864ab8a8c4a7dea1a90db1774630ee9d0cdff89799aaae5765c1eac313bd5e96ebb8dac51c41cdcaf5c78f46da6c7eeea18c4a506254990817c05aa840
languageName: node
linkType: hard
@@ -17620,8 +17620,8 @@ __metadata:
"@grafana/prometheus": "workspace:*"
"@grafana/runtime": "workspace:*"
"@grafana/saga-icons": "workspace:*"
"@grafana/scenes": "npm:^6.7.0"
"@grafana/scenes-react": "npm:^6.7.0"
"@grafana/scenes": "npm:^6.8.1"
"@grafana/scenes-react": "npm:^6.8.1"
"@grafana/schema": "workspace:*"
"@grafana/sql": "workspace:*"
"@grafana/tsconfig": "npm:^2.0.0"