Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

JsonVariable: New type of object variable where each value user selects has many properties (POC) #995

Draft
wants to merge 2 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 2 additions & 0 deletions packages/scenes-app/src/demos/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import { getUrlSyncTest } from './urlSyncTest';
import { getMlDemo } from './ml';
import { getSceneGraphEventsDemo } from './sceneGraphEvents';
import { getSeriesLimitTest } from './seriesLimit';
import { getJsonVariableDemo } from './jsonVariableDemo';

export interface DemoDescriptor {
title: string;
Expand Down Expand Up @@ -89,5 +90,6 @@ export function getDemos(): DemoDescriptor[] {
{ title: 'Machine Learning', getPage: getMlDemo },
{ title: 'Events on the Scene Graph', getPage: getSceneGraphEventsDemo },
{ title: 'Series limit', getPage: getSeriesLimitTest },
{ title: 'Json Variable', getPage: getJsonVariableDemo },
].sort((a, b) => a.title.localeCompare(b.title));
}
74 changes: 74 additions & 0 deletions packages/scenes-app/src/demos/jsonVariableDemo.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import {
EmbeddedScene,
JsonVariable,
JsonVariableOptionProviders,
PanelBuilders,
SceneAppPage,
SceneAppPageState,
SceneCSSGridLayout,
SceneVariableSet,
} from '@grafana/scenes';
import { getEmbeddedSceneDefaults } from './utils';

