Commit f5e603b6 authored by Kushal Pandya's avatar Kushal Pandya

Merge branch '215497-add-custom-links-to-panel' into 'master'

Add context menu links to metrics dashboard panel

Closes #215497

See merge request gitlab-org/gitlab!32646
parents 501b2356 66630d73
......@@ -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