QueryVariable: Support preview, static options and autocomplete for multi-props (#116259)

This commit is contained in:
Marc M. 2026-02-02 11:44:15 +01:00 committed by GitHub
parent 9c0b941561
commit 88f6bb83ab
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
41 changed files with 657 additions and 205 deletions

View file

@ -800,6 +800,8 @@ VariableOption: {
text: string | [...string]
// Value of the option
value: string | [...string]
// Additional properties for multi-props variables
properties?: {[string]: string}
}
// Query variable specification

View file

@ -804,6 +804,8 @@ VariableOption: {
text: string | [...string]
// Value of the option
value: string | [...string]
// Additional properties for multi-props variables
properties?: {[string]: string}
}
// Query variable specification

View file

@ -241,6 +241,8 @@ lineage: schemas: [{
text: string | [...string]
// Value of the option
value: string | [...string]
// Additional properties for multi-props variables
properties?: {[string]: string}
} @cuetsy(kind="interface")
// Options to config when to refresh a variable

View file

@ -241,6 +241,8 @@ lineage: schemas: [{
text: string | [...string]
// Value of the option
value: string | [...string]
// Additional properties for multi-props variables
properties?: {[string]: string}
} @cuetsy(kind="interface")
// Options to config when to refresh a variable

View file

@ -804,6 +804,8 @@ VariableOption: {
text: string | [...string]
// Value of the option
value: string | [...string]
// Additional properties for multi-props variables
properties?: {[string]: string}
}
// Query variable specification

View file

@ -1426,6 +1426,8 @@ type DashboardVariableOption struct {
Text DashboardStringOrArrayOfString `json:"text"`
// Value of the option
Value DashboardStringOrArrayOfString `json:"value"`
// Additional properties for multi-props variables
Properties map[string]string `json:"properties,omitempty"`
}
// NewDashboardVariableOption creates a new DashboardVariableOption object.

View file

@ -5133,6 +5133,22 @@ func schema_pkg_apis_dashboard_v2alpha1_DashboardVariableOption(ref common.Refer
Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardStringOrArrayOfString"),
},
},
"properties": {
SchemaProps: spec.SchemaProps{
Description: "Additional properties for multi-props variables",
Type: []string{"object"},
AdditionalProperties: &spec.SchemaOrBool{
Allows: true,
Schema: &spec.Schema{
SchemaProps: spec.SchemaProps{
Default: "",
Type: []string{"string"},
Format: "",
},
},
},
},
},
},
Required: []string{"text", "value"},
},

View file

@ -808,6 +808,8 @@ VariableOption: {
text: string | [...string]
// Value of the option
value: string | [...string]
// Additional properties for multi-props variables
properties?: {[string]: string}
}
// Query variable specification

View file

@ -1429,6 +1429,8 @@ type DashboardVariableOption struct {
Text DashboardStringOrArrayOfString `json:"text"`
// Value of the option
Value DashboardStringOrArrayOfString `json:"value"`
// Additional properties for multi-props variables
Properties map[string]string `json:"properties,omitempty"`
}
// NewDashboardVariableOption creates a new DashboardVariableOption object.

View file

@ -5196,6 +5196,22 @@ func schema_pkg_apis_dashboard_v2beta1_DashboardVariableOption(ref common.Refere
Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardStringOrArrayOfString"),
},
},
"properties": {
SchemaProps: spec.SchemaProps{
Description: "Additional properties for multi-props variables",
Type: []string{"object"},
AdditionalProperties: &spec.SchemaOrBool{
Allows: true,
Schema: &spec.Schema{
SchemaProps: spec.SchemaProps{
Default: "",
Type: []string{"string"},
Format: "",
},
},
},
},
},
},
Required: []string{"text", "value"},
},

File diff suppressed because one or more lines are too long

View file

@ -260,7 +260,7 @@
},
"packages/grafana-data/src/types/templateVars.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 2
"count": 3
}
},
"packages/grafana-data/src/types/trace.ts": {
@ -1861,11 +1861,6 @@
"count": 1
}
},
"public/app/features/dashboard-scene/settings/variables/components/QueryVariableForm.tsx": {
"no-restricted-syntax": {
"count": 1
}
},
"public/app/features/dashboard-scene/settings/variables/components/VariableSelectField.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 1

View file

