diff --git a/public/app/features/dashboard-scene/serialization/transformSaveModelToScene.ts b/public/app/features/dashboard-scene/serialization/transformSaveModelToScene.ts
index 07e4b2c410c..87c213ff3e6 100644
--- a/public/app/features/dashboard-scene/serialization/transformSaveModelToScene.ts
+++ b/public/app/features/dashboard-scene/serialization/transformSaveModelToScene.ts
@@ -215,7 +215,7 @@ export function createDashboardSceneFromDashboardModel(oldModel: DashboardModel)
layers = oldModel.annotations?.list.map((a) => {
// Each annotation query is an individual data layer
return new DashboardAnnotationsDataLayer({
- key: `annnotations-${a.name}`,
+ key: `annotations-${a.name}`,
query: a,
name: a.name,
isEnabled: Boolean(a.enable),
diff --git a/public/app/features/dashboard-scene/settings/AnnotationsEditView.test.tsx b/public/app/features/dashboard-scene/settings/AnnotationsEditView.test.tsx
new file mode 100644
index 00000000000..5ac0d3c824d
--- /dev/null
+++ b/public/app/features/dashboard-scene/settings/AnnotationsEditView.test.tsx
@@ -0,0 +1,126 @@
+import { map, of } from 'rxjs';
+
+import { DataQueryRequest, DataSourceApi, LoadingState, PanelData } from '@grafana/data';
+import { SceneDataLayers, SceneGridItem, SceneGridLayout, SceneTimeRange } from '@grafana/scenes';
+
+import { AlertStatesDataLayer } from '../scene/AlertStatesDataLayer';
+import { DashboardAnnotationsDataLayer } from '../scene/DashboardAnnotationsDataLayer';
+import { DashboardScene } from '../scene/DashboardScene';
+import { activateFullSceneTree } from '../utils/test-utils';
+
+import { AnnotationsEditView } from './AnnotationsEditView';
+
+const getDataSourceSrvSpy = jest.fn();
+const runRequestMock = jest.fn().mockImplementation((ds: DataSourceApi, request: DataQueryRequest) => {
+ const result: PanelData = {
+ state: LoadingState.Loading,
+ series: [],
+ timeRange: request.range,
+ };
+
+ return of([]).pipe(
+ map(() => {
+ result.state = LoadingState.Done;
+ result.series = [];
+
+ return result;
+ })
+ );
+});
+
+jest.mock('@grafana/runtime', () => ({
+ ...jest.requireActual('@grafana/runtime'),
+ getDataSourceSrv: () => {
+ getDataSourceSrvSpy();
+ },
+ getRunRequest: () => (ds: DataSourceApi, request: DataQueryRequest) => {
+ return runRequestMock(ds, request);
+ },
+ config: {
+ publicDashboardAccessToken: 'ac123',
+ },
+}));
+
+describe('AnnotationsEditView', () => {
+ describe('Dashboard annotations state', () => {
+ let annotationsView: AnnotationsEditView;
+
+ beforeEach(async () => {
+ const result = await buildTestScene();
+ annotationsView = result.annotationsView;
+ });
+
+ it('should return the correct urlKey', () => {
+ expect(annotationsView.getUrlKey()).toBe('annotations');
+ });
+
+ it('should return the scene data layers', () => {
+ const dataLayers = annotationsView.getSceneDataLayers();
+
+ expect(dataLayers).toBeInstanceOf(SceneDataLayers);
+ expect(dataLayers?.state.layers.length).toBe(2);
+ });
+
+ it('should return the annotations length', () => {
+ expect(annotationsView.getAnnotationsLength()).toBe(1);
+ });
+ });
+});
+
+async function buildTestScene() {
+ const annotationsView = new AnnotationsEditView({});
+ const dashboard = new DashboardScene({
+ $timeRange: new SceneTimeRange({}),
+ title: 'hello',
+ uid: 'dash-1',
+ version: 4,
+ meta: {
+ canEdit: true,
+ },
+ $data: new SceneDataLayers({
+ layers: [
+ new DashboardAnnotationsDataLayer({
+ key: `annotations-test`,
+ query: {
+ enable: true,
+ iconColor: 'red',
+ name: 'test',
+ datasource: {
+ type: 'grafana',
+ uid: '-- Grafana --',
+ },
+ },
+ name: 'test',
+ isEnabled: true,
+ isHidden: false,
+ }),
+ new AlertStatesDataLayer({
+ key: 'alert-states',
+ name: 'Alert States',
+ }),
+ ],
+ }),
+ body: new SceneGridLayout({
+ children: [
+ new SceneGridItem({
+ key: 'griditem-1',
+ x: 0,
+ y: 0,
+ width: 10,
+ height: 12,
+ body: undefined,
+ }),
+ ],
+ }),
+ editview: annotationsView,
+ });
+
+ activateFullSceneTree(dashboard);
+
+ await new Promise((r) => setTimeout(r, 1));
+
+ dashboard.onEnterEditMode();
+ annotationsView.activate();
+
+ return { dashboard, annotationsView };
+}
diff --git a/public/app/features/dashboard-scene/settings/AnnotationsEditView.tsx b/public/app/features/dashboard-scene/settings/AnnotationsEditView.tsx
index 89c2af02cea..a7b78675914 100644
--- a/public/app/features/dashboard-scene/settings/AnnotationsEditView.tsx
+++ b/public/app/features/dashboard-scene/settings/AnnotationsEditView.tsx
@@ -1,30 +1,107 @@
import React from 'react';
-import { PageLayoutType } from '@grafana/data';
-import { SceneComponentProps, SceneObjectBase } from '@grafana/scenes';
+import { AnnotationQuery, DataTopic, PageLayoutType } from '@grafana/data';
+import {
+ SceneComponentProps,
+ SceneDataLayerProvider,
+ SceneDataLayers,
+ SceneObjectBase,
+ sceneGraph,
+} from '@grafana/scenes';
import { Page } from 'app/core/components/Page/Page';
+import { DashboardScene } from '../scene/DashboardScene';
import { NavToolbarActions } from '../scene/NavToolbarActions';
+import { dataLayersToAnnotations } from '../serialization/dataLayersToAnnotations';
import { getDashboardSceneFor } from '../utils/utils';
+import { EditListViewSceneUrlSync } from './EditListViewSceneUrlSync';
+import { AnnotationSettingsList } from './annotations';
import { DashboardEditView, DashboardEditViewState, useDashboardEditPageNav } from './utils';
-export interface AnnotationsEditViewState extends DashboardEditViewState {}
+export interface AnnotationsEditViewState extends DashboardEditViewState {
+ editIndex?: number | undefined;
+}
export class AnnotationsEditView extends SceneObjectBase implements DashboardEditView {
+ static Component = AnnotationsSettingsView;
+
public getUrlKey(): string {
return 'annotations';
}
- static Component = ({ model }: SceneComponentProps) => {
- const dashboard = getDashboardSceneFor(model);
- const { navModel, pageNav } = useDashboardEditPageNav(dashboard, model.getUrlKey());
+ protected _urlSync = new EditListViewSceneUrlSync(this);
- return (
-
-
- Annotations todo
-
- );
+ private get _dashboard(): DashboardScene {
+ return getDashboardSceneFor(this);
+ }
+
+ private get _dataLayers(): SceneDataLayerProvider[] {
+ return sceneGraph.getDataLayers(this._dashboard);
+ }
+
+ public getSceneDataLayers(): SceneDataLayers | undefined {
+ const data = sceneGraph.getData(this);
+
+ if (!(data instanceof SceneDataLayers)) {
+ return undefined;
+ }
+
+ return data;
+ }
+
+ public getAnnotationsLength(): number {
+ return this._dataLayers.filter((layer) => layer.topic === DataTopic.Annotations).length;
+ }
+
+ public getDashboard(): DashboardScene {
+ return this._dashboard;
+ }
+
+ public onNew = () => {
+ console.log('todo: onNew');
+ };
+
+ public onEdit = (idx: number) => {
+ console.log('todo: onEdit');
+ };
+
+ public onMove = (idx: number, direction: number) => {
+ console.log('todo: onMove');
+ };
+
+ public onDelete = (idx: number) => {
+ console.log('todo: onDelete');
};
}
+
+function AnnotationsSettingsView({ model }: SceneComponentProps) {
+ const dashboard = model.getDashboard();
+ const sceneDataLayers = model.getSceneDataLayers();
+ const { navModel, pageNav } = useDashboardEditPageNav(dashboard, model.getUrlKey());
+ const { editIndex } = model.useState();
+
+ let annotations: AnnotationQuery[] = [];
+
+ if (sceneDataLayers) {
+ const { layers } = sceneDataLayers.useState();
+ annotations = dataLayersToAnnotations(layers);
+ }
+
+ const isEditing = editIndex != null && editIndex < model.getAnnotationsLength();
+
+ return (
+
+
+ {!isEditing && (
+
+ )}
+
+ );
+}
diff --git a/public/app/features/dashboard-scene/settings/EditListViewSceneUrlSync.ts b/public/app/features/dashboard-scene/settings/EditListViewSceneUrlSync.ts
index a81fda1cb6b..9821bcf13c5 100644
--- a/public/app/features/dashboard-scene/settings/EditListViewSceneUrlSync.ts
+++ b/public/app/features/dashboard-scene/settings/EditListViewSceneUrlSync.ts
@@ -1,10 +1,11 @@
import { SceneObjectUrlSyncHandler, SceneObjectUrlValues } from '@grafana/scenes';
+import { AnnotationsEditView, AnnotationsEditViewState } from './AnnotationsEditView';
import { DashboardLinksEditView, DashboardLinksEditViewState } from './DashboardLinksEditView';
import { VariablesEditView, VariablesEditViewState } from './VariablesEditView';
-type EditListViewUrlSync = DashboardLinksEditView | VariablesEditView;
-type EditListViewState = DashboardLinksEditViewState | VariablesEditViewState;
+type EditListViewUrlSync = DashboardLinksEditView | VariablesEditView | AnnotationsEditView;
+type EditListViewState = DashboardLinksEditViewState | VariablesEditViewState | AnnotationsEditViewState;
export class EditListViewSceneUrlSync implements SceneObjectUrlSyncHandler {
constructor(private _scene: EditListViewUrlSync) {}
diff --git a/public/app/features/dashboard-scene/settings/VersionsEditView.test.tsx b/public/app/features/dashboard-scene/settings/VersionsEditView.test.tsx
index 51b6fb8fd91..afafdc5089b 100644
--- a/public/app/features/dashboard-scene/settings/VersionsEditView.test.tsx
+++ b/public/app/features/dashboard-scene/settings/VersionsEditView.test.tsx
@@ -153,7 +153,7 @@ function getVersions() {
}
async function buildTestScene() {
- const versionsView = new VersionsEditView({ versions: [] });
+ const versionsView = new VersionsEditView({});
const dashboard = new DashboardScene({
$timeRange: new SceneTimeRange({}),
title: 'hello',
diff --git a/public/app/features/dashboard-scene/settings/annotations/AnnotationSettingsList.tsx b/public/app/features/dashboard-scene/settings/annotations/AnnotationSettingsList.tsx
new file mode 100644
index 00000000000..80476dce9f2
--- /dev/null
+++ b/public/app/features/dashboard-scene/settings/annotations/AnnotationSettingsList.tsx
@@ -0,0 +1,121 @@
+import { css } from '@emotion/css';
+import React from 'react';
+
+import { AnnotationQuery } from '@grafana/data';
+import { getDataSourceSrv } from '@grafana/runtime';
+import { Button, DeleteButton, IconButton, useStyles2, VerticalGroup } from '@grafana/ui';
+import EmptyListCTA from 'app/core/components/EmptyListCTA/EmptyListCTA';
+import { ListNewButton } from 'app/features/dashboard/components/DashboardSettings/ListNewButton';
+
+type Props = {
+ annotations: AnnotationQuery[];
+ onNew: () => void;
+ onEdit: (idx: number) => void;
+ onMove: (idx: number, dir: number) => void;
+ onDelete: (idx: number) => void;
+};
+
+export const AnnotationSettingsList = ({ annotations, onNew, onEdit, onMove, onDelete }: Props) => {
+ const styles = useStyles2(getStyles);
+
+ const showEmptyListCTA = annotations.length === 0 || (annotations.length === 1 && annotations[0].builtIn);
+
+ const getAnnotationName = (anno: AnnotationQuery) => {
+ if (anno.enable === false) {
+ return (Disabled) {anno.name} ;
+ }
+
+ if (anno.builtIn) {
+ return {anno.name} (Built-in) ;
+ }
+
+ return <>{anno.name}>;
+ };
+
+ const dataSourceSrv = getDataSourceSrv();
+ return (
+
+ {annotations.length > 0 && (
+
+
+
+
+ Query name
+ Data source
+
+
+
+
+ {annotations.map((annotation, idx) => (
+
+ {annotation.builtIn ? (
+ onEdit(idx)}>
+
+ {getAnnotationName(annotation)}
+
+
+ ) : (
+ onEdit(idx)}>
+
+ {getAnnotationName(annotation)}
+
+
+ )}
+ onEdit(idx)}>
+ {dataSourceSrv.getInstanceSettings(annotation.datasource)?.name || annotation.datasource?.uid}
+
+
+ {idx !== 0 && onMove(idx, -1)} tooltip="Move up" />}
+
+
+ {annotations.length > 1 && idx !== annotations.length - 1 ? (
+ onMove(idx, 1)} tooltip="Move down" />
+ ) : null}
+
+
+ {!annotation.builtIn && (
+ onDelete(idx)}
+ aria-label={`Delete query with title "${annotation.name}"`}
+ />
+ )}
+
+
+ ))}
+
+
+
+ )}
+ {showEmptyListCTA && (
+ Annotations provide a way to integrate event data into your graphs. They are visualized as vertical lines
+ and icons on all graph panels. When you hover over an annotation icon you can get event text & tags for
+ the event. You can add annotation events directly from grafana by holding CTRL or CMD + click on graph (or
+ drag region). These will be stored in Grafana's annotation database.
+
+ Checkout the
+ Annotations documentation
+ for more information.`,
+ }}
+ />
+ )}
+ {!showEmptyListCTA && New query }
+
+ );
+};
+
+const getStyles = () => ({
+ table: css({
+ width: '100%',
+ overflowX: 'scroll',
+ }),
+});
diff --git a/public/app/features/dashboard-scene/settings/annotations/index.tsx b/public/app/features/dashboard-scene/settings/annotations/index.tsx
new file mode 100644
index 00000000000..512165263b8
--- /dev/null
+++ b/public/app/features/dashboard-scene/settings/annotations/index.tsx
@@ -0,0 +1 @@
+export { AnnotationSettingsList } from './AnnotationSettingsList';
diff --git a/public/app/features/dashboard-scene/utils/DashboardModelCompatibilityWrapper.test.ts b/public/app/features/dashboard-scene/utils/DashboardModelCompatibilityWrapper.test.ts
index a5ded6539e9..93c7a45b45f 100644
--- a/public/app/features/dashboard-scene/utils/DashboardModelCompatibilityWrapper.test.ts
+++ b/public/app/features/dashboard-scene/utils/DashboardModelCompatibilityWrapper.test.ts
@@ -9,10 +9,13 @@ import {
VizPanel,
SceneTimePicker,
SceneDataTransformer,
+ SceneDataLayers,
} from '@grafana/scenes';
import { DashboardCursorSync } from '@grafana/schema';
import { SHARED_DASHBOARD_QUERY } from 'app/plugins/datasource/dashboard';
+import { AlertStatesDataLayer } from '../scene/AlertStatesDataLayer';
+import { DashboardAnnotationsDataLayer } from '../scene/DashboardAnnotationsDataLayer';
import { DashboardControls } from '../scene/DashboardControls';
import { DashboardLinksControls } from '../scene/DashboardLinksControls';
import { DashboardScene } from '../scene/DashboardScene';
@@ -38,6 +41,9 @@ describe('DashboardModelCompatibilityWrapper', () => {
expect(wrapper.timepicker.hidden).toEqual(true);
expect(wrapper.panels).toHaveLength(5);
+ expect(wrapper.annotations.list).toHaveLength(1);
+ expect(wrapper.annotations.list[0].name).toBe('test');
+
expect(wrapper.panels[0].targets).toHaveLength(1);
expect(wrapper.panels[0].targets[0]).toEqual({ refId: 'A' });
expect(wrapper.panels[1].targets).toHaveLength(0);
@@ -104,6 +110,22 @@ describe('DashboardModelCompatibilityWrapper', () => {
expect((scene.state.body as SceneGridLayout).state.children.length).toBe(4);
});
+
+ it('Checks if annotations are editable', () => {
+ const { wrapper, scene } = setup();
+
+ expect(wrapper.canEditAnnotations()).toBe(true);
+ expect(wrapper.canEditAnnotations(scene.state.uid)).toBe(false);
+
+ scene.setState({
+ meta: {
+ canEdit: false,
+ canMakeEditable: false,
+ },
+ });
+
+ expect(wrapper.canEditAnnotations()).toBe(false);
+ });
});
function setup() {
@@ -114,10 +136,45 @@ function setup() {
links: [NEW_LINK],
uid: 'dash-1',
editable: false,
+ meta: {
+ canEdit: true,
+ canMakeEditable: true,
+ annotationsPermissions: {
+ organization: {
+ canEdit: true,
+ canAdd: true,
+ canDelete: true,
+ },
+ dashboard: {
+ canEdit: false,
+ canAdd: false,
+ canDelete: false,
+ },
+ },
+ },
$timeRange: new SceneTimeRange({
weekStart: 'friday',
timeZone: 'America/New_York',
}),
+ $data: new SceneDataLayers({
+ layers: [
+ new DashboardAnnotationsDataLayer({
+ key: `annotations-test`,
+ query: {
+ enable: true,
+ iconColor: 'red',
+ name: 'test',
+ },
+ name: 'test',
+ isEnabled: true,
+ isHidden: false,
+ }),
+ new AlertStatesDataLayer({
+ key: 'alert-states',
+ name: 'Alert States',
+ }),
+ ],
+ }),
controls: [
new DashboardControls({
variableControls: [],
diff --git a/public/app/features/dashboard-scene/utils/DashboardModelCompatibilityWrapper.ts b/public/app/features/dashboard-scene/utils/DashboardModelCompatibilityWrapper.ts
index 4d1e5f4027f..81f3afa51b3 100644
--- a/public/app/features/dashboard-scene/utils/DashboardModelCompatibilityWrapper.ts
+++ b/public/app/features/dashboard-scene/utils/DashboardModelCompatibilityWrapper.ts
@@ -4,6 +4,7 @@ import { AnnotationQuery, DashboardCursorSync, dateTimeFormat, DateTimeInput, Ev
import { TimeRangeUpdatedEvent } from '@grafana/runtime';
import {
behaviors,
+ SceneDataLayers,
SceneDataTransformer,
sceneGraph,
SceneGridItem,
@@ -16,6 +17,7 @@ import { DataSourceRef } from '@grafana/schema';
import { DashboardScene } from '../scene/DashboardScene';
import { LibraryVizPanel } from '../scene/LibraryVizPanel';
+import { dataLayersToAnnotations } from '../serialization/dataLayersToAnnotations';
import { dashboardSceneGraph } from './dashboardSceneGraph';
import { findVizPanelByKey, getPanelIdForVizPanel, getQueryRunnerFor, getVizPanelKeyForPanelId } from './utils';
@@ -109,8 +111,13 @@ export class DashboardModelCompatibilityWrapper {
* Used from from timeseries migration handler to migrate time regions to dashboard annotations
*/
public get annotations(): { list: AnnotationQuery[] } {
- console.error('Scenes DashboardModelCompatibilityWrapper.annotations not implemented (yet)');
- return { list: [] };
+ const annotations: { list: AnnotationQuery[] } = { list: [] };
+
+ if (this._scene.state.$data instanceof SceneDataLayers) {
+ annotations.list = dataLayersToAnnotations(this._scene.state.$data.state.layers);
+ }
+
+ return annotations;
}
public getTimezone() {
@@ -204,8 +211,15 @@ export class DashboardModelCompatibilityWrapper {
}
public canEditAnnotations(dashboardUID?: string) {
- // TOOD
- return false;
+ if (!this._scene.canEditDashboard()) {
+ return false;
+ }
+
+ if (dashboardUID) {
+ return Boolean(this._scene.state.meta.annotationsPermissions?.dashboard.canEdit);
+ }
+
+ return Boolean(this._scene.state.meta.annotationsPermissions?.organization.canEdit);
}
public panelInitialized() {}