Dashboard: Improve static options editors on variables

This commit is contained in:
Bogdan Matei 2025-09-09 19:21:59 +03:00
parent 2c5ccd3283
commit aa13249b42
11 changed files with 469 additions and 167 deletions

View File

@ -2194,11 +2194,6 @@
"count": 1
}
},
"public/app/features/dashboard-scene/settings/variables/editors/QueryVariableEditor.tsx": {
"no-restricted-syntax": {
"count": 1
}
},
"public/app/features/dashboard-scene/settings/variables/utils.ts": {
"@typescript-eslint/consistent-type-assertions": {
"count": 1

View File

@ -559,6 +559,12 @@ export const versionedPages = {
customValueInput: {
[MIN_GRAFANA_VERSION]: 'data-testid custom-variable-input',
},
optionsOpenButton: {
[MIN_GRAFANA_VERSION]: 'data-testid custom-variable-options-open-button',
},
closeButton: {
[MIN_GRAFANA_VERSION]: 'data-testid custom-variable-close-button',
},
},
IntervalVariable: {
intervalsValueInput: {

View File

@ -1,16 +1,11 @@
import { FormEvent } from 'react';
import { lastValueFrom } from 'rxjs';
import { selectors } from '@grafana/e2e-selectors';
import { Trans, t } from '@grafana/i18n';
import { CustomVariable, SceneVariable } from '@grafana/scenes';
import { TextArea } from '@grafana/ui';
import { OptionsPaneItemDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneItemDescriptor';
import { VariableLegend } from '../components/VariableLegend';
import { VariableTextAreaField } from '../components/VariableTextAreaField';
import { SelectionOptionsForm } from './SelectionOptionsForm';
import { VariableLegend } from './VariableLegend';
import { VariableTextAreaField } from './VariableTextAreaField';
interface CustomVariableFormProps {
query: string;
@ -71,41 +66,3 @@ 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'),
id: 'custom-variable-values',
render: (descriptor) => <ValuesTextField id={descriptor.props.id} variable={variable} />,
}),
];
}
function ValuesTextField({ variable, id }: { variable: CustomVariable; id?: string }) {
const { query } = variable.useState();
const onBlur = async (event: FormEvent<HTMLTextAreaElement>) => {
variable.setState({ query: event.currentTarget.value });
await lastValueFrom(variable.validateAndUpdate!());
};
return (
<TextArea
id={id}
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}
/>
);
}

View File

@ -1,106 +0,0 @@
import { useState } from 'react';
import { selectors } from '@grafana/e2e-selectors';
import { t, Trans } from '@grafana/i18n';
import { VariableValueOption } from '@grafana/scenes';
import { Button, Input, Stack } from '@grafana/ui';
interface VariableOptionsFieldProps {
options: VariableValueOption[];
onChange: (options: VariableValueOption[]) => void;
width?: number;
}
export function VariableOptionsInput({ options, onChange, width }: VariableOptionsFieldProps) {
const [optionsLocal, setOptionsLocal] = useState(options.length ? options : [{ value: '', label: '' }]);
const updateOptions = (newOptions: VariableValueOption[]) => {
setOptionsLocal(newOptions);
onChange(
newOptions
.map((option) => ({
label: option.label.trim(),
value: String(option.value).trim(),
}))
.filter((option) => !!option.label)
);
};
const handleValueChange = (index: number, value: string) => {
if (optionsLocal[index].value !== value) {
const newOptions = [...optionsLocal];
newOptions[index] = { ...newOptions[index], value };
updateOptions(newOptions);
}
};
const handleLabelChange = (index: number, label: string) => {
if (optionsLocal[index].label !== label) {
const newOptions = [...optionsLocal];
newOptions[index] = { ...newOptions[index], label };
updateOptions(newOptions);
}
};
const addOption = () => {
const newOption: VariableValueOption = { value: '', label: '' };
const newOptions = [...optionsLocal, newOption];
updateOptions(newOptions);
};
const removeOption = (index: number) => {
const newOptions = optionsLocal.filter((_, i) => i !== index);
updateOptions(newOptions);
};
return (
<Stack direction="column" gap={2} width={width}>
{optionsLocal.map((option, index) => (
<Stack
direction="row"
key={index}
data-testid={selectors.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsStaticOptionsRow}
>
<Input
value={option.label}
placeholder={t('variables.query-variable-static-options.label-placeholder', 'display label')}
onChange={(e) => handleLabelChange(index, e.currentTarget.value)}
data-testid={
selectors.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsStaticOptionsLabelInput
}
/>
<Input
value={String(option.value)}
placeholder={t('variables.query-variable-static-options.value-placeholder', 'value, default empty string')}
onChange={(e) => handleValueChange(index, e.currentTarget.value)}
data-testid={
selectors.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsStaticOptionsValueInput
}
/>
<Button
icon="times"
variant="secondary"
aria-label={t('variables.query-variable-static-options.remove-option-button-label', 'Remove option')}
onClick={() => removeOption(index)}
data-testid={
selectors.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsStaticOptionsDeleteButton
}
/>
</Stack>
))}
<div>
<Button
icon="plus"
variant="secondary"
onClick={addOption}
data-testid={
selectors.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsStaticOptionsAddButton
}
aria-label={t('variables.query-variable-static-options.add-option-button-label', 'Add option')}
>
<Trans i18nKey="variables.query-variable-static-options.add-option-button-label">Add option</Trans>
</Button>
</div>
</Stack>
);
}

View File

@ -0,0 +1,113 @@
import { useEffect, useState, useRef } from 'react';
import { v4 as uuidv4 } from 'uuid';
import { selectors } from '@grafana/e2e-selectors';
import { t, Trans } from '@grafana/i18n';
import { VariableValueOption } from '@grafana/scenes';
import { Button, Stack } from '@grafana/ui';
import { VariableStaticOptionsFormItem } from './VariableStaticOptionsFormItemEditor';
import { VariableStaticOptionsFormItems } from './VariableStaticOptionsFormItems';
interface VariableStaticOptionsFormProps {
options: VariableValueOption[];
onChange: (options: VariableValueOption[]) => void;
allowEmptyValue?: boolean;
width?: number;
}
export function VariableStaticOptionsForm({
options,
onChange,
allowEmptyValue,
width,
}: VariableStaticOptionsFormProps) {
// Whenever the form is updated, we want to ignore the next update from the parent component.
// This is because the parent component will update the options, and we don't want to update the items again.
// This is a hack to prevent the form from updating twice and losing items and IDs.
// Alternatively, we could maintain a list of emitted items and compare the new options to it, but this is less performant.
const ignoreNextUpdate = useRef<boolean>(false);
const createEmptyItem: () => VariableStaticOptionsFormItem = () => {
return {
label: '',
value: '',
id: uuidv4(),
};
};
const [items, setItems] = useState<VariableStaticOptionsFormItem[]>(
options.length
? options.map((option) => ({
label: option.label,
value: String(option.value),
id: uuidv4(),
}))
: [createEmptyItem()]
);
useEffect(() => {
if (!ignoreNextUpdate.current) {
setItems(
options.length
? options.map((option) => ({
label: option.label,
value: String(option.value),
id: uuidv4(),
}))
: [createEmptyItem()]
);
}
ignoreNextUpdate.current = false;
}, [options]);
const updateItems = (items: VariableStaticOptionsFormItem[]) => {
setItems(items);
ignoreNextUpdate.current = true;
onChange(
items.reduce<VariableValueOption[]>((acc, item) => {
const value = item.value.trim();
if (!allowEmptyValue && !value) {
return acc;
}
const label = item.label.trim();
if (!label && !value) {
return acc;
}
acc.push({
label: label ? label : value,
value,
});
return acc;
}, [])
);
};
const handleAdd = () => setItems([...items, createEmptyItem()]);
return (
<Stack direction="column" gap={2} width={width}>
<VariableStaticOptionsFormItems items={items} onChange={updateItems} width={width} />
<div>
<Button
icon="plus"
variant="secondary"
onClick={handleAdd}
data-testid={
selectors.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsStaticOptionsAddButton
}
aria-label={t('variables.query-variable-static-options.add-option-button-label', 'Add option')}
>
<Trans i18nKey="variables.query-variable-static-options.add-option-button-label">Add option</Trans>
</Button>
</div>
</Stack>
);
}

View File

@ -0,0 +1,103 @@
import { css } from '@emotion/css';
import { Draggable } from '@hello-pangea/dnd';
import { ChangeEventHandler } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { t } from '@grafana/i18n';
import { Button, Icon, Input, Stack, useStyles2 } from '@grafana/ui';
export interface VariableStaticOptionsFormItem {
id: string;
label: string;
value: string;
}
interface VariableStaticOptionsFormItemEditorProps {
item: VariableStaticOptionsFormItem;
index: number;
onChange: (item: VariableStaticOptionsFormItem) => void;
onRemove: (item: VariableStaticOptionsFormItem) => void;
}
export function VariableStaticOptionsFormItemEditor({
item,
index,
onChange,
onRemove,
}: VariableStaticOptionsFormItemEditorProps) {
const styles = useStyles2(getStyles);
const handleValueChange: ChangeEventHandler<HTMLInputElement> = (evt) => {
if (item.value !== evt.currentTarget.value) {
onChange({ ...item, value: evt.currentTarget.value });
}
};
const handleLabelChange: ChangeEventHandler<HTMLInputElement> = (evt) => {
if (item.label !== evt.currentTarget.value) {
onChange({ ...item, label: evt.currentTarget.value });
}
};
const handleRemove = () => onRemove(item);
return (
<Draggable draggableId={item.id} index={index}>
{(draggableProvided) => (
<Stack
ref={draggableProvided.innerRef}
direction="row"
alignItems="center"
data-testid={selectors.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsStaticOptionsRow}
{...draggableProvided.draggableProps}
>
<div {...draggableProvided.dragHandleProps}>
<Icon
title={t('variables.query-variable-static-options.drag-and-drop', 'Drag and drop to reorder')}
name="draggabledots"
size="lg"
className={styles.dragIcon}
/>
</div>
<Input
value={item.value}
placeholder={t('variables.query-variable-static-options.value-placeholder', 'Value')}
onChange={handleValueChange}
data-testid={
selectors.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsStaticOptionsValueInput
}
/>
<Input
value={item.label}
placeholder={t('variables.query-variable-static-options.label-placeholder', 'Label, defaults to value')}
onChange={handleLabelChange}
data-testid={
selectors.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsStaticOptionsLabelInput
}
/>
<Button
icon="times"
variant="secondary"
aria-label={t('variables.query-variable-static-options.remove-option-button-label', 'Remove option')}
onClick={handleRemove}
data-testid={
selectors.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsStaticOptionsDeleteButton
}
/>
</Stack>
)}
</Draggable>
);
}
const getStyles = (theme: GrafanaTheme2) => ({
dragIcon: css({
cursor: 'grab',
color: theme.colors.text.disabled,
'&:hover': {
color: theme.colors.text.primary,
},
}),
});

View File

@ -0,0 +1,78 @@
import { DragDropContext, Droppable, DropResult } from '@hello-pangea/dnd';
import { Stack } from '@grafana/ui';
import {
VariableStaticOptionsFormItem,
VariableStaticOptionsFormItemEditor,
} from './VariableStaticOptionsFormItemEditor';
interface VariableStaticOptionsFormProps {
items: VariableStaticOptionsFormItem[];
onChange: (items: VariableStaticOptionsFormItem[]) => void;
width?: number;
}
export function VariableStaticOptionsFormItems({ items, onChange, width }: VariableStaticOptionsFormProps) {
const handleReorder = (result: DropResult) => {
if (!result || !result.destination) {
return;
}
const startIdx = result.source.index;
const endIdx = result.destination.index;
if (startIdx === endIdx) {
return;
}
const newItems = [...items];
const [removedItem] = newItems.splice(startIdx, 1);
newItems.splice(endIdx, 0, removedItem);
onChange(newItems);
};
const handleChange = (item: VariableStaticOptionsFormItem) => {
const idx = items.findIndex((currentItem) => currentItem.id === item.id);
if (idx === -1) {
return;
}
const newOptions = [...items];
newOptions[idx] = item;
onChange(newOptions);
};
const handleRemove = (item: VariableStaticOptionsFormItem) => {
const newOptions = items.filter((currentItem) => currentItem.id !== item.id);
onChange(newOptions);
};
return (
<DragDropContext onDragEnd={handleReorder}>
<Droppable droppableId="static-options-list" direction="vertical">
{(droppableProvided) => (
<Stack
direction="column"
gap={2}
width={width}
ref={droppableProvided.innerRef}
{...droppableProvided.droppableProps}
>
{items.map((item, idx) => (
<VariableStaticOptionsFormItemEditor
item={item}
index={idx}
onChange={handleChange}
onRemove={handleRemove}
key={item.id}
/>
))}
{droppableProvided.placeholder}
</Stack>
)}
</Droppable>
</DragDropContext>
);
}

View File

@ -1,13 +1,17 @@
import { FormEvent } from 'react';
import { FormEvent, useMemo, useState } from 'react';
import { lastValueFrom } from 'rxjs';
import { SelectableValue } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { t } from '@grafana/i18n';
import { CustomVariable, SceneVariable } from '@grafana/scenes';
import { TextArea } from '@grafana/ui';
import { t, Trans } from '@grafana/i18n';
import { CustomVariable, SceneVariable, VariableValueOption } from '@grafana/scenes';
import { Box, Button, Modal, RadioButtonGroup, Stack, TextArea } from '@grafana/ui';
import { OptionsPaneItemDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneItemDescriptor';
import { CustomVariableForm } from '../components/CustomVariableForm';
import { VariableStaticOptionsForm } from '../components/VariableStaticOptionsForm';
import { VariableValuesPreview } from '../components/VariableValuesPreview';
import { hasVariableOptions } from '../utils';
interface CustomVariableEditorProps {
variable: CustomVariable;
@ -59,11 +63,123 @@ export function getCustomVariableOptions(variable: SceneVariable): OptionsPaneIt
new OptionsPaneItemDescriptor({
title: t('dashboard.edit-pane.variable.custom-options.values', 'Values separated by comma'),
id: 'custom-variable-values',
render: ({ props }) => <ValuesTextField id={props.id} variable={variable} />,
render: ({ props }) => <ModalEditor id={props.id} variable={variable} />,
}),
];
}
export function ModalEditor({ variable, id }: { variable: CustomVariable; id?: string }) {
const [isOpen, setIsOpen] = useState(false);
return (
<>
<Box display="flex" direction="column" paddingBottom={1}>
<Button
tooltip={t(
'dashboard.edit-pane.variable.open-editor-tooltip',
'For more variable options open variable editor'
)}
onClick={() => setIsOpen(true)}
size="sm"
fullWidth
data-testid={selectors.pages.Dashboard.Settings.Variables.Edit.CustomVariable.optionsOpenButton}
>
<Trans i18nKey="dashboard.edit-pane.variable.open-editor">Open variable editor</Trans>
</Button>
</Box>
<Modal
title={t('dashboard.edit-pane.variable.custom-options.modal-title', 'Custom Variable')}
isOpen={isOpen}
onDismiss={() => setIsOpen(false)}
>
<Editor variable={variable} id={id} />
<Modal.ButtonRow>
<Button
variant="secondary"
fill="outline"
onClick={() => setIsOpen(false)}
data-testid={selectors.pages.Dashboard.Settings.Variables.Edit.CustomVariable.closeButton}
>
<Trans i18nKey="dashboard.edit-pane.variable.custom-options.close">Close</Trans>
</Button>
</Modal.ButtonRow>
</Modal>
</>
);
}
function Editor({ variable, id }: { variable: CustomVariable; id?: string }) {
// Workaround to toggle a component refresh when values change so that the preview is updated
variable.useState();
const [editorType, setEditorType] = useState<'builder' | 'static'>('builder');
const editorTypeOptions: Array<SelectableValue<'builder' | 'static'>> = useMemo(
() => [
{ label: t('dashboard.edit-pane.variable.custom-options.editor-type.builder', 'Builder'), value: 'builder' },
{ label: t('dashboard.edit-pane.variable.custom-options.editor-type.static', 'Static'), value: 'static' },
],
[]
);
const isHasVariableOptions = hasVariableOptions(variable);
return (
<Stack direction="column" gap={2}>
<RadioButtonGroup
value={editorType}
options={editorTypeOptions}
fullWidth
onChange={(value) => setEditorType(value)}
/>
{editorType === 'builder' ? (
<ValuesBuilder variable={variable} />
) : (
<ValuesTextField variable={variable} id={id} />
)}
{isHasVariableOptions && <VariableValuesPreview options={variable.getOptionsForSelect(false)} />}
</Stack>
);
}
function ValuesBuilder({ variable }: { variable: CustomVariable }) {
const { query } = variable.useState();
const match = useMemo(() => query.match(/(?:\\,|[^,])+/g) ?? [], [query]);
const options = useMemo<VariableValueOption[]>(
() =>
match.map((text) => {
text = text.replace(/\\,/g, ',');
const textMatch = /^\s*(.+)\s:\s(.+)$/g.exec(text) ?? [];
if (textMatch.length === 3) {
const [, label, value] = textMatch;
return { label: label.trim(), value: value.trim() };
}
text = text.trim();
return { label: '', value: text };
}),
[match]
);
const handleOptionsChange = async (options: VariableValueOption[]) => {
variable.setState({
query: options
.map((option) => {
if (!option.label || option.label === option.value) {
return String(option.value).replaceAll(',', '\\,');
}
return `${option.label.replaceAll(',', '\\,')} : ${String(option.value).replaceAll(',', '\\,')}`;
})
.join(', '),
});
await lastValueFrom(variable.validateAndUpdate!());
};
return <VariableStaticOptionsForm options={options} onChange={handleOptionsChange} />;
}
function ValuesTextField({ variable, id }: { variable: CustomVariable; id?: string }) {
const { query } = variable.useState();

View File

@ -14,7 +14,11 @@ import { DataSourcePicker } from 'app/features/datasources/components/picker/Dat
import { getVariableQueryEditor } from 'app/features/variables/editor/getVariableQueryEditor';
import { QueryVariableRefreshSelect } from 'app/features/variables/query/QueryVariableRefreshSelect';
import { QueryVariableSortSelect } from 'app/features/variables/query/QueryVariableSortSelect';
import { StaticOptionsOrderType, StaticOptionsType } from 'app/features/variables/query/QueryVariableStaticOptions';
import {
QueryVariableStaticOptions,
StaticOptionsOrderType,
StaticOptionsType,
} from 'app/features/variables/query/QueryVariableStaticOptions';
import { QueryVariableEditorForm } from '../components/QueryVariableForm';
import { VariableTextAreaField } from '../components/VariableTextAreaField';
@ -186,7 +190,15 @@ export function ModalEditor({ variable }: { variable: QueryVariable }) {
}
export function Editor({ variable }: { variable: QueryVariable }) {
const { datasource: datasourceRef, sort, refresh, query, regex } = variable.useState();
const {
datasource: datasourceRef,
sort,
refresh,
query,
regex,
staticOptions,
staticOptionsOrder,
} = variable.useState();
const { value: timeRange } = sceneGraph.getTimeRange(variable).useState();
const { value: dsConfig } = useAsync(async () => {
const datasource = await getDataSourceSrv().get(datasourceRef ?? '');
@ -229,6 +241,12 @@ export function Editor({ variable }: { variable: QueryVariable }) {
const onRefreshChange = (refresh: VariableRefresh) => {
variable.setState({ refresh: refresh });
};
const onStaticOptionsChange = (staticOptions: StaticOptionsType) => {
variable.setState({ staticOptions });
};
const onStaticOptionsOrderChange = (staticOptionsOrder: StaticOptionsOrderType) => {
variable.setState({ staticOptionsOrder });
};
const isHasVariableOptions = hasVariableOptions(variable);
@ -237,6 +255,7 @@ export function Editor({ variable }: { variable: QueryVariable }) {
<Field
label={t('dashboard-scene.query-variable-editor-form.label-target-data-source', 'Target data source')}
htmlFor="data-source-picker"
noMargin
>
<DataSourcePicker current={selectedDatasource} onChange={onDataSourceChange} variables={true} width={30} />
</Field>
@ -292,6 +311,15 @@ export function Editor({ variable }: { variable: QueryVariable }) {
refresh={refresh}
/>
{onStaticOptionsChange && onStaticOptionsOrderChange && (
<QueryVariableStaticOptions
staticOptions={staticOptions}
staticOptionsOrder={staticOptionsOrder}
onStaticOptionsChange={onStaticOptionsChange}
onStaticOptionsOrderChange={onStaticOptionsOrderChange}
/>
)}
{isHasVariableOptions && <VariableValuesPreview options={variable.getOptionsForSelect(false)} />}
</div>
);

View File

@ -5,8 +5,8 @@ import { t, Trans } from '@grafana/i18n';
import { QueryVariable } from '@grafana/scenes';
import { Field, Stack, Switch } from '@grafana/ui';
import { VariableLegend } from 'app/features/dashboard-scene/settings/variables/components/VariableLegend';
import { VariableOptionsInput } from 'app/features/dashboard-scene/settings/variables/components/VariableOptionsInput';
import { VariableSelectField } from 'app/features/dashboard-scene/settings/variables/components/VariableSelectField';
import { VariableStaticOptionsForm } from 'app/features/dashboard-scene/settings/variables/components/VariableStaticOptionsForm';
export type StaticOptionsType = QueryVariable['state']['staticOptions'];
export type StaticOptionsOrderType = QueryVariable['state']['staticOptionsOrder'];
@ -65,7 +65,12 @@ export function QueryVariableStaticOptions(props: QueryVariableStaticOptionsProp
/>
{areStaticOptionsEnabled && (
<VariableOptionsInput width={60} options={staticOptions ?? []} onChange={onStaticOptionsChange} />
<VariableStaticOptionsForm
allowEmptyValue
width={60}
options={staticOptions ?? []}
onChange={onStaticOptionsChange}
/>
)}
</Stack>
</>

View File

@ -4727,6 +4727,12 @@
},
"variable": {
"custom-options": {
"close": "Close",
"editor-type": {
"builder": "Builder",
"static": "Static"
},
"modal-title": "Custom Variable",
"values": "Values separated by comma",
"values-placeholder": "1, 10, mykey : myvalue, myvalue, escaped,value"
},
@ -14100,9 +14106,10 @@
"query-variable-static-options": {
"add-option-button-label": "Add option",
"description": "Add custom options in addition to query results",
"label-placeholder": "display label",
"drag-and-drop": "Drag and drop to reorder",
"label-placeholder": "Label, defaults to value",
"remove-option-button-label": "Remove option",
"value-placeholder": "value, default empty string"
"value-placeholder": "Value"
},
"query-variable-static-options-sort-select": {
"description-values-variable": "How to sort static options with query results"