Skip to content
Open
Show file tree
Hide file tree
Changes from 21 commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
2383d0b
feat: Add support in CustomVariable and QueryVariable
grafakus Sep 4, 2025
51bfbdf
chore(QueryVariable): Remove unused class property
grafakus Sep 4, 2025
e49743e
fix: Add missing properties when building options
grafakus Sep 4, 2025
da04a3b
refactor(MultiValueVariable): Add support valueProp and textProp
grafakus Sep 4, 2025
c32c895
Update packages/scenes/src/variables/types.ts
grafakus Sep 5, 2025
4d065f1
chore: Fix type
grafakus Sep 5, 2025
7a39e48
fix(MultiValueVariable): Fix incorrect valueProperties state update
grafakus Sep 12, 2025
b83b465
refactor: Introduce CustomOptionsProviders
grafakus Sep 12, 2025
c52ad61
chore: Remove comments
grafakus Sep 12, 2025
339cc7c
refactor(CustomVariable): Remove isJson config option
grafakus Sep 12, 2025
fcf42f3
refactor: Introduce options provider builder
grafakus Sep 12, 2025
86ac6ab
feat: Enable custom options provider registration
grafakus Sep 12, 2025
5a2b438
refactor: Early returns
grafakus Sep 12, 2025
901a7ff
refactor
grafakus Sep 19, 2025
5355a08
feat: Add demo
grafakus Sep 19, 2025
29c0bdf
fix
grafakus Sep 19, 2025
82a780a
feat: Better options provider + add QueryOptionsProvider
grafakus Sep 19, 2025
c6c76f4
refactor
grafakus Sep 19, 2025
20f655b
feat(QueryVariable): Support properties received in data frames
grafakus Sep 19, 2025
0fe0503
chore: Better naming
grafakus Sep 19, 2025
932bd3c
fix: Fix typecheck issue
grafakus Sep 19, 2025
ce11620
fix: Remove unnecessary code
grafakus Sep 23, 2025
0a39fea
fix: Remove unnecessary logic
grafakus Sep 23, 2025
f27e56a
chore: Simplify code
grafakus Sep 23, 2025
34e9ca0
test(MultiVarlueVariable): Simplify unit test
grafakus Sep 30, 2025
4b3b50f
chore: Remove unnecessary code
grafakus Sep 30, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions packages/scenes-app/src/demos/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import { getMlDemo } from './ml';
import { getSceneGraphEventsDemo } from './sceneGraphEvents';
import { getSeriesLimitTest } from './seriesLimit';
import { getScopesDemo } from './scopesDemo';
import { getVariableWithObjectValuesDemo } from './variableWithObjectValuesDemo';

export interface DemoDescriptor {
title: string;
Expand Down Expand Up @@ -305,5 +306,11 @@ export function getDemos(): DemoDescriptor[] {
getPage: getScopesDemo,
getSourceCodeModule: () => import('!!raw-loader!../demos/scopesDemo'),
},
{
title: 'Variables with object values',
description: '',
getPage: getVariableWithObjectValuesDemo,
getSourceCodeModule: () => import('!!raw-loader!../demos/variableWithObjectValuesDemo.tsx'),
},
].sort((a, b) => a.title.localeCompare(b.title));
}
51 changes: 51 additions & 0 deletions packages/scenes-app/src/demos/variableWithObjectValuesDemo.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import {
CustomVariable,
EmbeddedScene,
PanelBuilders,
SceneAppPage,
SceneAppPageState,
SceneFlexItem,
SceneFlexLayout,
SceneVariableSet,
VariableValueSelectors,
} from '@grafana/scenes';
import { getEmbeddedSceneDefaults } from './utils';

