diff --git a/packages/grafana-data/src/types/annotations.ts b/packages/grafana-data/src/types/annotations.ts index e907725741e..369f4d36b13 100644 --- a/packages/grafana-data/src/types/annotations.ts +++ b/packages/grafana-data/src/types/annotations.ts @@ -8,17 +8,23 @@ import { DataFrame } from './dataFrame'; * This JSON object is stored in the dashboard json model. */ export interface AnnotationQuery { - datasource: string; + datasource?: string | null; enable: boolean; name: string; iconColor: string; hide?: boolean; + builtIn?: number; + type?: string; + snapshotData?: any; // Standard datasource query target?: TQuery; // Convert a dataframe to an AnnotationEvent mappings?: AnnotationEventMappings; + + // Sadly plugins can set any propery directly on the main object + [key: string]: any; } export interface AnnotationEvent { diff --git a/packages/grafana-data/src/types/datasource.ts b/packages/grafana-data/src/types/datasource.ts index 3029b944608..f1eab95f809 100644 --- a/packages/grafana-data/src/types/datasource.ts +++ b/packages/grafana-data/src/types/datasource.ts @@ -3,7 +3,7 @@ import { ComponentType } from 'react'; import { GrafanaPlugin, PluginMeta } from './plugin'; import { PanelData } from './panel'; import { LogRowModel } from './logs'; -import { AnnotationEvent, AnnotationSupport } from './annotations'; +import { AnnotationEvent, AnnotationQuery, AnnotationSupport } from './annotations'; import { DataTopic, KeyValue, LoadingState, TableData, TimeSeries } from './data'; import { DataFrame, DataFrameDTO } from './dataFrame'; import { RawTimeRange, TimeRange } from './time'; @@ -609,11 +609,7 @@ export interface AnnotationQueryRequest { rangeRaw: RawTimeRange; // Should be DataModel but cannot import that here from the main app. Needs to be moved to package first. dashboard: any; - annotation: { - datasource: string; - enable: boolean; - name: string; - } & MoreOptions; + annotation: AnnotationQuery; } export interface HistoryItem { diff --git a/packages/grafana-data/src/utils/arrayUtils.ts b/packages/grafana-data/src/utils/arrayUtils.ts new file mode 100644 index 00000000000..2ad7e7dda07 --- /dev/null +++ b/packages/grafana-data/src/utils/arrayUtils.ts @@ -0,0 +1,6 @@ +/** @internal */ +export function moveItemImmutably(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; +} diff --git a/packages/grafana-data/src/utils/index.ts b/packages/grafana-data/src/utils/index.ts index 65ddef53de3..7912bf435b8 100644 --- a/packages/grafana-data/src/utils/index.ts +++ b/packages/grafana-data/src/utils/index.ts @@ -1,3 +1,4 @@ +import * as arrayUtils from './arrayUtils'; export * from './Registry'; export * from './datasource'; export * from './deprecationWarning'; @@ -10,7 +11,7 @@ export * from './namedColorsPalette'; export * from './series'; export * from './binaryOperators'; export { PanelOptionsEditorBuilder, FieldConfigEditorBuilder } from './OptionsUIBuilders'; - +export { arrayUtils }; export { getMappedValue } from './valueMappings'; export { getFlotPairs, getFlotPairsConstant } from './flotPairs'; export { locationUtil } from './location'; diff --git a/packages/grafana-runtime/src/components/DataSourcePicker.tsx b/packages/grafana-runtime/src/components/DataSourcePicker.tsx index 7872969bdcf..1fcf707ba63 100644 --- a/packages/grafana-runtime/src/components/DataSourcePicker.tsx +++ b/packages/grafana-runtime/src/components/DataSourcePicker.tsx @@ -28,6 +28,7 @@ export interface DataSourcePickerProps { variables?: boolean; pluginId?: string; noDefault?: boolean; + width?: number; } /** @@ -127,7 +128,7 @@ export class DataSourcePicker extends PureComponent ({ flex-direction: column; align-items: center; justify-content: center; + flex-grow: 1; `, message: css` margin-bottom: ${theme.spacing.lg}; diff --git a/packages/grafana-ui/src/components/CallToActionCard/__snapshots__/CallToActionCard.test.tsx.snap b/packages/grafana-ui/src/components/CallToActionCard/__snapshots__/CallToActionCard.test.tsx.snap index d90d5019b86..956c186ca85 100644 --- a/packages/grafana-ui/src/components/CallToActionCard/__snapshots__/CallToActionCard.test.tsx.snap +++ b/packages/grafana-ui/src/components/CallToActionCard/__snapshots__/CallToActionCard.test.tsx.snap @@ -2,7 +2,7 @@ exports[`CallToActionCard rendering when message and footer provided 1`] = `
{ position: relative; padding-left: ${checkboxSize}; vertical-align: middle; + height: ${theme.spacing.lg}; `, input: css` position: absolute; diff --git a/packages/grafana-ui/src/components/OptionsUI/color.tsx b/packages/grafana-ui/src/components/OptionsUI/color.tsx index 863a0fe6c8e..4c77b791b10 100644 --- a/packages/grafana-ui/src/components/OptionsUI/color.tsx +++ b/packages/grafana-ui/src/components/OptionsUI/color.tsx @@ -5,13 +5,18 @@ import { stylesFactory, useTheme } from '../../themes'; import { css } from '@emotion/css'; import { ColorPickerTrigger } from '../ColorPicker/ColorPickerTrigger'; -export interface Props { +/** + * @alpha + * */ +export interface ColorValueEditorProps { value?: string; onChange: (value?: string) => void; } -// Supporting FixedColor only currently -export const ColorValueEditor: React.FC = ({ value, onChange }) => { +/** + * @alpha + * */ +export const ColorValueEditor: React.FC = ({ value, onChange }) => { const theme = useTheme(); const styles = getStyles(theme); diff --git a/packages/grafana-ui/src/components/index.ts b/packages/grafana-ui/src/components/index.ts index ecb262f3b61..2c00fe24cc3 100644 --- a/packages/grafana-ui/src/components/index.ts +++ b/packages/grafana-ui/src/components/index.ts @@ -15,6 +15,7 @@ export { ButtonCascader } from './ButtonCascader/ButtonCascader'; export { LoadingPlaceholder, LoadingPlaceholderProps } from './LoadingPlaceholder/LoadingPlaceholder'; export { ColorPicker, SeriesColorPicker } from './ColorPicker/ColorPicker'; +export { ColorValueEditor, ColorValueEditorProps } from './OptionsUI/color'; export { SeriesColorPickerPopover, SeriesColorPickerPopoverWithTheme } from './ColorPicker/SeriesColorPickerPopover'; export { EmptySearchResult } from './EmptySearchResult/EmptySearchResult'; export { diff --git a/public/app/features/annotations/annotations_srv.ts b/public/app/features/annotations/annotations_srv.ts index dcd8a59f72a..e6ebcd19103 100644 --- a/public/app/features/annotations/annotations_srv.ts +++ b/public/app/features/annotations/annotations_srv.ts @@ -2,7 +2,6 @@ import flattenDeep from 'lodash/flattenDeep'; import cloneDeep from 'lodash/cloneDeep'; // Components -import './editor_ctrl'; import coreModule from 'app/core/core_module'; // Utils & Services import { dedupAnnotations } from './events_processing'; diff --git a/public/app/features/annotations/components/StandardAnnotationQueryEditor.tsx b/public/app/features/annotations/components/StandardAnnotationQueryEditor.tsx index d384e0c9bc4..9f6d3e8f9d0 100644 --- a/public/app/features/annotations/components/StandardAnnotationQueryEditor.tsx +++ b/public/app/features/annotations/components/StandardAnnotationQueryEditor.tsx @@ -16,7 +16,7 @@ import coreModule from 'app/core/core_module'; interface Props { datasource: DataSourceApi; annotation: AnnotationQuery; - change: (annotation: AnnotationQuery) => void; + onChange: (annotation: AnnotationQuery) => void; } interface State { @@ -48,7 +48,7 @@ export default class StandardAnnotationQueryEditor extends PureComponent { - this.props.change({ + this.props.onChange({ ...this.props.annotation, target, }); }; onMappingChange = (mappings: AnnotationEventMappings) => { - this.props.change({ + this.props.onChange({ ...this.props.annotation, mappings, }); diff --git a/public/app/features/annotations/editor_ctrl.ts b/public/app/features/annotations/editor_ctrl.ts deleted file mode 100644 index 2f5a940e9d2..00000000000 --- a/public/app/features/annotations/editor_ctrl.ts +++ /dev/null @@ -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: `

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. -

- Check out the -
Annotations documentation - 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); diff --git a/public/app/features/annotations/partials/editor.html b/public/app/features/annotations/partials/editor.html deleted file mode 100644 index 3c2df73fec7..00000000000 --- a/public/app/features/annotations/partials/editor.html +++ /dev/null @@ -1,147 +0,0 @@ -
-
-

- Annotations - New - Edit -

- -
- - - New - -
- -
- - - - - - - - - - - - - - - - - - -
Query nameData source
-   {{ annotation.name }} - -   - {{ annotation.name }} (Built-in) - - {{ annotation.datasource || 'Default' }} - - - - - - - - -
- - -
- -
-
- -
-
-
General
-
-
- Name - -
-
- Data source -
- -
-
-
-
- -
-
- - - - -
- - - - -
-
-
- -
Query
- - - - - - - - -
-
- - -
-
-
-
diff --git a/public/app/features/dashboard/components/AnnotationSettings/AngularEditorLoader.tsx b/public/app/features/dashboard/components/AnnotationSettings/AngularEditorLoader.tsx new file mode 100644 index 00000000000..3511a5d96b4 --- /dev/null +++ b/public/app/features/dashboard/components/AnnotationSettings/AngularEditorLoader.tsx @@ -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 = React.memo(({ annotation, datasource, onChange }) => { + const ref = useRef(null); + const [angularComponent, setAngularComponent] = useState(null); + + useEffect(() => { + return () => { + if (angularComponent) { + angularComponent.destroy(); + } + }; + }, [angularComponent]); + + useEffect(() => { + if (ref.current) { + const loader = getAngularLoader(); + const template = ` `; + 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
; +}); +AngularEditorLoader.displayName = 'AngularEditorLoader'; diff --git a/public/app/features/dashboard/components/AnnotationSettings/AnnotationSettingsEdit.tsx b/public/app/features/dashboard/components/AnnotationSettings/AnnotationSettingsEdit.tsx new file mode 100644 index 00000000000..5a3b79c2d43 --- /dev/null +++ b/public/app/features/dashboard/components/AnnotationSettings/AnnotationSettingsEdit.tsx @@ -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 = ({ 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) => { + onUpdate({ + ...annotation, + name: ev.currentTarget.value, + }); + }; + + const onDataSourceChange = (ds: DataSourceInstanceSettings) => { + onUpdate({ + ...annotation, + datasource: ds.name, + }); + }; + + const onChange = (ev: React.FocusEvent) => { + 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 ( +
+ + + + + + + + + + + + + + + + {ds?.annotations && ( + + )} + {ds && !ds.annotations && } + +
+ ); +}; + +AnnotationSettingsEdit.displayName = 'AnnotationSettingsEdit'; diff --git a/public/app/features/dashboard/components/AnnotationSettings/AnnotationSettingsList.tsx b/public/app/features/dashboard/components/AnnotationSettings/AnnotationSettingsList.tsx new file mode 100644 index 00000000000..8fc78668ef6 --- /dev/null +++ b/public/app/features/dashboard/components/AnnotationSettings/AnnotationSettingsList.tsx @@ -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 = ({ 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 ( + + {annotations.length > 0 && ( + + + + + + + + + + {dashboard.annotations.list.map((annotation, idx) => ( + + {!annotation.builtIn && ( + + )} + {annotation.builtIn && ( + + )} + + + + + + ))} + +
Query nameData source
onEdit(idx)}> +   {annotation.name} + onEdit(idx)}> +   {annotation.name} (Built-in) + onEdit(idx)}> + {annotation.datasource || 'Default'} + + {idx !== 0 && ( + onMove(idx, -1)} + /> + )} + + {dashboard.annotations.list.length > 1 && idx !== dashboard.annotations.list.length - 1 ? ( + onMove(idx, 1)} + /> + ) : null} + + onDelete(idx)} /> +
+ )} + {showEmptyListCTA && ( + 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. +

+ Checkout the + Annotations documentation + for more information.`, + }} + /> + )} + {!showEmptyListCTA && New query} +
+ ); +}; diff --git a/public/app/features/dashboard/components/AnnotationSettings/index.tsx b/public/app/features/dashboard/components/AnnotationSettings/index.tsx new file mode 100644 index 00000000000..b8110598fb2 --- /dev/null +++ b/public/app/features/dashboard/components/AnnotationSettings/index.tsx @@ -0,0 +1,2 @@ +export { AnnotationSettingsEdit } from './AnnotationSettingsEdit'; +export { AnnotationSettingsList } from './AnnotationSettingsList'; diff --git a/public/app/features/dashboard/components/DashboardSettings/AnnotationsSettings.test.tsx b/public/app/features/dashboard/components/DashboardSettings/AnnotationsSettings.test.tsx new file mode 100644 index 00000000000..46f927828e7 --- /dev/null +++ b/public/app/features/dashboard/components/DashboardSettings/AnnotationsSettings.test.tsx @@ -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 = { + 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(); + + 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(); + // 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(); + + 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); + }); +}); diff --git a/public/app/features/dashboard/components/DashboardSettings/AnnotationsSettings.tsx b/public/app/features/dashboard/components/DashboardSettings/AnnotationsSettings.tsx index f8acd844ab7..93fb799cc96 100644 --- a/public/app/features/dashboard/components/DashboardSettings/AnnotationsSettings.tsx +++ b/public/app/features/dashboard/components/DashboardSettings/AnnotationsSettings.tsx @@ -1,31 +1,36 @@ -import React, { PureComponent } from 'react'; +import React, { useState } from 'react'; import { DashboardModel } from '../../state/DashboardModel'; -import { AngularComponent, getAngularLoader } from '@grafana/runtime'; +import { AnnotationSettingsEdit, AnnotationSettingsList } from '../AnnotationSettings'; +import { newAnnotation } from '../AnnotationSettings/AnnotationSettingsEdit'; +import { DashboardSettingsHeader } from './DashboardSettingsHeader'; interface Props { dashboard: DashboardModel; } -export class AnnotationsSettings extends PureComponent { - element?: HTMLElement | null; - angularCmp?: AngularComponent; +export const AnnotationsSettings: React.FC = ({ dashboard }) => { + const [editIdx, setEditIdx] = useState(null); - componentDidMount() { - const loader = getAngularLoader(); + const onGoBack = () => { + setEditIdx(null); + }; - const template = '
'; - const scopeProps = { dashboard: this.props.dashboard }; - this.angularCmp = loader.load(this.element, scopeProps, template); - this.angularCmp.digest(); - } + const onNew = () => { + dashboard.annotations.list = [...dashboard.annotations.list, { ...newAnnotation }]; + setEditIdx(dashboard.annotations.list.length - 1); + }; - componentWillUnmount() { - if (this.angularCmp) { - this.angularCmp.destroy(); - } - } + const onEdit = (idx: number) => { + setEditIdx(idx); + }; - render() { - return
(this.element = ref)} />; - } -} + const isEditing = editIdx !== null; + + return ( + <> + + {!isEditing && } + {isEditing && } + + ); +}; diff --git a/public/app/features/dashboard/components/DashboardSettings/DashboardSettingsHeader.tsx b/public/app/features/dashboard/components/DashboardSettings/DashboardSettingsHeader.tsx new file mode 100644 index 00000000000..e17ee1daa3c --- /dev/null +++ b/public/app/features/dashboard/components/DashboardSettings/DashboardSettingsHeader.tsx @@ -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 = ({ onGoBack, isEditing, title }) => { + return ( +
+ +

+ + {title} + + {isEditing && ( + + Edit + + )} +

+
+
+ ); +}; diff --git a/public/app/features/dashboard/components/DashboardSettings/LinksSettings.test.tsx b/public/app/features/dashboard/components/DashboardSettings/LinksSettings.test.tsx index c6ea6b4de59..00e1bfe0008 100644 --- a/public/app/features/dashboard/components/DashboardSettings/LinksSettings.test.tsx +++ b/public/app/features/dashboard/components/DashboardSettings/LinksSettings.test.tsx @@ -48,12 +48,17 @@ describe('LinksSettings', () => { }, ]; + const getTableBody = () => screen.getAllByRole('rowgroup')[1]; + const getTableBodyRows = () => within(getTableBody()).getAllByRole('row'); + const assertRowHasText = (index: number, text: string) => { + expect(within(getTableBodyRows()[index]).queryByText(text)).toBeInTheDocument(); + }; + beforeEach(() => { dashboard = { id: 74, version: 7, links: [...links], - updateSubmenuVisibility: () => {}, }; }); @@ -73,9 +78,7 @@ describe('LinksSettings', () => { // @ts-ignore render(); - const tableBodyRows = within(screen.getAllByRole('rowgroup')[1]).getAllByRole('row'); - - expect(tableBodyRows.length).toBe(links.length); + expect(getTableBodyRows().length).toBe(links.length); expect( screen.queryByLabelText(selectors.components.CallToActionCard.button('Add dashboard link')) ).not.toBeInTheDocument(); @@ -85,56 +88,53 @@ describe('LinksSettings', () => { // @ts-ignore render(); - const tableBody = screen.getAllByRole('rowgroup')[1]; - const tableBodyRows = within(tableBody).getAllByRole('row'); + // Check that we have sorting buttons + expect(within(getTableBodyRows()[0]).queryByRole('button', { name: 'arrow-up' })).not.toBeInTheDocument(); + expect(within(getTableBodyRows()[0]).queryByRole('button', { name: 'arrow-down' })).toBeInTheDocument(); - expect(within(tableBody).getAllByRole('button', { name: 'arrow-down' }).length).toBe(links.length - 1); - expect(within(tableBody).getAllByRole('button', { name: 'arrow-up' }).length).toBe(links.length - 1); + expect(within(getTableBodyRows()[1]).queryByRole('button', { name: 'arrow-up' })).toBeInTheDocument(); + expect(within(getTableBodyRows()[1]).queryByRole('button', { name: 'arrow-down' })).toBeInTheDocument(); - expect(within(tableBodyRows[0]).getByText(links[0].title)).toBeInTheDocument(); - expect(within(tableBodyRows[1]).getByText(links[1].title)).toBeInTheDocument(); - expect(within(tableBodyRows[2]).getByText(links[2].url)).toBeInTheDocument(); + expect(within(getTableBodyRows()[2]).queryByRole('button', { name: 'arrow-up' })).toBeInTheDocument(); + expect(within(getTableBodyRows()[2]).queryByRole('button', { name: 'arrow-down' })).not.toBeInTheDocument(); - userEvent.click(within(tableBody).getAllByRole('button', { name: 'arrow-down' })[0]); + // Checking the original order + assertRowHasText(0, links[0].title); + assertRowHasText(1, links[1].title); + assertRowHasText(2, links[2].url); - expect(within(tableBodyRows[0]).getByText(links[1].title)).toBeInTheDocument(); - expect(within(tableBodyRows[1]).getByText(links[0].title)).toBeInTheDocument(); - expect(within(tableBodyRows[2]).getByText(links[2].url)).toBeInTheDocument(); + userEvent.click(within(getTableBody()).getAllByRole('button', { name: 'arrow-down' })[0]); + userEvent.click(within(getTableBody()).getAllByRole('button', { name: 'arrow-down' })[1]); + userEvent.click(within(getTableBody()).getAllByRole('button', { name: 'arrow-up' })[0]); - userEvent.click(within(tableBody).getAllByRole('button', { name: 'arrow-down' })[1]); - userEvent.click(within(tableBody).getAllByRole('button', { name: 'arrow-up' })[0]); - - expect(within(tableBodyRows[0]).getByText(links[2].url)).toBeInTheDocument(); - expect(within(tableBodyRows[1]).getByText(links[1].title)).toBeInTheDocument(); - expect(within(tableBodyRows[2]).getByText(links[0].title)).toBeInTheDocument(); + // Checking if it has changed the sorting accordingly + assertRowHasText(0, links[2].url); + assertRowHasText(1, links[1].title); + assertRowHasText(2, links[0].title); }); test('it duplicates dashboard links', () => { // @ts-ignore render(); - const tableBody = screen.getAllByRole('rowgroup')[1]; + expect(getTableBodyRows().length).toBe(links.length); - expect(within(tableBody).getAllByRole('row').length).toBe(links.length); + userEvent.click(within(getTableBody()).getAllByRole('button', { name: /copy/i })[0]); - userEvent.click(within(tableBody).getAllByRole('button', { name: /copy/i })[0]); - - expect(within(tableBody).getAllByRole('row').length).toBe(links.length + 1); - expect(within(tableBody).getAllByText(links[0].title).length).toBe(2); + expect(getTableBodyRows().length).toBe(links.length + 1); + expect(within(getTableBody()).getAllByText(links[0].title).length).toBe(2); }); test('it deletes dashboard links', () => { // @ts-ignore render(); - const tableBody = screen.getAllByRole('rowgroup')[1]; + expect(getTableBodyRows().length).toBe(links.length); - expect(within(tableBody).getAllByRole('row').length).toBe(links.length); + userEvent.click(within(getTableBody()).getAllByRole('button', { name: /delete/i })[0]); - userEvent.click(within(tableBody).getAllByRole('button', { name: /delete/i })[0]); - - expect(within(tableBody).getAllByRole('row').length).toBe(links.length - 1); - expect(within(tableBody).queryByText(links[0].title)).not.toBeInTheDocument(); + expect(getTableBodyRows().length).toBe(links.length - 1); + expect(within(getTableBody()).queryByText(links[0].title)).not.toBeInTheDocument(); }); test('it renders a form which modifies dashboard links', () => { @@ -160,20 +160,23 @@ describe('LinksSettings', () => { expect(screen.queryByText('Tooltip')).toBeInTheDocument(); expect(screen.queryByText('Icon')).toBeInTheDocument(); + userEvent.clear(screen.getByRole('textbox', { name: /title/i })); userEvent.type(screen.getByRole('textbox', { name: /title/i }), 'New Dashboard Link'); - userEvent.click(screen.getByRole('button', { name: /add/i })); + userEvent.click( + within(screen.getByRole('heading', { name: /dashboard links edit/i })).getByText(/dashboard links/i) + ); - const tableBody = screen.getAllByRole('rowgroup')[1]; - - expect(within(tableBody).getAllByRole('row').length).toBe(links.length + 1); - expect(within(tableBody).queryByText('New Dashboard Link')).toBeInTheDocument(); + expect(getTableBodyRows().length).toBe(links.length + 1); + expect(within(getTableBody()).queryByText('New Dashboard Link')).toBeInTheDocument(); userEvent.click(screen.getAllByText(links[0].type)[0]); userEvent.clear(screen.getByRole('textbox', { name: /title/i })); userEvent.type(screen.getByRole('textbox', { name: /title/i }), 'The first dashboard link'); - userEvent.click(screen.getByRole('button', { name: /update/i })); + userEvent.click( + within(screen.getByRole('heading', { name: /dashboard links edit/i })).getByText(/dashboard links/i) + ); - expect(within(screen.getAllByRole('rowgroup')[1]).queryByText(links[0].title)).not.toBeInTheDocument(); - expect(within(screen.getAllByRole('rowgroup')[1]).queryByText('The first dashboard link')).toBeInTheDocument(); + expect(within(getTableBody()).queryByText(links[0].title)).not.toBeInTheDocument(); + expect(within(getTableBody()).queryByText('The first dashboard link')).toBeInTheDocument(); }); }); diff --git a/public/app/features/dashboard/components/DashboardSettings/LinksSettings.tsx b/public/app/features/dashboard/components/DashboardSettings/LinksSettings.tsx index efb0a75a26e..4b3eebac82e 100644 --- a/public/app/features/dashboard/components/DashboardSettings/LinksSettings.tsx +++ b/public/app/features/dashboard/components/DashboardSettings/LinksSettings.tsx @@ -1,6 +1,8 @@ import React, { useState } from 'react'; import { DashboardModel } from '../../state/DashboardModel'; -import { LinkSettingsEdit, LinkSettingsHeader, LinkSettingsList } from '../LinksSettings'; +import { LinkSettingsEdit, LinkSettingsList } from '../LinksSettings'; +import { newLink } from '../LinksSettings/LinkSettingsEdit'; +import { DashboardSettingsHeader } from './DashboardSettingsHeader'; interface Props { dashboard: DashboardModel; } @@ -8,30 +10,28 @@ interface Props { export type LinkSettingsMode = 'list' | 'new' | 'edit'; export const LinksSettings: React.FC = ({ dashboard }) => { - const [mode, setMode] = useState('list'); - const [editLinkIdx, setEditLinkIdx] = useState(null); - const hasLinks = dashboard.links.length > 0; + const [editIdx, setEditIdx] = useState(null); - const backToList = () => { - setMode('list'); + const onGoBack = () => { + setEditIdx(null); }; - const setupNew = () => { - setEditLinkIdx(null); - setMode('new'); + + const onNew = () => { + dashboard.links = [...dashboard.links, { ...newLink }]; + setEditIdx(dashboard.links.length - 1); }; - const editLink = (idx: number) => { - setEditLinkIdx(idx); - setMode('edit'); + + const onEdit = (idx: number) => { + setEditIdx(idx); }; + const isEditing = editIdx !== null; + return ( <> - - {mode === 'list' ? ( - - ) : ( - - )} + + {!isEditing && } + {isEditing && } ); }; diff --git a/public/app/features/dashboard/components/DashboardSettings/ListNewButton.tsx b/public/app/features/dashboard/components/DashboardSettings/ListNewButton.tsx new file mode 100644 index 00000000000..be40e1ddb7f --- /dev/null +++ b/public/app/features/dashboard/components/DashboardSettings/ListNewButton.tsx @@ -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 {} + +export const ListNewButton: React.FC = ({ children, ...restProps }) => { + const styles = useStyles(getStyles); + return ( +
+ +
+ ); +}; + +const getStyles = (theme: GrafanaTheme) => ({ + buttonWrapper: css` + padding: ${theme.spacing.lg} 0; + `, +}); diff --git a/public/app/features/dashboard/components/LinksSettings/LinkSettingsEdit.tsx b/public/app/features/dashboard/components/LinksSettings/LinkSettingsEdit.tsx index 159fd3e461f..367b602d4f7 100644 --- a/public/app/features/dashboard/components/LinksSettings/LinkSettingsEdit.tsx +++ b/public/app/features/dashboard/components/LinksSettings/LinkSettingsEdit.tsx @@ -1,13 +1,11 @@ import React, { useState } from 'react'; -import { css } from '@emotion/css'; -import { CollapsableSection, Button, TagsInput, Select, Field, Input, Checkbox } from '@grafana/ui'; +import { CollapsableSection, TagsInput, Select, Field, Input, Checkbox } from '@grafana/ui'; import { SelectableValue } from '@grafana/data'; -import { LinkSettingsMode } from '../DashboardSettings/LinksSettings'; import { DashboardLink, DashboardModel } from '../../state/DashboardModel'; -const newLink = { +export const newLink = { icon: 'external link', - title: '', + title: 'New link', tooltip: '', type: 'dashboards', url: '', @@ -36,59 +34,61 @@ export const linkIconMap: { [key: string]: string } = { const linkIconOptions = Object.keys(linkIconMap).map((key) => ({ label: key, value: key })); type LinkSettingsEditProps = { - mode: LinkSettingsMode; - editLinkIdx: number | null; + editLinkIdx: number; dashboard: DashboardModel; - backToList: () => void; + onGoBack: () => void; }; -export const LinkSettingsEdit: React.FC = ({ mode, editLinkIdx, dashboard, backToList }) => { +export const LinkSettingsEdit: React.FC = ({ editLinkIdx, dashboard }) => { const [linkSettings, setLinkSettings] = useState(editLinkIdx !== null ? dashboard.links[editLinkIdx] : newLink); + const onUpdate = (link: DashboardLink) => { + const links = [...dashboard.links]; + links.splice(editLinkIdx, 1, link); + dashboard.links = links; + setLinkSettings(link); + }; + const onTagsChange = (tags: any[]) => { - setLinkSettings((link) => ({ ...link, tags: tags })); + onUpdate({ ...linkSettings, tags: tags }); }; const onTypeChange = (selectedItem: SelectableValue) => { - setLinkSettings((link) => ({ ...link, type: selectedItem.value })); + const update = { ...linkSettings, type: selectedItem.value }; + + // clear props that are no longe revant for this type + if (update.type === 'dashboards') { + update.url = ''; + update.tooltip = ''; + } else { + update.tags = []; + } + + onUpdate(update); }; const onIconChange = (selectedItem: SelectableValue) => { - setLinkSettings((link) => ({ ...link, icon: selectedItem.value })); + onUpdate({ ...linkSettings, icon: selectedItem.value }); }; const onChange = (ev: React.FocusEvent) => { const target = ev.currentTarget; - setLinkSettings((link) => ({ - ...link, + onUpdate({ + ...linkSettings, [target.name]: target.type === 'checkbox' ? target.checked : target.value, - })); + }); }; - const addLink = () => { - dashboard.links = [...dashboard.links, linkSettings]; - dashboard.updateSubmenuVisibility(); - backToList(); - }; - - const updateLink = () => { - dashboard.links.splice(editLinkIdx!, 1, linkSettings); - dashboard.updateSubmenuVisibility(); - backToList(); - }; + const isNew = linkSettings.title === newLink.title; return ( -
+
+ + + - {linkSettings.type === 'dashboards' && ( <> @@ -140,11 +140,6 @@ export const LinkSettingsEdit: React.FC = ({ mode, editLi /> - -
- {mode === 'new' && } - {mode === 'edit' && } -
); }; diff --git a/public/app/features/dashboard/components/LinksSettings/LinkSettingsHeader.tsx b/public/app/features/dashboard/components/LinksSettings/LinkSettingsHeader.tsx deleted file mode 100644 index 2089c06ed87..00000000000 --- a/public/app/features/dashboard/components/LinksSettings/LinkSettingsHeader.tsx +++ /dev/null @@ -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 = ({ onNavClick, onBtnClick, mode, hasLinks }) => { - const isEditing = mode !== 'list'; - - return ( -
- -

- - Dashboard links - - {isEditing && ( - - {mode === 'new' ? 'New' : 'Edit'} - - )} -

- {!isEditing && hasLinks ? : null} -
-
- ); -}; diff --git a/public/app/features/dashboard/components/LinksSettings/LinkSettingsList.tsx b/public/app/features/dashboard/components/LinksSettings/LinkSettingsList.tsx index 17687463ece..f3e1c69d929 100644 --- a/public/app/features/dashboard/components/LinksSettings/LinkSettingsList.tsx +++ b/public/app/features/dashboard/components/LinksSettings/LinkSettingsList.tsx @@ -1,121 +1,106 @@ import React, { useState } from 'react'; -import { css } from '@emotion/css'; -import { DeleteButton, Icon, IconButton, Tag, useTheme } from '@grafana/ui'; +import { DeleteButton, HorizontalGroup, Icon, IconButton, TagList } from '@grafana/ui'; import EmptyListCTA from 'app/core/components/EmptyListCTA/EmptyListCTA'; -import { arrayMove } from 'app/core/utils/arrayMove'; import { DashboardModel, DashboardLink } from '../../state/DashboardModel'; +import { ListNewButton } from '../DashboardSettings/ListNewButton'; +import { arrayUtils } from '@grafana/data'; type LinkSettingsListProps = { dashboard: DashboardModel; - setupNew: () => void; - editLink: (idx: number) => void; + onNew: () => void; + onEdit: (idx: number) => void; }; -export const LinkSettingsList: React.FC = ({ dashboard, setupNew, editLink }) => { - const theme = useTheme(); - // @ts-ignore - const [renderCounter, setRenderCounter] = useState(0); +export const LinkSettingsList: React.FC = ({ dashboard, onNew, onEdit }) => { + const [links, setLinks] = useState(dashboard.links); const moveLink = (idx: number, direction: number) => { - arrayMove(dashboard.links, idx, idx + direction); - setRenderCounter((renderCount) => renderCount + 1); - }; - const duplicateLink = (link: DashboardLink, idx: number) => { - dashboard.links.splice(idx, 0, link); - dashboard.updateSubmenuVisibility(); - setRenderCounter((renderCount) => renderCount + 1); - }; - const deleteLink = (idx: number) => { - dashboard.links.splice(idx, 1); - dashboard.updateSubmenuVisibility(); - setRenderCounter((renderCount) => renderCount + 1); + dashboard.links = arrayUtils.moveItemImmutably(links, idx, idx + direction); + setLinks(dashboard.links); }; + const duplicateLink = (link: DashboardLink, idx: number) => { + dashboard.links = [...links, { ...link }]; + setLinks(dashboard.links); + }; + + const deleteLink = (idx: number) => { + dashboard.links = [...links.slice(0, idx), ...links.slice(idx + 1)]; + setLinks(dashboard.links); + }; + + const isEmptyList = dashboard.links.length === 0; + + if (isEmptyList) { + return ( + Dashboard Links allow you to place links to other dashboards and web sites directly below the dashboard header.

', + }} + /> + ); + } + return ( -
- {dashboard.links.length === 0 ? ( - Dashboard links allow you to place links to other dashboards and web sites directly below the dashboard header.

', - }} - /> - ) : ( - - - - - - - - - {dashboard.links.map((link, idx) => ( - - + + + + + ))} + +
TypeInfo -
editLink(idx)}> - + + + + + + + + + {links.map((link, idx) => ( + + + + - - - - - - - ))} - -
TypeInfo +
onEdit(idx)}> +   {link.type} + + + {link.title && {link.title}} + {link.type === 'link' && {link.url}} + {link.type === 'dashboards' && } + + + {idx !== 0 && ( + moveLink(idx, -1)} /> - {link.type} - - {link.title &&
{link.title}
} - {!link.title && link.url ?
{link.url}
: null} - {!link.title && link.tags - ? link.tags.map((tag, idx) => ( - - )) - : null} -
- {idx !== 0 && ( - moveLink(idx, -1)} - /> - )} - - {dashboard.links.length > 1 && idx !== dashboard.links.length - 1 ? ( - moveLink(idx, 1)} - /> - ) : null} - - duplicateLink(link, idx)} /> - - deleteLink(idx)} /> -
- )} - + )} +
+ {links.length > 1 && idx !== links.length - 1 ? ( + moveLink(idx, 1)} + /> + ) : null} + + duplicateLink(link, idx)} /> + + deleteLink(idx)} /> +
+ New link + ); }; diff --git a/public/app/features/dashboard/components/LinksSettings/index.tsx b/public/app/features/dashboard/components/LinksSettings/index.tsx index 79c2ad885c8..21e45f1e3ba 100644 --- a/public/app/features/dashboard/components/LinksSettings/index.tsx +++ b/public/app/features/dashboard/components/LinksSettings/index.tsx @@ -1,3 +1,2 @@ export { LinkSettingsEdit } from './LinkSettingsEdit'; -export { LinkSettingsHeader } from './LinkSettingsHeader'; export { LinkSettingsList } from './LinkSettingsList'; diff --git a/public/app/features/dashboard/components/SubMenu/DashboardLinks.tsx b/public/app/features/dashboard/components/SubMenu/DashboardLinks.tsx index a71ed5d5404..3c370041177 100644 --- a/public/app/features/dashboard/components/SubMenu/DashboardLinks.tsx +++ b/public/app/features/dashboard/components/SubMenu/DashboardLinks.tsx @@ -28,13 +28,6 @@ export const DashboardLinks: FC = ({ dashboard, links }) => { }; }); - useEffectOnce(() => { - dashboard.on(CoreEvents.submenuVisibilityChanged, forceUpdate); - return () => { - dashboard.off(CoreEvents.submenuVisibilityChanged, forceUpdate); - }; - }); - if (!links.length) { return null; } diff --git a/public/app/features/dashboard/components/SubMenu/SubMenu.tsx b/public/app/features/dashboard/components/SubMenu/SubMenu.tsx index 4c1411e7d8d..9740119cec0 100644 --- a/public/app/features/dashboard/components/SubMenu/SubMenu.tsx +++ b/public/app/features/dashboard/components/SubMenu/SubMenu.tsx @@ -2,7 +2,7 @@ import React, { PureComponent } from 'react'; import { connect, MapStateToProps } from 'react-redux'; import { StoreState } from '../../../../types'; import { getSubMenuVariables } from '../../../variables/state/selectors'; -import { VariableHide, VariableModel } from '../../../variables/types'; +import { VariableModel } from '../../../variables/types'; import { DashboardModel } from '../../state'; import { DashboardLinks } from './DashboardLinks'; import { Annotations } from './Annotations'; @@ -38,24 +38,10 @@ class SubMenuUnConnected extends PureComponent { this.forceUpdate(); }; - isSubMenuVisible = () => { - if (this.props.dashboard.links.length > 0) { - return true; - } - - const visibleVariables = this.props.variables.filter((variable) => variable.hide !== VariableHide.hideVariable); - if (visibleVariables.length > 0) { - return true; - } - - const visibleAnnotations = this.props.dashboard.annotations.list.filter((annotation) => annotation.hide !== true); - return visibleAnnotations.length > 0; - }; - render() { const { dashboard, variables, links, annotations } = this.props; - if (!this.isSubMenuVisible()) { + if (!dashboard.isSubMenuVisible()) { return null; } @@ -78,4 +64,5 @@ const mapStateToProps: MapStateToProps = ( }; export const SubMenu = connect(mapStateToProps)(SubMenuUnConnected); + SubMenu.displayName = 'SubMenu'; diff --git a/public/app/features/dashboard/state/DashboardModel.test.ts b/public/app/features/dashboard/state/DashboardModel.test.ts index d046a958290..b8854b22708 100644 --- a/public/app/features/dashboard/state/DashboardModel.test.ts +++ b/public/app/features/dashboard/state/DashboardModel.test.ts @@ -260,20 +260,19 @@ describe('DashboardModel', () => { }); }); - describe('updateSubmenuVisibility with empty lists', () => { + describe('isSubMenuVisible with empty lists', () => { let model: DashboardModel; beforeEach(() => { model = new DashboardModel({}); - model.updateSubmenuVisibility(); }); - it('should not enable submmenu', () => { - expect(model.meta.submenuEnabled).toBe(false); + it('should not show submenu', () => { + expect(model.isSubMenuVisible()).toBe(false); }); }); - describe('updateSubmenuVisibility with annotation', () => { + describe('isSubMenuVisible with annotation', () => { let model: DashboardModel; beforeEach(() => { @@ -282,32 +281,35 @@ describe('DashboardModel', () => { list: [{}], }, }); - model.updateSubmenuVisibility(); }); - it('should enable submmenu', () => { - expect(model.meta.submenuEnabled).toBe(true); + it('should show submmenu', () => { + expect(model.isSubMenuVisible()).toBe(true); }); }); - describe('updateSubmenuVisibility with template var', () => { + describe('isSubMenuVisible with template var', () => { let model: DashboardModel; beforeEach(() => { - model = new DashboardModel({ - templating: { - list: [{}], + model = new DashboardModel( + { + templating: { + list: [{}], + }, }, - }); - model.updateSubmenuVisibility(); + {}, + // getVariablesFromState stub to return a variable + () => [{} as any] + ); }); it('should enable submmenu', () => { - expect(model.meta.submenuEnabled).toBe(true); + expect(model.isSubMenuVisible()).toBe(true); }); }); - describe('updateSubmenuVisibility with hidden template var', () => { + describe('isSubMenuVisible with hidden template var', () => { let model: DashboardModel; beforeEach(() => { @@ -316,15 +318,14 @@ describe('DashboardModel', () => { list: [{ hide: 2 }], }, }); - model.updateSubmenuVisibility(); }); it('should not enable submmenu', () => { - expect(model.meta.submenuEnabled).toBe(false); + expect(model.isSubMenuVisible()).toBe(false); }); }); - describe('updateSubmenuVisibility with hidden annotation toggle', () => { + describe('isSubMenuVisible with hidden annotation toggle', () => { let dashboard: DashboardModel; beforeEach(() => { @@ -333,11 +334,10 @@ describe('DashboardModel', () => { list: [{ hide: true }], }, }); - dashboard.updateSubmenuVisibility(); }); it('should not enable submmenu', () => { - expect(dashboard.meta.submenuEnabled).toBe(false); + expect(dashboard.isSubMenuVisible()).toBe(false); }); }); diff --git a/public/app/features/dashboard/state/DashboardModel.ts b/public/app/features/dashboard/state/DashboardModel.ts index 81b5a43a6c0..04af1fd75a7 100644 --- a/public/app/features/dashboard/state/DashboardModel.ts +++ b/public/app/features/dashboard/state/DashboardModel.ts @@ -10,6 +10,7 @@ import sortByKeys from 'app/core/utils/sort_by_keys'; import { GridPos, PanelModel } from './PanelModel'; import { DashboardMigrator } from './DashboardMigrator'; import { + AnnotationQuery, AppEvent, dateTimeFormat, dateTimeFormatTimeAgo, @@ -66,7 +67,7 @@ export class DashboardModel { timepicker: any; templating: { list: any[] }; private originalTemplating: any; - annotations: { list: any[] }; + annotations: { list: AnnotationQuery[] }; refresh: any; snapshot: any; schemaVersion: number; @@ -760,25 +761,20 @@ export class DashboardModel { } } - updateSubmenuVisibility() { - this.meta.submenuEnabled = (() => { - if (this.links.length > 0) { - return true; - } + isSubMenuVisible() { + if (this.links.length > 0) { + return true; + } - const visibleVars = _.filter(this.templating.list, (variable: any) => variable.hide !== 2); - if (visibleVars.length > 0) { - return true; - } + if (this.getVariables().find((variable) => variable.hide !== 2)) { + return true; + } - const visibleAnnotations = _.filter(this.annotations.list, (annotation: any) => annotation.hide !== true); - if (visibleAnnotations.length > 0) { - return true; - } + if (this.annotations.list.find((annotation) => annotation.hide !== true)) { + return true; + } - return false; - })(); - this.events.emit(CoreEvents.submenuVisibilityChanged, this.meta.submenuEnabled); + return false; } getPanelInfoById(panelId: number) { diff --git a/public/app/features/dashboard/state/initDashboard.ts b/public/app/features/dashboard/state/initDashboard.ts index 4c6dfc20c54..bd57d3703ef 100644 --- a/public/app/features/dashboard/state/initDashboard.ts +++ b/public/app/features/dashboard/state/initDashboard.ts @@ -203,7 +203,6 @@ export function initDashboard(args: InitDashboardArgs): ThunkResult { try { dashboard.processRepeats(); - dashboard.updateSubmenuVisibility(); // handle auto fix experimental feature if (queryParams.autofitpanels) { diff --git a/public/app/plugins/datasource/loki/datasource.test.ts b/public/app/plugins/datasource/loki/datasource.test.ts index 64fc678a9fd..2477d00dcf9 100644 --- a/public/app/plugins/datasource/loki/datasource.test.ts +++ b/public/app/plugins/datasource/loki/datasource.test.ts @@ -564,6 +564,7 @@ function makeAnnotationQueryRequest(): AnnotationQueryRequest { datasource: 'loki', enable: true, name: 'test-annotation', + iconColor: 'red', }, dashboard: { id: 1, diff --git a/public/app/types/events.ts b/public/app/types/events.ts index bad002eff33..afed02fc88e 100644 --- a/public/app/types/events.ts +++ b/public/app/types/events.ts @@ -95,7 +95,6 @@ export const toggleKioskMode = eventFactory('toggle-kios export const timeRangeUpdated = eventFactory('time-range-updated'); export const templateVariableValueUpdated = eventFactory('template-variable-value-updated'); -export const submenuVisibilityChanged = eventFactory('submenu-visibility-changed'); export const graphClicked = eventFactory('graph-click'); diff --git a/public/sass/components/_panel_editor.scss b/public/sass/components/_panel_editor.scss index 6273a65e9fb..a9816f2197e 100644 --- a/public/sass/components/_panel_editor.scss +++ b/public/sass/components/_panel_editor.scss @@ -27,12 +27,6 @@ } } -.ds-picker { - position: relative; - min-width: 200px; - max-width: 300px; -} - /* Used by old angular panels */ .panel-options-group { border-bottom: $panel-border;