From e56a2521580b6746598efc71a46a5f94c1d36f2a Mon Sep 17 00:00:00 2001 From: Ivan Ortega Alba Date: Fri, 1 Dec 2023 16:04:56 +0100 Subject: [PATCH] GeneralSettings: Edit general dashboards settings to scenes (#78492) --- .betterer.results | 9 +- .../kinds/core/dashboard/schema-reference.md | 24 +- kinds/dashboard/dashboard_kind.cue | 22 +- packages/grafana-schema/src/index.gen.ts | 2 + .../raw/dashboard/x/dashboard_types.gen.ts | 44 +- .../src/veneer/dashboard.types.ts | 3 + pkg/kinds/dashboard/dashboard_spec_gen.go | 30 +- .../scene/DashboardScene.test.tsx | 63 ++- .../dashboard-scene/scene/DashboardScene.tsx | 23 ++ .../transformSceneToSaveModel.test.ts.snap | 384 +++++++++++++++++- .../testfiles/dashboard_to_load1.json | 2 +- .../transformSaveModelToScene.ts | 2 + .../transformSceneToSaveModel.test.ts | 25 ++ .../transformSceneToSaveModel.ts | 24 +- .../settings/AnnotationsEditView.tsx | 3 +- .../settings/GeneralSettings.tsx | 33 -- .../settings/GeneralSettingsEditView.test.tsx | 160 ++++++++ .../settings/GeneralSettingsEditView.tsx | 276 +++++++++++++ .../settings/VariablesEditView.tsx | 3 +- .../dashboard-scene/settings/utils.ts | 2 +- ...DashboardModelCompatibilityWrapper.test.ts | 36 +- .../DashboardModelCompatibilityWrapper.ts | 31 ++ .../utils/dashboardSceneGraph.test.ts | 31 +- .../utils/dashboardSceneGraph.ts | 14 +- .../AutoRefreshIntervals.tsx | 2 +- .../GeneralSettings.test.tsx | 1 - .../DashboardSettings/GeneralSettings.tsx | 2 +- .../DashboardSettings/TimePickerSettings.tsx | 6 +- .../DeleteDashboard/DeleteDashboardButton.tsx | 43 +- .../containers/PublicDashboardPage.test.tsx | 2 +- 30 files changed, 1169 insertions(+), 133 deletions(-) delete mode 100644 public/app/features/dashboard-scene/settings/GeneralSettings.tsx create mode 100644 public/app/features/dashboard-scene/settings/GeneralSettingsEditView.test.tsx create mode 100644 public/app/features/dashboard-scene/settings/GeneralSettingsEditView.tsx diff --git a/.betterer.results b/.betterer.results index 9b21879f02a..99eaa495034 100644 --- a/.betterer.results +++ b/.betterer.results @@ -719,7 +719,8 @@ exports[`better eslint`] = { [0, 0, 0, "Do not use any type assertions.", "2"], [0, 0, 0, "Do not use any type assertions.", "3"], [0, 0, 0, "Do not use any type assertions.", "4"], - [0, 0, 0, "Do not use any type assertions.", "5"] + [0, 0, 0, "Do not use any type assertions.", "5"], + [0, 0, 0, "Do not use any type assertions.", "6"] ], "packages/grafana-ui/src/components/ColorPicker/ColorPicker.tsx:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"], @@ -2655,6 +2656,9 @@ exports[`better eslint`] = { "public/app/features/dashboard-scene/inspect/InspectJsonTab.tsx:5381": [ [0, 0, 0, "Use data-testid for E2E selectors instead of aria-label", "0"] ], + "public/app/features/dashboard-scene/scene/DashboardScene.test.tsx:5381": [ + [0, 0, 0, "Unexpected any. Specify a different type.", "0"] + ], "public/app/features/dashboard-scene/scene/PanelMenuBehavior.tsx:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"], [0, 0, 0, "Unexpected any. Specify a different type.", "1"] @@ -2680,7 +2684,8 @@ exports[`better eslint`] = { [0, 0, 0, "Unexpected any. Specify a different type.", "4"], [0, 0, 0, "Unexpected any. Specify a different type.", "5"], [0, 0, 0, "Unexpected any. Specify a different type.", "6"], - [0, 0, 0, "Unexpected any. Specify a different type.", "7"] + [0, 0, 0, "Unexpected any. Specify a different type.", "7"], + [0, 0, 0, "Unexpected any. Specify a different type.", "8"] ], "public/app/features/dashboard-scene/serialization/transformSceneToSaveModel.ts:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"], diff --git a/docs/sources/developers/kinds/core/dashboard/schema-reference.md b/docs/sources/developers/kinds/core/dashboard/schema-reference.md index 6e9fb08c0ef..e8fb56b1c18 100644 --- a/docs/sources/developers/kinds/core/dashboard/schema-reference.md +++ b/docs/sources/developers/kinds/core/dashboard/schema-reference.md @@ -88,7 +88,7 @@ extraFields is reserved for any fields that are pulled from the API server metad | `tags` | string[] | No | | Tags associated with dashboard. | | `templating` | [object](#templating) | No | | Configured template variables | | `time` | [object](#time) | No | | Time range for dashboard.
Accepted values are relative time strings like {from: 'now-6h', to: 'now'} or absolute time strings like {from: '2020-07-10T08:00:00.000Z', to: '2020-07-10T14:00:00.000Z'}. | -| `timepicker` | [object](#timepicker) | No | | Configuration of the time picker shown at the top of a dashboard. | +| `timepicker` | [TimePickerConfig](#timepickerconfig) | No | | Time picker configuration
It defines the default config for the time picker and the refresh picker for the specific dashboard. | | `timezone` | string | No | `browser` | Timezone of dashboard. Accepted values are IANA TZDB zone ID or "browser" or "utc". | | `title` | string | No | | Title of dashboard. | | `uid` | string | No | | Unique dashboard identifier that can be generated by anyone. string (8-40) | @@ -190,6 +190,17 @@ Sensitive information stripped: queries (metric, template,annotation) and panel | `userId` | uint32 | **Yes** | | user id of the snapshot creator | | `url` | string | No | | url of the snapshot, if snapshot was shared internally | +### TimePickerConfig + +Time picker configuration +It defines the default config for the time picker and the refresh picker for the specific dashboard. + +| Property | Type | Required | Default | Description | +|---------------------|----------|----------|---------------------------------------|---------------------------------------------------------------------------------------------------| +| `hidden` | boolean | **Yes** | `false` | Whether timepicker is visible or not. | +| `refresh_intervals` | string[] | **Yes** | `[5s 10s 30s 1m 5m 15m 30m 1h 2h 1d]` | Interval options available in the refresh picker dropdown. | +| `time_options` | string[] | **Yes** | `[5m 15m 1h 6h 12h 24h 2d 7d 30d]` | Selectable options available in the time picker dropdown. Has no effect on provisioned dashboard. | + ### Panels | Property | Type | Required | Default | Description | @@ -625,17 +636,6 @@ Accepted values are relative time strings like {from: 'now-6h', to: 'now'} or ab | `from` | string | **Yes** | `now-6h` | | | `to` | string | **Yes** | `now` | | -### Timepicker - -Configuration of the time picker shown at the top of a dashboard. - -| Property | Type | Required | Default | Description | -|---------------------|----------|----------|---------------------------------------|---------------------------------------------------------------------------------------------------| -| `collapse` | boolean | **Yes** | `false` | Whether timepicker is collapsed or not. Has no effect on provisioned dashboard. | -| `hidden` | boolean | **Yes** | `false` | Whether timepicker is visible or not. | -| `refresh_intervals` | string[] | **Yes** | `[5s 10s 30s 1m 5m 15m 30m 1h 2h 1d]` | Interval options available in the refresh picker dropdown. | -| `time_options` | string[] | **Yes** | `[5m 15m 1h 6h 12h 24h 2d 7d 30d]` | Selectable options available in the time picker dropdown. Has no effect on provisioned dashboard. | - ### Status | Property | Type | Required | Default | Description | diff --git a/kinds/dashboard/dashboard_kind.cue b/kinds/dashboard/dashboard_kind.cue index 233a864689d..85e3a45040b 100644 --- a/kinds/dashboard/dashboard_kind.cue +++ b/kinds/dashboard/dashboard_kind.cue @@ -56,16 +56,7 @@ lineage: schemas: [{ } // Configuration of the time picker shown at the top of a dashboard. - timepicker?: { - // Whether timepicker is visible or not. - hidden: bool | *false - // Interval options available in the refresh picker dropdown. - refresh_intervals: [...string] | *["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"] - // Whether timepicker is collapsed or not. Has no effect on provisioned dashboard. - collapse: bool | *false - // Selectable options available in the time picker dropdown. Has no effect on provisioned dashboard. - time_options: [...string] | *["5m", "15m", "1h", "6h", "12h", "24h", "2d", "7d", "30d"] - } + timepicker?: #TimePickerConfig // The month that the fiscal year starts on. 0 = January, 11 = December fiscalYearStartMonth?: uint8 & <12 | *0 @@ -452,6 +443,17 @@ lineage: schemas: [{ options: _ } @cuetsy(kind="interface") @grafana(TSVeneer="type") + // Time picker configuration + // It defines the default config for the time picker and the refresh picker for the specific dashboard. + #TimePickerConfig: { + // Whether timepicker is visible or not. + hidden: bool | *false + // Interval options available in the refresh picker dropdown. + refresh_intervals: [...string] | *["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"] + // Selectable options available in the time picker dropdown. Has no effect on provisioned dashboard. + time_options: [...string] | *["5m", "15m", "1h", "6h", "12h", "24h", "2d", "7d", "30d"] + } @cuetsy(kind="interface") @grafana(TSVeneer="type") + // 0 for no shared crosshair or tooltip (default). // 1 for shared crosshair. // 2 for shared crosshair AND shared tooltip. diff --git a/packages/grafana-schema/src/index.gen.ts b/packages/grafana-schema/src/index.gen.ts index 3fbe2d5d5c4..9748a56bce2 100644 --- a/packages/grafana-schema/src/index.gen.ts +++ b/packages/grafana-schema/src/index.gen.ts @@ -73,6 +73,7 @@ export type { VariableModel, DataSourceRef, DataTransformerConfig, + TimePickerConfig, Panel, FieldConfigSource, MatcherConfig, @@ -95,6 +96,7 @@ export { defaultAnnotationQuery, defaultVariableModel, VariableHide, + defaultTimePickerConfig, defaultPanel, defaultFieldConfigSource, defaultMatcherConfig, diff --git a/packages/grafana-schema/src/raw/dashboard/x/dashboard_types.gen.ts b/packages/grafana-schema/src/raw/dashboard/x/dashboard_types.gen.ts index 651a44553c6..8f4854b5bcb 100644 --- a/packages/grafana-schema/src/raw/dashboard/x/dashboard_types.gen.ts +++ b/packages/grafana-schema/src/raw/dashboard/x/dashboard_types.gen.ts @@ -625,6 +625,31 @@ export interface DataTransformerConfig { options: unknown; } +/** + * Time picker configuration + * It defines the default config for the time picker and the refresh picker for the specific dashboard. + */ +export interface TimePickerConfig { + /** + * Whether timepicker is visible or not. + */ + hidden: boolean; + /** + * Interval options available in the refresh picker dropdown. + */ + refresh_intervals: Array; + /** + * Selectable options available in the time picker dropdown. Has no effect on provisioned dashboard. + */ + time_options: Array; +} + +export const defaultTimePickerConfig: Partial = { + 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'], +}; + /** * 0 for no shared crosshair or tooltip (default). * 1 for shared crosshair. @@ -1096,24 +1121,7 @@ export interface Dashboard { /** * Configuration of the time picker shown at the top of a dashboard. */ - timepicker?: { - /** - * Whether timepicker is visible or not. - */ - hidden: boolean; - /** - * Interval options available in the refresh picker dropdown. - */ - refresh_intervals: Array; - /** - * Whether timepicker is collapsed or not. Has no effect on provisioned dashboard. - */ - collapse: boolean; - /** - * Selectable options available in the time picker dropdown. Has no effect on provisioned dashboard. - */ - time_options: Array; - }; + timepicker?: TimePickerConfig; /** * Timezone of dashboard. Accepted values are IANA TZDB zone ID or "browser" or "utc". */ diff --git a/packages/grafana-schema/src/veneer/dashboard.types.ts b/packages/grafana-schema/src/veneer/dashboard.types.ts index 48c9df97e7d..5e1db960f4d 100644 --- a/packages/grafana-schema/src/veneer/dashboard.types.ts +++ b/packages/grafana-schema/src/veneer/dashboard.types.ts @@ -61,10 +61,13 @@ export interface DataTransformerConfig extends raw.DataTransform options: TOptions; } +export interface TimePickerConfig extends raw.TimePickerConfig {} + export const defaultDashboard = raw.defaultDashboard as Dashboard; export const defaultVariableModel = { ...raw.defaultVariableModel, } as VariableModel; +export const defaultTimePickerConfig = raw.defaultTimePickerConfig as TimePickerConfig; export const defaultPanel: Partial = raw.defaultPanel; export const defaultRowPanel: Partial = raw.defaultRowPanel; export const defaultFieldConfig: Partial = raw.defaultFieldConfig; diff --git a/pkg/kinds/dashboard/dashboard_spec_gen.go b/pkg/kinds/dashboard/dashboard_spec_gen.go index 0b0f7b2d502..08c57edfd93 100644 --- a/pkg/kinds/dashboard/dashboard_spec_gen.go +++ b/pkg/kinds/dashboard/dashboard_spec_gen.go @@ -767,20 +767,9 @@ type Spec struct { To string `json:"to"` } `json:"time,omitempty"` - // Configuration of the time picker shown at the top of a dashboard. - Timepicker *struct { - // Whether timepicker is collapsed or not. Has no effect on provisioned dashboard. - Collapse bool `json:"collapse"` - - // Whether timepicker is visible or not. - Hidden bool `json:"hidden"` - - // Interval options available in the refresh picker dropdown. - RefreshIntervals []string `json:"refresh_intervals"` - - // Selectable options available in the time picker dropdown. Has no effect on provisioned dashboard. - TimeOptions []string `json:"time_options"` - } `json:"timepicker,omitempty"` + // Time picker configuration + // It defines the default config for the time picker and the refresh picker for the specific dashboard. + Timepicker *TimePickerConfig `json:"timepicker,omitempty"` // Timezone of dashboard. Accepted values are IANA TZDB zone ID or "browser" or "utc". Timezone *string `json:"timezone,omitempty"` @@ -850,6 +839,19 @@ type ThresholdsConfig struct { // Thresholds can either be `absolute` (specific number) or `percentage` (relative to min or max, it will be values between 0 and 1). type ThresholdsMode string +// Time picker configuration +// It defines the default config for the time picker and the refresh picker for the specific dashboard. +type TimePickerConfig struct { + // Whether timepicker is visible or not. + Hidden bool `json:"hidden"` + + // Interval options available in the refresh picker dropdown. + RefreshIntervals []string `json:"refresh_intervals"` + + // Selectable options available in the time picker dropdown. Has no effect on provisioned dashboard. + TimeOptions []string `json:"time_options"` +} + // 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. type ValueMap struct { diff --git a/public/app/features/dashboard-scene/scene/DashboardScene.test.tsx b/public/app/features/dashboard-scene/scene/DashboardScene.test.tsx index 480712a15d4..bede40a4ae0 100644 --- a/public/app/features/dashboard-scene/scene/DashboardScene.test.tsx +++ b/public/app/features/dashboard-scene/scene/DashboardScene.test.tsx @@ -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) { 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({ diff --git a/public/app/features/dashboard-scene/scene/DashboardScene.tsx b/public/app/features/dashboard-scene/scene/DashboardScene.tsx index e35f44c3ff2..86415822b23 100644 --- a/public/app/features/dashboard-scene/scene/DashboardScene.tsx +++ b/public/app/features/dashboard-scene/scene/DashboardScene.tsx @@ -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 { + static listenToChangesInProps = PERSISTED_PROPS; static Component = DashboardSceneRenderer; /** @@ -97,6 +106,7 @@ export class DashboardScene extends SceneObjectBase { super({ title: 'Dashboard', meta: {}, + editable: true, body: state.body ?? new SceneFlexLayout({ children: [] }), ...state, }); @@ -223,9 +233,22 @@ export class DashboardScene extends SceneObjectBase { 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(); + } } ); } diff --git a/public/app/features/dashboard-scene/serialization/__snapshots__/transformSceneToSaveModel.test.ts.snap b/public/app/features/dashboard-scene/serialization/__snapshots__/transformSceneToSaveModel.test.ts.snap index 5889d35d73f..8a3d5530c26 100644 --- a/public/app/features/dashboard-scene/serialization/__snapshots__/transformSceneToSaveModel.test.ts.snap +++ b/public/app/features/dashboard-scene/serialization/__snapshots__/transformSceneToSaveModel.test.ts.snap @@ -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", diff --git a/public/app/features/dashboard-scene/serialization/testfiles/dashboard_to_load1.json b/public/app/features/dashboard-scene/serialization/testfiles/dashboard_to_load1.json index 2032a958cfd..2c858faae85 100644 --- a/public/app/features/dashboard-scene/serialization/testfiles/dashboard_to_load1.json +++ b/public/app/features/dashboard-scene/serialization/testfiles/dashboard_to_load1.json @@ -60,7 +60,7 @@ }, "editable": true, "fiscalYearStartMonth": 1, - "graphTooltip": 0, + "graphTooltip": 1, "id": 1351, "links": [], "liveNow": false, diff --git a/public/app/features/dashboard-scene/serialization/transformSaveModelToScene.ts b/public/app/features/dashboard-scene/serialization/transformSaveModelToScene.ts index 026d98cc367..f67f1353f2e 100644 --- a/public/app/features/dashboard-scene/serialization/transformSaveModelToScene.ts +++ b/public/app/features/dashboard-scene/serialization/transformSaveModelToScene.ts @@ -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, diff --git a/public/app/features/dashboard-scene/serialization/transformSceneToSaveModel.test.ts b/public/app/features/dashboard-scene/serialization/transformSceneToSaveModel.test.ts index 86579778835..4f78d9de36b 100644 --- a/public/app/features/dashboard-scene/serialization/transformSceneToSaveModel.test.ts +++ b/public/app/features/dashboard-scene/serialization/transformSceneToSaveModel.test.ts @@ -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: {} }); diff --git a/public/app/features/dashboard-scene/serialization/transformSceneToSaveModel.ts b/public/app/features/dashboard-scene/serialization/transformSceneToSaveModel.ts index f98e2e870c1..03be204e311 100644 --- a/public/app/features/dashboard-scene/serialization/transformSceneToSaveModel.ts +++ b/public/app/features/dashboard-scene/serialization/transformSceneToSaveModel.ts @@ -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); diff --git a/public/app/features/dashboard-scene/settings/AnnotationsEditView.tsx b/public/app/features/dashboard-scene/settings/AnnotationsEditView.tsx index 9aefc2ff62a..89c2af02cea 100644 --- a/public/app/features/dashboard-scene/settings/AnnotationsEditView.tsx +++ b/public/app/features/dashboard-scene/settings/AnnotationsEditView.tsx @@ -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) => { + static Component = ({ model }: SceneComponentProps) => { const dashboard = getDashboardSceneFor(model); const { navModel, pageNav } = useDashboardEditPageNav(dashboard, model.getUrlKey()); diff --git a/public/app/features/dashboard-scene/settings/GeneralSettings.tsx b/public/app/features/dashboard-scene/settings/GeneralSettings.tsx deleted file mode 100644 index c0c5817ef9c..00000000000 --- a/public/app/features/dashboard-scene/settings/GeneralSettings.tsx +++ /dev/null @@ -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 - implements DashboardEditView -{ - public getUrlKey(): string { - return 'settings'; - } - - static Component = ({ model }: SceneComponentProps) => { - const dashboard = getDashboardSceneFor(model); - const { navModel, pageNav } = useDashboardEditPageNav(dashboard, model.getUrlKey()); - - return ( - - -
General todo
-
- ); - }; -} diff --git a/public/app/features/dashboard-scene/settings/GeneralSettingsEditView.test.tsx b/public/app/features/dashboard-scene/settings/GeneralSettingsEditView.test.tsx new file mode 100644 index 00000000000..dd862e6914a --- /dev/null +++ b/public/app/features/dashboard-scene/settings/GeneralSettingsEditView.test.tsx @@ -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 }; +} diff --git a/public/app/features/dashboard-scene/settings/GeneralSettingsEditView.tsx b/public/app/features/dashboard-scene/settings/GeneralSettingsEditView.tsx new file mode 100644 index 00000000000..f3bef069c6c --- /dev/null +++ b/public/app/features/dashboard-scene/settings/GeneralSettingsEditView.tsx @@ -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; +} + +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 + 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) => { + 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 ( + + +
+ + + + {/* TODO: Make the component use persisted model */} + {/* {config.featureToggles.dashgpt && ( + + )} */} + + } + > + ) => model.onTitleChange(e.target.value)} + /> + + + + + {/* {config.featureToggles.dashgpt && ( + + )} */} + + } + > +