Commit 66630d73 authored by Miguel Rincon's avatar Miguel Rincon Committed by Kushal Pandya

Add context menu links to metrics dashboard panel

This change allows the user to define "links" in their panel definition
to see them displayed next to their chart as a quick action to visit
other pages.
parent c9d309fc
......@@ -6,8 +6,9 @@ import {
GlResizeObserverDirective,
GlIcon,
GlLoadingIcon,
GlDropdown,
GlDropdownItem,
GlNewDropdown as GlDropdown,
GlNewDropdownItem as GlDropdownItem,
GlNewDropdownDivider as GlDropdownDivider,
GlModal,
GlModalDirective,
GlTooltip,
......@@ -28,6 +29,7 @@ import MonitorStackedColumnChart from './charts/stacked_column.vue';
import TrackEventDirective from '~/vue_shared/directives/track_event';
import AlertWidget from './alert_widget.vue';
import { timeRangeToUrl, downloadCSVOptions, generateLinkToChartOptions } from '../utils';
import { isSafeURL } from '~/lib/utils/url_utility';
const events = {
timeRangeZoom: 'timerangezoom',
......@@ -43,6 +45,7 @@ export default {
GlTooltip,
GlDropdown,
GlDropdownItem,
GlDropdownDivider,
GlModal,
},
directives: {
......@@ -118,6 +121,9 @@ export default {
metricsSavedToDb(state, getters) {
return getters[`${this.namespace}/metricsSavedToDb`];
},
selectedDashboard(state, getters) {
return getters[`${this.namespace}/selectedDashboard`];
},
}),
title() {
return this.graphData?.title || '';
......@@ -266,6 +272,9 @@ export default {
this.$delete(this.allAlerts, alertPath);
}
},
safeUrl(url) {
return isSafeURL(url) ? url : '#';
},
},
panelTypes,
};
......@@ -305,14 +314,13 @@ export default {
<div class="d-flex align-items-center">
<gl-dropdown
v-gl-tooltip
toggle-class="btn btn-transparent border-0"
toggle-class="shadow-none border-0"
data-qa-selector="prometheus_widgets_dropdown"
right
no-caret
:title="__('More actions')"
>
<template slot="button-content">
<gl-icon name="ellipsis_v" class="text-secondary" />
<gl-icon name="ellipsis_v" class="dropdown-icon text-secondary" />
</template>
<gl-dropdown-item
v-if="expandBtnAvailable"
......@@ -363,6 +371,23 @@ export default {
>
{{ __('Alerts') }}
</gl-dropdown-item>
<template v-if="graphData.links.length">
<gl-dropdown-divider />
<gl-dropdown-item
v-for="(link, index) in graphData.links"
:key="index"
:href="safeUrl(link.url)"
class="text-break"
>{{ link.title }}</gl-dropdown-item
>
</template>
<template v-if="selectedDashboard && selectedDashboard.can_edit">
<gl-dropdown-divider />
<gl-dropdown-item ref="manageLinksItem" :href="selectedDashboard.project_blob_path">{{
s__('Metrics|Manage chart links')
}}</gl-dropdown-item>
</template>
</gl-dropdown>
</div>
</div>
......
......@@ -3,6 +3,7 @@ import createGqClient, { fetchPolicies } from '~/lib/graphql';
import { SUPPORTED_FORMATS } from '~/lib/utils/unit_format';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { NOT_IN_DB_PREFIX } from '../constants';
import { isSafeURL } from '~/lib/utils/url_utility';
export const gqClient = createGqClient(
{},
......@@ -137,6 +138,23 @@ const mapYAxisToViewModel = ({
};
};
/**
* Maps a link to its view model, expects an url and
* (optionally) a title.
*
* Unsafe URLs are ignored.
*
* @param {Object} Link
* @returns {Object} Link object with a `title` and `url`.
*
*/
const mapLinksToViewModel = ({ url = null, title = '' } = {}) => {
return {
title: title || String(url),
url: url && isSafeURL(url) ? String(url) : '#',
};
};
/**
* Maps a metrics panel to its view model
*
......@@ -152,6 +170,7 @@ const mapPanelToViewModel = ({
y_label,
y_axis = {},
metrics = [],
links = [],
max_value,
}) => {
// Both `x_axis.name` and `x_label` are supported for now
......@@ -171,6 +190,7 @@ const mapPanelToViewModel = ({
yAxis,
xAxis,
maxValue: max_value,
links: links.map(mapLinksToViewModel),
metrics: mapToMetricsViewModel(metrics, yAxis.name),
};
};
......
---
title: Allow user to add custom links to their metrics dashboard panels
merge_request: 32646
author:
type: added
......@@ -13735,6 +13735,9 @@ msgstr ""
msgid "Metrics|Link contains invalid chart information, please verify the link to see the expanded panel."
msgstr ""
msgid "Metrics|Manage chart links"
msgstr ""
msgid "Metrics|Max"
msgstr ""
......
......@@ -4,7 +4,7 @@ import AxiosMockAdapter from 'axios-mock-adapter';
import { setTestTimeout } from 'helpers/timeout';
import invalidUrl from '~/lib/utils/invalid_url';
import axios from '~/lib/utils/axios_utils';
import { GlDropdownItem } from '@gitlab/ui';
import { GlNewDropdownItem as GlDropdownItem } from '@gitlab/ui';
import AlertWidget from '~/monitoring/components/alert_widget.vue';
import DashboardPanel from '~/monitoring/components/dashboard_panel.vue';
......@@ -55,7 +55,9 @@ describe('Dashboard Panel', () => {
const findCopyLink = () => wrapper.find({ ref: 'copyChartLink' });
const findTimeChart = () => wrapper.find({ ref: 'timeSeriesChart' });
const findTitle = () => wrapper.find({ ref: 'graphTitle' });
const findContextualMenu = () => wrapper.find({ ref: 'contextualMenu' });
const findCtxMenu = () => wrapper.find({ ref: 'contextualMenu' });
const findMenuItems = () => wrapper.findAll(GlDropdownItem);
const findMenuItemByText = text => findMenuItems().filter(i => i.text() === text);
const createWrapper = (props, options) => {
wrapper = shallowMount(DashboardPanel, {
......@@ -70,6 +72,15 @@ describe('Dashboard Panel', () => {
});
};
const mockGetterReturnValue = (getter, value) => {
jest.spyOn(monitoringDashboard.getters, getter).mockReturnValue(value);
store = new Vuex.Store({
modules: {
monitoringDashboard,
},
});
};
beforeEach(() => {
setTestTimeout(1000);
......@@ -119,7 +130,7 @@ describe('Dashboard Panel', () => {
});
it('does not contain graph widgets', () => {
expect(findContextualMenu().exists()).toBe(false);
expect(findCtxMenu().exists()).toBe(false);
});
it('The Empty Chart component is rendered and is a Vue instance', () => {
......@@ -152,7 +163,7 @@ describe('Dashboard Panel', () => {
});
it('does not contain graph widgets', () => {
expect(findContextualMenu().exists()).toBe(false);
expect(findCtxMenu().exists()).toBe(false);
});
it('The Empty Chart component is rendered and is a Vue instance', () => {
......@@ -175,7 +186,7 @@ describe('Dashboard Panel', () => {
});
it('contains graph widgets', () => {
expect(findContextualMenu().exists()).toBe(true);
expect(findCtxMenu().exists()).toBe(true);
expect(wrapper.find({ ref: 'downloadCsvLink' }).exists()).toBe(true);
});
......@@ -371,7 +382,7 @@ describe('Dashboard Panel', () => {
});
});
describe('when cliboard data is available', () => {
describe('when clipboard data is available', () => {
const clipboardText = 'A value to copy.';
beforeEach(() => {
......@@ -396,7 +407,7 @@ describe('Dashboard Panel', () => {
});
});
describe('when cliboard data is not available', () => {
describe('when clipboard data is not available', () => {
it('there is no "copy to clipboard" link for a null value', () => {
createWrapper({ clipboardText: null });
expect(findCopyLink().exists()).toBe(false);
......@@ -534,17 +545,9 @@ describe('Dashboard Panel', () => {
const setMetricsSavedToDb = val =>
monitoringDashboard.getters.metricsSavedToDb.mockReturnValue(val);
const findAlertsWidget = () => wrapper.find(AlertWidget);
const findMenuItemAlert = () =>
wrapper.findAll(GlDropdownItem).filter(i => i.text() === 'Alerts');
beforeEach(() => {
jest.spyOn(monitoringDashboard.getters, 'metricsSavedToDb').mockReturnValue([]);
store = new Vuex.Store({
modules: {
monitoringDashboard,
},
});
mockGetterReturnValue('metricsSavedToDb', []);
createWrapper();
});
......@@ -573,8 +576,99 @@ describe('Dashboard Panel', () => {
});
it(`${showsDesc} alert configuration`, () => {
expect(findMenuItemAlert().exists()).toBe(isShown);
expect(findMenuItemByText('Alerts').exists()).toBe(isShown);
});
});
});
describe('When graphData contains links', () => {
const findManageLinksItem = () => wrapper.find({ ref: 'manageLinksItem' });
const mockLinks = [
{
url: 'https://example.com',
title: 'Example 1',
},
{
url: 'https://gitlab.com',
title: 'Example 2',
},
];
const createWrapperWithLinks = (links = mockLinks) => {
createWrapper({
graphData: {
...graphData,
links,
},
});
};
it('custom links are shown', () => {
createWrapperWithLinks();
mockLinks.forEach(({ url, title }) => {
const link = findMenuItemByText(title).at(0);
expect(link.exists()).toBe(true);
expect(link.attributes('href')).toBe(url);
});
});
it("custom links don't show unsecure content", () => {
createWrapperWithLinks([
{
title: '<script>alert("XSS")</script>',
url: 'http://example.com',
},
]);
expect(findMenuItems().at(1).element.innerHTML).toBe(
'&lt;script&gt;alert("XSS")&lt;/script&gt;',
);
});
it("custom links don't show unsecure href attributes", () => {
const title = 'Owned!';
createWrapperWithLinks([
{
title,
// eslint-disable-next-line no-script-url
url: 'javascript:alert("Evil")',
},
]);
const link = findMenuItemByText(title).at(0);
expect(link.attributes('href')).toBe('#');
});
it('when an editable dashboard is selected, shows `Manage chart links` link to the blob path', () => {
const editUrl = '/edit';
mockGetterReturnValue('selectedDashboard', {
can_edit: true,
project_blob_path: editUrl,
});
createWrapperWithLinks();
expect(findManageLinksItem().exists()).toBe(true);
expect(findManageLinksItem().attributes('href')).toBe(editUrl);
});
it('when no dashboard is selected, does not show `Manage chart links`', () => {
mockGetterReturnValue('selectedDashboard', null);
createWrapperWithLinks();
expect(findManageLinksItem().exists()).toBe(false);
});
it('when non-editable dashboard is selected, does not show `Manage chart links`', () => {
const editUrl = '/edit';
mockGetterReturnValue('selectedDashboard', {
can_edit: false,
project_blob_path: editUrl,
});
createWrapperWithLinks();
expect(findManageLinksItem().exists()).toBe(false);
});
});
});
......@@ -63,6 +63,7 @@ describe('mapToDashboardViewModel', () => {
format: 'engineering',
precision: 2,
},
links: [],
metrics: [],
},
],
......@@ -147,6 +148,7 @@ describe('mapToDashboardViewModel', () => {
format: SUPPORTED_FORMATS.engineering,
precision: 2,
},
links: [],
metrics: [],
});
});
......@@ -170,6 +172,7 @@ describe('mapToDashboardViewModel', () => {
format: SUPPORTED_FORMATS.engineering,
precision: 2,
},
links: [],
metrics: [],
});
});
......@@ -238,6 +241,77 @@ describe('mapToDashboardViewModel', () => {
expect(getMappedPanel().maxValue).toBe(100);
});
describe('panel with links', () => {
const title = 'Example';
const url = 'https://example.com';
it('maps an empty link collection', () => {
setupWithPanel({
links: undefined,
});
expect(getMappedPanel().links).toEqual([]);
});
it('maps a link', () => {
setupWithPanel({ links: [{ title, url }] });
expect(getMappedPanel().links).toEqual([{ title, url }]);
});
it('maps a link without a title', () => {
setupWithPanel({
links: [{ url }],
});
expect(getMappedPanel().links).toEqual([{ title: url, url }]);
});
it('maps a link without a url', () => {
setupWithPanel({
links: [{ title }],
});
expect(getMappedPanel().links).toEqual([{ title, url: '#' }]);
});
it('maps a link without a url or title', () => {
setupWithPanel({
links: [{}],
});
expect(getMappedPanel().links).toEqual([{ title: 'null', url: '#' }]);
});
it('maps a link with an unsafe url safely', () => {
// eslint-disable-next-line no-script-url
const unsafeUrl = 'javascript:alert("XSS")';
setupWithPanel({
links: [
{
title,
url: unsafeUrl,
},
],
});
expect(getMappedPanel().links).toEqual([{ title, url: '#' }]);
});
it('maps multple links', () => {
setupWithPanel({
links: [{ title, url }, { url }, { title }],
});
expect(getMappedPanel().links).toEqual([
{ title, url },
{ title: url, url },
{ title, url: '#' },
]);
});
});
});
describe('metrics mapping', () => {
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment