Commit 6b8040dc authored by GitLab Bot's avatar GitLab Bot

Add latest changes from gitlab-org/gitlab@master

parent 7b875aa3
...@@ -3,11 +3,17 @@ import { mapActions, mapState } from 'vuex'; ...@@ -3,11 +3,17 @@ import { mapActions, mapState } from 'vuex';
import { import {
GlEmptyState, GlEmptyState,
GlButton, GlButton,
GlIcon,
GlLink, GlLink,
GlLoadingIcon, GlLoadingIcon,
GlTable, GlTable,
GlSearchBoxByClick, GlFormInput,
GlDropdown,
GlDropdownItem,
GlDropdownDivider,
GlTooltipDirective,
} from '@gitlab/ui'; } from '@gitlab/ui';
import AccessorUtils from '~/lib/utils/accessor';
import Icon from '~/vue_shared/components/icon.vue'; import Icon from '~/vue_shared/components/icon.vue';
import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue'; import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue';
import { __ } from '~/locale'; import { __ } from '~/locale';
...@@ -24,14 +30,19 @@ export default { ...@@ -24,14 +30,19 @@ export default {
components: { components: {
GlEmptyState, GlEmptyState,
GlButton, GlButton,
GlDropdown,
GlDropdownItem,
GlDropdownDivider,
GlIcon,
GlLink, GlLink,
GlLoadingIcon, GlLoadingIcon,
GlTable, GlTable,
GlSearchBoxByClick, GlFormInput,
Icon, Icon,
TimeAgo, TimeAgo,
}, },
directives: { directives: {
GlTooltip: GlTooltipDirective,
TrackEvent: TrackEventDirective, TrackEvent: TrackEventDirective,
}, },
props: { props: {
...@@ -56,13 +67,14 @@ export default { ...@@ -56,13 +67,14 @@ export default {
required: true, required: true,
}, },
}, },
hasLocalStorage: AccessorUtils.isLocalStorageAccessSafe(),
data() { data() {
return { return {
errorSearchQuery: '', errorSearchQuery: '',
}; };
}, },
computed: { computed: {
...mapState('list', ['errors', 'externalUrl', 'loading']), ...mapState('list', ['errors', 'externalUrl', 'loading', 'recentSearches']),
}, },
created() { created() {
if (this.errorTrackingEnabled) { if (this.errorTrackingEnabled) {
...@@ -70,9 +82,23 @@ export default { ...@@ -70,9 +82,23 @@ export default {
} }
}, },
methods: { methods: {
...mapActions('list', ['startPolling', 'restartPolling']), ...mapActions('list', [
'startPolling',
'restartPolling',
'addRecentSearch',
'clearRecentSearches',
'loadRecentSearches',
'setIndexPath',
]),
filterErrors() { filterErrors() {
this.startPolling(`${this.indexPath}?search_term=${this.errorSearchQuery}`); const searchTerm = this.errorSearchQuery.trim();
this.addRecentSearch(searchTerm);
this.startPolling(`${this.indexPath}?search_term=${searchTerm}`);
},
setSearchText(text) {
this.errorSearchQuery = text;
this.filterErrors();
}, },
trackViewInSentryOptions, trackViewInSentryOptions,
getDetailsLink(errorId) { getDetailsLink(errorId) {
...@@ -85,81 +111,119 @@ export default { ...@@ -85,81 +111,119 @@ export default {
<template> <template>
<div> <div>
<div v-if="errorTrackingEnabled"> <div v-if="errorTrackingEnabled">
<div> <div class="d-flex flex-row justify-content-around bg-secondary border p-3">
<div class="d-flex flex-row justify-content-around bg-secondary border"> <div class="filtered-search-box">
<gl-search-box-by-click <gl-dropdown
v-model="errorSearchQuery" :text="__('Recent searches')"
class="col-lg-10 m-3 p-0" class="filtered-search-history-dropdown-wrapper d-none d-md-block"
:placeholder="__('Search or filter results...')" toggle-class="filtered-search-history-dropdown-toggle-button"
type="search" :disabled="loading"
autofocus
@submit="filterErrors"
/>
<gl-button
v-track-event="trackViewInSentryOptions(externalUrl)"
class="m-3"
variant="primary"
:href="externalUrl"
target="_blank"
> >
{{ __('View in Sentry') }} <div v-if="!$options.hasLocalStorage" class="px-3">
<icon name="external-link" class="flex-shrink-0" /> {{ __('This feature requires local storage to be enabled') }}
</gl-button> </div>
</div> <template v-else-if="recentSearches.length > 0">
<gl-dropdown-item
<div v-if="loading" class="py-3"> v-for="searchQuery in recentSearches"
<gl-loading-icon size="md" /> :key="searchQuery"
@click="setSearchText(searchQuery)"
>{{ searchQuery }}</gl-dropdown-item
>
<gl-dropdown-divider />
<gl-dropdown-item ref="clearRecentSearches" @click="clearRecentSearches">{{
__('Clear recent searches')
}}</gl-dropdown-item>
</template>
<div v-else class="px-3">{{ __("You don't have any recent searches") }}</div>
</gl-dropdown>
<div class="filtered-search-input-container flex-fill">
<gl-form-input
v-model="errorSearchQuery"
class="pl-2 filtered-search"
:disabled="loading"
:placeholder="__('Search or filter results…')"
autofocus
@keyup.enter.native="filterErrors"
/>
</div>
<div class="gl-search-box-by-type-right-icons">
<gl-button
v-if="errorSearchQuery.length > 0"
v-gl-tooltip.hover
:title="__('Clear')"
class="clear-search text-secondary"
name="clear"
@click="errorSearchQuery = ''"
>
<gl-icon name="close" :size="12" />
</gl-button>
</div>
</div> </div>
<gl-table <gl-button
v-else v-track-event="trackViewInSentryOptions(externalUrl)"
class="mt-3" class="ml-3"
:items="errors" variant="primary"
:fields="$options.fields" :href="externalUrl"
:show-empty="true" target="_blank"
fixed
stacked="sm"
> >
<template slot="HEAD_events" slot-scope="data"> {{ __('View in Sentry') }}
<div class="text-md-right">{{ data.label }}</div> <icon name="external-link" class="flex-shrink-0" />
</template> </gl-button>
<template slot="HEAD_users" slot-scope="data"> </div>
<div class="text-md-right">{{ data.label }}</div>
</template>
<template slot="error" slot-scope="errors">
<div class="d-flex flex-column">
<gl-link class="d-flex text-dark" :href="getDetailsLink(errors.item.id)">
<strong class="text-truncate">{{ errors.item.title.trim() }}</strong>
</gl-link>
<span class="text-secondary text-truncate">
{{ errors.item.culprit }}
</span>
</div>
</template>
<template slot="events" slot-scope="errors"> <div v-if="loading" class="py-3">
<div class="text-md-right">{{ errors.item.count }}</div> <gl-loading-icon size="md" />
</template> </div>
<template slot="users" slot-scope="errors"> <gl-table
<div class="text-md-right">{{ errors.item.userCount }}</div> v-else
</template> class="mt-3"
:items="errors"
:fields="$options.fields"
:show-empty="true"
fixed
stacked="sm"
>
<template slot="HEAD_events" slot-scope="data">
<div class="text-md-right">{{ data.label }}</div>
</template>
<template slot="HEAD_users" slot-scope="data">
<div class="text-md-right">{{ data.label }}</div>
</template>
<template slot="error" slot-scope="errors">
<div class="d-flex flex-column">
<gl-link class="d-flex text-dark" :href="getDetailsLink(errors.item.id)">
<strong class="text-truncate">{{ errors.item.title.trim() }}</strong>
</gl-link>
<span class="text-secondary text-truncate">
{{ errors.item.culprit }}
</span>
</div>
</template>
<template slot="lastSeen" slot-scope="errors"> <template slot="events" slot-scope="errors">
<div class="d-flex align-items-center"> <div class="text-md-right">{{ errors.item.count }}</div>
<time-ago :time="errors.item.lastSeen" class="text-secondary" /> </template>
</div>
</template> <template slot="users" slot-scope="errors">
<template slot="empty"> <div class="text-md-right">{{ errors.item.userCount }}</div>
<div ref="empty"> </template>
{{ __('No errors to display.') }}
<gl-link class="js-try-again" @click="restartPolling"> <template slot="lastSeen" slot-scope="errors">
{{ __('Check again') }} <div class="d-flex align-items-center">
</gl-link> <time-ago :time="errors.item.lastSeen" class="text-secondary" />
</div> </div>
</template> </template>
</gl-table> <template slot="empty">
</div> <div ref="empty">
{{ __('No errors to display.') }}
<gl-link class="js-try-again" @click="restartPolling">
{{ __('Check again') }}
</gl-link>
</div>
</template>
</gl-table>
</div> </div>
<div v-else-if="userCanEnableErrorTracking"> <div v-else-if="userCanEnableErrorTracking">
<gl-empty-state <gl-empty-state
......
...@@ -51,4 +51,20 @@ export function restartPolling({ commit }) { ...@@ -51,4 +51,20 @@ export function restartPolling({ commit }) {
if (eTagPoll) eTagPoll.restart(); if (eTagPoll) eTagPoll.restart();
} }
export function setIndexPath({ commit }, path) {
commit(types.SET_INDEX_PATH, path);
}
export function loadRecentSearches({ commit }) {
commit(types.LOAD_RECENT_SEARCHES);
}
export function addRecentSearch({ commit }, searchQuery) {
commit(types.ADD_RECENT_SEARCH, searchQuery);
}
export function clearRecentSearches({ commit }) {
commit(types.CLEAR_RECENT_SEARCHES);
}
export default () => {}; export default () => {};
export const SET_ERRORS = 'SET_ERRORS'; export const SET_ERRORS = 'SET_ERRORS';
export const SET_EXTERNAL_URL = 'SET_EXTERNAL_URL'; export const SET_EXTERNAL_URL = 'SET_EXTERNAL_URL';
export const SET_INDEX_PATH = 'SET_INDEX_PATH';
export const SET_LOADING = 'SET_LOADING'; export const SET_LOADING = 'SET_LOADING';
export const ADD_RECENT_SEARCH = 'ADD_RECENT_SEARCH';
export const CLEAR_RECENT_SEARCHES = 'CLEAR_RECENT_SEARCHES';
export const LOAD_RECENT_SEARCHES = 'LOAD_RECENT_SEARCHES';
import * as types from './mutation_types'; import * as types from './mutation_types';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import AccessorUtils from '~/lib/utils/accessor';
export default { export default {
[types.SET_ERRORS](state, data) { [types.SET_ERRORS](state, data) {
...@@ -11,4 +12,39 @@ export default { ...@@ -11,4 +12,39 @@ export default {
[types.SET_LOADING](state, loading) { [types.SET_LOADING](state, loading) {
state.loading = loading; state.loading = loading;
}, },
[types.SET_INDEX_PATH](state, path) {
state.indexPath = path;
},
[types.ADD_RECENT_SEARCH](state, searchTerm) {
if (searchTerm.length === 0) {
return;
}
// remove any existing item, then add it to the start of the list
const recentSearches = state.recentSearches.filter(s => s !== searchTerm);
recentSearches.unshift(searchTerm);
// only keep the last 5
state.recentSearches = recentSearches.slice(0, 5);
if (AccessorUtils.isLocalStorageAccessSafe()) {
localStorage.setItem(
`recent-searches${state.indexPath}`,
JSON.stringify(state.recentSearches),
);
}
},
[types.CLEAR_RECENT_SEARCHES](state) {
state.recentSearches = [];
if (AccessorUtils.isLocalStorageAccessSafe()) {
localStorage.removeItem(`recent-searches${state.indexPath}`);
}
},
[types.LOAD_RECENT_SEARCHES](state) {
const recentSearches = localStorage.getItem(`recent-searches${state.indexPath}`) || [];
try {
state.recentSearches = JSON.parse(recentSearches);
} catch (e) {
state.recentSearches = [];
throw e;
}
},
}; };
...@@ -2,4 +2,6 @@ export default () => ({ ...@@ -2,4 +2,6 @@ export default () => ({
errors: [], errors: [],
externalUrl: '', externalUrl: '',
loading: true, loading: true,
indexPath: '',
recentSearches: [],
}); });
<script> <script>
import _ from 'underscore'; import _ from 'underscore';
import { mapActions, mapState } from 'vuex'; import { mapActions, mapState, mapGetters } from 'vuex';
import VueDraggable from 'vuedraggable'; import VueDraggable from 'vuedraggable';
import { import {
GlButton, GlButton,
...@@ -99,6 +99,10 @@ export default { ...@@ -99,6 +99,10 @@ export default {
type: String, type: String,
required: true, required: true,
}, },
emptyNoDataSmallSvgPath: {
type: String,
required: true,
},
emptyUnableToConnectSvgPath: { emptyUnableToConnectSvgPath: {
type: String, type: String,
required: true, required: true,
...@@ -176,11 +180,11 @@ export default { ...@@ -176,11 +180,11 @@ export default {
'showEmptyState', 'showEmptyState',
'environments', 'environments',
'deploymentData', 'deploymentData',
'metricsWithData',
'useDashboardEndpoint', 'useDashboardEndpoint',
'allDashboards', 'allDashboards',
'additionalPanelTypesEnabled', 'additionalPanelTypesEnabled',
]), ]),
...mapGetters('monitoringDashboard', ['metricsWithData']),
firstDashboard() { firstDashboard() {
return this.environmentsEndpoint.length > 0 && this.allDashboards.length > 0 return this.environmentsEndpoint.length > 0 && this.allDashboards.length > 0
? this.allDashboards[0] ? this.allDashboards[0]
...@@ -280,13 +284,8 @@ export default { ...@@ -280,13 +284,8 @@ export default {
submitCustomMetricsForm() { submitCustomMetricsForm() {
this.$refs.customMetricsForm.submit(); this.$refs.customMetricsForm.submit();
}, },
chartsWithData(panels) {
return panels.filter(panel =>
panel.metrics.some(metric => this.metricsWithData.includes(metric.metric_id)),
);
},
groupHasData(group) { groupHasData(group) {
return this.chartsWithData(group.panels).length > 0; return this.metricsWithData(group.key).length > 0;
}, },
onDateTimePickerApply(timeWindowUrlParams) { onDateTimePickerApply(timeWindowUrlParams) {
return redirectTo(mergeUrlParams(timeWindowUrlParams, window.location.href)); return redirectTo(mergeUrlParams(timeWindowUrlParams, window.location.href));
...@@ -447,42 +446,61 @@ export default { ...@@ -447,42 +446,61 @@ export default {
:key="`${groupData.group}.${groupData.priority}`" :key="`${groupData.group}.${groupData.priority}`"
:name="groupData.group" :name="groupData.group"
:show-panels="showPanels" :show-panels="showPanels"
:collapse-group="groupHasData(groupData)" :collapse-group="!groupHasData(groupData)"
> >
<vue-draggable <div v-if="groupHasData(groupData)">
:value="groupData.panels" <vue-draggable
group="metrics-dashboard" :value="groupData.panels"
:component-data="{ attrs: { class: 'row mx-0 w-100' } }" group="metrics-dashboard"
:disabled="!isRearrangingPanels" :component-data="{ attrs: { class: 'row mx-0 w-100' } }"
@input="updatePanels(groupData.key, $event)" :disabled="!isRearrangingPanels"
> @input="updatePanels(groupData.key, $event)"
<div
v-for="(graphData, graphIndex) in groupData.panels"
:key="`panel-type-${graphIndex}`"
class="col-12 col-lg-6 px-2 mb-2 draggable"
:class="{ 'draggable-enabled': isRearrangingPanels }"
> >
<div class="position-relative draggable-panel js-draggable-panel"> <div
<div v-for="(graphData, graphIndex) in groupData.panels"
v-if="isRearrangingPanels" :key="`panel-type-${graphIndex}`"
class="draggable-remove js-draggable-remove p-2 w-100 position-absolute d-flex justify-content-end" class="col-12 col-lg-6 px-2 mb-2 draggable"
@click="removePanel(groupData.key, groupData.panels, graphIndex)" :class="{ 'draggable-enabled': isRearrangingPanels }"
> >
<a class="mx-2 p-2 draggable-remove-link" :aria-label="__('Remove')" <div class="position-relative draggable-panel js-draggable-panel">
><icon name="close" <div
/></a> v-if="isRearrangingPanels"
</div> class="draggable-remove js-draggable-remove p-2 w-100 position-absolute d-flex justify-content-end"
@click="removePanel(groupData.key, groupData.panels, graphIndex)"
>
<a class="mx-2 p-2 draggable-remove-link" :aria-label="__('Remove')"
><icon name="close"
/></a>
</div>
<panel-type <panel-type
:clipboard-text="generateLink(groupData.group, graphData.title, graphData.y_label)" :clipboard-text="
:graph-data="graphData" generateLink(groupData.group, graphData.title, graphData.y_label)
:alerts-endpoint="alertsEndpoint" "
:prometheus-alerts-available="prometheusAlertsAvailable" :graph-data="graphData"
:index="`${index}-${graphIndex}`" :alerts-endpoint="alertsEndpoint"
/> :prometheus-alerts-available="prometheusAlertsAvailable"
:index="`${index}-${graphIndex}`"
/>
</div>
</div> </div>
</div> </vue-draggable>
</vue-draggable> </div>
<div v-else class="py-5 col col-sm-10 col-md-8 col-lg-7 col-xl-6">
<empty-state
ref="empty-group"
selected-state="noDataGroup"
:documentation-path="documentationPath"
:settings-path="settingsPath"
:clusters-path="clustersPath"
:empty-getting-started-svg-path="emptyGettingStartedSvgPath"
:empty-loading-svg-path="emptyLoadingSvgPath"
:empty-no-data-svg-path="emptyNoDataSvgPath"
:empty-no-data-small-svg-path="emptyNoDataSmallSvgPath"
:empty-unable-to-connect-svg-path="emptyUnableToConnectSvgPath"
:compact="true"
/>
</div>
</graph-group> </graph-group>
</div> </div>
<empty-state <empty-state
...@@ -494,6 +512,7 @@ export default { ...@@ -494,6 +512,7 @@ export default {
:empty-getting-started-svg-path="emptyGettingStartedSvgPath" :empty-getting-started-svg-path="emptyGettingStartedSvgPath"
:empty-loading-svg-path="emptyLoadingSvgPath" :empty-loading-svg-path="emptyLoadingSvgPath"
:empty-no-data-svg-path="emptyNoDataSvgPath" :empty-no-data-svg-path="emptyNoDataSvgPath"
:empty-no-data-small-svg-path="emptyNoDataSmallSvgPath"
:empty-unable-to-connect-svg-path="emptyUnableToConnectSvgPath" :empty-unable-to-connect-svg-path="emptyUnableToConnectSvgPath"
:compact="smallEmptyState" :compact="smallEmptyState"
/> />
......
<script> <script>
import { mapActions, mapState } from 'vuex'; import { mapActions, mapState, mapGetters } from 'vuex';
import { getParameterValues, removeParams } from '~/lib/utils/url_utility'; import { getParameterValues, removeParams } from '~/lib/utils/url_utility';
import PanelType from 'ee_else_ce/monitoring/components/panel_type.vue'; import PanelType from 'ee_else_ce/monitoring/components/panel_type.vue';
import GraphGroup from './graph_group.vue'; import GraphGroup from './graph_group.vue';
...@@ -35,7 +35,8 @@ export default { ...@@ -35,7 +35,8 @@ export default {
}; };
}, },
computed: { computed: {
...mapState('monitoringDashboard', ['dashboard', 'metricsWithData']), ...mapState('monitoringDashboard', ['dashboard']),
...mapGetters('monitoringDashboard', ['metricsWithData']),
charts() { charts() {
if (!this.dashboard || !this.dashboard.panel_groups) { if (!this.dashboard || !this.dashboard.panel_groups) {
return []; return [];
...@@ -73,7 +74,7 @@ export default { ...@@ -73,7 +74,7 @@ export default {
'setShowErrorBanner', 'setShowErrorBanner',
]), ]),
chartHasData(chart) { chartHasData(chart) {
return chart.metrics.some(metric => this.metricsWithData.includes(metric.metric_id)); return chart.metrics.some(metric => this.metricsWithData().includes(metric.metric_id));
}, },
onSidebarMutation() { onSidebarMutation() {
setTimeout(() => { setTimeout(() => {
......
...@@ -37,6 +37,10 @@ export default { ...@@ -37,6 +37,10 @@ export default {
type: String, type: String,
required: true, required: true,
}, },
emptyNoDataSmallSvgPath: {
type: String,
required: true,
},
emptyUnableToConnectSvgPath: { emptyUnableToConnectSvgPath: {
type: String, type: String,
required: true, required: true,
...@@ -80,6 +84,11 @@ export default { ...@@ -80,6 +84,11 @@ export default {
secondaryButtonText: '', secondaryButtonText: '',
secondaryButtonPath: '', secondaryButtonPath: '',
}, },
noDataGroup: {
svgUrl: this.emptyNoDataSmallSvgPath,
title: __('No data to display'),
description: __('The data source is connected, but there is no data to display.'),
},
unableToConnect: { unableToConnect: {
svgUrl: this.emptyUnableToConnectSvgPath, svgUrl: this.emptyUnableToConnectSvgPath,
title: __('Unable to connect to Prometheus server'), title: __('Unable to connect to Prometheus server'),
......
...@@ -15,31 +15,44 @@ export default { ...@@ -15,31 +15,44 @@ export default {
required: false, required: false,
default: true, default: true,
}, },
/**
* Initial value of collapse on mount.
*/
collapseGroup: { collapseGroup: {
type: Boolean, type: Boolean,
required: true, required: false,
default: false,
}, },
}, },
data() { data() {
return { return {
showGroup: true, isCollapsed: this.collapseGroup,
}; };
}, },
computed: { computed: {
caretIcon() { caretIcon() {
return this.collapseGroup && this.showGroup ? 'angle-down' : 'angle-right'; return this.isCollapsed ? 'angle-right' : 'angle-down';
},
},
watch: {
collapseGroup(val) {
// Respond to changes in collapseGroup but do not
// collapse it once was opened by the user.
if (this.showPanels && !val) {
this.isCollapsed = false;
}
}, },
}, },
methods: { methods: {
collapse() { collapse() {
this.showGroup = !this.showGroup; this.isCollapsed = !this.isCollapsed;
}, },
}, },
}; };
</script> </script>
<template> <template>
<div v-if="showPanels" class="card prometheus-panel"> <div v-if="showPanels" ref="graph-group" class="card prometheus-panel">
<div class="card-header d-flex align-items-center"> <div class="card-header d-flex align-items-center">
<h4 class="flex-grow-1">{{ name }}</h4> <h4 class="flex-grow-1">{{ name }}</h4>
<a role="button" class="js-graph-group-toggle" @click="collapse"> <a role="button" class="js-graph-group-toggle" @click="collapse">
...@@ -47,12 +60,12 @@ export default { ...@@ -47,12 +60,12 @@ export default {
</a> </a>
</div> </div>
<div <div
v-if="collapseGroup" v-show="!isCollapsed"
v-show="collapseGroup && showGroup" ref="graph-group-content"
class="card-body prometheus-graph-group p-0" class="card-body prometheus-graph-group p-0"
> >
<slot></slot> <slot></slot>
</div> </div>
</div> </div>
<div v-else class="prometheus-graph-group"><slot></slot></div> <div v-else ref="graph-group-content" class="prometheus-graph-group"><slot></slot></div>
</template> </template>
...@@ -4,7 +4,7 @@ import createFlash from '~/flash'; ...@@ -4,7 +4,7 @@ import createFlash from '~/flash';
import trackDashboardLoad from '../monitoring_tracking_helper'; import trackDashboardLoad from '../monitoring_tracking_helper';
import statusCodes from '../../lib/utils/http_status'; import statusCodes from '../../lib/utils/http_status';
import { backOff } from '../../lib/utils/common_utils'; import { backOff } from '../../lib/utils/common_utils';
import { s__ } from '../../locale'; import { s__, sprintf } from '../../locale';
const TWO_MINUTES = 120000; const TWO_MINUTES = 120000;
...@@ -74,17 +74,21 @@ export const fetchDashboard = ({ state, dispatch }, params) => { ...@@ -74,17 +74,21 @@ export const fetchDashboard = ({ state, dispatch }, params) => {
return backOffRequest(() => axios.get(state.dashboardEndpoint, { params })) return backOffRequest(() => axios.get(state.dashboardEndpoint, { params }))
.then(resp => resp.data) .then(resp => resp.data)
.then(response => dispatch('receiveMetricsDashboardSuccess', { response, params })) .then(response => dispatch('receiveMetricsDashboardSuccess', { response, params }))
.then(() => { .catch(e => {
const dashboardType = state.currentDashboard === '' ? 'default' : 'custom'; dispatch('receiveMetricsDashboardFailure', e);
return trackDashboardLoad({ if (state.showErrorBanner) {
label: `${dashboardType}_metrics_dashboard`, if (e.response.data && e.response.data.message) {
value: state.metricsWithData.length, const { message } = e.response.data;
}); createFlash(
}) sprintf(
.catch(error => { s__('Metrics|There was an error while retrieving metrics. %{message}'),
dispatch('receiveMetricsDashboardFailure', error); { message },
if (state.setShowErrorBanner) { false,
createFlash(s__('Metrics|There was an error while retrieving metrics')); ),
);
} else {
createFlash(s__('Metrics|There was an error while retrieving metrics'));
}
} }
}); });
}; };
...@@ -126,7 +130,7 @@ export const fetchPrometheusMetric = ({ commit }, { metric, params }) => { ...@@ -126,7 +130,7 @@ export const fetchPrometheusMetric = ({ commit }, { metric, params }) => {
}); });
}; };
export const fetchPrometheusMetrics = ({ state, commit, dispatch }, params) => { export const fetchPrometheusMetrics = ({ state, commit, dispatch, getters }, params) => {
commit(types.REQUEST_METRICS_DATA); commit(types.REQUEST_METRICS_DATA);
const promises = []; const promises = [];
...@@ -140,9 +144,11 @@ export const fetchPrometheusMetrics = ({ state, commit, dispatch }, params) => { ...@@ -140,9 +144,11 @@ export const fetchPrometheusMetrics = ({ state, commit, dispatch }, params) => {
return Promise.all(promises) return Promise.all(promises)
.then(() => { .then(() => {
if (state.metricsWithData.length === 0) { const dashboardType = state.currentDashboard === '' ? 'default' : 'custom';
commit(types.SET_NO_DATA_EMPTY_STATE); trackDashboardLoad({
} label: `${dashboardType}_metrics_dashboard`,
value: getters.metricsWithData().length,
});
}) })
.catch(() => { .catch(() => {
createFlash(s__(`Metrics|There was an error while retrieving metrics`), 'warning'); createFlash(s__(`Metrics|There was an error while retrieving metrics`), 'warning');
......
const metricsIdsInPanel = panel =>
panel.metrics.filter(metric => metric.metricId && metric.result).map(metric => metric.metricId);
/**
* Getter to obtain the list of metric ids that have data
*
* Useful to understand which parts of the dashboard should
* be displayed. It is a Vuex Method-Style Access getter.
*
* @param {Object} state
* @returns {Function} A function that returns an array of
* metrics in the dashboard that contain results, optionally
* filtered by group key.
*/
export const metricsWithData = state => groupKey => {
let groups = state.dashboard.panel_groups;
if (groupKey) {
groups = groups.filter(group => group.key === groupKey);
}
const res = [];
groups.forEach(group => {
group.panels.forEach(panel => {
res.push(...metricsIdsInPanel(panel));
});
});
return res;
};
// prevent babel-plugin-rewire from generating an invalid default during karma tests
export default () => {};
import Vue from 'vue'; import Vue from 'vue';
import Vuex from 'vuex'; import Vuex from 'vuex';
import * as actions from './actions'; import * as actions from './actions';
import * as getters from './getters';
import mutations from './mutations'; import mutations from './mutations';
import state from './state'; import state from './state';
...@@ -12,6 +13,7 @@ export const createStore = () => ...@@ -12,6 +13,7 @@ export const createStore = () =>
monitoringDashboard: { monitoringDashboard: {
namespaced: true, namespaced: true,
actions, actions,
getters,
mutations, mutations,
state, state,
}, },
......
...@@ -67,7 +67,6 @@ export default { ...@@ -67,7 +67,6 @@ export default {
group.panels.forEach(panel => { group.panels.forEach(panel => {
panel.metrics.forEach(metric => { panel.metrics.forEach(metric => {
if (metric.metric_id === metricId) { if (metric.metric_id === metricId) {
state.metricsWithData.push(metricId);
// ensure dates/numbers are correctly formatted for charts // ensure dates/numbers are correctly formatted for charts
const normalizedResults = result.map(normalizeQueryResult); const normalizedResults = result.map(normalizeQueryResult);
Vue.set(metric, 'result', Object.freeze(normalizedResults)); Vue.set(metric, 'result', Object.freeze(normalizedResults));
......
...@@ -13,7 +13,6 @@ export default () => ({ ...@@ -13,7 +13,6 @@ export default () => ({
}, },
deploymentData: [], deploymentData: [],
environments: [], environments: [],
metricsWithData: [],
allDashboards: [], allDashboards: [],
currentDashboard: null, currentDashboard: null,
projectPath: null, projectPath: null,
......
...@@ -28,6 +28,10 @@ export default { ...@@ -28,6 +28,10 @@ export default {
type: Object, type: Object,
required: true, required: true,
}, },
pipelineCoverageDelta: {
type: String,
required: false,
},
// This prop needs to be camelCase, html attributes are case insensive // This prop needs to be camelCase, html attributes are case insensive
// https://vuejs.org/v2/guide/components.html#camelCase-vs-kebab-case // https://vuejs.org/v2/guide/components.html#camelCase-vs-kebab-case
hasCi: { hasCi: {
...@@ -92,6 +96,16 @@ export default { ...@@ -92,6 +96,16 @@ export default {
showSourceBranch() { showSourceBranch() {
return Boolean(this.pipeline.ref.branch); return Boolean(this.pipeline.ref.branch);
}, },
coverageDeltaClass() {
const delta = this.pipelineCoverageDelta;
if (delta && parseFloat(delta) > 0) {
return 'text-success';
}
if (delta && parseFloat(delta) < 0) {
return 'text-danger';
}
return '';
},
}, },
}; };
</script> </script>
...@@ -142,6 +156,14 @@ export default { ...@@ -142,6 +156,14 @@ export default {
</div> </div>
<div v-if="pipeline.coverage" class="coverage"> <div v-if="pipeline.coverage" class="coverage">
{{ s__('Pipeline|Coverage') }} {{ pipeline.coverage }}% {{ s__('Pipeline|Coverage') }} {{ pipeline.coverage }}%
<span
v-if="pipelineCoverageDelta"
class="js-pipeline-coverage-delta"
:class="coverageDeltaClass"
>
({{ pipelineCoverageDelta }}%)
</span>
</div> </div>
</div> </div>
</div> </div>
......
...@@ -76,6 +76,7 @@ export default { ...@@ -76,6 +76,7 @@ export default {
<mr-widget-container> <mr-widget-container>
<mr-widget-pipeline <mr-widget-pipeline
:pipeline="pipeline" :pipeline="pipeline"
:pipeline-coverage-delta="mr.pipelineCoverageDelta"
:ci-status="mr.ciStatus" :ci-status="mr.ciStatus"
:has-ci="mr.hasCI" :has-ci="mr.hasCI"
:source-branch="branch" :source-branch="branch"
......
...@@ -42,6 +42,7 @@ export default class MergeRequestStore { ...@@ -42,6 +42,7 @@ export default class MergeRequestStore {
this.commitsCount = data.commits_count; this.commitsCount = data.commits_count;
this.divergedCommitsCount = data.diverged_commits_count; this.divergedCommitsCount = data.diverged_commits_count;
this.pipeline = data.pipeline || {}; this.pipeline = data.pipeline || {};
this.pipelineCoverageDelta = data.pipeline_coverage_delta;
this.mergePipeline = data.merge_pipeline || {}; this.mergePipeline = data.merge_pipeline || {};
this.deployments = this.deployments || data.deployments || []; this.deployments = this.deployments || data.deployments || [];
this.postMergeDeployments = this.postMergeDeployments || []; this.postMergeDeployments = this.postMergeDeployments || [];
......
...@@ -515,6 +515,12 @@ img.emoji { ...@@ -515,6 +515,12 @@ img.emoji {
cursor: pointer; cursor: pointer;
} }
// this needs to use "!important" due to some very specific styles
// around buttons
.cursor-default {
cursor: default !important;
}
// Make buttons/dropdowns full-width on mobile // Make buttons/dropdowns full-width on mobile
.full-width-mobile { .full-width-mobile {
@include media-breakpoint-down(xs) { @include media-breakpoint-down(xs) {
......
...@@ -214,8 +214,8 @@ ...@@ -214,8 +214,8 @@
padding-left: 0; padding-left: 0;
height: $input-height - 2; height: $input-height - 2;
line-height: inherit; line-height: inherit;
border-color: transparent;
&,
&:focus, &:focus,
&:hover { &:hover {
outline: none; outline: none;
......
...@@ -67,7 +67,6 @@ ...@@ -67,7 +67,6 @@
.prometheus-graph-group { .prometheus-graph-group {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
margin-top: $gl-padding-8;
} }
.prometheus-graph { .prometheus-graph {
......
...@@ -26,6 +26,7 @@ module EnvironmentsHelper ...@@ -26,6 +26,7 @@ module EnvironmentsHelper
"empty-getting-started-svg-path" => image_path('illustrations/monitoring/getting_started.svg'), "empty-getting-started-svg-path" => image_path('illustrations/monitoring/getting_started.svg'),
"empty-loading-svg-path" => image_path('illustrations/monitoring/loading.svg'), "empty-loading-svg-path" => image_path('illustrations/monitoring/loading.svg'),
"empty-no-data-svg-path" => image_path('illustrations/monitoring/no_data.svg'), "empty-no-data-svg-path" => image_path('illustrations/monitoring/no_data.svg'),
"empty-no-data-small-svg-path" => image_path('illustrations/chart-empty-state-small.svg'),
"empty-unable-to-connect-svg-path" => image_path('illustrations/monitoring/unable_to_connect.svg'), "empty-unable-to-connect-svg-path" => image_path('illustrations/monitoring/unable_to_connect.svg'),
"metrics-endpoint" => additional_metrics_project_environment_path(project, environment, format: :json), "metrics-endpoint" => additional_metrics_project_environment_path(project, environment, format: :json),
"dashboard-endpoint" => metrics_dashboard_project_environment_path(project, environment, format: :json), "dashboard-endpoint" => metrics_dashboard_project_environment_path(project, environment, format: :json),
......
...@@ -5,9 +5,6 @@ class ApplicationSetting < ApplicationRecord ...@@ -5,9 +5,6 @@ class ApplicationSetting < ApplicationRecord
include CacheMarkdownField include CacheMarkdownField
include TokenAuthenticatable include TokenAuthenticatable
include ChronicDurationAttribute include ChronicDurationAttribute
include IgnorableColumns
ignore_columns :pendo_enabled, :pendo_url, remove_after: '2019-12-01', remove_with: '12.6'
add_authentication_token_field :runners_registration_token, encrypted: -> { Feature.enabled?(:application_settings_tokens_optional_encryption, default_enabled: true) ? :optional : :required } add_authentication_token_field :runners_registration_token, encrypted: -> { Feature.enabled?(:application_settings_tokens_optional_encryption, default_enabled: true) ? :optional : :required }
add_authentication_token_field :health_check_access_token add_authentication_token_field :health_check_access_token
......
...@@ -1423,6 +1423,12 @@ class MergeRequest < ApplicationRecord ...@@ -1423,6 +1423,12 @@ class MergeRequest < ApplicationRecord
true true
end end
def pipeline_coverage_delta
if base_pipeline&.coverage && head_pipeline&.coverage
'%.2f' % (head_pipeline.coverage.to_f - base_pipeline.coverage.to_f)
end
end
def base_pipeline def base_pipeline
@base_pipeline ||= project.ci_pipelines @base_pipeline ||= project.ci_pipelines
.order(id: :desc) .order(id: :desc)
......
...@@ -57,6 +57,10 @@ class MergeRequestPollWidgetEntity < Grape::Entity ...@@ -57,6 +57,10 @@ class MergeRequestPollWidgetEntity < Grape::Entity
presenter(merge_request).ci_status presenter(merge_request).ci_status
end end
expose :pipeline_coverage_delta do |merge_request|
presenter(merge_request).pipeline_coverage_delta
end
expose :cancel_auto_merge_path do |merge_request| expose :cancel_auto_merge_path do |merge_request|
presenter(merge_request).cancel_auto_merge_path presenter(merge_request).cancel_auto_merge_path
end end
......
...@@ -7,7 +7,7 @@ ...@@ -7,7 +7,7 @@
window.gl.mrWidgetData = #{serialize_issuable(@merge_request, serializer: 'widget', issues_links: true)} window.gl.mrWidgetData = #{serialize_issuable(@merge_request, serializer: 'widget', issues_links: true)}
window.gl.mrWidgetData.squash_before_merge_help_path = '#{help_page_path("user/project/merge_requests/squash_and_merge")}'; window.gl.mrWidgetData.squash_before_merge_help_path = '#{help_page_path("user/project/merge_requests/squash_and_merge")}';
window.gl.mrWidgetData.troubleshooting_docs_path = '#{help_page_path('user/project/merge_requests/index.md', anchor: 'troubleshooting')}'; window.gl.mrWidgetData.troubleshooting_docs_path = '#{help_page_path('user/project/merge_requests/reviewing_and_managing_merge_requests.md', anchor: 'troubleshooting')}';
window.gl.mrWidgetData.security_approvals_help_page_path = '#{help_page_path('user/application_security/index.html', anchor: 'security-approvals-in-merge-requests-ultimate')}'; window.gl.mrWidgetData.security_approvals_help_page_path = '#{help_page_path('user/application_security/index.html', anchor: 'security-approvals-in-merge-requests-ultimate')}';
window.gl.mrWidgetData.eligible_approvers_docs_path = '#{help_page_path('user/project/merge_requests/merge_request_approvals', anchor: 'eligible-approvers')}'; window.gl.mrWidgetData.eligible_approvers_docs_path = '#{help_page_path('user/project/merge_requests/merge_request_approvals', anchor: 'eligible-approvers')}';
......
---
title: Add workers for 'soft-delete for groups' feature
merge_request: 19679
author:
type: added
---
title: Add recent search to error tracking
merge_request: 19301
author:
type: added
---
title: Add empty region when group metrics are missing
merge_request: 20900
author:
type: fixed
---
title: Add service desk information to projects API endpoint
merge_request: 20913
author:
type: changed
---
title: Add coverage difference visualization to merge request page
merge_request: 20676
author: Fabio Huser
type: added
...@@ -469,6 +469,9 @@ Settings.cron_jobs['namespaces_prune_aggregation_schedules_worker']['cron'] ||= ...@@ -469,6 +469,9 @@ Settings.cron_jobs['namespaces_prune_aggregation_schedules_worker']['cron'] ||=
Settings.cron_jobs['namespaces_prune_aggregation_schedules_worker']['job_class'] = 'Namespaces::PruneAggregationSchedulesWorker' Settings.cron_jobs['namespaces_prune_aggregation_schedules_worker']['job_class'] = 'Namespaces::PruneAggregationSchedulesWorker'
Gitlab.ee do Gitlab.ee do
Settings.cron_jobs['adjourned_group_deletion_worker'] ||= Settingslogic.new({})
Settings.cron_jobs['adjourned_group_deletion_worker']['cron'] ||= '0 3 * * *'
Settings.cron_jobs['adjourned_group_deletion_worker']['job_class'] = 'AdjournedGroupDeletionWorker'
Settings.cron_jobs['clear_shared_runners_minutes_worker'] ||= Settingslogic.new({}) Settings.cron_jobs['clear_shared_runners_minutes_worker'] ||= Settingslogic.new({})
Settings.cron_jobs['clear_shared_runners_minutes_worker']['cron'] ||= '0 0 1 * *' Settings.cron_jobs['clear_shared_runners_minutes_worker']['cron'] ||= '0 0 1 * *'
Settings.cron_jobs['clear_shared_runners_minutes_worker']['job_class'] = 'ClearSharedRunnersMinutesWorker' Settings.cron_jobs['clear_shared_runners_minutes_worker']['job_class'] = 'ClearSharedRunnersMinutesWorker'
......
# frozen_string_literal: true
class UpdateGroupDeletionSchedulesForeignKeys < ActiveRecord::Migration[5.2]
include Gitlab::Database::MigrationHelpers
DOWNTIME = false
disable_ddl_transaction!
def up
add_concurrent_foreign_key(:group_deletion_schedules, :users, column: :user_id, on_delete: :cascade, name: new_foreign_key_name)
remove_foreign_key_if_exists(:group_deletion_schedules, column: :user_id, on_delete: :nullify)
end
def down
add_concurrent_foreign_key(:group_deletion_schedules, :users, column: :user_id, on_delete: :nullify, name: existing_foreign_key_name)
remove_foreign_key_if_exists(:group_deletion_schedules, column: :user_id, on_delete: :cascade)
end
private
def new_foreign_key_name
concurrent_foreign_key_name(:group_deletion_schedules, :user_id)
end
def existing_foreign_key_name
'fk_group_deletion_schedules_users_user_id'
end
end
...@@ -4490,7 +4490,7 @@ ActiveRecord::Schema.define(version: 2019_12_06_122926) do ...@@ -4490,7 +4490,7 @@ ActiveRecord::Schema.define(version: 2019_12_06_122926) do
add_foreign_key "grafana_integrations", "projects", on_delete: :cascade add_foreign_key "grafana_integrations", "projects", on_delete: :cascade
add_foreign_key "group_custom_attributes", "namespaces", column: "group_id", on_delete: :cascade add_foreign_key "group_custom_attributes", "namespaces", column: "group_id", on_delete: :cascade
add_foreign_key "group_deletion_schedules", "namespaces", column: "group_id", on_delete: :cascade add_foreign_key "group_deletion_schedules", "namespaces", column: "group_id", on_delete: :cascade
add_foreign_key "group_deletion_schedules", "users", on_delete: :nullify add_foreign_key "group_deletion_schedules", "users", name: "fk_11e3ebfcdd", on_delete: :cascade
add_foreign_key "group_group_links", "namespaces", column: "shared_group_id", on_delete: :cascade add_foreign_key "group_group_links", "namespaces", column: "shared_group_id", on_delete: :cascade
add_foreign_key "group_group_links", "namespaces", column: "shared_with_group_id", on_delete: :cascade add_foreign_key "group_group_links", "namespaces", column: "shared_with_group_id", on_delete: :cascade
add_foreign_key "identities", "saml_providers", name: "fk_aade90f0fc", on_delete: :cascade add_foreign_key "identities", "saml_providers", name: "fk_aade90f0fc", on_delete: :cascade
......
...@@ -241,6 +241,19 @@ When the user is authenticated and `simple` is not set this returns something li ...@@ -241,6 +241,19 @@ When the user is authenticated and `simple` is not set this returns something li
"remove_source_branch_after_merge": false, "remove_source_branch_after_merge": false,
"request_access_enabled": false, "request_access_enabled": false,
"merge_method": "merge", "merge_method": "merge",
"auto_devops_enabled": true,
"auto_devops_deploy_strategy": "continuous",
"repository_storage": "default",
"approvals_before_merge": 0,
"mirror": false,
"mirror_user_id": 45,
"mirror_trigger_builds": false,
"only_mirror_protected_branches": false,
"mirror_overwrites_diverged_branches": false,
"external_authorization_classification_label": null,
"packages_enabled": true,
"service_desk_enabled": false,
"service_desk_address": null,
"statistics": { "statistics": {
"commit_count": 12, "commit_count": 12,
"storage_size": 2066080, "storage_size": 2066080,
...@@ -457,6 +470,19 @@ This endpoint supports [keyset pagination](README.md#keyset-based-pagination) fo ...@@ -457,6 +470,19 @@ This endpoint supports [keyset pagination](README.md#keyset-based-pagination) fo
"remove_source_branch_after_merge": false, "remove_source_branch_after_merge": false,
"request_access_enabled": false, "request_access_enabled": false,
"merge_method": "merge", "merge_method": "merge",
"auto_devops_enabled": true,
"auto_devops_deploy_strategy": "continuous",
"repository_storage": "default",
"approvals_before_merge": 0,
"mirror": false,
"mirror_user_id": 45,
"mirror_trigger_builds": false,
"only_mirror_protected_branches": false,
"mirror_overwrites_diverged_branches": false,
"external_authorization_classification_label": null,
"packages_enabled": true,
"service_desk_enabled": false,
"service_desk_address": null,
"statistics": { "statistics": {
"commit_count": 12, "commit_count": 12,
"storage_size": 2066080, "storage_size": 2066080,
...@@ -649,6 +675,19 @@ Example response: ...@@ -649,6 +675,19 @@ Example response:
"remove_source_branch_after_merge": false, "remove_source_branch_after_merge": false,
"request_access_enabled": false, "request_access_enabled": false,
"merge_method": "merge", "merge_method": "merge",
"auto_devops_enabled": true,
"auto_devops_deploy_strategy": "continuous",
"repository_storage": "default",
"approvals_before_merge": 0,
"mirror": false,
"mirror_user_id": 45,
"mirror_trigger_builds": false,
"only_mirror_protected_branches": false,
"mirror_overwrites_diverged_branches": false,
"external_authorization_classification_label": null,
"packages_enabled": true,
"service_desk_enabled": false,
"service_desk_address": null,
"statistics": { "statistics": {
"commit_count": 12, "commit_count": 12,
"storage_size": 2066080, "storage_size": 2066080,
...@@ -777,6 +816,19 @@ GET /projects/:id ...@@ -777,6 +816,19 @@ GET /projects/:id
"printing_merge_requests_link_enabled": true, "printing_merge_requests_link_enabled": true,
"request_access_enabled": false, "request_access_enabled": false,
"merge_method": "merge", "merge_method": "merge",
"auto_devops_enabled": true,
"auto_devops_deploy_strategy": "continuous",
"repository_storage": "default",
"approvals_before_merge": 0,
"mirror": false,
"mirror_user_id": 45,
"mirror_trigger_builds": false,
"only_mirror_protected_branches": false,
"mirror_overwrites_diverged_branches": false,
"external_authorization_classification_label": null,
"packages_enabled": true,
"service_desk_enabled": false,
"service_desk_address": null,
"statistics": { "statistics": {
"commit_count": 37, "commit_count": 37,
"storage_size": 1038090, "storage_size": 1038090,
......
...@@ -11157,6 +11157,9 @@ msgstr "" ...@@ -11157,6 +11157,9 @@ msgstr ""
msgid "Metrics|There was an error while retrieving metrics" msgid "Metrics|There was an error while retrieving metrics"
msgstr "" msgstr ""
msgid "Metrics|There was an error while retrieving metrics. %{message}"
msgstr ""
msgid "Metrics|Unexpected deployment data response from prometheus endpoint" msgid "Metrics|Unexpected deployment data response from prometheus endpoint"
msgstr "" msgstr ""
...@@ -15346,6 +15349,9 @@ msgstr "" ...@@ -15346,6 +15349,9 @@ msgstr ""
msgid "Search or filter results..." msgid "Search or filter results..."
msgstr "" msgstr ""
msgid "Search or filter results…"
msgstr ""
msgid "Search or jump to…" msgid "Search or jump to…"
msgstr "" msgstr ""
...@@ -17478,6 +17484,9 @@ msgstr "" ...@@ -17478,6 +17484,9 @@ msgstr ""
msgid "The content of this page is not encoded in UTF-8. Edits can only be made via the Git repository." msgid "The content of this page is not encoded in UTF-8. Edits can only be made via the Git repository."
msgstr "" msgstr ""
msgid "The data source is connected, but there is no data to display."
msgstr ""
msgid "The default CI configuration path for new projects." msgid "The default CI configuration path for new projects."
msgstr "" msgstr ""
......
...@@ -89,7 +89,7 @@ describe "Internal Project Access" do ...@@ -89,7 +89,7 @@ describe "Internal Project Access" do
it { is_expected.to be_denied_for(:visitor) } it { is_expected.to be_denied_for(:visitor) }
end end
describe "GET /:project_path/settings/members" do describe "GET /:project_path/-/settings/members" do
subject { project_settings_members_path(project) } subject { project_settings_members_path(project) }
it { is_expected.to be_allowed_for(:admin) } it { is_expected.to be_allowed_for(:admin) }
...@@ -103,7 +103,7 @@ describe "Internal Project Access" do ...@@ -103,7 +103,7 @@ describe "Internal Project Access" do
it { is_expected.to be_denied_for(:external) } it { is_expected.to be_denied_for(:external) }
end end
describe "GET /:project_path/settings/ci_cd" do describe "GET /:project_path/-/settings/ci_cd" do
subject { project_settings_ci_cd_path(project) } subject { project_settings_ci_cd_path(project) }
it { is_expected.to be_allowed_for(:admin) } it { is_expected.to be_allowed_for(:admin) }
...@@ -117,7 +117,7 @@ describe "Internal Project Access" do ...@@ -117,7 +117,7 @@ describe "Internal Project Access" do
it { is_expected.to be_denied_for(:external) } it { is_expected.to be_denied_for(:external) }
end end
describe "GET /:project_path/settings/repository" do describe "GET /:project_path/-/settings/repository" do
subject { project_settings_repository_path(project) } subject { project_settings_repository_path(project) }
it { is_expected.to be_allowed_for(:admin) } it { is_expected.to be_allowed_for(:admin) }
...@@ -301,7 +301,7 @@ describe "Internal Project Access" do ...@@ -301,7 +301,7 @@ describe "Internal Project Access" do
it { is_expected.to be_denied_for(:visitor) } it { is_expected.to be_denied_for(:visitor) }
end end
describe "GET /:project_path/settings/integrations" do describe "GET /:project_path/-/settings/integrations" do
subject { project_settings_integrations_path(project) } subject { project_settings_integrations_path(project) }
it { is_expected.to be_allowed_for(:admin) } it { is_expected.to be_allowed_for(:admin) }
...@@ -470,7 +470,7 @@ describe "Internal Project Access" do ...@@ -470,7 +470,7 @@ describe "Internal Project Access" do
it { is_expected.to be_denied_for(:visitor) } it { is_expected.to be_denied_for(:visitor) }
end end
describe "GET /:project_path/environments" do describe "GET /:project_path/-/environments" do
subject { project_environments_path(project) } subject { project_environments_path(project) }
it { is_expected.to be_allowed_for(:admin) } it { is_expected.to be_allowed_for(:admin) }
...@@ -484,7 +484,7 @@ describe "Internal Project Access" do ...@@ -484,7 +484,7 @@ describe "Internal Project Access" do
it { is_expected.to be_denied_for(:visitor) } it { is_expected.to be_denied_for(:visitor) }
end end
describe "GET /:project_path/environments/:id" do describe "GET /:project_path/-/environments/:id" do
let(:environment) { create(:environment, project: project) } let(:environment) { create(:environment, project: project) }
subject { project_environment_path(project, environment) } subject { project_environment_path(project, environment) }
...@@ -499,7 +499,7 @@ describe "Internal Project Access" do ...@@ -499,7 +499,7 @@ describe "Internal Project Access" do
it { is_expected.to be_denied_for(:visitor) } it { is_expected.to be_denied_for(:visitor) }
end end
describe "GET /:project_path/environments/:id/deployments" do describe "GET /:project_path/-/environments/:id/deployments" do
let(:environment) { create(:environment, project: project) } let(:environment) { create(:environment, project: project) }
subject { project_environment_deployments_path(project, environment) } subject { project_environment_deployments_path(project, environment) }
...@@ -514,7 +514,7 @@ describe "Internal Project Access" do ...@@ -514,7 +514,7 @@ describe "Internal Project Access" do
it { is_expected.to be_denied_for(:visitor) } it { is_expected.to be_denied_for(:visitor) }
end end
describe "GET /:project_path/environments/new" do describe "GET /:project_path/-/environments/new" do
subject { new_project_environment_path(project) } subject { new_project_environment_path(project) }
it { is_expected.to be_allowed_for(:admin) } it { is_expected.to be_allowed_for(:admin) }
......
...@@ -89,7 +89,7 @@ describe "Private Project Access" do ...@@ -89,7 +89,7 @@ describe "Private Project Access" do
it { is_expected.to be_denied_for(:visitor) } it { is_expected.to be_denied_for(:visitor) }
end end
describe "GET /:project_path/settings/members" do describe "GET /:project_path/-/settings/members" do
subject { project_settings_members_path(project) } subject { project_settings_members_path(project) }
it { is_expected.to be_allowed_for(:admin) } it { is_expected.to be_allowed_for(:admin) }
...@@ -103,7 +103,7 @@ describe "Private Project Access" do ...@@ -103,7 +103,7 @@ describe "Private Project Access" do
it { is_expected.to be_denied_for(:external) } it { is_expected.to be_denied_for(:external) }
end end
describe "GET /:project_path/settings/ci_cd" do describe "GET /:project_path/-/settings/ci_cd" do
subject { project_settings_ci_cd_path(project) } subject { project_settings_ci_cd_path(project) }
it { is_expected.to be_allowed_for(:admin) } it { is_expected.to be_allowed_for(:admin) }
...@@ -117,7 +117,7 @@ describe "Private Project Access" do ...@@ -117,7 +117,7 @@ describe "Private Project Access" do
it { is_expected.to be_denied_for(:external) } it { is_expected.to be_denied_for(:external) }
end end
describe "GET /:project_path/settings/repository" do describe "GET /:project_path/-/settings/repository" do
subject { project_settings_repository_path(project) } subject { project_settings_repository_path(project) }
it { is_expected.to be_allowed_for(:admin) } it { is_expected.to be_allowed_for(:admin) }
...@@ -273,7 +273,7 @@ describe "Private Project Access" do ...@@ -273,7 +273,7 @@ describe "Private Project Access" do
it { is_expected.to be_denied_for(:visitor) } it { is_expected.to be_denied_for(:visitor) }
end end
describe "GET /:project_path/namespace/hooks" do describe "GET /:project_path/-/settings/integrations" do
subject { project_settings_integrations_path(project) } subject { project_settings_integrations_path(project) }
it { is_expected.to be_allowed_for(:admin) } it { is_expected.to be_allowed_for(:admin) }
...@@ -431,7 +431,7 @@ describe "Private Project Access" do ...@@ -431,7 +431,7 @@ describe "Private Project Access" do
end end
end end
describe "GET /:project_path/environments" do describe "GET /:project_path/-/environments" do
subject { project_environments_path(project) } subject { project_environments_path(project) }
it { is_expected.to be_allowed_for(:admin) } it { is_expected.to be_allowed_for(:admin) }
...@@ -445,7 +445,7 @@ describe "Private Project Access" do ...@@ -445,7 +445,7 @@ describe "Private Project Access" do
it { is_expected.to be_denied_for(:visitor) } it { is_expected.to be_denied_for(:visitor) }
end end
describe "GET /:project_path/environments/:id" do describe "GET /:project_path/-/environments/:id" do
let(:environment) { create(:environment, project: project) } let(:environment) { create(:environment, project: project) }
subject { project_environment_path(project, environment) } subject { project_environment_path(project, environment) }
...@@ -460,7 +460,7 @@ describe "Private Project Access" do ...@@ -460,7 +460,7 @@ describe "Private Project Access" do
it { is_expected.to be_denied_for(:visitor) } it { is_expected.to be_denied_for(:visitor) }
end end
describe "GET /:project_path/environments/:id/deployments" do describe "GET /:project_path/-/environments/:id/deployments" do
let(:environment) { create(:environment, project: project) } let(:environment) { create(:environment, project: project) }
subject { project_environment_deployments_path(project, environment) } subject { project_environment_deployments_path(project, environment) }
...@@ -475,7 +475,7 @@ describe "Private Project Access" do ...@@ -475,7 +475,7 @@ describe "Private Project Access" do
it { is_expected.to be_denied_for(:visitor) } it { is_expected.to be_denied_for(:visitor) }
end end
describe "GET /:project_path/environments/new" do describe "GET /:project_path/-/environments/new" do
subject { new_project_environment_path(project) } subject { new_project_environment_path(project) }
it { is_expected.to be_allowed_for(:admin) } it { is_expected.to be_allowed_for(:admin) }
...@@ -517,7 +517,7 @@ describe "Private Project Access" do ...@@ -517,7 +517,7 @@ describe "Private Project Access" do
it { is_expected.to be_denied_for(:visitor) } it { is_expected.to be_denied_for(:visitor) }
end end
describe "GET /:project_path/environments/new" do describe "GET /:project_path/-/environments/new" do
subject { new_project_pipeline_schedule_path(project) } subject { new_project_pipeline_schedule_path(project) }
it { is_expected.to be_allowed_for(:admin) } it { is_expected.to be_allowed_for(:admin) }
......
...@@ -89,7 +89,7 @@ describe "Public Project Access" do ...@@ -89,7 +89,7 @@ describe "Public Project Access" do
it { is_expected.to be_allowed_for(:visitor) } it { is_expected.to be_allowed_for(:visitor) }
end end
describe "GET /:project_path/settings/members" do describe "GET /:project_path/-/settings/members" do
subject { project_settings_members_path(project) } subject { project_settings_members_path(project) }
it { is_expected.to be_allowed_for(:admin) } it { is_expected.to be_allowed_for(:admin) }
...@@ -103,7 +103,7 @@ describe "Public Project Access" do ...@@ -103,7 +103,7 @@ describe "Public Project Access" do
it { is_expected.to be_allowed_for(:external) } it { is_expected.to be_allowed_for(:external) }
end end
describe "GET /:project_path/settings/ci_cd" do describe "GET /:project_path/-/settings/ci_cd" do
subject { project_settings_ci_cd_path(project) } subject { project_settings_ci_cd_path(project) }
it { is_expected.to be_allowed_for(:admin) } it { is_expected.to be_allowed_for(:admin) }
...@@ -117,7 +117,7 @@ describe "Public Project Access" do ...@@ -117,7 +117,7 @@ describe "Public Project Access" do
it { is_expected.to be_denied_for(:external) } it { is_expected.to be_denied_for(:external) }
end end
describe "GET /:project_path/settings/repository" do describe "GET /:project_path/-/settings/repository" do
subject { project_settings_repository_path(project) } subject { project_settings_repository_path(project) }
it { is_expected.to be_allowed_for(:admin) } it { is_expected.to be_allowed_for(:admin) }
...@@ -286,7 +286,7 @@ describe "Public Project Access" do ...@@ -286,7 +286,7 @@ describe "Public Project Access" do
it { is_expected.to be_denied_for(:visitor) } it { is_expected.to be_denied_for(:visitor) }
end end
describe "GET /:project_path/environments" do describe "GET /:project_path/-/environments" do
subject { project_environments_path(project) } subject { project_environments_path(project) }
it { is_expected.to be_allowed_for(:admin) } it { is_expected.to be_allowed_for(:admin) }
...@@ -300,7 +300,7 @@ describe "Public Project Access" do ...@@ -300,7 +300,7 @@ describe "Public Project Access" do
it { is_expected.to be_denied_for(:visitor) } it { is_expected.to be_denied_for(:visitor) }
end end
describe "GET /:project_path/environments/:id" do describe "GET /:project_path/-/environments/:id" do
let(:environment) { create(:environment, project: project) } let(:environment) { create(:environment, project: project) }
subject { project_environment_path(project, environment) } subject { project_environment_path(project, environment) }
...@@ -315,7 +315,7 @@ describe "Public Project Access" do ...@@ -315,7 +315,7 @@ describe "Public Project Access" do
it { is_expected.to be_denied_for(:visitor) } it { is_expected.to be_denied_for(:visitor) }
end end
describe "GET /:project_path/environments/:id/deployments" do describe "GET /:project_path/-/environments/:id/deployments" do
let(:environment) { create(:environment, project: project) } let(:environment) { create(:environment, project: project) }
subject { project_environment_deployments_path(project, environment) } subject { project_environment_deployments_path(project, environment) }
...@@ -330,7 +330,7 @@ describe "Public Project Access" do ...@@ -330,7 +330,7 @@ describe "Public Project Access" do
it { is_expected.to be_denied_for(:visitor) } it { is_expected.to be_denied_for(:visitor) }
end end
describe "GET /:project_path/environments/new" do describe "GET /:project_path/-/environments/new" do
subject { new_project_environment_path(project) } subject { new_project_environment_path(project) }
it { is_expected.to be_allowed_for(:admin) } it { is_expected.to be_allowed_for(:admin) }
...@@ -514,7 +514,7 @@ describe "Public Project Access" do ...@@ -514,7 +514,7 @@ describe "Public Project Access" do
it { is_expected.to be_allowed_for(:visitor) } it { is_expected.to be_allowed_for(:visitor) }
end end
describe "GET /:project_path/settings/integrations" do describe "GET /:project_path/-/settings/integrations" do
subject { project_settings_integrations_path(project) } subject { project_settings_integrations_path(project) }
it { is_expected.to be_allowed_for(:admin) } it { is_expected.to be_allowed_for(:admin) }
......
...@@ -6,8 +6,11 @@ import { ...@@ -6,8 +6,11 @@ import {
GlLoadingIcon, GlLoadingIcon,
GlTable, GlTable,
GlLink, GlLink,
GlSearchBoxByClick, GlFormInput,
GlDropdown,
GlDropdownItem,
} from '@gitlab/ui'; } from '@gitlab/ui';
import createListState from '~/error_tracking/store/list/state';
import ErrorTrackingList from '~/error_tracking/components/error_tracking_list.vue'; import ErrorTrackingList from '~/error_tracking/components/error_tracking_list.vue';
import errorsList from './list_mock.json'; import errorsList from './list_mock.json';
...@@ -51,12 +54,13 @@ describe('ErrorTrackingList', () => { ...@@ -51,12 +54,13 @@ describe('ErrorTrackingList', () => {
getErrorList: () => {}, getErrorList: () => {},
startPolling: jest.fn(), startPolling: jest.fn(),
restartPolling: jest.fn().mockName('restartPolling'), restartPolling: jest.fn().mockName('restartPolling'),
addRecentSearch: jest.fn(),
loadRecentSearches: jest.fn(),
setIndexPath: jest.fn(),
clearRecentSearches: jest.fn(),
}; };
const state = { const state = createListState();
errors: errorsList,
loading: true,
};
store = new Vuex.Store({ store = new Vuex.Store({
modules: { modules: {
...@@ -90,6 +94,7 @@ describe('ErrorTrackingList', () => { ...@@ -90,6 +94,7 @@ describe('ErrorTrackingList', () => {
describe('results', () => { describe('results', () => {
beforeEach(() => { beforeEach(() => {
store.state.list.loading = false; store.state.list.loading = false;
store.state.list.errors = errorsList;
mountComponent(); mountComponent();
}); });
...@@ -114,7 +119,7 @@ describe('ErrorTrackingList', () => { ...@@ -114,7 +119,7 @@ describe('ErrorTrackingList', () => {
}); });
describe('filtering', () => { describe('filtering', () => {
const findSearchBox = () => wrapper.find(GlSearchBoxByClick); const findSearchBox = () => wrapper.find(GlFormInput);
it('shows search box', () => { it('shows search box', () => {
expect(findSearchBox().exists()).toBe(true); expect(findSearchBox().exists()).toBe(true);
...@@ -122,7 +127,9 @@ describe('ErrorTrackingList', () => { ...@@ -122,7 +127,9 @@ describe('ErrorTrackingList', () => {
it('makes network request on submit', () => { it('makes network request on submit', () => {
expect(actions.startPolling).toHaveBeenCalledTimes(1); expect(actions.startPolling).toHaveBeenCalledTimes(1);
findSearchBox().vm.$emit('submit');
findSearchBox().trigger('keyup.enter');
expect(actions.startPolling).toHaveBeenCalledTimes(2); expect(actions.startPolling).toHaveBeenCalledTimes(2);
}); });
}); });
...@@ -185,4 +192,51 @@ describe('ErrorTrackingList', () => { ...@@ -185,4 +192,51 @@ describe('ErrorTrackingList', () => {
); );
}); });
}); });
describe('recent searches', () => {
beforeEach(() => {
mountComponent();
});
it('shows empty message', () => {
store.state.list.recentSearches = [];
expect(wrapper.find(GlDropdown).text()).toBe("You don't have any recent searches");
});
it('shows items', () => {
store.state.list.recentSearches = ['great', 'search'];
const dropdownItems = wrapper.findAll(GlDropdownItem);
expect(dropdownItems.length).toBe(3);
expect(dropdownItems.at(0).text()).toBe('great');
expect(dropdownItems.at(1).text()).toBe('search');
});
describe('clear', () => {
const clearRecentButton = () => wrapper.find({ ref: 'clearRecentSearches' });
it('is hidden when list empty', () => {
store.state.list.recentSearches = [];
expect(clearRecentButton().exists()).toBe(false);
});
it('is visible when list has items', () => {
store.state.list.recentSearches = ['some', 'searches'];
expect(clearRecentButton().exists()).toBe(true);
expect(clearRecentButton().text()).toBe('Clear recent searches');
});
it('clears items on click', () => {
store.state.list.recentSearches = ['some', 'searches'];
clearRecentButton().vm.$emit('click');
expect(actions.clearRecentSearches).toHaveBeenCalledTimes(1);
});
});
});
}); });
import mutations from '~/error_tracking/store/list/mutations'; import mutations from '~/error_tracking/store/list/mutations';
import * as types from '~/error_tracking/store/list/mutation_types'; import * as types from '~/error_tracking/store/list/mutation_types';
import { useLocalStorageSpy } from 'helpers/local_storage_helper';
const ADD_RECENT_SEARCH = mutations[types.ADD_RECENT_SEARCH];
const CLEAR_RECENT_SEARCHES = mutations[types.CLEAR_RECENT_SEARCHES];
const LOAD_RECENT_SEARCHES = mutations[types.LOAD_RECENT_SEARCHES];
describe('Error tracking mutations', () => { describe('Error tracking mutations', () => {
describe('SET_ERRORS', () => { describe('SET_ERRORS', () => {
...@@ -33,4 +38,81 @@ describe('Error tracking mutations', () => { ...@@ -33,4 +38,81 @@ describe('Error tracking mutations', () => {
}); });
}); });
}); });
describe('recent searches', () => {
useLocalStorageSpy();
let state;
beforeEach(() => {
state = {
indexPath: '/project/errors.json',
recentSearches: [],
};
});
describe('ADD_RECENT_SEARCH', () => {
it('adds search queries to recentSearches and localStorage', () => {
ADD_RECENT_SEARCH(state, 'my issue');
expect(state.recentSearches).toEqual(['my issue']);
expect(localStorage.setItem).toHaveBeenCalledWith(
'recent-searches/project/errors.json',
'["my issue"]',
);
});
it('does not add empty searches', () => {
ADD_RECENT_SEARCH(state, '');
expect(state.recentSearches).toEqual([]);
expect(localStorage.setItem).not.toHaveBeenCalled();
});
it('adds new queries to start of the list', () => {
state.recentSearches = ['previous', 'searches'];
ADD_RECENT_SEARCH(state, 'new search');
expect(state.recentSearches).toEqual(['new search', 'previous', 'searches']);
});
it('limits recentSearches to 5 items', () => {
state.recentSearches = [1, 2, 3, 4, 5];
ADD_RECENT_SEARCH(state, 'new search');
expect(state.recentSearches).toEqual(['new search', 1, 2, 3, 4]);
});
it('does not add same search query twice', () => {
state.recentSearches = ['already', 'searched'];
ADD_RECENT_SEARCH(state, 'searched');
expect(state.recentSearches).toEqual(['searched', 'already']);
});
});
describe('CLEAR_RECENT_SEARCHES', () => {
it('clears recentSearches and localStorage', () => {
state.recentSearches = ['first', 'second'];
CLEAR_RECENT_SEARCHES(state);
expect(state.recentSearches).toEqual([]);
expect(localStorage.removeItem).toHaveBeenCalledWith('recent-searches/project/errors.json');
});
});
describe('LOAD_RECENT_SEARCHES', () => {
it('loads recent searches from localStorage', () => {
jest.spyOn(window.localStorage, 'getItem').mockReturnValue('["first", "second"]');
LOAD_RECENT_SEARCHES(state);
expect(state.recentSearches).toEqual(['first', 'second']);
expect(localStorage.getItem).toHaveBeenCalledWith('recent-searches/project/errors.json');
});
});
});
}); });
...@@ -45,10 +45,11 @@ describe('Time series component', () => { ...@@ -45,10 +45,11 @@ describe('Time series component', () => {
store.commit(`monitoringDashboard/${types.RECEIVE_DEPLOYMENTS_DATA_SUCCESS}`, deploymentData); store.commit(`monitoringDashboard/${types.RECEIVE_DEPLOYMENTS_DATA_SUCCESS}`, deploymentData);
// Mock data contains 2 panels, pick the first one // Mock data contains 2 panel groups, with 1 and 2 panels respectively
store.commit(`monitoringDashboard/${types.SET_QUERY_RESULT}`, mockedQueryResultPayload); store.commit(`monitoringDashboard/${types.SET_QUERY_RESULT}`, mockedQueryResultPayload);
[mockGraphData] = store.state.monitoringDashboard.dashboard.panel_groups[0].panels; // Pick the second panel group and the first panel in it
[mockGraphData] = store.state.monitoringDashboard.dashboard.panel_groups[1].panels;
makeTimeSeriesChart = (graphData, type) => makeTimeSeriesChart = (graphData, type) =>
shallowMount(TimeSeries, { shallowMount(TimeSeries, {
......
...@@ -11,6 +11,7 @@ function createComponent(props) { ...@@ -11,6 +11,7 @@ function createComponent(props) {
emptyGettingStartedSvgPath: '/path/to/getting-started.svg', emptyGettingStartedSvgPath: '/path/to/getting-started.svg',
emptyLoadingSvgPath: '/path/to/loading.svg', emptyLoadingSvgPath: '/path/to/loading.svg',
emptyNoDataSvgPath: '/path/to/no-data.svg', emptyNoDataSvgPath: '/path/to/no-data.svg',
emptyNoDataSmallSvgPath: '/path/to/no-data-small.svg',
emptyUnableToConnectSvgPath: '/path/to/unable-to-connect.svg', emptyUnableToConnectSvgPath: '/path/to/unable-to-connect.svg',
}, },
}); });
......
...@@ -12,6 +12,7 @@ describe('Embed', () => { ...@@ -12,6 +12,7 @@ describe('Embed', () => {
let wrapper; let wrapper;
let store; let store;
let actions; let actions;
let metricsWithDataGetter;
function mountComponent() { function mountComponent() {
wrapper = shallowMount(Embed, { wrapper = shallowMount(Embed, {
...@@ -31,11 +32,16 @@ describe('Embed', () => { ...@@ -31,11 +32,16 @@ describe('Embed', () => {
fetchMetricsData: () => {}, fetchMetricsData: () => {},
}; };
metricsWithDataGetter = jest.fn();
store = new Vuex.Store({ store = new Vuex.Store({
modules: { modules: {
monitoringDashboard: { monitoringDashboard: {
namespaced: true, namespaced: true,
actions, actions,
getters: {
metricsWithData: () => metricsWithDataGetter,
},
state: initialState, state: initialState,
}, },
}, },
...@@ -43,6 +49,7 @@ describe('Embed', () => { ...@@ -43,6 +49,7 @@ describe('Embed', () => {
}); });
afterEach(() => { afterEach(() => {
metricsWithDataGetter.mockClear();
if (wrapper) { if (wrapper) {
wrapper.destroy(); wrapper.destroy();
} }
...@@ -63,13 +70,13 @@ describe('Embed', () => { ...@@ -63,13 +70,13 @@ describe('Embed', () => {
beforeEach(() => { beforeEach(() => {
store.state.monitoringDashboard.dashboard.panel_groups = groups; store.state.monitoringDashboard.dashboard.panel_groups = groups;
store.state.monitoringDashboard.dashboard.panel_groups[0].panels = metricsData; store.state.monitoringDashboard.dashboard.panel_groups[0].panels = metricsData;
store.state.monitoringDashboard.metricsWithData = metricsWithData;
metricsWithDataGetter.mockReturnValue(metricsWithData);
mountComponent(); mountComponent();
}); });
it('shows a chart when metrics are present', () => { it('shows a chart when metrics are present', () => {
wrapper.setProps({});
expect(wrapper.find('.metrics-embed').exists()).toBe(true); expect(wrapper.find('.metrics-embed').exists()).toBe(true);
expect(wrapper.find(PanelType).exists()).toBe(true); expect(wrapper.find(PanelType).exists()).toBe(true);
expect(wrapper.findAll(PanelType).length).toBe(2); expect(wrapper.findAll(PanelType).length).toBe(2);
......
...@@ -75,11 +75,9 @@ export const metricsData = [ ...@@ -75,11 +75,9 @@ export const metricsData = [
}, },
]; ];
export const initialState = { export const initialState = () => ({
monitoringDashboard: {},
dashboard: { dashboard: {
panel_groups: [], panel_groups: [],
}, },
metricsWithData: [],
useDashboardEndpoint: true, useDashboardEndpoint: true,
}; });
...@@ -240,6 +240,11 @@ export const metricsNewGroupsAPIResponse = [ ...@@ -240,6 +240,11 @@ export const metricsNewGroupsAPIResponse = [
}, },
]; ];
export const mockedEmptyResult = {
metricId: '1_response_metrics_nginx_ingress_throughput_status_code',
result: [],
};
export const mockedQueryResultPayload = { export const mockedQueryResultPayload = {
metricId: '17_system_metrics_kubernetes_container_memory_average', metricId: '17_system_metrics_kubernetes_container_memory_average',
result: [ result: [
...@@ -327,6 +332,30 @@ export const mockedQueryResultPayloadCoresTotal = { ...@@ -327,6 +332,30 @@ export const mockedQueryResultPayloadCoresTotal = {
}; };
export const metricsGroupsAPIResponse = [ export const metricsGroupsAPIResponse = [
{
group: 'Response metrics (NGINX Ingress VTS)',
priority: 10,
panels: [
{
metrics: [
{
id: 'response_metrics_nginx_ingress_throughput_status_code',
label: 'Status Code',
metric_id: 1,
prometheus_endpoint_path:
'/root/autodevops-deploy/environments/32/prometheus/api/v1/query_range?query=sum%28rate%28nginx_upstream_responses_total%7Bupstream%3D~%22%25%7Bkube_namespace%7D-%25%7Bci_environment_slug%7D-.%2A%22%7D%5B2m%5D%29%29+by+%28status_code%29',
query_range:
'sum(rate(nginx_upstream_responses_total{upstream=~"%{kube_namespace}-%{ci_environment_slug}-.*"}[2m])) by (status_code)',
unit: 'req / sec',
},
],
title: 'Throughput',
type: 'area-chart',
weight: 1,
y_label: 'Requests / Sec',
},
],
},
{ {
group: 'System metrics (Kubernetes)', group: 'System metrics (Kubernetes)',
priority: 5, priority: 5,
......
...@@ -191,12 +191,11 @@ describe('Monitoring store actions', () => { ...@@ -191,12 +191,11 @@ describe('Monitoring store actions', () => {
let state; let state;
const response = metricsDashboardResponse; const response = metricsDashboardResponse;
beforeEach(() => { beforeEach(() => {
jest.spyOn(Tracking, 'event');
dispatch = jest.fn(); dispatch = jest.fn();
state = storeState(); state = storeState();
state.dashboardEndpoint = '/dashboard'; state.dashboardEndpoint = '/dashboard';
}); });
it('dispatches receive and success actions', done => { it('on success, dispatches receive and success actions', done => {
const params = {}; const params = {};
document.body.dataset.page = 'projects:environments:metrics'; document.body.dataset.page = 'projects:environments:metrics';
mock.onGet(state.dashboardEndpoint).reply(200, response); mock.onGet(state.dashboardEndpoint).reply(200, response);
...@@ -213,39 +212,65 @@ describe('Monitoring store actions', () => { ...@@ -213,39 +212,65 @@ describe('Monitoring store actions', () => {
response, response,
params, params,
}); });
})
.then(() => {
expect(Tracking.event).toHaveBeenCalledWith(
document.body.dataset.page,
'dashboard_fetch',
{
label: 'custom_metrics_dashboard',
property: 'count',
value: 0,
},
);
done(); done();
}) })
.catch(done.fail); .catch(done.fail);
}); });
it('dispatches failure action', done => {
const params = {}; describe('on failure', () => {
mock.onGet(state.dashboardEndpoint).reply(500); let result;
fetchDashboard( let errorResponse;
{ beforeEach(() => {
state, const params = {};
dispatch, result = () => {
}, mock.onGet(state.dashboardEndpoint).replyOnce(500, errorResponse);
params, return fetchDashboard({ state, dispatch }, params);
) };
.then(() => { });
expect(dispatch).toHaveBeenCalledWith(
'receiveMetricsDashboardFailure', it('dispatches a failure action', done => {
new Error('Request failed with status code 500'), errorResponse = {};
); result()
done(); .then(() => {
}) expect(dispatch).toHaveBeenCalledWith(
.catch(done.fail); 'receiveMetricsDashboardFailure',
new Error('Request failed with status code 500'),
);
expect(createFlash).toHaveBeenCalled();
done();
})
.catch(done.fail);
});
it('dispatches a failure action when a message is returned', done => {
const message = 'Something went wrong with Prometheus!';
errorResponse = { message };
result()
.then(() => {
expect(dispatch).toHaveBeenCalledWith(
'receiveMetricsDashboardFailure',
new Error('Request failed with status code 500'),
);
expect(createFlash).toHaveBeenCalledWith(expect.stringContaining(message));
done();
})
.catch(done.fail);
});
it('does not show a flash error when showErrorBanner is disabled', done => {
state.showErrorBanner = false;
result()
.then(() => {
expect(dispatch).toHaveBeenCalledWith(
'receiveMetricsDashboardFailure',
new Error('Request failed with status code 500'),
);
expect(createFlash).not.toHaveBeenCalled();
done();
})
.catch(done.fail);
});
}); });
}); });
describe('receiveMetricsDashboardSuccess', () => { describe('receiveMetricsDashboardSuccess', () => {
...@@ -317,18 +342,33 @@ describe('Monitoring store actions', () => { ...@@ -317,18 +342,33 @@ describe('Monitoring store actions', () => {
}); });
}); });
describe('fetchPrometheusMetrics', () => { describe('fetchPrometheusMetrics', () => {
const params = {};
let commit; let commit;
let dispatch; let dispatch;
let state;
beforeEach(() => { beforeEach(() => {
jest.spyOn(Tracking, 'event');
commit = jest.fn(); commit = jest.fn();
dispatch = jest.fn(); dispatch = jest.fn();
state = storeState();
}); });
it('commits empty state when state.groups is empty', done => { it('commits empty state when state.groups is empty', done => {
const state = storeState(); const getters = {
const params = {}; metricsWithData: () => [],
fetchPrometheusMetrics({ state, commit, dispatch }, params) };
fetchPrometheusMetrics({ state, commit, dispatch, getters }, params)
.then(() => { .then(() => {
expect(commit).toHaveBeenCalledWith(types.SET_NO_DATA_EMPTY_STATE); expect(Tracking.event).toHaveBeenCalledWith(
document.body.dataset.page,
'dashboard_fetch',
{
label: 'custom_metrics_dashboard',
property: 'count',
value: 0,
},
);
expect(dispatch).not.toHaveBeenCalled(); expect(dispatch).not.toHaveBeenCalled();
expect(createFlash).not.toHaveBeenCalled(); expect(createFlash).not.toHaveBeenCalled();
done(); done();
...@@ -336,19 +376,28 @@ describe('Monitoring store actions', () => { ...@@ -336,19 +376,28 @@ describe('Monitoring store actions', () => {
.catch(done.fail); .catch(done.fail);
}); });
it('dispatches fetchPrometheusMetric for each panel query', done => { it('dispatches fetchPrometheusMetric for each panel query', done => {
const params = {};
const state = storeState();
state.dashboard.panel_groups = metricsDashboardResponse.dashboard.panel_groups; state.dashboard.panel_groups = metricsDashboardResponse.dashboard.panel_groups;
const metric = state.dashboard.panel_groups[0].panels[0].metrics[0]; const [metric] = state.dashboard.panel_groups[0].panels[0].metrics;
fetchPrometheusMetrics({ state, commit, dispatch }, params) const getters = {
metricsWithData: () => [metric.id],
};
fetchPrometheusMetrics({ state, commit, dispatch, getters }, params)
.then(() => { .then(() => {
expect(dispatch).toHaveBeenCalledTimes(3);
expect(dispatch).toHaveBeenCalledWith('fetchPrometheusMetric', { expect(dispatch).toHaveBeenCalledWith('fetchPrometheusMetric', {
metric, metric,
params, params,
}); });
expect(createFlash).not.toHaveBeenCalled(); expect(Tracking.event).toHaveBeenCalledWith(
document.body.dataset.page,
'dashboard_fetch',
{
label: 'custom_metrics_dashboard',
property: 'count',
value: 1,
},
);
done(); done();
}) })
...@@ -357,8 +406,6 @@ describe('Monitoring store actions', () => { ...@@ -357,8 +406,6 @@ describe('Monitoring store actions', () => {
}); });
it('dispatches fetchPrometheusMetric for each panel query, handles an error', done => { it('dispatches fetchPrometheusMetric for each panel query, handles an error', done => {
const params = {};
const state = storeState();
state.dashboard.panel_groups = metricsDashboardResponse.dashboard.panel_groups; state.dashboard.panel_groups = metricsDashboardResponse.dashboard.panel_groups;
const metric = state.dashboard.panel_groups[0].panels[0].metrics[0]; const metric = state.dashboard.panel_groups[0].panels[0].metrics[0];
......
import * as getters from '~/monitoring/stores/getters';
import mutations from '~/monitoring/stores/mutations';
import * as types from '~/monitoring/stores/mutation_types';
import {
metricsGroupsAPIResponse,
mockedEmptyResult,
mockedQueryResultPayload,
mockedQueryResultPayloadCoresTotal,
} from '../mock_data';
describe('Monitoring store Getters', () => {
describe('metricsWithData', () => {
let metricsWithData;
let setupState;
let state;
beforeEach(() => {
setupState = (initState = {}) => {
state = initState;
metricsWithData = getters.metricsWithData(state);
};
});
afterEach(() => {
state = null;
});
it('has method-style access', () => {
setupState();
expect(metricsWithData).toEqual(expect.any(Function));
});
it('when dashboard has no panel groups, returns empty', () => {
setupState({
dashboard: {
panel_groups: [],
},
});
expect(metricsWithData()).toEqual([]);
});
describe('when the dashboard is set', () => {
beforeEach(() => {
setupState({
dashboard: { panel_groups: [] },
});
});
it('no loaded metric returns empty', () => {
mutations[types.RECEIVE_METRICS_DATA_SUCCESS](state, metricsGroupsAPIResponse);
expect(metricsWithData()).toEqual([]);
});
it('an empty metric, returns empty', () => {
mutations[types.RECEIVE_METRICS_DATA_SUCCESS](state, metricsGroupsAPIResponse);
mutations[types.SET_QUERY_RESULT](state, mockedEmptyResult);
expect(metricsWithData()).toEqual([]);
});
it('a metric with results, it returns a metric', () => {
mutations[types.RECEIVE_METRICS_DATA_SUCCESS](state, metricsGroupsAPIResponse);
mutations[types.SET_QUERY_RESULT](state, mockedQueryResultPayload);
expect(metricsWithData()).toEqual([mockedQueryResultPayload.metricId]);
});
it('multiple metrics with results, it return multiple metrics', () => {
mutations[types.RECEIVE_METRICS_DATA_SUCCESS](state, metricsGroupsAPIResponse);
mutations[types.SET_QUERY_RESULT](state, mockedQueryResultPayload);
mutations[types.SET_QUERY_RESULT](state, mockedQueryResultPayloadCoresTotal);
expect(metricsWithData()).toEqual([
mockedQueryResultPayload.metricId,
mockedQueryResultPayloadCoresTotal.metricId,
]);
});
it('multiple metrics with results, it returns metrics filtered by group', () => {
mutations[types.RECEIVE_METRICS_DATA_SUCCESS](state, metricsGroupsAPIResponse);
mutations[types.SET_QUERY_RESULT](state, mockedQueryResultPayload);
mutations[types.SET_QUERY_RESULT](state, mockedQueryResultPayloadCoresTotal);
// First group has no metrics
expect(metricsWithData(state.dashboard.panel_groups[0].key)).toEqual([]);
// Second group has metrics
expect(metricsWithData(state.dashboard.panel_groups[1].key)).toEqual([
mockedQueryResultPayload.metricId,
mockedQueryResultPayloadCoresTotal.metricId,
]);
});
});
});
});
...@@ -7,41 +7,59 @@ import { ...@@ -7,41 +7,59 @@ import {
metricsDashboardResponse, metricsDashboardResponse,
dashboardGitResponse, dashboardGitResponse,
} from '../mock_data'; } from '../mock_data';
import { uniqMetricsId } from '~/monitoring/stores/utils';
describe('Monitoring mutations', () => { describe('Monitoring mutations', () => {
let stateCopy; let stateCopy;
beforeEach(() => { beforeEach(() => {
stateCopy = state(); stateCopy = state();
}); });
describe('RECEIVE_METRICS_DATA_SUCCESS', () => { describe('RECEIVE_METRICS_DATA_SUCCESS', () => {
let groups; let payload;
const getGroups = () => stateCopy.dashboard.panel_groups;
beforeEach(() => { beforeEach(() => {
stateCopy.dashboard.panel_groups = []; stateCopy.dashboard.panel_groups = [];
groups = metricsGroupsAPIResponse; payload = metricsGroupsAPIResponse;
}); });
it('adds a key to the group', () => { it('adds a key to the group', () => {
mutations[types.RECEIVE_METRICS_DATA_SUCCESS](stateCopy, groups); mutations[types.RECEIVE_METRICS_DATA_SUCCESS](stateCopy, payload);
expect(stateCopy.dashboard.panel_groups[0].key).toBe('system-metrics-kubernetes--0'); const groups = getGroups();
expect(groups[0].key).toBe('response-metrics-nginx-ingress-vts--0');
expect(groups[1].key).toBe('system-metrics-kubernetes--1');
}); });
it('normalizes values', () => { it('normalizes values', () => {
mutations[types.RECEIVE_METRICS_DATA_SUCCESS](stateCopy, groups); mutations[types.RECEIVE_METRICS_DATA_SUCCESS](stateCopy, payload);
const expectedLabel = 'Pod average'; const expectedLabel = 'Pod average';
const { label, query_range } = stateCopy.dashboard.panel_groups[0].panels[0].metrics[0]; const { label, query_range } = getGroups()[1].panels[0].metrics[0];
expect(label).toEqual(expectedLabel); expect(label).toEqual(expectedLabel);
expect(query_range.length).toBeGreaterThan(0); expect(query_range.length).toBeGreaterThan(0);
}); });
it('contains one group, which it has two panels and one metrics property', () => { it('contains two groups, with panels with a metric each', () => {
mutations[types.RECEIVE_METRICS_DATA_SUCCESS](stateCopy, groups); mutations[types.RECEIVE_METRICS_DATA_SUCCESS](stateCopy, payload);
expect(stateCopy.dashboard.panel_groups).toBeDefined();
expect(stateCopy.dashboard.panel_groups.length).toEqual(1); const groups = getGroups();
expect(stateCopy.dashboard.panel_groups[0].panels.length).toEqual(2);
expect(stateCopy.dashboard.panel_groups[0].panels[0].metrics.length).toEqual(1); expect(groups).toBeDefined();
expect(stateCopy.dashboard.panel_groups[0].panels[1].metrics.length).toEqual(1); expect(groups).toHaveLength(2);
expect(groups[0].panels).toHaveLength(1);
expect(groups[0].panels[0].metrics).toHaveLength(1);
expect(groups[1].panels).toHaveLength(2);
expect(groups[1].panels[0].metrics).toHaveLength(1);
expect(groups[1].panels[1].metrics).toHaveLength(1);
}); });
it('assigns metrics a metric id', () => { it('assigns metrics a metric id', () => {
mutations[types.RECEIVE_METRICS_DATA_SUCCESS](stateCopy, groups); mutations[types.RECEIVE_METRICS_DATA_SUCCESS](stateCopy, payload);
expect(stateCopy.dashboard.panel_groups[0].panels[0].metrics[0].metricId).toEqual(
const groups = getGroups();
expect(groups[0].panels[0].metrics[0].metricId).toEqual(
'1_response_metrics_nginx_ingress_throughput_status_code',
);
expect(groups[1].panels[0].metrics[0].metricId).toEqual(
'17_system_metrics_kubernetes_container_memory_average', '17_system_metrics_kubernetes_container_memory_average',
); );
}); });
...@@ -52,7 +70,7 @@ describe('Monitoring mutations', () => { ...@@ -52,7 +70,7 @@ describe('Monitoring mutations', () => {
stateCopy.deploymentData = []; stateCopy.deploymentData = [];
mutations[types.RECEIVE_DEPLOYMENTS_DATA_SUCCESS](stateCopy, deploymentData); mutations[types.RECEIVE_DEPLOYMENTS_DATA_SUCCESS](stateCopy, deploymentData);
expect(stateCopy.deploymentData).toBeDefined(); expect(stateCopy.deploymentData).toBeDefined();
expect(stateCopy.deploymentData.length).toEqual(3); expect(stateCopy.deploymentData).toHaveLength(3);
expect(typeof stateCopy.deploymentData[0]).toEqual('object'); expect(typeof stateCopy.deploymentData[0]).toEqual('object');
}); });
}); });
...@@ -73,41 +91,38 @@ describe('Monitoring mutations', () => { ...@@ -73,41 +91,38 @@ describe('Monitoring mutations', () => {
}); });
}); });
describe('SET_QUERY_RESULT', () => { describe('SET_QUERY_RESULT', () => {
const metricId = 12; const metricId = '12_system_metrics_kubernetes_container_memory_total';
const id = 'system_metrics_kubernetes_container_memory_total';
const result = [ const result = [
{ {
values: [[0, 1], [1, 1], [1, 3]], values: [[0, 1], [1, 1], [1, 3]],
}, },
]; ];
const dashboardGroups = metricsDashboardResponse.dashboard.panel_groups;
const getMetrics = () => stateCopy.dashboard.panel_groups[0].panels[0].metrics;
beforeEach(() => { beforeEach(() => {
const dashboardGroups = metricsDashboardResponse.dashboard.panel_groups;
mutations[types.RECEIVE_METRICS_DATA_SUCCESS](stateCopy, dashboardGroups); mutations[types.RECEIVE_METRICS_DATA_SUCCESS](stateCopy, dashboardGroups);
}); });
it('clears empty state', () => { it('clears empty state', () => {
expect(stateCopy.showEmptyState).toBe(true);
mutations[types.SET_QUERY_RESULT](stateCopy, { mutations[types.SET_QUERY_RESULT](stateCopy, {
metricId, metricId,
result, result,
}); });
expect(stateCopy.showEmptyState).toBe(false); expect(stateCopy.showEmptyState).toBe(false);
}); });
it('sets metricsWithData value', () => {
const uniqId = uniqMetricsId({ it('adds results to the store', () => {
metric_id: metricId, expect(getMetrics()[0].result).toBe(undefined);
id,
});
mutations[types.SET_QUERY_RESULT](stateCopy, {
metricId: uniqId,
result,
});
expect(stateCopy.metricsWithData).toEqual([uniqId]);
});
it('does not store empty results', () => {
mutations[types.SET_QUERY_RESULT](stateCopy, { mutations[types.SET_QUERY_RESULT](stateCopy, {
metricId, metricId,
result: [], result,
}); });
expect(stateCopy.metricsWithData).toEqual([]);
expect(getMetrics()[0].result).toHaveLength(result.length);
}); });
}); });
describe('SET_ALL_DASHBOARDS', () => { describe('SET_ALL_DASHBOARDS', () => {
......
...@@ -4,11 +4,13 @@ import { GlToast } from '@gitlab/ui'; ...@@ -4,11 +4,13 @@ import { GlToast } from '@gitlab/ui';
import VueDraggable from 'vuedraggable'; import VueDraggable from 'vuedraggable';
import MockAdapter from 'axios-mock-adapter'; import MockAdapter from 'axios-mock-adapter';
import Dashboard from '~/monitoring/components/dashboard.vue'; import Dashboard from '~/monitoring/components/dashboard.vue';
import EmptyState from '~/monitoring/components/empty_state.vue';
import * as types from '~/monitoring/stores/mutation_types'; import * as types from '~/monitoring/stores/mutation_types';
import { createStore } from '~/monitoring/stores'; import { createStore } from '~/monitoring/stores';
import axios from '~/lib/utils/axios_utils'; import axios from '~/lib/utils/axios_utils';
import { import {
metricsGroupsAPIResponse, metricsGroupsAPIResponse,
mockedEmptyResult,
mockedQueryResultPayload, mockedQueryResultPayload,
mockedQueryResultPayloadCoresTotal, mockedQueryResultPayloadCoresTotal,
mockApiEndpoint, mockApiEndpoint,
...@@ -29,6 +31,7 @@ const propsData = { ...@@ -29,6 +31,7 @@ const propsData = {
emptyGettingStartedSvgPath: '/path/to/getting-started.svg', emptyGettingStartedSvgPath: '/path/to/getting-started.svg',
emptyLoadingSvgPath: '/path/to/loading.svg', emptyLoadingSvgPath: '/path/to/loading.svg',
emptyNoDataSvgPath: '/path/to/no-data.svg', emptyNoDataSvgPath: '/path/to/no-data.svg',
emptyNoDataSmallSvgPath: '/path/to/no-data-small.svg',
emptyUnableToConnectSvgPath: '/path/to/unable-to-connect.svg', emptyUnableToConnectSvgPath: '/path/to/unable-to-connect.svg',
environmentsEndpoint: '/root/hello-prometheus/environments/35', environmentsEndpoint: '/root/hello-prometheus/environments/35',
currentEnvironmentName: 'production', currentEnvironmentName: 'production',
...@@ -43,15 +46,17 @@ const resetSpy = spy => { ...@@ -43,15 +46,17 @@ const resetSpy = spy => {
} }
}; };
export default propsData; let expectedPanelCount;
function setupComponentStore(component) { function setupComponentStore(component) {
// Load 2 panel groups
component.$store.commit( component.$store.commit(
`monitoringDashboard/${types.RECEIVE_METRICS_DATA_SUCCESS}`, `monitoringDashboard/${types.RECEIVE_METRICS_DATA_SUCCESS}`,
metricsGroupsAPIResponse, metricsGroupsAPIResponse,
); );
// Load 2 panels to the dashboard // Load 3 panels to the dashboard, one with an empty result
component.$store.commit(`monitoringDashboard/${types.SET_QUERY_RESULT}`, mockedEmptyResult);
component.$store.commit( component.$store.commit(
`monitoringDashboard/${types.SET_QUERY_RESULT}`, `monitoringDashboard/${types.SET_QUERY_RESULT}`,
mockedQueryResultPayload, mockedQueryResultPayload,
...@@ -61,6 +66,8 @@ function setupComponentStore(component) { ...@@ -61,6 +66,8 @@ function setupComponentStore(component) {
mockedQueryResultPayloadCoresTotal, mockedQueryResultPayloadCoresTotal,
); );
expectedPanelCount = 2;
component.$store.commit( component.$store.commit(
`monitoringDashboard/${types.RECEIVE_ENVIRONMENTS_DATA_SUCCESS}`, `monitoringDashboard/${types.RECEIVE_ENVIRONMENTS_DATA_SUCCESS}`,
environmentData, environmentData,
...@@ -126,13 +133,9 @@ describe('Dashboard', () => { ...@@ -126,13 +133,9 @@ describe('Dashboard', () => {
describe('no data found', () => { describe('no data found', () => {
it('shows the environment selector dropdown', () => { it('shows the environment selector dropdown', () => {
component = new DashboardComponent({ createComponentWrapper();
el: document.querySelector('.prometheus-graphs'),
propsData: { ...propsData, showEmptyState: true },
store,
});
expect(component.$el.querySelector('.js-environments-dropdown')).toBeTruthy(); expect(wrapper.find('.js-environments-dropdown').exists()).toBeTruthy();
}); });
}); });
...@@ -389,9 +392,36 @@ describe('Dashboard', () => { ...@@ -389,9 +392,36 @@ describe('Dashboard', () => {
}); });
}); });
describe('drag and drop function', () => { describe('when one of the metrics is missing', () => {
let expectedPanelCount; // also called metrics, naming to be improved: https://gitlab.com/gitlab-org/gitlab/issues/31565 beforeEach(() => {
mock.onGet(mockApiEndpoint).reply(200, metricsGroupsAPIResponse);
});
beforeEach(done => {
createComponentWrapper({ hasMetrics: true }, { attachToDocument: true });
setupComponentStore(wrapper.vm);
wrapper.vm.$nextTick(done);
});
it('shows a group empty area', () => {
const emptyGroup = wrapper.findAll({ ref: 'empty-group' });
expect(emptyGroup).toHaveLength(1);
expect(emptyGroup.is(EmptyState)).toBe(true);
});
it('group empty area displays a "noDataGroup"', () => {
expect(
wrapper
.findAll({ ref: 'empty-group' })
.at(0)
.props('selectedState'),
).toEqual('noDataGroup');
});
});
describe('drag and drop function', () => {
const findDraggables = () => wrapper.findAll(VueDraggable); const findDraggables = () => wrapper.findAll(VueDraggable);
const findEnabledDraggables = () => findDraggables().filter(f => !f.attributes('disabled')); const findEnabledDraggables = () => findDraggables().filter(f => !f.attributes('disabled'));
const findDraggablePanels = () => wrapper.findAll('.js-draggable-panel'); const findDraggablePanels = () => wrapper.findAll('.js-draggable-panel');
...@@ -399,10 +429,6 @@ describe('Dashboard', () => { ...@@ -399,10 +429,6 @@ describe('Dashboard', () => {
beforeEach(() => { beforeEach(() => {
mock.onGet(mockApiEndpoint).reply(200, metricsGroupsAPIResponse); mock.onGet(mockApiEndpoint).reply(200, metricsGroupsAPIResponse);
expectedPanelCount = metricsGroupsAPIResponse.reduce(
(acc, group) => group.panels.length + acc,
0,
);
}); });
beforeEach(done => { beforeEach(done => {
...@@ -417,10 +443,6 @@ describe('Dashboard', () => { ...@@ -417,10 +443,6 @@ describe('Dashboard', () => {
wrapper.destroy(); wrapper.destroy();
}); });
afterEach(() => {
wrapper.destroy();
});
it('wraps vuedraggable', () => { it('wraps vuedraggable', () => {
expect(findDraggablePanels().exists()).toBe(true); expect(findDraggablePanels().exists()).toBe(true);
expect(findDraggablePanels().length).toEqual(expectedPanelCount); expect(findDraggablePanels().length).toEqual(expectedPanelCount);
...@@ -459,22 +481,20 @@ describe('Dashboard', () => { ...@@ -459,22 +481,20 @@ describe('Dashboard', () => {
it('metrics can be swapped', done => { it('metrics can be swapped', done => {
const firstDraggable = findDraggables().at(0); const firstDraggable = findDraggables().at(0);
const mockMetrics = [...metricsGroupsAPIResponse[0].panels]; const mockMetrics = [...metricsGroupsAPIResponse[1].panels];
const value = () => firstDraggable.props('value');
expect(value().length).toBe(mockMetrics.length); const firstTitle = mockMetrics[0].title;
value().forEach((metric, i) => { const secondTitle = mockMetrics[1].title;
expect(metric.title).toBe(mockMetrics[i].title);
});
// swap two elements and `input` them // swap two elements and `input` them
[mockMetrics[0], mockMetrics[1]] = [mockMetrics[1], mockMetrics[0]]; [mockMetrics[0], mockMetrics[1]] = [mockMetrics[1], mockMetrics[0]];
firstDraggable.vm.$emit('input', mockMetrics); firstDraggable.vm.$emit('input', mockMetrics);
firstDraggable.vm.$nextTick(() => { wrapper.vm.$nextTick(() => {
value().forEach((metric, i) => { const { panels } = wrapper.vm.dashboard.panel_groups[1];
expect(metric.title).toBe(mockMetrics[i].title);
}); expect(panels[1].title).toEqual(firstTitle);
expect(panels[0].title).toEqual(secondTitle);
done(); done();
}); });
}); });
...@@ -584,7 +604,7 @@ describe('Dashboard', () => { ...@@ -584,7 +604,7 @@ describe('Dashboard', () => {
setupComponentStore(component); setupComponentStore(component);
return Vue.nextTick().then(() => { return Vue.nextTick().then(() => {
promPanel = component.$el.querySelector('.prometheus-panel'); [, promPanel] = component.$el.querySelectorAll('.prometheus-panel');
promGroup = promPanel.querySelector('.prometheus-graph-group'); promGroup = promPanel.querySelector('.prometheus-graph-group');
panelToggle = promPanel.querySelector('.js-graph-group-toggle'); panelToggle = promPanel.querySelector('.js-graph-group-toggle');
chart = promGroup.querySelector('.position-relative svg'); chart = promGroup.querySelector('.position-relative svg');
......
import { shallowMount, createLocalVue } from '@vue/test-utils'; import { shallowMount, createLocalVue } from '@vue/test-utils';
import GraphGroup from '~/monitoring/components/graph_group.vue'; import GraphGroup from '~/monitoring/components/graph_group.vue';
import Icon from '~/vue_shared/components/icon.vue';
const localVue = createLocalVue(); const localVue = createLocalVue();
describe('Graph group component', () => { describe('Graph group component', () => {
let graphGroup; let wrapper;
const findPrometheusGroup = () => graphGroup.find('.prometheus-graph-group'); const findGroup = () => wrapper.find({ ref: 'graph-group' });
const findPrometheusPanel = () => graphGroup.find('.prometheus-panel'); const findContent = () => wrapper.find({ ref: 'graph-group-content' });
const findCaretIcon = () => wrapper.find(Icon);
const createComponent = propsData => { const createComponent = propsData => {
graphGroup = shallowMount(localVue.extend(GraphGroup), { wrapper = shallowMount(localVue.extend(GraphGroup), {
propsData, propsData,
sync: false, sync: false,
localVue, localVue,
...@@ -18,57 +20,100 @@ describe('Graph group component', () => { ...@@ -18,57 +20,100 @@ describe('Graph group component', () => {
}; };
afterEach(() => { afterEach(() => {
graphGroup.destroy(); wrapper.destroy();
}); });
describe('When groups can be collapsed', () => { describe('When group is not collapsed', () => {
beforeEach(() => { beforeEach(() => {
createComponent({ createComponent({
name: 'panel', name: 'panel',
collapseGroup: true, collapseGroup: false,
}); });
}); });
it('should show the angle-down caret icon when collapseGroup is true', () => { it('should show the angle-down caret icon', () => {
expect(graphGroup.vm.caretIcon).toBe('angle-down'); expect(findContent().isVisible()).toBe(true);
expect(findCaretIcon().props('name')).toBe('angle-down');
}); });
it('should show the angle-right caret icon when collapseGroup is false', () => { it('should show the angle-right caret icon when the user collapses the group', done => {
graphGroup.vm.collapse(); wrapper.vm.collapse();
expect(graphGroup.vm.caretIcon).toBe('angle-right'); wrapper.vm.$nextTick(() => {
expect(findContent().isVisible()).toBe(false);
expect(findCaretIcon().props('name')).toBe('angle-right');
done();
});
}); });
});
describe('When groups can not be collapsed', () => { it('should show the open the group when collapseGroup is set to true', done => {
beforeEach(() => { wrapper.setProps({
createComponent({
name: 'panel',
collapseGroup: true, collapseGroup: true,
showPanels: false, });
wrapper.vm.$nextTick(() => {
expect(findContent().isVisible()).toBe(true);
expect(findCaretIcon().props('name')).toBe('angle-down');
done();
}); });
}); });
it('should not contain a prometheus-panel container when showPanels is false', () => { describe('When group is collapsed', () => {
expect(findPrometheusPanel().exists()).toBe(false); beforeEach(() => {
createComponent({
name: 'panel',
collapseGroup: true,
});
});
it('should show the angle-down caret icon when collapseGroup is true', () => {
expect(wrapper.vm.caretIcon).toBe('angle-right');
});
it('should show the angle-right caret icon when collapseGroup is false', () => {
wrapper.vm.collapse();
expect(wrapper.vm.caretIcon).toBe('angle-down');
});
}); });
});
describe('When collapseGroup prop is updated', () => { describe('When groups can not be collapsed', () => {
beforeEach(() => { beforeEach(() => {
createComponent({ name: 'panel', collapseGroup: false }); createComponent({
name: 'panel',
showPanels: false,
collapseGroup: false,
});
});
it('should not have a container when showPanels is false', () => {
expect(findGroup().exists()).toBe(false);
expect(findContent().exists()).toBe(true);
});
}); });
it('previously collapsed group should respond to the prop change', done => { describe('When group does not show a panel heading', () => {
expect(findPrometheusGroup().exists()).toBe(false); beforeEach(() => {
createComponent({
name: 'panel',
showPanels: false,
collapseGroup: false,
});
});
graphGroup.setProps({ it('should collapse the panel content', () => {
collapseGroup: true, expect(findContent().isVisible()).toBe(true);
expect(findCaretIcon().exists()).toBe(false);
}); });
graphGroup.vm.$nextTick(() => { it('should show the panel content when clicked', done => {
expect(findPrometheusGroup().exists()).toBe(true); wrapper.vm.collapse();
done();
wrapper.vm.$nextTick(() => {
expect(findContent().isVisible()).toBe(true);
expect(findCaretIcon().exists()).toBe(false);
done();
});
}); });
}); });
}); });
......
import Vue from 'vue'; import Vue from 'vue';
import mountComponent from 'spec/helpers/vue_mount_component_helper';
import axios from '~/lib/utils/axios_utils'; import axios from '~/lib/utils/axios_utils';
import stopJobsModal from '~/pages/admin/jobs/index/components/stop_jobs_modal.vue'; import stopJobsModal from '~/pages/admin/jobs/index/components/stop_jobs_modal.vue';
import mountComponent from 'spec/helpers/vue_mount_component_helper';
describe('stop_jobs_modal.vue', () => { describe('stop_jobs_modal.vue', () => {
const props = { const props = {
url: `${gl.TEST_HOST}/stop_jobs_modal.vue/stopAll`, url: `${gl.TEST_HOST}/stop_jobs_modal.vue/stopAll`,
......
import Vue from 'vue'; import Vue from 'vue';
import mountComponent from 'spec/helpers/vue_mount_component_helper';
import promoteLabelModal from '~/pages/projects/labels/components/promote_label_modal.vue'; import promoteLabelModal from '~/pages/projects/labels/components/promote_label_modal.vue';
import eventHub from '~/pages/projects/labels/event_hub'; import eventHub from '~/pages/projects/labels/event_hub';
import axios from '~/lib/utils/axios_utils'; import axios from '~/lib/utils/axios_utils';
import mountComponent from 'spec/helpers/vue_mount_component_helper';
describe('Promote label modal', () => { describe('Promote label modal', () => {
let vm; let vm;
......
import Vue from 'vue'; import Vue from 'vue';
import mountComponent from 'spec/helpers/vue_mount_component_helper';
import axios from '~/lib/utils/axios_utils'; import axios from '~/lib/utils/axios_utils';
import deleteMilestoneModal from '~/pages/milestones/shared/components/delete_milestone_modal.vue'; import deleteMilestoneModal from '~/pages/milestones/shared/components/delete_milestone_modal.vue';
import eventHub from '~/pages/milestones/shared/event_hub'; import eventHub from '~/pages/milestones/shared/event_hub';
import mountComponent from 'spec/helpers/vue_mount_component_helper';
describe('delete_milestone_modal.vue', () => { describe('delete_milestone_modal.vue', () => {
const Component = Vue.extend(deleteMilestoneModal); const Component = Vue.extend(deleteMilestoneModal);
const props = { const props = {
......
import Vue from 'vue'; import Vue from 'vue';
import mountComponent from 'spec/helpers/vue_mount_component_helper';
import promoteMilestoneModal from '~/pages/milestones/shared/components/promote_milestone_modal.vue'; import promoteMilestoneModal from '~/pages/milestones/shared/components/promote_milestone_modal.vue';
import eventHub from '~/pages/milestones/shared/event_hub'; import eventHub from '~/pages/milestones/shared/event_hub';
import axios from '~/lib/utils/axios_utils'; import axios from '~/lib/utils/axios_utils';
import mountComponent from 'spec/helpers/vue_mount_component_helper';
describe('Promote milestone modal', () => { describe('Promote milestone modal', () => {
let vm; let vm;
......
...@@ -2,8 +2,8 @@ import Vue from 'vue'; ...@@ -2,8 +2,8 @@ import Vue from 'vue';
import { GlobalWorkerOptions } from 'pdfjs-dist/build/pdf'; import { GlobalWorkerOptions } from 'pdfjs-dist/build/pdf';
import workerSrc from 'pdfjs-dist/build/pdf.worker.min'; import workerSrc from 'pdfjs-dist/build/pdf.worker.min';
import PDFLab from '~/pdf/index.vue';
import { FIXTURES_PATH } from 'spec/test_constants'; import { FIXTURES_PATH } from 'spec/test_constants';
import PDFLab from '~/pdf/index.vue';
const pdf = `${FIXTURES_PATH}/blob/pdf/test.pdf`; const pdf = `${FIXTURES_PATH}/blob/pdf/test.pdf`;
......
...@@ -2,9 +2,9 @@ import Vue from 'vue'; ...@@ -2,9 +2,9 @@ import Vue from 'vue';
import pdfjsLib from 'pdfjs-dist/build/pdf'; import pdfjsLib from 'pdfjs-dist/build/pdf';
import workerSrc from 'pdfjs-dist/build/pdf.worker.min'; import workerSrc from 'pdfjs-dist/build/pdf.worker.min';
import PageComponent from '~/pdf/page/index.vue';
import mountComponent from 'spec/helpers/vue_mount_component_helper'; import mountComponent from 'spec/helpers/vue_mount_component_helper';
import { FIXTURES_PATH } from 'spec/test_constants'; import { FIXTURES_PATH } from 'spec/test_constants';
import PageComponent from '~/pdf/page/index.vue';
const testPDF = `${FIXTURES_PATH}/blob/pdf/test.pdf`; const testPDF = `${FIXTURES_PATH}/blob/pdf/test.pdf`;
......
import MockAdapter from 'axios-mock-adapter';
import axios from '~/lib/utils/axios_utils'; import axios from '~/lib/utils/axios_utils';
import '~/performance_bar/components/performance_bar_app.vue'; import '~/performance_bar/components/performance_bar_app.vue';
import performanceBar from '~/performance_bar'; import performanceBar from '~/performance_bar';
import PerformanceBarService from '~/performance_bar/services/performance_bar_service'; import PerformanceBarService from '~/performance_bar/services/performance_bar_service';
import MockAdapter from 'axios-mock-adapter';
describe('performance bar wrapper', () => { describe('performance bar wrapper', () => {
let mock; let mock;
let vm; let vm;
......
import MockAdapter from 'axios-mock-adapter'; import MockAdapter from 'axios-mock-adapter';
import setTimeoutPromise from 'spec/helpers/set_timeout_promise_helper';
import axios from '~/lib/utils/axios_utils'; import axios from '~/lib/utils/axios_utils';
import PersistentUserCallout from '~/persistent_user_callout'; import PersistentUserCallout from '~/persistent_user_callout';
import setTimeoutPromise from 'spec/helpers/set_timeout_promise_helper';
describe('PersistentUserCallout', () => { describe('PersistentUserCallout', () => {
const dismissEndpoint = '/dismiss'; const dismissEndpoint = '/dismiss';
......
import Vue from 'vue'; import Vue from 'vue';
import JobGroupDropdown from '~/pipelines/components/graph/job_group_dropdown.vue';
import mountComponent from 'spec/helpers/vue_mount_component_helper'; import mountComponent from 'spec/helpers/vue_mount_component_helper';
import JobGroupDropdown from '~/pipelines/components/graph/job_group_dropdown.vue';
describe('job group dropdown component', () => { describe('job group dropdown component', () => {
const Component = Vue.extend(JobGroupDropdown); const Component = Vue.extend(JobGroupDropdown);
......
import Vue from 'vue'; import Vue from 'vue';
import LinkedPipelinesColumn from '~/pipelines/components/graph/linked_pipelines_column.vue';
import mountComponent from 'spec/helpers/vue_mount_component_helper'; import mountComponent from 'spec/helpers/vue_mount_component_helper';
import LinkedPipelinesColumn from '~/pipelines/components/graph/linked_pipelines_column.vue';
import mockData from './linked_pipelines_mock_data'; import mockData from './linked_pipelines_mock_data';
describe('Linked Pipelines Column', () => { describe('Linked Pipelines Column', () => {
......
import Vue from 'vue'; import Vue from 'vue';
import stageColumnComponent from '~/pipelines/components/graph/stage_column_component.vue';
import mountComponent from 'spec/helpers/vue_mount_component_helper'; import mountComponent from 'spec/helpers/vue_mount_component_helper';
import stageColumnComponent from '~/pipelines/components/graph/stage_column_component.vue';
describe('stage column component', () => { describe('stage column component', () => {
let component; let component;
......
import Vue from 'vue'; import Vue from 'vue';
import MockAdapter from 'axios-mock-adapter'; import MockAdapter from 'axios-mock-adapter';
import axios from '~/lib/utils/axios_utils';
import PipelinesActions from '~/pipelines/components/pipelines_actions.vue';
import mountComponent from 'spec/helpers/vue_mount_component_helper'; import mountComponent from 'spec/helpers/vue_mount_component_helper';
import { TEST_HOST } from 'spec/test_constants'; import { TEST_HOST } from 'spec/test_constants';
import axios from '~/lib/utils/axios_utils';
import PipelinesActions from '~/pipelines/components/pipelines_actions.vue';
describe('Pipelines Actions dropdown', () => { describe('Pipelines Actions dropdown', () => {
const Component = Vue.extend(PipelinesActions); const Component = Vue.extend(PipelinesActions);
......
import Vue from 'vue'; import Vue from 'vue';
import MockAdapter from 'axios-mock-adapter'; import MockAdapter from 'axios-mock-adapter';
import mountComponent from 'spec/helpers/vue_mount_component_helper';
import axios from '~/lib/utils/axios_utils'; import axios from '~/lib/utils/axios_utils';
import pipelinesComp from '~/pipelines/components/pipelines.vue'; import pipelinesComp from '~/pipelines/components/pipelines.vue';
import Store from '~/pipelines/stores/pipelines_store'; import Store from '~/pipelines/stores/pipelines_store';
import mountComponent from 'spec/helpers/vue_mount_component_helper';
import { pipelineWithStages, stageReply } from './mock_data'; import { pipelineWithStages, stageReply } from './mock_data';
describe('Pipelines', () => { describe('Pipelines', () => {
......
import Vue from 'vue'; import Vue from 'vue';
import MockAdapter from 'axios-mock-adapter'; import MockAdapter from 'axios-mock-adapter';
import mountComponent from 'spec/helpers/vue_mount_component_helper';
import axios from '~/lib/utils/axios_utils'; import axios from '~/lib/utils/axios_utils';
import stage from '~/pipelines/components/stage.vue'; import stage from '~/pipelines/components/stage.vue';
import eventHub from '~/pipelines/event_hub'; import eventHub from '~/pipelines/event_hub';
import mountComponent from 'spec/helpers/vue_mount_component_helper';
import { stageReply } from './mock_data'; import { stageReply } from './mock_data';
describe('Pipelines stage component', () => { describe('Pipelines stage component', () => {
......
import Vue from 'vue'; import Vue from 'vue';
import deleteAccountModal from '~/profile/account/components/delete_account_modal.vue';
import mountComponent from 'spec/helpers/vue_mount_component_helper'; import mountComponent from 'spec/helpers/vue_mount_component_helper';
import deleteAccountModal from '~/profile/account/components/delete_account_modal.vue';
describe('DeleteAccountModal component', () => { describe('DeleteAccountModal component', () => {
const actionUrl = `${gl.TEST_HOST}/delete/user`; const actionUrl = `${gl.TEST_HOST}/delete/user`;
......
import Vue from 'vue'; import Vue from 'vue';
import axios from '~/lib/utils/axios_utils';
import MockAdapter from 'axios-mock-adapter'; import MockAdapter from 'axios-mock-adapter';
import mountComponent from 'spec/helpers/vue_mount_component_helper';
import axios from '~/lib/utils/axios_utils';
import updateUsername from '~/profile/account/components/update_username.vue'; import updateUsername from '~/profile/account/components/update_username.vue';
import mountComponent from 'spec/helpers/vue_mount_component_helper';
describe('UpdateUsername component', () => { describe('UpdateUsername component', () => {
const rootUrl = gl.TEST_HOST; const rootUrl = gl.TEST_HOST;
......
import MockAdapter from 'axios-mock-adapter'; import MockAdapter from 'axios-mock-adapter';
import testAction from 'spec/helpers/vuex_action_helper';
import axios from '~/lib/utils/axios_utils'; import axios from '~/lib/utils/axios_utils';
import * as types from '~/related_merge_requests/store/mutation_types'; import * as types from '~/related_merge_requests/store/mutation_types';
import actionsModule, * as actions from '~/related_merge_requests/store/actions'; import actionsModule, * as actions from '~/related_merge_requests/store/actions';
import testAction from 'spec/helpers/vuex_action_helper';
describe('RelatedMergeRequest store actions', () => { describe('RelatedMergeRequest store actions', () => {
let state; let state;
......
import Vue from 'vue';
import _ from 'underscore'; import _ from 'underscore';
import Vue from 'vue';
import { mountComponentWithStore } from 'spec/helpers/vue_mount_component_helper';
import app from '~/releases/list/components/app.vue'; import app from '~/releases/list/components/app.vue';
import createStore from '~/releases/list/store'; import createStore from '~/releases/list/store';
import api from '~/api'; import api from '~/api';
import { mountComponentWithStore } from 'spec/helpers/vue_mount_component_helper';
import { resetStore } from '../store/helpers'; import { resetStore } from '../store/helpers';
import { import {
pageInfoHeadersWithoutPagination, pageInfoHeadersWithoutPagination,
......
import testAction from 'spec/helpers/vuex_action_helper';
import { import {
requestReleases, requestReleases,
fetchReleases, fetchReleases,
...@@ -8,7 +9,6 @@ import state from '~/releases/list/store/state'; ...@@ -8,7 +9,6 @@ import state from '~/releases/list/store/state';
import * as types from '~/releases/list/store/mutation_types'; import * as types from '~/releases/list/store/mutation_types';
import api from '~/api'; import api from '~/api';
import { parseIntPagination } from '~/lib/utils/common_utils'; import { parseIntPagination } from '~/lib/utils/common_utils';
import testAction from 'spec/helpers/vuex_action_helper';
import { pageInfoHeadersWithoutPagination, releases } from '../../mock_data'; import { pageInfoHeadersWithoutPagination, releases } from '../../mock_data';
describe('Releases State actions', () => { describe('Releases State actions', () => {
......
import Vue from 'vue'; import Vue from 'vue';
import Vuex from 'vuex'; import Vuex from 'vuex';
import component from '~/reports/components/modal_open_name.vue';
import { mountComponentWithStore } from 'spec/helpers/vue_mount_component_helper'; import { mountComponentWithStore } from 'spec/helpers/vue_mount_component_helper';
import component from '~/reports/components/modal_open_name.vue';
Vue.use(Vuex); Vue.use(Vuex);
......
import Vue from 'vue'; import Vue from 'vue';
import component from '~/reports/components/summary_row.vue';
import mountComponent from 'spec/helpers/vue_mount_component_helper'; import mountComponent from 'spec/helpers/vue_mount_component_helper';
import component from '~/reports/components/summary_row.vue';
describe('Summary row', () => { describe('Summary row', () => {
const Component = Vue.extend(component); const Component = Vue.extend(component);
......
import MockAdapter from 'axios-mock-adapter'; import MockAdapter from 'axios-mock-adapter';
import testAction from 'spec/helpers/vuex_action_helper';
import { TEST_HOST } from 'spec/test_constants';
import axios from '~/lib/utils/axios_utils'; import axios from '~/lib/utils/axios_utils';
import { import {
setEndpoint, setEndpoint,
...@@ -13,8 +15,6 @@ import { ...@@ -13,8 +15,6 @@ import {
} from '~/reports/store/actions'; } from '~/reports/store/actions';
import state from '~/reports/store/state'; import state from '~/reports/store/state';
import * as types from '~/reports/store/mutation_types'; import * as types from '~/reports/store/mutation_types';
import testAction from 'spec/helpers/vuex_action_helper';
import { TEST_HOST } from 'spec/test_constants';
describe('Reports Store Actions', () => { describe('Reports Store Actions', () => {
let mockedState; let mockedState;
......
...@@ -34,6 +34,7 @@ describe('MrWidgetPipelineContainer', () => { ...@@ -34,6 +34,7 @@ describe('MrWidgetPipelineContainer', () => {
expect(wrapper.find(MrWidgetPipeline).props()).toEqual( expect(wrapper.find(MrWidgetPipeline).props()).toEqual(
jasmine.objectContaining({ jasmine.objectContaining({
pipeline: mockStore.pipeline, pipeline: mockStore.pipeline,
pipelineCoverageDelta: mockStore.pipelineCoverageDelta,
ciStatus: mockStore.ciStatus, ciStatus: mockStore.ciStatus,
hasCi: mockStore.hasCI, hasCi: mockStore.hasCI,
sourceBranch: mockStore.sourceBranch, sourceBranch: mockStore.sourceBranch,
...@@ -68,6 +69,7 @@ describe('MrWidgetPipelineContainer', () => { ...@@ -68,6 +69,7 @@ describe('MrWidgetPipelineContainer', () => {
expect(wrapper.find(MrWidgetPipeline).props()).toEqual( expect(wrapper.find(MrWidgetPipeline).props()).toEqual(
jasmine.objectContaining({ jasmine.objectContaining({
pipeline: mockStore.mergePipeline, pipeline: mockStore.mergePipeline,
pipelineCoverageDelta: mockStore.pipelineCoverageDelta,
ciStatus: mockStore.ciStatus, ciStatus: mockStore.ciStatus,
hasCi: mockStore.hasCI, hasCi: mockStore.hasCI,
sourceBranch: mockStore.targetBranch, sourceBranch: mockStore.targetBranch,
......
...@@ -62,6 +62,38 @@ describe('MRWidgetPipeline', () => { ...@@ -62,6 +62,38 @@ describe('MRWidgetPipeline', () => {
expect(vm.hasCIError).toEqual(true); expect(vm.hasCIError).toEqual(true);
}); });
}); });
describe('coverageDeltaClass', () => {
it('should return no class if there is no coverage change', () => {
vm = mountComponent(Component, {
pipeline: mockData.pipeline,
pipelineCoverageDelta: '0',
troubleshootingDocsPath: 'help',
});
expect(vm.coverageDeltaClass).toEqual('');
});
it('should return text-success if the coverage increased', () => {
vm = mountComponent(Component, {
pipeline: mockData.pipeline,
pipelineCoverageDelta: '10',
troubleshootingDocsPath: 'help',
});
expect(vm.coverageDeltaClass).toEqual('text-success');
});
it('should return text-danger if the coverage decreased', () => {
vm = mountComponent(Component, {
pipeline: mockData.pipeline,
pipelineCoverageDelta: '-12',
troubleshootingDocsPath: 'help',
});
expect(vm.coverageDeltaClass).toEqual('text-danger');
});
});
}); });
describe('rendered output', () => { describe('rendered output', () => {
...@@ -96,6 +128,7 @@ describe('MRWidgetPipeline', () => { ...@@ -96,6 +128,7 @@ describe('MRWidgetPipeline', () => {
pipeline: mockData.pipeline, pipeline: mockData.pipeline,
hasCi: true, hasCi: true,
ciStatus: 'success', ciStatus: 'success',
pipelineCoverageDelta: mockData.pipelineCoverageDelta,
troubleshootingDocsPath: 'help', troubleshootingDocsPath: 'help',
}); });
}); });
...@@ -132,6 +165,13 @@ describe('MRWidgetPipeline', () => { ...@@ -132,6 +165,13 @@ describe('MRWidgetPipeline', () => {
`Coverage ${mockData.pipeline.coverage}`, `Coverage ${mockData.pipeline.coverage}`,
); );
}); });
it('should render pipeline coverage delta information', () => {
expect(vm.$el.querySelector('.js-pipeline-coverage-delta.text-danger')).toBeDefined();
expect(vm.$el.querySelector('.js-pipeline-coverage-delta').textContent).toContain(
`(${mockData.pipelineCoverageDelta}%)`,
);
});
}); });
describe('without commit path', () => { describe('without commit path', () => {
......
...@@ -185,6 +185,7 @@ export default { ...@@ -185,6 +185,7 @@ export default {
created_at: '2017-04-07T12:27:19.520Z', created_at: '2017-04-07T12:27:19.520Z',
updated_at: '2017-04-07T15:28:44.800Z', updated_at: '2017-04-07T15:28:44.800Z',
}, },
pipelineCoverageDelta: '15.25',
work_in_progress: false, work_in_progress: false,
source_branch_exists: false, source_branch_exists: false,
mergeable_discussions_state: true, mergeable_discussions_state: true,
......
...@@ -2821,6 +2821,63 @@ describe MergeRequest do ...@@ -2821,6 +2821,63 @@ describe MergeRequest do
end end
end end
describe '#pipeline_coverage_delta' do
let!(:project) { create(:project, :repository) }
let!(:merge_request) { create(:merge_request, source_project: project) }
let!(:source_pipeline) do
create(:ci_pipeline,
project: project,
ref: merge_request.source_branch,
sha: merge_request.diff_head_sha
)
end
let!(:target_pipeline) do
create(:ci_pipeline,
project: project,
ref: merge_request.target_branch,
sha: merge_request.diff_base_sha
)
end
def create_build(pipeline, coverage, name)
create(:ci_build, :success, pipeline: pipeline, coverage: coverage, name: name)
merge_request.update_head_pipeline
end
context 'when both source and target branches have coverage information' do
it 'returns the appropriate coverage delta' do
create_build(source_pipeline, 60.2, 'test:1')
create_build(target_pipeline, 50, 'test:2')
expect(merge_request.pipeline_coverage_delta).to eq('10.20')
end
end
context 'when target branch does not have coverage information' do
it 'returns nil' do
create_build(source_pipeline, 50, 'test:1')
expect(merge_request.pipeline_coverage_delta).to be_nil
end
end
context 'when source branch does not have coverage information' do
it 'returns nil for coverage_delta' do
create_build(target_pipeline, 50, 'test:1')
expect(merge_request.pipeline_coverage_delta).to be_nil
end
end
context 'neither source nor target branch has coverage information' do
it 'returns nil for coverage_delta' do
expect(merge_request.pipeline_coverage_delta).to be_nil
end
end
end
describe '#base_pipeline' do describe '#base_pipeline' do
let(:pipeline_arguments) do let(:pipeline_arguments) do
{ {
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment