diff options
55 files changed, 821 insertions, 408 deletions
diff --git a/app/assets/javascripts/boards/components/board_content_sidebar.vue b/app/assets/javascripts/boards/components/board_content_sidebar.vue index 9bbb8a1a1b2..d9d18dc0079 100644 --- a/app/assets/javascripts/boards/components/board_content_sidebar.vue +++ b/app/assets/javascripts/boards/components/board_content_sidebar.vue @@ -15,6 +15,7 @@ import SidebarDateWidget from '~/sidebar/components/date/sidebar_date_widget.vue import SidebarSubscriptionsWidget from '~/sidebar/components/subscriptions/sidebar_subscriptions_widget.vue'; import SidebarTodoWidget from '~/sidebar/components/todo_toggle/sidebar_todo_widget.vue'; import SidebarLabelsWidget from '~/vue_shared/components/sidebar/labels_select_widget/labels_select_root.vue'; +import { LabelType } from '~/vue_shared/components/sidebar/labels_select_widget/constants'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; export default { @@ -63,7 +64,7 @@ export default { 'groupPathForActiveIssue', 'projectPathForActiveIssue', ]), - ...mapState(['sidebarType', 'issuableType', 'isSettingLabels']), + ...mapState(['sidebarType', 'issuableType']), isIssuableSidebar() { return this.sidebarType === ISSUABLE; }, @@ -84,7 +85,10 @@ export default { }); }, attrWorkspacePath() { - return this.isGroupBoard ? this.groupPathForActiveIssue : undefined; + return this.isGroupBoard ? this.groupPathForActiveIssue : this.projectPathForActiveIssue; + }, + labelType() { + return this.isGroupBoard ? LabelType.group : LabelType.project; }, }, methods: { @@ -98,21 +102,19 @@ export default { handleClose() { this.toggleBoardItem({ boardItem: this.activeBoardItem, sidebarType: this.sidebarType }); }, - handleUpdateSelectedLabels(input) { + handleUpdateSelectedLabels({ labels, id }) { this.setActiveBoardItemLabels({ - iid: this.activeBoardItem.iid, + id, projectPath: this.projectPathForActiveIssue, - addLabelIds: input.map((label) => getIdFromGraphQLId(label.id)), - removeLabelIds: this.activeBoardItem.labels - .filter((label) => !input.find((selected) => selected.id === label.id)) - .map((label) => label.id), + labelIds: labels.map((label) => getIdFromGraphQLId(label.id)), + labels, }); }, - handleLabelRemove(input) { + handleLabelRemove(removeLabelId) { this.setActiveBoardItemLabels({ iid: this.activeBoardItem.iid, projectPath: this.projectPathForActiveIssue, - removeLabelIds: [input], + removeLabelIds: [removeLabelId], }); }, }, @@ -207,14 +209,13 @@ export default { :full-path="projectPathForActiveIssue" :allow-label-remove="allowLabelEdit" :allow-multiselect="true" - :selected-labels="activeBoardItem.labels" - :labels-select-in-progress="isSettingLabels" :footer-create-label-title="createLabelTitle" :footer-manage-label-title="manageLabelTitle" :labels-create-title="createLabelTitle" :labels-filter-base-path="projectPathForActiveIssue" :attr-workspace-path="attrWorkspacePath" :issuable-type="issuableType" + :label-type="labelType" @onLabelRemove="handleLabelRemove" @updateSelectedLabels="handleUpdateSelectedLabels" > diff --git a/app/assets/javascripts/boards/components/sidebar/board_sidebar_labels_select.vue b/app/assets/javascripts/boards/components/sidebar/board_sidebar_labels_select.vue index e74463825c5..ec53947fd5f 100644 --- a/app/assets/javascripts/boards/components/sidebar/board_sidebar_labels_select.vue +++ b/app/assets/javascripts/boards/components/sidebar/board_sidebar_labels_select.vue @@ -91,9 +91,7 @@ export default { try { const addLabelIds = payload.filter((label) => label.set).map((label) => label.id); - const removeLabelIds = this.selectedLabels - .filter((label) => !payload.find((selected) => selected.id === label.id)) - .map((label) => label.id); + const removeLabelIds = payload.filter((label) => !label.set).map((label) => label.id); const input = { addLabelIds, @@ -164,7 +162,7 @@ export default { :labels-list-title="__('Select label')" :dropdown-button-text="__('Choose labels')" :is-editing="edit" - variant="embedded" + variant="sidebar" class="gl-display-block labels gl-w-full" @updateSelectedLabels="setLabels" > diff --git a/app/assets/javascripts/boards/stores/actions.js b/app/assets/javascripts/boards/stores/actions.js index ca993e75cf9..c81e3cb79db 100644 --- a/app/assets/javascripts/boards/stores/actions.js +++ b/app/assets/javascripts/boards/stores/actions.js @@ -656,30 +656,45 @@ export default { }, setActiveIssueLabels: async ({ commit, getters }, input) => { - commit(types.SET_LABELS_LOADING, true); const { activeBoardItem } = getters; - const { data } = await gqlClient.mutate({ - mutation: issueSetLabelsMutation, - variables: { - input: { - iid: input.iid || String(activeBoardItem.iid), - addLabelIds: input.addLabelIds ?? [], - removeLabelIds: input.removeLabelIds ?? [], - projectPath: input.projectPath, + + if (!gon.features?.labelsWidget) { + const { data } = await gqlClient.mutate({ + mutation: issueSetLabelsMutation, + variables: { + input: { + iid: input.iid || String(activeBoardItem.iid), + labelIds: input.labelsId ?? undefined, + addLabelIds: input.addLabelIds ?? [], + removeLabelIds: input.removeLabelIds ?? [], + projectPath: input.projectPath, + }, }, - }, - }); + }); + + if (data.updateIssue?.errors?.length > 0) { + throw new Error(data.updateIssue.errors); + } - commit(types.SET_LABELS_LOADING, false); + commit(types.UPDATE_BOARD_ITEM_BY_ID, { + itemId: data.updateIssue?.issue?.id || activeBoardItem.id, + prop: 'labels', + value: data.updateIssue?.issue?.labels.nodes, + }); - if (data.updateIssue?.errors?.length > 0) { - throw new Error(data.updateIssue.errors); + return; } + let labels = input?.labels || []; + if (input.removeLabelIds) { + labels = activeBoardItem.labels.filter( + (label) => input.removeLabelIds[0] !== getIdFromGraphQLId(label.id), + ); + } commit(types.UPDATE_BOARD_ITEM_BY_ID, { - itemId: data.updateIssue?.issue?.id || activeBoardItem.id, + itemId: input.id || activeBoardItem.id, prop: 'labels', - value: data.updateIssue.issue.labels.nodes, + value: labels, }); }, diff --git a/app/assets/javascripts/boards/stores/mutation_types.js b/app/assets/javascripts/boards/stores/mutation_types.js index 26b785932bb..928cece19f7 100644 --- a/app/assets/javascripts/boards/stores/mutation_types.js +++ b/app/assets/javascripts/boards/stores/mutation_types.js @@ -28,7 +28,6 @@ export const ADD_BOARD_ITEM_TO_LIST = 'ADD_BOARD_ITEM_TO_LIST'; export const REMOVE_BOARD_ITEM_FROM_LIST = 'REMOVE_BOARD_ITEM_FROM_LIST'; export const SET_ACTIVE_ID = 'SET_ACTIVE_ID'; export const UPDATE_BOARD_ITEM_BY_ID = 'UPDATE_BOARD_ITEM_BY_ID'; -export const SET_LABELS_LOADING = 'SET_LABELS_LOADING'; export const SET_ASSIGNEE_LOADING = 'SET_ASSIGNEE_LOADING'; export const RESET_ISSUES = 'RESET_ISSUES'; export const REQUEST_GROUP_PROJECTS = 'REQUEST_GROUP_PROJECTS'; diff --git a/app/assets/javascripts/boards/stores/mutations.js b/app/assets/javascripts/boards/stores/mutations.js index d381c076c19..ef5b84b4575 100644 --- a/app/assets/javascripts/boards/stores/mutations.js +++ b/app/assets/javascripts/boards/stores/mutations.js @@ -195,10 +195,6 @@ export default { Vue.set(state.boardItems[itemId], prop, value); }, - [mutationTypes.SET_LABELS_LOADING](state, isLoading) { - state.isSettingLabels = isLoading; - }, - [mutationTypes.SET_ASSIGNEE_LOADING](state, isLoading) { state.isSettingAssignees = isLoading; }, diff --git a/app/assets/javascripts/boards/stores/state.js b/app/assets/javascripts/boards/stores/state.js index 2a6605e687b..80c51c966d2 100644 --- a/app/assets/javascripts/boards/stores/state.js +++ b/app/assets/javascripts/boards/stores/state.js @@ -12,7 +12,6 @@ export default () => ({ listsFlags: {}, boardItemsByListId: {}, backupItemsList: [], - isSettingLabels: false, isSettingAssignees: false, pageInfoByListId: {}, boardItems: {}, diff --git a/app/assets/javascripts/sidebar/components/labels/sidebar_labels.vue b/app/assets/javascripts/sidebar/components/labels/sidebar_labels.vue index d5647619ea3..4c08419bb88 100644 --- a/app/assets/javascripts/sidebar/components/labels/sidebar_labels.vue +++ b/app/assets/javascripts/sidebar/components/labels/sidebar_labels.vue @@ -11,6 +11,7 @@ import { toLabelGid } from '~/sidebar/utils'; import { DropdownVariant } from '~/vue_shared/components/sidebar/labels_select_vue/constants'; import LabelsSelect from '~/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue'; import LabelsSelectWidget from '~/vue_shared/components/sidebar/labels_select_widget/labels_select_root.vue'; +import { LabelType } from '~/vue_shared/components/sidebar/labels_select_widget/constants'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; const mutationMap = { @@ -48,6 +49,7 @@ export default { return { isLabelsSelectInProgress: false, selectedLabels: this.initiallySelectedLabels, + LabelType, }; }, methods: { @@ -154,13 +156,11 @@ export default { :footer-manage-label-title="__('Manage project labels')" :labels-create-title="__('Create project label')" :labels-filter-base-path="projectIssuesPath" - :labels-select-in-progress="isLabelsSelectInProgress" - :selected-labels="selectedLabels" :variant="$options.variant" :issuable-type="issuableType" + :attr-workspace-path="fullPath" + :label-type="LabelType.project" data-qa-selector="labels_block" - @onLabelRemove="handleLabelRemove" - @updateSelectedLabels="handleUpdateSelectedLabels" > {{ __('None') }} </labels-select-widget> diff --git a/app/assets/javascripts/sidebar/constants.js b/app/assets/javascripts/sidebar/constants.js index e593973da82..02843dd3c5b 100644 --- a/app/assets/javascripts/sidebar/constants.js +++ b/app/assets/javascripts/sidebar/constants.js @@ -1,3 +1,4 @@ +import updateIssueLabelsMutation from '~/boards/graphql/issue_set_labels.mutation.graphql'; import { IssuableType } from '~/issue_show/constants'; import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; import epicConfidentialQuery from '~/sidebar/queries/epic_confidential.query.graphql'; @@ -29,6 +30,7 @@ import updateIssueConfidentialMutation from '~/sidebar/queries/update_issue_conf import updateIssueDueDateMutation from '~/sidebar/queries/update_issue_due_date.mutation.graphql'; import updateIssueSubscriptionMutation from '~/sidebar/queries/update_issue_subscription.mutation.graphql'; import mergeRequestMilestoneMutation from '~/sidebar/queries/update_merge_request_milestone.mutation.graphql'; +import updateMergeRequestLabelsMutation from '~/sidebar/queries/update_merge_request_labels.mutation.graphql'; import updateMergeRequestSubscriptionMutation from '~/sidebar/queries/update_merge_request_subscription.mutation.graphql'; import updateAlertAssigneesMutation from '~/vue_shared/alert_details/graphql/mutations/alert_set_assignees.mutation.graphql'; import epicLabelsQuery from '~/vue_shared/components/sidebar/labels_select_widget/graphql/epic_labels.query.graphql'; @@ -120,6 +122,17 @@ export const labelsQueries = { }, }; +export const labelsMutations = { + [IssuableType.Issue]: { + mutation: updateIssueLabelsMutation, + mutationName: 'updateIssue', + }, + [IssuableType.MergeRequest]: { + mutation: updateMergeRequestLabelsMutation, + mutationName: 'mergeRequestSetLabels', + }, +}; + export const dateTypes = { start: 'startDate', due: 'dueDate', diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/constants.js b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/constants.js index 389eb174c0e..3e5d1d6d7d7 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/constants.js +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/constants.js @@ -5,3 +5,8 @@ export const DropdownVariant = { Standalone: 'standalone', Embedded: 'embedded', }; + +export const LabelType = { + group: 'GroupLabel', + project: 'ProjectLabel', +}; diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents.vue index 3ee0baf8812..9c57dc2a9ee 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents.vue @@ -1,20 +1,25 @@ <script> import { GlButton, GlDropdown, GlDropdownItem, GlLink } from '@gitlab/ui'; +import { debounce } from 'lodash'; +import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; import { __, s__, sprintf } from '~/locale'; import DropdownContentsCreateView from './dropdown_contents_create_view.vue'; import DropdownContentsLabelsView from './dropdown_contents_labels_view.vue'; +import DropdownFooter from './dropdown_footer.vue'; +import DropdownHeader from './dropdown_header.vue'; import { isDropdownVariantStandalone, isDropdownVariantSidebar } from './utils'; export default { components: { DropdownContentsLabelsView, DropdownContentsCreateView, + DropdownHeader, + DropdownFooter, GlButton, GlDropdown, GlDropdownItem, GlLink, }, - inject: ['allowLabelCreate', 'labelsManagePath'], props: { labelsCreateTitle: { type: String, @@ -63,8 +68,11 @@ export default { }, attrWorkspacePath: { type: String, - required: false, - default: undefined, + required: true, + }, + labelType: { + type: String, + required: true, }, }, data() { @@ -72,6 +80,7 @@ export default { showDropdownContentsCreateView: false, localSelectedLabels: [...this.selectedLabels], isDirty: false, + searchKey: '', }; }, computed: { @@ -113,15 +122,24 @@ export default { if (newVal) { this.$refs.dropdown.show(); this.isDirty = false; + this.localSelectedLabels = this.selectedLabels; } else { this.$refs.dropdown.hide(); this.setLabels(); } }, selectedLabels(newVal) { - this.localSelectedLabels = newVal; + if (!this.isDirty) { + this.localSelectedLabels = newVal; + } }, }, + created() { + this.debouncedSearchKeyUpdate = debounce(this.setSearchKey, DEFAULT_DEBOUNCE_AND_THROTTLE_MS); + }, + beforeDestroy() { + this.debouncedSearchKeyUpdate.cancel(); + }, methods: { toggleDropdownContentsCreateView() { this.showDropdownContentsCreateView = !this.showDropdownContentsCreateView; @@ -144,6 +162,12 @@ export default { this.setLabels(); } }, + setSearchKey(value) { + this.searchKey = value; + }, + setFocus() { + this.$refs.header.focusInput(); + }, }, }; </script> @@ -155,60 +179,41 @@ export default { class="gl-w-full gl-mt-2" data-qa-selector="labels_dropdown_content" @hide="handleDropdownHide" + @shown="setFocus" > <template #header> - <div + <dropdown-header v-if="!isStandalone" - class="dropdown-title gl-display-flex gl-align-items-center gl-pt-0 gl-pb-3!" - data-testid="dropdown-header" - > - <gl-button - v-if="showDropdownContentsCreateView" - :aria-label="__('Go back')" - variant="link" - size="small" - class="js-btn-back dropdown-header-button gl-p-0" - icon="arrow-left" - data-testid="go-back-button" - @click.stop="toggleDropdownContent" - /> - <span class="gl-flex-grow-1">{{ dropdownTitle }}</span> - <gl-button - :aria-label="__('Close')" - variant="link" - size="small" - class="dropdown-header-button gl-p-0!" - icon="close" - data-testid="close-button" - @click="$emit('closeDropdown')" - /> - </div> + ref="header" + v-model="searchKey" + :labels-create-title="labelsCreateTitle" + :labels-list-title="labelsListTitle" + :show-dropdown-contents-create-view="showDropdownContentsCreateView" + @toggleDropdownContentsCreateView="toggleDropdownContent" + @closeDropdown="$emit('closeDropdown')" + @input="debouncedSearchKeyUpdate" + /> </template> <template #default> <component :is="dropdownContentsView" v-model="localSelectedLabels" - :selected-labels="selectedLabels" + :search-key="searchKey" :allow-multiselect="allowMultiselect" :issuable-type="issuableType" :full-path="fullPath" :attr-workspace-path="attrWorkspacePath" + :label-type="labelType" @hideCreateView="toggleDropdownContentsCreateView" /> </template> <template #footer> - <div v-if="showDropdownFooter" data-testid="dropdown-footer"> - <gl-dropdown-item - v-if="allowLabelCreate" - data-testid="create-label-button" - @click.capture.native.stop="toggleDropdownContent" - > - {{ footerCreateLabelTitle }} - </gl-dropdown-item> - <gl-dropdown-item :href="labelsManagePath" @click.capture.native.stop> - {{ footerManageLabelTitle }} - </gl-dropdown-item> - </div> + <dropdown-footer + v-if="showDropdownFooter" + :footer-create-label-title="footerCreateLabelTitle" + :footer-manage-label-title="footerManageLabelTitle" + @toggleDropdownContentsCreateView="toggleDropdownContent" + /> </template> </gl-dropdown> </template> diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view.vue index a2ed08e6b28..10743d8564b 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view.vue @@ -2,10 +2,10 @@ import { GlTooltipDirective, GlButton, GlFormInput, GlLink, GlLoadingIcon } from '@gitlab/ui'; import produce from 'immer'; import createFlash from '~/flash'; -import { IssuableType } from '~/issue_show/constants'; import { __ } from '~/locale'; import { labelsQueries } from '~/sidebar/constants'; import createLabelMutation from './graphql/create_label.mutation.graphql'; +import { LabelType } from './constants'; const errorMessage = __('Error creating label.'); @@ -30,8 +30,11 @@ export default { }, attrWorkspacePath: { type: String, - required: false, - default: undefined, + required: true, + }, + labelType: { + type: String, + required: true, }, }, data() { @@ -50,25 +53,13 @@ export default { return Object.keys(colorsMap).map((color) => ({ [color]: colorsMap[color] })); }, mutationVariables() { - if (this.issuableType === IssuableType.Epic) { - return { - title: this.labelTitle, - color: this.selectedColor, - groupPath: this.fullPath, - }; - } + const attributePath = this.labelType === LabelType.group ? 'groupPath' : 'projectPath'; - return this.attrWorkspacePath !== undefined - ? { - title: this.labelTitle, - color: this.selectedColor, - groupPath: this.attrWorkspacePath, - } - : { - title: this.labelTitle, - color: this.selectedColor, - projectPath: this.fullPath, - }; + return { + title: this.labelTitle, + color: this.selectedColor, + [attributePath]: this.attrWorkspacePath, + }; }, }, methods: { diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view.vue index e6a25362ff0..2b44219a95b 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view.vue @@ -1,16 +1,8 @@ <script> -import { - GlDropdownForm, - GlDropdownItem, - GlLoadingIcon, - GlSearchBoxByType, - GlIntersectionObserver, -} from '@gitlab/ui'; +import { GlDropdownForm, GlDropdownItem, GlLoadingIcon, GlIntersectionObserver } from '@gitlab/ui'; import fuzzaldrinPlus from 'fuzzaldrin-plus'; -import { debounce } from 'lodash'; import createFlash from '~/flash'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; -import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; import { __ } from '~/locale'; import { labelsQueries } from '~/sidebar/constants'; import LabelItem from './label_item.vue'; @@ -20,7 +12,6 @@ export default { GlDropdownForm, GlDropdownItem, GlLoadingIcon, - GlSearchBoxByType, GlIntersectionObserver, LabelItem, }, @@ -28,10 +19,6 @@ export default { prop: 'localSelectedLabels', }, props: { - selectedLabels: { - type: Array, - required: true, - }, allowMultiselect: { type: Boolean, required: true, @@ -48,10 +35,13 @@ export default { type: String, required: true, }, + searchKey: { + type: String, + required: true, + }, }, data() { return { - searchKey: '', labels: [], isVisible: false, }; @@ -71,12 +61,6 @@ export default { return this.searchKey.length === 1 || !this.isVisible; }, update: (data) => data.workspace?.labels?.nodes || [], - async result() { - if (this.$refs.searchInput) { - await this.$nextTick; - this.$refs.searchInput.focusInput(); - } - }, error() { createFlash({ message: __('Error fetching labels.') }); }, @@ -101,12 +85,6 @@ export default { return Boolean(this.searchKey) && this.visibleLabels.length === 0; }, }, - created() { - this.debouncedSearchKeyUpdate = debounce(this.setSearchKey, DEFAULT_DEBOUNCE_AND_THROTTLE_MS); - }, - beforeDestroy() { - this.debouncedSearchKeyUpdate.cancel(); - }, methods: { isLabelSelected(label) { return this.localSelectedLabelsIds.includes(getIdFromGraphQLId(label.id)); @@ -153,12 +131,8 @@ export default { this.$emit('closeDropdown', this.localSelectedLabels); } }, - setSearchKey(value) { - this.searchKey = value; - }, onDropdownAppear() { this.isVisible = true; - this.$refs.searchInput.focusInput(); }, }, }; @@ -167,14 +141,6 @@ export default { <template> <gl-intersection-observer @appear="onDropdownAppear"> <gl-dropdown-form class="labels-select-contents-list js-labels-list"> - <gl-search-box-by-type - ref="searchInput" - :value="searchKey" - :disabled="labelsFetchInProgress" - data-qa-selector="dropdown_input_field" - data-testid="dropdown-input-field" - @input="debouncedSearchKeyUpdate" - /> <div ref="labelsListContainer" data-testid="dropdown-content"> <gl-loading-icon v-if="labelsFetchInProgress" diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_footer.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_footer.vue new file mode 100644 index 00000000000..e67e704ffb8 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_footer.vue @@ -0,0 +1,35 @@ +<script> +import { GlDropdownItem } from '@gitlab/ui'; + +export default { + components: { + GlDropdownItem, + }, + inject: ['allowLabelCreate', 'labelsManagePath'], + props: { + footerCreateLabelTitle: { + type: String, + required: true, + }, + footerManageLabelTitle: { + type: String, + required: true, + }, + }, +}; +</script> + +<template> + <div data-testid="dropdown-footer"> + <gl-dropdown-item + v-if="allowLabelCreate" + data-testid="create-label-button" + @click.capture.native.stop="$emit('toggleDropdownContentsCreateView')" + > + {{ footerCreateLabelTitle }} + </gl-dropdown-item> + <gl-dropdown-item :href="labelsManagePath" @click.capture.native.stop> + {{ footerManageLabelTitle }} + </gl-dropdown-item> + </div> +</template> diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_header.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_header.vue new file mode 100644 index 00000000000..92a4fcd4660 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_header.vue @@ -0,0 +1,82 @@ +<script> +import { GlButton, GlSearchBoxByType } from '@gitlab/ui'; + +export default { + components: { + GlButton, + GlSearchBoxByType, + }, + model: { + prop: 'searchKey', + }, + props: { + labelsCreateTitle: { + type: String, + required: true, + }, + labelsListTitle: { + type: String, + required: true, + }, + showDropdownContentsCreateView: { + type: Boolean, + required: true, + }, + labelsFetchInProgress: { + type: Boolean, + required: false, + default: false, + }, + searchKey: { + type: String, + required: true, + }, + }, + computed: { + dropdownTitle() { + return this.showDropdownContentsCreateView ? this.labelsCreateTitle : this.labelsListTitle; + }, + }, + methods: { + focusInput() { + this.$refs.searchInput.focusInput(); + }, + }, +}; +</script> + +<template> + <div data-testid="dropdown-header"> + <div class="dropdown-title gl-display-flex gl-align-items-center gl-pt-0 gl-pb-3!"> + <gl-button + v-if="showDropdownContentsCreateView" + :aria-label="__('Go back')" + variant="link" + size="small" + class="js-btn-back dropdown-header-button gl-p-0" + icon="arrow-left" + data-testid="go-back-button" + @click.stop="$emit('toggleDropdownContentsCreateView')" + /> + <span class="gl-flex-grow-1">{{ dropdownTitle }}</span> + <gl-button + :aria-label="__('Close')" + variant="link" + size="small" + class="dropdown-header-button gl-p-0!" + icon="close" + data-testid="close-button" + @click="$emit('closeDropdown')" + /> + </div> + <gl-search-box-by-type + v-if="!showDropdownContentsCreateView" + ref="searchInput" + :value="searchKey" + :disabled="labelsFetchInProgress" + data-qa-selector="dropdown_input_field" + data-testid="dropdown-input-field" + @input="$emit('input', $event)" + /> + </div> +</template> diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/labels_select_root.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/labels_select_root.vue index 6bd43da2203..6b19eda6706 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/labels_select_root.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/labels_select_root.vue @@ -1,8 +1,10 @@ <script> +import { MutationOperationMode } from '~/graphql_shared/utils'; import createFlash from '~/flash'; +import { IssuableType } from '~/issue_show/constants'; import { __ } from '~/locale'; import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue'; -import { labelsQueries } from '~/sidebar/constants'; +import { labelsQueries, labelsMutations } from '~/sidebar/constants'; import { DropdownVariant } from './constants'; import DropdownContents from './dropdown_contents.vue'; import DropdownValue from './dropdown_value.vue'; @@ -50,16 +52,6 @@ export default { required: false, default: DropdownVariant.Sidebar, }, - selectedLabels: { - type: Array, - required: false, - default: () => [], - }, - labelsSelectInProgress: { - type: Boolean, - required: false, - default: false, - }, labelsFilterBasePath: { type: String, required: false, @@ -95,25 +87,25 @@ export default { required: false, default: __('Manage group labels'), }, - isEditing: { - type: Boolean, - required: false, - default: false, - }, issuableType: { type: String, required: true, }, attrWorkspacePath: { type: String, - required: false, - default: undefined, + required: true, + }, + labelType: { + type: String, + required: true, }, }, data() { return { contentIsOnViewport: true, issuableLabels: [], + labelsSelectInProgress: false, + oldIid: null, }; }, computed: { @@ -143,9 +135,19 @@ export default { }, }, }, + watch: { + iid(_, oldVal) { + this.oldIid = oldVal; + }, + }, methods: { handleDropdownClose(labels) { - this.$emit('updateSelectedLabels', labels); + if (this.iid !== '') { + this.updateSelectedLabels(this.getUpdateVariables(labels)); + } else { + this.$emit('updateSelectedLabels', { labels }); + } + this.collapseEditableItem(); }, collapseEditableItem() { @@ -154,6 +156,85 @@ export default { handleCollapsedValueClick() { this.$emit('toggleCollapse'); }, + getUpdateVariables(labels) { + let labelIds = []; + + labelIds = labels.map(({ id }) => id); + const currentIid = this.oldIid || this.iid; + + const updateVariables = { + iid: currentIid, + projectPath: this.fullPath, + labelIds, + }; + + switch (this.issuableType) { + case IssuableType.Issue: + return updateVariables; + case IssuableType.MergeRequest: + updateVariables.operationMode = MutationOperationMode.Replace; + return updateVariables; + default: + return {}; + } + }, + updateSelectedLabels(inputVariables) { + this.labelsSelectInProgress = true; + + this.$apollo + .mutate({ + mutation: labelsMutations[this.issuableType].mutation, + variables: { input: inputVariables }, + }) + .then(({ data }) => { + const { mutationName } = labelsMutations[this.issuableType]; + + if (data[mutationName]?.errors?.length) { + throw new Error(); + } + + this.$emit('updateSelectedLabels', { + id: data[mutationName]?.[this.issuableType].id, + labels: data[mutationName]?.[this.issuableType].labels?.nodes, + }); + }) + .catch((error) => + createFlash({ + message: __('An error occurred while updating labels.'), + captureError: true, + error, + }), + ) + .finally(() => { + this.labelsSelectInProgress = false; + }); + }, + getRemoveVariables(labelId) { + const removeVariables = { + iid: this.iid, + projectPath: this.fullPath, + }; + + switch (this.issuableType) { + case IssuableType.Issue: + return { + ...removeVariables, + removeLabelIds: [labelId], + }; + case IssuableType.MergeRequest: + return { + ...removeVariables, + labelIds: [labelId], + operationMode: MutationOperationMode.Remove, + }; + default: + return {}; + } + }, + handleLabelRemove(labelId) { + this.updateSelectedLabels(this.getRemoveVariables(labelId)); + this.$emit('onLabelRemove', labelId); + }, isDropdownVariantSidebar, isDropdownVariantStandalone, isDropdownVariantEmbedded, @@ -180,6 +261,7 @@ export default { :title="__('Labels')" :loading="isLoading" :can-edit="allowLabelEdit" + @open="oldIid = null" > <template #collapsed> <dropdown-value @@ -188,7 +270,7 @@ export default { :allow-label-remove="allowLabelRemove" :labels-filter-base-path="labelsFilterBasePath" :labels-filter-param="labelsFilterParam" - @onLabelRemove="$emit('onLabelRemove', $event)" + @onLabelRemove="handleLabelRemove" > <slot></slot> </dropdown-value> @@ -201,7 +283,7 @@ export default { :labels-filter-base-path="labelsFilterBasePath" :labels-filter-param="labelsFilterParam" class="gl-mb-2" - @onLabelRemove="$emit('onLabelRemove', $event)" + @onLabelRemove="handleLabelRemove" > <slot></slot> </dropdown-value> @@ -212,12 +294,13 @@ export default { :footer-create-label-title="footerCreateLabelTitle" :footer-manage-label-title="footerManageLabelTitle" :labels-create-title="labelsCreateTitle" - :selected-labels="selectedLabels" + :selected-labels="issuableLabels" :variant="variant" :issuable-type="issuableType" :is-visible="edit" :full-path="fullPath" :attr-workspace-path="attrWorkspacePath" + :label-type="labelType" @setLabels="handleDropdownClose" @closeDropdown="collapseEditableItem" /> @@ -233,10 +316,12 @@ export default { :footer-create-label-title="footerCreateLabelTitle" :footer-manage-label-title="footerManageLabelTitle" :labels-create-title="labelsCreateTitle" - :selected-labels="selectedLabels" + :selected-labels="issuableLabels" :variant="variant" :issuable-type="issuableType" :full-path="fullPath" + :attr-workspace-path="attrWorkspacePath" + :label-type="labelType" @setLabels="handleDropdownClose" /> </div> diff --git a/app/controllers/projects/commits_controller.rb b/app/controllers/projects/commits_controller.rb index 9ca917841e9..0132306dd90 100644 --- a/app/controllers/projects/commits_controller.rb +++ b/app/controllers/projects/commits_controller.rb @@ -6,6 +6,8 @@ class Projects::CommitsController < Projects::ApplicationController include ExtractsPath include RendersCommits + COMMITS_DEFAULT_LIMIT = 40 + prepend_before_action(only: [:show]) { authenticate_sessionless_user!(:rss) } around_action :allow_gitaly_ref_name_caching before_action :require_non_empty_project @@ -63,7 +65,9 @@ class Projects::CommitsController < Projects::ApplicationController def set_commits render_404 unless @path.empty? || request.format == :atom || @repository.blob_at(@commit.id, @path) || @repository.tree(@commit.id, @path).entries.present? - @limit = (params[:limit] || 40).to_i + + limit = params[:limit].to_i + @limit = limit > 0 ? limit : COMMITS_DEFAULT_LIMIT # limit can only ever be a positive number @offset = (params[:offset] || 0).to_i search = params[:search] author = params[:author] diff --git a/app/helpers/issuables_helper.rb b/app/helpers/issuables_helper.rb index 24c6ef8cd68..c64d9c0d4f1 100644 --- a/app/helpers/issuables_helper.rb +++ b/app/helpers/issuables_helper.rb @@ -284,9 +284,7 @@ module IssuablesHelper end def issuables_count_for_state(issuable_type, state) - store_in_cache = parent.is_a?(Group) ? parent.cached_issues_state_count_enabled? : false - - Gitlab::IssuablesCountForState.new(finder, store_in_redis_cache: store_in_cache)[state] + Gitlab::IssuablesCountForState.new(finder, store_in_redis_cache: true)[state] end def close_issuable_path(issuable) @@ -442,7 +440,7 @@ module IssuablesHelper end def format_count(issuable_type, count, threshold) - if issuable_type == :issues && parent.is_a?(Group) && parent.cached_issues_state_count_enabled? + if issuable_type == :issues && parent.is_a?(Group) format_cached_count(threshold, count) else number_with_delimiter(count) diff --git a/app/models/error_tracking/error_event.rb b/app/models/error_tracking/error_event.rb index 686518a39fb..b4892e5c66b 100644 --- a/app/models/error_tracking/error_event.rb +++ b/app/models/error_tracking/error_event.rb @@ -61,9 +61,9 @@ class ErrorTracking::ErrorEvent < ApplicationRecord pre_context = entry['pre_context'] post_context = entry['post_context'] - context += lines_with_position(pre_context, error_line_no - pre_context.size) + context += lines_with_position(pre_context, error_line_no - pre_context.size) if pre_context context += lines_with_position([error_line], error_line_no) - context += lines_with_position(post_context, error_line_no + 1) + context += lines_with_position(post_context, error_line_no + 1) if post_context context.reject(&:blank?) end diff --git a/app/models/group.rb b/app/models/group.rb index c5e119451e3..51eeee419e0 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -760,10 +760,6 @@ class Group < Namespace Timelog.in_group(self) end - def cached_issues_state_count_enabled? - Feature.enabled?(:cached_issues_state_count, self, default_enabled: :yaml) - end - def organizations ::CustomerRelations::Organization.where(group_id: self.id) end diff --git a/app/models/project.rb b/app/models/project.rb index b827f48e706..cb58fee206b 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -2582,18 +2582,21 @@ class Project < ApplicationRecord config = Gitlab.config.incoming_email wildcard = Gitlab::IncomingEmail::WILDCARD_PLACEHOLDER - config.address&.gsub(wildcard, "#{full_path_slug}-#{id}-issue-") + config.address&.gsub(wildcard, "#{full_path_slug}-#{default_service_desk_suffix}") end def service_desk_custom_address return unless Gitlab::ServiceDeskEmail.enabled? - key = service_desk_setting&.project_key - return unless key.present? + key = service_desk_setting&.project_key || default_service_desk_suffix Gitlab::ServiceDeskEmail.address_for_key("#{full_path_slug}-#{key}") end + def default_service_desk_suffix + "#{id}-issue-" + end + def root_namespace if namespace.has_parent? namespace.root_ancestor diff --git a/app/views/groups/settings/_transfer.html.haml b/app/views/groups/settings/_transfer.html.haml index 1472ae42152..b2379d77314 100644 --- a/app/views/groups/settings/_transfer.html.haml +++ b/app/views/groups/settings/_transfer.html.haml @@ -14,7 +14,7 @@ %li= s_("GroupSettings|If the parent group's visibility is lower than the group current visibility, visibility levels for subgroups and projects will be changed to match the new parent group's visibility.") - if group.paid? - .gl-alert.gl-alert-info.gl-mb-5{ data: { testid: 'group-to-transfer-has-linked-subscription-alert' } } + .gl-alert.gl-alert-info.gl-mb-5 = sprite_icon('information-o', size: 16, css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title') .gl-alert-body = html_escape(_("This group can't be transfered because it is linked to a subscription. To transfer this group, %{linkStart}link the subscription%{linkEnd} with a different group.")) % { linkStart: "<a href=\"#{help_page_path('subscriptions/index', anchor: 'change-the-linked-namespace')}\">".html_safe, linkEnd: '</a>'.html_safe } diff --git a/config/feature_flags/development/cached_issues_state_count.yml b/config/feature_flags/development/cached_issues_state_count.yml deleted file mode 100644 index 34d96b601d9..00000000000 --- a/config/feature_flags/development/cached_issues_state_count.yml +++ /dev/null @@ -1,8 +0,0 @@ ---- -name: cached_issues_state_count -introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/67418 -rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/333089 -milestone: '14.3' -type: development -group: group::product planning -default_enabled: false diff --git a/doc/development/snowplow/implementation.md b/doc/development/snowplow/implementation.md index 0d81b442850..a7755e285f8 100644 --- a/doc/development/snowplow/implementation.md +++ b/doc/development/snowplow/implementation.md @@ -102,14 +102,12 @@ track_action: "click_button" }) ### Implement Vue component tracking For custom event tracking, use a Vue `mixin` in components. Vue `mixin` exposes the `Tracking.event` -static method and the `track` method called from components or templates. You can specify tracking -options in `data` or `computed`. These options override any defaults and allow the values to be dynamic -from props or based on state. +static method and the `track` method. You can specify tracking options in `data` or `computed`. +These options override any defaults and allow the values to be dynamic from props or based on state. -Default options are passed when an event is tracked from the component. If you don't specify an option, -the default `document.body.dataset.page` is used. The default options are: +Several default options are passed when an event is tracked from the component: -- `category` +- `category`: If you don't specify, by default `document.body.dataset.page` is used. - `label` - `property` - `value` diff --git a/doc/user/project/service_desk.md b/doc/user/project/service_desk.md index fa5ef35418a..61ef68e69af 100644 --- a/doc/user/project/service_desk.md +++ b/doc/user/project/service_desk.md @@ -166,13 +166,13 @@ To edit the custom email display name: > - [Feature flag removed](https://gitlab.com/gitlab-org/gitlab/-/issues/284656) in GitLab 13.8. It is possible to customize the email address used by Service Desk. To do this, you must configure -both a [custom mailbox](#configuring-a-custom-mailbox) and a +a [custom mailbox](#configuring-a-custom-mailbox). If you want you can also configure a [custom suffix](#configuring-a-custom-email-address-suffix). #### Configuring a custom mailbox NOTE: -On GitLab.com a custom mailbox is already configured with `contact-project+%{key}@incoming.gitlab.com` as the email address, so you only have to configure the +On GitLab.com a custom mailbox is already configured with `contact-project+%{key}@incoming.gitlab.com` as the email address, you can still configure the [custom suffix](#configuring-a-custom-email-address-suffix) in project settings. Using the `service_desk_email` configuration, you can customize the mailbox @@ -271,6 +271,8 @@ For example, suppose the `mygroup/myproject` project Service Desk settings has t The Service Desk email address for this project is: `contact+mygroup-myproject-support@example.com`. The [incoming email](../../administration/incoming_email.md) address still works. +If you don't configure the custom suffix, the default project identification will be used for identifying the project. You can see that email address in the project settings. + ## Using Service Desk You can use Service Desk to [create an issue](#as-an-end-user-issue-creator) or [respond to one](#as-a-responder-to-the-issue). diff --git a/lib/gitlab/email/handler/service_desk_handler.rb b/lib/gitlab/email/handler/service_desk_handler.rb index 74c8d0a1fd7..c24ec347dd6 100644 --- a/lib/gitlab/email/handler/service_desk_handler.rb +++ b/lib/gitlab/email/handler/service_desk_handler.rb @@ -15,16 +15,14 @@ module Gitlab PROJECT_KEY_PATTERN = /\A(?<slug>.+)-(?<key>[a-z0-9_]+)\z/.freeze def initialize(mail, mail_key, service_desk_key: nil) - super(mail, mail_key) - - if service_desk_key.present? + if service_desk_key + mail_key ||= service_desk_key @service_desk_key = service_desk_key - elsif !mail_key&.include?('/') && (matched = HANDLER_REGEX.match(mail_key.to_s)) - @project_slug = matched[:project_slug] - @project_id = matched[:project_id]&.to_i - elsif matched = HANDLER_REGEX_LEGACY.match(mail_key.to_s) - @project_path = matched[:project_path] end + + super(mail, mail_key) + + match_project_slug || match_legacy_project_slug end def can_handle? @@ -42,15 +40,29 @@ module Gitlab end end + def match_project_slug + return if mail_key&.include?('/') + return unless matched = HANDLER_REGEX.match(mail_key.to_s) + + @project_slug = matched[:project_slug] + @project_id = matched[:project_id]&.to_i + end + + def match_legacy_project_slug + return unless matched = HANDLER_REGEX_LEGACY.match(mail_key.to_s) + + @project_path = matched[:project_path] + end + def metrics_event :receive_email_service_desk end def project strong_memoize(:project) do - @project = service_desk_key ? project_from_key : super - @project = nil unless @project&.service_desk_enabled? - @project + project_record = super + project_record ||= project_from_key if service_desk_key + project_record&.service_desk_enabled? ? project_record : nil end end diff --git a/qa/qa/resource/bulk_import_group.rb b/qa/qa/resource/bulk_import_group.rb index 5380bb16f10..e8dc2d291b8 100644 --- a/qa/qa/resource/bulk_import_group.rb +++ b/qa/qa/resource/bulk_import_group.rb @@ -59,6 +59,9 @@ module QA } end + # Get import status + # + # @return [String] def import_status response = get(Runtime::API::Request.new(api_client, "/bulk_imports/#{import_id}").url) @@ -69,6 +72,15 @@ module QA parse_body(response)[:status] end + # Get import details + # + # @return [Array] + def import_details + response = get(Runtime::API::Request.new(api_client, "/bulk_imports/#{import_id}/entities").url) + + parse_body(response) + end + private def transform_api_resource(api_resource) diff --git a/qa/qa/specs/features/api/1_manage/bulk_import_project_spec.rb b/qa/qa/specs/features/api/1_manage/bulk_import_project_spec.rb index 25c8683971b..272793b9010 100644 --- a/qa/qa/specs/features/api/1_manage/bulk_import_project_spec.rb +++ b/qa/qa/specs/features/api/1_manage/bulk_import_project_spec.rb @@ -48,6 +48,10 @@ module QA imported_group.reload!.projects end + let(:import_details) do + imported_group.import_details.find { |entity| entity[:destination_name] == source_project.name } + end + before do Runtime::Feature.enable(:bulk_import_projects) Runtime::Feature.enable(:top_level_group_creation_enabled) if staging? @@ -70,6 +74,7 @@ module QA testcase: 'https://gitlab.com/gitlab-org/quality/testcases/-/quality/test_cases/2297' ) do expect { imported_group.import_status }.to eventually_eq('finished').within(import_wait_duration) + expect(import_details[:failures]).to be_empty, "Expected to not have import errors, was: #{import_details}" aggregate_failures do expect(imported_projects.count).to eq(1) @@ -109,6 +114,7 @@ module QA testcase: 'https://gitlab.com/gitlab-org/quality/testcases/-/quality/test_cases/2325' ) do expect { imported_group.import_status }.to eventually_eq('finished').within(import_wait_duration) + expect(import_details[:failures]).to be_empty, "Expected to not have import errors, was: #{import_details}" aggregate_failures do expect(imported_issues.count).to eq(1) diff --git a/spec/controllers/projects/commits_controller_spec.rb b/spec/controllers/projects/commits_controller_spec.rb index 4cf77fde3a1..a8e71d73beb 100644 --- a/spec/controllers/projects/commits_controller_spec.rb +++ b/spec/controllers/projects/commits_controller_spec.rb @@ -67,6 +67,29 @@ RSpec.describe Projects::CommitsController do end end + context "with an invalid limit" do + let(:id) { "master/README.md" } + + it "uses the default limit" do + expect_any_instance_of(Repository).to receive(:commits).with( + "master", + path: "README.md", + limit: described_class::COMMITS_DEFAULT_LIMIT, + offset: 0 + ).and_call_original + + get(:show, + params: { + namespace_id: project.namespace, + project_id: project, + id: id, + limit: "foo" + }) + + expect(response).to be_successful + end + end + context "when the ref name ends in .atom" do context "when the ref does not exist with the suffix" do before do diff --git a/spec/factories/error_tracking/error_event.rb b/spec/factories/error_tracking/error_event.rb index 9620e3999d6..83f38150b11 100644 --- a/spec/factories/error_tracking/error_event.rb +++ b/spec/factories/error_tracking/error_event.rb @@ -63,5 +63,9 @@ FactoryBot.define do level { 'error' } occurred_at { Time.now.iso8601 } payload { Gitlab::Json.parse(File.read(Rails.root.join('spec/fixtures/', 'error_tracking/parsed_event.json'))) } + + trait :browser do + payload { Gitlab::Json.parse(File.read(Rails.root.join('spec/fixtures/', 'error_tracking/browser_event.json'))) } + end end end diff --git a/spec/features/admin/admin_appearance_spec.rb b/spec/features/admin/admin_appearance_spec.rb index cb69eac8035..0785c736cfb 100644 --- a/spec/features/admin/admin_appearance_spec.rb +++ b/spec/features/admin/admin_appearance_spec.rb @@ -94,7 +94,7 @@ RSpec.describe 'Admin Appearance' do sign_in(admin) gitlab_enable_admin_mode_sign_in(admin) visit new_project_path - find('[data-qa-panel-name="blank_project"]').click # rubocop:disable QA/SelectorUsage + click_link 'Create blank project' expect_custom_new_project_appearance(appearance) end diff --git a/spec/features/groups/issues_spec.rb b/spec/features/groups/issues_spec.rb index 489beb70ab3..4e59ab40d04 100644 --- a/spec/features/groups/issues_spec.rb +++ b/spec/features/groups/issues_spec.rb @@ -83,6 +83,18 @@ RSpec.describe 'Group issues page' do end end + it 'truncates issue counts if over the threshold', :clean_gitlab_redis_cache do + allow(Rails.cache).to receive(:read).and_call_original + allow(Rails.cache).to receive(:read).with( + ['group', group.id, 'issues'], + { expires_in: Gitlab::IssuablesCountForState::CACHE_EXPIRES_IN } + ).and_return({ opened: 1050, closed: 500, all: 1550 }) + + visit issues_group_path(group) + + expect(page).to have_text('Open 1.1k Closed 500 All 1.6k') + end + context 'when project is archived' do before do ::Projects::UpdateService.new(project, user_in_group, archived: true).execute @@ -94,41 +106,6 @@ RSpec.describe 'Group issues page' do expect(page).not_to have_content issue.title[0..80] end end - - context 'when cached issues state count is enabled', :clean_gitlab_redis_cache do - before do - stub_feature_flags(cached_issues_state_count: true) - end - - it 'truncates issue counts if over the threshold' do - allow(Rails.cache).to receive(:read).and_call_original - allow(Rails.cache).to receive(:read).with( - ['group', group.id, 'issues'], - { expires_in: Gitlab::IssuablesCountForState::CACHE_EXPIRES_IN } - ).and_return({ opened: 1050, closed: 500, all: 1550 }) - - visit issues_group_path(group) - - expect(page).to have_text('Open 1.1k Closed 500 All 1.6k') - end - end - - context 'when cached issues state count is disabled', :clean_gitlab_redis_cache do - before do - stub_feature_flags(cached_issues_state_count: false) - end - - it 'does not truncate counts if they are over the threshold' do - allow_next_instance_of(IssuesFinder) do |finder| - allow(finder).to receive(:count_by_state).and_return(true) - .and_return({ opened: 1050, closed: 500, all: 1550 }) - end - - visit issues_group_path(group) - - expect(page).to have_text('Open 1,050 Closed 500 All 1,550') - end - end end context 'projects with issues disabled' do diff --git a/spec/features/project_variables_spec.rb b/spec/features/project_variables_spec.rb index 5139c724d82..cc59fea173b 100644 --- a/spec/features/project_variables_spec.rb +++ b/spec/features/project_variables_spec.rb @@ -21,7 +21,7 @@ RSpec.describe 'Project variables', :js do click_button('Add variable') page.within('#add-ci-variable') do - find('[data-qa-selector="ci_variable_key_field"] input').set('akey') # rubocop:disable QA/SelectorUsage + fill_in 'Key', with: 'akey' find('#ci-variable-value').set('akey_value') find('[data-testid="environment-scope"]').click find('[data-testid="ci-environment-search"]').set('review/*') diff --git a/spec/features/projects/import_export/import_file_spec.rb b/spec/features/projects/import_export/import_file_spec.rb index 00e85a215b8..3afd1937652 100644 --- a/spec/features/projects/import_export/import_file_spec.rb +++ b/spec/features/projects/import_export/import_file_spec.rb @@ -31,7 +31,7 @@ RSpec.describe 'Import/Export - project import integration test', :js do it 'user imports an exported project successfully', :sidekiq_might_not_need_inline do visit new_project_path - click_import_project + click_link 'Import project' click_link 'GitLab export' fill_in :name, with: 'Test Project Name', visible: true @@ -50,7 +50,7 @@ RSpec.describe 'Import/Export - project import integration test', :js do visit new_project_path - click_import_project + click_link 'Import project' click_link 'GitLab export' fill_in :name, with: project.name, visible: true attach_file('file', file) @@ -61,8 +61,4 @@ RSpec.describe 'Import/Export - project import integration test', :js do end end end - - def click_import_project - find('[data-qa-panel-name="import_project"]').click # rubocop:disable QA/SelectorUsage - end end diff --git a/spec/features/projects/new_project_spec.rb b/spec/features/projects/new_project_spec.rb index dacbaa826a0..4dedd5689de 100644 --- a/spec/features/projects/new_project_spec.rb +++ b/spec/features/projects/new_project_spec.rb @@ -23,7 +23,7 @@ RSpec.describe 'New project', :js do ) visit new_project_path - find('[data-qa-panel-name="blank_project"]').click # rubocop:disable QA/SelectorUsage + click_link 'Create blank project' expect(page).to have_content 'Other visibility settings have been disabled by the administrator.' end @@ -34,7 +34,7 @@ RSpec.describe 'New project', :js do ) visit new_project_path - find('[data-qa-panel-name="blank_project"]').click # rubocop:disable QA/SelectorUsage + click_link 'Create blank project' expect(page).to have_content 'Visibility settings have been disabled by the administrator.' end @@ -49,14 +49,14 @@ RSpec.describe 'New project', :js do it 'shows "New project" page', :js do visit new_project_path - find('[data-qa-panel-name="blank_project"]').click # rubocop:disable QA/SelectorUsage + click_link 'Create blank project' expect(page).to have_content('Project name') expect(page).to have_content('Project URL') expect(page).to have_content('Project slug') click_link('New project') - find('[data-qa-panel-name="import_project"]').click # rubocop:disable QA/SelectorUsage + click_link 'Import project' expect(page).to have_link('GitHub') expect(page).to have_link('Bitbucket') @@ -69,7 +69,7 @@ RSpec.describe 'New project', :js do before do visit new_project_path - find('[data-qa-panel-name="import_project"]').click # rubocop:disable QA/SelectorUsage + click_link 'Import project' end it 'has Manifest file' do @@ -83,7 +83,7 @@ RSpec.describe 'New project', :js do stub_application_setting(default_project_visibility: level) visit new_project_path - find('[data-qa-panel-name="blank_project"]').click # rubocop:disable QA/SelectorUsage + click_link 'Create blank project' page.within('#blank-project-pane') do expect(find_field("project_visibility_level_#{level}")).to be_checked end @@ -91,7 +91,7 @@ RSpec.describe 'New project', :js do it "saves visibility level #{level} on validation error" do visit new_project_path - find('[data-qa-panel-name="blank_project"]').click # rubocop:disable QA/SelectorUsage + click_link 'Create blank project' choose(key) click_button('Create project') @@ -111,7 +111,7 @@ RSpec.describe 'New project', :js do context 'when admin mode is enabled', :enable_admin_mode do it 'has private selected' do visit new_project_path(namespace_id: group.id) - find('[data-qa-panel-name="blank_project"]').click # rubocop:disable QA/SelectorUsage + click_link 'Create blank project' page.within('#blank-project-pane') do expect(find_field("project_visibility_level_#{Gitlab::VisibilityLevel::PRIVATE}")).to be_checked @@ -138,7 +138,7 @@ RSpec.describe 'New project', :js do context 'when admin mode is enabled', :enable_admin_mode do it 'has private selected' do visit new_project_path(namespace_id: group.id, project: { visibility_level: Gitlab::VisibilityLevel::PRIVATE }) - find('[data-qa-panel-name="blank_project"]').click # rubocop:disable QA/SelectorUsage + click_link 'Create blank project' page.within('#blank-project-pane') do expect(find_field("project_visibility_level_#{Gitlab::VisibilityLevel::PRIVATE}")).to be_checked @@ -159,7 +159,7 @@ RSpec.describe 'New project', :js do context 'Readme selector' do it 'shows the initialize with Readme checkbox on "Blank project" tab' do visit new_project_path - find('[data-qa-panel-name="blank_project"]').click # rubocop:disable QA/SelectorUsage + click_link 'Create blank project' expect(page).to have_css('input#project_initialize_with_readme') expect(page).to have_content('Initialize repository with a README') @@ -167,7 +167,7 @@ RSpec.describe 'New project', :js do it 'does not show the initialize with Readme checkbox on "Create from template" tab' do visit new_project_path - find('[data-qa-panel-name="create_from_template"]').click # rubocop:disable QA/SelectorUsage + click_link 'Create from template' first('.choose-template').click page.within '.project-fields-form' do @@ -178,7 +178,7 @@ RSpec.describe 'New project', :js do it 'does not show the initialize with Readme checkbox on "Import project" tab' do visit new_project_path - find('[data-qa-panel-name="import_project"]').click # rubocop:disable QA/SelectorUsage + click_link 'Import project' first('.js-import-git-toggle-button').click page.within '#import-project-pane' do @@ -192,7 +192,7 @@ RSpec.describe 'New project', :js do context 'with user namespace' do before do visit new_project_path - find('[data-qa-panel-name="blank_project"]').click # rubocop:disable QA/SelectorUsage + click_link 'Create blank project' end it 'selects the user namespace' do @@ -208,7 +208,7 @@ RSpec.describe 'New project', :js do before do group.add_owner(user) visit new_project_path(namespace_id: group.id) - find('[data-qa-panel-name="blank_project"]').click # rubocop:disable QA/SelectorUsage + click_link 'Create blank project' end it 'selects the group namespace' do @@ -225,7 +225,7 @@ RSpec.describe 'New project', :js do before do group.add_maintainer(user) visit new_project_path(namespace_id: subgroup.id) - find('[data-qa-panel-name="blank_project"]').click # rubocop:disable QA/SelectorUsage + click_link 'Create blank project' end it 'selects the group namespace' do @@ -245,7 +245,7 @@ RSpec.describe 'New project', :js do internal_group.add_owner(user) private_group.add_owner(user) visit new_project_path(namespace_id: public_group.id) - find('[data-qa-panel-name="blank_project"]').click # rubocop:disable QA/SelectorUsage + click_link 'Create blank project' end it 'enables the correct visibility options' do @@ -275,7 +275,7 @@ RSpec.describe 'New project', :js do context 'Import project options', :js do before do visit new_project_path - find('[data-qa-panel-name="import_project"]').click # rubocop:disable QA/SelectorUsage + click_link 'Import project' end context 'from git repository url, "Repo by URL"' do @@ -351,7 +351,7 @@ RSpec.describe 'New project', :js do before do group.add_developer(user) visit new_project_path(namespace_id: group.id) - find('[data-qa-panel-name="blank_project"]').click # rubocop:disable QA/SelectorUsage + click_link 'Create blank project' end it 'selects the group namespace' do diff --git a/spec/features/projects/settings/service_desk_setting_spec.rb b/spec/features/projects/settings/service_desk_setting_spec.rb index 0924f8320e1..0df4bd3f0d9 100644 --- a/spec/features/projects/settings/service_desk_setting_spec.rb +++ b/spec/features/projects/settings/service_desk_setting_spec.rb @@ -54,7 +54,7 @@ RSpec.describe 'Service Desk Setting', :js, :clean_gitlab_redis_cache do wait_for_requests project.reload - expect(find('[data-testid="incoming-email"]').value).to eq(project.service_desk_incoming_address) + expect(find('[data-testid="incoming-email"]').value).to eq(project.service_desk_custom_address) page.within '#js-service-desk' do fill_in('service-desk-project-suffix', with: 'foo') diff --git a/spec/features/projects/user_creates_project_spec.rb b/spec/features/projects/user_creates_project_spec.rb index 5d482f9fbd0..f5e8a5e8fc1 100644 --- a/spec/features/projects/user_creates_project_spec.rb +++ b/spec/features/projects/user_creates_project_spec.rb @@ -14,7 +14,7 @@ RSpec.describe 'User creates a project', :js do it 'creates a new project' do visit(new_project_path) - find('[data-qa-panel-name="blank_project"]').click # rubocop:disable QA/SelectorUsage + click_link 'Create blank project' fill_in(:project_name, with: 'Empty') expect(page).to have_checked_field 'Initialize repository with a README' @@ -38,7 +38,7 @@ RSpec.describe 'User creates a project', :js do visit(new_project_path) - find('[data-qa-panel-name="blank_project"]').click # rubocop:disable QA/SelectorUsage + click_link 'Create blank project' fill_in(:project_name, with: 'With initial commits') expect(page).to have_checked_field 'Initialize repository with a README' @@ -67,7 +67,7 @@ RSpec.describe 'User creates a project', :js do it 'creates a new project' do visit(new_project_path) - find('[data-qa-panel-name="blank_project"]').click # rubocop:disable QA/SelectorUsage + click_link 'Create blank project' fill_in :project_name, with: 'A Subgroup Project' fill_in :project_path, with: 'a-subgroup-project' @@ -96,7 +96,7 @@ RSpec.describe 'User creates a project', :js do it 'creates a new project' do visit(new_project_path) - find('[data-qa-panel-name="blank_project"]').click # rubocop:disable QA/SelectorUsage + click_link 'Create blank project' fill_in :project_name, with: 'a-new-project' fill_in :project_path, with: 'a-new-project' diff --git a/spec/features/projects_spec.rb b/spec/features/projects_spec.rb index 59ad7d31ea7..149e8db7178 100644 --- a/spec/features/projects_spec.rb +++ b/spec/features/projects_spec.rb @@ -16,7 +16,7 @@ RSpec.describe 'Project' do shared_examples 'creates from template' do |template, sub_template_tab = nil| it "is created from template", :js do - find('[data-qa-panel-name="create_from_template"]').click # rubocop:disable QA/SelectorUsage + click_link 'Create from template' find(".project-template #{sub_template_tab}").click if sub_template_tab find("label[for=#{template.name}]").click fill_in("project_name", with: template.name) diff --git a/spec/fixtures/emails/service_desk_custom_address_no_key.eml b/spec/fixtures/emails/service_desk_custom_address_no_key.eml new file mode 100644 index 00000000000..4781e3d4fbd --- /dev/null +++ b/spec/fixtures/emails/service_desk_custom_address_no_key.eml @@ -0,0 +1,27 @@ +Return-Path: <jake@adventuretime.ooo> +Received: from iceking.adventuretime.ooo ([unix socket]) by iceking (Cyrus v2.2.13-Debian-2.2.13-19+squeeze3) with LMTPA; Thu, 13 Jun 2013 17:03:50 -0400 +Received: by mail-ie0-f180.google.com with SMTP id f4so21977375iea.25 for <support+project_slug-project_key@example.com>; Thu, 13 Jun 2013 14:03:48 -0700 +Received: by 10.0.0.1 with HTTP; Thu, 13 Jun 2013 14:03:48 -0700 +Date: Thu, 13 Jun 2013 17:03:48 -0400 +From: Jake the Dog <jake@adventuretime.ooo> +To: support+email-test-project_id-issue-@example.com +Message-ID: <CADkmRc+rNGAGGbV2iE5p918UVy4UyJqVcXRO2=otppgzduJSg@mail.gmail.com> +Subject: The message subject! @all +Mime-Version: 1.0 +Content-Type: text/plain; + charset=ISO-8859-1 +Content-Transfer-Encoding: 7bit +X-Sieve: CMU Sieve 2.2 +X-Received: by 10.0.0.1 with SMTP id n7mr11234144ipb.85.1371157428600; Thu, + 13 Jun 2013 14:03:48 -0700 (PDT) +X-Scanned-By: MIMEDefang 2.69 on IPv6:2001:470:1d:165::1 + +Service desk stuff! + +``` +a = b +``` + +/label ~label1 +/assign @user1 +/close diff --git a/spec/fixtures/error_tracking/browser_event.json b/spec/fixtures/error_tracking/browser_event.json new file mode 100644 index 00000000000..65918c3dc7a --- /dev/null +++ b/spec/fixtures/error_tracking/browser_event.json @@ -0,0 +1 @@ +{"sdk":{"name":"sentry.javascript.browser","version":"5.7.1","packages":[{"name":"npm:@sentry/browser","version":"5.7.1"}],"integrations":["InboundFilters","FunctionToString","TryCatch","Breadcrumbs","GlobalHandlers","LinkedErrors","UserAgent","Dedupe","ExtraErrorData","ReportingObserver","RewriteFrames","Vue"]},"level":"error","request":{"url":"http://localhost:5444/","headers":{"User-Agent":"Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:91.0) Gecko/20100101 Firefox/91.0"}},"event_id":"6a32dc45cd924196930e06aa21b48c8d","platform":"javascript","exception":{"values":[{"type":"TypeError","value":"Cannot read property 'filter' of undefined","mechanism":{"type":"generic","handled":true},"stacktrace":{"frames":[{"colno":34,"in_app":true,"lineno":6395,"filename":"webpack-internal:///./node_modules/vue/dist/vue.runtime.esm.js","function":"hydrate"},{"colno":57,"in_app":true,"lineno":6362,"filename":"webpack-internal:///./node_modules/vue/dist/vue.runtime.esm.js","function":"hydrate"},{"colno":13,"in_app":true,"lineno":3115,"filename":"webpack-internal:///./node_modules/vue/dist/vue.runtime.esm.js","function":"init"},{"colno":10,"in_app":true,"lineno":8399,"filename":"webpack-internal:///./node_modules/vue/dist/vue.runtime.esm.js","function":"Vue.prototype.$mount"},{"colno":3,"in_app":true,"lineno":4061,"filename":"webpack-internal:///./node_modules/vue/dist/vue.runtime.esm.js","function":"mountComponent"},{"colno":12,"in_app":true,"lineno":4456,"filename":"webpack-internal:///./node_modules/vue/dist/vue.runtime.esm.js","function":"Watcher"},{"colno":25,"in_app":true,"lineno":4467,"filename":"webpack-internal:///./node_modules/vue/dist/vue.runtime.esm.js","function":"get"},{"colno":10,"in_app":true,"lineno":4048,"filename":"webpack-internal:///./node_modules/vue/dist/vue.runtime.esm.js","function":"updateComponent"},{"colno":19,"in_app":true,"lineno":3933,"filename":"webpack-internal:///./node_modules/vue/dist/vue.runtime.esm.js","function":"lifecycleMixin/Vue.prototype._update"},{"colno":24,"in_app":true,"lineno":6477,"filename":"webpack-internal:///./node_modules/vue/dist/vue.runtime.esm.js","function":"patch"},{"colno":34,"in_app":true,"lineno":6395,"filename":"webpack-internal:///./node_modules/vue/dist/vue.runtime.esm.js","function":"hydrate"},{"colno":64,"in_app":true,"lineno":78,"filename":"webpack-internal:///./node_modules/babel-loader/lib/index.js?!./node_modules/vue-loader/lib/index.js?!./pages/index.vue?vue&type=script&lang=js&","function":"data"}]}}]},"environment":"development"}
\ No newline at end of file diff --git a/spec/frontend/boards/components/sidebar/board_sidebar_labels_select_spec.js b/spec/frontend/boards/components/sidebar/board_sidebar_labels_select_spec.js index 60474767f2d..fb9d823107e 100644 --- a/spec/frontend/boards/components/sidebar/board_sidebar_labels_select_spec.js +++ b/spec/frontend/boards/components/sidebar/board_sidebar_labels_select_spec.js @@ -105,6 +105,7 @@ describe('~/boards/components/sidebar/board_sidebar_labels_select.vue', () => { describe('when labels are updated over existing labels', () => { const testLabelsPayload = [ { id: 5, set: true }, + { id: 6, set: false }, { id: 7, set: true }, ]; const expectedLabels = [{ id: 5 }, { id: 7 }]; diff --git a/spec/frontend/boards/stores/actions_spec.js b/spec/frontend/boards/stores/actions_spec.js index 0b90912a584..e245325b956 100644 --- a/spec/frontend/boards/stores/actions_spec.js +++ b/spec/frontend/boards/stores/actions_spec.js @@ -27,6 +27,7 @@ import issueCreateMutation from '~/boards/graphql/issue_create.mutation.graphql' import actions from '~/boards/stores/actions'; import * as types from '~/boards/stores/mutation_types'; import mutations from '~/boards/stores/mutations'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { mockLists, @@ -1572,12 +1573,13 @@ describe('setActiveIssueLabels', () => { const getters = { activeBoardItem: mockIssue }; const testLabelIds = labels.map((label) => label.id); const input = { - addLabelIds: testLabelIds, + labelIds: testLabelIds, removeLabelIds: [], projectPath: 'h/b', + labels, }; - it('should assign labels on success, and sets loading state for labels', (done) => { + it('should assign labels on success', (done) => { jest .spyOn(gqlClient, 'mutate') .mockResolvedValue({ data: { updateIssue: { issue: { labels: { nodes: labels } } } } }); @@ -1594,14 +1596,6 @@ describe('setActiveIssueLabels', () => { { ...state, ...getters }, [ { - type: types.SET_LABELS_LOADING, - payload: true, - }, - { - type: types.SET_LABELS_LOADING, - payload: false, - }, - { type: types.UPDATE_BOARD_ITEM_BY_ID, payload, }, @@ -1618,6 +1612,64 @@ describe('setActiveIssueLabels', () => { await expect(actions.setActiveIssueLabels({ getters }, input)).rejects.toThrow(Error); }); + + describe('labels_widget FF on', () => { + beforeEach(() => { + window.gon = { + features: { labelsWidget: true }, + }; + + getters.activeBoardItem = { ...mockIssue, labels }; + }); + + afterEach(() => { + window.gon = { + features: {}, + }; + }); + + it('should assign labels', () => { + const payload = { + itemId: getters.activeBoardItem.id, + prop: 'labels', + value: labels, + }; + + testAction( + actions.setActiveIssueLabels, + input, + { ...state, ...getters }, + [ + { + type: types.UPDATE_BOARD_ITEM_BY_ID, + payload, + }, + ], + [], + ); + }); + + it('should remove label', () => { + const payload = { + itemId: getters.activeBoardItem.id, + prop: 'labels', + value: [labels[1]], + }; + + testAction( + actions.setActiveIssueLabels, + { ...input, removeLabelIds: [getIdFromGraphQLId(labels[0].id)] }, + { ...state, ...getters }, + [ + { + type: types.UPDATE_BOARD_ITEM_BY_ID, + payload, + }, + ], + [], + ); + }); + }); }); describe('setActiveItemSubscribed', () => { diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view_spec.js index 8931584e12c..03d015b5624 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view_spec.js +++ b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view_spec.js @@ -51,6 +51,7 @@ describe('DropdownContentsCreateView', () => { const createComponent = ({ mutationHandler = createLabelSuccessHandler, issuableType = IssuableType.Issue, + labelType = 'ProjectLabel', } = {}) => { const mockApollo = createMockApollo([[createLabelMutation, mutationHandler]]); mockApollo.clients.defaultClient.cache.writeQuery({ @@ -68,6 +69,8 @@ describe('DropdownContentsCreateView', () => { propsData: { issuableType, fullPath: '', + attrWorkspacePath: '', + labelType, }, }); }; @@ -174,7 +177,7 @@ describe('DropdownContentsCreateView', () => { }); it('calls a mutation with `groupPath` variable on the epic', () => { - createComponent({ issuableType: IssuableType.Epic }); + createComponent({ issuableType: IssuableType.Epic, labelType: 'GroupLabel' }); fillLabelAttributes(); findCreateButton().vm.$emit('click'); diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view_spec.js index fac3331a2b8..5407e391d7a 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view_spec.js +++ b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view_spec.js @@ -43,6 +43,7 @@ describe('DropdownContentsLabelsView', () => { initialState = mockConfig, queryHandler = successfulQueryHandler, injected = {}, + searchKey = '', } = {}) => { const mockApollo = createMockApollo([[projectLabelsQuery, queryHandler]]); @@ -57,6 +58,7 @@ describe('DropdownContentsLabelsView', () => { ...initialState, localSelectedLabels, issuableType: IssuableType.Issue, + searchKey, }, stubs: { GlSearchBoxByType, @@ -68,7 +70,6 @@ describe('DropdownContentsLabelsView', () => { wrapper.destroy(); }); - const findSearchInput = () => wrapper.findComponent(GlSearchBoxByType); const findLabels = () => wrapper.findAllComponents(LabelItem); const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); const findObserver = () => wrapper.findComponent(GlIntersectionObserver); @@ -81,12 +82,6 @@ describe('DropdownContentsLabelsView', () => { } describe('when loading labels', () => { - it('renders disabled search input field', async () => { - createComponent(); - await makeObserverAppear(); - expect(findSearchInput().props('disabled')).toBe(true); - }); - it('renders loading icon', async () => { createComponent(); await makeObserverAppear(); @@ -107,10 +102,6 @@ describe('DropdownContentsLabelsView', () => { await waitForPromises(); }); - it('renders enabled search input field', async () => { - expect(findSearchInput().props('disabled')).toBe(false); - }); - it('does not render loading icon', async () => { expect(findLoadingIcon().exists()).toBe(false); }); @@ -132,9 +123,9 @@ describe('DropdownContentsLabelsView', () => { }, }, }), + searchKey: '123', }); await makeObserverAppear(); - findSearchInput().vm.$emit('input', '123'); await waitForPromises(); await nextTick(); diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_spec.js index 36704ac5ef3..3b5ef4a8c90 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_spec.js +++ b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_spec.js @@ -4,6 +4,8 @@ import { DropdownVariant } from '~/vue_shared/components/sidebar/labels_select_w import DropdownContents from '~/vue_shared/components/sidebar/labels_select_widget/dropdown_contents.vue'; import DropdownContentsCreateView from '~/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view.vue'; import DropdownContentsLabelsView from '~/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view.vue'; +import DropdownHeader from '~/vue_shared/components/sidebar/labels_select_widget/dropdown_header.vue'; +import DropdownFooter from '~/vue_shared/components/sidebar/labels_select_widget/dropdown_footer.vue'; import { mockLabels } from './mock_data'; @@ -26,7 +28,7 @@ const GlDropdownStub = { describe('DropdownContent', () => { let wrapper; - const createComponent = ({ props = {}, injected = {}, data = {} } = {}) => { + const createComponent = ({ props = {}, data = {} } = {}) => { wrapper = shallowMount(DropdownContents, { propsData: { labelsCreateTitle: 'test', @@ -39,6 +41,8 @@ describe('DropdownContent', () => { variant: 'sidebar', issuableType: 'issue', fullPath: 'test', + labelType: 'ProjectLabel', + attrWorkspacePath: 'path', ...props, }, data() { @@ -46,11 +50,6 @@ describe('DropdownContent', () => { ...data, }; }, - provide: { - allowLabelCreate: true, - labelsManagePath: 'foo/bar', - ...injected, - }, stubs: { GlDropdown: GlDropdownStub, }, @@ -63,13 +62,10 @@ describe('DropdownContent', () => { const findCreateView = () => wrapper.findComponent(DropdownContentsCreateView); const findLabelsView = () => wrapper.findComponent(DropdownContentsLabelsView); + const findDropdownHeader = () => wrapper.findComponent(DropdownHeader); + const findDropdownFooter = () => wrapper.findComponent(DropdownFooter); const findDropdown = () => wrapper.findComponent(GlDropdownStub); - const findDropdownFooter = () => wrapper.find('[data-testid="dropdown-footer"]'); - const findDropdownHeader = () => wrapper.find('[data-testid="dropdown-header"]'); - const findCreateLabelButton = () => wrapper.find('[data-testid="create-label-button"]'); - const findGoBackButton = () => wrapper.find('[data-testid="go-back-button"]'); - it('calls dropdown `show` method on `isVisible` prop change', async () => { createComponent(); await wrapper.setProps({ @@ -136,6 +132,16 @@ describe('DropdownContent', () => { expect(findDropdownHeader().exists()).toBe(true); }); + it('sets searchKey for labels view on input event from header', async () => { + createComponent(); + + expect(wrapper.vm.searchKey).toEqual(''); + findDropdownHeader().vm.$emit('input', '123'); + await nextTick(); + + expect(findLabelsView().props('searchKey')).toEqual('123'); + }); + describe('Create view', () => { beforeEach(() => { createComponent({ data: { showDropdownContentsCreateView: true } }); @@ -149,16 +155,8 @@ describe('DropdownContent', () => { expect(findDropdownFooter().exists()).toBe(false); }); - it('does not render create label button', () => { - expect(findCreateLabelButton().exists()).toBe(false); - }); - - it('renders go back button', () => { - expect(findGoBackButton().exists()).toBe(true); - }); - - it('changes the view to Labels view on back button click', async () => { - findGoBackButton().vm.$emit('click', new MouseEvent('click')); + it('changes the view to Labels view on `toggleDropdownContentsCreateView` event', async () => { + findDropdownHeader().vm.$emit('toggleDropdownContentsCreateView'); await nextTick(); expect(findCreateView().exists()).toBe(false); @@ -198,32 +196,5 @@ describe('DropdownContent', () => { expect(findDropdownFooter().exists()).toBe(true); }); - - it('does not render go back button', () => { - expect(findGoBackButton().exists()).toBe(false); - }); - - it('does not render create label button if `allowLabelCreate` is false', () => { - createComponent({ injected: { allowLabelCreate: false } }); - - expect(findCreateLabelButton().exists()).toBe(false); - }); - - describe('when `allowLabelCreate` is true', () => { - beforeEach(() => { - createComponent(); - }); - - it('renders create label button', () => { - expect(findCreateLabelButton().exists()).toBe(true); - }); - - it('changes the view to Create on create label button click', async () => { - findCreateLabelButton().trigger('click'); - - await nextTick(); - expect(findLabelsView().exists()).toBe(false); - }); - }); }); }); diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_footer_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_footer_spec.js new file mode 100644 index 00000000000..0508a059195 --- /dev/null +++ b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_footer_spec.js @@ -0,0 +1,57 @@ +import { shallowMount } from '@vue/test-utils'; +import { nextTick } from 'vue'; +import DropdownFooter from '~/vue_shared/components/sidebar/labels_select_widget/dropdown_footer.vue'; + +describe('DropdownFooter', () => { + let wrapper; + + const createComponent = ({ props = {}, injected = {} } = {}) => { + wrapper = shallowMount(DropdownFooter, { + propsData: { + footerCreateLabelTitle: 'create', + footerManageLabelTitle: 'manage', + ...props, + }, + provide: { + allowLabelCreate: true, + labelsManagePath: 'foo/bar', + ...injected, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + const findCreateLabelButton = () => wrapper.find('[data-testid="create-label-button"]'); + + describe('Labels view', () => { + beforeEach(() => { + createComponent(); + }); + + it('does not render create label button if `allowLabelCreate` is false', () => { + createComponent({ injected: { allowLabelCreate: false } }); + + expect(findCreateLabelButton().exists()).toBe(false); + }); + + describe('when `allowLabelCreate` is true', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders create label button', () => { + expect(findCreateLabelButton().exists()).toBe(true); + }); + + it('emits `toggleDropdownContentsCreateView` event on create label button click', async () => { + findCreateLabelButton().trigger('click'); + + await nextTick(); + expect(wrapper.emitted('toggleDropdownContentsCreateView')).toEqual([[]]); + }); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_header_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_header_spec.js new file mode 100644 index 00000000000..592559ef305 --- /dev/null +++ b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_header_spec.js @@ -0,0 +1,75 @@ +import { GlSearchBoxByType } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import DropdownHeader from '~/vue_shared/components/sidebar/labels_select_widget/dropdown_header.vue'; + +describe('DropdownHeader', () => { + let wrapper; + + const createComponent = ({ + showDropdownContentsCreateView = false, + labelsFetchInProgress = false, + } = {}) => { + wrapper = extendedWrapper( + shallowMount(DropdownHeader, { + propsData: { + showDropdownContentsCreateView, + labelsFetchInProgress, + labelsCreateTitle: 'Create label', + labelsListTitle: 'Select label', + searchKey: '', + }, + stubs: { + GlSearchBoxByType, + }, + }), + ); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + const findSearchInput = () => wrapper.findComponent(GlSearchBoxByType); + const findGoBackButton = () => wrapper.findByTestId('go-back-button'); + + beforeEach(() => { + createComponent(); + }); + + describe('Create view', () => { + beforeEach(() => { + createComponent({ showDropdownContentsCreateView: true }); + }); + + it('renders go back button', () => { + expect(findGoBackButton().exists()).toBe(true); + }); + + it('does not render search input field', async () => { + expect(findSearchInput().exists()).toBe(false); + }); + }); + + describe('Labels view', () => { + beforeEach(() => { + createComponent(); + }); + + it('does not render go back button', () => { + expect(findGoBackButton().exists()).toBe(false); + }); + + it.each` + labelsFetchInProgress | disabled + ${true} | ${true} + ${false} | ${false} + `( + 'when labelsFetchInProgress is $labelsFetchInProgress, renders search input with disabled prop to $disabled', + ({ labelsFetchInProgress, disabled }) => { + createComponent({ labelsFetchInProgress }); + expect(findSearchInput().props('disabled')).toBe(disabled); + }, + ); + }); +}); diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/labels_select_root_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/labels_select_root_spec.js index b5441d711a5..b21d4194d8e 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/labels_select_root_spec.js +++ b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/labels_select_root_spec.js @@ -41,6 +41,7 @@ describe('LabelsSelectRoot', () => { propsData: { ...config, issuableType: IssuableType.Issue, + labelType: 'ProjectLabel', }, stubs: { SidebarEditableItem, @@ -121,11 +122,11 @@ describe('LabelsSelectRoot', () => { }); }); - it('emits `updateSelectedLabels` event on dropdown contents `setLabels` event', async () => { + it('emits `updateSelectedLabels` event on dropdown contents `setLabels` event if iid is not set', async () => { const label = { id: 'gid://gitlab/ProjectLabel/1' }; - createComponent(); + createComponent({ config: { ...mockConfig, iid: undefined } }); findDropdownContents().vm.$emit('setLabels', [label]); - expect(wrapper.emitted('updateSelectedLabels')).toEqual([[[label]]]); + expect(wrapper.emitted('updateSelectedLabels')).toEqual([[{ labels: [label] }]]); }); }); diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/mock_data.js b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/mock_data.js index 23a457848d9..92f3549b398 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/mock_data.js +++ b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/mock_data.js @@ -40,12 +40,12 @@ export const mockConfig = { labelsListTitle: 'Assign labels', labelsCreateTitle: 'Create label', variant: 'sidebar', - selectedLabels: [mockRegularLabel, mockScopedLabel], labelsSelectInProgress: false, labelsFilterBasePath: '/gitlab-org/my-project/issues', labelsFilterParam: 'label_name', footerCreateLabelTitle: 'create', footerManageLabelTitle: 'manage', + attrWorkspacePath: 'test', }; export const mockSuggestedColors = { diff --git a/spec/helpers/issuables_helper_spec.rb b/spec/helpers/issuables_helper_spec.rb index 30049745433..fa19395ebc7 100644 --- a/spec/helpers/issuables_helper_spec.rb +++ b/spec/helpers/issuables_helper_spec.rb @@ -169,26 +169,9 @@ RSpec.describe IssuablesHelper do stub_const("Gitlab::IssuablesCountForState::THRESHOLD", 1000) end - context 'when feature flag cached_issues_state_count is disabled' do - before do - stub_feature_flags(cached_issues_state_count: false) - end - - it 'returns complete count' do - expect(helper.issuables_state_counter_text(:issues, :opened, true)) - .to eq('<span>Open</span> <span class="badge badge-muted badge-pill gl-badge gl-tab-counter-badge sm gl-display-none gl-sm-display-inline-flex">1,100</span>') - end - end - - context 'when feature flag cached_issues_state_count is enabled' do - before do - stub_feature_flags(cached_issues_state_count: true) - end - - it 'returns truncated count' do - expect(helper.issuables_state_counter_text(:issues, :opened, true)) - .to eq('<span>Open</span> <span class="badge badge-muted badge-pill gl-badge gl-tab-counter-badge sm gl-display-none gl-sm-display-inline-flex">1.1k</span>') - end + it 'returns truncated count' do + expect(helper.issuables_state_counter_text(:issues, :opened, true)) + .to eq('<span>Open</span> <span class="badge badge-muted badge-pill gl-badge gl-tab-counter-badge sm gl-display-none gl-sm-display-inline-flex">1.1k</span>') end end end diff --git a/spec/lib/gitlab/email/handler/service_desk_handler_spec.rb b/spec/lib/gitlab/email/handler/service_desk_handler_spec.rb index 8cb1ccc065b..4fb2dc241dc 100644 --- a/spec/lib/gitlab/email/handler/service_desk_handler_spec.rb +++ b/spec/lib/gitlab/email/handler/service_desk_handler_spec.rb @@ -196,51 +196,64 @@ RSpec.describe Gitlab::Email::Handler::ServiceDeskHandler do end end - context 'when using service desk key' do - let_it_be(:service_desk_key) { 'mykey' } - - let(:email_raw) { service_desk_fixture('emails/service_desk_custom_address.eml') } + context 'when using custom service desk address' do let(:receiver) { Gitlab::Email::ServiceDeskReceiver.new(email_raw) } before do stub_service_desk_email_setting(enabled: true, address: 'support+%{key}@example.com') end - before_all do - create(:service_desk_setting, project: project, project_key: service_desk_key) - end + context 'when using project key' do + let_it_be(:service_desk_key) { 'mykey' } - it_behaves_like 'a new issue request' + let(:email_raw) { service_desk_fixture('emails/service_desk_custom_address.eml') } + + before_all do + create(:service_desk_setting, project: project, project_key: service_desk_key) + end - context 'when there is no project with the key' do - let(:email_raw) { service_desk_fixture('emails/service_desk_custom_address.eml', key: 'some_key') } + it_behaves_like 'a new issue request' - it 'bounces the email' do - expect { receiver.execute }.to raise_error(Gitlab::Email::ProjectNotFound) + context 'when there is no project with the key' do + let(:email_raw) { service_desk_fixture('emails/service_desk_custom_address.eml', key: 'some_key') } + + it 'bounces the email' do + expect { receiver.execute }.to raise_error(Gitlab::Email::ProjectNotFound) + end end - end - context 'when the project slug does not match' do - let(:email_raw) { service_desk_fixture('emails/service_desk_custom_address.eml', slug: 'some-slug') } + context 'when the project slug does not match' do + let(:email_raw) { service_desk_fixture('emails/service_desk_custom_address.eml', slug: 'some-slug') } + + it 'bounces the email' do + expect { receiver.execute }.to raise_error(Gitlab::Email::ProjectNotFound) + end + end + + context 'when there are multiple projects with same key' do + let_it_be(:project_with_same_key) { create(:project, group: group, service_desk_enabled: true) } - it 'bounces the email' do - expect { receiver.execute }.to raise_error(Gitlab::Email::ProjectNotFound) + let(:email_raw) { service_desk_fixture('emails/service_desk_custom_address.eml', slug: project_with_same_key.full_path_slug.to_s) } + + before do + create(:service_desk_setting, project: project_with_same_key, project_key: service_desk_key) + end + + it 'process email for project with matching slug' do + expect { receiver.execute }.to change { Issue.count }.by(1) + expect(Issue.last.project).to eq(project_with_same_key) + end end end - context 'when there are multiple projects with same key' do - let_it_be(:project_with_same_key) { create(:project, group: group, service_desk_enabled: true) } - - let(:email_raw) { service_desk_fixture('emails/service_desk_custom_address.eml', slug: project_with_same_key.full_path_slug.to_s) } + context 'when project key is not set' do + let(:email_raw) { email_fixture('emails/service_desk_custom_address_no_key.eml') } before do - create(:service_desk_setting, project: project_with_same_key, project_key: service_desk_key) + stub_service_desk_email_setting(enabled: true, address: 'support+%{key}@example.com') end - it 'process email for project with matching slug' do - expect { receiver.execute }.to change { Issue.count }.by(1) - expect(Issue.last.project).to eq(project_with_same_key) - end + it_behaves_like 'a new issue request' end end diff --git a/spec/models/error_tracking/error_event_spec.rb b/spec/models/error_tracking/error_event_spec.rb index 8e20eb25353..1268e1997e5 100644 --- a/spec/models/error_tracking/error_event_spec.rb +++ b/spec/models/error_tracking/error_event_spec.rb @@ -37,6 +37,23 @@ RSpec.describe ErrorTracking::ErrorEvent, type: :model do expect(event.stacktrace).to be_kind_of(Array) expect(event.stacktrace.first).to eq(expected_entry) end + + context 'error context is missing' do + let(:event) { create(:error_tracking_error_event, :browser) } + + it 'generates a stacktrace without context' do + expected_entry = { + 'lineNo' => 6395, + 'context' => [], + 'filename' => 'webpack-internal:///./node_modules/vue/dist/vue.runtime.esm.js', + 'function' => 'hydrate', + 'colNo' => 0 + } + + expect(event.stacktrace).to be_kind_of(Array) + expect(event.stacktrace.first).to eq(expected_entry) + end + end end describe '#to_sentry_error_event' do diff --git a/spec/models/project_spec.rb b/spec/models/project_spec.rb index 3f5f0858178..157d2e55536 100644 --- a/spec/models/project_spec.rb +++ b/spec/models/project_spec.rb @@ -1715,13 +1715,19 @@ RSpec.describe Project, factory_default: :keep do allow(::Gitlab::ServiceDeskEmail).to receive(:config).and_return(config) end - it 'returns custom address when project_key is set' do - create(:service_desk_setting, project: project, project_key: 'key1') + context 'when project_key is set' do + it 'returns custom address including the project_key' do + create(:service_desk_setting, project: project, project_key: 'key1') - expect(subject).to eq("foo+#{project.full_path_slug}-key1@bar.com") + expect(subject).to eq("foo+#{project.full_path_slug}-key1@bar.com") + end end - it_behaves_like 'with incoming email address' + context 'when project_key is not set' do + it 'returns custom address including the project full path' do + expect(subject).to eq("foo+#{project.full_path_slug}-#{project.project_id}-issue-@bar.com") + end + end end end diff --git a/spec/views/groups/settings/_transfer.html.haml_spec.rb b/spec/views/groups/settings/_transfer.html.haml_spec.rb index b557c989eae..911eb5b7ab3 100644 --- a/spec/views/groups/settings/_transfer.html.haml_spec.rb +++ b/spec/views/groups/settings/_transfer.html.haml_spec.rb @@ -9,9 +9,9 @@ RSpec.describe 'groups/settings/_transfer.html.haml' do render 'groups/settings/transfer', group: group - expect(rendered).to have_selector '[data-qa-selector="select_group_dropdown"]' # rubocop:disable QA/SelectorUsage - expect(rendered).not_to have_selector '[data-qa-selector="select_group_dropdown"][disabled]' # rubocop:disable QA/SelectorUsage - expect(rendered).not_to have_selector '[data-testid="group-to-transfer-has-linked-subscription-alert"]' + expect(rendered).to have_button 'Select parent group' + expect(rendered).not_to have_button 'Select parent group', disabled: true + expect(rendered).not_to have_text "This group can't be transfered because it is linked to a subscription." end end end diff --git a/workhorse/internal/dependencyproxy/dependencyproxy.go b/workhorse/internal/dependencyproxy/dependencyproxy.go index b21600d5186..0bba2610d9e 100644 --- a/workhorse/internal/dependencyproxy/dependencyproxy.go +++ b/workhorse/internal/dependencyproxy/dependencyproxy.go @@ -67,6 +67,8 @@ func (p *Injector) Inject(w http.ResponseWriter, r *http.Request, sendData strin return } + w.Header().Set("Content-Length", dependencyResponse.Header.Get("Content-Length")) + teeReader := io.TeeReader(dependencyResponse.Body, w) saveFileRequest, err := http.NewRequestWithContext(r.Context(), "POST", r.URL.String()+"/upload", teeReader) if err != nil { @@ -75,8 +77,6 @@ func (p *Injector) Inject(w http.ResponseWriter, r *http.Request, sendData strin saveFileRequest.Header = helper.HeaderClone(r.Header) saveFileRequest.ContentLength = dependencyResponse.ContentLength - w.Header().Del("Content-Length") - nrw := &nullResponseWriter{header: make(http.Header)} p.uploadHandler.ServeHTTP(nrw, saveFileRequest) diff --git a/workhorse/internal/dependencyproxy/dependencyproxy_test.go b/workhorse/internal/dependencyproxy/dependencyproxy_test.go index 37e54c0b756..657ea388e18 100644 --- a/workhorse/internal/dependencyproxy/dependencyproxy_test.go +++ b/workhorse/internal/dependencyproxy/dependencyproxy_test.go @@ -33,7 +33,7 @@ func (f *fakeUploadHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { type errWriter struct{ writes int } -func (w *errWriter) Header() http.Header { return nil } +func (w *errWriter) Header() http.Header { return make(http.Header) } func (w *errWriter) WriteHeader(h int) {} // First call of Write function succeeds while all the subsequent ones fail @@ -112,8 +112,9 @@ func TestInject(t *testing.T) { func TestSuccessfullRequest(t *testing.T) { content := []byte("result") + contentLength := strconv.Itoa(len(content)) originResourceServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Length", strconv.Itoa(len(content))) + w.Header().Set("Content-Length", contentLength) w.Write(content) })) @@ -135,6 +136,7 @@ func TestSuccessfullRequest(t *testing.T) { require.Equal(t, 200, response.Code) require.Equal(t, string(content), response.Body.String()) + require.Equal(t, contentLength, response.Header().Get("Content-Length")) } func TestIncorrectSendData(t *testing.T) { |