CustomVariable: support values with multiple properties (json values format) (#113844)
* update Scenes libraries --------- Co-authored-by: idastambuk <ida.stambuk@grafana.com>
This commit is contained in:
@@ -129,7 +129,7 @@ DashboardLink: {
|
||||
placement?: DashboardLinkPlacement
|
||||
}
|
||||
|
||||
// Dashboard Link placement. Defines where the link should be displayed.
|
||||
// Dashboard Link placement. Defines where the link should be displayed.
|
||||
// - "inControlsMenu" renders the link in bottom part of the dashboard controls dropdown menu
|
||||
DashboardLinkPlacement: "inControlsMenu"
|
||||
|
||||
@@ -932,6 +932,7 @@ CustomVariableSpec: {
|
||||
skipUrlSync: bool | *false
|
||||
description?: string
|
||||
allowCustomValue: bool | *true
|
||||
valuesFormat?: "csv" | "json"
|
||||
}
|
||||
|
||||
// Custom variable kind
|
||||
|
||||
@@ -935,6 +935,7 @@ CustomVariableSpec: {
|
||||
skipUrlSync: bool | *false
|
||||
description?: string
|
||||
allowCustomValue: bool | *true
|
||||
valuesFormat?: "csv" | "json"
|
||||
}
|
||||
|
||||
// Custom variable kind
|
||||
|
||||
@@ -222,8 +222,10 @@ lineage: schemas: [{
|
||||
// Optional field, if you want to extract part of a series name or metric node segment.
|
||||
// Named capture groups can be used to separate the display text and value.
|
||||
regex?: string
|
||||
// Determine whether regex applies to variable value or display text
|
||||
regexApplyTo?: #VariableRegexApplyTo
|
||||
// Optional, indicates whether a custom type variable uses CSV or JSON to define its values
|
||||
valuesFormat?: "csv" | "json" | *"csv"
|
||||
// Determine whether regex applies to variable value or display text
|
||||
regexApplyTo?: #VariableRegexApplyTo
|
||||
// Additional static options for query variable
|
||||
staticOptions?: [...#VariableOption]
|
||||
// Ordering of static options in relation to options returned from data source for query variable
|
||||
|
||||
@@ -222,8 +222,10 @@ lineage: schemas: [{
|
||||
// Optional field, if you want to extract part of a series name or metric node segment.
|
||||
// Named capture groups can be used to separate the display text and value.
|
||||
regex?: string
|
||||
// Determine whether regex applies to variable value or display text
|
||||
regexApplyTo?: #VariableRegexApplyTo
|
||||
// Optional, indicates whether a custom type variable uses CSV or JSON to define its values
|
||||
valuesFormat?: "csv" | "json" | *"csv"
|
||||
// Determine whether regex applies to variable value or display text
|
||||
regexApplyTo?: #VariableRegexApplyTo
|
||||
// Additional static options for query variable
|
||||
staticOptions?: [...#VariableOption]
|
||||
// Ordering of static options in relation to options returned from data source for query variable
|
||||
|
||||
@@ -133,7 +133,7 @@ DashboardLink: {
|
||||
placement?: DashboardLinkPlacement
|
||||
}
|
||||
|
||||
// Dashboard Link placement. Defines where the link should be displayed.
|
||||
// Dashboard Link placement. Defines where the link should be displayed.
|
||||
// - "inControlsMenu" renders the link in bottom part of the dashboard controls dropdown menu
|
||||
DashboardLinkPlacement: "inControlsMenu"
|
||||
|
||||
@@ -936,6 +936,7 @@ CustomVariableSpec: {
|
||||
skipUrlSync: bool | *false
|
||||
description?: string
|
||||
allowCustomValue: bool | *true
|
||||
valuesFormat?: "csv" | "json"
|
||||
}
|
||||
|
||||
// Custom variable kind
|
||||
|
||||
+21
-12
@@ -1703,18 +1703,19 @@ func NewDashboardCustomVariableKind() *DashboardCustomVariableKind {
|
||||
// Custom variable specification
|
||||
// +k8s:openapi-gen=true
|
||||
type DashboardCustomVariableSpec struct {
|
||||
Name string `json:"name"`
|
||||
Query string `json:"query"`
|
||||
Current DashboardVariableOption `json:"current"`
|
||||
Options []DashboardVariableOption `json:"options"`
|
||||
Multi bool `json:"multi"`
|
||||
IncludeAll bool `json:"includeAll"`
|
||||
AllValue *string `json:"allValue,omitempty"`
|
||||
Label *string `json:"label,omitempty"`
|
||||
Hide DashboardVariableHide `json:"hide"`
|
||||
SkipUrlSync bool `json:"skipUrlSync"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
AllowCustomValue bool `json:"allowCustomValue"`
|
||||
Name string `json:"name"`
|
||||
Query string `json:"query"`
|
||||
Current DashboardVariableOption `json:"current"`
|
||||
Options []DashboardVariableOption `json:"options"`
|
||||
Multi bool `json:"multi"`
|
||||
IncludeAll bool `json:"includeAll"`
|
||||
AllValue *string `json:"allValue,omitempty"`
|
||||
Label *string `json:"label,omitempty"`
|
||||
Hide DashboardVariableHide `json:"hide"`
|
||||
SkipUrlSync bool `json:"skipUrlSync"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
AllowCustomValue bool `json:"allowCustomValue"`
|
||||
ValuesFormat *DashboardCustomVariableSpecValuesFormat `json:"valuesFormat,omitempty"`
|
||||
}
|
||||
|
||||
// NewDashboardCustomVariableSpec creates a new DashboardCustomVariableSpec object.
|
||||
@@ -2098,6 +2099,14 @@ const (
|
||||
DashboardQueryVariableSpecStaticOptionsOrderSorted DashboardQueryVariableSpecStaticOptionsOrder = "sorted"
|
||||
)
|
||||
|
||||
// +k8s:openapi-gen=true
|
||||
type DashboardCustomVariableSpecValuesFormat string
|
||||
|
||||
const (
|
||||
DashboardCustomVariableSpecValuesFormatCsv DashboardCustomVariableSpecValuesFormat = "csv"
|
||||
DashboardCustomVariableSpecValuesFormatJson DashboardCustomVariableSpecValuesFormat = "json"
|
||||
)
|
||||
|
||||
// +k8s:openapi-gen=true
|
||||
type DashboardPanelKindOrLibraryPanelKind struct {
|
||||
PanelKind *DashboardPanelKind `json:"PanelKind,omitempty"`
|
||||
|
||||
@@ -1548,6 +1548,12 @@ func schema_pkg_apis_dashboard_v2alpha1_DashboardCustomVariableSpec(ref common.R
|
||||
Format: "",
|
||||
},
|
||||
},
|
||||
"valuesFormat": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Type: []string{"string"},
|
||||
Format: "",
|
||||
},
|
||||
},
|
||||
},
|
||||
Required: []string{"name", "query", "current", "options", "multi", "includeAll", "hide", "skipUrlSync", "allowCustomValue"},
|
||||
},
|
||||
|
||||
@@ -939,6 +939,7 @@ CustomVariableSpec: {
|
||||
skipUrlSync: bool | *false
|
||||
description?: string
|
||||
allowCustomValue: bool | *true
|
||||
valuesFormat?: "csv" | "json"
|
||||
}
|
||||
|
||||
// Custom variable kind
|
||||
|
||||
+21
-12
@@ -1707,18 +1707,19 @@ func NewDashboardCustomVariableKind() *DashboardCustomVariableKind {
|
||||
// Custom variable specification
|
||||
// +k8s:openapi-gen=true
|
||||
type DashboardCustomVariableSpec struct {
|
||||
Name string `json:"name"`
|
||||
Query string `json:"query"`
|
||||
Current DashboardVariableOption `json:"current"`
|
||||
Options []DashboardVariableOption `json:"options"`
|
||||
Multi bool `json:"multi"`
|
||||
IncludeAll bool `json:"includeAll"`
|
||||
AllValue *string `json:"allValue,omitempty"`
|
||||
Label *string `json:"label,omitempty"`
|
||||
Hide DashboardVariableHide `json:"hide"`
|
||||
SkipUrlSync bool `json:"skipUrlSync"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
AllowCustomValue bool `json:"allowCustomValue"`
|
||||
Name string `json:"name"`
|
||||
Query string `json:"query"`
|
||||
Current DashboardVariableOption `json:"current"`
|
||||
Options []DashboardVariableOption `json:"options"`
|
||||
Multi bool `json:"multi"`
|
||||
IncludeAll bool `json:"includeAll"`
|
||||
AllValue *string `json:"allValue,omitempty"`
|
||||
Label *string `json:"label,omitempty"`
|
||||
Hide DashboardVariableHide `json:"hide"`
|
||||
SkipUrlSync bool `json:"skipUrlSync"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
AllowCustomValue bool `json:"allowCustomValue"`
|
||||
ValuesFormat *DashboardCustomVariableSpecValuesFormat `json:"valuesFormat,omitempty"`
|
||||
}
|
||||
|
||||
// NewDashboardCustomVariableSpec creates a new DashboardCustomVariableSpec object.
|
||||
@@ -2133,6 +2134,14 @@ const (
|
||||
DashboardQueryVariableSpecStaticOptionsOrderSorted DashboardQueryVariableSpecStaticOptionsOrder = "sorted"
|
||||
)
|
||||
|
||||
// +k8s:openapi-gen=true
|
||||
type DashboardCustomVariableSpecValuesFormat string
|
||||
|
||||
const (
|
||||
DashboardCustomVariableSpecValuesFormatCsv DashboardCustomVariableSpecValuesFormat = "csv"
|
||||
DashboardCustomVariableSpecValuesFormatJson DashboardCustomVariableSpecValuesFormat = "json"
|
||||
)
|
||||
|
||||
// +k8s:openapi-gen=true
|
||||
type DashboardPanelKindOrLibraryPanelKind struct {
|
||||
PanelKind *DashboardPanelKind `json:"PanelKind,omitempty"`
|
||||
|
||||
@@ -1560,6 +1560,12 @@ func schema_pkg_apis_dashboard_v2beta1_DashboardCustomVariableSpec(ref common.Re
|
||||
Format: "",
|
||||
},
|
||||
},
|
||||
"valuesFormat": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Type: []string{"string"},
|
||||
Format: "",
|
||||
},
|
||||
},
|
||||
},
|
||||
Required: []string{"name", "query", "current", "options", "multi", "includeAll", "hide", "skipUrlSync", "allowCustomValue"},
|
||||
},
|
||||
|
||||
+2
-2
File diff suppressed because one or more lines are too long
@@ -1336,6 +1336,17 @@ func buildCustomVariable(varMap map[string]interface{}, commonProps CommonVariab
|
||||
customVar.Spec.AllValue = &allValue
|
||||
}
|
||||
|
||||
if valuesFormat := schemaversion.GetStringValue(varMap, "valuesFormat"); valuesFormat != "" {
|
||||
switch valuesFormat {
|
||||
case string(dashv2alpha1.DashboardCustomVariableSpecValuesFormatJson):
|
||||
format := dashv2alpha1.DashboardCustomVariableSpecValuesFormatJson
|
||||
customVar.Spec.ValuesFormat = &format
|
||||
case string(dashv2alpha1.DashboardCustomVariableSpecValuesFormatCsv):
|
||||
format := dashv2alpha1.DashboardCustomVariableSpecValuesFormatCsv
|
||||
customVar.Spec.ValuesFormat = &format
|
||||
}
|
||||
}
|
||||
|
||||
return dashv2alpha1.DashboardVariableKind{
|
||||
CustomVariableKind: customVar,
|
||||
}, nil
|
||||
|
||||
@@ -685,6 +685,7 @@ func convertVariable_V2alpha1_to_V2beta1(in *dashv2alpha1.DashboardVariableKind,
|
||||
SkipUrlSync: in.CustomVariableKind.Spec.SkipUrlSync,
|
||||
Description: in.CustomVariableKind.Spec.Description,
|
||||
AllowCustomValue: in.CustomVariableKind.Spec.AllowCustomValue,
|
||||
ValuesFormat: convertCustomValuesFormat_V2alpha1_to_V2beta1(in.CustomVariableKind.Spec.ValuesFormat),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -758,6 +759,23 @@ func convertVariable_V2alpha1_to_V2beta1(in *dashv2alpha1.DashboardVariableKind,
|
||||
return nil
|
||||
}
|
||||
|
||||
func convertCustomValuesFormat_V2alpha1_to_V2beta1(in *dashv2alpha1.DashboardCustomVariableSpecValuesFormat) *dashv2beta1.DashboardCustomVariableSpecValuesFormat {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
switch *in {
|
||||
case dashv2alpha1.DashboardCustomVariableSpecValuesFormatJson:
|
||||
v := dashv2beta1.DashboardCustomVariableSpecValuesFormatJson
|
||||
return &v
|
||||
case dashv2alpha1.DashboardCustomVariableSpecValuesFormatCsv:
|
||||
v := dashv2beta1.DashboardCustomVariableSpecValuesFormatCsv
|
||||
return &v
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func convertQueryVariableSpec_V2alpha1_to_V2beta1(in *dashv2alpha1.DashboardQueryVariableSpec, out *dashv2beta1.DashboardQueryVariableSpec, scope conversion.Scope) error {
|
||||
out.Name = in.Name
|
||||
out.Current = convertVariableOption_V2alpha1_to_V2beta1(in.Current)
|
||||
|
||||
@@ -218,8 +218,10 @@ lineage: schemas: [{
|
||||
// Optional field, if you want to extract part of a series name or metric node segment.
|
||||
// Named capture groups can be used to separate the display text and value.
|
||||
regex?: string
|
||||
// Determine whether regex applies to variable value or display text
|
||||
regexApplyTo?: #VariableRegexApplyTo
|
||||
// Optional, indicates whether a custom type variable uses CSV or JSON to define its values
|
||||
valuesFormat?: "csv" | "json" | *"csv"
|
||||
// Determine whether regex applies to variable value or display text
|
||||
regexApplyTo?: #VariableRegexApplyTo
|
||||
// Additional static options for query variable
|
||||
staticOptions?: [...#VariableOption]
|
||||
// Ordering of static options in relation to options returned from data source for query variable
|
||||
|
||||
+2
-2
@@ -295,8 +295,8 @@
|
||||
"@grafana/plugin-ui": "^0.11.1",
|
||||
"@grafana/prometheus": "workspace:*",
|
||||
"@grafana/runtime": "workspace:*",
|
||||
"@grafana/scenes": "6.52.0",
|
||||
"@grafana/scenes-react": "6.52.0",
|
||||
"@grafana/scenes": "v6.52.1",
|
||||
"@grafana/scenes-react": "v6.52.1",
|
||||
"@grafana/schema": "workspace:*",
|
||||
"@grafana/sql": "workspace:*",
|
||||
"@grafana/ui": "workspace:*",
|
||||
|
||||
@@ -103,6 +103,7 @@ export interface IntervalVariableModel extends VariableWithOptions {
|
||||
|
||||
export interface CustomVariableModel extends VariableWithMultiSupport {
|
||||
type: 'custom';
|
||||
valuesFormat?: 'csv' | 'json';
|
||||
}
|
||||
|
||||
export interface DataSourceVariableModel extends VariableWithMultiSupport {
|
||||
|
||||
@@ -211,6 +211,10 @@ export interface VariableModel {
|
||||
* Type of variable
|
||||
*/
|
||||
type: VariableType;
|
||||
/**
|
||||
* Optional, indicates whether a custom type variable uses CSV or JSON to define its values
|
||||
*/
|
||||
valuesFormat?: ('csv' | 'json');
|
||||
}
|
||||
|
||||
export const defaultVariableModel: Partial<VariableModel> = {
|
||||
@@ -220,6 +224,7 @@ export const defaultVariableModel: Partial<VariableModel> = {
|
||||
options: [],
|
||||
skipUrlSync: false,
|
||||
staticOptions: [],
|
||||
valuesFormat: 'csv',
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -317,6 +317,7 @@ export const handyTestingSchema: Spec = {
|
||||
query: 'option1, option2',
|
||||
skipUrlSync: false,
|
||||
allowCustomValue: true,
|
||||
valuesFormat: 'csv',
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -300,7 +300,7 @@ export interface FieldConfig {
|
||||
description?: string;
|
||||
// An explicit path to the field in the datasource. When the frame meta includes a path,
|
||||
// This will default to `${frame.meta.path}/${field.name}
|
||||
//
|
||||
//
|
||||
// When defined, this value can be used as an identifier within the datasource scope, and
|
||||
// may be used to update the results
|
||||
path?: string;
|
||||
@@ -1353,6 +1353,7 @@ export interface CustomVariableSpec {
|
||||
skipUrlSync: boolean;
|
||||
description?: string;
|
||||
allowCustomValue: boolean;
|
||||
valuesFormat?: "csv" | "json";
|
||||
}
|
||||
|
||||
export const defaultCustomVariableSpec = (): CustomVariableSpec => ({
|
||||
@@ -1365,6 +1366,7 @@ export const defaultCustomVariableSpec = (): CustomVariableSpec => ({
|
||||
hide: "dontHide",
|
||||
skipUrlSync: false,
|
||||
allowCustomValue: true,
|
||||
valuesFormat: undefined,
|
||||
});
|
||||
|
||||
// Group variable kind
|
||||
@@ -1549,4 +1551,3 @@ export const defaultSpec = (): Spec => ({
|
||||
title: "",
|
||||
variables: [],
|
||||
});
|
||||
|
||||
|
||||
@@ -1359,6 +1359,7 @@ export interface CustomVariableSpec {
|
||||
skipUrlSync: boolean;
|
||||
description?: string;
|
||||
allowCustomValue: boolean;
|
||||
valuesFormat?: "csv" | "json";
|
||||
}
|
||||
|
||||
export const defaultCustomVariableSpec = (): CustomVariableSpec => ({
|
||||
|
||||
+10
@@ -837,6 +837,8 @@ type VariableModel struct {
|
||||
// Optional field, if you want to extract part of a series name or metric node segment.
|
||||
// Named capture groups can be used to separate the display text and value.
|
||||
Regex *string `json:"regex,omitempty"`
|
||||
// Optional, indicates whether a custom type variable uses CSV or JSON to define its values
|
||||
ValuesFormat *VariableModelValuesFormat `json:"valuesFormat,omitempty"`
|
||||
// Determine whether regex applies to variable value or display text
|
||||
RegexApplyTo *VariableRegexApplyTo `json:"regexApplyTo,omitempty"`
|
||||
// Additional static options for query variable
|
||||
@@ -852,6 +854,7 @@ func NewVariableModel() *VariableModel {
|
||||
Multi: (func(input bool) *bool { return &input })(false),
|
||||
AllowCustomValue: (func(input bool) *bool { return &input })(true),
|
||||
IncludeAll: (func(input bool) *bool { return &input })(false),
|
||||
ValuesFormat: (func(input VariableModelValuesFormat) *VariableModelValuesFormat { return &input })(VariableModelValuesFormatCsv),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1191,6 +1194,13 @@ const (
|
||||
DataTransformerConfigTopicAlertStates DataTransformerConfigTopic = "alertStates"
|
||||
)
|
||||
|
||||
type VariableModelValuesFormat string
|
||||
|
||||
const (
|
||||
VariableModelValuesFormatCsv VariableModelValuesFormat = "csv"
|
||||
VariableModelValuesFormatJson VariableModelValuesFormat = "json"
|
||||
)
|
||||
|
||||
type VariableModelStaticOptionsOrder string
|
||||
|
||||
const (
|
||||
|
||||
@@ -1786,6 +1786,13 @@
|
||||
"skipUrlSync": {
|
||||
"type": "boolean",
|
||||
"default": false
|
||||
},
|
||||
"valuesFormat": {
|
||||
"enum": [
|
||||
"csv",
|
||||
"json"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
|
||||
@@ -1801,6 +1801,13 @@
|
||||
"skipUrlSync": {
|
||||
"type": "boolean",
|
||||
"default": false
|
||||
},
|
||||
"valuesFormat": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"csv",
|
||||
"json"
|
||||
]
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
|
||||
@@ -345,6 +345,16 @@ describe('DashboardSceneSerializer', () => {
|
||||
type: 'textbox',
|
||||
name: 'search',
|
||||
},
|
||||
{
|
||||
name: 'custom_csv',
|
||||
type: 'custom',
|
||||
valuesFormat: 'csv',
|
||||
},
|
||||
{
|
||||
name: 'custom_json',
|
||||
type: 'custom',
|
||||
valuesFormat: 'json',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
@@ -359,6 +369,9 @@ describe('DashboardSceneSerializer', () => {
|
||||
panel_type_row_count: 1,
|
||||
variable_type_query_count: 2,
|
||||
variable_type_textbox_count: 1,
|
||||
variable_type_custom_count: 2,
|
||||
variable_type_custom_csv_count: 1,
|
||||
variable_type_custom_json_count: 1,
|
||||
settings_nowdelay: undefined,
|
||||
settings_livenow: true,
|
||||
varsWithDataSource: [
|
||||
@@ -701,7 +714,9 @@ describe('DashboardSceneSerializer', () => {
|
||||
panel_type_timeseries_count: 6,
|
||||
variable_type_adhoc_count: 1,
|
||||
variable_type_datasource_count: 1,
|
||||
variable_type_custom_count: 1,
|
||||
variable_type_custom_count: 3,
|
||||
variable_type_custom_csv_count: 2,
|
||||
variable_type_custom_json_count: 1,
|
||||
variable_type_query_count: 1,
|
||||
varsWithDataSource: [
|
||||
{ type: 'query', datasource: 'cloudwatch' },
|
||||
@@ -714,7 +729,7 @@ describe('DashboardSceneSerializer', () => {
|
||||
panelCount: 6,
|
||||
rowCount: 6,
|
||||
tabCount: 4,
|
||||
templateVariableCount: 4,
|
||||
templateVariableCount: 6,
|
||||
maxNestingLevel: 3,
|
||||
dashStructure:
|
||||
'[{"kind":"row","children":[{"kind":"row","children":[{"kind":"tab","children":[{"kind":"panel"},{"kind":"panel"},{"kind":"panel"}]},{"kind":"tab","children":[]}]},{"kind":"row","children":[{"kind":"row","children":[{"kind":"panel"}]}]}]},{"kind":"row","children":[{"kind":"row","children":[{"kind":"tab","children":[{"kind":"panel"}]},{"kind":"tab","children":[{"kind":"panel"}]}]}]}]',
|
||||
@@ -866,6 +881,7 @@ describe('DashboardSceneSerializer', () => {
|
||||
query: 'app1',
|
||||
skipUrlSync: false,
|
||||
allowCustomValue: true,
|
||||
valuesFormat: 'csv',
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
+5
@@ -294,6 +294,7 @@ exports[`Given a scene with custom quick ranges should save quick ranges to save
|
||||
"options": [],
|
||||
"query": "a, b, c",
|
||||
"type": "custom",
|
||||
"valuesFormat": "csv",
|
||||
},
|
||||
{
|
||||
"current": {
|
||||
@@ -680,6 +681,7 @@ exports[`transformSceneToSaveModel Given a scene with rows Should transform back
|
||||
"options": [],
|
||||
"query": "A,B,C,D,E,F,E,G,H,I,J,K,L",
|
||||
"type": "custom",
|
||||
"valuesFormat": "csv",
|
||||
},
|
||||
{
|
||||
"current": {
|
||||
@@ -698,6 +700,7 @@ exports[`transformSceneToSaveModel Given a scene with rows Should transform back
|
||||
"options": [],
|
||||
"query": "Bob : 1, Rob : 2,Sod : 3, Hod : 4, Cod : 5",
|
||||
"type": "custom",
|
||||
"valuesFormat": "csv",
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -1021,6 +1024,7 @@ exports[`transformSceneToSaveModel Given a simple scene with custom settings Sho
|
||||
"options": [],
|
||||
"query": "a, b, c",
|
||||
"type": "custom",
|
||||
"valuesFormat": "csv",
|
||||
},
|
||||
{
|
||||
"current": {
|
||||
@@ -1381,6 +1385,7 @@ exports[`transformSceneToSaveModel Given a simple scene with variables Should tr
|
||||
"options": [],
|
||||
"query": "a, b, c",
|
||||
"type": "custom",
|
||||
"valuesFormat": "csv",
|
||||
},
|
||||
{
|
||||
"current": {
|
||||
|
||||
+1
@@ -196,6 +196,7 @@ exports[`transformSceneToSaveModelSchemaV2 should transform scene to save model
|
||||
"options": [],
|
||||
"query": "option1, option2",
|
||||
"skipUrlSync": false,
|
||||
"valuesFormat": "csv",
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -376,6 +376,7 @@ describe('sceneVariablesSetToVariables', () => {
|
||||
"options": [],
|
||||
"query": "test,test1,test2",
|
||||
"type": "custom",
|
||||
"valuesFormat": "csv",
|
||||
}
|
||||
`);
|
||||
});
|
||||
@@ -1180,6 +1181,7 @@ describe('sceneVariablesSetToVariables', () => {
|
||||
"options": [],
|
||||
"query": "test,test1,test2",
|
||||
"skipUrlSync": false,
|
||||
"valuesFormat": "csv",
|
||||
},
|
||||
}
|
||||
`);
|
||||
|
||||
@@ -120,6 +120,9 @@ export function sceneVariablesSetToVariables(set: SceneVariables, keepQueryOptio
|
||||
allValue: variable.state.allValue,
|
||||
includeAll: variable.state.includeAll,
|
||||
...(variable.state.allowCustomValue !== undefined && { allowCustomValue: variable.state.allowCustomValue }),
|
||||
// Ensure we persist the backend default when not specified to stay aligned with
|
||||
// transformSaveModelSchemaV2ToScene which injects 'csv' on load.
|
||||
valuesFormat: variable.state.valuesFormat ?? 'csv',
|
||||
};
|
||||
variables.push(customVariable);
|
||||
} else if (sceneUtils.isDataSourceVariable(variable)) {
|
||||
@@ -408,6 +411,7 @@ export function sceneVariablesSetToSchemaV2Variables(
|
||||
allValue: variable.state.allValue,
|
||||
includeAll: variable.state.includeAll ?? false,
|
||||
allowCustomValue: variable.state.allowCustomValue ?? true,
|
||||
valuesFormat: variable.state.valuesFormat ?? 'csv',
|
||||
},
|
||||
};
|
||||
variables.push(customVariable);
|
||||
|
||||
@@ -1169,6 +1169,57 @@
|
||||
"skipUrlSync": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"kind": "CustomVariable",
|
||||
"spec": {
|
||||
"allowCustomValue": true,
|
||||
"current": {
|
||||
"text": "test",
|
||||
"value": "test"
|
||||
},
|
||||
"hide": "dontHide",
|
||||
"includeAll": false,
|
||||
"multi": false,
|
||||
"name": "custom0",
|
||||
"options": [
|
||||
{
|
||||
"selected": true,
|
||||
"text": "test",
|
||||
"value": "test"
|
||||
}
|
||||
],
|
||||
"valuesFormat": "csv",
|
||||
"query": "test",
|
||||
"skipUrlSync": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"kind": "CustomVariable",
|
||||
"spec": {
|
||||
"allowCustomValue": true,
|
||||
"current": {
|
||||
"text": "test",
|
||||
"value": "test"
|
||||
},
|
||||
"hide": "dontHide",
|
||||
"includeAll": false,
|
||||
"multi": false,
|
||||
"name": "custom0",
|
||||
"options": [
|
||||
{
|
||||
"selected": true,
|
||||
"text": "test",
|
||||
"value": "test",
|
||||
"properties": {
|
||||
"testProp": "test"
|
||||
}
|
||||
}
|
||||
],
|
||||
"valuesFormat": "json",
|
||||
"query": "test",
|
||||
"skipUrlSync": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"kind": "DatasourceVariable",
|
||||
"spec": {
|
||||
|
||||
+2
-1
@@ -343,12 +343,12 @@ function createSceneVariableFromVariableModel(variable: TypedVariableModelV2): S
|
||||
}
|
||||
return new AdHocFiltersVariable(adhocVariableState);
|
||||
}
|
||||
|
||||
if (variable.kind === defaultCustomVariableKind().kind) {
|
||||
return new CustomVariable({
|
||||
...commonProperties,
|
||||
value: variable.spec.current?.value ?? '',
|
||||
text: variable.spec.current?.text ?? '',
|
||||
|
||||
query: variable.spec.query,
|
||||
isMulti: variable.spec.multi,
|
||||
allValue: variable.spec.allValue || undefined,
|
||||
@@ -357,6 +357,7 @@ function createSceneVariableFromVariableModel(variable: TypedVariableModelV2): S
|
||||
skipUrlSync: variable.spec.skipUrlSync,
|
||||
hide: transformVariableHideToEnumV1(variable.spec.hide),
|
||||
...(variable.spec.allowCustomValue !== undefined && { allowCustomValue: variable.spec.allowCustomValue }),
|
||||
valuesFormat: variable.spec.valuesFormat || 'csv',
|
||||
});
|
||||
} else if (variable.kind === defaultQueryVariableKind().kind) {
|
||||
return new QueryVariable({
|
||||
|
||||
@@ -9,7 +9,7 @@ import { Trans, t } from '@grafana/i18n';
|
||||
import { reportInteraction } from '@grafana/runtime';
|
||||
import { SceneVariable } from '@grafana/scenes';
|
||||
import { VariableHide, defaultVariableModel } from '@grafana/schema';
|
||||
import { Button, LoadingPlaceholder, ConfirmModal, ModalsController, Stack, useStyles2 } from '@grafana/ui';
|
||||
import { Button, ConfirmModal, LoadingPlaceholder, ModalsController, Stack, useStyles2 } from '@grafana/ui';
|
||||
import { VariableDisplaySelect } from 'app/features/dashboard-scene/settings/variables/components/VariableDisplaySelect';
|
||||
import { VariableLegend } from 'app/features/dashboard-scene/settings/variables/components/VariableLegend';
|
||||
import { VariableTextAreaField } from 'app/features/dashboard-scene/settings/variables/components/VariableTextAreaField';
|
||||
@@ -68,6 +68,8 @@ 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');
|
||||
@@ -123,7 +125,7 @@ export function VariableEditorForm({ variable, onTypeChange, onGoBack, onDelete
|
||||
|
||||
{EditorToRender && <EditorToRender variable={variable} onRunQuery={onRunQuery} />}
|
||||
|
||||
{isHasVariableOptions && <VariableValuesPreview options={variable.getOptionsForSelect(false)} />}
|
||||
{isHasVariableOptions && <VariableValuesPreview options={optionsForSelect} hasMultiProps={hasMultiProps} />}
|
||||
|
||||
<div className={styles.buttonContainer}>
|
||||
<Stack gap={2}>
|
||||
|
||||
+75
-1
@@ -1,9 +1,16 @@
|
||||
import { render, fireEvent } from '@testing-library/react';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import { selectors } from '@grafana/e2e-selectors';
|
||||
|
||||
import { CustomVariableForm } from './CustomVariableForm';
|
||||
|
||||
jest.mock('@grafana/runtime', () => {
|
||||
const actual = jest.requireActual('@grafana/runtime');
|
||||
actual.config.featureToggles = { multiPropsVariables: true };
|
||||
return actual;
|
||||
});
|
||||
|
||||
describe('CustomVariableForm', () => {
|
||||
const onQueryChange = jest.fn();
|
||||
const onMultiChange = jest.fn();
|
||||
@@ -130,4 +137,71 @@ describe('CustomVariableForm', () => {
|
||||
expect(onMultiChange).not.toHaveBeenCalled();
|
||||
expect(onIncludeAllChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe('JSON values format', () => {
|
||||
test('should render the form fields correctly', async () => {
|
||||
const { getByTestId, queryByTestId } = render(
|
||||
<CustomVariableForm
|
||||
query="query"
|
||||
valuesFormat="json"
|
||||
multi={true}
|
||||
allowCustomValue={true}
|
||||
includeAll={true}
|
||||
allValue="custom value"
|
||||
onQueryChange={onQueryChange}
|
||||
onMultiChange={onMultiChange}
|
||||
onIncludeAllChange={onIncludeAllChange}
|
||||
onAllValueChange={onAllValueChange}
|
||||
onAllowCustomValueChange={onAllowCustomValueChange}
|
||||
/>
|
||||
);
|
||||
|
||||
await userEvent.click(screen.getByText('JSON'));
|
||||
|
||||
const multiCheckbox = getByTestId(
|
||||
selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsMultiSwitch
|
||||
);
|
||||
const allowCustomValueCheckbox = queryByTestId(
|
||||
selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsAllowCustomValueSwitch
|
||||
);
|
||||
const includeAllCheckbox = getByTestId(
|
||||
selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsIncludeAllSwitch
|
||||
);
|
||||
const allValueInput = queryByTestId(
|
||||
selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsCustomAllInput
|
||||
);
|
||||
|
||||
expect(multiCheckbox).toBeInTheDocument();
|
||||
expect(multiCheckbox).toBeChecked();
|
||||
expect(includeAllCheckbox).toBeInTheDocument();
|
||||
expect(includeAllCheckbox).toBeChecked();
|
||||
|
||||
expect(allowCustomValueCheckbox).not.toBeInTheDocument();
|
||||
expect(allValueInput).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should display validation error', async () => {
|
||||
const validationError = new Error('Ooops! Validation error.');
|
||||
|
||||
const { findByText } = render(
|
||||
<CustomVariableForm
|
||||
query="query"
|
||||
valuesFormat="json"
|
||||
queryValidationError={validationError}
|
||||
multi={false}
|
||||
includeAll={false}
|
||||
onQueryChange={onQueryChange}
|
||||
onMultiChange={onMultiChange}
|
||||
onIncludeAllChange={onIncludeAllChange}
|
||||
onAllValueChange={onAllValueChange}
|
||||
onAllowCustomValueChange={onAllowCustomValueChange}
|
||||
/>
|
||||
);
|
||||
|
||||
await userEvent.click(screen.getByText('JSON'));
|
||||
|
||||
const errorEl = await findByText(validationError.message);
|
||||
expect(errorEl).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+144
-1
@@ -1,7 +1,10 @@
|
||||
import { FormEvent } from 'react';
|
||||
|
||||
import { CustomVariableModel } from '@grafana/data';
|
||||
import { selectors } from '@grafana/e2e-selectors';
|
||||
import { Trans, t } from '@grafana/i18n';
|
||||
import { config } from '@grafana/runtime';
|
||||
import { Alert, FieldValidationMessage, Icon, RadioButtonGroup, Stack, TextLink, Tooltip } from '@grafana/ui';
|
||||
|
||||
import { SelectionOptionsForm } from './SelectionOptionsForm';
|
||||
import { VariableLegend } from './VariableLegend';
|
||||
@@ -9,10 +12,12 @@ import { VariableTextAreaField } from './VariableTextAreaField';
|
||||
|
||||
interface CustomVariableFormProps {
|
||||
query: string;
|
||||
valuesFormat?: CustomVariableModel['valuesFormat'];
|
||||
multi: boolean;
|
||||
allValue?: string | null;
|
||||
includeAll: boolean;
|
||||
allowCustomValue?: boolean;
|
||||
queryValidationError?: Error;
|
||||
onQueryChange: (event: FormEvent<HTMLTextAreaElement>) => void;
|
||||
onMultiChange: (event: FormEvent<HTMLInputElement>) => void;
|
||||
onIncludeAllChange: (event: FormEvent<HTMLInputElement>) => void;
|
||||
@@ -20,9 +25,137 @@ interface CustomVariableFormProps {
|
||||
onQueryBlur?: (event: FormEvent<HTMLTextAreaElement>) => void;
|
||||
onAllValueBlur?: (event: FormEvent<HTMLInputElement>) => void;
|
||||
onAllowCustomValueChange?: (event: FormEvent<HTMLInputElement>) => void;
|
||||
onValuesFormatChange?: (format: CustomVariableModel['valuesFormat']) => void;
|
||||
}
|
||||
|
||||
export function CustomVariableForm({
|
||||
query,
|
||||
valuesFormat,
|
||||
multi,
|
||||
allValue,
|
||||
includeAll,
|
||||
allowCustomValue,
|
||||
queryValidationError,
|
||||
onQueryChange,
|
||||
onMultiChange,
|
||||
onIncludeAllChange,
|
||||
onAllValueChange,
|
||||
onAllowCustomValueChange,
|
||||
onValuesFormatChange,
|
||||
}: CustomVariableFormProps) {
|
||||
if (!config.featureToggles.multiPropsVariables) {
|
||||
return (
|
||||
<CustomVariableFormNonMultiProps
|
||||
displayMultiPropsWarningBanner={valuesFormat === 'json'}
|
||||
query={query}
|
||||
multi={multi}
|
||||
allValue={allValue}
|
||||
includeAll={includeAll}
|
||||
allowCustomValue={allowCustomValue}
|
||||
onQueryChange={onQueryChange}
|
||||
onMultiChange={onMultiChange}
|
||||
onIncludeAllChange={onAllValueChange}
|
||||
onAllValueChange={onAllValueChange}
|
||||
onAllowCustomValueChange={onAllowCustomValueChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<VariableLegend>
|
||||
<Trans i18nKey="dashboard-scene.custom-variable-form.custom-options">Custom options</Trans>
|
||||
</VariableLegend>
|
||||
|
||||
<ValuesFormatSelector valuesFormat={valuesFormat} onValuesFormatChange={onValuesFormatChange} />
|
||||
|
||||
<VariableTextAreaField
|
||||
// we don't use a controlled component so we make sure the textarea content is cleared when changing format by providing a key
|
||||
key={valuesFormat}
|
||||
name=""
|
||||
placeholder={
|
||||
valuesFormat === 'json'
|
||||
? // eslint-disable-next-line @grafana/i18n/no-untranslated-strings
|
||||
'[{ "text":"text1", "value":"val1", "propA":"a1", "propB":"b1" },\n{ "text":"text2", "value":"val2", "propA":"a2", "propB":"b2" }]'
|
||||
: // eslint-disable-next-line @grafana/i18n/no-untranslated-strings
|
||||
'1, 10, mykey : myvalue, myvalue, escaped\,value'
|
||||
}
|
||||
defaultValue={query}
|
||||
onBlur={onQueryChange}
|
||||
required
|
||||
width={52}
|
||||
testId={selectors.pages.Dashboard.Settings.Variables.Edit.CustomVariable.customValueInput}
|
||||
/>
|
||||
{queryValidationError && <FieldValidationMessage>{queryValidationError.message}</FieldValidationMessage>}
|
||||
|
||||
<VariableLegend>
|
||||
<Trans i18nKey="dashboard-scene.custom-variable-form.selection-options">Selection options</Trans>
|
||||
</VariableLegend>
|
||||
<SelectionOptionsForm
|
||||
multi={multi}
|
||||
includeAll={includeAll}
|
||||
allValue={allValue}
|
||||
allowCustomValue={allowCustomValue}
|
||||
disableAllowCustomValue={valuesFormat === 'json'}
|
||||
disableCustomAllValue={valuesFormat === 'json'}
|
||||
onMultiChange={onMultiChange}
|
||||
onIncludeAllChange={onIncludeAllChange}
|
||||
onAllValueChange={onAllValueChange}
|
||||
onAllowCustomValueChange={onAllowCustomValueChange}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface ValuesFormatSelectorProps {
|
||||
valuesFormat?: CustomVariableModel['valuesFormat'];
|
||||
onValuesFormatChange?: (format: CustomVariableModel['valuesFormat']) => void;
|
||||
}
|
||||
|
||||
export function ValuesFormatSelector({ valuesFormat, onValuesFormatChange }: ValuesFormatSelectorProps) {
|
||||
return (
|
||||
<Stack direction="row" gap={1}>
|
||||
<RadioButtonGroup
|
||||
value={valuesFormat}
|
||||
onChange={onValuesFormatChange}
|
||||
options={[
|
||||
{
|
||||
value: 'csv',
|
||||
label: config.featureToggles.multiPropsVariables
|
||||
? t('dashboard-scene.custom-variable-form.name-csv-values', 'CSV')
|
||||
: t('dashboard-scene.custom-variable-form.name-values-separated-comma', 'Values separated by comma'),
|
||||
},
|
||||
{
|
||||
value: 'json',
|
||||
label: t('dashboard-scene.custom-variable-form.name-json-values', 'JSON'),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
{valuesFormat === 'json' && (
|
||||
<Tooltip
|
||||
content={
|
||||
<Trans i18nKey="dashboard-scene.custom-variable-form.json-values-tooltip">
|
||||
Provide a JSON representing an array of objects, where each object can have any number of properties.
|
||||
<br />
|
||||
Check{' '}
|
||||
<TextLink href="https://grafana.com/docs/grafana/latest/variables/xxx" external>
|
||||
our docs
|
||||
</TextLink>{' '}
|
||||
for more information.
|
||||
</Trans>
|
||||
}
|
||||
placement="top"
|
||||
interactive
|
||||
>
|
||||
<Icon name="info-circle" />
|
||||
</Tooltip>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function CustomVariableFormNonMultiProps({
|
||||
displayMultiPropsWarningBanner,
|
||||
query,
|
||||
multi,
|
||||
allValue,
|
||||
@@ -33,13 +166,23 @@ export function CustomVariableForm({
|
||||
onIncludeAllChange,
|
||||
onAllValueChange,
|
||||
onAllowCustomValueChange,
|
||||
}: CustomVariableFormProps) {
|
||||
}: CustomVariableFormProps & { displayMultiPropsWarningBanner: boolean }) {
|
||||
return (
|
||||
<>
|
||||
<VariableLegend>
|
||||
<Trans i18nKey="dashboard-scene.custom-variable-form.custom-options">Custom options</Trans>
|
||||
</VariableLegend>
|
||||
|
||||
{displayMultiPropsWarningBanner && (
|
||||
<div style={{ maxWidth: '25%' }}>
|
||||
{/* eslint-disable-next-line @grafana/i18n/no-untranslated-strings */}
|
||||
<Alert severity="warning" title="Custom options with multi-properties are unavailable">
|
||||
This feature is temporarily disabled, sorry for any inconvenience. Please recreate these options without
|
||||
multi-properties.
|
||||
</Alert>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<VariableTextAreaField
|
||||
name={t('dashboard-scene.custom-variable-form.name-values-separated-comma', 'Values separated by comma')}
|
||||
defaultValue={query}
|
||||
|
||||
+18
-13
@@ -10,7 +10,9 @@ interface SelectionOptionsFormProps {
|
||||
multi: boolean;
|
||||
includeAll: boolean;
|
||||
allowCustomValue?: boolean;
|
||||
disableAllowCustomValue?: boolean;
|
||||
allValue?: string | null;
|
||||
disableCustomAllValue?: boolean;
|
||||
onMultiChange: (event: ChangeEvent<HTMLInputElement>) => void;
|
||||
onAllowCustomValueChange?: (event: ChangeEvent<HTMLInputElement>) => void;
|
||||
onIncludeAllChange: (event: ChangeEvent<HTMLInputElement>) => void;
|
||||
@@ -20,8 +22,10 @@ interface SelectionOptionsFormProps {
|
||||
export function SelectionOptionsForm({
|
||||
multi,
|
||||
allowCustomValue,
|
||||
disableAllowCustomValue,
|
||||
includeAll,
|
||||
allValue,
|
||||
disableCustomAllValue,
|
||||
onMultiChange,
|
||||
onAllowCustomValueChange,
|
||||
onIncludeAllChange,
|
||||
@@ -39,18 +43,19 @@ export function SelectionOptionsForm({
|
||||
onChange={onMultiChange}
|
||||
testId={selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsMultiSwitch}
|
||||
/>
|
||||
{onAllowCustomValueChange && ( // backwards compat with old arch, remove on cleanup
|
||||
<VariableCheckboxField
|
||||
value={allowCustomValue ?? true}
|
||||
name={t('dashboard-scene.selection-options-form.name-allow-custom-values', 'Allow custom values')}
|
||||
description={t(
|
||||
'dashboard-scene.selection-options-form.description-enables-users-custom-values',
|
||||
'Enables users to add custom values to the list'
|
||||
)}
|
||||
onChange={onAllowCustomValueChange}
|
||||
testId={selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsAllowCustomValueSwitch}
|
||||
/>
|
||||
)}
|
||||
{!disableAllowCustomValue &&
|
||||
onAllowCustomValueChange && ( // backwards compat with old arch, remove on cleanup
|
||||
<VariableCheckboxField
|
||||
value={allowCustomValue ?? true}
|
||||
name={t('dashboard-scene.selection-options-form.name-allow-custom-values', 'Allow custom values')}
|
||||
description={t(
|
||||
'dashboard-scene.selection-options-form.description-enables-users-custom-values',
|
||||
'Enables users to add custom values to the list'
|
||||
)}
|
||||
onChange={onAllowCustomValueChange}
|
||||
testId={selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsAllowCustomValueSwitch}
|
||||
/>
|
||||
)}
|
||||
<VariableCheckboxField
|
||||
value={includeAll}
|
||||
name={t('dashboard-scene.selection-options-form.name-include-all-option', 'Include All option')}
|
||||
@@ -61,7 +66,7 @@ export function SelectionOptionsForm({
|
||||
onChange={onIncludeAllChange}
|
||||
testId={selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsIncludeAllSwitch}
|
||||
/>
|
||||
{includeAll && (
|
||||
{!disableCustomAllValue && includeAll && (
|
||||
<VariableTextField
|
||||
defaultValue={allValue ?? ''}
|
||||
onBlur={onAllValueChange}
|
||||
|
||||
+93
-18
@@ -1,17 +1,94 @@
|
||||
import { css } from '@emotion/css';
|
||||
import { MouseEvent, useCallback, useEffect, useState } from 'react';
|
||||
import { MouseEvent, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { selectors } from '@grafana/e2e-selectors';
|
||||
import { Trans } from '@grafana/i18n';
|
||||
import { VariableValueOption } from '@grafana/scenes';
|
||||
import { Button, InlineFieldRow, InlineLabel, useStyles2, Text } from '@grafana/ui';
|
||||
import { config } from '@grafana/runtime';
|
||||
import { VariableValueOption, VariableValueOptionProperties } from '@grafana/scenes';
|
||||
import { Button, InlineFieldRow, InlineLabel, InteractiveTable, Text, useStyles2 } from '@grafana/ui';
|
||||
|
||||
export interface VariableValuesPreviewProps {
|
||||
export interface Props {
|
||||
options: VariableValueOption[];
|
||||
hasMultiProps?: boolean;
|
||||
}
|
||||
|
||||
export const VariableValuesPreview = ({ options }: VariableValuesPreviewProps) => {
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
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 {};
|
||||
}
|
||||
|
||||
const result: Record<string, string> = {};
|
||||
|
||||
for (const [key, value] of Object.entries(properties)) {
|
||||
const newPath = path ? `${path}.${key}` : key;
|
||||
|
||||
if (typeof value === 'object') {
|
||||
Object.assign(result, flattenProperties(value, newPath));
|
||||
} else {
|
||||
// see https://github.com/TanStack/table/issues/1671
|
||||
result[sanitizeKey(newPath)] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function VariableValuesWithoutPropsPreview({ options }: { options: VariableValueOption[] }) {
|
||||
const styles = useStyles2(getStyles);
|
||||
const [previewLimit, setPreviewLimit] = useState(20);
|
||||
const [previewOptions, setPreviewOptions] = useState<VariableValueOption[]>([]);
|
||||
const showMoreOptions = useCallback(
|
||||
@@ -21,18 +98,10 @@ export const VariableValuesPreview = ({ options }: VariableValuesPreviewProps) =
|
||||
},
|
||||
[previewLimit, setPreviewLimit]
|
||||
);
|
||||
const styles = useStyles2(getStyles);
|
||||
useEffect(() => setPreviewOptions(options.slice(0, previewLimit)), [previewLimit, options]);
|
||||
|
||||
if (!previewOptions.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', marginTop: '16px' }}>
|
||||
<Text variant="bodySmall" weight="medium">
|
||||
<Trans i18nKey="dashboard-scene.variable-values-preview.preview-of-values">Preview of values</Trans>
|
||||
</Text>
|
||||
<>
|
||||
<InlineFieldRow>
|
||||
{previewOptions.map((o, index) => (
|
||||
<InlineFieldRow key={`${o.value}-${index}`} className={styles.optionContainer}>
|
||||
@@ -49,16 +118,17 @@ export const VariableValuesPreview = ({ options }: VariableValuesPreviewProps) =
|
||||
</Button>
|
||||
</InlineFieldRow>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
VariableValuesPreview.displayName = 'VariableValuesPreview';
|
||||
}
|
||||
VariableValuesWithoutPropsPreview.displayName = 'VariableValuesWithoutPropsPreview';
|
||||
|
||||
function getStyles(theme: GrafanaTheme2) {
|
||||
return {
|
||||
wrapper: css({
|
||||
previewContainer: css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: theme.spacing(1),
|
||||
marginTop: theme.spacing(2),
|
||||
}),
|
||||
optionContainer: css({
|
||||
@@ -71,5 +141,10 @@ function getStyles(theme: GrafanaTheme2) {
|
||||
textOverflow: 'ellipsis',
|
||||
maxWidth: '50vw',
|
||||
}),
|
||||
table: css({
|
||||
td: css({
|
||||
padding: theme.spacing(0.5, 1),
|
||||
}),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
+207
-93
@@ -5,117 +5,231 @@ import { CustomVariable } from '@grafana/scenes';
|
||||
|
||||
import { CustomVariableEditor } from './CustomVariableEditor';
|
||||
|
||||
jest.mock('@grafana/runtime', () => {
|
||||
const actual = jest.requireActual('@grafana/runtime');
|
||||
actual.config.featureToggles = { multiPropsVariables: true };
|
||||
return actual;
|
||||
});
|
||||
|
||||
function setup(options: Partial<ConstructorParameters<typeof CustomVariable>[0]> = {}) {
|
||||
return {
|
||||
variable: new CustomVariable({
|
||||
name: 'customVar',
|
||||
...options,
|
||||
}),
|
||||
onRunQuery: jest.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
function renderEditor(ui: React.ReactNode) {
|
||||
const renderResult = render(ui);
|
||||
|
||||
const elements = {
|
||||
formatButton: (label: string) => renderResult.queryByLabelText(label) as HTMLElement,
|
||||
queryInput: () =>
|
||||
renderResult.queryByTestId(
|
||||
selectors.pages.Dashboard.Settings.Variables.Edit.CustomVariable.customValueInput
|
||||
) as HTMLTextAreaElement,
|
||||
multiValueCheckbox: () =>
|
||||
renderResult.queryByTestId(
|
||||
selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsMultiSwitch
|
||||
) as HTMLInputElement,
|
||||
allowCustomValueCheckbox: () =>
|
||||
renderResult.queryByTestId(
|
||||
selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsAllowCustomValueSwitch
|
||||
) as HTMLInputElement,
|
||||
includeAllCheckbox: () =>
|
||||
renderResult.queryByTestId(
|
||||
selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsIncludeAllSwitch
|
||||
) as HTMLInputElement,
|
||||
customAllValueInput: () =>
|
||||
renderResult.queryByTestId(
|
||||
selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsCustomAllInput
|
||||
) as HTMLInputElement,
|
||||
};
|
||||
|
||||
return {
|
||||
...renderResult,
|
||||
elements,
|
||||
actions: {
|
||||
updateValuesInput(newQuery: string) {
|
||||
fireEvent.change(elements.queryInput(), { target: { value: newQuery } });
|
||||
fireEvent.blur(elements.queryInput());
|
||||
},
|
||||
changeValuesFormat(newFormat: 'csv' | 'json') {
|
||||
const targetLabel = newFormat === 'json' ? 'JSON' : 'CSV';
|
||||
|
||||
const formatButton = elements.formatButton(targetLabel);
|
||||
if (formatButton === null) {
|
||||
throw new Error(`Unable to fire a "click" event - button with label "${targetLabel}" not found in DOM`);
|
||||
}
|
||||
|
||||
fireEvent.click(formatButton);
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('CustomVariableEditor', () => {
|
||||
it('should render the CustomVariableForm with correct initial values', () => {
|
||||
const variable = new CustomVariable({
|
||||
name: 'customVar',
|
||||
query: 'test, test2',
|
||||
value: 'test',
|
||||
isMulti: true,
|
||||
includeAll: true,
|
||||
allValue: 'test',
|
||||
describe('CSV values format', () => {
|
||||
it('should render CustomVariableForm with the correct initial values', () => {
|
||||
const { variable, onRunQuery } = setup({
|
||||
query: 'test, test2',
|
||||
value: 'test',
|
||||
isMulti: true,
|
||||
includeAll: true,
|
||||
allowCustomValue: true,
|
||||
allValue: 'all',
|
||||
});
|
||||
|
||||
const { elements } = renderEditor(<CustomVariableEditor variable={variable} onRunQuery={onRunQuery} />);
|
||||
|
||||
expect(elements.queryInput().value).toBe('test, test2');
|
||||
expect(elements.multiValueCheckbox().checked).toBe(true);
|
||||
expect(elements.allowCustomValueCheckbox().checked).toBe(true);
|
||||
expect(elements.includeAllCheckbox().checked).toBe(true);
|
||||
expect(elements.customAllValueInput().value).toBe('all');
|
||||
});
|
||||
const onRunQuery = jest.fn();
|
||||
|
||||
const { getByTestId } = render(<CustomVariableEditor variable={variable} onRunQuery={onRunQuery} />);
|
||||
it('should update the variable state when some input values change ("Multi-value", "Allow custom values" & "Include All option")', () => {
|
||||
const { variable, onRunQuery } = setup({
|
||||
query: 'test, test2',
|
||||
value: 'test',
|
||||
isMulti: false,
|
||||
allowCustomValue: false,
|
||||
includeAll: false,
|
||||
});
|
||||
|
||||
const queryInput = getByTestId(
|
||||
selectors.pages.Dashboard.Settings.Variables.Edit.CustomVariable.customValueInput
|
||||
) as HTMLInputElement;
|
||||
const allValueInput = getByTestId(
|
||||
selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsCustomAllInput
|
||||
) as HTMLInputElement;
|
||||
const multiCheckbox = getByTestId(
|
||||
selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsMultiSwitch
|
||||
) as HTMLInputElement;
|
||||
const includeAllCheckbox = getByTestId(
|
||||
selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsIncludeAllSwitch
|
||||
) as HTMLInputElement;
|
||||
const { elements } = renderEditor(<CustomVariableEditor variable={variable} onRunQuery={onRunQuery} />);
|
||||
|
||||
expect(queryInput.value).toBe('test, test2');
|
||||
expect(allValueInput.value).toBe('test');
|
||||
expect(multiCheckbox.checked).toBe(true);
|
||||
expect(includeAllCheckbox.checked).toBe(true);
|
||||
expect(elements.multiValueCheckbox().checked).toBe(false);
|
||||
expect(elements.allowCustomValueCheckbox().checked).toBe(false);
|
||||
expect(elements.includeAllCheckbox().checked).toBe(false);
|
||||
// include-all-custom input appears after include-all checkbox is checked only
|
||||
expect(elements.customAllValueInput()).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(elements.multiValueCheckbox());
|
||||
fireEvent.click(elements.allowCustomValueCheckbox());
|
||||
fireEvent.click(elements.includeAllCheckbox());
|
||||
|
||||
expect(variable.state.isMulti).toBe(true);
|
||||
expect(variable.state.allowCustomValue).toBe(true);
|
||||
expect(variable.state.includeAll).toBe(true);
|
||||
expect(elements.customAllValueInput()).toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe('when the values textarea loses focus after its value has changed', () => {
|
||||
it('should update the query in the variable state and call the onRunQuery callback', async () => {
|
||||
const { variable, onRunQuery } = setup({ query: 'test, test2', value: 'test' });
|
||||
|
||||
const { actions } = renderEditor(<CustomVariableEditor variable={variable} onRunQuery={onRunQuery} />);
|
||||
|
||||
actions.updateValuesInput('test3, test4');
|
||||
|
||||
expect(variable.state.query).toBe('test3, test4');
|
||||
expect(onRunQuery).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('when the "Custom all value" input loses focus after its value has changed', () => {
|
||||
it('should update the variable state', () => {
|
||||
const { variable, onRunQuery } = setup({
|
||||
query: 'test, test2',
|
||||
value: 'test',
|
||||
isMulti: true,
|
||||
includeAll: true,
|
||||
});
|
||||
|
||||
const { elements } = renderEditor(<CustomVariableEditor variable={variable} onRunQuery={onRunQuery} />);
|
||||
|
||||
fireEvent.change(elements.customAllValueInput(), { target: { value: 'new custom all' } });
|
||||
fireEvent.blur(elements.customAllValueInput());
|
||||
|
||||
expect(variable.state.allValue).toBe('new custom all');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should update the variable state when input values change', () => {
|
||||
const variable = new CustomVariable({
|
||||
name: 'customVar',
|
||||
query: 'test, test2',
|
||||
value: 'test',
|
||||
describe('JSON values format', () => {
|
||||
const initialJsonQuery = `[
|
||||
{"value":1,"text":"Development","aws":"dev","azure":"development"},
|
||||
{"value":2,"text":"Production","aws":"prod","azure":"production"}
|
||||
]`;
|
||||
|
||||
it('should render CustomVariableForm with the correct initial values', () => {
|
||||
const { variable, onRunQuery } = setup({
|
||||
valuesFormat: 'json',
|
||||
query: initialJsonQuery,
|
||||
isMulti: true,
|
||||
includeAll: true,
|
||||
});
|
||||
|
||||
const { elements } = renderEditor(<CustomVariableEditor variable={variable} onRunQuery={onRunQuery} />);
|
||||
|
||||
expect(elements.queryInput().value).toBe(initialJsonQuery);
|
||||
expect(elements.multiValueCheckbox().checked).toBe(true);
|
||||
expect(elements.allowCustomValueCheckbox()).not.toBeInTheDocument();
|
||||
expect(elements.includeAllCheckbox().checked).toBe(true);
|
||||
expect(elements.customAllValueInput()).not.toBeInTheDocument();
|
||||
});
|
||||
const onRunQuery = jest.fn();
|
||||
|
||||
const { getByTestId } = render(<CustomVariableEditor variable={variable} onRunQuery={onRunQuery} />);
|
||||
describe('when the values textarea loses focus after its value has changed', () => {
|
||||
describe('if the value is valid JSON', () => {
|
||||
it('should update the query in the variable state and call the onRunQuery callback', async () => {
|
||||
const { variable, onRunQuery } = setup({ valuesFormat: 'json', query: initialJsonQuery });
|
||||
|
||||
const multiCheckbox = getByTestId(
|
||||
selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsMultiSwitch
|
||||
);
|
||||
const includeAllCheckbox = getByTestId(
|
||||
selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsIncludeAllSwitch
|
||||
);
|
||||
const { actions } = renderEditor(<CustomVariableEditor variable={variable} onRunQuery={onRunQuery} />);
|
||||
|
||||
const allowCustomValueCheckbox = getByTestId(
|
||||
selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsAllowCustomValueSwitch
|
||||
);
|
||||
actions.updateValuesInput('[]');
|
||||
|
||||
// It include-all-custom input appears after include-all checkbox is checked only
|
||||
expect(() =>
|
||||
getByTestId(selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsCustomAllInput)
|
||||
).toThrow('Unable to find an element');
|
||||
expect(variable.state.query).toBe('[]');
|
||||
expect(onRunQuery).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
fireEvent.click(allowCustomValueCheckbox);
|
||||
describe('if the value is NOT valid JSON', () => {
|
||||
it('should display a validation error message and neither update the query in the variable state nor call the onRunQuery callback', async () => {
|
||||
const { variable, onRunQuery } = setup({ valuesFormat: 'json', query: initialJsonQuery });
|
||||
|
||||
fireEvent.click(multiCheckbox);
|
||||
const { actions, getByRole } = renderEditor(
|
||||
<CustomVariableEditor variable={variable} onRunQuery={onRunQuery} />
|
||||
);
|
||||
|
||||
fireEvent.click(includeAllCheckbox);
|
||||
const allValueInput = getByTestId(
|
||||
selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsCustomAllInput
|
||||
);
|
||||
actions.updateValuesInput('[x]');
|
||||
|
||||
expect(variable.state.isMulti).toBe(true);
|
||||
expect(variable.state.includeAll).toBe(true);
|
||||
expect(variable.state.allowCustomValue).toBe(false);
|
||||
expect(allValueInput).toBeInTheDocument();
|
||||
expect(getByRole('alert')).toHaveTextContent(`Unexpected token 'x', "[x]" is not valid JSON`);
|
||||
expect(variable.state.query).toBe(initialJsonQuery);
|
||||
expect(onRunQuery).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should call update query and re-run query when input loses focus', async () => {
|
||||
const variable = new CustomVariable({
|
||||
name: 'customVar',
|
||||
query: 'test, test2',
|
||||
value: 'test',
|
||||
describe('when switching values format', () => {
|
||||
it('should switch the visibility of the proper form inputs ("Allow custom values" and "Custom all value")', () => {
|
||||
const { variable, onRunQuery } = setup({
|
||||
valuesFormat: 'csv',
|
||||
query: '',
|
||||
isMulti: true,
|
||||
includeAll: true,
|
||||
allowCustomValue: true,
|
||||
allValue: '',
|
||||
});
|
||||
|
||||
const { elements, actions } = renderEditor(<CustomVariableEditor variable={variable} onRunQuery={onRunQuery} />);
|
||||
|
||||
expect(elements.allowCustomValueCheckbox()).toBeInTheDocument();
|
||||
expect(elements.customAllValueInput()).toBeInTheDocument();
|
||||
|
||||
actions.changeValuesFormat('json');
|
||||
|
||||
expect(elements.allowCustomValueCheckbox()).not.toBeInTheDocument();
|
||||
expect(elements.customAllValueInput()).not.toBeInTheDocument();
|
||||
|
||||
actions.changeValuesFormat('csv');
|
||||
|
||||
expect(elements.allowCustomValueCheckbox()).toBeInTheDocument();
|
||||
expect(elements.customAllValueInput()).toBeInTheDocument();
|
||||
});
|
||||
const onRunQuery = jest.fn();
|
||||
|
||||
const { getByTestId } = render(<CustomVariableEditor variable={variable} onRunQuery={onRunQuery} />);
|
||||
|
||||
const queryInput = getByTestId(selectors.pages.Dashboard.Settings.Variables.Edit.CustomVariable.customValueInput);
|
||||
fireEvent.change(queryInput, { target: { value: 'test3, test4' } });
|
||||
fireEvent.blur(queryInput);
|
||||
|
||||
expect(onRunQuery).toHaveBeenCalled();
|
||||
expect(variable.state.query).toBe('test3, test4');
|
||||
});
|
||||
|
||||
it('should update the variable state when all-custom-value input loses focus', () => {
|
||||
const variable = new CustomVariable({
|
||||
name: 'customVar',
|
||||
query: 'test, test2',
|
||||
value: 'test',
|
||||
isMulti: true,
|
||||
includeAll: true,
|
||||
});
|
||||
const onRunQuery = jest.fn();
|
||||
|
||||
const { getByTestId } = render(<CustomVariableEditor variable={variable} onRunQuery={onRunQuery} />);
|
||||
|
||||
const allValueInput = getByTestId(
|
||||
selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsCustomAllInput
|
||||
) as HTMLInputElement;
|
||||
|
||||
fireEvent.change(allValueInput, { target: { value: 'new custom all' } });
|
||||
fireEvent.blur(allValueInput);
|
||||
|
||||
expect(variable.state.allValue).toBe('new custom all');
|
||||
});
|
||||
});
|
||||
|
||||
+105
-5
@@ -1,16 +1,42 @@
|
||||
import { FormEvent, useCallback } from 'react';
|
||||
import { isObject } from 'lodash';
|
||||
import { FormEvent, useCallback, useState } from 'react';
|
||||
|
||||
import { CustomVariable } from '@grafana/scenes';
|
||||
import { CustomVariableModel, shallowCompare } from '@grafana/data';
|
||||
import { t } from '@grafana/i18n';
|
||||
import { config } from '@grafana/runtime';
|
||||
import { CustomVariable, SceneVariable } from '@grafana/scenes';
|
||||
import { OptionsPaneItemDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneItemDescriptor';
|
||||
|
||||
import { CustomVariableForm } from '../../components/CustomVariableForm';
|
||||
|
||||
import { PaneItem } from './PaneItem';
|
||||
|
||||
interface CustomVariableEditorProps {
|
||||
variable: CustomVariable;
|
||||
onRunQuery: () => void;
|
||||
}
|
||||
|
||||
export function CustomVariableEditor({ variable, onRunQuery }: CustomVariableEditorProps) {
|
||||
const { query, isMulti, allValue, includeAll, allowCustomValue } = variable.useState();
|
||||
const { query, valuesFormat, isMulti, allValue, includeAll, allowCustomValue } = variable.useState();
|
||||
const [queryValidationError, setQueryValidationError] = useState<Error>();
|
||||
|
||||
const [prevQuery, setPrevQuery] = useState('');
|
||||
const onValuesFormatChange = useCallback(
|
||||
(format: CustomVariableModel['valuesFormat']) => {
|
||||
variable.setState({ query: prevQuery });
|
||||
variable.setState({ value: isMulti ? [] : undefined });
|
||||
variable.setState({ valuesFormat: format });
|
||||
variable.setState({ allowCustomValue: false });
|
||||
variable.setState({ allValue: undefined });
|
||||
onRunQuery();
|
||||
|
||||
setQueryValidationError(undefined);
|
||||
if (query !== prevQuery) {
|
||||
setPrevQuery(query);
|
||||
}
|
||||
},
|
||||
[isMulti, onRunQuery, prevQuery, query, variable]
|
||||
);
|
||||
|
||||
const onMultiChange = useCallback(
|
||||
(event: FormEvent<HTMLInputElement>) => {
|
||||
@@ -28,10 +54,24 @@ export function CustomVariableEditor({ variable, onRunQuery }: CustomVariableEdi
|
||||
|
||||
const onQueryChange = useCallback(
|
||||
(event: FormEvent<HTMLTextAreaElement>) => {
|
||||
setPrevQuery('');
|
||||
|
||||
if (config.featureToggles.multiPropsVariables && valuesFormat === 'json') {
|
||||
const validationError = validateJsonQuery(event.currentTarget.value.trim());
|
||||
setQueryValidationError(validationError);
|
||||
if (validationError) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!config.featureToggles.multiPropsVariables) {
|
||||
variable.setState({ valuesFormat: 'csv' });
|
||||
}
|
||||
|
||||
variable.setState({ query: event.currentTarget.value });
|
||||
onRunQuery();
|
||||
},
|
||||
[variable, onRunQuery]
|
||||
[valuesFormat, variable, onRunQuery]
|
||||
);
|
||||
|
||||
const onAllValueChange = useCallback(
|
||||
@@ -51,15 +91,75 @@ export function CustomVariableEditor({ variable, onRunQuery }: CustomVariableEdi
|
||||
return (
|
||||
<CustomVariableForm
|
||||
query={query ?? ''}
|
||||
valuesFormat={valuesFormat ?? 'csv'}
|
||||
multi={!!isMulti}
|
||||
allValue={allValue ?? ''}
|
||||
includeAll={!!includeAll}
|
||||
allowCustomValue={allowCustomValue}
|
||||
queryValidationError={queryValidationError}
|
||||
onQueryChange={onQueryChange}
|
||||
onMultiChange={onMultiChange}
|
||||
onIncludeAllChange={onIncludeAllChange}
|
||||
onQueryChange={onQueryChange}
|
||||
onAllValueChange={onAllValueChange}
|
||||
onAllowCustomValueChange={onAllowCustomValueChange}
|
||||
onValuesFormatChange={onValuesFormatChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function getCustomVariableOptions(variable: SceneVariable): OptionsPaneItemDescriptor[] {
|
||||
if (!(variable instanceof CustomVariable)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
new OptionsPaneItemDescriptor({
|
||||
title: t('dashboard.edit-pane.variable.custom-options.values', 'Values separated by comma'),
|
||||
id: 'custom-variable-values',
|
||||
render: ({ props }) => <PaneItem id={props.id} variable={variable} />,
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
export const validateJsonQuery = (query: string): Error | undefined => {
|
||||
if (!query) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const options = JSON.parse(query);
|
||||
|
||||
if (!Array.isArray(options)) {
|
||||
throw new Error('Enter a valid JSON array of objects');
|
||||
}
|
||||
|
||||
if (!options.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
let errorIndex = options.findIndex((item) => !isObject(item));
|
||||
if (errorIndex !== -1) {
|
||||
throw new Error(`All items must be objects. The item at index ${errorIndex} is not an object.`);
|
||||
}
|
||||
|
||||
const keys = Object.keys(options[0]);
|
||||
if (!keys.includes('value')) {
|
||||
throw new Error('Each object in the array must include at least a "value" property');
|
||||
}
|
||||
if (keys.includes('')) {
|
||||
throw new Error('Object property names cannot be empty strings');
|
||||
}
|
||||
|
||||
errorIndex = options.findIndex((o) => !shallowCompare(keys, Object.keys(o)));
|
||||
if (errorIndex !== -1) {
|
||||
throw new Error(
|
||||
`All objects must have the same set of properties. The object at index ${errorIndex} does not match the expected properties`
|
||||
);
|
||||
}
|
||||
|
||||
return;
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
return error as Error;
|
||||
}
|
||||
};
|
||||
|
||||
+116
-43
@@ -1,23 +1,43 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import { FormEvent, useMemo, useRef, useState } from 'react';
|
||||
import { lastValueFrom } from 'rxjs';
|
||||
|
||||
import { CustomVariableModel } from '@grafana/data';
|
||||
import { selectors } from '@grafana/e2e-selectors';
|
||||
import { t, Trans } from '@grafana/i18n';
|
||||
import { CustomVariable, VariableValueOption, VariableValueSingle } from '@grafana/scenes';
|
||||
import { Button, Modal, Stack } from '@grafana/ui';
|
||||
import { config } from '@grafana/runtime';
|
||||
import { CustomVariable } from '@grafana/scenes';
|
||||
import { Button, FieldValidationMessage, Modal, Stack, TextArea } from '@grafana/ui';
|
||||
|
||||
import { dashboardEditActions } from '../../../../edit-pane/shared';
|
||||
import { VariableStaticOptionsForm, VariableStaticOptionsFormRef } from '../../components/VariableStaticOptionsForm';
|
||||
import { VariableStaticOptionsFormAddButton } from '../../components/VariableStaticOptionsFormAddButton';
|
||||
import { ValuesFormatSelector } from '../../components/CustomVariableForm';
|
||||
import { VariableValuesPreview } from '../../components/VariableValuesPreview';
|
||||
|
||||
import { validateJsonQuery } from './CustomVariableEditor';
|
||||
import { ModalEditorNonMultiProps } from './ModalEditorNonMultiProps';
|
||||
|
||||
interface ModalEditorProps {
|
||||
variable: CustomVariable;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function ModalEditor(props: ModalEditorProps) {
|
||||
const { formRef, onCloseModal, options, onChangeOptions, onAddNewOption, onSaveOptions } = useModalEditor(props);
|
||||
if (!config.featureToggles.multiPropsVariables) {
|
||||
return <ModalEditorNonMultiProps {...props} />;
|
||||
}
|
||||
return <ModalEditorMultiProps {...props} />;
|
||||
}
|
||||
|
||||
function ModalEditorMultiProps(props: ModalEditorProps) {
|
||||
const {
|
||||
valuesFormat,
|
||||
query,
|
||||
queryValidationError,
|
||||
options,
|
||||
onCloseModal,
|
||||
onValuesFormatChange,
|
||||
onQueryChange,
|
||||
onSaveOptions,
|
||||
} = useModalEditor(props);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
@@ -28,10 +48,31 @@ export function ModalEditor(props: ModalEditorProps) {
|
||||
closeOnEscape={false}
|
||||
>
|
||||
<Stack direction="column" gap={2}>
|
||||
<VariableStaticOptionsForm options={options} onChange={onChangeOptions} ref={formRef} isInModal />
|
||||
<VariableValuesPreview options={options} />
|
||||
<ValuesFormatSelector valuesFormat={valuesFormat} onValuesFormatChange={onValuesFormatChange} />
|
||||
<div>
|
||||
<TextArea
|
||||
id={valuesFormat}
|
||||
key={valuesFormat}
|
||||
rows={4}
|
||||
defaultValue={query}
|
||||
onChange={onQueryChange}
|
||||
placeholder={
|
||||
valuesFormat === 'json'
|
||||
? // eslint-disable-next-line @grafana/i18n/no-untranslated-strings
|
||||
'[{ "text":"text1", "value":"val1", "propA":"a1", "propB":"b1" },\n{ "text":"text2", "value":"val2", "propA":"a2", "propB":"b2" }]'
|
||||
: // eslint-disable-next-line @grafana/i18n/no-untranslated-strings
|
||||
'1, 10, mykey : myvalue, myvalue, escaped\,value'
|
||||
}
|
||||
required
|
||||
data-testid={selectors.pages.Dashboard.Settings.Variables.Edit.CustomVariable.customValueInput}
|
||||
/>
|
||||
{queryValidationError && <FieldValidationMessage>{queryValidationError.message}</FieldValidationMessage>}
|
||||
</div>
|
||||
<div>
|
||||
<VariableValuesPreview options={options} hasMultiProps={valuesFormat === 'json'} />
|
||||
</div>
|
||||
</Stack>
|
||||
<Modal.ButtonRow leftItems={<VariableStaticOptionsFormAddButton onAdd={onAddNewOption} />}>
|
||||
<Modal.ButtonRow>
|
||||
<Button
|
||||
variant="secondary"
|
||||
fill="outline"
|
||||
@@ -43,6 +84,7 @@ export function ModalEditor(props: ModalEditorProps) {
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={onSaveOptions}
|
||||
disabled={Boolean(queryValidationError)}
|
||||
data-testid={selectors.pages.Dashboard.Settings.Variables.Edit.CustomVariable.applyButton}
|
||||
>
|
||||
<Trans i18nKey="dashboard.edit-pane.variable.custom-options.apply">Apply</Trans>
|
||||
@@ -53,51 +95,82 @@ export function ModalEditor(props: ModalEditorProps) {
|
||||
}
|
||||
|
||||
function useModalEditor({ variable, onClose }: ModalEditorProps) {
|
||||
const { query } = variable.state;
|
||||
const [options, setOptions] = useState(() => transformQueryToOptions(variable, query));
|
||||
const initialQueryRef = useRef(query);
|
||||
const formRef = useRef<VariableStaticOptionsFormRef | null>(null);
|
||||
const initialValuesFormatRef = useRef(variable.state.valuesFormat);
|
||||
const initialQueryRef = useRef(variable.state.query);
|
||||
const [valuesFormat, setValuesFormat] = useState(() => variable.state.valuesFormat);
|
||||
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]);
|
||||
|
||||
return {
|
||||
formRef,
|
||||
onCloseModal: onClose,
|
||||
valuesFormat,
|
||||
query,
|
||||
queryValidationError,
|
||||
options,
|
||||
onChangeOptions: setOptions,
|
||||
onAddNewOption() {
|
||||
formRef.current?.addItem();
|
||||
onCloseModal: onClose,
|
||||
onValuesFormatChange(newFormat: CustomVariableModel['valuesFormat']) {
|
||||
setQuery(prevQuery);
|
||||
setValuesFormat(newFormat);
|
||||
setQueryValidationError(undefined);
|
||||
if (query !== prevQuery) {
|
||||
setPrevQuery(query);
|
||||
}
|
||||
},
|
||||
onQueryChange(event: FormEvent<HTMLTextAreaElement>) {
|
||||
setPrevQuery('');
|
||||
if (valuesFormat === 'json') {
|
||||
const validationError = validateJsonQuery(event.currentTarget.value);
|
||||
setQueryValidationError(validationError);
|
||||
if (validationError) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
setQuery(event.currentTarget.value);
|
||||
},
|
||||
onSaveOptions() {
|
||||
dashboardEditActions.edit({
|
||||
source: variable,
|
||||
description: t('dashboard.edit-pane.variable.custom-options.change-value', 'Change variable value'),
|
||||
perform: () => {
|
||||
variable.setState({ query: transformOptionsToQuery(options) });
|
||||
lastValueFrom(variable.validateAndUpdate!());
|
||||
description: t('dashboard-scene.use-modal-editor.description.change-variable-query', 'Change variable query'),
|
||||
perform: async () => {
|
||||
if (!config.featureToggles.multiPropsVariables) {
|
||||
variable.setState({ valuesFormat: 'csv', query, value: undefined });
|
||||
} else {
|
||||
variable.setState({ valuesFormat, query, value: undefined });
|
||||
}
|
||||
|
||||
if (valuesFormat === 'json') {
|
||||
variable.setState({ allowCustomValue: false, allValue: undefined });
|
||||
}
|
||||
|
||||
await lastValueFrom(variable.validateAndUpdate!());
|
||||
},
|
||||
undo: () => {
|
||||
variable.setState({ query: initialQueryRef.current });
|
||||
lastValueFrom(variable.validateAndUpdate!());
|
||||
undo: async () => {
|
||||
variable.setState({
|
||||
valuesFormat: initialValuesFormatRef.current,
|
||||
query: initialQueryRef.current,
|
||||
value: undefined,
|
||||
});
|
||||
|
||||
if (initialValuesFormatRef.current === 'json') {
|
||||
variable.setState({ allowCustomValue: false });
|
||||
variable.setState({ allValue: undefined });
|
||||
}
|
||||
|
||||
await lastValueFrom(variable.validateAndUpdate!());
|
||||
},
|
||||
});
|
||||
|
||||
onClose();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const transformQueryToOptions = (variable: ModalEditorProps['variable'], query: string) =>
|
||||
variable.transformCsvStringToOptions(query, false).map(({ label, value }) => ({
|
||||
value,
|
||||
label: value === label ? '' : label,
|
||||
}));
|
||||
|
||||
const formatOption = (option: VariableValueOption) => {
|
||||
if (!option.label || option.label === option.value) {
|
||||
return escapeEntities(option.value);
|
||||
}
|
||||
return `${escapeEntities(option.label)} : ${escapeEntities(String(option.value))}`;
|
||||
};
|
||||
|
||||
const escapeEntities = (text: VariableValueSingle) => String(text).trim().replaceAll(',', '\\,');
|
||||
|
||||
const transformOptionsToQuery = (options: VariableValueOption[]) => options.map(formatOption).join(', ');
|
||||
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import { lastValueFrom } from 'rxjs';
|
||||
|
||||
import { selectors } from '@grafana/e2e-selectors';
|
||||
import { t, Trans } from '@grafana/i18n';
|
||||
import { CustomVariable, VariableValueOption, VariableValueSingle } from '@grafana/scenes';
|
||||
import { Alert, Button, Modal, Stack } from '@grafana/ui';
|
||||
|
||||
import { dashboardEditActions } from '../../../../edit-pane/shared';
|
||||
import { VariableStaticOptionsForm, VariableStaticOptionsFormRef } from '../../components/VariableStaticOptionsForm';
|
||||
import { VariableStaticOptionsFormAddButton } from '../../components/VariableStaticOptionsFormAddButton';
|
||||
import { VariableValuesPreview } from '../../components/VariableValuesPreview';
|
||||
|
||||
interface ModalEditorProps {
|
||||
variable: CustomVariable;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function ModalEditorNonMultiProps(props: ModalEditorProps) {
|
||||
const {
|
||||
displayMultiPropsWarningBanner,
|
||||
formRef,
|
||||
onCloseModal,
|
||||
options,
|
||||
onChangeOptions,
|
||||
onAddNewOption,
|
||||
onSaveOptions,
|
||||
} = useModalEditor(props);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={t('dashboard.edit-pane.variable.custom-options.modal-title', 'Custom Variable')}
|
||||
isOpen={true}
|
||||
onDismiss={onCloseModal}
|
||||
closeOnBackdropClick={false}
|
||||
closeOnEscape={false}
|
||||
>
|
||||
{displayMultiPropsWarningBanner && (
|
||||
// eslint-disable-next-line @grafana/i18n/no-untranslated-strings
|
||||
<Alert severity="warning" title="Custom options with multi-properties are unavailable">
|
||||
This feature is temporarily disabled, sorry for any inconvenience. Please recreate these options without
|
||||
multi-properties.
|
||||
</Alert>
|
||||
)}
|
||||
<Stack direction="column" gap={2}>
|
||||
<VariableStaticOptionsForm options={options} onChange={onChangeOptions} ref={formRef} isInModal />
|
||||
<VariableValuesPreview options={options} />
|
||||
</Stack>
|
||||
<Modal.ButtonRow leftItems={<VariableStaticOptionsFormAddButton onAdd={onAddNewOption} />}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
fill="outline"
|
||||
onClick={onCloseModal}
|
||||
data-testid={selectors.pages.Dashboard.Settings.Variables.Edit.CustomVariable.closeButton}
|
||||
>
|
||||
<Trans i18nKey="dashboard.edit-pane.variable.custom-options.discard">Discard</Trans>
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={onSaveOptions}
|
||||
data-testid={selectors.pages.Dashboard.Settings.Variables.Edit.CustomVariable.applyButton}
|
||||
>
|
||||
<Trans i18nKey="dashboard.edit-pane.variable.custom-options.apply">Apply</Trans>
|
||||
</Button>
|
||||
</Modal.ButtonRow>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function useModalEditor({ variable, onClose }: ModalEditorProps) {
|
||||
const { query, valuesFormat } = variable.state;
|
||||
const [options, setOptions] = useState(() => transformQueryToOptions(variable, query));
|
||||
const initialQueryRef = useRef(query);
|
||||
const formRef = useRef<VariableStaticOptionsFormRef | null>(null);
|
||||
|
||||
return {
|
||||
displayMultiPropsWarningBanner: valuesFormat === 'json',
|
||||
formRef,
|
||||
onCloseModal: onClose,
|
||||
options,
|
||||
onChangeOptions: setOptions,
|
||||
onAddNewOption() {
|
||||
formRef.current?.addItem();
|
||||
},
|
||||
onSaveOptions() {
|
||||
dashboardEditActions.edit({
|
||||
source: variable,
|
||||
description: t('dashboard.edit-pane.variable.custom-options.change-value', 'Change variable value'),
|
||||
perform: () => {
|
||||
variable.setState({ query: transformOptionsToQuery(options) });
|
||||
lastValueFrom(variable.validateAndUpdate!());
|
||||
},
|
||||
undo: () => {
|
||||
variable.setState({ query: initialQueryRef.current });
|
||||
lastValueFrom(variable.validateAndUpdate!());
|
||||
},
|
||||
});
|
||||
|
||||
onClose();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const transformQueryToOptions = (variable: ModalEditorProps['variable'], query: string) =>
|
||||
variable.transformCsvStringToOptions(query, false).map(({ label, value }) => ({
|
||||
value,
|
||||
label: value === label ? '' : label,
|
||||
}));
|
||||
|
||||
const formatOption = (option: VariableValueOption) => {
|
||||
if (!option.label || option.label === option.value) {
|
||||
return escapeEntities(option.value);
|
||||
}
|
||||
return `${escapeEntities(option.label)} : ${escapeEntities(String(option.value))}`;
|
||||
};
|
||||
|
||||
const escapeEntities = (text: VariableValueSingle) => String(text).trim().replaceAll(',', '\\,');
|
||||
|
||||
const transformOptionsToQuery = (options: VariableValueOption[]) => options.map(formatOption).join(', ');
|
||||
+15
-1
@@ -1,11 +1,19 @@
|
||||
import { useCallback, useId, useMemo, useRef } from 'react';
|
||||
|
||||
import { t } from '@grafana/i18n';
|
||||
import { config } from '@grafana/runtime';
|
||||
import { MultiValueVariable, SceneVariableValueChangedEvent } from '@grafana/scenes';
|
||||
import { Input, Switch } from '@grafana/ui';
|
||||
import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor';
|
||||
import { OptionsPaneItemDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneItemDescriptor';
|
||||
|
||||
function useVariableHasMultiProps(variable: MultiValueVariable) {
|
||||
const state = variable.useState();
|
||||
const hasMultiProps =
|
||||
config.featureToggles.multiPropsVariables && 'valuesFormat' in state && state.valuesFormat === 'json';
|
||||
return hasMultiProps;
|
||||
}
|
||||
|
||||
export function useVariableSelectionOptionsCategory(variable: MultiValueVariable): OptionsPaneCategoryDescriptor {
|
||||
const multiValueId = useId();
|
||||
const includeAllId = useId();
|
||||
@@ -45,7 +53,9 @@ export function useVariableSelectionOptionsCategory(variable: MultiValueVariable
|
||||
'A wildcard regex or other value to represent All'
|
||||
),
|
||||
useShowIf: () => {
|
||||
return variable.useState().includeAll ?? false;
|
||||
const state = variable.useState();
|
||||
const hasMultiProps = useVariableHasMultiProps(variable);
|
||||
return hasMultiProps ? false : (state.includeAll ?? false);
|
||||
},
|
||||
render: (descriptor) => <CustomAllValueInput id={descriptor.props.id} variable={variable} />,
|
||||
})
|
||||
@@ -58,6 +68,10 @@ export function useVariableSelectionOptionsCategory(variable: MultiValueVariable
|
||||
'dashboard.edit-pane.variable.selection-options.allow-custom-values-description',
|
||||
'Enables users to enter values'
|
||||
),
|
||||
useShowIf: () => {
|
||||
const hasMultiProps = useVariableHasMultiProps(variable);
|
||||
return !hasMultiProps;
|
||||
},
|
||||
render: (descriptor) => <AllowCustomSwitch id={descriptor.props.id} variable={variable} />,
|
||||
})
|
||||
);
|
||||
|
||||
@@ -43,6 +43,7 @@ export function getLocalVariableValueSet(
|
||||
name: variable.state.name,
|
||||
value,
|
||||
text,
|
||||
properties: variable.state.options.find((o) => o.value === value)?.properties,
|
||||
isMulti: variable.state.isMulti,
|
||||
includeAll: variable.state.includeAll,
|
||||
}),
|
||||
|
||||
@@ -77,7 +77,7 @@ describe('dashboard tracking', () => {
|
||||
isScene: true,
|
||||
tabCount: 4,
|
||||
rowCount: 2,
|
||||
templateVariableCount: 4,
|
||||
templateVariableCount: 6,
|
||||
maxNestingLevel: 3,
|
||||
panel_type_timeseries_count: 6,
|
||||
panels_count: 6,
|
||||
@@ -92,7 +92,9 @@ describe('dashboard tracking', () => {
|
||||
theme: undefined,
|
||||
title: 'Cloudwatch ec2 new layout',
|
||||
uid: 'dashboard-test',
|
||||
variable_type_custom_count: 1,
|
||||
variable_type_custom_count: 3,
|
||||
variable_type_custom_csv_count: 2,
|
||||
variable_type_custom_json_count: 1,
|
||||
variable_type_query_count: 1,
|
||||
variable_type_datasource_count: 1,
|
||||
variable_type_adhoc_count: 1,
|
||||
|
||||
@@ -184,6 +184,7 @@ export function createSceneVariableFromVariableModel(variable: TypedVariableMode
|
||||
skipUrlSync: variable.skipUrlSync,
|
||||
hide: variable.hide,
|
||||
allowCustomValue: variable.allowCustomValue,
|
||||
valuesFormat: variable.valuesFormat ?? 'csv',
|
||||
});
|
||||
// Query variable
|
||||
} else if (variable.type === 'query') {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { VariableModel } from '@grafana/schema/dist/esm/index';
|
||||
import {
|
||||
AdhocVariableKind,
|
||||
CustomVariableKind,
|
||||
DatasourceVariableKind,
|
||||
QueryVariableKind,
|
||||
VariableKind,
|
||||
@@ -49,6 +50,11 @@ export function getV1SchemaVariables(variableList: VariableModel[]) {
|
||||
return {
|
||||
// Count variable types
|
||||
...variableList.reduce<Record<string, number>>((variables, current) => {
|
||||
// track how many custom CSV and custom JSON variables are used in a dashboard
|
||||
if (current.type === 'custom') {
|
||||
const name = variableName(`custom_${current.valuesFormat || 'csv'}`);
|
||||
variables[name] = 1 + (variables[name] || 0);
|
||||
}
|
||||
variables[variableName(current.type)] = 1 + (variables[variableName(current.type)] || 0);
|
||||
return variables;
|
||||
}, {}),
|
||||
@@ -94,6 +100,11 @@ export function getV2SchemaVariables(variableList: VariableKind[]) {
|
||||
...variableList.reduce<Record<string, number>>((variables, current) => {
|
||||
const type = mapNewToOldTypes(current.kind);
|
||||
if (type) {
|
||||
// track how many custom CSV and custom JSON variables are used in a dashboard
|
||||
if (isCustomVariable(current)) {
|
||||
const name = variableName(`custom_${current.spec.valuesFormat || 'csv'}`);
|
||||
variables[name] = 1 + (variables[name] || 0);
|
||||
}
|
||||
variables[variableName(type)] = 1 + (variables[variableName(type)] || 0);
|
||||
}
|
||||
return variables;
|
||||
@@ -120,3 +131,7 @@ const isQueryVar: (v: VariableKind) => v is QueryVariableKind = (v) => v.kind ==
|
||||
|
||||
const getDatasourceFromVar = (v: VariableKind) =>
|
||||
isAdhocVar(v) ? v.group : isDatasourceVar(v) ? v.spec.pluginId : isQueryVar(v) ? v.spec?.query.group : '';
|
||||
|
||||
export function isCustomVariable(variable: VariableKind): variable is CustomVariableKind {
|
||||
return variable.kind === 'CustomVariable';
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
locationUtil,
|
||||
ScopedVars,
|
||||
textUtil,
|
||||
TypedVariableModel,
|
||||
urlUtil,
|
||||
VariableOrigin,
|
||||
VariableSuggestion,
|
||||
@@ -79,14 +80,48 @@ const buildLabelPath = (label: string) => {
|
||||
return label.includes('.') || label.trim().includes(' ') ? `["${label}"]` : `.${label}`;
|
||||
};
|
||||
|
||||
const getVariableValueProperties = (variable: TypedVariableModel): string[] => {
|
||||
if (!('valuesFormat' in variable) || variable.valuesFormat !== 'json') {
|
||||
return [];
|
||||
}
|
||||
|
||||
function collectFieldPaths(option: Record<string, string>, 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);
|
||||
}
|
||||
}
|
||||
return paths;
|
||||
}
|
||||
|
||||
try {
|
||||
return collectFieldPaths(JSON.parse(variable.query)[0], variable.name);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
export const getPanelLinksVariableSuggestions = (): VariableSuggestion[] => [
|
||||
...getTemplateSrv()
|
||||
.getVariables()
|
||||
.map((variable) => ({
|
||||
value: variable.name,
|
||||
label: variable.name,
|
||||
origin: VariableOrigin.Template,
|
||||
})),
|
||||
.flatMap((variable) => [
|
||||
{
|
||||
value: variable.name,
|
||||
label: variable.name,
|
||||
origin: VariableOrigin.Template,
|
||||
},
|
||||
...getVariableValueProperties(variable).map((fieldPath) => ({
|
||||
value: fieldPath,
|
||||
label: fieldPath,
|
||||
origin: VariableOrigin.Template,
|
||||
})),
|
||||
]),
|
||||
{
|
||||
value: `${DataLinkBuiltInVars.includeVars}`,
|
||||
label: t('panel.get-panel-links-variable-suggestions.label.all-variables', 'All variables'),
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import { FieldType, GrafanaConfig, locationUtil, toDataFrame, VariableOrigin } from '@grafana/data';
|
||||
import {
|
||||
DataLinkBuiltInVars,
|
||||
FieldType,
|
||||
GrafanaConfig,
|
||||
locationUtil,
|
||||
toDataFrame,
|
||||
VariableOrigin,
|
||||
} from '@grafana/data';
|
||||
import { setTemplateSrv } from '@grafana/runtime';
|
||||
import { DashboardLink } from '@grafana/schema';
|
||||
import { ContextSrv } from 'app/core/services/context_srv';
|
||||
@@ -10,7 +17,7 @@ import { createQueryVariableAdapter } from 'app/features/variables/query/adapter
|
||||
|
||||
import { initTemplateSrv } from '../../../../../test/helpers/initTemplateSrv';
|
||||
import { updateConfig } from '../../../../core/config';
|
||||
import { getDataFrameVars, LinkSrv } from '../link_srv';
|
||||
import { getDataFrameVars, getPanelLinksVariableSuggestions, LinkSrv } from '../link_srv';
|
||||
|
||||
jest.mock('app/core/services/context_srv', () => ({
|
||||
appEvents: {
|
||||
@@ -261,237 +268,295 @@ describe('linkSrv', () => {
|
||||
expect(linkSrv.getLinkUrl(anchorInfo)).toBe(anchorInfo.url);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getDataFrameVars', () => {
|
||||
describe('when called with a DataFrame that contains fields without nested path', () => {
|
||||
it('then it should return correct suggestions', () => {
|
||||
const frame = toDataFrame({
|
||||
name: 'indoor',
|
||||
fields: [
|
||||
{ name: 'time', type: FieldType.time, values: [1, 2, 3] },
|
||||
{ name: 'temperature', type: FieldType.number, values: [10, 11, 12] },
|
||||
],
|
||||
describe('getDataFrameVars', () => {
|
||||
describe('when called with a DataFrame that contains fields without nested path', () => {
|
||||
it('then it should return correct suggestions', () => {
|
||||
const frame = toDataFrame({
|
||||
name: 'indoor',
|
||||
fields: [
|
||||
{ name: 'time', type: FieldType.time, values: [1, 2, 3] },
|
||||
{ name: 'temperature', type: FieldType.number, values: [10, 11, 12] },
|
||||
],
|
||||
});
|
||||
|
||||
const suggestions = getDataFrameVars([frame]);
|
||||
|
||||
expect(suggestions).toEqual([
|
||||
{
|
||||
value: '__data.fields.time',
|
||||
label: 'time',
|
||||
documentation: `Formatted value for time on the same row`,
|
||||
origin: VariableOrigin.Fields,
|
||||
},
|
||||
{
|
||||
value: '__data.fields.temperature',
|
||||
label: 'temperature',
|
||||
documentation: `Formatted value for temperature on the same row`,
|
||||
origin: VariableOrigin.Fields,
|
||||
},
|
||||
{
|
||||
value: `__data.fields[0]`,
|
||||
label: `Select by index`,
|
||||
documentation: `Enter the field order`,
|
||||
origin: VariableOrigin.Fields,
|
||||
},
|
||||
{
|
||||
value: `__data.fields.temperature.numeric`,
|
||||
label: `Show numeric value`,
|
||||
documentation: `the numeric field value`,
|
||||
origin: VariableOrigin.Fields,
|
||||
},
|
||||
{
|
||||
value: `__data.fields.temperature.text`,
|
||||
label: `Show text value`,
|
||||
documentation: `the text value`,
|
||||
origin: VariableOrigin.Fields,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
const suggestions = getDataFrameVars([frame]);
|
||||
describe('when called with a DataFrame that contains fields with nested path', () => {
|
||||
it('then it should return correct suggestions', () => {
|
||||
const frame = toDataFrame({
|
||||
name: 'temperatures',
|
||||
fields: [
|
||||
{ name: 'time', type: FieldType.time, values: [1, 2, 3] },
|
||||
{ name: 'temperature.indoor', type: FieldType.number, values: [10, 11, 12] },
|
||||
],
|
||||
});
|
||||
|
||||
expect(suggestions).toEqual([
|
||||
{
|
||||
value: '__data.fields.time',
|
||||
label: 'time',
|
||||
documentation: `Formatted value for time on the same row`,
|
||||
origin: VariableOrigin.Fields,
|
||||
},
|
||||
{
|
||||
value: '__data.fields.temperature',
|
||||
label: 'temperature',
|
||||
documentation: `Formatted value for temperature on the same row`,
|
||||
origin: VariableOrigin.Fields,
|
||||
},
|
||||
{
|
||||
value: `__data.fields[0]`,
|
||||
label: `Select by index`,
|
||||
documentation: `Enter the field order`,
|
||||
origin: VariableOrigin.Fields,
|
||||
},
|
||||
{
|
||||
value: `__data.fields.temperature.numeric`,
|
||||
label: `Show numeric value`,
|
||||
documentation: `the numeric field value`,
|
||||
origin: VariableOrigin.Fields,
|
||||
},
|
||||
{
|
||||
value: `__data.fields.temperature.text`,
|
||||
label: `Show text value`,
|
||||
documentation: `the text value`,
|
||||
origin: VariableOrigin.Fields,
|
||||
},
|
||||
]);
|
||||
const suggestions = getDataFrameVars([frame]);
|
||||
|
||||
expect(suggestions).toEqual([
|
||||
{
|
||||
value: '__data.fields.time',
|
||||
label: 'time',
|
||||
documentation: `Formatted value for time on the same row`,
|
||||
origin: VariableOrigin.Fields,
|
||||
},
|
||||
{
|
||||
value: '__data.fields["temperature.indoor"]',
|
||||
label: 'temperature.indoor',
|
||||
documentation: `Formatted value for temperature.indoor on the same row`,
|
||||
origin: VariableOrigin.Fields,
|
||||
},
|
||||
{
|
||||
value: `__data.fields[0]`,
|
||||
label: `Select by index`,
|
||||
documentation: `Enter the field order`,
|
||||
origin: VariableOrigin.Fields,
|
||||
},
|
||||
{
|
||||
value: `__data.fields["temperature.indoor"].numeric`,
|
||||
label: `Show numeric value`,
|
||||
documentation: `the numeric field value`,
|
||||
origin: VariableOrigin.Fields,
|
||||
},
|
||||
{
|
||||
value: `__data.fields["temperature.indoor"].text`,
|
||||
label: `Show text value`,
|
||||
documentation: `the text value`,
|
||||
origin: VariableOrigin.Fields,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when called with a DataFrame that contains fields with displayName', () => {
|
||||
it('then it should return correct suggestions', () => {
|
||||
const frame = toDataFrame({
|
||||
name: 'temperatures',
|
||||
fields: [
|
||||
{ name: 'time', type: FieldType.time, values: [1, 2, 3] },
|
||||
{ name: 'temperature.indoor', type: FieldType.number, values: [10, 11, 12] },
|
||||
],
|
||||
});
|
||||
|
||||
frame.fields[1].config = { ...frame.fields[1].config, displayName: 'Indoor Temperature' };
|
||||
|
||||
const suggestions = getDataFrameVars([frame]);
|
||||
|
||||
expect(suggestions).toEqual([
|
||||
{
|
||||
value: '__data.fields.time',
|
||||
label: 'time',
|
||||
documentation: `Formatted value for time on the same row`,
|
||||
origin: VariableOrigin.Fields,
|
||||
},
|
||||
{
|
||||
value: '__data.fields["Indoor Temperature"]',
|
||||
label: 'Indoor Temperature',
|
||||
documentation: `Formatted value for Indoor Temperature on the same row`,
|
||||
origin: VariableOrigin.Fields,
|
||||
},
|
||||
{
|
||||
value: `__data.fields[0]`,
|
||||
label: `Select by index`,
|
||||
documentation: `Enter the field order`,
|
||||
origin: VariableOrigin.Fields,
|
||||
},
|
||||
{
|
||||
value: `__data.fields["Indoor Temperature"].numeric`,
|
||||
label: `Show numeric value`,
|
||||
documentation: `the numeric field value`,
|
||||
origin: VariableOrigin.Fields,
|
||||
},
|
||||
{
|
||||
value: `__data.fields["Indoor Temperature"].text`,
|
||||
label: `Show text value`,
|
||||
documentation: `the text value`,
|
||||
origin: VariableOrigin.Fields,
|
||||
},
|
||||
{
|
||||
value: `__data.fields["Indoor Temperature"]`,
|
||||
label: `Select by title`,
|
||||
documentation: `Use the title to pick the field`,
|
||||
origin: VariableOrigin.Fields,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when called with a DataFrame that contains fields with duplicate names', () => {
|
||||
it('then it should ignore duplicates', () => {
|
||||
const frame = toDataFrame({
|
||||
name: 'temperatures',
|
||||
fields: [
|
||||
{ name: 'time', type: FieldType.time, values: [1, 2, 3] },
|
||||
{ name: 'temperature.indoor', type: FieldType.number, values: [10, 11, 12] },
|
||||
{ name: 'temperature.outdoor', type: FieldType.number, values: [20, 21, 22] },
|
||||
],
|
||||
});
|
||||
|
||||
frame.fields[1].config = { ...frame.fields[1].config, displayName: 'Indoor Temperature' };
|
||||
// Someone makes a mistake when renaming a field
|
||||
frame.fields[2].config = { ...frame.fields[2].config, displayName: 'Indoor Temperature' };
|
||||
|
||||
const suggestions = getDataFrameVars([frame]);
|
||||
|
||||
expect(suggestions).toEqual([
|
||||
{
|
||||
value: '__data.fields.time',
|
||||
label: 'time',
|
||||
documentation: `Formatted value for time on the same row`,
|
||||
origin: VariableOrigin.Fields,
|
||||
},
|
||||
{
|
||||
value: '__data.fields["Indoor Temperature"]',
|
||||
label: 'Indoor Temperature',
|
||||
documentation: `Formatted value for Indoor Temperature on the same row`,
|
||||
origin: VariableOrigin.Fields,
|
||||
},
|
||||
{
|
||||
value: `__data.fields[0]`,
|
||||
label: `Select by index`,
|
||||
documentation: `Enter the field order`,
|
||||
origin: VariableOrigin.Fields,
|
||||
},
|
||||
{
|
||||
value: `__data.fields["Indoor Temperature"].numeric`,
|
||||
label: `Show numeric value`,
|
||||
documentation: `the numeric field value`,
|
||||
origin: VariableOrigin.Fields,
|
||||
},
|
||||
{
|
||||
value: `__data.fields["Indoor Temperature"].text`,
|
||||
label: `Show text value`,
|
||||
documentation: `the text value`,
|
||||
origin: VariableOrigin.Fields,
|
||||
},
|
||||
{
|
||||
value: `__data.fields["Indoor Temperature"]`,
|
||||
label: `Select by title`,
|
||||
documentation: `Use the title to pick the field`,
|
||||
origin: VariableOrigin.Fields,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when called with multiple DataFrames', () => {
|
||||
it('it should not return any suggestions', () => {
|
||||
const frame1 = toDataFrame({
|
||||
name: 'server1',
|
||||
fields: [
|
||||
{ name: 'time', type: FieldType.time, values: [1, 2, 3] },
|
||||
{ name: 'value', type: FieldType.number, values: [10, 11, 12] },
|
||||
],
|
||||
});
|
||||
|
||||
const frame2 = toDataFrame({
|
||||
name: 'server2',
|
||||
fields: [
|
||||
{ name: 'time', type: FieldType.time, values: [1, 2, 3] },
|
||||
{ name: 'value', type: FieldType.number, values: [10, 11, 12] },
|
||||
],
|
||||
});
|
||||
|
||||
const suggestions = getDataFrameVars([frame1, frame2]);
|
||||
|
||||
expect(suggestions).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when called with a DataFrame that contains fields with nested path', () => {
|
||||
it('then it should return correct suggestions', () => {
|
||||
const frame = toDataFrame({
|
||||
name: 'temperatures',
|
||||
fields: [
|
||||
{ name: 'time', type: FieldType.time, values: [1, 2, 3] },
|
||||
{ name: 'temperature.indoor', type: FieldType.number, values: [10, 11, 12] },
|
||||
],
|
||||
});
|
||||
describe('getPanelLinksVariableSuggestions', () => {
|
||||
it('then it should return template variables, json 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"}]',
|
||||
},
|
||||
]);
|
||||
setTemplateSrv(templateSrvWithJsonValues);
|
||||
|
||||
const suggestions = getDataFrameVars([frame]);
|
||||
const suggestions = getPanelLinksVariableSuggestions();
|
||||
|
||||
expect(suggestions).toEqual([
|
||||
{
|
||||
value: '__data.fields.time',
|
||||
label: 'time',
|
||||
documentation: `Formatted value for time on the same row`,
|
||||
origin: VariableOrigin.Fields,
|
||||
value: 'customServers',
|
||||
label: 'customServers',
|
||||
origin: VariableOrigin.Template,
|
||||
},
|
||||
{
|
||||
value: '__data.fields["temperature.indoor"]',
|
||||
label: 'temperature.indoor',
|
||||
documentation: `Formatted value for temperature.indoor on the same row`,
|
||||
origin: VariableOrigin.Fields,
|
||||
value: 'customServers.name',
|
||||
label: 'customServers.name',
|
||||
origin: VariableOrigin.Template,
|
||||
},
|
||||
{
|
||||
value: `__data.fields[0]`,
|
||||
label: `Select by index`,
|
||||
documentation: `Enter the field order`,
|
||||
origin: VariableOrigin.Fields,
|
||||
value: 'customServers.ip',
|
||||
label: 'customServers.ip',
|
||||
origin: VariableOrigin.Template,
|
||||
},
|
||||
{
|
||||
value: `__data.fields["temperature.indoor"].numeric`,
|
||||
label: `Show numeric value`,
|
||||
documentation: `the numeric field value`,
|
||||
origin: VariableOrigin.Fields,
|
||||
value: `${DataLinkBuiltInVars.includeVars}`,
|
||||
label: 'All variables',
|
||||
documentation: 'Adds current variables',
|
||||
origin: VariableOrigin.Template,
|
||||
},
|
||||
{
|
||||
value: `__data.fields["temperature.indoor"].text`,
|
||||
label: `Show text value`,
|
||||
documentation: `the text value`,
|
||||
origin: VariableOrigin.Fields,
|
||||
value: `${DataLinkBuiltInVars.keepTime}`,
|
||||
label: 'Time range',
|
||||
documentation: 'Adds current time range',
|
||||
origin: VariableOrigin.BuiltIn,
|
||||
},
|
||||
{
|
||||
value: `${DataLinkBuiltInVars.timeRangeFrom}`,
|
||||
label: 'Time range: from',
|
||||
documentation: "Adds current time range's from value",
|
||||
origin: VariableOrigin.BuiltIn,
|
||||
},
|
||||
{
|
||||
value: `${DataLinkBuiltInVars.timeRangeTo}`,
|
||||
label: 'Time range: to',
|
||||
documentation: "Adds current time range's to value",
|
||||
origin: VariableOrigin.BuiltIn,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when called with a DataFrame that contains fields with displayName', () => {
|
||||
it('then it should return correct suggestions', () => {
|
||||
const frame = toDataFrame({
|
||||
name: 'temperatures',
|
||||
fields: [
|
||||
{ name: 'time', type: FieldType.time, values: [1, 2, 3] },
|
||||
{ name: 'temperature.indoor', type: FieldType.number, values: [10, 11, 12] },
|
||||
],
|
||||
});
|
||||
|
||||
frame.fields[1].config = { ...frame.fields[1].config, displayName: 'Indoor Temperature' };
|
||||
|
||||
const suggestions = getDataFrameVars([frame]);
|
||||
|
||||
expect(suggestions).toEqual([
|
||||
{
|
||||
value: '__data.fields.time',
|
||||
label: 'time',
|
||||
documentation: `Formatted value for time on the same row`,
|
||||
origin: VariableOrigin.Fields,
|
||||
},
|
||||
{
|
||||
value: '__data.fields["Indoor Temperature"]',
|
||||
label: 'Indoor Temperature',
|
||||
documentation: `Formatted value for Indoor Temperature on the same row`,
|
||||
origin: VariableOrigin.Fields,
|
||||
},
|
||||
{
|
||||
value: `__data.fields[0]`,
|
||||
label: `Select by index`,
|
||||
documentation: `Enter the field order`,
|
||||
origin: VariableOrigin.Fields,
|
||||
},
|
||||
{
|
||||
value: `__data.fields["Indoor Temperature"].numeric`,
|
||||
label: `Show numeric value`,
|
||||
documentation: `the numeric field value`,
|
||||
origin: VariableOrigin.Fields,
|
||||
},
|
||||
{
|
||||
value: `__data.fields["Indoor Temperature"].text`,
|
||||
label: `Show text value`,
|
||||
documentation: `the text value`,
|
||||
origin: VariableOrigin.Fields,
|
||||
},
|
||||
{
|
||||
value: `__data.fields["Indoor Temperature"]`,
|
||||
label: `Select by title`,
|
||||
documentation: `Use the title to pick the field`,
|
||||
origin: VariableOrigin.Fields,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when called with a DataFrame that contains fields with duplicate names', () => {
|
||||
it('then it should ignore duplicates', () => {
|
||||
const frame = toDataFrame({
|
||||
name: 'temperatures',
|
||||
fields: [
|
||||
{ name: 'time', type: FieldType.time, values: [1, 2, 3] },
|
||||
{ name: 'temperature.indoor', type: FieldType.number, values: [10, 11, 12] },
|
||||
{ name: 'temperature.outdoor', type: FieldType.number, values: [20, 21, 22] },
|
||||
],
|
||||
});
|
||||
|
||||
frame.fields[1].config = { ...frame.fields[1].config, displayName: 'Indoor Temperature' };
|
||||
// Someone makes a mistake when renaming a field
|
||||
frame.fields[2].config = { ...frame.fields[2].config, displayName: 'Indoor Temperature' };
|
||||
|
||||
const suggestions = getDataFrameVars([frame]);
|
||||
|
||||
expect(suggestions).toEqual([
|
||||
{
|
||||
value: '__data.fields.time',
|
||||
label: 'time',
|
||||
documentation: `Formatted value for time on the same row`,
|
||||
origin: VariableOrigin.Fields,
|
||||
},
|
||||
{
|
||||
value: '__data.fields["Indoor Temperature"]',
|
||||
label: 'Indoor Temperature',
|
||||
documentation: `Formatted value for Indoor Temperature on the same row`,
|
||||
origin: VariableOrigin.Fields,
|
||||
},
|
||||
{
|
||||
value: `__data.fields[0]`,
|
||||
label: `Select by index`,
|
||||
documentation: `Enter the field order`,
|
||||
origin: VariableOrigin.Fields,
|
||||
},
|
||||
{
|
||||
value: `__data.fields["Indoor Temperature"].numeric`,
|
||||
label: `Show numeric value`,
|
||||
documentation: `the numeric field value`,
|
||||
origin: VariableOrigin.Fields,
|
||||
},
|
||||
{
|
||||
value: `__data.fields["Indoor Temperature"].text`,
|
||||
label: `Show text value`,
|
||||
documentation: `the text value`,
|
||||
origin: VariableOrigin.Fields,
|
||||
},
|
||||
{
|
||||
value: `__data.fields["Indoor Temperature"]`,
|
||||
label: `Select by title`,
|
||||
documentation: `Use the title to pick the field`,
|
||||
origin: VariableOrigin.Fields,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when called with multiple DataFrames', () => {
|
||||
it('it should not return any suggestions', () => {
|
||||
const frame1 = toDataFrame({
|
||||
name: 'server1',
|
||||
fields: [
|
||||
{ name: 'time', type: FieldType.time, values: [1, 2, 3] },
|
||||
{ name: 'value', type: FieldType.number, values: [10, 11, 12] },
|
||||
],
|
||||
});
|
||||
|
||||
const frame2 = toDataFrame({
|
||||
name: 'server2',
|
||||
fields: [
|
||||
{ name: 'time', type: FieldType.time, values: [1, 2, 3] },
|
||||
{ name: 'value', type: FieldType.number, values: [10, 11, 12] },
|
||||
],
|
||||
});
|
||||
|
||||
const suggestions = getDataFrameVars([frame1, frame2]);
|
||||
|
||||
expect(suggestions).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4874,7 +4874,8 @@
|
||||
"apply": "Apply",
|
||||
"change-value": "Change variable value",
|
||||
"discard": "Discard",
|
||||
"modal-title": "Custom Variable"
|
||||
"modal-title": "Custom Variable",
|
||||
"values": "Values separated by comma"
|
||||
},
|
||||
"datasource-options": {
|
||||
"name-filter": "Name filter",
|
||||
@@ -5968,6 +5969,9 @@
|
||||
},
|
||||
"custom-variable-form": {
|
||||
"custom-options": "Custom options",
|
||||
"json-values-tooltip": "Provide a JSON representing an array of objects, where each object can have any number of properties.<br />Check <4>our docs</4> for more information.",
|
||||
"name-csv-values": "CSV",
|
||||
"name-json-values": "JSON",
|
||||
"name-values-separated-comma": "Values separated by comma",
|
||||
"selection-options": "Selection options"
|
||||
},
|
||||
@@ -6555,6 +6559,11 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"use-modal-editor": {
|
||||
"description": {
|
||||
"change-variable-query": "Change variable query"
|
||||
}
|
||||
},
|
||||
"use-save-dashboard": {
|
||||
"message-dashboard-saved": "Dashboard saved"
|
||||
},
|
||||
@@ -6638,7 +6647,8 @@
|
||||
"tooltip-show-usages": "Show usages"
|
||||
},
|
||||
"variable-values-preview": {
|
||||
"preview-of-values": "Preview of values",
|
||||
"preview-of-values_one": "Preview of values ({{count}})",
|
||||
"preview-of-values_other": "Preview of values ({{count}})",
|
||||
"show-more": "Show more"
|
||||
},
|
||||
"version-history": {
|
||||
|
||||
@@ -3604,11 +3604,11 @@ __metadata:
|
||||
languageName: unknown
|
||||
linkType: soft
|
||||
|
||||
"@grafana/scenes-react@npm:6.52.0":
|
||||
version: 6.52.0
|
||||
resolution: "@grafana/scenes-react@npm:6.52.0"
|
||||
"@grafana/scenes-react@npm:v6.52.1":
|
||||
version: 6.52.1
|
||||
resolution: "@grafana/scenes-react@npm:6.52.1"
|
||||
dependencies:
|
||||
"@grafana/scenes": "npm:6.52.0"
|
||||
"@grafana/scenes": "npm:6.52.1"
|
||||
lru-cache: "npm:^10.2.2"
|
||||
react-use: "npm:^17.4.0"
|
||||
peerDependencies:
|
||||
@@ -3620,7 +3620,7 @@ __metadata:
|
||||
react: ^18.0.0
|
||||
react-dom: ^18.0.0
|
||||
react-router-dom: ^6.28.0
|
||||
checksum: 10/7f121bcc4fd50f525c7c3457666ad3a32b04783d322d6715aedb6119538f911d0ec265c9c5b49a80478c1deb99286d36d003399a8831f76b6c483f4458b4ce8b
|
||||
checksum: 10/2f7c6ca8e26befd331808afb0cb934e2991e889a4de78be1122c536219676261c59c6204510761a1d4250fd44a3767818f0f225d23b2e7243cfc17baf8ca6ca3
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -3650,9 +3650,9 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@grafana/scenes@npm:6.52.0":
|
||||
version: 6.52.0
|
||||
resolution: "@grafana/scenes@npm:6.52.0"
|
||||
"@grafana/scenes@npm:6.52.1, @grafana/scenes@npm:v6.52.1":
|
||||
version: 6.52.1
|
||||
resolution: "@grafana/scenes@npm:6.52.1"
|
||||
dependencies:
|
||||
"@floating-ui/react": "npm:^0.26.16"
|
||||
"@leeoniya/ufuzzy": "npm:^1.0.16"
|
||||
@@ -3672,7 +3672,7 @@ __metadata:
|
||||
react: ^18.0.0
|
||||
react-dom: ^18.0.0
|
||||
react-router-dom: ^6.28.0
|
||||
checksum: 10/e52e0fb83396776c6cb79f8ac6a8aad0799eb2ccce9d0139f5734a49c3add7a1e3b97f14e0142c95b2bceee3ed8fa97b675b9b94c02382ecd683f470d06ef145
|
||||
checksum: 10/d6172b51121e03c7dcbf30046772f99fc45922c1f7b360a7c3d2c0391300e378f306cb78251dda3b30895679379c38db30e4d52fee67a56cd95f18f38aadf3fb
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -19496,8 +19496,8 @@ __metadata:
|
||||
"@grafana/plugin-ui": "npm:^0.11.1"
|
||||
"@grafana/prometheus": "workspace:*"
|
||||
"@grafana/runtime": "workspace:*"
|
||||
"@grafana/scenes": "npm:6.52.0"
|
||||
"@grafana/scenes-react": "npm:6.52.0"
|
||||
"@grafana/scenes": "npm:v6.52.1"
|
||||
"@grafana/scenes-react": "npm:v6.52.1"
|
||||
"@grafana/schema": "workspace:*"
|
||||
"@grafana/sql": "workspace:*"
|
||||
"@grafana/test-utils": "workspace:*"
|
||||
|
||||
Reference in New Issue
Block a user