diff options
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 { |