Dashboard: Improve static options editors on variables (#110831)

This commit is contained in:
Bogdan Matei
2025-10-10 13:36:37 +00:00
committed by GitHub
parent cb91186276
commit f4e7dfe827
27 changed files with 966 additions and 354 deletions
@@ -0,0 +1,212 @@
import { Locator } from '@playwright/test';
import { test, expect, DashboardPage, E2ESelectorGroups } from '@grafana/plugin-e2e';
import { flows } from './utils';
test.use({
featureToggles: {
kubernetesDashboards: true,
dashboardNewLayouts: true,
dashboardUndoRedo: true,
groupByVariable: true,
},
});
test.use({
viewport: { width: 1920, height: 1080 },
});
const PAGE_UNDER_TEST = 'kVi2Gex7z/test-variable-output';
const DASHBOARD_NAME = 'Test variable output';
test.describe(
'Dashboard edit - Custom variable',
{
tag: ['@dashboards'],
},
() => {
let addButton: Locator | undefined;
let rows: Locator | undefined;
let valueInputs: Locator | undefined;
let labelInputs: Locator | undefined;
let deleteButtons: Locator | undefined;
const getAddButton = async (dashboardPage: DashboardPage, selectors: E2ESelectorGroups) => {
addButton = dashboardPage.getByGrafanaSelector(
selectors.pages.Dashboard.Settings.Variables.Edit.StaticOptionsEditor.addButton
);
await expect(addButton).toBeVisible();
};
const refetchItems = (dashboardPage: DashboardPage, selectors: E2ESelectorGroups) => {
rows = dashboardPage.getByGrafanaSelector(
selectors.pages.Dashboard.Settings.Variables.Edit.StaticOptionsEditor.row
);
valueInputs = dashboardPage.getByGrafanaSelector(
selectors.pages.Dashboard.Settings.Variables.Edit.StaticOptionsEditor.valueInput
);
labelInputs = dashboardPage.getByGrafanaSelector(
selectors.pages.Dashboard.Settings.Variables.Edit.StaticOptionsEditor.labelInput
);
deleteButtons = dashboardPage.getByGrafanaSelector(
selectors.pages.Dashboard.Settings.Variables.Edit.StaticOptionsEditor.deleteButton
);
};
const checkRows = async (length: number) => {
expect(await rows!.all()).toHaveLength(length);
};
const fillValue = async (text: string, index: number) => {
await valueInputs!.nth(index).fill(text);
};
const fillLabel = async (text: string, index: number) => {
await labelInputs!.nth(index).fill(text);
};
const fillLabelValue = async (value: string, label: string, index: number) => {
await fillValue(value, index);
await fillLabel(label, index);
};
const openModal = async (dashboardPage: DashboardPage, selectors: E2ESelectorGroups) => {
await dashboardPage
.getByGrafanaSelector(selectors.pages.Dashboard.Settings.Variables.Edit.CustomVariable.optionsOpenButton)
.click();
await getAddButton(dashboardPage, selectors);
refetchItems(dashboardPage, selectors);
};
const closeModal = async (dashboardPage: DashboardPage, selectors: E2ESelectorGroups) => {
await dashboardPage
.getByGrafanaSelector(selectors.pages.Dashboard.Settings.Variables.Edit.CustomVariable.closeButton)
.click();
};
const checkItems = async (items: Array<[string, string?]>) => {
for (let i = 0; i < items.length; i++) {
const [value, label] = items[i];
await expect(valueInputs!.nth(i)).toHaveValue(value);
await expect(labelInputs!.nth(i)).toHaveValue(label ?? '');
}
};
const checkPreview = async (dashboardPage: DashboardPage, selectors: E2ESelectorGroups, labels: string[]) => {
const previewOptions = dashboardPage.getByGrafanaSelector(
selectors.pages.Dashboard.Settings.Variables.Edit.General.previewOfValuesOption
);
for (let i = 0; i < labels.length; i++) {
expect(await previewOptions.nth(i).textContent()).toBe(labels[i]);
}
};
const addItem = async (dashboardPage: DashboardPage, selectors: E2ESelectorGroups, value = '', label = '') => {
await addButton!.click();
refetchItems(dashboardPage, selectors);
await fillLabelValue(value ?? '', label ?? '', (await rows!.all()).length - 1);
};
const removeItem = async (dashboardPage: DashboardPage, selectors: E2ESelectorGroups, index: number) => {
await deleteButtons!.nth(index).click();
refetchItems(dashboardPage, selectors);
};
test.beforeEach(() => {
valueInputs = undefined;
labelInputs = undefined;
deleteButtons = undefined;
});
test('can add a new custom variable', async ({ gotoDashboardPage, selectors, page }) => {
const dashboardPage = await gotoDashboardPage({ uid: PAGE_UNDER_TEST });
await expect(page.getByText(DASHBOARD_NAME)).toBeVisible();
// common steps to add a new variable
await flows.newEditPaneVariableClick(dashboardPage, selectors);
await flows.newEditPanelCommonVariableInputs(dashboardPage, selectors, {
type: 'custom',
name: 'foo',
label: 'Foo',
value: '',
});
await openModal(dashboardPage, selectors);
await checkRows(1);
await addItem(dashboardPage, selectors);
await checkRows(2);
await fillValue('first value', 0);
await fillLabelValue('second value', 'second label', 1);
await addItem(dashboardPage, selectors, 'third value', 'third label');
await addItem(dashboardPage, selectors, 'fourth value', 'fourth value');
await removeItem(dashboardPage, selectors, 2);
await checkRows(3);
await checkPreview(dashboardPage, selectors, ['first value', 'second label', 'fourth value']);
await closeModal(dashboardPage, selectors);
// assert variable is visible and has the correct values
const variableLabel = dashboardPage.getByGrafanaSelector(
selectors.pages.Dashboard.SubMenu.submenuItemLabels('Foo')
);
await expect(variableLabel).toBeVisible();
await expect(variableLabel).toContainText('Foo');
await expect(
dashboardPage.getByGrafanaSelector(
selectors.pages.Dashboard.SubMenu.submenuItemValueDropDownValueLinkTexts('first value')
)
).toBeVisible();
// check that variable deletion works
await dashboardPage.getByGrafanaSelector(selectors.components.EditPaneHeader.deleteButton).click();
await expect(variableLabel).toBeHidden();
});
test('can edit a custom variable', async ({ gotoDashboardPage, selectors, page }) => {
const dashboardPage = await gotoDashboardPage({
uid: PAGE_UNDER_TEST,
queryParams: new URLSearchParams({ orgId: '1', editview: 'variables' }),
});
await expect(page.getByText(DASHBOARD_NAME)).toBeVisible();
// Create a custom variable in the dashboard settings page
await dashboardPage.getByGrafanaSelector(selectors.components.CallToActionCard.buttonV2('Add variable')).click();
const typeSelect = dashboardPage
.getByGrafanaSelector(selectors.pages.Dashboard.Settings.Variables.Edit.General.generalTypeSelectV2)
.locator('input');
await typeSelect.fill('Custom');
await typeSelect.press('Enter');
await dashboardPage
.getByGrafanaSelector(selectors.pages.Dashboard.Settings.Variables.Edit.General.generalNameInputV2)
.fill('foo');
await dashboardPage
.getByGrafanaSelector(selectors.pages.Dashboard.Settings.Variables.Edit.General.generalLabelInputV2)
.fill('Foo');
await dashboardPage
.getByGrafanaSelector(selectors.pages.Dashboard.Settings.Variables.Edit.CustomVariable.customValueInput)
.fill('first value, second label : second value, fourth value : fourth value');
await dashboardPage
.getByGrafanaSelector(selectors.pages.Dashboard.Settings.Variables.Edit.General.applyButton)
.click();
await dashboardPage
.getByGrafanaSelector(selectors.components.NavToolbar.editDashboard.backToDashboardButton)
.click();
// Open the modal editor in the side pane
await dashboardPage.getByGrafanaSelector(selectors.components.PanelEditor.Outline.section).click();
await dashboardPage.getByGrafanaSelector(selectors.components.PanelEditor.Outline.node('Variables')).click();
await dashboardPage.getByGrafanaSelector(selectors.components.PanelEditor.Outline.item('foo')).click();
await openModal(dashboardPage, selectors);
// Check the items
await checkItems([['first value'], ['second value', 'second label'], ['fourth value']]);
await checkPreview(dashboardPage, selectors, ['first value', 'second label', 'fourth value']);
});
}
);
@@ -20,46 +20,6 @@ test.describe(
tag: ['@dashboards'],
},
() => {
test('can add a new custom variable', async ({ gotoDashboardPage, selectors, page }) => {
const dashboardPage = await gotoDashboardPage({ uid: PAGE_UNDER_TEST });
await expect(page.getByText(DASHBOARD_NAME)).toBeVisible();
const variable: Variable = {
type: 'custom',
name: 'foo',
label: 'Foo',
value: 'one,two,three',
};
// common steps to add a new variable
await flows.newEditPaneVariableClick(dashboardPage, selectors);
await flows.newEditPanelCommonVariableInputs(dashboardPage, selectors, variable);
// set the custom variable value
const customValueInput = dashboardPage.getByGrafanaSelector(
selectors.pages.Dashboard.Settings.Variables.Edit.CustomVariable.customValueInput
);
await customValueInput.fill(variable.value);
await customValueInput.blur();
// assert the dropdown for the variable is visible and has the correct values
const variableLabel = dashboardPage.getByGrafanaSelector(
selectors.pages.Dashboard.SubMenu.submenuItemLabels(variable.label!)
);
await expect(variableLabel).toBeVisible();
await expect(variableLabel).toContainText(variable.label!);
const values = variable.value.split(',');
const firstValueLink = dashboardPage.getByGrafanaSelector(
selectors.pages.Dashboard.SubMenu.submenuItemValueDropDownValueLinkTexts(values[0])
);
await expect(firstValueLink).toBeVisible();
// check that variable deletion works
await dashboardPage.getByGrafanaSelector(selectors.components.EditPaneHeader.deleteButton).click();
await expect(variableLabel).toBeHidden();
});
test('can add a new constant variable', async ({ gotoDashboardPage, selectors, page }) => {
const dashboardPage = await gotoDashboardPage({ uid: PAGE_UNDER_TEST });
await expect(page.getByText(DASHBOARD_NAME)).toBeVisible();
-13
View File
@@ -2189,19 +2189,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
},
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"public/app/features/dashboard-scene/sharing/ShareButton/share-externally/EmailShare/ConfigEmailSharing/ConfigEmailSharing.tsx": {
"no-restricted-syntax": {
"count": 1
+2 -2
View File
@@ -296,8 +296,8 @@
"@grafana/plugin-ui": "^0.10.10",
"@grafana/prometheus": "workspace:*",
"@grafana/runtime": "workspace:*",
"@grafana/scenes": "6.39.3",
"@grafana/scenes-react": "6.39.3",
"@grafana/scenes": "6.39.4",
"@grafana/scenes-react": "6.39.4",
"@grafana/schema": "workspace:*",
"@grafana/sql": "workspace:*",
"@grafana/ui": "workspace:*",
@@ -500,24 +500,9 @@ export const versionedPages = {
queryOptionsQueryInput: {
'10.4.0': 'data-testid Variable editor Form Default Variable Query Editor textarea',
},
queryOptionsStaticOptionsRow: {
[MIN_GRAFANA_VERSION]: 'Variable editor Form Query Static Options row',
},
queryOptionsStaticOptionsToggle: {
[MIN_GRAFANA_VERSION]: 'Variable editor Form Query Static Options toggle',
},
queryOptionsStaticOptionsLabelInput: {
[MIN_GRAFANA_VERSION]: 'Variable editor Form Query Static Options Label input',
},
queryOptionsStaticOptionsValueInput: {
[MIN_GRAFANA_VERSION]: 'Variable editor Form Query Static Options Value input',
},
queryOptionsStaticOptionsDeleteButton: {
[MIN_GRAFANA_VERSION]: 'Variable editor Form Query Static Options Delete button',
},
queryOptionsStaticOptionsAddButton: {
[MIN_GRAFANA_VERSION]: 'Variable editor Form Query Static Options Add button',
},
queryOptionsStaticOptionsOrderDropdown: {
[MIN_GRAFANA_VERSION]: 'Variable editor Form Query Static Options Order dropdown',
},
@@ -559,6 +544,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: {
@@ -607,6 +598,26 @@ export const versionedPages = {
['12.3.0']: 'data-testid switch variable disabled value input',
},
},
StaticOptionsEditor: {
addButton: {
[MIN_GRAFANA_VERSION]: 'data-testid Variable editor Form Static Options Add button',
},
labelInput: {
[MIN_GRAFANA_VERSION]: 'data-testid Variable editor Form Static Options Label input',
},
valueInput: {
[MIN_GRAFANA_VERSION]: 'data-testid Variable editor Form Static Options Value input',
},
moveButton: {
[MIN_GRAFANA_VERSION]: 'data-testid Variable editor Form Static Options Move button',
},
deleteButton: {
[MIN_GRAFANA_VERSION]: 'data-testid Variable editor Form Static Options Delete button',
},
row: {
[MIN_GRAFANA_VERSION]: 'data-testid Variable editor Form Static Options Row',
},
},
},
},
},
@@ -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}
/>
);
}
@@ -337,17 +337,15 @@ describe('QueryVariableEditorForm', () => {
);
await userEvent.click(staticOptionsToggle);
const addButton = getByTestId(
selectors.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsStaticOptionsAddButton
);
const addButton = getByTestId(selectors.pages.Dashboard.Settings.Variables.Edit.StaticOptionsEditor.addButton);
await userEvent.click(addButton);
// Now enter label and value for the new option
const labelInputs = getAllByTestId(
selectors.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsStaticOptionsLabelInput
selectors.pages.Dashboard.Settings.Variables.Edit.StaticOptionsEditor.labelInput
);
const valueInputs = getAllByTestId(
selectors.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsStaticOptionsValueInput
selectors.pages.Dashboard.Settings.Variables.Edit.StaticOptionsEditor.valueInput
);
// Enter label for the new option (second input)
@@ -372,7 +370,7 @@ describe('QueryVariableEditorForm', () => {
});
const deleteButtons = getAllByTestId(
selectors.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsStaticOptionsDeleteButton
selectors.pages.Dashboard.Settings.Variables.Edit.StaticOptionsEditor.deleteButton
);
// Remove the first option
@@ -392,7 +390,7 @@ describe('QueryVariableEditorForm', () => {
});
const labelInputs = getAllByTestId(
selectors.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsStaticOptionsLabelInput
selectors.pages.Dashboard.Settings.Variables.Edit.StaticOptionsEditor.labelInput
);
await userEvent.clear(labelInputs[0]);
@@ -411,7 +409,7 @@ describe('QueryVariableEditorForm', () => {
});
const valueInputs = getAllByTestId(
selectors.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsStaticOptionsValueInput
selectors.pages.Dashboard.Settings.Variables.Edit.StaticOptionsEditor.valueInput
);
await userEvent.clear(valueInputs[0]);
@@ -443,9 +441,7 @@ describe('QueryVariableEditorForm', () => {
).toBeInTheDocument();
// Option rows should be visible
expect(
getAllByTestId(selectors.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsStaticOptionsRow)
).toHaveLength(2);
expect(getAllByTestId(selectors.pages.Dashboard.Settings.Variables.Edit.StaticOptionsEditor.row)).toHaveLength(2);
// Uncheck the static options switch
const staticOptionsToggle = getByTestId(
@@ -464,7 +460,7 @@ describe('QueryVariableEditorForm', () => {
)
).not.toBeInTheDocument();
expect(
queryByTestId(selectors.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsStaticOptionsRow)
queryByTestId(selectors.pages.Dashboard.Settings.Variables.Edit.StaticOptionsEditor.row)
).not.toBeInTheDocument();
});
});
@@ -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,139 @@
import { css } from '@emotion/css';
import { useEffect, useState, useRef, useCallback, useImperativeHandle, forwardRef } from 'react';
import { v4 as uuidv4 } from 'uuid';
import { GrafanaTheme2 } from '@grafana/data';
import { VariableValueOption } from '@grafana/scenes';
import { useStyles2 } from '@grafana/ui';
import { VariableStaticOptionsFormAddButton } from './VariableStaticOptionsFormAddButton';
import { VariableStaticOptionsFormItem } from './VariableStaticOptionsFormItemEditor';
import { VariableStaticOptionsFormItems } from './VariableStaticOptionsFormItems';
interface VariableStaticOptionsFormProps {
options: VariableValueOption[];
onChange: (options: VariableValueOption[]) => void;
allowEmptyValue?: boolean;
isInModal?: boolean;
}
export interface VariableStaticOptionsFormRef {
addItem: () => void;
}
export const VariableStaticOptionsForm = forwardRef<VariableStaticOptionsFormRef, VariableStaticOptionsFormProps>(
function ({ options, onChange, allowEmptyValue, isInModal = false }: VariableStaticOptionsFormProps, ref) {
const styles = useStyles2(getStyles, isInModal);
// 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 mapOption = useCallback(
(option: VariableValueOption) => ({
label: option.label,
value: String(option.value),
id: uuidv4(),
}),
[]
);
const [items, setItems] = useState<VariableStaticOptionsFormItem[]>(
options.length ? options.map(mapOption) : [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 = useCallback(
(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;
}, [])
);
},
[allowEmptyValue, onChange]
);
const handleAdd = useCallback(() => setItems([...items, createEmptyItem()]), [items]);
useImperativeHandle(ref, () => ({ addItem: handleAdd }), [handleAdd]);
return (
<div className={styles.container}>
<VariableStaticOptionsFormItems items={items} onChange={updateItems} />
{!isInModal && (
<div>
<VariableStaticOptionsFormAddButton onAdd={handleAdd} />
</div>
)}
</div>
);
}
);
VariableStaticOptionsForm.displayName = 'VariableStaticOptionsForm';
const getStyles = (theme: GrafanaTheme2, isInModal: boolean) => ({
container: css({
display: 'flex',
flexDirection: 'column',
gap: theme.spacing(2),
width: '100%',
maxWidth: theme.spacing(60),
...(isInModal
? {
maxWidth: '100%',
// Simulate sticky modal buttons
maxHeight: 'calc(80vh - 170px)',
overflow: 'auto',
minHeight: theme.spacing(5),
}
: {}),
}),
});
function createEmptyItem(): VariableStaticOptionsFormItem {
return {
label: '',
value: '',
id: uuidv4(),
};
}
@@ -0,0 +1,21 @@
import { selectors } from '@grafana/e2e-selectors';
import { t, Trans } from '@grafana/i18n';
import { Button } from '@grafana/ui';
interface VariableStaticOptionsFormAddButtonProps {
onAdd: () => void;
}
export const VariableStaticOptionsFormAddButton = ({ onAdd }: VariableStaticOptionsFormAddButtonProps) => {
return (
<Button
icon="plus"
variant="secondary"
onClick={onAdd}
data-testid={selectors.pages.Dashboard.Settings.Variables.Edit.StaticOptionsEditor.addButton}
aria-label={t('variables.static-options.add-option-button-label', 'Add new option')}
>
<Trans i18nKey="variables.static-options.add-option-button-label">Add new option</Trans>
</Button>
);
};
@@ -0,0 +1,117 @@
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 { Icon, IconButton, 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) => (
<tr
ref={draggableProvided.innerRef}
data-testid={selectors.pages.Dashboard.Settings.Variables.Edit.StaticOptionsEditor.row}
{...draggableProvided.draggableProps}
>
<td>
<Stack
direction="row"
alignItems="center"
data-testid={selectors.pages.Dashboard.Settings.Variables.Edit.StaticOptionsEditor.moveButton}
{...draggableProvided.dragHandleProps}
>
<Icon
title={t('variables.static-options.drag-and-drop', 'Drag and drop to reorder')}
name="draggabledots"
size="lg"
className={styles.dragIcon}
/>
</Stack>
</td>
<td>
<Input
value={item.value}
placeholder={t('variables.static-options.value-placeholder', 'Value')}
onChange={handleValueChange}
data-testid={selectors.pages.Dashboard.Settings.Variables.Edit.StaticOptionsEditor.valueInput}
/>
</td>
<td>
<Input
value={item.label}
placeholder={t('variables.static-options.label-placeholder', 'Defaults to value')}
onChange={handleLabelChange}
data-testid={selectors.pages.Dashboard.Settings.Variables.Edit.StaticOptionsEditor.labelInput}
/>
</td>
<td>
<Stack direction="row" alignItems="center">
<IconButton
name="trash-alt"
aria-label={t('variables.static-options.remove-option-button-label', 'Remove option')}
onClick={handleRemove}
data-testid={selectors.pages.Dashboard.Settings.Variables.Edit.StaticOptionsEditor.deleteButton}
/>
</Stack>
</td>
</tr>
)}
</Draggable>
);
}
const getStyles = (theme: GrafanaTheme2) => ({
dragIcon: css({
cursor: 'grab',
// create a focus ring around the whole row when the drag handle is tab-focused
// needs position: relative on the drag row to work correctly
'&:focus-visible&:after': {
bottom: 0,
content: '""',
left: 0,
position: 'absolute',
right: 0,
top: 0,
outline: `2px solid ${theme.colors.primary.main}`,
outlineOffset: '-2px',
},
}),
});
@@ -0,0 +1,117 @@
import { css } from '@emotion/css';
import { DragDropContext, Droppable, DropResult } from '@hello-pangea/dnd';
import { GrafanaTheme2 } from '@grafana/data';
import { Trans } from '@grafana/i18n';
import { useStyles2 } from '@grafana/ui';
import {
VariableStaticOptionsFormItem,
VariableStaticOptionsFormItemEditor,
} from './VariableStaticOptionsFormItemEditor';
interface VariableStaticOptionsFormProps {
items: VariableStaticOptionsFormItem[];
onChange: (items: VariableStaticOptionsFormItem[]) => void;
}
export function VariableStaticOptionsFormItems({ items, onChange }: VariableStaticOptionsFormProps) {
const styles = useStyles2(getStyles);
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 (
<table className={styles.table}>
<thead>
<tr>
<th className={styles.headerIconColumn} />
<th className={styles.headerInputColumn}>
<Trans i18nKey="variables.static-options.value-header">Value</Trans>
</th>
<th className={styles.headerInputColumn}>
<Trans i18nKey="variables.static-options.label-header">Display text</Trans>
</th>
<th className={styles.headerIconColumn} />
</tr>
</thead>
<DragDropContext onDragEnd={handleReorder}>
<Droppable droppableId="static-options-list" direction="vertical">
{(droppableProvided) => (
<tbody ref={droppableProvided.innerRef} {...droppableProvided.droppableProps}>
{items.map((item, idx) => (
<VariableStaticOptionsFormItemEditor
item={item}
index={idx}
onChange={handleChange}
onRemove={handleRemove}
key={item.id}
/>
))}
{droppableProvided.placeholder}
</tbody>
)}
</Droppable>
</DragDropContext>
</table>
);
}
const getStyles = (theme: GrafanaTheme2) => ({
table: css({
'tbody tr': css({
position: 'relative',
}),
'tbody tr:hover': css({
background: theme.colors.action.hover,
}),
'th, td': {
padding: theme.spacing(1),
width: '49%',
},
'th:first-child, td:first-child, th:last-child, td:last-child': css({
width: '1%',
}),
}),
headerIconColumn: css({
width: '1%',
}),
headerInputColumn: css({
width: '49%',
}),
});
@@ -1,89 +0,0 @@
import { FormEvent } from 'react';
import { lastValueFrom } from 'rxjs';
import { selectors } from '@grafana/e2e-selectors';
import { 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 { CustomVariableForm } from '../components/CustomVariableForm';
interface CustomVariableEditorProps {
variable: CustomVariable;
onRunQuery: () => void;
}
export function CustomVariableEditor({ variable, onRunQuery }: CustomVariableEditorProps) {
const { query, isMulti, allValue, includeAll, allowCustomValue } = variable.useState();
const onMultiChange = (event: FormEvent<HTMLInputElement>) => {
variable.setState({ isMulti: event.currentTarget.checked });
};
const onIncludeAllChange = (event: FormEvent<HTMLInputElement>) => {
variable.setState({ includeAll: event.currentTarget.checked });
};
const onQueryChange = (event: FormEvent<HTMLTextAreaElement>) => {
variable.setState({ query: event.currentTarget.value });
onRunQuery();
};
const onAllValueChange = (event: FormEvent<HTMLInputElement>) => {
variable.setState({ allValue: event.currentTarget.value });
};
const onAllowCustomValueChange = (event: FormEvent<HTMLInputElement>) => {
variable.setState({ allowCustomValue: event.currentTarget.checked });
};
return (
<CustomVariableForm
query={query ?? ''}
multi={!!isMulti}
allValue={allValue ?? ''}
includeAll={!!includeAll}
allowCustomValue={allowCustomValue}
onMultiChange={onMultiChange}
onIncludeAllChange={onIncludeAllChange}
onQueryChange={onQueryChange}
onAllValueChange={onAllValueChange}
onAllowCustomValueChange={onAllowCustomValueChange}
/>
);
}
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: ({ props }) => <ValuesTextField id={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}
/>
);
}
@@ -0,0 +1,83 @@
import { FormEvent, useCallback } from 'react';
import { t } from '@grafana/i18n';
import { CustomVariable, SceneVariable } from '@grafana/scenes';
import { OptionsPaneItemDescriptor } from '../../../../../dashboard/components/PanelEditor/OptionsPaneItemDescriptor';
import { CustomVariableForm } from '../../components/CustomVariableForm';
import { PaneItem } from './PaneItem';
interface CustomVariableEditorProps {
variable: CustomVariable;
onRunQuery: () => void;
}
export function CustomVariableEditor({ variable, onRunQuery }: CustomVariableEditorProps) {
const { query, isMulti, allValue, includeAll, allowCustomValue } = variable.useState();
const onMultiChange = useCallback(
(event: FormEvent<HTMLInputElement>) => {
variable.setState({ isMulti: event.currentTarget.checked });
},
[variable]
);
const onIncludeAllChange = useCallback(
(event: FormEvent<HTMLInputElement>) => {
variable.setState({ includeAll: event.currentTarget.checked });
},
[variable]
);
const onQueryChange = useCallback(
(event: FormEvent<HTMLTextAreaElement>) => {
variable.setState({ query: event.currentTarget.value });
onRunQuery();
},
[variable, onRunQuery]
);
const onAllValueChange = useCallback(
(event: FormEvent<HTMLInputElement>) => {
variable.setState({ allValue: event.currentTarget.value });
},
[variable]
);
const onAllowCustomValueChange = useCallback(
(event: FormEvent<HTMLInputElement>) => {
variable.setState({ allowCustomValue: event.currentTarget.checked });
},
[variable]
);
return (
<CustomVariableForm
query={query ?? ''}
multi={!!isMulti}
allValue={allValue ?? ''}
includeAll={!!includeAll}
allowCustomValue={allowCustomValue}
onMultiChange={onMultiChange}
onIncludeAllChange={onIncludeAllChange}
onQueryChange={onQueryChange}
onAllValueChange={onAllValueChange}
onAllowCustomValueChange={onAllowCustomValueChange}
/>
);
}
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: ({ props }) => <PaneItem id={props.id} variable={variable} />,
}),
];
}
@@ -0,0 +1,47 @@
import { useCallback, useRef } from 'react';
import { selectors } from '@grafana/e2e-selectors';
import { t, Trans } from '@grafana/i18n';
import { CustomVariable } from '@grafana/scenes';
import { Button, Modal, Stack } from '@grafana/ui';
import { VariableStaticOptionsFormRef } from '../../components/VariableStaticOptionsForm';
import { VariableStaticOptionsFormAddButton } from '../../components/VariableStaticOptionsFormAddButton';
import { ValuesBuilder } from './ValuesBuilder';
import { ValuesPreview } from './ValuesPreview';
interface ModalEditorProps {
variable: CustomVariable;
isOpen: boolean;
onClose: () => void;
}
export function ModalEditor({ variable, isOpen, onClose }: ModalEditorProps) {
const formRef = useRef<VariableStaticOptionsFormRef | null>(null);
const handleOnAdd = useCallback(() => formRef.current?.addItem(), []);
return (
<Modal
title={t('dashboard.edit-pane.variable.custom-options.modal-title', 'Custom Variable')}
isOpen={isOpen}
onDismiss={onClose}
>
<Stack direction="column" gap={2}>
<ValuesBuilder variable={variable} ref={formRef} />
<ValuesPreview variable={variable} />
</Stack>
<Modal.ButtonRow leftItems={<VariableStaticOptionsFormAddButton onAdd={handleOnAdd} />}>
<Button
variant="secondary"
fill="outline"
onClick={onClose}
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>
);
}
@@ -0,0 +1,37 @@
import { useState } from 'react';
import { selectors } from '@grafana/e2e-selectors';
import { t, Trans } from '@grafana/i18n';
import { CustomVariable } from '@grafana/scenes';
import { Box, Button } from '@grafana/ui';
import { ModalEditor } from './ModalEditor';
interface PaneItemProps {
variable: CustomVariable;
id?: string;
}
export function PaneItem({ variable }: PaneItemProps) {
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>
<ModalEditor variable={variable} isOpen={isOpen} onClose={() => setIsOpen(false)} />
</>
);
}
@@ -0,0 +1,52 @@
import { forwardRef, useCallback } from 'react';
import { lastValueFrom } from 'rxjs';
import { CustomVariable, VariableValueOption, VariableValueSingle } from '@grafana/scenes';
import { VariableStaticOptionsForm, VariableStaticOptionsFormRef } from '../../components/VariableStaticOptionsForm';
interface ValuesBuilderProps {
variable: CustomVariable;
}
export const ValuesBuilder = forwardRef<VariableStaticOptionsFormRef, ValuesBuilderProps>(function (
{ variable }: ValuesBuilderProps,
ref
) {
const { query } = variable.useState();
const options = variable.transformCsvStringToOptions(query, false).map(({ label, value }) => ({
value,
label: value === label ? '' : label,
}));
const escapeEntities = useCallback((text: VariableValueSingle) => String(text).trim().replaceAll(',', '\\,'), []);
const formatOption = useCallback(
(option: VariableValueOption) => {
if (!option.label || option.label === option.value) {
return escapeEntities(option.value);
}
return `${escapeEntities(option.label)} : ${escapeEntities(String(option.value))}`;
},
[escapeEntities]
);
const generateQuery = useCallback(
(options: VariableValueOption[]) => options.map(formatOption).join(', '),
[formatOption]
);
const handleOptionsChange = useCallback(
async (options: VariableValueOption[]) => {
variable.setState({ query: generateQuery(options) });
await lastValueFrom(variable.validateAndUpdate!());
},
[variable, generateQuery]
);
return <VariableStaticOptionsForm options={options} onChange={handleOptionsChange} ref={ref} isInModal />;
});
ValuesBuilder.displayName = 'ValuesBuilder';
@@ -0,0 +1,13 @@
import { CustomVariable } from '@grafana/scenes';
import { VariableValuesPreview } from '../../components/VariableValuesPreview';
import { hasVariableOptions } from '../../utils';
export function ValuesPreview({ variable }: { variable: CustomVariable }) {
// Workaround to toggle a component refresh when values change so that the preview is updated
variable.useState();
const isHasVariableOptions = hasVariableOptions(variable);
return isHasVariableOptions ? <VariableValuesPreview options={variable.getOptionsForSelect(false)} /> : null;
}
@@ -0,0 +1,20 @@
import { t } from '@grafana/i18n';
import { CustomVariable, SceneVariable } from '@grafana/scenes';
import { OptionsPaneItemDescriptor } from '../../../../../dashboard/components/PanelEditor/OptionsPaneItemDescriptor';
import { PaneItem } from './PaneItem';
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: ({ props }) => <PaneItem id={props.id} variable={variable} />,
}),
];
}
@@ -384,17 +384,15 @@ describe('QueryVariableEditor', () => {
await userEvent.click(staticOptionsToggle);
// Add first static option
const addButton = getByTestId(
selectors.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsStaticOptionsAddButton
);
const addButton = getByTestId(selectors.pages.Dashboard.Settings.Variables.Edit.StaticOptionsEditor.addButton);
await user.click(addButton);
// Enter label and value for first option
const labelInputs = getAllByTestId(
selectors.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsStaticOptionsLabelInput
selectors.pages.Dashboard.Settings.Variables.Edit.StaticOptionsEditor.labelInput
);
const valueInputs = getAllByTestId(
selectors.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsStaticOptionsValueInput
selectors.pages.Dashboard.Settings.Variables.Edit.StaticOptionsEditor.valueInput
);
await user.type(labelInputs[0], 'First Option');
@@ -411,10 +409,10 @@ describe('QueryVariableEditor', () => {
// Get updated inputs (now there should be 2 sets)
const updatedLabelInputs = getAllByTestId(
selectors.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsStaticOptionsLabelInput
selectors.pages.Dashboard.Settings.Variables.Edit.StaticOptionsEditor.labelInput
);
const updatedValueInputs = getAllByTestId(
selectors.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsStaticOptionsValueInput
selectors.pages.Dashboard.Settings.Variables.Edit.StaticOptionsEditor.valueInput
);
// Enter label and value for second option
@@ -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>
);
@@ -17,7 +17,7 @@ import { SHARED_DASHBOARD_QUERY, DASHBOARD_DATASOURCE_PLUGIN_ID } from 'app/plug
import { AdHocFiltersVariableEditor } from './editors/AdHocFiltersVariableEditor';
import { ConstantVariableEditor } from './editors/ConstantVariableEditor';
import { CustomVariableEditor } from './editors/CustomVariableEditor';
import { CustomVariableEditor } from './editors/CustomVariableEditor/CustomVariableEditor';
import { DataSourceVariableEditor } from './editors/DataSourceVariableEditor';
import { GroupByVariableEditor } from './editors/GroupByVariableEditor';
import { IntervalVariableEditor } from './editors/IntervalVariableEditor';
@@ -27,7 +27,8 @@ import { getIntervalsQueryFromNewIntervalModel } from '../../utils/utils';
import { AdHocFiltersVariableEditor, getAdHocFilterOptions } from './editors/AdHocFiltersVariableEditor';
import { ConstantVariableEditor, getConstantVariableOptions } from './editors/ConstantVariableEditor';
import { CustomVariableEditor, getCustomVariableOptions } from './editors/CustomVariableEditor';
import { CustomVariableEditor } from './editors/CustomVariableEditor/CustomVariableEditor';
import { getCustomVariableOptions } from './editors/CustomVariableEditor/getCustomVariableOptions';
import { DataSourceVariableEditor, getDataSourceVariableOptions } from './editors/DataSourceVariableEditor';
import { getGroupByVariableOptions, GroupByVariableEditor } from './editors/GroupByVariableEditor';
import { getIntervalVariableOptions, IntervalVariableEditor } from './editors/IntervalVariableEditor';
@@ -38,7 +39,7 @@ import { TextBoxVariableEditor, getTextBoxVariableOptions } from './editors/Text
interface EditableVariableConfig {
name: string;
description: string;
editor: React.ComponentType<any>;
editor: React.ComponentType<any>; // eslint-disable-line @typescript-eslint/no-explicit-any
getOptions?: (variable: SceneVariable) => OptionsPaneItemDescriptor[];
}
@@ -132,6 +133,7 @@ export const getEditableVariables: () => Record<EditableVariableType, EditableVa
export function getEditableVariableDefinition(type: string): EditableVariableConfig {
const editableVariables = getEditableVariables();
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const editableVariable = editableVariables[type as EditableVariableType];
if (!editableVariable) {
throw new Error(`Variable type ${type} not found`);
@@ -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,11 @@ export function QueryVariableStaticOptions(props: QueryVariableStaticOptionsProp
/>
{areStaticOptionsEnabled && (
<VariableOptionsInput width={60} options={staticOptions ?? []} onChange={onStaticOptionsChange} />
<VariableStaticOptionsForm
allowEmptyValue
options={staticOptions ?? []}
onChange={onStaticOptionsChange}
/>
)}
</Stack>
</>
+13 -7
View File
@@ -4749,8 +4749,9 @@
},
"variable": {
"custom-options": {
"values": "Values separated by comma",
"values-placeholder": "1, 10, mykey : myvalue, myvalue, escaped,value"
"close": "Close",
"modal-title": "Custom Variable",
"values": "Values separated by comma"
},
"datasource-options": {
"name-filter": "Name filter",
@@ -14139,15 +14140,20 @@
}
},
"query-variable-static-options": {
"add-option-button-label": "Add option",
"description": "Add custom options in addition to query results",
"label-placeholder": "display label",
"remove-option-button-label": "Remove option",
"value-placeholder": "value, default empty string"
"description": "Add custom options in addition to query results"
},
"query-variable-static-options-sort-select": {
"description-values-variable": "How to sort static options with query results"
},
"static-options": {
"add-option-button-label": "Add new option",
"drag-and-drop": "Drag and drop to reorder",
"label-header": "Display text",
"label-placeholder": "Defaults to value",
"remove-option-button-label": "Remove option",
"value-header": "Value",
"value-placeholder": "Value"
},
"text-box-variable-editor": {
"name-default-value": "Default value",
"placeholder-default-value-if-any": "default value, if any",
+11 -11
View File
@@ -3524,11 +3524,11 @@ __metadata:
languageName: unknown
linkType: soft
"@grafana/scenes-react@npm:6.39.3":
version: 6.39.3
resolution: "@grafana/scenes-react@npm:6.39.3"
"@grafana/scenes-react@npm:6.39.4":
version: 6.39.4
resolution: "@grafana/scenes-react@npm:6.39.4"
dependencies:
"@grafana/scenes": "npm:6.39.3"
"@grafana/scenes": "npm:6.39.4"
lru-cache: "npm:^10.2.2"
react-use: "npm:^17.4.0"
peerDependencies:
@@ -3540,13 +3540,13 @@ __metadata:
react: ^18.0.0
react-dom: ^18.0.0
react-router-dom: ^6.28.0
checksum: 10/7e989c0d34ab23add873fcdf8f2ebf2bb94c7cfd427631be13da4a08a0f78832c354868d97e584534a69a4bf4224fcb49a8d49fb16bf43cbed58203bd36c90d4
checksum: 10/9acbf6b80af12cb6807f9ba364d6684c967ebe65eb4ab51e8c3f6e461476d71cb89e64b53ba9a9e29a62972fb7876877be630a685aa32c815d4b36cb9ff4d9f5
languageName: node
linkType: hard
"@grafana/scenes@npm:6.39.3":
version: 6.39.3
resolution: "@grafana/scenes@npm:6.39.3"
"@grafana/scenes@npm:6.39.4":
version: 6.39.4
resolution: "@grafana/scenes@npm:6.39.4"
dependencies:
"@floating-ui/react": "npm:^0.26.16"
"@leeoniya/ufuzzy": "npm:^1.0.16"
@@ -3566,7 +3566,7 @@ __metadata:
react: ^18.0.0
react-dom: ^18.0.0
react-router-dom: ^6.28.0
checksum: 10/e5ddaf4f5e9afc7370eb4b6608d26056d1e4072465c2179e87415ac4ad01a4f8693bafaa372d7ed8ec6f295847561116ea85993cb5e5e6c4927fbb82b56f2e96
checksum: 10/07126c816f69fa69a06e6f4c77028bed515d4bd4d9cc0e081bbd2e4e4bfa8c9b1b0c41e5b686a646542a205e22a88826bcdd795896240e95857823c82a8a5fe5
languageName: node
linkType: hard
@@ -18289,8 +18289,8 @@ __metadata:
"@grafana/plugin-ui": "npm:^0.10.10"
"@grafana/prometheus": "workspace:*"
"@grafana/runtime": "workspace:*"
"@grafana/scenes": "npm:6.39.3"
"@grafana/scenes-react": "npm:6.39.3"
"@grafana/scenes": "npm:6.39.4"
"@grafana/scenes-react": "npm:6.39.4"
"@grafana/schema": "workspace:*"
"@grafana/sql": "workspace:*"
"@grafana/test-utils": "workspace:*"