export function getJsonVariableDemo(defaults: SceneAppPageState) {
return new SceneAppPage({
...defaults,
subTitle: 'Example of a JSON variable',
getScene: () => {
return new EmbeddedScene({
...getEmbeddedSceneDefaults(),
$variables: new SceneVariableSet({
variables: [
new JsonVariable({
name: 'env',
value: 'test',
provider: JsonVariableOptionProviders.fromString({
Copy link
Member

Choose a reason for hiding this comment

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

Good idea with Observables provider, technically those can be easily retrieved from an async source, sounds very powerful.

json: `[
{ "id": 1, "name": "dev", "cluster": "us-dev-1", "status": "updating" },
{ "id": 2, "name": "prod", "cluster": "us-prod-2", "status": "ok" },
{ "id": 3, "name": "staging", "cluster": "us-staging-2", "status": "down" }
]`,
}),
}),
new JsonVariable({
name: 'testRun',
label: 'Test run',
value: 'test',
provider: JsonVariableOptionProviders.fromObjectArray({
options: [
{ runId: 'CAM-01', timeTaken: '10s', startTime: 1733492238318, endTime: 1733492338318 },
{ runId: 'SSL-02', timeTaken: '2s', startTime: 1733472238318, endTime: 1733482338318 },
{ runId: 'MRA-02', timeTaken: '13s', startTime: 1733462238318, endTime: 1733472338318 },
],
valueProp: 'runId',
Copy link
Member

Choose a reason for hiding this comment

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

so this will be the property used for default interpolation, when no path provided in the variable syntax? i.e. if i just used ${testRun} the that would by default be equivalent to ${testRun.runId}?

}),
}),
],
}),
body: new SceneCSSGridLayout({
children: [
PanelBuilders.text()
.setTitle('Interpolation demos')
.setOption(
'content',
`

* env.id = \${env.id}
* env.name = \${env.name}
* env.status = \${env.status}


* testRun.runId = \${testRun.runId}
* testRun.timeTaken = \${testRun.timeTaken}
* testRun.startTime = \${testRun.startTime}
* testRun.endTime = \${testRun.endTime}
* testRun.endTime:date = \${testRun.endTime:date}
`
)
.build(),
],
}),
});
},
});
}
6 changes: 6 additions & 0 deletions packages/scenes/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,12 @@ export { DataSourceVariable } from './variables/variants/DataSourceVariable';
export { QueryVariable } from './variables/variants/query/QueryVariable';
export { TestVariable } from './variables/variants/TestVariable';
export { TextBoxVariable } from './variables/variants/TextBoxVariable';
export {
JsonVariable,
type JsonVariableOptionProvider,
type JsonVariableOption,
} from './variables/variants/json/JsonVariable';
export { JsonVariableOptionProviders } from './variables/variants/json/JsonVariableOptionProviders';
export {
MultiValueVariable,
type MultiValueVariableState,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { Observable } from 'rxjs';
import { JsonVariableOptionProvider, JsonVariableOption } from './JsonVariable';

export interface JsonStringOptionPrividerOptions {
/**
* String contauining JSON with an array of objects or a map of objects
*/
json: string;
/**
* Defaults to name if not specified
*/
valueProp?: string;
}

export class JsonStringOptionPrivider implements JsonVariableOptionProvider {
public constructor(private options: JsonStringOptionPrividerOptions) {}

public getOptions(): Observable<JsonVariableOption[]> {
return new Observable((subscriber) => {
try {
const { json, valueProp = 'name' } = this.options;
const jsonValue = JSON.parse(json);

if (!Array.isArray(jsonValue)) {
throw new Error('JSON must be an array');
}

const resultOptions: JsonVariableOption[] = [];

jsonValue.forEach((option) => {
if (option[valueProp] == null) {
return;
}

resultOptions.push({
value: option[valueProp],
label: option[valueProp],
obj: option,
});
});

subscriber.next(resultOptions);
subscriber.complete();
} catch (error) {
subscriber.error(error);
}
});
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { Observable } from 'rxjs';
import { JsonVariableOptionProvider, JsonVariableOption } from './JsonVariable';

export interface JsonStringOptionPrividerOptions {
/**
* String contauining JSON with an array of objects or a map of objects
*/
json: string;
/**
* Defaults to name if not specified
*/
valueProp?: string;
}

export class JsonStringOptionProvider implements JsonVariableOptionProvider {
public constructor(private options: JsonStringOptionPrividerOptions) {}

public getOptions(): Observable<JsonVariableOption[]> {
return new Observable((subscriber) => {
try {
const { json, valueProp = 'name' } = this.options;
const jsonValue = JSON.parse(json);

if (!Array.isArray(jsonValue)) {
throw new Error('JSON must be an array');
}

const resultOptions: JsonVariableOption[] = [];

jsonValue.forEach((option) => {
if (option[valueProp] == null) {
return;
}

resultOptions.push({
value: option[valueProp],
label: option[valueProp],
obj: option,
});
});

subscriber.next(resultOptions);
subscriber.complete();
} catch (error) {
subscriber.error(error);
}
});
}
}
43 changes: 43 additions & 0 deletions packages/scenes/src/variables/variants/json/JsonVariable.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { lastValueFrom } from 'rxjs';
import { JsonVariable } from './JsonVariable';
import { JsonVariableOptionProviders } from './JsonVariableOptionProviders';

describe('JsonVariable', () => {
describe('fromString', () => {
it('Should parse out an array of objects', async () => {
const variable = new JsonVariable({
name: 'env',
value: 'prod',
provider: JsonVariableOptionProviders.fromString({
json: `[
{ "id": 1, "name": "dev", "cluster": "us-dev-1" } ,
{ "id": 2, "name": "prod", "cluster": "us-prod-2" }
]`,
}),
});

await lastValueFrom(variable.validateAndUpdate());

expect(variable.getValue('cluster')).toBe('us-prod-2');
});
});

describe('fromObjectArray', () => {
it('Should get options', async () => {
const variable = new JsonVariable({
name: 'env',
value: 'prod',
provider: JsonVariableOptionProviders.fromObjectArray({
options: [
{ id: 1, name: 'dev', cluster: 'us-dev-1' },
{ id: 2, name: 'prod', cluster: 'us-prod-2' },
],
}),
});

await lastValueFrom(variable.validateAndUpdate());

expect(variable.getValue('cluster')).toBe('us-prod-2');
});
});
});
131 changes: 131 additions & 0 deletions packages/scenes/src/variables/variants/json/JsonVariable.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import React from 'react';
import { property } from 'lodash';
import { Observable, map, of } from 'rxjs';
import { Select } from '@grafana/ui';
import { SelectableValue } from '@grafana/data';
import { SceneObjectBase } from '../../../core/SceneObjectBase';
import { SceneComponentProps } from '../../../core/types';
import {
SceneVariableState,
SceneVariable,
ValidateAndUpdateResult,
VariableValue,
SceneVariableValueChangedEvent,
} from '../../types';

export interface JsonVariableState extends SceneVariableState {
/**
* The current value
*/
value?: string;
/**
* O
*/
options: JsonVariableOption[];
/**
* The thing that generates/returns possible values / options
*/
provider?: JsonVariableOptionProvider;
}

export interface JsonVariableOption {
value: string;
label: string;
obj: unknown;
}

export interface JsonVariableOptionProvider {
getOptions(): Observable<JsonVariableOption[]>;
}

export class JsonVariable extends SceneObjectBase<JsonVariableState> implements SceneVariable {
public constructor(state: Partial<JsonVariableState>) {
super({
// @ts-ignore
type: 'json',
options: [],
...state,
});
}

private static fieldAccessorCache: FieldAccessorCache = {};

public validateAndUpdate(): Observable<ValidateAndUpdateResult> {
if (!this.state.provider) {
return of({});
}

return this.state.provider.getOptions().pipe(
map((options) => {
this.updateValueGivenNewOptions(options);
return {};
})
);
}

private updateValueGivenNewOptions(options: JsonVariableOption[]) {
if (!this.state.value) {
return;
}

const stateUpdate: Partial<JsonVariableState> = { options };

const found = options.find((option) => option.value === this.state.value);

if (!found) {
if (options.length > 0) {
stateUpdate.value = options[0].value;
} else {
stateUpdate.value = undefined;
}
}

this.setState(stateUpdate);
}

public getValueText?(fieldPath?: string): string {
const current = this.state.options.find((option) => option.value === this.state.value);
return current ? current.label : '';
}

public getValue(fieldPath: string): VariableValue {
const current = this.state.options.find((option) => option.value === this.state.value);
return current ? this.getFieldAccessor(fieldPath)(current.obj) : '';
}

private getFieldAccessor(fieldPath: string) {
const accessor = JsonVariable.fieldAccessorCache[fieldPath];
if (accessor) {
return accessor;
}

return (JsonVariable.fieldAccessorCache[fieldPath] = property(fieldPath));
}

public _onChange = (selected: SelectableValue<string>) => {
this.setState({ value: selected.value });
this.publishEvent(new SceneVariableValueChangedEvent(this), true);
};

public static Component = ({ model }: SceneComponentProps<JsonVariable>) => {
const { key, value, options } = model.useState();

const current = options.find((option) => option.value === value)?.value;

return (
<Select
id={key}
placeholder="Select value"
width="auto"
value={current}
tabSelectsValue={false}
options={options}
onChange={model._onChange}
/>
);
};
}

interface FieldAccessorCache {
[key: string]: (obj: any) => any;
}
Loading
Loading