Commit 814f76e5 authored by Andrew Fontaine's avatar Andrew Fontaine

Merge branch '214882-render-single-panel' into 'master'

Add action to view a detailed version of a panel

Closes #214882

See merge request gitlab-org/gitlab!29902
parents 502f5265 31e85bbd
......@@ -3,6 +3,8 @@ import { debounce, pickBy } from 'lodash';
import { mapActions, mapState, mapGetters } from 'vuex';
import VueDraggable from 'vuedraggable';
import {
GlIcon,
GlButton,
GlDeprecatedButton,
GlDropdown,
GlDropdownItem,
......@@ -17,7 +19,6 @@ import {
import DashboardPanel from './dashboard_panel.vue';
import { s__ } from '~/locale';
import createFlash from '~/flash';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import CustomMetricsFormFields from '~/custom_metrics/components/custom_metrics_form_fields.vue';
import { mergeUrlParams, redirectTo, updateHistory } from '~/lib/utils/url_utility';
import invalidUrl from '~/lib/utils/invalid_url';
......@@ -39,6 +40,8 @@ export default {
VueDraggable,
DashboardPanel,
Icon,
GlIcon,
GlButton,
GlDeprecatedButton,
GlDropdown,
GlLoadingIcon,
......@@ -60,7 +63,6 @@ export default {
GlTooltip: GlTooltipDirective,
TrackEvent: TrackEventDirective,
},
mixins: [glFeatureFlagsMixin()],
props: {
externalDashboardUrl: {
type: String,
......@@ -197,7 +199,6 @@ export default {
},
data() {
return {
state: 'gettingStarted',
formIsValid: null,
selectedTimeRange: timeRangeFromUrl() || defaultTimeRange,
hasValidDates: true,
......@@ -212,8 +213,8 @@ export default {
'showEmptyState',
'useDashboardEndpoint',
'allDashboards',
'additionalPanelTypesEnabled',
'environmentsLoading',
'expandedPanel',
]),
...mapGetters('monitoringDashboard', ['getMetricStates', 'filteredEnvironments']),
firstDashboard() {
......@@ -232,14 +233,6 @@ export default {
this.firstDashboard === this.selectedDashboard
);
},
hasHeaderButtons() {
return (
this.addingMetricsAvailable ||
this.showRearrangePanelsBtn ||
this.selectedDashboard.can_edit ||
this.externalDashboardUrl.length
);
},
shouldShowEnvironmentsDropdownNoMatchedMsg() {
return !this.environmentsLoading && this.filteredEnvironments.length === 0;
},
......@@ -273,6 +266,8 @@ export default {
'setInitialState',
'setPanelGroupMetrics',
'filterEnvironments',
'setExpandedPanel',
'clearExpandedPanel',
]),
updatePanels(key, panels) {
this.setPanelGroupMetrics({
......@@ -300,9 +295,13 @@ export default {
this.selectedTimeRange = defaultTimeRange;
},
generateLink(group, title, yLabel) {
generatePanelLink(group, graphData) {
if (!group || !graphData) {
return null;
}
const dashboard = this.currentDashboard || this.firstDashboard.path;
const params = pickBy({ dashboard, group, title, y_label: yLabel }, value => value != null);
const { y_label, title } = graphData;
const params = pickBy({ dashboard, group, title, y_label }, value => value != null);
return mergeUrlParams(params, window.location.href);
},
hideAddMetricModal() {
......@@ -366,11 +365,20 @@ export default {
});
this.selectedTimeRange = { start, end };
},
onExpandPanel(group, panel) {
this.setExpandedPanel({ group, panel });
},
onGoBack() {
this.clearExpandedPanel();
},
},
addMetric: {
title: s__('Metrics|Add metric'),
modalId: 'add-metric',
},
i18n: {
goBackLabel: s__('Metrics|Go back'),
},
};
</script>
......@@ -541,59 +549,88 @@ export default {
</div>
<div v-if="!showEmptyState">
<graph-group
v-for="(groupData, index) in dashboard.panelGroups"
:key="`${groupData.group}.${groupData.priority}`"
:name="groupData.group"
:show-panels="showPanels"
:collapse-group="collapseGroup(groupData.key)"
<dashboard-panel
v-show="expandedPanel.panel"
ref="expandedPanel"
:clipboard-text="generatePanelLink(expandedPanel.group, expandedPanel.panel)"
:graph-data="expandedPanel.panel"
:alerts-endpoint="alertsEndpoint"
:height="600"
:prometheus-alerts-available="prometheusAlertsAvailable"
@timerangezoom="onTimeRangeZoom"
>
<vue-draggable
v-if="!groupSingleEmptyState(groupData.key)"
:value="groupData.panels"
group="metrics-dashboard"
:component-data="{ attrs: { class: 'row mx-0 w-100' } }"
:disabled="!isRearrangingPanels"
@input="updatePanels(groupData.key, $event)"
<template #topLeft>
<gl-button
ref="goBackBtn"
v-gl-tooltip
class="mr-3 my-3"
:title="$options.i18n.goBackLabel"
@click="onGoBack"
>
<gl-icon
name="arrow-left"
:aria-label="$options.i18n.goBackLabel"
class="text-secondary"
/>
</gl-button>
</template>
</dashboard-panel>
<div v-show="!expandedPanel.panel">
<graph-group
v-for="groupData in dashboard.panelGroups"
:key="`${groupData.group}.${groupData.priority}`"
:name="groupData.group"
:show-panels="showPanels"
:collapse-group="collapseGroup(groupData.key)"
>
<div
v-for="(graphData, graphIndex) in groupData.panels"
:key="`dashboard-panel-${graphIndex}`"
class="col-12 col-lg-6 px-2 mb-2 draggable"
:class="{ 'draggable-enabled': isRearrangingPanels }"
<vue-draggable
v-if="!groupSingleEmptyState(groupData.key)"
:value="groupData.panels"
group="metrics-dashboard"
:component-data="{ attrs: { class: 'row mx-0 w-100' } }"
:disabled="!isRearrangingPanels"
@input="updatePanels(groupData.key, $event)"
>
<div class="position-relative draggable-panel js-draggable-panel">
<div
v-if="isRearrangingPanels"
class="draggable-remove js-draggable-remove p-2 w-100 position-absolute d-flex justify-content-end"
@click="removePanel(groupData.key, groupData.panels, graphIndex)"
>
<a class="mx-2 p-2 draggable-remove-link" :aria-label="__('Remove')">
<icon name="close" />
</a>
</div>
<div
v-for="(graphData, graphIndex) in groupData.panels"
:key="`dashboard-panel-${graphIndex}`"
class="col-12 col-lg-6 px-2 mb-2 draggable"
:class="{ 'draggable-enabled': isRearrangingPanels }"
>
<div class="position-relative draggable-panel js-draggable-panel">
<div
v-if="isRearrangingPanels"
class="draggable-remove js-draggable-remove p-2 w-100 position-absolute d-flex justify-content-end"
@click="removePanel(groupData.key, groupData.panels, graphIndex)"
>
<a class="mx-2 p-2 draggable-remove-link" :aria-label="__('Remove')">
<icon name="close" />
</a>
</div>
<dashboard-panel
:clipboard-text="generateLink(groupData.group, graphData.title, graphData.y_label)"
:graph-data="graphData"
:alerts-endpoint="alertsEndpoint"
:prometheus-alerts-available="prometheusAlertsAvailable"
:index="`${index}-${graphIndex}`"
@timerangezoom="onTimeRangeZoom"
/>
<dashboard-panel
:clipboard-text="generatePanelLink(groupData.group, graphData)"
:graph-data="graphData"
:alerts-endpoint="alertsEndpoint"
:prometheus-alerts-available="prometheusAlertsAvailable"
@timerangezoom="onTimeRangeZoom"
@expand="onExpandPanel(groupData.group, graphData)"
/>
</div>
</div>
</vue-draggable>
<div v-else class="py-5 col col-sm-10 col-md-8 col-lg-7 col-xl-6">
<group-empty-state
ref="empty-group"
:documentation-path="documentationPath"
:settings-path="settingsPath"
:selected-state="groupSingleEmptyState(groupData.key)"
:svg-path="emptyNoDataSmallSvgPath"
/>
</div>
</vue-draggable>
<div v-else class="py-5 col col-sm-10 col-md-8 col-lg-7 col-xl-6">
<group-empty-state
ref="empty-group"
:documentation-path="documentationPath"
:settings-path="settingsPath"
:selected-state="groupSingleEmptyState(groupData.key)"
:svg-path="emptyNoDataSmallSvgPath"
/>
</div>
</graph-group>
</graph-group>
</div>
</div>
<empty-state
v-else
......
......@@ -59,7 +59,8 @@ export default {
},
graphData: {
type: Object,
required: true,
required: false,
default: null,
},
groupId: {
type: String,
......@@ -114,17 +115,13 @@ export default {
},
}),
title() {
return this.graphData.title || '';
return this.graphData?.title || '';
},
graphDataHasResult() {
return (
this.graphData.metrics &&
this.graphData.metrics[0].result &&
this.graphData.metrics[0].result.length > 0
);
return this.graphData?.metrics?.[0]?.result?.length > 0;
},
graphDataIsLoading() {
const { metrics = [] } = this.graphData;
const metrics = this.graphData?.metrics || [];
return metrics.some(({ loading }) => loading);
},
logsPathWithTimeRange() {
......@@ -136,7 +133,7 @@ export default {
return null;
},
csvText() {
const chartData = this.graphData.metrics[0].result[0].values;
const chartData = this.graphData?.metrics[0].result[0].values || [];
const yLabel = this.graphData.y_label;
const header = `timestamp,${yLabel}\r\n`; // eslint-disable-line @gitlab/require-i18n-strings
return chartData.reduce((csv, data) => {
......@@ -230,7 +227,7 @@ export default {
return Object.values(this.getGraphAlerts(queries));
},
isPanelType(type) {
return this.graphData.type && this.graphData.type === type;
return this.graphData?.type === type;
},
showToast() {
this.$toast.show(__('Link copied'));
......
......@@ -89,6 +89,17 @@ export const setShowErrorBanner = ({ commit }, enabled) => {
commit(types.SET_SHOW_ERROR_BANNER, enabled);
};
export const setExpandedPanel = ({ commit }, { group, panel }) => {
commit(types.SET_EXPANDED_PANEL, { group, panel });
};
export const clearExpandedPanel = ({ commit }) => {
commit(types.SET_EXPANDED_PANEL, {
group: null,
panel: null,
});
};
// All Data
export const fetchData = ({ dispatch }) => {
......
......@@ -31,5 +31,5 @@ export const SET_GETTING_STARTED_EMPTY_STATE = 'SET_GETTING_STARTED_EMPTY_STATE'
export const SET_NO_DATA_EMPTY_STATE = 'SET_NO_DATA_EMPTY_STATE';
export const SET_SHOW_ERROR_BANNER = 'SET_SHOW_ERROR_BANNER';
export const SET_PANEL_GROUP_METRICS = 'SET_PANEL_GROUP_METRICS';
export const SET_ENVIRONMENTS_FILTER = 'SET_ENVIRONMENTS_FILTER';
export const SET_EXPANDED_PANEL = 'SET_EXPANDED_PANEL';
......@@ -134,6 +134,8 @@ export default {
metric.loading = false;
metric.result = null;
},
// Parameters and other information
[types.SET_INITIAL_STATE](state, initialState = {}) {
Object.assign(state, pick(initialState, initialStateKeys));
},
......@@ -163,4 +165,8 @@ export default {
[types.SET_ENVIRONMENTS_FILTER](state, searchTerm) {
state.environmentsSearchTerm = searchTerm;
},
[types.SET_EXPANDED_PANEL](state, { group, panel }) {
state.expandedPanel.group = group;
state.expandedPanel.panel = panel;
},
};
......@@ -17,6 +17,21 @@ export default () => ({
dashboard: {
panelGroups: [],
},
/**
* Panel that is currently "zoomed" in as
* a single panel in view.
*/
expandedPanel: {
/**
* {?String} Panel's group name.
*/
group: null,
/**
* {?Object} Panel content from `dashboard`
* null when no panel is expanded.
*/
panel: null,
},
allDashboards: [],
// Other project data
......
---
title: View a details of a panel in 'full screen mode'
merge_request: 29902
author:
type: added
......@@ -13129,6 +13129,9 @@ msgstr ""
msgid "Metrics|For grouping similar metrics"
msgstr ""
msgid "Metrics|Go back"
msgstr ""
msgid "Metrics|Invalid time range, please verify."
msgstr ""
......
......@@ -56,7 +56,7 @@ describe('Dashboard Panel', () => {
const findTitle = () => wrapper.find({ ref: 'graphTitle' });
const findContextualMenu = () => wrapper.find({ ref: 'contextualMenu' });
const createWrapper = (props, options = {}) => {
const createWrapper = (props, options) => {
wrapper = shallowMount(DashboardPanel, {
propsData: {
graphData,
......@@ -108,24 +108,51 @@ describe('Dashboard Panel', () => {
wrapper.destroy();
});
describe('Empty Chart component', () => {
it('renders the chart title', () => {
expect(findTitle().text()).toBe(graphDataEmpty.title);
});
it('renders the chart title', () => {
expect(findTitle().text()).toBe(graphDataEmpty.title);
});
it('renders the no download csv link', () => {
expect(wrapper.find({ ref: 'downloadCsvLink' }).exists()).toBe(false);
});
it('renders no download csv link', () => {
expect(wrapper.find({ ref: 'downloadCsvLink' }).exists()).toBe(false);
});
it('does not contain graph widgets', () => {
expect(findContextualMenu().exists()).toBe(false);
});
it('does not contain graph widgets', () => {
expect(findContextualMenu().exists()).toBe(false);
});
it('is a Vue instance', () => {
expect(wrapper.find(MonitorEmptyChart).exists()).toBe(true);
expect(wrapper.find(MonitorEmptyChart).isVueInstance()).toBe(true);
it('The Empty Chart component is rendered and is a Vue instance', () => {
expect(wrapper.find(MonitorEmptyChart).exists()).toBe(true);
expect(wrapper.find(MonitorEmptyChart).isVueInstance()).toBe(true);
});
});
describe('When graphData is null', () => {
beforeEach(() => {
createWrapper({
graphData: null,
});
});
afterEach(() => {
wrapper.destroy();
});
it('renders no chart title', () => {
expect(findTitle().text()).toBe('');
});
it('renders no download csv link', () => {
expect(wrapper.find({ ref: 'downloadCsvLink' }).exists()).toBe(false);
});
it('does not contain graph widgets', () => {
expect(findContextualMenu().exists()).toBe(false);
});
it('The Empty Chart component is rendered and is a Vue instance', () => {
expect(wrapper.find(MonitorEmptyChart).exists()).toBe(true);
expect(wrapper.find(MonitorEmptyChart).isVueInstance()).toBe(true);
});
});
describe('When graphData is available', () => {
......
......@@ -212,6 +212,97 @@ describe('Dashboard', () => {
});
});
describe('single panel expands to "full screen" mode', () => {
const findExpandedPanel = () => wrapper.find({ ref: 'expandedPanel' });
describe('when the panel is not expanded', () => {
beforeEach(() => {
createShallowWrapper({ hasMetrics: true });
setupStoreWithData(wrapper.vm.$store);
return wrapper.vm.$nextTick();
});
it('expanded panel is not visible', () => {
expect(findExpandedPanel().isVisible()).toBe(false);
});
it('can set a panel as expanded', () => {
const panel = wrapper.findAll(DashboardPanel).at(1);
jest.spyOn(store, 'dispatch');
panel.vm.$emit('expand');
const groupData = metricsDashboardViewModel.panelGroups[0];
expect(store.dispatch).toHaveBeenCalledWith('monitoringDashboard/setExpandedPanel', {
group: groupData.group,
panel: expect.objectContaining({
id: groupData.panels[0].id,
}),
});
});
});
describe('when the panel is expanded', () => {
let group;
let panel;
const MockPanel = {
template: `<div><slot name="topLeft"/></div>`,
};
beforeEach(() => {
createShallowWrapper({ hasMetrics: true }, { stubs: { DashboardPanel: MockPanel } });
setupStoreWithData(wrapper.vm.$store);
const { panelGroups } = wrapper.vm.$store.state.monitoringDashboard.dashboard;
group = panelGroups[0].group;
[panel] = panelGroups[0].panels;
wrapper.vm.$store.commit(`monitoringDashboard/${types.SET_EXPANDED_PANEL}`, {
group,
panel,
});
return wrapper.vm.$nextTick();
});
it('displays a single panel and others are hidden', () => {
const panels = wrapper.findAll(MockPanel);
const visiblePanels = panels.filter(w => w.isVisible());
expect(findExpandedPanel().isVisible()).toBe(true);
// v-show for hiding panels is more performant than v-if
// check for panels to be hidden.
expect(panels.length).toBe(metricsDashboardPanelCount + 1);
expect(visiblePanels.length).toBe(1);
});
it('sets a link to the expanded panel', () => {
const searchQuery =
'?group=System%20metrics%20(Kubernetes)&title=Memory%20Usage%20(Total)&y_label=Total%20Memory%20Used%20(GB)';
expect(findExpandedPanel().attributes('clipboard-text')).toEqual(
expect.stringContaining(searchQuery),
);
});
it('restores full dashboard by clicking `back`', () => {
const backBtn = wrapper.find({ ref: 'goBackBtn' });
expect(backBtn.exists()).toBe(true);
jest.spyOn(store, 'dispatch');
backBtn.vm.$emit('click');
expect(store.dispatch).toHaveBeenCalledWith(
'monitoringDashboard/clearExpandedPanel',
undefined,
);
});
});
});
describe('when one of the metrics is missing', () => {
beforeEach(() => {
createShallowWrapper({ hasMetrics: true });
......@@ -499,11 +590,12 @@ describe('Dashboard', () => {
describe('Clipboard text in panels', () => {
const currentDashboard = 'TEST_DASHBOARD';
const panelIndex = 1; // skip expanded panel
const getClipboardTextAt = i =>
const getClipboardTextFirstPanel = () =>
wrapper
.findAll(DashboardPanel)
.at(i)
.at(panelIndex)
.props('clipboardText');
beforeEach(() => {
......@@ -515,18 +607,18 @@ describe('Dashboard', () => {
});
it('contains a link to the dashboard', () => {
expect(getClipboardTextAt(0)).toContain(`dashboard=${currentDashboard}`);
expect(getClipboardTextAt(0)).toContain(`group=`);
expect(getClipboardTextAt(0)).toContain(`title=`);
expect(getClipboardTextAt(0)).toContain(`y_label=`);
expect(getClipboardTextFirstPanel()).toContain(`dashboard=${currentDashboard}`);
expect(getClipboardTextFirstPanel()).toContain(`group=`);
expect(getClipboardTextFirstPanel()).toContain(`title=`);
expect(getClipboardTextFirstPanel()).toContain(`y_label=`);
});
it('strips the undefined parameter', () => {
wrapper.setProps({ currentDashboard: undefined });
return wrapper.vm.$nextTick(() => {
expect(getClipboardTextAt(0)).not.toContain(`dashboard=`);
expect(getClipboardTextAt(0)).toContain(`y_label=`);
expect(getClipboardTextFirstPanel()).not.toContain(`dashboard=`);
expect(getClipboardTextFirstPanel()).toContain(`y_label=`);
});
});
......@@ -534,8 +626,8 @@ describe('Dashboard', () => {
wrapper.setProps({ currentDashboard: null });
return wrapper.vm.$nextTick(() => {
expect(getClipboardTextAt(0)).not.toContain(`dashboard=`);
expect(getClipboardTextAt(0)).toContain(`y_label=`);
expect(getClipboardTextFirstPanel()).not.toContain(`dashboard=`);
expect(getClipboardTextFirstPanel()).toContain(`y_label=`);
});
});
});
......
......@@ -20,6 +20,8 @@ import {
fetchPrometheusMetric,
setInitialState,
filterEnvironments,
setExpandedPanel,
clearExpandedPanel,
setGettingStartedEmptyState,
duplicateSystemDashboard,
} from '~/monitoring/stores/actions';
......@@ -870,4 +872,43 @@ describe('Monitoring store actions', () => {
});
});
});
describe('setExpandedPanel', () => {
let state;
beforeEach(() => {
state = storeState();
});
it('Sets a panel as expanded', () => {
const group = 'group_1';
const panel = { title: 'A Panel' };
return testAction(
setExpandedPanel,
{ group, panel },
state,
[{ type: types.SET_EXPANDED_PANEL, payload: { group, panel } }],
[],
);
});
});
describe('clearExpandedPanel', () => {
let state;
beforeEach(() => {
state = storeState();
});
it('Clears a panel as expanded', () => {
return testAction(
clearExpandedPanel,
undefined,
state,
[{ type: types.SET_EXPANDED_PANEL, payload: { group: null, panel: null } }],
[],
);
});
});
});
......@@ -342,4 +342,26 @@ describe('Monitoring mutations', () => {
expect(stateCopy.allDashboards).toEqual(dashboardGitResponse);
});
});
describe('SET_EXPANDED_PANEL', () => {
it('no expanded panel is set initally', () => {
expect(stateCopy.expandedPanel.panel).toEqual(null);
expect(stateCopy.expandedPanel.group).toEqual(null);
});
it('sets a panel id as the expanded panel', () => {
const group = 'group_1';
const panel = { title: 'A Panel' };
mutations[types.SET_EXPANDED_PANEL](stateCopy, { group, panel });
expect(stateCopy.expandedPanel).toEqual({ group, panel });
});
it('clears panel as the expanded panel', () => {
mutations[types.SET_EXPANDED_PANEL](stateCopy, { group: null, panel: null });
expect(stateCopy.expandedPanel.group).toEqual(null);
expect(stateCopy.expandedPanel.panel).toEqual(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