mirror of
https://github.com/grafana/grafana.git
synced 2026-02-03 20:49:50 -05:00
QueryVariable: Support preview, static options and autocomplete for multi-props (#116259)
This commit is contained in:
parent
9c0b941561
commit
88f6bb83ab
41 changed files with 657 additions and 205 deletions
|
|
@ -800,6 +800,8 @@ VariableOption: {
|
||||||
text: string | [...string]
|
text: string | [...string]
|
||||||
// Value of the option
|
// Value of the option
|
||||||
value: string | [...string]
|
value: string | [...string]
|
||||||
|
// Additional properties for multi-props variables
|
||||||
|
properties?: {[string]: string}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Query variable specification
|
// Query variable specification
|
||||||
|
|
|
||||||
|
|
@ -804,6 +804,8 @@ VariableOption: {
|
||||||
text: string | [...string]
|
text: string | [...string]
|
||||||
// Value of the option
|
// Value of the option
|
||||||
value: string | [...string]
|
value: string | [...string]
|
||||||
|
// Additional properties for multi-props variables
|
||||||
|
properties?: {[string]: string}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Query variable specification
|
// Query variable specification
|
||||||
|
|
|
||||||
|
|
@ -241,6 +241,8 @@ lineage: schemas: [{
|
||||||
text: string | [...string]
|
text: string | [...string]
|
||||||
// Value of the option
|
// Value of the option
|
||||||
value: string | [...string]
|
value: string | [...string]
|
||||||
|
// Additional properties for multi-props variables
|
||||||
|
properties?: {[string]: string}
|
||||||
} @cuetsy(kind="interface")
|
} @cuetsy(kind="interface")
|
||||||
|
|
||||||
// Options to config when to refresh a variable
|
// Options to config when to refresh a variable
|
||||||
|
|
|
||||||
|
|
@ -241,6 +241,8 @@ lineage: schemas: [{
|
||||||
text: string | [...string]
|
text: string | [...string]
|
||||||
// Value of the option
|
// Value of the option
|
||||||
value: string | [...string]
|
value: string | [...string]
|
||||||
|
// Additional properties for multi-props variables
|
||||||
|
properties?: {[string]: string}
|
||||||
} @cuetsy(kind="interface")
|
} @cuetsy(kind="interface")
|
||||||
|
|
||||||
// Options to config when to refresh a variable
|
// Options to config when to refresh a variable
|
||||||
|
|
|
||||||
|
|
@ -804,6 +804,8 @@ VariableOption: {
|
||||||
text: string | [...string]
|
text: string | [...string]
|
||||||
// Value of the option
|
// Value of the option
|
||||||
value: string | [...string]
|
value: string | [...string]
|
||||||
|
// Additional properties for multi-props variables
|
||||||
|
properties?: {[string]: string}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Query variable specification
|
// Query variable specification
|
||||||
|
|
|
||||||
|
|
@ -1426,6 +1426,8 @@ type DashboardVariableOption struct {
|
||||||
Text DashboardStringOrArrayOfString `json:"text"`
|
Text DashboardStringOrArrayOfString `json:"text"`
|
||||||
// Value of the option
|
// Value of the option
|
||||||
Value DashboardStringOrArrayOfString `json:"value"`
|
Value DashboardStringOrArrayOfString `json:"value"`
|
||||||
|
// Additional properties for multi-props variables
|
||||||
|
Properties map[string]string `json:"properties,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewDashboardVariableOption creates a new DashboardVariableOption object.
|
// NewDashboardVariableOption creates a new DashboardVariableOption object.
|
||||||
|
|
|
||||||
|
|
@ -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"),
|
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"},
|
Required: []string{"text", "value"},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -808,6 +808,8 @@ VariableOption: {
|
||||||
text: string | [...string]
|
text: string | [...string]
|
||||||
// Value of the option
|
// Value of the option
|
||||||
value: string | [...string]
|
value: string | [...string]
|
||||||
|
// Additional properties for multi-props variables
|
||||||
|
properties?: {[string]: string}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Query variable specification
|
// Query variable specification
|
||||||
|
|
|
||||||
|
|
@ -1429,6 +1429,8 @@ type DashboardVariableOption struct {
|
||||||
Text DashboardStringOrArrayOfString `json:"text"`
|
Text DashboardStringOrArrayOfString `json:"text"`
|
||||||
// Value of the option
|
// Value of the option
|
||||||
Value DashboardStringOrArrayOfString `json:"value"`
|
Value DashboardStringOrArrayOfString `json:"value"`
|
||||||
|
// Additional properties for multi-props variables
|
||||||
|
Properties map[string]string `json:"properties,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewDashboardVariableOption creates a new DashboardVariableOption object.
|
// NewDashboardVariableOption creates a new DashboardVariableOption object.
|
||||||
|
|
|
||||||
|
|
@ -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"),
|
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"},
|
Required: []string{"text", "value"},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
4
apps/dashboard/pkg/apis/dashboard_manifest.go
generated
4
apps/dashboard/pkg/apis/dashboard_manifest.go
generated
File diff suppressed because one or more lines are too long
|
|
@ -260,7 +260,7 @@
|
||||||
},
|
},
|
||||||
"packages/grafana-data/src/types/templateVars.ts": {
|
"packages/grafana-data/src/types/templateVars.ts": {
|
||||||
"@typescript-eslint/no-explicit-any": {
|
"@typescript-eslint/no-explicit-any": {
|
||||||
"count": 2
|
"count": 3
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"packages/grafana-data/src/types/trace.ts": {
|
"packages/grafana-data/src/types/trace.ts": {
|
||||||
|
|
@ -1861,11 +1861,6 @@
|
||||||
"count": 1
|
"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": {
|
"public/app/features/dashboard-scene/settings/variables/components/VariableSelectField.tsx": {
|
||||||
"@typescript-eslint/no-explicit-any": {
|
"@typescript-eslint/no-explicit-any": {
|
||||||
"count": 1
|
"count": 1
|
||||||
|
|
|
||||||
|
|
@ -237,6 +237,8 @@ lineage: schemas: [{
|
||||||
text: string | [...string]
|
text: string | [...string]
|
||||||
// Value of the option
|
// Value of the option
|
||||||
value: string | [...string]
|
value: string | [...string]
|
||||||
|
// Additional properties for multi-props variables
|
||||||
|
properties?: {[string]: string}
|
||||||
} @cuetsy(kind="interface")
|
} @cuetsy(kind="interface")
|
||||||
|
|
||||||
// Options to config when to refresh a variable
|
// Options to config when to refresh a variable
|
||||||
|
|
|
||||||
|
|
@ -976,6 +976,10 @@ export type DashboardTimeSettingsSpec = {
|
||||||
weekStart?: 'saturday' | 'monday' | 'sunday';
|
weekStart?: 'saturday' | 'monday' | 'sunday';
|
||||||
};
|
};
|
||||||
export type DashboardVariableOption = {
|
export type DashboardVariableOption = {
|
||||||
|
/** Additional properties for multi-props variables */
|
||||||
|
properties?: {
|
||||||
|
[key: string]: string;
|
||||||
|
};
|
||||||
/** Whether the option is selected or not */
|
/** Whether the option is selected or not */
|
||||||
selected?: boolean;
|
selected?: boolean;
|
||||||
/** Text to be displayed for the option */
|
/** Text to be displayed for the option */
|
||||||
|
|
|
||||||
|
|
@ -91,6 +91,7 @@ export interface VariableOption {
|
||||||
text: string | string[];
|
text: string | string[];
|
||||||
value: string | string[];
|
value: string | string[];
|
||||||
isNone?: boolean;
|
isNone?: boolean;
|
||||||
|
properties?: Record<string, any>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IntervalVariableModel extends VariableWithOptions {
|
export interface IntervalVariableModel extends VariableWithOptions {
|
||||||
|
|
|
||||||
|
|
@ -231,6 +231,10 @@ export const defaultVariableModel: Partial<VariableModel> = {
|
||||||
* Option to be selected in a variable.
|
* Option to be selected in a variable.
|
||||||
*/
|
*/
|
||||||
export interface VariableOption {
|
export interface VariableOption {
|
||||||
|
/**
|
||||||
|
* Additional properties for multi-props variables
|
||||||
|
*/
|
||||||
|
properties?: Record<string, string>;
|
||||||
/**
|
/**
|
||||||
* Whether the option is selected or not
|
* Whether the option is selected or not
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -715,7 +715,9 @@ VariableOption: {
|
||||||
// Text to be displayed for the option
|
// Text to be displayed for the option
|
||||||
text: string | [...string]
|
text: string | [...string]
|
||||||
// Value of the option
|
// Value of the option
|
||||||
value: string | [...string]
|
value: string | [...string]
|
||||||
|
// Additional properties for multi-props variables
|
||||||
|
properties?: {[string]: string}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Query variable specification
|
// Query variable specification
|
||||||
|
|
|
||||||
|
|
@ -1152,6 +1152,8 @@ export interface VariableOption {
|
||||||
text: string | string[];
|
text: string | string[];
|
||||||
// Value of the option
|
// Value of the option
|
||||||
value: string | string[];
|
value: string | string[];
|
||||||
|
// Additional properties for multi-props variables
|
||||||
|
properties?: Record<string, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const defaultVariableOption = (): VariableOption => ({
|
export const defaultVariableOption = (): VariableOption => ({
|
||||||
|
|
|
||||||
|
|
@ -1158,6 +1158,8 @@ export interface VariableOption {
|
||||||
text: string | string[];
|
text: string | string[];
|
||||||
// Value of the option
|
// Value of the option
|
||||||
value: string | string[];
|
value: string | string[];
|
||||||
|
// Additional properties for multi-props variables
|
||||||
|
properties?: Record<string, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const defaultVariableOption = (): VariableOption => ({
|
export const defaultVariableOption = (): VariableOption => ({
|
||||||
|
|
|
||||||
2
pkg/kinds/dashboard/dashboard_spec_gen.go
generated
2
pkg/kinds/dashboard/dashboard_spec_gen.go
generated
|
|
@ -903,6 +903,8 @@ type VariableOption struct {
|
||||||
Text StringOrArrayOfString `json:"text"`
|
Text StringOrArrayOfString `json:"text"`
|
||||||
// Value of the option
|
// Value of the option
|
||||||
Value StringOrArrayOfString `json:"value"`
|
Value StringOrArrayOfString `json:"value"`
|
||||||
|
// Additional properties for multi-props variables
|
||||||
|
Properties map[string]string `json:"properties,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewVariableOption creates a new VariableOption object.
|
// NewVariableOption creates a new VariableOption object.
|
||||||
|
|
|
||||||
|
|
@ -3832,6 +3832,13 @@
|
||||||
"value"
|
"value"
|
||||||
],
|
],
|
||||||
"properties": {
|
"properties": {
|
||||||
|
"properties": {
|
||||||
|
"description": "Additional properties for multi-props variables",
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
"selected": {
|
"selected": {
|
||||||
"description": "Whether the option is selected or not",
|
"description": "Whether the option is selected or not",
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
|
|
|
||||||
|
|
@ -3859,6 +3859,13 @@
|
||||||
"value"
|
"value"
|
||||||
],
|
],
|
||||||
"properties": {
|
"properties": {
|
||||||
|
"properties": {
|
||||||
|
"description": "Additional properties for multi-props variables",
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
"selected": {
|
"selected": {
|
||||||
"description": "Whether the option is selected or not",
|
"description": "Whether the option is selected or not",
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
|
|
|
||||||
|
|
@ -93,6 +93,7 @@ export function sceneVariablesSetToVariables(set: SceneVariables, keepQueryOptio
|
||||||
staticOptions: variable.state.staticOptions?.map((option) => ({
|
staticOptions: variable.state.staticOptions?.map((option) => ({
|
||||||
text: option.label,
|
text: option.label,
|
||||||
value: String(option.value),
|
value: String(option.value),
|
||||||
|
...(option.properties && { properties: option.properties }),
|
||||||
})),
|
})),
|
||||||
staticOptionsOrder: variable.state.staticOptionsOrder,
|
staticOptionsOrder: variable.state.staticOptionsOrder,
|
||||||
};
|
};
|
||||||
|
|
@ -284,6 +285,7 @@ function variableValueOptionsToVariableOptions(varState: MultiValueVariable['sta
|
||||||
value: String(o.value),
|
value: String(o.value),
|
||||||
text: o.label,
|
text: o.label,
|
||||||
selected: Array.isArray(varState.value) ? varState.value.includes(o.value) : varState.value === o.value,
|
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) => ({
|
staticOptions: variable.state.staticOptions?.map((option) => ({
|
||||||
text: option.label,
|
text: option.label,
|
||||||
value: String(option.value),
|
value: String(option.value),
|
||||||
|
...(option.properties && { properties: option.properties }),
|
||||||
})),
|
})),
|
||||||
staticOptionsOrder: variable.state.staticOptionsOrder,
|
staticOptionsOrder: variable.state.staticOptionsOrder,
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -383,6 +383,7 @@ function createSceneVariableFromVariableModel(variable: TypedVariableModelV2): S
|
||||||
staticOptions: variable.spec.staticOptions.map((option) => ({
|
staticOptions: variable.spec.staticOptions.map((option) => ({
|
||||||
label: String(option.text),
|
label: String(option.text),
|
||||||
value: String(option.value),
|
value: String(option.value),
|
||||||
|
properties: option.properties,
|
||||||
})),
|
})),
|
||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
|
|
|
||||||
|
|
@ -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 { VariableLegend } from 'app/features/dashboard-scene/settings/variables/components/VariableLegend';
|
||||||
import { VariableTextAreaField } from 'app/features/dashboard-scene/settings/variables/components/VariableTextAreaField';
|
import { VariableTextAreaField } from 'app/features/dashboard-scene/settings/variables/components/VariableTextAreaField';
|
||||||
import { VariableTextField } from 'app/features/dashboard-scene/settings/variables/components/VariableTextField';
|
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 { VariableNameConstraints } from 'app/features/variables/editor/types';
|
||||||
|
|
||||||
import { VariableTypeSelect } from './components/VariableTypeSelect';
|
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 onDisplayChange = (display: VariableHide) => variable.setState({ hide: display });
|
||||||
|
|
||||||
const isHasVariableOptions = hasVariableOptions(variable);
|
const isHasVariableOptions = hasVariableOptions(variable);
|
||||||
const optionsForSelect = isHasVariableOptions ? variable.getOptionsForSelect(false) : [];
|
|
||||||
const hasMultiProps = 'valuesFormat' in variable.state && variable.state.valuesFormat === 'json';
|
|
||||||
|
|
||||||
const onDeleteVariable = (hideModal: () => void) => () => {
|
const onDeleteVariable = (hideModal: () => void) => () => {
|
||||||
reportInteraction('Delete variable');
|
reportInteraction('Delete variable');
|
||||||
|
|
@ -77,6 +78,8 @@ export function VariableEditorForm({ variable, onTypeChange, onGoBack, onDelete
|
||||||
hideModal();
|
hideModal();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const { options, staticOptions } = useGetAllVariableOptions(variable);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form
|
<form
|
||||||
aria-label={t('dashboard-scene.variable-editor-form.aria-label-variable-editor-form', 'Variable editor 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} />}
|
{EditorToRender && <EditorToRender variable={variable} onRunQuery={onRunQuery} />}
|
||||||
|
|
||||||
{isHasVariableOptions && <VariableValuesPreview options={optionsForSelect} hasMultiProps={hasMultiProps} />}
|
{isHasVariableOptions && <VariableValuesPreview options={options} staticOptions={staticOptions} />}
|
||||||
|
|
||||||
<div className={styles.buttonContainer}>
|
<div className={styles.buttonContainer}>
|
||||||
<Stack gap={2}>
|
<Stack gap={2}>
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,12 @@ const promDatasource = mockDataSource({
|
||||||
|
|
||||||
jest.mock('@grafana/runtime', () => ({
|
jest.mock('@grafana/runtime', () => ({
|
||||||
...jest.requireActual('@grafana/runtime'),
|
...jest.requireActual('@grafana/runtime'),
|
||||||
|
config: {
|
||||||
|
...jest.requireActual('@grafana/runtime').config,
|
||||||
|
featureToggles: {
|
||||||
|
multiPropsVariables: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
getDataSourceSrv: () => ({
|
getDataSourceSrv: () => ({
|
||||||
get: async () => ({
|
get: async () => ({
|
||||||
...defaultDatasource,
|
...defaultDatasource,
|
||||||
|
|
@ -106,6 +112,7 @@ describe('QueryVariableEditorForm', () => {
|
||||||
onAllowCustomValueChange: mockOnAllowCustomValueChange,
|
onAllowCustomValueChange: mockOnAllowCustomValueChange,
|
||||||
onStaticOptionsChange: mockOnStaticOptionsChange,
|
onStaticOptionsChange: mockOnStaticOptionsChange,
|
||||||
onStaticOptionsOrderChange: mockOnStaticOptionsOrderChange,
|
onStaticOptionsOrderChange: mockOnStaticOptionsOrderChange,
|
||||||
|
options: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
async function setup(props?: React.ComponentProps<typeof QueryVariableEditorForm>) {
|
async function setup(props?: React.ComponentProps<typeof QueryVariableEditorForm>) {
|
||||||
|
|
@ -351,7 +358,7 @@ describe('QueryVariableEditorForm', () => {
|
||||||
|
|
||||||
it('should call onStaticOptionsChange when adding a static option', async () => {
|
it('should call onStaticOptionsChange when adding a static option', async () => {
|
||||||
const {
|
const {
|
||||||
renderer: { getByTestId, getAllByTestId },
|
renderer: { getByTestId, getByPlaceholderText },
|
||||||
} = await setup();
|
} = await setup();
|
||||||
|
|
||||||
// First enable static options
|
// First enable static options
|
||||||
|
|
@ -363,83 +370,78 @@ describe('QueryVariableEditorForm', () => {
|
||||||
const addButton = getByTestId(selectors.pages.Dashboard.Settings.Variables.Edit.StaticOptionsEditor.addButton);
|
const addButton = getByTestId(selectors.pages.Dashboard.Settings.Variables.Edit.StaticOptionsEditor.addButton);
|
||||||
await userEvent.click(addButton);
|
await userEvent.click(addButton);
|
||||||
|
|
||||||
// Now enter label and value for the new option
|
// Enter label for the new option
|
||||||
const labelInputs = getAllByTestId(
|
await userEvent.type(getByPlaceholderText('text'), 'New Option Label[Tab]');
|
||||||
selectors.pages.Dashboard.Settings.Variables.Edit.StaticOptionsEditor.labelInput
|
await userEvent.type(getByPlaceholderText('value'), 'new-option-value[Tab]');
|
||||||
);
|
await screen.findByDisplayValue('new-option-value');
|
||||||
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');
|
|
||||||
|
|
||||||
expect(mockOnStaticOptionsChange).toHaveBeenCalled();
|
expect(mockOnStaticOptionsChange).toHaveBeenCalled();
|
||||||
expect(mockOnStaticOptionsChange.mock.lastCall[0]).toEqual([
|
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 () => {
|
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 {
|
const {
|
||||||
renderer: { getAllByTestId },
|
renderer: { getAllByLabelText },
|
||||||
} = await setup({
|
} = await setup({
|
||||||
...defaultProps,
|
...defaultProps,
|
||||||
staticOptions: [
|
staticOptions,
|
||||||
{ value: 'option1', label: 'Option 1' },
|
|
||||||
{ value: 'option2', label: 'Option 2' },
|
|
||||||
],
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const deleteButtons = getAllByTestId(
|
const deleteButtons = getAllByLabelText('Remove option');
|
||||||
selectors.pages.Dashboard.Settings.Variables.Edit.StaticOptionsEditor.deleteButton
|
|
||||||
);
|
|
||||||
|
|
||||||
// Remove the first option
|
// Remove the first option
|
||||||
await userEvent.click(deleteButtons[0]);
|
await userEvent.click(deleteButtons[0]);
|
||||||
|
|
||||||
expect(mockOnStaticOptionsChange).toHaveBeenCalledTimes(1);
|
expect(mockOnStaticOptionsChange).toHaveBeenCalledTimes(1);
|
||||||
// Should call with only the second option remaining
|
// 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 () => {
|
it('should call onStaticOptionsChange when editing a static option label', async () => {
|
||||||
const {
|
const {
|
||||||
renderer: { getAllByTestId },
|
renderer: { getByPlaceholderText },
|
||||||
} = await setup({
|
} = await setup({
|
||||||
...defaultProps,
|
...defaultProps,
|
||||||
staticOptions: [{ value: 'test', label: 'Test Label' }],
|
staticOptions: [{ value: 'test', label: 'Test Label', properties: { value: 'test', text: 'Test Label' } }],
|
||||||
});
|
});
|
||||||
|
|
||||||
const labelInputs = getAllByTestId(
|
const labelInput = getByPlaceholderText('text');
|
||||||
selectors.pages.Dashboard.Settings.Variables.Edit.StaticOptionsEditor.labelInput
|
await userEvent.clear(labelInput);
|
||||||
);
|
await userEvent.type(labelInput, 'Updated Label[Tab]');
|
||||||
|
|
||||||
await userEvent.clear(labelInputs[0]);
|
expect(mockOnStaticOptionsChange).toHaveBeenCalledWith([
|
||||||
await userEvent.type(labelInputs[0], 'Updated Label');
|
{ value: 'test', label: 'Updated Label', properties: { value: 'test', text: 'Updated Label' } },
|
||||||
|
]);
|
||||||
expect(mockOnStaticOptionsChange).toHaveBeenCalled();
|
|
||||||
expect(mockOnStaticOptionsChange.mock.lastCall[0]).toEqual([{ value: 'test', label: 'Updated Label' }]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should call onStaticOptionsChange when editing a static option value', async () => {
|
it('should call onStaticOptionsChange when editing a static option value', async () => {
|
||||||
const {
|
const {
|
||||||
renderer: { getAllByTestId },
|
renderer: { getByPlaceholderText },
|
||||||
} = await setup({
|
} = await setup({
|
||||||
...defaultProps,
|
...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(
|
const valueInput = getByPlaceholderText('value');
|
||||||
selectors.pages.Dashboard.Settings.Variables.Edit.StaticOptionsEditor.valueInput
|
|
||||||
);
|
|
||||||
|
|
||||||
await userEvent.clear(valueInputs[0]);
|
await userEvent.clear(valueInput);
|
||||||
await userEvent.type(valueInputs[0], 'new-value');
|
await userEvent.type(valueInput, 'new-value[Tab]');
|
||||||
|
|
||||||
expect(mockOnStaticOptionsChange).toHaveBeenCalled();
|
expect(mockOnStaticOptionsChange).toHaveBeenCalledWith([
|
||||||
expect(mockOnStaticOptionsChange.mock.lastCall[0]).toEqual([{ value: 'new-value', label: 'Test Label' }]);
|
{ 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 () => {
|
it('should remove static options and hide UI elements when static options switch is unchecked', async () => {
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import { DataSourceInstanceSettings, SelectableValue, TimeRange, VariableRegexAp
|
||||||
import { selectors } from '@grafana/e2e-selectors';
|
import { selectors } from '@grafana/e2e-selectors';
|
||||||
import { Trans, t } from '@grafana/i18n';
|
import { Trans, t } from '@grafana/i18n';
|
||||||
import { getDataSourceSrv } from '@grafana/runtime';
|
import { getDataSourceSrv } from '@grafana/runtime';
|
||||||
import { QueryVariable } from '@grafana/scenes';
|
import { QueryVariable, VariableValueOption } from '@grafana/scenes';
|
||||||
import { DataSourceRef, VariableRefresh, VariableSort } from '@grafana/schema';
|
import { DataSourceRef, VariableRefresh, VariableSort } from '@grafana/schema';
|
||||||
import { Field } from '@grafana/ui';
|
import { Field } from '@grafana/ui';
|
||||||
import { QueryEditor } from 'app/features/dashboard-scene/settings/variables/components/QueryEditor';
|
import { QueryEditor } from 'app/features/dashboard-scene/settings/variables/components/QueryEditor';
|
||||||
|
|
@ -52,6 +52,7 @@ interface QueryVariableEditorFormProps {
|
||||||
staticOptionsOrder?: StaticOptionsOrderType;
|
staticOptionsOrder?: StaticOptionsOrderType;
|
||||||
onStaticOptionsChange?: (staticOptions: StaticOptionsType) => void;
|
onStaticOptionsChange?: (staticOptions: StaticOptionsType) => void;
|
||||||
onStaticOptionsOrderChange?: (staticOptionsOrder: StaticOptionsOrderType) => void;
|
onStaticOptionsOrderChange?: (staticOptionsOrder: StaticOptionsOrderType) => void;
|
||||||
|
options: VariableValueOption[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function QueryVariableEditorForm({
|
export function QueryVariableEditorForm({
|
||||||
|
|
@ -81,6 +82,7 @@ export function QueryVariableEditorForm({
|
||||||
staticOptionsOrder,
|
staticOptionsOrder,
|
||||||
onStaticOptionsChange,
|
onStaticOptionsChange,
|
||||||
onStaticOptionsOrderChange,
|
onStaticOptionsOrderChange,
|
||||||
|
options,
|
||||||
}: QueryVariableEditorFormProps) {
|
}: QueryVariableEditorFormProps) {
|
||||||
const { value: dsConfig } = useAsync(async () => {
|
const { value: dsConfig } = useAsync(async () => {
|
||||||
const datasource = await getDataSourceSrv().get(datasourceRef ?? '');
|
const datasource = await getDataSourceSrv().get(datasourceRef ?? '');
|
||||||
|
|
@ -120,6 +122,7 @@ export function QueryVariableEditorForm({
|
||||||
<Field
|
<Field
|
||||||
label={t('dashboard-scene.query-variable-editor-form.label-data-source', 'Data source')}
|
label={t('dashboard-scene.query-variable-editor-form.label-data-source', 'Data source')}
|
||||||
htmlFor="data-source-picker"
|
htmlFor="data-source-picker"
|
||||||
|
noMargin
|
||||||
>
|
>
|
||||||
<DataSourcePicker current={datasourceRef} onChange={datasourceChangeHandler} variables={true} width={30} />
|
<DataSourcePicker current={datasourceRef} onChange={datasourceChangeHandler} variables={true} width={30} />
|
||||||
</Field>
|
</Field>
|
||||||
|
|
@ -156,6 +159,7 @@ export function QueryVariableEditorForm({
|
||||||
|
|
||||||
{onStaticOptionsChange && onStaticOptionsOrderChange && (
|
{onStaticOptionsChange && onStaticOptionsOrderChange && (
|
||||||
<QueryVariableStaticOptions
|
<QueryVariableStaticOptions
|
||||||
|
options={options}
|
||||||
staticOptions={staticOptions}
|
staticOptions={staticOptions}
|
||||||
staticOptionsOrder={staticOptionsOrder}
|
staticOptionsOrder={staticOptionsOrder}
|
||||||
onStaticOptionsChange={onStaticOptionsChange}
|
onStaticOptionsChange={onStaticOptionsChange}
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
@ -13,7 +13,6 @@ import { VariableStaticOptionsFormItems } from './VariableStaticOptionsFormItems
|
||||||
interface VariableStaticOptionsFormProps {
|
interface VariableStaticOptionsFormProps {
|
||||||
options: VariableValueOption[];
|
options: VariableValueOption[];
|
||||||
onChange: (options: VariableValueOption[]) => void;
|
onChange: (options: VariableValueOption[]) => void;
|
||||||
|
|
||||||
allowEmptyValue?: boolean;
|
allowEmptyValue?: boolean;
|
||||||
isInModal?: boolean;
|
isInModal?: boolean;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,67 +5,30 @@ import { GrafanaTheme2 } from '@grafana/data';
|
||||||
import { selectors } from '@grafana/e2e-selectors';
|
import { selectors } from '@grafana/e2e-selectors';
|
||||||
import { Trans } from '@grafana/i18n';
|
import { Trans } from '@grafana/i18n';
|
||||||
import { config } from '@grafana/runtime';
|
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 { 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[];
|
options: VariableValueOption[];
|
||||||
hasMultiProps?: boolean;
|
staticOptions: VariableValueOption[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const VariableValuesPreview = ({ options, hasMultiProps }: Props) => {
|
export const useGetAllVariableOptions = (
|
||||||
const styles = useStyles2(getStyles);
|
variable: SceneVariable
|
||||||
const hasOptions = options.length > 0;
|
): { options: VariableValueOption[]; staticOptions: VariableValueOption[] } => {
|
||||||
const displayMultiPropsPreview = config.featureToggles.multiPropsVariables && hasMultiProps;
|
const state = variable.useState();
|
||||||
|
return {
|
||||||
return (
|
options:
|
||||||
<div className={styles.previewContainer} style={{ gap: '8px' }}>
|
'getOptionsForSelect' in variable && typeof variable.getOptionsForSelect === 'function'
|
||||||
<Text variant="bodySmall" weight="medium">
|
? variable.getOptionsForSelect(false)
|
||||||
<Trans i18nKey="dashboard-scene.variable-values-preview.preview-of-values" values={{ count: options.length }}>
|
: 'options' in state
|
||||||
Preview of values ({'{{count}}'})
|
? (state.options ?? [])
|
||||||
</Trans>
|
: [],
|
||||||
{hasOptions && displayMultiPropsPreview && <VariableValuesWithPropsPreview options={options} />}
|
staticOptions: 'staticOptions' in state && Array.isArray(state.staticOptions) ? state.staticOptions : [],
|
||||||
{hasOptions && !displayMultiPropsPreview && <VariableValuesWithoutPropsPreview options={options} />}
|
};
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
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> {
|
function flattenProperties(properties?: VariableValueOptionProperties, path = ''): Record<string, string> {
|
||||||
if (properties === undefined) {
|
if (properties === undefined) {
|
||||||
return {};
|
return {};
|
||||||
|
|
@ -76,18 +39,90 @@ function flattenProperties(properties?: VariableValueOptionProperties, path = ''
|
||||||
for (const [key, value] of Object.entries(properties)) {
|
for (const [key, value] of Object.entries(properties)) {
|
||||||
const newPath = path ? `${path}.${key}` : key;
|
const newPath = path ? `${path}.${key}` : key;
|
||||||
|
|
||||||
if (typeof value === 'object') {
|
if (typeof value === 'object' && value !== null) {
|
||||||
Object.assign(result, flattenProperties(value, newPath));
|
Object.assign(result, flattenProperties(value, newPath));
|
||||||
} else {
|
} else {
|
||||||
// see https://github.com/TanStack/table/issues/1671
|
result[sanitizeKey(newPath)] = value; // see https://github.com/TanStack/table/issues/1671
|
||||||
result[sanitizeKey(newPath)] = value;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
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 styles = useStyles2(getStyles);
|
||||||
const [previewLimit, setPreviewLimit] = useState(20);
|
const [previewLimit, setPreviewLimit] = useState(20);
|
||||||
const [previewOptions, setPreviewOptions] = useState<VariableValueOption[]>([]);
|
const [previewOptions, setPreviewOptions] = useState<VariableValueOption[]>([]);
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { FormEvent, useMemo, useRef, useState } from 'react';
|
import { FormEvent, useRef, useState } from 'react';
|
||||||
import { lastValueFrom } from 'rxjs';
|
import { lastValueFrom } from 'rxjs';
|
||||||
|
|
||||||
import { CustomVariableModel } from '@grafana/data';
|
import { CustomVariableModel } from '@grafana/data';
|
||||||
|
|
@ -10,7 +10,7 @@ import { Button, FieldValidationMessage, Modal, Stack, TextArea } from '@grafana
|
||||||
|
|
||||||
import { dashboardEditActions } from '../../../../edit-pane/shared';
|
import { dashboardEditActions } from '../../../../edit-pane/shared';
|
||||||
import { ValuesFormatSelector } from '../../components/CustomVariableForm';
|
import { ValuesFormatSelector } from '../../components/CustomVariableForm';
|
||||||
import { VariableValuesPreview } from '../../components/VariableValuesPreview';
|
import { useGetAllVariableOptions, VariableValuesPreview } from '../../components/VariableValuesPreview';
|
||||||
|
|
||||||
import { validateJsonQuery } from './CustomVariableEditor';
|
import { validateJsonQuery } from './CustomVariableEditor';
|
||||||
import { ModalEditorNonMultiProps } from './ModalEditorNonMultiProps';
|
import { ModalEditorNonMultiProps } from './ModalEditorNonMultiProps';
|
||||||
|
|
@ -29,10 +29,11 @@ export function ModalEditor(props: ModalEditorProps) {
|
||||||
|
|
||||||
function ModalEditorMultiProps(props: ModalEditorProps) {
|
function ModalEditorMultiProps(props: ModalEditorProps) {
|
||||||
const {
|
const {
|
||||||
|
options,
|
||||||
|
staticOptions,
|
||||||
valuesFormat,
|
valuesFormat,
|
||||||
query,
|
query,
|
||||||
queryValidationError,
|
queryValidationError,
|
||||||
options,
|
|
||||||
onCloseModal,
|
onCloseModal,
|
||||||
onValuesFormatChange,
|
onValuesFormatChange,
|
||||||
onQueryChange,
|
onQueryChange,
|
||||||
|
|
@ -41,7 +42,7 @@ function ModalEditorMultiProps(props: ModalEditorProps) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<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}
|
isOpen={true}
|
||||||
onDismiss={onCloseModal}
|
onDismiss={onCloseModal}
|
||||||
closeOnBackdropClick={false}
|
closeOnBackdropClick={false}
|
||||||
|
|
@ -69,7 +70,7 @@ function ModalEditorMultiProps(props: ModalEditorProps) {
|
||||||
{queryValidationError && <FieldValidationMessage>{queryValidationError.message}</FieldValidationMessage>}
|
{queryValidationError && <FieldValidationMessage>{queryValidationError.message}</FieldValidationMessage>}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<VariableValuesPreview options={options} hasMultiProps={valuesFormat === 'json'} />
|
<VariableValuesPreview options={options} staticOptions={staticOptions} />
|
||||||
</div>
|
</div>
|
||||||
</Stack>
|
</Stack>
|
||||||
<Modal.ButtonRow>
|
<Modal.ButtonRow>
|
||||||
|
|
@ -101,23 +102,14 @@ function useModalEditor({ variable, onClose }: ModalEditorProps) {
|
||||||
const [query, setQuery] = useState(() => variable.state.query);
|
const [query, setQuery] = useState(() => variable.state.query);
|
||||||
const [prevQuery, setPrevQuery] = useState('');
|
const [prevQuery, setPrevQuery] = useState('');
|
||||||
const [queryValidationError, setQueryValidationError] = useState<Error>();
|
const [queryValidationError, setQueryValidationError] = useState<Error>();
|
||||||
|
const { options, staticOptions } = useGetAllVariableOptions(variable);
|
||||||
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]);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
options,
|
||||||
|
staticOptions,
|
||||||
valuesFormat,
|
valuesFormat,
|
||||||
query,
|
query,
|
||||||
queryValidationError,
|
queryValidationError,
|
||||||
options,
|
|
||||||
onCloseModal: onClose,
|
onCloseModal: onClose,
|
||||||
onValuesFormatChange(newFormat: CustomVariableModel['valuesFormat']) {
|
onValuesFormatChange(newFormat: CustomVariableModel['valuesFormat']) {
|
||||||
setQuery(prevQuery);
|
setQuery(prevQuery);
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,7 @@ export function ModalEditorNonMultiProps(props: ModalEditorProps) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<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}
|
isOpen={true}
|
||||||
onDismiss={onCloseModal}
|
onDismiss={onCloseModal}
|
||||||
closeOnBackdropClick={false}
|
closeOnBackdropClick={false}
|
||||||
|
|
@ -44,7 +44,7 @@ export function ModalEditorNonMultiProps(props: ModalEditorProps) {
|
||||||
)}
|
)}
|
||||||
<Stack direction="column" gap={2}>
|
<Stack direction="column" gap={2}>
|
||||||
<VariableStaticOptionsForm options={options} onChange={onChangeOptions} ref={formRef} isInModal />
|
<VariableStaticOptionsForm options={options} onChange={onChangeOptions} ref={formRef} isInModal />
|
||||||
<VariableValuesPreview options={options} />
|
<VariableValuesPreview options={options} staticOptions={[]} />
|
||||||
</Stack>
|
</Stack>
|
||||||
<Modal.ButtonRow leftItems={<VariableStaticOptionsFormAddButton onAdd={onAddNewOption} />}>
|
<Modal.ButtonRow leftItems={<VariableStaticOptionsFormAddButton onAdd={onAddNewOption} />}>
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -74,6 +74,7 @@ function useModalEditor({ variable, onClose }: ModalEditorProps) {
|
||||||
const formRef = useRef<VariableStaticOptionsFormRef | null>(null);
|
const formRef = useRef<VariableStaticOptionsFormRef | null>(null);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
variable,
|
||||||
displayMultiPropsWarningBanner: valuesFormat === 'json',
|
displayMultiPropsWarningBanner: valuesFormat === 'json',
|
||||||
formRef,
|
formRef,
|
||||||
onCloseModal: onClose,
|
onCloseModal: onClose,
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,12 @@ const promDatasource = mockDataSource({
|
||||||
|
|
||||||
jest.mock('@grafana/runtime', () => ({
|
jest.mock('@grafana/runtime', () => ({
|
||||||
...jest.requireActual('@grafana/runtime'),
|
...jest.requireActual('@grafana/runtime'),
|
||||||
|
config: {
|
||||||
|
...jest.requireActual('@grafana/runtime').config,
|
||||||
|
featureToggles: {
|
||||||
|
multiPropsVariables: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
getDataSourceSrv: () => ({
|
getDataSourceSrv: () => ({
|
||||||
get: async () => ({
|
get: async () => ({
|
||||||
...defaultDatasource,
|
...defaultDatasource,
|
||||||
|
|
@ -387,7 +393,7 @@ describe('QueryVariableEditor', () => {
|
||||||
it('should update the variable state when adding two static options', async () => {
|
it('should update the variable state when adding two static options', async () => {
|
||||||
const {
|
const {
|
||||||
variable,
|
variable,
|
||||||
renderer: { getByTestId, getAllByTestId },
|
renderer: { getByTestId, getAllByPlaceholderText },
|
||||||
user,
|
user,
|
||||||
} = await setup();
|
} = await setup();
|
||||||
|
|
||||||
|
|
@ -405,44 +411,33 @@ describe('QueryVariableEditor', () => {
|
||||||
await user.click(addButton);
|
await user.click(addButton);
|
||||||
|
|
||||||
// Enter label and value for first option
|
// Enter label and value for first option
|
||||||
const labelInputs = getAllByTestId(
|
await user.type(getAllByPlaceholderText('text')[0], 'First Option[Tab]');
|
||||||
selectors.pages.Dashboard.Settings.Variables.Edit.StaticOptionsEditor.labelInput
|
await user.type(getAllByPlaceholderText('value')[0], 'first-value[Tab]');
|
||||||
);
|
await screen.findByDisplayValue('first-value');
|
||||||
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 waitFor(async () => {
|
await waitFor(async () => {
|
||||||
await lastValueFrom(variable.validateAndUpdate());
|
await lastValueFrom(variable.validateAndUpdate());
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(variable.state.staticOptions).toEqual([
|
expect(variable.state.staticOptions).toEqual([
|
||||||
{ label: 'First Option', value: 'first-value' },
|
{ label: 'First Option', value: 'first-value', properties: { text: 'First Option', value: 'first-value' } },
|
||||||
{ label: 'Second Option', value: 'second-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' } },
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
import { useState, FormEvent } from 'react';
|
import { FormEvent, useState } from 'react';
|
||||||
import { useAsync } from 'react-use';
|
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 { selectors } from '@grafana/e2e-selectors';
|
||||||
import { Trans, t } from '@grafana/i18n';
|
import { t, Trans } from '@grafana/i18n';
|
||||||
import { getDataSourceSrv } from '@grafana/runtime';
|
import { getDataSourceSrv } from '@grafana/runtime';
|
||||||
import { QueryVariable, sceneGraph, SceneVariable } from '@grafana/scenes';
|
import { QueryVariable, sceneGraph, SceneVariable } from '@grafana/scenes';
|
||||||
import { VariableRefresh, VariableSort } from '@grafana/schema';
|
import { VariableRefresh, VariableSort } from '@grafana/schema';
|
||||||
|
|
@ -43,6 +43,7 @@ export function QueryVariableEditor({ variable, onRunQuery }: QueryVariableEdito
|
||||||
allValue,
|
allValue,
|
||||||
query,
|
query,
|
||||||
allowCustomValue,
|
allowCustomValue,
|
||||||
|
options,
|
||||||
staticOptions,
|
staticOptions,
|
||||||
staticOptionsOrder,
|
staticOptionsOrder,
|
||||||
} = variable.useState();
|
} = variable.useState();
|
||||||
|
|
@ -125,6 +126,7 @@ export function QueryVariableEditor({ variable, onRunQuery }: QueryVariableEdito
|
||||||
staticOptionsOrder={staticOptionsOrder}
|
staticOptionsOrder={staticOptionsOrder}
|
||||||
onStaticOptionsChange={onStaticOptionsChange}
|
onStaticOptionsChange={onStaticOptionsChange}
|
||||||
onStaticOptionsOrderChange={onStaticOptionsOrderChange}
|
onStaticOptionsOrderChange={onStaticOptionsOrderChange}
|
||||||
|
options={options}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -203,6 +205,7 @@ export function Editor({ variable }: { variable: QueryVariable }) {
|
||||||
query,
|
query,
|
||||||
regex,
|
regex,
|
||||||
regexApplyTo,
|
regexApplyTo,
|
||||||
|
options,
|
||||||
staticOptions,
|
staticOptions,
|
||||||
staticOptionsOrder,
|
staticOptionsOrder,
|
||||||
} = variable.useState();
|
} = variable.useState();
|
||||||
|
|
@ -298,16 +301,15 @@ export function Editor({ variable }: { variable: QueryVariable }) {
|
||||||
refresh={refresh}
|
refresh={refresh}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{onStaticOptionsChange && onStaticOptionsOrderChange && (
|
<QueryVariableStaticOptions
|
||||||
<QueryVariableStaticOptions
|
options={options}
|
||||||
staticOptions={staticOptions}
|
staticOptions={staticOptions}
|
||||||
staticOptionsOrder={staticOptionsOrder}
|
staticOptionsOrder={staticOptionsOrder}
|
||||||
onStaticOptionsChange={onStaticOptionsChange}
|
onStaticOptionsChange={onStaticOptionsChange}
|
||||||
onStaticOptionsOrderChange={onStaticOptionsOrderChange}
|
onStaticOptionsOrderChange={onStaticOptionsOrderChange}
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
|
|
||||||
{isHasVariableOptions && <VariableValuesPreview options={variable.getOptionsForSelect(false)} />}
|
{isHasVariableOptions && <VariableValuesPreview options={options} staticOptions={staticOptions ?? []} />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -209,6 +209,7 @@ export function createSceneVariableFromVariableModel(variable: TypedVariableMode
|
||||||
staticOptions: variable.staticOptions?.map((option) => ({
|
staticOptions: variable.staticOptions?.map((option) => ({
|
||||||
label: String(option.text),
|
label: String(option.text),
|
||||||
value: String(option.value),
|
value: String(option.value),
|
||||||
|
properties: option.properties,
|
||||||
})),
|
})),
|
||||||
staticOptionsOrder: variable.staticOptionsOrder,
|
staticOptionsOrder: variable.staticOptionsOrder,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -80,31 +80,27 @@ const buildLabelPath = (label: string) => {
|
||||||
return label.includes('.') || label.trim().includes(' ') ? `["${label}"]` : `.${label}`;
|
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[] => {
|
const getVariableValueProperties = (variable: TypedVariableModel): string[] => {
|
||||||
if (!('valuesFormat' in variable) || variable.valuesFormat !== 'json') {
|
if (!('options' in variable) || !variable.options[0].properties) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
function collectFieldPaths(option: Record<string, string>, currentPath: string) {
|
function collectFieldPaths(properties: Record<string, unknown> | unknown[], currentPath: string) {
|
||||||
let paths: string[] = [];
|
let paths: string[] = [];
|
||||||
for (const field in option) {
|
for (const [field, value] of Object.entries(properties)) {
|
||||||
if (option.hasOwnProperty(field)) {
|
const newPath = `${currentPath}.${field}`;
|
||||||
const newPath = `${currentPath}.${field}`;
|
if (isRecordOrArray(value)) {
|
||||||
const value = option[field];
|
paths = [...paths, ...collectFieldPaths(value, newPath)];
|
||||||
if (typeof value === 'object' && value !== null) {
|
|
||||||
paths = [...paths, ...collectFieldPaths(value, newPath)];
|
|
||||||
}
|
|
||||||
paths.push(newPath);
|
|
||||||
}
|
}
|
||||||
|
paths.push(newPath);
|
||||||
}
|
}
|
||||||
return paths;
|
return paths;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
return collectFieldPaths(variable.options[0].properties, variable.name);
|
||||||
return collectFieldPaths(JSON.parse(variable.query)[0], variable.name);
|
|
||||||
} catch {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getPanelLinksVariableSuggestions = (): VariableSuggestion[] => [
|
export const getPanelLinksVariableSuggestions = (): VariableSuggestion[] => [
|
||||||
|
|
|
||||||
|
|
@ -503,13 +503,16 @@ describe('linkSrv', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('getPanelLinksVariableSuggestions', () => {
|
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', [
|
const templateSrvWithJsonValues = initTemplateSrv('key', [
|
||||||
{
|
{
|
||||||
type: 'custom',
|
type: 'custom',
|
||||||
name: 'customServers',
|
name: 'customServers',
|
||||||
valuesFormat: 'json',
|
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);
|
setTemplateSrv(templateSrvWithJsonValues);
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,14 @@ import { FormEvent, PureComponent } from 'react';
|
||||||
import { connect, ConnectedProps } from 'react-redux';
|
import { connect, ConnectedProps } from 'react-redux';
|
||||||
import { bindActionCreators } from '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 { selectors } from '@grafana/e2e-selectors';
|
||||||
import { Trans, t } from '@grafana/i18n';
|
import { Trans, t } from '@grafana/i18n';
|
||||||
import { locationService } from '@grafana/runtime';
|
import { locationService } from '@grafana/runtime';
|
||||||
|
|
@ -29,6 +36,16 @@ import { VariableTypeSelect } from './VariableTypeSelect';
|
||||||
import { changeVariableName, variableEditorMount, variableEditorUnMount } from './actions';
|
import { changeVariableName, variableEditorMount, variableEditorUnMount } from './actions';
|
||||||
import { OnPropChangeArguments, VariableNameConstraints } from './types';
|
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) => ({
|
const mapStateToProps = (state: StoreState, ownProps: OwnProps) => ({
|
||||||
editor: getVariablesState(ownProps.identifier.rootStateKey, state).editor,
|
editor: getVariablesState(ownProps.identifier.rootStateKey, state).editor,
|
||||||
variable: getVariable(ownProps.identifier, state),
|
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} />}
|
{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' }}>
|
<div style={{ marginTop: '16px' }}>
|
||||||
<Stack gap={2} height="inherit">
|
<Stack gap={2} height="inherit">
|
||||||
|
|
|
||||||
|
|
@ -157,6 +157,11 @@ export class QueryVariableEditorUnConnected extends PureComponent<Props, State>
|
||||||
onMultiChange={this.onMultiChange}
|
onMultiChange={this.onMultiChange}
|
||||||
onIncludeAllChange={this.onIncludeAllChange}
|
onIncludeAllChange={this.onIncludeAllChange}
|
||||||
onAllValueChange={this.onAllValueChange}
|
onAllValueChange={this.onAllValueChange}
|
||||||
|
options={variable.options.map((o) => ({
|
||||||
|
label: String(o.text),
|
||||||
|
value: String(o.value),
|
||||||
|
properties: o.properties,
|
||||||
|
}))}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,16 +2,20 @@ import { useState } from 'react';
|
||||||
|
|
||||||
import { selectors } from '@grafana/e2e-selectors';
|
import { selectors } from '@grafana/e2e-selectors';
|
||||||
import { t, Trans } from '@grafana/i18n';
|
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 { Field, Stack, Switch } from '@grafana/ui';
|
||||||
import { VariableLegend } from 'app/features/dashboard-scene/settings/variables/components/VariableLegend';
|
import { VariableLegend } from 'app/features/dashboard-scene/settings/variables/components/VariableLegend';
|
||||||
|
import { VariableMultiPropStaticOptionsForm } from 'app/features/dashboard-scene/settings/variables/components/VariableMultiPropStaticOptionsForm';
|
||||||
import { VariableSelectField } from 'app/features/dashboard-scene/settings/variables/components/VariableSelectField';
|
import { VariableSelectField } from 'app/features/dashboard-scene/settings/variables/components/VariableSelectField';
|
||||||
import { VariableStaticOptionsForm } from 'app/features/dashboard-scene/settings/variables/components/VariableStaticOptionsForm';
|
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 StaticOptionsType = QueryVariable['state']['staticOptions'];
|
||||||
export type StaticOptionsOrderType = QueryVariable['state']['staticOptionsOrder'];
|
export type StaticOptionsOrderType = QueryVariable['state']['staticOptionsOrder'];
|
||||||
|
|
||||||
interface QueryVariableStaticOptionsProps {
|
interface QueryVariableStaticOptionsProps {
|
||||||
|
options: VariableValueOption[];
|
||||||
staticOptions: StaticOptionsType;
|
staticOptions: StaticOptionsType;
|
||||||
staticOptionsOrder: StaticOptionsOrderType;
|
staticOptionsOrder: StaticOptionsOrderType;
|
||||||
onStaticOptionsChange: (staticOptions: StaticOptionsType) => void;
|
onStaticOptionsChange: (staticOptions: StaticOptionsType) => void;
|
||||||
|
|
@ -25,11 +29,11 @@ const SORT_OPTIONS = [
|
||||||
];
|
];
|
||||||
|
|
||||||
export function QueryVariableStaticOptions(props: QueryVariableStaticOptionsProps) {
|
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 value = SORT_OPTIONS.find((o) => o.value === staticOptionsOrder) ?? SORT_OPTIONS[0];
|
||||||
|
|
||||||
const [areStaticOptionsEnabled, setAreStaticOptionsEnabled] = useState(!!staticOptions?.length);
|
const [areStaticOptionsEnabled, setAreStaticOptionsEnabled] = useState(!!staticOptions?.length);
|
||||||
|
const displayMultiPropsEditor = areStaticOptionsEnabled && config.featureToggles.multiPropsVariables;
|
||||||
|
const properties = useGetPropertiesFromOptions(options, staticOptions);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|
@ -64,13 +68,17 @@ export function QueryVariableStaticOptions(props: QueryVariableStaticOptionsProp
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{areStaticOptionsEnabled && (
|
{displayMultiPropsEditor && (
|
||||||
<VariableStaticOptionsForm
|
<VariableMultiPropStaticOptionsForm
|
||||||
allowEmptyValue
|
|
||||||
options={staticOptions ?? []}
|
options={staticOptions ?? []}
|
||||||
|
properties={properties}
|
||||||
onChange={onStaticOptionsChange}
|
onChange={onStaticOptionsChange}
|
||||||
|
allowEmptyValue
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{!displayMultiPropsEditor && areStaticOptionsEnabled && (
|
||||||
|
<VariableStaticOptionsForm options={staticOptions ?? []} onChange={onStaticOptionsChange} />
|
||||||
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
</>
|
</>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
|
||||||
|
|
@ -4911,7 +4911,7 @@
|
||||||
"apply": "Apply",
|
"apply": "Apply",
|
||||||
"change-value": "Change variable value",
|
"change-value": "Change variable value",
|
||||||
"discard": "Discard",
|
"discard": "Discard",
|
||||||
"modal-title": "Custom Variable",
|
"modal-title": "Custom options",
|
||||||
"values": "Values separated by comma"
|
"values": "Values separated by comma"
|
||||||
},
|
},
|
||||||
"datasource-options": {
|
"datasource-options": {
|
||||||
|
|
@ -6317,6 +6317,11 @@
|
||||||
"remove-panel": "Remove panel"
|
"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": {
|
"panel-data-alerting-tab": {
|
||||||
"tab-label": "Alert"
|
"tab-label": "Alert"
|
||||||
},
|
},
|
||||||
|
|
@ -6685,6 +6690,9 @@
|
||||||
},
|
},
|
||||||
"label": "Hide"
|
"label": "Hide"
|
||||||
},
|
},
|
||||||
|
"variable-multi-prop-static-options-form": {
|
||||||
|
"aria-label-static-options": "Static options"
|
||||||
|
},
|
||||||
"variable-type-select": {
|
"variable-type-select": {
|
||||||
"name-variable-type": "Variable type"
|
"name-variable-type": "Variable type"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue