diff options
Diffstat (limited to 'spec/frontend/monitoring')
26 files changed, 2767 insertions, 283 deletions
diff --git a/spec/frontend/monitoring/__snapshots__/alert_widget_spec.js.snap b/spec/frontend/monitoring/__snapshots__/alert_widget_spec.js.snap new file mode 100644 index 00000000000..2179e7b4ab5 --- /dev/null +++ b/spec/frontend/monitoring/__snapshots__/alert_widget_spec.js.snap @@ -0,0 +1,43 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`AlertWidget Alert firing displays a warning icon and matches snapshot 1`] = ` +<gl-badge-stub + class="d-flex-center text-truncate" + pill="" + variant="danger" +> + <gl-icon-stub + class="flex-shrink-0" + name="warning" + size="16" + /> + + <span + class="text-truncate gl-pl-1-deprecated-no-really-do-not-use-me" + > + Firing: + alert-label > 42 + + </span> +</gl-badge-stub> +`; + +exports[`AlertWidget Alert not firing displays a warning icon and matches snapshot 1`] = ` +<gl-badge-stub + class="d-flex-center text-truncate" + pill="" + variant="secondary" +> + <gl-icon-stub + class="flex-shrink-0" + name="warning" + size="16" + /> + + <span + class="text-truncate gl-pl-1-deprecated-no-really-do-not-use-me" + > + alert-label > 42 + </span> +</gl-badge-stub> +`; diff --git a/spec/frontend/monitoring/alert_widget_spec.js b/spec/frontend/monitoring/alert_widget_spec.js new file mode 100644 index 00000000000..f0355dfa01b --- /dev/null +++ b/spec/frontend/monitoring/alert_widget_spec.js @@ -0,0 +1,422 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlLoadingIcon, GlTooltip, GlSprintf, GlBadge } from '@gitlab/ui'; +import AlertWidget from '~/monitoring/components/alert_widget.vue'; +import waitForPromises from 'helpers/wait_for_promises'; +import createFlash from '~/flash'; + +const mockReadAlert = jest.fn(); +const mockCreateAlert = jest.fn(); +const mockUpdateAlert = jest.fn(); +const mockDeleteAlert = jest.fn(); + +jest.mock('~/flash'); +jest.mock( + '~/monitoring/services/alerts_service', + () => + function AlertsServiceMock() { + return { + readAlert: mockReadAlert, + createAlert: mockCreateAlert, + updateAlert: mockUpdateAlert, + deleteAlert: mockDeleteAlert, + }; + }, +); + +describe('AlertWidget', () => { + let wrapper; + + const nonFiringAlertResult = [ + { + values: [[0, 1], [1, 42], [2, 41]], + }, + ]; + const firingAlertResult = [ + { + values: [[0, 42], [1, 43], [2, 44]], + }, + ]; + const metricId = '5'; + const alertPath = 'my/alert.json'; + + const relevantQueries = [ + { + metricId, + label: 'alert-label', + alert_path: alertPath, + result: nonFiringAlertResult, + }, + ]; + + const firingRelevantQueries = [ + { + metricId, + label: 'alert-label', + alert_path: alertPath, + result: firingAlertResult, + }, + ]; + + const defaultProps = { + alertsEndpoint: '', + relevantQueries, + alertsToManage: {}, + modalId: 'alert-modal-1', + }; + + const propsWithAlert = { + relevantQueries, + }; + + const propsWithAlertData = { + relevantQueries, + alertsToManage: { + [alertPath]: { operator: '>', threshold: 42, alert_path: alertPath, metricId }, + }, + }; + + const createComponent = propsData => { + wrapper = shallowMount(AlertWidget, { + stubs: { GlTooltip, GlSprintf }, + propsData: { + ...defaultProps, + ...propsData, + }, + }); + }; + const hasLoadingIcon = () => wrapper.contains(GlLoadingIcon); + const findWidgetForm = () => wrapper.find({ ref: 'widgetForm' }); + const findAlertErrorMessage = () => wrapper.find({ ref: 'alertErrorMessage' }); + const findCurrentSettingsText = () => + wrapper + .find({ ref: 'alertCurrentSetting' }) + .text() + .replace(/\s\s+/g, ' '); + const findBadge = () => wrapper.find(GlBadge); + const findTooltip = () => wrapper.find(GlTooltip); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + it('displays a loading spinner and disables form when fetching alerts', () => { + let resolveReadAlert; + mockReadAlert.mockReturnValue( + new Promise(resolve => { + resolveReadAlert = resolve; + }), + ); + createComponent(defaultProps); + return wrapper.vm + .$nextTick() + .then(() => { + expect(hasLoadingIcon()).toBe(true); + expect(findWidgetForm().props('disabled')).toBe(true); + + resolveReadAlert({ operator: '==', threshold: 42 }); + }) + .then(() => waitForPromises()) + .then(() => { + expect(hasLoadingIcon()).toBe(false); + expect(findWidgetForm().props('disabled')).toBe(false); + }); + }); + + it('does not render loading spinner if showLoadingState is false', () => { + let resolveReadAlert; + mockReadAlert.mockReturnValue( + new Promise(resolve => { + resolveReadAlert = resolve; + }), + ); + createComponent({ + ...defaultProps, + showLoadingState: false, + }); + return wrapper.vm + .$nextTick() + .then(() => { + expect(wrapper.find(GlLoadingIcon).exists()).toBe(false); + + resolveReadAlert({ operator: '==', threshold: 42 }); + }) + .then(() => waitForPromises()) + .then(() => { + expect(wrapper.find(GlLoadingIcon).exists()).toBe(false); + }); + }); + + it('displays an error message when fetch fails', () => { + mockReadAlert.mockRejectedValue(); + createComponent(propsWithAlert); + expect(hasLoadingIcon()).toBe(true); + + return waitForPromises().then(() => { + expect(createFlash).toHaveBeenCalled(); + expect(hasLoadingIcon()).toBe(false); + }); + }); + + describe('Alert not firing', () => { + it('displays a warning icon and matches snapshot', () => { + mockReadAlert.mockResolvedValue({ operator: '>', threshold: 42 }); + createComponent(propsWithAlertData); + + return waitForPromises().then(() => { + expect(findBadge().element).toMatchSnapshot(); + }); + }); + + it('displays an alert summary when there is a single alert', () => { + mockReadAlert.mockResolvedValue({ operator: '>', threshold: 42 }); + createComponent(propsWithAlertData); + return waitForPromises().then(() => { + expect(findCurrentSettingsText()).toEqual('alert-label > 42'); + }); + }); + + it('displays a combined alert summary when there are multiple alerts', () => { + mockReadAlert.mockResolvedValue({ operator: '>', threshold: 42 }); + const propsWithManyAlerts = { + relevantQueries: [ + ...relevantQueries, + ...[ + { + metricId: '6', + alert_path: 'my/alert2.json', + label: 'alert-label2', + result: [{ values: [] }], + }, + ], + ], + alertsToManage: { + 'my/alert.json': { + operator: '>', + threshold: 42, + alert_path: alertPath, + metricId, + }, + 'my/alert2.json': { + operator: '==', + threshold: 900, + alert_path: 'my/alert2.json', + metricId: '6', + }, + }, + }; + createComponent(propsWithManyAlerts); + return waitForPromises().then(() => { + expect(findCurrentSettingsText()).toContain('2 alerts applied'); + }); + }); + }); + + describe('Alert firing', () => { + it('displays a warning icon and matches snapshot', () => { + mockReadAlert.mockResolvedValue({ operator: '>', threshold: 42 }); + propsWithAlertData.relevantQueries = firingRelevantQueries; + createComponent(propsWithAlertData); + + return waitForPromises().then(() => { + expect(findBadge().element).toMatchSnapshot(); + }); + }); + + it('displays an alert summary when there is a single alert', () => { + mockReadAlert.mockResolvedValue({ operator: '>', threshold: 42 }); + propsWithAlertData.relevantQueries = firingRelevantQueries; + createComponent(propsWithAlertData); + return waitForPromises().then(() => { + expect(findCurrentSettingsText()).toEqual('Firing: alert-label > 42'); + }); + }); + + it('displays a combined alert summary when there are multiple alerts', () => { + mockReadAlert.mockResolvedValue({ operator: '>', threshold: 42 }); + const propsWithManyAlerts = { + relevantQueries: [ + ...firingRelevantQueries, + ...[ + { + metricId: '6', + alert_path: 'my/alert2.json', + label: 'alert-label2', + result: [{ values: [] }], + }, + ], + ], + alertsToManage: { + 'my/alert.json': { + operator: '>', + threshold: 42, + alert_path: alertPath, + metricId, + }, + 'my/alert2.json': { + operator: '==', + threshold: 900, + alert_path: 'my/alert2.json', + metricId: '6', + }, + }, + }; + createComponent(propsWithManyAlerts); + + return waitForPromises().then(() => { + expect(findCurrentSettingsText()).toContain('2 alerts applied, 1 firing'); + }); + }); + + it('should display tooltip with thresholds summary', () => { + mockReadAlert.mockResolvedValue({ operator: '>', threshold: 42 }); + const propsWithManyAlerts = { + relevantQueries: [ + ...firingRelevantQueries, + ...[ + { + metricId: '6', + alert_path: 'my/alert2.json', + label: 'alert-label2', + result: [{ values: [] }], + }, + ], + ], + alertsToManage: { + 'my/alert.json': { + operator: '>', + threshold: 42, + alert_path: alertPath, + metricId, + }, + 'my/alert2.json': { + operator: '==', + threshold: 900, + alert_path: 'my/alert2.json', + metricId: '6', + }, + }, + }; + createComponent(propsWithManyAlerts); + + return waitForPromises().then(() => { + expect( + findTooltip() + .text() + .replace(/\s\s+/g, ' '), + ).toEqual('Firing: alert-label > 42'); + }); + }); + }); + + it('creates an alert with an appropriate handler', () => { + const alertParams = { + operator: '<', + threshold: 4, + prometheus_metric_id: '5', + }; + mockReadAlert.mockResolvedValue({ operator: '>', threshold: 42 }); + const fakeAlertPath = 'foo/bar'; + mockCreateAlert.mockResolvedValue({ alert_path: fakeAlertPath, ...alertParams }); + createComponent({ + alertsToManage: { + [fakeAlertPath]: { + alert_path: fakeAlertPath, + operator: '<', + threshold: 4, + prometheus_metric_id: '5', + metricId: '5', + }, + }, + }); + + findWidgetForm().vm.$emit('create', alertParams); + + expect(mockCreateAlert).toHaveBeenCalledWith(alertParams); + }); + + it('updates an alert with an appropriate handler', () => { + const alertParams = { operator: '<', threshold: 4, alert_path: alertPath }; + const newAlertParams = { operator: '==', threshold: 12 }; + mockReadAlert.mockResolvedValue(alertParams); + mockUpdateAlert.mockResolvedValue({ ...alertParams, ...newAlertParams }); + createComponent({ + ...propsWithAlertData, + alertsToManage: { + [alertPath]: { + alert_path: alertPath, + operator: '==', + threshold: 12, + metricId: '5', + }, + }, + }); + + findWidgetForm().vm.$emit('update', { + alert: alertPath, + ...newAlertParams, + prometheus_metric_id: '5', + }); + + expect(mockUpdateAlert).toHaveBeenCalledWith(alertPath, newAlertParams); + }); + + it('deletes an alert with an appropriate handler', () => { + const alertParams = { alert_path: alertPath, operator: '>', threshold: 42 }; + mockReadAlert.mockResolvedValue(alertParams); + mockDeleteAlert.mockResolvedValue({}); + createComponent({ + ...propsWithAlert, + alertsToManage: { + [alertPath]: { + alert_path: alertPath, + operator: '>', + threshold: 42, + metricId: '5', + }, + }, + }); + + findWidgetForm().vm.$emit('delete', { alert: alertPath }); + + return wrapper.vm.$nextTick().then(() => { + expect(mockDeleteAlert).toHaveBeenCalledWith(alertPath); + expect(findAlertErrorMessage().exists()).toBe(false); + }); + }); + + describe('when delete fails', () => { + beforeEach(() => { + const alertParams = { alert_path: alertPath, operator: '>', threshold: 42 }; + mockReadAlert.mockResolvedValue(alertParams); + mockDeleteAlert.mockRejectedValue(); + + createComponent({ + ...propsWithAlert, + alertsToManage: { + [alertPath]: { + alert_path: alertPath, + operator: '>', + threshold: 42, + metricId: '5', + }, + }, + }); + + findWidgetForm().vm.$emit('delete', { alert: alertPath }); + return wrapper.vm.$nextTick(); + }); + + it('shows error message', () => { + expect(findAlertErrorMessage().text()).toEqual('Error deleting alert'); + }); + + it('dismisses error message on cancel', () => { + findWidgetForm().vm.$emit('cancel'); + + return wrapper.vm.$nextTick().then(() => { + expect(findAlertErrorMessage().exists()).toBe(false); + }); + }); + }); +}); diff --git a/spec/frontend/monitoring/components/__snapshots__/dashboard_template_spec.js.snap b/spec/frontend/monitoring/components/__snapshots__/dashboard_template_spec.js.snap index 1906ad7c6ed..9be5fa72110 100644 --- a/spec/frontend/monitoring/components/__snapshots__/dashboard_template_spec.js.snap +++ b/spec/frontend/monitoring/components/__snapshots__/dashboard_template_spec.js.snap @@ -16,7 +16,6 @@ exports[`Dashboard template matches the default snapshot 1`] = ` data-qa-selector="dashboards_filter_dropdown" defaultbranch="master" id="monitor-dashboards-dropdown" - selecteddashboard="[object Object]" toggle-class="dropdown-menu-toggle" /> </div> @@ -72,7 +71,7 @@ exports[`Dashboard template matches the default snapshot 1`] = ` <date-time-picker-stub class="flex-grow-1 show-last-dropdown" customenabled="true" - data-qa-selector="show_last_dropdown" + data-qa-selector="range_picker_dropdown" options="[object Object],[object Object],[object Object],[object Object],[object Object],[object Object],[object Object]" value="[object Object]" /> @@ -101,6 +100,26 @@ exports[`Dashboard template matches the default snapshot 1`] = ` <div class="d-sm-flex" > + <div + class="mb-2 mr-2 d-flex" + > + <div + class="flex-grow-1" + title="Star dashboard" + > + <gl-deprecated-button-stub + class="w-100" + size="md" + variant="default" + > + <gl-icon-stub + name="star-o" + size="16" + /> + </gl-deprecated-button-stub> + </div> + </div> + <!----> <!----> @@ -111,6 +130,8 @@ exports[`Dashboard template matches the default snapshot 1`] = ` </div> </div> + <!----> + <empty-state-stub clusterspath="/path/to/clusters" documentationpath="/path/to/docs" diff --git a/spec/frontend/monitoring/components/alert_widget_form_spec.js b/spec/frontend/monitoring/components/alert_widget_form_spec.js new file mode 100644 index 00000000000..a8416216a94 --- /dev/null +++ b/spec/frontend/monitoring/components/alert_widget_form_spec.js @@ -0,0 +1,220 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlLink } from '@gitlab/ui'; +import AlertWidgetForm from '~/monitoring/components/alert_widget_form.vue'; +import ModalStub from '../stubs/modal_stub'; + +describe('AlertWidgetForm', () => { + let wrapper; + + const metricId = '8'; + const alertPath = 'alert'; + const relevantQueries = [{ metricId, alert_path: alertPath, label: 'alert-label' }]; + const dataTrackingOptions = { + create: { action: 'click_button', label: 'create_alert' }, + delete: { action: 'click_button', label: 'delete_alert' }, + update: { action: 'click_button', label: 'update_alert' }, + }; + + const defaultProps = { + disabled: false, + relevantQueries, + modalId: 'alert-modal-1', + }; + + const propsWithAlertData = { + ...defaultProps, + alertsToManage: { + alert: { alert_path: alertPath, operator: '<', threshold: 5, metricId }, + }, + configuredAlert: metricId, + }; + + function createComponent(props = {}) { + const propsData = { + ...defaultProps, + ...props, + }; + + wrapper = shallowMount(AlertWidgetForm, { + propsData, + stubs: { + GlModal: ModalStub, + }, + }); + } + + const modal = () => wrapper.find(ModalStub); + const modalTitle = () => modal().attributes('title'); + const submitButton = () => modal().find(GlLink); + const submitButtonTrackingOpts = () => + JSON.parse(submitButton().attributes('data-tracking-options')); + const e = { + preventDefault: jest.fn(), + }; + + beforeEach(() => { + e.preventDefault.mockReset(); + }); + + afterEach(() => { + if (wrapper) wrapper.destroy(); + }); + + it('disables the form when disabled prop is set', () => { + createComponent({ disabled: true }); + + expect(modal().attributes('ok-disabled')).toBe('true'); + }); + + it('disables the form if no query is selected', () => { + createComponent(); + + expect(modal().attributes('ok-disabled')).toBe('true'); + }); + + it('shows correct title and button text', () => { + expect(modalTitle()).toBe('Add alert'); + expect(submitButton().text()).toBe('Add'); + }); + + it('sets tracking options for create alert', () => { + expect(submitButtonTrackingOpts()).toEqual(dataTrackingOptions.create); + }); + + it('emits a "create" event when form submitted without existing alert', () => { + createComponent(); + + wrapper.vm.selectQuery('9'); + wrapper.setData({ + threshold: 900, + }); + + wrapper.vm.handleSubmit(e); + + expect(wrapper.emitted().create[0]).toEqual([ + { + alert: undefined, + operator: '>', + threshold: 900, + prometheus_metric_id: '9', + }, + ]); + expect(e.preventDefault).toHaveBeenCalledTimes(1); + }); + + it('resets form when modal is dismissed (hidden)', () => { + createComponent(); + + wrapper.vm.selectQuery('9'); + wrapper.vm.selectQuery('>'); + wrapper.setData({ + threshold: 800, + }); + + modal().vm.$emit('hidden'); + + expect(wrapper.vm.selectedAlert).toEqual({}); + expect(wrapper.vm.operator).toBe(null); + expect(wrapper.vm.threshold).toBe(null); + expect(wrapper.vm.prometheusMetricId).toBe(null); + }); + + it('sets selectedAlert to the provided configuredAlert on modal show', () => { + createComponent(propsWithAlertData); + + modal().vm.$emit('shown'); + + expect(wrapper.vm.selectedAlert).toEqual(propsWithAlertData.alertsToManage[alertPath]); + }); + + it('sets selectedAlert to the first relevantQueries if there is only one option on modal show', () => { + createComponent({ + ...propsWithAlertData, + configuredAlert: '', + }); + + modal().vm.$emit('shown'); + + expect(wrapper.vm.selectedAlert).toEqual(propsWithAlertData.alertsToManage[alertPath]); + }); + + it('does not set selectedAlert to the first relevantQueries if there is more than one option on modal show', () => { + createComponent({ + relevantQueries: [ + { + metricId: '8', + alertPath: 'alert', + label: 'alert-label', + }, + { + metricId: '9', + alertPath: 'alert', + label: 'alert-label', + }, + ], + }); + + modal().vm.$emit('shown'); + + expect(wrapper.vm.selectedAlert).toEqual({}); + }); + + describe('with existing alert', () => { + beforeEach(() => { + createComponent(propsWithAlertData); + + wrapper.vm.selectQuery(metricId); + }); + + it('sets tracking options for delete alert', () => { + expect(submitButtonTrackingOpts()).toEqual(dataTrackingOptions.delete); + }); + + it('updates button text', () => { + expect(modalTitle()).toBe('Edit alert'); + expect(submitButton().text()).toBe('Delete'); + }); + + it('emits "delete" event when form values unchanged', () => { + wrapper.vm.handleSubmit(e); + + expect(wrapper.emitted().delete[0]).toEqual([ + { + alert: 'alert', + operator: '<', + threshold: 5, + prometheus_metric_id: '8', + }, + ]); + expect(e.preventDefault).toHaveBeenCalledTimes(1); + }); + + it('emits "update" event when form changed', () => { + wrapper.setData({ + threshold: 11, + }); + + wrapper.vm.handleSubmit(e); + + expect(wrapper.emitted().update[0]).toEqual([ + { + alert: 'alert', + operator: '<', + threshold: 11, + prometheus_metric_id: '8', + }, + ]); + expect(e.preventDefault).toHaveBeenCalledTimes(1); + }); + + it('sets tracking options for update alert', () => { + wrapper.setData({ + threshold: 11, + }); + + return wrapper.vm.$nextTick(() => { + expect(submitButtonTrackingOpts()).toEqual(dataTrackingOptions.update); + }); + }); + }); +}); diff --git a/spec/frontend/monitoring/components/charts/single_stat_spec.js b/spec/frontend/monitoring/components/charts/single_stat_spec.js index fb0682d0338..9cc5970da82 100644 --- a/spec/frontend/monitoring/components/charts/single_stat_spec.js +++ b/spec/frontend/monitoring/components/charts/single_stat_spec.js @@ -1,6 +1,6 @@ import { shallowMount } from '@vue/test-utils'; import SingleStatChart from '~/monitoring/components/charts/single_stat.vue'; -import { graphDataPrometheusQuery } from '../../mock_data'; +import { singleStatMetricsResult } from '../../mock_data'; describe('Single Stat Chart component', () => { let singleStatChart; @@ -8,7 +8,7 @@ describe('Single Stat Chart component', () => { beforeEach(() => { singleStatChart = shallowMount(SingleStatChart, { propsData: { - graphData: graphDataPrometheusQuery, + graphData: singleStatMetricsResult, }, }); }); @@ -26,7 +26,7 @@ describe('Single Stat Chart component', () => { it('should change the value representation to a percentile one', () => { singleStatChart.setProps({ graphData: { - ...graphDataPrometheusQuery, + ...singleStatMetricsResult, maxValue: 120, }, }); @@ -37,7 +37,7 @@ describe('Single Stat Chart component', () => { it('should display NaN for non numeric maxValue values', () => { singleStatChart.setProps({ graphData: { - ...graphDataPrometheusQuery, + ...singleStatMetricsResult, maxValue: 'not a number', }, }); @@ -48,13 +48,13 @@ describe('Single Stat Chart component', () => { it('should display NaN for missing query values', () => { singleStatChart.setProps({ graphData: { - ...graphDataPrometheusQuery, + ...singleStatMetricsResult, metrics: [ { - ...graphDataPrometheusQuery.metrics[0], + ...singleStatMetricsResult.metrics[0], result: [ { - ...graphDataPrometheusQuery.metrics[0].result[0], + ...singleStatMetricsResult.metrics[0].result[0], value: [''], }, ], diff --git a/spec/frontend/monitoring/components/charts/time_series_spec.js b/spec/frontend/monitoring/components/charts/time_series_spec.js index 5ac716b0c63..7d5a08bc4a1 100644 --- a/spec/frontend/monitoring/components/charts/time_series_spec.js +++ b/spec/frontend/monitoring/components/charts/time_series_spec.js @@ -1,4 +1,4 @@ -import { mount } from '@vue/test-utils'; +import { mount, shallowMount } from '@vue/test-utils'; import { setTestTimeout } from 'helpers/timeout'; import { GlLink } from '@gitlab/ui'; import { TEST_HOST } from 'jest/helpers/test_constants'; @@ -11,6 +11,7 @@ import { import { cloneDeep } from 'lodash'; import { shallowWrapperContainsSlotText } from 'helpers/vue_test_utils_helper'; import { createStore } from '~/monitoring/stores'; +import { panelTypes, chartHeight } from '~/monitoring/constants'; import TimeSeries from '~/monitoring/components/charts/time_series.vue'; import * as types from '~/monitoring/stores/mutation_types'; import { deploymentData, mockProjectDir, annotationsData } from '../../mock_data'; @@ -39,10 +40,10 @@ describe('Time series component', () => { let mockGraphData; let store; - const makeTimeSeriesChart = (graphData, type) => - mount(TimeSeries, { + const createWrapper = (graphData = mockGraphData, mountingMethod = shallowMount) => + mountingMethod(TimeSeries, { propsData: { - graphData: { ...graphData, type }, + graphData, deploymentData: store.state.monitoringDashboard.deploymentData, annotations: store.state.monitoringDashboard.annotations, projectPath: `${TEST_HOST}${mockProjectDir}`, @@ -79,9 +80,9 @@ describe('Time series component', () => { const findChart = () => timeSeriesChart.find({ ref: 'chart' }); - beforeEach(done => { - timeSeriesChart = makeTimeSeriesChart(mockGraphData, 'area-chart'); - timeSeriesChart.vm.$nextTick(done); + beforeEach(() => { + timeSeriesChart = createWrapper(mockGraphData, mount); + return timeSeriesChart.vm.$nextTick(); }); it('allows user to override max value label text using prop', () => { @@ -100,6 +101,21 @@ describe('Time series component', () => { }); }); + it('chart sets a default height', () => { + const wrapper = createWrapper(); + expect(wrapper.props('height')).toBe(chartHeight); + }); + + it('chart has a configurable height', () => { + const mockHeight = 599; + const wrapper = createWrapper(); + + wrapper.setProps({ height: mockHeight }); + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.props('height')).toBe(mockHeight); + }); + }); + describe('events', () => { describe('datazoom', () => { let eChartMock; @@ -125,7 +141,7 @@ describe('Time series component', () => { }), }; - timeSeriesChart = makeTimeSeriesChart(mockGraphData); + timeSeriesChart = createWrapper(mockGraphData, mount); timeSeriesChart.vm.$nextTick(() => { findChart().vm.$emit('created', eChartMock); done(); @@ -535,11 +551,11 @@ describe('Time series component', () => { describe('wrapped components', () => { const glChartComponents = [ { - chartType: 'area-chart', + chartType: panelTypes.AREA_CHART, component: GlAreaChart, }, { - chartType: 'line-chart', + chartType: panelTypes.LINE_CHART, component: GlLineChart, }, ]; @@ -550,7 +566,10 @@ describe('Time series component', () => { const findChartComponent = () => timeSeriesAreaChart.find(dynamicComponent.component); beforeEach(done => { - timeSeriesAreaChart = makeTimeSeriesChart(mockGraphData, dynamicComponent.chartType); + timeSeriesAreaChart = createWrapper( + { ...mockGraphData, type: dynamicComponent.chartType }, + mount, + ); timeSeriesAreaChart.vm.$nextTick(done); }); @@ -632,7 +651,7 @@ describe('Time series component', () => { Object.assign(metric, { result: metricResultStatus.result }), ); - timeSeriesChart = makeTimeSeriesChart(graphData, 'area-chart'); + timeSeriesChart = createWrapper({ ...graphData, type: 'area-chart' }, mount); timeSeriesChart.vm.$nextTick(done); }); diff --git a/spec/frontend/monitoring/components/panel_type_spec.js b/spec/frontend/monitoring/components/dashboard_panel_spec.js index 819b5235284..f8c9bd56721 100644 --- a/spec/frontend/monitoring/components/panel_type_spec.js +++ b/spec/frontend/monitoring/components/dashboard_panel_spec.js @@ -1,13 +1,13 @@ +import Vuex from 'vuex'; import { shallowMount } from '@vue/test-utils'; 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 AlertWidget from '~/monitoring/components/alert_widget.vue'; -import PanelType from '~/monitoring/components/panel_type.vue'; -import EmptyChart from '~/monitoring/components/charts/empty_chart.vue'; -import TimeSeriesChart from '~/monitoring/components/charts/time_series.vue'; -import AnomalyChart from '~/monitoring/components/charts/anomaly.vue'; +import DashboardPanel from '~/monitoring/components/dashboard_panel.vue'; import { anomalyMockGraphData, mockLogsHref, @@ -15,8 +15,23 @@ import { mockNamespace, mockNamespacedData, mockTimeRange, + singleStatMetricsResult, + graphDataPrometheusQueryRangeMultiTrack, + barMockData, + propsData, } from '../mock_data'; +import { panelTypes } from '~/monitoring/constants'; + +import MonitorEmptyChart from '~/monitoring/components/charts/empty_chart.vue'; +import MonitorTimeSeriesChart from '~/monitoring/components/charts/time_series.vue'; +import MonitorAnomalyChart from '~/monitoring/components/charts/anomaly.vue'; +import MonitorSingleStatChart from '~/monitoring/components/charts/single_stat.vue'; +import MonitorHeatmapChart from '~/monitoring/components/charts/heatmap.vue'; +import MonitorColumnChart from '~/monitoring/components/charts/column.vue'; +import MonitorBarChart from '~/monitoring/components/charts/bar.vue'; +import MonitorStackedColumnChart from '~/monitoring/components/charts/stacked_column.vue'; + import { graphData, graphDataEmpty } from '../fixture_data'; import { createStore, monitoringDashboard } from '~/monitoring/stores'; import { createStore as createEmbedGroupStore } from '~/monitoring/stores/embed_group'; @@ -29,7 +44,7 @@ const mocks = { }, }; -describe('Panel Type component', () => { +describe('Dashboard Panel', () => { let axiosMock; let store; let state; @@ -38,18 +53,20 @@ describe('Panel Type component', () => { const exampleText = 'example_text'; const findCopyLink = () => wrapper.find({ ref: 'copyChartLink' }); - const findTimeChart = () => wrapper.find({ ref: 'timeChart' }); + const findTimeChart = () => wrapper.find({ ref: 'timeSeriesChart' }); const findTitle = () => wrapper.find({ ref: 'graphTitle' }); const findContextualMenu = () => wrapper.find({ ref: 'contextualMenu' }); - const createWrapper = props => { - wrapper = shallowMount(PanelType, { + const createWrapper = (props, options) => { + wrapper = shallowMount(DashboardPanel, { propsData: { graphData, + settingsPath: propsData.settingsPath, ...props, }, store, mocks, + ...options, }); }; @@ -66,6 +83,22 @@ describe('Panel Type component', () => { axiosMock.reset(); }); + describe('Renders slots', () => { + it('renders "topLeft" slot', () => { + createWrapper( + {}, + { + slots: { + topLeft: `<div class="top-left-content">OK</div>`, + }, + }, + ); + + expect(wrapper.find('.top-left-content').exists()).toBe(true); + expect(wrapper.find('.top-left-content').text()).toBe('OK'); + }); + }); + describe('When no graphData is available', () => { beforeEach(() => { createWrapper({ @@ -77,27 +110,54 @@ describe('Panel Type component', () => { 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(EmptyChart).exists()).toBe(true); - expect(wrapper.find(EmptyChart).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 graph data is available', () => { + describe('When graphData is available', () => { beforeEach(() => { createWrapper(); }); @@ -134,34 +194,54 @@ describe('Panel Type component', () => { }); }); - describe('Time Series Chart panel type', () => { - it('is rendered', () => { - expect(wrapper.find(TimeSeriesChart).isVueInstance()).toBe(true); - expect(wrapper.find(TimeSeriesChart).exists()).toBe(true); - }); + it('includes a default group id', () => { + expect(wrapper.vm.groupId).toBe('dashboard-panel'); + }); + + describe('Supports different panel types', () => { + const dataWithType = type => { + return { + ...graphData, + type, + }; + }; - it('includes a default group id', () => { - expect(wrapper.vm.groupId).toBe('panel-type-chart'); + it('empty chart is rendered for empty results', () => { + createWrapper({ graphData: graphDataEmpty }); + expect(wrapper.find(MonitorEmptyChart).exists()).toBe(true); + expect(wrapper.find(MonitorEmptyChart).isVueInstance()).toBe(true); }); - }); - describe('Anomaly Chart panel type', () => { - beforeEach(() => { - wrapper.setProps({ - graphData: anomalyMockGraphData, - }); - return wrapper.vm.$nextTick(); + it('area chart is rendered by default', () => { + createWrapper(); + expect(wrapper.find(MonitorTimeSeriesChart).exists()).toBe(true); + expect(wrapper.find(MonitorTimeSeriesChart).isVueInstance()).toBe(true); }); - it('is rendered with an anomaly chart', () => { - expect(wrapper.find(AnomalyChart).isVueInstance()).toBe(true); - expect(wrapper.find(AnomalyChart).exists()).toBe(true); + it.each` + data | component + ${dataWithType(panelTypes.AREA_CHART)} | ${MonitorTimeSeriesChart} + ${dataWithType(panelTypes.LINE_CHART)} | ${MonitorTimeSeriesChart} + ${anomalyMockGraphData} | ${MonitorAnomalyChart} + ${dataWithType(panelTypes.COLUMN)} | ${MonitorColumnChart} + ${dataWithType(panelTypes.STACKED_COLUMN)} | ${MonitorStackedColumnChart} + ${singleStatMetricsResult} | ${MonitorSingleStatChart} + ${graphDataPrometheusQueryRangeMultiTrack} | ${MonitorHeatmapChart} + ${barMockData} | ${MonitorBarChart} + `('wrapps a $data.type component binding attributes', ({ data, component }) => { + const attrs = { attr1: 'attr1Value', attr2: 'attr2Value' }; + createWrapper({ graphData: data }, { attrs }); + + expect(wrapper.find(component).exists()).toBe(true); + expect(wrapper.find(component).isVueInstance()).toBe(true); + expect(wrapper.find(component).attributes()).toMatchObject(attrs); }); }); }); describe('Edit custom metric dropdown item', () => { const findEditCustomMetricLink = () => wrapper.find({ ref: 'editMetricLink' }); + const mockEditPath = '/root/kubernetes-gke-project/prometheus/metrics/23/edit'; beforeEach(() => { createWrapper(); @@ -180,7 +260,7 @@ describe('Panel Type component', () => { metrics: [ { ...graphData.metrics[0], - edit_path: '/root/kubernetes-gke-project/prometheus/metrics/23/edit', + edit_path: mockEditPath, }, ], }, @@ -189,10 +269,11 @@ describe('Panel Type component', () => { return wrapper.vm.$nextTick(() => { expect(findEditCustomMetricLink().exists()).toBe(true); expect(findEditCustomMetricLink().text()).toBe('Edit metric'); + expect(findEditCustomMetricLink().attributes('href')).toBe(mockEditPath); }); }); - it('shows an "Edit metrics" link for a panel with multiple metrics', () => { + it('shows an "Edit metrics" link pointing to settingsPath for a panel with multiple metrics', () => { wrapper.setProps({ graphData: { ...graphData, @@ -211,6 +292,7 @@ describe('Panel Type component', () => { return wrapper.vm.$nextTick(() => { expect(findEditCustomMetricLink().text()).toBe('Edit metrics'); + expect(findEditCustomMetricLink().attributes('href')).toBe(propsData.settingsPath); }); }); }); @@ -294,10 +376,6 @@ describe('Panel Type component', () => { }); }); - afterEach(() => { - wrapper.destroy(); - }); - it('sets clipboard text on the dropdown', () => { expect(findCopyLink().exists()).toBe(true); expect(findCopyLink().element.dataset.clipboardText).toBe(clipboardText); @@ -314,11 +392,24 @@ describe('Panel Type component', () => { }); }); + 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(PanelType, { + wrapper = shallowMount(DashboardPanel, { propsData: { clipboardText: exampleText, + settingsPath: propsData.settingsPath, graphData: { y_label: 'metric', ...graphData, @@ -365,9 +456,10 @@ describe('Panel Type component', () => { store.registerModule(mockNamespace, monitoringDashboard); store.state.embedGroup.modules.push(mockNamespace); - wrapper = shallowMount(PanelType, { + wrapper = shallowMount(DashboardPanel, { propsData: { graphData, + settingsPath: propsData.settingsPath, namespace: mockNamespace, }, store, @@ -401,8 +493,84 @@ describe('Panel Type component', () => { }); it('it renders a time series chart with no errors', () => { - expect(wrapper.find(TimeSeriesChart).isVueInstance()).toBe(true); - expect(wrapper.find(TimeSeriesChart).exists()).toBe(true); + expect(wrapper.find(MonitorTimeSeriesChart).isVueInstance()).toBe(true); + expect(wrapper.find(MonitorTimeSeriesChart).exists()).toBe(true); + }); + }); + + describe('Expand to full screen', () => { + const findExpandBtn = () => wrapper.find({ ref: 'expandBtn' }); + + describe('when there is no @expand listener', () => { + it('does not show `View full screen` option', () => { + createWrapper(); + expect(findExpandBtn().exists()).toBe(false); + }); + }); + + describe('when there is an @expand listener', () => { + beforeEach(() => { + createWrapper({}, { listeners: { expand: () => {} } }); + }); + + it('shows the `expand` option', () => { + expect(findExpandBtn().exists()).toBe(true); + }); + + it('emits the `expand` event', () => { + const preventDefault = jest.fn(); + findExpandBtn().vm.$emit('click', { preventDefault }); + expect(wrapper.emitted('expand')).toHaveLength(1); + expect(preventDefault).toHaveBeenCalled(); + }); + }); + }); + + describe('panel alerts', () => { + 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, + }, + }); + + createWrapper(); + }); + + describe.each` + desc | metricsSavedToDb | props | isShown + ${'with permission and no metrics in db'} | ${[]} | ${{}} | ${false} + ${'with permission and related metrics in db'} | ${[graphData.metrics[0].metricId]} | ${{}} | ${true} + ${'without permission and related metrics in db'} | ${[graphData.metrics[0].metricId]} | ${{ prometheusAlertsAvailable: false }} | ${false} + ${'with permission and unrelated metrics in db'} | ${['another_metric_id']} | ${{}} | ${false} + `('$desc', ({ metricsSavedToDb, isShown, props }) => { + const showsDesc = isShown ? 'shows' : 'does not show'; + + beforeEach(() => { + setMetricsSavedToDb(metricsSavedToDb); + createWrapper({ + alertsEndpoint: '/endpoint', + prometheusAlertsAvailable: true, + ...props, + }); + return wrapper.vm.$nextTick(); + }); + + it(`${showsDesc} alert widget`, () => { + expect(findAlertsWidget().exists()).toBe(isShown); + }); + + it(`${showsDesc} alert configuration`, () => { + expect(findMenuItemAlert().exists()).toBe(isShown); + }); }); }); }); diff --git a/spec/frontend/monitoring/components/dashboard_spec.js b/spec/frontend/monitoring/components/dashboard_spec.js index 8b6ee9b3bf6..b2c9fe93cde 100644 --- a/spec/frontend/monitoring/components/dashboard_spec.js +++ b/spec/frontend/monitoring/components/dashboard_spec.js @@ -1,6 +1,8 @@ import { shallowMount, mount } from '@vue/test-utils'; import Tracking from '~/tracking'; -import { GlModal, GlDropdownItem, GlDeprecatedButton } from '@gitlab/ui'; +import { ESC_KEY, ESC_KEY_IE11 } from '~/lib/utils/keys'; +import { GlModal, GlDropdownItem, GlDeprecatedButton, GlIcon } 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'; @@ -11,13 +13,23 @@ import Dashboard from '~/monitoring/components/dashboard.vue'; import DateTimePicker from '~/vue_shared/components/date_time_picker/date_time_picker.vue'; import CustomMetricsFormFields from '~/custom_metrics/components/custom_metrics_form_fields.vue'; import DashboardsDropdown from '~/monitoring/components/dashboards_dropdown.vue'; +import EmptyState from '~/monitoring/components/empty_state.vue'; import GroupEmptyState from '~/monitoring/components/group_empty_state.vue'; -import PanelType from 'ee_else_ce/monitoring/components/panel_type.vue'; +import DashboardPanel from '~/monitoring/components/dashboard_panel.vue'; import { createStore } from '~/monitoring/stores'; import * as types from '~/monitoring/stores/mutation_types'; -import { setupStoreWithDashboard, setMetricResult, setupStoreWithData } from '../store_utils'; +import { + setupAllDashboards, + setupStoreWithDashboard, + setMetricResult, + setupStoreWithData, + setupStoreWithVariable, +} 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; @@ -27,15 +39,12 @@ describe('Dashboard', () => { const findEnvironmentsDropdown = () => wrapper.find({ ref: 'monitorEnvironmentsDropdown' }); const findAllEnvironmentsDropdownItems = () => findEnvironmentsDropdown().findAll(GlDropdownItem); const setSearchTerm = searchTerm => { - wrapper.vm.$store.commit(`monitoringDashboard/${types.SET_ENVIRONMENTS_FILTER}`, searchTerm); + store.commit(`monitoringDashboard/${types.SET_ENVIRONMENTS_FILTER}`, searchTerm); }; const createShallowWrapper = (props = {}, options = {}) => { wrapper = shallowMount(Dashboard, { propsData: { ...propsData, ...props }, - methods: { - fetchData: jest.fn(), - }, store, ...options, }); @@ -44,10 +53,8 @@ describe('Dashboard', () => { const createMountedWrapper = (props = {}, options = {}) => { wrapper = mount(Dashboard, { propsData: { ...propsData, ...props }, - methods: { - fetchData: jest.fn(), - }, store, + stubs: ['graph-group', 'dashboard-panel'], ...options, }); }; @@ -55,19 +62,18 @@ describe('Dashboard', () => { beforeEach(() => { store = createStore(); mock = new MockAdapter(axios); + jest.spyOn(store, 'dispatch').mockResolvedValue(); }); afterEach(() => { - if (wrapper) { - wrapper.destroy(); - wrapper = null; - } mock.restore(); + if (store.dispatch.mockReset) { + store.dispatch.mockReset(); + } }); describe('no metrics are available yet', () => { beforeEach(() => { - jest.spyOn(store, 'dispatch'); createShallowWrapper(); }); @@ -103,9 +109,7 @@ describe('Dashboard', () => { describe('request information to the server', () => { it('calls to set time range and fetch data', () => { - jest.spyOn(store, 'dispatch'); - - createShallowWrapper({ hasMetrics: true }, { methods: {} }); + createShallowWrapper({ hasMetrics: true }); return wrapper.vm.$nextTick().then(() => { expect(store.dispatch).toHaveBeenCalledWith( @@ -118,20 +122,20 @@ describe('Dashboard', () => { }); it('shows up a loading state', () => { - createShallowWrapper({ hasMetrics: true }, { methods: {} }); + store.state.monitoringDashboard.emptyState = 'loading'; + + createShallowWrapper({ hasMetrics: true }); return wrapper.vm.$nextTick().then(() => { - expect(wrapper.vm.emptyState).toEqual('loading'); + expect(wrapper.find(EmptyState).exists()).toBe(true); + expect(wrapper.find(EmptyState).props('selectedState')).toBe('loading'); }); }); it('hides the group panels when showPanels is false', () => { - createMountedWrapper( - { hasMetrics: true, showPanels: false }, - { stubs: ['graph-group', 'panel-type'] }, - ); + createMountedWrapper({ hasMetrics: true, showPanels: false }); - setupStoreWithData(wrapper.vm.$store); + setupStoreWithData(store); return wrapper.vm.$nextTick().then(() => { expect(wrapper.vm.showEmptyState).toEqual(false); @@ -142,9 +146,9 @@ describe('Dashboard', () => { it('fetches the metrics data with proper time window', () => { jest.spyOn(store, 'dispatch'); - createMountedWrapper({ hasMetrics: true }, { stubs: ['graph-group', 'panel-type'] }); + createMountedWrapper({ hasMetrics: true }); - wrapper.vm.$store.commit( + store.commit( `monitoringDashboard/${types.RECEIVE_ENVIRONMENTS_DATA_SUCCESS}`, environmentData, ); @@ -155,11 +159,176 @@ 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(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(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(store); + + return wrapper.vm.$nextTick().then(() => { + expect(createFlash).toHaveBeenCalled(); + expect(store.dispatch).not.toHaveBeenCalledWith( + 'monitoringDashboard/setExpandedPanel', + expect.anything(), + ); + }); + }); + }); + + describe('when the panel is expanded', () => { + let group; + let panel; + + const expandPanel = (mockGroup, mockPanel) => { + store.commit(`monitoringDashboard/${types.SET_EXPANDED_PANEL}`, { + group: mockGroup, + panel: mockPanel, + }); + }; + + beforeEach(() => { + setupStoreWithData(store); + + const { panelGroups } = store.state.monitoringDashboard.dashboard; + group = panelGroups[0].group; + [panel] = panelGroups[0].panels; + + jest.spyOn(window.history, 'pushState').mockImplementation(); + }); + + afterEach(() => { + window.history.pushState.mockRestore(); + }); + + it('URL is updated with panel parameters', () => { + createMountedWrapper({ hasMetrics: true }); + expandPanel(group, panel); + + const expectedSearch = objectToQuery({ + group, + title: panel.title, + y_label: panel.y_label, + }); + + return wrapper.vm.$nextTick(() => { + expect(window.history.pushState).toHaveBeenCalledTimes(1); + expect(window.history.pushState).toHaveBeenCalledWith( + expect.anything(), // state + expect.any(String), // document title + expect.stringContaining(`${expectedSearch}`), + ); + }); + }); + + it('URL is updated with panel parameters and custom dashboard', () => { + const dashboard = 'dashboard.yml'; + + createMountedWrapper({ hasMetrics: true, currentDashboard: dashboard }); + expandPanel(group, panel); + + const expectedSearch = objectToQuery({ + dashboard, + group, + title: panel.title, + y_label: panel.y_label, + }); + + return wrapper.vm.$nextTick(() => { + expect(window.history.pushState).toHaveBeenCalledTimes(1); + expect(window.history.pushState).toHaveBeenCalledWith( + expect.anything(), // state + expect.any(String), // document title + expect.stringContaining(`${expectedSearch}`), + ); + }); + }); + + it('URL is updated with no parameters', () => { + expandPanel(group, panel); + createMountedWrapper({ hasMetrics: true }); + expandPanel(null, null); + + return wrapper.vm.$nextTick(() => { + expect(window.history.pushState).toHaveBeenCalledTimes(1); + expect(window.history.pushState).toHaveBeenCalledWith( + expect.anything(), // state + expect.any(String), // document title + expect.not.stringMatching(/group|title|y_label/), // no panel params + ); + }); + }); + }); + describe('when all requests have been commited by the store', () => { beforeEach(() => { - createMountedWrapper({ hasMetrics: true }, { stubs: ['graph-group', 'panel-type'] }); + createMountedWrapper({ hasMetrics: true }); - setupStoreWithData(wrapper.vm.$store); + setupStoreWithData(store); return wrapper.vm.$nextTick(); }); @@ -185,10 +354,89 @@ describe('Dashboard', () => { }); }); + describe('star dashboards', () => { + const findToggleStar = () => wrapper.find({ ref: 'toggleStarBtn' }); + const findToggleStarIcon = () => findToggleStar().find(GlIcon); + + beforeEach(() => { + createShallowWrapper(); + setupAllDashboards(store); + }); + + it('toggle star button is shown', () => { + expect(findToggleStar().exists()).toBe(true); + expect(findToggleStar().props('disabled')).toBe(false); + }); + + it('toggle star button is disabled when starring is taking place', () => { + store.commit(`monitoringDashboard/${types.REQUEST_DASHBOARD_STARRING}`); + + return wrapper.vm.$nextTick(() => { + expect(findToggleStar().exists()).toBe(true); + expect(findToggleStar().props('disabled')).toBe(true); + }); + }); + + describe('when the dashboard list is loaded', () => { + // Tooltip element should wrap directly + const getToggleTooltip = () => findToggleStar().element.parentElement.getAttribute('title'); + + beforeEach(() => { + setupAllDashboards(store); + jest.spyOn(store, 'dispatch'); + }); + + it('dispatches a toggle star action', () => { + findToggleStar().vm.$emit('click'); + + return wrapper.vm.$nextTick().then(() => { + expect(store.dispatch).toHaveBeenCalledWith( + 'monitoringDashboard/toggleStarredValue', + undefined, + ); + }); + }); + + describe('when dashboard is not starred', () => { + beforeEach(() => { + store.commit(`monitoringDashboard/${types.SET_INITIAL_STATE}`, { + currentDashboard: dashboardGitResponse[0].path, + }); + return wrapper.vm.$nextTick(); + }); + + it('toggle star button shows "Star dashboard"', () => { + expect(getToggleTooltip()).toBe('Star dashboard'); + }); + + it('toggle star button shows an unstarred state', () => { + expect(findToggleStarIcon().attributes('name')).toBe('star-o'); + }); + }); + + describe('when dashboard is starred', () => { + beforeEach(() => { + store.commit(`monitoringDashboard/${types.SET_INITIAL_STATE}`, { + currentDashboard: dashboardGitResponse[1].path, + }); + return wrapper.vm.$nextTick(); + }); + + it('toggle star button shows "Star dashboard"', () => { + expect(getToggleTooltip()).toBe('Unstar dashboard'); + }); + + it('toggle star button shows a starred state', () => { + expect(findToggleStarIcon().attributes('name')).toBe('star'); + }); + }); + }); + }); + it('hides the environments dropdown list when there is no environments', () => { - createMountedWrapper({ hasMetrics: true }, { stubs: ['graph-group', 'panel-type'] }); + createMountedWrapper({ hasMetrics: true }); - setupStoreWithDashboard(wrapper.vm.$store); + setupStoreWithDashboard(store); return wrapper.vm.$nextTick().then(() => { expect(findAllEnvironmentsDropdownItems()).toHaveLength(0); @@ -196,9 +444,9 @@ describe('Dashboard', () => { }); it('renders the datetimepicker dropdown', () => { - createMountedWrapper({ hasMetrics: true }, { stubs: ['graph-group', 'panel-type'] }); + createMountedWrapper({ hasMetrics: true }); - setupStoreWithData(wrapper.vm.$store); + setupStoreWithData(store); return wrapper.vm.$nextTick().then(() => { expect(wrapper.find(DateTimePicker).exists()).toBe(true); @@ -206,9 +454,9 @@ describe('Dashboard', () => { }); it('renders the refresh dashboard button', () => { - createMountedWrapper({ hasMetrics: true }, { stubs: ['graph-group', 'panel-type'] }); + createMountedWrapper({ hasMetrics: true }); - setupStoreWithData(wrapper.vm.$store); + setupStoreWithData(store); return wrapper.vm.$nextTick().then(() => { const refreshBtn = wrapper.findAll({ ref: 'refreshDashboardBtn' }); @@ -218,14 +466,135 @@ describe('Dashboard', () => { }); }); - describe('when one of the metrics is missing', () => { + describe('variables section', () => { beforeEach(() => { createShallowWrapper({ hasMetrics: true }); + setupStoreWithData(store); + setupStoreWithVariable(store); + + return wrapper.vm.$nextTick(); + }); + + it('shows the variables section', () => { + expect(wrapper.vm.shouldShowVariablesSection).toBe(true); + }); + }); + + describe('single panel expands to "full screen" mode', () => { + const findExpandedPanel = () => wrapper.find({ ref: 'expandedPanel' }); - const { $store } = wrapper.vm; + describe('when the panel is not expanded', () => { + beforeEach(() => { + createShallowWrapper({ hasMetrics: true }); + setupStoreWithData(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 mockKeyup = key => window.dispatchEvent(new KeyboardEvent('keyup', { key })); + + const MockPanel = { + template: `<div><slot name="topLeft"/></div>`, + }; + + beforeEach(() => { + createShallowWrapper({ hasMetrics: true }, { stubs: { DashboardPanel: MockPanel } }); + setupStoreWithData(store); + + const { panelGroups } = store.state.monitoringDashboard.dashboard; + + group = panelGroups[0].group; + [panel] = panelGroups[0].panels; + + store.commit(`monitoringDashboard/${types.SET_EXPANDED_PANEL}`, { + group, + panel, + }); + + jest.spyOn(store, 'dispatch'); + + return wrapper.vm.$nextTick(); + }); - setupStoreWithDashboard($store); - setMetricResult({ $store, result: [], panel: 2 }); + 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 = + '?dashboard=config%2Fprometheus%2Fcommon_metrics.yml&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`', () => { + wrapper.find({ ref: 'goBackBtn' }).vm.$emit('click'); + + expect(store.dispatch).toHaveBeenCalledWith( + 'monitoringDashboard/clearExpandedPanel', + undefined, + ); + }); + + it('restores dashboard from full screen by typing the Escape key', () => { + mockKeyup(ESC_KEY); + expect(store.dispatch).toHaveBeenCalledWith( + `monitoringDashboard/clearExpandedPanel`, + undefined, + ); + }); + + it('restores dashboard from full screen by typing the Escape key on IE11', () => { + mockKeyup(ESC_KEY_IE11); + + expect(store.dispatch).toHaveBeenCalledWith( + `monitoringDashboard/clearExpandedPanel`, + undefined, + ); + }); + }); + }); + + describe('when one of the metrics is missing', () => { + beforeEach(() => { + createShallowWrapper({ hasMetrics: true }); + + setupStoreWithDashboard(store); + setMetricResult({ store, result: [], panel: 2 }); return wrapper.vm.$nextTick(); }); @@ -249,19 +618,17 @@ describe('Dashboard', () => { describe('searchable environments dropdown', () => { beforeEach(() => { - createMountedWrapper( - { hasMetrics: true }, - { - attachToDocument: true, - stubs: ['graph-group', 'panel-type'], - }, - ); + createMountedWrapper({ hasMetrics: true }, { attachToDocument: true }); - setupStoreWithData(wrapper.vm.$store); + setupStoreWithData(store); return wrapper.vm.$nextTick(); }); + afterEach(() => { + wrapper.destroy(); + }); + it('renders a search input', () => { expect(wrapper.find({ ref: 'monitorEnvironmentsDropdownSearch' }).exists()).toBe(true); }); @@ -304,7 +671,7 @@ describe('Dashboard', () => { }); it('shows loading element when environments fetch is still loading', () => { - wrapper.vm.$store.commit(`monitoringDashboard/${types.REQUEST_ENVIRONMENTS_DATA}`); + store.commit(`monitoringDashboard/${types.REQUEST_ENVIRONMENTS_DATA}`); return wrapper.vm .$nextTick() @@ -312,7 +679,7 @@ describe('Dashboard', () => { expect(wrapper.find({ ref: 'monitorEnvironmentsDropdownLoading' }).exists()).toBe(true); }) .then(() => { - wrapper.vm.$store.commit( + store.commit( `monitoringDashboard/${types.RECEIVE_ENVIRONMENTS_DATA_SUCCESS}`, environmentData, ); @@ -330,9 +697,11 @@ describe('Dashboard', () => { const findRearrangeButton = () => wrapper.find('.js-rearrange-button'); beforeEach(() => { - createShallowWrapper({ hasMetrics: true }); + // call original dispatch + store.dispatch.mockRestore(); - setupStoreWithData(wrapper.vm.$store); + createShallowWrapper({ hasMetrics: true }); + setupStoreWithData(store); return wrapper.vm.$nextTick(); }); @@ -420,7 +789,7 @@ describe('Dashboard', () => { createShallowWrapper({ hasMetrics: true, showHeader: false }); // all_dashboards is not defined in health dashboards - wrapper.vm.$store.commit(`monitoringDashboard/${types.SET_ALL_DASHBOARDS}`, undefined); + store.commit(`monitoringDashboard/${types.SET_ALL_DASHBOARDS}`, undefined); return wrapper.vm.$nextTick(); }); @@ -440,10 +809,7 @@ describe('Dashboard', () => { beforeEach(() => { createShallowWrapper({ hasMetrics: true }); - wrapper.vm.$store.commit( - `monitoringDashboard/${types.SET_ALL_DASHBOARDS}`, - dashboardGitResponse, - ); + setupAllDashboards(store); return wrapper.vm.$nextTick(); }); @@ -452,10 +818,11 @@ describe('Dashboard', () => { }); it('is present for a custom dashboard, and links to its edit_path', () => { - const dashboard = dashboardGitResponse[1]; // non-default dashboard - const currentDashboard = dashboard.path; + const dashboard = dashboardGitResponse[1]; + store.commit(`monitoringDashboard/${types.SET_INITIAL_STATE}`, { + currentDashboard: dashboard.path, + }); - wrapper.setProps({ currentDashboard }); return wrapper.vm.$nextTick().then(() => { expect(findEditLink().exists()).toBe(true); expect(findEditLink().attributes('href')).toBe(dashboard.project_blob_path); @@ -465,13 +832,8 @@ describe('Dashboard', () => { describe('Dashboard dropdown', () => { beforeEach(() => { - createMountedWrapper({ hasMetrics: true }, { stubs: ['graph-group', 'panel-type'] }); - - wrapper.vm.$store.commit( - `monitoringDashboard/${types.SET_ALL_DASHBOARDS}`, - dashboardGitResponse, - ); - + createMountedWrapper({ hasMetrics: true }); + setupAllDashboards(store); return wrapper.vm.$nextTick(); }); @@ -484,15 +846,12 @@ describe('Dashboard', () => { describe('external dashboard link', () => { beforeEach(() => { - createMountedWrapper( - { - hasMetrics: true, - showPanels: false, - showTimeWindowDropdown: false, - externalDashboardUrl: '/mockUrl', - }, - { stubs: ['graph-group', 'panel-type'] }, - ); + createMountedWrapper({ + hasMetrics: true, + showPanels: false, + showTimeWindowDropdown: false, + externalDashboardUrl: '/mockUrl', + }); return wrapper.vm.$nextTick(); }); @@ -507,45 +866,29 @@ describe('Dashboard', () => { }); describe('Clipboard text in panels', () => { - const currentDashboard = 'TEST_DASHBOARD'; + const currentDashboard = dashboardGitResponse[1].path; + const panelIndex = 1; // skip expanded panel - const getClipboardTextAt = i => + const getClipboardTextFirstPanel = () => wrapper - .findAll(PanelType) - .at(i) + .findAll(DashboardPanel) + .at(panelIndex) .props('clipboardText'); beforeEach(() => { + setupStoreWithData(store); createShallowWrapper({ hasMetrics: true, currentDashboard }); - setupStoreWithData(wrapper.vm.$store); - return wrapper.vm.$nextTick(); }); 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=`); - }); - - 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=`); - }); - }); + const dashboardParam = `dashboard=${encodeURIComponent(currentDashboard)}`; - it('null parameter is stripped', () => { - wrapper.setProps({ currentDashboard: null }); - - return wrapper.vm.$nextTick(() => { - expect(getClipboardTextAt(0)).not.toContain(`dashboard=`); - expect(getClipboardTextAt(0)).toContain(`y_label=`); - }); + expect(getClipboardTextFirstPanel()).toContain(dashboardParam); + expect(getClipboardTextFirstPanel()).toContain(`group=`); + expect(getClipboardTextFirstPanel()).toContain(`title=`); + expect(getClipboardTextFirstPanel()).toContain(`y_label=`); }); }); @@ -572,7 +915,7 @@ describe('Dashboard', () => { customMetricsPath: '/endpoint', customMetricsAvailable: true, }); - setupStoreWithData(wrapper.vm.$store); + setupStoreWithData(store); origPage = document.body.dataset.page; document.body.dataset.page = 'projects:environments:metrics'; diff --git a/spec/frontend/monitoring/components/dashboard_template_spec.js b/spec/frontend/monitoring/components/dashboard_template_spec.js index d1790df4189..cc0ac348b11 100644 --- a/spec/frontend/monitoring/components/dashboard_template_spec.js +++ b/spec/frontend/monitoring/components/dashboard_template_spec.js @@ -3,6 +3,7 @@ import MockAdapter from 'axios-mock-adapter'; import axios from '~/lib/utils/axios_utils'; import Dashboard from '~/monitoring/components/dashboard.vue'; import { createStore } from '~/monitoring/stores'; +import { setupAllDashboards } from '../store_utils'; import { propsData } from '../mock_data'; jest.mock('~/lib/utils/url_utility'); @@ -15,24 +16,16 @@ describe('Dashboard template', () => { beforeEach(() => { store = createStore(); mock = new MockAdapter(axios); + + setupAllDashboards(store); }); afterEach(() => { - if (wrapper) { - wrapper.destroy(); - wrapper = null; - } mock.restore(); }); it('matches the default snapshot', () => { - wrapper = shallowMount(Dashboard, { - propsData: { ...propsData }, - methods: { - fetchData: jest.fn(), - }, - store, - }); + wrapper = shallowMount(Dashboard, { propsData: { ...propsData }, store }); expect(wrapper.element).toMatchSnapshot(); }); diff --git a/spec/frontend/monitoring/components/dashboard_url_time_spec.js b/spec/frontend/monitoring/components/dashboard_url_time_spec.js index 65e9d036d1a..9bba5280007 100644 --- a/spec/frontend/monitoring/components/dashboard_url_time_spec.js +++ b/spec/frontend/monitoring/components/dashboard_url_time_spec.js @@ -27,7 +27,7 @@ describe('dashboard invalid url parameters', () => { wrapper = mount(Dashboard, { propsData: { ...propsData, ...props }, store, - stubs: ['graph-group', 'panel-type'], + stubs: ['graph-group', 'dashboard-panel'], ...options, }); }; diff --git a/spec/frontend/monitoring/components/dashboards_dropdown_spec.js b/spec/frontend/monitoring/components/dashboards_dropdown_spec.js index 0bcfabe6415..b29d86cbc5b 100644 --- a/spec/frontend/monitoring/components/dashboards_dropdown_spec.js +++ b/spec/frontend/monitoring/components/dashboards_dropdown_spec.js @@ -1,5 +1,5 @@ import { shallowMount } from '@vue/test-utils'; -import { GlDropdownItem, GlModal, GlLoadingIcon, GlAlert } from '@gitlab/ui'; +import { GlDropdownItem, GlModal, GlLoadingIcon, GlAlert, GlIcon } from '@gitlab/ui'; import waitForPromises from 'helpers/wait_for_promises'; import DashboardsDropdown from '~/monitoring/components/dashboards_dropdown.vue'; @@ -9,36 +9,48 @@ import { dashboardGitResponse } from '../mock_data'; const defaultBranch = 'master'; -function createComponent(props, opts = {}) { - const storeOpts = { - methods: { - duplicateSystemDashboard: jest.fn(), - }, - computed: { - allDashboards: () => dashboardGitResponse, - }, - }; - - return shallowMount(DashboardsDropdown, { - propsData: { - ...props, - defaultBranch, - }, - sync: false, - ...storeOpts, - ...opts, - }); -} +const starredDashboards = dashboardGitResponse.filter(({ starred }) => starred); +const notStarredDashboards = dashboardGitResponse.filter(({ starred }) => !starred); describe('DashboardsDropdown', () => { let wrapper; + let mockDashboards; + let mockSelectedDashboard; + + function createComponent(props, opts = {}) { + const storeOpts = { + methods: { + duplicateSystemDashboard: jest.fn(), + }, + computed: { + allDashboards: () => mockDashboards, + selectedDashboard: () => mockSelectedDashboard, + }, + }; + + return shallowMount(DashboardsDropdown, { + propsData: { + ...props, + defaultBranch, + }, + sync: false, + ...storeOpts, + ...opts, + }); + } const findItems = () => wrapper.findAll(GlDropdownItem); const findItemAt = i => wrapper.findAll(GlDropdownItem).at(i); const findSearchInput = () => wrapper.find({ ref: 'monitorDashboardsDropdownSearch' }); const findNoItemsMsg = () => wrapper.find({ ref: 'monitorDashboardsDropdownMsg' }); + const findStarredListDivider = () => wrapper.find({ ref: 'starredListDivider' }); const setSearchTerm = searchTerm => wrapper.setData({ searchTerm }); + beforeEach(() => { + mockDashboards = dashboardGitResponse; + mockSelectedDashboard = null; + }); + describe('when it receives dashboards data', () => { beforeEach(() => { wrapper = createComponent(); @@ -48,10 +60,14 @@ describe('DashboardsDropdown', () => { expect(findItems().length).toEqual(dashboardGitResponse.length); }); - it('displays items with the dashboard display name', () => { - expect(findItemAt(0).text()).toBe(dashboardGitResponse[0].display_name); - expect(findItemAt(1).text()).toBe(dashboardGitResponse[1].display_name); - expect(findItemAt(2).text()).toBe(dashboardGitResponse[2].display_name); + it('displays items with the dashboard display name, with starred dashboards first', () => { + expect(findItemAt(0).text()).toBe(starredDashboards[0].display_name); + expect(findItemAt(1).text()).toBe(notStarredDashboards[0].display_name); + expect(findItemAt(2).text()).toBe(notStarredDashboards[1].display_name); + }); + + it('displays separator between starred and not starred dashboards', () => { + expect(findStarredListDivider().exists()).toBe(true); }); it('displays a search input', () => { @@ -81,18 +97,71 @@ describe('DashboardsDropdown', () => { }); }); + describe('when the dashboard is missing a display name', () => { + beforeEach(() => { + mockDashboards = dashboardGitResponse.map(d => ({ ...d, display_name: undefined })); + wrapper = createComponent(); + }); + + it('displays items with the dashboard path, with starred dashboards first', () => { + expect(findItemAt(0).text()).toBe(starredDashboards[0].path); + expect(findItemAt(1).text()).toBe(notStarredDashboards[0].path); + expect(findItemAt(2).text()).toBe(notStarredDashboards[1].path); + }); + }); + + describe('when it receives starred dashboards', () => { + beforeEach(() => { + mockDashboards = starredDashboards; + wrapper = createComponent(); + }); + + it('displays an item for each dashboard', () => { + expect(findItems().length).toEqual(starredDashboards.length); + }); + + it('displays a star icon', () => { + const star = findItemAt(0).find(GlIcon); + expect(star.exists()).toBe(true); + expect(star.attributes('name')).toBe('star'); + }); + + it('displays no separator between starred and not starred dashboards', () => { + expect(findStarredListDivider().exists()).toBe(false); + }); + }); + + describe('when it receives only not-starred dashboards', () => { + beforeEach(() => { + mockDashboards = notStarredDashboards; + wrapper = createComponent(); + }); + + it('displays an item for each dashboard', () => { + expect(findItems().length).toEqual(notStarredDashboards.length); + }); + + it('displays no star icon', () => { + const star = findItemAt(0).find(GlIcon); + expect(star.exists()).toBe(false); + }); + + it('displays no separator between starred and not starred dashboards', () => { + expect(findStarredListDivider().exists()).toBe(false); + }); + }); + describe('when a system dashboard is selected', () => { let duplicateDashboardAction; let modalDirective; beforeEach(() => { + [mockSelectedDashboard] = dashboardGitResponse; modalDirective = jest.fn(); duplicateDashboardAction = jest.fn().mockResolvedValue(); wrapper = createComponent( - { - selectedDashboard: dashboardGitResponse[0], - }, + {}, { directives: { GlModal: modalDirective, @@ -260,7 +329,7 @@ describe('DashboardsDropdown', () => { expect(wrapper.emitted().selectDashboard).toBeTruthy(); }); it('emits a "selectDashboard" event with dashboard information', () => { - expect(wrapper.emitted().selectDashboard[0]).toEqual([dashboardGitResponse[1]]); + expect(wrapper.emitted().selectDashboard[0]).toEqual([dashboardGitResponse[0]]); }); }); }); diff --git a/spec/frontend/monitoring/components/duplicate_dashboard_form_spec.js b/spec/frontend/monitoring/components/duplicate_dashboard_form_spec.js index 10fd58f749d..216ec345552 100644 --- a/spec/frontend/monitoring/components/duplicate_dashboard_form_spec.js +++ b/spec/frontend/monitoring/components/duplicate_dashboard_form_spec.js @@ -81,7 +81,8 @@ describe('DuplicateDashboardForm', () => { it('with the inital form values', () => { expect(wrapper.emitted().change).toHaveLength(1); - expect(lastChange()).resolves.toEqual({ + + return expect(lastChange()).resolves.toEqual({ branch: '', commitMessage: expect.any(String), dashboard: dashboardGitResponse[0].path, @@ -92,7 +93,7 @@ describe('DuplicateDashboardForm', () => { it('containing an inputted file name', () => { setValue('fileName', 'my_dashboard.yml'); - expect(lastChange()).resolves.toMatchObject({ + return expect(lastChange()).resolves.toMatchObject({ fileName: 'my_dashboard.yml', }); }); @@ -100,7 +101,7 @@ describe('DuplicateDashboardForm', () => { it('containing a default commit message when no message is set', () => { setValue('commitMessage', ''); - expect(lastChange()).resolves.toMatchObject({ + return expect(lastChange()).resolves.toMatchObject({ commitMessage: expect.stringContaining('Create custom dashboard'), }); }); @@ -108,7 +109,7 @@ describe('DuplicateDashboardForm', () => { it('containing an inputted commit message', () => { setValue('commitMessage', 'My commit message'); - expect(lastChange()).resolves.toMatchObject({ + return expect(lastChange()).resolves.toMatchObject({ commitMessage: expect.stringContaining('My commit message'), }); }); @@ -116,7 +117,7 @@ describe('DuplicateDashboardForm', () => { it('containing an inputted branch name', () => { setValue('branchName', 'a-new-branch'); - expect(lastChange()).resolves.toMatchObject({ + return expect(lastChange()).resolves.toMatchObject({ branch: 'a-new-branch', }); }); @@ -125,13 +126,14 @@ describe('DuplicateDashboardForm', () => { setChecked(wrapper.vm.$options.radioVals.DEFAULT); setValue('branchName', 'a-new-branch'); - expect(lastChange()).resolves.toMatchObject({ - branch: defaultBranch, - }); - - return wrapper.vm.$nextTick(() => { - expect(findByRef('branchName').isVisible()).toBe(false); - }); + return Promise.all([ + expect(lastChange()).resolves.toMatchObject({ + branch: defaultBranch, + }), + wrapper.vm.$nextTick(() => { + expect(findByRef('branchName').isVisible()).toBe(false); + }), + ]); }); it('when `new` branch option is chosen, focuses on the branch name input', () => { diff --git a/spec/frontend/monitoring/components/embeds/metric_embed_spec.js b/spec/frontend/monitoring/components/embeds/metric_embed_spec.js index b829cd53479..f23823ccad6 100644 --- a/spec/frontend/monitoring/components/embeds/metric_embed_spec.js +++ b/spec/frontend/monitoring/components/embeds/metric_embed_spec.js @@ -1,6 +1,6 @@ import { createLocalVue, shallowMount } from '@vue/test-utils'; import Vuex from 'vuex'; -import PanelType from 'ee_else_ce/monitoring/components/panel_type.vue'; +import DashboardPanel from '~/monitoring/components/dashboard_panel.vue'; import { TEST_HOST } from 'helpers/test_constants'; import MetricEmbed from '~/monitoring/components/embeds/metric_embed.vue'; import { groups, initialState, metricsData, metricsWithData } from './mock_data'; @@ -62,7 +62,7 @@ describe('MetricEmbed', () => { it('shows an empty state when no metrics are present', () => { expect(wrapper.find('.metrics-embed').exists()).toBe(true); - expect(wrapper.find(PanelType).exists()).toBe(false); + expect(wrapper.find(DashboardPanel).exists()).toBe(false); }); }); @@ -90,12 +90,12 @@ describe('MetricEmbed', () => { it('shows a chart when metrics are present', () => { expect(wrapper.find('.metrics-embed').exists()).toBe(true); - expect(wrapper.find(PanelType).exists()).toBe(true); - expect(wrapper.findAll(PanelType).length).toBe(2); + expect(wrapper.find(DashboardPanel).exists()).toBe(true); + expect(wrapper.findAll(DashboardPanel).length).toBe(2); }); it('includes groupId with dashboardUrl', () => { - expect(wrapper.find(PanelType).props('groupId')).toBe(TEST_HOST); + expect(wrapper.find(DashboardPanel).props('groupId')).toBe(TEST_HOST); }); }); }); diff --git a/spec/frontend/monitoring/components/variables/custom_variable_spec.js b/spec/frontend/monitoring/components/variables/custom_variable_spec.js new file mode 100644 index 00000000000..5a2b26219b6 --- /dev/null +++ b/spec/frontend/monitoring/components/variables/custom_variable_spec.js @@ -0,0 +1,52 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; +import CustomVariable from '~/monitoring/components/variables/custom_variable.vue'; + +describe('Custom variable component', () => { + let wrapper; + const propsData = { + name: 'env', + label: 'Select environment', + value: 'Production', + options: [{ text: 'Production', value: 'prod' }, { text: 'Canary', value: 'canary' }], + }; + const createShallowWrapper = () => { + wrapper = shallowMount(CustomVariable, { + propsData, + }); + }; + + const findDropdown = () => wrapper.find(GlDropdown); + const findDropdownItems = () => wrapper.findAll(GlDropdownItem); + + it('renders dropdown element when all necessary props are passed', () => { + createShallowWrapper(); + + expect(findDropdown()).toExist(); + }); + + it('renders dropdown element with a text', () => { + createShallowWrapper(); + + expect(findDropdown().attributes('text')).toBe(propsData.value); + }); + + it('renders all the dropdown items', () => { + createShallowWrapper(); + + expect(findDropdownItems()).toHaveLength(propsData.options.length); + }); + + it('changing dropdown items triggers update', () => { + createShallowWrapper(); + jest.spyOn(wrapper.vm, '$emit'); + + findDropdownItems() + .at(1) + .vm.$emit('click'); + + return wrapper.vm.$nextTick(() => { + expect(wrapper.vm.$emit).toHaveBeenCalledWith('onUpdate', 'env', 'canary'); + }); + }); +}); diff --git a/spec/frontend/monitoring/components/variables/text_variable_spec.js b/spec/frontend/monitoring/components/variables/text_variable_spec.js new file mode 100644 index 00000000000..f01584ae8bc --- /dev/null +++ b/spec/frontend/monitoring/components/variables/text_variable_spec.js @@ -0,0 +1,59 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlFormInput } from '@gitlab/ui'; +import TextVariable from '~/monitoring/components/variables/text_variable.vue'; + +describe('Text variable component', () => { + let wrapper; + const propsData = { + name: 'pod', + label: 'Select pod', + value: 'test-pod', + }; + const createShallowWrapper = () => { + wrapper = shallowMount(TextVariable, { + propsData, + }); + }; + + const findInput = () => wrapper.find(GlFormInput); + + it('renders a text input when all props are passed', () => { + createShallowWrapper(); + + expect(findInput()).toExist(); + }); + + it('always has a default value', () => { + createShallowWrapper(); + + return wrapper.vm.$nextTick(() => { + expect(findInput().attributes('value')).toBe(propsData.value); + }); + }); + + it('triggers keyup enter', () => { + createShallowWrapper(); + jest.spyOn(wrapper.vm, '$emit'); + + findInput().element.value = 'prod-pod'; + findInput().trigger('input'); + findInput().trigger('keyup.enter'); + + return wrapper.vm.$nextTick(() => { + expect(wrapper.vm.$emit).toHaveBeenCalledWith('onUpdate', 'pod', 'prod-pod'); + }); + }); + + it('triggers blur enter', () => { + createShallowWrapper(); + jest.spyOn(wrapper.vm, '$emit'); + + findInput().element.value = 'canary-pod'; + findInput().trigger('input'); + findInput().trigger('blur'); + + return wrapper.vm.$nextTick(() => { + expect(wrapper.vm.$emit).toHaveBeenCalledWith('onUpdate', 'pod', 'canary-pod'); + }); + }); +}); diff --git a/spec/frontend/monitoring/components/variables_section_spec.js b/spec/frontend/monitoring/components/variables_section_spec.js new file mode 100644 index 00000000000..095d89c9231 --- /dev/null +++ b/spec/frontend/monitoring/components/variables_section_spec.js @@ -0,0 +1,126 @@ +import { shallowMount } from '@vue/test-utils'; +import Vuex from 'vuex'; +import VariablesSection from '~/monitoring/components/variables_section.vue'; +import CustomVariable from '~/monitoring/components/variables/custom_variable.vue'; +import TextVariable from '~/monitoring/components/variables/text_variable.vue'; +import { updateHistory, mergeUrlParams } from '~/lib/utils/url_utility'; +import { createStore } from '~/monitoring/stores'; +import { convertVariablesForURL } from '~/monitoring/utils'; +import * as types from '~/monitoring/stores/mutation_types'; +import { mockTemplatingDataResponses } from '../mock_data'; + +jest.mock('~/lib/utils/url_utility', () => ({ + updateHistory: jest.fn(), + mergeUrlParams: jest.fn(), +})); + +describe('Metrics dashboard/variables section component', () => { + let store; + let wrapper; + const sampleVariables = { + label1: mockTemplatingDataResponses.simpleText.simpleText, + label2: mockTemplatingDataResponses.advText.advText, + label3: mockTemplatingDataResponses.simpleCustom.simpleCustom, + }; + + const createShallowWrapper = () => { + wrapper = shallowMount(VariablesSection, { + store, + }); + }; + + const findTextInput = () => wrapper.findAll(TextVariable); + const findCustomInput = () => wrapper.findAll(CustomVariable); + + beforeEach(() => { + store = createStore(); + + store.state.monitoringDashboard.showEmptyState = false; + }); + + it('does not show the variables section', () => { + createShallowWrapper(); + const allInputs = findTextInput().length + findCustomInput().length; + + expect(allInputs).toBe(0); + }); + + it('shows the variables section', () => { + createShallowWrapper(); + store.commit(`monitoringDashboard/${types.SET_VARIABLES}`, sampleVariables); + + return wrapper.vm.$nextTick(() => { + const allInputs = findTextInput().length + findCustomInput().length; + + expect(allInputs).toBe(Object.keys(sampleVariables).length); + }); + }); + + describe('when changing the variable inputs', () => { + const fetchDashboardData = jest.fn(); + const updateVariableValues = jest.fn(); + + beforeEach(() => { + store = new Vuex.Store({ + modules: { + monitoringDashboard: { + namespaced: true, + state: { + showEmptyState: false, + promVariables: sampleVariables, + }, + actions: { + fetchDashboardData, + updateVariableValues, + }, + }, + }, + }); + + createShallowWrapper(); + }); + + it('merges the url params and refreshes the dashboard when a text-based variables inputs are updated', () => { + const firstInput = findTextInput().at(0); + + firstInput.vm.$emit('onUpdate', 'label1', 'test'); + + return wrapper.vm.$nextTick(() => { + expect(updateVariableValues).toHaveBeenCalled(); + expect(mergeUrlParams).toHaveBeenCalledWith( + convertVariablesForURL(sampleVariables), + window.location.href, + ); + expect(updateHistory).toHaveBeenCalled(); + expect(fetchDashboardData).toHaveBeenCalled(); + }); + }); + + it('merges the url params and refreshes the dashboard when a custom-based variables inputs are updated', () => { + const firstInput = findCustomInput().at(0); + + firstInput.vm.$emit('onUpdate', 'label1', 'test'); + + return wrapper.vm.$nextTick(() => { + expect(updateVariableValues).toHaveBeenCalled(); + expect(mergeUrlParams).toHaveBeenCalledWith( + convertVariablesForURL(sampleVariables), + window.location.href, + ); + expect(updateHistory).toHaveBeenCalled(); + expect(fetchDashboardData).toHaveBeenCalled(); + }); + }); + + it('does not merge the url params and refreshes the dashboard if the value entered is not different that is what currently stored', () => { + const firstInput = findTextInput().at(0); + + firstInput.vm.$emit('onUpdate', 'label1', 'Simple text'); + + expect(updateVariableValues).not.toHaveBeenCalled(); + expect(mergeUrlParams).not.toHaveBeenCalled(); + expect(updateHistory).not.toHaveBeenCalled(); + expect(fetchDashboardData).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/spec/frontend/monitoring/mock_data.js b/spec/frontend/monitoring/mock_data.js index 56236918c68..4611e6f1b18 100644 --- a/spec/frontend/monitoring/mock_data.js +++ b/spec/frontend/monitoring/mock_data.js @@ -34,6 +34,7 @@ const customDashboardsData = new Array(30).fill(null).map((_, idx) => ({ system_dashboard: false, project_blob_path: `${mockProjectDir}/blob/master/dashboards/.gitlab/dashboards/dashboard_${idx}.yml`, path: `.gitlab/dashboards/dashboard_${idx}.yml`, + starred: false, })); export const mockDashboardsErrorResponse = { @@ -323,6 +324,18 @@ export const dashboardGitResponse = [ system_dashboard: true, project_blob_path: null, path: 'config/prometheus/common_metrics.yml', + starred: false, + user_starred_path: `${mockProjectDir}/metrics_user_starred_dashboards?dashboard_path=config/prometheus/common_metrics.yml`, + }, + { + default: false, + display_name: 'dashboard.yml', + can_edit: true, + system_dashboard: false, + project_blob_path: `${mockProjectDir}/-/blob/master/.gitlab/dashboards/dashboard.yml`, + path: '.gitlab/dashboards/dashboard.yml', + starred: true, + user_starred_path: `${mockProjectDir}/metrics_user_starred_dashboards?dashboard_path=.gitlab/dashboards/dashboard.yml`, }, ...customDashboardsData, ]; @@ -341,7 +354,7 @@ export const metricsResult = [ }, ]; -export const graphDataPrometheusQuery = { +export const singleStatMetricsResult = { title: 'Super Chart A2', type: 'single-stat', weight: 2, @@ -489,7 +502,7 @@ export const stackedColumnMockedData = { export const barMockData = { title: 'SLA Trends - Primary Services', - type: 'bar-chart', + type: 'bar', xLabel: 'service', y_label: 'percentile', metrics: [ @@ -549,3 +562,217 @@ export const mockNamespacedData = { export const mockLogsPath = '/mockLogsPath'; export const mockLogsHref = `${mockLogsPath}?duration_seconds=${mockTimeRange.duration.seconds}`; + +const templatingVariableTypes = { + text: { + simple: 'Simple text', + advanced: { + label: 'Variable 4', + type: 'text', + options: { + default_value: 'default', + }, + }, + }, + custom: { + simple: ['value1', 'value2', 'value3'], + advanced: { + normal: { + label: 'Advanced Var', + type: 'custom', + options: { + values: [ + { value: 'value1', text: 'Var 1 Option 1' }, + { + value: 'value2', + text: 'Var 1 Option 2', + default: true, + }, + ], + }, + }, + withoutOpts: { + type: 'custom', + options: {}, + }, + withoutLabel: { + type: 'custom', + options: { + values: [ + { value: 'value1', text: 'Var 1 Option 1' }, + { + value: 'value2', + text: 'Var 1 Option 2', + default: true, + }, + ], + }, + }, + withoutType: { + label: 'Variable 2', + options: { + values: [ + { value: 'value1', text: 'Var 1 Option 1' }, + { + value: 'value2', + text: 'Var 1 Option 2', + default: true, + }, + ], + }, + }, + }, + }, +}; + +const generateMockTemplatingData = data => { + const vars = data + ? { + variables: { + ...data, + }, + } + : {}; + return { + dashboard: { + templating: vars, + }, + }; +}; + +const responseForSimpleTextVariable = { + simpleText: { + label: 'simpleText', + type: 'text', + value: 'Simple text', + }, +}; + +const responseForAdvTextVariable = { + advText: { + label: 'Variable 4', + type: 'text', + value: 'default', + }, +}; + +const responseForSimpleCustomVariable = { + simpleCustom: { + label: 'simpleCustom', + value: 'value1', + options: [ + { + default: false, + text: 'value1', + value: 'value1', + }, + { + default: false, + text: 'value2', + value: 'value2', + }, + { + default: false, + text: 'value3', + value: 'value3', + }, + ], + type: 'custom', + }, +}; + +const responseForAdvancedCustomVariableWithoutOptions = { + advCustomWithoutOpts: { + label: 'advCustomWithoutOpts', + options: [], + type: 'custom', + }, +}; + +const responseForAdvancedCustomVariableWithoutLabel = { + advCustomWithoutLabel: { + label: 'advCustomWithoutLabel', + value: 'value2', + options: [ + { + default: false, + text: 'Var 1 Option 1', + value: 'value1', + }, + { + default: true, + text: 'Var 1 Option 2', + value: 'value2', + }, + ], + type: 'custom', + }, +}; + +const responseForAdvancedCustomVariable = { + ...responseForSimpleCustomVariable, + advCustomNormal: { + label: 'Advanced Var', + value: 'value2', + options: [ + { + default: false, + text: 'Var 1 Option 1', + value: 'value1', + }, + { + default: true, + text: 'Var 1 Option 2', + value: 'value2', + }, + ], + type: 'custom', + }, +}; + +const responsesForAllVariableTypes = { + ...responseForSimpleTextVariable, + ...responseForAdvTextVariable, + ...responseForSimpleCustomVariable, + ...responseForAdvancedCustomVariable, +}; + +export const mockTemplatingData = { + emptyTemplatingProp: generateMockTemplatingData(), + emptyVariablesProp: generateMockTemplatingData({}), + simpleText: generateMockTemplatingData({ simpleText: templatingVariableTypes.text.simple }), + advText: generateMockTemplatingData({ advText: templatingVariableTypes.text.advanced }), + simpleCustom: generateMockTemplatingData({ simpleCustom: templatingVariableTypes.custom.simple }), + advCustomWithoutOpts: generateMockTemplatingData({ + advCustomWithoutOpts: templatingVariableTypes.custom.advanced.withoutOpts, + }), + advCustomWithoutType: generateMockTemplatingData({ + advCustomWithoutType: templatingVariableTypes.custom.advanced.withoutType, + }), + advCustomWithoutLabel: generateMockTemplatingData({ + advCustomWithoutLabel: templatingVariableTypes.custom.advanced.withoutLabel, + }), + simpleAndAdv: generateMockTemplatingData({ + simpleCustom: templatingVariableTypes.custom.simple, + advCustomNormal: templatingVariableTypes.custom.advanced.normal, + }), + allVariableTypes: generateMockTemplatingData({ + simpleText: templatingVariableTypes.text.simple, + advText: templatingVariableTypes.text.advanced, + simpleCustom: templatingVariableTypes.custom.simple, + advCustomNormal: templatingVariableTypes.custom.advanced.normal, + }), +}; + +export const mockTemplatingDataResponses = { + emptyTemplatingProp: {}, + emptyVariablesProp: {}, + simpleText: responseForSimpleTextVariable, + advText: responseForAdvTextVariable, + simpleCustom: responseForSimpleCustomVariable, + advCustomWithoutOpts: responseForAdvancedCustomVariableWithoutOptions, + advCustomWithoutType: {}, + advCustomWithoutLabel: responseForAdvancedCustomVariableWithoutLabel, + simpleAndAdv: responseForAdvancedCustomVariable, + allVariableTypes: responsesForAllVariableTypes, +}; diff --git a/spec/frontend/monitoring/store/actions_spec.js b/spec/frontend/monitoring/store/actions_spec.js index f312aa1fd34..8914f2e66ea 100644 --- a/spec/frontend/monitoring/store/actions_spec.js +++ b/spec/frontend/monitoring/store/actions_spec.js @@ -11,17 +11,22 @@ 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, fetchEnvironmentsData, fetchDashboardData, fetchAnnotations, + toggleStarredValue, fetchPrometheusMetric, setInitialState, filterEnvironments, + setExpandedPanel, + clearExpandedPanel, setGettingStartedEmptyState, duplicateSystemDashboard, + updateVariableValues, } from '~/monitoring/stores/actions'; import { gqClient, @@ -35,6 +40,7 @@ import { deploymentData, environmentData, annotationsData, + mockTemplatingData, dashboardGitResponse, mockDashboardsErrorResponse, } from '../mock_data'; @@ -62,9 +68,6 @@ describe('Monitoring store actions', () => { beforeEach(() => { mock = new MockAdapter(axios); - // Mock `backOff` function to remove exponential algorithm delay. - jest.useFakeTimers(); - jest.spyOn(commonUtils, 'backOff').mockImplementation(callback => { const q = new Promise((resolve, reject) => { const stop = arg => (arg instanceof Error ? reject(arg) : resolve(arg)); @@ -87,6 +90,45 @@ 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' }, + { type: 'fetchAnnotations' }, + ], + ); + }); + + 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; @@ -310,6 +352,49 @@ describe('Monitoring store actions', () => { }); }); + describe('Toggles starred value of current dashboard', () => { + const { state } = store; + let unstarredDashboard; + let starredDashboard; + + beforeEach(() => { + state.isUpdatingStarredValue = false; + [unstarredDashboard, starredDashboard] = dashboardGitResponse; + }); + + describe('toggleStarredValue', () => { + it('performs no changes if no dashboard is selected', () => { + return testAction(toggleStarredValue, null, state, [], []); + }); + + it('performs no changes if already changing starred value', () => { + state.selectedDashboard = unstarredDashboard; + state.isUpdatingStarredValue = true; + return testAction(toggleStarredValue, null, state, [], []); + }); + + it('stars dashboard if it is not starred', () => { + state.selectedDashboard = unstarredDashboard; + mock.onPost(unstarredDashboard.user_starred_path).reply(200); + + return testAction(toggleStarredValue, null, state, [ + { type: types.REQUEST_DASHBOARD_STARRING }, + { type: types.RECEIVE_DASHBOARD_STARRING_SUCCESS, payload: true }, + ]); + }); + + it('unstars dashboard if it is starred', () => { + state.selectedDashboard = starredDashboard; + mock.onPost(starredDashboard.user_starred_path).reply(200); + + return testAction(toggleStarredValue, null, state, [ + { type: types.REQUEST_DASHBOARD_STARRING }, + { type: types.RECEIVE_DASHBOARD_STARRING_FAILURE }, + ]); + }); + }); + }); + describe('Set initial state', () => { let mockedState; beforeEach(() => { @@ -357,6 +442,29 @@ describe('Monitoring store actions', () => { ); }); }); + + describe('updateVariableValues', () => { + let mockedState; + beforeEach(() => { + mockedState = storeState(); + }); + it('should commit UPDATE_VARIABLE_VALUES mutation', done => { + testAction( + updateVariableValues, + { pod: 'POD' }, + mockedState, + [ + { + type: types.UPDATE_VARIABLE_VALUES, + payload: { pod: 'POD' }, + }, + ], + [], + done, + ); + }); + }); + describe('fetchDashboard', () => { let dispatch; let state; @@ -467,6 +575,33 @@ describe('Monitoring store actions', () => { ); expect(dispatch).toHaveBeenCalledWith('fetchDashboardData'); }); + + it('stores templating variables', () => { + const response = { + ...metricsDashboardResponse.dashboard, + ...mockTemplatingData.allVariableTypes.dashboard, + }; + + receiveMetricsDashboardSuccess( + { state, commit, dispatch }, + { + response: { + ...metricsDashboardResponse, + dashboard: { + ...metricsDashboardResponse.dashboard, + ...mockTemplatingData.allVariableTypes.dashboard, + }, + }, + }, + ); + + expect(commit).toHaveBeenCalledWith( + types.RECEIVE_METRICS_DASHBOARD_SUCCESS, + + response, + ); + }); + it('sets the dashboards loaded from the repository', () => { const params = {}; const response = metricsDashboardResponse; @@ -873,4 +1008,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 } }], + [], + ); + }); + }); }); diff --git a/spec/frontend/monitoring/store/getters_spec.js b/spec/frontend/monitoring/store/getters_spec.js index f040876b832..365052e68e3 100644 --- a/spec/frontend/monitoring/store/getters_spec.js +++ b/spec/frontend/monitoring/store/getters_spec.js @@ -3,7 +3,12 @@ import * as getters from '~/monitoring/stores/getters'; import mutations from '~/monitoring/stores/mutations'; import * as types from '~/monitoring/stores/mutation_types'; import { metricStates } from '~/monitoring/constants'; -import { environmentData, metricsResult } from '../mock_data'; +import { + environmentData, + metricsResult, + dashboardGitResponse, + mockTemplatingDataResponses, +} from '../mock_data'; import { metricsDashboardPayload, metricResultStatus, @@ -323,4 +328,81 @@ describe('Monitoring store Getters', () => { expect(metricsSavedToDb).toEqual([`${id1}_${metric1.id}`, `${id2}_${metric2.id}`]); }); }); + + describe('getCustomVariablesArray', () => { + let state; + + beforeEach(() => { + state = { + promVariables: {}, + }; + }); + + it('transforms the promVariables object to an array in the [variable, variable_value] format for all variable types', () => { + mutations[types.SET_VARIABLES](state, mockTemplatingDataResponses.allVariableTypes); + const variablesArray = getters.getCustomVariablesArray(state); + + expect(variablesArray).toEqual([ + 'simpleText', + 'Simple text', + 'advText', + 'default', + 'simpleCustom', + 'value1', + 'advCustomNormal', + 'value2', + ]); + }); + + it('transforms the promVariables object to an empty array when no keys are present', () => { + mutations[types.SET_VARIABLES](state, {}); + const variablesArray = getters.getCustomVariablesArray(state); + + expect(variablesArray).toEqual([]); + }); + }); + + describe('selectedDashboard', () => { + const { selectedDashboard } = getters; + + it('returns a dashboard', () => { + const state = { + allDashboards: dashboardGitResponse, + currentDashboard: dashboardGitResponse[0].path, + }; + expect(selectedDashboard(state)).toEqual(dashboardGitResponse[0]); + }); + + it('returns a non-default dashboard', () => { + const state = { + allDashboards: dashboardGitResponse, + currentDashboard: dashboardGitResponse[1].path, + }; + expect(selectedDashboard(state)).toEqual(dashboardGitResponse[1]); + }); + + it('returns a default dashboard when no dashboard is selected', () => { + const state = { + allDashboards: dashboardGitResponse, + currentDashboard: null, + }; + expect(selectedDashboard(state)).toEqual(dashboardGitResponse[0]); + }); + + it('returns a default dashboard when dashboard cannot be found', () => { + const state = { + allDashboards: dashboardGitResponse, + currentDashboard: 'wrong_path', + }; + expect(selectedDashboard(state)).toEqual(dashboardGitResponse[0]); + }); + + it('returns null when no dashboards are present', () => { + const state = { + allDashboards: [], + currentDashboard: dashboardGitResponse[0].path, + }; + expect(selectedDashboard(state)).toEqual(null); + }); + }); }); diff --git a/spec/frontend/monitoring/store/mutations_spec.js b/spec/frontend/monitoring/store/mutations_spec.js index 1452e9bc491..4306243689a 100644 --- a/spec/frontend/monitoring/store/mutations_spec.js +++ b/spec/frontend/monitoring/store/mutations_spec.js @@ -72,6 +72,49 @@ describe('Monitoring mutations', () => { }); }); + describe('Dashboard starring mutations', () => { + it('REQUEST_DASHBOARD_STARRING', () => { + stateCopy = { isUpdatingStarredValue: false }; + mutations[types.REQUEST_DASHBOARD_STARRING](stateCopy); + + expect(stateCopy.isUpdatingStarredValue).toBe(true); + }); + + describe('RECEIVE_DASHBOARD_STARRING_SUCCESS', () => { + let allDashboards; + + beforeEach(() => { + allDashboards = [...dashboardGitResponse]; + stateCopy = { + allDashboards, + currentDashboard: allDashboards[1].path, + isUpdatingStarredValue: true, + }; + }); + + it('sets a dashboard as starred', () => { + mutations[types.RECEIVE_DASHBOARD_STARRING_SUCCESS](stateCopy, true); + + expect(stateCopy.isUpdatingStarredValue).toBe(false); + expect(stateCopy.allDashboards[1].starred).toBe(true); + }); + + it('sets a dashboard as unstarred', () => { + mutations[types.RECEIVE_DASHBOARD_STARRING_SUCCESS](stateCopy, false); + + expect(stateCopy.isUpdatingStarredValue).toBe(false); + expect(stateCopy.allDashboards[1].starred).toBe(false); + }); + }); + + it('RECEIVE_DASHBOARD_STARRING_FAILURE', () => { + stateCopy = { isUpdatingStarredValue: true }; + mutations[types.RECEIVE_DASHBOARD_STARRING_FAILURE](stateCopy); + + expect(stateCopy.isUpdatingStarredValue).toBe(false); + }); + }); + describe('RECEIVE_DEPLOYMENTS_DATA_SUCCESS', () => { it('stores the deployment data', () => { stateCopy.deploymentData = []; @@ -342,4 +385,53 @@ 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); + }); + }); + + describe('SET_VARIABLES', () => { + it('stores an empty variables array when no custom variables are given', () => { + mutations[types.SET_VARIABLES](stateCopy, {}); + + expect(stateCopy.promVariables).toEqual({}); + }); + + it('stores variables in the key key_value format in the array', () => { + mutations[types.SET_VARIABLES](stateCopy, { pod: 'POD', stage: 'main ops' }); + + expect(stateCopy.promVariables).toEqual({ pod: 'POD', stage: 'main ops' }); + }); + }); + + describe('UPDATE_VARIABLE_VALUES', () => { + afterEach(() => { + mutations[types.SET_VARIABLES](stateCopy, {}); + }); + + it('updates only the value of the variable in promVariables', () => { + mutations[types.SET_VARIABLES](stateCopy, { environment: { value: 'prod', type: 'text' } }); + mutations[types.UPDATE_VARIABLE_VALUES](stateCopy, { key: 'environment', value: 'new prod' }); + + expect(stateCopy.promVariables).toEqual({ environment: { value: 'new prod', type: 'text' } }); + }); + }); }); diff --git a/spec/frontend/monitoring/store/utils_spec.js b/spec/frontend/monitoring/store/utils_spec.js index 7ee2a16b4bd..fe5754e1216 100644 --- a/spec/frontend/monitoring/store/utils_spec.js +++ b/spec/frontend/monitoring/store/utils_spec.js @@ -27,6 +27,7 @@ describe('mapToDashboardViewModel', () => { group: 'Group 1', panels: [ { + id: 'ID_ABC', title: 'Title A', xLabel: '', xAxis: { @@ -49,6 +50,7 @@ describe('mapToDashboardViewModel', () => { key: 'group-1-0', panels: [ { + id: 'ID_ABC', title: 'Title A', type: 'chart-type', xLabel: '', @@ -127,11 +129,13 @@ describe('mapToDashboardViewModel', () => { it('panel with x_label', () => { setupWithPanel({ + id: 'ID_123', title: panelTitle, x_label: 'x label', }); expect(getMappedPanel()).toEqual({ + id: 'ID_123', title: panelTitle, xLabel: 'x label', xAxis: { @@ -149,10 +153,12 @@ describe('mapToDashboardViewModel', () => { it('group y_axis defaults', () => { setupWithPanel({ + id: 'ID_456', title: panelTitle, }); expect(getMappedPanel()).toEqual({ + id: 'ID_456', title: panelTitle, xLabel: '', y_label: '', diff --git a/spec/frontend/monitoring/store/variable_mapping_spec.js b/spec/frontend/monitoring/store/variable_mapping_spec.js new file mode 100644 index 00000000000..47681ac7c65 --- /dev/null +++ b/spec/frontend/monitoring/store/variable_mapping_spec.js @@ -0,0 +1,22 @@ +import { parseTemplatingVariables } from '~/monitoring/stores/variable_mapping'; +import { mockTemplatingData, mockTemplatingDataResponses } from '../mock_data'; + +describe('parseTemplatingVariables', () => { + it.each` + case | input | expected + ${'Returns empty object for no dashboard input'} | ${{}} | ${{}} + ${'Returns empty object for empty dashboard input'} | ${{ dashboard: {} }} | ${{}} + ${'Returns empty object for empty templating prop'} | ${mockTemplatingData.emptyTemplatingProp} | ${{}} + ${'Returns empty object for empty variables prop'} | ${mockTemplatingData.emptyVariablesProp} | ${{}} + ${'Returns parsed object for simple text variable'} | ${mockTemplatingData.simpleText} | ${mockTemplatingDataResponses.simpleText} + ${'Returns parsed object for advanced text variable'} | ${mockTemplatingData.advText} | ${mockTemplatingDataResponses.advText} + ${'Returns parsed object for simple custom variable'} | ${mockTemplatingData.simpleCustom} | ${mockTemplatingDataResponses.simpleCustom} + ${'Returns parsed object for advanced custom variable without options'} | ${mockTemplatingData.advCustomWithoutOpts} | ${mockTemplatingDataResponses.advCustomWithoutOpts} + ${'Returns parsed object for advanced custom variable without type'} | ${mockTemplatingData.advCustomWithoutType} | ${{}} + ${'Returns parsed object for advanced custom variable without label'} | ${mockTemplatingData.advCustomWithoutLabel} | ${mockTemplatingDataResponses.advCustomWithoutLabel} + ${'Returns parsed object for simple and advanced custom variables'} | ${mockTemplatingData.simpleAndAdv} | ${mockTemplatingDataResponses.simpleAndAdv} + ${'Returns parsed object for all variable types'} | ${mockTemplatingData.allVariableTypes} | ${mockTemplatingDataResponses.allVariableTypes} + `('$case', ({ input, expected }) => { + expect(parseTemplatingVariables(input?.dashboard?.templating)).toEqual(expected); + }); +}); diff --git a/spec/frontend/monitoring/store_utils.js b/spec/frontend/monitoring/store_utils.js index d764a79ccc3..338af79dbbe 100644 --- a/spec/frontend/monitoring/store_utils.js +++ b/spec/frontend/monitoring/store_utils.js @@ -1,34 +1,49 @@ import * as types from '~/monitoring/stores/mutation_types'; -import { metricsResult, environmentData } from './mock_data'; +import { metricsResult, environmentData, dashboardGitResponse } from './mock_data'; import { metricsDashboardPayload } from './fixture_data'; -export const setMetricResult = ({ $store, result, group = 0, panel = 0, metric = 0 }) => { - const { dashboard } = $store.state.monitoringDashboard; +export const setMetricResult = ({ store, result, group = 0, panel = 0, metric = 0 }) => { + const { dashboard } = store.state.monitoringDashboard; const { metricId } = dashboard.panelGroups[group].panels[panel].metrics[metric]; - $store.commit(`monitoringDashboard/${types.RECEIVE_METRIC_RESULT_SUCCESS}`, { + store.commit(`monitoringDashboard/${types.RECEIVE_METRIC_RESULT_SUCCESS}`, { metricId, result, }); }; -const setEnvironmentData = $store => { - $store.commit(`monitoringDashboard/${types.RECEIVE_ENVIRONMENTS_DATA_SUCCESS}`, environmentData); +const setEnvironmentData = store => { + store.commit(`monitoringDashboard/${types.RECEIVE_ENVIRONMENTS_DATA_SUCCESS}`, environmentData); }; -export const setupStoreWithDashboard = $store => { - $store.commit( +export const setupAllDashboards = store => { + store.commit(`monitoringDashboard/${types.SET_ALL_DASHBOARDS}`, dashboardGitResponse); +}; + +export const setupStoreWithDashboard = store => { + store.commit( + `monitoringDashboard/${types.RECEIVE_METRICS_DASHBOARD_SUCCESS}`, + metricsDashboardPayload, + ); + store.commit( `monitoringDashboard/${types.RECEIVE_METRICS_DASHBOARD_SUCCESS}`, metricsDashboardPayload, ); }; -export const setupStoreWithData = $store => { - setupStoreWithDashboard($store); +export const setupStoreWithVariable = store => { + store.commit(`monitoringDashboard/${types.SET_VARIABLES}`, { + label1: 'pod', + }); +}; + +export const setupStoreWithData = store => { + setupAllDashboards(store); + setupStoreWithDashboard(store); - setMetricResult({ $store, result: [], panel: 0 }); - setMetricResult({ $store, result: metricsResult, panel: 1 }); - setMetricResult({ $store, result: metricsResult, panel: 2 }); + setMetricResult({ store, result: [], panel: 0 }); + setMetricResult({ store, result: metricsResult, panel: 1 }); + setMetricResult({ store, result: metricsResult, panel: 2 }); - setEnvironmentData($store); + setEnvironmentData(store); }; diff --git a/spec/frontend/monitoring/stubs/modal_stub.js b/spec/frontend/monitoring/stubs/modal_stub.js new file mode 100644 index 00000000000..4cd0362096e --- /dev/null +++ b/spec/frontend/monitoring/stubs/modal_stub.js @@ -0,0 +1,11 @@ +const ModalStub = { + name: 'glmodal-stub', + template: ` + <div> + <slot></slot> + <slot name="modal-ok"></slot> + </div> + `, +}; + +export default ModalStub; diff --git a/spec/frontend/monitoring/utils_spec.js b/spec/frontend/monitoring/utils_spec.js index 0bb1b987b2e..aa5a4459a72 100644 --- a/spec/frontend/monitoring/utils_spec.js +++ b/spec/frontend/monitoring/utils_spec.js @@ -1,15 +1,13 @@ 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, - graphDataPrometheusQuery, + singleStatMetricsResult, 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'; @@ -89,7 +82,7 @@ describe('monitoring/utils', () => { it('validates data with the query format', () => { const validGraphData = monitoringUtils.graphDataValidatorForValues( true, - graphDataPrometheusQuery, + singleStatMetricsResult, ); expect(validGraphData).toBe(true); @@ -112,7 +105,7 @@ describe('monitoring/utils', () => { let threeMetrics; let fourMetrics; beforeEach(() => { - oneMetric = graphDataPrometheusQuery; + oneMetric = singleStatMetricsResult; threeMetrics = anomalyMockGraphData; const metrics = [...threeMetrics.metrics]; @@ -139,18 +132,25 @@ describe('monitoring/utils', () => { }); describe('timeRangeFromUrl', () => { - const { timeRangeFromUrl } = monitoringUtils; + beforeEach(() => { + jest.spyOn(urlUtils, 'queryToObject'); + }); + + afterEach(() => { + urlUtils.queryToObject.mockRestore(); + }); - it('returns a fixed range when query contains `start` and `end` paramters are given', () => { - queryToObject.mockReturnValueOnce(range); + 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,59 @@ 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('getPromCustomVariablesFromUrl', () => { + const { getPromCustomVariablesFromUrl } = monitoringUtils; + + beforeEach(() => { + jest.spyOn(urlUtils, 'queryToObject'); + }); + + afterEach(() => { + urlUtils.queryToObject.mockRestore(); + }); + + it('returns an object with only the custom variables', () => { + urlUtils.queryToObject.mockReturnValueOnce({ + dashboard: '.gitlab/dashboards/custom_dashboard.yml', + y_label: 'memory usage', + group: 'kubernetes', + title: 'Kubernetes memory total', + start: '2020-05-06', + end: '2020-05-07', + duration_seconds: '86400', + direction: 'left', + anchor: 'top', + pod: 'POD', + 'var-pod': 'POD', + }); + + expect(getPromCustomVariablesFromUrl()).toEqual(expect.objectContaining({ pod: 'POD' })); + }); + + it('returns an empty object when no custom variables are present', () => { + urlUtils.queryToObject.mockReturnValueOnce({ + dashboard: '.gitlab/dashboards/custom_dashboard.yml', + }); + + expect(getPromCustomVariablesFromUrl()).toStrictEqual({}); }); }); 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 +220,126 @@ 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; + + const getUrlParams = url => urlUtils.queryToObject(url.split('?')[1]); + + it('returns URL for a panel when query parameters are given', () => { + const params = getUrlParams(panelToUrl(dashboard, {}, panelGroup.group, panel)); + + expect(params).toEqual( + expect.objectContaining({ + dashboard, + group: panelGroup.group, + title: panel.title, + y_label: panel.y_label, + }), + ); + }); + + it('returns a dashboard only URL if group is missing', () => { + const params = getUrlParams(panelToUrl(dashboard, {}, null, panel)); + expect(params).toEqual(expect.objectContaining({ dashboard: 'metrics.yml' })); + }); + + it('returns a dashboard only URL if panel is missing', () => { + const params = getUrlParams(panelToUrl(dashboard, {}, panelGroup.group, null)); + expect(params).toEqual(expect.objectContaining({ dashboard: 'metrics.yml' })); + }); + + it('returns URL for a panel when query paramters are given including custom variables', () => { + const params = getUrlParams(panelToUrl(dashboard, { pod: 'pod' }, panelGroup.group, null)); + expect(params).toEqual(expect.objectContaining({ dashboard: 'metrics.yml', pod: 'pod' })); }); }); @@ -271,4 +405,108 @@ describe('monitoring/utils', () => { }); }); }); + + describe('removePrefixFromLabel', () => { + it.each` + input | expected + ${undefined} | ${''} + ${null} | ${''} + ${''} | ${''} + ${' '} | ${' '} + ${'pod-1'} | ${'pod-1'} + ${'pod-var-1'} | ${'pod-var-1'} + ${'pod-1-var'} | ${'pod-1-var'} + ${'podvar--1'} | ${'podvar--1'} + ${'povar-d-1'} | ${'povar-d-1'} + ${'var-pod-1'} | ${'pod-1'} + ${'var-var-pod-1'} | ${'var-pod-1'} + ${'varvar-pod-1'} | ${'varvar-pod-1'} + ${'var-pod-1-var-'} | ${'pod-1-var-'} + `('removePrefixFromLabel returns $expected with input $input', ({ input, expected }) => { + expect(monitoringUtils.removePrefixFromLabel(input)).toEqual(expected); + }); + }); + + describe('mergeURLVariables', () => { + beforeEach(() => { + jest.spyOn(urlUtils, 'queryToObject'); + }); + + afterEach(() => { + urlUtils.queryToObject.mockRestore(); + }); + + it('returns empty object if variables are not defined in yml or URL', () => { + urlUtils.queryToObject.mockReturnValueOnce({}); + + expect(monitoringUtils.mergeURLVariables({})).toEqual({}); + }); + + it('returns empty object if variables are defined in URL but not in yml', () => { + urlUtils.queryToObject.mockReturnValueOnce({ + 'var-env': 'one', + 'var-instance': 'localhost', + }); + + expect(monitoringUtils.mergeURLVariables({})).toEqual({}); + }); + + it('returns yml variables if variables defined in yml but not in the URL', () => { + urlUtils.queryToObject.mockReturnValueOnce({}); + + const params = { + env: 'one', + instance: 'localhost', + }; + + expect(monitoringUtils.mergeURLVariables(params)).toEqual(params); + }); + + it('returns yml variables if variables defined in URL do not match with yml variables', () => { + const urlParams = { + 'var-env': 'one', + 'var-instance': 'localhost', + }; + const ymlParams = { + pod: { value: 'one' }, + service: { value: 'database' }, + }; + urlUtils.queryToObject.mockReturnValueOnce(urlParams); + + expect(monitoringUtils.mergeURLVariables(ymlParams)).toEqual(ymlParams); + }); + + it('returns merged yml and URL variables if there is some match', () => { + const urlParams = { + 'var-env': 'one', + 'var-instance': 'localhost:8080', + }; + const ymlParams = { + instance: { value: 'localhost' }, + service: { value: 'database' }, + }; + + const merged = { + instance: { value: 'localhost:8080' }, + service: { value: 'database' }, + }; + + urlUtils.queryToObject.mockReturnValueOnce(urlParams); + + expect(monitoringUtils.mergeURLVariables(ymlParams)).toEqual(merged); + }); + }); + + describe('convertVariablesForURL', () => { + it.each` + input | expected + ${undefined} | ${{}} + ${null} | ${{}} + ${{}} | ${{}} + ${{ env: { value: 'prod' } }} | ${{ 'var-env': 'prod' }} + ${{ 'var-env': { value: 'prod' } }} | ${{ 'var-var-env': 'prod' }} + `('convertVariablesForURL returns $expected with input $input', ({ input, expected }) => { + expect(monitoringUtils.convertVariablesForURL(input)).toEqual(expected); + }); + }); }); diff --git a/spec/frontend/monitoring/validators_spec.js b/spec/frontend/monitoring/validators_spec.js new file mode 100644 index 00000000000..0c3d77a7d98 --- /dev/null +++ b/spec/frontend/monitoring/validators_spec.js @@ -0,0 +1,80 @@ +import { alertsValidator, queriesValidator } from '~/monitoring/validators'; + +describe('alertsValidator', () => { + const validAlert = { + alert_path: 'my/alert.json', + operator: '<', + threshold: 5, + metricId: '8', + }; + it('requires all alerts to have an alert path', () => { + const { operator, threshold, metricId } = validAlert; + const input = { + [validAlert.alert_path]: { + operator, + threshold, + metricId, + }, + }; + expect(alertsValidator(input)).toEqual(false); + }); + it('requires that the object key matches the alert path', () => { + const input = { + undefined: validAlert, + }; + expect(alertsValidator(input)).toEqual(false); + }); + it('requires all alerts to have a metric id', () => { + const input = { + [validAlert.alert_path]: { ...validAlert, metricId: undefined }, + }; + expect(alertsValidator(input)).toEqual(false); + }); + it('requires the metricId to be a string', () => { + const input = { + [validAlert.alert_path]: { ...validAlert, metricId: 8 }, + }; + expect(alertsValidator(input)).toEqual(false); + }); + it('requires all alerts to have an operator', () => { + const input = { + [validAlert.alert_path]: { ...validAlert, operator: '' }, + }; + expect(alertsValidator(input)).toEqual(false); + }); + it('requires all alerts to have an numeric threshold', () => { + const input = { + [validAlert.alert_path]: { ...validAlert, threshold: '60' }, + }; + expect(alertsValidator(input)).toEqual(false); + }); + it('correctly identifies a valid alerts object', () => { + const input = { + [validAlert.alert_path]: validAlert, + }; + expect(alertsValidator(input)).toEqual(true); + }); +}); +describe('queriesValidator', () => { + const validQuery = { + metricId: '8', + alert_path: 'alert', + label: 'alert-label', + }; + it('requires all alerts to have a metric id', () => { + const input = [{ ...validQuery, metricId: undefined }]; + expect(queriesValidator(input)).toEqual(false); + }); + it('requires the metricId to be a string', () => { + const input = [{ ...validQuery, metricId: 8 }]; + expect(queriesValidator(input)).toEqual(false); + }); + it('requires all queries to have a label', () => { + const input = [{ ...validQuery, label: undefined }]; + expect(queriesValidator(input)).toEqual(false); + }); + it('correctly identifies a valid queries array', () => { + const input = [validQuery]; + expect(queriesValidator(input)).toEqual(true); + }); +}); |