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 { ...@@ -6,8 +6,9 @@ import {
GlResizeObserverDirective, GlResizeObserverDirective,
GlIcon, GlIcon,
GlLoadingIcon, GlLoadingIcon,
GlDropdown, GlNewDropdown as GlDropdown,
GlDropdownItem, GlNewDropdownItem as GlDropdownItem,
GlNewDropdownDivider as GlDropdownDivider,
GlModal, GlModal,
GlModalDirective, GlModalDirective,
GlTooltip, GlTooltip,
...@@ -28,6 +29,7 @@ import MonitorStackedColumnChart from './charts/stacked_column.vue'; ...@@ -28,6 +29,7 @@ import MonitorStackedColumnChart from './charts/stacked_column.vue';
import TrackEventDirective from '~/vue_shared/directives/track_event'; import TrackEventDirective from '~/vue_shared/directives/track_event';
import AlertWidget from './alert_widget.vue'; import AlertWidget from './alert_widget.vue';
import { timeRangeToUrl, downloadCSVOptions, generateLinkToChartOptions } from '../utils'; import { timeRangeToUrl, downloadCSVOptions, generateLinkToChartOptions } from '../utils';
import { isSafeURL } from '~/lib/utils/url_utility';
const events = { const events = {
timeRangeZoom: 'timerangezoom', timeRangeZoom: 'timerangezoom',
...@@ -43,6 +45,7 @@ export default { ...@@ -43,6 +45,7 @@ export default {
GlTooltip, GlTooltip,
GlDropdown, GlDropdown,
GlDropdownItem, GlDropdownItem,
GlDropdownDivider,
GlModal, GlModal,
}, },
directives: { directives: {
...@@ -118,6 +121,9 @@ export default { ...@@ -118,6 +121,9 @@ export default {
metricsSavedToDb(state, getters) { metricsSavedToDb(state, getters) {
return getters[`${this.namespace}/metricsSavedToDb`]; return getters[`${this.namespace}/metricsSavedToDb`];
}, },
selectedDashboard(state, getters) {
return getters[`${this.namespace}/selectedDashboard`];
},
}), }),
title() { title() {
return this.graphData?.title || ''; return this.graphData?.title || '';
...@@ -266,6 +272,9 @@ export default { ...@@ -266,6 +272,9 @@ export default {
this.$delete(this.allAlerts, alertPath); this.$delete(this.allAlerts, alertPath);
} }
}, },
safeUrl(url) {
return isSafeURL(url) ? url : '#';
},
}, },
panelTypes, panelTypes,
}; };
...@@ -305,14 +314,13 @@ export default { ...@@ -305,14 +314,13 @@ export default {
<div class="d-flex align-items-center"> <div class="d-flex align-items-center">
<gl-dropdown <gl-dropdown
v-gl-tooltip v-gl-tooltip
toggle-class="btn btn-transparent border-0" toggle-class="shadow-none border-0"
data-qa-selector="prometheus_widgets_dropdown" data-qa-selector="prometheus_widgets_dropdown"
right right
no-caret
:title="__('More actions')" :title="__('More actions')"
> >
<template slot="button-content"> <template slot="button-content">
<gl-icon name="ellipsis_v" class="text-secondary" /> <gl-icon name="ellipsis_v" class="dropdown-icon text-secondary" />
</template> </template>
<gl-dropdown-item <gl-dropdown-item
v-if="expandBtnAvailable" v-if="expandBtnAvailable"
...@@ -363,6 +371,23 @@ export default { ...@@ -363,6 +371,23 @@ export default {
> >
{{ __('Alerts') }} {{ __('Alerts') }}
</gl-dropdown-item> </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> </gl-dropdown>
</div> </div>
</div> </div>
......
...@@ -3,6 +3,7 @@ import createGqClient, { fetchPolicies } from '~/lib/graphql'; ...@@ -3,6 +3,7 @@ import createGqClient, { fetchPolicies } from '~/lib/graphql';
import { SUPPORTED_FORMATS } from '~/lib/utils/unit_format'; import { SUPPORTED_FORMATS } from '~/lib/utils/unit_format';
import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { NOT_IN_DB_PREFIX } from '../constants'; import { NOT_IN_DB_PREFIX } from '../constants';
import { isSafeURL } from '~/lib/utils/url_utility';
export const gqClient = createGqClient( export const gqClient = createGqClient(
{}, {},
...@@ -137,6 +138,23 @@ const mapYAxisToViewModel = ({ ...@@ -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 * Maps a metrics panel to its view model
* *
...@@ -152,6 +170,7 @@ const mapPanelToViewModel = ({ ...@@ -152,6 +170,7 @@ const mapPanelToViewModel = ({
y_label, y_label,
y_axis = {}, y_axis = {},
metrics = [], metrics = [],
links = [],
max_value, max_value,
}) => { }) => {
// Both `x_axis.name` and `x_label` are supported for now // Both `x_axis.name` and `x_label` are supported for now
...@@ -171,6 +190,7 @@ const mapPanelToViewModel = ({ ...@@ -171,6 +190,7 @@ const mapPanelToViewModel = ({
yAxis, yAxis,
xAxis, xAxis,
maxValue: max_value, maxValue: max_value,
links: links.map(mapLinksToViewModel),
metrics: mapToMetricsViewModel(metrics, yAxis.name), 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 "" ...@@ -13735,6 +13735,9 @@ msgstr ""
msgid "Metrics|Link contains invalid chart information, please verify the link to see the expanded panel." msgid "Metrics|Link contains invalid chart information, please verify the link to see the expanded panel."
msgstr "" msgstr ""
msgid "Metrics|Manage chart links"
msgstr ""
msgid "Metrics|Max" msgid "Metrics|Max"
msgstr "" msgstr ""
......
...@@ -4,7 +4,7 @@ import AxiosMockAdapter from 'axios-mock-adapter'; ...@@ -4,7 +4,7 @@ import AxiosMockAdapter from 'axios-mock-adapter';
import { setTestTimeout } from 'helpers/timeout'; import { setTestTimeout } from 'helpers/timeout';
import invalidUrl from '~/lib/utils/invalid_url'; import invalidUrl from '~/lib/utils/invalid_url';
import axios from '~/lib/utils/axios_utils'; 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 AlertWidget from '~/monitoring/components/alert_widget.vue';
import DashboardPanel from '~/monitoring/components/dashboard_panel.vue'; import DashboardPanel from '~/monitoring/components/dashboard_panel.vue';
...@@ -55,7 +55,9 @@ describe('Dashboard Panel', () => { ...@@ -55,7 +55,9 @@ describe('Dashboard Panel', () => {
const findCopyLink = () => wrapper.find({ ref: 'copyChartLink' }); const findCopyLink = () => wrapper.find({ ref: 'copyChartLink' });
const findTimeChart = () => wrapper.find({ ref: 'timeSeriesChart' }); const findTimeChart = () => wrapper.find({ ref: 'timeSeriesChart' });
const findTitle = () => wrapper.find({ ref: 'graphTitle' }); 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) => { const createWrapper = (props, options) => {
wrapper = shallowMount(DashboardPanel, { wrapper = shallowMount(DashboardPanel, {
...@@ -70,6 +72,15 @@ describe('Dashboard Panel', () => { ...@@ -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(() => { beforeEach(() => {
setTestTimeout(1000); setTestTimeout(1000);
...@@ -119,7 +130,7 @@ describe('Dashboard Panel', () => { ...@@ -119,7 +130,7 @@ describe('Dashboard Panel', () => {
}); });
it('does not contain graph widgets', () => { 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', () => { it('The Empty Chart component is rendered and is a Vue instance', () => {
...@@ -152,7 +163,7 @@ describe('Dashboard Panel', () => { ...@@ -152,7 +163,7 @@ describe('Dashboard Panel', () => {
}); });
it('does not contain graph widgets', () => { 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', () => { it('The Empty Chart component is rendered and is a Vue instance', () => {
...@@ -175,7 +186,7 @@ describe('Dashboard Panel', () => { ...@@ -175,7 +186,7 @@ describe('Dashboard Panel', () => {
}); });
it('contains graph widgets', () => { it('contains graph widgets', () => {
expect(findContextualMenu().exists()).toBe(true); expect(findCtxMenu().exists()).toBe(true);
expect(wrapper.find({ ref: 'downloadCsvLink' }).exists()).toBe(true); expect(wrapper.find({ ref: 'downloadCsvLink' }).exists()).toBe(true);
}); });
...@@ -371,7 +382,7 @@ describe('Dashboard Panel', () => { ...@@ -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.'; const clipboardText = 'A value to copy.';
beforeEach(() => { beforeEach(() => {
...@@ -396,7 +407,7 @@ describe('Dashboard Panel', () => { ...@@ -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', () => { it('there is no "copy to clipboard" link for a null value', () => {
createWrapper({ clipboardText: null }); createWrapper({ clipboardText: null });
expect(findCopyLink().exists()).toBe(false); expect(findCopyLink().exists()).toBe(false);
...@@ -534,17 +545,9 @@ describe('Dashboard Panel', () => { ...@@ -534,17 +545,9 @@ describe('Dashboard Panel', () => {
const setMetricsSavedToDb = val => const setMetricsSavedToDb = val =>
monitoringDashboard.getters.metricsSavedToDb.mockReturnValue(val); monitoringDashboard.getters.metricsSavedToDb.mockReturnValue(val);
const findAlertsWidget = () => wrapper.find(AlertWidget); const findAlertsWidget = () => wrapper.find(AlertWidget);
const findMenuItemAlert = () =>
wrapper.findAll(GlDropdownItem).filter(i => i.text() === 'Alerts');
beforeEach(() => { beforeEach(() => {
jest.spyOn(monitoringDashboard.getters, 'metricsSavedToDb').mockReturnValue([]); mockGetterReturnValue('metricsSavedToDb', []);
store = new Vuex.Store({
modules: {
monitoringDashboard,
},
});
createWrapper(); createWrapper();
}); });
...@@ -573,8 +576,99 @@ describe('Dashboard Panel', () => { ...@@ -573,8 +576,99 @@ describe('Dashboard Panel', () => {
}); });
it(`${showsDesc} alert configuration`, () => { 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', () => { ...@@ -63,6 +63,7 @@ describe('mapToDashboardViewModel', () => {
format: 'engineering', format: 'engineering',
precision: 2, precision: 2,
}, },
links: [],
metrics: [], metrics: [],
}, },
], ],
...@@ -147,6 +148,7 @@ describe('mapToDashboardViewModel', () => { ...@@ -147,6 +148,7 @@ describe('mapToDashboardViewModel', () => {
format: SUPPORTED_FORMATS.engineering, format: SUPPORTED_FORMATS.engineering,
precision: 2, precision: 2,
}, },
links: [],
metrics: [], metrics: [],
}); });
}); });
...@@ -170,6 +172,7 @@ describe('mapToDashboardViewModel', () => { ...@@ -170,6 +172,7 @@ describe('mapToDashboardViewModel', () => {
format: SUPPORTED_FORMATS.engineering, format: SUPPORTED_FORMATS.engineering,
precision: 2, precision: 2,
}, },
links: [],
metrics: [], metrics: [],
}); });
}); });
...@@ -238,6 +241,77 @@ describe('mapToDashboardViewModel', () => { ...@@ -238,6 +241,77 @@ describe('mapToDashboardViewModel', () => {
expect(getMappedPanel().maxValue).toBe(100); 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', () => { 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