Welcome to mirror list, hosted at ThFree Co, Russian Federation.

gitlab.com/gitlab-org/gitlab-foss.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2019-12-11 12:08:12 +0300
committerGitLab Bot <gitlab-bot@gitlab.com>2019-12-11 12:08:12 +0300
commit6b8040dc25fdc5fe614c3796a147517dd50bc7d8 (patch)
tree1930c21748fc632a7900659a71fcb7248097879f
parent7b875aa3fd1645e2e881997256ba94c6cb73ab3d (diff)
Add latest changes from gitlab-org/gitlab@master
-rw-r--r--app/assets/javascripts/error_tracking/components/error_tracking_list.vue210
-rw-r--r--app/assets/javascripts/error_tracking/store/list/actions.js16
-rw-r--r--app/assets/javascripts/error_tracking/store/list/mutation_types.js4
-rw-r--r--app/assets/javascripts/error_tracking/store/list/mutations.js36
-rw-r--r--app/assets/javascripts/error_tracking/store/list/state.js2
-rw-r--r--app/assets/javascripts/monitoring/components/dashboard.vue99
-rw-r--r--app/assets/javascripts/monitoring/components/embed.vue7
-rw-r--r--app/assets/javascripts/monitoring/components/empty_state.vue9
-rw-r--r--app/assets/javascripts/monitoring/components/graph_group.vue29
-rw-r--r--app/assets/javascripts/monitoring/stores/actions.js38
-rw-r--r--app/assets/javascripts/monitoring/stores/getters.js32
-rw-r--r--app/assets/javascripts/monitoring/stores/index.js2
-rw-r--r--app/assets/javascripts/monitoring/stores/mutations.js1
-rw-r--r--app/assets/javascripts/monitoring/stores/state.js1
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue22
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline_container.vue1
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js1
-rw-r--r--app/assets/stylesheets/framework/common.scss6
-rw-r--r--app/assets/stylesheets/framework/filters.scss2
-rw-r--r--app/assets/stylesheets/pages/prometheus.scss1
-rw-r--r--app/helpers/environments_helper.rb1
-rw-r--r--app/models/application_setting.rb3
-rw-r--r--app/models/merge_request.rb6
-rw-r--r--app/serializers/merge_request_poll_widget_entity.rb4
-rw-r--r--app/views/projects/merge_requests/_widget.html.haml2
-rw-r--r--changelogs/unreleased/33257-prevent-accidental-deletions-via-soft-delete-for-groups-workers.yml5
-rw-r--r--changelogs/unreleased/34067-add-recent-searches-to-sentry-error-list-in-gitlab.yml5
-rw-r--r--changelogs/unreleased/34121-group-level-no-data-store.yml5
-rw-r--r--changelogs/unreleased/34261-service-desk-to-api.yml5
-rw-r--r--changelogs/unreleased/feat-merge-request-coverage-delta.yml5
-rw-r--r--config/initializers/1_settings.rb3
-rw-r--r--db/migrate/20191203121729_update_group_deletion_schedules_foreign_keys.rb29
-rw-r--r--db/schema.rb2
-rw-r--r--doc/api/projects.md52
-rw-r--r--locale/gitlab.pot9
-rw-r--r--spec/features/security/project/internal_access_spec.rb16
-rw-r--r--spec/features/security/project/private_access_spec.rb18
-rw-r--r--spec/features/security/project/public_access_spec.rb16
-rw-r--r--spec/frontend/error_tracking/components/error_tracking_list_spec.js68
-rw-r--r--spec/frontend/error_tracking/store/list/mutation_spec.js82
-rw-r--r--spec/frontend/monitoring/charts/time_series_spec.js5
-rw-r--r--spec/frontend/monitoring/dashboard_state_spec.js1
-rw-r--r--spec/frontend/monitoring/embed/embed_spec.js11
-rw-r--r--spec/frontend/monitoring/embed/mock_data.js6
-rw-r--r--spec/frontend/monitoring/mock_data.js29
-rw-r--r--spec/frontend/monitoring/store/actions_spec.js133
-rw-r--r--spec/frontend/monitoring/store/getters_spec.js99
-rw-r--r--spec/frontend/monitoring/store/mutations_spec.js83
-rw-r--r--spec/javascripts/monitoring/components/dashboard_spec.js78
-rw-r--r--spec/javascripts/monitoring/components/graph_group_spec.js107
-rw-r--r--spec/javascripts/pages/admin/jobs/index/components/stop_jobs_modal_spec.js3
-rw-r--r--spec/javascripts/pages/labels/components/promote_label_modal_spec.js2
-rw-r--r--spec/javascripts/pages/milestones/shared/components/delete_milestone_modal_spec.js3
-rw-r--r--spec/javascripts/pages/milestones/shared/components/promote_milestone_modal_spec.js2
-rw-r--r--spec/javascripts/pdf/index_spec.js2
-rw-r--r--spec/javascripts/pdf/page_spec.js2
-rw-r--r--spec/javascripts/performance_bar/index_spec.js3
-rw-r--r--spec/javascripts/persistent_user_callout_spec.js2
-rw-r--r--spec/javascripts/pipelines/graph/job_group_dropdown_spec.js2
-rw-r--r--spec/javascripts/pipelines/graph/linked_pipelines_column_spec.js2
-rw-r--r--spec/javascripts/pipelines/graph/stage_column_component_spec.js2
-rw-r--r--spec/javascripts/pipelines/pipelines_actions_spec.js4
-rw-r--r--spec/javascripts/pipelines/pipelines_spec.js2
-rw-r--r--spec/javascripts/pipelines/stage_spec.js2
-rw-r--r--spec/javascripts/profile/account/components/delete_account_modal_spec.js3
-rw-r--r--spec/javascripts/profile/account/components/update_username_spec.js4
-rw-r--r--spec/javascripts/related_merge_requests/store/actions_spec.js2
-rw-r--r--spec/javascripts/releases/list/components/app_spec.js4
-rw-r--r--spec/javascripts/releases/list/store/actions_spec.js2
-rw-r--r--spec/javascripts/reports/components/modal_open_name_spec.js2
-rw-r--r--spec/javascripts/reports/components/summary_row_spec.js2
-rw-r--r--spec/javascripts/reports/store/actions_spec.js4
-rw-r--r--spec/javascripts/vue_mr_widget/components/mr_widget_pipeline_container_spec.js2
-rw-r--r--spec/javascripts/vue_mr_widget/components/mr_widget_pipeline_spec.js40
-rw-r--r--spec/javascripts/vue_mr_widget/mock_data.js1
-rw-r--r--spec/models/merge_request_spec.rb57
76 files changed, 1207 insertions, 356 deletions
diff --git a/app/assets/javascripts/error_tracking/components/error_tracking_list.vue b/app/assets/javascripts/error_tracking/components/error_tracking_list.vue
index 9d8e5396dea..5cd68687329 100644
--- a/app/assets/javascripts/error_tracking/components/error_tracking_list.vue
+++ b/app/assets/javascripts/error_tracking/components/error_tracking_list.vue
@@ -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
diff --git a/app/assets/javascripts/error_tracking/store/list/actions.js b/app/assets/javascripts/error_tracking/store/list/actions.js
index 401fef5983e..13b15549d81 100644
--- a/app/assets/javascripts/error_tracking/store/list/actions.js
+++ b/app/assets/javascripts/error_tracking/store/list/actions.js
@@ -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 () => {};
diff --git a/app/assets/javascripts/error_tracking/store/list/mutation_types.js b/app/assets/javascripts/error_tracking/store/list/mutation_types.js
index f9d77a6b08e..4199e8d5cda 100644
--- a/app/assets/javascripts/error_tracking/store/list/mutation_types.js
+++ b/app/assets/javascripts/error_tracking/store/list/mutation_types.js
@@ -1,3 +1,7 @@
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';
diff --git a/app/assets/javascripts/error_tracking/store/list/mutations.js b/app/assets/javascripts/error_tracking/store/list/mutations.js
index e4bd81db9c9..18404d3b0af 100644
--- a/app/assets/javascripts/error_tracking/store/list/mutations.js
+++ b/app/assets/javascripts/error_tracking/store/list/mutations.js
@@ -1,5 +1,6 @@
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;
+ }
+ },
};
diff --git a/app/assets/javascripts/error_tracking/store/list/state.js b/app/assets/javascripts/error_tracking/store/list/state.js
index d371350ef0e..f1f0369e5f3 100644
--- a/app/assets/javascripts/error_tracking/store/list/state.js
+++ b/app/assets/javascripts/error_tracking/store/list/state.js
@@ -2,4 +2,6 @@ export default () => ({
errors: [],
externalUrl: '',
loading: true,
+ indexPath: '',
+ recentSearches: [],
});
diff --git a/app/assets/javascripts/monitoring/components/dashboard.vue b/app/assets/javascripts/monitoring/components/dashboard.vue
index fb6f5dc73b8..2a9321f6733 100644
--- a/app/assets/javascripts/monitoring/components/dashboard.vue
+++ b/app/assets/javascripts/monitoring/components/dashboard.vue
@@ -1,6 +1,6 @@
<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"
/>
diff --git a/app/assets/javascripts/monitoring/components/embed.vue b/app/assets/javascripts/monitoring/components/embed.vue
index a5c933a0071..dae1fbad547 100644
--- a/app/assets/javascripts/monitoring/components/embed.vue
+++ b/app/assets/javascripts/monitoring/components/embed.vue
@@ -1,5 +1,5 @@
<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(() => {
diff --git a/app/assets/javascripts/monitoring/components/empty_state.vue b/app/assets/javascripts/monitoring/components/empty_state.vue
index 1bb40447a3e..ab8c9712ce4 100644
--- a/app/assets/javascripts/monitoring/components/empty_state.vue
+++ b/app/assets/javascripts/monitoring/components/empty_state.vue
@@ -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'),
diff --git a/app/assets/javascripts/monitoring/components/graph_group.vue b/app/assets/javascripts/monitoring/components/graph_group.vue
index e01324372a7..5a7981b6534 100644
--- a/app/assets/javascripts/monitoring/components/graph_group.vue
+++ b/app/assets/javascripts/monitoring/components/graph_group.vue
@@ -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>
diff --git a/app/assets/javascripts/monitoring/stores/actions.js b/app/assets/javascripts/monitoring/stores/actions.js
index 3612e4d173f..268d9d636b1 100644
--- a/app/assets/javascripts/monitoring/stores/actions.js
+++ b/app/assets/javascripts/monitoring/stores/actions.js
@@ -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');
diff --git a/app/assets/javascripts/monitoring/stores/getters.js b/app/assets/javascripts/monitoring/stores/getters.js
new file mode 100644
index 00000000000..3eddd52705d
--- /dev/null
+++ b/app/assets/javascripts/monitoring/stores/getters.js
@@ -0,0 +1,32 @@
+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 () => {};
diff --git a/app/assets/javascripts/monitoring/stores/index.js b/app/assets/javascripts/monitoring/stores/index.js
index d58398c54ae..c1c466b7cf0 100644
--- a/app/assets/javascripts/monitoring/stores/index.js
+++ b/app/assets/javascripts/monitoring/stores/index.js
@@ -1,6 +1,7 @@
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,
},
diff --git a/app/assets/javascripts/monitoring/stores/mutations.js b/app/assets/javascripts/monitoring/stores/mutations.js
index bfa76aa7cea..db5ec4e9e2b 100644
--- a/app/assets/javascripts/monitoring/stores/mutations.js
+++ b/app/assets/javascripts/monitoring/stores/mutations.js
@@ -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));
diff --git a/app/assets/javascripts/monitoring/stores/state.js b/app/assets/javascripts/monitoring/stores/state.js
index e3300967022..88f333aeb80 100644
--- a/app/assets/javascripts/monitoring/stores/state.js
+++ b/app/assets/javascripts/monitoring/stores/state.js
@@ -13,7 +13,6 @@ export default () => ({
},
deploymentData: [],
environments: [],
- metricsWithData: [],
allDashboards: [],
currentDashboard: null,
projectPath: null,
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue
index 4ff8447485f..42db1935123 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue
@@ -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>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline_container.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline_container.vue
index c8b26889076..90fb254ecca 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline_container.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline_container.vue
@@ -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"
diff --git a/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js b/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js
index 1a25edee9b8..c7949fa264e 100644
--- a/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js
+++ b/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js
@@ -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 || [];
diff --git a/app/assets/stylesheets/framework/common.scss b/app/assets/stylesheets/framework/common.scss
index 9a15505dd25..4cbb2f5ba71 100644
--- a/app/assets/stylesheets/framework/common.scss
+++ b/app/assets/stylesheets/framework/common.scss
@@ -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) {
diff --git a/app/assets/stylesheets/framework/filters.scss b/app/assets/stylesheets/framework/filters.scss
index 2d826064569..1c252584047 100644
--- a/app/assets/stylesheets/framework/filters.scss
+++ b/app/assets/stylesheets/framework/filters.scss
@@ -214,8 +214,8 @@
padding-left: 0;
height: $input-height - 2;
line-height: inherit;
- border-color: transparent;
+ &,
&:focus,
&:hover {
outline: none;
diff --git a/app/assets/stylesheets/pages/prometheus.scss b/app/assets/stylesheets/pages/prometheus.scss
index 154e505f7a4..e20e58e21cf 100644
--- a/app/assets/stylesheets/pages/prometheus.scss
+++ b/app/assets/stylesheets/pages/prometheus.scss
@@ -67,7 +67,6 @@
.prometheus-graph-group {
display: flex;
flex-wrap: wrap;
- margin-top: $gl-padding-8;
}
.prometheus-graph {
diff --git a/app/helpers/environments_helper.rb b/app/helpers/environments_helper.rb
index f57d0fa19d4..59972118ae3 100644
--- a/app/helpers/environments_helper.rb
+++ b/app/helpers/environments_helper.rb
@@ -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),
diff --git a/app/models/application_setting.rb b/app/models/application_setting.rb
index 4ba0317b580..e764b6c56b0 100644
--- a/app/models/application_setting.rb
+++ b/app/models/application_setting.rb
@@ -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
diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb
index 240c143abba..bbc54987407 100644
--- a/app/models/merge_request.rb
+++ b/app/models/merge_request.rb
@@ -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)
diff --git a/app/serializers/merge_request_poll_widget_entity.rb b/app/serializers/merge_request_poll_widget_entity.rb
index 028de38e42a..a45026ea016 100644
--- a/app/serializers/merge_request_poll_widget_entity.rb
+++ b/app/serializers/merge_request_poll_widget_entity.rb
@@ -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
diff --git a/app/views/projects/merge_requests/_widget.html.haml b/app/views/projects/merge_requests/_widget.html.haml
index b00c95c3cd7..3fe6f0a6640 100644
--- a/app/views/projects/merge_requests/_widget.html.haml
+++ b/app/views/projects/merge_requests/_widget.html.haml
@@ -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')}';
diff --git a/changelogs/unreleased/33257-prevent-accidental-deletions-via-soft-delete-for-groups-workers.yml b/changelogs/unreleased/33257-prevent-accidental-deletions-via-soft-delete-for-groups-workers.yml
new file mode 100644
index 00000000000..d4ce5cd8310
--- /dev/null
+++ b/changelogs/unreleased/33257-prevent-accidental-deletions-via-soft-delete-for-groups-workers.yml
@@ -0,0 +1,5 @@
+---
+title: Add workers for 'soft-delete for groups' feature
+merge_request: 19679
+author:
+type: added
diff --git a/changelogs/unreleased/34067-add-recent-searches-to-sentry-error-list-in-gitlab.yml b/changelogs/unreleased/34067-add-recent-searches-to-sentry-error-list-in-gitlab.yml
new file mode 100644
index 00000000000..ec5b0a569c6
--- /dev/null
+++ b/changelogs/unreleased/34067-add-recent-searches-to-sentry-error-list-in-gitlab.yml
@@ -0,0 +1,5 @@
+---
+title: Add recent search to error tracking
+merge_request: 19301
+author:
+type: added
diff --git a/changelogs/unreleased/34121-group-level-no-data-store.yml b/changelogs/unreleased/34121-group-level-no-data-store.yml
new file mode 100644
index 00000000000..25e6f20f3f7
--- /dev/null
+++ b/changelogs/unreleased/34121-group-level-no-data-store.yml
@@ -0,0 +1,5 @@
+---
+title: Add empty region when group metrics are missing
+merge_request: 20900
+author:
+type: fixed
diff --git a/changelogs/unreleased/34261-service-desk-to-api.yml b/changelogs/unreleased/34261-service-desk-to-api.yml
new file mode 100644
index 00000000000..5b7363e21a8
--- /dev/null
+++ b/changelogs/unreleased/34261-service-desk-to-api.yml
@@ -0,0 +1,5 @@
+---
+title: Add service desk information to projects API endpoint
+merge_request: 20913
+author:
+type: changed
diff --git a/changelogs/unreleased/feat-merge-request-coverage-delta.yml b/changelogs/unreleased/feat-merge-request-coverage-delta.yml
new file mode 100644
index 00000000000..d158088eb49
--- /dev/null
+++ b/changelogs/unreleased/feat-merge-request-coverage-delta.yml
@@ -0,0 +1,5 @@
+---
+title: Add coverage difference visualization to merge request page
+merge_request: 20676
+author: Fabio Huser
+type: added
diff --git a/config/initializers/1_settings.rb b/config/initializers/1_settings.rb
index 07c348975b5..8e4aa5701b4 100644
--- a/config/initializers/1_settings.rb
+++ b/config/initializers/1_settings.rb
@@ -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'
diff --git a/db/migrate/20191203121729_update_group_deletion_schedules_foreign_keys.rb b/db/migrate/20191203121729_update_group_deletion_schedules_foreign_keys.rb
new file mode 100644
index 00000000000..99531a1e93e
--- /dev/null
+++ b/db/migrate/20191203121729_update_group_deletion_schedules_foreign_keys.rb
@@ -0,0 +1,29 @@
+# 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
diff --git a/db/schema.rb b/db/schema.rb
index 42420e653aa..da0cfd80ac2 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -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
diff --git a/doc/api/projects.md b/doc/api/projects.md
index ec3a081f5a3..0327c458ca1 100644
--- a/doc/api/projects.md
+++ b/doc/api/projects.md
@@ -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,
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index 33774dd26ab..82509e3c880 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -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 ""
diff --git a/spec/features/security/project/internal_access_spec.rb b/spec/features/security/project/internal_access_spec.rb
index 9c1c81918fa..e2ecf1e3b7e 100644
--- a/spec/features/security/project/internal_access_spec.rb
+++ b/spec/features/security/project/internal_access_spec.rb
@@ -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) }
diff --git a/spec/features/security/project/private_access_spec.rb b/spec/features/security/project/private_access_spec.rb
index dbaf97bc3fd..f692fa3f8ee 100644
--- a/spec/features/security/project/private_access_spec.rb
+++ b/spec/features/security/project/private_access_spec.rb
@@ -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) }
diff --git a/spec/features/security/project/public_access_spec.rb b/spec/features/security/project/public_access_spec.rb
index 35cbc195f4f..1bb9f766719 100644
--- a/spec/features/security/project/public_access_spec.rb
+++ b/spec/features/security/project/public_access_spec.rb
@@ -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) }
diff --git a/spec/frontend/error_tracking/components/error_tracking_list_spec.js b/spec/frontend/error_tracking/components/error_tracking_list_spec.js
index 6e47d2bd648..776ce589cff 100644
--- a/spec/frontend/error_tracking/components/error_tracking_list_spec.js
+++ b/spec/frontend/error_tracking/components/error_tracking_list_spec.js
@@ -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);
+ });
+ });
+ });
});
diff --git a/spec/frontend/error_tracking/store/list/mutation_spec.js b/spec/frontend/error_tracking/store/list/mutation_spec.js
index 6e021185b4d..5e6505e13cd 100644
--- a/spec/frontend/error_tracking/store/list/mutation_spec.js
+++ b/spec/frontend/error_tracking/store/list/mutation_spec.js
@@ -1,5 +1,10 @@
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');
+ });
+ });
+ });
});
diff --git a/spec/frontend/monitoring/charts/time_series_spec.js b/spec/frontend/monitoring/charts/time_series_spec.js
index efc2da40a52..15967992374 100644
--- a/spec/frontend/monitoring/charts/time_series_spec.js
+++ b/spec/frontend/monitoring/charts/time_series_spec.js
@@ -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, {
diff --git a/spec/frontend/monitoring/dashboard_state_spec.js b/spec/frontend/monitoring/dashboard_state_spec.js
index 950422911eb..e985e5fb443 100644
--- a/spec/frontend/monitoring/dashboard_state_spec.js
+++ b/spec/frontend/monitoring/dashboard_state_spec.js
@@ -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',
},
});
diff --git a/spec/frontend/monitoring/embed/embed_spec.js b/spec/frontend/monitoring/embed/embed_spec.js
index 15ea7c63875..831ab1ed157 100644
--- a/spec/frontend/monitoring/embed/embed_spec.js
+++ b/spec/frontend/monitoring/embed/embed_spec.js
@@ -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);
diff --git a/spec/frontend/monitoring/embed/mock_data.js b/spec/frontend/monitoring/embed/mock_data.js
index 8941c183807..1dc31846034 100644
--- a/spec/frontend/monitoring/embed/mock_data.js
+++ b/spec/frontend/monitoring/embed/mock_data.js
@@ -75,11 +75,9 @@ export const metricsData = [
},
];
-export const initialState = {
- monitoringDashboard: {},
+export const initialState = () => ({
dashboard: {
panel_groups: [],
},
- metricsWithData: [],
useDashboardEndpoint: true,
-};
+});
diff --git a/spec/frontend/monitoring/mock_data.js b/spec/frontend/monitoring/mock_data.js
index a184892773b..6ded22b4a3f 100644
--- a/spec/frontend/monitoring/mock_data.js
+++ b/spec/frontend/monitoring/mock_data.js
@@ -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: [
@@ -328,6 +333,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,
panels: [
diff --git a/spec/frontend/monitoring/store/actions_spec.js b/spec/frontend/monitoring/store/actions_spec.js
index 9ee8e4e9144..e41158eb4b0 100644
--- a/spec/frontend/monitoring/store/actions_spec.js
+++ b/spec/frontend/monitoring/store/actions_spec.js
@@ -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];
diff --git a/spec/frontend/monitoring/store/getters_spec.js b/spec/frontend/monitoring/store/getters_spec.js
new file mode 100644
index 00000000000..066f927dce7
--- /dev/null
+++ b/spec/frontend/monitoring/store/getters_spec.js
@@ -0,0 +1,99 @@
+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,
+ ]);
+ });
+ });
+ });
+});
diff --git a/spec/frontend/monitoring/store/mutations_spec.js b/spec/frontend/monitoring/store/mutations_spec.js
index 42031e01878..47177180aa1 100644
--- a/spec/frontend/monitoring/store/mutations_spec.js
+++ b/spec/frontend/monitoring/store/mutations_spec.js
@@ -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', () => {
diff --git a/spec/javascripts/monitoring/components/dashboard_spec.js b/spec/javascripts/monitoring/components/dashboard_spec.js
index 3529a3d72ba..7d6a1d7b097 100644
--- a/spec/javascripts/monitoring/components/dashboard_spec.js
+++ b/spec/javascripts/monitoring/components/dashboard_spec.js
@@ -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');
diff --git a/spec/javascripts/monitoring/components/graph_group_spec.js b/spec/javascripts/monitoring/components/graph_group_spec.js
index 04371091ca8..43ca17c3cbc 100644
--- a/spec/javascripts/monitoring/components/graph_group_spec.js
+++ b/spec/javascripts/monitoring/components/graph_group_spec.js
@@ -1,16 +1,18 @@
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();
+ });
});
});
});
diff --git a/spec/javascripts/pages/admin/jobs/index/components/stop_jobs_modal_spec.js b/spec/javascripts/pages/admin/jobs/index/components/stop_jobs_modal_spec.js
index 6bfb3f5ca21..9ad72e0b043 100644
--- a/spec/javascripts/pages/admin/jobs/index/components/stop_jobs_modal_spec.js
+++ b/spec/javascripts/pages/admin/jobs/index/components/stop_jobs_modal_spec.js
@@ -1,10 +1,9 @@
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`,
diff --git a/spec/javascripts/pages/labels/components/promote_label_modal_spec.js b/spec/javascripts/pages/labels/components/promote_label_modal_spec.js
index 75912612255..5bad13c1ef2 100644
--- a/spec/javascripts/pages/labels/components/promote_label_modal_spec.js
+++ b/spec/javascripts/pages/labels/components/promote_label_modal_spec.js
@@ -1,8 +1,8 @@
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;
diff --git a/spec/javascripts/pages/milestones/shared/components/delete_milestone_modal_spec.js b/spec/javascripts/pages/milestones/shared/components/delete_milestone_modal_spec.js
index fe293083e4c..9075c8aa97a 100644
--- a/spec/javascripts/pages/milestones/shared/components/delete_milestone_modal_spec.js
+++ b/spec/javascripts/pages/milestones/shared/components/delete_milestone_modal_spec.js
@@ -1,11 +1,10 @@
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 = {
diff --git a/spec/javascripts/pages/milestones/shared/components/promote_milestone_modal_spec.js b/spec/javascripts/pages/milestones/shared/components/promote_milestone_modal_spec.js
index 3d25a278cef..78c0070187c 100644
--- a/spec/javascripts/pages/milestones/shared/components/promote_milestone_modal_spec.js
+++ b/spec/javascripts/pages/milestones/shared/components/promote_milestone_modal_spec.js
@@ -1,8 +1,8 @@
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;
diff --git a/spec/javascripts/pdf/index_spec.js b/spec/javascripts/pdf/index_spec.js
index c746d5644e8..e14f1b27f6c 100644
--- a/spec/javascripts/pdf/index_spec.js
+++ b/spec/javascripts/pdf/index_spec.js
@@ -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`;
diff --git a/spec/javascripts/pdf/page_spec.js b/spec/javascripts/pdf/page_spec.js
index efeb65acf87..bb2294e8d18 100644
--- a/spec/javascripts/pdf/page_spec.js
+++ b/spec/javascripts/pdf/page_spec.js
@@ -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`;
diff --git a/spec/javascripts/performance_bar/index_spec.js b/spec/javascripts/performance_bar/index_spec.js
index de0ef25e7fa..3957edce9e0 100644
--- a/spec/javascripts/performance_bar/index_spec.js
+++ b/spec/javascripts/performance_bar/index_spec.js
@@ -1,10 +1,9 @@
+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;
diff --git a/spec/javascripts/persistent_user_callout_spec.js b/spec/javascripts/persistent_user_callout_spec.js
index d15758be5d2..d4cb92cacfd 100644
--- a/spec/javascripts/persistent_user_callout_spec.js
+++ b/spec/javascripts/persistent_user_callout_spec.js
@@ -1,7 +1,7 @@
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';
diff --git a/spec/javascripts/pipelines/graph/job_group_dropdown_spec.js b/spec/javascripts/pipelines/graph/job_group_dropdown_spec.js
index 24631cc1c89..a3957f94caa 100644
--- a/spec/javascripts/pipelines/graph/job_group_dropdown_spec.js
+++ b/spec/javascripts/pipelines/graph/job_group_dropdown_spec.js
@@ -1,6 +1,6 @@
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);
diff --git a/spec/javascripts/pipelines/graph/linked_pipelines_column_spec.js b/spec/javascripts/pipelines/graph/linked_pipelines_column_spec.js
index 0584a118f81..fe7039da9e4 100644
--- a/spec/javascripts/pipelines/graph/linked_pipelines_column_spec.js
+++ b/spec/javascripts/pipelines/graph/linked_pipelines_column_spec.js
@@ -1,6 +1,6 @@
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', () => {
diff --git a/spec/javascripts/pipelines/graph/stage_column_component_spec.js b/spec/javascripts/pipelines/graph/stage_column_component_spec.js
index 5183f8dd2d6..dbfeeae43fe 100644
--- a/spec/javascripts/pipelines/graph/stage_column_component_spec.js
+++ b/spec/javascripts/pipelines/graph/stage_column_component_spec.js
@@ -1,6 +1,6 @@
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;
diff --git a/spec/javascripts/pipelines/pipelines_actions_spec.js b/spec/javascripts/pipelines/pipelines_actions_spec.js
index 953a42b9d15..91f7d2167cc 100644
--- a/spec/javascripts/pipelines/pipelines_actions_spec.js
+++ b/spec/javascripts/pipelines/pipelines_actions_spec.js
@@ -1,9 +1,9 @@
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);
diff --git a/spec/javascripts/pipelines/pipelines_spec.js b/spec/javascripts/pipelines/pipelines_spec.js
index daa898ca687..e1123cc7248 100644
--- a/spec/javascripts/pipelines/pipelines_spec.js
+++ b/spec/javascripts/pipelines/pipelines_spec.js
@@ -1,9 +1,9 @@
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', () => {
diff --git a/spec/javascripts/pipelines/stage_spec.js b/spec/javascripts/pipelines/stage_spec.js
index 19ae7860333..b99688ec371 100644
--- a/spec/javascripts/pipelines/stage_spec.js
+++ b/spec/javascripts/pipelines/stage_spec.js
@@ -1,9 +1,9 @@
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', () => {
diff --git a/spec/javascripts/profile/account/components/delete_account_modal_spec.js b/spec/javascripts/profile/account/components/delete_account_modal_spec.js
index d5f5cabc63e..e2c557d79a9 100644
--- a/spec/javascripts/profile/account/components/delete_account_modal_spec.js
+++ b/spec/javascripts/profile/account/components/delete_account_modal_spec.js
@@ -1,8 +1,7 @@
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`;
diff --git a/spec/javascripts/profile/account/components/update_username_spec.js b/spec/javascripts/profile/account/components/update_username_spec.js
index cc07a5f6e43..902e00b85fd 100644
--- a/spec/javascripts/profile/account/components/update_username_spec.js
+++ b/spec/javascripts/profile/account/components/update_username_spec.js
@@ -1,9 +1,9 @@
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;
diff --git a/spec/javascripts/related_merge_requests/store/actions_spec.js b/spec/javascripts/related_merge_requests/store/actions_spec.js
index 65e436fbb17..c4cd9f5f803 100644
--- a/spec/javascripts/related_merge_requests/store/actions_spec.js
+++ b/spec/javascripts/related_merge_requests/store/actions_spec.js
@@ -1,8 +1,8 @@
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;
diff --git a/spec/javascripts/releases/list/components/app_spec.js b/spec/javascripts/releases/list/components/app_spec.js
index 994488581d7..de6208ab1fd 100644
--- a/spec/javascripts/releases/list/components/app_spec.js
+++ b/spec/javascripts/releases/list/components/app_spec.js
@@ -1,9 +1,9 @@
-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,
diff --git a/spec/javascripts/releases/list/store/actions_spec.js b/spec/javascripts/releases/list/store/actions_spec.js
index c4b49c39e28..f03e019b95c 100644
--- a/spec/javascripts/releases/list/store/actions_spec.js
+++ b/spec/javascripts/releases/list/store/actions_spec.js
@@ -1,3 +1,4 @@
+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', () => {
diff --git a/spec/javascripts/reports/components/modal_open_name_spec.js b/spec/javascripts/reports/components/modal_open_name_spec.js
index 53ae6453915..ae1fb2bf187 100644
--- a/spec/javascripts/reports/components/modal_open_name_spec.js
+++ b/spec/javascripts/reports/components/modal_open_name_spec.js
@@ -1,7 +1,7 @@
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);
diff --git a/spec/javascripts/reports/components/summary_row_spec.js b/spec/javascripts/reports/components/summary_row_spec.js
index fab7693581c..a19fbad403c 100644
--- a/spec/javascripts/reports/components/summary_row_spec.js
+++ b/spec/javascripts/reports/components/summary_row_spec.js
@@ -1,6 +1,6 @@
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);
diff --git a/spec/javascripts/reports/store/actions_spec.js b/spec/javascripts/reports/store/actions_spec.js
index 41137b50847..18fdb179597 100644
--- a/spec/javascripts/reports/store/actions_spec.js
+++ b/spec/javascripts/reports/store/actions_spec.js
@@ -1,4 +1,6 @@
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;
diff --git a/spec/javascripts/vue_mr_widget/components/mr_widget_pipeline_container_spec.js b/spec/javascripts/vue_mr_widget/components/mr_widget_pipeline_container_spec.js
index 703b889cd5d..2d6d22d66aa 100644
--- a/spec/javascripts/vue_mr_widget/components/mr_widget_pipeline_container_spec.js
+++ b/spec/javascripts/vue_mr_widget/components/mr_widget_pipeline_container_spec.js
@@ -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,
diff --git a/spec/javascripts/vue_mr_widget/components/mr_widget_pipeline_spec.js b/spec/javascripts/vue_mr_widget/components/mr_widget_pipeline_spec.js
index 032fb90ca3c..5997c93105e 100644
--- a/spec/javascripts/vue_mr_widget/components/mr_widget_pipeline_spec.js
+++ b/spec/javascripts/vue_mr_widget/components/mr_widget_pipeline_spec.js
@@ -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', () => {
diff --git a/spec/javascripts/vue_mr_widget/mock_data.js b/spec/javascripts/vue_mr_widget/mock_data.js
index ba999adb2a9..c7ca93c58cf 100644
--- a/spec/javascripts/vue_mr_widget/mock_data.js
+++ b/spec/javascripts/vue_mr_widget/mock_data.js
@@ -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,
diff --git a/spec/models/merge_request_spec.rb b/spec/models/merge_request_spec.rb
index 72e8294e237..bf6fa20dc17 100644
--- a/spec/models/merge_request_spec.rb
+++ b/spec/models/merge_request_spec.rb
@@ -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
{