From 4e6d24f9a41a4a0b0a55320b3a4995dbfdc73920 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torkel=20=C3=96degaard?= Date: Fri, 6 Dec 2024 10:12:32 +0100 Subject: [PATCH 1/2] JsonVariable test --- .../variables/variants/JsonVariable.test.ts | 23 +++ .../src/variables/variants/JsonVariable.ts | 151 ++++++++++++++++++ 2 files changed, 174 insertions(+) create mode 100644 packages/scenes/src/variables/variants/JsonVariable.test.ts create mode 100644 packages/scenes/src/variables/variants/JsonVariable.ts diff --git a/packages/scenes/src/variables/variants/JsonVariable.test.ts b/packages/scenes/src/variables/variants/JsonVariable.test.ts new file mode 100644 index 000000000..35922dc8c --- /dev/null +++ b/packages/scenes/src/variables/variants/JsonVariable.test.ts @@ -0,0 +1,23 @@ +import { lastValueFrom } from 'rxjs'; +import { JsonStringOptionPrivider, JsonVariable } from './JsonVariable'; + +describe('JsonVariable', () => { + describe('When given a json string', () => { + it('Should parse out an array of objects', async () => { + const variable = new JsonVariable({ + name: 'env', + value: 'prod', + provider: new JsonStringOptionPrivider({ + 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'); + }); + }); +}); diff --git a/packages/scenes/src/variables/variants/JsonVariable.ts b/packages/scenes/src/variables/variants/JsonVariable.ts new file mode 100644 index 000000000..4beaf8fd1 --- /dev/null +++ b/packages/scenes/src/variables/variants/JsonVariable.ts @@ -0,0 +1,151 @@ +import { property } from 'lodash'; +import { Observable, map, of } from 'rxjs'; + +import { + SceneObjectBase, + SceneVariable, + SceneVariableState, + ValidateAndUpdateResult, + VariableValue, +} from '@grafana/scenes'; + +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; +} + +export class JsonVariable extends SceneObjectBase implements SceneVariable { + public constructor(state: Partial) { + super({ + // @ts-ignore + type: 'json', + ...state, + }); + } + + private static fieldAccessorCache: FieldAccessorCache = {}; + + public validateAndUpdate(): Observable { + if (!this.state.provider) { + return of({}); + } + + return this.state.provider.getOptions().pipe( + map((options) => { + console.log('options', options); + this.updateValueGivenNewOptions(options); + return {}; + }) + ); + } + + private updateValueGivenNewOptions(options: JsonVariableOption[]) { + if (!this.state.value) { + return; + } + + const stateUpdate: Partial = { 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)); + } +} + +interface FieldAccessorCache { + [key: string]: (obj: any) => any; +} + +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 { + 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); + } + }); + } +} From 95ff68211f371df1880514fc71fec2aa0a47da06 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torkel=20=C3=96degaard?= Date: Fri, 6 Dec 2024 14:45:29 +0100 Subject: [PATCH 2/2] demo working --- packages/scenes-app/src/demos/index.ts | 2 + .../scenes-app/src/demos/jsonVariableDemo.tsx | 74 +++++++++++++ packages/scenes/src/index.ts | 6 + .../variables/variants/JsonVariable.test.ts | 23 ---- .../json/JsonStringOptionPrivider copy.tsx | 49 +++++++++ .../json/JsonStringOptionPrivider.tsx | 49 +++++++++ .../variants/json/JsonVariable.test.ts | 43 ++++++++ .../JsonVariable.tsx} | 84 ++++++-------- .../json/JsonVariableOptionProviders.ts | 104 ++++++++++++++++++ 9 files changed, 359 insertions(+), 75 deletions(-) create mode 100644 packages/scenes-app/src/demos/jsonVariableDemo.tsx delete mode 100644 packages/scenes/src/variables/variants/JsonVariable.test.ts create mode 100644 packages/scenes/src/variables/variants/json/JsonStringOptionPrivider copy.tsx create mode 100644 packages/scenes/src/variables/variants/json/JsonStringOptionPrivider.tsx create mode 100644 packages/scenes/src/variables/variants/json/JsonVariable.test.ts rename packages/scenes/src/variables/variants/{JsonVariable.ts => json/JsonVariable.tsx} (65%) create mode 100644 packages/scenes/src/variables/variants/json/JsonVariableOptionProviders.ts diff --git a/packages/scenes-app/src/demos/index.ts b/packages/scenes-app/src/demos/index.ts index d2296b432..50ef62205 100644 --- a/packages/scenes-app/src/demos/index.ts +++ b/packages/scenes-app/src/demos/index.ts @@ -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; @@ -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)); } diff --git a/packages/scenes-app/src/demos/jsonVariableDemo.tsx b/packages/scenes-app/src/demos/jsonVariableDemo.tsx new file mode 100644 index 000000000..45f5a33f2 --- /dev/null +++ b/packages/scenes-app/src/demos/jsonVariableDemo.tsx @@ -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({ + 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', + }), + }), + ], + }), + 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(), + ], + }), + }); + }, + }); +} diff --git a/packages/scenes/src/index.ts b/packages/scenes/src/index.ts index 3b2c4a371..26b23587a 100644 --- a/packages/scenes/src/index.ts +++ b/packages/scenes/src/index.ts @@ -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, diff --git a/packages/scenes/src/variables/variants/JsonVariable.test.ts b/packages/scenes/src/variables/variants/JsonVariable.test.ts deleted file mode 100644 index 35922dc8c..000000000 --- a/packages/scenes/src/variables/variants/JsonVariable.test.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { lastValueFrom } from 'rxjs'; -import { JsonStringOptionPrivider, JsonVariable } from './JsonVariable'; - -describe('JsonVariable', () => { - describe('When given a json string', () => { - it('Should parse out an array of objects', async () => { - const variable = new JsonVariable({ - name: 'env', - value: 'prod', - provider: new JsonStringOptionPrivider({ - 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'); - }); - }); -}); diff --git a/packages/scenes/src/variables/variants/json/JsonStringOptionPrivider copy.tsx b/packages/scenes/src/variables/variants/json/JsonStringOptionPrivider copy.tsx new file mode 100644 index 000000000..0b15bf57c --- /dev/null +++ b/packages/scenes/src/variables/variants/json/JsonStringOptionPrivider copy.tsx @@ -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 { + 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); + } + }); + } +} diff --git a/packages/scenes/src/variables/variants/json/JsonStringOptionPrivider.tsx b/packages/scenes/src/variables/variants/json/JsonStringOptionPrivider.tsx new file mode 100644 index 000000000..11f563020 --- /dev/null +++ b/packages/scenes/src/variables/variants/json/JsonStringOptionPrivider.tsx @@ -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 { + 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); + } + }); + } +} diff --git a/packages/scenes/src/variables/variants/json/JsonVariable.test.ts b/packages/scenes/src/variables/variants/json/JsonVariable.test.ts new file mode 100644 index 000000000..35ed39971 --- /dev/null +++ b/packages/scenes/src/variables/variants/json/JsonVariable.test.ts @@ -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'); + }); + }); +}); diff --git a/packages/scenes/src/variables/variants/JsonVariable.ts b/packages/scenes/src/variables/variants/json/JsonVariable.tsx similarity index 65% rename from packages/scenes/src/variables/variants/JsonVariable.ts rename to packages/scenes/src/variables/variants/json/JsonVariable.tsx index 4beaf8fd1..23c3520f2 100644 --- a/packages/scenes/src/variables/variants/JsonVariable.ts +++ b/packages/scenes/src/variables/variants/json/JsonVariable.tsx @@ -1,13 +1,17 @@ +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 { - SceneObjectBase, - SceneVariable, SceneVariableState, + SceneVariable, ValidateAndUpdateResult, VariableValue, -} from '@grafana/scenes'; + SceneVariableValueChangedEvent, +} from '../../types'; export interface JsonVariableState extends SceneVariableState { /** @@ -39,6 +43,7 @@ export class JsonVariable extends SceneObjectBase implements super({ // @ts-ignore type: 'json', + options: [], ...state, }); } @@ -52,7 +57,6 @@ export class JsonVariable extends SceneObjectBase implements return this.state.provider.getOptions().pipe( map((options) => { - console.log('options', options); this.updateValueGivenNewOptions(options); return {}; }) @@ -97,55 +101,31 @@ export class JsonVariable extends SceneObjectBase implements return (JsonVariable.fieldAccessorCache[fieldPath] = property(fieldPath)); } + + public _onChange = (selected: SelectableValue) => { + this.setState({ value: selected.value }); + this.publishEvent(new SceneVariableValueChangedEvent(this), true); + }; + + public static Component = ({ model }: SceneComponentProps) => { + const { key, value, options } = model.useState(); + + const current = options.find((option) => option.value === value)?.value; + + return ( +