@ -237,6 +237,8 @@ lineage: schemas: [{
text: string | [...string]
// Value of the option
value: string | [...string]
// Additional properties for multi-props variables
properties?: {[string]: string}
} @cuetsy(kind="interface")
// Options to config when to refresh a variable

View file

@ -976,6 +976,10 @@ export type DashboardTimeSettingsSpec = {
weekStart?: 'saturday' | 'monday' | 'sunday';
};
export type DashboardVariableOption = {
/** Additional properties for multi-props variables */
properties?: {
[key: string]: string;
};
/** Whether the option is selected or not */
selected?: boolean;
/** Text to be displayed for the option */

View file

@ -91,6 +91,7 @@ export interface VariableOption {
text: string | string[];
value: string | string[];
isNone?: boolean;
properties?: Record<string, any>;
}
export interface IntervalVariableModel extends VariableWithOptions {

View file

@ -231,6 +231,10 @@ export const defaultVariableModel: Partial<VariableModel> = {
* Option to be selected in a variable.
*/
export interface VariableOption {
/**
* Additional properties for multi-props variables
*/
properties?: Record<string, string>;
/**
* Whether the option is selected or not
*/

View file

@ -715,7 +715,9 @@ VariableOption: {
// Text to be displayed for the option
text: string | [...string]
// Value of the option
value: string | [...string]
value: string | [...string]
// Additional properties for multi-props variables
properties?: {[string]: string}
}
// Query variable specification

View file

@ -1152,6 +1152,8 @@ export interface VariableOption {
text: string | string[];
// Value of the option
value: string | string[];
// Additional properties for multi-props variables
properties?: Record<string, string>;
}
export const defaultVariableOption = (): VariableOption => ({

View file

@ -1158,6 +1158,8 @@ export interface VariableOption {
text: string | string[];
// Value of the option
value: string | string[];
// Additional properties for multi-props variables
properties?: Record<string, string>;
}
export const defaultVariableOption = (): VariableOption => ({

View file

@ -903,6 +903,8 @@ type VariableOption struct {
Text StringOrArrayOfString `json:"text"`
// Value of the option
Value StringOrArrayOfString `json:"value"`
// Additional properties for multi-props variables
Properties map[string]string `json:"properties,omitempty"`
}
// NewVariableOption creates a new VariableOption object.

View file

@ -3832,6 +3832,13 @@
"value"
],
"properties": {
"properties": {
"description": "Additional properties for multi-props variables",
"type": "object",
"additionalProperties": {
"type": "string"
}
},
"selected": {
"description": "Whether the option is selected or not",
"type": "boolean"

View file

@ -3859,6 +3859,13 @@
"value"
],
"properties": {
"properties": {
"description": "Additional properties for multi-props variables",
"type": "object",
"additionalProperties": {
"type": "string"
}
},
"selected": {
"description": "Whether the option is selected or not",
"type": "boolean"

View file

@ -93,6 +93,7 @@ export function sceneVariablesSetToVariables(set: SceneVariables, keepQueryOptio
staticOptions: variable.state.staticOptions?.map((option) => ({
text: option.label,
value: String(option.value),
...(option.properties && { properties: option.properties }),
})),
staticOptionsOrder: variable.state.staticOptionsOrder,
};
@ -284,6 +285,7 @@ function variableValueOptionsToVariableOptions(varState: MultiValueVariable['sta
value: String(o.value),
text: o.label,
selected: Array.isArray(varState.value) ? varState.value.includes(o.value) : varState.value === o.value,
...(o.properties && { properties: o.properties }),
}));
}
@ -392,6 +394,7 @@ export function sceneVariablesSetToSchemaV2Variables(
staticOptions: variable.state.staticOptions?.map((option) => ({
text: option.label,
value: String(option.value),
...(option.properties && { properties: option.properties }),
})),
staticOptionsOrder: variable.state.staticOptionsOrder,
},

View file

@ -383,6 +383,7 @@ function createSceneVariableFromVariableModel(variable: TypedVariableModelV2): S
staticOptions: variable.spec.staticOptions.map((option) => ({
label: String(option.text),
value: String(option.value),
properties: option.properties,
})),
}
: {}),

View file

@ -14,7 +14,10 @@ import { VariableDisplaySelect } from 'app/features/dashboard-scene/settings/var
import { VariableLegend } from 'app/features/dashboard-scene/settings/variables/components/VariableLegend';
import { VariableTextAreaField } from 'app/features/dashboard-scene/settings/variables/components/VariableTextAreaField';
import { VariableTextField } from 'app/features/dashboard-scene/settings/variables/components/VariableTextField';
import { VariableValuesPreview } from 'app/features/dashboard-scene/settings/variables/components/VariableValuesPreview';
import {
useGetAllVariableOptions,
VariableValuesPreview,
} from 'app/features/dashboard-scene/settings/variables/components/VariableValuesPreview';
import { VariableNameConstraints } from 'app/features/variables/editor/types';
import { VariableTypeSelect } from './components/VariableTypeSelect';
@ -68,8 +71,6 @@ export function VariableEditorForm({ variable, onTypeChange, onGoBack, onDelete
const onDisplayChange = (display: VariableHide) => variable.setState({ hide: display });
const isHasVariableOptions = hasVariableOptions(variable);
const optionsForSelect = isHasVariableOptions ? variable.getOptionsForSelect(false) : [];
const hasMultiProps = 'valuesFormat' in variable.state && variable.state.valuesFormat === 'json';
const onDeleteVariable = (hideModal: () => void) => () => {
reportInteraction('Delete variable');
@ -77,6 +78,8 @@ export function VariableEditorForm({ variable, onTypeChange, onGoBack, onDelete
hideModal();
};
const { options, staticOptions } = useGetAllVariableOptions(variable);
return (
<form
aria-label={t('dashboard-scene.variable-editor-form.aria-label-variable-editor-form', 'Variable editor form')}
@ -125,7 +128,7 @@ export function VariableEditorForm({ variable, onTypeChange, onGoBack, onDelete
{EditorToRender && <EditorToRender variable={variable} onRunQuery={onRunQuery} />}
{isHasVariableOptions && <VariableValuesPreview options={optionsForSelect} hasMultiProps={hasMultiProps} />}
{isHasVariableOptions && <VariableValuesPreview options={options} staticOptions={staticOptions} />}
<div className={styles.buttonContainer}>
<Stack gap={2}>

View file

@ -33,6 +33,12 @@ const promDatasource = mockDataSource({
jest.mock('@grafana/runtime', () => ({
...jest.requireActual('@grafana/runtime'),
config: {
...jest.requireActual('@grafana/runtime').config,
featureToggles: {
multiPropsVariables: true,
},
},
getDataSourceSrv: () => ({
get: async () => ({
...defaultDatasource,
@ -106,6 +112,7 @@ describe('QueryVariableEditorForm', () => {
onAllowCustomValueChange: mockOnAllowCustomValueChange,
onStaticOptionsChange: mockOnStaticOptionsChange,
onStaticOptionsOrderChange: mockOnStaticOptionsOrderChange,
options: [],
};
async function setup(props?: React.ComponentProps<typeof QueryVariableEditorForm>) {
@ -351,7 +358,7 @@ describe('QueryVariableEditorForm', () => {
it('should call onStaticOptionsChange when adding a static option', async () => {
const {
renderer: { getByTestId, getAllByTestId },
renderer: { getByTestId, getByPlaceholderText },
} = await setup();
// First enable static options
@ -363,83 +370,78 @@ describe('QueryVariableEditorForm', () => {
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.StaticOptionsEditor.labelInput
);
const valueInputs = getAllByTestId(
selectors.pages.Dashboard.Settings.Variables.Edit.StaticOptionsEditor.valueInput
);
// Enter label for the new option (second input)
await userEvent.type(labelInputs[1], 'New Option Label');
await userEvent.type(valueInputs[1], 'new-option-value');
// Enter label for the new option
await userEvent.type(getByPlaceholderText('text'), 'New Option Label[Tab]');
await userEvent.type(getByPlaceholderText('value'), 'new-option-value[Tab]');
await screen.findByDisplayValue('new-option-value');
expect(mockOnStaticOptionsChange).toHaveBeenCalled();
expect(mockOnStaticOptionsChange.mock.lastCall[0]).toEqual([
{ value: 'new-option-value', label: 'New Option Label' },
{
value: 'new-option-value',
label: 'New Option Label',
properties: { value: 'new-option-value', text: 'New Option Label' },
},
]);
});
it('should call onStaticOptionsChange when removing a static option', async () => {
const staticOptions = [
{ value: 'option1', label: 'Option 1', properties: { value: 'option1', text: 'Option 1' } },
{ value: 'option2', label: 'Option 2', properties: { value: 'option2', text: 'Option 2' } },
];
const {
renderer: { getAllByTestId },
renderer: { getAllByLabelText },
} = await setup({
...defaultProps,
staticOptions: [
{ value: 'option1', label: 'Option 1' },
{ value: 'option2', label: 'Option 2' },
],
staticOptions,
});
const deleteButtons = getAllByTestId(
selectors.pages.Dashboard.Settings.Variables.Edit.StaticOptionsEditor.deleteButton
);
const deleteButtons = getAllByLabelText('Remove option');
// Remove the first option
await userEvent.click(deleteButtons[0]);
expect(mockOnStaticOptionsChange).toHaveBeenCalledTimes(1);
// Should call with only the second option remaining
expect(mockOnStaticOptionsChange.mock.calls[0][0]).toEqual([{ value: 'option2', label: 'Option 2' }]);
expect(mockOnStaticOptionsChange.mock.calls[0][0]).toEqual([staticOptions[1]]);
});
it('should call onStaticOptionsChange when editing a static option label', async () => {
const {
renderer: { getAllByTestId },
renderer: { getByPlaceholderText },
} = await setup({
...defaultProps,
staticOptions: [{ value: 'test', label: 'Test Label' }],
staticOptions: [{ value: 'test', label: 'Test Label', properties: { value: 'test', text: 'Test Label' } }],
});
const labelInputs = getAllByTestId(
selectors.pages.Dashboard.Settings.Variables.Edit.StaticOptionsEditor.labelInput
);
const labelInput = getByPlaceholderText('text');
await userEvent.clear(labelInput);
await userEvent.type(labelInput, 'Updated Label[Tab]');
await userEvent.clear(labelInputs[0]);
await userEvent.type(labelInputs[0], 'Updated Label');
expect(mockOnStaticOptionsChange).toHaveBeenCalled();
expect(mockOnStaticOptionsChange.mock.lastCall[0]).toEqual([{ value: 'test', label: 'Updated Label' }]);
expect(mockOnStaticOptionsChange).toHaveBeenCalledWith([
{ value: 'test', label: 'Updated Label', properties: { value: 'test', text: 'Updated Label' } },
]);
});
it('should call onStaticOptionsChange when editing a static option value', async () => {
const {
renderer: { getAllByTestId },
renderer: { getByPlaceholderText },
} = await setup({
...defaultProps,
staticOptions: [{ value: 'old-value', label: 'Test Label' }],
staticOptions: [
{ value: 'old-value', label: 'Test Label', properties: { value: 'old-value', text: 'Test Label' } },
],
});
const valueInputs = getAllByTestId(
selectors.pages.Dashboard.Settings.Variables.Edit.StaticOptionsEditor.valueInput
);
const valueInput = getByPlaceholderText('value');
await userEvent.clear(valueInputs[0]);
await userEvent.type(valueInputs[0], 'new-value');
await userEvent.clear(valueInput);
await userEvent.type(valueInput, 'new-value[Tab]');
expect(mockOnStaticOptionsChange).toHaveBeenCalled();
expect(mockOnStaticOptionsChange.mock.lastCall[0]).toEqual([{ value: 'new-value', label: 'Test Label' }]);
expect(mockOnStaticOptionsChange).toHaveBeenCalledWith([
{ value: 'new-value', label: 'Test Label', properties: { value: 'new-value', text: 'Test Label' } },
]);
});
it('should remove static options and hide UI elements when static options switch is unchecked', async () => {

View file

@ -5,7 +5,7 @@ import { DataSourceInstanceSettings, SelectableValue, TimeRange, VariableRegexAp
import { selectors } from '@grafana/e2e-selectors';
import { Trans, t } from '@grafana/i18n';
import { getDataSourceSrv } from '@grafana/runtime';
import { QueryVariable } from '@grafana/scenes';
import { QueryVariable, VariableValueOption } from '@grafana/scenes';
import { DataSourceRef, VariableRefresh, VariableSort } from '@grafana/schema';
import { Field } from '@grafana/ui';
import { QueryEditor } from 'app/features/dashboard-scene/settings/variables/components/QueryEditor';
@ -52,6 +52,7 @@ interface QueryVariableEditorFormProps {
staticOptionsOrder?: StaticOptionsOrderType;
onStaticOptionsChange?: (staticOptions: StaticOptionsType) => void;
onStaticOptionsOrderChange?: (staticOptionsOrder: StaticOptionsOrderType) => void;
options: VariableValueOption[];
}
export function QueryVariableEditorForm({
@ -81,6 +82,7 @@ export function QueryVariableEditorForm({
staticOptionsOrder,
onStaticOptionsChange,
onStaticOptionsOrderChange,
options,
}: QueryVariableEditorFormProps) {
const { value: dsConfig } = useAsync(async () => {
const datasource = await getDataSourceSrv().get(datasourceRef ?? '');
@ -120,6 +122,7 @@ export function QueryVariableEditorForm({
<Field
label={t('dashboard-scene.query-variable-editor-form.label-data-source', 'Data source')}
htmlFor="data-source-picker"
noMargin
>
<DataSourcePicker current={datasourceRef} onChange={datasourceChangeHandler} variables={true} width={30} />
</Field>
@ -156,6 +159,7 @@ export function QueryVariableEditorForm({
{onStaticOptionsChange && onStaticOptionsOrderChange && (
<QueryVariableStaticOptions
options={options}
staticOptions={staticOptions}
staticOptionsOrder={staticOptionsOrder}
onStaticOptionsChange={onStaticOptionsChange}

View file

@ -0,0 +1,301 @@
import { css } from '@emotion/css';
import { DragDropContext, Draggable, Droppable, DropResult } from '@hello-pangea/dnd';
import { useEffect, useRef, useState } from 'react';
import { v4 as uuidv4 } from 'uuid';
import { GrafanaTheme2 } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { t } from '@grafana/i18n';
import { VariableValueOption, VariableValueOptionProperties } from '@grafana/scenes';
import { Icon, IconButton, Input, Stack, useStyles2 } from '@grafana/ui';
import { VariableStaticOptionsFormAddButton } from './VariableStaticOptionsFormAddButton';
type Option = VariableValueOption & {
id: string;
properties: VariableValueOptionProperties;
};
type VariableMultiPropStaticOptionsFormProps = {
options: VariableValueOption[];
properties: string[];
onChange: (options: VariableValueOption[]) => void;
allowEmptyValue?: boolean;
isInModal?: boolean;
};
const useVariableMultiPropStaticOptionsForm = ({
options,
properties,
onChange,
}: VariableMultiPropStaticOptionsFormProps) => {
const [internalOptions, setInternalOptions] = useState<Option[]>(() =>
options.map((o) => ({ id: uuidv4(), ...o, properties: o.properties ?? {} }))
);
// track id of newly added option for auto-focus
const autoFocusIdRef = useRef<string | null>(null);
useEffect(() => {
autoFocusIdRef.current = null;
});
const updateOptions = (newOptions: Option[]) => {
setInternalOptions(newOptions);
onChange(newOptions.map((o) => ({ label: o.label, value: o.value, properties: o.properties })));
};
const onAddNewOption = () => {
const newId = uuidv4();
autoFocusIdRef.current = newId;
const newOption = {
id: newId,
label: '',
value: '',
properties: properties.reduce((acc, p) => ({ ...acc, [p]: '' }), {}),
};
updateOptions([...internalOptions, newOption]);
};
const onRemoveOption = (o: Option) => {
const newOptions = internalOptions.filter(({ id }) => o.id !== id);
updateOptions(newOptions);
};
const onOptionsReordered = (result: DropResult) => {
if (!result || !result.destination) {
return;
}
const startIdx = result.source.index;
const endIdx = result.destination.index;
if (startIdx === endIdx) {
return;
}
const newOptions = [...internalOptions];
const [removedItem] = newOptions.splice(startIdx, 1);
newOptions.splice(endIdx, 0, removedItem);
updateOptions(newOptions);
};
const onValueChange = (o: Option, key: string, value: string) => {
const newOptions = internalOptions.map((option) => {
if (option.id === o.id) {
const newProperties = { ...option.properties, [key]: value };
return {
...option,
label: newProperties.text,
value: newProperties.value,
properties: newProperties,
};
} else {
return option;
}
});
updateOptions(newOptions);
};
return {
properties,
options: internalOptions,
autoFocusId: autoFocusIdRef.current,
onAddNewOption,
onRemoveOption,
onOptionsReordered,
onValueChange,
};
};
export const VariableMultiPropStaticOptionsForm = (props: VariableMultiPropStaticOptionsFormProps) => {
const styles = useStyles2(getStyles, props.properties.length);
const { properties, options, autoFocusId, onAddNewOption, onRemoveOption, onOptionsReordered, onValueChange } =
useVariableMultiPropStaticOptionsForm(props);
return (
<div className={styles.wrapper}>
<div
className={styles.grid}
role="grid"
aria-label={t(
'dashboard-scene.variable-multi-prop-static-options-form.aria-label-static-options',
'Static options'
)}
>
<div className={styles.headerRow} role="row">
<div className={styles.headerCell} role="columnheader" />
{properties.map((p) => (
<div key={p} className={styles.headerCell} role="columnheader">
{p}
</div>
))}
</div>
<DragDropContext onDragEnd={onOptionsReordered}>
<Droppable droppableId="static-options-list" direction="vertical">
{(droppableProvided) => (
<div
className={styles.body}
ref={droppableProvided.innerRef}
{...droppableProvided.droppableProps}
role="rowgroup"
>
{options.map((o, i) => (
<OptionRow
key={o.id}
index={i}
option={o}
properties={properties}
autoFocusFirstInput={o.id === autoFocusId}
onRemoveOption={onRemoveOption}
onValueChange={onValueChange}
onAddNewOption={i === options.length - 1 ? onAddNewOption : undefined}
/>
))}
{droppableProvided.placeholder}
</div>
)}
</Droppable>
</DragDropContext>
</div>
<div className={styles.addNewOptionButton}>
<VariableStaticOptionsFormAddButton onAdd={onAddNewOption} />
</div>
</div>
);
};
type OptionRowProps = {
index: number;
option: Option;
properties: string[];
autoFocusFirstInput?: boolean;
onRemoveOption: (option: Option) => void;
onValueChange: (option: Option, key: string, value: string) => void;
onAddNewOption?: () => void;
};
function OptionRow({
index,
option,
properties,
autoFocusFirstInput,
onAddNewOption,
onRemoveOption,
onValueChange,
}: OptionRowProps) {
const styles = useStyles2(getStyles, properties.length);
const onKeyDown = onAddNewOption
? (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
onAddNewOption();
}
}
: undefined;
return (
<Draggable draggableId={option.id} index={index}>
{(draggableProvided) => (
<div
className={styles.row}
ref={draggableProvided.innerRef}
{...draggableProvided.draggableProps}
data-testid={selectors.pages.Dashboard.Settings.Variables.Edit.StaticOptionsEditor.row}
role="row"
style={{ ...draggableProvided.draggableProps.style }}
>
<div className={styles.cell} role="gridcell">
<Stack direction="row" alignItems="center" {...draggableProvided.dragHandleProps}>
<Icon
title={t('dashboard-scene.option-row.title-drag-and-drop-to-reorder', 'Drag and drop to reorder')}
name="draggabledots"
size="lg"
className={styles.dragIcon}
/>
</Stack>
</div>
{properties.map((p, i) => (
<div key={`r1-${p}`} className={styles.cell} role="gridcell">
<Input
autoFocus={autoFocusFirstInput && !i}
tabIndex={0}
placeholder={p}
value={option.properties[p] ?? ''}
onChange={(e) => {
if (option.properties[p] !== e.currentTarget.value) {
onValueChange(option, p, e.currentTarget.value);
}
}}
onKeyDown={i === properties.length - 1 ? onKeyDown : undefined}
/>
</div>
))}
<div className={styles.cell} role="gridcell">
<IconButton
name="trash-alt"
variant="destructive"
onClick={() => onRemoveOption(option)}
aria-label={t('dashboard-scene.option-row.aria-label-remove-option', 'Remove option')}
tooltip={t('dashboard-scene.option-row.tooltip-remove-option', 'Remove option')}
tooltipPlacement="top"
/>
</div>
</div>
)}
</Draggable>
);
}
const getStyles = (theme: GrafanaTheme2, propertiesCount: number) => {
const gridTemplateColumns = `min-content repeat(${propertiesCount}, 1fr) min-content`;
return {
wrapper: css({
display: 'flex',
flexDirection: 'column',
}),
grid: css({
display: 'grid',
gap: theme.spacing(0.5),
width: '100%',
}),
headerRow: css({
display: 'grid',
gridTemplateColumns,
alignItems: 'end',
background: theme.colors.background.primary,
borderBottom: `1px solid ${theme.colors.border.weak}`,
}),
headerCell: css({
fontWeight: theme.typography.fontWeightBold,
color: theme.colors.text.primary,
textAlign: 'left',
padding: theme.spacing(0, 0.5, 1, 0.5),
}),
deletePropertyButton: css({
position: 'absolute',
right: '2px',
zIndex: 1,
}),
body: css({
display: 'flex',
flexDirection: 'column',
gap: theme.spacing(0.5),
}),
row: css({
display: 'grid',
gridTemplateColumns,
alignItems: 'center',
position: 'relative',
}),
cell: css({
padding: theme.spacing(0.5),
}),
dragIcon: css({
cursor: 'grab',
}),
addNewOptionButton: css({
margin: theme.spacing(1, 0, 1, 0),
}),
};
};

View file

@ -13,7 +13,6 @@ import { VariableStaticOptionsFormItems } from './VariableStaticOptionsFormItems
interface VariableStaticOptionsFormProps {
options: VariableValueOption[];
onChange: (options: VariableValueOption[]) => void;
allowEmptyValue?: boolean;
isInModal?: boolean;
}

View file

@ -5,67 +5,30 @@ import { GrafanaTheme2 } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { Trans } from '@grafana/i18n';
import { config } from '@grafana/runtime';
import { VariableValueOption, VariableValueOptionProperties } from '@grafana/scenes';
import { SceneVariable, VariableValueOption, VariableValueOptionProperties } from '@grafana/scenes';
import { Button, InlineFieldRow, InlineLabel, InteractiveTable, Text, useStyles2 } from '@grafana/ui';
import { ALL_VARIABLE_VALUE } from 'app/features/variables/constants';
export interface Props {
interface VariableValuesPreviewProps {
options: VariableValueOption[];
hasMultiProps?: boolean;
staticOptions: VariableValueOption[];
}
export const VariableValuesPreview = ({ options, hasMultiProps }: Props) => {
const styles = useStyles2(getStyles);
const hasOptions = options.length > 0;
const displayMultiPropsPreview = config.featureToggles.multiPropsVariables && hasMultiProps;
return (
<div className={styles.previewContainer} style={{ gap: '8px' }}>
<Text variant="bodySmall" weight="medium">
<Trans i18nKey="dashboard-scene.variable-values-preview.preview-of-values" values={{ count: options.length }}>
Preview of values ({'{{count}}'})
</Trans>
{hasOptions && displayMultiPropsPreview && <VariableValuesWithPropsPreview options={options} />}
{hasOptions && !displayMultiPropsPreview && <VariableValuesWithoutPropsPreview options={options} />}
</Text>
</div>
);
export const useGetAllVariableOptions = (
variable: SceneVariable
): { options: VariableValueOption[]; staticOptions: VariableValueOption[] } => {
const state = variable.useState();
return {
options:
'getOptionsForSelect' in variable && typeof variable.getOptionsForSelect === 'function'
? variable.getOptionsForSelect(false)
: 'options' in state
? (state.options ?? [])
: [],
staticOptions: 'staticOptions' in state && Array.isArray(state.staticOptions) ? state.staticOptions : [],
};
};
function VariableValuesWithPropsPreview({ options }: { options: VariableValueOption[] }) {
const styles = useStyles2(getStyles);
const { data, columns } = useMemo(() => {
const data = options.map(({ label, value, properties }) => ({
label: String(label),
value: String(value),
...flattenProperties(properties),
}));
return {
data,
columns: Object.keys(data[0] ?? {}).map((id) => ({
id,
// see https://github.com/TanStack/table/issues/1671
header: unsanitizeKey(id),
sortType: 'alphanumeric' as const,
})),
};
}, [options]);
return (
<InteractiveTable
className={styles.table}
columns={columns}
data={data}
getRowId={(r) => String(r.value)}
pageSize={8}
/>
);
}
const sanitizeKey = (key: string) => key.replace(/\./g, '__dot__');
const unsanitizeKey = (key: string) => key.replace(/__dot__/g, '.');
function flattenProperties(properties?: VariableValueOptionProperties, path = ''): Record<string, string> {
if (properties === undefined) {
return {};
@ -76,18 +39,90 @@ function flattenProperties(properties?: VariableValueOptionProperties, path = ''
for (const [key, value] of Object.entries(properties)) {
const newPath = path ? `${path}.${key}` : key;
if (typeof value === 'object') {
if (typeof value === 'object' && value !== null) {
Object.assign(result, flattenProperties(value, newPath));
} else {
// see https://github.com/TanStack/table/issues/1671
result[sanitizeKey(newPath)] = value;
result[sanitizeKey(newPath)] = value; // see https://github.com/TanStack/table/issues/1671
}
}
return result;
}
function VariableValuesWithoutPropsPreview({ options }: { options: VariableValueOption[] }) {
// Use the first non-static option which is not the "All" option to derive properties
export const useGetPropertiesFromOptions = (
options: VariableValueOption[],
staticOptions: VariableValueOption[] = []
) =>
useMemo(() => {
const staticValues = new Set(staticOptions?.map((s) => s.value) ?? []);
const queryOption = options.find((o) => o.value !== ALL_VARIABLE_VALUE && !staticValues.has(o.value));
const flattened = flattenProperties(queryOption?.properties);
const keys = Object.keys(flattened).filter((p) => !['text', 'value'].includes(p));
return ['text', 'value', ...keys];
}, [options, staticOptions]);
export const VariableValuesPreview = ({ options, staticOptions }: VariableValuesPreviewProps) => {
const styles = useStyles2(getStyles);
const properties = useGetPropertiesFromOptions(options, staticOptions);
const hasOptions = options.length > 0;
const displayMultiPropsPreview = config.featureToggles.multiPropsVariables && hasOptions && properties.length > 2;
return (
<div className={styles.previewContainer} style={{ gap: '8px' }}>
<Text variant="bodySmall" weight="medium">
<Trans i18nKey="dashboard-scene.variable-values-preview.preview-of-values" values={{ count: options.length }}>
Preview of values ({'{{count}}'})
</Trans>
{hasOptions && displayMultiPropsPreview && (
<VariableValuesWithPropsPreview options={options} properties={properties} />
)}
{hasOptions && !displayMultiPropsPreview && <VariableValuesWithoutPropsPreview options={options} />}
</Text>
</div>
);
};
function VariableValuesWithPropsPreview({
options,
properties,
}: {
options: VariableValueOption[];
properties: string[];
}) {
const styles = useStyles2(getStyles);
const { data, columns } = useMemo(() => {
const data = options.map(({ label, value, properties }) => ({
text: label,
value,
...flattenProperties(properties),
}));
return {
data,
columns: properties.map((id) => ({
id,
header: unsanitizeKey(id), // see https://github.com/TanStack/table/issues/1671
sortType: 'alphanumeric' as const,
})),
};
}, [options, properties]);
return (
<InteractiveTable
className={styles.table}
columns={columns}
data={data}
getRowId={(r) => String(r.value)}
pageSize={8}
/>
);
}
const sanitizeKey = (key: string) => key.replace(/\./g, '__dot__');
const unsanitizeKey = (key: string) => key.replace(/__dot__/g, '.');
export function VariableValuesWithoutPropsPreview({ options }: { options: VariableValueOption[] }) {
const styles = useStyles2(getStyles);
const [previewLimit, setPreviewLimit] = useState(20);
const [previewOptions, setPreviewOptions] = useState<VariableValueOption[]>([]);

View file

@ -1,4 +1,4 @@
import { FormEvent, useMemo, useRef, useState } from 'react';
import { FormEvent, useRef, useState } from 'react';
import { lastValueFrom } from 'rxjs';
import { CustomVariableModel } from '@grafana/data';
@ -10,7 +10,7 @@ import { Button, FieldValidationMessage, Modal, Stack, TextArea } from '@grafana
import { dashboardEditActions } from '../../../../edit-pane/shared';
import { ValuesFormatSelector } from '../../components/CustomVariableForm';
import { VariableValuesPreview } from '../../components/VariableValuesPreview';
import { useGetAllVariableOptions, VariableValuesPreview } from '../../components/VariableValuesPreview';
import { validateJsonQuery } from './CustomVariableEditor';
import { ModalEditorNonMultiProps } from './ModalEditorNonMultiProps';
@ -29,10 +29,11 @@ export function ModalEditor(props: ModalEditorProps) {
function ModalEditorMultiProps(props: ModalEditorProps) {
const {
options,
staticOptions,
valuesFormat,
query,
queryValidationError,
options,
onCloseModal,
onValuesFormatChange,
onQueryChange,
@ -41,7 +42,7 @@ function ModalEditorMultiProps(props: ModalEditorProps) {
return (
<Modal
title={t('dashboard.edit-pane.variable.custom-options.modal-title', 'Custom Variable')}
title={t('dashboard.edit-pane.variable.custom-options.modal-title', 'Custom options')}
isOpen={true}
onDismiss={onCloseModal}
closeOnBackdropClick={false}
@ -69,7 +70,7 @@ function ModalEditorMultiProps(props: ModalEditorProps) {
{queryValidationError && <FieldValidationMessage>{queryValidationError.message}</FieldValidationMessage>}
</div>
<div>
<VariableValuesPreview options={options} hasMultiProps={valuesFormat === 'json'} />
<VariableValuesPreview options={options} staticOptions={staticOptions} />
</div>
</Stack>
<Modal.ButtonRow>
@ -101,23 +102,14 @@ function useModalEditor({ variable, onClose }: ModalEditorProps) {
const [query, setQuery] = useState(() => variable.state.query);
const [prevQuery, setPrevQuery] = useState('');
const [queryValidationError, setQueryValidationError] = useState<Error>();
const options = useMemo(() => {
if (valuesFormat === 'csv') {
return variable.transformCsvStringToOptions(query, false).map(({ label, value }) => ({
value,
label: value === label ? '' : label,
}));
} else {
return variable.transformJsonToOptions(query);
}
}, [query, valuesFormat, variable]);
const { options, staticOptions } = useGetAllVariableOptions(variable);
return {
options,
staticOptions,
valuesFormat,
query,
queryValidationError,
options,
onCloseModal: onClose,
onValuesFormatChange(newFormat: CustomVariableModel['valuesFormat']) {
setQuery(prevQuery);

View file

@ -29,7 +29,7 @@ export function ModalEditorNonMultiProps(props: ModalEditorProps) {
return (
<Modal
title={t('dashboard.edit-pane.variable.custom-options.modal-title', 'Custom Variable')}
title={t('dashboard.edit-pane.variable.custom-options.modal-title', 'Custom options')}
isOpen={true}
onDismiss={onCloseModal}
closeOnBackdropClick={false}
@ -44,7 +44,7 @@ export function ModalEditorNonMultiProps(props: ModalEditorProps) {
)}
<Stack direction="column" gap={2}>
<VariableStaticOptionsForm options={options} onChange={onChangeOptions} ref={formRef} isInModal />
<VariableValuesPreview options={options} />
<VariableValuesPreview options={options} staticOptions={[]} />
</Stack>
<Modal.ButtonRow leftItems={<VariableStaticOptionsFormAddButton onAdd={onAddNewOption} />}>
<Button
@ -74,6 +74,7 @@ function useModalEditor({ variable, onClose }: ModalEditorProps) {
const formRef = useRef<VariableStaticOptionsFormRef | null>(null);
return {
variable,
displayMultiPropsWarningBanner: valuesFormat === 'json',
formRef,
onCloseModal: onClose,

View file

@ -33,6 +33,12 @@ const promDatasource = mockDataSource({
jest.mock('@grafana/runtime', () => ({
...jest.requireActual('@grafana/runtime'),
config: {
...jest.requireActual('@grafana/runtime').config,
featureToggles: {
multiPropsVariables: true,
},
},
getDataSourceSrv: () => ({
get: async () => ({
...defaultDatasource,
@ -387,7 +393,7 @@ describe('QueryVariableEditor', () => {
it('should update the variable state when adding two static options', async () => {
const {
variable,
renderer: { getByTestId, getAllByTestId },
renderer: { getByTestId, getAllByPlaceholderText },
user,
} = await setup();
@ -405,44 +411,33 @@ describe('QueryVariableEditor', () => {
await user.click(addButton);
// Enter label and value for first option
const labelInputs = getAllByTestId(
selectors.pages.Dashboard.Settings.Variables.Edit.StaticOptionsEditor.labelInput
);
const valueInputs = getAllByTestId(
selectors.pages.Dashboard.Settings.Variables.Edit.StaticOptionsEditor.valueInput
);
await user.type(labelInputs[0], 'First Option');
await user.type(valueInputs[0], 'first-value');
await waitFor(async () => {
await lastValueFrom(variable.validateAndUpdate());
});
expect(variable.state.staticOptions).toEqual([{ label: 'First Option', value: 'first-value' }]);
// Add second static option
await user.click(addButton);
// Get updated inputs (now there should be 2 sets)
const updatedLabelInputs = getAllByTestId(
selectors.pages.Dashboard.Settings.Variables.Edit.StaticOptionsEditor.labelInput
);
const updatedValueInputs = getAllByTestId(
selectors.pages.Dashboard.Settings.Variables.Edit.StaticOptionsEditor.valueInput
);
// Enter label and value for second option
await user.type(updatedLabelInputs[1], 'Second Option');
await user.type(updatedValueInputs[1], 'second-value');
await user.type(getAllByPlaceholderText('text')[0], 'First Option[Tab]');
await user.type(getAllByPlaceholderText('value')[0], 'first-value[Tab]');
await screen.findByDisplayValue('first-value');
await waitFor(async () => {
await lastValueFrom(variable.validateAndUpdate());
});
expect(variable.state.staticOptions).toEqual([
{ label: 'First Option', value: 'first-value' },
{ label: 'Second Option', value: 'second-value' },
{ label: 'First Option', value: 'first-value', properties: { text: 'First Option', value: 'first-value' } },
]);
// Add second static option
await user.click(addButton);
// Enter label and value for second option
await user.type(getAllByPlaceholderText('text')[1], 'Second Option[Tab]');
await user.type(getAllByPlaceholderText('value')[1], 'second-value[Tab]');
await screen.findByDisplayValue('second-value');
await waitFor(async () => {
await lastValueFrom(variable.validateAndUpdate());
});
expect(variable.state.staticOptions).toEqual([
{ label: 'First Option', value: 'first-value', properties: { text: 'First Option', value: 'first-value' } },
{ label: 'Second Option', value: 'second-value', properties: { text: 'Second Option', value: 'second-value' } },
]);
});

View file

@ -1,9 +1,9 @@
import { useState, FormEvent } from 'react';
import { FormEvent, useState } from 'react';
import { useAsync } from 'react-use';
import { SelectableValue, DataSourceInstanceSettings, getDataSourceRef, VariableRegexApplyTo } from '@grafana/data';
import { DataSourceInstanceSettings, getDataSourceRef, SelectableValue, VariableRegexApplyTo } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { Trans, t } from '@grafana/i18n';
import { t, Trans } from '@grafana/i18n';
import { getDataSourceSrv } from '@grafana/runtime';
import { QueryVariable, sceneGraph, SceneVariable } from '@grafana/scenes';
import { VariableRefresh, VariableSort } from '@grafana/schema';
@ -43,6 +43,7 @@ export function QueryVariableEditor({ variable, onRunQuery }: QueryVariableEdito
allValue,
query,
allowCustomValue,
options,
staticOptions,
staticOptionsOrder,
} = variable.useState();
@ -125,6 +126,7 @@ export function QueryVariableEditor({ variable, onRunQuery }: QueryVariableEdito
staticOptionsOrder={staticOptionsOrder}
onStaticOptionsChange={onStaticOptionsChange}
onStaticOptionsOrderChange={onStaticOptionsOrderChange}
options={options}
/>
);
}
@ -203,6 +205,7 @@ export function Editor({ variable }: { variable: QueryVariable }) {
query,
regex,
regexApplyTo,
options,
staticOptions,
staticOptionsOrder,
} = variable.useState();
@ -298,16 +301,15 @@ export function Editor({ variable }: { variable: QueryVariable }) {
refresh={refresh}
/>
{onStaticOptionsChange && onStaticOptionsOrderChange && (
<QueryVariableStaticOptions
staticOptions={staticOptions}
staticOptionsOrder={staticOptionsOrder}
onStaticOptionsChange={onStaticOptionsChange}
onStaticOptionsOrderChange={onStaticOptionsOrderChange}
/>
)}
<QueryVariableStaticOptions
options={options}
staticOptions={staticOptions}
staticOptionsOrder={staticOptionsOrder}
onStaticOptionsChange={onStaticOptionsChange}
onStaticOptionsOrderChange={onStaticOptionsOrderChange}
/>
{isHasVariableOptions && <VariableValuesPreview options={variable.getOptionsForSelect(false)} />}
{isHasVariableOptions && <VariableValuesPreview options={options} staticOptions={staticOptions ?? []} />}
</div>
);
}

View file

@ -209,6 +209,7 @@ export function createSceneVariableFromVariableModel(variable: TypedVariableMode
staticOptions: variable.staticOptions?.map((option) => ({
label: String(option.text),
value: String(option.value),
properties: option.properties,
})),
staticOptionsOrder: variable.staticOptionsOrder,
});

View file

@ -80,31 +80,27 @@ const buildLabelPath = (label: string) => {
return label.includes('.') || label.trim().includes(' ') ? `["${label}"]` : `.${label}`;
};
const isRecordOrArray = (value: unknown): value is Record<string, unknown> | unknown[] =>
typeof value === 'object' && value !== null;
const getVariableValueProperties = (variable: TypedVariableModel): string[] => {
if (!('valuesFormat' in variable) || variable.valuesFormat !== 'json') {
if (!('options' in variable) || !variable.options[0].properties) {
return [];
}
function collectFieldPaths(option: Record<string, string>, currentPath: string) {
function collectFieldPaths(properties: Record<string, unknown> | unknown[], currentPath: string) {
let paths: string[] = [];
for (const field in option) {
if (option.hasOwnProperty(field)) {
const newPath = `${currentPath}.${field}`;
const value = option[field];
if (typeof value === 'object' && value !== null) {
paths = [...paths, ...collectFieldPaths(value, newPath)];
}
paths.push(newPath);
for (const [field, value] of Object.entries(properties)) {
const newPath = `${currentPath}.${field}`;
if (isRecordOrArray(value)) {
paths = [...paths, ...collectFieldPaths(value, newPath)];
}
paths.push(newPath);
}
return paths;
}
try {
return collectFieldPaths(JSON.parse(variable.query)[0], variable.name);
} catch {
return [];
}
return collectFieldPaths(variable.options[0].properties, variable.name);
};
export const getPanelLinksVariableSuggestions = (): VariableSuggestion[] => [

View file

@ -503,13 +503,16 @@ describe('linkSrv', () => {
});
describe('getPanelLinksVariableSuggestions', () => {
it('then it should return template variables, json properties and built-ins', () => {
it('then it should return template variables, options properties and built-ins', () => {
const templateSrvWithJsonValues = initTemplateSrv('key', [
{
type: 'custom',
name: 'customServers',
valuesFormat: 'json',
query: '[{"name":"web","ip":"192.168.0.100"},{"name":"ads","ip":"192.168.0.142"}]',
options: [
{ text: 'web', value: 'web', properties: { name: 'web', ip: '192.168.0.100' } },
{ text: 'ads', value: 'ads', properties: { name: 'ads', ip: '192.168.0.142' } },
],
},
]);
setTemplateSrv(templateSrvWithJsonValues);

View file

@ -3,7 +3,14 @@ import { FormEvent, PureComponent } from 'react';
import { connect, ConnectedProps } from 'react-redux';
import { bindActionCreators } from 'redux';
import { GrafanaTheme2, LoadingState, SelectableValue, VariableHide, VariableType } from '@grafana/data';
import {
GrafanaTheme2,
LoadingState,
SelectableValue,
VariableHide,
VariableType,
VariableWithOptions,
} from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { Trans, t } from '@grafana/i18n';
import { locationService } from '@grafana/runtime';
@ -29,6 +36,16 @@ import { VariableTypeSelect } from './VariableTypeSelect';
import { changeVariableName, variableEditorMount, variableEditorUnMount } from './actions';
import { OnPropChangeArguments, VariableNameConstraints } from './types';
// Adapter to make legacy VariableWithOptions compatible with VariableValuesPreview
function LegacyVariableValuesPreview({ variable }: { variable: VariableWithOptions }) {
const options = variable.options.map((opt) => ({
label: String(opt.text),
value: Array.isArray(opt.value) ? opt.value.join(', ') : opt.value,
properties: opt.properties,
}));
return <VariableValuesPreview options={options} staticOptions={[]} />;
}
const mapStateToProps = (state: StoreState, ownProps: OwnProps) => ({
editor: getVariablesState(ownProps.identifier.rootStateKey, state).editor,
variable: getVariable(ownProps.identifier, state),
@ -216,7 +233,7 @@ export class VariableEditorEditorUnConnected extends PureComponent<Props, State>
{EditorToRender && <EditorToRender variable={this.props.variable} onPropChange={this.onPropChanged} />}
{hasOptions(this.props.variable) ? <VariableValuesPreview options={this.getVariableOptions()} /> : null}
{hasOptions(this.props.variable) ? <LegacyVariableValuesPreview variable={this.props.variable} /> : null}
<div style={{ marginTop: '16px' }}>
<Stack gap={2} height="inherit">

View file

@ -157,6 +157,11 @@ export class QueryVariableEditorUnConnected extends PureComponent<Props, State>
onMultiChange={this.onMultiChange}
onIncludeAllChange={this.onIncludeAllChange}
onAllValueChange={this.onAllValueChange}
options={variable.options.map((o) => ({
label: String(o.text),
value: String(o.value),
properties: o.properties,
}))}
/>
);
}

View file

@ -2,16 +2,20 @@ import { useState } from 'react';
import { selectors } from '@grafana/e2e-selectors';
import { t, Trans } from '@grafana/i18n';
import { QueryVariable } from '@grafana/scenes';
import { config } from '@grafana/runtime';
import { QueryVariable, VariableValueOption } from '@grafana/scenes';
import { Field, Stack, Switch } from '@grafana/ui';
import { VariableLegend } from 'app/features/dashboard-scene/settings/variables/components/VariableLegend';
import { VariableMultiPropStaticOptionsForm } from 'app/features/dashboard-scene/settings/variables/components/VariableMultiPropStaticOptionsForm';
import { VariableSelectField } from 'app/features/dashboard-scene/settings/variables/components/VariableSelectField';
import { VariableStaticOptionsForm } from 'app/features/dashboard-scene/settings/variables/components/VariableStaticOptionsForm';
import { useGetPropertiesFromOptions } from 'app/features/dashboard-scene/settings/variables/components/VariableValuesPreview';
export type StaticOptionsType = QueryVariable['state']['staticOptions'];
export type StaticOptionsOrderType = QueryVariable['state']['staticOptionsOrder'];
interface QueryVariableStaticOptionsProps {
options: VariableValueOption[];
staticOptions: StaticOptionsType;
staticOptionsOrder: StaticOptionsOrderType;
onStaticOptionsChange: (staticOptions: StaticOptionsType) => void;
@ -25,11 +29,11 @@ const SORT_OPTIONS = [
];
export function QueryVariableStaticOptions(props: QueryVariableStaticOptionsProps) {
const { staticOptions, onStaticOptionsChange, staticOptionsOrder, onStaticOptionsOrderChange } = props;
const { options, staticOptions, onStaticOptionsChange, staticOptionsOrder, onStaticOptionsOrderChange } = props;
const value = SORT_OPTIONS.find((o) => o.value === staticOptionsOrder) ?? SORT_OPTIONS[0];
const [areStaticOptionsEnabled, setAreStaticOptionsEnabled] = useState(!!staticOptions?.length);
const displayMultiPropsEditor = areStaticOptionsEnabled && config.featureToggles.multiPropsVariables;
const properties = useGetPropertiesFromOptions(options, staticOptions);
return (
<>
@ -64,13 +68,17 @@ export function QueryVariableStaticOptions(props: QueryVariableStaticOptionsProp
}}
/>
{areStaticOptionsEnabled && (
<VariableStaticOptionsForm
allowEmptyValue
{displayMultiPropsEditor && (
<VariableMultiPropStaticOptionsForm
options={staticOptions ?? []}
properties={properties}
onChange={onStaticOptionsChange}
allowEmptyValue
/>
)}
{!displayMultiPropsEditor && areStaticOptionsEnabled && (
<VariableStaticOptionsForm options={staticOptions ?? []} onChange={onStaticOptionsChange} />
)}
</Stack>
</>
</Field>

View file

@ -4911,7 +4911,7 @@
"apply": "Apply",
"change-value": "Change variable value",
"discard": "Discard",
"modal-title": "Custom Variable",
"modal-title": "Custom options",
"values": "Values separated by comma"
},
"datasource-options": {
@ -6317,6 +6317,11 @@
"remove-panel": "Remove panel"
}
},
"option-row": {
"aria-label-remove-option": "Remove option",
"title-drag-and-drop-to-reorder": "Drag and drop to reorder",
"tooltip-remove-option": "Remove option"
},
"panel-data-alerting-tab": {
"tab-label": "Alert"
},
@ -6685,6 +6690,9 @@
},
"label": "Hide"
},
"variable-multi-prop-static-options-form": {
"aria-label-static-options": "Static options"
},
"variable-type-select": {
"name-variable-type": "Variable type"
},