diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2021-09-29 06:12:00 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2021-09-29 06:12:00 +0300 |
commit | 616cd97932e5317a7bada6afe6813ec7111ed0c4 (patch) | |
tree | 8c4ee0282e560212ae3fcf49b8d51d1c2ba98a7d | |
parent | be6cab1d69ab884df73b155d8402a8d13289e066 (diff) |
Add latest changes from gitlab-org/gitlab@master
23 files changed, 534 insertions, 215 deletions
diff --git a/.gitlab/issue_templates/Geo Replicate a new Git repository type.md b/.gitlab/issue_templates/Geo Replicate a new Git repository type.md index 476ee14a632..0d822945798 100644 --- a/.gitlab/issue_templates/Geo Replicate a new Git repository type.md +++ b/.gitlab/issue_templates/Geo Replicate a new Git repository type.md @@ -109,7 +109,7 @@ Geo secondary sites have a [Geo tracking database](https://gitlab.com/gitlab-org bin/rake geo:db:migrate ``` -- [ ] Be sure to commit the relevant changes in `ee/db/geo/schema.rb` +- [ ] Be sure to commit the relevant changes in `ee/db/geo/structure.sql` ### Add verification state fields on the Geo primary site diff --git a/.gitlab/issue_templates/Geo Replicate a new blob type.md b/.gitlab/issue_templates/Geo Replicate a new blob type.md index aef983f6495..00a71fa406e 100644 --- a/.gitlab/issue_templates/Geo Replicate a new blob type.md +++ b/.gitlab/issue_templates/Geo Replicate a new blob type.md @@ -110,7 +110,7 @@ Geo secondary sites have a [Geo tracking database](https://gitlab.com/gitlab-org bin/rake geo:db:migrate ``` -- [ ] Be sure to commit the relevant changes in `ee/db/geo/schema.rb` +- [ ] Be sure to commit the relevant changes in `ee/db/geo/structure.sql` ### Add verification state fields on the Geo primary site diff --git a/.rubocop.yml b/.rubocop.yml index 141ba874b21..4bf2392867d 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -28,7 +28,6 @@ AllCops: - 'node_modules/**/*' - 'db/fixtures/**/*' - 'db/schema.rb' - - 'ee/db/geo/schema.rb' - 'tmp/**/*' - 'bin/**/*' - 'generator_templates/**/*' diff --git a/.rubocop_manual_todo.yml b/.rubocop_manual_todo.yml index 80421ca6bfe..0d9c95ccf96 100644 --- a/.rubocop_manual_todo.yml +++ b/.rubocop_manual_todo.yml @@ -2603,7 +2603,6 @@ Rails/IncludeUrlHelper: - 'ee/spec/lib/banzai/filter/cross_project_issuable_information_filter_spec.rb' - 'ee/spec/lib/banzai/filter/issuable_state_filter_spec.rb' - 'lib/gitlab/ci/badge/metadata.rb' - - 'lib/gitlab/email/message/in_product_marketing/helper.rb' - 'spec/helpers/merge_requests_helper_spec.rb' - 'spec/helpers/nav/top_nav_helper_spec.rb' - 'spec/helpers/notify_helper_spec.rb' diff --git a/app/assets/javascripts/sidebar/components/labels/sidebar_labels.vue b/app/assets/javascripts/sidebar/components/labels/sidebar_labels.vue index 9fdf941579d..af426584f4f 100644 --- a/app/assets/javascripts/sidebar/components/labels/sidebar_labels.vue +++ b/app/assets/javascripts/sidebar/components/labels/sidebar_labels.vue @@ -53,30 +53,32 @@ export default { handleDropdownClose() { $(this.$el).trigger('hidden.gl.dropdown'); }, - getUpdateVariables(dropdownLabels) { - const currentLabelIds = this.selectedLabels.map((label) => label.id); - const dropdownLabelIds = dropdownLabels.map((label) => label.id); - const userAddedLabelIds = this.glFeatures.labelsWidget - ? difference(dropdownLabelIds, currentLabelIds) - : dropdownLabels.filter((label) => label.set).map((label) => label.id); - const userRemovedLabelIds = this.glFeatures.labelsWidget - ? difference(currentLabelIds, dropdownLabelIds) - : dropdownLabels.filter((label) => !label.set).map((label) => label.id); + getUpdateVariables(labels) { + let labelIds = []; - const labelIds = difference(union(currentLabelIds, userAddedLabelIds), userRemovedLabelIds); + if (this.glFeatures.labelsWidget) { + labelIds = labels.map(({ id }) => toLabelGid(id)); + } else { + const currentLabelIds = this.selectedLabels.map((label) => label.id); + const userAddedLabelIds = labels.filter((label) => label.set).map((label) => label.id); + const userRemovedLabelIds = labels.filter((label) => !label.set).map((label) => label.id); + + labelIds = difference(union(currentLabelIds, userAddedLabelIds), userRemovedLabelIds).map( + toLabelGid, + ); + } switch (this.issuableType) { case IssuableType.Issue: return { - addLabelIds: userAddedLabelIds, iid: this.iid, projectPath: this.projectPath, - removeLabelIds: userRemovedLabelIds, + labelIds, }; case IssuableType.MergeRequest: return { iid: this.iid, - labelIds: labelIds.map(toLabelGid), + labelIds, operationMode: MutationOperationMode.Replace, projectPath: this.projectPath, }; @@ -152,8 +154,8 @@ export default { :labels-select-in-progress="isLabelsSelectInProgress" :selected-labels="selectedLabels" :variant="$options.variant" + :issuable-type="issuableType" data-qa-selector="labels_block" - @onDropdownClose="handleDropdownClose" @onLabelRemove="handleLabelRemove" @updateSelectedLabels="handleUpdateSelectedLabels" > diff --git a/app/assets/javascripts/sidebar/constants.js b/app/assets/javascripts/sidebar/constants.js index fd43fb80b7f..e593973da82 100644 --- a/app/assets/javascripts/sidebar/constants.js +++ b/app/assets/javascripts/sidebar/constants.js @@ -31,6 +31,10 @@ import updateIssueSubscriptionMutation from '~/sidebar/queries/update_issue_subs import mergeRequestMilestoneMutation from '~/sidebar/queries/update_merge_request_milestone.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'; +import groupLabelsQuery from '~/vue_shared/components/sidebar/labels_select_widget/graphql/group_labels.query.graphql'; +import issueLabelsQuery from '~/vue_shared/components/sidebar/labels_select_widget/graphql/issue_labels.query.graphql'; +import projectLabelsQuery from '~/vue_shared/components/sidebar/labels_select_widget/graphql/project_labels.query.graphql'; import getAlertAssignees from '~/vue_shared/components/sidebar/queries/get_alert_assignees.query.graphql'; import getIssueAssignees from '~/vue_shared/components/sidebar/queries/get_issue_assignees.query.graphql'; import issueParticipantsQuery from '~/vue_shared/components/sidebar/queries/get_issue_participants.query.graphql'; @@ -105,6 +109,17 @@ export const referenceQueries = { }, }; +export const labelsQueries = { + [IssuableType.Issue]: { + issuableQuery: issueLabelsQuery, + workspaceQuery: projectLabelsQuery, + }, + [IssuableType.Epic]: { + issuableQuery: epicLabelsQuery, + workspaceQuery: groupLabelsQuery, + }, +}; + export const dateTypes = { start: 'startDate', due: 'dueDate', diff --git a/app/assets/javascripts/sidebar/mount_sidebar.js b/app/assets/javascripts/sidebar/mount_sidebar.js index 10ab80f4ec2..9f5a2f4ebb0 100644 --- a/app/assets/javascripts/sidebar/mount_sidebar.js +++ b/app/assets/javascripts/sidebar/mount_sidebar.js @@ -241,6 +241,7 @@ function mountMilestoneSelect() { export function mountSidebarLabels() { const el = document.querySelector('.js-sidebar-labels'); + const { fullPath } = getSidebarOptions(); if (!el) { return false; @@ -251,6 +252,7 @@ export function mountSidebarLabels() { apolloProvider, provide: { ...el.dataset, + fullPath, allowLabelCreate: parseBoolean(el.dataset.allowLabelCreate), allowLabelEdit: parseBoolean(el.dataset.canEdit), allowScopedLabels: parseBoolean(el.dataset.allowScopedLabels), 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 0fcc67c0ffa..1e3d7814829 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,9 +1,9 @@ <script> import { GlButton, GlDropdown, GlDropdownItem, GlLink } from '@gitlab/ui'; - +import { __, s__, sprintf } from '~/locale'; import DropdownContentsCreateView from './dropdown_contents_create_view.vue'; import DropdownContentsLabelsView from './dropdown_contents_labels_view.vue'; -import { isDropdownVariantSidebar, isDropdownVariantEmbedded } from './utils'; +import { isDropdownVariantStandalone } from './utils'; export default { components: { @@ -48,10 +48,15 @@ export default { type: String, required: true, }, + issuableType: { + type: String, + required: true, + }, }, data() { return { showDropdownContentsCreateView: false, + localSelectedLabels: [...this.selectedLabels], }; }, computed: { @@ -64,28 +69,42 @@ export default { dropdownTitle() { return this.showDropdownContentsCreateView ? this.labelsCreateTitle : this.labelsListTitle; }, + buttonText() { + if (!this.localSelectedLabels.length) { + return this.dropdownButtonText || __('Label'); + } else if (this.localSelectedLabels.length > 1) { + return sprintf(s__('LabelSelect|%{firstLabelName} +%{remainingLabelCount} more'), { + firstLabelName: this.localSelectedLabels[0].title, + remainingLabelCount: this.localSelectedLabels.length - 1, + }); + } + return this.localSelectedLabels[0].title; + }, showDropdownFooter() { - return ( - !this.showDropdownContentsCreateView && - (this.isDropdownVariantSidebar(this.variant) || - this.isDropdownVariantEmbedded(this.variant)) - ); + return !this.showDropdownContentsCreateView && !this.isStandalone; }, + isStandalone() { + return isDropdownVariantStandalone(this.variant); + }, + }, + mounted() { + this.$refs.dropdown.show(); }, methods: { - showDropdown() { - this.$refs.dropdown.show(); - }, toggleDropdownContentsCreateView() { this.showDropdownContentsCreateView = !this.showDropdownContentsCreateView; }, toggleDropdownContent() { this.toggleDropdownContentsCreateView(); // Required to recalculate dropdown position as its size changes - this.$refs.dropdown.$refs.dropdown.$_popper.scheduleUpdate(); + if (this.$refs.dropdown?.$refs.dropdown) { + this.$refs.dropdown.$refs.dropdown.$_popper.scheduleUpdate(); + } + }, + closeDropdown() { + this.$emit('setLabels', this.localSelectedLabels); + this.$refs.dropdown.hide(); }, - isDropdownVariantSidebar, - isDropdownVariantEmbedded, }, }; </script> @@ -93,14 +112,16 @@ export default { <template> <gl-dropdown ref="dropdown" - :text="dropdownButtonText" + :text="buttonText" class="gl-w-full gl-mt-2" data-qa-selector="labels_dropdown_content" + @hide="$emit('setLabels', localSelectedLabels)" > <template #header> <div - v-if="isDropdownVariantSidebar(variant) || isDropdownVariantEmbedded(variant)" + 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" @@ -119,27 +140,31 @@ export default { size="small" class="dropdown-header-button gl-p-0!" icon="close" - @click="$emit('closeDropdown')" + data-testid="close-button" + @click="closeDropdown" /> </div> </template> - <component - :is="dropdownContentsView" - :selected-labels="selectedLabels" - :allow-multiselect="allowMultiselect" - @hideCreateView="toggleDropdownContentsCreateView" - @setLabels="$emit('setLabels', $event)" - /> + <template #default> + <component + :is="dropdownContentsView" + v-model="localSelectedLabels" + :selected-labels="selectedLabels" + :allow-multiselect="allowMultiselect" + :issuable-type="issuableType" + @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.native.capture.stop="toggleDropdownContent" + @click.capture.native.stop="toggleDropdownContent" > {{ footerCreateLabelTitle }} </gl-dropdown-item> - <gl-dropdown-item :href="labelsManagePath" @click.native.capture.stop> + <gl-dropdown-item :href="labelsManagePath" @click.capture.native.stop> {{ footerManageLabelTitle }} </gl-dropdown-item> </div> 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 2e31b386fdd..0f660c92e7c 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,9 +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 projectLabelsQuery from './graphql/project_labels.query.graphql'; const errorMessage = __('Error creating label.'); @@ -19,10 +20,16 @@ export default { GlTooltip: GlTooltipDirective, }, inject: { - projectPath: { + fullPath: { default: '', }, }, + props: { + issuableType: { + type: String, + required: true, + }, + }, data() { return { labelTitle: '', @@ -38,6 +45,19 @@ export default { const colorsMap = gon.suggested_label_colors; return Object.keys(colorsMap).map((color) => ({ [color]: colorsMap[color] })); }, + mutationVariables() { + return this.issuableType === IssuableType.Epic + ? { + title: this.labelTitle, + color: this.selectedColor, + groupPath: this.fullPath, + } + : { + title: this.labelTitle, + color: this.selectedColor, + projectPath: this.fullPath, + }; + }, }, methods: { getColorCode(color) { @@ -51,8 +71,8 @@ export default { }, updateLabelsInCache(store, label) { const sourceData = store.readQuery({ - query: projectLabelsQuery, - variables: { fullPath: this.projectPath, searchTerm: '' }, + query: labelsQueries[this.issuableType].workspaceQuery, + variables: { fullPath: this.fullPath, searchTerm: '' }, }); const collator = new Intl.Collator('en'); @@ -63,8 +83,8 @@ export default { }); store.writeQuery({ - query: projectLabelsQuery, - variables: { fullPath: this.projectPath, searchTerm: '' }, + query: labelsQueries[this.issuableType].workspaceQuery, + variables: { fullPath: this.fullPath, searchTerm: '' }, data, }); }, @@ -75,11 +95,7 @@ export default { data: { labelCreate }, } = await this.$apollo.mutate({ mutation: createLabelMutation, - variables: { - title: this.labelTitle, - color: this.selectedColor, - projectPath: this.projectPath, - }, + variables: this.mutationVariables, update: ( store, { 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 857367a0721..e8c42226ca8 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,12 +1,18 @@ <script> -import { GlDropdownForm, GlDropdownItem, GlLoadingIcon, GlSearchBoxByType } from '@gitlab/ui'; +import { + GlDropdownForm, + GlDropdownItem, + GlLoadingIcon, + GlSearchBoxByType, + 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 projectLabelsQuery from './graphql/project_labels.query.graphql'; +import { labelsQueries } from '~/sidebar/constants'; import LabelItem from './label_item.vue'; export default { @@ -15,9 +21,13 @@ export default { GlDropdownItem, GlLoadingIcon, GlSearchBoxByType, + GlIntersectionObserver, LabelItem, }, - inject: ['projectPath'], + inject: ['fullPath'], + model: { + prop: 'localSelectedLabels', + }, props: { selectedLabels: { type: Array, @@ -27,20 +37,29 @@ export default { type: Boolean, required: true, }, + issuableType: { + type: String, + required: true, + }, + localSelectedLabels: { + type: Array, + required: true, + }, }, data() { return { searchKey: '', labels: [], - localSelectedLabels: [...this.selectedLabels], }; }, apollo: { labels: { - query: projectLabelsQuery, + query() { + return labelsQueries[this.issuableType].workspaceQuery; + }, variables() { return { - fullPath: this.projectPath, + fullPath: this.fullPath, searchTerm: this.searchKey, }; }, @@ -50,8 +69,8 @@ export default { update: (data) => data.workspace?.labels?.nodes || [], async result() { if (this.$refs.searchInput) { - await this.$nextTick(); - this.$refs.searchInput.focusInput(); + await this.$nextTick; + this.focusInputField(); } }, error() { @@ -82,7 +101,6 @@ export default { this.debouncedSearchKeyUpdate = debounce(this.setSearchKey, DEFAULT_DEBOUNCE_AND_THROTTLE_MS); }, beforeDestroy() { - this.$emit('setLabels', this.localSelectedLabels); this.debouncedSearchKeyUpdate.cancel(); }, methods: { @@ -109,16 +127,19 @@ export default { } }, updateSelectedLabels(label) { + let labels; if (this.isLabelSelected(label)) { - this.localSelectedLabels = this.localSelectedLabels.filter( - ({ id }) => id !== getIdFromGraphQLId(label.id), - ); + labels = this.localSelectedLabels.filter(({ id }) => id !== getIdFromGraphQLId(label.id)); } else { - this.localSelectedLabels.push({ - ...label, - id: getIdFromGraphQLId(label.id), - }); + labels = [ + ...this.localSelectedLabels, + { + ...label, + id: getIdFromGraphQLId(label.id), + }, + ]; } + this.$emit('input', labels); }, handleLabelClick(label) { this.updateSelectedLabels(label); @@ -129,46 +150,51 @@ export default { setSearchKey(value) { this.searchKey = value; }, + focusInputField() { + this.$refs.searchInput.focusInput(); + }, }, }; </script> <template> - <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" - class="labels-fetch-loading gl-align-items-center gl-w-full gl-h-full" - size="md" + <gl-intersection-observer @appear="focusInputField"> + <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" /> - <template v-else> - <gl-dropdown-item - v-for="label in visibleLabels" - :key="label.id" - :is-checked="isLabelSelected(label)" - :is-check-centered="true" - :is-check-item="true" - data-testid="labels-list" - @click.native.capture.stop="handleLabelClick(label)" - > - <label-item :label="label" /> - </gl-dropdown-item> - <gl-dropdown-item - v-show="showNoMatchingResultsMessage" - class="gl-p-3 gl-text-center" - data-testid="no-results" - > - {{ __('No matching results') }} - </gl-dropdown-item> - </template> - </div> - </gl-dropdown-form> + <div ref="labelsListContainer" data-testid="dropdown-content"> + <gl-loading-icon + v-if="labelsFetchInProgress" + class="labels-fetch-loading gl-align-items-center gl-w-full gl-h-full gl-mb-3" + size="md" + /> + <template v-else> + <gl-dropdown-item + v-for="label in visibleLabels" + :key="label.id" + :is-checked="isLabelSelected(label)" + :is-check-centered="true" + :is-check-item="true" + data-testid="labels-list" + @click.native.capture.stop="handleLabelClick(label)" + > + <label-item :label="label" /> + </gl-dropdown-item> + <gl-dropdown-item + v-show="showNoMatchingResultsMessage" + class="gl-p-3 gl-text-center" + data-testid="no-results" + > + {{ __('No matching results') }} + </gl-dropdown-item> + </template> + </div> + </gl-dropdown-form> + </gl-intersection-observer> </template> diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/epic_labels.query.graphql b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/epic_labels.query.graphql new file mode 100644 index 00000000000..a2e8579486f --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/epic_labels.query.graphql @@ -0,0 +1,15 @@ +query epicLabels($fullPath: ID!, $iid: ID) { + workspace: group(fullPath: $fullPath) { + issuable: epic(iid: $iid) { + id + labels { + nodes { + id + title + color + description + } + } + } + } +} diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/group_labels.query.graphql b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/group_labels.query.graphql new file mode 100644 index 00000000000..acc9bcd2015 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/group_labels.query.graphql @@ -0,0 +1,12 @@ +query groupLabels($fullPath: ID!, $searchTerm: String) { + workspace: group(fullPath: $fullPath) { + labels(searchTerm: $searchTerm, onlyGroupLabels: true) { + nodes { + id + title + color + description + } + } + } +} 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 3c834770563..496bb9817f0 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,21 +1,18 @@ <script> -import Vue from 'vue'; -import Vuex from 'vuex'; +import createFlash from '~/flash'; import { __ } from '~/locale'; import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue'; +import { labelsQueries } from '~/sidebar/constants'; import { DropdownVariant } from './constants'; import DropdownContents from './dropdown_contents.vue'; import DropdownValue from './dropdown_value.vue'; import DropdownValueCollapsed from './dropdown_value_collapsed.vue'; -import issueLabelsQuery from './graphql/issue_labels.query.graphql'; import { isDropdownVariantSidebar, isDropdownVariantStandalone, isDropdownVariantEmbedded, } from './utils'; -Vue.use(Vuex); - export default { components: { DropdownValue, @@ -23,7 +20,15 @@ export default { DropdownValueCollapsed, SidebarEditableItem, }, - inject: ['iid', 'projectPath', 'allowLabelEdit'], + inject: { + iid: { + default: '', + }, + allowLabelEdit: { + default: false, + }, + fullPath: {}, + }, props: { allowLabelRemove: { type: Boolean, @@ -90,43 +95,52 @@ export default { required: false, default: false, }, + issuableType: { + type: String, + required: true, + }, }, data() { return { contentIsOnViewport: true, - issueLabels: [], + issuableLabels: [], }; }, + computed: { + isLoading() { + return this.labelsSelectInProgress || this.$apollo.queries.issuableLabels.loading; + }, + }, apollo: { - issueLabels: { - query: issueLabelsQuery, + issuableLabels: { + query() { + return labelsQueries[this.issuableType].issuableQuery; + }, + skip() { + return !isDropdownVariantSidebar(this.variant); + }, variables() { return { iid: this.iid, - fullPath: this.projectPath, + fullPath: this.fullPath, }; }, update(data) { return data.workspace?.issuable?.labels.nodes || []; }, + error() { + createFlash({ message: __('Error fetching labels.') }); + }, }, }, methods: { handleDropdownClose(labels) { - if (labels.length) this.$emit('updateSelectedLabels', labels); - this.$emit('onDropdownClose'); - }, - collapseDropdown() { - this.$refs.editable.collapse(); + this.$emit('updateSelectedLabels', labels); + this.$refs.editable?.collapse(); }, handleCollapsedValueClick() { this.$emit('toggleCollapse'); }, - showDropdown() { - this.$nextTick(() => { - this.$refs.dropdownContents.showDropdown(); - }); - }, isDropdownVariantSidebar, isDropdownVariantStandalone, isDropdownVariantEmbedded, @@ -145,20 +159,19 @@ export default { <template v-if="isDropdownVariantSidebar(variant)"> <dropdown-value-collapsed ref="dropdownButtonCollapsed" - :labels="issueLabels" + :labels="issuableLabels" @onValueClick="handleCollapsedValueClick" /> <sidebar-editable-item ref="editable" :title="__('Labels')" - :loading="labelsSelectInProgress" + :loading="isLoading" :can-edit="allowLabelEdit" - @open="showDropdown" > <template #collapsed> <dropdown-value :disable-labels="labelsSelectInProgress" - :selected-labels="issueLabels" + :selected-labels="issuableLabels" :allow-label-remove="allowLabelRemove" :labels-filter-base-path="labelsFilterBasePath" :labels-filter-param="labelsFilterParam" @@ -170,7 +183,7 @@ export default { <template #default="{ edit }"> <dropdown-value :disable-labels="labelsSelectInProgress" - :selected-labels="issueLabels" + :selected-labels="issuableLabels" :allow-label-remove="allowLabelRemove" :labels-filter-base-path="labelsFilterBasePath" :labels-filter-param="labelsFilterParam" @@ -181,7 +194,6 @@ export default { </dropdown-value> <dropdown-contents v-if="edit" - ref="dropdownContents" :dropdown-button-text="dropdownButtonText" :allow-multiselect="allowMultiselect" :labels-list-title="labelsListTitle" @@ -190,11 +202,25 @@ export default { :labels-create-title="labelsCreateTitle" :selected-labels="selectedLabels" :variant="variant" - @closeDropdown="collapseDropdown" + :issuable-type="issuableType" @setLabels="handleDropdownClose" /> </template> </sidebar-editable-item> </template> + <dropdown-contents + v-else + ref="dropdownContents" + :allow-multiselect="allowMultiselect" + :dropdown-button-text="dropdownButtonText" + :labels-list-title="labelsListTitle" + :footer-create-label-title="footerCreateLabelTitle" + :footer-manage-label-title="footerManageLabelTitle" + :labels-create-title="labelsCreateTitle" + :selected-labels="selectedLabels" + :variant="variant" + :issuable-type="issuableType" + @setLabels="handleDropdownClose" + /> </div> </template> diff --git a/danger/database/Dangerfile b/danger/database/Dangerfile index 3018196ddbc..693c03b9dad 100644 --- a/danger/database/Dangerfile +++ b/danger/database/Dangerfile @@ -33,7 +33,7 @@ MSG DATABASE_APPROVED_LABEL = 'database::approved' non_geo_db_schema_updated = !git.modified_files.grep(%r{\Adb/structure\.sql}).empty? -geo_db_schema_updated = !git.modified_files.grep(%r{\Aee/db/geo/schema\.rb}).empty? +geo_db_schema_updated = !git.modified_files.grep(%r{\Aee/db/geo/structure\.sql}).empty? non_geo_migration_created = !git.added_files.grep(%r{\A(db/(post_)?migrate)/}).empty? geo_migration_created = !git.added_files.grep(%r{\Aee/db/geo/(post_)?migrate/}).empty? @@ -45,7 +45,7 @@ if non_geo_migration_created && !non_geo_db_schema_updated end if geo_migration_created && !geo_db_schema_updated - warn format(format_str, migrations: 'Geo migrations', schema: helper.html_link("ee/db/geo/schema.rb")) + warn format(format_str, migrations: 'Geo migrations', schema: helper.html_link("ee/db/geo/structure.sql")) end return unless helper.ci? diff --git a/doc/ci/jobs/ci_job_token.md b/doc/ci/jobs/ci_job_token.md index 70c22d566e5..6b4505ad1b3 100644 --- a/doc/ci/jobs/ci_job_token.md +++ b/doc/ci/jobs/ci_job_token.md @@ -12,9 +12,9 @@ When a pipeline job is about to run, GitLab generates a unique token and injects You can use a GitLab CI/CD job token to authenticate with specific API endpoints: - Packages: - - [Package Registry](../../user/packages/package_registry/index.md). To push to the + - [Package Registry](../../user/packages/package_registry/index.md#use-gitlab-cicd-to-build-packages). To push to the Package Registry, you can use [deploy tokens](../../user/project/deploy_tokens/index.md). - - [Container Registry](../../user/packages/container_registry/index.md) + - [Container Registry](../../user/packages/container_registry/index.md#build-and-push-by-using-gitlab-cicd) (the `$CI_REGISTRY_PASSWORD` is `$CI_JOB_TOKEN`). - [Container Registry API](../../api/container_registry.md) (scoped to the job's project, when the `ci_job_token_scope` feature flag is enabled). diff --git a/lib/gitlab/email/message/in_product_marketing/base.rb b/lib/gitlab/email/message/in_product_marketing/base.rb index 96551c89837..c4895d35a14 100644 --- a/lib/gitlab/email/message/in_product_marketing/base.rb +++ b/lib/gitlab/email/message/in_product_marketing/base.rb @@ -50,7 +50,7 @@ module Gitlab def cta_link case format when :html - link_to cta_text, group_email_campaigns_url(group, track: track, series: series), target: '_blank', rel: 'noopener noreferrer' + ActionController::Base.helpers.link_to cta_text, group_email_campaigns_url(group, track: track, series: series), target: '_blank', rel: 'noopener noreferrer' else [cta_text, group_email_campaigns_url(group, track: track, series: series)].join(' >> ') end @@ -89,7 +89,7 @@ module Gitlab case format when :html links.map do |text, link| - link_to(text, link) + ActionController::Base.helpers.link_to(text, link) end else '| ' + links.map do |text, link| diff --git a/lib/gitlab/email/message/in_product_marketing/helper.rb b/lib/gitlab/email/message/in_product_marketing/helper.rb index 4780e08322a..cec0aad44a6 100644 --- a/lib/gitlab/email/message/in_product_marketing/helper.rb +++ b/lib/gitlab/email/message/in_product_marketing/helper.rb @@ -7,7 +7,6 @@ module Gitlab module Helper include ActionView::Context include ActionView::Helpers::TagHelper - include ActionView::Helpers::UrlHelper private @@ -32,7 +31,7 @@ module Gitlab def link(text, link) case format when :html - link_to text, link + ActionController::Base.helpers.link_to text, link else "#{text} (#{link})" end diff --git a/spec/frontend/sidebar/sidebar_labels_spec.js b/spec/frontend/sidebar/sidebar_labels_spec.js index 7455f684380..1141db5b812 100644 --- a/spec/frontend/sidebar/sidebar_labels_spec.js +++ b/spec/frontend/sidebar/sidebar_labels_spec.js @@ -110,10 +110,9 @@ describe('sidebar labels', () => { mutation: updateIssueLabelsMutation, variables: { input: { - addLabelIds: [40], iid: defaultProps.iid, projectPath: defaultProps.projectPath, - removeLabelIds: [26, 55], + labelIds: [toLabelGid(29), toLabelGid(28), toLabelGid(27), toLabelGid(40)], }, }, }; 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 843298a1406..2e43ebe3f80 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 @@ -5,13 +5,14 @@ import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; import createFlash from '~/flash'; +import { IssuableType } from '~/issue_show/constants'; +import { labelsQueries } from '~/sidebar/constants'; import DropdownContentsCreateView from '~/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view.vue'; import createLabelMutation from '~/vue_shared/components/sidebar/labels_select_widget/graphql/create_label.mutation.graphql'; -import projectLabelsQuery from '~/vue_shared/components/sidebar/labels_select_widget/graphql/project_labels.query.graphql'; import { mockSuggestedColors, createLabelSuccessfulResponse, - labelsQueryResponse, + workspaceLabelsQueryResponse, } from './mock_data'; jest.mock('~/flash'); @@ -47,11 +48,14 @@ describe('DropdownContentsCreateView', () => { findAllColors().at(0).vm.$emit('click', new Event('mouseclick')); }; - const createComponent = ({ mutationHandler = createLabelSuccessHandler } = {}) => { + const createComponent = ({ + mutationHandler = createLabelSuccessHandler, + issuableType = IssuableType.Issue, + } = {}) => { const mockApollo = createMockApollo([[createLabelMutation, mutationHandler]]); mockApollo.clients.defaultClient.cache.writeQuery({ - query: projectLabelsQuery, - data: labelsQueryResponse.data, + query: labelsQueries[issuableType].workspaceQuery, + data: workspaceLabelsQueryResponse.data, variables: { fullPath: '', searchTerm: '', @@ -61,6 +65,9 @@ describe('DropdownContentsCreateView', () => { wrapper = shallowMount(DropdownContentsCreateView, { localVue, apolloProvider: mockApollo, + propsData: { + issuableType, + }, }); }; @@ -135,15 +142,6 @@ describe('DropdownContentsCreateView', () => { expect(findCreateButton().props('disabled')).toBe(false); }); - it('calls a mutation with correct parameters on Create button click', () => { - findCreateButton().vm.$emit('click'); - expect(createLabelSuccessHandler).toHaveBeenCalledWith({ - color: '#009966', - projectPath: '', - title: 'Test title', - }); - }); - it('renders a loader spinner after Create button click', async () => { findCreateButton().vm.$emit('click'); await nextTick(); @@ -162,6 +160,30 @@ describe('DropdownContentsCreateView', () => { }); }); + it('calls a mutation with `projectPath` variable on the issue', () => { + createComponent(); + fillLabelAttributes(); + findCreateButton().vm.$emit('click'); + + expect(createLabelSuccessHandler).toHaveBeenCalledWith({ + color: '#009966', + projectPath: '', + title: 'Test title', + }); + }); + + it('calls a mutation with `groupPath` variable on the epic', () => { + createComponent({ issuableType: IssuableType.Epic }); + fillLabelAttributes(); + findCreateButton().vm.$emit('click'); + + expect(createLabelSuccessHandler).toHaveBeenCalledWith({ + color: '#009966', + groupPath: '', + title: 'Test title', + }); + }); + it('calls createFlash is mutation has a user-recoverable error', async () => { createComponent({ mutationHandler: createLabelUserRecoverableErrorHandler }); fillLabelAttributes(); 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 537bbc8e71e..effb365d67e 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 @@ -1,36 +1,38 @@ -import { GlLoadingIcon, GlSearchBoxByType } from '@gitlab/ui'; +import { GlLoadingIcon, GlSearchBoxByType, GlDropdownItem } from '@gitlab/ui'; import { shallowMount, createLocalVue } from '@vue/test-utils'; import { nextTick } from 'vue'; import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; import createFlash from '~/flash'; +import { IssuableType } from '~/issue_show/constants'; import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; import { DropdownVariant } from '~/vue_shared/components/sidebar/labels_select_widget/constants'; import DropdownContentsLabelsView from '~/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view.vue'; import projectLabelsQuery from '~/vue_shared/components/sidebar/labels_select_widget/graphql/project_labels.query.graphql'; import LabelItem from '~/vue_shared/components/sidebar/labels_select_widget/label_item.vue'; -import { mockConfig, labelsQueryResponse } from './mock_data'; +import { mockConfig, workspaceLabelsQueryResponse } from './mock_data'; jest.mock('~/flash'); const localVue = createLocalVue(); localVue.use(VueApollo); -const selectedLabels = [ +const localSelectedLabels = [ { - id: 28, - title: 'Bug', - description: 'Label for bugs', - color: '#FF0000', - textColor: '#FFFFFF', + color: '#2f7b2e', + description: null, + id: 'gid://gitlab/ProjectLabel/2', + title: 'Label2', }, ]; describe('DropdownContentsLabelsView', () => { let wrapper; - const successfulQueryHandler = jest.fn().mockResolvedValue(labelsQueryResponse); + const successfulQueryHandler = jest.fn().mockResolvedValue(workspaceLabelsQueryResponse); + + const findFirstLabel = () => wrapper.findAllComponents(GlDropdownItem).at(0); const createComponent = ({ initialState = mockConfig, @@ -43,14 +45,15 @@ describe('DropdownContentsLabelsView', () => { localVue, apolloProvider: mockApollo, provide: { - projectPath: 'test', + fullPath: 'test', iid: 1, variant: DropdownVariant.Sidebar, ...injected, }, propsData: { ...initialState, - selectedLabels, + localSelectedLabels, + issuableType: IssuableType.Issue, }, stubs: { GlSearchBoxByType, @@ -129,6 +132,15 @@ describe('DropdownContentsLabelsView', () => { createComponent({ queryHandler: jest.fn().mockRejectedValue('Houston, we have a problem!') }); jest.advanceTimersByTime(DEFAULT_DEBOUNCE_AND_THROTTLE_MS); await waitForPromises(); + expect(createFlash).toHaveBeenCalled(); }); + + it('emits an `input` event on label click', async () => { + createComponent(); + await waitForPromises(); + findFirstLabel().trigger('click'); + + expect(wrapper.emitted('input')[0][0]).toEqual(expect.arrayContaining(localSelectedLabels)); + }); }); 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 a1b40a891ec..38fd1cc0a3d 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 @@ -1,6 +1,5 @@ -import { GlDropdown } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; - +import { nextTick } from 'vue'; import { DropdownVariant } from '~/vue_shared/components/sidebar/labels_select_widget/constants'; 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'; @@ -8,10 +7,25 @@ import DropdownContentsLabelsView from '~/vue_shared/components/sidebar/labels_s import { mockLabels } from './mock_data'; +const showDropdown = jest.fn(); + +const GlDropdownStub = { + template: ` + <div data-testid="dropdown"> + <slot name="header"></slot> + <slot></slot> + <slot name="footer"></slot> + </div> + `, + methods: { + show: showDropdown, + }, +}; + describe('DropdownContent', () => { let wrapper; - const createComponent = ({ props = {}, injected = {} } = {}) => { + const createComponent = ({ props = {}, injected = {}, data = {} } = {}) => { wrapper = shallowMount(DropdownContents, { propsData: { labelsCreateTitle: 'test', @@ -22,38 +36,76 @@ describe('DropdownContent', () => { footerManageLabelTitle: 'manage', dropdownButtonText: 'Labels', variant: 'sidebar', + issuableType: 'issue', ...props, }, + data() { + return { + ...data, + }; + }, provide: { allowLabelCreate: true, labelsManagePath: 'foo/bar', ...injected, }, stubs: { - GlDropdown, + GlDropdown: GlDropdownStub, }, }); }; - beforeEach(() => { - createComponent(); - }); - afterEach(() => { wrapper.destroy(); }); + const findCreateView = () => wrapper.findComponent(DropdownContentsCreateView); + const findLabelsView = () => wrapper.findComponent(DropdownContentsLabelsView); + 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 component mount', () => { + createComponent(); + + expect(showDropdown).toHaveBeenCalled(); + }); + + it('emits `setLabels` event on dropdown hide', () => { + createComponent(); + findDropdown().vm.$emit('hide'); + + expect(wrapper.emitted('setLabels')).toEqual([[mockLabels]]); + }); + + it('does not render header on standalone variant', () => { + createComponent({ props: { variant: DropdownVariant.Standalone } }); + + expect(findDropdownHeader().exists()).toBe(false); + }); + + it('renders header on embedded variant', () => { + createComponent({ props: { variant: DropdownVariant.Embedded } }); + + expect(findDropdownHeader().exists()).toBe(true); + }); + + it('renders header on sidebar variant', () => { + createComponent(); + + expect(findDropdownHeader().exists()).toBe(true); + }); + describe('Create view', () => { beforeEach(() => { - wrapper.vm.toggleDropdownContentsCreateView(); + createComponent({ data: { showDropdownContentsCreateView: true } }); }); it('renders create view when `showDropdownContentsCreateView` prop is `true`', () => { - expect(wrapper.findComponent(DropdownContentsCreateView).exists()).toBe(true); + expect(findCreateView().exists()).toBe(true); }); it('does not render footer', () => { @@ -67,11 +119,31 @@ describe('DropdownContent', () => { 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')); + await nextTick(); + + expect(findCreateView().exists()).toBe(false); + expect(findLabelsView().exists()).toBe(true); + }); + + it('changes the view to Labels view on `hideCreateView` event', async () => { + findCreateView().vm.$emit('hideCreateView'); + await nextTick(); + + expect(findCreateView().exists()).toBe(false); + expect(findLabelsView().exists()).toBe(true); + }); }); describe('Labels view', () => { + beforeEach(() => { + createComponent(); + }); + it('renders labels view when `showDropdownContentsCreateView` when `showDropdownContentsCreateView` prop is `false`', () => { - expect(wrapper.findComponent(DropdownContentsLabelsView).exists()).toBe(true); + expect(findLabelsView().exists()).toBe(true); }); it('renders footer on sidebar dropdown', () => { @@ -109,19 +181,12 @@ describe('DropdownContent', () => { expect(findCreateLabelButton().exists()).toBe(true); }); - it('triggers `toggleDropdownContent` method on create label button click', () => { - jest.spyOn(wrapper.vm, 'toggleDropdownContent').mockImplementation(() => {}); + it('changes the view to Create on create label button click', async () => { findCreateLabelButton().trigger('click'); - expect(wrapper.vm.toggleDropdownContent).toHaveBeenCalled(); + await nextTick(); + expect(findLabelsView().exists()).toBe(false); }); }); }); - - describe('template', () => { - it('renders component container element with classes `gl-w-full gl-mt-2` and no styles', () => { - expect(wrapper.attributes('class')).toContain('gl-w-full gl-mt-2'); - expect(wrapper.attributes('style')).toBeUndefined(); - }); - }); }); 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 a18511fa21d..0c0319376b7 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 @@ -1,28 +1,59 @@ -import { shallowMount } from '@vue/test-utils'; +import { shallowMount, createLocalVue } from '@vue/test-utils'; +import { nextTick } from 'vue'; +import VueApollo from 'vue-apollo'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import createFlash from '~/flash'; +import { IssuableType } from '~/issue_show/constants'; import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue'; import DropdownContents from '~/vue_shared/components/sidebar/labels_select_widget/dropdown_contents.vue'; import DropdownValue from '~/vue_shared/components/sidebar/labels_select_widget/dropdown_value.vue'; -import DropdownValueCollapsed from '~/vue_shared/components/sidebar/labels_select_widget/dropdown_value_collapsed.vue'; +import issueLabelsQuery from '~/vue_shared/components/sidebar/labels_select_widget/graphql/issue_labels.query.graphql'; import LabelsSelectRoot from '~/vue_shared/components/sidebar/labels_select_widget/labels_select_root.vue'; +import { mockConfig, issuableLabelsQueryResponse } from './mock_data'; -import { mockConfig } from './mock_data'; +jest.mock('~/flash'); + +const localVue = createLocalVue(); +localVue.use(VueApollo); + +const successfulQueryHandler = jest.fn().mockResolvedValue(issuableLabelsQueryResponse); +const errorQueryHandler = jest.fn().mockRejectedValue('Houston, we have a problem'); describe('LabelsSelectRoot', () => { let wrapper; - const createComponent = (config = mockConfig, slots = {}) => { + const findSidebarEditableItem = () => wrapper.findComponent(SidebarEditableItem); + const findDropdownValue = () => wrapper.findComponent(DropdownValue); + const findDropdownContents = () => wrapper.findComponent(DropdownContents); + + const expandDropdown = () => wrapper.vm.$refs.editable.expand(); + + const createComponent = ({ + config = mockConfig, + slots = {}, + queryHandler = successfulQueryHandler, + } = {}) => { + const mockApollo = createMockApollo([[issueLabelsQuery, queryHandler]]); + wrapper = shallowMount(LabelsSelectRoot, { slots, - propsData: config, + apolloProvider: mockApollo, + localVue, + propsData: { + ...config, + issuableType: IssuableType.Issue, + }, stubs: { - DropdownContents, SidebarEditableItem, }, provide: { iid: '1', - projectPath: 'test', + fullPath: 'test', canUpdate: true, allowLabelEdit: true, + allowLabelCreate: true, + labelsManagePath: 'test', }, }); }; @@ -42,33 +73,67 @@ describe('LabelsSelectRoot', () => { ${'embedded'} | ${'is-embedded'} `( 'renders component root element with CSS class `$cssClass` when `state.variant` is "$variant"', - ({ variant, cssClass }) => { + async ({ variant, cssClass }) => { createComponent({ - ...mockConfig, - variant, + config: { ...mockConfig, variant }, }); - return wrapper.vm.$nextTick(() => { - expect(wrapper.classes()).toContain(cssClass); - }); + await nextTick(); + expect(wrapper.classes()).toContain(cssClass); }, ); - it('renders `dropdown-value-collapsed` component when `allowLabelCreate` prop is `true`', async () => { - createComponent(); - await wrapper.vm.$nextTick; - expect(wrapper.find(DropdownValueCollapsed).exists()).toBe(true); - }); + describe('if dropdown variant is `sidebar`', () => { + it('renders sidebar editable item', () => { + createComponent(); + expect(findSidebarEditableItem().exists()).toBe(true); + }); + + it('passes true `loading` prop to sidebar editable item when loading labels', () => { + createComponent(); + expect(findSidebarEditableItem().props('loading')).toBe(true); + }); - it('renders `dropdown-value` component', async () => { - createComponent(mockConfig, { - default: 'None', + describe('when labels are fetched successfully', () => { + beforeEach(async () => { + createComponent(); + await waitForPromises(); + }); + + it('passes true `loading` prop to sidebar editable item', () => { + expect(findSidebarEditableItem().props('loading')).toBe(false); + }); + + it('renders dropdown value component when query labels is resolved', () => { + expect(findDropdownValue().exists()).toBe(true); + expect(findDropdownValue().props('selectedLabels')).toEqual( + issuableLabelsQueryResponse.data.workspace.issuable.labels.nodes, + ); + }); + + it('emits `onLabelRemove` event on dropdown value label remove event', () => { + const label = { id: 'gid://gitlab/ProjectLabel/1' }; + findDropdownValue().vm.$emit('onLabelRemove', label); + expect(wrapper.emitted('onLabelRemove')).toEqual([[label]]); + }); + }); + + it('creates flash with error message when query is rejected', async () => { + createComponent({ queryHandler: errorQueryHandler }); + await waitForPromises(); + expect(createFlash).toHaveBeenCalledWith({ message: 'Error fetching labels.' }); }); - await wrapper.vm.$nextTick; + }); + + it('emits `updateSelectedLabels` event on dropdown contents `setLabels` event if there are labels to update', async () => { + const label = { id: 'gid://gitlab/ProjectLabel/1' }; + createComponent(); + await waitForPromises(); - const valueComp = wrapper.find(DropdownValue); + expandDropdown(); + await nextTick(); - expect(valueComp.exists()).toBe(true); - expect(valueComp.text()).toBe('None'); + findDropdownContents().vm.$emit('setLabels', [label]); + expect(wrapper.emitted('updateSelectedLabels')).toEqual([[[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 fceaabec2d0..4ff33f578e1 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 @@ -86,7 +86,7 @@ export const createLabelSuccessfulResponse = { }, }; -export const labelsQueryResponse = { +export const workspaceLabelsQueryResponse = { data: { workspace: { labels: { @@ -108,3 +108,23 @@ export const labelsQueryResponse = { }, }, }; + +export const issuableLabelsQueryResponse = { + data: { + workspace: { + issuable: { + id: '1', + labels: { + nodes: [ + { + color: '#330066', + description: null, + id: 'gid://gitlab/ProjectLabel/1', + title: 'Label1', + }, + ], + }, + }, + }, + }, +}; |