export function getVariableWithObjectValuesDemo(defaults: SceneAppPageState) {
return new SceneAppPage({
...defaults,
getScene: () => {
return new EmbeddedScene({
...getEmbeddedSceneDefaults(),
controls: [new VariableValueSelectors({})],
$variables: new SceneVariableSet({
variables: [
new CustomVariable({
name: 'envs',
label: 'Environments',
isMulti: true,
optionsProvider: { type: 'json', valueProp: 'id', textProp: 'name' },
query: `
[
{ "id": 1, "name": "Development", "aws_environment": "development", "azure_environment": "dev" },
{ "id": 2, "name": "Staging", "aws_environment": "staging", "azure_environment": "stg" },
{ "id": 3, "name": "Production", "aws_environment": "prod", "azure_environment": "prd" }
]
`,
}),
],
}),
body: new SceneFlexLayout({
children: [
new SceneFlexItem({
body: PanelBuilders.text().setTitle('AWS').setOption('content', '${envs.aws_environment}').build(),
}),
new SceneFlexItem({
body: PanelBuilders.text().setTitle('Azure').setOption('content', '${envs.azure_environment}').build(),
}),
],
}),
});
},
});
}
2 changes: 1 addition & 1 deletion packages/scenes/src/variables/groupby/GroupByVariable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -257,7 +257,7 @@ export class GroupByVariable extends MultiValueVariable<GroupByVariableState> {
/**
* Allows clearing the value of the variable to an empty value. Overrides default behavior of a MultiValueVariable
*/
public getDefaultMultiState(options: VariableValueOption[]): { value: VariableValueSingle[]; text: string[] } {
public getDefaultMultiState(options: VariableValueOption[]) {
return { value: [], text: [] };
}
}
Expand Down
2 changes: 2 additions & 0 deletions packages/scenes/src/variables/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,10 +72,12 @@ export interface CustomVariableValue {
}

export interface ValidateAndUpdateResult {}
export interface VariableValueOptionProperties extends Record<string, any> {}
export interface VariableValueOption {
label: string;
value: VariableValueSingle;
group?: string;
properties?: VariableValueOptionProperties;
}

export interface SceneVariableSetState extends SceneObjectState {
Expand Down
261 changes: 261 additions & 0 deletions packages/scenes/src/variables/variants/CustomOptionsProviders.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,261 @@
import { v4 as uuidv4 } from 'uuid';
import { catchError, filter, from, mergeMap, Observable, of, take, throwError } from 'rxjs';
import { sceneGraph } from '../../core/sceneGraph';
import { VariableValueOption } from '../types';
import { CustomVariable } from './CustomVariable';
import { QueryVariable } from './query/QueryVariable';
import { CoreApp, DataQueryRequest, LoadingState, PanelData, ScopedVars } from '@grafana/data';
import { DataQuery } from '@grafana/schema';
import { registerQueryWithController } from '../../querying/registerQueryWithController';
import { getDataSource } from '../../utils/getDataSource';
import { wrapInSafeSerializableSceneObject } from '../../utils/wrapInSafeSerializableSceneObject';
import { createQueryVariableRunner } from './query/createQueryVariableRunner';
import { toMetricFindValues } from './query/toMetricFindValues';
import { metricNamesToVariableValues, sortVariableValues } from './query/utils';
import { MultiValueVariable, VariableGetOptionsArgs } from './MultiValueVariable';

type BuilderFunction = (variable: MultiValueVariable) => CustomOptionsProvider;

const OPTIONS_PROVIDERS_REGISTRY = new Map<string, BuilderFunction>([
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we anticipate more formats introduction? I don't see a justification for having this complexity at this stage. Eventually when we decide to unify custom/query variable under List variable, this will become unnecessary.

[
'csv',
(variable: MultiValueVariable) => {
if (!(variable instanceof CustomVariable)) {
throw new TypeError('Variable is not a CustomVariable');
}
return new CsvOptionsProvider({
csv: sceneGraph.interpolate(variable, variable.state.query),
});
},
],
[
'json',
(variable: MultiValueVariable) => {
if (!(variable instanceof CustomVariable)) {
throw new TypeError('Variable is not a CustomVariable');
}
return new JsonOptionsProvider({
json: sceneGraph.interpolate(variable, variable.state.query),
valueProp: variable.state.optionsProvider.valueProp,
textProp: variable.state.optionsProvider.textProp,
});
},
],
[
'query',
(variable: MultiValueVariable) => {
if (!(variable instanceof QueryVariable)) {
throw new TypeError('Variable is not a QueryVariable');
}
return new QueryOptionsProvider({
variable: variable, // TEMP, in the future, can we pass only minimal info?
});
},
],
]);

export function buildOptionsProvider(variable: MultiValueVariable) {
const { optionsProvider } = variable.state;
if (!optionsProvider) {
throw new Error('Variable is missing optionsProvider');
}
if (OPTIONS_PROVIDERS_REGISTRY.has(optionsProvider.type)) {
return OPTIONS_PROVIDERS_REGISTRY.get(optionsProvider.type)!(variable);
}
throw new Error(`Unknown options provider "${optionsProvider.type}"`);
}

export function registerOptionsProvider(type: string, builderFn: BuilderFunction) {
if (OPTIONS_PROVIDERS_REGISTRY.has(type)) {
throw new Error(`Options provider "${type}" already registered`);
}
OPTIONS_PROVIDERS_REGISTRY.set(type, builderFn);
}

export type OptionsProviderSettings = {
type: string;
/**
* For object values, these settings control which properties will be used to get the text and the value of the current option
*/
valueProp?: string;
textProp?: string;
};

export interface CustomOptionsProvider {
getOptions(args?: VariableGetOptionsArgs): Observable<VariableValueOption[]>;
}

/* CSV */

interface CsvProviderParams {
csv: string;
}

export class CsvOptionsProvider implements CustomOptionsProvider {
public constructor(private params: CsvProviderParams) {}

public getOptions(): Observable<VariableValueOption[]> {
return new Observable((subscriber) => {
try {
const match = this.params.csv.match(/(?:\\,|[^,])+/g) ?? [];

const options = match.map((text) => {
text = text.replace(/\\,/g, ',');
const textMatch = /^\s*(.+)\s:\s(.+)$/g.exec(text) ?? [];
if (textMatch.length === 3) {
const [, key, value] = textMatch;
return { label: key.trim(), value: value.trim() };
} else {
return { label: text.trim(), value: text.trim() };
}
});

subscriber.next(options);
subscriber.complete();
} catch (error) {
subscriber.error(error);
}
});
}
}

/* JSON */

interface JsonProviderParams {
json: string;
valueProp?: string;
textProp?: string;
}

export class JsonOptionsProvider implements CustomOptionsProvider {
public constructor(private params: JsonProviderParams) {}

private parseAndValidateJson(json: string): VariableValueOption[] {
const parsedOptions = JSON.parse(json);

if (!Array.isArray(parsedOptions)) {
throw new Error('Query must be a JSON array');
}

if (typeof parsedOptions[0] === 'string') {
return parsedOptions.map((value) => ({ label: value.trim(), value: value.trim() }));
}

if (typeof parsedOptions[0] !== 'object' || parsedOptions[0] === null) {
throw new Error('Query must be a JSON array of strings or objects');
}

const { valueProp, textProp } = this.params;
if (!valueProp) {
throw new Error('Variable is missing valueProp');
}

return parsedOptions.map((o) => ({
value: String(o[valueProp]).trim(),
label: String(o[textProp as any] || o[valueProp])?.trim(),
properties: o,
}));
}

public getOptions(): Observable<VariableValueOption[]> {
return new Observable((subscriber) => {
try {
subscriber.next(this.parseAndValidateJson(this.params.json));
subscriber.complete();
} catch (error) {
subscriber.error(error);
}
});
}
}

/* QUERY */

interface QueryProviderParams {
variable: QueryVariable;
}

export class QueryOptionsProvider implements CustomOptionsProvider {
public constructor(private params: QueryProviderParams) {}

public getOptions(args: VariableGetOptionsArgs): Observable<VariableValueOption[]> {
const { variable } = this.params;
const { datasource, optionsProvider, regex, sort, staticOptions, staticOptionsOrder } = variable.state;

return from(getDataSource(datasource, { __sceneObject: wrapInSafeSerializableSceneObject(variable) })).pipe(
mergeMap((ds) => {
const runner = createQueryVariableRunner(ds);
const target = runner.getTarget(variable);
const request = this.getRequest(target, args.searchFilter);

return runner.runRequest({ variable, searchFilter: args.searchFilter }, request).pipe(
registerQueryWithController({
type: 'QueryVariable/getValueOptions',
request: request,
origin: variable,
}),
filter((data) => data.state === LoadingState.Done || data.state === LoadingState.Error), // we only care about done or error for now
take(1), // take the first result, using first caused a bug where it in some situations throw an uncaught error because of no results had been received yet
mergeMap((data: PanelData) => {
if (data.state === LoadingState.Error) {
return throwError(() => data.error);
}
return of(data);
}),
toMetricFindValues(optionsProvider.valueProp, optionsProvider.textProp),
mergeMap((values) => {
const interpolatedRegex = regex ? sceneGraph.interpolate(variable, regex, undefined, 'regex') : '';
let options = metricNamesToVariableValues(interpolatedRegex, sort, values);
if (staticOptions) {
const customOptions = staticOptions;
options = options.filter((option) => !customOptions.find((custom) => custom.value === option.value));
if (staticOptionsOrder === 'after') {
options.push(...customOptions);
} else if (staticOptionsOrder === 'sorted') {
options = sortVariableValues(options.concat(customOptions), sort);
} else {
options.unshift(...customOptions);
}
}
return of(options);
}),
catchError((error) => {
if (error.cancelled) {
return of([]);
}
return throwError(() => error);
})
);
})
);
}

private getRequest(target: DataQuery | string, searchFilter?: string) {
const { variable } = this.params;

const scopedVars: ScopedVars = {
__sceneObject: wrapInSafeSerializableSceneObject(variable),
};

if (searchFilter) {
scopedVars.__searchFilter = { value: searchFilter, text: searchFilter };
}

const range = sceneGraph.getTimeRange(variable).state.value;

const request: DataQueryRequest = {
app: CoreApp.Dashboard,
requestId: uuidv4(),
timezone: '',
range,
interval: '',
intervalMs: 0,
// @ts-ignore
targets: [target],
scopedVars,
startTime: Date.now(),
};

return request;
}
}
Loading