DashboardSettings: Migrate Link Settings to React (#31150)

* feat(dashboardsettings): migrate dashboard links EmptyListCTA to react

* feat(dashboardsettings): initial commit of links settings migration to react

* feat(dashboardsettings): add links form functionality

* refactor(dashboardsettings): separate out linksettings components and concerns

* Updates to links list

* Form improvements

* test(dashboardlinks): update links so tests run

* refactor: move _.move to arrayMove for testing purposes

* test(dashboardsettings): initial commit of link settings tests

* refactor(app): put back lodash move method for backwards compatibility

* test(dashboardsettings): add links settings tests

* style(dashboardsettings): camelcase constants

* chore(dashboardsettings): delete old angular links settings view

* fix(dashboardsettings): forceupdate links on submenuVisibilityChanged and correct imports

* chore: remove reference to old angular link settings components

Co-authored-by: Torkel Ödegaard <torkel@grafana.com>
This commit is contained in:
Jack Westbrook 2021-02-25 11:50:10 +01:00 committed by GitHub
parent 47d2a8085b
commit ad68f3c5e6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 546 additions and 340 deletions

View File

@ -30,16 +30,15 @@ const getStyles = stylesFactory((theme: GrafanaTheme) => {
transition: opacity 0.1s ease; transition: opacity 0.1s ease;
z-index: 0; z-index: 0;
`, `,
confirmButtonContainer: css` confirmButton: css`
align-items: flex-start;
background: ${theme.colors.bg1};
display: flex;
overflow: hidden; overflow: hidden;
position: absolute; position: absolute;
z-index: 1;
`,
confirmButton: css`
display: flex;
align-items: flex-start;
`, `,
confirmButtonShow: css` confirmButtonShow: css`
z-index: 1;
opacity: 1; opacity: 1;
transition: opacity 0.08s ease-out, transform 0.1s ease-out; transition: opacity 0.08s ease-out, transform 0.1s ease-out;
transform: translateX(0); transform: translateX(0);
@ -137,6 +136,7 @@ class UnThemedConfirmButton extends PureComponent<Props, State> {
styles.confirmButton, styles.confirmButton,
this.state.showConfirm ? styles.confirmButtonShow : styles.confirmButtonHide this.state.showConfirm ? styles.confirmButtonShow : styles.confirmButtonHide
); );
const onClick = disabled ? () => {} : this.onClickButton; const onClick = disabled ? () => {} : this.onClickButton;
return ( return (
@ -152,7 +152,6 @@ class UnThemedConfirmButton extends PureComponent<Props, State> {
{children} {children}
</span> </span>
)} )}
<span className={styles.confirmButtonContainer}>
<span className={confirmButtonClass}> <span className={confirmButtonClass}>
<Button size={size} variant="link" onClick={this.onClickCancel}> <Button size={size} variant="link" onClick={this.onClickCancel}>
Cancel Cancel
@ -162,7 +161,6 @@ class UnThemedConfirmButton extends PureComponent<Props, State> {
</Button> </Button>
</span> </span>
</span> </span>
</span>
); );
} }
} }

View File

@ -31,6 +31,7 @@ import {
} from '@grafana/data'; } from '@grafana/data';
import appEvents from 'app/core/app_events'; import appEvents from 'app/core/app_events';
import { checkBrowserCompatibility } from 'app/core/utils/browser'; import { checkBrowserCompatibility } from 'app/core/utils/browser';
import { arrayMove } from 'app/core/utils/arrayMove';
import { importPluginModule } from 'app/features/plugins/plugin_loader'; import { importPluginModule } from 'app/features/plugins/plugin_loader';
import { angularModules, coreModule } from 'app/core/core_module'; import { angularModules, coreModule } from 'app/core/core_module';
import { registerAngularDirectives } from 'app/core/core'; 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 { monkeyPatchInjectorWithPreAssignedBindings } from './core/injectorMonkeyPatch';
import { setVariableQueryRunner, VariableQueryRunner } from './features/variables/query/VariableQueryRunner'; 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 // @ts-ignore
_.move = (array: [], fromIndex: number, toIndex: number) => { _.move = arrayMove;
array.splice(toIndex, 0, array.splice(fromIndex, 1)[0]);
return array;
};
// import symlinked extensions // import symlinked extensions
const extensionsIndex = (require as any).context('.', true, /extensions\/index.ts/); const extensionsIndex = (require as any).context('.', true, /extensions\/index.ts/);

