Commit fabb7dc3 authored by Nathan Friend's avatar Nathan Friend

Merge branch '215472-single-chart-from-url' into 'master'

Load expanded dashboard when certain URL parameters match the panel

Closes #215472

See merge request gitlab-org/gitlab!30476
parents 5a4fde47 34dcf767
<script>
import { debounce, pickBy } from 'lodash';
import { debounce } from 'lodash';
import { mapActions, mapState, mapGetters } from 'vuex';
import VueDraggable from 'vuedraggable';
import {
......@@ -32,7 +32,13 @@ import GroupEmptyState from './group_empty_state.vue';
import DashboardsDropdown from './dashboards_dropdown.vue';
import TrackEventDirective from '~/vue_shared/directives/track_event';
import { getAddMetricTrackingOptions, timeRangeToUrl, timeRangeFromUrl } from '../utils';
import {
getAddMetricTrackingOptions,
timeRangeToUrl,
timeRangeFromUrl,
panelToUrl,
expandedPanelPayloadFromUrl,
} from '../utils';
import { metricStates } from '../constants';
import { defaultTimeRange, timeRanges } from '~/vue_shared/constants';
......@@ -238,6 +244,23 @@ export default {
return !this.environmentsLoading && this.filteredEnvironments.length === 0;
},
},
watch: {
dashboard(newDashboard) {
try {
const expandedPanel = expandedPanelPayloadFromUrl(newDashboard);
if (expandedPanel) {
this.setExpandedPanel(expandedPanel);
}
} catch {
createFlash(
s__(
'Metrics|Link contains invalid chart information, please verify the link to see the expanded panel.',
),
);
}
},
},
created() {
this.setInitialState({
metricsEndpoint: this.metricsEndpoint,
......@@ -299,15 +322,9 @@ export default {
// As a fallback, switch to default time range instead
this.selectedTimeRange = defaultTimeRange;
},
generatePanelLink(group, graphData) {
if (!group || !graphData) {
return null;
}
const dashboard = this.currentDashboard || this.firstDashboard.path;
const { y_label, title } = graphData;
const params = pickBy({ dashboard, group, title, y_label }, value => value != null);
return mergeUrlParams(params, window.location.href);
generatePanelUrl(groupKey, panel) {
const dashboardPath = this.currentDashboard || this.firstDashboard.path;
return panelToUrl(dashboardPath, groupKey, panel);
},
hideAddMetricModal() {
this.$refs.addMetricModal.hide();
......@@ -564,7 +581,7 @@ export default {
v-show="expandedPanel.panel"
ref="expandedPanel"
:settings-path="settingsPath"
:clipboard-text="generatePanelLink(expandedPanel.group, expandedPanel.panel)"
:clipboard-text="generatePanelUrl(expandedPanel.group, expandedPanel.panel)"
:graph-data="expandedPanel.panel"
:alerts-endpoint="alertsEndpoint"
:height="600"
......@@ -623,7 +640,7 @@ export default {
<dashboard-panel
:settings-path="settingsPath"
:clipboard-text="generatePanelLink(groupData.group, graphData)"
:clipboard-text="generatePanelUrl(groupData.group, graphData)"
:graph-data="graphData"
:alerts-endpoint="alertsEndpoint"
:prometheus-alerts-available="prometheusAlertsAvailable"
......
......@@ -102,6 +102,13 @@ export const clearExpandedPanel = ({ commit }) => {
// All Data
/**
* Fetch all dashboard data.
*
* @param {Object} store
* @returns A promise that resolves when the dashboard
* skeleton has been loaded.
*/
export const fetchData = ({ dispatch }) => {
dispatch('fetchEnvironmentsData');
dispatch('fetchDashboard');
......
import { pickBy } from 'lodash';
import { queryToObject, mergeUrlParams, removeParams } from '~/lib/utils/url_utility';
import {
timeRangeParamNames,
......@@ -28,7 +29,6 @@ export const graphDataValidatorForValues = (isValues, graphData) => {
);
};
/* eslint-disable @gitlab/require-i18n-strings */
/**
* Checks that element that triggered event is located on cluster health check dashboard
* @param {HTMLElement} element to check against
......@@ -36,6 +36,7 @@ export const graphDataValidatorForValues = (isValues, graphData) => {
*/
const isClusterHealthBoard = () => (document.body.dataset.page || '').includes(':clusters:show');
/* eslint-disable @gitlab/require-i18n-strings */
/**
* Tracks snowplow event when user generates link to metric chart
* @param {String} chart link that will be sent as a property for the event
......@@ -71,6 +72,7 @@ export const downloadCSVOptions = title => {
return { category, action, label: 'Chart title', property: title };
};
/* eslint-enable @gitlab/require-i18n-strings */
/**
* Generate options for snowplow to track adding a new metric via the dashboard
......@@ -132,6 +134,68 @@ export const timeRangeToUrl = (timeRange, url = window.location.href) => {
return mergeUrlParams(params, toUrl);
};
/**
* Locates a panel (and its corresponding group) given a (URL) search query. Returns
* it as payload for the store to set the right expandaded panel.
*
* Params used to locate a panel are:
* - group: Group identifier
* - title: Panel title
* - y_label: Panel y_label
*
* @param {Object} dashboard - Dashboard reference from the Vuex store
* @param {String} search - URL location search query
* @returns {Object} payload - Payload for expanded panel to be displayed
* @returns {String} payload.group - Group where panel is located
* @returns {Object} payload.panel - Dashboard panel (graphData) reference
* @throws Will throw an error if Panel cannot be located.
*/
export const expandedPanelPayloadFromUrl = (dashboard, search = window.location.search) => {
const params = queryToObject(search);
// Search for the panel if any of the search params is identified
if (params.group || params.title || params.y_label) {
const panelGroup = dashboard.panelGroups.find(({ group }) => params.group === group);
const panel = panelGroup.panels.find(
// eslint-disable-next-line babel/camelcase
({ y_label, title }) => y_label === params.y_label && title === params.title,
);
if (!panel) {
// eslint-disable-next-line @gitlab/require-i18n-strings
throw new Error('Panel could no found by URL parameters.');
}
return { group: panelGroup.group, panel };
}
return null;
};
/**
* Convert panel information to a URL for the user to
* bookmark or share highlighting a specific panel.
*
* @param {String} dashboardPath - Dashboard path used as identifier
* @param {String} group - Group Identifier
* @param {?Object} panel - Panel object from the dashboard
* @param {?String} url - Base URL including current search params
* @returns Dashboard URL which expands a panel (chart)
*/
export const panelToUrl = (dashboardPath, group, panel, url = window.location.href) => {
if (!group || !panel) {
return null;
}
const params = pickBy(
{
dashboard: dashboardPath,
group,
title: panel.title,
y_label: panel.y_label,
},
value => value != null,
);
return mergeUrlParams(params, url);
};
/**
* Get the metric value from first data point.
* Currently only used for bar charts
......
---
title: Display expanded dashboard from a panel's "Link to chart" URL
merge_request: 30476
author:
type: added
......@@ -13326,6 +13326,9 @@ msgstr ""
msgid "Metrics|Link contains an invalid time window, please verify the link to see the requested time range."
msgstr ""
msgid "Metrics|Link contains invalid chart information, please verify the link to see the expanded panel."
msgstr ""
msgid "Metrics|Max"
msgstr ""
......
......@@ -376,10 +376,6 @@ describe('Dashboard Panel', () => {
});
});
afterEach(() => {
wrapper.destroy();
});
it('sets clipboard text on the dropdown', () => {
expect(findCopyLink().exists()).toBe(true);
expect(findCopyLink().element.dataset.clipboardText).toBe(clipboardText);
......@@ -396,6 +392,18 @@ describe('Dashboard Panel', () => {
});
});
describe('when cliboard data is not available', () => {
it('there is no "copy to clipboard" link for a null value', () => {
createWrapper({ clipboardText: null });
expect(findCopyLink().exists()).toBe(false);
});
it('there is no "copy to clipboard" link for an empty value', () => {
createWrapper({ clipboardText: '' });
expect(findCopyLink().exists()).toBe(false);
});
});
describe('when downloading metrics data as CSV', () => {
beforeEach(() => {
wrapper = shallowMount(DashboardPanel, {
......
......@@ -2,6 +2,7 @@ import { shallowMount, mount } from '@vue/test-utils';
import Tracking from '~/tracking';
import { ESC_KEY, ESC_KEY_IE11 } from '~/lib/utils/keys';
import { GlModal, GlDropdownItem, GlDeprecatedButton } from '@gitlab/ui';
import { objectToQuery } from '~/lib/utils/url_utility';
import VueDraggable from 'vuedraggable';
import MockAdapter from 'axios-mock-adapter';
import axios from '~/lib/utils/axios_utils';
......@@ -20,6 +21,9 @@ import * as types from '~/monitoring/stores/mutation_types';
import { setupStoreWithDashboard, setMetricResult, setupStoreWithData } from '../store_utils';
import { environmentData, dashboardGitResponse, propsData } from '../mock_data';
import { metricsDashboardViewModel, metricsDashboardPanelCount } from '../fixture_data';
import createFlash from '~/flash';
jest.mock('~/flash');
describe('Dashboard', () => {
let store;
......@@ -64,7 +68,6 @@ describe('Dashboard', () => {
describe('no metrics are available yet', () => {
beforeEach(() => {
jest.spyOn(store, 'dispatch');
createShallowWrapper();
});
......@@ -150,6 +153,87 @@ describe('Dashboard', () => {
});
});
describe('when the URL contains a reference to a panel', () => {
let location;
const setSearch = search => {
window.location = { ...location, search };
};
beforeEach(() => {
location = window.location;
delete window.location;
});
afterEach(() => {
window.location = location;
});
it('when the URL points to a panel it expands', () => {
const panelGroup = metricsDashboardViewModel.panelGroups[0];
const panel = panelGroup.panels[0];
setSearch(
objectToQuery({
group: panelGroup.group,
title: panel.title,
y_label: panel.y_label,
}),
);
createMountedWrapper({ hasMetrics: true });
setupStoreWithData(wrapper.vm.$store);
return wrapper.vm.$nextTick().then(() => {
expect(store.dispatch).toHaveBeenCalledWith('monitoringDashboard/setExpandedPanel', {
group: panelGroup.group,
panel: expect.objectContaining({
title: panel.title,
y_label: panel.y_label,
}),
});
});
});
it('when the URL does not link to any panel, no panel is expanded', () => {
setSearch('');
createMountedWrapper({ hasMetrics: true });
setupStoreWithData(wrapper.vm.$store);
return wrapper.vm.$nextTick().then(() => {
expect(store.dispatch).not.toHaveBeenCalledWith(
'monitoringDashboard/setExpandedPanel',
expect.anything(),
);
});
});
it('when the URL points to an incorrect panel it shows an error', () => {
const panelGroup = metricsDashboardViewModel.panelGroups[0];
const panel = panelGroup.panels[0];
setSearch(
objectToQuery({
group: panelGroup.group,
title: 'incorrect',
y_label: panel.y_label,
}),
);
createMountedWrapper({ hasMetrics: true });
setupStoreWithData(wrapper.vm.$store);
return wrapper.vm.$nextTick().then(() => {
expect(createFlash).toHaveBeenCalled();
expect(store.dispatch).not.toHaveBeenCalledWith(
'monitoringDashboard/setExpandedPanel',
expect.anything(),
);
});
});
});
describe('when all requests have been commited by the store', () => {
beforeEach(() => {
createMountedWrapper({ hasMetrics: true });
......
......@@ -11,6 +11,7 @@ import { ENVIRONMENT_AVAILABLE_STATE } from '~/monitoring/constants';
import store from '~/monitoring/stores';
import * as types from '~/monitoring/stores/mutation_types';
import {
fetchData,
fetchDashboard,
receiveMetricsDashboardSuccess,
fetchDeploymentsData,
......@@ -86,6 +87,41 @@ describe('Monitoring store actions', () => {
createFlash.mockReset();
});
describe('fetchData', () => {
it('dispatches fetchEnvironmentsData and fetchEnvironmentsData', () => {
const { state } = store;
return testAction(
fetchData,
null,
state,
[],
[{ type: 'fetchEnvironmentsData' }, { type: 'fetchDashboard' }],
);
});
it('dispatches when feature metricsDashboardAnnotations is on', () => {
const origGon = window.gon;
window.gon = { features: { metricsDashboardAnnotations: true } };
const { state } = store;
return testAction(
fetchData,
null,
state,
[],
[
{ type: 'fetchEnvironmentsData' },
{ type: 'fetchDashboard' },
{ type: 'fetchAnnotations' },
],
).then(() => {
window.gon = origGon;
});
});
});
describe('fetchDeploymentsData', () => {
it('dispatches receiveDeploymentsDataSuccess on success', () => {
const { state } = store;
......
import * as monitoringUtils from '~/monitoring/utils';
import { queryToObject, mergeUrlParams, removeParams } from '~/lib/utils/url_utility';
import * as urlUtils from '~/lib/utils/url_utility';
import { TEST_HOST } from 'jest/helpers/test_constants';
import {
mockProjectDir,
......@@ -7,9 +7,7 @@ import {
anomalyMockGraphData,
barMockData,
} from './mock_data';
import { graphData } from './fixture_data';
jest.mock('~/lib/utils/url_utility');
import { metricsDashboardViewModel, graphData } from './fixture_data';
const mockPath = `${TEST_HOST}${mockProjectDir}/-/environments/29/metrics`;
......@@ -27,11 +25,6 @@ const rollingRange = {
};
describe('monitoring/utils', () => {
afterEach(() => {
mergeUrlParams.mockReset();
queryToObject.mockReset();
});
describe('trackGenerateLinkToChartEventOptions', () => {
it('should return Cluster Monitoring options if located on Cluster Health Dashboard', () => {
document.body.dataset.page = 'groups:clusters:show';
......@@ -139,18 +132,25 @@ describe('monitoring/utils', () => {
});
describe('timeRangeFromUrl', () => {
const { timeRangeFromUrl } = monitoringUtils;
beforeEach(() => {
jest.spyOn(urlUtils, 'queryToObject');
});
it('returns a fixed range when query contains `start` and `end` paramters are given', () => {
queryToObject.mockReturnValueOnce(range);
afterEach(() => {
urlUtils.queryToObject.mockRestore();
});
const { timeRangeFromUrl } = monitoringUtils;
it('returns a fixed range when query contains `start` and `end` parameters are given', () => {
urlUtils.queryToObject.mockReturnValueOnce(range);
expect(timeRangeFromUrl()).toEqual(range);
});
it('returns a rolling range when query contains `duration_seconds` paramters are given', () => {
it('returns a rolling range when query contains `duration_seconds` parameters are given', () => {
const { seconds } = rollingRange.duration;
queryToObject.mockReturnValueOnce({
urlUtils.queryToObject.mockReturnValueOnce({
dashboard: '.gitlab/dashboard/my_dashboard.yml',
duration_seconds: `${seconds}`,
});
......@@ -158,23 +158,21 @@ describe('monitoring/utils', () => {
expect(timeRangeFromUrl()).toEqual(rollingRange);
});
it('returns null when no time range paramters are given', () => {
const params = {
it('returns null when no time range parameters are given', () => {
urlUtils.queryToObject.mockReturnValueOnce({
dashboard: '.gitlab/dashboards/custom_dashboard.yml',
param1: 'value1',
param2: 'value2',
};
});
expect(timeRangeFromUrl(params, mockPath)).toBe(null);
expect(timeRangeFromUrl()).toBe(null);
});
});
describe('removeTimeRangeParams', () => {
const { removeTimeRangeParams } = monitoringUtils;
it('returns when query contains `start` and `end` paramters are given', () => {
removeParams.mockReturnValueOnce(mockPath);
it('returns when query contains `start` and `end` parameters are given', () => {
expect(removeTimeRangeParams(`${mockPath}?start=${range.start}&end=${range.end}`)).toEqual(
mockPath,
);
......@@ -184,28 +182,116 @@ describe('monitoring/utils', () => {
describe('timeRangeToUrl', () => {
const { timeRangeToUrl } = monitoringUtils;
it('returns a fixed range when query contains `start` and `end` paramters are given', () => {
beforeEach(() => {
jest.spyOn(urlUtils, 'mergeUrlParams');
jest.spyOn(urlUtils, 'removeParams');
});
afterEach(() => {
urlUtils.mergeUrlParams.mockRestore();
urlUtils.removeParams.mockRestore();
});
it('returns a fixed range when query contains `start` and `end` parameters are given', () => {
const toUrl = `${mockPath}?start=${range.start}&end=${range.end}`;
const fromUrl = mockPath;
removeParams.mockReturnValueOnce(fromUrl);
mergeUrlParams.mockReturnValueOnce(toUrl);
urlUtils.removeParams.mockReturnValueOnce(fromUrl);
urlUtils.mergeUrlParams.mockReturnValueOnce(toUrl);
expect(timeRangeToUrl(range)).toEqual(toUrl);
expect(mergeUrlParams).toHaveBeenCalledWith(range, fromUrl);
expect(urlUtils.mergeUrlParams).toHaveBeenCalledWith(range, fromUrl);
});
it('returns a rolling range when query contains `duration_seconds` paramters are given', () => {
it('returns a rolling range when query contains `duration_seconds` parameters are given', () => {
const { seconds } = rollingRange.duration;
const toUrl = `${mockPath}?duration_seconds=${seconds}`;
const fromUrl = mockPath;
removeParams.mockReturnValueOnce(fromUrl);
mergeUrlParams.mockReturnValueOnce(toUrl);
urlUtils.removeParams.mockReturnValueOnce(fromUrl);
urlUtils.mergeUrlParams.mockReturnValueOnce(toUrl);
expect(timeRangeToUrl(rollingRange)).toEqual(toUrl);
expect(mergeUrlParams).toHaveBeenCalledWith({ duration_seconds: `${seconds}` }, fromUrl);
expect(urlUtils.mergeUrlParams).toHaveBeenCalledWith(
{ duration_seconds: `${seconds}` },
fromUrl,
);
});
});
describe('expandedPanelPayloadFromUrl', () => {
const { expandedPanelPayloadFromUrl } = monitoringUtils;
const [panelGroup] = metricsDashboardViewModel.panelGroups;
const [panel] = panelGroup.panels;
const { group } = panelGroup;
const { title, y_label: yLabel } = panel;
it('returns payload for a panel when query parameters are given', () => {
const search = `?group=${group}&title=${title}&y_label=${yLabel}`;
expect(expandedPanelPayloadFromUrl(metricsDashboardViewModel, search)).toEqual({
group: panelGroup.group,
panel,
});
});
it('returns null when no parameters are given', () => {
expect(expandedPanelPayloadFromUrl(metricsDashboardViewModel, '')).toBe(null);
});
it('throws an error when no group is provided', () => {
const search = `?title=${panel.title}&y_label=${yLabel}`;
expect(() => expandedPanelPayloadFromUrl(metricsDashboardViewModel, search)).toThrow();
});
it('throws an error when no title is provided', () => {
const search = `?title=${title}&y_label=${yLabel}`;
expect(() => expandedPanelPayloadFromUrl(metricsDashboardViewModel, search)).toThrow();
});
it('throws an error when no y_label group is provided', () => {
const search = `?group=${group}&title=${title}`;
expect(() => expandedPanelPayloadFromUrl(metricsDashboardViewModel, search)).toThrow();
});
test.each`
group | title | yLabel | missingField
${'NOT_A_GROUP'} | ${title} | ${yLabel} | ${'group'}
${group} | ${'NOT_A_TITLE'} | ${yLabel} | ${'title'}
${group} | ${title} | ${'NOT_A_Y_LABEL'} | ${'y_label'}
`('throws an error when $missingField is incorrect', params => {
const search = `?group=${params.group}&title=${params.title}&y_label=${params.yLabel}`;
expect(() => expandedPanelPayloadFromUrl(metricsDashboardViewModel, search)).toThrow();
});
});
describe('panelToUrl', () => {
const { panelToUrl } = monitoringUtils;
const dashboard = 'metrics.yml';
const [panelGroup] = metricsDashboardViewModel.panelGroups;
const [panel] = panelGroup.panels;
it('returns URL for a panel when query parameters are given', () => {
const [, query] = panelToUrl(dashboard, panelGroup.group, panel).split('?');
const params = urlUtils.queryToObject(query);
expect(params).toEqual({
dashboard,
group: panelGroup.group,
title: panel.title,
y_label: panel.y_label,
});
});
it('returns `null` if group is missing', () => {
expect(panelToUrl(dashboard, null, panel)).toBe(null);
});
it('returns `null` if panel is missing', () => {
expect(panelToUrl(dashboard, panelGroup.group, null)).toBe(null);
});
});
......
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