diff --git a/e2e-playwright/dashboard-new-layouts/dashboards-edit-custom-variables.spec.ts b/e2e-playwright/dashboard-new-layouts/dashboards-edit-custom-variables.spec.ts
new file mode 100644
index 00000000000..ce5465c1e33
--- /dev/null
+++ b/e2e-playwright/dashboard-new-layouts/dashboards-edit-custom-variables.spec.ts
@@ -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']);
+ });
+ }
+);
diff --git a/e2e-playwright/dashboard-new-layouts/dashboards-edit-variables.spec.ts b/e2e-playwright/dashboard-new-layouts/dashboards-edit-variables.spec.ts
index 54991dfd623..4b5ea9574f8 100644
--- a/e2e-playwright/dashboard-new-layouts/dashboards-edit-variables.spec.ts
+++ b/e2e-playwright/dashboard-new-layouts/dashboards-edit-variables.spec.ts
@@ -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();
diff --git a/eslint-suppressions.json b/eslint-suppressions.json
index 1d3a1535b9b..cb9a72a644b 100644
--- a/eslint-suppressions.json
+++ b/eslint-suppressions.json
@@ -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
diff --git a/package.json b/package.json
index d8fe5451d99..bce42eab31e 100644
--- a/package.json
+++ b/package.json
@@ -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:*",
diff --git a/packages/grafana-e2e-selectors/src/selectors/pages.ts b/packages/grafana-e2e-selectors/src/selectors/pages.ts
index b45357f05f7..7716ccc1436 100644
--- a/packages/grafana-e2e-selectors/src/selectors/pages.ts
+++ b/packages/grafana-e2e-selectors/src/selectors/pages.ts
@@ -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',
+ },
+ },
},
},
},
diff --git a/public/app/features/dashboard-scene/settings/variables/components/CustomVariableForm.tsx b/public/app/features/dashboard-scene/settings/variables/components/CustomVariableForm.tsx
index f11037fb80c..b3c78330156 100644
--- a/public/app/features/dashboard-scene/settings/variables/components/CustomVariableForm.tsx
+++ b/public/app/features/dashboard-scene/settings/variables/components/CustomVariableForm.tsx
@@ -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) => ,
- }),
- ];
-}
-
-function ValuesTextField({ variable, id }: { variable: CustomVariable; id?: string }) {
- const { query } = variable.useState();
-
- const onBlur = async (event: FormEvent) => {
- variable.setState({ query: event.currentTarget.value });
- await lastValueFrom(variable.validateAndUpdate!());
- };
-
- return (
-
- );
-}
diff --git a/public/app/features/dashboard-scene/settings/variables/components/QueryVariableForm.test.tsx b/public/app/features/dashboard-scene/settings/variables/components/QueryVariableForm.test.tsx
index c643a98b212..63a1912fde7 100644
--- a/public/app/features/dashboard-scene/settings/variables/components/QueryVariableForm.test.tsx
+++ b/public/app/features/dashboard-scene/settings/variables/components/QueryVariableForm.test.tsx
@@ -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();
});
});
diff --git a/public/app/features/dashboard-scene/settings/variables/components/VariableOptionsInput.tsx b/public/app/features/dashboard-scene/settings/variables/components/VariableOptionsInput.tsx
deleted file mode 100644
index 103aa10c54f..00000000000
--- a/public/app/features/dashboard-scene/settings/variables/components/VariableOptionsInput.tsx
+++ /dev/null
@@ -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 (
-
- {optionsLocal.map((option, index) => (
-
- handleLabelChange(index, e.currentTarget.value)}
- data-testid={
- selectors.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsStaticOptionsLabelInput
- }
- />
- handleValueChange(index, e.currentTarget.value)}
- data-testid={
- selectors.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsStaticOptionsValueInput
- }
- />
-
- ))}
-
-
-
-
- );
-}
diff --git a/public/app/features/dashboard-scene/settings/variables/components/VariableStaticOptionsForm.tsx b/public/app/features/dashboard-scene/settings/variables/components/VariableStaticOptionsForm.tsx
new file mode 100644
index 00000000000..8f0fd067a71
--- /dev/null
+++ b/public/app/features/dashboard-scene/settings/variables/components/VariableStaticOptionsForm.tsx
@@ -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(
+ 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(false);
+
+ const mapOption = useCallback(
+ (option: VariableValueOption) => ({
+ label: option.label,
+ value: String(option.value),
+ id: uuidv4(),
+ }),
+ []
+ );
+
+ const [items, setItems] = useState(
+ 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((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 (
+
+
+ {!isInModal && (
+
+
+
+ )}
+
+ );
+ }
+);
+
+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(),
+ };
+}
diff --git a/public/app/features/dashboard-scene/settings/variables/components/VariableStaticOptionsFormAddButton.tsx b/public/app/features/dashboard-scene/settings/variables/components/VariableStaticOptionsFormAddButton.tsx
new file mode 100644
index 00000000000..26dc4a21479
--- /dev/null
+++ b/public/app/features/dashboard-scene/settings/variables/components/VariableStaticOptionsFormAddButton.tsx
@@ -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 (
+
+ );
+};
diff --git a/public/app/features/dashboard-scene/settings/variables/components/VariableStaticOptionsFormItemEditor.tsx b/public/app/features/dashboard-scene/settings/variables/components/VariableStaticOptionsFormItemEditor.tsx
new file mode 100644
index 00000000000..f1cf42123e9
--- /dev/null
+++ b/public/app/features/dashboard-scene/settings/variables/components/VariableStaticOptionsFormItemEditor.tsx
@@ -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 = (evt) => {
+ if (item.value !== evt.currentTarget.value) {
+ onChange({ ...item, value: evt.currentTarget.value });
+ }
+ };
+
+ const handleLabelChange: ChangeEventHandler = (evt) => {
+ if (item.label !== evt.currentTarget.value) {
+ onChange({ ...item, label: evt.currentTarget.value });
+ }
+ };
+
+ const handleRemove = () => onRemove(item);
+
+ return (
+
+ {(draggableProvided) => (
+
+ |
+
+
+
+ |
+
+
+ |
+
+
+ |
+
+
+
+
+ |
+
+ )}
+
+ );
+}
+
+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',
+ },
+ }),
+});
diff --git a/public/app/features/dashboard-scene/settings/variables/components/VariableStaticOptionsFormItems.tsx b/public/app/features/dashboard-scene/settings/variables/components/VariableStaticOptionsFormItems.tsx
new file mode 100644
index 00000000000..b5c11927577
--- /dev/null
+++ b/public/app/features/dashboard-scene/settings/variables/components/VariableStaticOptionsFormItems.tsx
@@ -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 (
+
+
+
+ |
+
+ Value
+ |
+
+ Display text
+ |
+ |
+
+
+
+
+ {(droppableProvided) => (
+
+ {items.map((item, idx) => (
+
+ ))}
+ {droppableProvided.placeholder}
+
+ )}
+
+
+
+ );
+}
+
+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%',
+ }),
+});
diff --git a/public/app/features/dashboard-scene/settings/variables/editors/CustomVariableEditor.tsx b/public/app/features/dashboard-scene/settings/variables/editors/CustomVariableEditor.tsx
deleted file mode 100644
index d7db272b5ab..00000000000
--- a/public/app/features/dashboard-scene/settings/variables/editors/CustomVariableEditor.tsx
+++ /dev/null
@@ -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) => {
- variable.setState({ isMulti: event.currentTarget.checked });
- };
- const onIncludeAllChange = (event: FormEvent) => {
- variable.setState({ includeAll: event.currentTarget.checked });
- };
- const onQueryChange = (event: FormEvent) => {
- variable.setState({ query: event.currentTarget.value });
- onRunQuery();
- };
- const onAllValueChange = (event: FormEvent) => {
- variable.setState({ allValue: event.currentTarget.value });
- };
- const onAllowCustomValueChange = (event: FormEvent) => {
- variable.setState({ allowCustomValue: event.currentTarget.checked });
- };
-
- return (
-
- );
-}
-
-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 }) => ,
- }),
- ];
-}
-
-function ValuesTextField({ variable, id }: { variable: CustomVariable; id?: string }) {
- const { query } = variable.useState();
-
- const onBlur = async (event: FormEvent) => {
- variable.setState({ query: event.currentTarget.value });
- await lastValueFrom(variable.validateAndUpdate!());
- };
-
- return (
-
- );
-}
diff --git a/public/app/features/dashboard-scene/settings/variables/editors/CustomVariableEditor.test.tsx b/public/app/features/dashboard-scene/settings/variables/editors/CustomVariableEditor/CustomVariableEditor.test.tsx
similarity index 100%
rename from public/app/features/dashboard-scene/settings/variables/editors/CustomVariableEditor.test.tsx
rename to public/app/features/dashboard-scene/settings/variables/editors/CustomVariableEditor/CustomVariableEditor.test.tsx
diff --git a/public/app/features/dashboard-scene/settings/variables/editors/CustomVariableEditor/CustomVariableEditor.tsx b/public/app/features/dashboard-scene/settings/variables/editors/CustomVariableEditor/CustomVariableEditor.tsx
new file mode 100644
index 00000000000..8dfc7b3eac1
--- /dev/null
+++ b/public/app/features/dashboard-scene/settings/variables/editors/CustomVariableEditor/CustomVariableEditor.tsx
@@ -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) => {
+ variable.setState({ isMulti: event.currentTarget.checked });
+ },
+ [variable]
+ );
+
+ const onIncludeAllChange = useCallback(
+ (event: FormEvent) => {
+ variable.setState({ includeAll: event.currentTarget.checked });
+ },
+ [variable]
+ );
+
+ const onQueryChange = useCallback(
+ (event: FormEvent) => {
+ variable.setState({ query: event.currentTarget.value });
+ onRunQuery();
+ },
+ [variable, onRunQuery]
+ );
+
+ const onAllValueChange = useCallback(
+ (event: FormEvent) => {
+ variable.setState({ allValue: event.currentTarget.value });
+ },
+ [variable]
+ );
+
+ const onAllowCustomValueChange = useCallback(
+ (event: FormEvent) => {
+ variable.setState({ allowCustomValue: event.currentTarget.checked });
+ },
+ [variable]
+ );
+
+ return (
+
+ );
+}
+
+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 }) => ,
+ }),
+ ];
+}
diff --git a/public/app/features/dashboard-scene/settings/variables/editors/CustomVariableEditor/ModalEditor.tsx b/public/app/features/dashboard-scene/settings/variables/editors/CustomVariableEditor/ModalEditor.tsx
new file mode 100644
index 00000000000..3e8a8aa57b1
--- /dev/null
+++ b/public/app/features/dashboard-scene/settings/variables/editors/CustomVariableEditor/ModalEditor.tsx
@@ -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(null);
+
+ const handleOnAdd = useCallback(() => formRef.current?.addItem(), []);
+
+ return (
+
+
+
+
+
+ }>
+
+
+
+ );
+}
diff --git a/public/app/features/dashboard-scene/settings/variables/editors/CustomVariableEditor/PaneItem.tsx b/public/app/features/dashboard-scene/settings/variables/editors/CustomVariableEditor/PaneItem.tsx
new file mode 100644
index 00000000000..e453fc6b8b8
--- /dev/null
+++ b/public/app/features/dashboard-scene/settings/variables/editors/CustomVariableEditor/PaneItem.tsx
@@ -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 (
+ <>
+
+
+
+ setIsOpen(false)} />
+ >
+ );
+}
diff --git a/public/app/features/dashboard-scene/settings/variables/editors/CustomVariableEditor/ValuesBuilder.tsx b/public/app/features/dashboard-scene/settings/variables/editors/CustomVariableEditor/ValuesBuilder.tsx
new file mode 100644
index 00000000000..e2eceea5fd3
--- /dev/null
+++ b/public/app/features/dashboard-scene/settings/variables/editors/CustomVariableEditor/ValuesBuilder.tsx
@@ -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(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 ;
+});
+
+ValuesBuilder.displayName = 'ValuesBuilder';
diff --git a/public/app/features/dashboard-scene/settings/variables/editors/CustomVariableEditor/ValuesPreview.tsx b/public/app/features/dashboard-scene/settings/variables/editors/CustomVariableEditor/ValuesPreview.tsx
new file mode 100644
index 00000000000..49a3e8dd55b
--- /dev/null
+++ b/public/app/features/dashboard-scene/settings/variables/editors/CustomVariableEditor/ValuesPreview.tsx
@@ -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 ? : null;
+}
diff --git a/public/app/features/dashboard-scene/settings/variables/editors/CustomVariableEditor/getCustomVariableOptions.tsx b/public/app/features/dashboard-scene/settings/variables/editors/CustomVariableEditor/getCustomVariableOptions.tsx
new file mode 100644
index 00000000000..5033ebb1407
--- /dev/null
+++ b/public/app/features/dashboard-scene/settings/variables/editors/CustomVariableEditor/getCustomVariableOptions.tsx
@@ -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 }) => ,
+ }),
+ ];
+}
diff --git a/public/app/features/dashboard-scene/settings/variables/editors/QueryVariableEditor.test.tsx b/public/app/features/dashboard-scene/settings/variables/editors/QueryVariableEditor.test.tsx
index 64c14e4cfc1..ff1b0e88a66 100644
--- a/public/app/features/dashboard-scene/settings/variables/editors/QueryVariableEditor.test.tsx
+++ b/public/app/features/dashboard-scene/settings/variables/editors/QueryVariableEditor.test.tsx
@@ -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
diff --git a/public/app/features/dashboard-scene/settings/variables/editors/QueryVariableEditor.tsx b/public/app/features/dashboard-scene/settings/variables/editors/QueryVariableEditor.tsx
index 2e32afe02cf..da1649776b2 100644
--- a/public/app/features/dashboard-scene/settings/variables/editors/QueryVariableEditor.tsx
+++ b/public/app/features/dashboard-scene/settings/variables/editors/QueryVariableEditor.tsx
@@ -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 }) {
@@ -292,6 +311,15 @@ export function Editor({ variable }: { variable: QueryVariable }) {
refresh={refresh}
/>
+ {onStaticOptionsChange && onStaticOptionsOrderChange && (
+
+ )}
+
{isHasVariableOptions && }
);
diff --git a/public/app/features/dashboard-scene/settings/variables/utils.test.ts b/public/app/features/dashboard-scene/settings/variables/utils.test.ts
index 2d9b07c1d38..52e5847148e 100644
--- a/public/app/features/dashboard-scene/settings/variables/utils.test.ts
+++ b/public/app/features/dashboard-scene/settings/variables/utils.test.ts
@@ -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';
diff --git a/public/app/features/dashboard-scene/settings/variables/utils.ts b/public/app/features/dashboard-scene/settings/variables/utils.ts
index adebfc52889..23941e3e330 100644
--- a/public/app/features/dashboard-scene/settings/variables/utils.ts
+++ b/public/app/features/dashboard-scene/settings/variables/utils.ts
@@ -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;
+ editor: React.ComponentType; // eslint-disable-line @typescript-eslint/no-explicit-any
getOptions?: (variable: SceneVariable) => OptionsPaneItemDescriptor[];
}
@@ -132,6 +133,7 @@ export const getEditableVariables: () => Record
{areStaticOptionsEnabled && (
-
+
)}
>
diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json
index 189ec56399c..1556f5b49eb 100644
--- a/public/locales/en-US/grafana.json
+++ b/public/locales/en-US/grafana.json
@@ -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",
diff --git a/yarn.lock b/yarn.lock
index 820778b8c7d..81878e5f144 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -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:*"