mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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:
parent
1315c67c8b
commit
1fe32479d7
@ -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: {
|
||||
|
@ -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();
|
||||
}
|
||||
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -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);
|
||||
});
|
||||
});
|
@ -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',
|
||||
},
|
||||
];
|
@ -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);
|
||||
});
|
||||
});
|
@ -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
|
||||
|
@ -1 +1,2 @@
|
||||
export { AnnotationSettingsEdit, newAnnotationName } from './AnnotationSettingsEdit';
|
||||
export { AnnotationSettingsList } from './AnnotationSettingsList';
|
||||
|
@ -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,
|
||||
|
@ -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,
|
||||
};
|
||||
|
@ -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;
|
||||
|
Loading…
Reference in New Issue
Block a user