mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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:
parent
7d07599dc1
commit
7b23ed728f
@ -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 {
|
||||
|
@ -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> {
|
||||
|
6
packages/grafana-data/src/utils/arrayUtils.ts
Normal file
6
packages/grafana-data/src/utils/arrayUtils.ts
Normal 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;
|
||||
}
|
@ -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';
|
||||
|
@ -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}
|
||||
|
@ -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};
|
||||
|
@ -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"
|
||||
|
@ -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;
|
||||
|
@ -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);
|
||||
|
||||
|
@ -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 {
|
||||
|
@ -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';
|
||||
|
@ -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,
|
||||
});
|
||||
|
@ -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);
|
@ -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> {{ annotation.name }}
|
||||
</td>
|
||||
<td style="width:90%" ng-show="annotation.builtIn" class="pointer" ng-click="ctrl.edit(annotation)">
|
||||
<icon name="'comment-alt'"></icon>
|
||||
<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>
|
@ -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';
|
@ -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';
|
@ -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" /> {annotation.name}
|
||||
</td>
|
||||
)}
|
||||
{annotation.builtIn && (
|
||||
<td style={{ width: '90%' }} className="pointer" onClick={() => onEdit(idx)}>
|
||||
<Icon name="comment-alt" /> <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 & 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>
|
||||
);
|
||||
};
|
@ -0,0 +1,2 @@
|
||||
export { AnnotationSettingsEdit } from './AnnotationSettingsEdit';
|
||||
export { AnnotationSettingsList } from './AnnotationSettingsList';
|
@ -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);
|
||||
});
|
||||
});
|
@ -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!} />}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
@ -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} />}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
@ -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;
|
||||
`,
|
||||
});
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
@ -1,121 +1,106 @@
|
||||
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 (
|
||||
<EmptyListCTA
|
||||
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>',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{dashboard.links.length === 0 ? (
|
||||
<EmptyListCTA
|
||||
onClick={setupNew}
|
||||
title="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>',
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<table className="filter-table filter-table--hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Type</th>
|
||||
<th>Info</th>
|
||||
<th colSpan={3} />
|
||||
</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};
|
||||
`}
|
||||
<>
|
||||
<table className="filter-table filter-table--hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Type</th>
|
||||
<th>Info</th>
|
||||
<th colSpan={3} />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{links.map((link, idx) => (
|
||||
<tr key={`${link.title}-${idx}`}>
|
||||
<td className="pointer" onClick={() => onEdit(idx)}>
|
||||
<Icon name="external-link-alt" /> {link.type}
|
||||
</td>
|
||||
<td>
|
||||
<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 && (
|
||||
<IconButton
|
||||
surface="header"
|
||||
name="arrow-up"
|
||||
aria-label="arrow-up"
|
||||
onClick={() => moveLink(idx, -1)}
|
||||
/>
|
||||
{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}
|
||||
</td>
|
||||
<td style={{ width: '1%' }}>
|
||||
{idx !== 0 && (
|
||||
<IconButton
|
||||
surface="header"
|
||||
name="arrow-up"
|
||||
aria-label="arrow-up"
|
||||
onClick={() => moveLink(idx, -1)}
|
||||
/>
|
||||
)}
|
||||
</td>
|
||||
<td style={{ width: '1%' }}>
|
||||
{dashboard.links.length > 1 && idx !== dashboard.links.length - 1 ? (
|
||||
<IconButton
|
||||
surface="header"
|
||||
name="arrow-down"
|
||||
aria-label="arrow-down"
|
||||
onClick={() => moveLink(idx, 1)}
|
||||
/>
|
||||
) : null}
|
||||
</td>
|
||||
<td style={{ width: '1%' }}>
|
||||
<IconButton surface="header" aria-label="copy" name="copy" onClick={() => duplicateLink(link, idx)} />
|
||||
</td>
|
||||
<td style={{ width: '1%' }}>
|
||||
<DeleteButton size="sm" onConfirm={() => deleteLink(idx)} />
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td style={{ width: '1%' }}>
|
||||
{links.length > 1 && idx !== links.length - 1 ? (
|
||||
<IconButton
|
||||
surface="header"
|
||||
name="arrow-down"
|
||||
aria-label="arrow-down"
|
||||
onClick={() => moveLink(idx, 1)}
|
||||
/>
|
||||
) : null}
|
||||
</td>
|
||||
<td style={{ width: '1%' }}>
|
||||
<IconButton surface="header" aria-label="copy" name="copy" onClick={() => duplicateLink(link, idx)} />
|
||||
</td>
|
||||
<td style={{ width: '1%' }}>
|
||||
<DeleteButton size="sm" onConfirm={() => deleteLink(idx)} />
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<ListNewButton onClick={onNew}>New link</ListNewButton>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
@ -1,3 +1,2 @@
|
||||
export { LinkSettingsEdit } from './LinkSettingsEdit';
|
||||
export { LinkSettingsHeader } from './LinkSettingsHeader';
|
||||
export { LinkSettingsList } from './LinkSettingsList';
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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';
|
||||
|
@ -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({
|
||||
templating: {
|
||||
list: [{}],
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -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 = (() => {
|
||||
if (this.links.length > 0) {
|
||||
return true;
|
||||
}
|
||||
isSubMenuVisible() {
|
||||
if (this.links.length > 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const visibleVars = _.filter(this.templating.list, (variable: any) => variable.hide !== 2);
|
||||
if (visibleVars.length > 0) {
|
||||
return true;
|
||||
}
|
||||
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) {
|
||||
return true;
|
||||
}
|
||||
if (this.annotations.list.find((annotation) => annotation.hide !== true)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
})();
|
||||
this.events.emit(CoreEvents.submenuVisibilityChanged, this.meta.submenuEnabled);
|
||||
return false;
|
||||
}
|
||||
|
||||
getPanelInfoById(panelId: number) {
|
||||
|
@ -203,7 +203,6 @@ export function initDashboard(args: InitDashboardArgs): ThunkResult<void> {
|
||||
|
||||
try {
|
||||
dashboard.processRepeats();
|
||||
dashboard.updateSubmenuVisibility();
|
||||
|
||||
// handle auto fix experimental feature
|
||||
if (queryParams.autofitpanels) {
|
||||
|
@ -564,6 +564,7 @@ function makeAnnotationQueryRequest(): AnnotationQueryRequest<LokiQuery> {
|
||||
datasource: 'loki',
|
||||
enable: true,
|
||||
name: 'test-annotation',
|
||||
iconColor: 'red',
|
||||
},
|
||||
dashboard: {
|
||||
id: 1,
|
||||
|
@ -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');
|
||||
|
||||
|
@ -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;
|
||||
|
Loading…
Reference in New Issue
Block a user