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.
|
* This JSON object is stored in the dashboard json model.
|
||||||
*/
|
*/
|
||||||
export interface AnnotationQuery<TQuery extends DataQuery = DataQuery> {
|
export interface AnnotationQuery<TQuery extends DataQuery = DataQuery> {
|
||||||
datasource: string;
|
datasource?: string | null;
|
||||||
enable: boolean;
|
enable: boolean;
|
||||||
name: string;
|
name: string;
|
||||||
iconColor: string;
|
iconColor: string;
|
||||||
hide?: boolean;
|
hide?: boolean;
|
||||||
|
builtIn?: number;
|
||||||
|
type?: string;
|
||||||
|
snapshotData?: any;
|
||||||
|
|
||||||
// Standard datasource query
|
// Standard datasource query
|
||||||
target?: TQuery;
|
target?: TQuery;
|
||||||
|
|
||||||
// Convert a dataframe to an AnnotationEvent
|
// Convert a dataframe to an AnnotationEvent
|
||||||
mappings?: AnnotationEventMappings;
|
mappings?: AnnotationEventMappings;
|
||||||
|
|
||||||
|
// Sadly plugins can set any propery directly on the main object
|
||||||
|
[key: string]: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AnnotationEvent {
|
export interface AnnotationEvent {
|
||||||
|
@ -3,7 +3,7 @@ import { ComponentType } from 'react';
|
|||||||
import { GrafanaPlugin, PluginMeta } from './plugin';
|
import { GrafanaPlugin, PluginMeta } from './plugin';
|
||||||
import { PanelData } from './panel';
|
import { PanelData } from './panel';
|
||||||
import { LogRowModel } from './logs';
|
import { LogRowModel } from './logs';
|
||||||
import { AnnotationEvent, AnnotationSupport } from './annotations';
|
import { AnnotationEvent, AnnotationQuery, AnnotationSupport } from './annotations';
|
||||||
import { DataTopic, KeyValue, LoadingState, TableData, TimeSeries } from './data';
|
import { DataTopic, KeyValue, LoadingState, TableData, TimeSeries } from './data';
|
||||||
import { DataFrame, DataFrameDTO } from './dataFrame';
|
import { DataFrame, DataFrameDTO } from './dataFrame';
|
||||||
import { RawTimeRange, TimeRange } from './time';
|
import { RawTimeRange, TimeRange } from './time';
|
||||||
@ -609,11 +609,7 @@ export interface AnnotationQueryRequest<MoreOptions = {}> {
|
|||||||
rangeRaw: RawTimeRange;
|
rangeRaw: RawTimeRange;
|
||||||
// Should be DataModel but cannot import that here from the main app. Needs to be moved to package first.
|
// Should be DataModel but cannot import that here from the main app. Needs to be moved to package first.
|
||||||
dashboard: any;
|
dashboard: any;
|
||||||
annotation: {
|
annotation: AnnotationQuery;
|
||||||
datasource: string;
|
|
||||||
enable: boolean;
|
|
||||||
name: string;
|
|
||||||
} & MoreOptions;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface HistoryItem<TQuery extends DataQuery = DataQuery> {
|
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 './Registry';
|
||||||
export * from './datasource';
|
export * from './datasource';
|
||||||
export * from './deprecationWarning';
|
export * from './deprecationWarning';
|
||||||
@ -10,7 +11,7 @@ export * from './namedColorsPalette';
|
|||||||
export * from './series';
|
export * from './series';
|
||||||
export * from './binaryOperators';
|
export * from './binaryOperators';
|
||||||
export { PanelOptionsEditorBuilder, FieldConfigEditorBuilder } from './OptionsUIBuilders';
|
export { PanelOptionsEditorBuilder, FieldConfigEditorBuilder } from './OptionsUIBuilders';
|
||||||
|
export { arrayUtils };
|
||||||
export { getMappedValue } from './valueMappings';
|
export { getMappedValue } from './valueMappings';
|
||||||
export { getFlotPairs, getFlotPairsConstant } from './flotPairs';
|
export { getFlotPairs, getFlotPairsConstant } from './flotPairs';
|
||||||
export { locationUtil } from './location';
|
export { locationUtil } from './location';
|
||||||
|
@ -28,6 +28,7 @@ export interface DataSourcePickerProps {
|
|||||||
variables?: boolean;
|
variables?: boolean;
|
||||||
pluginId?: string;
|
pluginId?: string;
|
||||||
noDefault?: boolean;
|
noDefault?: boolean;
|
||||||
|
width?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -127,7 +128,7 @@ export class DataSourcePicker extends PureComponent<DataSourcePickerProps, DataS
|
|||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { autoFocus, onBlur, openMenuOnFocus, placeholder } = this.props;
|
const { autoFocus, onBlur, openMenuOnFocus, placeholder, width } = this.props;
|
||||||
const { error } = this.state;
|
const { error } = this.state;
|
||||||
const options = this.getDataSourceOptions();
|
const options = this.getDataSourceOptions();
|
||||||
const value = this.getCurrentValue();
|
const value = this.getCurrentValue();
|
||||||
@ -143,6 +144,7 @@ export class DataSourcePicker extends PureComponent<DataSourcePickerProps, DataS
|
|||||||
options={options}
|
options={options}
|
||||||
autoFocus={autoFocus}
|
autoFocus={autoFocus}
|
||||||
onBlur={onBlur}
|
onBlur={onBlur}
|
||||||
|
width={width}
|
||||||
openMenuOnFocus={openMenuOnFocus}
|
openMenuOnFocus={openMenuOnFocus}
|
||||||
maxMenuHeight={500}
|
maxMenuHeight={500}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
|
@ -21,6 +21,7 @@ const getCallToActionCardStyles = stylesFactory((theme: GrafanaTheme) => ({
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
flex-grow: 1;
|
||||||
`,
|
`,
|
||||||
message: css`
|
message: css`
|
||||||
margin-bottom: ${theme.spacing.lg};
|
margin-bottom: ${theme.spacing.lg};
|
||||||
|
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
exports[`CallToActionCard rendering when message and footer provided 1`] = `
|
exports[`CallToActionCard rendering when message and footer provided 1`] = `
|
||||||
<div
|
<div
|
||||||
class="css-ujo8b3-call-to-action-card"
|
class="css-1lvb0kq-call-to-action-card"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="css-m2iibx"
|
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`] = `
|
exports[`CallToActionCard rendering when message and no footer provided 1`] = `
|
||||||
<div
|
<div
|
||||||
class="css-ujo8b3-call-to-action-card"
|
class="css-1lvb0kq-call-to-action-card"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="css-m2iibx"
|
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`] = `
|
exports[`CallToActionCard rendering when no message and footer provided 1`] = `
|
||||||
<div
|
<div
|
||||||
class="css-ujo8b3-call-to-action-card"
|
class="css-1lvb0kq-call-to-action-card"
|
||||||
>
|
>
|
||||||
<a
|
<a
|
||||||
href="http://dummy.link"
|
href="http://dummy.link"
|
||||||
|
@ -33,6 +33,7 @@ export const getCheckboxStyles = stylesFactory((theme: GrafanaTheme) => {
|
|||||||
position: relative;
|
position: relative;
|
||||||
padding-left: ${checkboxSize};
|
padding-left: ${checkboxSize};
|
||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
|
height: ${theme.spacing.lg};
|
||||||
`,
|
`,
|
||||||
input: css`
|
input: css`
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
@ -5,13 +5,18 @@ import { stylesFactory, useTheme } from '../../themes';
|
|||||||
import { css } from '@emotion/css';
|
import { css } from '@emotion/css';
|
||||||
import { ColorPickerTrigger } from '../ColorPicker/ColorPickerTrigger';
|
import { ColorPickerTrigger } from '../ColorPicker/ColorPickerTrigger';
|
||||||
|
|
||||||
export interface Props {
|
/**
|
||||||
|
* @alpha
|
||||||
|
* */
|
||||||
|
export interface ColorValueEditorProps {
|
||||||
value?: string;
|
value?: string;
|
||||||
onChange: (value?: string) => void;
|
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 theme = useTheme();
|
||||||
const styles = getStyles(theme);
|
const styles = getStyles(theme);
|
||||||
|
|
||||||
|
@ -15,6 +15,7 @@ export { ButtonCascader } from './ButtonCascader/ButtonCascader';
|
|||||||
|
|
||||||
export { LoadingPlaceholder, LoadingPlaceholderProps } from './LoadingPlaceholder/LoadingPlaceholder';
|
export { LoadingPlaceholder, LoadingPlaceholderProps } from './LoadingPlaceholder/LoadingPlaceholder';
|
||||||
export { ColorPicker, SeriesColorPicker } from './ColorPicker/ColorPicker';
|
export { ColorPicker, SeriesColorPicker } from './ColorPicker/ColorPicker';
|
||||||
|
export { ColorValueEditor, ColorValueEditorProps } from './OptionsUI/color';
|
||||||
export { SeriesColorPickerPopover, SeriesColorPickerPopoverWithTheme } from './ColorPicker/SeriesColorPickerPopover';
|
export { SeriesColorPickerPopover, SeriesColorPickerPopoverWithTheme } from './ColorPicker/SeriesColorPickerPopover';
|
||||||
export { EmptySearchResult } from './EmptySearchResult/EmptySearchResult';
|
export { EmptySearchResult } from './EmptySearchResult/EmptySearchResult';
|
||||||
export {
|
export {
|
||||||
|
@ -2,7 +2,6 @@
|
|||||||
import flattenDeep from 'lodash/flattenDeep';
|
import flattenDeep from 'lodash/flattenDeep';
|
||||||
import cloneDeep from 'lodash/cloneDeep';
|
import cloneDeep from 'lodash/cloneDeep';
|
||||||
// Components
|
// Components
|
||||||
import './editor_ctrl';
|
|
||||||
import coreModule from 'app/core/core_module';
|
import coreModule from 'app/core/core_module';
|
||||||
// Utils & Services
|
// Utils & Services
|
||||||
import { dedupAnnotations } from './events_processing';
|
import { dedupAnnotations } from './events_processing';
|
||||||
|
@ -16,7 +16,7 @@ import coreModule from 'app/core/core_module';
|
|||||||
interface Props {
|
interface Props {
|
||||||
datasource: DataSourceApi;
|
datasource: DataSourceApi;
|
||||||
annotation: AnnotationQuery<DataQuery>;
|
annotation: AnnotationQuery<DataQuery>;
|
||||||
change: (annotation: AnnotationQuery<DataQuery>) => void;
|
onChange: (annotation: AnnotationQuery<DataQuery>) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface State {
|
interface State {
|
||||||
@ -48,7 +48,7 @@ export default class StandardAnnotationQueryEditor extends PureComponent<Props,
|
|||||||
|
|
||||||
const fixed = processor.prepareAnnotation!(annotation);
|
const fixed = processor.prepareAnnotation!(annotation);
|
||||||
if (fixed !== annotation) {
|
if (fixed !== annotation) {
|
||||||
this.props.change(fixed);
|
this.props.onChange(fixed);
|
||||||
} else {
|
} else {
|
||||||
this.onRunQuery();
|
this.onRunQuery();
|
||||||
}
|
}
|
||||||
@ -75,14 +75,14 @@ export default class StandardAnnotationQueryEditor extends PureComponent<Props,
|
|||||||
};
|
};
|
||||||
|
|
||||||
onQueryChange = (target: DataQuery) => {
|
onQueryChange = (target: DataQuery) => {
|
||||||
this.props.change({
|
this.props.onChange({
|
||||||
...this.props.annotation,
|
...this.props.annotation,
|
||||||
target,
|
target,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
onMappingChange = (mappings: AnnotationEventMappings) => {
|
onMappingChange = (mappings: AnnotationEventMappings) => {
|
||||||
this.props.change({
|
this.props.onChange({
|
||||||
...this.props.annotation,
|
...this.props.annotation,
|
||||||
mappings,
|
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 { 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 {
|
interface Props {
|
||||||
dashboard: DashboardModel;
|
dashboard: DashboardModel;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class AnnotationsSettings extends PureComponent<Props> {
|
export const AnnotationsSettings: React.FC<Props> = ({ dashboard }) => {
|
||||||
element?: HTMLElement | null;
|
const [editIdx, setEditIdx] = useState<number | null>(null);
|
||||||
angularCmp?: AngularComponent;
|
|
||||||
|
|
||||||
componentDidMount() {
|
const onGoBack = () => {
|
||||||
const loader = getAngularLoader();
|
setEditIdx(null);
|
||||||
|
};
|
||||||
|
|
||||||
const template = '<div ng-include="\'public/app/features/annotations/partials/editor.html\'" />';
|
const onNew = () => {
|
||||||
const scopeProps = { dashboard: this.props.dashboard };
|
dashboard.annotations.list = [...dashboard.annotations.list, { ...newAnnotation }];
|
||||||
this.angularCmp = loader.load(this.element, scopeProps, template);
|
setEditIdx(dashboard.annotations.list.length - 1);
|
||||||
this.angularCmp.digest();
|
};
|
||||||
}
|
|
||||||
|
|
||||||
componentWillUnmount() {
|
const onEdit = (idx: number) => {
|
||||||
if (this.angularCmp) {
|
setEditIdx(idx);
|
||||||
this.angularCmp.destroy();
|
};
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
const isEditing = editIdx !== null;
|
||||||
return <div ref={(ref) => (this.element = ref)} />;
|
|
||||||
}
|
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(() => {
|
beforeEach(() => {
|
||||||
dashboard = {
|
dashboard = {
|
||||||
id: 74,
|
id: 74,
|
||||||
version: 7,
|
version: 7,
|
||||||
links: [...links],
|
links: [...links],
|
||||||
updateSubmenuVisibility: () => {},
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -73,9 +78,7 @@ describe('LinksSettings', () => {
|
|||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
render(<LinksSettings dashboard={dashboard} />);
|
render(<LinksSettings dashboard={dashboard} />);
|
||||||
|
|
||||||
const tableBodyRows = within(screen.getAllByRole('rowgroup')[1]).getAllByRole('row');
|
expect(getTableBodyRows().length).toBe(links.length);
|
||||||
|
|
||||||
expect(tableBodyRows.length).toBe(links.length);
|
|
||||||
expect(
|
expect(
|
||||||
screen.queryByLabelText(selectors.components.CallToActionCard.button('Add dashboard link'))
|
screen.queryByLabelText(selectors.components.CallToActionCard.button('Add dashboard link'))
|
||||||
).not.toBeInTheDocument();
|
).not.toBeInTheDocument();
|
||||||
@ -85,56 +88,53 @@ describe('LinksSettings', () => {
|
|||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
render(<LinksSettings dashboard={dashboard} />);
|
render(<LinksSettings dashboard={dashboard} />);
|
||||||
|
|
||||||
const tableBody = screen.getAllByRole('rowgroup')[1];
|
// Check that we have sorting buttons
|
||||||
const tableBodyRows = within(tableBody).getAllByRole('row');
|
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(getTableBodyRows()[1]).queryByRole('button', { name: 'arrow-up' })).toBeInTheDocument();
|
||||||
expect(within(tableBody).getAllByRole('button', { name: 'arrow-up' }).length).toBe(links.length - 1);
|
expect(within(getTableBodyRows()[1]).queryByRole('button', { name: 'arrow-down' })).toBeInTheDocument();
|
||||||
|
|
||||||
expect(within(tableBodyRows[0]).getByText(links[0].title)).toBeInTheDocument();
|
expect(within(getTableBodyRows()[2]).queryByRole('button', { name: 'arrow-up' })).toBeInTheDocument();
|
||||||
expect(within(tableBodyRows[1]).getByText(links[1].title)).toBeInTheDocument();
|
expect(within(getTableBodyRows()[2]).queryByRole('button', { name: 'arrow-down' })).not.toBeInTheDocument();
|
||||||
expect(within(tableBodyRows[2]).getByText(links[2].url)).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();
|
userEvent.click(within(getTableBody()).getAllByRole('button', { name: 'arrow-down' })[0]);
|
||||||
expect(within(tableBodyRows[1]).getByText(links[0].title)).toBeInTheDocument();
|
userEvent.click(within(getTableBody()).getAllByRole('button', { name: 'arrow-down' })[1]);
|
||||||
expect(within(tableBodyRows[2]).getByText(links[2].url)).toBeInTheDocument();
|
userEvent.click(within(getTableBody()).getAllByRole('button', { name: 'arrow-up' })[0]);
|
||||||
|
|
||||||
userEvent.click(within(tableBody).getAllByRole('button', { name: 'arrow-down' })[1]);
|
// Checking if it has changed the sorting accordingly
|
||||||
userEvent.click(within(tableBody).getAllByRole('button', { name: 'arrow-up' })[0]);
|
assertRowHasText(0, links[2].url);
|
||||||
|
assertRowHasText(1, links[1].title);
|
||||||
expect(within(tableBodyRows[0]).getByText(links[2].url)).toBeInTheDocument();
|
assertRowHasText(2, links[0].title);
|
||||||
expect(within(tableBodyRows[1]).getByText(links[1].title)).toBeInTheDocument();
|
|
||||||
expect(within(tableBodyRows[2]).getByText(links[0].title)).toBeInTheDocument();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('it duplicates dashboard links', () => {
|
test('it duplicates dashboard links', () => {
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
render(<LinksSettings dashboard={dashboard} />);
|
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(getTableBodyRows().length).toBe(links.length + 1);
|
||||||
|
expect(within(getTableBody()).getAllByText(links[0].title).length).toBe(2);
|
||||||
expect(within(tableBody).getAllByRole('row').length).toBe(links.length + 1);
|
|
||||||
expect(within(tableBody).getAllByText(links[0].title).length).toBe(2);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('it deletes dashboard links', () => {
|
test('it deletes dashboard links', () => {
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
render(<LinksSettings dashboard={dashboard} />);
|
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(getTableBodyRows().length).toBe(links.length - 1);
|
||||||
|
expect(within(getTableBody()).queryByText(links[0].title)).not.toBeInTheDocument();
|
||||||
expect(within(tableBody).getAllByRole('row').length).toBe(links.length - 1);
|
|
||||||
expect(within(tableBody).queryByText(links[0].title)).not.toBeInTheDocument();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('it renders a form which modifies dashboard links', () => {
|
test('it renders a form which modifies dashboard links', () => {
|
||||||
@ -160,20 +160,23 @@ describe('LinksSettings', () => {
|
|||||||
expect(screen.queryByText('Tooltip')).toBeInTheDocument();
|
expect(screen.queryByText('Tooltip')).toBeInTheDocument();
|
||||||
expect(screen.queryByText('Icon')).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.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(getTableBodyRows().length).toBe(links.length + 1);
|
||||||
|
expect(within(getTableBody()).queryByText('New Dashboard Link')).toBeInTheDocument();
|
||||||
expect(within(tableBody).getAllByRole('row').length).toBe(links.length + 1);
|
|
||||||
expect(within(tableBody).queryByText('New Dashboard Link')).toBeInTheDocument();
|
|
||||||
|
|
||||||
userEvent.click(screen.getAllByText(links[0].type)[0]);
|
userEvent.click(screen.getAllByText(links[0].type)[0]);
|
||||||
userEvent.clear(screen.getByRole('textbox', { name: /title/i }));
|
userEvent.clear(screen.getByRole('textbox', { name: /title/i }));
|
||||||
userEvent.type(screen.getByRole('textbox', { name: /title/i }), 'The first dashboard link');
|
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(getTableBody()).queryByText(links[0].title)).not.toBeInTheDocument();
|
||||||
expect(within(screen.getAllByRole('rowgroup')[1]).queryByText('The first dashboard link')).toBeInTheDocument();
|
expect(within(getTableBody()).queryByText('The first dashboard link')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -1,6 +1,8 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { DashboardModel } from '../../state/DashboardModel';
|
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 {
|
interface Props {
|
||||||
dashboard: DashboardModel;
|
dashboard: DashboardModel;
|
||||||
}
|
}
|
||||||
@ -8,30 +10,28 @@ interface Props {
|
|||||||
export type LinkSettingsMode = 'list' | 'new' | 'edit';
|
export type LinkSettingsMode = 'list' | 'new' | 'edit';
|
||||||
|
|
||||||
export const LinksSettings: React.FC<Props> = ({ dashboard }) => {
|
export const LinksSettings: React.FC<Props> = ({ dashboard }) => {
|
||||||
const [mode, setMode] = useState<LinkSettingsMode>('list');
|
const [editIdx, setEditIdx] = useState<number | null>(null);
|
||||||
const [editLinkIdx, setEditLinkIdx] = useState<number | null>(null);
|
|
||||||
const hasLinks = dashboard.links.length > 0;
|
|
||||||
|
|
||||||
const backToList = () => {
|
const onGoBack = () => {
|
||||||
setMode('list');
|
setEditIdx(null);
|
||||||
};
|
};
|
||||||
const setupNew = () => {
|
|
||||||
setEditLinkIdx(null);
|
const onNew = () => {
|
||||||
setMode('new');
|
dashboard.links = [...dashboard.links, { ...newLink }];
|
||||||
|
setEditIdx(dashboard.links.length - 1);
|
||||||
};
|
};
|
||||||
const editLink = (idx: number) => {
|
|
||||||
setEditLinkIdx(idx);
|
const onEdit = (idx: number) => {
|
||||||
setMode('edit');
|
setEditIdx(idx);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isEditing = editIdx !== null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<LinkSettingsHeader onNavClick={backToList} onBtnClick={setupNew} mode={mode} hasLinks={hasLinks} />
|
<DashboardSettingsHeader onGoBack={onGoBack} title="Dashboard links" isEditing={isEditing} />
|
||||||
{mode === 'list' ? (
|
{!isEditing && <LinkSettingsList dashboard={dashboard} onNew={onNew} onEdit={onEdit} />}
|
||||||
<LinkSettingsList dashboard={dashboard} setupNew={setupNew} editLink={editLink} />
|
{isEditing && <LinkSettingsEdit dashboard={dashboard} editLinkIdx={editIdx!} onGoBack={onGoBack} />}
|
||||||
) : (
|
|
||||||
<LinkSettingsEdit dashboard={dashboard} mode={mode} editLinkIdx={editLinkIdx} backToList={backToList} />
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -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 React, { useState } from 'react';
|
||||||
import { css } from '@emotion/css';
|
import { CollapsableSection, TagsInput, Select, Field, Input, Checkbox } from '@grafana/ui';
|
||||||
import { CollapsableSection, Button, TagsInput, Select, Field, Input, Checkbox } from '@grafana/ui';
|
|
||||||
import { SelectableValue } from '@grafana/data';
|
import { SelectableValue } from '@grafana/data';
|
||||||
import { LinkSettingsMode } from '../DashboardSettings/LinksSettings';
|
|
||||||
import { DashboardLink, DashboardModel } from '../../state/DashboardModel';
|
import { DashboardLink, DashboardModel } from '../../state/DashboardModel';
|
||||||
|
|
||||||
const newLink = {
|
export const newLink = {
|
||||||
icon: 'external link',
|
icon: 'external link',
|
||||||
title: '',
|
title: 'New link',
|
||||||
tooltip: '',
|
tooltip: '',
|
||||||
type: 'dashboards',
|
type: 'dashboards',
|
||||||
url: '',
|
url: '',
|
||||||
@ -36,59 +34,61 @@ export const linkIconMap: { [key: string]: string } = {
|
|||||||
const linkIconOptions = Object.keys(linkIconMap).map((key) => ({ label: key, value: key }));
|
const linkIconOptions = Object.keys(linkIconMap).map((key) => ({ label: key, value: key }));
|
||||||
|
|
||||||
type LinkSettingsEditProps = {
|
type LinkSettingsEditProps = {
|
||||||
mode: LinkSettingsMode;
|
editLinkIdx: number;
|
||||||
editLinkIdx: number | null;
|
|
||||||
dashboard: DashboardModel;
|
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 [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[]) => {
|
const onTagsChange = (tags: any[]) => {
|
||||||
setLinkSettings((link) => ({ ...link, tags: tags }));
|
onUpdate({ ...linkSettings, tags: tags });
|
||||||
};
|
};
|
||||||
|
|
||||||
const onTypeChange = (selectedItem: SelectableValue) => {
|
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) => {
|
const onIconChange = (selectedItem: SelectableValue) => {
|
||||||
setLinkSettings((link) => ({ ...link, icon: selectedItem.value }));
|
onUpdate({ ...linkSettings, icon: selectedItem.value });
|
||||||
};
|
};
|
||||||
|
|
||||||
const onChange = (ev: React.FocusEvent<HTMLInputElement>) => {
|
const onChange = (ev: React.FocusEvent<HTMLInputElement>) => {
|
||||||
const target = ev.currentTarget;
|
const target = ev.currentTarget;
|
||||||
setLinkSettings((link) => ({
|
onUpdate({
|
||||||
...link,
|
...linkSettings,
|
||||||
[target.name]: target.type === 'checkbox' ? target.checked : target.value,
|
[target.name]: target.type === 'checkbox' ? target.checked : target.value,
|
||||||
}));
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const addLink = () => {
|
const isNew = linkSettings.title === newLink.title;
|
||||||
dashboard.links = [...dashboard.links, linkSettings];
|
|
||||||
dashboard.updateSubmenuVisibility();
|
|
||||||
backToList();
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateLink = () => {
|
|
||||||
dashboard.links.splice(editLinkIdx!, 1, linkSettings);
|
|
||||||
dashboard.updateSubmenuVisibility();
|
|
||||||
backToList();
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div style={{ maxWidth: '600px' }}>
|
||||||
className={css`
|
<Field label="Title">
|
||||||
max-width: 600px;
|
<Input name="title" id="title" value={linkSettings.title} onChange={onChange} autoFocus={isNew} />
|
||||||
`}
|
</Field>
|
||||||
>
|
|
||||||
<Field label="Type">
|
<Field label="Type">
|
||||||
<Select value={linkSettings.type} options={linkTypeOptions} onChange={onTypeChange} />
|
<Select value={linkSettings.type} options={linkTypeOptions} onChange={onTypeChange} />
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="Title">
|
|
||||||
<Input name="title" aria-label="title" value={linkSettings.title} onChange={onChange} />
|
|
||||||
</Field>
|
|
||||||
{linkSettings.type === 'dashboards' && (
|
{linkSettings.type === 'dashboards' && (
|
||||||
<>
|
<>
|
||||||
<Field label="With tags">
|
<Field label="With tags">
|
||||||
@ -140,11 +140,6 @@ export const LinkSettingsEdit: React.FC<LinkSettingsEditProps> = ({ mode, editLi
|
|||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
</CollapsableSection>
|
</CollapsableSection>
|
||||||
|
|
||||||
<div className="gf-form-button-row">
|
|
||||||
{mode === 'new' && <Button onClick={addLink}>Add</Button>}
|
|
||||||
{mode === 'edit' && <Button onClick={updateLink}>Update</Button>}
|
|
||||||
</div>
|
|
||||||
</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 React, { useState } from 'react';
|
||||||
import { css } from '@emotion/css';
|
import { DeleteButton, HorizontalGroup, Icon, IconButton, TagList } from '@grafana/ui';
|
||||||
import { DeleteButton, Icon, IconButton, Tag, useTheme } from '@grafana/ui';
|
|
||||||
import EmptyListCTA from 'app/core/components/EmptyListCTA/EmptyListCTA';
|
import EmptyListCTA from 'app/core/components/EmptyListCTA/EmptyListCTA';
|
||||||
import { arrayMove } from 'app/core/utils/arrayMove';
|
|
||||||
import { DashboardModel, DashboardLink } from '../../state/DashboardModel';
|
import { DashboardModel, DashboardLink } from '../../state/DashboardModel';
|
||||||
|
import { ListNewButton } from '../DashboardSettings/ListNewButton';
|
||||||
|
import { arrayUtils } from '@grafana/data';
|
||||||
|
|
||||||
type LinkSettingsListProps = {
|
type LinkSettingsListProps = {
|
||||||
dashboard: DashboardModel;
|
dashboard: DashboardModel;
|
||||||
setupNew: () => void;
|
onNew: () => void;
|
||||||
editLink: (idx: number) => void;
|
onEdit: (idx: number) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const LinkSettingsList: React.FC<LinkSettingsListProps> = ({ dashboard, setupNew, editLink }) => {
|
export const LinkSettingsList: React.FC<LinkSettingsListProps> = ({ dashboard, onNew, onEdit }) => {
|
||||||
const theme = useTheme();
|
const [links, setLinks] = useState(dashboard.links);
|
||||||
// @ts-ignore
|
|
||||||
const [renderCounter, setRenderCounter] = useState(0);
|
|
||||||
|
|
||||||
const moveLink = (idx: number, direction: number) => {
|
const moveLink = (idx: number, direction: number) => {
|
||||||
arrayMove(dashboard.links, idx, idx + direction);
|
dashboard.links = arrayUtils.moveItemImmutably(links, idx, idx + direction);
|
||||||
setRenderCounter((renderCount) => renderCount + 1);
|
setLinks(dashboard.links);
|
||||||
};
|
|
||||||
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);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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 (
|
return (
|
||||||
<div>
|
<>
|
||||||
{dashboard.links.length === 0 ? (
|
<table className="filter-table filter-table--hover">
|
||||||
<EmptyListCTA
|
<thead>
|
||||||
onClick={setupNew}
|
<tr>
|
||||||
title="No dashboard links added yet"
|
<th>Type</th>
|
||||||
buttonIcon="link"
|
<th>Info</th>
|
||||||
buttonTitle="Add dashboard link"
|
<th colSpan={3} />
|
||||||
infoBoxTitle="What are dashboard links?"
|
</tr>
|
||||||
infoBox={{
|
</thead>
|
||||||
__html:
|
<tbody>
|
||||||
'<p>Dashboard links allow you to place links to other dashboards and web sites directly below the dashboard header.</p>',
|
{links.map((link, idx) => (
|
||||||
}}
|
<tr key={`${link.title}-${idx}`}>
|
||||||
/>
|
<td className="pointer" onClick={() => onEdit(idx)}>
|
||||||
) : (
|
<Icon name="external-link-alt" /> {link.type}
|
||||||
<table className="filter-table filter-table--hover">
|
</td>
|
||||||
<thead>
|
<td>
|
||||||
<tr>
|
<HorizontalGroup>
|
||||||
<th>Type</th>
|
{link.title && <span>{link.title}</span>}
|
||||||
<th>Info</th>
|
{link.type === 'link' && <span>{link.url}</span>}
|
||||||
<th colSpan={3} />
|
{link.type === 'dashboards' && <TagList tags={link.tags ?? []} />}
|
||||||
</tr>
|
</HorizontalGroup>
|
||||||
</thead>
|
</td>
|
||||||
<tbody>
|
<td style={{ width: '1%' }}>
|
||||||
{dashboard.links.map((link, idx) => (
|
{idx !== 0 && (
|
||||||
<tr key={idx}>
|
<IconButton
|
||||||
<td className="pointer" onClick={() => editLink(idx)}>
|
surface="header"
|
||||||
<Icon
|
name="arrow-up"
|
||||||
name="external-link-alt"
|
aria-label="arrow-up"
|
||||||
className={css`
|
onClick={() => moveLink(idx, -1)}
|
||||||
margin-right: ${theme.spacing.xs};
|
|
||||||
`}
|
|
||||||
/>
|
/>
|
||||||
{link.type}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td style={{ width: '1%' }}>
|
||||||
{link.title && <div>{link.title}</div>}
|
{links.length > 1 && idx !== links.length - 1 ? (
|
||||||
{!link.title && link.url ? <div>{link.url}</div> : null}
|
<IconButton
|
||||||
{!link.title && link.tags
|
surface="header"
|
||||||
? link.tags.map((tag, idx) => (
|
name="arrow-down"
|
||||||
<Tag
|
aria-label="arrow-down"
|
||||||
name={tag}
|
onClick={() => moveLink(idx, 1)}
|
||||||
key={tag}
|
/>
|
||||||
className={
|
) : null}
|
||||||
idx !== 0
|
</td>
|
||||||
? css`
|
<td style={{ width: '1%' }}>
|
||||||
margin-left: ${theme.spacing.xs};
|
<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>
|
||||||
: null}
|
))}
|
||||||
</td>
|
</tbody>
|
||||||
<td style={{ width: '1%' }}>
|
</table>
|
||||||
{idx !== 0 && (
|
<ListNewButton onClick={onNew}>New link</ListNewButton>
|
||||||
<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>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -1,3 +1,2 @@
|
|||||||
export { LinkSettingsEdit } from './LinkSettingsEdit';
|
export { LinkSettingsEdit } from './LinkSettingsEdit';
|
||||||
export { LinkSettingsHeader } from './LinkSettingsHeader';
|
|
||||||
export { LinkSettingsList } from './LinkSettingsList';
|
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) {
|
if (!links.length) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
@ -2,7 +2,7 @@ import React, { PureComponent } from 'react';
|
|||||||
import { connect, MapStateToProps } from 'react-redux';
|
import { connect, MapStateToProps } from 'react-redux';
|
||||||
import { StoreState } from '../../../../types';
|
import { StoreState } from '../../../../types';
|
||||||
import { getSubMenuVariables } from '../../../variables/state/selectors';
|
import { getSubMenuVariables } from '../../../variables/state/selectors';
|
||||||
import { VariableHide, VariableModel } from '../../../variables/types';
|
import { VariableModel } from '../../../variables/types';
|
||||||
import { DashboardModel } from '../../state';
|
import { DashboardModel } from '../../state';
|
||||||
import { DashboardLinks } from './DashboardLinks';
|
import { DashboardLinks } from './DashboardLinks';
|
||||||
import { Annotations } from './Annotations';
|
import { Annotations } from './Annotations';
|
||||||
@ -38,24 +38,10 @@ class SubMenuUnConnected extends PureComponent<Props> {
|
|||||||
this.forceUpdate();
|
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() {
|
render() {
|
||||||
const { dashboard, variables, links, annotations } = this.props;
|
const { dashboard, variables, links, annotations } = this.props;
|
||||||
|
|
||||||
if (!this.isSubMenuVisible()) {
|
if (!dashboard.isSubMenuVisible()) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -78,4 +64,5 @@ const mapStateToProps: MapStateToProps<ConnectedProps, OwnProps, StoreState> = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const SubMenu = connect(mapStateToProps)(SubMenuUnConnected);
|
export const SubMenu = connect(mapStateToProps)(SubMenuUnConnected);
|
||||||
|
|
||||||
SubMenu.displayName = 'SubMenu';
|
SubMenu.displayName = 'SubMenu';
|
||||||
|
@ -260,20 +260,19 @@ describe('DashboardModel', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('updateSubmenuVisibility with empty lists', () => {
|
describe('isSubMenuVisible with empty lists', () => {
|
||||||
let model: DashboardModel;
|
let model: DashboardModel;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
model = new DashboardModel({});
|
model = new DashboardModel({});
|
||||||
model.updateSubmenuVisibility();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not enable submmenu', () => {
|
it('should not show submenu', () => {
|
||||||
expect(model.meta.submenuEnabled).toBe(false);
|
expect(model.isSubMenuVisible()).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('updateSubmenuVisibility with annotation', () => {
|
describe('isSubMenuVisible with annotation', () => {
|
||||||
let model: DashboardModel;
|
let model: DashboardModel;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@ -282,32 +281,35 @@ describe('DashboardModel', () => {
|
|||||||
list: [{}],
|
list: [{}],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
model.updateSubmenuVisibility();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should enable submmenu', () => {
|
it('should show submmenu', () => {
|
||||||
expect(model.meta.submenuEnabled).toBe(true);
|
expect(model.isSubMenuVisible()).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('updateSubmenuVisibility with template var', () => {
|
describe('isSubMenuVisible with template var', () => {
|
||||||
let model: DashboardModel;
|
let model: DashboardModel;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
model = new DashboardModel({
|
model = new DashboardModel(
|
||||||
templating: {
|
{
|
||||||
list: [{}],
|
templating: {
|
||||||
|
list: [{}],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
{},
|
||||||
model.updateSubmenuVisibility();
|
// getVariablesFromState stub to return a variable
|
||||||
|
() => [{} as any]
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should enable submmenu', () => {
|
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;
|
let model: DashboardModel;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@ -316,15 +318,14 @@ describe('DashboardModel', () => {
|
|||||||
list: [{ hide: 2 }],
|
list: [{ hide: 2 }],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
model.updateSubmenuVisibility();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not enable submmenu', () => {
|
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;
|
let dashboard: DashboardModel;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@ -333,11 +334,10 @@ describe('DashboardModel', () => {
|
|||||||
list: [{ hide: true }],
|
list: [{ hide: true }],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
dashboard.updateSubmenuVisibility();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not enable submmenu', () => {
|
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 { GridPos, PanelModel } from './PanelModel';
|
||||||
import { DashboardMigrator } from './DashboardMigrator';
|
import { DashboardMigrator } from './DashboardMigrator';
|
||||||
import {
|
import {
|
||||||
|
AnnotationQuery,
|
||||||
AppEvent,
|
AppEvent,
|
||||||
dateTimeFormat,
|
dateTimeFormat,
|
||||||
dateTimeFormatTimeAgo,
|
dateTimeFormatTimeAgo,
|
||||||
@ -66,7 +67,7 @@ export class DashboardModel {
|
|||||||
timepicker: any;
|
timepicker: any;
|
||||||
templating: { list: any[] };
|
templating: { list: any[] };
|
||||||
private originalTemplating: any;
|
private originalTemplating: any;
|
||||||
annotations: { list: any[] };
|
annotations: { list: AnnotationQuery[] };
|
||||||
refresh: any;
|
refresh: any;
|
||||||
snapshot: any;
|
snapshot: any;
|
||||||
schemaVersion: number;
|
schemaVersion: number;
|
||||||
@ -760,25 +761,20 @@ export class DashboardModel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
updateSubmenuVisibility() {
|
isSubMenuVisible() {
|
||||||
this.meta.submenuEnabled = (() => {
|
if (this.links.length > 0) {
|
||||||
if (this.links.length > 0) {
|
return true;
|
||||||
return true;
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const visibleVars = _.filter(this.templating.list, (variable: any) => variable.hide !== 2);
|
if (this.getVariables().find((variable) => variable.hide !== 2)) {
|
||||||
if (visibleVars.length > 0) {
|
return true;
|
||||||
return true;
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const visibleAnnotations = _.filter(this.annotations.list, (annotation: any) => annotation.hide !== true);
|
if (this.annotations.list.find((annotation) => annotation.hide !== true)) {
|
||||||
if (visibleAnnotations.length > 0) {
|
return true;
|
||||||
return true;
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
})();
|
|
||||||
this.events.emit(CoreEvents.submenuVisibilityChanged, this.meta.submenuEnabled);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
getPanelInfoById(panelId: number) {
|
getPanelInfoById(panelId: number) {
|
||||||
|
@ -203,7 +203,6 @@ export function initDashboard(args: InitDashboardArgs): ThunkResult<void> {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
dashboard.processRepeats();
|
dashboard.processRepeats();
|
||||||
dashboard.updateSubmenuVisibility();
|
|
||||||
|
|
||||||
// handle auto fix experimental feature
|
// handle auto fix experimental feature
|
||||||
if (queryParams.autofitpanels) {
|
if (queryParams.autofitpanels) {
|
||||||
|
@ -564,6 +564,7 @@ function makeAnnotationQueryRequest(): AnnotationQueryRequest<LokiQuery> {
|
|||||||
datasource: 'loki',
|
datasource: 'loki',
|
||||||
enable: true,
|
enable: true,
|
||||||
name: 'test-annotation',
|
name: 'test-annotation',
|
||||||
|
iconColor: 'red',
|
||||||
},
|
},
|
||||||
dashboard: {
|
dashboard: {
|
||||||
id: 1,
|
id: 1,
|
||||||
|
@ -95,7 +95,6 @@ export const toggleKioskMode = eventFactory<ToggleKioskModePayload>('toggle-kios
|
|||||||
|
|
||||||
export const timeRangeUpdated = eventFactory<TimeRange>('time-range-updated');
|
export const timeRangeUpdated = eventFactory<TimeRange>('time-range-updated');
|
||||||
export const templateVariableValueUpdated = eventFactory('template-variable-value-updated');
|
export const templateVariableValueUpdated = eventFactory('template-variable-value-updated');
|
||||||
export const submenuVisibilityChanged = eventFactory<boolean>('submenu-visibility-changed');
|
|
||||||
|
|
||||||
export const graphClicked = eventFactory<GraphClickedPayload>('graph-click');
|
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 */
|
/* Used by old angular panels */
|
||||||
.panel-options-group {
|
.panel-options-group {
|
||||||
border-bottom: $panel-border;
|
border-bottom: $panel-border;
|
||||||
|
Loading…
Reference in New Issue
Block a user