diff --git a/packages/grafana-ui/src/components/ConfirmButton/ConfirmButton.tsx b/packages/grafana-ui/src/components/ConfirmButton/ConfirmButton.tsx index 3d57bd43ab2..eb864c143e7 100644 --- a/packages/grafana-ui/src/components/ConfirmButton/ConfirmButton.tsx +++ b/packages/grafana-ui/src/components/ConfirmButton/ConfirmButton.tsx @@ -30,16 +30,15 @@ const getStyles = stylesFactory((theme: GrafanaTheme) => { transition: opacity 0.1s ease; z-index: 0; `, - confirmButtonContainer: css` + confirmButton: css` + align-items: flex-start; + background: ${theme.colors.bg1}; + display: flex; overflow: hidden; position: absolute; - z-index: 1; - `, - confirmButton: css` - display: flex; - align-items: flex-start; `, confirmButtonShow: css` + z-index: 1; opacity: 1; transition: opacity 0.08s ease-out, transform 0.1s ease-out; transform: translateX(0); @@ -137,6 +136,7 @@ class UnThemedConfirmButton extends PureComponent { styles.confirmButton, this.state.showConfirm ? styles.confirmButtonShow : styles.confirmButtonHide ); + const onClick = disabled ? () => {} : this.onClickButton; return ( @@ -152,15 +152,13 @@ class UnThemedConfirmButton extends PureComponent { {children} )} - - - - - + + + ); diff --git a/public/app/app.ts b/public/app/app.ts index 71628c38e73..f7ee13e5ac4 100644 --- a/public/app/app.ts +++ b/public/app/app.ts @@ -31,6 +31,7 @@ import { } from '@grafana/data'; import appEvents from 'app/core/app_events'; import { checkBrowserCompatibility } from 'app/core/utils/browser'; +import { arrayMove } from 'app/core/utils/arrayMove'; import { importPluginModule } from 'app/features/plugins/plugin_loader'; import { angularModules, coreModule } from 'app/core/core_module'; import { registerAngularDirectives } from 'app/core/core'; @@ -49,12 +50,9 @@ import { SentryEchoBackend } from './core/services/echo/backends/sentry/SentryBa import { monkeyPatchInjectorWithPreAssignedBindings } from './core/injectorMonkeyPatch'; import { setVariableQueryRunner, VariableQueryRunner } from './features/variables/query/VariableQueryRunner'; -// add move to lodash for backward compatabiltiy +// add move to lodash for backward compatabilty with plugins // @ts-ignore -_.move = (array: [], fromIndex: number, toIndex: number) => { - array.splice(toIndex, 0, array.splice(fromIndex, 1)[0]); - return array; -}; +_.move = arrayMove; // import symlinked extensions const extensionsIndex = (require as any).context('.', true, /extensions\/index.ts/); diff --git a/public/app/core/utils/arrayMove.ts b/public/app/core/utils/arrayMove.ts new file mode 100644 index 00000000000..a6bac69fca9 --- /dev/null +++ b/public/app/core/utils/arrayMove.ts @@ -0,0 +1,4 @@ +export const arrayMove = (array: T[], fromIndex: number, toIndex: number): T[] => { + array.splice(toIndex, 0, array.splice(fromIndex, 1)[0]); + return array; +}; diff --git a/public/app/features/dashboard/components/DashLinks/DashLinksEditorCtrl.ts b/public/app/features/dashboard/components/DashLinks/DashLinksEditorCtrl.ts deleted file mode 100644 index 9445041594b..00000000000 --- a/public/app/features/dashboard/components/DashLinks/DashLinksEditorCtrl.ts +++ /dev/null @@ -1,101 +0,0 @@ -import angular from 'angular'; -import _ from 'lodash'; -import { DashboardModel } from 'app/features/dashboard/state'; -import { GrafanaRootScope } from 'app/routes/GrafanaCtrl'; -import { CoreEvents } from 'app/types'; - -export let iconMap: { [key: string]: string } = { - 'external link': 'external-link-alt', - dashboard: 'apps', - question: 'question-circle', - info: 'info-circle', - bolt: 'bolt', - doc: 'file-alt', - cloud: 'cloud', -}; - -export class DashLinksEditorCtrl { - dashboard: DashboardModel; - iconMap: any; - mode: any; - link: any; - - emptyListCta = { - title: 'There are no dashboard links added yet', - buttonIcon: 'link', - buttonTitle: 'Add Dashboard Link', - infoBox: { - __html: `

- Dashboard Links allow you to place links to other dashboards and web sites directly below the dashboard - header. -

`, - }, - infoBoxTitle: 'What are Dashboard Links?', - }; - - /** @ngInject */ - constructor($scope: any, $rootScope: GrafanaRootScope) { - this.iconMap = iconMap; - this.dashboard.links = this.dashboard.links || []; - this.mode = 'list'; - - $scope.$on('$destroy', () => { - $rootScope.appEvent(CoreEvents.dashLinksUpdated); - }); - } - - backToList() { - this.mode = 'list'; - } - - setupNew = () => { - this.mode = 'new'; - this.link = { type: 'dashboards', icon: 'external link' }; - }; - - addLink() { - this.dashboard.links = [...this.dashboard.links, this.link]; - this.mode = 'list'; - this.dashboard.updateSubmenuVisibility(); - } - - editLink(link: any) { - this.link = link; - this.mode = 'edit'; - } - - saveLink() { - this.dashboard.links = _.cloneDeep(this.dashboard.links); - this.backToList(); - } - - moveLink(index: string | number, dir: string | number) { - // @ts-ignore - _.move(this.dashboard.links, index, index + dir); - } - - duplicateLink(link: any, index: number) { - this.dashboard.links.splice(index, 0, link); - this.dashboard.updateSubmenuVisibility(); - } - - deleteLink(index: number) { - this.dashboard.links.splice(index, 1); - this.dashboard.updateSubmenuVisibility(); - } -} - -function dashLinksEditor() { - return { - restrict: 'E', - controller: DashLinksEditorCtrl, - templateUrl: 'public/app/features/dashboard/components/DashLinks/editor.html', - bindToController: true, - controllerAs: 'ctrl', - scope: { - dashboard: '=', - }, - }; -} - -angular.module('grafana.directives').directive('dashLinksEditor', dashLinksEditor); diff --git a/public/app/features/dashboard/components/DashLinks/editor.html b/public/app/features/dashboard/components/DashLinks/editor.html deleted file mode 100644 index dcfad1a33d9..00000000000 --- a/public/app/features/dashboard/components/DashLinks/editor.html +++ /dev/null @@ -1,190 +0,0 @@ -
-

- Dashboard Links - New - Edit -

- -
- - New - -
- -
-
- -
- -
- - - - - - - - - - - - - - - - - - -
TypeInfo
- - {{ link.type }} - -
- {{ link.title }} -
-
- {{ link.url }} -
- - {{ tag }} - -
- - - - - - - - - - - -
-
-
- -
-
-
-
- Type -
- -
-
- -
- With tags - -
- - -
- Title - -
-
-
-
  • Url
  • - -
    - -
    - Title - -
    - -
    - Tooltip - -
    - -
    - Icon -
    - -
    -
    -
    -
    - -
    -
    Include
    -
    - - - -
    -
    -
    - - -
    diff --git a/public/app/features/dashboard/components/DashLinks/index.ts b/public/app/features/dashboard/components/DashLinks/index.ts deleted file mode 100644 index 61585668048..00000000000 --- a/public/app/features/dashboard/components/DashLinks/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { DashLinksEditorCtrl } from './DashLinksEditorCtrl'; diff --git a/public/app/features/dashboard/components/DashboardSettings/LinksSettings.test.tsx b/public/app/features/dashboard/components/DashboardSettings/LinksSettings.test.tsx new file mode 100644 index 00000000000..7e6598ca0cd --- /dev/null +++ b/public/app/features/dashboard/components/DashboardSettings/LinksSettings.test.tsx @@ -0,0 +1,173 @@ +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 { LinksSettings } from './LinksSettings'; + +describe('LinksSettings', () => { + let dashboard = {}; + const links = [ + { + asDropdown: false, + icon: 'external link', + includeVars: false, + keepTime: false, + tags: [], + targetBlank: false, + title: 'link 1', + tooltip: '', + type: 'link', + url: 'https://www.google.com', + }, + { + asDropdown: false, + icon: 'external link', + includeVars: false, + keepTime: false, + tags: ['gdev'], + targetBlank: false, + title: 'link 2', + tooltip: '', + type: 'dashboards', + url: '', + }, + { + asDropdown: false, + icon: 'external link', + includeVars: false, + keepTime: false, + tags: [], + targetBlank: false, + title: '', + tooltip: '', + type: 'link', + url: 'https://www.bing.com', + }, + ]; + + beforeEach(() => { + dashboard = { + id: 74, + version: 7, + links: [...links], + updateSubmenuVisibility: () => {}, + }; + }); + + test('it renders a header and cta if no links', () => { + const linklessDashboard = { ...dashboard, links: [] }; + // @ts-ignore + render(); + + expect(screen.getByRole('heading', { name: 'Dashboard Links' })).toBeInTheDocument(); + expect(screen.getByLabelText('Call to action button Add Dashboard Link')).toBeInTheDocument(); + expect(screen.queryByRole('table')).not.toBeInTheDocument(); + }); + + test('it renders a table of links', () => { + // @ts-ignore + render(); + + const tableBodyRows = within(screen.getAllByRole('rowgroup')[1]).getAllByRole('row'); + + expect(tableBodyRows.length).toBe(links.length); + expect(screen.queryByLabelText('Call to action button Add Dashboard Link')).not.toBeInTheDocument(); + }); + + test('it rearranges the order of dashboard links', () => { + // @ts-ignore + render(); + + const tableBody = screen.getAllByRole('rowgroup')[1]; + const tableBodyRows = within(tableBody).getAllByRole('row'); + + 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(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(); + + userEvent.click(within(tableBody).getAllByRole('button', { name: 'arrow-down' })[0]); + + 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(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(); + }); + + test('it duplicates dashboard links', () => { + // @ts-ignore + render(); + + const tableBody = screen.getAllByRole('rowgroup')[1]; + + expect(within(tableBody).getAllByRole('row').length).toBe(links.length); + + 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); + }); + + test('it deletes dashboard links', () => { + // @ts-ignore + render(); + + const tableBody = screen.getAllByRole('rowgroup')[1]; + + expect(within(tableBody).getAllByRole('row').length).toBe(links.length); + + 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(); + }); + + test('it renders a form which modifies dashboard links', () => { + // @ts-ignore + render(); + userEvent.click(screen.getByRole('button', { name: /new/i })); + + expect(screen.queryByText('Type')).toBeInTheDocument(); + expect(screen.queryByText('Title')).toBeInTheDocument(); + expect(screen.queryByText('With tags')).toBeInTheDocument(); + + expect(screen.queryByText('Url')).not.toBeInTheDocument(); + expect(screen.queryByText('Tooltip')).not.toBeInTheDocument(); + expect(screen.queryByText('Icon')).not.toBeInTheDocument(); + + userEvent.click(screen.getByText('Dashboards')); + expect(screen.queryAllByText('Dashboards')).toHaveLength(2); + expect(screen.queryByText('Link')).toBeVisible(); + + userEvent.click(screen.getByText('Link')); + + expect(screen.queryByText('Url')).toBeInTheDocument(); + expect(screen.queryByText('Tooltip')).toBeInTheDocument(); + expect(screen.queryByText('Icon')).toBeInTheDocument(); + + userEvent.type(screen.getByRole('textbox', { name: /title/i }), 'New Dashboard Link'); + userEvent.click(screen.getByRole('button', { name: /add/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(); + + 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 })); + + expect(within(screen.getAllByRole('rowgroup')[1]).queryByText(links[0].title)).not.toBeInTheDocument(); + expect(within(screen.getAllByRole('rowgroup')[1]).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 cc4d7504f5f..efb0a75a26e 100644 --- a/public/app/features/dashboard/components/DashboardSettings/LinksSettings.tsx +++ b/public/app/features/dashboard/components/DashboardSettings/LinksSettings.tsx @@ -1,30 +1,37 @@ -import React, { PureComponent } from 'react'; +import React, { useState } from 'react'; import { DashboardModel } from '../../state/DashboardModel'; -import { AngularComponent, getAngularLoader } from '@grafana/runtime'; - +import { LinkSettingsEdit, LinkSettingsHeader, LinkSettingsList } from '../LinksSettings'; interface Props { dashboard: DashboardModel; } -export class LinksSettings extends PureComponent { - element?: HTMLElement | null; - angularCmp?: AngularComponent; +export type LinkSettingsMode = 'list' | 'new' | 'edit'; - componentDidMount() { - const loader = getAngularLoader(); +export const LinksSettings: React.FC = ({ dashboard }) => { + const [mode, setMode] = useState('list'); + const [editLinkIdx, setEditLinkIdx] = useState(null); + const hasLinks = dashboard.links.length > 0; - const template = ''; - const scopeProps = { dashboard: this.props.dashboard }; - this.angularCmp = loader.load(this.element, scopeProps, template); - } + const backToList = () => { + setMode('list'); + }; + const setupNew = () => { + setEditLinkIdx(null); + setMode('new'); + }; + const editLink = (idx: number) => { + setEditLinkIdx(idx); + setMode('edit'); + }; - componentWillUnmount() { - if (this.angularCmp) { - this.angularCmp.destroy(); - } - } - - render() { - return
    (this.element = ref)} />; - } -} + return ( + <> + + {mode === 'list' ? ( + + ) : ( + + )} + + ); +}; diff --git a/public/app/features/dashboard/components/LinksSettings/LinkSettingsEdit.tsx b/public/app/features/dashboard/components/LinksSettings/LinkSettingsEdit.tsx new file mode 100644 index 00000000000..e0603c6f712 --- /dev/null +++ b/public/app/features/dashboard/components/LinksSettings/LinkSettingsEdit.tsx @@ -0,0 +1,150 @@ +import React, { useState } from 'react'; +import { css } from 'emotion'; +import { CollapsableSection, Button, 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 = { + icon: 'external link', + title: '', + tooltip: '', + type: 'dashboards', + url: '', + asDropdown: false, + tags: [], + targetBlank: false, + keepTime: false, + includeVars: false, +} as DashboardLink; + +const linkTypeOptions = [ + { value: 'dashboards', label: 'Dashboards' }, + { value: 'link', label: 'Link' }, +]; + +export const linkIconMap: { [key: string]: string } = { + 'external link': 'external-link-alt', + dashboard: 'apps', + question: 'question-circle', + info: 'info-circle', + bolt: 'bolt', + doc: 'file-alt', + cloud: 'cloud', +}; + +const linkIconOptions = Object.keys(linkIconMap).map((key) => ({ label: key, value: key })); + +type LinkSettingsEditProps = { + mode: LinkSettingsMode; + editLinkIdx: number | null; + dashboard: DashboardModel; + backToList: () => void; +}; + +export const LinkSettingsEdit: React.FC = ({ mode, editLinkIdx, dashboard, backToList }) => { + const [linkSettings, setLinkSettings] = useState(editLinkIdx !== null ? dashboard.links[editLinkIdx] : newLink); + + const onTagsChange = (tags: any[]) => { + setLinkSettings((link) => ({ ...link, tags: tags })); + }; + + const onTypeChange = (selectedItem: SelectableValue) => { + setLinkSettings((link) => ({ ...link, type: selectedItem.value })); + }; + + const onIconChange = (selectedItem: SelectableValue) => { + setLinkSettings((link) => ({ ...link, icon: selectedItem.value })); + }; + + const onChange = (ev: React.FocusEvent) => { + const target = ev.currentTarget; + setLinkSettings((link) => ({ + ...link, + [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(); + }; + + return ( +
    + + + + {linkSettings.type === 'dashboards' && ( + <> + + + + + )} + {linkSettings.type === 'link' && ( + <> + + + + + + + +