GeneralSettings: Edit general dashboards settings to scenes (#78492)

This commit is contained in:
Ivan Ortega Alba
2023-12-01 16:04:56 +01:00
committed by GitHub
parent c354c7bfff
commit e56a252158
30 changed files with 1169 additions and 133 deletions

View File

@@ -3,6 +3,8 @@ import {
sceneGraph,
SceneGridItem,
SceneGridLayout,
SceneRefreshPicker,
SceneTimeRange,
SceneQueryRunner,
SceneVariableSet,
TestVariable,
@@ -12,8 +14,11 @@ import appEvents from 'app/core/app_events';
import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv';
import { VariablesChanged } from 'app/features/variables/types';
import { dashboardSceneGraph } from '../utils/dashboardSceneGraph';
import { djb2Hash } from '../utils/djb2Hash';
import { DashboardControls } from './DashboardControls';
import { DashboardLinksControls } from './DashboardLinksControls';
import { DashboardScene, DashboardSceneState } from './DashboardScene';
describe('DashboardScene', () => {
@@ -45,12 +50,54 @@ describe('DashboardScene', () => {
expect(scene.state.isDirty).toBe(true);
// verify can discard change
scene.onDiscard();
const gridItem2 = sceneGraph.findObject(scene, (p) => p.state.key === 'griditem-1') as SceneGridItem;
expect(gridItem2.state.x).toBe(0);
});
it.each`
prop | value
${'title'} | ${'new title'}
${'description'} | ${'new description'}
${'tags'} | ${['tag1', 'tag2']}
${'editable'} | ${false}
${'graphTooltip'} | ${1}
`(
'A change to $prop should set isDirty true',
({ prop, value }: { prop: keyof DashboardSceneState; value: any }) => {
scene.setState({ [prop]: value });
expect(scene.state.isDirty).toBe(true);
// TODO: Discard doesn't restore the previous state
// scene.onDiscard();
// expect(scene.state[prop]).toBe(prevState);
}
);
// TODO: Make the dashboard to restore the defaults on discard
it.skip('A change to refresh picker interval settings should set isDirty true', () => {
const refreshPicker = dashboardSceneGraph.getRefreshPicker(scene)!;
const prevState = refreshPicker.state.intervals;
refreshPicker.setState({ intervals: ['10s'] });
expect(scene.state.isDirty).toBe(true);
scene.onDiscard();
expect(refreshPicker.state.intervals).toEqual(prevState);
});
// TODO: Make the dashboard to restore the defaults on discard
it.skip('A change to time zone should set isDirty true', () => {
const timeRange = scene.state.$timeRange!;
const prevState = timeRange.state.timeZone;
timeRange.setState({ timeZone: 'UTC' });
expect(scene.state.isDirty).toBe(true);
scene.onDiscard();
expect(timeRange.state.timeZone).toBe(prevState);
});
});
});
@@ -101,6 +148,18 @@ function buildTestScene(overrides?: Partial<DashboardSceneState>) {
const scene = new DashboardScene({
title: 'hello',
uid: 'dash-1',
$timeRange: new SceneTimeRange({}),
controls: [
new DashboardControls({
variableControls: [],
linkControls: new DashboardLinksControls({}),
timeControls: [
new SceneRefreshPicker({
intervals: ['1s'],
}),
],
}),
],
body: new SceneGridLayout({
children: [
new SceneGridItem({

View File

@@ -12,6 +12,8 @@ import {
SceneObjectBase,
SceneObjectState,
SceneObjectStateChangedEvent,
SceneRefreshPicker,
SceneTimeRange,
sceneUtils,
SceneVariable,
SceneVariableDependencyConfigLike,
@@ -35,13 +37,19 @@ import { DashboardSceneUrlSync } from './DashboardSceneUrlSync';
import { ViewPanelScene } from './ViewPanelScene';
import { setupKeyboardShortcuts } from './keyboardShortcuts';
export const PERSISTED_PROPS = ['title', 'description', 'tags', 'editable', 'graphTooltip'];
export interface DashboardSceneState extends SceneObjectState {
/** The title */
title: string;
/** The description */
description?: string;
/** Tags */
tags?: string[];
/** Links */
links?: DashboardLink[];
/** Is editable */
editable?: boolean;
/** A uid when saved */
uid?: string;
/** @deprecated */
@@ -69,6 +77,7 @@ export interface DashboardSceneState extends SceneObjectState {
}
export class DashboardScene extends SceneObjectBase<DashboardSceneState> {
static listenToChangesInProps = PERSISTED_PROPS;
static Component = DashboardSceneRenderer;
/**
@@ -97,6 +106,7 @@ export class DashboardScene extends SceneObjectBase<DashboardSceneState> {
super({
title: 'Dashboard',
meta: {},
editable: true,
body: state.body ?? new SceneFlexLayout({ children: [] }),
...state,
});
@@ -223,9 +233,22 @@ export class DashboardScene extends SceneObjectBase<DashboardSceneState> {
this._changeTrackerSub = this.subscribeToEvent(
SceneObjectStateChangedEvent,
(event: SceneObjectStateChangedEvent) => {
if (event.payload.changedObject instanceof SceneRefreshPicker) {
if (Object.prototype.hasOwnProperty.call(event.payload.partialUpdate, 'intervals')) {
this.setIsDirty();
}
}
if (event.payload.changedObject instanceof SceneGridItem) {
this.setIsDirty();
}
if (event.payload.changedObject instanceof DashboardScene) {
if (Object.keys(event.payload.partialUpdate).some((key) => PERSISTED_PROPS.includes(key))) {
this.setIsDirty();
}
}
if (event.payload.changedObject instanceof SceneTimeRange) {
this.setIsDirty();
}
}
);
}

View File

@@ -186,7 +186,10 @@ exports[`transformSceneToSaveModel Given a scene with rows Should transform back
},
],
"schemaVersion": 36,
"tags": [],
"tags": [
"templating",
"gdev",
],
"templating": {
"list": [
{
@@ -231,6 +234,32 @@ exports[`transformSceneToSaveModel Given a scene with rows Should transform back
"from": "now-6h",
"to": "now",
},
"timepicker": {
"hidden": false,
"refresh_intervals": [
"5s",
"10s",
"30s",
"1m",
"5m",
"15m",
"30m",
"1h",
"2h",
"1d",
],
"time_options": [
"5m",
"15m",
"1h",
"6h",
"12h",
"24h",
"2d",
"7d",
"30d",
],
},
"timezone": "",
"title": "Repeating rows",
"uid": "Repeating-rows-uid",
@@ -238,6 +267,328 @@ exports[`transformSceneToSaveModel Given a scene with rows Should transform back
}
`;
exports[`transformSceneToSaveModel Given a simple scene with custom settings Should transform back to persisted model 1`] = `
{
"annotations": {
"list": [
{
"builtIn": 1,
"datasource": {
"type": "datasource",
"uid": "grafana",
},
"enable": true,
"hide": false,
"iconColor": "rgba(0, 211, 255, 1)",
"name": "Annotations & Alerts",
"type": "dashboard",
},
{
"datasource": {
"type": "testdata",
"uid": "gdev-testdata",
},
"enable": true,
"hide": false,
"iconColor": "red",
"name": "Enabled",
"target": {
"lines": 4,
"refId": "Anno",
"scenarioId": "annotations",
},
},
{
"datasource": {
"type": "testdata",
"uid": "gdev-testdata",
},
"enable": false,
"hide": false,
"iconColor": "yellow",
"name": "Disabled",
"target": {
"lines": 5,
"refId": "Anno",
"scenarioId": "annotations",
},
},
{
"datasource": {
"type": "testdata",
"uid": "gdev-testdata",
},
"enable": true,
"hide": true,
"iconColor": "dark-purple",
"name": "Hidden",
"target": {
"lines": 6,
"refId": "Anno",
"scenarioId": "annotations",
},
},
],
},
"description": "My custom description",
"editable": false,
"fiscalYearStartMonth": 1,
"graphTooltip": 0,
"id": 1351,
"links": [],
"panels": [
{
"datasource": {
"type": "testdata",
"uid": "PD8C576611E62080A",
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic",
},
"custom": {
"fillOpacity": 0,
"gradientMode": "none",
"lineWidth": 2,
},
},
"overrides": [],
},
"gridPos": {
"h": 8,
"w": 12,
"x": 12,
"y": 0,
},
"id": 28,
"options": {
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom",
"showLegend": true,
},
"tooltip": {
"mode": "single",
"sort": "none",
},
},
"targets": [
{
"alias": "series",
"datasource": {
"type": "testdata",
"uid": "PD8C576611E62080A",
},
"refId": "A",
"scenarioId": "random_walk",
"seriesCount": 1,
},
],
"title": "Simple time series graph ",
"transformations": [],
"transparent": false,
"type": "timeseries",
},
{
"collapsed": false,
"gridPos": {
"h": 1,
"w": 24,
"x": 0,
"y": 8,
},
"id": 5,
"panels": [],
"title": "Row title",
"type": "row",
},
{
"datasource": {
"type": "testdata",
"uid": "PD8C576611E62080A",
},
"fieldConfig": {
"defaults": {},
"overrides": [],
},
"gridPos": {
"h": 10,
"w": 12,
"x": 0,
"y": 9,
},
"id": 29,
"options": {},
"targets": [
{
"alias": "series",
"datasource": {
"type": "testdata",
"uid": "PD8C576611E62080A",
},
"refId": "A",
"scenarioId": "random_walk",
"seriesCount": 1,
},
],
"title": "panel inside row",
"transformations": [],
"transparent": false,
"type": "timeseries",
},
{
"fieldConfig": {
"defaults": {},
"overrides": [],
},
"gridPos": {
"h": 10,
"w": 11,
"x": 12,
"y": 9,
},
"id": 25,
"options": {
"code": {
"language": "plaintext",
"showLineNumbers": false,
"showMiniMap": false,
},
"content": "content",
"mode": "markdown",
},
"title": "Transparent text panel",
"transformations": [],
"transparent": true,
"type": "text",
},
],
"schemaVersion": 36,
"tags": [
"tag1",
"tag2",
],
"templating": {
"list": [
{
"auto": true,
"auto_count": 30,
"auto_min": "10s",
"current": {
"text": "1m",
"value": "1m",
},
"hide": 2,
"name": "intervalVar",
"query": "1m,10m,30m,1h,6h,12h,1d,7d,14d,30d",
"refresh": 2,
"type": "interval",
},
{
"current": {
"text": [
"a",
],
"value": [
"a",
],
},
"includeAll": true,
"multi": true,
"name": "customVar",
"options": [],
"query": "a, b, c",
"type": "custom",
},
{
"current": {
"text": "gdev-testdata",
"value": "PD8C576611E62080A",
},
"includeAll": false,
"name": "dsVar",
"options": [],
"query": "grafana-testdata-datasource",
"refresh": 1,
"regex": "",
"type": "datasource",
},
{
"current": {
"text": "A",
"value": "A",
},
"includeAll": false,
"name": "query0",
"options": [],
"query": {
"query": "*",
"refId": "StandardVariableQuery",
},
"refresh": 1,
"regex": "",
"type": "query",
},
{
"current": {
"text": "test",
"value": "test",
},
"hide": 2,
"name": "constant",
"query": "test",
"skipUrlSync": true,
"type": "constant",
},
{
"datasource": {
"type": "prometheus",
"uid": "wc2AL7L7k",
},
"name": "Filters",
"type": "adhoc",
},
],
},
"time": {
"from": "now-5m",
"to": "now",
},
"timepicker": {
"hidden": false,
"refresh_intervals": [
"5s",
"10s",
"30s",
"1m",
"5m",
"15m",
"30m",
"1h",
"2h",
"1d",
],
"time_options": [
"5m",
"15m",
"1h",
"6h",
"12h",
"24h",
"2d",
"7d",
"30d",
],
},
"timezone": "America/New_York",
"title": "My custom title",
"uid": "nP8rcffGkasd",
"weekStart": "monday",
}
`;
exports[`transformSceneToSaveModel Given a simple scene with variables Should transform back to persisted model 1`] = `
{
"annotations": {
@@ -436,7 +787,11 @@ exports[`transformSceneToSaveModel Given a simple scene with variables Should tr
},
],
"schemaVersion": 36,
"tags": [],
"tags": [
"gdev",
"graph-ng",
"demo",
],
"templating": {
"list": [
{
@@ -523,6 +878,31 @@ exports[`transformSceneToSaveModel Given a simple scene with variables Should tr
"from": "now-5m",
"to": "now",
},
"timepicker": {
"hidden": false,
"refresh_intervals": [
"10s",
"30s",
"1m",
"5m",
"15m",
"30m",
"1h",
"2h",
"1d",
],
"time_options": [
"5m",
"15m",
"1h",
"6h",
"12h",
"24h",
"2d",
"7d",
"30d",
],
},
"timezone": "America/New_York",
"title": "Dashboard to load1",
"uid": "nP8rcffGkasd",

View File

@@ -60,7 +60,7 @@
},
"editable": true,
"fiscalYearStartMonth": 1,
"graphTooltip": 0,
"graphTooltip": 1,
"id": 1351,
"links": [],
"liveNow": false,

View File

@@ -229,6 +229,8 @@ export function createDashboardSceneFromDashboardModel(oldModel: DashboardModel)
links: oldModel.links || [],
uid: oldModel.uid,
id: oldModel.id,
description: oldModel.description,
editable: oldModel.editable,
meta: oldModel.meta,
body: new SceneGridLayout({
isLazy: true,

View File

@@ -168,6 +168,31 @@ describe('transformSceneToSaveModel', () => {
getPluginLinkExtensionsMock.mockReturnValue({ extensions: [] });
});
describe('Given a simple scene with custom settings', () => {
it('Should transform back to persisted model', () => {
const dashboardWithCustomSettings = {
...dashboard_to_load1,
title: 'My custom title',
description: 'My custom description',
tags: ['tag1', 'tag2'],
timezone: 'America/New_York',
weekStart: 'monday',
graphTooltip: 1,
editable: false,
timepicker: {
...dashboard_to_load1.timepicker,
refresh_intervals: ['5m', '15m', '30m', '1h'],
time_options: ['5m', '15m', '30m'],
hidden: true,
},
};
const scene = transformSaveModelToScene({ dashboard: dashboardWithCustomSettings as any, meta: {} });
const saveModel = transformSceneToSaveModel(scene);
expect(saveModel).toMatchSnapshot();
});
});
describe('Given a simple scene with variables', () => {
it('Should transform back to persisted model', () => {
const scene = transformSaveModelToScene({ dashboard: dashboard_to_load1 as any, meta: {} });

View File

@@ -1,5 +1,6 @@
import { isEmptyObject, ScopedVars, TimeRange } from '@grafana/data';
import {
behaviors,
SceneDataLayers,
SceneGridItem,
SceneGridItemLike,
@@ -11,12 +12,14 @@ import {
SceneVariableSet,
AdHocFilterSet,
LocalValueVariable,
SceneRefreshPicker,
} from '@grafana/scenes';
import {
AnnotationQuery,
Dashboard,
DataTransformerConfig,
defaultDashboard,
defaultTimePickerConfig,
FieldConfigSource,
Panel,
RowPanel,
@@ -47,8 +50,9 @@ export function transformSceneToSaveModel(scene: DashboardScene, isSnapshot = fa
const data = state.$data;
const variablesSet = state.$variables;
const body = state.body;
let refresh_intervals = defaultTimePickerConfig.refresh_intervals;
let panels: Panel[] = [];
let graphTooltip = defaultDashboard.graphTooltip;
let variables: VariableModel[] = [];
if (body instanceof SceneGridLayout) {
@@ -83,6 +87,12 @@ export function transformSceneToSaveModel(scene: DashboardScene, isSnapshot = fa
}
if (state.controls && state.controls[0] instanceof DashboardControls) {
const timeControls = state.controls[0].state.timeControls;
for (const control of timeControls) {
if (control instanceof SceneRefreshPicker && control.state.intervals) {
refresh_intervals = control.state.intervals;
}
}
const variableControls = state.controls[0].state.variableControls;
for (const control of variableControls) {
if (control instanceof AdHocFilterSet) {
@@ -95,15 +105,25 @@ export function transformSceneToSaveModel(scene: DashboardScene, isSnapshot = fa
}
}
if (state.$behaviors && state.$behaviors[0] instanceof behaviors.CursorSync) {
graphTooltip = state.$behaviors[0].state.sync;
}
const dashboard: Dashboard = {
...defaultDashboard,
title: state.title,
description: state.description || undefined,
uid: state.uid,
id: state.id,
editable: state.editable,
time: {
from: timeRange.from,
to: timeRange.to,
},
timepicker: {
...defaultTimePickerConfig,
refresh_intervals,
},
panels,
annotations: {
list: annotations,
@@ -114,6 +134,8 @@ export function transformSceneToSaveModel(scene: DashboardScene, isSnapshot = fa
timezone: timeRange.timeZone,
fiscalYearStartMonth: timeRange.fiscalYearStartMonth,
weekStart: timeRange.weekStart,
tags: state.tags,
graphTooltip,
};
return sortedDeepCloneWithoutNulls(dashboard);

View File

@@ -7,7 +7,6 @@ import { Page } from 'app/core/components/Page/Page';
import { NavToolbarActions } from '../scene/NavToolbarActions';
import { getDashboardSceneFor } from '../utils/utils';
import { GeneralSettingsEditView } from './GeneralSettings';
import { DashboardEditView, DashboardEditViewState, useDashboardEditPageNav } from './utils';
export interface AnnotationsEditViewState extends DashboardEditViewState {}
@@ -17,7 +16,7 @@ export class AnnotationsEditView extends SceneObjectBase<AnnotationsEditViewStat
return 'annotations';
}
static Component = ({ model }: SceneComponentProps<GeneralSettingsEditView>) => {
static Component = ({ model }: SceneComponentProps<AnnotationsEditView>) => {
const dashboard = getDashboardSceneFor(model);
const { navModel, pageNav } = useDashboardEditPageNav(dashboard, model.getUrlKey());

View File

@@ -1,33 +0,0 @@
import React from 'react';
import { PageLayoutType } from '@grafana/data';
import { SceneComponentProps, SceneObjectBase } from '@grafana/scenes';
import { Page } from 'app/core/components/Page/Page';
import { NavToolbarActions } from '../scene/NavToolbarActions';
import { getDashboardSceneFor } from '../utils/utils';
import { DashboardEditView, DashboardEditViewState, useDashboardEditPageNav } from './utils';
export interface GeneralSettingsEditViewState extends DashboardEditViewState {}
export class GeneralSettingsEditView
extends SceneObjectBase<GeneralSettingsEditViewState>
implements DashboardEditView
{
public getUrlKey(): string {
return 'settings';
}
static Component = ({ model }: SceneComponentProps<GeneralSettingsEditView>) => {
const dashboard = getDashboardSceneFor(model);
const { navModel, pageNav } = useDashboardEditPageNav(dashboard, model.getUrlKey());
return (
<Page navModel={navModel} pageNav={pageNav} layout={PageLayoutType.Standard}>
<NavToolbarActions dashboard={dashboard} />
<div>General todo</div>
</Page>
);
};
}

View File

@@ -0,0 +1,160 @@
import { behaviors, SceneGridLayout, SceneGridItem, SceneRefreshPicker, SceneTimeRange } from '@grafana/scenes';
import { DashboardCursorSync } from '@grafana/schema';
import { DashboardControls } from '../scene/DashboardControls';
import { DashboardLinksControls } from '../scene/DashboardLinksControls';
import { DashboardScene } from '../scene/DashboardScene';
import { activateFullSceneTree } from '../utils/test-utils';
import { GeneralSettingsEditView } from './GeneralSettingsEditView';
describe('GeneralSettingsEditView', () => {
describe('Dashboard state', () => {
let dashboard: DashboardScene;
let settings: GeneralSettingsEditView;
beforeEach(async () => {
const result = await buildTestScene();
dashboard = result.dashboard;
settings = result.settings;
});
it('should return the correct urlKey', () => {
expect(settings.getUrlKey()).toBe('settings');
});
it('should return the dashboard', () => {
expect(settings.getDashboard()).toBe(dashboard);
});
it('should return the dashboard time range', () => {
expect(settings.getTimeRange()).toBe(dashboard.state.$timeRange);
});
it('should return the dashboard refresh picker', () => {
expect(settings.getRefreshPicker()).toBe(
(dashboard.state?.controls?.[0] as DashboardControls)?.state?.timeControls?.[0]
);
});
it('should return the cursor sync', () => {
expect(settings.getCursorSync()).toBe(dashboard.state.$behaviors?.[0]);
});
});
describe('Dashboard updates', () => {
let dashboard: DashboardScene;
let settings: GeneralSettingsEditView;
beforeEach(async () => {
const result = await buildTestScene();
dashboard = result.dashboard;
settings = result.settings;
});
it('should have isDirty false', () => {
expect(dashboard.state.isDirty).toBeFalsy();
});
it('A change to title updates the dashboard state', () => {
settings.onTitleChange('new title');
expect(dashboard.state.title).toBe('new title');
});
it('A change to description updates the dashboard state', () => {
settings.onDescriptionChange('new description');
expect(dashboard.state.description).toBe('new description');
});
it('A change to description updates the dashboard state', () => {
settings.onTagsChange(['tag1', 'tag2']);
expect(dashboard.state.tags).toEqual(['tag1', 'tag2']);
});
it('A change to editable permissions updates the dashboard state', () => {
settings.onEditableChange(false);
expect(dashboard.state.editable).toBe(false);
});
it('A change to timezone updates the dashboard state', () => {
settings.onTimeZoneChange('UTC');
expect(dashboard.state.$timeRange?.state.timeZone).toBe('UTC');
});
it('A change to week start updates the dashboard state', () => {
settings.onWeekStartChange('monday');
expect(settings.getTimeRange().state.weekStart).toBe('monday');
});
it('A change to refresh interval updates the dashboard state', () => {
settings.onRefreshIntervalChange(['5s']);
expect(settings.getRefreshPicker()?.state?.intervals).toEqual(['5s']);
});
it('A change to folder updates the dashboard state', () => {
settings.onFolderChange('folder-2', 'folder 2');
expect(dashboard.state.meta.folderUid).toBe('folder-2');
expect(dashboard.state.meta.folderTitle).toBe('folder 2');
});
it('A change to tooltip settings updates the dashboard state', () => {
settings.onTooltipChange(DashboardCursorSync.Crosshair);
expect(settings.getCursorSync()?.state.sync).toBe(DashboardCursorSync.Crosshair);
});
});
});
async function buildTestScene() {
const dashboard = new DashboardScene({
$timeRange: new SceneTimeRange({}),
$behaviors: [new behaviors.CursorSync({ sync: DashboardCursorSync.Off })],
controls: [
new DashboardControls({
variableControls: [],
linkControls: new DashboardLinksControls({}),
timeControls: [
new SceneRefreshPicker({
intervals: ['1s'],
}),
],
}),
],
title: 'hello',
uid: 'dash-1',
meta: {
canEdit: true,
},
body: new SceneGridLayout({
children: [
new SceneGridItem({
key: 'griditem-1',
x: 0,
y: 0,
width: 10,
height: 12,
body: undefined,
}),
],
}),
});
const settings = new GeneralSettingsEditView({
dashboardRef: dashboard.getRef(),
});
activateFullSceneTree(dashboard);
await new Promise((r) => setTimeout(r, 1));
dashboard.onEnterEditMode();
settings.activate();
return { dashboard, settings };
}

View File

@@ -0,0 +1,276 @@
import React, { ChangeEvent } from 'react';
import { PageLayoutType } from '@grafana/data';
import {
behaviors,
SceneComponentProps,
SceneObjectBase,
SceneObjectRef,
SceneTimePicker,
sceneGraph,
} from '@grafana/scenes';
import { TimeZone } from '@grafana/schema';
import {
Box,
CollapsableSection,
Field,
HorizontalGroup,
Input,
Label,
RadioButtonGroup,
TagsInput,
TextArea,
} from '@grafana/ui';
import { Page } from 'app/core/components/Page/Page';
import { FolderPicker } from 'app/core/components/Select/FolderPicker';
import { t, Trans } from 'app/core/internationalization';
import { TimePickerSettings } from 'app/features/dashboard/components/DashboardSettings/TimePickerSettings';
import { DeleteDashboardButton } from 'app/features/dashboard/components/DeleteDashboard/DeleteDashboardButton';
import { DashboardControls } from '../scene/DashboardControls';
import { DashboardScene } from '../scene/DashboardScene';
import { NavToolbarActions } from '../scene/NavToolbarActions';
import { dashboardSceneGraph } from '../utils/dashboardSceneGraph';
import { DashboardEditView, DashboardEditViewState, useDashboardEditPageNav } from './utils';
export interface GeneralSettingsEditViewState extends DashboardEditViewState {
dashboardRef: SceneObjectRef<DashboardScene>;
}
const EDITABLE_OPTIONS = [
{ label: 'Editable', value: true },
{ label: 'Read-only', value: false },
];
const GRAPH_TOOLTIP_OPTIONS = [
{ value: 0, label: 'Default' },
{ value: 1, label: 'Shared crosshair' },
{ value: 2, label: 'Shared Tooltip' },
];
export class GeneralSettingsEditView
extends SceneObjectBase<GeneralSettingsEditViewState>
implements DashboardEditView
{
private get _dashboard(): DashboardScene {
return this.state.dashboardRef.resolve();
}
public getUrlKey(): string {
return 'settings';
}
public getDashboard(): DashboardScene {
return this._dashboard;
}
public getTimeRange() {
return sceneGraph.getTimeRange(this._dashboard);
}
public getRefreshPicker() {
return dashboardSceneGraph.getRefreshPicker(this._dashboard);
}
public getCursorSync() {
const cursorSync = this._dashboard.state.$behaviors?.find((b) => b instanceof behaviors.CursorSync);
if (cursorSync instanceof behaviors.CursorSync) {
return cursorSync;
}
return;
}
public onTitleChange = (value: string) => {
this._dashboard.setState({ title: value });
};
public onDescriptionChange = (value: string) => {
this._dashboard.setState({ description: value });
};
public onTagsChange = (value: string[]) => {
this._dashboard.setState({ tags: value });
};
public onFolderChange = (newUID: string, newTitle: string) => {
const newMeta = {
...this._dashboard.state.meta,
folderUid: newUID || this._dashboard.state.meta.folderUid,
folderTitle: newTitle || this._dashboard.state.meta.folderTitle,
hasUnsavedFolderChange: true,
};
this._dashboard.setState({ meta: newMeta });
};
public onEditableChange = (value: boolean) => {
this._dashboard.setState({ editable: value });
};
public onTimeZoneChange = (value: TimeZone) => {
this.getTimeRange().setState({
timeZone: value,
});
};
public onWeekStartChange = (value: string) => {
this.getTimeRange().setState({
weekStart: value,
});
};
public onRefreshIntervalChange = (value: string[]) => {
const control = this.getRefreshPicker();
control?.setState({
intervals: value,
});
};
public onNowDelayChange = (value: string) => {
// TODO: Figure out how to store nowDelay in Dashboard Scene
};
public onHideTimePickerChange = (value: boolean) => {
if (this._dashboard.state.controls instanceof DashboardControls) {
for (const control of this._dashboard.state.controls.state.timeControls) {
if (control instanceof SceneTimePicker) {
control.setState({
// TODO: Control visibility from DashboardControls
// hidden: value,
});
}
}
}
};
public onLiveNowChange = (value: boolean) => {
// TODO: Figure out how to store liveNow in Dashboard Scene
};
public onTooltipChange = (value: number) => {
this.getCursorSync()?.setState({ sync: value });
};
static Component = ({ model }: SceneComponentProps<GeneralSettingsEditView>) => {
const { navModel, pageNav } = useDashboardEditPageNav(model.getDashboard(), model.getUrlKey());
const { title, description, tags, meta, editable, overlay } = model.getDashboard().useState();
const { sync: graphTooltip } = model.getCursorSync()?.useState() || {};
const { timeZone, weekStart } = model.getTimeRange().useState();
const { intervals } = model.getRefreshPicker()?.useState() || {};
return (
<Page navModel={navModel} pageNav={pageNav} layout={PageLayoutType.Standard}>
<NavToolbarActions dashboard={model.getDashboard()} />
<div style={{ maxWidth: '600px' }}>
<Box marginBottom={5}>
<Field
label={
<HorizontalGroup justify="space-between">
<Label htmlFor="title-input">
<Trans i18nKey="dashboard-settings.general.title-label">Title</Trans>
</Label>
{/* TODO: Make the component use persisted model */}
{/* {config.featureToggles.dashgpt && (
<GenAIDashTitleButton onGenerate={onTitleChange} dashboard={dashboard} />
)} */}
</HorizontalGroup>
}
>
<Input
id="title-input"
name="title"
defaultValue={title}
onBlur={(e: ChangeEvent<HTMLInputElement>) => model.onTitleChange(e.target.value)}
/>
</Field>
<Field
label={
<HorizontalGroup justify="space-between">
<Label htmlFor="description-input">
{t('dashboard-settings.general.description-label', 'Description')}
</Label>
{/* {config.featureToggles.dashgpt && (
<GenAIDashDescriptionButton onGenerate={onDescriptionChange} dashboard={dashboard} />
)} */}
</HorizontalGroup>
}
>
<TextArea
id="description-input"
name="description"
defaultValue={description}
onBlur={(e: ChangeEvent<HTMLTextAreaElement>) => model.onDescriptionChange(e.target.value)}
/>
</Field>
<Field label={t('dashboard-settings.general.tags-label', 'Tags')}>
<TagsInput id="tags-input" tags={tags} onChange={model.onTagsChange} width={40} />
</Field>
<Field label={t('dashboard-settings.general.folder-label', 'Folder')}>
<FolderPicker
value={meta.folderUid}
onChange={model.onFolderChange}
// TODO deprecated props that can be removed once NestedFolderPicker is enabled by default
initialTitle={meta.folderTitle}
inputId="dashboard-folder-input"
enableCreateNew
skipInitialLoad
/>
</Field>
<Field
label={t('dashboard-settings.general.editable-label', 'Editable')}
description={t(
'dashboard-settings.general.editable-description',
'Set to read-only to disable all editing. Reload the dashboard for changes to take effect'
)}
>
<RadioButtonGroup value={editable} options={EDITABLE_OPTIONS} onChange={model.onEditableChange} />
</Field>
</Box>
<TimePickerSettings
onTimeZoneChange={model.onTimeZoneChange}
onWeekStartChange={model.onWeekStartChange}
onRefreshIntervalChange={model.onRefreshIntervalChange}
onNowDelayChange={model.onNowDelayChange}
onHideTimePickerChange={model.onHideTimePickerChange}
onLiveNowChange={model.onLiveNowChange}
refreshIntervals={intervals}
// TODO: Control visibility of time picker
// timePickerHidden={timepicker?.state?.hidden}
// TODO: Implement this in dashboard scene
// nowDelay={timepicker.nowDelay || ''}
// TODO: Implement this in dashboard scene
// liveNow={liveNow}
liveNow={false}
timezone={timeZone || ''}
weekStart={weekStart || ''}
/>
{/* @todo: Update "Graph tooltip" description to remove prompt about reloading when resolving #46581 */}
<CollapsableSection
label={t('dashboard-settings.general.panel-options-label', 'Panel options')}
isOpen={true}
>
<Field
label={t('dashboard-settings.general.panel-options-graph-tooltip-label', 'Graph tooltip')}
description={t(
'dashboard-settings.general.panel-options-graph-tooltip-description',
'Controls tooltip and hover highlight behavior across different panels. Reload the dashboard for changes to take effect'
)}
>
<RadioButtonGroup onChange={model.onTooltipChange} options={GRAPH_TOOLTIP_OPTIONS} value={graphTooltip} />
</Field>
</CollapsableSection>
<Box marginTop={3}>{meta.canDelete && <DeleteDashboardButton />}</Box>
</div>
{overlay && <overlay.Component model={overlay} />}
</Page>
);
};
}

View File

@@ -7,7 +7,6 @@ import { Page } from 'app/core/components/Page/Page';
import { NavToolbarActions } from '../scene/NavToolbarActions';
import { getDashboardSceneFor } from '../utils/utils';
import { GeneralSettingsEditView } from './GeneralSettings';
import { DashboardEditView, DashboardEditViewState, useDashboardEditPageNav } from './utils';
export interface VariablesEditViewState extends DashboardEditViewState {}
@@ -17,7 +16,7 @@ export class VariablesEditView extends SceneObjectBase<VariablesEditViewState> i
return 'variables';
}
static Component = ({ model }: SceneComponentProps<GeneralSettingsEditView>) => {
static Component = ({ model }: SceneComponentProps<VariablesEditView>) => {
const dashboard = getDashboardSceneFor(model);
const { navModel, pageNav } = useDashboardEditPageNav(dashboard, model.getUrlKey());

View File

@@ -10,7 +10,7 @@ import { DashboardScene } from '../scene/DashboardScene';
import { AnnotationsEditView } from './AnnotationsEditView';
import { DashboardLinksEditView } from './DashboardLinksEditView';
import { GeneralSettingsEditView } from './GeneralSettings';
import { GeneralSettingsEditView } from './GeneralSettingsEditView';
import { VariablesEditView } from './VariablesEditView';
export interface DashboardEditViewState extends SceneObjectState {

View File

@@ -1,7 +1,17 @@
import { TimeRangeUpdatedEvent } from '@grafana/runtime';
import { behaviors, SceneGridItem, SceneGridLayout, SceneQueryRunner, SceneTimeRange, VizPanel } from '@grafana/scenes';
import {
behaviors,
SceneGridItem,
SceneGridLayout,
SceneRefreshPicker,
SceneQueryRunner,
SceneTimeRange,
VizPanel,
} from '@grafana/scenes';
import { DashboardCursorSync } from '@grafana/schema';
import { DashboardControls } from '../scene/DashboardControls';
import { DashboardLinksControls } from '../scene/DashboardLinksControls';
import { DashboardScene } from '../scene/DashboardScene';
import { DashboardModelCompatibilityWrapper } from './DashboardModelCompatibilityWrapper';
@@ -12,8 +22,14 @@ describe('DashboardModelCompatibilityWrapper', () => {
expect(wrapper.uid).toBe('dash-1');
expect(wrapper.title).toBe('hello');
expect(wrapper.description).toBe('hello description');
expect(wrapper.editable).toBe(false);
expect(wrapper.graphTooltip).toBe(DashboardCursorSync.Off);
expect(wrapper.tags).toEqual(['hello-tag']);
expect(wrapper.time.from).toBe('now-6h');
expect(wrapper.timezone).toBe('America/New_York');
expect(wrapper.weekStart).toBe('friday');
expect(wrapper.timepicker.refresh_intervals).toEqual(['1s']);
});
it('Shared tooltip functions', () => {
@@ -25,6 +41,7 @@ describe('DashboardModelCompatibilityWrapper', () => {
expect(wrapper.sharedTooltipModeEnabled()).toBe(true);
expect(wrapper.sharedCrosshairModeOnly()).toBe(true);
expect(wrapper.graphTooltip).toBe(DashboardCursorSync.Crosshair);
});
it('Get timezone from time range', () => {
@@ -60,10 +77,25 @@ describe('DashboardModelCompatibilityWrapper', () => {
function setup() {
const scene = new DashboardScene({
title: 'hello',
description: 'hello description',
tags: ['hello-tag'],
uid: 'dash-1',
editable: false,
$timeRange: new SceneTimeRange({
weekStart: 'friday',
timeZone: 'America/New_York',
}),
controls: [
new DashboardControls({
variableControls: [],
linkControls: new DashboardLinksControls({}),
timeControls: [
new SceneRefreshPicker({
intervals: ['1s'],
}),
],
}),
],
body: new SceneGridLayout({
children: [
new SceneGridItem({

View File

@@ -14,6 +14,7 @@ import {
import { DashboardScene } from '../scene/DashboardScene';
import { dashboardSceneGraph } from './dashboardSceneGraph';
import { findVizPanelByKey, getPanelIdForVizPanel, getVizPanelKeyForPanelId } from './utils';
/**
@@ -47,6 +48,36 @@ export class DashboardModelCompatibilityWrapper {
return this._scene.state.title;
}
public get description() {
return this._scene.state.description;
}
public get editable() {
return this._scene.state.editable;
}
public get graphTooltip() {
return this._getSyncMode();
}
public get timepicker() {
return {
refresh_intervals: dashboardSceneGraph.getRefreshPicker(this._scene)?.state.intervals,
};
}
public get timezone() {
return this.getTimezone();
}
public get weekStart() {
return sceneGraph.getTimeRange(this._scene).state.weekStart;
}
public get tags() {
return this._scene.state.tags;
}
public get meta() {
return this._scene.state.meta;
}

View File

@@ -12,7 +12,6 @@ describe('dashboardSceneGraph', () => {
...(dashboard_to_load as unknown as DashboardDataDTO),
timepicker: {
hidden: true,
collapse: false,
refresh_intervals: [],
time_options: [],
},
@@ -36,4 +35,34 @@ describe('dashboardSceneGraph', () => {
expect(timePicker).not.toBeNull();
});
});
describe('getRefreshPicker', () => {
it('should return null if no refresh picker', () => {
const dashboard: DashboardDataDTO = {
...(dashboard_to_load as unknown as DashboardDataDTO),
timepicker: {
hidden: true,
refresh_intervals: [],
time_options: [],
},
};
const scene = transformSaveModelToScene({
dashboard: dashboard as unknown as DashboardDataDTO,
meta: {},
});
const refreshPicker = dashboardSceneGraph.getRefreshPicker(scene);
expect(refreshPicker).toBeNull();
});
it('should return refresh picker', () => {
const scene = transformSaveModelToScene({
dashboard: dashboard_to_load as unknown as DashboardDataDTO,
meta: {},
});
const refreshPicker = dashboardSceneGraph.getRefreshPicker(scene);
expect(refreshPicker).not.toBeNull();
});
});
});

View File

@@ -1,4 +1,4 @@
import { SceneTimePicker } from '@grafana/scenes';
import { SceneTimePicker, SceneRefreshPicker } from '@grafana/scenes';
import { DashboardControls } from '../scene/DashboardControls';
import { DashboardScene } from '../scene/DashboardScene';
@@ -17,6 +17,18 @@ function getTimePicker(scene: DashboardScene) {
return null;
}
function getRefreshPicker(scene: DashboardScene) {
if (scene.state.controls?.[0] instanceof DashboardControls) {
for (const control of scene.state.controls[0].state.timeControls) {
if (control instanceof SceneRefreshPicker) {
return control;
}
}
}
return null;
}
export const dashboardSceneGraph = {
getTimePicker,
getRefreshPicker,
};

View File

@@ -6,7 +6,7 @@ import { t } from 'app/core/internationalization';
import { getTimeSrv } from '../../services/TimeSrv';
export interface Props {
refreshIntervals: string[];
refreshIntervals?: string[];
onRefreshIntervalChange: (interval: string[]) => void;
getIntervalsFunc?: typeof getValidIntervals;
validateIntervalsFunc?: typeof validateIntervals;

View File

@@ -30,7 +30,6 @@ const setupTestContext = (options: Partial<Props>) => {
timepicker: {
refresh_intervals: ['5s', '10s', '30s', '1m', '5m', '15m', '30m', '1h', '2h', '1d', '2d'],
time_options: ['5m', '15m', '1h', '6h', '12h', '24h', '2d', '7d', '30d'],
collapse: true,
hidden: false,
},
timezone: 'utc',

View File

@@ -222,7 +222,7 @@ export function GeneralSettingsUnconnected({
</Field>
</CollapsableSection>
<Box marginTop={3}>{dashboard.meta.canDelete && <DeleteDashboardButton dashboard={dashboard} />}</Box>
<Box marginTop={3}>{dashboard.meta.canDelete && <DeleteDashboardButton />}</Box>
</div>
</Page>
);

View File

@@ -15,9 +15,9 @@ interface Props {
onNowDelayChange: (nowDelay: string) => void;
onHideTimePickerChange: (hide: boolean) => void;
onLiveNowChange: (liveNow: boolean) => void;
refreshIntervals: string[];
timePickerHidden: boolean;
nowDelay: string;
refreshIntervals?: string[];
timePickerHidden?: boolean;
nowDelay?: string;
timezone: TimeZone;
weekStart: string;
liveNow: boolean;

View File

@@ -3,29 +3,28 @@ import React from 'react';
import { Button, ModalsController } from '@grafana/ui';
import { Trans } from 'app/core/internationalization';
import { DashboardModel } from '../../state';
import { getDashboardSrv } from '../../services/DashboardSrv';
import { DeleteDashboardModal } from './DeleteDashboardModal';
type Props = {
dashboard: DashboardModel;
export const DeleteDashboardButton = () => {
const dashboard = getDashboardSrv().getCurrent()!;
return (
<ModalsController>
{({ showModal, hideModal }) => (
<Button
variant="destructive"
onClick={() => {
showModal(DeleteDashboardModal, {
dashboard,
hideModal,
});
}}
aria-label="Dashboard settings page delete dashboard button"
>
<Trans i18nKey="dashboard-settings.dashboard-delete-button">Delete Dashboard</Trans>
</Button>
)}
</ModalsController>
);
};
export const DeleteDashboardButton = ({ dashboard }: Props) => (
<ModalsController>
{({ showModal, hideModal }) => (
<Button
variant="destructive"
onClick={() => {
showModal(DeleteDashboardModal, {
dashboard,
hideModal,
});
}}
aria-label="Dashboard settings page delete dashboard button"
>
<Trans i18nKey="dashboard-settings.dashboard-delete-button">Delete Dashboard</Trans>
</Button>
)}
</ModalsController>
);

View File

@@ -249,7 +249,7 @@ describe('PublicDashboardPage', () => {
...dashboardBase,
getModel: () =>
getTestDashboard({
timepicker: { hidden: false, collapse: false, refresh_intervals: [], time_options: [] },
timepicker: { hidden: false, refresh_intervals: [], time_options: [] },
}),
},
});