Scenes: Annotations functionality in settings (#81361)

* wip listing/viewing annotations

* list annotations in settings

* fix tests

* wip edit mode

* PR mods

* move, delete, edit, add new

* edit annotation in settings

* Annotations functionality

* revert change

* add ui tests, move angularEditorLoader

* remove flaky test

* refactor

* bump scenes version, refactor getVizPanels, refactor edit page

* fix nav breadcrumbs

* annotation set dirty flag, add overlay to edit view

* PR mods

* PR mods

* remove flaky test

* change dirty flag setting logic for anotations

* change button variants
This commit is contained in:
Victor Marin 2024-02-12 16:06:46 +02:00 committed by GitHub
parent 1315c67c8b
commit 1fe32479d7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 1112 additions and 62 deletions

View File

@ -95,6 +95,7 @@ export const Pages = {
*/
addAnnotationCTA: Components.CallToActionCard.button('Add annotation query'),
addAnnotationCTAV2: Components.CallToActionCard.buttonV2('Add annotation query'),
annotations: 'data-testid list-annotations',
},
Settings: {
name: 'Annotations settings name input',
@ -103,6 +104,10 @@ export const Pages = {
panelFilterSelect: 'data-testid annotations-panel-filter',
showInLabel: 'show-in-label',
previewInDashboard: 'data-testid annotations-preview',
delete: 'data-testid annotations-delete',
apply: 'data-testid annotations-apply',
enable: 'data-testid annotation-enable',
hide: 'data-testid annotation-hide',
},
},
Variables: {

View File

@ -6,7 +6,9 @@ import { Unsubscribable } from 'rxjs';
import { AppEvents, CoreApp, DataQueryRequest, NavIndex, NavModelItem, locationUtil, textUtil } from '@grafana/data';
import { locationService, config } from '@grafana/runtime';
import {
dataLayers,
getUrlSyncManager,
SceneDataLayers,
SceneFlexLayout,
sceneGraph,
SceneGridItem,
@ -373,6 +375,14 @@ export class DashboardScene extends SceneObjectBase<DashboardSceneState> {
this.setIsDirty();
}
}
if (event.payload.changedObject instanceof SceneDataLayers) {
this.setIsDirty();
}
if (event.payload.changedObject instanceof dataLayers.AnnotationsDataLayer) {
if (!Object.prototype.hasOwnProperty.call(event.payload.partialUpdate, 'data')) {
this.setIsDirty();
}
}
if (event.payload.changedObject instanceof SceneGridItem) {
this.setIsDirty();
}

View File

@ -1,16 +1,17 @@
import { map, of } from 'rxjs';
import { DataQueryRequest, DataSourceApi, LoadingState, PanelData } from '@grafana/data';
import { SceneDataLayers, SceneGridItem, SceneGridLayout, SceneTimeRange } from '@grafana/scenes';
import { AnnotationQuery, DataQueryRequest, DataSourceApi, LoadingState, PanelData } from '@grafana/data';
import { SceneDataLayers, SceneGridItem, SceneGridLayout, SceneTimeRange, dataLayers } from '@grafana/scenes';
import { AlertStatesDataLayer } from '../scene/AlertStatesDataLayer';
import { DashboardAnnotationsDataLayer } from '../scene/DashboardAnnotationsDataLayer';
import { DashboardScene } from '../scene/DashboardScene';
import { dashboardSceneGraph } from '../utils/dashboardSceneGraph';
import { activateFullSceneTree } from '../utils/test-utils';
import { AnnotationsEditView } from './AnnotationsEditView';
import { AnnotationsEditView, MoveDirection } from './AnnotationsEditView';
import { newAnnotationName } from './annotations/AnnotationSettingsEdit';
const getDataSourceSrvSpy = jest.fn();
const runRequestMock = jest.fn().mockImplementation((ds: DataSourceApi, request: DataQueryRequest) => {
const result: PanelData = {
state: LoadingState.Loading,
@ -31,7 +32,9 @@ const runRequestMock = jest.fn().mockImplementation((ds: DataSourceApi, request:
jest.mock('@grafana/runtime', () => ({
...jest.requireActual('@grafana/runtime'),
getDataSourceSrv: () => {
getDataSourceSrvSpy();
return {
getInstanceSettings: jest.fn().mockResolvedValue({ uid: 'ds1' }),
};
},
getRunRequest: () => (ds: DataSourceApi, request: DataQueryRequest) => {
return runRequestMock(ds, request);
@ -44,26 +47,103 @@ jest.mock('@grafana/runtime', () => ({
describe('AnnotationsEditView', () => {
describe('Dashboard annotations state', () => {
let annotationsView: AnnotationsEditView;
let dashboardScene: DashboardScene;
beforeEach(async () => {
const result = await buildTestScene();
annotationsView = result.annotationsView;
dashboardScene = result.dashboard;
});
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);
});
it('should return 0 if no annotations', () => {
dashboardScene.setState({
$data: new SceneDataLayers({ layers: [] }),
});
expect(annotationsView.getAnnotationsLength()).toBe(0);
});
it('should add a new annotation and group it with the other annotations', () => {
const dataLayers = dashboardSceneGraph.getDataLayers(annotationsView.getDashboard());
expect(dataLayers?.state.layers.length).toBe(2);
annotationsView.onNew();
expect(dataLayers?.state.layers.length).toBe(3);
expect(dataLayers?.state.layers[1].state.name).toBe(newAnnotationName);
expect(dataLayers?.state.layers[1].isActive).toBe(true);
});
it('should move an annotation up one position', () => {
const dataLayers = dashboardSceneGraph.getDataLayers(annotationsView.getDashboard());
annotationsView.onNew();
expect(dataLayers?.state.layers.length).toBe(3);
expect(dataLayers?.state.layers[0].state.name).toBe('test');
annotationsView.onMove(1, MoveDirection.UP);
expect(dataLayers?.state.layers.length).toBe(3);
expect(dataLayers?.state.layers[0].state.name).toBe(newAnnotationName);
});
it('should move an annotation down one position', () => {
const dataLayers = dashboardSceneGraph.getDataLayers(annotationsView.getDashboard());
annotationsView.onNew();
expect(dataLayers?.state.layers.length).toBe(3);
expect(dataLayers?.state.layers[0].state.name).toBe('test');
annotationsView.onMove(0, MoveDirection.DOWN);
expect(dataLayers?.state.layers.length).toBe(3);
expect(dataLayers?.state.layers[0].state.name).toBe(newAnnotationName);
});
it('should delete annotation at index', () => {
const dataLayers = dashboardSceneGraph.getDataLayers(annotationsView.getDashboard());
expect(dataLayers?.state.layers.length).toBe(2);
annotationsView.onDelete(0);
expect(dataLayers?.state.layers.length).toBe(1);
expect(dataLayers?.state.layers[0].state.name).toBe('Alert States');
});
it('should update an annotation at index', () => {
const dataLayers = dashboardSceneGraph.getDataLayers(annotationsView.getDashboard());
expect(dataLayers?.state.layers[0].state.name).toBe('test');
const annotation: AnnotationQuery = {
...(dataLayers?.state.layers[0] as dataLayers.AnnotationsDataLayer).state.query,
};
annotation.name = 'new name';
annotation.hide = true;
annotation.enable = false;
annotation.iconColor = 'blue';
annotationsView.onUpdate(annotation, 0);
expect(dataLayers?.state.layers.length).toBe(2);
expect(dataLayers?.state.layers[0].state.name).toBe('new name');
expect((dataLayers?.state.layers[0] as dataLayers.AnnotationsDataLayer).state.query.name).toBe('new name');
expect((dataLayers?.state.layers[0] as dataLayers.AnnotationsDataLayer).state.query.hide).toBe(true);
expect((dataLayers?.state.layers[0] as dataLayers.AnnotationsDataLayer).state.query.enable).toBe(false);
expect((dataLayers?.state.layers[0] as dataLayers.AnnotationsDataLayer).state.query.iconColor).toBe('blue');
});
});
});

View File

@ -1,24 +1,26 @@
import React from 'react';
import { AnnotationQuery, DataTopic, PageLayoutType } from '@grafana/data';
import {
SceneComponentProps,
SceneDataLayerProvider,
SceneDataLayers,
SceneObjectBase,
sceneGraph,
} from '@grafana/scenes';
import { AnnotationQuery, DataTopic, NavModel, NavModelItem, PageLayoutType, getDataSourceRef } from '@grafana/data';
import { getDataSourceSrv } from '@grafana/runtime';
import { SceneComponentProps, SceneObjectBase, VizPanel, dataLayers } from '@grafana/scenes';
import { Page } from 'app/core/components/Page/Page';
import { DashboardAnnotationsDataLayer } from '../scene/DashboardAnnotationsDataLayer';
import { DashboardScene } from '../scene/DashboardScene';
import { NavToolbarActions } from '../scene/NavToolbarActions';
import { dataLayersToAnnotations } from '../serialization/dataLayersToAnnotations';
import { dashboardSceneGraph } from '../utils/dashboardSceneGraph';
import { getDashboardSceneFor } from '../utils/utils';
import { EditListViewSceneUrlSync } from './EditListViewSceneUrlSync';
import { AnnotationSettingsList } from './annotations';
import { AnnotationSettingsEdit, AnnotationSettingsList, newAnnotationName } from './annotations';
import { DashboardEditView, DashboardEditViewState, useDashboardEditPageNav } from './utils';
export enum MoveDirection {
UP = -1,
DOWN = 1,
}
export interface AnnotationsEditViewState extends DashboardEditViewState {
editIndex?: number | undefined;
}
@ -36,22 +38,21 @@ export class AnnotationsEditView extends SceneObjectBase<AnnotationsEditViewStat
return getDashboardSceneFor(this);
}
private get _dataLayers(): SceneDataLayerProvider[] {
return sceneGraph.getDataLayers(this._dashboard);
}
public getDataLayer(editIndex: number): dataLayers.AnnotationsDataLayer {
const data = dashboardSceneGraph.getDataLayers(this._dashboard);
const layer = data.state.layers[editIndex];
public getSceneDataLayers(): SceneDataLayers | undefined {
const data = sceneGraph.getData(this);
if (!(data instanceof SceneDataLayers)) {
return undefined;
if (!(layer instanceof dataLayers.AnnotationsDataLayer)) {
throw new Error('AnnotationsDataLayer not found at index ' + editIndex);
}
return data;
return layer;
}
public getAnnotationsLength(): number {
return this._dataLayers.filter((layer) => layer.topic === DataTopic.Annotations).length;
return dashboardSceneGraph
.getDataLayers(this._dashboard)
.state.layers.filter((layer) => layer.topic === DataTopic.Annotations).length;
}
public getDashboard(): DashboardScene {
@ -59,49 +60,167 @@ export class AnnotationsEditView extends SceneObjectBase<AnnotationsEditViewStat
}
public onNew = () => {
console.log('todo: onNew');
const newAnnotationQuery: AnnotationQuery = {
name: newAnnotationName,
enable: true,
datasource: getDataSourceRef(getDataSourceSrv().getInstanceSettings(null)!),
iconColor: 'red',
};
const newAnnotation = new DashboardAnnotationsDataLayer({
key: `annotations-${newAnnotationQuery.name}`,
query: newAnnotationQuery,
name: newAnnotationQuery.name,
isEnabled: Boolean(newAnnotationQuery.enable),
isHidden: Boolean(newAnnotationQuery.hide),
});
const data = dashboardSceneGraph.getDataLayers(this._dashboard);
const layers = [...data.state.layers];
//keep annotation layers together
layers.splice(this.getAnnotationsLength(), 0, newAnnotation);
data.setState({
layers,
});
newAnnotation.activate();
this.setState({ editIndex: this.getAnnotationsLength() - 1 });
};
public onEdit = (idx: number) => {
console.log('todo: onEdit');
this.setState({ editIndex: idx });
};
public onMove = (idx: number, direction: number) => {
console.log('todo: onMove');
public onBackToList = () => {
this.setState({ editIndex: undefined });
};
public onMove = (idx: number, direction: MoveDirection) => {
const data = dashboardSceneGraph.getDataLayers(this._dashboard);
const layers = [...data.state.layers];
const [layer] = layers.splice(idx, 1);
layers.splice(idx + direction, 0, layer);
data.setState({
layers,
});
};
public onDelete = (idx: number) => {
console.log('todo: onDelete');
const data = dashboardSceneGraph.getDataLayers(this._dashboard);
const layers = [...data.state.layers];
layers.splice(idx, 1);
data.setState({
layers,
});
};
public onUpdate = (annotation: AnnotationQuery, editIndex: number) => {
const layer = this.getDataLayer(editIndex);
layer.setState({
key: `annotations-${annotation.name}`,
name: annotation.name,
isEnabled: Boolean(annotation.enable),
isHidden: Boolean(annotation.hide),
query: annotation,
});
//need to rerun the layer to update the query and
//see the annotation on the panel
layer.runLayer();
};
}
function AnnotationsSettingsView({ model }: SceneComponentProps<AnnotationsEditView>) {
const dashboard = model.getDashboard();
const sceneDataLayers = model.getSceneDataLayers();
const { layers } = dashboardSceneGraph.getDataLayers(dashboard).useState();
const { navModel, pageNav } = useDashboardEditPageNav(dashboard, model.getUrlKey());
const { editIndex } = model.useState();
const panels = dashboardSceneGraph.getVizPanels(dashboard);
let annotations: AnnotationQuery[] = [];
const annotations: AnnotationQuery[] = dataLayersToAnnotations(layers);
if (sceneDataLayers) {
const { layers } = sceneDataLayers.useState();
annotations = dataLayersToAnnotations(layers);
if (editIndex != null && editIndex < model.getAnnotationsLength()) {
return (
<AnnotationsSettingsEditView
annotationLayer={model.getDataLayer(editIndex)}
pageNav={pageNav}
panels={panels}
editIndex={editIndex}
navModel={navModel}
dashboard={dashboard}
onUpdate={model.onUpdate}
onBackToList={model.onBackToList}
onDelete={model.onDelete}
/>
);
}
const isEditing = editIndex != null && editIndex < model.getAnnotationsLength();
return (
<Page navModel={navModel} pageNav={pageNav} layout={PageLayoutType.Standard}>
<NavToolbarActions dashboard={dashboard} />
{!isEditing && (
<AnnotationSettingsList
annotations={annotations}
onNew={model.onNew}
onEdit={model.onEdit}
onDelete={model.onDelete}
onMove={model.onMove}
/>
)}
<AnnotationSettingsList
annotations={annotations}
onNew={model.onNew}
onEdit={model.onEdit}
onDelete={model.onDelete}
onMove={model.onMove}
/>
</Page>
);
}
interface AnnotationsSettingsEditViewProps {
annotationLayer: dataLayers.AnnotationsDataLayer;
pageNav: NavModelItem;
panels: VizPanel[];
editIndex: number;
navModel: NavModel;
dashboard: DashboardScene;
onUpdate: (annotation: AnnotationQuery, editIndex: number) => void;
onBackToList: () => void;
onDelete: (idx: number) => void;
}
function AnnotationsSettingsEditView({
annotationLayer,
pageNav,
navModel,
panels,
editIndex,
dashboard,
onUpdate,
onBackToList,
onDelete,
}: AnnotationsSettingsEditViewProps) {
const parentTab = pageNav.children!.find((p) => p.active)!;
parentTab.parentItem = pageNav;
const { name, query } = annotationLayer.useState();
const editAnnotationPageNav = {
text: name,
parentItem: parentTab,
};
return (
<Page navModel={navModel} pageNav={editAnnotationPageNav} layout={PageLayoutType.Standard}>
<NavToolbarActions dashboard={dashboard} />
<AnnotationSettingsEdit
annotation={query}
editIndex={editIndex}
panels={panels}
onUpdate={onUpdate}
onBackToList={onBackToList}
onDelete={onDelete}
/>
</Page>
);
}

View File

@ -0,0 +1,226 @@
import { act, render } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import { of } from 'rxjs';
import {
AnnotationQuery,
FieldType,
LoadingState,
PanelData,
VariableSupportType,
getDefaultTimeRange,
toDataFrame,
} from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { setRunRequest } from '@grafana/runtime';
import { mockDataSource } from 'app/features/alerting/unified/mocks';
import { LegacyVariableQueryEditor } from 'app/features/variables/editor/LegacyVariableQueryEditor';
import { AnnotationSettingsEdit } from './AnnotationSettingsEdit';
const defaultDatasource = mockDataSource({
name: 'Default Test Data Source',
type: 'test',
});
const promDatasource = mockDataSource({
name: 'Prometheus',
type: 'prometheus',
});
jest.mock('@grafana/runtime', () => ({
...jest.requireActual('@grafana/runtime'),
getAngularLoader: () => ({
load: () => ({
destroy: jest.fn(),
digest: jest.fn(),
getScope: () => ({
$watch: jest.fn(),
}),
}),
}),
}));
jest.mock('@grafana/runtime/src/services/dataSourceSrv', () => ({
...jest.requireActual('@grafana/runtime/src/services/dataSourceSrv'),
getDataSourceSrv: () => ({
get: async () => ({
...defaultDatasource,
variables: {
getType: () => VariableSupportType.Custom,
query: jest.fn(),
editor: jest.fn().mockImplementation(LegacyVariableQueryEditor),
},
}),
getList: () => [defaultDatasource, promDatasource],
getInstanceSettings: () => ({ ...defaultDatasource }),
}),
}));
jest.mock('./AngularEditorLoader', () => ({ AngularEditorLoader: () => 'mocked AngularEditorLoader' }));
const runRequestMock = jest.fn().mockReturnValue(
of<PanelData>({
state: LoadingState.Done,
series: [
toDataFrame({
fields: [{ name: 'text', type: FieldType.string, values: ['val1', 'val2', 'val11'] }],
}),
],
timeRange: getDefaultTimeRange(),
})
);
setRunRequest(runRequestMock);
describe('AnnotationSettingsEdit', () => {
const mockOnUpdate = jest.fn();
const mockGoBackToList = jest.fn();
const mockOnDelete = jest.fn();
async function setup() {
const annotationQuery: AnnotationQuery = {
name: 'test',
datasource: defaultDatasource,
enable: true,
hide: false,
iconColor: 'blue',
};
const props = {
annotation: annotationQuery,
onUpdate: mockOnUpdate,
editIndex: 1,
panels: [],
onBackToList: mockGoBackToList,
onDelete: mockOnDelete,
};
return {
anno: annotationQuery,
renderer: await act(async () => render(<AnnotationSettingsEdit {...props} />)),
user: userEvent.setup(),
};
}
afterEach(() => {
jest.clearAllMocks();
});
it('should render', async () => {
const {
renderer: { getByTestId },
} = await setup();
const nameInput = getByTestId(selectors.pages.Dashboard.Settings.Annotations.Settings.name);
const dataSourceSelect = getByTestId(selectors.components.DataSourcePicker.container);
const enableToggle = getByTestId(selectors.pages.Dashboard.Settings.Annotations.NewAnnotation.enable);
const hideToggle = getByTestId(selectors.pages.Dashboard.Settings.Annotations.NewAnnotation.hide);
const iconColorToggle = getByTestId(selectors.components.ColorSwatch.name);
const panelSelect = getByTestId(selectors.pages.Dashboard.Settings.Annotations.NewAnnotation.showInLabel);
const deleteAnno = getByTestId(selectors.pages.Dashboard.Settings.Annotations.NewAnnotation.delete);
const apply = getByTestId(selectors.pages.Dashboard.Settings.Annotations.NewAnnotation.apply);
expect(nameInput).toBeInTheDocument();
expect(dataSourceSelect).toBeInTheDocument();
expect(enableToggle).toBeInTheDocument();
expect(hideToggle).toBeInTheDocument();
expect(iconColorToggle).toBeInTheDocument();
expect(panelSelect).toBeInTheDocument();
expect(deleteAnno).toBeInTheDocument();
expect(apply).toBeInTheDocument();
});
it('should update annotation name on change', async () => {
const {
renderer: { getByTestId },
user,
} = await setup();
await user.type(getByTestId(selectors.pages.Dashboard.Settings.Annotations.Settings.name), 'new name');
expect(mockOnUpdate).toHaveBeenCalled();
});
it('should toggle annotation enabled on change', async () => {
const {
renderer: { getByTestId },
user,
anno,
} = await setup();
const annoArg = {
...anno,
enable: !anno.enable,
};
const enableToggle = getByTestId(selectors.pages.Dashboard.Settings.Annotations.NewAnnotation.enable);
await user.click(enableToggle);
expect(mockOnUpdate).toHaveBeenCalledTimes(1);
expect(mockOnUpdate).toHaveBeenCalledWith(annoArg, 1);
});
it('should toggle annotation hide on change', async () => {
const {
renderer: { getByTestId },
user,
anno,
} = await setup();
const annoArg = {
...anno,
hide: !anno.hide,
};
const hideToggle = getByTestId(selectors.pages.Dashboard.Settings.Annotations.NewAnnotation.hide);
await user.click(hideToggle);
expect(mockOnUpdate).toHaveBeenCalledTimes(1);
expect(mockOnUpdate).toHaveBeenCalledWith(annoArg, 1);
});
it('should set annotation filter', async () => {
const {
renderer: { getByTestId },
user,
} = await setup();
const panelSelect = getByTestId(selectors.components.Annotations.annotationsTypeInput);
await user.click(panelSelect);
await user.tab();
expect(mockOnUpdate).toHaveBeenCalledTimes(1);
expect(mockOnUpdate).toHaveBeenCalledWith(expect.objectContaining({ filter: undefined }), 1);
});
it('should delete annotation', async () => {
const {
renderer: { getByTestId },
user,
} = await setup();
const deleteAnno = getByTestId(selectors.pages.Dashboard.Settings.Annotations.NewAnnotation.delete);
await user.click(deleteAnno);
expect(mockOnDelete).toHaveBeenCalledTimes(1);
});
it('should go back to list annotation', async () => {
const {
renderer: { getByTestId },
user,
} = await setup();
const goBack = getByTestId(selectors.pages.Dashboard.Settings.Annotations.NewAnnotation.apply);
await user.click(goBack);
expect(mockGoBackToList).toHaveBeenCalledTimes(1);
});
});

View File

@ -0,0 +1,321 @@
import { css } from '@emotion/css';
import React, { useMemo } from 'react';
import { useAsync } from 'react-use';
import {
AnnotationQuery,
DataSourceInstanceSettings,
getDataSourceRef,
GrafanaTheme2,
SelectableValue,
} from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { config, getDataSourceSrv } from '@grafana/runtime';
import { VizPanel } from '@grafana/scenes';
import { AnnotationPanelFilter } from '@grafana/schema/src/raw/dashboard/x/dashboard_types.gen';
import {
Button,
Checkbox,
Field,
FieldSet,
HorizontalGroup,
Input,
MultiSelect,
Select,
useStyles2,
Stack,
} from '@grafana/ui';
import { ColorValueEditor } from 'app/core/components/OptionsUI/color';
import StandardAnnotationQueryEditor from 'app/features/annotations/components/StandardAnnotationQueryEditor';
import { DataSourcePicker } from 'app/features/datasources/components/picker/DataSourcePicker';
import { getPanelIdForVizPanel } from '../../utils/utils';
import { AngularEditorLoader } from './AngularEditorLoader';
type Props = {
annotation: AnnotationQuery;
editIndex: number;
panels: VizPanel[];
onUpdate: (annotation: AnnotationQuery, editIndex: number) => void;
onBackToList: () => void;
onDelete: (index: number) => void;
};
export const newAnnotationName = 'New annotation';
export const AnnotationSettingsEdit = ({ annotation, editIndex, panels, onUpdate, onBackToList, onDelete }: Props) => {
const styles = useStyles2(getStyles);
const panelFilter = useMemo(() => {
if (!annotation.filter) {
return PanelFilterType.AllPanels;
}
return annotation.filter.exclude ? PanelFilterType.ExcludePanels : PanelFilterType.IncludePanels;
}, [annotation.filter]);
const { value: ds } = useAsync(() => {
return getDataSourceSrv().get(annotation.datasource);
}, [annotation.datasource]);
const dsi = getDataSourceSrv().getInstanceSettings(annotation.datasource);
const onNameChange = (ev: React.FocusEvent<HTMLInputElement>) => {
onUpdate(
{
...annotation,
name: ev.currentTarget.value,
},
editIndex
);
};
const onDataSourceChange = (ds: DataSourceInstanceSettings) => {
const dsRef = getDataSourceRef(ds);
if (annotation.datasource?.type !== dsRef.type) {
onUpdate(
{
datasource: dsRef,
builtIn: annotation.builtIn,
enable: annotation.enable,
iconColor: annotation.iconColor,
name: annotation.name,
hide: annotation.hide,
filter: annotation.filter,
mappings: annotation.mappings,
type: annotation.type,
},
editIndex
);
} else {
onUpdate(
{
...annotation,
datasource: dsRef,
},
editIndex
);
}
};
const onChange = (ev: React.FocusEvent<HTMLInputElement>) => {
const target = ev.currentTarget;
onUpdate(
{
...annotation,
[target.name]: target.type === 'checkbox' ? target.checked : target.value,
},
editIndex
);
};
const onColorChange = (color?: string) => {
onUpdate(
{
...annotation,
iconColor: color!,
},
editIndex
);
};
const onFilterTypeChange = (v: SelectableValue<PanelFilterType>) => {
let filter =
v.value === PanelFilterType.AllPanels
? undefined
: {
exclude: v.value === PanelFilterType.ExcludePanels,
ids: annotation.filter?.ids ?? [],
};
onUpdate({ ...annotation, filter }, editIndex);
};
const onAddFilterPanelID = (selections: Array<SelectableValue<number>>) => {
if (!Array.isArray(selections)) {
return;
}
const filter: AnnotationPanelFilter = {
exclude: panelFilter === PanelFilterType.ExcludePanels,
ids: [],
};
selections.forEach((selection) => selection.value && filter.ids.push(selection.value));
onUpdate({ ...annotation, filter }, editIndex);
};
const onDeleteAndLeavePage = () => {
onDelete(editIndex);
onBackToList();
};
const isNewAnnotation = annotation.name === newAnnotationName;
const sortFn = (a: SelectableValue<number>, b: SelectableValue<number>) => {
if (a.label && b.label) {
return a.label.toLowerCase().localeCompare(b.label.toLowerCase());
}
return -1;
};
const selectablePanels: Array<SelectableValue<number>> = useMemo(
() =>
panels
// Filtering out rows at the moment, revisit to only include panels that support annotations
// However the information to know if a panel supports annotations requires it to be already loaded
// panel.plugin?.dataSupport?.annotations
.filter((panel) => config.panels[panel.state.pluginId])
.map((panel) => ({
value: getPanelIdForVizPanel(panel),
label: panel.state.title ?? `Panel ${getPanelIdForVizPanel(panel)}`,
description: panel.state.description,
imgUrl: config.panels[panel.state.pluginId].info.logos.small,
}))
.sort(sortFn) ?? [],
[panels]
);
return (
<div>
<FieldSet className={styles.settingsForm}>
<Field label="Name">
<Input
data-testid={selectors.pages.Dashboard.Settings.Annotations.Settings.name}
name="name"
id="name"
autoFocus={isNewAnnotation}
value={annotation.name}
onChange={onNameChange}
/>
</Field>
<Field label="Data source" htmlFor="data-source-picker">
<DataSourcePicker annotations variables current={annotation.datasource} onChange={onDataSourceChange} />
</Field>
<Field label="Enabled" description="When enabled the annotation query is issued every dashboard refresh">
<Checkbox
name="enable"
id="enable"
value={annotation.enable}
onChange={onChange}
data-testid={selectors.pages.Dashboard.Settings.Annotations.NewAnnotation.enable}
/>
</Field>
<Field
label="Hidden"
description="Annotation queries can be toggled on or off at the top of the dashboard. With this option checked this toggle will be hidden."
>
<Checkbox
name="hide"
id="hide"
value={annotation.hide}
onChange={onChange}
data-testid={selectors.pages.Dashboard.Settings.Annotations.NewAnnotation.hide}
/>
</Field>
<Field label="Color" description="Color to use for the annotation event markers">
<HorizontalGroup>
<ColorValueEditor value={annotation?.iconColor} onChange={onColorChange} />
</HorizontalGroup>
</Field>
<Field label="Show in" data-testid={selectors.pages.Dashboard.Settings.Annotations.NewAnnotation.showInLabel}>
<>
<Select
options={panelFilters}
value={panelFilter}
onChange={onFilterTypeChange}
data-testid={selectors.components.Annotations.annotationsTypeInput}
/>
{panelFilter !== PanelFilterType.AllPanels && (
<MultiSelect
options={selectablePanels}
value={selectablePanels.filter((panel) => annotation.filter?.ids.includes(panel.value!))}
onChange={onAddFilterPanelID}
isClearable={true}
placeholder="Choose panels"
width={100}
closeMenuOnSelect={false}
className={styles.select}
data-testid={selectors.components.Annotations.annotationsChoosePanelInput}
/>
)}
</>
</Field>
</FieldSet>
<FieldSet>
<h3 className="page-heading">Query</h3>
{ds?.annotations && dsi && (
<StandardAnnotationQueryEditor
datasource={ds}
datasourceInstanceSettings={dsi}
annotation={annotation}
onChange={(annotation) => onUpdate(annotation, editIndex)}
/>
)}
{ds && !ds.annotations && (
<AngularEditorLoader
datasource={ds}
annotation={annotation}
onChange={(annotation) => onUpdate(annotation, editIndex)}
/>
)}
</FieldSet>
<Stack>
{!annotation.builtIn && (
<Button
variant="destructive"
onClick={onDeleteAndLeavePage}
data-testid={selectors.pages.Dashboard.Settings.Annotations.NewAnnotation.delete}
>
Delete
</Button>
)}
<Button
variant="secondary"
onClick={onBackToList}
data-testid={selectors.pages.Dashboard.Settings.Annotations.NewAnnotation.apply}
>
Back to list
</Button>
</Stack>
</div>
);
};
const getStyles = (theme: GrafanaTheme2) => {
return {
settingsForm: css({
maxWidth: theme.spacing(60),
marginBottom: theme.spacing(2),
}),
select: css({
marginTop: '8px',
}),
};
};
// Synthetic type
enum PanelFilterType {
AllPanels,
IncludePanels,
ExcludePanels,
}
const panelFilters = [
{
label: 'All panels',
value: PanelFilterType.AllPanels,
description: 'Send the annotation data to all panels that support annotations',
},
{
label: 'Selected panels',
value: PanelFilterType.IncludePanels,
description: 'Send the annotations to the explicitly listed panels',
},
{
label: 'All panels except',
value: PanelFilterType.ExcludePanels,
description: 'Do not send annotation data to the following panels',
},
];

View File

@ -0,0 +1,139 @@
import { act, render } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import { AnnotationQuery } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { mockDataSource } from 'app/features/alerting/unified/mocks';
import { MoveDirection } from '../AnnotationsEditView';
import { AnnotationSettingsList, BUTTON_TITLE } from './AnnotationSettingsList';
const defaultDatasource = mockDataSource({
name: 'Default Test Data Source',
type: 'test',
});
jest.mock('@grafana/runtime/src/services/dataSourceSrv', () => ({
...jest.requireActual('@grafana/runtime/src/services/dataSourceSrv'),
getDataSourceSrv: () => ({
getInstanceSettings: () => ({ ...defaultDatasource }),
}),
}));
describe('AnnotationSettingsEdit', () => {
const mockOnNew = jest.fn();
const mockOnEdit = jest.fn();
const mockOnMove = jest.fn();
const mockOnDelete = jest.fn();
async function setup(emptyList = false) {
const annotationQuery1: AnnotationQuery = {
name: 'test1',
datasource: defaultDatasource,
enable: true,
hide: false,
iconColor: 'blue',
};
const annotationQuery2: AnnotationQuery = {
name: 'test2',
datasource: defaultDatasource,
enable: true,
hide: false,
iconColor: 'red',
};
const props = {
annotations: emptyList ? [] : [annotationQuery1, annotationQuery2],
onNew: mockOnNew,
onEdit: mockOnEdit,
onMove: mockOnMove,
onDelete: mockOnDelete,
};
return {
renderer: await act(async () => render(<AnnotationSettingsList {...props} />)),
user: userEvent.setup(),
};
}
afterEach(() => {
jest.clearAllMocks();
});
it('should render with empty list message', async () => {
const {
renderer: { getByTestId },
} = await setup(true);
const emptyListBtn = getByTestId(selectors.components.CallToActionCard.buttonV2(BUTTON_TITLE));
expect(emptyListBtn).toBeInTheDocument();
});
it('should create new annotation when empty list button is pressed', async () => {
const {
renderer: { getByTestId },
user,
} = await setup(true);
const emptyListBtn = getByTestId(selectors.components.CallToActionCard.buttonV2(BUTTON_TITLE));
await user.click(emptyListBtn);
expect(mockOnNew).toHaveBeenCalledTimes(1);
});
it('should render annotation list', async () => {
const {
renderer: { getByTestId },
} = await setup();
const list = getByTestId(selectors.pages.Dashboard.Settings.Annotations.List.annotations);
expect(list.children.length).toBe(2);
});
it('should edit annotation', async () => {
const {
renderer: { getAllByRole },
user,
} = await setup();
const gridCells = getAllByRole('gridcell');
await user.click(gridCells[0]);
expect(mockOnEdit).toHaveBeenCalledTimes(1);
});
it('should move annotation up', async () => {
const {
renderer: { getAllByLabelText },
user,
} = await setup();
const moveBtns = getAllByLabelText('Move up');
await user.click(moveBtns[0]);
expect(mockOnMove).toHaveBeenCalledTimes(1);
expect(mockOnMove).toHaveBeenCalledWith(expect.anything(), MoveDirection.UP);
});
it('should move annotation down', async () => {
const {
renderer: { getAllByLabelText },
user,
} = await setup();
const moveBtns = getAllByLabelText('Move down');
await user.click(moveBtns[0]);
expect(mockOnMove).toHaveBeenCalledTimes(1);
expect(mockOnMove).toHaveBeenCalledWith(expect.anything(), MoveDirection.DOWN);
});
});

View File

@ -2,19 +2,24 @@ import { css } from '@emotion/css';
import React from 'react';
import { AnnotationQuery } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
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';
import { MoveDirection } from '../AnnotationsEditView';
type Props = {
annotations: AnnotationQuery[];
onNew: () => void;
onEdit: (idx: number) => void;
onMove: (idx: number, dir: number) => void;
onMove: (idx: number, dir: MoveDirection) => void;
onDelete: (idx: number) => void;
};
export const BUTTON_TITLE = 'Add annotation query';
export const AnnotationSettingsList = ({ annotations, onNew, onEdit, onMove, onDelete }: Props) => {
const styles = useStyles2(getStyles);
@ -45,7 +50,7 @@ export const AnnotationSettingsList = ({ annotations, onNew, onEdit, onMove, onD
<th colSpan={3}></th>
</tr>
</thead>
<tbody>
<tbody data-testid={selectors.pages.Dashboard.Settings.Annotations.List.annotations}>
{annotations.map((annotation, idx) => (
<tr key={`${annotation.name}-${idx}`}>
{annotation.builtIn ? (
@ -65,11 +70,17 @@ export const AnnotationSettingsList = ({ annotations, onNew, onEdit, onMove, onD
{dataSourceSrv.getInstanceSettings(annotation.datasource)?.name || annotation.datasource?.uid}
</td>
<td role="gridcell" style={{ width: '1%' }}>
{idx !== 0 && <IconButton name="arrow-up" onClick={() => onMove(idx, -1)} tooltip="Move up" />}
{idx !== 0 && (
<IconButton name="arrow-up" onClick={() => onMove(idx, MoveDirection.UP)} tooltip="Move up" />
)}
</td>
<td role="gridcell" style={{ width: '1%' }}>
{annotations.length > 1 && idx !== annotations.length - 1 ? (
<IconButton name="arrow-down" onClick={() => onMove(idx, 1)} tooltip="Move down" />
<IconButton
name="arrow-down"
onClick={() => onMove(idx, MoveDirection.DOWN)}
tooltip="Move down"
/>
) : null}
</td>
<td role="gridcell" style={{ width: '1%' }}>
@ -92,7 +103,7 @@ export const AnnotationSettingsList = ({ annotations, onNew, onEdit, onMove, onD
onClick={onNew}
title="There are no custom annotation queries added yet"
buttonIcon="comment-alt"
buttonTitle="Add annotation query"
buttonTitle={BUTTON_TITLE}
infoBoxTitle="What are annotation queries?"
infoBox={{
__html: `<p>Annotations provide a way to integrate event data into your graphs. They are visualized as vertical lines

View File

@ -1 +1,2 @@
export { AnnotationSettingsEdit, newAnnotationName } from './AnnotationSettingsEdit';
export { AnnotationSettingsList } from './AnnotationSettingsList';

View File

@ -1,6 +1,8 @@
import {
SceneDataLayers,
SceneGridItem,
SceneGridLayout,
SceneGridRow,
SceneQueryRunner,
SceneRefreshPicker,
SceneTimePicker,
@ -8,6 +10,8 @@ import {
VizPanel,
} from '@grafana/scenes';
import { AlertStatesDataLayer } from '../scene/AlertStatesDataLayer';
import { DashboardAnnotationsDataLayer } from '../scene/DashboardAnnotationsDataLayer';
import { DashboardControls } from '../scene/DashboardControls';
import { DashboardLinksControls } from '../scene/DashboardLinksControls';
import { DashboardScene, DashboardSceneState } from '../scene/DashboardScene';
@ -91,6 +95,59 @@ describe('dashboardSceneGraph', () => {
expect(dashboardSceneGraph.getPanelLinks(panelWithNoLinks)).toBeInstanceOf(VizPanelLinks);
});
});
describe('getVizPanels', () => {
let scene: DashboardScene;
beforeEach(async () => {
scene = buildTestScene();
});
it('Should return all panels', () => {
const vizPanels = dashboardSceneGraph.getVizPanels(scene);
expect(vizPanels.length).toBe(6);
expect(vizPanels[0].state.title).toBe('Panel A');
expect(vizPanels[1].state.title).toBe('Panel B');
expect(vizPanels[2].state.title).toBe('Panel C');
expect(vizPanels[3].state.title).toBe('Panel D');
expect(vizPanels[4].state.title).toBe('Panel E');
expect(vizPanels[5].state.title).toBe('Panel F');
});
it('Should return an empty array when scene has no panels', () => {
scene.setState({
body: new SceneGridLayout({ children: [] }),
});
const vizPanels = dashboardSceneGraph.getVizPanels(scene);
expect(vizPanels.length).toBe(0);
});
});
describe('getDataLayers', () => {
let scene: DashboardScene;
beforeEach(async () => {
scene = buildTestScene();
});
it('should return the scene data layers', () => {
const dataLayers = dashboardSceneGraph.getDataLayers(scene);
expect(dataLayers).toBeInstanceOf(SceneDataLayers);
expect(dataLayers?.state.layers.length).toBe(2);
});
it('should throw if there are no scene data layers', () => {
scene.setState({
$data: undefined,
});
expect(() => dashboardSceneGraph.getDataLayers(scene)).toThrow('SceneDataLayers not found');
});
});
});
function buildTestScene(overrides?: Partial<DashboardSceneState>) {
@ -110,6 +167,26 @@ function buildTestScene(overrides?: Partial<DashboardSceneState>) {
],
}),
],
$data: new SceneDataLayers({
layers: [
new DashboardAnnotationsDataLayer({
key: `annotation`,
query: {
enable: true,
hide: false,
iconColor: 'red',
name: 'a',
},
name: 'a',
isEnabled: true,
isHidden: false,
}),
new AlertStatesDataLayer({
key: 'alert-states',
name: 'Alert States',
}),
],
}),
body: new SceneGridLayout({
children: [
new SceneGridItem({
@ -131,7 +208,7 @@ function buildTestScene(overrides?: Partial<DashboardSceneState>) {
}),
new SceneGridItem({
body: new VizPanel({
title: 'Panel B',
title: 'Panel C',
key: 'panel-2-clone-1',
pluginId: 'table',
$data: new SceneQueryRunner({ key: 'data-query-runner2', queries: [{ refId: 'A' }] }),
@ -139,13 +216,33 @@ function buildTestScene(overrides?: Partial<DashboardSceneState>) {
}),
new SceneGridItem({
body: new VizPanel({
title: 'Panel B',
title: 'Panel D',
key: 'panel-with-links',
pluginId: 'table',
$data: new SceneQueryRunner({ key: 'data-query-runner3', queries: [{ refId: 'A' }] }),
titleItems: [new VizPanelLinks({ menu: new VizPanelLinksMenu({}) })],
}),
}),
new SceneGridRow({
key: 'key',
title: 'row',
children: [
new SceneGridItem({
body: new VizPanel({
title: 'Panel E',
key: 'panel-2-clone-2',
pluginId: 'table',
}),
}),
new SceneGridItem({
body: new VizPanel({
title: 'Panel F',
key: 'panel-2-clone-2',
pluginId: 'table',
}),
}),
],
}),
],
}),
...overrides,

View File

@ -1,4 +1,12 @@
import { SceneTimePicker, SceneRefreshPicker, VizPanel } from '@grafana/scenes';
import {
SceneTimePicker,
SceneRefreshPicker,
VizPanel,
SceneGridItem,
SceneGridRow,
SceneDataLayers,
sceneGraph,
} from '@grafana/scenes';
import { DashboardControls } from '../scene/DashboardControls';
import { DashboardScene } from '../scene/DashboardScene';
@ -49,9 +57,43 @@ function getPanelLinks(panel: VizPanel) {
throw new Error('VizPanelLinks links not found');
}
function getVizPanels(scene: DashboardScene): VizPanel[] {
const panels: VizPanel[] = [];
scene.state.body.forEachChild((child) => {
if (child instanceof SceneGridItem) {
if (child.state.body instanceof VizPanel) {
panels.push(child.state.body);
}
} else if (child instanceof SceneGridRow) {
child.forEachChild((child) => {
if (child instanceof SceneGridItem) {
if (child.state.body instanceof VizPanel) {
panels.push(child.state.body);
}
}
});
}
});
return panels;
}
function getDataLayers(scene: DashboardScene): SceneDataLayers {
const data = sceneGraph.getData(scene);
if (!(data instanceof SceneDataLayers)) {
throw new Error('SceneDataLayers not found');
}
return data;
}
export const dashboardSceneGraph = {
getTimePicker,
getRefreshPicker,
getDashboardControls,
getPanelLinks,
getVizPanels,
getDataLayers,
};

View File

@ -27,12 +27,11 @@ import {
import { ColorValueEditor } from 'app/core/components/OptionsUI/color';
import config from 'app/core/config';
import StandardAnnotationQueryEditor from 'app/features/annotations/components/StandardAnnotationQueryEditor';
import { AngularEditorLoader } from 'app/features/dashboard-scene/settings/annotations/AngularEditorLoader';
import { DataSourcePicker } from 'app/features/datasources/components/picker/DataSourcePicker';
import { DashboardModel } from '../../state/DashboardModel';
import { AngularEditorLoader } from './AngularEditorLoader';
type Props = {
editIdx: number;
dashboard: DashboardModel;