Schema V2 to DashboardScene: Settings + Panels (#96074)

* Transform Save mode to Scene: Settings

* Transform Save mode to Scene: Panels

---------

Co-authored-by: Dominik Prokop <dominik.prokop@grafana.com>
Co-authored-by: alexandra vargas <alexa1866@gmail.com>
Co-authored-by: Haris Rozajac <haris.rozajac12@gmail.com>
This commit is contained in:
Ivan Ortega Alba
2024-11-27 14:35:37 +01:00
committed by GitHub
co-authored by Dominik Prokop alexandra vargas Haris Rozajac
parent 16c78f6a98
commit d18cdae3e2
8 changed files with 1284 additions and 87 deletions
@@ -124,6 +124,14 @@ export interface DataSourceRef {
export const defaultDataSourceRef = (): DataSourceRef => ({
});
export enum DataTopic {
AlertStates = "alertStates",
Annotations = "annotations",
Series = "series",
}
export const defaultDataTopic = (): DataTopic => (DataTopic.AlertStates);
// Transformations allow to manipulate data returned by a query before the system applies a visualization.
// Using transformations you can: rename fields, join time series data, perform mathematical operations across queries,
// use the output of one transformation as the input to another transformation, etc.
@@ -135,8 +143,7 @@ export interface DataTransformerConfig {
// Optional frame matcher. When missing it will be applied to all results
filter?: MatcherConfig;
// Where to pull DataFrames from as input to transformation
// replaced with common.DataTopic
topic?: "series" | "annotations" | "alertStates";
topic?: DataTopic;
// Options to be passed to the transformer
// Valid options depend on the transformer id
options: any;
@@ -247,7 +254,7 @@ export const defaultMatcherConfig = (): MatcherConfig => ({
});
export interface Threshold {
value: number | null;
value: number;
color: string;
}
@@ -283,13 +290,13 @@ export const defaultValueMapping = (): ValueMapping => (defaultValueMap());
// `regex`: Maps regular expressions to replacement text and a color. For example, if a value is www.example.com, you can configure a regex value mapping so that Grafana displays www and truncates the domain.
// `special`: Maps special values like Null, NaN (not a number), and boolean values like true and false to a display text and color. See SpecialValueMatch to see the list of special values. For example, you can configure a special value mapping so that null values appear as N/A.
export enum MappingType {
Value = "value",
Range = "range",
Regex = "regex",
Special = "special",
ValueToText = "value",
RangeToText = "range",
RegexToText = "regex",
SpecialValue = "special",
}
export const defaultMappingType = (): MappingType => (MappingType.Value);
export const defaultMappingType = (): MappingType => (MappingType.ValueToText);
// Maps text values to a color or different display text and color.
// For example, you can configure a value mapping so that all instances of the value 10 appear as Perfection! rather than the number.
@@ -376,7 +383,7 @@ export enum SpecialValueMatch {
False = "false",
Null = "null",
NotANumber = "nan",
NullNan = "null+nan",
NullAndNaN = "null+nan",
Empty = "empty",
}
@@ -542,6 +549,7 @@ export interface QueryOptionsSpec {
queryCachingTTL?: number;
interval?: string;
cacheTimeout?: string;
hideTimeOverride?: boolean;
}
export const defaultQueryOptionsSpec = (): QueryOptionsSpec => ({
@@ -102,6 +102,8 @@ DataSourceRef: {
uid?: string
}
DataTopic: "alertStates" | "annotations" | "series"
// Transformations allow to manipulate data returned by a query before the system applies a visualization.
// Using transformations you can: rename fields, join time series data, perform mathematical operations across queries,
// use the output of one transformation as the input to another transformation, etc.
@@ -113,7 +115,7 @@ DataTransformerConfig: {
// Optional frame matcher. When missing it will be applied to all results
filter?: MatcherConfig
// Where to pull DataFrames from as input to transformation
topic?: "series" | "annotations" | "alertStates" // replaced with common.DataTopic
topic?: DataTopic
// Options to be passed to the transformer
// Valid options depend on the transformer id
options: _
@@ -217,7 +219,7 @@ MatcherConfig: {
}
Threshold: {
value: number | null
value: number
color: string
}
@@ -235,7 +237,7 @@ ValueMapping: ValueMap | RangeMap | RegexMap | SpecialValueMap
// `range`: Maps numerical ranges to a display text and color. For example, if a value is within a certain range, you can configure a range value mapping to display Low or High rather than the number.
// `regex`: Maps regular expressions to replacement text and a color. For example, if a value is www.example.com, you can configure a regex value mapping so that Grafana displays www and truncates the domain.
// `special`: Maps special values like Null, NaN (not a number), and boolean values like true and false to a display text and color. See SpecialValueMatch to see the list of special values. For example, you can configure a special value mapping so that null values appear as N/A.
MappingType: "value" | "range" | "regex" | "special"
MappingType: "value" | "range" | "regex" | "special" @cog(kind="enum",memberNames="ValueToText|RangeToText|RegexToText|SpecialValue")
// Maps text values to a color or different display text and color.
// For example, you can configure a value mapping so that all instances of the value 10 appear as Perfection! rather than the number.
@@ -287,7 +289,7 @@ SpecialValueMap: {
}
// Special value types supported by the `SpecialValueMap`
SpecialValueMatch: "true" | "false" | "null" | "nan" | "null+nan" | "empty"
SpecialValueMatch: "true" | "false" | "null" | "nan" | "null+nan" | "empty" @cog(kind="enum",memberNames="True|False|Null|NaN|NullAndNaN|Empty")
// Result used as replacement with text and color when the value matches
ValueMappingResult: {
@@ -383,6 +385,7 @@ QueryOptionsSpec: {
queryCachingTTL?: int
interval?: string
cacheTimeout?: string
hideTimeOverride?: bool
}
DataQueryKind: {
@@ -1,36 +1,38 @@
import { DashboardCursorSync, DashboardV2Spec } from './dashboard.gen';
import {
DashboardCursorSync,
DashboardLinkType,
DashboardV2Spec,
VariableHide,
VariableRefresh,
VariableSort,
} from './dashboard.gen';
export const handyTestingSchema: DashboardV2Spec = {
id: 1,
title: 'Default Dashboard',
description: 'This is a default dashboard',
cursorSync: DashboardCursorSync.Off,
liveNow: false,
preload: false,
title: 'Test Dashboard',
description: 'Test Description',
editable: true,
links: [],
tags: [],
schemaVersion: 39,
preload: true,
schemaVersion: 40,
tags: ['tag1', 'tag2'],
liveNow: true,
cursorSync: DashboardCursorSync.Crosshair,
timeSettings: {
timezone: 'browser',
from: 'now-6h',
to: 'now',
autoRefresh: '10s',
autoRefreshIntervals: ['10s', '1m', '5m', '15m', '30m', '1h', '6h', '12h', '1d'],
quickRanges: ['now/d', 'now/w', 'now/M', 'now/y'],
hideTimepicker: false,
weekStart: 'sunday',
autoRefresh: '5s',
autoRefreshIntervals: ['5s', '10s', '30s'],
fiscalYearStartMonth: 1,
from: 'now-1h',
hideTimepicker: false,
nowDelay: '1m',
quickRanges: [],
timezone: 'UTC',
to: 'now',
weekStart: 'monday',
},
annotations: [],
elements: {
timeSeriesTest: {
'test-panel-uid': {
kind: 'Panel',
spec: {
title: 'Time Series Test',
description: 'This is a test panel',
uid: 'timeSeriesTest',
links: [],
data: {
kind: 'QueryGroup',
spec: {
@@ -38,44 +40,63 @@ export const handyTestingSchema: DashboardV2Spec = {
{
kind: 'PanelQuery',
spec: {
refId: 'A',
datasource: {
type: 'prometheus',
uid: 'datasource1',
},
query: {
kind: 'prometheus',
spec: {
query: 'up',
expr: 'test-query',
},
},
datasource: { uid: 'gdev-prometheus', type: 'prometheus' },
hidden: false,
refId: 'A',
},
},
],
queryOptions: {
timeFrom: '1h',
maxDataPoints: 100,
timeShift: '1h',
queryCachingTTL: 60,
interval: '1m',
cacheTimeout: '1m',
hideTimeOverride: false,
},
transformations: [
{
kind: 'limit',
spec: {
id: 'limit', // id is competing w/ kind
id: 'limit',
disabled: false,
filter: {
id: 'byValue',
options: {
reducer: 'sum',
},
},
options: {
limit: 10,
},
},
},
],
queryOptions: {
maxDataPoints: 100,
cacheTimeout: '1m',
},
},
},
description: 'Test Description',
links: [],
title: 'Test Panel',
uid: 'test-panel-uid',
vizConfig: {
kind: 'timeseries',
spec: {
pluginVersion: '11.0.0',
options: {},
fieldConfig: {
defaults: {},
overrides: [],
},
options: {},
pluginVersion: '7.0.0',
},
},
},
@@ -88,62 +109,252 @@ export const handyTestingSchema: DashboardV2Spec = {
{
kind: 'GridLayoutItem',
spec: {
element: { kind: 'ElementReference', name: 'timeSeriesTest' },
element: {
kind: 'ElementReference',
name: 'test-panel-uid',
},
height: 0,
width: 0,
x: 0,
y: 0,
width: 12,
height: 6,
},
},
],
},
},
variables: [],
annotations: [
links: [
{
kind: 'AnnotationQuery',
asDropdown: false,
icon: '',
includeVars: false,
keepTime: false,
tags: [],
targetBlank: false,
title: 'Test Link',
tooltip: '',
type: DashboardLinkType.Dashboards,
url: 'http://test.com',
},
],
variables: [
{
kind: 'QueryVariable',
spec: {
datasource: { type: 'datasource', uid: 'grafana' },
query: {
kind: 'grafana',
spec: {
queryType: 'timeRegions',
matchAny: false,
timeRegion: {
from: '12:27',
fromDayOfWeek: 2,
timezone: 'browser',
to: '11:30',
toDayOfWeek: 2,
},
},
allValue: '*',
current: {
text: 'text1',
value: 'value1',
},
enable: true,
filter: {
ids: [],
datasource: {
type: 'prometheus',
uid: 'datasource1',
},
hide: false,
iconColor: 'blue',
name: 'Grafana annotations',
definition: 'definition1',
description: 'A query variable',
hide: VariableHide.DontHide,
includeAll: true,
label: 'Query Variable',
multi: true,
name: 'queryVar',
options: [],
query: 'query1',
refresh: VariableRefresh.OnDashboardLoad,
regex: 'regex1',
skipUrlSync: false,
sort: VariableSort.Disabled,
},
},
{
kind: 'AnnotationQuery',
kind: 'CustomVariable',
spec: {
datasource: { uid: 'gdev-prometheus', type: 'prometheus' },
query: {
kind: 'prometheus',
spec: {
query: 'up',
allValue: 'All',
current: {
text: 'option1',
value: 'option1',
},
description: 'A custom variable',
hide: VariableHide.DontHide,
includeAll: true,
label: 'Custom Variable',
multi: true,
name: 'customVar',
options: [
{
selected: true,
text: 'option1',
value: 'option1',
},
{
selected: false,
text: 'option2',
value: 'option2',
},
],
query: 'option1, option2',
skipUrlSync: false,
},
},
{
kind: 'DatasourceVariable',
spec: {
allValue: undefined,
current: {
text: 'text1',
value: 'value1',
},
enable: true,
filter: {
ids: [],
defaultOptionEnabled: true,
description: 'A datasource variable',
hide: VariableHide.DontHide,
includeAll: false,
label: 'Datasource Variable',
multi: false,
name: 'datasourceVar',
options: [],
pluginId: 'datasource1',
refresh: VariableRefresh.OnDashboardLoad,
regex: 'regex1',
skipUrlSync: false,
},
},
{
kind: 'ConstantVariable',
spec: {
current: {
text: 'value4',
value: 'value4',
},
hide: false,
iconColor: 'red',
name: 'Prometheus annotations',
description: 'A constant variable',
hide: VariableHide.DontHide,
label: 'Constant Variable',
name: 'constantVar',
query: 'value4',
skipUrlSync: true,
},
},
{
kind: 'IntervalVariable',
spec: {
auto: false,
auto_count: 10,
auto_min: '1m',
current: {
text: '1m',
value: '1m',
},
description: 'An interval variable',
hide: VariableHide.DontHide,
label: 'Interval Variable',
name: 'intervalVar',
options: [
{
selected: true,
text: '1m',
value: '1m',
},
{
selected: false,
text: '5m',
value: '5m',
},
{
selected: false,
text: '10m',
value: '10m',
},
],
query: '1m,5m,10m',
refresh: VariableRefresh.OnDashboardLoad,
skipUrlSync: false,
},
},
{
kind: 'TextVariable',
spec: {
current: {
text: 'value6',
value: 'value6',
},
description: 'A text variable',
hide: VariableHide.DontHide,
label: 'Text Variable',
name: 'textVar',
query: 'value6',
skipUrlSync: false,
},
},
{
kind: 'GroupByVariable',
spec: {
current: {
text: 'text7',
value: 'value7',
},
datasource: {
type: 'prometheus',
uid: 'datasource2',
},
description: 'A group by variable',
hide: VariableHide.DontHide,
includeAll: false,
label: 'Group By Variable',
multi: false,
name: 'groupByVar',
options: [
{
text: 'option1',
value: 'option1',
},
{
text: 'option2',
value: 'option2',
},
],
skipUrlSync: false,
},
},
{
kind: 'AdhocVariable',
spec: {
baseFilters: [
{
condition: 'AND',
key: 'key1',
operator: '=',
value: 'value1',
},
{
condition: 'OR',
key: 'key2',
operator: '=',
value: 'value2',
},
],
datasource: {
type: 'prometheus',
uid: 'datasource3',
},
defaultKeys: [
{
expandable: true,
group: 'defaultGroup1',
text: 'defaultKey1',
value: 'defaultKey1',
},
],
description: 'An adhoc variable',
filters: [
{
condition: 'AND',
key: 'key3',
operator: '=',
value: 'value3',
},
],
hide: VariableHide.DontHide,
label: 'Adhoc Variable',
name: 'adhocVar',
skipUrlSync: false,
},
},
],
@@ -0,0 +1,213 @@
import { cloneDeep } from 'lodash';
import { config } from '@grafana/runtime';
import { behaviors, sceneGraph, SceneQueryRunner } from '@grafana/scenes';
import { DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2alpha0/dashboard.gen';
import { handyTestingSchema } from '@grafana/schema/dist/esm/schema/dashboard/v2alpha0/examples';
import { DashboardWithAccessInfo } from 'app/features/dashboard/api/dashboard_api';
import { MIXED_DATASOURCE_NAME } from 'app/plugins/datasource/mixed/MixedDataSource';
import { DashboardLayoutManager } from '../scene/types';
import { dashboardSceneGraph } from '../utils/dashboardSceneGraph';
import { getQueryRunnerFor } from '../utils/utils';
import { transformSaveModelSchemaV2ToScene } from './transformSaveModelSchemaV2ToScene';
import { transformCursorSynctoEnum } from './transformToV2TypesUtils';
const defaultDashboard: DashboardWithAccessInfo<DashboardV2Spec> = {
kind: 'DashboardWithAccessInfo',
metadata: {
name: 'dashboard-uid',
namespace: 'default',
labels: {},
resourceVersion: '',
creationTimestamp: '',
},
spec: handyTestingSchema,
access: {},
apiVersion: 'v2',
};
jest.mock('@grafana/runtime', () => ({
...jest.requireActual('@grafana/runtime'),
getDataSourceSrv: () => ({
getInstanceSettings: jest.fn(),
}),
}));
describe('transformSaveModelSchemaV2ToScene', () => {
beforeAll(() => {
config.featureToggles.groupByVariable = true;
});
afterAll(() => {
config.featureToggles.groupByVariable = false;
});
it('should initialize the DashboardScene with the model state', () => {
const scene = transformSaveModelSchemaV2ToScene(defaultDashboard);
const dashboardControls = scene.state.controls!;
const dash = defaultDashboard.spec;
expect(scene.state.uid).toEqual(defaultDashboard.metadata.name);
expect(scene.state.title).toEqual(dash.title);
expect(scene.state.description).toEqual(dash.description);
expect(scene.state.editable).toEqual(dash.editable);
expect(scene.state.preload).toEqual(true);
expect(scene.state.version).toEqual(dash.schemaVersion);
expect(scene.state.tags).toEqual(dash.tags);
const liveNow = scene.state.$behaviors?.find((b) => b instanceof behaviors.LiveNowTimer);
expect(liveNow?.state.enabled).toEqual(dash.liveNow);
const cursorSync = scene.state.$behaviors?.find((b) => b instanceof behaviors.CursorSync);
expect(transformCursorSynctoEnum(cursorSync?.state.sync)).toEqual(dash.cursorSync);
// Dashboard links
expect(scene.state.links).toHaveLength(dash.links.length);
expect(scene.state.links![0].title).toBe(dash.links[0].title);
// Time settings
const time = dash.timeSettings;
const refreshPicker = dashboardSceneGraph.getRefreshPicker(scene)!;
const timeRange = sceneGraph.getTimeRange(scene)!;
// Time settings
expect(refreshPicker.state.refresh).toEqual(time.autoRefresh);
expect(refreshPicker.state.intervals).toEqual(time.autoRefreshIntervals);
expect(timeRange?.state.fiscalYearStartMonth).toEqual(dash.timeSettings.fiscalYearStartMonth);
expect(timeRange?.state.value.raw).toEqual({ from: dash.timeSettings.from, to: dash.timeSettings.to });
expect(dashboardControls.state.hideTimeControls).toEqual(dash.timeSettings.hideTimepicker);
expect(timeRange?.state.UNSAFE_nowDelay).toEqual(dash.timeSettings.nowDelay);
expect(timeRange?.state.timeZone).toEqual(dash.timeSettings.timezone);
expect(timeRange?.state.weekStart).toEqual(dash.timeSettings.weekStart);
expect(dashboardControls).toBeDefined();
expect(dashboardControls.state.refreshPicker.state.intervals).toEqual(time.autoRefreshIntervals);
expect(dashboardControls.state.hideTimeControls).toBe(time.hideTimepicker);
// TODO: Variables
// expect(scene.state?.$variables?.state.variables).toHaveLength(dash.variables.length);
// expect(scene.state?.$variables?.getByName(dash.variables[0].spec.name)).toBeInstanceOf(QueryVariable);
// expect(scene.state?.$variables?.getByName(dash.variables[1].spec.name)).toBeInstanceOf(TextBoxVariable); ...
// TODO: Annotations
// expect(scene.state.annotations).toHaveLength(dash.annotations.length);
// expect(scene.state.annotations[0].text).toBe(dash.annotations[0].text); ...
// To be implemented
// expect(timePicker.state.ranges).toEqual(dash.timeSettings.quickRanges);
// VizPanel
const vizPanels = (scene.state.body as DashboardLayoutManager).getVizPanels();
expect(vizPanels).toHaveLength(1);
const vizPanel = vizPanels[0];
expect(vizPanel.state.title).toBe(dash.elements['test-panel-uid'].spec.title);
expect(vizPanel.state.description).toBe(dash.elements['test-panel-uid'].spec.description);
expect(vizPanel.state.pluginId).toBe(dash.elements['test-panel-uid'].spec.vizConfig.kind);
expect(vizPanel.state.pluginVersion).toBe(dash.elements['test-panel-uid'].spec.vizConfig.spec.pluginVersion);
expect(vizPanel.state.options).toEqual(dash.elements['test-panel-uid'].spec.vizConfig.spec.options);
expect(vizPanel.state.fieldConfig).toEqual(dash.elements['test-panel-uid'].spec.vizConfig.spec.fieldConfig);
// FIXME: There is an error of data being undefined
// expect(vizPanel.state.$data).toBeInstanceOf(SceneDataTransformer);
// const dataTransformer = vizPanel.state.$data as SceneDataTransformer;
// expect(dataTransformer.state.transformations).toEqual([{ id: 'transform1', options: {} }]);
// expect(dataTransformer.state.$data).toBeInstanceOf(SceneQueryRunner);
const queryRunner = getQueryRunnerFor(vizPanel);
expect(queryRunner).toBeInstanceOf(SceneQueryRunner);
expect(queryRunner?.state.datasource).toBeUndefined();
// expect(queryRunner.state.queries).toEqual([{ query: 'test-query', datasource: { uid: 'datasource1', type: 'prometheus' } }]);
// expect(queryRunner.state.maxDataPoints).toBe(100);
// expect(queryRunner.state.cacheTimeout).toBe('1m');
// expect(queryRunner.state.queryCachingTTL).toBe(60);
// expect(queryRunner.state.minInterval).toBe('1m');
// expect(queryRunner.state.dataLayerFilter?.panelId).toBe(1);
// FIXME: Fix the key incompatibility since panel is not numeric anymore
// expect(vizPanel.state.key).toBe(dash.elements['test-panel-uid'].spec.uid);
// FIXME: Tests for layout
});
it('should set panel ds if it is mixed DS', () => {
const dashboard = cloneDeep(defaultDashboard);
dashboard.spec.elements['test-panel-uid'].spec.data.spec.queries.push({
kind: 'PanelQuery',
spec: {
refId: 'A',
datasource: {
type: 'graphite',
uid: 'datasource1',
},
hidden: false,
query: {
kind: 'prometheus',
spec: {
expr: 'test-query',
},
},
},
});
const scene = transformSaveModelSchemaV2ToScene(dashboard);
const vizPanels = (scene.state.body as DashboardLayoutManager).getVizPanels();
expect(vizPanels.length).toBe(1);
expect(getQueryRunnerFor(vizPanels[0])?.state.datasource?.type).toBe('mixed');
expect(getQueryRunnerFor(vizPanels[0])?.state.datasource?.uid).toBe(MIXED_DATASOURCE_NAME);
});
it('should set panel ds as undefined if it is not mixed DS', () => {
const dashboard = cloneDeep(defaultDashboard);
dashboard.spec.elements['test-panel-uid'].spec.data.spec.queries.push({
kind: 'PanelQuery',
spec: {
refId: 'A',
datasource: {
type: 'prometheus',
uid: 'datasource1',
},
hidden: false,
query: {
kind: 'prometheus',
spec: {
expr: 'test-query',
},
},
},
});
const scene = transformSaveModelSchemaV2ToScene(dashboard);
const vizPanels = (scene.state.body as DashboardLayoutManager).getVizPanels();
expect(vizPanels.length).toBe(1);
expect(getQueryRunnerFor(vizPanels[0])?.state.datasource).toBeUndefined();
});
// Skipping the test because the schema doesn't accept the ds to be undefined.
// In future PR, we will mark it as optional so, this test should pass and the runtime code should be updated.
it.skip('should set panel ds as undefined if it is not mixed DS', () => {
const dashboard = cloneDeep(defaultDashboard);
dashboard.spec.elements['test-panel-uid'].spec.data.spec.queries.push({
kind: 'PanelQuery',
// @ts-expect-error TODO: When marking DS as optional, this should be fixed
spec: {
refId: 'A',
hidden: false,
query: {
kind: 'prometheus',
spec: {
expr: 'test-query',
},
},
},
});
const scene = transformSaveModelSchemaV2ToScene(dashboard);
const vizPanels = (scene.state.body as DashboardLayoutManager).getVizPanels();
expect(vizPanels.length).toBe(1);
expect(getQueryRunnerFor(vizPanels[0])?.state.datasource).toBeUndefined();
});
});
@@ -0,0 +1,631 @@
import { uniqueId } from 'lodash';
import { config, getDataSourceSrv } from '@grafana/runtime';
import {
AdHocFiltersVariable,
behaviors,
ConstantVariable,
CustomVariable,
DataSourceVariable,
GroupByVariable,
IntervalVariable,
QueryVariable,
SceneDataLayerControls,
SceneDataProvider,
SceneDataQuery,
SceneDataTransformer,
SceneGridItemLike,
SceneGridLayout,
SceneObject,
SceneQueryRunner,
SceneRefreshPicker,
SceneTimePicker,
SceneTimeRange,
SceneVariable,
SceneVariableSet,
TextBoxVariable,
VariableValueSelectors,
VizPanel,
VizPanelMenu,
VizPanelState,
} from '@grafana/scenes';
import { DataSourceRef } from '@grafana/schema/dist/esm/index.gen';
import {
AdhocVariableKind,
ConstantVariableKind,
CustomVariableKind,
DashboardV2Spec,
DatasourceVariableKind,
defaultAdhocVariableKind,
defaultConstantVariableKind,
defaultCustomVariableKind,
defaultDatasourceVariableKind,
defaultGroupByVariableKind,
defaultIntervalVariableKind,
defaultQueryVariableKind,
defaultTextVariableKind,
GroupByVariableKind,
IntervalVariableKind,
PanelKind,
PanelQueryKind,
QueryVariableKind,
TextVariableKind,
} from '@grafana/schema/src/schema/dashboard/v2alpha0/dashboard.gen';
import { DashboardWithAccessInfo } from 'app/features/dashboard/api/dashboard_api';
import { MIXED_DATASOURCE_NAME } from 'app/plugins/datasource/mixed/MixedDataSource';
import { addPanelsOnLoadBehavior } from '../addToDashboard/addPanelsOnLoadBehavior';
import { DashboardAnnotationsDataLayer } from '../scene/DashboardAnnotationsDataLayer';
import { DashboardControls } from '../scene/DashboardControls';
import { DashboardDataLayerSet } from '../scene/DashboardDataLayerSet';
import { DashboardDatasourceBehaviour } from '../scene/DashboardDatasourceBehaviour';
import { registerDashboardMacro } from '../scene/DashboardMacro';
import { DashboardReloadBehavior } from '../scene/DashboardReloadBehavior';
import { DashboardScene } from '../scene/DashboardScene';
import { DashboardScopesFacade } from '../scene/DashboardScopesFacade';
import { panelMenuBehavior } from '../scene/PanelMenuBehavior';
import { PanelNotices } from '../scene/PanelNotices';
import { PanelTimeRange } from '../scene/PanelTimeRange';
import { AngularDeprecation } from '../scene/angular/AngularDeprecation';
import { DashboardGridItem } from '../scene/layout-default/DashboardGridItem';
import { DefaultGridLayoutManager } from '../scene/layout-default/DefaultGridLayoutManager';
import { setDashboardPanelContext } from '../scene/setDashboardPanelContext';
import { preserveDashboardSceneStateInLocalStorage } from '../utils/dashboardSessionState';
import { getDashboardSceneFor, getIntervalsFromQueryString } from '../utils/utils';
import { SnapshotVariable } from './custom-variables/SnapshotVariable';
import { registerPanelInteractionsReporter } from './transformSaveModelToScene';
import {
transformCursorSyncV2ToV1,
transformSortVariableToEnumV1,
transformValueMappingsToV1,
transformVariableHideToEnumV1,
transformVariableRefreshToEnumV1,
} from './transformToV1TypesUtils';
const DEFAULT_DATASOURCE = 'default';
type TypedVariableModelv2 =
| QueryVariableKind
| TextVariableKind
| ConstantVariableKind
| DatasourceVariableKind
| IntervalVariableKind
| CustomVariableKind
| GroupByVariableKind
| AdhocVariableKind;
export function transformSaveModelSchemaV2ToScene(dto: DashboardWithAccessInfo<DashboardV2Spec>): DashboardScene {
const { spec: dashboard, metadata } = dto;
const annotationLayers = dashboard.annotations.map((annotation) => {
return new DashboardAnnotationsDataLayer({
key: uniqueId('annotations-'),
query: annotation.spec,
name: annotation.spec.name,
isEnabled: Boolean(annotation.spec.enable),
isHidden: Boolean(annotation.spec.hide),
});
});
const dashboardScene = new DashboardScene({
description: dashboard.description,
editable: dashboard.editable,
preload: dashboard.preload,
id: dashboard.id,
isDirty: false,
links: dashboard.links,
// TODO: Combine access and metadata to compose the V1 meta object
meta: {},
tags: dashboard.tags,
title: dashboard.title,
uid: metadata.name,
version: dashboard.schemaVersion,
body: new DefaultGridLayoutManager({
grid: new SceneGridLayout({
isLazy: dashboard.preload ? false : true,
children: createSceneGridLayoutForItems(dashboard),
$behaviors: [trackIfEmpty],
}),
}),
$timeRange: new SceneTimeRange({
from: dashboard.timeSettings.from,
to: dashboard.timeSettings.to,
fiscalYearStartMonth: dashboard.timeSettings.fiscalYearStartMonth,
timeZone: dashboard.timeSettings.timezone,
weekStart: dashboard.timeSettings.weekStart,
UNSAFE_nowDelay: dashboard.timeSettings.nowDelay,
}),
$variables: getVariables(dashboard),
$behaviors: [
new behaviors.CursorSync({
sync: transformCursorSyncV2ToV1(dashboard.cursorSync),
}),
new behaviors.SceneQueryController(),
registerDashboardMacro,
registerPanelInteractionsReporter,
new behaviors.LiveNowTimer({ enabled: dashboard.liveNow }),
preserveDashboardSceneStateInLocalStorage,
addPanelsOnLoadBehavior,
new DashboardScopesFacade({
reloadOnParamsChange: config.featureToggles.reloadDashboardsOnParamsChange,
uid: dashboard.id?.toString(),
}),
new DashboardReloadBehavior({
reloadOnParamsChange: config.featureToggles.reloadDashboardsOnParamsChange,
uid: dashboard.id?.toString(),
version: 1,
}),
],
$data: new DashboardDataLayerSet({
annotationLayers,
}),
controls: new DashboardControls({
variableControls: [new VariableValueSelectors({}), new SceneDataLayerControls()],
timePicker: new SceneTimePicker({}),
refreshPicker: new SceneRefreshPicker({
refresh: dashboard.timeSettings.autoRefresh,
intervals: dashboard.timeSettings.autoRefreshIntervals,
withText: true,
}),
hideTimeControls: dashboard.timeSettings.hideTimepicker,
}),
});
return dashboardScene;
}
function createSceneGridLayoutForItems(dashboard: DashboardV2Spec): SceneGridItemLike[] {
const gridElements = dashboard.layout.spec.items;
return gridElements.map((element) => {
if (element.kind === 'GridLayoutItem') {
const panel = dashboard.elements[element.spec.element.name];
if (!panel) {
throw new Error(`Panel with uid ${element.spec.element.name} not found in the dashboard elements`);
}
if (panel.kind === 'Panel') {
const vizPanel = buildVizPanel(panel);
return new DashboardGridItem({
key: `grid-item-${panel.spec.uid}`,
x: element.spec.x,
y: element.spec.y,
width: element.spec.width,
height: element.spec.height,
itemHeight: element.spec.height,
body: vizPanel,
});
} else {
throw new Error(`Unknown element kind: ${element.kind}`);
}
} else {
throw new Error(`Unknown layout element kind: ${element.kind}`);
}
});
}
function buildVizPanel(panel: PanelKind): VizPanel {
const titleItems: SceneObject[] = [];
if (config.featureToggles.angularDeprecationUI) {
titleItems.push(new AngularDeprecation());
}
// FIXME: Links in a panel are DashboardLinks not DataLinks
// titleItems.push(
// new VizPanelLinks({
// rawLinks: panel.spec.links,
// menu: new VizPanelLinksMenu({ $behaviors: [panelLinksBehavior] }),
// })
// );
titleItems.push(new PanelNotices());
const queryOptions = panel.spec.data.spec.queryOptions;
const timeOverrideShown = (queryOptions.timeFrom || queryOptions.timeShift) && !queryOptions.hideTimeOverride;
const vizPanelState: VizPanelState = {
key: panel.spec.uid,
title: panel.spec.title,
description: panel.spec.description,
pluginId: panel.spec.vizConfig.kind,
options: panel.spec.vizConfig.spec.options,
fieldConfig: transformValueMappingsToV1(panel.spec.vizConfig.spec.fieldConfig),
pluginVersion: panel.spec.vizConfig.spec.pluginVersion,
// FIXME: Transparent is not added to the schema yet
// displayMode: panel.spec.transparent ? 'transparent' : undefined,
hoverHeader: !panel.spec.title && !timeOverrideShown,
hoverHeaderOffset: 0,
$data: createPanelDataProvider(panel),
titleItems,
$behaviors: [],
extendPanelContext: setDashboardPanelContext,
// _UNSAFE_customMigrationHandler: getAngularPanelMigrationHandler(panel), //FIXME: Angular Migration
};
// FIXME: Library Panel
// if (panel.spec.libraryPanel) {
// vizPanelState.$behaviors!.push(
// new LibraryPanelBehavior({ uid: panel.spec.libraryPanel.uid, name: panel.spec.libraryPanel.name })
// );
// vizPanelState.pluginId = LibraryPanelBehavior.LOADING_VIZ_PANEL_PLUGIN_ID;
// vizPanelState.$data = undefined;
// }
if (!config.publicDashboardAccessToken) {
vizPanelState.menu = new VizPanelMenu({
$behaviors: [panelMenuBehavior],
});
}
if (queryOptions.timeFrom || queryOptions.timeShift) {
vizPanelState.$timeRange = new PanelTimeRange({
timeFrom: queryOptions.timeFrom,
timeShift: queryOptions.timeShift,
hideTimeOverride: queryOptions.hideTimeOverride,
});
}
return new VizPanel(vizPanelState);
}
function trackIfEmpty(grid: SceneGridLayout) {
getDashboardSceneFor(grid).setState({ isEmpty: grid.state.children.length === 0 });
const sub = grid.subscribeToState((n, p) => {
if (n.children.length !== p.children.length || n.children !== p.children) {
getDashboardSceneFor(grid).setState({ isEmpty: n.children.length === 0 });
}
});
return () => {
sub.unsubscribe();
};
}
function getPanelDataSource(panel: PanelKind): DataSourceRef | undefined {
if (!panel.spec.data?.spec.queries?.length) {
return undefined;
}
let datasource: DataSourceRef | undefined = undefined;
let isMixedDatasource = false;
panel.spec.data.spec.queries.forEach((query) => {
if (!datasource) {
datasource = query.spec.datasource;
} else if (datasource.uid !== query.spec.datasource.uid || datasource.type !== query.spec.datasource.type) {
isMixedDatasource = true;
}
});
return isMixedDatasource ? { type: 'mixed', uid: MIXED_DATASOURCE_NAME } : undefined;
}
function panelQueryKindToSceneQuery(query: PanelQueryKind): SceneDataQuery {
return {
refId: query.spec.refId,
datasource: query.spec.datasource,
hide: query.spec.hidden,
...query.spec.query.spec,
};
}
export function createPanelDataProvider(panelKind: PanelKind): SceneDataProvider | undefined {
const panel = panelKind.spec;
const targets = panel.data?.spec.queries ?? [];
// Skip setting query runner for panels without queries
if (!targets?.length) {
return undefined;
}
// Skip setting query runner for panel plugins with skipDataQuery
if (config.panels[panel.vizConfig.kind]?.skipDataQuery) {
return undefined;
}
let dataProvider: SceneDataProvider | undefined = undefined;
const datasource = getPanelDataSource(panelKind);
dataProvider = new SceneQueryRunner({
datasource,
queries: targets.map(panelQueryKindToSceneQuery),
maxDataPoints: panel.data.spec.queryOptions.maxDataPoints ?? undefined,
maxDataPointsFromWidth: true,
cacheTimeout: panel.data.spec.queryOptions.cacheTimeout,
queryCachingTTL: panel.data.spec.queryOptions.queryCachingTTL,
minInterval: panel.data.spec.queryOptions.interval ?? undefined,
dataLayerFilter: {
// FIXME: This is asking for a number as panel ID but here the uid of a panel is string
panelId: Number.isNaN(parseInt(panel.uid, 10)) ? 0 : parseInt(panel.uid, 10),
},
$behaviors: [new DashboardDatasourceBehaviour({})],
});
// Wrap inner data provider in a data transformer
return new SceneDataTransformer({
$data: dataProvider,
transformations: panel.data.spec.transformations.map((transformation) => transformation.spec),
});
}
function getVariables(dashboard: DashboardV2Spec): SceneVariableSet | undefined {
let variables: SceneVariableSet | undefined;
if (dashboard.variables.length) {
if (false) {
// FIXME: isSnapshot is not added to the schema yet
//if (dashboard.meta?.isSnapshot) {
// in the old model we use .meta.isSnapshot but meta is not persisted
// variables = createVariablesForSnapshot(dashboard);
} else {
variables = createVariablesForDashboard(dashboard);
}
} else {
// Create empty variable set
variables = new SceneVariableSet({
variables: [],
});
}
return variables;
}
function createVariablesForDashboard(dashboard: DashboardV2Spec) {
const variableObjects = dashboard.variables
.map((v) => {
try {
return createSceneVariableFromVariableModel(v);
} catch (err) {
console.error(err);
return null;
}
})
// TODO: Remove filter
// Added temporarily to allow skipping non-compatible variables
.filter((v): v is SceneVariable => Boolean(v));
return new SceneVariableSet({
variables: variableObjects,
});
}
function createSceneVariableFromVariableModel(variable: TypedVariableModelv2): SceneVariable {
const commonProperties = {
name: variable.spec.name,
label: variable.spec.label,
description: variable.spec.description,
};
if (variable.kind === defaultAdhocVariableKind().kind) {
return new AdHocFiltersVariable({
...commonProperties,
description: variable.spec.description,
skipUrlSync: variable.spec.skipUrlSync,
hide: transformVariableHideToEnumV1(variable.spec.hide),
datasource: variable.spec.datasource,
applyMode: 'auto',
filters: variable.spec.filters ?? [],
baseFilters: variable.spec.baseFilters ?? [],
defaultKeys: variable.spec.defaultKeys,
useQueriesAsFilterForOptions: true,
layout: config.featureToggles.newFiltersUI ? 'combobox' : undefined,
supportsMultiValueOperators: Boolean(
getDataSourceSrv().getInstanceSettings(variable.spec.datasource)?.meta.multiValueFilterOperators
),
});
}
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,
includeAll: variable.spec.includeAll,
defaultToAll: Boolean(variable.spec.includeAll),
skipUrlSync: variable.spec.skipUrlSync,
hide: transformVariableHideToEnumV1(variable.spec.hide),
});
} else if (variable.kind === defaultQueryVariableKind().kind) {
return new QueryVariable({
...commonProperties,
value: variable.spec.current?.value ?? '',
text: variable.spec.current?.text ?? '',
query: getDataQueryForVariable(variable),
datasource: variable.spec.datasource,
sort: transformSortVariableToEnumV1(variable.spec.sort),
refresh: transformVariableRefreshToEnumV1(variable.spec.refresh),
regex: variable.spec.regex,
allValue: variable.spec.allValue || undefined,
includeAll: variable.spec.includeAll,
defaultToAll: Boolean(variable.spec.includeAll),
isMulti: variable.spec.multi,
skipUrlSync: variable.spec.skipUrlSync,
hide: transformVariableHideToEnumV1(variable.spec.hide),
definition: variable.spec.definition,
});
} else if (variable.kind === defaultDatasourceVariableKind().kind) {
return new DataSourceVariable({
...commonProperties,
value: variable.spec.current?.value ?? '',
text: variable.spec.current?.text ?? '',
regex: variable.spec.regex,
pluginId: variable.spec.pluginId,
allValue: variable.spec.allValue || undefined,
includeAll: variable.spec.includeAll,
defaultToAll: Boolean(variable.spec.includeAll),
skipUrlSync: variable.spec.skipUrlSync,
isMulti: variable.spec.multi,
hide: transformVariableHideToEnumV1(variable.spec.hide),
defaultOptionEnabled:
variable.spec.current?.value === DEFAULT_DATASOURCE && variable.spec.current?.text === 'default',
});
} else if (variable.kind === defaultIntervalVariableKind().kind) {
const intervals = getIntervalsFromQueryString(variable.spec.query);
const currentInterval = getCurrentValueForOldIntervalModel(variable, intervals);
return new IntervalVariable({
...commonProperties,
value: currentInterval,
intervals: intervals,
autoEnabled: variable.spec.auto,
autoStepCount: variable.spec.auto_count,
autoMinInterval: variable.spec.auto_min,
refresh: transformVariableRefreshToEnumV1(variable.spec.refresh),
skipUrlSync: variable.spec.skipUrlSync,
hide: transformVariableHideToEnumV1(variable.spec.hide),
});
} else if (variable.kind === defaultConstantVariableKind().kind) {
return new ConstantVariable({
...commonProperties,
value: variable.spec.query,
skipUrlSync: variable.spec.skipUrlSync,
hide: transformVariableHideToEnumV1(variable.spec.hide),
});
} else if (variable.kind === defaultTextVariableKind().kind) {
let val;
if (!variable?.spec.current?.value) {
val = variable.spec.query;
} else {
if (typeof variable.spec.current.value === 'string') {
val = variable.spec.current.value;
} else {
val = variable.spec.current.value[0];
}
}
return new TextBoxVariable({
...commonProperties,
value: val,
skipUrlSync: variable.spec.skipUrlSync,
hide: transformVariableHideToEnumV1(variable.spec.hide),
});
} else if (config.featureToggles.groupByVariable && variable.kind === defaultGroupByVariableKind().kind) {
return new GroupByVariable({
...commonProperties,
datasource: variable.spec.datasource,
value: variable.spec.current?.value || [],
text: variable.spec.current?.text || [],
skipUrlSync: variable.spec.skipUrlSync,
hide: transformVariableHideToEnumV1(variable.spec.hide),
// @ts-expect-error
defaultOptions: variable.options,
});
} else {
throw new Error(`Scenes: Unsupported variable type ${variable.kind}`);
}
}
function getDataQueryForVariable(variable: QueryVariableKind) {
return typeof variable.spec.query !== 'string'
? {
...variable.spec.query.spec,
refId: variable.spec.query.spec.refId ?? 'A',
}
: (variable.spec.query ?? '');
}
export function getCurrentValueForOldIntervalModel(variable: IntervalVariableKind, intervals: string[]): string {
const selectedInterval = Array.isArray(variable.spec.current.value)
? variable.spec.current.value[0]
: variable.spec.current.value;
// If the interval is the old auto format, return the new auto interval from scenes.
if (selectedInterval.startsWith('$__auto_interval_')) {
return '$__auto';
}
// Check if the selected interval is valid.
if (intervals.includes(selectedInterval)) {
return selectedInterval;
}
// If the selected interval is not valid, return the first valid interval.
return intervals[0];
}
export function createVariablesForSnapshot(dashboard: DashboardV2Spec): SceneVariableSet {
const variableObjects = dashboard.variables
.map((v) => {
try {
// for adhoc we are using the AdHocFiltersVariable from scenes becuase of its complexity
if (v.kind === 'AdhocVariable') {
return new AdHocFiltersVariable({
name: v.spec.name,
label: v.spec.label,
readOnly: true,
description: v.spec.description,
skipUrlSync: v.spec.skipUrlSync,
hide: transformVariableHideToEnumV1(v.spec.hide),
datasource: v.spec.datasource,
applyMode: 'auto',
filters: v.spec.filters ?? [],
baseFilters: v.spec.baseFilters ?? [],
defaultKeys: v.spec.defaultKeys,
useQueriesAsFilterForOptions: true,
layout: config.featureToggles.newFiltersUI ? 'combobox' : undefined,
supportsMultiValueOperators: Boolean(
getDataSourceSrv().getInstanceSettings(v.spec.datasource)?.meta.multiValueFilterOperators
),
});
}
// for other variable types we are using the SnapshotVariable
return createSnapshotVariable(v);
} catch (err) {
console.error(err);
return null;
}
})
// TODO: Remove filter
// Added temporarily to allow skipping non-compatible variables
.filter((v): v is SceneVariable => Boolean(v));
return new SceneVariableSet({
variables: variableObjects,
});
}
/** Snapshots variables are read-only and should not be updated */
export function createSnapshotVariable(variable: TypedVariableModelv2): SceneVariable {
let snapshotVariable: SnapshotVariable;
let current: { value: string | string[]; text: string | string[] };
if (variable.kind === 'IntervalVariable') {
const intervals = getIntervalsFromQueryString(variable.spec.query);
const currentInterval = getCurrentValueForOldIntervalModel(variable, intervals);
snapshotVariable = new SnapshotVariable({
name: variable.spec.name,
label: variable.spec.label,
description: variable.spec.description,
value: currentInterval,
text: currentInterval,
hide: transformVariableHideToEnumV1(variable.spec.hide),
});
return snapshotVariable;
}
if (variable.kind === 'ConstantVariable' || variable.kind === 'AdhocVariable') {
current = {
value: '',
text: '',
};
} else {
current = {
value: variable.spec.current?.value ?? '',
text: variable.spec.current?.text ?? '',
};
}
snapshotVariable = new SnapshotVariable({
name: variable.spec.name,
label: variable.spec.label,
description: variable.spec.description,
value: current?.value ?? '',
text: current?.text ?? '',
hide: transformVariableHideToEnumV1(variable.spec.hide),
});
return snapshotVariable;
}
@@ -359,7 +359,7 @@ export function buildGridItemForPanel(panel: PanelModel): DashboardGridItem {
});
}
function registerPanelInteractionsReporter(scene: DashboardScene) {
export function registerPanelInteractionsReporter(scene: DashboardScene) {
// Subscriptions set with subscribeToEvent are automatically unsubscribed when the scene deactivated
scene.subscribeToEvent(UserActionEvent, (e) => {
const { interaction } = e.payload;
@@ -0,0 +1,131 @@
import { FieldConfigSource as FieldConfigSourceV1, SpecialValueMatch as SpecialValueMatchV1 } from '@grafana/data';
import {
VariableHide as VariableHideV1,
VariableRefresh as VariableRefreshV1,
VariableSort as VariableSortV1,
DashboardCursorSync as DashboardCursorSyncV1,
defaultDashboardCursorSync,
} from '@grafana/schema';
import {
DashboardCursorSync,
MappingType,
VariableHide,
VariableRefresh,
VariableSort,
FieldConfigSource,
SpecialValueMatch,
} from '@grafana/schema/dist/esm/schema/dashboard/v2alpha0/dashboard.gen';
export function transformVariableRefreshToEnumV1(refresh?: VariableRefresh): VariableRefreshV1 {
switch (refresh) {
case VariableRefresh.Never:
return VariableRefreshV1.never;
case VariableRefresh.OnDashboardLoad:
return VariableRefreshV1.onDashboardLoad;
case VariableRefresh.OnTimeRangeChanged:
return VariableRefreshV1.onTimeRangeChanged;
default:
return VariableRefreshV1.never;
}
}
export function transformVariableHideToEnumV1(hide?: VariableHide): VariableHideV1 {
switch (hide) {
case VariableHide.DontHide:
return VariableHideV1.dontHide;
case VariableHide.HideLabel:
return VariableHideV1.hideLabel;
case VariableHide.HideVariable:
return VariableHideV1.hideVariable;
default:
return VariableHideV1.dontHide;
}
}
export function transformSortVariableToEnumV1(sort?: VariableSort): VariableSortV1 {
switch (sort) {
case VariableSort.Disabled:
return VariableSortV1.disabled;
case VariableSort.NumericalAsc:
return VariableSortV1.numericalAsc;
case VariableSort.NumericalDesc:
return VariableSortV1.numericalDesc;
case VariableSort.AlphabeticalAsc:
return VariableSortV1.alphabeticalAsc;
case VariableSort.AlphabeticalDesc:
return VariableSortV1.alphabeticalDesc;
default:
return VariableSortV1.disabled;
}
}
export function transformCursorSyncV2ToV1(cursorSync: DashboardCursorSync): DashboardCursorSyncV1 {
switch (cursorSync) {
case DashboardCursorSync.Crosshair:
return DashboardCursorSyncV1.Crosshair;
case DashboardCursorSync.Tooltip:
return DashboardCursorSyncV1.Tooltip;
case DashboardCursorSync.Off:
return DashboardCursorSyncV1.Off;
default:
return defaultDashboardCursorSync;
}
}
function transformSpecialValueMatchToV1(match: SpecialValueMatch): SpecialValueMatchV1 {
switch (match) {
case SpecialValueMatch.True:
return SpecialValueMatchV1.True;
case SpecialValueMatch.False:
return SpecialValueMatchV1.False;
case SpecialValueMatch.Null:
return SpecialValueMatchV1.Null;
case SpecialValueMatch.NotANumber:
return SpecialValueMatchV1.NaN;
case SpecialValueMatch.NullAndNaN:
return SpecialValueMatchV1.NullAndNaN;
case SpecialValueMatch.Empty:
return SpecialValueMatchV1.Empty;
default:
throw new Error(`Unknown match type: ${match}`);
}
}
export function transformValueMappingsToV1(fieldConfig: FieldConfigSource): FieldConfigSourceV1 {
return {
...fieldConfig,
defaults: {
...fieldConfig.defaults,
mappings: fieldConfig.defaults.mappings?.map((mapping) => {
switch (mapping.type) {
case 'value':
return {
...mapping,
type: MappingType.ValueToText,
};
case 'range':
return {
...mapping,
type: MappingType.RangeToText,
};
case 'regex':
return {
...mapping,
type: MappingType.RegexToText,
};
case 'special':
return {
...mapping,
options: {
...mapping.options,
match: transformSpecialValueMatchToV1(mapping.options.match),
},
type: MappingType.SpecialValue,
};
default:
return mapping;
}
}),
},
};
}
@@ -46,7 +46,7 @@ class LegacyDashboardAPI implements DashboardAPI {
}
}
interface DashboardWithAccessInfo extends Resource<DashboardDataDTO, 'DashboardWithAccessInfo'> {
export interface DashboardWithAccessInfo<T = DashboardDataDTO> extends Resource<T, 'DashboardWithAccessInfo'> {
access: Object; // TODO...
}