diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2020-05-12 15:10:33 +0000 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2020-05-12 15:10:33 +0000 |
commit | 8ff63012e9b7e3dc2279e636868af9a438d1fa93 (patch) | |
tree | 4dd67f247345cbc2e8629c4a5f2b935dafc988e3 | |
parent | ef7cfec30c9fab7b9e757877c472ca7ca2eccc2d (diff) | |
download | gitlab-ce-8ff63012e9b7e3dc2279e636868af9a438d1fa93.tar.gz |
Add latest changes from gitlab-org/gitlab@master
79 files changed, 968 insertions, 544 deletions
diff --git a/app/assets/javascripts/diffs/store/getters_versions_dropdowns.js b/app/assets/javascripts/diffs/store/getters_versions_dropdowns.js index acc8874dad8..1e8e736c028 100644 --- a/app/assets/javascripts/diffs/store/getters_versions_dropdowns.js +++ b/app/assets/javascripts/diffs/store/getters_versions_dropdowns.js @@ -40,10 +40,7 @@ export const diffCompareDropdownTargetVersions = (state, getters) => { }; }; - if (gon.features?.diffCompareWithHead) { - return [...state.mergeRequestDiffs.slice(1).map(formatVersion), baseVersion, headVersion]; - } - return [...state.mergeRequestDiffs.slice(1).map(formatVersion), baseVersion]; + return [...state.mergeRequestDiffs.slice(1).map(formatVersion), baseVersion, headVersion]; }; export const diffCompareDropdownSourceVersions = (state, getters) => { diff --git a/app/assets/javascripts/monitoring/components/dashboard.vue b/app/assets/javascripts/monitoring/components/dashboard.vue index 2b5a10907f5..0a6329fbdfb 100644 --- a/app/assets/javascripts/monitoring/components/dashboard.vue +++ b/app/assets/javascripts/monitoring/components/dashboard.vue @@ -440,7 +440,6 @@ export default { class="flex-grow-1" toggle-class="dropdown-menu-toggle" :default-branch="defaultBranch" - :selected-dashboard="selectedDashboard" @selectDashboard="selectDashboard($event)" /> </div> diff --git a/app/assets/javascripts/monitoring/components/dashboards_dropdown.vue b/app/assets/javascripts/monitoring/components/dashboards_dropdown.vue index 9ef4b93d543..8b86890715f 100644 --- a/app/assets/javascripts/monitoring/components/dashboards_dropdown.vue +++ b/app/assets/javascripts/monitoring/components/dashboards_dropdown.vue @@ -1,5 +1,5 @@ <script> -import { mapState, mapActions } from 'vuex'; +import { mapState, mapActions, mapGetters } from 'vuex'; import { GlAlert, GlIcon, @@ -36,11 +36,6 @@ export default { GlModal: GlModalDirective, }, props: { - selectedDashboard: { - type: Object, - required: false, - default: () => ({}), - }, defaultBranch: { type: String, required: true, @@ -56,11 +51,15 @@ export default { }, computed: { ...mapState('monitoringDashboard', ['allDashboards']), + ...mapGetters('monitoringDashboard', ['selectedDashboard']), isSystemDashboard() { - return this.selectedDashboard.system_dashboard; + return this.selectedDashboard?.system_dashboard; }, selectedDashboardText() { - return this.selectedDashboard.display_name; + return this.selectedDashboard?.display_name; + }, + selectedDashboardPath() { + return this.selectedDashboard?.path; }, filteredDashboards() { @@ -145,7 +144,7 @@ export default { <gl-dropdown-item v-for="dashboard in starredDashboards" :key="dashboard.path" - :active="dashboard.path === selectedDashboard.path" + :active="dashboard.path === selectedDashboardPath" active-class="is-active" @click="selectDashboard(dashboard)" > @@ -163,7 +162,7 @@ export default { <gl-dropdown-item v-for="dashboard in nonStarredDashboards" :key="dashboard.path" - :active="dashboard.path === selectedDashboard.path" + :active="dashboard.path === selectedDashboardPath" active-class="is-active" @click="selectDashboard(dashboard)" > diff --git a/app/assets/javascripts/monitoring/stores/actions.js b/app/assets/javascripts/monitoring/stores/actions.js index 903105babeb..4481395cbbd 100644 --- a/app/assets/javascripts/monitoring/stores/actions.js +++ b/app/assets/javascripts/monitoring/stores/actions.js @@ -345,6 +345,35 @@ export const receiveAnnotationsFailure = ({ commit }) => commit(types.RECEIVE_AN // Dashboard manipulation +export const toggleStarredValue = ({ commit, state, getters }) => { + const { selectedDashboard } = getters; + + if (state.isUpdatingStarredValue) { + // Prevent repeating requests for the same change + return; + } + if (!selectedDashboard) { + return; + } + + const method = selectedDashboard.starred ? 'DELETE' : 'POST'; + const url = selectedDashboard.user_starred_path; + const newStarredValue = !selectedDashboard.starred; + + commit(types.REQUEST_DASHBOARD_STARRING); + + axios({ + url, + method, + }) + .then(() => { + commit(types.RECEIVE_DASHBOARD_STARRING_SUCCESS, newStarredValue); + }) + .catch(() => { + commit(types.RECEIVE_DASHBOARD_STARRING_FAILURE); + }); +}; + /** * Set a new array of metrics to a panel group * @param {*} data An object containing diff --git a/app/assets/javascripts/monitoring/stores/getters.js b/app/assets/javascripts/monitoring/stores/getters.js index 1cadc287204..8f8dc8127a0 100644 --- a/app/assets/javascripts/monitoring/stores/getters.js +++ b/app/assets/javascripts/monitoring/stores/getters.js @@ -4,6 +4,19 @@ const metricsIdsInPanel = panel => panel.metrics.filter(metric => metric.metricId && metric.result).map(metric => metric.metricId); /** + * Returns a reference to the currently selected dashboard + * from the list of dashboards. + * + * @param {Object} state + */ +export const selectedDashboard = state => { + const { allDashboards } = state; + return ( + allDashboards.find(({ path }) => path === state.currentDashboard) || allDashboards[0] || null + ); +}; + +/** * Get all state for metric in the dashboard or a group. The * states are not repeated so the dashboard or group can show * a global state. diff --git a/app/assets/javascripts/monitoring/stores/mutation_types.js b/app/assets/javascripts/monitoring/stores/mutation_types.js index 2fd0efa4ab7..dc78e50476e 100644 --- a/app/assets/javascripts/monitoring/stores/mutation_types.js +++ b/app/assets/javascripts/monitoring/stores/mutation_types.js @@ -5,6 +5,10 @@ export const RECEIVE_METRICS_DASHBOARD_FAILURE = 'RECEIVE_METRICS_DASHBOARD_FAIL export const SET_PROM_QUERY_VARIABLES = 'SET_PROM_QUERY_VARIABLES'; export const UPDATE_VARIABLE_DATA = 'UPDATE_VARIABLE_DATA'; +export const REQUEST_DASHBOARD_STARRING = 'REQUEST_DASHBOARD_STARRING'; +export const RECEIVE_DASHBOARD_STARRING_SUCCESS = 'RECEIVE_DASHBOARD_STARRING_SUCCESS'; +export const RECEIVE_DASHBOARD_STARRING_FAILURE = 'RECEIVE_DASHBOARD_STARRING_FAILURE'; + // Annotations export const RECEIVE_ANNOTATIONS_SUCCESS = 'RECEIVE_ANNOTATIONS_SUCCESS'; export const RECEIVE_ANNOTATIONS_FAILURE = 'RECEIVE_ANNOTATIONS_FAILURE'; diff --git a/app/assets/javascripts/monitoring/stores/mutations.js b/app/assets/javascripts/monitoring/stores/mutations.js index 8de1430302a..ec519ee4576 100644 --- a/app/assets/javascripts/monitoring/stores/mutations.js +++ b/app/assets/javascripts/monitoring/stores/mutations.js @@ -1,5 +1,7 @@ +import Vue from 'vue'; import { pick } from 'lodash'; import * as types from './mutation_types'; +import { selectedDashboard } from './getters'; import { mapToDashboardViewModel, normalizeQueryResult } from './utils'; import { BACKOFF_TIMEOUT } from '../../lib/utils/common_utils'; import { endpointKeys, initialStateKeys, metricStates } from '../constants'; @@ -71,6 +73,23 @@ export default { state.showEmptyState = true; }, + [types.REQUEST_DASHBOARD_STARRING](state) { + state.isUpdatingStarredValue = true; + }, + [types.RECEIVE_DASHBOARD_STARRING_SUCCESS](state, newStarredValue) { + const dashboard = selectedDashboard(state); + const index = state.allDashboards.findIndex(d => d === dashboard); + + state.isUpdatingStarredValue = false; + + // Trigger state updates in the reactivity system for this change + // https://vuejs.org/v2/guide/reactivity.html#For-Arrays + Vue.set(state.allDashboards, index, { ...dashboard, starred: newStarredValue }); + }, + [types.RECEIVE_DASHBOARD_STARRING_FAILURE](state) { + state.isUpdatingStarredValue = false; + }, + /** * Deployments and environments */ diff --git a/app/assets/javascripts/monitoring/stores/state.js b/app/assets/javascripts/monitoring/stores/state.js index f1b2baf0f74..9ae1da93e5f 100644 --- a/app/assets/javascripts/monitoring/stores/state.js +++ b/app/assets/javascripts/monitoring/stores/state.js @@ -14,6 +14,7 @@ export default () => ({ emptyState: 'gettingStarted', showEmptyState: true, showErrorBanner: true, + isUpdatingStarredValue: false, dashboard: { panelGroups: [], }, diff --git a/app/assets/javascripts/static_site_editor/components/edit_area.vue b/app/assets/javascripts/static_site_editor/components/edit_area.vue index 921d93669c5..aafa5a2d0ba 100644 --- a/app/assets/javascripts/static_site_editor/components/edit_area.vue +++ b/app/assets/javascripts/static_site_editor/components/edit_area.vue @@ -1,18 +1,68 @@ <script> import { GlFormTextarea } from '@gitlab/ui'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; + +import RichContentEditor from '~/vue_shared/components/rich_content_editor/rich_content_editor.vue'; +import PublishToolbar from '../components/publish_toolbar.vue'; +import EditHeader from '../components/edit_header.vue'; + export default { components: { GlFormTextarea, + RichContentEditor, + PublishToolbar, + EditHeader, }, + mixins: [glFeatureFlagsMixin()], props: { - value: { + title: { type: String, required: true, }, + content: { + type: String, + required: true, + }, + savingChanges: { + type: Boolean, + required: true, + }, + returnUrl: { + type: String, + required: false, + default: '', + }, + }, + data() { + return { + editableContent: this.content, + saveable: false, + }; + }, + computed: { + modified() { + return this.content !== this.editableContent; + }, + }, + methods: { + onSubmit() { + this.$emit('submit', { content: this.editableContent }); + }, }, }; </script> <template> - <gl-form-textarea :value="value" v-on="$listeners" /> + <div class="d-flex flex-grow-1 flex-column"> + <edit-header class="py-2" :title="title" /> + <rich-content-editor v-if="glFeatures.richContentEditor" v-model="editableContent" /> + <gl-form-textarea v-else v-model="editableContent" class="h-100 shadow-none" /> + <publish-toolbar + class="gl-fixed gl-left-0 gl-bottom-0 gl-w-full" + :return-url="returnUrl" + :saveable="modified" + :saving-changes="savingChanges" + @submit="onSubmit" + /> + </div> </template> diff --git a/app/assets/javascripts/static_site_editor/components/skeleton_loader.vue b/app/assets/javascripts/static_site_editor/components/skeleton_loader.vue new file mode 100644 index 00000000000..1b6179883aa --- /dev/null +++ b/app/assets/javascripts/static_site_editor/components/skeleton_loader.vue @@ -0,0 +1,19 @@ +<script> +import { GlSkeletonLoader } from '@gitlab/ui'; + +export default { + components: { + GlSkeletonLoader, + }, +}; +</script> +<template> + <gl-skeleton-loader :width="500" :height="102"> + <rect width="500" height="16" rx="4" /> + <rect y="20" width="375" height="16" rx="4" /> + <rect x="380" y="20" width="120" height="16" rx="4" /> + <rect y="40" width="250" height="16" rx="4" /> + <rect x="255" y="40" width="150" height="16" rx="4" /> + <rect x="410" y="40" width="90" height="16" rx="4" /> + </gl-skeleton-loader> +</template> diff --git a/app/assets/javascripts/static_site_editor/pages/home.vue b/app/assets/javascripts/static_site_editor/pages/home.vue index 9003c381456..e48e8b5d23c 100644 --- a/app/assets/javascripts/static_site_editor/pages/home.vue +++ b/app/assets/javascripts/static_site_editor/pages/home.vue @@ -1,13 +1,9 @@ <script> -import { mapState, mapGetters, mapActions } from 'vuex'; -import { GlSkeletonLoader } from '@gitlab/ui'; +import { mapState, mapActions } from 'vuex'; -import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; -import RichContentEditor from '~/vue_shared/components/rich_content_editor/rich_content_editor.vue'; +import SkeletonLoader from '../components/skeleton_loader.vue'; import EditArea from '../components/edit_area.vue'; -import EditHeader from '../components/edit_header.vue'; import SavedChangesMessage from '../components/saved_changes_message.vue'; -import PublishToolbar from '../components/publish_toolbar.vue'; import InvalidContentMessage from '../components/invalid_content_message.vue'; import SubmitChangesError from '../components/submit_changes_error.vue'; @@ -20,16 +16,12 @@ import { LOAD_CONTENT_ERROR } from '../constants'; export default { components: { - RichContentEditor, + SkeletonLoader, EditArea, - EditHeader, InvalidContentMessage, - GlSkeletonLoader, SavedChangesMessage, - PublishToolbar, SubmitChangesError, }, - mixins: [glFeatureFlagsMixin()], apollo: { appData: { query: appDataQuery, @@ -58,80 +50,51 @@ export default { }, }, computed: { - ...mapState([ - 'content', - 'isLoadingContent', - 'isSavingChanges', - 'isContentLoaded', - 'returnUrl', - 'title', - 'submitChangesError', - 'savedContentMeta', - ]), - ...mapGetters(['contentChanged']), - }, - mounted() { - if (this.appData.isSupportedContent) { - this.loadContent(); - } + ...mapState(['isSavingChanges', 'submitChangesError', 'savedContentMeta']), + isLoadingContent() { + return this.$apollo.queries.sourceContent.loading; + }, + isContentLoaded() { + return Boolean(this.sourceContent); + }, }, methods: { - ...mapActions(['loadContent', 'setContent', 'submitChanges', 'dismissSubmitChangesError']), + ...mapActions(['setContent', 'submitChanges', 'dismissSubmitChangesError']), + onSubmit({ content }) { + this.setContent(content); + this.submitChanges(); + }, }, }; </script> <template> - <div class="d-flex justify-content-center h-100 pt-2"> + <div class="container d-flex gl-flex-direction-column pt-2 h-100"> <!-- Success view --> <saved-changes-message v-if="savedContentMeta" - class="w-75" :branch="savedContentMeta.branch" :commit="savedContentMeta.commit" :merge-request="savedContentMeta.mergeRequest" - :return-url="returnUrl" + :return-url="appData.returnUrl" /> <!-- Main view --> <template v-else-if="appData.isSupportedContent"> - <div v-if="isLoadingContent" class="w-50 h-50"> - <gl-skeleton-loader :width="500" :height="102"> - <rect width="500" height="16" rx="4" /> - <rect y="20" width="375" height="16" rx="4" /> - <rect x="380" y="20" width="120" height="16" rx="4" /> - <rect y="40" width="250" height="16" rx="4" /> - <rect x="255" y="40" width="150" height="16" rx="4" /> - <rect x="410" y="40" width="90" height="16" rx="4" /> - </gl-skeleton-loader> - </div> - <div v-if="isContentLoaded" class="d-flex flex-grow-1 flex-column"> - <submit-changes-error - v-if="submitChangesError" - class="w-75 align-self-center" - :error="submitChangesError" - @retry="submitChanges" - @dismiss="dismissSubmitChangesError" - /> - <edit-header class="w-75 align-self-center py-2" :title="title" /> - <rich-content-editor - v-if="glFeatures.richContentEditor" - class="w-75 gl-align-self-center" - :value="content" - @input="setContent" - /> - <edit-area - v-else - class="w-75 h-100 shadow-none align-self-center" - :value="content" - @input="setContent" - /> - <publish-toolbar - :return-url="returnUrl" - :saveable="contentChanged" - :saving-changes="isSavingChanges" - @submit="submitChanges" - /> - </div> + <skeleton-loader v-if="isLoadingContent" class="w-75 gl-align-self-center gl-mt-5" /> + <submit-changes-error + v-if="submitChangesError" + :error="submitChangesError" + @retry="submitChanges" + @dismiss="dismissSubmitChangesError" + /> + <edit-area + v-if="isContentLoaded" + :title="sourceContent.title" + :content="sourceContent.content" + :saving-changes="isSavingChanges" + :return-url="appData.returnUrl" + @submit="onSubmit" + /> </template> <!-- Error view --> diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_button.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_button.vue index 2730e8ee1da..f45c14f8344 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_button.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_button.vue @@ -27,9 +27,9 @@ export default { class="labels-select-dropdown-button js-dropdown-button w-100 text-left" @click="handleButtonClick" > - <span class="dropdown-toggle-text" :class="{ 'flex-fill': isDropdownVariantStandalone }">{{ - dropdownButtonText - }}</span> + <span class="dropdown-toggle-text flex-fill"> + {{ dropdownButtonText }} + </span> <gl-icon name="chevron-down" class="pull-right" /> </gl-button> </template> diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view.vue index a548ef4ac86..1ef2e8b3bed 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view.vue @@ -1,16 +1,18 @@ <script> import { mapState, mapGetters, mapActions } from 'vuex'; -import { GlLoadingIcon, GlButton, GlIcon, GlSearchBoxByType, GlLink } from '@gitlab/ui'; +import { GlLoadingIcon, GlButton, GlSearchBoxByType, GlLink } from '@gitlab/ui'; import { UP_KEY_CODE, DOWN_KEY_CODE, ENTER_KEY_CODE, ESC_KEY_CODE } from '~/lib/utils/keycodes'; +import LabelItem from './label_item.vue'; + export default { components: { GlLoadingIcon, GlButton, - GlIcon, GlSearchBoxByType, GlLink, + LabelItem, }, data() { return { @@ -60,11 +62,6 @@ export default { 'updateSelectedLabels', 'toggleDropdownContents', ]), - getDropdownLabelBoxStyle(label) { - return { - backgroundColor: label.color, - }; - }, isLabelSelected(label) { return this.selectedLabelsList.includes(label.id); }, @@ -138,24 +135,19 @@ export default { @click="toggleDropdownContents" /> </div> - <div class="dropdown-input"> + <div class="dropdown-input" @click.stop="() => {}"> <gl-search-box-by-type v-model="searchKey" :autofocus="true" /> </div> - <div v-if="!labelsFetchInProgress" ref="labelsListContainer" class="dropdown-content"> + <div v-show="!labelsFetchInProgress" ref="labelsListContainer" class="dropdown-content"> <ul class="list-unstyled mb-0"> <li v-for="(label, index) in visibleLabels" :key="label.id" class="d-block text-left"> - <gl-link - class="d-flex align-items-baseline text-break-word label-item" - :class="{ 'is-focused': index === currentHighlightItem }" - @click="handleLabelClick(label)" - > - <gl-icon v-show="label.set" name="mobile-issue-close" class="mr-2 align-self-center" /> - <span v-show="!label.set" class="mr-3 pr-2"></span> - <span class="dropdown-label-box" :style="getDropdownLabelBoxStyle(label)"></span> - <span>{{ label.title }}</span> - </gl-link> + <label-item + :label="label" + :highlight="index === currentHighlightItem" + @clickLabel="handleLabelClick(label)" + /> </li> - <li v-if="!visibleLabels.length" class="p-2 text-center"> + <li v-show="!visibleLabels.length" class="p-2 text-center"> {{ __('No matching results') }} </li> </ul> @@ -170,9 +162,9 @@ export default { > </li> <li> - <gl-link :href="labelsManagePath" class="d-flex flex-row text-break-word label-item"> - {{ footerManageLabelTitle }} - </gl-link> + <gl-link :href="labelsManagePath" class="d-flex flex-row text-break-word label-item">{{ + footerManageLabelTitle + }}</gl-link> </li> </ul> </div> diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/label_item.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/label_item.vue new file mode 100644 index 00000000000..c95221d71b5 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/label_item.vue @@ -0,0 +1,52 @@ +<script> +import { GlIcon, GlLink } from '@gitlab/ui'; + +export default { + components: { + GlIcon, + GlLink, + }, + props: { + label: { + type: Object, + required: true, + }, + highlight: { + type: Boolean, + required: false, + default: false, + }, + }, + data() { + return { + isSet: this.label.set, + }; + }, + computed: { + labelBoxStyle() { + return { + backgroundColor: this.label.color, + }; + }, + }, + methods: { + handleClick() { + this.isSet = !this.isSet; + this.$emit('clickLabel', this.label); + }, + }, +}; +</script> + +<template> + <gl-link + class="d-flex align-items-baseline text-break-word label-item" + :class="{ 'is-focused': highlight }" + @click="handleClick" + > + <gl-icon v-show="isSet" name="mobile-issue-close" class="mr-2 align-self-center" /> + <span v-show="!isSet" data-testid="no-icon" class="mr-3 pr-2"></span> + <span class="dropdown-label-box" data-testid="label-color-box" :style="labelBoxStyle"></span> + <span>{{ label.title }}</span> + </gl-link> +</template> diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/mutations.js b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/mutations.js index b55a58850a1..54f8c78b4e1 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/mutations.js +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/mutations.js @@ -58,29 +58,13 @@ export default { }, [types.UPDATE_SELECTED_LABELS](state, { labels }) { - // Iterate over all the labels and update - // `set` prop value to represent their current state. - const labelIds = labels.map(label => label.id); - state.labels = state.labels.reduce((allLabels, label) => { - if (labelIds.includes(label.id)) { - allLabels.push({ - ...label, - touched: true, - set: !label.set, - }); - } else { - // In case multiselect is not allowed - // we unselect any existing selected label - const unchangedLabel = state.allowMultiselect - ? label - : { - ...label, - touched: true, - set: false, - }; - allLabels.push(unchangedLabel); - } - return allLabels; - }, []); + // Find the label to update from all the labels + // and change `set` prop value to represent their current state. + const labelId = labels.pop()?.id; + const candidateLabel = state.labels.find(label => labelId === label.id); + if (candidateLabel) { + candidateLabel.touched = true; + candidateLabel.set = !candidateLabel.set; + } }, }; diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb index 9e6c42ea2be..ae7965a2eae 100644 --- a/app/controllers/projects/merge_requests_controller.rb +++ b/app/controllers/projects/merge_requests_controller.rb @@ -26,7 +26,6 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo push_frontend_feature_flag(:code_navigation, @project) push_frontend_feature_flag(:widget_visibility_polling, @project, default_enabled: true) push_frontend_feature_flag(:merge_ref_head_comments, @project) - push_frontend_feature_flag(:diff_compare_with_head, @project) push_frontend_feature_flag(:accessibility_merge_request_widget, @project) end diff --git a/app/helpers/application_settings_helper.rb b/app/helpers/application_settings_helper.rb index f36a638555a..df8009c3d00 100644 --- a/app/helpers/application_settings_helper.rb +++ b/app/helpers/application_settings_helper.rb @@ -367,7 +367,7 @@ module ApplicationSettingsHelper end end -ApplicationSettingsHelper.prepend_if_ee('EE::ApplicationSettingsHelper') # rubocop: disable Cop/InjectEnterpriseEditionModule +ApplicationSettingsHelper.prepend_if_ee('EE::ApplicationSettingsHelper') # The methods in `EE::ApplicationSettingsHelper` should be available as both # instance and class methods. diff --git a/app/helpers/auth_helper.rb b/app/helpers/auth_helper.rb index 12f75217a8a..a57e27d23c8 100644 --- a/app/helpers/auth_helper.rb +++ b/app/helpers/auth_helper.rb @@ -152,7 +152,7 @@ module AuthHelper extend self end -AuthHelper.prepend_if_ee('EE::AuthHelper') # rubocop: disable Cop/InjectEnterpriseEditionModule +AuthHelper.prepend_if_ee('EE::AuthHelper') # The methods added in EE should be available as both class and instance # methods, just like the methods provided by `AuthHelper` itself. diff --git a/app/helpers/milestones_helper.rb b/app/helpers/milestones_helper.rb index 2f5aac892ab..df1ee54c5ac 100644 --- a/app/helpers/milestones_helper.rb +++ b/app/helpers/milestones_helper.rb @@ -249,7 +249,7 @@ module MilestonesHelper if milestone.legacy_group_milestone? group_milestone_path(@group, milestone.safe_title, title: milestone.title, milestone: params) else - group_milestone_path(@group, milestone.iid, milestone: params) + group_milestone_path(milestone.group, milestone.iid, milestone: params) end end diff --git a/app/helpers/projects_helper.rb b/app/helpers/projects_helper.rb index 2151d1a85d7..5c7c128da90 100644 --- a/app/helpers/projects_helper.rb +++ b/app/helpers/projects_helper.rb @@ -1,8 +1,6 @@ # frozen_string_literal: true module ProjectsHelper - prepend_if_ee('::EE::ProjectsHelper') # rubocop: disable Cop/InjectEnterpriseEditionModule - def project_incident_management_setting @project_incident_management_setting ||= @project.incident_management_setting || @project.build_incident_management_setting diff --git a/app/helpers/services_helper.rb b/app/helpers/services_helper.rb index dcdf6bbd3da..b13cc93436f 100644 --- a/app/helpers/services_helper.rb +++ b/app/helpers/services_helper.rb @@ -101,7 +101,7 @@ module ServicesHelper extend self end -ServicesHelper.prepend_if_ee('EE::ServicesHelper') # rubocop: disable Cop/InjectEnterpriseEditionModule +ServicesHelper.prepend_if_ee('EE::ServicesHelper') # The methods in `EE::ServicesHelper` should be available as both instance and # class methods. diff --git a/app/helpers/system_note_helper.rb b/app/helpers/system_note_helper.rb index d3b6ecf2bd7..33268b5dbc9 100644 --- a/app/helpers/system_note_helper.rb +++ b/app/helpers/system_note_helper.rb @@ -42,7 +42,7 @@ module SystemNoteHelper extend self end -SystemNoteHelper.prepend_if_ee('EE::SystemNoteHelper') # rubocop: disable Cop/InjectEnterpriseEditionModule +SystemNoteHelper.prepend_if_ee('EE::SystemNoteHelper') # The methods in `EE::SystemNoteHelper` should be available as both instance and # class methods. diff --git a/app/models/concerns/noteable.rb b/app/models/concerns/noteable.rb index d1e54d46d40..247ba0822fe 100644 --- a/app/models/concerns/noteable.rb +++ b/app/models/concerns/noteable.rb @@ -150,5 +150,5 @@ end Noteable.extend(Noteable::ClassMethods) -Noteable::ClassMethods.prepend_if_ee('EE::Noteable::ClassMethods') # rubocop: disable Cop/InjectEnterpriseEditionModule +Noteable::ClassMethods.prepend_if_ee('EE::Noteable::ClassMethods') Noteable.prepend_if_ee('EE::Noteable') diff --git a/app/models/concerns/protected_ref_access.rb b/app/models/concerns/protected_ref_access.rb index 7373f006d64..d1e3d9b2aff 100644 --- a/app/models/concerns/protected_ref_access.rb +++ b/app/models/concerns/protected_ref_access.rb @@ -50,8 +50,8 @@ module ProtectedRefAccess end end -ProtectedRefAccess.include_if_ee('EE::ProtectedRefAccess::Scopes') # rubocop: disable Cop/InjectEnterpriseEditionModule -ProtectedRefAccess.prepend_if_ee('EE::ProtectedRefAccess') # rubocop: disable Cop/InjectEnterpriseEditionModule +ProtectedRefAccess.include_if_ee('EE::ProtectedRefAccess::Scopes') +ProtectedRefAccess.prepend_if_ee('EE::ProtectedRefAccess') # When using `prepend` (or `include` for that matter), the `ClassMethods` # constants are not merged. This means that `class_methods` in diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb index 3270c7c131f..7d7788c7e23 100644 --- a/app/models/merge_request.rb +++ b/app/models/merge_request.rb @@ -875,7 +875,7 @@ class MergeRequest < ApplicationRecord # rubocop: enable CodeReuse/ServiceClass def diffable_merge_ref? - Feature.enabled?(:diff_compare_with_head, target_project) && can_be_merged? && merge_ref_head.present? + can_be_merged? && merge_ref_head.present? end # Returns boolean indicating the merge_status should be rechecked in order to diff --git a/app/models/sent_notification.rb b/app/models/sent_notification.rb index 1bfcad02f38..4165d3b753f 100644 --- a/app/models/sent_notification.rb +++ b/app/models/sent_notification.rb @@ -82,6 +82,8 @@ class SentNotification < ApplicationRecord if new_position.is_a?(Hash) new_position = new_position.with_indifferent_access new_position = Gitlab::Diff::Position.new(new_position) + else + new_position = nil end super(new_position) diff --git a/app/policies/ci/build_policy.rb b/app/policies/ci/build_policy.rb index ebb99270b9a..12892a69257 100644 --- a/app/policies/ci/build_policy.rb +++ b/app/policies/ci/build_policy.rb @@ -12,6 +12,14 @@ module Ci end end + condition(:unprotected_ref) do + if @subject.tag? + !ProtectedTag.protected?(@subject.project, @subject.ref) + else + !ProtectedBranch.protected?(@subject.project, @subject.ref) + end + end + condition(:owner_of_job) do @subject.triggered_by?(@user) end @@ -34,7 +42,7 @@ module Ci prevent :erase_build end - rule { can?(:admin_build) | (can?(:update_build) & owner_of_job) }.enable :erase_build + rule { can?(:admin_build) | (can?(:update_build) & owner_of_job & unprotected_ref) }.enable :erase_build rule { can?(:public_access) & branch_allows_collaboration }.policy do enable :update_build diff --git a/app/serializers/analytics_summary_entity.rb b/app/serializers/analytics_summary_entity.rb index 57e9225e2da..62828fc1428 100644 --- a/app/serializers/analytics_summary_entity.rb +++ b/app/serializers/analytics_summary_entity.rb @@ -8,8 +8,6 @@ class AnalyticsSummaryEntity < Grape::Entity private def value - return object.value if object.value.is_a? String - - object.value&.nonzero? ? object.value.to_s : '-' + object.value.to_s end end diff --git a/app/services/branches/create_service.rb b/app/services/branches/create_service.rb index c8afd97e6bf..958dd5c9965 100644 --- a/app/services/branches/create_service.rb +++ b/app/services/branches/create_service.rb @@ -14,7 +14,7 @@ module Branches if new_branch success(new_branch) else - error("Invalid reference name: #{branch_name}") + error("Invalid reference name: #{ref}") end rescue Gitlab::Git::PreReceiveError => ex error(ex.message) diff --git a/app/workers/all_queues.yml b/app/workers/all_queues.yml index 33a20afc0ba..1f9a53d64d9 100644 --- a/app/workers/all_queues.yml +++ b/app/workers/all_queues.yml @@ -10,6 +10,13 @@ :resource_boundary: :unknown :weight: 1 :idempotent: true +- :name: authorized_project_update:authorized_project_update_user_refresh_with_low_urgency + :feature_category: :authentication_and_authorization + :has_external_dependencies: + :urgency: :low + :resource_boundary: :unknown + :weight: 1 + :idempotent: true - :name: auto_devops:auto_devops_disable :feature_category: :auto_devops :has_external_dependencies: @@ -934,13 +941,6 @@ :resource_boundary: :unknown :weight: 2 :idempotent: true -- :name: authorized_project_update_user_refresh_with_low_urgency - :feature_category: :authentication_and_authorization - :has_external_dependencies: - :urgency: :low - :resource_boundary: :unknown - :weight: 1 - :idempotent: true - :name: authorized_projects :feature_category: :authentication_and_authorization :has_external_dependencies: diff --git a/app/workers/authorized_project_update/user_refresh_with_low_urgency_worker.rb b/app/workers/authorized_project_update/user_refresh_with_low_urgency_worker.rb index 54025422874..19038cb8900 100644 --- a/app/workers/authorized_project_update/user_refresh_with_low_urgency_worker.rb +++ b/app/workers/authorized_project_update/user_refresh_with_low_urgency_worker.rb @@ -4,6 +4,7 @@ module AuthorizedProjectUpdate class UserRefreshWithLowUrgencyWorker < ::AuthorizedProjectsWorker feature_category :authentication_and_authorization urgency :low + queue_namespace :authorized_project_update idempotent! end diff --git a/changelogs/unreleased/35069-protect-builds.yml b/changelogs/unreleased/35069-protect-builds.yml new file mode 100644 index 00000000000..7efe4ab529c --- /dev/null +++ b/changelogs/unreleased/35069-protect-builds.yml @@ -0,0 +1,5 @@ +--- +title: Disallow developers to delete builds of protected branches +merge_request: 28881 +author: Alexander Kutelev +type: changed diff --git a/changelogs/unreleased/ac-backfill-environment_id-on-deployment_merge_requests.yml b/changelogs/unreleased/ac-backfill-environment_id-on-deployment_merge_requests.yml deleted file mode 100644 index ac9ae44b958..00000000000 --- a/changelogs/unreleased/ac-backfill-environment_id-on-deployment_merge_requests.yml +++ /dev/null @@ -1,5 +0,0 @@ ---- -title: backfill environment_id on deployment_merge_requests -merge_request: 27219 -author: -type: other diff --git a/changelogs/unreleased/fix-cop-inject-multiple.yml b/changelogs/unreleased/fix-cop-inject-multiple.yml new file mode 100644 index 00000000000..8fe1556abac --- /dev/null +++ b/changelogs/unreleased/fix-cop-inject-multiple.yml @@ -0,0 +1,5 @@ +--- +title: Allow multiple usage of EE extension/inclusion on last lines +merge_request: 31183 +author: Rajendra Kadam +type: fixed diff --git a/changelogs/unreleased/fix-subgroup-milestone-links.yml b/changelogs/unreleased/fix-subgroup-milestone-links.yml new file mode 100644 index 00000000000..dab14b33f9e --- /dev/null +++ b/changelogs/unreleased/fix-subgroup-milestone-links.yml @@ -0,0 +1,5 @@ +--- +title: Link to subgroup milestones correctly from group milestones page +merge_request: 31383 +author: +type: fixed diff --git a/changelogs/unreleased/id-remove-diff-compare-with-head-feature-flag.yml b/changelogs/unreleased/id-remove-diff-compare-with-head-feature-flag.yml new file mode 100644 index 00000000000..c7222c6583d --- /dev/null +++ b/changelogs/unreleased/id-remove-diff-compare-with-head-feature-flag.yml @@ -0,0 +1,5 @@ +--- +title: Allow showing merge request diffs compared to current version of target branch +merge_request: 31325 +author: +type: added diff --git a/config/initializers/rack_attack_new.rb b/config/initializers/rack_attack_new.rb index 38e581f188f..51b49bec864 100644 --- a/config/initializers/rack_attack_new.rb +++ b/config/initializers/rack_attack_new.rb @@ -160,5 +160,5 @@ class Rack::Attack end end -::Rack::Attack.extend_if_ee('::EE::Gitlab::Rack::Attack') # rubocop: disable Cop/InjectEnterpriseEditionModule +::Rack::Attack.extend_if_ee('::EE::Gitlab::Rack::Attack') ::Rack::Attack::Request.prepend_if_ee('::EE::Gitlab::Rack::Attack::Request') diff --git a/config/sidekiq_queues.yml b/config/sidekiq_queues.yml index 960a2eeae10..e6e0b4b4409 100644 --- a/config/sidekiq_queues.yml +++ b/config/sidekiq_queues.yml @@ -34,8 +34,6 @@ - 2 - - authorized_project_update - 1 -- - authorized_project_update_user_refresh_with_low_urgency - - 1 - - authorized_projects - 2 - - auto_devops diff --git a/db/post_migrate/20200312134637_backfill_environment_id_on_deployment_merge_requests.rb b/db/post_migrate/20200312134637_backfill_environment_id_on_deployment_merge_requests.rb index 24b652a3299..77cb1ae8508 100644 --- a/db/post_migrate/20200312134637_backfill_environment_id_on_deployment_merge_requests.rb +++ b/db/post_migrate/20200312134637_backfill_environment_id_on_deployment_merge_requests.rb @@ -4,46 +4,19 @@ class BackfillEnvironmentIdOnDeploymentMergeRequests < ActiveRecord::Migration[6 include Gitlab::Database::MigrationHelpers DOWNTIME = false - BATCH_SIZE = 400 - DELAY = 1.minute disable_ddl_transaction! def up - max_mr_id = DeploymentMergeRequest - .select(:merge_request_id) - .distinct - .order(merge_request_id: :desc) - .limit(1) - .pluck(:merge_request_id) - .first || 0 - - last_mr_id = 0 - step = 0 - - while last_mr_id < max_mr_id - stop = - DeploymentMergeRequest - .select(:merge_request_id) - .distinct - .where('merge_request_id > ?', last_mr_id) - .order(:merge_request_id) - .offset(BATCH_SIZE) - .limit(1) - .pluck(:merge_request_id) - .first - - stop ||= max_mr_id - - migrate_in( - step * DELAY, - 'BackfillEnvironmentIdDeploymentMergeRequests', - [last_mr_id + 1, stop] - ) + # no-op - last_mr_id = stop - step += 1 - end + # this migration is deleted because there is no foreign key for + # deployments.environment_id and this caused a failure upgrading + # deployments_merge_requests.environment_id + # + # Details on the following issues: + # * https://gitlab.com/gitlab-org/gitlab/-/issues/217191 + # * https://gitlab.com/gitlab-org/gitlab/-/issues/26229 end def down diff --git a/doc/api/labels.md b/doc/api/labels.md index eb8ec906ec1..3ced7da8ed5 100644 --- a/doc/api/labels.md +++ b/doc/api/labels.md @@ -7,6 +7,8 @@ The `description_html` - was added to response JSON in [GitLab 12.7](https://git Get all labels for a given project. +By default, this request returns 20 results at a time because the API results [are paginated](README.md#pagination). + ```plaintext GET /projects/:id/labels ``` diff --git a/doc/ci/caching/index.md b/doc/ci/caching/index.md index 12267b4ab9f..16cabae353e 100644 --- a/doc/ci/caching/index.md +++ b/doc/ci/caching/index.md @@ -39,8 +39,9 @@ runtime dependencies needed to compile the project: - `artifacts`: **Use for stage results that will be passed between stages.** Artifacts are files generated by a job which are stored and uploaded, and can then - be fetched and used by jobs in later stages of the **same pipeline**. This data - will not be available in different pipelines, but is available to be downloaded + be fetched and used by jobs in later stages of the **same pipeline**. In other words, + [you can't create an artifact in job-A in stage-1, and then use this artifact in job-B in stage-1](https://gitlab.com/gitlab-org/gitlab/-/issues/25837). + This data will not be available in different pipelines, but is available to be downloaded from the UI. The name `artifacts` sounds like it's only useful outside of the job, like for downloading diff --git a/doc/user/permissions.md b/doc/user/permissions.md index f23f1858883..e54063e3eed 100644 --- a/doc/user/permissions.md +++ b/doc/user/permissions.md @@ -403,7 +403,9 @@ instance and project. In addition, all admins can use the admin interface under | See events in the system | | | | ✓ | | Admin interface | | | | ✓ | -1. Only if the job was triggered by the user +1. Only if the job was: + - Triggered by the user + - [Since GitLab 13.0](https://gitlab.com/gitlab-org/gitlab/-/issues/35069), not run for a protected branch ### Job permissions diff --git a/doc/user/project/merge_requests/versions.md b/doc/user/project/merge_requests/versions.md index 2f51af24a95..84934148bdc 100644 --- a/doc/user/project/merge_requests/versions.md +++ b/doc/user/project/merge_requests/versions.md @@ -57,7 +57,7 @@ source and target branch can be shown mixed together making it hard to understand which changes are being added and which already exist in the target branch. -In GitLab 12.10, we added an **experimental** comparison mode, which +In GitLab 12.10, we added a comparison mode, which shows a diff calculated by simulating how it would look like once merged - a more accurate representation of the changes rather than using the base of the two branches. The new mode is available from the comparison target drop down @@ -67,26 +67,6 @@ current default comparison. ![Merge request versions compare HEAD](img/versions_compare_head_v12_10.png) -### Enable or disable `HEAD` comparison mode **(CORE ONLY)** - -`HEAD` comparison mode is under development and not ready for production use. It is -deployed behind a feature flag that is **disabled by default**. -[GitLab administrators with access to the GitLab Rails console](../../../administration/troubleshooting/navigating_gitlab_via_rails_console.md#starting-a-rails-console-session) -can enable it for your instance. You're welcome to test it, but use it at your -own risk. - -To enable it: - -```ruby -Feature.enable(:diff_compare_with_head) -``` - -To disable it: - -```ruby -Feature.disable(:diff_compare_with_head) -``` - <!-- ## Troubleshooting Include any troubleshooting steps that you can foresee. If you know beforehand what issues diff --git a/lib/gitlab/background_migration/backfill_environment_id_deployment_merge_requests.rb b/lib/gitlab/background_migration/backfill_environment_id_deployment_merge_requests.rb index 67b5854352b..4fd3b81fda3 100644 --- a/lib/gitlab/background_migration/backfill_environment_id_deployment_merge_requests.rb +++ b/lib/gitlab/background_migration/backfill_environment_id_deployment_merge_requests.rb @@ -5,7 +5,14 @@ module Gitlab # BackfillEnvironmentIdDeploymentMergeRequests deletes duplicates # from deployment_merge_requests table and backfills environment_id class BackfillEnvironmentIdDeploymentMergeRequests - def perform(start_mr_id, stop_mr_id) + def perform(_start_mr_id, _stop_mr_id) + # no-op + + # Background migration removed due to + # https://gitlab.com/gitlab-org/gitlab/-/issues/217191 + end + + def backfill_range(start_mr_id, stop_mr_id) start_mr_id = Integer(start_mr_id) stop_mr_id = Integer(stop_mr_id) diff --git a/lib/gitlab/cycle_analytics/stage_summary.rb b/lib/gitlab/cycle_analytics/stage_summary.rb index 564feb0319f..7559cd376bf 100644 --- a/lib/gitlab/cycle_analytics/stage_summary.rb +++ b/lib/gitlab/cycle_analytics/stage_summary.rb @@ -28,8 +28,7 @@ module Gitlab end def deployments_summary - @deployments_summary ||= - Summary::Deploy.new(project: @project, from: @from, to: @to) + @deployments_summary ||= Summary::Deploy.new(project: @project, from: @from, to: @to) end def deploy_stats @@ -39,7 +38,7 @@ module Gitlab def deployment_frequency_stats serialize( Summary::DeploymentFrequency.new( - deployments: deployments_summary.value, + deployments: deployments_summary.value.raw_value, from: @from, to: @to), with_unit: true diff --git a/lib/gitlab/cycle_analytics/summary/commit.rb b/lib/gitlab/cycle_analytics/summary/commit.rb index 76049c6b742..1f426b81800 100644 --- a/lib/gitlab/cycle_analytics/summary/commit.rb +++ b/lib/gitlab/cycle_analytics/summary/commit.rb @@ -9,7 +9,7 @@ module Gitlab end def value - @value ||= count_commits + @value ||= commits_count ? Value::PrettyNumeric.new(commits_count) : Value::None.new end private @@ -18,10 +18,10 @@ module Gitlab # a limit. Since we need a commit count, we _can't_ enforce a limit, so # the easiest way forward is to replicate the relevant portions of the # `log` function here. - def count_commits + def commits_count return unless ref - gitaly_commit_client.commit_count(ref, after: @from, before: @to) + @commits_count ||= gitaly_commit_client.commit_count(ref, after: @from, before: @to) end def gitaly_commit_client diff --git a/lib/gitlab/cycle_analytics/summary/deploy.rb b/lib/gitlab/cycle_analytics/summary/deploy.rb index 5ff8d881143..8544ea1a91e 100644 --- a/lib/gitlab/cycle_analytics/summary/deploy.rb +++ b/lib/gitlab/cycle_analytics/summary/deploy.rb @@ -4,18 +4,20 @@ module Gitlab module CycleAnalytics module Summary class Deploy < Base - include Gitlab::Utils::StrongMemoize - def title n_('Deploy', 'Deploys', value) end def value - strong_memoize(:value) do - query = @project.deployments.success.where("created_at >= ?", @from) - query = query.where("created_at <= ?", @to) if @to - query.count - end + @value ||= Value::PrettyNumeric.new(deployments_count) + end + + private + + def deployments_count + query = @project.deployments.success.where("created_at >= ?", @from) + query = query.where("created_at <= ?", @to) if @to + query.count end end end diff --git a/lib/gitlab/cycle_analytics/summary/deployment_frequency.rb b/lib/gitlab/cycle_analytics/summary/deployment_frequency.rb index 436dc91bd6b..00676a02a6f 100644 --- a/lib/gitlab/cycle_analytics/summary/deployment_frequency.rb +++ b/lib/gitlab/cycle_analytics/summary/deployment_frequency.rb @@ -17,8 +17,7 @@ module Gitlab end def value - @value ||= - frequency(@deployments, @from, @to || Time.now) + @value ||= frequency(@deployments, @from, @to || Time.now) end def unit diff --git a/lib/gitlab/cycle_analytics/summary/issue.rb b/lib/gitlab/cycle_analytics/summary/issue.rb index 52892eb5a1a..ce7788590b9 100644 --- a/lib/gitlab/cycle_analytics/summary/issue.rb +++ b/lib/gitlab/cycle_analytics/summary/issue.rb @@ -16,7 +16,16 @@ module Gitlab end def value - @value ||= IssuesFinder.new(@current_user, project_id: @project.id, created_after: @from, created_before: @to).execute.count + @value ||= Value::PrettyNumeric.new(issues_count) + end + + private + + def issues_count + IssuesFinder + .new(@current_user, project_id: @project.id, created_after: @from, created_before: @to) + .execute + .count end end end diff --git a/lib/gitlab/cycle_analytics/summary/value.rb b/lib/gitlab/cycle_analytics/summary/value.rb new file mode 100644 index 00000000000..9516f9ec974 --- /dev/null +++ b/lib/gitlab/cycle_analytics/summary/value.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +module Gitlab + module CycleAnalytics + module Summary + class Value + attr_reader :value + + def raw_value + value + end + + def to_s + raise NotImplementedError + end + + class None < self + def to_s + '-' + end + end + + class Numeric < self + def initialize(value) + @value = value + end + + def to_s + value.zero? ? '0' : value.to_s + end + end + + class PrettyNumeric < Numeric + def to_s + # 0 is shown as - + value.nonzero? ? super : None.new.to_s + end + end + end + end + end +end diff --git a/lib/gitlab/cycle_analytics/summary_helper.rb b/lib/gitlab/cycle_analytics/summary_helper.rb index 06abcd151d4..3cf9f463024 100644 --- a/lib/gitlab/cycle_analytics/summary_helper.rb +++ b/lib/gitlab/cycle_analytics/summary_helper.rb @@ -4,10 +4,11 @@ module Gitlab module CycleAnalytics module SummaryHelper def frequency(count, from, to) - return count if count.zero? + return Summary::Value::None.new if count.zero? freq = (count / days(from, to)).round(1) - freq.zero? ? '0' : freq + + Summary::Value::Numeric.new(freq) end def days(from, to) diff --git a/lib/gitlab/url_builder.rb b/lib/gitlab/url_builder.rb index a2ebe9f6936..329f87d8be8 100644 --- a/lib/gitlab/url_builder.rb +++ b/lib/gitlab/url_builder.rb @@ -11,6 +11,9 @@ module Gitlab class << self include ActionView::RecordIdentifier + # Using a case statement here is preferable for readability and maintainability. + # See discussion in https://gitlab.com/gitlab-org/gitlab/-/issues/217397 + # # rubocop:disable Metrics/CyclomaticComplexity def build(object, **options) # Objects are sometimes wrapped in a BatchLoader instance diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 50a78658c28..ccd972a7037 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -3857,15 +3857,6 @@ msgstr "" msgid "Checkout|(x%{numberOfUsers})" msgstr "" -msgid "Checkout|1. Your profile" -msgstr "" - -msgid "Checkout|2. Checkout" -msgstr "" - -msgid "Checkout|3. Your GitLab group" -msgstr "" - msgid "Checkout|Billing address" msgstr "" @@ -6513,6 +6504,9 @@ msgstr "" msgid "Customize your pipeline configuration." msgstr "" +msgid "Cycle Time" +msgstr "" + msgid "CycleAnalyticsEvent|Issue closed" msgstr "" @@ -17256,6 +17250,18 @@ msgstr "" msgid "Registration" msgstr "" +msgid "Registration|Checkout" +msgstr "" + +msgid "Registration|Your GitLab group" +msgstr "" + +msgid "Registration|Your first project" +msgstr "" + +msgid "Registration|Your profile" +msgstr "" + msgid "Related Deployed Jobs" msgstr "" diff --git a/rubocop/cop/inject_enterprise_edition_module.rb b/rubocop/cop/inject_enterprise_edition_module.rb index 6f007e667f2..7edce94eaee 100644 --- a/rubocop/cop/inject_enterprise_edition_module.rb +++ b/rubocop/cop/inject_enterprise_edition_module.rb @@ -17,6 +17,8 @@ module RuboCop CHECK_LINE_METHODS = Set.new(%i[include_if_ee extend_if_ee prepend_if_ee]).freeze + CHECK_LINE_METHODS_REGEXP = Regexp.union(CHECK_LINE_METHODS.map(&:to_s)).freeze + DISALLOW_METHODS = Set.new(%i[include extend prepend]).freeze def ee_const?(node) @@ -48,7 +50,13 @@ module RuboCop # the expression is the last line _of code_. last_line -= 1 if buffer.source.end_with?("\n") - add_offense(node, message: INVALID_LINE) if line < last_line + last_line_content = buffer.source.split("\n")[-1] + + if CHECK_LINE_METHODS_REGEXP.match?(last_line_content) + ignore_node(node) + elsif line < last_line + add_offense(node, message: INVALID_LINE) + end end def verify_argument_type(node) diff --git a/spec/frontend/diffs/store/getters_versions_dropdowns_spec.js b/spec/frontend/diffs/store/getters_versions_dropdowns_spec.js index eb0f2364a50..0343ef75732 100644 --- a/spec/frontend/diffs/store/getters_versions_dropdowns_spec.js +++ b/spec/frontend/diffs/store/getters_versions_dropdowns_spec.js @@ -18,7 +18,6 @@ describe('Compare diff version dropdowns', () => { }; localState.targetBranchName = 'baseVersion'; localState.mergeRequestDiffs = diffsMockData; - gon.features = { diffCompareWithHead: true }; }); describe('selectedTargetIndex', () => { @@ -129,14 +128,6 @@ describe('Compare diff version dropdowns', () => { }); assertVersions(targetVersions); }); - - it('does not list head version if feature flag is not enabled', () => { - gon.features = { diffCompareWithHead: false }; - setupTest(); - const targetVersions = getters.diffCompareDropdownTargetVersions(localState, getters); - - expect(targetVersions.find(version => version.isHead)).toBeUndefined(); - }); }); it('diffCompareDropdownSourceVersions', () => { diff --git a/spec/frontend/monitoring/components/__snapshots__/dashboard_template_spec.js.snap b/spec/frontend/monitoring/components/__snapshots__/dashboard_template_spec.js.snap index 6cb7821b341..5770778d8ee 100644 --- a/spec/frontend/monitoring/components/__snapshots__/dashboard_template_spec.js.snap +++ b/spec/frontend/monitoring/components/__snapshots__/dashboard_template_spec.js.snap @@ -16,7 +16,6 @@ exports[`Dashboard template matches the default snapshot 1`] = ` data-qa-selector="dashboards_filter_dropdown" defaultbranch="master" id="monitor-dashboards-dropdown" - selecteddashboard="[object Object]" toggle-class="dropdown-menu-toggle" /> </div> diff --git a/spec/frontend/monitoring/components/dashboards_dropdown_spec.js b/spec/frontend/monitoring/components/dashboards_dropdown_spec.js index 25b31a793f7..b29d86cbc5b 100644 --- a/spec/frontend/monitoring/components/dashboards_dropdown_spec.js +++ b/spec/frontend/monitoring/components/dashboards_dropdown_spec.js @@ -15,6 +15,7 @@ const notStarredDashboards = dashboardGitResponse.filter(({ starred }) => !starr describe('DashboardsDropdown', () => { let wrapper; let mockDashboards; + let mockSelectedDashboard; function createComponent(props, opts = {}) { const storeOpts = { @@ -23,6 +24,7 @@ describe('DashboardsDropdown', () => { }, computed: { allDashboards: () => mockDashboards, + selectedDashboard: () => mockSelectedDashboard, }, }; @@ -46,6 +48,7 @@ describe('DashboardsDropdown', () => { beforeEach(() => { mockDashboards = dashboardGitResponse; + mockSelectedDashboard = null; }); describe('when it receives dashboards data', () => { @@ -153,13 +156,12 @@ describe('DashboardsDropdown', () => { let modalDirective; beforeEach(() => { + [mockSelectedDashboard] = dashboardGitResponse; modalDirective = jest.fn(); duplicateDashboardAction = jest.fn().mockResolvedValue(); wrapper = createComponent( - { - selectedDashboard: dashboardGitResponse[0], - }, + {}, { directives: { GlModal: modalDirective, diff --git a/spec/frontend/monitoring/mock_data.js b/spec/frontend/monitoring/mock_data.js index dab560d197d..2fa88dfa87a 100644 --- a/spec/frontend/monitoring/mock_data.js +++ b/spec/frontend/monitoring/mock_data.js @@ -325,6 +325,7 @@ export const dashboardGitResponse = [ project_blob_path: null, path: 'config/prometheus/common_metrics.yml', starred: false, + user_starred_path: `${mockProjectDir}/metrics_user_starred_dashboards?dashboard_path=config/prometheus/common_metrics.yml`, }, { default: false, @@ -334,6 +335,7 @@ export const dashboardGitResponse = [ project_blob_path: `${mockProjectDir}/-/blob/master/.gitlab/dashboards/dashboard.yml`, path: '.gitlab/dashboards/dashboard.yml', starred: true, + user_starred_path: `${mockProjectDir}/metrics_user_starred_dashboards?dashboard_path=.gitlab/dashboards/dashboard.yml`, }, ...customDashboardsData, ]; diff --git a/spec/frontend/monitoring/store/actions_spec.js b/spec/frontend/monitoring/store/actions_spec.js index 901b698b703..44626bfcc57 100644 --- a/spec/frontend/monitoring/store/actions_spec.js +++ b/spec/frontend/monitoring/store/actions_spec.js @@ -18,6 +18,7 @@ import { fetchEnvironmentsData, fetchDashboardData, fetchAnnotations, + toggleStarredValue, fetchPrometheusMetric, setInitialState, filterEnvironments, @@ -350,6 +351,49 @@ describe('Monitoring store actions', () => { }); }); + describe('Toggles starred value of current dashboard', () => { + const { state } = store; + let unstarredDashboard; + let starredDashboard; + + beforeEach(() => { + state.isUpdatingStarredValue = false; + [unstarredDashboard, starredDashboard] = dashboardGitResponse; + }); + + describe('toggleStarredValue', () => { + it('performs no changes if no dashboard is selected', () => { + return testAction(toggleStarredValue, null, state, [], []); + }); + + it('performs no changes if already changing starred value', () => { + state.selectedDashboard = unstarredDashboard; + state.isUpdatingStarredValue = true; + return testAction(toggleStarredValue, null, state, [], []); + }); + + it('stars dashboard if it is not starred', () => { + state.selectedDashboard = unstarredDashboard; + mock.onPost(unstarredDashboard.user_starred_path).reply(200); + + return testAction(toggleStarredValue, null, state, [ + { type: types.REQUEST_DASHBOARD_STARRING }, + { type: types.RECEIVE_DASHBOARD_STARRING_SUCCESS, payload: true }, + ]); + }); + + it('unstars dashboard if it is starred', () => { + state.selectedDashboard = starredDashboard; + mock.onPost(starredDashboard.user_starred_path).reply(200); + + return testAction(toggleStarredValue, null, state, [ + { type: types.REQUEST_DASHBOARD_STARRING }, + { type: types.RECEIVE_DASHBOARD_STARRING_FAILURE }, + ]); + }); + }); + }); + describe('Set initial state', () => { let mockedState; beforeEach(() => { diff --git a/spec/frontend/monitoring/store/getters_spec.js b/spec/frontend/monitoring/store/getters_spec.js index e9622071aeb..f07ae4c5a1e 100644 --- a/spec/frontend/monitoring/store/getters_spec.js +++ b/spec/frontend/monitoring/store/getters_spec.js @@ -3,7 +3,7 @@ import * as getters from '~/monitoring/stores/getters'; import mutations from '~/monitoring/stores/mutations'; import * as types from '~/monitoring/stores/mutation_types'; import { metricStates } from '~/monitoring/constants'; -import { environmentData, metricsResult } from '../mock_data'; +import { environmentData, metricsResult, dashboardGitResponse } from '../mock_data'; import { metricsDashboardPayload, metricResultStatus, @@ -350,4 +350,48 @@ describe('Monitoring store Getters', () => { expect(variablesArray).toEqual([]); }); }); + + describe('selectedDashboard', () => { + const { selectedDashboard } = getters; + + it('returns a dashboard', () => { + const state = { + allDashboards: dashboardGitResponse, + currentDashboard: dashboardGitResponse[0].path, + }; + expect(selectedDashboard(state)).toEqual(dashboardGitResponse[0]); + }); + + it('returns a non-default dashboard', () => { + const state = { + allDashboards: dashboardGitResponse, + currentDashboard: dashboardGitResponse[1].path, + }; + expect(selectedDashboard(state)).toEqual(dashboardGitResponse[1]); + }); + + it('returns a default dashboard when no dashboard is selected', () => { + const state = { + allDashboards: dashboardGitResponse, + currentDashboard: null, + }; + expect(selectedDashboard(state)).toEqual(dashboardGitResponse[0]); + }); + + it('returns a default dashboard when dashboard cannot be found', () => { + const state = { + allDashboards: dashboardGitResponse, + currentDashboard: 'wrong_path', + }; + expect(selectedDashboard(state)).toEqual(dashboardGitResponse[0]); + }); + + it('returns null when no dashboards are present', () => { + const state = { + allDashboards: [], + currentDashboard: dashboardGitResponse[0].path, + }; + expect(selectedDashboard(state)).toEqual(null); + }); + }); }); diff --git a/spec/frontend/monitoring/store/mutations_spec.js b/spec/frontend/monitoring/store/mutations_spec.js index e6564f5e329..29628c99256 100644 --- a/spec/frontend/monitoring/store/mutations_spec.js +++ b/spec/frontend/monitoring/store/mutations_spec.js @@ -72,6 +72,49 @@ describe('Monitoring mutations', () => { }); }); + describe('Dashboard starring mutations', () => { + it('REQUEST_DASHBOARD_STARRING', () => { + stateCopy = { isUpdatingStarredValue: false }; + mutations[types.REQUEST_DASHBOARD_STARRING](stateCopy); + + expect(stateCopy.isUpdatingStarredValue).toBe(true); + }); + + describe('RECEIVE_DASHBOARD_STARRING_SUCCESS', () => { + let allDashboards; + + beforeEach(() => { + allDashboards = [...dashboardGitResponse]; + stateCopy = { + allDashboards, + currentDashboard: allDashboards[1].path, + isUpdatingStarredValue: true, + }; + }); + + it('sets a dashboard as starred', () => { + mutations[types.RECEIVE_DASHBOARD_STARRING_SUCCESS](stateCopy, true); + + expect(stateCopy.isUpdatingStarredValue).toBe(false); + expect(stateCopy.allDashboards[1].starred).toBe(true); + }); + + it('sets a dashboard as unstarred', () => { + mutations[types.RECEIVE_DASHBOARD_STARRING_SUCCESS](stateCopy, false); + + expect(stateCopy.isUpdatingStarredValue).toBe(false); + expect(stateCopy.allDashboards[1].starred).toBe(false); + }); + }); + + it('RECEIVE_DASHBOARD_STARRING_FAILURE', () => { + stateCopy = { isUpdatingStarredValue: true }; + mutations[types.RECEIVE_DASHBOARD_STARRING_FAILURE](stateCopy); + + expect(stateCopy.isUpdatingStarredValue).toBe(false); + }); + }); + describe('RECEIVE_DEPLOYMENTS_DATA_SUCCESS', () => { it('stores the deployment data', () => { stateCopy.deploymentData = []; diff --git a/spec/frontend/static_site_editor/components/edit_area_spec.js b/spec/frontend/static_site_editor/components/edit_area_spec.js new file mode 100644 index 00000000000..19330039812 --- /dev/null +++ b/spec/frontend/static_site_editor/components/edit_area_spec.js @@ -0,0 +1,79 @@ +import { shallowMount } from '@vue/test-utils'; + +import RichContentEditor from '~/vue_shared/components/rich_content_editor/rich_content_editor.vue'; + +import EditArea from '~/static_site_editor/components/edit_area.vue'; +import PublishToolbar from '~/static_site_editor/components/publish_toolbar.vue'; +import EditHeader from '~/static_site_editor/components/edit_header.vue'; + +import { sourceContentTitle as title, sourceContent as content, returnUrl } from '../mock_data'; + +describe('~/static_site_editor/components/edit_area.vue', () => { + let wrapper; + const savingChanges = true; + const newContent = `new ${content}`; + + const buildWrapper = (propsData = {}) => { + wrapper = shallowMount(EditArea, { + provide: { + glFeatures: { richContentEditor: true }, + }, + propsData: { + title, + content, + returnUrl, + savingChanges, + ...propsData, + }, + }); + }; + + const findEditHeader = () => wrapper.find(EditHeader); + const findRichContentEditor = () => wrapper.find(RichContentEditor); + const findPublishToolbar = () => wrapper.find(PublishToolbar); + + beforeEach(() => { + buildWrapper(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders edit header', () => { + expect(findEditHeader().exists()).toBe(true); + expect(findEditHeader().props('title')).toBe(title); + }); + + it('renders rich content editor', () => { + expect(findRichContentEditor().exists()).toBe(true); + expect(findRichContentEditor().props('value')).toBe(content); + }); + + it('renders publish toolbar', () => { + expect(findPublishToolbar().exists()).toBe(true); + expect(findPublishToolbar().props('returnUrl')).toBe(returnUrl); + expect(findPublishToolbar().props('savingChanges')).toBe(savingChanges); + expect(findPublishToolbar().props('saveable')).toBe(false); + }); + + describe('when content changes', () => { + beforeEach(() => { + findRichContentEditor().vm.$emit('input', newContent); + + return wrapper.vm.$nextTick(); + }); + + it('sets publish toolbar as saveable when content changes', () => { + expect(findPublishToolbar().props('saveable')).toBe(true); + }); + + it('sets publish toolbar as not saveable when content changes are rollback', () => { + findRichContentEditor().vm.$emit('input', content); + + return wrapper.vm.$nextTick().then(() => { + expect(findPublishToolbar().props('saveable')).toBe(false); + }); + }); + }); +}); diff --git a/spec/frontend/static_site_editor/pages/home_spec.js b/spec/frontend/static_site_editor/pages/home_spec.js index 69646abade5..281b764a801 100644 --- a/spec/frontend/static_site_editor/pages/home_spec.js +++ b/spec/frontend/static_site_editor/pages/home_spec.js @@ -1,21 +1,20 @@ import Vuex from 'vuex'; import { shallowMount, createLocalVue } from '@vue/test-utils'; -import { GlSkeletonLoader } from '@gitlab/ui'; import createState from '~/static_site_editor/store/state'; import Home from '~/static_site_editor/pages/home.vue'; -import RichContentEditor from '~/vue_shared/components/rich_content_editor/rich_content_editor.vue'; -import EditHeader from '~/static_site_editor/components/edit_header.vue'; + +import SkeletonLoader from '~/static_site_editor/components/skeleton_loader.vue'; +import EditArea from '~/static_site_editor/components/edit_area.vue'; import InvalidContentMessage from '~/static_site_editor/components/invalid_content_message.vue'; -import PublishToolbar from '~/static_site_editor/components/publish_toolbar.vue'; import SubmitChangesError from '~/static_site_editor/components/submit_changes_error.vue'; import SavedChangesMessage from '~/static_site_editor/components/saved_changes_message.vue'; import { returnUrl, - sourceContent, - sourceContentTitle, + sourceContent as content, + sourceContentTitle as title, savedContentMeta, submitChangesError, } from '../mock_data'; @@ -27,13 +26,12 @@ localVue.use(Vuex); describe('static_site_editor/pages/home', () => { let wrapper; let store; - let loadContentActionMock; + let $apollo; let setContentActionMock; let submitChangesActionMock; let dismissSubmitChangesErrorActionMock; const buildStore = ({ initialState, getters } = {}) => { - loadContentActionMock = jest.fn(); setContentActionMock = jest.fn(); submitChangesActionMock = jest.fn(); dismissSubmitChangesErrorActionMock = jest.fn(); @@ -47,53 +45,55 @@ describe('static_site_editor/pages/home', () => { ...getters, }, actions: { - loadContent: loadContentActionMock, setContent: setContentActionMock, submitChanges: submitChangesActionMock, dismissSubmitChangesError: dismissSubmitChangesErrorActionMock, }, }); }; - const buildContentLoadedStore = ({ initialState, getters } = {}) => { - buildStore({ - initialState: { - isContentLoaded: true, - ...initialState, - }, - getters: { - ...getters, + + const buildApollo = (queries = {}) => { + $apollo = { + queries: { + sourceContent: { + loading: false, + }, + ...queries, }, - }); + }; }; - const buildWrapper = (data = { appData: { isSupportedContent: true } }) => { + const buildWrapper = (data = {}) => { wrapper = shallowMount(Home, { localVue, store, - provide: { - glFeatures: { richContentEditor: true }, + mocks: { + $apollo, }, data() { - return data; + return { + appData: { isSupportedContent: true, returnUrl }, + ...data, + }; }, }); }; - const findRichContentEditor = () => wrapper.find(RichContentEditor); - const findEditHeader = () => wrapper.find(EditHeader); + const findEditArea = () => wrapper.find(EditArea); const findInvalidContentMessage = () => wrapper.find(InvalidContentMessage); - const findPublishToolbar = () => wrapper.find(PublishToolbar); - const findSkeletonLoader = () => wrapper.find(GlSkeletonLoader); + const findSkeletonLoader = () => wrapper.find(SkeletonLoader); const findSubmitChangesError = () => wrapper.find(SubmitChangesError); const findSavedChangesMessage = () => wrapper.find(SavedChangesMessage); beforeEach(() => { + buildApollo(); buildStore(); - buildWrapper(); }); afterEach(() => { wrapper.destroy(); + wrapper = null; + $apollo = null; }); it('renders the saved changes message when changes are submitted successfully', () => { @@ -107,103 +107,69 @@ describe('static_site_editor/pages/home', () => { }); }); - describe('when content is not loaded', () => { - it('does not render rich content editor', () => { - expect(findRichContentEditor().exists()).toBe(false); - }); - - it('does not render edit header', () => { - expect(findEditHeader().exists()).toBe(false); - }); - - it('does not render toolbar', () => { - expect(findPublishToolbar().exists()).toBe(false); - }); + it('does not render the saved changes message when changes are not submitted', () => { + buildWrapper(); - it('does not render saved changes message', () => { - expect(findSavedChangesMessage().exists()).toBe(false); - }); + expect(findSavedChangesMessage().exists()).toBe(false); }); describe('when content is loaded', () => { - const content = sourceContent; - const title = sourceContentTitle; - beforeEach(() => { - buildContentLoadedStore({ initialState: { content, title } }); - buildWrapper(); + buildStore({ initialState: { isSavingChanges: true } }); + buildWrapper({ sourceContent: { title, content } }); }); - it('renders the rich content editor', () => { - expect(findRichContentEditor().exists()).toBe(true); + it('renders edit area', () => { + expect(findEditArea().exists()).toBe(true); }); - it('renders the edit header', () => { - expect(findEditHeader().exists()).toBe(true); + it('provides source content to the edit area', () => { + expect(findEditArea().props()).toMatchObject({ + title, + content, + }); }); - it('does not render skeleton loader', () => { - expect(findSkeletonLoader().exists()).toBe(false); + it('provides returnUrl to the edit area', () => { + expect(findEditArea().props('returnUrl')).toBe(returnUrl); }); - it('passes page content to the rich content editor', () => { - expect(findRichContentEditor().props('value')).toBe(content); + it('provides isSavingChanges to the edit area', () => { + expect(findEditArea().props('savingChanges')).toBe(true); }); + }); - it('passes page title to edit header', () => { - expect(findEditHeader().props('title')).toBe(title); - }); + it('does not render edit area when content is not loaded', () => { + buildWrapper({ sourceContent: null }); - it('renders toolbar', () => { - expect(findPublishToolbar().exists()).toBe(true); - }); + expect(findEditArea().exists()).toBe(false); }); - it('sets toolbar as saveable when content changes', () => { - buildContentLoadedStore({ - getters: { - contentChanged: () => true, + it('renders skeleton loader when content is not loading', () => { + buildApollo({ + sourceContent: { + loading: true, }, }); buildWrapper(); - expect(findPublishToolbar().props('saveable')).toBe(true); - }); - - it('displays skeleton loader when loading content', () => { - buildStore({ initialState: { isLoadingContent: true } }); - buildWrapper(); - expect(findSkeletonLoader().exists()).toBe(true); }); - it('does not display submit changes error when an error does not exist', () => { - buildContentLoadedStore(); - buildWrapper(); - - expect(findSubmitChangesError().exists()).toBe(false); - }); - - it('sets toolbar as saving when saving changes', () => { - buildContentLoadedStore({ - initialState: { - isSavingChanges: true, + it('does not render skeleton loader when content is not loading', () => { + buildApollo({ + sourceContent: { + loading: false, }, }); buildWrapper(); - expect(findPublishToolbar().props('savingChanges')).toBe(true); - }); - - it('displays invalid content message when content is not supported', () => { - buildWrapper({ appData: { isSupportedContent: false } }); - - expect(findInvalidContentMessage().exists()).toBe(true); + expect(findSkeletonLoader().exists()).toBe(false); }); describe('when submitting changes fail', () => { beforeEach(() => { - buildContentLoadedStore({ + buildStore({ initialState: { submitChangesError, }, @@ -228,24 +194,32 @@ describe('static_site_editor/pages/home', () => { }); }); - it('dispatches load content action', () => { - expect(loadContentActionMock).toHaveBeenCalled(); - }); - - it('dispatches setContent action when rich content editor emits input event', () => { - buildContentLoadedStore(); + it('does not display submit changes error when an error does not exist', () => { buildWrapper(); - findRichContentEditor().vm.$emit('input', sourceContent); + expect(findSubmitChangesError().exists()).toBe(false); + }); - expect(setContentActionMock).toHaveBeenCalledWith(expect.anything(), sourceContent, undefined); + it('displays invalid content message when content is not supported', () => { + buildWrapper({ appData: { isSupportedContent: false } }); + + expect(findInvalidContentMessage().exists()).toBe(true); }); - it('dispatches submitChanges action when toolbar emits submit event', () => { - buildContentLoadedStore(); - buildWrapper(); - findPublishToolbar().vm.$emit('submit'); + describe('when edit area emits submit event', () => { + const newContent = `new ${content}`; - expect(submitChangesActionMock).toHaveBeenCalled(); + beforeEach(() => { + buildWrapper({ sourceContent: { title, content } }); + findEditArea().vm.$emit('submit', { content: newContent }); + }); + + it('dispatches setContent property', () => { + expect(setContentActionMock).toHaveBeenCalledWith(expect.anything(), newContent, undefined); + }); + + it('dispatches submitChanges action', () => { + expect(submitChangesActionMock).toHaveBeenCalled(); + }); }); }); diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view_spec.js index 951ee3c9b3d..74c769f86a3 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view_spec.js +++ b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view_spec.js @@ -1,9 +1,10 @@ import Vuex from 'vuex'; import { shallowMount, createLocalVue } from '@vue/test-utils'; -import { GlButton, GlLoadingIcon, GlIcon, GlSearchBoxByType, GlLink } from '@gitlab/ui'; +import { GlButton, GlLoadingIcon, GlSearchBoxByType, GlLink } from '@gitlab/ui'; import { UP_KEY_CODE, DOWN_KEY_CODE, ENTER_KEY_CODE, ESC_KEY_CODE } from '~/lib/utils/keycodes'; import DropdownContentsLabelsView from '~/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view.vue'; +import LabelItem from '~/vue_shared/components/sidebar/labels_select_vue/label_item.vue'; import defaultState from '~/vue_shared/components/sidebar/labels_select_vue/store/state'; import mutations from '~/vue_shared/components/sidebar/labels_select_vue/store/mutations'; @@ -78,16 +79,6 @@ describe('DropdownContentsLabelsView', () => { }); describe('methods', () => { - describe('getDropdownLabelBoxStyle', () => { - it('returns an object containing `backgroundColor` based on provided `label` param', () => { - expect(wrapper.vm.getDropdownLabelBoxStyle(mockRegularLabel)).toEqual( - expect.objectContaining({ - backgroundColor: mockRegularLabel.color, - }), - ); - }); - }); - describe('isLabelSelected', () => { it('returns true when provided `label` param is one of the selected labels', () => { expect(wrapper.vm.isLabelSelected(mockRegularLabel)).toBe(true); @@ -234,16 +225,7 @@ describe('DropdownContentsLabelsView', () => { }); it('renders label elements for all labels', () => { - const labelsEl = wrapper.findAll('.dropdown-content li'); - const labelItemEl = labelsEl.at(0).find(GlLink); - - expect(labelsEl.length).toBe(mockLabels.length); - expect(labelItemEl.exists()).toBe(true); - expect(labelItemEl.find(GlIcon).props('name')).toBe('mobile-issue-close'); - expect(labelItemEl.find('.dropdown-label-box').attributes('style')).toBe( - 'background-color: rgb(186, 218, 85);', - ); - expect(labelItemEl.find(GlLink).text()).toContain(mockLabels[0].title); + expect(wrapper.findAll(LabelItem)).toHaveLength(mockLabels.length); }); it('renders label element with "is-focused" when value of `currentHighlightItem` is more than -1', () => { @@ -253,9 +235,9 @@ describe('DropdownContentsLabelsView', () => { return wrapper.vm.$nextTick(() => { const labelsEl = wrapper.findAll('.dropdown-content li'); - const labelItemEl = labelsEl.at(0).find(GlLink); + const labelItemEl = labelsEl.at(0).find(LabelItem); - expect(labelItemEl.attributes('class')).toContain('is-focused'); + expect(labelItemEl.props('highlight')).toBe(true); }); }); @@ -267,7 +249,7 @@ describe('DropdownContentsLabelsView', () => { return wrapper.vm.$nextTick(() => { const noMatchEl = wrapper.find('.dropdown-content li'); - expect(noMatchEl.exists()).toBe(true); + expect(noMatchEl.isVisible()).toBe(true); expect(noMatchEl.text()).toContain('No matching results'); }); }); diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/label_item_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/label_item_spec.js new file mode 100644 index 00000000000..401d208da5c --- /dev/null +++ b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/label_item_spec.js @@ -0,0 +1,111 @@ +import { shallowMount } from '@vue/test-utils'; + +import { GlIcon, GlLink } from '@gitlab/ui'; +import LabelItem from '~/vue_shared/components/sidebar/labels_select_vue/label_item.vue'; +import { mockRegularLabel } from './mock_data'; + +const createComponent = ({ label = mockRegularLabel, highlight = true } = {}) => + shallowMount(LabelItem, { + propsData: { + label, + highlight, + }, + }); + +describe('LabelItem', () => { + let wrapper; + + beforeEach(() => { + wrapper = createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('computed', () => { + describe('labelBoxStyle', () => { + it('returns an object containing `backgroundColor` based on `label` prop', () => { + expect(wrapper.vm.labelBoxStyle).toEqual( + expect.objectContaining({ + backgroundColor: mockRegularLabel.color, + }), + ); + }); + }); + }); + + describe('methods', () => { + describe('handleClick', () => { + it('sets value of `isSet` data prop to opposite of its current value', () => { + wrapper.setData({ + isSet: true, + }); + + wrapper.vm.handleClick(); + expect(wrapper.vm.isSet).toBe(false); + wrapper.vm.handleClick(); + expect(wrapper.vm.isSet).toBe(true); + }); + + it('emits event `clickLabel` on component with `label` prop as param', () => { + wrapper.vm.handleClick(); + + expect(wrapper.emitted('clickLabel')).toBeTruthy(); + expect(wrapper.emitted('clickLabel')[0]).toEqual([mockRegularLabel]); + }); + }); + }); + + describe('template', () => { + it('renders gl-link component', () => { + expect(wrapper.find(GlLink).exists()).toBe(true); + }); + + it('renders gl-link component with class `is-focused` when `highlight` prop is true', () => { + wrapper.setProps({ + highlight: true, + }); + + return wrapper.vm.$nextTick(() => { + expect(wrapper.find(GlLink).classes()).toContain('is-focused'); + }); + }); + + it('renders visible gl-icon component when `isSet` prop is true', () => { + wrapper.setData({ + isSet: true, + }); + + return wrapper.vm.$nextTick(() => { + const iconEl = wrapper.find(GlIcon); + + expect(iconEl.isVisible()).toBe(true); + expect(iconEl.props('name')).toBe('mobile-issue-close'); + }); + }); + + it('renders visible span element as placeholder instead of gl-icon when `isSet` prop is false', () => { + wrapper.setData({ + isSet: false, + }); + + return wrapper.vm.$nextTick(() => { + const placeholderEl = wrapper.find('[data-testid="no-icon"]'); + + expect(placeholderEl.isVisible()).toBe(true); + }); + }); + + it('renders label color element', () => { + const colorEl = wrapper.find('[data-testid="label-color-box"]'); + + expect(colorEl.exists()).toBe(true); + expect(colorEl.attributes('style')).toBe('background-color: rgb(186, 218, 85);'); + }); + + it('renders label title', () => { + expect(wrapper.text()).toContain(mockRegularLabel.title); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/store/mutations_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/store/mutations_spec.js index 3031df3362f..8081806e314 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/store/mutations_spec.js +++ b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/store/mutations_spec.js @@ -155,29 +155,12 @@ describe('LabelsSelect Mutations', () => { describe(`${types.UPDATE_SELECTED_LABELS}`, () => { const labels = [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }]; - it('updates `state.labels` to include `touched` and `set` props based on provided `labels` param when `state.allowMultiselect` is `true`', () => { - const updatedLabelIds = [2, 4]; - const state = { - labels, - allowMultiselect: true, - }; - mutations[types.UPDATE_SELECTED_LABELS](state, { labels }); - - state.labels.forEach(label => { - if (updatedLabelIds.includes(label.id)) { - expect(label.touched).toBe(true); - expect(label.set).toBe(true); - } - }); - }); - - it('updates `state.labels` to include `touched` and `set` props based on provided `labels` param when `state.allowMultiselect` is `false`', () => { + it('updates `state.labels` to include `touched` and `set` props based on provided `labels` param', () => { const updatedLabelIds = [2]; const state = { labels, - allowMultiselect: false, }; - mutations[types.UPDATE_SELECTED_LABELS](state, { labels }); + mutations[types.UPDATE_SELECTED_LABELS](state, { labels: [{ id: 2 }] }); state.labels.forEach(label => { if (updatedLabelIds.includes(label.id)) { diff --git a/spec/helpers/milestones_helper_spec.rb b/spec/helpers/milestones_helper_spec.rb index 3574066e03e..4ce7143bdf0 100644 --- a/spec/helpers/milestones_helper_spec.rb +++ b/spec/helpers/milestones_helper_spec.rb @@ -85,4 +85,19 @@ describe MilestonesHelper do end end end + + describe "#group_milestone_route" do + let(:group) { build_stubbed(:group) } + let(:subgroup) { build_stubbed(:group, parent: group, name: "Test Subgrp") } + + context "when in subgroup" do + let(:milestone) { build_stubbed(:group_milestone, group: subgroup) } + + it 'generates correct url despite assigned @group' do + assign(:group, group) + milestone_path = "/groups/#{subgroup.full_path}/-/milestones/#{milestone.iid}" + expect(helper.group_milestone_route(milestone)).to eq(milestone_path) + end + end + end end diff --git a/spec/lib/gitlab/background_migration/backfill_environment_id_deployment_merge_requests_spec.rb b/spec/lib/gitlab/background_migration/backfill_environment_id_deployment_merge_requests_spec.rb index c3bb975727b..34ac70071bb 100644 --- a/spec/lib/gitlab/background_migration/backfill_environment_id_deployment_merge_requests_spec.rb +++ b/spec/lib/gitlab/background_migration/backfill_environment_id_deployment_merge_requests_spec.rb @@ -32,7 +32,7 @@ describe Gitlab::BackgroundMigration::BackfillEnvironmentIdDeploymentMergeReques expect(deployment_merge_requests.where(environment_id: nil).count).to eq(3) - migration.perform(1, mr.id) + migration.backfill_range(1, mr.id) expect(deployment_merge_requests.where(environment_id: nil).count).to be_zero expect(deployment_merge_requests.count).to eq(2) diff --git a/spec/lib/gitlab/cycle_analytics/summary/value_spec.rb b/spec/lib/gitlab/cycle_analytics/summary/value_spec.rb new file mode 100644 index 00000000000..21ee42295cd --- /dev/null +++ b/spec/lib/gitlab/cycle_analytics/summary/value_spec.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Gitlab::CycleAnalytics::Summary::Value do + describe Gitlab::CycleAnalytics::Summary::Value::None do + it 'returns `-`' do + expect(described_class.new.to_s).to eq('-') + end + end + + describe Gitlab::CycleAnalytics::Summary::Value::Numeric do + it 'returns the string representation of the number' do + expect(described_class.new(3.2).to_s).to eq('3.2') + end + end + + describe Gitlab::CycleAnalytics::Summary::Value::PrettyNumeric do + describe '#to_s' do + it 'returns `-` when the number is 0' do + expect(described_class.new(0).to_s).to eq('-') + end + + it 'returns the string representation of the number' do + expect(described_class.new(100).to_s).to eq('100') + end + end + end +end diff --git a/spec/migrations/backfill_environment_id_on_deployment_merge_requests_spec.rb b/spec/migrations/backfill_environment_id_on_deployment_merge_requests_spec.rb deleted file mode 100644 index 296ae07cc21..00000000000 --- a/spec/migrations/backfill_environment_id_on_deployment_merge_requests_spec.rb +++ /dev/null @@ -1,81 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' -require Rails.root.join('db', 'post_migrate', '20200312134637_backfill_environment_id_on_deployment_merge_requests.rb') - -describe BackfillEnvironmentIdOnDeploymentMergeRequests do - let(:environments) { table(:environments) } - let(:merge_requests) { table(:merge_requests) } - let(:deployments) { table(:deployments) } - let(:deployment_merge_requests) { table(:deployment_merge_requests) } - let(:namespaces) { table(:namespaces) } - let(:projects) { table(:projects) } - - let(:migration_worker) { double('BackgroundMigrationWorker') } - - before do - stub_const('BackgroundMigrationWorker', migration_worker) - end - - it 'schedules nothing when there are no entries' do - expect(migration_worker).not_to receive(:perform_in) - - migrate! - end - - it 'batches the workload' do - stub_const("#{described_class.name}::BATCH_SIZE", 10) - - namespace = namespaces.create!(name: 'foo', path: 'foo') - project = projects.create!(namespace_id: namespace.id) - - environment = environments.create!(project_id: project.id, name: 'staging', slug: 'staging') - - # Batching is based on DeploymentMergeRequest.merge_request_id, in order to test it - # we must generate more than described_class::BATCH_SIZE merge requests, deployments, - # and deployment_merge_requests entries - entries = 13 - expect(entries).to be > described_class::BATCH_SIZE - - # merge requests and deployments bulk generation - mrs_params = [] - deployments_params = [] - entries.times do |i| - mrs_params << { source_branch: 'x', target_branch: 'master', target_project_id: project.id } - - deployments_params << { environment_id: environment.id, iid: i + 1, project_id: project.id, ref: 'master', tag: false, sha: '123abcdef', status: 1 } - end - - all_mrs = merge_requests.insert_all(mrs_params) - all_deployments = deployments.insert_all(deployments_params) - - # deployment_merge_requests bulk generation - dmr_params = [] - entries.times do |index| - mr_id = all_mrs.rows[index].first - deployment_id = all_deployments.rows[index].first - - dmr_params << { deployment_id: deployment_id, merge_request_id: mr_id } - end - - deployment_merge_requests.insert_all(dmr_params) - - first_batch_limit = dmr_params[described_class::BATCH_SIZE][:merge_request_id] - second_batch_limit = dmr_params.last[:merge_request_id] - - expect(migration_worker).to receive(:perform_in) - .with( - 0, - 'BackfillEnvironmentIdDeploymentMergeRequests', - [1, first_batch_limit] - ) - expect(migration_worker).to receive(:perform_in) - .with( - described_class::DELAY, - 'BackfillEnvironmentIdDeploymentMergeRequests', - [first_batch_limit + 1, second_batch_limit] - ) - - migrate! - end -end diff --git a/spec/models/merge_request_spec.rb b/spec/models/merge_request_spec.rb index 5fe0a9052cf..38b92f22faf 100644 --- a/spec/models/merge_request_spec.rb +++ b/spec/models/merge_request_spec.rb @@ -3836,40 +3836,28 @@ describe MergeRequest do end describe '#diffable_merge_ref?' do - context 'diff_compare_with_head enabled' do - context 'merge request can be merged' do - context 'merge_to_ref is not calculated' do - it 'returns true' do - expect(subject.diffable_merge_ref?).to eq(false) - end - end - - context 'merge_to_ref is calculated' do - before do - MergeRequests::MergeToRefService.new(subject.project, subject.author).execute(subject) - end - - it 'returns true' do - expect(subject.diffable_merge_ref?).to eq(true) - end + context 'merge request can be merged' do + context 'merge_to_ref is not calculated' do + it 'returns true' do + expect(subject.diffable_merge_ref?).to eq(false) end end - context 'merge request cannot be merged' do - it 'returns false' do - subject.mark_as_unchecked! + context 'merge_to_ref is calculated' do + before do + MergeRequests::MergeToRefService.new(subject.project, subject.author).execute(subject) + end - expect(subject.diffable_merge_ref?).to eq(false) + it 'returns true' do + expect(subject.diffable_merge_ref?).to eq(true) end end end - context 'diff_compare_with_head disabled' do - before do - stub_feature_flags(diff_compare_with_head: { enabled: false, thing: subject.target_project }) - end - + context 'merge request cannot be merged' do it 'returns false' do + subject.mark_as_unchecked! + expect(subject.diffable_merge_ref?).to eq(false) end end diff --git a/spec/models/sent_notification_spec.rb b/spec/models/sent_notification_spec.rb index fedaae372c4..087bc957373 100644 --- a/spec/models/sent_notification_spec.rb +++ b/spec/models/sent_notification_spec.rb @@ -326,4 +326,26 @@ describe SentNotification do end end end + + describe "#position=" do + subject { build(:sent_notification, noteable: create(:issue)) } + + it "doesn't accept non-hash JSON passed as a string" do + subject.position = "true" + + expect(subject.attributes_before_type_cast["position"]).to be(nil) + end + + it "does accept a position hash as a string" do + subject.position = '{ "base_sha": "test" }' + + expect(subject.position.base_sha).to eq("test") + end + + it "does accept a hash" do + subject.position = { "base_sha" => "test" } + + expect(subject.position.base_sha).to eq("test") + end + end end diff --git a/spec/policies/ci/build_policy_spec.rb b/spec/policies/ci/build_policy_spec.rb index 333f4e560cf..f29ed26f2aa 100644 --- a/spec/policies/ci/build_policy_spec.rb +++ b/spec/policies/ci/build_policy_spec.rb @@ -176,15 +176,21 @@ describe Ci::BuildPolicy do end context 'when developers can push to the branch' do - before do - create(:protected_branch, :developers_can_push, - name: build.ref, project: project) - end - context 'when the build was created by the developer' do let(:owner) { user } - it { expect(policy).to be_allowed :erase_build } + context 'when the build was created for a protected ref' do + before do + create(:protected_branch, :developers_can_push, + name: build.ref, project: project) + end + + it { expect(policy).to be_disallowed :erase_build } + end + + context 'when the build was created for an unprotected ref' do + it { expect(policy).to be_allowed :erase_build } + end end context 'when the build was created by the other' do diff --git a/spec/requests/api/branches_spec.rb b/spec/requests/api/branches_spec.rb index 5e8223ec3cc..f2dc5b1c045 100644 --- a/spec/requests/api/branches_spec.rb +++ b/spec/requests/api/branches_spec.rb @@ -630,7 +630,7 @@ describe API::Branches do post api(route, user), params: { branch: 'new_design3', ref: 'foo' } expect(response).to have_gitlab_http_status(:bad_request) - expect(json_response['message']).to eq('Invalid reference name: new_design3') + expect(json_response['message']).to eq('Invalid reference name: foo') end end diff --git a/spec/requests/api/graphql/mutations/branches/create_spec.rb b/spec/requests/api/graphql/mutations/branches/create_spec.rb index 406ab8959a2..8340f5a7db2 100644 --- a/spec/requests/api/graphql/mutations/branches/create_spec.rb +++ b/spec/requests/api/graphql/mutations/branches/create_spec.rb @@ -38,7 +38,7 @@ describe 'Creation of a new branch' do let(:ref) { 'unknown' } it_behaves_like 'a mutation that returns errors in the response', - errors: ['Invalid reference name: new_branch'] + errors: ['Invalid reference name: unknown'] end end end diff --git a/spec/rubocop/cop/inject_enterprise_edition_module_spec.rb b/spec/rubocop/cop/inject_enterprise_edition_module_spec.rb index ce20d494542..3cb1dbbbc2c 100644 --- a/spec/rubocop/cop/inject_enterprise_edition_module_spec.rb +++ b/spec/rubocop/cop/inject_enterprise_edition_module_spec.rb @@ -159,6 +159,17 @@ describe RuboCop::Cop::InjectEnterpriseEditionModule do SOURCE end + it 'does not flag the double use of `X_if_ee` on the last line' do + expect_no_offenses(<<~SOURCE) + class Foo + end + + Foo.extend_if_ee('EE::Foo') + Foo.include_if_ee('EE::Foo') + Foo.prepend_if_ee('EE::Foo') + SOURCE + end + it 'autocorrects offenses by just disabling the Cop' do source = <<~SOURCE class Foo diff --git a/spec/services/branches/create_service_spec.rb b/spec/services/branches/create_service_spec.rb index b0629c5e25a..072a86d17fc 100644 --- a/spec/services/branches/create_service_spec.rb +++ b/spec/services/branches/create_service_spec.rb @@ -3,39 +3,45 @@ require 'spec_helper' describe Branches::CreateService do - let(:user) { create(:user) } - subject(:service) { described_class.new(project, user) } + let_it_be(:project) { create(:project_empty_repo) } + let_it_be(:user) { create(:user) } + describe '#execute' do context 'when repository is empty' do - let(:project) { create(:project_empty_repo) } - it 'creates master branch' do service.execute('my-feature', 'master') expect(project.repository.branch_exists?('master')).to be_truthy end - it 'creates my-feature branch' do - service.execute('my-feature', 'master') + it 'creates another-feature branch' do + service.execute('another-feature', 'master') - expect(project.repository.branch_exists?('my-feature')).to be_truthy + expect(project.repository.branch_exists?('another-feature')).to be_truthy end end - context 'when creating a branch fails' do - let(:project) { create(:project_empty_repo) } + context 'when branch already exists' do + it 'returns an error' do + result = service.execute('master', 'master') + + expect(result[:status]).to eq(:error) + expect(result[:message]).to eq('Branch already exists') + end + end + context 'when incorrect reference is provided' do before do allow(project.repository).to receive(:add_branch).and_return(false) end - it 'returns an error with the branch name' do - result = service.execute('my-feature', 'master') + it 'returns an error with a reference name' do + result = service.execute('new-feature', 'unknown') expect(result[:status]).to eq(:error) - expect(result[:message]).to eq("Invalid reference name: my-feature") + expect(result[:message]).to eq('Invalid reference name: unknown') end end end diff --git a/vendor/gitignore/C++.gitignore b/vendor/gitignore/C++.gitignore index 259148fa18f..259148fa18f 100755..100644 --- a/vendor/gitignore/C++.gitignore +++ b/vendor/gitignore/C++.gitignore diff --git a/vendor/gitignore/Java.gitignore b/vendor/gitignore/Java.gitignore index a1c2a238a96..a1c2a238a96 100755..100644 --- a/vendor/gitignore/Java.gitignore +++ b/vendor/gitignore/Java.gitignore |