DashboardSettings: Migrates annotations list & edit view from angular to react and new forms styles (#31950)

* Initial commit, list and edit page working

* Progress

* angular and standard editors work

* Unifying more between annotations list and links list

* Remove submenu visibilty stuff

* Update packages/grafana-data/src/types/annotations.ts

Co-authored-by: Alex Khomenko <Clarity-89@users.noreply.github.com>

* Review feedback

* fixed checkbox

* Fixes

* test(annotationsettings): initial commit of tests

* delete files brought back by master merge

* update datasourcepicker import path

* update emotion import

* test(linksettings): clean up tests

* Fixed test

* test(annotationssettings): add remaining tests

* docs(grafana-data): export namespace for docs build

* docs(grafana-ui): export ColorValueEditorProps for docs build

* docs(grafana-ui): add docs annotation for ColorValueEditorProps

Co-authored-by: Alex Khomenko <Clarity-89@users.noreply.github.com>
Co-authored-by: Jack Westbrook <jack.westbrook@gmail.com>
This commit is contained in:
Torkel Ödegaard 2021-04-12 09:41:07 +02:00 committed by GitHub
parent 7d07599dc1
commit 7b23ed728f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
36 changed files with 857 additions and 649 deletions

View File

@ -8,17 +8,23 @@ import { DataFrame } from './dataFrame';
* This JSON object is stored in the dashboard json model.
*/
export interface AnnotationQuery<TQuery extends DataQuery = DataQuery> {
datasource: string;
datasource?: string | null;
enable: boolean;
name: string;
iconColor: string;
hide?: boolean;
builtIn?: number;
type?: string;
snapshotData?: any;
// Standard datasource query
target?: TQuery;
// Convert a dataframe to an AnnotationEvent
mappings?: AnnotationEventMappings;
// Sadly plugins can set any propery directly on the main object
[key: string]: any;
}
export interface AnnotationEvent {

View File

@ -3,7 +3,7 @@ import { ComponentType } from 'react';
import { GrafanaPlugin, PluginMeta } from './plugin';
import { PanelData } from './panel';
import { LogRowModel } from './logs';
import { AnnotationEvent, AnnotationSupport } from './annotations';
import { AnnotationEvent, AnnotationQuery, AnnotationSupport } from './annotations';
import { DataTopic, KeyValue, LoadingState, TableData, TimeSeries } from './data';
import { DataFrame, DataFrameDTO } from './dataFrame';
import { RawTimeRange, TimeRange } from './time';
@ -609,11 +609,7 @@ export interface AnnotationQueryRequest<MoreOptions = {}> {
rangeRaw: RawTimeRange;
// Should be DataModel but cannot import that here from the main app. Needs to be moved to package first.
dashboard: any;
annotation: {
datasource: string;
enable: boolean;
name: string;
} & MoreOptions;
annotation: AnnotationQuery;
}
export interface HistoryItem<TQuery extends DataQuery = DataQuery> {

View File

@ -0,0 +1,6 @@
/** @internal */
export function moveItemImmutably<T>(arr: T[], from: number, to: number) {
const clone = [...arr];
Array.prototype.splice.call(clone, to, 0, Array.prototype.splice.call(clone, from, 1)[0]);
return clone;
}

View File

@ -1,3 +1,4 @@
import * as arrayUtils from './arrayUtils';
export * from './Registry';
export * from './datasource';
export * from './deprecationWarning';
@ -10,7 +11,7 @@ export * from './namedColorsPalette';
export * from './series';
export * from './binaryOperators';
export { PanelOptionsEditorBuilder, FieldConfigEditorBuilder } from './OptionsUIBuilders';
export { arrayUtils };
export { getMappedValue } from './valueMappings';
export { getFlotPairs, getFlotPairsConstant } from './flotPairs';
export { locationUtil } from './location';

View File

@ -28,6 +28,7 @@ export interface DataSourcePickerProps {
variables?: boolean;
pluginId?: string;
noDefault?: boolean;
width?: number;
}
/**
@ -127,7 +128,7 @@ export class DataSourcePicker extends PureComponent<DataSourcePickerProps, DataS
}
render() {
const { autoFocus, onBlur, openMenuOnFocus, placeholder } = this.props;
const { autoFocus, onBlur, openMenuOnFocus, placeholder, width } = this.props;
const { error } = this.state;
const options = this.getDataSourceOptions();
const value = this.getCurrentValue();
@ -143,6 +144,7 @@ export class DataSourcePicker extends PureComponent<DataSourcePickerProps, DataS
options={options}
autoFocus={autoFocus}
onBlur={onBlur}
width={width}
openMenuOnFocus={openMenuOnFocus}
maxMenuHeight={500}
placeholder={placeholder}

View File

@ -21,6 +21,7 @@ const getCallToActionCardStyles = stylesFactory((theme: GrafanaTheme) => ({
flex-direction: column;
align-items: center;
justify-content: center;
flex-grow: 1;
`,
message: css`
margin-bottom: ${theme.spacing.lg};

View File

@ -2,7 +2,7 @@
exports[`CallToActionCard rendering when message and footer provided 1`] = `
<div
class="css-ujo8b3-call-to-action-card"
class="css-1lvb0kq-call-to-action-card"
>
<div
class="css-m2iibx"
@ -24,7 +24,7 @@ exports[`CallToActionCard rendering when message and footer provided 1`] = `
exports[`CallToActionCard rendering when message and no footer provided 1`] = `
<div
class="css-ujo8b3-call-to-action-card"
class="css-1lvb0kq-call-to-action-card"
>
<div
class="css-m2iibx"
@ -41,7 +41,7 @@ exports[`CallToActionCard rendering when message and no footer provided 1`] = `
exports[`CallToActionCard rendering when no message and footer provided 1`] = `
<div
class="css-ujo8b3-call-to-action-card"
class="css-1lvb0kq-call-to-action-card"
>
<a
href="http://dummy.link"

View File

@ -33,6 +33,7 @@ export const getCheckboxStyles = stylesFactory((theme: GrafanaTheme) => {
position: relative;
padding-left: ${checkboxSize};
vertical-align: middle;
height: ${theme.spacing.lg};
`,
input: css`
position: absolute;

View File

@ -5,13 +5,18 @@ import { stylesFactory, useTheme } from '../../themes';
import { css } from '@emotion/css';
import { ColorPickerTrigger } from '../ColorPicker/ColorPickerTrigger';
export interface Props {
/**
* @alpha
* */
export interface ColorValueEditorProps {
value?: string;
onChange: (value?: string) => void;
}
// Supporting FixedColor only currently
export const ColorValueEditor: React.FC<Props> = ({ value, onChange }) => {
/**
* @alpha
* */
export const ColorValueEditor: React.FC<ColorValueEditorProps> = ({ value, onChange }) => {
const theme = useTheme();
const styles = getStyles(theme);

View File

@ -15,6 +15,7 @@ export { ButtonCascader } from './ButtonCascader/ButtonCascader';
export { LoadingPlaceholder, LoadingPlaceholderProps } from './LoadingPlaceholder/LoadingPlaceholder';
export { ColorPicker, SeriesColorPicker } from './ColorPicker/ColorPicker';
export { ColorValueEditor, ColorValueEditorProps } from './OptionsUI/color';
export { SeriesColorPickerPopover, SeriesColorPickerPopoverWithTheme } from './ColorPicker/SeriesColorPickerPopover';
export { EmptySearchResult } from './EmptySearchResult/EmptySearchResult';
export {

View File

@ -2,7 +2,6 @@
import flattenDeep from 'lodash/flattenDeep';
import cloneDeep from 'lodash/cloneDeep';
// Components
import './editor_ctrl';
import coreModule from 'app/core/core_module';
// Utils & Services
import { dedupAnnotations } from './events_processing';

View File

@ -16,7 +16,7 @@ import coreModule from 'app/core/core_module';
interface Props {
datasource: DataSourceApi;
annotation: AnnotationQuery<DataQuery>;
change: (annotation: AnnotationQuery<DataQuery>) => void;
onChange: (annotation: AnnotationQuery<DataQuery>) => void;
}
interface State {
@ -48,7 +48,7 @@ export default class StandardAnnotationQueryEditor extends PureComponent<Props,
const fixed = processor.prepareAnnotation!(annotation);
if (fixed !== annotation) {
this.props.change(fixed);
this.props.onChange(fixed);
} else {
this.onRunQuery();
}
@ -75,14 +75,14 @@ export default class StandardAnnotationQueryEditor extends PureComponent<Props,
};
onQueryChange = (target: DataQuery) => {
this.props.change({
this.props.onChange({
...this.props.annotation,
target,
});
};
onMappingChange = (mappings: AnnotationEventMappings) => {
this.props.change({
this.props.onChange({
...this.props.annotation,
mappings,
});

View File

@ -1,156 +0,0 @@
import angular from 'angular';
import _ from 'lodash';
import $ from 'jquery';
import coreModule from 'app/core/core_module';
import { DashboardModel } from 'app/features/dashboard/state';
import DatasourceSrv from '../plugins/datasource_srv';
import appEvents from 'app/core/app_events';
import { AnnotationQuery, AppEvents } from '@grafana/data';
// Registeres the angular directive
import './components/StandardAnnotationQueryEditor';
export class AnnotationsEditorCtrl {
mode: any;
datasources: any;
currentAnnotation: any;
currentDatasource: any;
currentIsNew: any;
dashboard: DashboardModel;
annotationDefaults: any = {
name: '',
datasource: null,
iconColor: 'rgba(255, 96, 96, 1)',
enable: true,
showIn: 0,
hide: false,
};
emptyListCta = {
title: 'There are no custom annotation queries added yet',
buttonIcon: 'comment-alt',
buttonTitle: 'Add Annotation Query',
infoBox: {
__html: `<p>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 and 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.
</p>
Check out the
<a class='external-link' target='_blank' href='https://grafana.com/docs/grafana/latest/dashboards/annotations/'
>Annotations documentation</a
>
for more information.`,
},
infoBoxTitle: 'What are annotations?',
};
showOptions: any = [
{ text: 'All Panels', value: 0 },
{ text: 'Specific Panels', value: 1 },
];
/** @ngInject */
constructor(private $scope: any, private datasourceSrv: DatasourceSrv) {
$scope.ctrl = this;
this.dashboard = $scope.dashboard;
this.mode = 'list';
this.datasources = datasourceSrv.getAnnotationSources();
this.dashboard.annotations.list = this.dashboard.annotations.list ?? [];
this.reset();
this.onColorChange = this.onColorChange.bind(this);
}
async datasourceChanged() {
const newDatasource = await this.datasourceSrv.get(this.currentAnnotation.datasource);
this.$scope.$apply(() => {
this.currentDatasource = newDatasource;
});
}
/**
* Called from the react editor
*/
onAnnotationChange = (annotation: AnnotationQuery) => {
let replaced = false;
this.dashboard.annotations.list = this.dashboard.annotations.list.map((a) => {
if (a.name !== annotation.name) {
return a;
}
replaced = true;
return annotation;
});
if (!replaced) {
console.warn('updating annotation, but not in the dashboard', annotation);
}
this.currentAnnotation = annotation;
};
edit(annotation: AnnotationQuery) {
this.currentAnnotation = annotation;
this.currentAnnotation.showIn = this.currentAnnotation.showIn || 0;
this.currentIsNew = false;
this.datasourceChanged();
this.mode = 'edit';
$('.tooltip.in').remove();
}
reset() {
this.currentAnnotation = angular.copy(this.annotationDefaults);
this.currentAnnotation.datasource = this.datasources[0].name;
this.currentIsNew = true;
this.datasourceChanged();
}
update() {
this.dashboard.annotations.list = [...this.dashboard.annotations.list];
this.reset();
this.mode = 'list';
}
setupNew = () => {
this.mode = 'new';
this.reset();
};
backToList() {
this.mode = 'list';
}
move(index: number, dir: number) {
const list = [...this.dashboard.annotations.list];
Array.prototype.splice.call(list, index + dir, 0, Array.prototype.splice.call(list, index, 1)[0]);
this.dashboard.annotations.list = list;
}
add() {
const sameName: any = _.find(this.dashboard.annotations.list, { name: this.currentAnnotation.name });
if (sameName) {
appEvents.emit(AppEvents.alertWarning, ['Validation', 'Annotation with the same name already exists']);
return;
}
this.dashboard.annotations.list = [...this.dashboard.annotations.list, this.currentAnnotation];
this.reset();
this.mode = 'list';
this.dashboard.updateSubmenuVisibility();
}
removeAnnotation(annotation: AnnotationQuery) {
this.dashboard.annotations.list = this.dashboard.annotations.list.filter((a) => {
return a.name !== annotation.name;
});
this.dashboard.updateSubmenuVisibility();
}
onColorChange(newColor: string) {
this.currentAnnotation.iconColor = newColor;
}
}
coreModule.controller('AnnotationsEditorCtrl', AnnotationsEditorCtrl);

View File

@ -1,147 +0,0 @@
<div ng-controller="AnnotationsEditorCtrl">
<div class="page-action-bar">
<h3 class="dashboard-settings__header">
<a ng-click="ctrl.backToList()">Annotations</a>
<span ng-show="ctrl.mode === 'new'"><icon name="'angle-right'"></icon> New</span>
<span ng-show="ctrl.mode === 'edit'"><icon name="'angle-right'"></icon> Edit</span>
</h3>
<div class="page-action-bar__spacer"></div>
<a
type="button"
class="btn btn-primary"
ng-click="ctrl.setupNew();"
ng-if="ctrl.dashboard.annotations.list.length > 1"
ng-hide="ctrl.mode === 'edit' || ctrl.mode === 'new'"
>
New
</a>
</div>
<div ng-if="ctrl.mode === 'list'">
<table class="filter-table filter-table--hover">
<thead>
<tr>
<th>Query name</th>
<th>Data source</th>
<th colspan="3"></th>
</tr>
</thead>
<tbody>
<tr ng-repeat="annotation in ctrl.dashboard.annotations.list track by annotation.name">
<td style="width:90%" ng-hide="annotation.builtIn" class="pointer" ng-click="ctrl.edit(annotation)">
<icon name="'comment-alt'" style="color:{{ annotation.iconColor }}"></icon> &nbsp; {{ annotation.name }}
</td>
<td style="width:90%" ng-show="annotation.builtIn" class="pointer" ng-click="ctrl.edit(annotation)">
<icon name="'comment-alt'"></icon> &nbsp;
<em class="muted">{{ annotation.name }} (Built-in)</em>
</td>
<td class="pointer" ng-click="ctrl.edit(annotation)">
{{ annotation.datasource || 'Default' }}
</td>
<td style="width: 1%">
<icon ng-click="ctrl.move($index,-1)" ng-hide="$first" name="'arrow-up'"></icon>
</td>
<td style="width: 1%">
<icon ng-click="ctrl.move($index,1)" ng-hide="$last" name="'arrow-down'"></icon>
</td>
<td style="width: 1%">
<a
ng-click="ctrl.removeAnnotation(annotation)"
class="btn btn-danger btn-small"
ng-hide="annotation.builtIn"
>
<icon name="'times'" style="margin-bottom: 0;"></icon>
</a>
</td>
</tr>
</tbody>
</table>
<!-- empty list cta, there is always one built in query -->
<div ng-if="ctrl.dashboard.annotations.list.length === 1" class="p-t-2">
<empty-list-cta
title="ctrl.emptyListCta.title"
buttonIcon="ctrl.emptyListCta.buttonIcon"
buttonTitle="ctrl.emptyListCta.buttonTitle"
infoBox="ctrl.emptyListCta.infoBox"
infoBoxTitle="ctrl.emptyListCta.infoBoxTitle"
on-click="ctrl.setupNew"
/>
</div>
</div>
<div class="annotations-basic-settings" ng-if="ctrl.mode === 'edit' || ctrl.mode === 'new'">
<div class="gf-form-group">
<h5 class="section-heading">General</h5>
<div class="gf-form-inline">
<div class="gf-form">
<span class="gf-form-label width-7">Name</span>
<input type="text" class="gf-form-input width-20" ng-model="ctrl.currentAnnotation.name" placeholder="name" />
</div>
<div class="gf-form">
<span class="gf-form-label width-7">Data source</span>
<div class="gf-form-select-wrapper">
<select
class="gf-form-input"
ng-model="ctrl.currentAnnotation.datasource"
ng-options="f.name as f.name for f in ctrl.datasources"
ng-change="ctrl.datasourceChanged()"
></select>
</div>
</div>
</div>
</div>
<div class="gf-form-group">
<div class="gf-form-inline">
<gf-form-switch class="gf-form" label="Enabled" checked="ctrl.currentAnnotation.enable" label-class="width-7">
</gf-form-switch>
<gf-form-switch
class="gf-form"
label="Hidden"
tooltip="Hides the annotation query toggle from showing at the top of the dashboard"
checked="ctrl.currentAnnotation.hide"
label-class="width-7"
>
</gf-form-switch>
<div class="gf-form">
<label class="gf-form-label width-9">Color</label>
<span class="gf-form-label">
<color-picker color="ctrl.currentAnnotation.iconColor" onChange="ctrl.onColorChange"></color-picker>
</span>
</div>
</div>
</div>
<h5 class="section-heading">Query</h5>
<rebuild-on-change property="ctrl.currentDatasource">
<!-- Legacy angular -->
<plugin-component ng-if="!ctrl.currentDatasource.annotations" type="annotations-query-ctrl"> </plugin-component>
<!-- React query editor -->
<standard-annotation-editor
ng-if="ctrl.currentDatasource.annotations"
annotation="ctrl.currentAnnotation"
datasource="ctrl.currentDatasource"
change="ctrl.onAnnotationChange" />
</rebuild-on-change>
<div class="gf-form">
<div class="gf-form-button-row p-y-0">
<button
ng-show="ctrl.mode === 'new'"
type="button"
class="btn gf-form-button btn-primary"
ng-click="ctrl.add()"
>
Add
</button>
<button ng-show="ctrl.mode === 'edit'" type="button" class="btn btn-primary pull-left" ng-click="ctrl.update()">
Update
</button>
</div>
</div>
</div>
</div>

View File

@ -0,0 +1,48 @@
import React, { useEffect, useRef, useState } from 'react';
import { AnnotationQuery, DataSourceApi } from '@grafana/data';
import { AngularComponent, getAngularLoader } from '@grafana/runtime';
export interface Props {
annotation: AnnotationQuery;
datasource: DataSourceApi;
onChange: (annotation: AnnotationQuery) => void;
}
export const AngularEditorLoader: React.FC<Props> = React.memo(({ annotation, datasource, onChange }) => {
const ref = useRef<HTMLDivElement>(null);
const [angularComponent, setAngularComponent] = useState<AngularComponent | null>(null);
useEffect(() => {
return () => {
if (angularComponent) {
angularComponent.destroy();
}
};
}, [angularComponent]);
useEffect(() => {
if (ref.current) {
const loader = getAngularLoader();
const template = `<plugin-component ng-if="!ctrl.currentDatasource.annotations" type="annotations-query-ctrl"> </plugin-component>`;
const scopeProps = {
ctrl: {
currentDatasource: datasource,
currentAnnotation: annotation,
},
};
const component = loader.load(ref.current, scopeProps, template);
component.digest();
component.getScope().$watch(() => {
onChange({
...annotation,
});
});
setAngularComponent(component);
}
}, [ref]);
return <div ref={ref} />;
});
AngularEditorLoader.displayName = 'AngularEditorLoader';

View File

@ -0,0 +1,103 @@
import React, { useState } from 'react';
import { Checkbox, CollapsableSection, ColorValueEditor, Field, HorizontalGroup, Input } from '@grafana/ui';
import { DashboardModel } from '../../state/DashboardModel';
import { AnnotationQuery, DataSourceInstanceSettings } from '@grafana/data';
import { getDataSourceSrv, DataSourcePicker } from '@grafana/runtime';
import { useAsync } from 'react-use';
import StandardAnnotationQueryEditor from 'app/features/annotations/components/StandardAnnotationQueryEditor';
import { AngularEditorLoader } from './AngularEditorLoader';
export const newAnnotation: AnnotationQuery = {
name: 'New annotation',
enable: true,
datasource: null,
iconColor: 'red',
};
type Props = {
editIdx: number;
dashboard: DashboardModel;
};
export const AnnotationSettingsEdit: React.FC<Props> = ({ editIdx, dashboard }) => {
const [annotation, setAnnotation] = useState(editIdx !== null ? dashboard.annotations.list[editIdx] : newAnnotation);
const { value: ds } = useAsync(() => {
return getDataSourceSrv().get(annotation.datasource);
}, [annotation.datasource]);
const onUpdate = (annotation: AnnotationQuery) => {
const list = [...dashboard.annotations.list];
list.splice(editIdx, 1, annotation);
setAnnotation(annotation);
dashboard.annotations.list = list;
};
const onNameChange = (ev: React.FocusEvent<HTMLInputElement>) => {
onUpdate({
...annotation,
name: ev.currentTarget.value,
});
};
const onDataSourceChange = (ds: DataSourceInstanceSettings) => {
onUpdate({
...annotation,
datasource: ds.name,
});
};
const onChange = (ev: React.FocusEvent<HTMLInputElement>) => {
const target = ev.currentTarget;
onUpdate({
...annotation,
[target.name]: target.type === 'checkbox' ? target.checked : target.value,
});
};
const onColorChange = (color: string) => {
onUpdate({
...annotation,
iconColor: color,
});
};
const isNewAnnotation = annotation.name === newAnnotation.name;
return (
<div>
<Field label="Name">
<Input
name="name"
id="name"
autoFocus={isNewAnnotation}
value={annotation.name}
onChange={onNameChange}
width={50}
/>
</Field>
<Field label="Data source">
<DataSourcePicker width={50} annotations current={annotation.datasource} onChange={onDataSourceChange} />
</Field>
<Field
label="Hidden"
description="Annotation queries can be toggled on or of 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} />
</Field>
<Field label="Color" description="Color to use for the annotation event markers">
<HorizontalGroup>
<ColorValueEditor value={annotation?.iconColor} onChange={onColorChange} />
</HorizontalGroup>
</Field>
<CollapsableSection isOpen={true} label="Query">
{ds?.annotations && (
<StandardAnnotationQueryEditor datasource={ds} annotation={annotation} onChange={onUpdate} />
)}
{ds && !ds.annotations && <AngularEditorLoader datasource={ds} annotation={annotation} onChange={onUpdate} />}
</CollapsableSection>
</div>
);
};
AnnotationSettingsEdit.displayName = 'AnnotationSettingsEdit';

View File

@ -0,0 +1,108 @@
import React, { useState } from 'react';
import { DeleteButton, Icon, IconButton, VerticalGroup } from '@grafana/ui';
import EmptyListCTA from 'app/core/components/EmptyListCTA/EmptyListCTA';
import { DashboardModel } from '../../state/DashboardModel';
import { ListNewButton } from '../DashboardSettings/ListNewButton';
import { arrayUtils } from '@grafana/data';
type Props = {
dashboard: DashboardModel;
onNew: () => void;
onEdit: (idx: number) => void;
};
export const AnnotationSettingsList: React.FC<Props> = ({ dashboard, onNew, onEdit }) => {
const [annotations, updateAnnotations] = useState(dashboard.annotations.list);
const onMove = (idx: number, direction: number) => {
dashboard.annotations.list = arrayUtils.moveItemImmutably(annotations, idx, idx + direction);
updateAnnotations(dashboard.annotations.list);
};
const onDelete = (idx: number) => {
dashboard.annotations.list = [...annotations.slice(0, idx), ...annotations.slice(idx + 1)];
updateAnnotations(dashboard.annotations.list);
};
const showEmptyListCTA = annotations.length === 0 || (annotations.length === 1 && annotations[0].builtIn);
return (
<VerticalGroup>
{annotations.length > 0 && (
<table className="filter-table filter-table--hover">
<thead>
<tr>
<th>Query name</th>
<th>Data source</th>
<th colSpan={3}></th>
</tr>
</thead>
<tbody>
{dashboard.annotations.list.map((annotation, idx) => (
<tr key={`${annotation.name}-${idx}`}>
{!annotation.builtIn && (
<td className="pointer" onClick={() => onEdit(idx)}>
<Icon name="comment-alt" /> &nbsp; {annotation.name}
</td>
)}
{annotation.builtIn && (
<td style={{ width: '90%' }} className="pointer" onClick={() => onEdit(idx)}>
<Icon name="comment-alt" /> &nbsp; <em className="muted">{annotation.name} (Built-in)</em>
</td>
)}
<td className="pointer" onClick={() => onEdit(idx)}>
{annotation.datasource || 'Default'}
</td>
<td style={{ width: '1%' }}>
{idx !== 0 && (
<IconButton
surface="header"
name="arrow-up"
aria-label="arrow-up"
onClick={() => onMove(idx, -1)}
/>
)}
</td>
<td style={{ width: '1%' }}>
{dashboard.annotations.list.length > 1 && idx !== dashboard.annotations.list.length - 1 ? (
<IconButton
surface="header"
name="arrow-down"
aria-label="arrow-down"
onClick={() => onMove(idx, 1)}
/>
) : null}
</td>
<td style={{ width: '1%' }}>
<DeleteButton size="sm" onConfirm={() => onDelete(idx)} />
</td>
</tr>
))}
</tbody>
</table>
)}
{showEmptyListCTA && (
<EmptyListCTA
onClick={onNew}
title="There are no custom annotation queries added yet"
buttonIcon="comment-alt"
buttonTitle="Add annotation query"
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
and icons on all graph panels. When you hover over an annotation icon you can get event text &amp; 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.
</p>
Checkout the
<a class='external-link' target='_blank' href='http://docs.grafana.org/reference/annotations/'
>Annotations documentation</a
>
for more information.`,
}}
/>
)}
{!showEmptyListCTA && <ListNewButton onClick={onNew}>New query</ListNewButton>}
</VerticalGroup>
);
};

View File

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

View File

@ -0,0 +1,258 @@
import React from 'react';
import '@testing-library/jest-dom';
import { render, screen } from '@testing-library/react';
import { within } from '@testing-library/dom';
import userEvent from '@testing-library/user-event';
import { selectors } from '@grafana/e2e-selectors';
import { setDataSourceSrv } from '@grafana/runtime';
import { setAngularLoader } from 'app/core/services/AngularLoader';
import { AnnotationsSettings } from './AnnotationsSettings';
describe('AnnotationsSettings', () => {
let dashboard: any;
const datasources: Record<string, any> = {
Grafana: {
name: 'Grafana',
meta: {
type: 'datasource',
name: 'Grafana',
id: 'grafana',
info: {
logos: {
small: 'public/img/icn-datasource.svg',
},
},
},
},
Testdata: {
name: 'Testdata',
id: 4,
meta: {
type: 'datasource',
name: 'TestData',
id: 'testdata',
info: {
logos: {
small: 'public/app/plugins/datasource/testdata/img/testdata.svg',
},
},
},
},
Prometheus: {
name: 'Prometheus',
id: 33,
meta: {
type: 'datasource',
name: 'Prometheus',
id: 'prometheus',
info: {
logos: {
small: 'public/app/plugins/datasource/prometheus/img/prometheus_logo.svg',
},
},
},
},
};
const getTableBody = () => screen.getAllByRole('rowgroup')[1];
const getTableBodyRows = () => within(getTableBody()).getAllByRole('row');
beforeAll(() => {
setDataSourceSrv({
getList() {
return Object.values(datasources).map((d) => d);
},
getInstanceSettings(name: string) {
return name
? {
name: datasources[name].name,
value: datasources[name].name,
meta: datasources[name].meta,
}
: {
name: datasources.Testdata.name,
value: datasources.Testdata.name,
meta: datasources.Testdata.meta,
};
},
get(name: string) {
return Promise.resolve(name ? datasources[name] : datasources.Testdata);
},
} as any);
// @ts-ignore
setAngularLoader({
load: () => ({
destroy: jest.fn(),
digest: jest.fn(),
getScope: () => ({ $watch: () => {} }),
}),
});
});
beforeEach(() => {
dashboard = {
id: 74,
version: 7,
annotations: {
list: [
{
builtIn: 1,
datasource: 'Grafana',
enable: true,
hide: true,
iconColor: 'rgba(0, 211, 255, 1)',
name: 'Annotations & Alerts',
type: 'dashboard',
},
],
},
links: [],
};
});
test('it renders a header and cta if no annotations or only builtIn annotation', () => {
render(<AnnotationsSettings dashboard={dashboard} />);
expect(screen.getByRole('heading', { name: /annotations/i })).toBeInTheDocument();
expect(screen.queryByRole('table')).toBeInTheDocument();
expect(
screen.getByRole('row', { name: /annotations & alerts \(built\-in\) grafana cancel delete/i })
).toBeInTheDocument();
expect(
screen.queryByLabelText(selectors.components.CallToActionCard.button('Add annotation query'))
).toBeInTheDocument();
expect(screen.queryByRole('link', { name: /annotations documentation/i })).toBeInTheDocument();
userEvent.click(screen.getByRole('cell', { name: /annotations & alerts \(built\-in\)/i }));
const heading = screen.getByRole('heading', {
name: /annotations edit/i,
});
const nameInput = screen.getByRole('textbox', { name: /name/i });
expect(heading).toBeInTheDocument();
userEvent.clear(nameInput);
userEvent.type(nameInput, 'My Annotation');
expect(screen.queryByText(/grafana/i)).toBeInTheDocument();
expect(screen.getByRole('checkbox', { name: /hidden/i })).toBeChecked();
userEvent.click(within(heading).getByText(/annotations/i));
expect(screen.getByRole('table')).toBeInTheDocument();
expect(screen.getByRole('row', { name: /my annotation \(built\-in\) grafana cancel delete/i })).toBeInTheDocument();
expect(
screen.queryByLabelText(selectors.components.CallToActionCard.button('Add annotation query'))
).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /new query/i })).not.toBeInTheDocument();
userEvent.click(screen.getByRole('button', { name: /delete/i }));
expect(screen.queryAllByRole('row').length).toBe(0);
expect(
screen.queryByLabelText(selectors.components.CallToActionCard.button('Add annotation query'))
).toBeInTheDocument();
});
test('it renders a sortable table of annotations', () => {
const annotationsList = [
...dashboard.annotations.list,
{
builtIn: 0,
datasource: 'Prometheus',
enable: true,
hide: true,
iconColor: 'rgba(0, 211, 255, 1)',
name: 'Annotation 2',
type: 'dashboard',
},
{
builtIn: 0,
datasource: 'Prometheus',
enable: true,
hide: true,
iconColor: 'rgba(0, 211, 255, 1)',
name: 'Annotation 3',
type: 'dashboard',
},
];
const dashboardWithAnnotations = {
...dashboard,
annotations: {
list: [...annotationsList],
},
};
render(<AnnotationsSettings dashboard={dashboardWithAnnotations} />);
// Check that we have sorting buttons
expect(within(getTableBodyRows()[0]).queryByRole('button', { name: 'arrow-up' })).not.toBeInTheDocument();
expect(within(getTableBodyRows()[0]).queryByRole('button', { name: 'arrow-down' })).toBeInTheDocument();
expect(within(getTableBodyRows()[1]).queryByRole('button', { name: 'arrow-up' })).toBeInTheDocument();
expect(within(getTableBodyRows()[1]).queryByRole('button', { name: 'arrow-down' })).toBeInTheDocument();
expect(within(getTableBodyRows()[2]).queryByRole('button', { name: 'arrow-up' })).toBeInTheDocument();
expect(within(getTableBodyRows()[2]).queryByRole('button', { name: 'arrow-down' })).not.toBeInTheDocument();
// Check the original order
expect(within(getTableBodyRows()[0]).queryByText(/annotations & alerts/i)).toBeInTheDocument();
expect(within(getTableBodyRows()[1]).queryByText(/annotation 2/i)).toBeInTheDocument();
expect(within(getTableBodyRows()[2]).queryByText(/annotation 3/i)).toBeInTheDocument();
userEvent.click(within(getTableBody()).getAllByRole('button', { name: 'arrow-down' })[0]);
userEvent.click(within(getTableBody()).getAllByRole('button', { name: 'arrow-down' })[1]);
userEvent.click(within(getTableBody()).getAllByRole('button', { name: 'arrow-up' })[0]);
// Checking if it has changed the sorting accordingly
expect(within(getTableBodyRows()[0]).queryByText(/annotation 3/i)).toBeInTheDocument();
expect(within(getTableBodyRows()[1]).queryByText(/annotation 2/i)).toBeInTheDocument();
expect(within(getTableBodyRows()[2]).queryByText(/annotations & alerts/i)).toBeInTheDocument();
});
test('it renders a form for adding/editing annotations', () => {
render(<AnnotationsSettings dashboard={dashboard} />);
userEvent.click(screen.getByLabelText(selectors.components.CallToActionCard.button('Add annotation query')));
const heading = screen.getByRole('heading', {
name: /annotations edit/i,
});
const nameInput = screen.getByRole('textbox', { name: /name/i });
expect(heading).toBeInTheDocument();
userEvent.clear(nameInput);
userEvent.type(nameInput, 'My Prometheus Annotation');
userEvent.click(screen.getByText(/testdata/i));
expect(screen.queryByText(/prometheus/i)).toBeVisible();
expect(screen.queryAllByText(/testdata/i)).toHaveLength(2);
userEvent.click(screen.getByText(/prometheus/i));
expect(screen.getByRole('checkbox', { name: /hidden/i })).not.toBeChecked();
userEvent.click(within(heading).getByText(/annotations/i));
expect(within(screen.getAllByRole('rowgroup')[1]).getAllByRole('row').length).toBe(2);
expect(
screen.queryByRole('row', { name: /my prometheus annotation prometheus cancel delete/i })
).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /new query/i })).toBeInTheDocument();
expect(
screen.queryByLabelText(selectors.components.CallToActionCard.button('Add annotation query'))
).not.toBeInTheDocument();
userEvent.click(screen.getByRole('button', { name: /new query/i }));
userEvent.click(within(screen.getByRole('heading', { name: /annotations edit/i })).getByText(/annotations/i));
expect(within(screen.getAllByRole('rowgroup')[1]).getAllByRole('row').length).toBe(3);
userEvent.click(screen.getAllByRole('button', { name: /delete/i })[1]);
expect(within(screen.getAllByRole('rowgroup')[1]).getAllByRole('row').length).toBe(2);
});
});

View File

@ -1,31 +1,36 @@
import React, { PureComponent } from 'react';
import React, { useState } from 'react';
import { DashboardModel } from '../../state/DashboardModel';
import { AngularComponent, getAngularLoader } from '@grafana/runtime';
import { AnnotationSettingsEdit, AnnotationSettingsList } from '../AnnotationSettings';
import { newAnnotation } from '../AnnotationSettings/AnnotationSettingsEdit';
import { DashboardSettingsHeader } from './DashboardSettingsHeader';
interface Props {
dashboard: DashboardModel;
}
export class AnnotationsSettings extends PureComponent<Props> {
element?: HTMLElement | null;
angularCmp?: AngularComponent;
export const AnnotationsSettings: React.FC<Props> = ({ dashboard }) => {
const [editIdx, setEditIdx] = useState<number | null>(null);
componentDidMount() {
const loader = getAngularLoader();
const onGoBack = () => {
setEditIdx(null);
};
const template = '<div ng-include="\'public/app/features/annotations/partials/editor.html\'" />';
const scopeProps = { dashboard: this.props.dashboard };
this.angularCmp = loader.load(this.element, scopeProps, template);
this.angularCmp.digest();
}
const onNew = () => {
dashboard.annotations.list = [...dashboard.annotations.list, { ...newAnnotation }];
setEditIdx(dashboard.annotations.list.length - 1);
};
componentWillUnmount() {
if (this.angularCmp) {
this.angularCmp.destroy();
}
}
const onEdit = (idx: number) => {
setEditIdx(idx);
};
render() {
return <div ref={(ref) => (this.element = ref)} />;
}
}
const isEditing = editIdx !== null;
return (
<>
<DashboardSettingsHeader title="Annotations" onGoBack={onGoBack} isEditing={isEditing} />
{!isEditing && <AnnotationSettingsList dashboard={dashboard} onNew={onNew} onEdit={onEdit} />}
{isEditing && <AnnotationSettingsEdit dashboard={dashboard} editIdx={editIdx!} />}
</>
);
};

View File

@ -0,0 +1,27 @@
import React from 'react';
import { Icon, HorizontalGroup } from '@grafana/ui';
type Props = {
title: string;
onGoBack: () => void;
isEditing: boolean;
};
export const DashboardSettingsHeader: React.FC<Props> = ({ onGoBack, isEditing, title }) => {
return (
<div className="dashboard-settings__header">
<HorizontalGroup align="center" justify="space-between">
<h3>
<span onClick={onGoBack} className={isEditing ? 'pointer' : ''}>
{title}
</span>
{isEditing && (
<span>
<Icon name="angle-right" /> Edit
</span>
)}
</h3>
</HorizontalGroup>
</div>
);
};

View File

@ -48,12 +48,17 @@ describe('LinksSettings', () => {
},
];
const getTableBody = () => screen.getAllByRole('rowgroup')[1];
const getTableBodyRows = () => within(getTableBody()).getAllByRole('row');
const assertRowHasText = (index: number, text: string) => {
expect(within(getTableBodyRows()[index]).queryByText(text)).toBeInTheDocument();
};
beforeEach(() => {
dashboard = {
id: 74,
version: 7,
links: [...links],
updateSubmenuVisibility: () => {},
};
});
@ -73,9 +78,7 @@ describe('LinksSettings', () => {
// @ts-ignore
render(<LinksSettings dashboard={dashboard} />);
const tableBodyRows = within(screen.getAllByRole('rowgroup')[1]).getAllByRole('row');
expect(tableBodyRows.length).toBe(links.length);
expect(getTableBodyRows().length).toBe(links.length);
expect(
screen.queryByLabelText(selectors.components.CallToActionCard.button('Add dashboard link'))
).not.toBeInTheDocument();
@ -85,56 +88,53 @@ describe('LinksSettings', () => {
// @ts-ignore
render(<LinksSettings dashboard={dashboard} />);
const tableBody = screen.getAllByRole('rowgroup')[1];
const tableBodyRows = within(tableBody).getAllByRole('row');
// Check that we have sorting buttons
expect(within(getTableBodyRows()[0]).queryByRole('button', { name: 'arrow-up' })).not.toBeInTheDocument();
expect(within(getTableBodyRows()[0]).queryByRole('button', { name: 'arrow-down' })).toBeInTheDocument();
expect(within(tableBody).getAllByRole('button', { name: 'arrow-down' }).length).toBe(links.length - 1);
expect(within(tableBody).getAllByRole('button', { name: 'arrow-up' }).length).toBe(links.length - 1);
expect(within(getTableBodyRows()[1]).queryByRole('button', { name: 'arrow-up' })).toBeInTheDocument();
expect(within(getTableBodyRows()[1]).queryByRole('button', { name: 'arrow-down' })).toBeInTheDocument();
expect(within(tableBodyRows[0]).getByText(links[0].title)).toBeInTheDocument();
expect(within(tableBodyRows[1]).getByText(links[1].title)).toBeInTheDocument();
expect(within(tableBodyRows[2]).getByText(links[2].url)).toBeInTheDocument();
expect(within(getTableBodyRows()[2]).queryByRole('button', { name: 'arrow-up' })).toBeInTheDocument();
expect(within(getTableBodyRows()[2]).queryByRole('button', { name: 'arrow-down' })).not.toBeInTheDocument();
userEvent.click(within(tableBody).getAllByRole('button', { name: 'arrow-down' })[0]);
// Checking the original order
assertRowHasText(0, links[0].title);
assertRowHasText(1, links[1].title);
assertRowHasText(2, links[2].url);
expect(within(tableBodyRows[0]).getByText(links[1].title)).toBeInTheDocument();
expect(within(tableBodyRows[1]).getByText(links[0].title)).toBeInTheDocument();
expect(within(tableBodyRows[2]).getByText(links[2].url)).toBeInTheDocument();
userEvent.click(within(getTableBody()).getAllByRole('button', { name: 'arrow-down' })[0]);
userEvent.click(within(getTableBody()).getAllByRole('button', { name: 'arrow-down' })[1]);
userEvent.click(within(getTableBody()).getAllByRole('button', { name: 'arrow-up' })[0]);
userEvent.click(within(tableBody).getAllByRole('button', { name: 'arrow-down' })[1]);
userEvent.click(within(tableBody).getAllByRole('button', { name: 'arrow-up' })[0]);
expect(within(tableBodyRows[0]).getByText(links[2].url)).toBeInTheDocument();
expect(within(tableBodyRows[1]).getByText(links[1].title)).toBeInTheDocument();
expect(within(tableBodyRows[2]).getByText(links[0].title)).toBeInTheDocument();
// Checking if it has changed the sorting accordingly
assertRowHasText(0, links[2].url);
assertRowHasText(1, links[1].title);
assertRowHasText(2, links[0].title);
});
test('it duplicates dashboard links', () => {
// @ts-ignore
render(<LinksSettings dashboard={dashboard} />);
const tableBody = screen.getAllByRole('rowgroup')[1];
expect(getTableBodyRows().length).toBe(links.length);
expect(within(tableBody).getAllByRole('row').length).toBe(links.length);
userEvent.click(within(getTableBody()).getAllByRole('button', { name: /copy/i })[0]);
userEvent.click(within(tableBody).getAllByRole('button', { name: /copy/i })[0]);
expect(within(tableBody).getAllByRole('row').length).toBe(links.length + 1);
expect(within(tableBody).getAllByText(links[0].title).length).toBe(2);
expect(getTableBodyRows().length).toBe(links.length + 1);
expect(within(getTableBody()).getAllByText(links[0].title).length).toBe(2);
});
test('it deletes dashboard links', () => {
// @ts-ignore
render(<LinksSettings dashboard={dashboard} />);
const tableBody = screen.getAllByRole('rowgroup')[1];
expect(getTableBodyRows().length).toBe(links.length);
expect(within(tableBody).getAllByRole('row').length).toBe(links.length);
userEvent.click(within(getTableBody()).getAllByRole('button', { name: /delete/i })[0]);
userEvent.click(within(tableBody).getAllByRole('button', { name: /delete/i })[0]);
expect(within(tableBody).getAllByRole('row').length).toBe(links.length - 1);
expect(within(tableBody).queryByText(links[0].title)).not.toBeInTheDocument();
expect(getTableBodyRows().length).toBe(links.length - 1);
expect(within(getTableBody()).queryByText(links[0].title)).not.toBeInTheDocument();
});
test('it renders a form which modifies dashboard links', () => {
@ -160,20 +160,23 @@ describe('LinksSettings', () => {
expect(screen.queryByText('Tooltip')).toBeInTheDocument();
expect(screen.queryByText('Icon')).toBeInTheDocument();
userEvent.clear(screen.getByRole('textbox', { name: /title/i }));
userEvent.type(screen.getByRole('textbox', { name: /title/i }), 'New Dashboard Link');
userEvent.click(screen.getByRole('button', { name: /add/i }));
userEvent.click(
within(screen.getByRole('heading', { name: /dashboard links edit/i })).getByText(/dashboard links/i)
);
const tableBody = screen.getAllByRole('rowgroup')[1];
expect(within(tableBody).getAllByRole('row').length).toBe(links.length + 1);
expect(within(tableBody).queryByText('New Dashboard Link')).toBeInTheDocument();
expect(getTableBodyRows().length).toBe(links.length + 1);
expect(within(getTableBody()).queryByText('New Dashboard Link')).toBeInTheDocument();
userEvent.click(screen.getAllByText(links[0].type)[0]);
userEvent.clear(screen.getByRole('textbox', { name: /title/i }));
userEvent.type(screen.getByRole('textbox', { name: /title/i }), 'The first dashboard link');
userEvent.click(screen.getByRole('button', { name: /update/i }));
userEvent.click(
within(screen.getByRole('heading', { name: /dashboard links edit/i })).getByText(/dashboard links/i)
);
expect(within(screen.getAllByRole('rowgroup')[1]).queryByText(links[0].title)).not.toBeInTheDocument();
expect(within(screen.getAllByRole('rowgroup')[1]).queryByText('The first dashboard link')).toBeInTheDocument();
expect(within(getTableBody()).queryByText(links[0].title)).not.toBeInTheDocument();
expect(within(getTableBody()).queryByText('The first dashboard link')).toBeInTheDocument();
});
});

View File

@ -1,6 +1,8 @@
import React, { useState } from 'react';
import { DashboardModel } from '../../state/DashboardModel';
import { LinkSettingsEdit, LinkSettingsHeader, LinkSettingsList } from '../LinksSettings';
import { LinkSettingsEdit, LinkSettingsList } from '../LinksSettings';
import { newLink } from '../LinksSettings/LinkSettingsEdit';
import { DashboardSettingsHeader } from './DashboardSettingsHeader';
interface Props {
dashboard: DashboardModel;
}
@ -8,30 +10,28 @@ interface Props {
export type LinkSettingsMode = 'list' | 'new' | 'edit';
export const LinksSettings: React.FC<Props> = ({ dashboard }) => {
const [mode, setMode] = useState<LinkSettingsMode>('list');
const [editLinkIdx, setEditLinkIdx] = useState<number | null>(null);
const hasLinks = dashboard.links.length > 0;
const [editIdx, setEditIdx] = useState<number | null>(null);
const backToList = () => {
setMode('list');
const onGoBack = () => {
setEditIdx(null);
};
const setupNew = () => {
setEditLinkIdx(null);
setMode('new');
const onNew = () => {
dashboard.links = [...dashboard.links, { ...newLink }];
setEditIdx(dashboard.links.length - 1);
};
const editLink = (idx: number) => {
setEditLinkIdx(idx);
setMode('edit');
const onEdit = (idx: number) => {
setEditIdx(idx);
};
const isEditing = editIdx !== null;
return (
<>
<LinkSettingsHeader onNavClick={backToList} onBtnClick={setupNew} mode={mode} hasLinks={hasLinks} />
{mode === 'list' ? (
<LinkSettingsList dashboard={dashboard} setupNew={setupNew} editLink={editLink} />
) : (
<LinkSettingsEdit dashboard={dashboard} mode={mode} editLinkIdx={editLinkIdx} backToList={backToList} />
)}
<DashboardSettingsHeader onGoBack={onGoBack} title="Dashboard links" isEditing={isEditing} />
{!isEditing && <LinkSettingsList dashboard={dashboard} onNew={onNew} onEdit={onEdit} />}
{isEditing && <LinkSettingsEdit dashboard={dashboard} editLinkIdx={editIdx!} onGoBack={onGoBack} />}
</>
);
};

View File

@ -0,0 +1,23 @@
import React, { ButtonHTMLAttributes } from 'react';
import { Button, useStyles } from '@grafana/ui';
import { GrafanaTheme } from '@grafana/data';
import { css } from '@emotion/css';
export interface Props extends ButtonHTMLAttributes<HTMLButtonElement> {}
export const ListNewButton: React.FC<Props> = ({ children, ...restProps }) => {
const styles = useStyles(getStyles);
return (
<div className={styles.buttonWrapper}>
<Button icon="plus" variant="secondary" {...restProps}>
{children}
</Button>
</div>
);
};
const getStyles = (theme: GrafanaTheme) => ({
buttonWrapper: css`
padding: ${theme.spacing.lg} 0;
`,
});

View File

@ -1,13 +1,11 @@
import React, { useState } from 'react';
import { css } from '@emotion/css';
import { CollapsableSection, Button, TagsInput, Select, Field, Input, Checkbox } from '@grafana/ui';
import { CollapsableSection, TagsInput, Select, Field, Input, Checkbox } from '@grafana/ui';
import { SelectableValue } from '@grafana/data';
import { LinkSettingsMode } from '../DashboardSettings/LinksSettings';
import { DashboardLink, DashboardModel } from '../../state/DashboardModel';
const newLink = {
export const newLink = {
icon: 'external link',
title: '',
title: 'New link',
tooltip: '',
type: 'dashboards',
url: '',
@ -36,59 +34,61 @@ export const linkIconMap: { [key: string]: string } = {
const linkIconOptions = Object.keys(linkIconMap).map((key) => ({ label: key, value: key }));
type LinkSettingsEditProps = {
mode: LinkSettingsMode;
editLinkIdx: number | null;
editLinkIdx: number;
dashboard: DashboardModel;
backToList: () => void;
onGoBack: () => void;
};
export const LinkSettingsEdit: React.FC<LinkSettingsEditProps> = ({ mode, editLinkIdx, dashboard, backToList }) => {
export const LinkSettingsEdit: React.FC<LinkSettingsEditProps> = ({ editLinkIdx, dashboard }) => {
const [linkSettings, setLinkSettings] = useState(editLinkIdx !== null ? dashboard.links[editLinkIdx] : newLink);
const onUpdate = (link: DashboardLink) => {
const links = [...dashboard.links];
links.splice(editLinkIdx, 1, link);
dashboard.links = links;
setLinkSettings(link);
};
const onTagsChange = (tags: any[]) => {
setLinkSettings((link) => ({ ...link, tags: tags }));
onUpdate({ ...linkSettings, tags: tags });
};
const onTypeChange = (selectedItem: SelectableValue) => {
setLinkSettings((link) => ({ ...link, type: selectedItem.value }));
const update = { ...linkSettings, type: selectedItem.value };
// clear props that are no longe revant for this type
if (update.type === 'dashboards') {
update.url = '';
update.tooltip = '';
} else {
update.tags = [];
}
onUpdate(update);
};
const onIconChange = (selectedItem: SelectableValue) => {
setLinkSettings((link) => ({ ...link, icon: selectedItem.value }));
onUpdate({ ...linkSettings, icon: selectedItem.value });
};
const onChange = (ev: React.FocusEvent<HTMLInputElement>) => {
const target = ev.currentTarget;
setLinkSettings((link) => ({
...link,
onUpdate({
...linkSettings,
[target.name]: target.type === 'checkbox' ? target.checked : target.value,
}));
});
};
const addLink = () => {
dashboard.links = [...dashboard.links, linkSettings];
dashboard.updateSubmenuVisibility();
backToList();
};
const updateLink = () => {
dashboard.links.splice(editLinkIdx!, 1, linkSettings);
dashboard.updateSubmenuVisibility();
backToList();
};
const isNew = linkSettings.title === newLink.title;
return (
<div
className={css`
max-width: 600px;
`}
>
<div style={{ maxWidth: '600px' }}>
<Field label="Title">
<Input name="title" id="title" value={linkSettings.title} onChange={onChange} autoFocus={isNew} />
</Field>
<Field label="Type">
<Select value={linkSettings.type} options={linkTypeOptions} onChange={onTypeChange} />
</Field>
<Field label="Title">
<Input name="title" aria-label="title" value={linkSettings.title} onChange={onChange} />
</Field>
{linkSettings.type === 'dashboards' && (
<>
<Field label="With tags">
@ -140,11 +140,6 @@ export const LinkSettingsEdit: React.FC<LinkSettingsEditProps> = ({ mode, editLi
/>
</Field>
</CollapsableSection>
<div className="gf-form-button-row">
{mode === 'new' && <Button onClick={addLink}>Add</Button>}
{mode === 'edit' && <Button onClick={updateLink}>Update</Button>}
</div>
</div>
);
};

View File

@ -1,32 +0,0 @@
import React from 'react';
import { Button, Icon, HorizontalGroup } from '@grafana/ui';
import { LinkSettingsMode } from '../DashboardSettings/LinksSettings';
type LinkSettingsHeaderProps = {
onNavClick: () => void;
onBtnClick: () => void;
mode: LinkSettingsMode;
hasLinks: boolean;
};
export const LinkSettingsHeader: React.FC<LinkSettingsHeaderProps> = ({ onNavClick, onBtnClick, mode, hasLinks }) => {
const isEditing = mode !== 'list';
return (
<div className="dashboard-settings__header">
<HorizontalGroup align="center" justify="space-between">
<h3>
<span onClick={onNavClick} className={isEditing ? 'pointer' : ''}>
Dashboard links
</span>
{isEditing && (
<span>
<Icon name="angle-right" /> {mode === 'new' ? 'New' : 'Edit'}
</span>
)}
</h3>
{!isEditing && hasLinks ? <Button onClick={onBtnClick}>New</Button> : null}
</HorizontalGroup>
</div>
);
};

View File

@ -1,51 +1,54 @@
import React, { useState } from 'react';
import { css } from '@emotion/css';
import { DeleteButton, Icon, IconButton, Tag, useTheme } from '@grafana/ui';
import { DeleteButton, HorizontalGroup, Icon, IconButton, TagList } from '@grafana/ui';
import EmptyListCTA from 'app/core/components/EmptyListCTA/EmptyListCTA';
import { arrayMove } from 'app/core/utils/arrayMove';
import { DashboardModel, DashboardLink } from '../../state/DashboardModel';
import { ListNewButton } from '../DashboardSettings/ListNewButton';
import { arrayUtils } from '@grafana/data';
type LinkSettingsListProps = {
dashboard: DashboardModel;
setupNew: () => void;
editLink: (idx: number) => void;
onNew: () => void;
onEdit: (idx: number) => void;
};
export const LinkSettingsList: React.FC<LinkSettingsListProps> = ({ dashboard, setupNew, editLink }) => {
const theme = useTheme();
// @ts-ignore
const [renderCounter, setRenderCounter] = useState(0);
export const LinkSettingsList: React.FC<LinkSettingsListProps> = ({ dashboard, onNew, onEdit }) => {
const [links, setLinks] = useState(dashboard.links);
const moveLink = (idx: number, direction: number) => {
arrayMove(dashboard.links, idx, idx + direction);
setRenderCounter((renderCount) => renderCount + 1);
};
const duplicateLink = (link: DashboardLink, idx: number) => {
dashboard.links.splice(idx, 0, link);
dashboard.updateSubmenuVisibility();
setRenderCounter((renderCount) => renderCount + 1);
};
const deleteLink = (idx: number) => {
dashboard.links.splice(idx, 1);
dashboard.updateSubmenuVisibility();
setRenderCounter((renderCount) => renderCount + 1);
dashboard.links = arrayUtils.moveItemImmutably(links, idx, idx + direction);
setLinks(dashboard.links);
};
const duplicateLink = (link: DashboardLink, idx: number) => {
dashboard.links = [...links, { ...link }];
setLinks(dashboard.links);
};
const deleteLink = (idx: number) => {
dashboard.links = [...links.slice(0, idx), ...links.slice(idx + 1)];
setLinks(dashboard.links);
};
const isEmptyList = dashboard.links.length === 0;
if (isEmptyList) {
return (
<div>
{dashboard.links.length === 0 ? (
<EmptyListCTA
onClick={setupNew}
title="No dashboard links added yet"
onClick={onNew}
title="There are no dashboard links added yet"
buttonIcon="link"
buttonTitle="Add dashboard link"
infoBoxTitle="What are dashboard links?"
infoBox={{
__html:
'<p>Dashboard links allow you to place links to other dashboards and web sites directly below the dashboard header.</p>',
'<p>Dashboard Links allow you to place links to other dashboards and web sites directly below the dashboard header.</p>',
}}
/>
) : (
);
}
return (
<>
<table className="filter-table filter-table--hover">
<thead>
<tr>
@ -55,35 +58,17 @@ export const LinkSettingsList: React.FC<LinkSettingsListProps> = ({ dashboard, s
</tr>
</thead>
<tbody>
{dashboard.links.map((link, idx) => (
<tr key={idx}>
<td className="pointer" onClick={() => editLink(idx)}>
<Icon
name="external-link-alt"
className={css`
margin-right: ${theme.spacing.xs};
`}
/>
{link.type}
{links.map((link, idx) => (
<tr key={`${link.title}-${idx}`}>
<td className="pointer" onClick={() => onEdit(idx)}>
<Icon name="external-link-alt" /> &nbsp; {link.type}
</td>
<td>
{link.title && <div>{link.title}</div>}
{!link.title && link.url ? <div>{link.url}</div> : null}
{!link.title && link.tags
? link.tags.map((tag, idx) => (
<Tag
name={tag}
key={tag}
className={
idx !== 0
? css`
margin-left: ${theme.spacing.xs};
`
: ''
}
/>
))
: null}
<HorizontalGroup>
{link.title && <span>{link.title}</span>}
{link.type === 'link' && <span>{link.url}</span>}
{link.type === 'dashboards' && <TagList tags={link.tags ?? []} />}
</HorizontalGroup>
</td>
<td style={{ width: '1%' }}>
{idx !== 0 && (
@ -96,7 +81,7 @@ export const LinkSettingsList: React.FC<LinkSettingsListProps> = ({ dashboard, s
)}
</td>
<td style={{ width: '1%' }}>
{dashboard.links.length > 1 && idx !== dashboard.links.length - 1 ? (
{links.length > 1 && idx !== links.length - 1 ? (
<IconButton
surface="header"
name="arrow-down"
@ -115,7 +100,7 @@ export const LinkSettingsList: React.FC<LinkSettingsListProps> = ({ dashboard, s
))}
</tbody>
</table>
)}
</div>
<ListNewButton onClick={onNew}>New link</ListNewButton>
</>
);
};

View File

@ -1,3 +1,2 @@
export { LinkSettingsEdit } from './LinkSettingsEdit';
export { LinkSettingsHeader } from './LinkSettingsHeader';
export { LinkSettingsList } from './LinkSettingsList';

View File

@ -28,13 +28,6 @@ export const DashboardLinks: FC<Props> = ({ dashboard, links }) => {
};
});
useEffectOnce(() => {
dashboard.on(CoreEvents.submenuVisibilityChanged, forceUpdate);
return () => {
dashboard.off(CoreEvents.submenuVisibilityChanged, forceUpdate);
};
});
if (!links.length) {
return null;
}

View File

@ -2,7 +2,7 @@ import React, { PureComponent } from 'react';
import { connect, MapStateToProps } from 'react-redux';
import { StoreState } from '../../../../types';
import { getSubMenuVariables } from '../../../variables/state/selectors';
import { VariableHide, VariableModel } from '../../../variables/types';
import { VariableModel } from '../../../variables/types';
import { DashboardModel } from '../../state';
import { DashboardLinks } from './DashboardLinks';
import { Annotations } from './Annotations';
@ -38,24 +38,10 @@ class SubMenuUnConnected extends PureComponent<Props> {
this.forceUpdate();
};
isSubMenuVisible = () => {
if (this.props.dashboard.links.length > 0) {
return true;
}
const visibleVariables = this.props.variables.filter((variable) => variable.hide !== VariableHide.hideVariable);
if (visibleVariables.length > 0) {
return true;
}
const visibleAnnotations = this.props.dashboard.annotations.list.filter((annotation) => annotation.hide !== true);
return visibleAnnotations.length > 0;
};
render() {
const { dashboard, variables, links, annotations } = this.props;
if (!this.isSubMenuVisible()) {
if (!dashboard.isSubMenuVisible()) {
return null;
}
@ -78,4 +64,5 @@ const mapStateToProps: MapStateToProps<ConnectedProps, OwnProps, StoreState> = (
};
export const SubMenu = connect(mapStateToProps)(SubMenuUnConnected);
SubMenu.displayName = 'SubMenu';

View File

@ -260,20 +260,19 @@ describe('DashboardModel', () => {
});
});
describe('updateSubmenuVisibility with empty lists', () => {
describe('isSubMenuVisible with empty lists', () => {
let model: DashboardModel;
beforeEach(() => {
model = new DashboardModel({});
model.updateSubmenuVisibility();
});
it('should not enable submmenu', () => {
expect(model.meta.submenuEnabled).toBe(false);
it('should not show submenu', () => {
expect(model.isSubMenuVisible()).toBe(false);
});
});
describe('updateSubmenuVisibility with annotation', () => {
describe('isSubMenuVisible with annotation', () => {
let model: DashboardModel;
beforeEach(() => {
@ -282,32 +281,35 @@ describe('DashboardModel', () => {
list: [{}],
},
});
model.updateSubmenuVisibility();
});
it('should enable submmenu', () => {
expect(model.meta.submenuEnabled).toBe(true);
it('should show submmenu', () => {
expect(model.isSubMenuVisible()).toBe(true);
});
});
describe('updateSubmenuVisibility with template var', () => {
describe('isSubMenuVisible with template var', () => {
let model: DashboardModel;
beforeEach(() => {
model = new DashboardModel({
model = new DashboardModel(
{
templating: {
list: [{}],
},
});
model.updateSubmenuVisibility();
},
{},
// getVariablesFromState stub to return a variable
() => [{} as any]
);
});
it('should enable submmenu', () => {
expect(model.meta.submenuEnabled).toBe(true);
expect(model.isSubMenuVisible()).toBe(true);
});
});
describe('updateSubmenuVisibility with hidden template var', () => {
describe('isSubMenuVisible with hidden template var', () => {
let model: DashboardModel;
beforeEach(() => {
@ -316,15 +318,14 @@ describe('DashboardModel', () => {
list: [{ hide: 2 }],
},
});
model.updateSubmenuVisibility();
});
it('should not enable submmenu', () => {
expect(model.meta.submenuEnabled).toBe(false);
expect(model.isSubMenuVisible()).toBe(false);
});
});
describe('updateSubmenuVisibility with hidden annotation toggle', () => {
describe('isSubMenuVisible with hidden annotation toggle', () => {
let dashboard: DashboardModel;
beforeEach(() => {
@ -333,11 +334,10 @@ describe('DashboardModel', () => {
list: [{ hide: true }],
},
});
dashboard.updateSubmenuVisibility();
});
it('should not enable submmenu', () => {
expect(dashboard.meta.submenuEnabled).toBe(false);
expect(dashboard.isSubMenuVisible()).toBe(false);
});
});

View File

@ -10,6 +10,7 @@ import sortByKeys from 'app/core/utils/sort_by_keys';
import { GridPos, PanelModel } from './PanelModel';
import { DashboardMigrator } from './DashboardMigrator';
import {
AnnotationQuery,
AppEvent,
dateTimeFormat,
dateTimeFormatTimeAgo,
@ -66,7 +67,7 @@ export class DashboardModel {
timepicker: any;
templating: { list: any[] };
private originalTemplating: any;
annotations: { list: any[] };
annotations: { list: AnnotationQuery[] };
refresh: any;
snapshot: any;
schemaVersion: number;
@ -760,25 +761,20 @@ export class DashboardModel {
}
}
updateSubmenuVisibility() {
this.meta.submenuEnabled = (() => {
isSubMenuVisible() {
if (this.links.length > 0) {
return true;
}
const visibleVars = _.filter(this.templating.list, (variable: any) => variable.hide !== 2);
if (visibleVars.length > 0) {
if (this.getVariables().find((variable) => variable.hide !== 2)) {
return true;
}
const visibleAnnotations = _.filter(this.annotations.list, (annotation: any) => annotation.hide !== true);
if (visibleAnnotations.length > 0) {
if (this.annotations.list.find((annotation) => annotation.hide !== true)) {
return true;
}
return false;
})();
this.events.emit(CoreEvents.submenuVisibilityChanged, this.meta.submenuEnabled);
}
getPanelInfoById(panelId: number) {

View File

@ -203,7 +203,6 @@ export function initDashboard(args: InitDashboardArgs): ThunkResult<void> {
try {
dashboard.processRepeats();
dashboard.updateSubmenuVisibility();
// handle auto fix experimental feature
if (queryParams.autofitpanels) {

View File

@ -564,6 +564,7 @@ function makeAnnotationQueryRequest(): AnnotationQueryRequest<LokiQuery> {
datasource: 'loki',
enable: true,
name: 'test-annotation',
iconColor: 'red',
},
dashboard: {
id: 1,

View File

@ -95,7 +95,6 @@ export const toggleKioskMode = eventFactory<ToggleKioskModePayload>('toggle-kios
export const timeRangeUpdated = eventFactory<TimeRange>('time-range-updated');
export const templateVariableValueUpdated = eventFactory('template-variable-value-updated');
export const submenuVisibilityChanged = eventFactory<boolean>('submenu-visibility-changed');
export const graphClicked = eventFactory<GraphClickedPayload>('graph-click');

View File

@ -27,12 +27,6 @@
}
}
.ds-picker {
position: relative;
min-width: 200px;
max-width: 300px;
}
/* Used by old angular panels */
.panel-options-group {
border-bottom: $panel-border;