View File

@ -0,0 +1,4 @@
export const arrayMove = <T>(array: T[], fromIndex: number, toIndex: number): T[] => {
array.splice(toIndex, 0, array.splice(fromIndex, 1)[0]);
return array;
};

View File

@ -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: `<p>
Dashboard Links allow you to place links to other dashboards and web sites directly below the dashboard
header.
</p>`,
},
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);

View File

@ -1,190 +0,0 @@
<div class="page-action-bar">
<h3 class="dashboard-settings__header">
<a ng-click="ctrl.backToList()">Dashboard Links</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.links.length > 0"
ng-hide="ctrl.mode === 'edit' || ctrl.mode === 'new'"
>
New
</a>
</div>
<div ng-if="ctrl.mode == 'list'">
<div ng-if="ctrl.dashboard.links.length === 0">
<empty-list-cta
on-click="ctrl.setupNew"
title="ctrl.emptyListCta.title"
buttonIcon="ctrl.emptyListCta.buttonIcon"
buttonTitle="ctrl.emptyListCta.buttonTitle"
infoBox="ctrl.emptyListCta.infoBox"
infoBoxTitle="ctrl.emptyListCta.infoBoxTitle"
/>
</div>
<div ng-if="ctrl.dashboard.links.length > 0">
<table class="filter-table filter-table--hover">
<thead>
<tr>
<th>Type</th>
<th>Info</th>
<th colspan="3"></th>
</tr>
</thead>
<tbody>
<tr ng-repeat="link in ctrl.dashboard.links track by $index">
<td class="pointer" ng-click="ctrl.editLink(link)">
<icon name="'external-link-alt'"></icon>
{{ link.type }}
</td>
<td>
<div ng-if="link.title">
{{ link.title }}
</div>
<div ng-if="!link.title && link.url">
{{ link.url }}
</div>
<span
ng-if="!link.title && link.tags"
ng-repeat="tag in link.tags"
tag-color-from-name="tag"
class="label label-tag"
style="margin-right: 6px"
>
{{ tag }}
</span>
</td>
<td style="width: 1%">
<icon ng-click="ctrl.moveLink($index, -1)" ng-hide="$first" name="'arrow-up'"></icon>
</td>
<td style="width: 1%">
<icon ng-click="ctrl.moveLink($index, 1)" ng-hide="$last" name="'arrow-down'"></icon>
</td>
<td style="width: 1%">
<a ng-click="ctrl.duplicateLink(link, $index)" class="btn">
<icon name="'copy'" style="margin-bottom: 0;"></icon>
</a>
</td>
<td style="width: 1%">
<a ng-click="ctrl.deleteLink($index)" class="btn" ng-hide="annotation.builtIn">
<icon name="'trash-alt'" style="margin-bottom: 0;"></icon>
</a>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div ng-if="ctrl.mode == 'edit' || ctrl.mode == 'new'">
<div class="gf-form-group">
<div class="gf-form-group">
<div class="gf-form">
<span class="gf-form-label width-8">Type</span>
<div class="gf-form-select-wrapper width-10">
<select
class="gf-form-input"
ng-model="ctrl.link.type"
ng-options="f for f in ['dashboards','link']"
></select>
</div>
</div>
<div class="gf-form" ng-show="ctrl.link.type === 'dashboards'">
<span class="gf-form-label width-8">With tags</span>
<bootstrap-tagsinput
ng-model="ctrl.link.tags"
tagclass="label label-tag"
placeholder="add tags"
style="margin-right: .25rem"
></bootstrap-tagsinput>
</div>
<gf-form-switch
ng-show="ctrl.link.type === 'dashboards'"
class="gf-form"
label="As dropdown"
checked="ctrl.link.asDropdown"
switch-class="max-width-4"
label-class="width-8"
></gf-form-switch>
<div class="gf-form" ng-show="ctrl.link.type === 'dashboards' && ctrl.link.asDropdown">
<span class="gf-form-label width-8">Title</span>
<input type="text" ng-model="ctrl.link.title" class="gf-form-input max-width-10" ng-model-onblur />
</div>
<div ng-show="ctrl.link.type === 'link'">
<div class="gf-form">
<li class="gf-form-label width-8">Url</li>
<input type="text" ng-model="ctrl.link.url" class="gf-form-input width-20" ng-model-onblur />
</div>
<div class="gf-form">
<span class="gf-form-label width-8">Title</span>
<input type="text" ng-model="ctrl.link.title" class="gf-form-input width-20" ng-model-onblur />
</div>
<div class="gf-form">
<span class="gf-form-label width-8">Tooltip</span>
<input
type="text"
ng-model="ctrl.link.tooltip"
class="gf-form-input width-20"
placeholder="Open dashboard"
ng-model-onblur
/>
</div>
<div class="gf-form">
<span class="gf-form-label width-8">Icon</span>
<div class="gf-form-select-wrapper width-20">
<select
class="gf-form-input"
ng-model="ctrl.link.icon"
ng-options="k as k for (k, v) in ctrl.iconMap"
></select>
</div>
</div>
</div>
</div>
<div class="gf-form-group">
<h5 class="section-heading">Include</h5>
<div>
<gf-form-switch
class="gf-form"
label="Time range"
checked="ctrl.link.keepTime"
switch-class="max-width-6"
label-class="width-9"
></gf-form-switch>
<gf-form-switch
class="gf-form"
label="Variable values"
checked="ctrl.link.includeVars"
switch-class="max-width-6"
label-class="width-9"
></gf-form-switch>
<gf-form-switch
class="gf-form"
label="Open in new tab"
checked="ctrl.link.targetBlank"
switch-class="max-width-6"
label-class="width-9"
></gf-form-switch>
</div>
</div>
</div>
<button class="btn btn-primary" ng-if="ctrl.mode == 'new'" ng-click="ctrl.addLink()">
Add
</button>
<button class="btn btn-primary" ng-if="ctrl.mode == 'edit'" ng-click="ctrl.saveLink()">
Update
</button>
</div>

View File

@ -1 +0,0 @@
export { DashLinksEditorCtrl } from './DashLinksEditorCtrl';

View File

@ -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(<LinksSettings dashboard={linklessDashboard} />);
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(<LinksSettings dashboard={dashboard} />);
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(<LinksSettings dashboard={dashboard} />);
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(<LinksSettings dashboard={dashboard} />);
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(<LinksSettings dashboard={dashboard} />);
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(<LinksSettings dashboard={dashboard} />);
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();
});
});

View File

@ -1,30 +1,37 @@
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 { LinkSettingsEdit, LinkSettingsHeader, LinkSettingsList } from '../LinksSettings';
interface Props { interface Props {
dashboard: DashboardModel; dashboard: DashboardModel;
} }
export class LinksSettings extends PureComponent<Props> { export type LinkSettingsMode = 'list' | 'new' | 'edit';
element?: HTMLElement | null;
angularCmp?: AngularComponent;
componentDidMount() { export const LinksSettings: React.FC<Props> = ({ dashboard }) => {
const loader = getAngularLoader(); const [mode, setMode] = useState<LinkSettingsMode>('list');
const [editLinkIdx, setEditLinkIdx] = useState<number | null>(null);
const hasLinks = dashboard.links.length > 0;
const template = '<dash-links-editor dashboard="dashboard" />'; const backToList = () => {
const scopeProps = { dashboard: this.props.dashboard }; setMode('list');
this.angularCmp = loader.load(this.element, scopeProps, template); };
} const setupNew = () => {
setEditLinkIdx(null);
setMode('new');
};
const editLink = (idx: number) => {
setEditLinkIdx(idx);
setMode('edit');
};
componentWillUnmount() { return (
if (this.angularCmp) { <>
this.angularCmp.destroy(); <LinkSettingsHeader onNavClick={backToList} onBtnClick={setupNew} mode={mode} hasLinks={hasLinks} />
} {mode === 'list' ? (
} <LinkSettingsList dashboard={dashboard} setupNew={setupNew} editLink={editLink} />
) : (
render() { <LinkSettingsEdit dashboard={dashboard} mode={mode} editLinkIdx={editLinkIdx} backToList={backToList} />
return <div ref={(ref) => (this.element = ref)} />; )}
} </>
} );
};

View File

