mirror of https://github.com/grafana/grafana.git
Dashboard: Improve static options editors on variables
This commit is contained in:
parent
2c5ccd3283
commit
aa13249b42
|
@ -2194,11 +2194,6 @@
|
||||||
"count": 1
|
"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": {
|
"public/app/features/dashboard-scene/settings/variables/utils.ts": {
|
||||||
"@typescript-eslint/consistent-type-assertions": {
|
"@typescript-eslint/consistent-type-assertions": {
|
||||||
"count": 1
|
"count": 1
|
||||||
|
|
|
@ -559,6 +559,12 @@ export const versionedPages = {
|
||||||
customValueInput: {
|
customValueInput: {
|
||||||
[MIN_GRAFANA_VERSION]: 'data-testid custom-variable-input',
|
[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: {
|
IntervalVariable: {
|
||||||
intervalsValueInput: {
|
intervalsValueInput: {
|
||||||
|
|
|
@ -1,16 +1,11 @@
|
||||||
import { FormEvent } from 'react';
|
import { FormEvent } from 'react';
|
||||||
import { lastValueFrom } from 'rxjs';
|
|
||||||
|
|
||||||
import { selectors } from '@grafana/e2e-selectors';
|
import { selectors } from '@grafana/e2e-selectors';
|
||||||
import { Trans, t } from '@grafana/i18n';
|
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 { SelectionOptionsForm } from './SelectionOptionsForm';
|
||||||
|
import { VariableLegend } from './VariableLegend';
|
||||||
|
import { VariableTextAreaField } from './VariableTextAreaField';
|
||||||
|
|
||||||
interface CustomVariableFormProps {
|
interface CustomVariableFormProps {
|
||||||
query: string;
|
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}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
|
@ -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,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
|
@ -1,13 +1,17 @@
|
||||||
import { FormEvent } from 'react';
|
import { FormEvent, useMemo, useState } from 'react';
|
||||||
import { lastValueFrom } from 'rxjs';
|
import { lastValueFrom } from 'rxjs';
|
||||||
|
|
||||||
|
import { SelectableValue } from '@grafana/data';
|
||||||
import { selectors } from '@grafana/e2e-selectors';
|
import { selectors } from '@grafana/e2e-selectors';
|
||||||
import { t } from '@grafana/i18n';
|
import { t, Trans } from '@grafana/i18n';
|
||||||
import { CustomVariable, SceneVariable } from '@grafana/scenes';
|
import { CustomVariable, SceneVariable, VariableValueOption } from '@grafana/scenes';
|
||||||
import { TextArea } from '@grafana/ui';
|
import { Box, Button, Modal, RadioButtonGroup, Stack, TextArea } from '@grafana/ui';
|
||||||
import { OptionsPaneItemDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneItemDescriptor';
|
import { OptionsPaneItemDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneItemDescriptor';
|
||||||
|
|
||||||
import { CustomVariableForm } from '../components/CustomVariableForm';
|
import { CustomVariableForm } from '../components/CustomVariableForm';
|
||||||
|
import { VariableStaticOptionsForm } from '../components/VariableStaticOptionsForm';
|
||||||
|
import { VariableValuesPreview } from '../components/VariableValuesPreview';
|
||||||
|
import { hasVariableOptions } from '../utils';
|
||||||
|
|
||||||
interface CustomVariableEditorProps {
|
interface CustomVariableEditorProps {
|
||||||
variable: CustomVariable;
|
variable: CustomVariable;
|
||||||
|
@ -59,11 +63,123 @@ export function getCustomVariableOptions(variable: SceneVariable): OptionsPaneIt
|
||||||
new OptionsPaneItemDescriptor({
|
new OptionsPaneItemDescriptor({
|
||||||
title: t('dashboard.edit-pane.variable.custom-options.values', 'Values separated by comma'),
|
title: t('dashboard.edit-pane.variable.custom-options.values', 'Values separated by comma'),
|
||||||
id: 'custom-variable-values',
|
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 }) {
|
function ValuesTextField({ variable, id }: { variable: CustomVariable; id?: string }) {
|
||||||
const { query } = variable.useState();
|
const { query } = variable.useState();
|
||||||
|
|
||||||
|
|
|
@ -14,7 +14,11 @@ import { DataSourcePicker } from 'app/features/datasources/components/picker/Dat
|
||||||
import { getVariableQueryEditor } from 'app/features/variables/editor/getVariableQueryEditor';
|
import { getVariableQueryEditor } from 'app/features/variables/editor/getVariableQueryEditor';
|
||||||
import { QueryVariableRefreshSelect } from 'app/features/variables/query/QueryVariableRefreshSelect';
|
import { QueryVariableRefreshSelect } from 'app/features/variables/query/QueryVariableRefreshSelect';
|
||||||
import { QueryVariableSortSelect } from 'app/features/variables/query/QueryVariableSortSelect';
|
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 { QueryVariableEditorForm } from '../components/QueryVariableForm';
|
||||||
import { VariableTextAreaField } from '../components/VariableTextAreaField';
|
import { VariableTextAreaField } from '../components/VariableTextAreaField';
|
||||||
|
@ -186,7 +190,15 @@ export function ModalEditor({ variable }: { variable: QueryVariable }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Editor({ 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: timeRange } = sceneGraph.getTimeRange(variable).useState();
|
||||||
const { value: dsConfig } = useAsync(async () => {
|
const { value: dsConfig } = useAsync(async () => {
|
||||||
const datasource = await getDataSourceSrv().get(datasourceRef ?? '');
|
const datasource = await getDataSourceSrv().get(datasourceRef ?? '');
|
||||||
|
@ -229,6 +241,12 @@ export function Editor({ variable }: { variable: QueryVariable }) {
|
||||||
const onRefreshChange = (refresh: VariableRefresh) => {
|
const onRefreshChange = (refresh: VariableRefresh) => {
|
||||||
variable.setState({ refresh: refresh });
|
variable.setState({ refresh: refresh });
|
||||||
};
|
};
|
||||||
|
const onStaticOptionsChange = (staticOptions: StaticOptionsType) => {
|
||||||
|
variable.setState({ staticOptions });
|
||||||
|
};
|
||||||
|
const onStaticOptionsOrderChange = (staticOptionsOrder: StaticOptionsOrderType) => {
|
||||||
|
variable.setState({ staticOptionsOrder });
|
||||||
|
};
|
||||||
|
|
||||||
const isHasVariableOptions = hasVariableOptions(variable);
|
const isHasVariableOptions = hasVariableOptions(variable);
|
||||||
|
|
||||||
|
@ -237,6 +255,7 @@ export function Editor({ variable }: { variable: QueryVariable }) {
|
||||||
<Field
|
<Field
|
||||||
label={t('dashboard-scene.query-variable-editor-form.label-target-data-source', 'Target data source')}
|
label={t('dashboard-scene.query-variable-editor-form.label-target-data-source', 'Target data source')}
|
||||||
htmlFor="data-source-picker"
|
htmlFor="data-source-picker"
|
||||||
|
noMargin
|
||||||
>
|
>
|
||||||
<DataSourcePicker current={selectedDatasource} onChange={onDataSourceChange} variables={true} width={30} />
|
<DataSourcePicker current={selectedDatasource} onChange={onDataSourceChange} variables={true} width={30} />
|
||||||
</Field>
|
</Field>
|
||||||
|
@ -292,6 +311,15 @@ export function Editor({ variable }: { variable: QueryVariable }) {
|
||||||
refresh={refresh}
|
refresh={refresh}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{onStaticOptionsChange && onStaticOptionsOrderChange && (
|
||||||
|
<QueryVariableStaticOptions
|
||||||
|
staticOptions={staticOptions}
|
||||||
|
staticOptionsOrder={staticOptionsOrder}
|
||||||
|
onStaticOptionsChange={onStaticOptionsChange}
|
||||||
|
onStaticOptionsOrderChange={onStaticOptionsOrderChange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{isHasVariableOptions && <VariableValuesPreview options={variable.getOptionsForSelect(false)} />}
|
{isHasVariableOptions && <VariableValuesPreview options={variable.getOptionsForSelect(false)} />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
@ -5,8 +5,8 @@ import { t, Trans } from '@grafana/i18n';
|
||||||
import { QueryVariable } from '@grafana/scenes';
|
import { QueryVariable } from '@grafana/scenes';
|
||||||
import { Field, Stack, Switch } from '@grafana/ui';
|
import { Field, Stack, Switch } from '@grafana/ui';
|
||||||
import { VariableLegend } from 'app/features/dashboard-scene/settings/variables/components/VariableLegend';
|
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 { 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 StaticOptionsType = QueryVariable['state']['staticOptions'];
|
||||||
export type StaticOptionsOrderType = QueryVariable['state']['staticOptionsOrder'];
|
export type StaticOptionsOrderType = QueryVariable['state']['staticOptionsOrder'];
|
||||||
|
@ -65,7 +65,12 @@ export function QueryVariableStaticOptions(props: QueryVariableStaticOptionsProp
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{areStaticOptionsEnabled && (
|
{areStaticOptionsEnabled && (
|
||||||
<VariableOptionsInput width={60} options={staticOptions ?? []} onChange={onStaticOptionsChange} />
|
<VariableStaticOptionsForm
|
||||||
|
allowEmptyValue
|
||||||
|
width={60}
|
||||||
|
options={staticOptions ?? []}
|
||||||
|
onChange={onStaticOptionsChange}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
</>
|
</>
|
||||||
|
|
|
@ -4727,6 +4727,12 @@
|
||||||
},
|
},
|
||||||
"variable": {
|
"variable": {
|
||||||
"custom-options": {
|
"custom-options": {
|
||||||
|
"close": "Close",
|
||||||
|
"editor-type": {
|
||||||
|
"builder": "Builder",
|
||||||
|
"static": "Static"
|
||||||
|
},
|
||||||
|
"modal-title": "Custom Variable",
|
||||||
"values": "Values separated by comma",
|
"values": "Values separated by comma",
|
||||||
"values-placeholder": "1, 10, mykey : myvalue, myvalue, escaped,value"
|
"values-placeholder": "1, 10, mykey : myvalue, myvalue, escaped,value"
|
||||||
},
|
},
|
||||||
|
@ -14100,9 +14106,10 @@
|
||||||
"query-variable-static-options": {
|
"query-variable-static-options": {
|
||||||
"add-option-button-label": "Add option",
|
"add-option-button-label": "Add option",
|
||||||
"description": "Add custom options in addition to query results",
|
"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",
|
"remove-option-button-label": "Remove option",
|
||||||
"value-placeholder": "value, default empty string"
|
"value-placeholder": "Value"
|
||||||
},
|
},
|
||||||
"query-variable-static-options-sort-select": {
|
"query-variable-static-options-sort-select": {
|
||||||
"description-values-variable": "How to sort static options with query results"
|
"description-values-variable": "How to sort static options with query results"
|
||||||
|
|
Loading…
Reference in New Issue