mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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:
parent
47d2a8085b
commit
ad68f3c5e6
@ -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<Props, State> {
|
||||
styles.confirmButton,
|
||||
this.state.showConfirm ? styles.confirmButtonShow : styles.confirmButtonHide
|
||||
);
|
||||
|
||||
const onClick = disabled ? () => {} : this.onClickButton;
|
||||
|
||||
return (
|
||||
@ -152,15 +152,13 @@ class UnThemedConfirmButton extends PureComponent<Props, State> {
|
||||
{children}
|
||||
</span>
|
||||
)}
|
||||
<span className={styles.confirmButtonContainer}>
|
||||
<span className={confirmButtonClass}>
|
||||
<Button size={size} variant="link" onClick={this.onClickCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button size={size} variant={confirmButtonVariant} onClick={this.onConfirm}>
|
||||
{confirmText}
|
||||
</Button>
|
||||
</span>
|
||||
<span className={confirmButtonClass}>
|
||||
<Button size={size} variant="link" onClick={this.onClickCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button size={size} variant={confirmButtonVariant} onClick={this.onConfirm}>
|
||||
{confirmText}
|
||||
</Button>
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
|
@ -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/);
|
||||
|
4
public/app/core/utils/arrayMove.ts
Normal file
4
public/app/core/utils/arrayMove.ts
Normal 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;
|
||||
};
|
@ -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);
|
@ -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>
|
@ -1 +0,0 @@
|
||||
export { DashLinksEditorCtrl } from './DashLinksEditorCtrl';
|
@ -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();
|
||||
});
|
||||
});
|
@ -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<Props> {
|
||||
element?: HTMLElement | null;
|
||||
angularCmp?: AngularComponent;
|
||||
export type LinkSettingsMode = 'list' | 'new' | 'edit';
|
||||
|
||||
componentDidMount() {
|
||||
const loader = getAngularLoader();
|
||||
export const LinksSettings: React.FC<Props> = ({ dashboard }) => {
|
||||
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 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 <div ref={(ref) => (this.element = ref)} />;
|
||||
}
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<LinkSettingsHeader onNavClick={backToList} onBtnClick={setupNew} mode={mode} hasLinks={hasLinks} />
|
||||
{mode === 'list' ? (
|
||||
<LinkSettingsList dashboard={dashboard} setupNew={setupNew} editLink={editLink} />
|
||||
) : (
|
||||
<LinkSettingsEdit dashboard={dashboard} mode={mode} editLinkIdx={editLinkIdx} backToList={backToList} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
@ -0,0 +1,3 @@
|
||||
export { LinkSettingsEdit } from './LinkSettingsEdit';
|
||||
export { LinkSettingsHeader } from './LinkSettingsHeader';
|
||||
export { LinkSettingsList } from './LinkSettingsList';
|
@ -6,7 +6,7 @@ import { getLinkSrv } from '../../../panel/panellinks/link_srv';
|
||||
|
||||
import { DashboardModel } from '../../state';
|
||||
import { DashboardLink } from '../../state/DashboardModel';
|
||||
import { iconMap } from '../DashLinks/DashLinksEditorCtrl';
|
||||
import { linkIconMap } from '../LinksSettings/LinkSettingsEdit';
|
||||
import { useEffectOnce } from 'react-use';
|
||||
import { CoreEvents } from 'app/types';
|
||||
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 (
|
||||
<>
|
||||
{links.map((link: DashboardLink, index: number) => {
|
||||
@ -50,7 +57,7 @@ export const DashboardLinks: FC<Props> = ({ dashboard, links }) => {
|
||||
rel="noreferrer"
|
||||
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>
|
||||
</a>
|
||||
);
|
||||
|
@ -8,6 +8,8 @@ describe('searchForTags', () => {
|
||||
const tags = ['A', 'B'];
|
||||
const link: DashboardLink = {
|
||||
targetBlank: false,
|
||||
keepTime: false,
|
||||
includeVars: false,
|
||||
asDropdown: false,
|
||||
icon: 'some icon',
|
||||
tags,
|
||||
@ -40,6 +42,8 @@ describe('resolveLinks', () => {
|
||||
const setupTestContext = (dashboardId: number, searchHitId: number) => {
|
||||
const link: DashboardLink = {
|
||||
targetBlank: false,
|
||||
keepTime: false,
|
||||
includeVars: false,
|
||||
asDropdown: false,
|
||||
icon: 'some icon',
|
||||
tags: [],
|
||||
|
@ -3,7 +3,6 @@ import './services/UnsavedChangesSrv';
|
||||
import './services/DashboardLoaderSrv';
|
||||
import './services/DashboardSrv';
|
||||
// Components
|
||||
import './components/DashLinks';
|
||||
import './components/DashExportModal';
|
||||
import './components/DashNav';
|
||||
import './components/VersionHistory';
|
||||
|
@ -34,7 +34,7 @@ export interface CloneOptions {
|
||||
message?: string;
|
||||
}
|
||||
|
||||
type DashboardLinkType = 'link' | 'dashboards';
|
||||
export type DashboardLinkType = 'link' | 'dashboards';
|
||||
|
||||
export interface DashboardLink {
|
||||
icon: string;
|
||||
@ -46,6 +46,8 @@ export interface DashboardLink {
|
||||
tags: any[];
|
||||
searchHits?: any[];
|
||||
targetBlank: boolean;
|
||||
keepTime: boolean;
|
||||
includeVars: boolean;
|
||||
}
|
||||
|
||||
export class DashboardModel {
|
||||
|
@ -1,4 +1,5 @@
|
||||
import _ from 'lodash';
|
||||
import { arrayMove } from 'app/core/utils/arrayMove';
|
||||
import { Parser } from './parser';
|
||||
import { TemplateSrv } from '@grafana/runtime';
|
||||
import { ScopedVars } from '@grafana/data';
|
||||
@ -146,8 +147,7 @@ export default class GraphiteQuery {
|
||||
|
||||
moveFunction(func: any, offset: number) {
|
||||
const index = this.functions.indexOf(func);
|
||||
// @ts-ignore
|
||||
_.move(this.functions, index, index + offset);
|
||||
arrayMove(this.functions, index, index + offset);
|
||||
}
|
||||
|
||||
updateModelTarget(targets: any) {
|
||||
|
Loading…
Reference in New Issue
Block a user