@ -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<LinkSettingsEditProps> = ({ 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<HTMLInputElement>) => {
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 (
<div
className={css`
max-width: 600px;
`}
>
<Field label="Type">
<Select value={linkSettings.type} options={linkTypeOptions} onChange={onTypeChange} />
</Field>
<Field label="Title">
<Input name="title" aria-label="title" value={linkSettings.title} onChange={onChange} />
</Field>
{linkSettings.type === 'dashboards' && (
<>
<Field label="With tags">
<TagsInput tags={linkSettings.tags} placeholder="add tags" onChange={onTagsChange} />
</Field>
</>
)}
{linkSettings.type === 'link' && (
<>
<Field label="Url">
<Input name="url" value={linkSettings.url} onChange={onChange} />
</Field>
<Field label="Tooltip">
<Input name="tooltip" value={linkSettings.tooltip} onChange={onChange} placeholder="Open dashboard" />
</Field>
<Field label="Icon">
<Select value={linkSettings.icon} options={linkIconOptions} onChange={onIconChange} />
</Field>
</>
)}
<CollapsableSection label="Options" isOpen={true}>
{linkSettings.type === 'dashboards' && (
<Field>
<Checkbox label="Show as dropdown" name="asDropdown" value={linkSettings.asDropdown} onChange={onChange} />
</Field>
)}
<Field>
<Checkbox
label="Include current time range"
name="keepTime"
value={linkSettings.keepTime}
onChange={onChange}
/>
</Field>
<Field>
<Checkbox
label="Include current template variable values"
name="includeVars"
value={linkSettings.includeVars}
onChange={onChange}
/>
</Field>
<Field>
<Checkbox
label="Open link in new tab"
name="targetBlank"
value={linkSettings.targetBlank}
onChange={onChange}
/>
</Field>
</CollapsableSection>
<div className="gf-form-button-row">
{mode === 'new' && <Button onClick={addLink}>Add</Button>}
{mode === 'edit' && <Button onClick={updateLink}>Update</Button>}
</div>
</div>
);
};

View File

@ -0,0 +1,32 @@
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

@ -0,0 +1,121 @@
import React, { useState } from 'react';
import { css } from 'emotion';
import { DeleteButton, Icon, IconButton, Tag, useTheme } from '@grafana/ui';
import EmptyListCTA from 'app/core/components/EmptyListCTA/EmptyListCTA';
import { arrayMove } from 'app/core/utils/arrayMove';
import { DashboardModel, DashboardLink } from '../../state/DashboardModel';
type LinkSettingsListProps = {
dashboard: DashboardModel;
setupNew: () => void;
editLink: (idx: number) => void;
};
export const LinkSettingsList: React.FC<LinkSettingsListProps> = ({ dashboard, setupNew, editLink }) => {
const theme = useTheme();
// @ts-ignore
const [renderCounter, setRenderCounter] = useState(0);
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);
};
return (
<div>
{dashboard.links.length === 0 ? (
<EmptyListCTA
onClick={setupNew}
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>',
}}
/>
) : (
<table className="filter-table filter-table--hover">
<thead>
<tr>
<th>Type</th>
<th>Info</th>
<th colSpan={3} />
</tr>
</thead>
<tbody>
{dashboard.links.map((link, idx) => (
<tr key={idx}>
<td className="pointer" onClick={() => editLink(idx)}>
<Icon
name="external-link-alt"
className={css`
margin-right: ${theme.spacing.xs};
`}
/>
{link.type}
</td>
<td>
{link.title && <div>{link.title}</div>}
{!link.title && link.url ? <div>{link.url}</div> : null}
{!link.title && link.tags
? link.tags.map((tag, idx) => (
<Tag
name={tag}
key={tag}
className={
idx !== 0
? css`
margin-left: ${theme.spacing.xs};
`
: ''
}
/>
))
: null}
</td>
<td style={{ width: '1%' }}>
{idx !== 0 && (
<IconButton
surface="header"
name="arrow-up"
aria-label="arrow-up"
onClick={() => moveLink(idx, -1)}
/>
)}
</td>
<td style={{ width: '1%' }}>
{dashboard.links.length > 1 && idx !== dashboard.links.length - 1 ? (
<IconButton
surface="header"
name="arrow-down"
aria-label="arrow-down"
onClick={() => moveLink(idx, 1)}
/>
) : null}
</td>
<td style={{ width: '1%' }}>
<IconButton surface="header" aria-label="copy" name="copy" onClick={() => duplicateLink(link, idx)} />
</td>
<td style={{ width: '1%' }}>
<DeleteButton size="sm" onConfirm={() => deleteLink(idx)} />
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
);
};

View File

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

View File

@ -6,7 +6,7 @@ import { getLinkSrv } from '../../../panel/panellinks/link_srv';
import { DashboardModel } from '../../state'; import { DashboardModel } from '../../state';
import { DashboardLink } from '../../state/DashboardModel'; import { DashboardLink } from '../../state/DashboardModel';
import { iconMap } from '../DashLinks/DashLinksEditorCtrl'; import { linkIconMap } from '../LinksSettings/LinkSettingsEdit';
import { useEffectOnce } from 'react-use'; import { useEffectOnce } from 'react-use';
import { CoreEvents } from 'app/types'; import { CoreEvents } from 'app/types';
import { selectors } from '@grafana/e2e-selectors'; import { selectors } from '@grafana/e2e-selectors';
@ -32,6 +32,13 @@ export const DashboardLinks: FC<Props> = ({ dashboard, links }) => {
}; };
}); });
useEffectOnce(() => {
dashboard.on(CoreEvents.submenuVisibilityChanged, forceUpdate);
return () => {
dashboard.off(CoreEvents.submenuVisibilityChanged, forceUpdate);
};
});
return ( return (
<> <>
{links.map((link: DashboardLink, index: number) => { {links.map((link: DashboardLink, index: number) => {
@ -50,7 +57,7 @@ export const DashboardLinks: FC<Props> = ({ dashboard, links }) => {
rel="noreferrer" rel="noreferrer"
aria-label={selectors.components.DashboardLinks.link} aria-label={selectors.components.DashboardLinks.link}
> >
<Icon name={iconMap[link.icon] as IconName} style={{ marginRight: '4px' }} /> <Icon name={linkIconMap[link.icon] as IconName} style={{ marginRight: '4px' }} />
<span>{sanitize(linkInfo.title)}</span> <span>{sanitize(linkInfo.title)}</span>
</a> </a>
); );

View File

@ -8,6 +8,8 @@ describe('searchForTags', () => {
const tags = ['A', 'B']; const tags = ['A', 'B'];
const link: DashboardLink = { const link: DashboardLink = {
targetBlank: false, targetBlank: false,
keepTime: false,
includeVars: false,
asDropdown: false, asDropdown: false,
icon: 'some icon', icon: 'some icon',
tags, tags,
@ -40,6 +42,8 @@ describe('resolveLinks', () => {
const setupTestContext = (dashboardId: number, searchHitId: number) => { const setupTestContext = (dashboardId: number, searchHitId: number) => {
const link: DashboardLink = { const link: DashboardLink = {
targetBlank: false, targetBlank: false,
keepTime: false,
includeVars: false,
asDropdown: false, asDropdown: false,
icon: 'some icon', icon: 'some icon',
tags: [], tags: [],

View File

@ -3,7 +3,6 @@ import './services/UnsavedChangesSrv';
import './services/DashboardLoaderSrv'; import './services/DashboardLoaderSrv';
import './services/DashboardSrv'; import './services/DashboardSrv';
// Components // Components
import './components/DashLinks';
import './components/DashExportModal'; import './components/DashExportModal';
import './components/DashNav'; import './components/DashNav';
import './components/VersionHistory'; import './components/VersionHistory';

View File

@ -34,7 +34,7 @@ export interface CloneOptions {
message?: string; message?: string;
} }
type DashboardLinkType = 'link' | 'dashboards'; export type DashboardLinkType = 'link' | 'dashboards';
export interface DashboardLink { export interface DashboardLink {
icon: string; icon: string;
@ -46,6 +46,8 @@ export interface DashboardLink {
tags: any[]; tags: any[];
searchHits?: any[]; searchHits?: any[];
targetBlank: boolean; targetBlank: boolean;
keepTime: boolean;
includeVars: boolean;
} }
export class DashboardModel { export class DashboardModel {

View File

@ -1,4 +1,5 @@
import _ from 'lodash'; import _ from 'lodash';
import { arrayMove } from 'app/core/utils/arrayMove';
import { Parser } from './parser'; import { Parser } from './parser';
import { TemplateSrv } from '@grafana/runtime'; import { TemplateSrv } from '@grafana/runtime';
import { ScopedVars } from '@grafana/data'; import { ScopedVars } from '@grafana/data';
@ -146,8 +147,7 @@ export default class GraphiteQuery {
moveFunction(func: any, offset: number) { moveFunction(func: any, offset: number) {
const index = this.functions.indexOf(func); const index = this.functions.indexOf(func);
// @ts-ignore arrayMove(this.functions, index, index + offset);
_.move(this.functions, index, index + offset);
} }
updateModelTarget(targets: any) { updateModelTarget(targets: any) {