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';
import {
GlEmptyState,
GlButton,
GlIcon,
GlLink,
GlLoadingIcon,
GlTable,
GlSearchBoxByClick,
GlFormInput,
GlDropdown,
GlDropdownItem,
GlDropdownDivider,
GlTooltipDirective,
} from '@gitlab/ui';
import AccessorUtils from '~/lib/utils/accessor';
import Icon from '~/vue_shared/components/icon.vue';
import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue';
import { __ } from '~/locale';
......@@ -24,14 +30,19 @@ export default {
components: {
GlEmptyState,
GlButton,
GlDropdown,
GlDropdownItem,
GlDropdownDivider,
GlIcon,
GlLink,
GlLoadingIcon,
GlTable,
GlSearchBoxByClick,
GlFormInput,
Icon,
TimeAgo,
},
directives: {
GlTooltip: GlTooltipDirective,
TrackEvent: TrackEventDirective,
},
props: {
......@@ -56,13 +67,14 @@ export default {
required: true,
},
},
hasLocalStorage: AccessorUtils.isLocalStorageAccessSafe(),
data() {
return {
errorSearchQuery: '',
};
},
computed: {
...mapState('list', ['errors', 'externalUrl', 'loading']),
...mapState('list', ['errors', 'externalUrl', 'loading', 'recentSearches']),
},
created() {
if (this.errorTrackingEnabled) {
......@@ -70,9 +82,23 @@ export default {
}
},
methods: {
...mapActions('list', ['startPolling', 'restartPolling']),
...mapActions('list', [
'startPolling',
'restartPolling',
'addRecentSearch',
'clearRecentSearches',
'loadRecentSearches',
'setIndexPath',
]),
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,
getDetailsLink(errorId) {
......@@ -85,81 +111,119 @@ export default {
<template>
<div>
<div v-if="errorTrackingEnabled">
<div>
<div class="d-flex flex-row justify-content-around bg-secondary border">
<gl-search-box-by-click
v-model="errorSearchQuery"
class="col-lg-10 m-3 p-0"
:placeholder="__('Search or filter results...')"
type="search"
autofocus
@submit="filterErrors"
/>
<gl-button
v-track-event="trackViewInSentryOptions(externalUrl)"
class="m-3"
variant="primary"
:href="externalUrl"
target="_blank"
<div class="d-flex flex-row justify-content-around bg-secondary border p-3">
<div class="filtered-search-box">
<gl-dropdown
:text="__('Recent searches')"
class="filtered-search-history-dropdown-wrapper d-none d-md-block"
toggle-class="filtered-search-history-dropdown-toggle-button"
:disabled="loading"
>
{{ __('View in Sentry') }}
<icon name="external-link" class="flex-shrink-0" />
</gl-button>
</div>
<div v-if="loading" class="py-3">
<gl-loading-icon size="md" />
<div v-if="!$options.hasLocalStorage" class="px-3">
{{ __('This feature requires local storage to be enabled') }}
</div>
<template v-else-if="recentSearches.length > 0">
<gl-dropdown-item
v-for="searchQuery in recentSearches"
: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>
<gl-table
v-else
class="mt-3"
:items="errors"
:fields="$options.fields"
:show-empty="true"
fixed
stacked="sm"
<gl-button
v-track-event="trackViewInSentryOptions(externalUrl)"
class="ml-3"
variant="primary"
:href="externalUrl"
target="_blank"
>
<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>
{{ __('View in Sentry') }}
<icon name="external-link" class="flex-shrink-0" />
</gl-button>
</div>
<template slot="events" slot-scope="errors">
<div class="text-md-right">{{ errors.item.count }}</div>
</template>
<div v-if="loading" class="py-3">
<gl-loading-icon size="md" />
</div>
<template slot="users" slot-scope="errors">
<div class="text-md-right">{{ errors.item.userCount }}</div>
</template>
<gl-table
v-else
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">
<div class="d-flex align-items-center">
<time-ago :time="errors.item.lastSeen" class="text-secondary" />
</div>
</template>
<template slot="empty">
<div ref="empty">
{{ __('No errors to display.') }}
<gl-link class="js-try-again" @click="restartPolling">
{{ __('Check again') }}
</gl-link>
</div>
</template>
</gl-table>
</div>
<template slot="events" slot-scope="errors">
<div class="text-md-right">{{ errors.item.count }}</div>
</template>
<template slot="users" slot-scope="errors">
<div class="text-md-right">{{ errors.item.userCount }}</div>
</template>
<template slot="lastSeen" slot-scope="errors">
<div class="d-flex align-items-center">
<time-ago :time="errors.item.lastSeen" class="text-secondary" />
</div>
</template>
<template slot="empty">
<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 v-else-if="userCanEnableErrorTracking">
<gl-empty-state
......
......@@ -51,4 +51,20 @@ export function restartPolling({ commit }) {
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 const SET_ERRORS = 'SET_ERRORS';
export const SET_EXTERNAL_URL = 'SET_EXTERNAL_URL';
export const SET_INDEX_PATH = 'SET_INDEX_PATH';
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 { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import AccessorUtils from '~/lib/utils/accessor';
export default {
[types.SET_ERRORS](state, data) {
......@@ -11,4 +12,39 @@ export default {
[types.SET_LOADING](state, 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 () => ({
errors: [],
externalUrl: '',
loading: true,
indexPath: '',
recentSearches: [],
});
<script>
import _ from 'underscore';
import { mapActions, mapState } from 'vuex';
import { mapActions, mapState, mapGetters } from 'vuex';
import VueDraggable from 'vuedraggable';
import {
GlButton,
......@@ -99,6 +99,10 @@ export default {
type: String,
required: true,
},
emptyNoDataSmallSvgPath: {
type: String,
required: true,
},
emptyUnableToConnectSvgPath: {
type: String,
required: true,
......@@ -176,11 +180,11 @@ export default {
'showEmptyState',
'environments',
'deploymentData',
'metricsWithData',
'useDashboardEndpoint',
'allDashboards',
'additionalPanelTypesEnabled',
]),
...mapGetters('monitoringDashboard', ['metricsWithData']),
firstDashboard() {
return this.environmentsEndpoint.length > 0 && this.allDashboards.length > 0
? this.allDashboards[0]
......@@ -280,13 +284,8 @@ export default {
submitCustomMetricsForm() {
this.$refs.customMetricsForm.submit();
},
chartsWithData(panels) {
return panels.filter(panel =>
panel.metrics.some(metric => this.metricsWithData.includes(metric.metric_id)),
);
},
groupHasData(group) {
return this.chartsWithData(group.panels).length > 0;
return this.metricsWithData(group.key).length > 0;
},
onDateTimePickerApply(timeWindowUrlParams) {
return redirectTo(mergeUrlParams(timeWindowUrlParams, window.location.href));
......@@ -447,42 +446,61 @@ export default {
:key="`${groupData.group}.${groupData.priority}`"
:name="groupData.group"
:show-panels="showPanels"
:collapse-group="groupHasData(groupData)"
:collapse-group="!groupHasData(groupData)"
>
<vue-draggable
:value="groupData.panels"
group="metrics-dashboard"
:component-data="{ attrs: { class: 'row mx-0 w-100' } }"
: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 v-if="groupHasData(groupData)">
<vue-draggable
:value="groupData.panels"
group="metrics-dashboard"
:component-data="{ attrs: { class: 'row mx-0 w-100' } }"
:disabled="!isRearrangingPanels"
@input="updatePanels(groupData.key, $event)"
>
<div class="position-relative draggable-panel js-draggable-panel">
<div
v-if="isRearrangingPanels"
class="draggable-remove js-draggable-remove p-2 w-100 position-absolute d-flex justify-content-end"
@click="removePanel(groupData.key, groupData.panels, graphIndex)"
>
<a class="mx-2 p-2 draggable-remove-link" :aria-label="__('Remove')"
><icon name="close"
/></a>
</div>
<div
v-for="(graphData, graphIndex) in groupData.panels"
:key="`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
v-if="isRearrangingPanels"
class="draggable-remove js-draggable-remove p-2 w-100 position-absolute d-flex justify-content-end"
@click="removePanel(groupData.key, groupData.panels, graphIndex)"
>
<a class="mx-2 p-2 draggable-remove-link" :aria-label="__('Remove')"
><icon name="close"
/></a>
</div>
<panel-type
:clipboard-text="generateLink(groupData.group, graphData.title, graphData.y_label)"
:graph-data="graphData"
:alerts-endpoint="alertsEndpoint"
:prometheus-alerts-available="prometheusAlertsAvailable"
:index="`${index}-${graphIndex}`"
/>
<panel-type
:clipboard-text="
generateLink(groupData.group, graphData.title, graphData.y_label)
"
:graph-data="graphData"
:alerts-endpoint="alertsEndpoint"
:prometheus-alerts-available="prometheusAlertsAvailable"
:index="`${index}-${graphIndex}`"
/>
</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>
</div>
<empty-state
......@@ -494,6 +512,7 @@ export default {
: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="smallEmptyState"
/>
......
<script>
import { mapActions, mapState } from 'vuex';
import { mapActions, mapState, mapGetters } from 'vuex';
import { getParameterValues, removeParams } from '~/lib/utils/url_utility';
import PanelType from 'ee_else_ce/monitoring/components/panel_type.vue';
import GraphGroup from './graph_group.vue';
......@@ -35,7 +35,8 @@ export default {
};
},
computed: {
...mapState('monitoringDashboard', ['dashboard', 'metricsWithData']),
...mapState('monitoringDashboard', ['dashboard']),
...mapGetters('monitoringDashboard', ['metricsWithData']),
charts() {
if (!this.dashboard || !this.dashboard.panel_groups) {
return [];
......@@ -73,7 +74,7 @@ export default {
'setShowErrorBanner',
]),
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() {
setTimeout(() => {
......
......@@ -37,6 +37,10 @@ export default {
type: String,
required: true,
},
emptyNoDataSmallSvgPath: {
type: String,
required: true,
},
emptyUnableToConnectSvgPath: {
type: String,
required: true,
......@@ -80,6 +84,11 @@ export default {
secondaryButtonText: '',
secondaryButtonPath: '',
},
noDataGroup: {
svgUrl: this.emptyNoDataSmallSvgPath,
title: __('No data to display'),
description: __('The data source is connected, but there is no data to display.'),
},
unableToConnect: {
svgUrl: this.emptyUnableToConnectSvgPath,
title: __('Unable to connect to Prometheus server'),
......
......@@ -15,31 +15,44 @@ export default {
required: false,
default: true,
},
/**
* Initial value of collapse on mount.
*/
collapseGroup: {
type: Boolean,
required: true,
required: false,
default: false,
},
},
data() {
return {
showGroup: true,
isCollapsed: this.collapseGroup,
};
},
computed: {
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: {
collapse() {
this.showGroup = !this.showGroup;
this.isCollapsed = !this.isCollapsed;
},
},
};
</script>
<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">
<h4 class="flex-grow-1">{{ name }}</h4>
<a role="button" class="js-graph-group-toggle" @click="collapse">
......@@ -47,12 +60,12 @@ export default {
</a>
</div>
<div
v-if="collapseGroup"
v-show="collapseGroup && showGroup"
v-show="!isCollapsed"
ref="graph-group-content"
class="card-body prometheus-graph-group p-0"
>
<slot></slot>
</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>
......@@ -4,7 +4,7 @@ import createFlash from '~/flash';
import trackDashboardLoad from '../monitoring_tracking_helper';
import statusCodes from '../../lib/utils/http_status';
import { backOff } from '../../lib/utils/common_utils';
import { s__ } from '../../locale';
import { s__, sprintf } from '../../locale';
const TWO_MINUTES = 120000;
......@@ -74,17 +74,21 @@ export const fetchDashboard = ({ state, dispatch }, params) => {
return backOffRequest(() => axios.get(state.dashboardEndpoint, { params }))
.then(resp => resp.data)
.then(response => dispatch('receiveMetricsDashboardSuccess', { response, params }))
.then(() => {
const dashboardType = state.currentDashboard === '' ? 'default' : 'custom';
return trackDashboardLoad({
label: `${dashboardType}_metrics_dashboard`,
value: state.metricsWithData.length,
});
})
.catch(error => {
dispatch('receiveMetricsDashboardFailure', error);
if (state.setShowErrorBanner) {
createFlash(s__('Metrics|There was an error while retrieving metrics'));
.catch(e => {
dispatch('receiveMetricsDashboardFailure', e);
if (state.showErrorBanner) {
if (e.response.data && e.response.data.message) {
const { message } = e.response.data;
createFlash(
sprintf(
s__('Metrics|There was an error while retrieving metrics. %{message}'),
{ message },
false,
),
);
} else {
createFlash(s__('Metrics|There was an error while retrieving metrics'));
}
}
});
};
......@@ -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);
const promises = [];
......@@ -140,9 +144,11 @@ export const fetchPrometheusMetrics = ({ state, commit, dispatch }, params) => {
return Promise.all(promises)
.then(() => {
if (state.metricsWithData.length === 0) {
commit(types.SET_NO_DATA_EMPTY_STATE);
}
const dashboardType = state.currentDashboard === '' ? 'default' : 'custom';
trackDashboardLoad({
label: `${dashboardType}_metrics_dashboard`,
value: getters.metricsWithData().length,
});
})
.catch(() => {
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 Vuex from 'vuex';
import * as actions from './actions';
import * as getters from './getters';
import mutations from './mutations';
import state from './state';
......@@ -12,6 +13,7 @@ export const createStore = () =>
monitoringDashboard: {
namespaced: true,
actions,
getters,
mutations,
state,
},
......
......@@ -67,7 +67,6 @@ export default {
group.panels.forEach(panel => {
panel.metrics.forEach(metric => {
if (metric.metric_id === metricId) {
state.metricsWithData.push(metricId);
// ensure dates/numbers are correctly formatted for charts
const normalizedResults = result.map(normalizeQueryResult);
Vue.set(metric, 'result', Object.freeze(normalizedResults));
......
......@@ -13,7 +13,6 @@ export default () => ({
},
deploymentData: [],
environments: [],
metricsWithData: [],
allDashboards: [],
currentDashboard: null,
projectPath: null,
......
......@@ -28,6 +28,10 @@ export default {
type: Object,
required: true,
},
pipelineCoverageDelta: {
type: String,
required: false,
},
// This prop needs to be camelCase, html attributes are case insensive
// https://vuejs.org/v2/guide/components.html#camelCase-vs-kebab-case
hasCi: {
......@@ -92,6 +96,16 @@ export default {
showSourceBranch() {
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>
......@@ -142,6 +156,14 @@ export default {
</div>
<div v-if="pipeline.coverage" class="coverage">
{{ s__('Pipeline|Coverage') }} {{ pipeline.coverage }}%
<span
v-if="pipelineCoverageDelta"
class="js-pipeline-coverage-delta"
:class="coverageDeltaClass"
>
({{ pipelineCoverageDelta }}%)
</span>
</div>
</div>
</div>
......
......@@ -76,6 +76,7 @@ export default {
<mr-widget-container>
<mr-widget-pipeline
:pipeline="pipeline"
:pipeline-coverage-delta="mr.pipelineCoverageDelta"
:ci-status="mr.ciStatus"
:has-ci="mr.hasCI"
:source-branch="branch"
......
......@@ -42,6 +42,7 @@ export default class MergeRequestStore {
this.commitsCount = data.commits_count;
this.divergedCommitsCount = data.diverged_commits_count;
this.pipeline = data.pipeline || {};
this.pipelineCoverageDelta = data.pipeline_coverage_delta;
this.mergePipeline = data.merge_pipeline || {};
this.deployments = this.deployments || data.deployments || [];
this.postMergeDeployments = this.postMergeDeployments || [];
......
......@@ -515,6 +515,12 @@ img.emoji {
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
.full-width-mobile {
@include media-breakpoint-down(xs) {
......
......@@ -214,8 +214,8 @@
padding-left: 0;
height: $input-height - 2;
line-height: inherit;
border-color: transparent;
&,
&:focus,
&:hover {
outline: none;
......
......@@ -67,7 +67,6 @@
.prometheus-graph-group {
display: flex;
flex-wrap: wrap;
margin-top: $gl-padding-8;
}
.prometheus-graph {
......
......@@ -26,6 +26,7 @@ module EnvironmentsHelper
"empty-getting-started-svg-path" => image_path('illustrations/monitoring/getting_started.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-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'),
"metrics-endpoint" => additional_metrics_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
include CacheMarkdownField
include TokenAuthenticatable
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 :health_check_access_token
......
......@@ -1423,6 +1423,12 @@ class MergeRequest < ApplicationRecord
true
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
@base_pipeline ||= project.ci_pipelines
.order(id: :desc)
......
......@@ -57,6 +57,10 @@ class MergeRequestPollWidgetEntity < Grape::Entity
presenter(merge_request).ci_status
end
expose :pipeline_coverage_delta do |merge_request|
presenter(merge_request).pipeline_coverage_delta
end
expose :cancel_auto_merge_path do |merge_request|
presenter(merge_request).cancel_auto_merge_path
end
......
......@@ -7,7 +7,7 @@
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.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.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'] ||=
Settings.cron_jobs['namespaces_prune_aggregation_schedules_worker']['job_class'] = 'Namespaces::PruneAggregationSchedulesWorker'
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']['cron'] ||= '0 0 1 * *'
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
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_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_with_group_id", 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
"remove_source_branch_after_merge": false,
"request_access_enabled": false,
"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": {
"commit_count": 12,
"storage_size": 2066080,
......@@ -457,6 +470,19 @@ This endpoint supports [keyset pagination](README.md#keyset-based-pagination) fo
"remove_source_branch_after_merge": false,
"request_access_enabled": false,
"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": {
"commit_count": 12,
"storage_size": 2066080,
......@@ -649,6 +675,19 @@ Example response:
"remove_source_branch_after_merge": false,
"request_access_enabled": false,
"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": {
"commit_count": 12,
"storage_size": 2066080,
......@@ -777,6 +816,19 @@ GET /projects/:id
"printing_merge_requests_link_enabled": true,
"request_access_enabled": false,
"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": {
"commit_count": 37,
"storage_size": 1038090,
......
......@@ -11157,6 +11157,9 @@ msgstr ""
msgid "Metrics|There was an error while retrieving metrics"
msgstr ""
msgid "Metrics|There was an error while retrieving metrics. %{message}"
msgstr ""
msgid "Metrics|Unexpected deployment data response from prometheus endpoint"
msgstr ""
......@@ -15346,6 +15349,9 @@ msgstr ""
msgid "Search or filter results..."
msgstr ""
msgid "Search or filter results…"
msgstr ""
msgid "Search or jump to…"
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."
msgstr ""
msgid "The data source is connected, but there is no data to display."
msgstr ""
msgid "The default CI configuration path for new projects."
msgstr ""
......
......@@ -89,7 +89,7 @@ describe "Internal Project Access" do
it { is_expected.to be_denied_for(:visitor) }
end
describe "GET /:project_path/settings/members" do
describe "GET /:project_path/-/settings/members" do
subject { project_settings_members_path(project) }
it { is_expected.to be_allowed_for(:admin) }
......@@ -103,7 +103,7 @@ describe "Internal Project Access" do
it { is_expected.to be_denied_for(:external) }
end
describe "GET /:project_path/settings/ci_cd" do
describe "GET /:project_path/-/settings/ci_cd" do
subject { project_settings_ci_cd_path(project) }
it { is_expected.to be_allowed_for(:admin) }
......@@ -117,7 +117,7 @@ describe "Internal Project Access" do
it { is_expected.to be_denied_for(:external) }
end
describe "GET /:project_path/settings/repository" do
describe "GET /:project_path/-/settings/repository" do
subject { project_settings_repository_path(project) }
it { is_expected.to be_allowed_for(:admin) }
......@@ -301,7 +301,7 @@ describe "Internal Project Access" do
it { is_expected.to be_denied_for(:visitor) }
end
describe "GET /:project_path/settings/integrations" do
describe "GET /:project_path/-/settings/integrations" do
subject { project_settings_integrations_path(project) }
it { is_expected.to be_allowed_for(:admin) }
......@@ -470,7 +470,7 @@ describe "Internal Project Access" do
it { is_expected.to be_denied_for(:visitor) }
end
describe "GET /:project_path/environments" do
describe "GET /:project_path/-/environments" do
subject { project_environments_path(project) }
it { is_expected.to be_allowed_for(:admin) }
......@@ -484,7 +484,7 @@ describe "Internal Project Access" do
it { is_expected.to be_denied_for(:visitor) }
end
describe "GET /:project_path/environments/:id" do
describe "GET /:project_path/-/environments/:id" do
let(:environment) { create(:environment, project: project) }
subject { project_environment_path(project, environment) }
......@@ -499,7 +499,7 @@ describe "Internal Project Access" do
it { is_expected.to be_denied_for(:visitor) }
end
describe "GET /:project_path/environments/:id/deployments" do
describe "GET /:project_path/-/environments/:id/deployments" do
let(:environment) { create(:environment, project: project) }
subject { project_environment_deployments_path(project, environment) }
......@@ -514,7 +514,7 @@ describe "Internal Project Access" do
it { is_expected.to be_denied_for(:visitor) }
end
describe "GET /:project_path/environments/new" do
describe "GET /:project_path/-/environments/new" do
subject { new_project_environment_path(project) }
it { is_expected.to be_allowed_for(:admin) }
......
......@@ -89,7 +89,7 @@ describe "Private Project Access" do
it { is_expected.to be_denied_for(:visitor) }
end
describe "GET /:project_path/settings/members" do
describe "GET /:project_path/-/settings/members" do
subject { project_settings_members_path(project) }
it { is_expected.to be_allowed_for(:admin) }
......@@ -103,7 +103,7 @@ describe "Private Project Access" do
it { is_expected.to be_denied_for(:external) }
end
describe "GET /:project_path/settings/ci_cd" do
describe "GET /:project_path/-/settings/ci_cd" do
subject { project_settings_ci_cd_path(project) }
it { is_expected.to be_allowed_for(:admin) }
......@@ -117,7 +117,7 @@ describe "Private Project Access" do
it { is_expected.to be_denied_for(:external) }
end
describe "GET /:project_path/settings/repository" do
describe "GET /:project_path/-/settings/repository" do
subject { project_settings_repository_path(project) }
it { is_expected.to be_allowed_for(:admin) }
......@@ -273,7 +273,7 @@ describe "Private Project Access" do
it { is_expected.to be_denied_for(:visitor) }
end
describe "GET /:project_path/namespace/hooks" do
describe "GET /:project_path/-/settings/integrations" do
subject { project_settings_integrations_path(project) }
it { is_expected.to be_allowed_for(:admin) }
......@@ -431,7 +431,7 @@ describe "Private Project Access" do
end
end
describe "GET /:project_path/environments" do
describe "GET /:project_path/-/environments" do
subject { project_environments_path(project) }
it { is_expected.to be_allowed_for(:admin) }
......@@ -445,7 +445,7 @@ describe "Private Project Access" do
it { is_expected.to be_denied_for(:visitor) }
end
describe "GET /:project_path/environments/:id" do
describe "GET /:project_path/-/environments/:id" do
let(:environment) { create(:environment, project: project) }
subject { project_environment_path(project, environment) }
......@@ -460,7 +460,7 @@ describe "Private Project Access" do
it { is_expected.to be_denied_for(:visitor) }
end
describe "GET /:project_path/environments/:id/deployments" do
describe "GET /:project_path/-/environments/:id/deployments" do
let(:environment) { create(:environment, project: project) }
subject { project_environment_deployments_path(project, environment) }
......@@ -475,7 +475,7 @@ describe "Private Project Access" do
it { is_expected.to be_denied_for(:visitor) }
end
describe "GET /:project_path/environments/new" do
describe "GET /:project_path/-/environments/new" do
subject { new_project_environment_path(project) }
it { is_expected.to be_allowed_for(:admin) }
......@@ -517,7 +517,7 @@ describe "Private Project Access" do
it { is_expected.to be_denied_for(:visitor) }
end
describe "GET /:project_path/environments/new" do
describe "GET /:project_path/-/environments/new" do
subject { new_project_pipeline_schedule_path(project) }
it { is_expected.to be_allowed_for(:admin) }
......
......@@ -89,7 +89,7 @@ describe "Public Project Access" do
it { is_expected.to be_allowed_for(:visitor) }
end
describe "GET /:project_path/settings/members" do
describe "GET /:project_path/-/settings/members" do
subject { project_settings_members_path(project) }
it { is_expected.to be_allowed_for(:admin) }
......@@ -103,7 +103,7 @@ describe "Public Project Access" do
it { is_expected.to be_allowed_for(:external) }
end
describe "GET /:project_path/settings/ci_cd" do
describe "GET /:project_path/-/settings/ci_cd" do
subject { project_settings_ci_cd_path(project) }
it { is_expected.to be_allowed_for(:admin) }
......@@ -117,7 +117,7 @@ describe "Public Project Access" do
it { is_expected.to be_denied_for(:external) }
end
describe "GET /:project_path/settings/repository" do
describe "GET /:project_path/-/settings/repository" do
subject { project_settings_repository_path(project) }
it { is_expected.to be_allowed_for(:admin) }
......@@ -286,7 +286,7 @@ describe "Public Project Access" do
it { is_expected.to be_denied_for(:visitor) }
end
describe "GET /:project_path/environments" do
describe "GET /:project_path/-/environments" do
subject { project_environments_path(project) }
it { is_expected.to be_allowed_for(:admin) }
......@@ -300,7 +300,7 @@ describe "Public Project Access" do
it { is_expected.to be_denied_for(:visitor) }
end
describe "GET /:project_path/environments/:id" do
describe "GET /:project_path/-/environments/:id" do
let(:environment) { create(:environment, project: project) }
subject { project_environment_path(project, environment) }
......@@ -315,7 +315,7 @@ describe "Public Project Access" do
it { is_expected.to be_denied_for(:visitor) }
end
describe "GET /:project_path/environments/:id/deployments" do
describe "GET /:project_path/-/environments/:id/deployments" do
let(:environment) { create(:environment, project: project) }
subject { project_environment_deployments_path(project, environment) }
......@@ -330,7 +330,7 @@ describe "Public Project Access" do
it { is_expected.to be_denied_for(:visitor) }
end
describe "GET /:project_path/environments/new" do
describe "GET /:project_path/-/environments/new" do
subject { new_project_environment_path(project) }
it { is_expected.to be_allowed_for(:admin) }
......@@ -514,7 +514,7 @@ describe "Public Project Access" do
it { is_expected.to be_allowed_for(:visitor) }
end
describe "GET /:project_path/settings/integrations" do
describe "GET /:project_path/-/settings/integrations" do
subject { project_settings_integrations_path(project) }
it { is_expected.to be_allowed_for(:admin) }
......
......@@ -6,8 +6,11 @@ import {
GlLoadingIcon,
GlTable,
GlLink,
GlSearchBoxByClick,
GlFormInput,
GlDropdown,
GlDropdownItem,
} from '@gitlab/ui';
import createListState from '~/error_tracking/store/list/state';
import ErrorTrackingList from '~/error_tracking/components/error_tracking_list.vue';
import errorsList from './list_mock.json';
......@@ -51,12 +54,13 @@ describe('ErrorTrackingList', () => {
getErrorList: () => {},
startPolling: jest.fn(),
restartPolling: jest.fn().mockName('restartPolling'),
addRecentSearch: jest.fn(),
loadRecentSearches: jest.fn(),
setIndexPath: jest.fn(),
clearRecentSearches: jest.fn(),
};
const state = {
errors: errorsList,
loading: true,
};
const state = createListState();
store = new Vuex.Store({
modules: {
......@@ -90,6 +94,7 @@ describe('ErrorTrackingList', () => {
describe('results', () => {
beforeEach(() => {
store.state.list.loading = false;
store.state.list.errors = errorsList;
mountComponent();
});
......@@ -114,7 +119,7 @@ describe('ErrorTrackingList', () => {
});
describe('filtering', () => {
const findSearchBox = () => wrapper.find(GlSearchBoxByClick);
const findSearchBox = () => wrapper.find(GlFormInput);
it('shows search box', () => {
expect(findSearchBox().exists()).toBe(true);
......@@ -122,7 +127,9 @@ describe('ErrorTrackingList', () => {
it('makes network request on submit', () => {
expect(actions.startPolling).toHaveBeenCalledTimes(1);
findSearchBox().vm.$emit('submit');
findSearchBox().trigger('keyup.enter');
expect(actions.startPolling).toHaveBeenCalledTimes(2);
});
});
......@@ -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 * 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('SET_ERRORS', () => {
......@@ -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', () => {
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);
[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) =>
shallowMount(TimeSeries, {
......
......@@ -11,6 +11,7 @@ function createComponent(props) {
emptyGettingStartedSvgPath: '/path/to/getting-started.svg',
emptyLoadingSvgPath: '/path/to/loading.svg',
emptyNoDataSvgPath: '/path/to/no-data.svg',
emptyNoDataSmallSvgPath: '/path/to/no-data-small.svg',
emptyUnableToConnectSvgPath: '/path/to/unable-to-connect.svg',
},
});
......
......@@ -12,6 +12,7 @@ describe('Embed', () => {
let wrapper;
let store;
let actions;
let metricsWithDataGetter;
function mountComponent() {
wrapper = shallowMount(Embed, {
......@@ -31,11 +32,16 @@ describe('Embed', () => {
fetchMetricsData: () => {},
};
metricsWithDataGetter = jest.fn();
store = new Vuex.Store({
modules: {
monitoringDashboard: {
namespaced: true,
actions,
getters: {
metricsWithData: () => metricsWithDataGetter,
},
state: initialState,
},
},
......@@ -43,6 +49,7 @@ describe('Embed', () => {
});
afterEach(() => {
metricsWithDataGetter.mockClear();
if (wrapper) {
wrapper.destroy();
}
......@@ -63,13 +70,13 @@ describe('Embed', () => {
beforeEach(() => {
store.state.monitoringDashboard.dashboard.panel_groups = groups;
store.state.monitoringDashboard.dashboard.panel_groups[0].panels = metricsData;
store.state.monitoringDashboard.metricsWithData = metricsWithData;
metricsWithDataGetter.mockReturnValue(metricsWithData);
mountComponent();
});
it('shows a chart when metrics are present', () => {
wrapper.setProps({});
expect(wrapper.find('.metrics-embed').exists()).toBe(true);
expect(wrapper.find(PanelType).exists()).toBe(true);
expect(wrapper.findAll(PanelType).length).toBe(2);
......
......@@ -75,11 +75,9 @@ export const metricsData = [
},
];
export const initialState = {
monitoringDashboard: {},
export const initialState = () => ({
dashboard: {
panel_groups: [],
},
metricsWithData: [],
useDashboardEndpoint: true,
};
});
......@@ -240,6 +240,11 @@ export const metricsNewGroupsAPIResponse = [
},
];
export const mockedEmptyResult = {
metricId: '1_response_metrics_nginx_ingress_throughput_status_code',
result: [],
};
export const mockedQueryResultPayload = {
metricId: '17_system_metrics_kubernetes_container_memory_average',
result: [
......@@ -327,6 +332,30 @@ export const mockedQueryResultPayloadCoresTotal = {
};
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)',
priority: 5,
......
......@@ -191,12 +191,11 @@ describe('Monitoring store actions', () => {
let state;
const response = metricsDashboardResponse;
beforeEach(() => {
jest.spyOn(Tracking, 'event');
dispatch = jest.fn();
state = storeState();
state.dashboardEndpoint = '/dashboard';
});
it('dispatches receive and success actions', done => {
it('on success, dispatches receive and success actions', done => {
const params = {};
document.body.dataset.page = 'projects:environments:metrics';
mock.onGet(state.dashboardEndpoint).reply(200, response);
......@@ -213,39 +212,65 @@ describe('Monitoring store actions', () => {
response,
params,
});
})
.then(() => {
expect(Tracking.event).toHaveBeenCalledWith(
document.body.dataset.page,
'dashboard_fetch',
{
label: 'custom_metrics_dashboard',
property: 'count',
value: 0,
},
);
done();
})
.catch(done.fail);
});
it('dispatches failure action', done => {
const params = {};
mock.onGet(state.dashboardEndpoint).reply(500);
fetchDashboard(
{
state,
dispatch,
},
params,
)
.then(() => {
expect(dispatch).toHaveBeenCalledWith(
'receiveMetricsDashboardFailure',
new Error('Request failed with status code 500'),
);
done();
})
.catch(done.fail);
describe('on failure', () => {
let result;
let errorResponse;
beforeEach(() => {
const params = {};
result = () => {
mock.onGet(state.dashboardEndpoint).replyOnce(500, errorResponse);
return fetchDashboard({ state, dispatch }, params);
};
});
it('dispatches a failure action', done => {
errorResponse = {};
result()
.then(() => {
expect(dispatch).toHaveBeenCalledWith(
'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', () => {
......@@ -317,18 +342,33 @@ describe('Monitoring store actions', () => {
});
});
describe('fetchPrometheusMetrics', () => {
const params = {};
let commit;
let dispatch;
let state;
beforeEach(() => {
jest.spyOn(Tracking, 'event');
commit = jest.fn();
dispatch = jest.fn();
state = storeState();
});
it('commits empty state when state.groups is empty', done => {
const state = storeState();
const params = {};
fetchPrometheusMetrics({ state, commit, dispatch }, params)
const getters = {
metricsWithData: () => [],
};
fetchPrometheusMetrics({ state, commit, dispatch, getters }, params)
.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(createFlash).not.toHaveBeenCalled();
done();
......@@ -336,19 +376,28 @@ describe('Monitoring store actions', () => {
.catch(done.fail);
});
it('dispatches fetchPrometheusMetric for each panel query', done => {
const params = {};
const state = storeState();
state.dashboard.panel_groups = metricsDashboardResponse.dashboard.panel_groups;
const metric = state.dashboard.panel_groups[0].panels[0].metrics[0];
fetchPrometheusMetrics({ state, commit, dispatch }, params)
const [metric] = state.dashboard.panel_groups[0].panels[0].metrics;
const getters = {
metricsWithData: () => [metric.id],
};
fetchPrometheusMetrics({ state, commit, dispatch, getters }, params)
.then(() => {
expect(dispatch).toHaveBeenCalledTimes(3);
expect(dispatch).toHaveBeenCalledWith('fetchPrometheusMetric', {
metric,
params,
});
expect(createFlash).not.toHaveBeenCalled();
expect(Tracking.event).toHaveBeenCalledWith(
document.body.dataset.page,
'dashboard_fetch',
{
label: 'custom_metrics_dashboard',
property: 'count',
value: 1,
},
);
done();
})
......@@ -357,8 +406,6 @@ describe('Monitoring store actions', () => {
});
it('dispatches fetchPrometheusMetric for each panel query, handles an error', done => {
const params = {};
const state = storeState();
state.dashboard.panel_groups = metricsDashboardResponse.dashboard.panel_groups;
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 {
metricsDashboardResponse,
dashboardGitResponse,
} from '../mock_data';
import { uniqMetricsId } from '~/monitoring/stores/utils';
describe('Monitoring mutations', () => {
let stateCopy;
beforeEach(() => {
stateCopy = state();
});
describe('RECEIVE_METRICS_DATA_SUCCESS', () => {
let groups;
let payload;
const getGroups = () => stateCopy.dashboard.panel_groups;
beforeEach(() => {
stateCopy.dashboard.panel_groups = [];
groups = metricsGroupsAPIResponse;
payload = metricsGroupsAPIResponse;
});
it('adds a key to the group', () => {
mutations[types.RECEIVE_METRICS_DATA_SUCCESS](stateCopy, groups);
expect(stateCopy.dashboard.panel_groups[0].key).toBe('system-metrics-kubernetes--0');
mutations[types.RECEIVE_METRICS_DATA_SUCCESS](stateCopy, payload);
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', () => {
mutations[types.RECEIVE_METRICS_DATA_SUCCESS](stateCopy, groups);
mutations[types.RECEIVE_METRICS_DATA_SUCCESS](stateCopy, payload);
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(query_range.length).toBeGreaterThan(0);
});
it('contains one group, which it has two panels and one metrics property', () => {
mutations[types.RECEIVE_METRICS_DATA_SUCCESS](stateCopy, groups);
expect(stateCopy.dashboard.panel_groups).toBeDefined();
expect(stateCopy.dashboard.panel_groups.length).toEqual(1);
expect(stateCopy.dashboard.panel_groups[0].panels.length).toEqual(2);
expect(stateCopy.dashboard.panel_groups[0].panels[0].metrics.length).toEqual(1);
expect(stateCopy.dashboard.panel_groups[0].panels[1].metrics.length).toEqual(1);
it('contains two groups, with panels with a metric each', () => {
mutations[types.RECEIVE_METRICS_DATA_SUCCESS](stateCopy, payload);
const groups = getGroups();
expect(groups).toBeDefined();
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', () => {
mutations[types.RECEIVE_METRICS_DATA_SUCCESS](stateCopy, groups);
expect(stateCopy.dashboard.panel_groups[0].panels[0].metrics[0].metricId).toEqual(
mutations[types.RECEIVE_METRICS_DATA_SUCCESS](stateCopy, payload);
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',
);
});
......@@ -52,7 +70,7 @@ describe('Monitoring mutations', () => {
stateCopy.deploymentData = [];
mutations[types.RECEIVE_DEPLOYMENTS_DATA_SUCCESS](stateCopy, deploymentData);
expect(stateCopy.deploymentData).toBeDefined();
expect(stateCopy.deploymentData.length).toEqual(3);
expect(stateCopy.deploymentData).toHaveLength(3);
expect(typeof stateCopy.deploymentData[0]).toEqual('object');
});
});
......@@ -73,41 +91,38 @@ describe('Monitoring mutations', () => {
});
});
describe('SET_QUERY_RESULT', () => {
const metricId = 12;
const id = 'system_metrics_kubernetes_container_memory_total';
const metricId = '12_system_metrics_kubernetes_container_memory_total';
const result = [
{
values: [[0, 1], [1, 1], [1, 3]],
},
];
const dashboardGroups = metricsDashboardResponse.dashboard.panel_groups;
const getMetrics = () => stateCopy.dashboard.panel_groups[0].panels[0].metrics;
beforeEach(() => {
const dashboardGroups = metricsDashboardResponse.dashboard.panel_groups;
mutations[types.RECEIVE_METRICS_DATA_SUCCESS](stateCopy, dashboardGroups);
});
it('clears empty state', () => {
expect(stateCopy.showEmptyState).toBe(true);
mutations[types.SET_QUERY_RESULT](stateCopy, {
metricId,
result,
});
expect(stateCopy.showEmptyState).toBe(false);
});
it('sets metricsWithData value', () => {
const uniqId = uniqMetricsId({
metric_id: metricId,
id,
});
mutations[types.SET_QUERY_RESULT](stateCopy, {
metricId: uniqId,
result,
});
expect(stateCopy.metricsWithData).toEqual([uniqId]);
});
it('does not store empty results', () => {
it('adds results to the store', () => {
expect(getMetrics()[0].result).toBe(undefined);
mutations[types.SET_QUERY_RESULT](stateCopy, {
metricId,
result: [],
result,
});
expect(stateCopy.metricsWithData).toEqual([]);
expect(getMetrics()[0].result).toHaveLength(result.length);
});
});
describe('SET_ALL_DASHBOARDS', () => {
......
......@@ -4,11 +4,13 @@ import { GlToast } from '@gitlab/ui';
import VueDraggable from 'vuedraggable';
import MockAdapter from 'axios-mock-adapter';
import Dashboard from '~/monitoring/components/dashboard.vue';
import EmptyState from '~/monitoring/components/empty_state.vue';
import * as types from '~/monitoring/stores/mutation_types';
import { createStore } from '~/monitoring/stores';
import axios from '~/lib/utils/axios_utils';
import {
metricsGroupsAPIResponse,
mockedEmptyResult,
mockedQueryResultPayload,
mockedQueryResultPayloadCoresTotal,
mockApiEndpoint,
......@@ -29,6 +31,7 @@ const propsData = {
emptyGettingStartedSvgPath: '/path/to/getting-started.svg',
emptyLoadingSvgPath: '/path/to/loading.svg',
emptyNoDataSvgPath: '/path/to/no-data.svg',
emptyNoDataSmallSvgPath: '/path/to/no-data-small.svg',
emptyUnableToConnectSvgPath: '/path/to/unable-to-connect.svg',
environmentsEndpoint: '/root/hello-prometheus/environments/35',
currentEnvironmentName: 'production',
......@@ -43,15 +46,17 @@ const resetSpy = spy => {
}
};
export default propsData;
let expectedPanelCount;
function setupComponentStore(component) {
// Load 2 panel groups
component.$store.commit(
`monitoringDashboard/${types.RECEIVE_METRICS_DATA_SUCCESS}`,
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(
`monitoringDashboard/${types.SET_QUERY_RESULT}`,
mockedQueryResultPayload,
......@@ -61,6 +66,8 @@ function setupComponentStore(component) {
mockedQueryResultPayloadCoresTotal,
);
expectedPanelCount = 2;
component.$store.commit(
`monitoringDashboard/${types.RECEIVE_ENVIRONMENTS_DATA_SUCCESS}`,
environmentData,
......@@ -126,13 +133,9 @@ describe('Dashboard', () => {
describe('no data found', () => {
it('shows the environment selector dropdown', () => {
component = new DashboardComponent({
el: document.querySelector('.prometheus-graphs'),
propsData: { ...propsData, showEmptyState: true },
store,
});
createComponentWrapper();
expect(component.$el.querySelector('.js-environments-dropdown')).toBeTruthy();
expect(wrapper.find('.js-environments-dropdown').exists()).toBeTruthy();
});
});
......@@ -389,9 +392,36 @@ describe('Dashboard', () => {
});
});
describe('drag and drop function', () => {
let expectedPanelCount; // also called metrics, naming to be improved: https://gitlab.com/gitlab-org/gitlab/issues/31565
describe('when one of the metrics is missing', () => {
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 findEnabledDraggables = () => findDraggables().filter(f => !f.attributes('disabled'));
const findDraggablePanels = () => wrapper.findAll('.js-draggable-panel');
......@@ -399,10 +429,6 @@ describe('Dashboard', () => {
beforeEach(() => {
mock.onGet(mockApiEndpoint).reply(200, metricsGroupsAPIResponse);
expectedPanelCount = metricsGroupsAPIResponse.reduce(
(acc, group) => group.panels.length + acc,
0,
);
});
beforeEach(done => {
......@@ -417,10 +443,6 @@ describe('Dashboard', () => {
wrapper.destroy();
});
afterEach(() => {
wrapper.destroy();
});
it('wraps vuedraggable', () => {
expect(findDraggablePanels().exists()).toBe(true);
expect(findDraggablePanels().length).toEqual(expectedPanelCount);
......@@ -459,22 +481,20 @@ describe('Dashboard', () => {
it('metrics can be swapped', done => {
const firstDraggable = findDraggables().at(0);
const mockMetrics = [...metricsGroupsAPIResponse[0].panels];
const value = () => firstDraggable.props('value');
const mockMetrics = [...metricsGroupsAPIResponse[1].panels];
expect(value().length).toBe(mockMetrics.length);
value().forEach((metric, i) => {
expect(metric.title).toBe(mockMetrics[i].title);
});
const firstTitle = mockMetrics[0].title;
const secondTitle = mockMetrics[1].title;
// swap two elements and `input` them
[mockMetrics[0], mockMetrics[1]] = [mockMetrics[1], mockMetrics[0]];
firstDraggable.vm.$emit('input', mockMetrics);
firstDraggable.vm.$nextTick(() => {
value().forEach((metric, i) => {
expect(metric.title).toBe(mockMetrics[i].title);
});
wrapper.vm.$nextTick(() => {
const { panels } = wrapper.vm.dashboard.panel_groups[1];
expect(panels[1].title).toEqual(firstTitle);
expect(panels[0].title).toEqual(secondTitle);
done();
});
});
......@@ -584,7 +604,7 @@ describe('Dashboard', () => {
setupComponentStore(component);
return Vue.nextTick().then(() => {
promPanel = component.$el.querySelector('.prometheus-panel');
[, promPanel] = component.$el.querySelectorAll('.prometheus-panel');
promGroup = promPanel.querySelector('.prometheus-graph-group');
panelToggle = promPanel.querySelector('.js-graph-group-toggle');
chart = promGroup.querySelector('.position-relative svg');
......
import { shallowMount, createLocalVue } from '@vue/test-utils';
import GraphGroup from '~/monitoring/components/graph_group.vue';
import Icon from '~/vue_shared/components/icon.vue';
const localVue = createLocalVue();
describe('Graph group component', () => {
let graphGroup;
let wrapper;
const findPrometheusGroup = () => graphGroup.find('.prometheus-graph-group');
const findPrometheusPanel = () => graphGroup.find('.prometheus-panel');
const findGroup = () => wrapper.find({ ref: 'graph-group' });
const findContent = () => wrapper.find({ ref: 'graph-group-content' });
const findCaretIcon = () => wrapper.find(Icon);
const createComponent = propsData => {
graphGroup = shallowMount(localVue.extend(GraphGroup), {
wrapper = shallowMount(localVue.extend(GraphGroup), {
propsData,
sync: false,
localVue,
......@@ -18,57 +20,100 @@ describe('Graph group component', () => {
};
afterEach(() => {
graphGroup.destroy();
wrapper.destroy();
});
describe('When groups can be collapsed', () => {
describe('When group is not collapsed', () => {
beforeEach(() => {
createComponent({
name: 'panel',
collapseGroup: true,
collapseGroup: false,
});
});
it('should show the angle-down caret icon when collapseGroup is true', () => {
expect(graphGroup.vm.caretIcon).toBe('angle-down');
it('should show the angle-down caret icon', () => {
expect(findContent().isVisible()).toBe(true);
expect(findCaretIcon().props('name')).toBe('angle-down');
});
it('should show the angle-right caret icon when collapseGroup is false', () => {
graphGroup.vm.collapse();
it('should show the angle-right caret icon when the user collapses the group', done => {
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', () => {
beforeEach(() => {
createComponent({
name: 'panel',
it('should show the open the group when collapseGroup is set to true', done => {
wrapper.setProps({
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', () => {
expect(findPrometheusPanel().exists()).toBe(false);
describe('When group is collapsed', () => {
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', () => {
beforeEach(() => {
createComponent({ name: 'panel', collapseGroup: false });
describe('When groups can not be collapsed', () => {
beforeEach(() => {
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 => {
expect(findPrometheusGroup().exists()).toBe(false);
describe('When group does not show a panel heading', () => {
beforeEach(() => {
createComponent({
name: 'panel',
showPanels: false,
collapseGroup: false,
});
});
graphGroup.setProps({
collapseGroup: true,
it('should collapse the panel content', () => {
expect(findContent().isVisible()).toBe(true);
expect(findCaretIcon().exists()).toBe(false);
});
graphGroup.vm.$nextTick(() => {
expect(findPrometheusGroup().exists()).toBe(true);
done();
it('should show the panel content when clicked', done => {
wrapper.vm.collapse();
wrapper.vm.$nextTick(() => {
expect(findContent().isVisible()).toBe(true);
expect(findCaretIcon().exists()).toBe(false);
done();
});
});
});
});
......
import Vue from 'vue';
import mountComponent from 'spec/helpers/vue_mount_component_helper';
import axios from '~/lib/utils/axios_utils';
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', () => {
const props = {
url: `${gl.TEST_HOST}/stop_jobs_modal.vue/stopAll`,
......
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 eventHub from '~/pages/projects/labels/event_hub';
import axios from '~/lib/utils/axios_utils';
import mountComponent from 'spec/helpers/vue_mount_component_helper';
describe('Promote label modal', () => {
let vm;
......
import Vue from 'vue';
import mountComponent from 'spec/helpers/vue_mount_component_helper';
import axios from '~/lib/utils/axios_utils';
import deleteMilestoneModal from '~/pages/milestones/shared/components/delete_milestone_modal.vue';
import eventHub from '~/pages/milestones/shared/event_hub';
import mountComponent from 'spec/helpers/vue_mount_component_helper';
describe('delete_milestone_modal.vue', () => {
const Component = Vue.extend(deleteMilestoneModal);
const props = {
......
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 eventHub from '~/pages/milestones/shared/event_hub';
import axios from '~/lib/utils/axios_utils';
import mountComponent from 'spec/helpers/vue_mount_component_helper';
describe('Promote milestone modal', () => {
let vm;
......
......@@ -2,8 +2,8 @@ import Vue from 'vue';
import { GlobalWorkerOptions } from 'pdfjs-dist/build/pdf';
import workerSrc from 'pdfjs-dist/build/pdf.worker.min';
import PDFLab from '~/pdf/index.vue';
import { FIXTURES_PATH } from 'spec/test_constants';
import PDFLab from '~/pdf/index.vue';
const pdf = `${FIXTURES_PATH}/blob/pdf/test.pdf`;
......
......@@ -2,9 +2,9 @@ import Vue from 'vue';
import pdfjsLib from 'pdfjs-dist/build/pdf';
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 { FIXTURES_PATH } from 'spec/test_constants';
import PageComponent from '~/pdf/page/index.vue';
const testPDF = `${FIXTURES_PATH}/blob/pdf/test.pdf`;
......
import MockAdapter from 'axios-mock-adapter';
import axios from '~/lib/utils/axios_utils';
import '~/performance_bar/components/performance_bar_app.vue';
import performanceBar from '~/performance_bar';
import PerformanceBarService from '~/performance_bar/services/performance_bar_service';
import MockAdapter from 'axios-mock-adapter';
describe('performance bar wrapper', () => {
let mock;
let vm;
......
import MockAdapter from 'axios-mock-adapter';
import setTimeoutPromise from 'spec/helpers/set_timeout_promise_helper';
import axios from '~/lib/utils/axios_utils';
import PersistentUserCallout from '~/persistent_user_callout';
import setTimeoutPromise from 'spec/helpers/set_timeout_promise_helper';
describe('PersistentUserCallout', () => {
const dismissEndpoint = '/dismiss';
......
import Vue from 'vue';
import JobGroupDropdown from '~/pipelines/components/graph/job_group_dropdown.vue';
import mountComponent from 'spec/helpers/vue_mount_component_helper';
import JobGroupDropdown from '~/pipelines/components/graph/job_group_dropdown.vue';
describe('job group dropdown component', () => {
const Component = Vue.extend(JobGroupDropdown);
......
import Vue from 'vue';
import LinkedPipelinesColumn from '~/pipelines/components/graph/linked_pipelines_column.vue';
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';
describe('Linked Pipelines Column', () => {
......
import Vue from 'vue';
import stageColumnComponent from '~/pipelines/components/graph/stage_column_component.vue';
import mountComponent from 'spec/helpers/vue_mount_component_helper';
import stageColumnComponent from '~/pipelines/components/graph/stage_column_component.vue';
describe('stage column component', () => {
let component;
......
import Vue from 'vue';
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 { 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', () => {
const Component = Vue.extend(PipelinesActions);
......
import Vue from 'vue';
import MockAdapter from 'axios-mock-adapter';
import mountComponent from 'spec/helpers/vue_mount_component_helper';
import axios from '~/lib/utils/axios_utils';
import pipelinesComp from '~/pipelines/components/pipelines.vue';
import Store from '~/pipelines/stores/pipelines_store';
import mountComponent from 'spec/helpers/vue_mount_component_helper';
import { pipelineWithStages, stageReply } from './mock_data';
describe('Pipelines', () => {
......
import Vue from 'vue';
import MockAdapter from 'axios-mock-adapter';
import mountComponent from 'spec/helpers/vue_mount_component_helper';
import axios from '~/lib/utils/axios_utils';
import stage from '~/pipelines/components/stage.vue';
import eventHub from '~/pipelines/event_hub';
import mountComponent from 'spec/helpers/vue_mount_component_helper';
import { stageReply } from './mock_data';
describe('Pipelines stage component', () => {
......
import Vue from 'vue';
import deleteAccountModal from '~/profile/account/components/delete_account_modal.vue';
import mountComponent from 'spec/helpers/vue_mount_component_helper';
import deleteAccountModal from '~/profile/account/components/delete_account_modal.vue';
describe('DeleteAccountModal component', () => {
const actionUrl = `${gl.TEST_HOST}/delete/user`;
......
import Vue from 'vue';
import axios from '~/lib/utils/axios_utils';
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 mountComponent from 'spec/helpers/vue_mount_component_helper';
describe('UpdateUsername component', () => {
const rootUrl = gl.TEST_HOST;
......
import MockAdapter from 'axios-mock-adapter';
import testAction from 'spec/helpers/vuex_action_helper';
import axios from '~/lib/utils/axios_utils';
import * as types from '~/related_merge_requests/store/mutation_types';
import actionsModule, * as actions from '~/related_merge_requests/store/actions';
import testAction from 'spec/helpers/vuex_action_helper';
describe('RelatedMergeRequest store actions', () => {
let state;
......
import Vue from 'vue';
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 createStore from '~/releases/list/store';
import api from '~/api';
import { mountComponentWithStore } from 'spec/helpers/vue_mount_component_helper';
import { resetStore } from '../store/helpers';
import {
pageInfoHeadersWithoutPagination,
......
import testAction from 'spec/helpers/vuex_action_helper';
import {
requestReleases,
fetchReleases,
......@@ -8,7 +9,6 @@ import state from '~/releases/list/store/state';
import * as types from '~/releases/list/store/mutation_types';
import api from '~/api';
import { parseIntPagination } from '~/lib/utils/common_utils';
import testAction from 'spec/helpers/vuex_action_helper';
import { pageInfoHeadersWithoutPagination, releases } from '../../mock_data';
describe('Releases State actions', () => {
......
import Vue from 'vue';
import Vuex from 'vuex';
import component from '~/reports/components/modal_open_name.vue';
import { mountComponentWithStore } from 'spec/helpers/vue_mount_component_helper';
import component from '~/reports/components/modal_open_name.vue';
Vue.use(Vuex);
......
import Vue from 'vue';
import component from '~/reports/components/summary_row.vue';
import mountComponent from 'spec/helpers/vue_mount_component_helper';
import component from '~/reports/components/summary_row.vue';
describe('Summary row', () => {
const Component = Vue.extend(component);
......
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 {
setEndpoint,
......@@ -13,8 +15,6 @@ import {
} from '~/reports/store/actions';
import state from '~/reports/store/state';
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', () => {
let mockedState;
......
......@@ -34,6 +34,7 @@ describe('MrWidgetPipelineContainer', () => {
expect(wrapper.find(MrWidgetPipeline).props()).toEqual(
jasmine.objectContaining({
pipeline: mockStore.pipeline,
pipelineCoverageDelta: mockStore.pipelineCoverageDelta,
ciStatus: mockStore.ciStatus,
hasCi: mockStore.hasCI,
sourceBranch: mockStore.sourceBranch,
......@@ -68,6 +69,7 @@ describe('MrWidgetPipelineContainer', () => {
expect(wrapper.find(MrWidgetPipeline).props()).toEqual(
jasmine.objectContaining({
pipeline: mockStore.mergePipeline,
pipelineCoverageDelta: mockStore.pipelineCoverageDelta,
ciStatus: mockStore.ciStatus,
hasCi: mockStore.hasCI,
sourceBranch: mockStore.targetBranch,
......
......@@ -62,6 +62,38 @@ describe('MRWidgetPipeline', () => {
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', () => {
......@@ -96,6 +128,7 @@ describe('MRWidgetPipeline', () => {
pipeline: mockData.pipeline,
hasCi: true,
ciStatus: 'success',
pipelineCoverageDelta: mockData.pipelineCoverageDelta,
troubleshootingDocsPath: 'help',
});
});
......@@ -132,6 +165,13 @@ describe('MRWidgetPipeline', () => {
`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', () => {
......
......@@ -185,6 +185,7 @@ export default {
created_at: '2017-04-07T12:27:19.520Z',
updated_at: '2017-04-07T15:28:44.800Z',
},
pipelineCoverageDelta: '15.25',
work_in_progress: false,
source_branch_exists: false,
mergeable_discussions_state: true,
......
......@@ -2821,6 +2821,63 @@ describe MergeRequest do
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
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