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

* Initial commit, list and edit page working

* Progress

* angular and standard editors work

* Unifying more between annotations list and links list

* Remove submenu visibilty stuff

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

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

* Review feedback

* fixed checkbox

* Fixes

* test(annotationsettings): initial commit of tests

* delete files brought back by master merge

* update datasourcepicker import path

* update emotion import

* test(linksettings): clean up tests

* Fixed test

* test(annotationssettings): add remaining tests

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

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

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

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

View File

@ -8,17 +8,23 @@ import { DataFrame } from './dataFrame';
* This JSON object is stored in the dashboard json model. * 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 {

View File

@ -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> {

View File

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

View File

@ -1,3 +1,4 @@
import * as arrayUtils from './arrayUtils';
export * from './Registry'; export * from './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';

View File

@ -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}

View File

@ -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};

View File

@ -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"

View File

@ -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;

View File

@ -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);

View File

@ -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 {

View File

@ -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';

View File

@ -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,
}); });

View File

@ -1,156 +0,0 @@
import angular from 'angular';
import _ from 'lodash';
import $ from 'jquery';
import coreModule from 'app/core/core_module';
import { DashboardModel } from 'app/features/dashboard/state';
import DatasourceSrv from '../plugins/datasource_srv';
import appEvents from 'app/core/app_events';
import { AnnotationQuery, AppEvents } from '@grafana/data';
// Registeres the angular directive
import './components/StandardAnnotationQueryEditor';
export class AnnotationsEditorCtrl {
mode: any;
datasources: any;
currentAnnotation: any;
currentDatasource: any;
currentIsNew: any;
dashboard: DashboardModel;
annotationDefaults: any = {
name: '',
datasource: null,
iconColor: 'rgba(255, 96, 96, 1)',
enable: true,
showIn: 0,
hide: false,
};
emptyListCta = {
title: 'There are no custom annotation queries added yet',
buttonIcon: 'comment-alt',
buttonTitle: 'Add Annotation Query',
infoBox: {
__html: `<p>Annotations provide a way to integrate event data into your graphs. They are visualized as vertical lines
and icons on all graph panels. When you hover over an annotation icon you can get event text and tags for
the event. You can add annotation events directly from Grafana by holding Ctrl or CMD + click on graph (or
drag region). These will be stored in Grafana's annotation database.
</p>
Check out the
<a class='external-link' target='_blank' href='https://grafana.com/docs/grafana/latest/dashboards/annotations/'
>Annotations documentation</a
>
for more information.`,
},
infoBoxTitle: 'What are annotations?',
};
showOptions: any = [
{ text: 'All Panels', value: 0 },
{ text: 'Specific Panels', value: 1 },
];
/** @ngInject */
constructor(private $scope: any, private datasourceSrv: DatasourceSrv) {
$scope.ctrl = this;
this.dashboard = $scope.dashboard;
this.mode = 'list';
this.datasources = datasourceSrv.getAnnotationSources();
this.dashboard.annotations.list = this.dashboard.annotations.list ?? [];
this.reset();
this.onColorChange = this.onColorChange.bind(this);
}
async datasourceChanged() {
const newDatasource = await this.datasourceSrv.get(this.currentAnnotation.datasource);
this.$scope.$apply(() => {
this.currentDatasource = newDatasource;
});
}
/**
* Called from the react editor
*/
onAnnotationChange = (annotation: AnnotationQuery) => {
let replaced = false;
this.dashboard.annotations.list = this.dashboard.annotations.list.map((a) => {
if (a.name !== annotation.name) {
return a;
}
replaced = true;
return annotation;
});
if (!replaced) {
console.warn('updating annotation, but not in the dashboard', annotation);
}
this.currentAnnotation = annotation;
};
edit(annotation: AnnotationQuery) {
this.currentAnnotation = annotation;
this.currentAnnotation.showIn = this.currentAnnotation.showIn || 0;
this.currentIsNew = false;
this.datasourceChanged();
this.mode = 'edit';
$('.tooltip.in').remove();
}
reset() {
this.currentAnnotation = angular.copy(this.annotationDefaults);
this.currentAnnotation.datasource = this.datasources[0].name;
this.currentIsNew = true;
this.datasourceChanged();
}
update() {
this.dashboard.annotations.list = [...this.dashboard.annotations.list];
this.reset();
this.mode = 'list';
}
setupNew = () => {
this.mode = 'new';
this.reset();
};
backToList() {
this.mode = 'list';
}
move(index: number, dir: number) {
const list = [...this.dashboard.annotations.list];
Array.prototype.splice.call(list, index + dir, 0, Array.prototype.splice.call(list, index, 1)[0]);
this.dashboard.annotations.list = list;
}
add() {
const sameName: any = _.find(this.dashboard.annotations.list, { name: this.currentAnnotation.name });
if (sameName) {
appEvents.emit(AppEvents.alertWarning, ['Validation', 'Annotation with the same name already exists']);
return;
}
this.dashboard.annotations.list = [...this.dashboard.annotations.list, this.currentAnnotation];
this.reset();
this.mode = 'list';
this.dashboard.updateSubmenuVisibility();
}
removeAnnotation(annotation: AnnotationQuery) {
this.dashboard.annotations.list = this.dashboard.annotations.list.filter((a) => {
return a.name !== annotation.name;
});
this.dashboard.updateSubmenuVisibility();
}
onColorChange(newColor: string) {
this.currentAnnotation.iconColor = newColor;
}
}
coreModule.controller('AnnotationsEditorCtrl', AnnotationsEditorCtrl);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,31 +1,36 @@
import React, { PureComponent } from 'react'; import React, { useState } from 'react';
import { DashboardModel } from '../../state/DashboardModel'; import { 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!} />}
</>
);
};

View File

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

View File

@ -48,12 +48,17 @@ describe('LinksSettings', () => {
}, },
]; ];
const getTableBody = () => screen.getAllByRole('rowgroup')[1];
const getTableBodyRows = () => within(getTableBody()).getAllByRole('row');
const assertRowHasText = (index: number, text: string) => {
expect(within(getTableBodyRows()[index]).queryByText(text)).toBeInTheDocument();
};
beforeEach(() => { 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();
}); });
}); });

View File

@ -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} />
)}
</> </>
); );
}; };

View File

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

View File

@ -1,13 +1,11 @@
import React, { useState } from 'react'; import 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>
); );
}; };

View File

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

View File

@ -1,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" /> &nbsp; {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>
); );
}; };

View File

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

View File

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

View File

@ -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';

View File

@ -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);
}); });
}); });

View File

@ -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) {

View File

@ -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) {

View File

@ -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,

View File

@ -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');

View File

@ -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;