From 6a7f12a15505d24899ca67b99a4575091f613961 Mon Sep 17 00:00:00 2001
From: Rajan Mistry <rmistry@gitlab.com>
Date: Mon, 19 Feb 2024 04:37:54 +0000
Subject: [PATCH] Work items labels widget with edit button

Add edit pattern for labels widget
---
 .../work_item_attributes_wrapper.vue          |  34 +-
 ...labels.vue => work_item_labels_inline.vue} |   0
 .../components/work_item_labels_with_edit.vue | 267 ++++++++++++
 ...pec.js => work_item_labels_inline_spec.js} |   6 +-
 .../work_item_labels_with_edit_spec.js        |  56 +++
 locale/gitlab.pot                             |   8 +
 .../projects/work_items/work_item_spec.rb     |  18 +-
 .../work_item_attributes_wrapper_spec.js      |  26 +-
 ...pec.js => work_item_labels_inline_spec.js} |  11 +-
 .../work_item_labels_with_edit_spec.js        | 395 ++++++++++++++++++
 spec/frontend/work_items/mock_data.js         |  32 ++
 .../features/work_items_shared_examples.rb    | 190 ++++++---
 12 files changed, 956 insertions(+), 87 deletions(-)
 rename app/assets/javascripts/work_items/components/{work_item_labels.vue => work_item_labels_inline.vue} (100%)
 create mode 100644 app/assets/javascripts/work_items/components/work_item_labels_with_edit.vue
 rename ee/spec/frontend/work_items/components/{work_item_labels_spec.js => work_item_labels_inline_spec.js} (88%)
 create mode 100644 ee/spec/frontend/work_items/components/work_item_labels_with_edit_spec.js
 rename spec/frontend/work_items/components/{work_item_labels_spec.js => work_item_labels_inline_spec.js} (97%)
 create mode 100644 spec/frontend/work_items/components/work_item_labels_with_edit_spec.js

diff --git a/app/assets/javascripts/work_items/components/work_item_attributes_wrapper.vue b/app/assets/javascripts/work_items/components/work_item_attributes_wrapper.vue
index 662edda13f18e..d5c7cd4c05ce7 100644
--- a/app/assets/javascripts/work_items/components/work_item_attributes_wrapper.vue
+++ b/app/assets/javascripts/work_items/components/work_item_attributes_wrapper.vue
@@ -21,7 +21,8 @@ import WorkItemAssigneesInline from './work_item_assignees_inline.vue';
 import WorkItemAssigneesWithEdit from './work_item_assignees_with_edit.vue';
 import WorkItemDueDateInline from './work_item_due_date_inline.vue';
 import WorkItemDueDateWithEdit from './work_item_due_date_with_edit.vue';
-import WorkItemLabels from './work_item_labels.vue';
+import WorkItemLabelsInline from './work_item_labels_inline.vue';
+import WorkItemLabelsWithEdit from './work_item_labels_with_edit.vue';
 import WorkItemMilestoneInline from './work_item_milestone_inline.vue';
 import WorkItemMilestoneWithEdit from './work_item_milestone_with_edit.vue';
 import WorkItemParentInline from './work_item_parent_inline.vue';
@@ -30,7 +31,8 @@ import WorkItemParent from './work_item_parent_with_edit.vue';
 export default {
   components: {
     Participants,
-    WorkItemLabels,
+    WorkItemLabelsInline,
+    WorkItemLabelsWithEdit,
     WorkItemMilestoneInline,
     WorkItemMilestoneWithEdit,
     WorkItemAssigneesInline,
@@ -155,14 +157,26 @@ export default {
         @error="$emit('error', $event)"
       />
     </template>
-    <work-item-labels
-      v-if="workItemLabels"
-      :can-update="canUpdate"
-      :full-path="fullPath"
-      :work-item-id="workItem.id"
-      :work-item-iid="workItem.iid"
-      @error="$emit('error', $event)"
-    />
+    <template v-if="workItemLabels">
+      <work-item-labels-with-edit
+        v-if="glFeatures.workItemsMvc2"
+        class="gl-mb-5"
+        :can-update="canUpdate"
+        :full-path="fullPath"
+        :work-item-id="workItem.id"
+        :work-item-iid="workItem.iid"
+        :work-item-type="workItemType"
+        @error="$emit('error', $event)"
+      />
+      <work-item-labels-inline
+        v-else
+        :can-update="canUpdate"
+        :full-path="fullPath"
+        :work-item-id="workItem.id"
+        :work-item-iid="workItem.iid"
+        @error="$emit('error', $event)"
+      />
+    </template>
     <template v-if="workItemWeight">
       <work-item-weight
         v-if="glFeatures.workItemsMvc2"
diff --git a/app/assets/javascripts/work_items/components/work_item_labels.vue b/app/assets/javascripts/work_items/components/work_item_labels_inline.vue
similarity index 100%
rename from app/assets/javascripts/work_items/components/work_item_labels.vue
rename to app/assets/javascripts/work_items/components/work_item_labels_inline.vue
diff --git a/app/assets/javascripts/work_items/components/work_item_labels_with_edit.vue b/app/assets/javascripts/work_items/components/work_item_labels_with_edit.vue
new file mode 100644
index 0000000000000..5533f743f736c
--- /dev/null
+++ b/app/assets/javascripts/work_items/components/work_item_labels_with_edit.vue
@@ -0,0 +1,267 @@
+<script>
+import { GlLabel } from '@gitlab/ui';
+import { difference } from 'lodash';
+import { __, n__ } from '~/locale';
+import WorkItemSidebarDropdownWidgetWithEdit from '~/work_items/components/shared/work_item_sidebar_dropdown_widget_with_edit.vue';
+import groupLabelsQuery from '~/sidebar/components/labels/labels_select_widget/graphql/group_labels.query.graphql';
+import projectLabelsQuery from '~/sidebar/components/labels/labels_select_widget/graphql/project_labels.query.graphql';
+import { isScopedLabel } from '~/lib/utils/common_utils';
+import Tracking from '~/tracking';
+import groupWorkItemByIidQuery from '../graphql/group_work_item_by_iid.query.graphql';
+import workItemByIidQuery from '../graphql/work_item_by_iid.query.graphql';
+import updateWorkItemMutation from '../graphql/update_work_item.mutation.graphql';
+import { i18n, I18N_WORK_ITEM_ERROR_FETCHING_LABELS, TRACKING_CATEGORY_SHOW } from '../constants';
+import { isLabelsWidget } from '../utils';
+
+export default {
+  components: {
+    WorkItemSidebarDropdownWidgetWithEdit,
+    GlLabel,
+  },
+  mixins: [Tracking.mixin()],
+  inject: {
+    issuesListPath: {
+      type: String,
+    },
+    isGroup: {
+      type: Boolean,
+    },
+  },
+  props: {
+    fullPath: {
+      type: String,
+      required: true,
+    },
+    workItemId: {
+      type: String,
+      required: true,
+    },
+    workItemIid: {
+      type: String,
+      required: true,
+    },
+    workItemType: {
+      type: String,
+      required: true,
+    },
+    canUpdate: {
+      type: Boolean,
+      required: false,
+      default: false,
+    },
+  },
+  data() {
+    return {
+      searchTerm: '',
+      searchStarted: false,
+      updateInProgress: false,
+      removeLabelIds: [],
+      addLabelIds: [],
+    };
+  },
+  computed: {
+    tracking() {
+      return {
+        category: TRACKING_CATEGORY_SHOW,
+        label: 'item_label',
+        property: `type_${this.workItemType}`,
+      };
+    },
+    areLabelsSelected() {
+      return this.addLabelIds.length > 0 || this.itemValues.length > 0;
+    },
+    selectedLabelCount() {
+      return this.addLabelIds.length + this.itemValues.length - this.removeLabelIds.length;
+    },
+    dropDownLabelText() {
+      return n__('%d label', '%d labels', this.selectedLabelCount);
+    },
+    dropdownText() {
+      return this.areLabelsSelected ? `${this.dropDownLabelText}` : __('No labels');
+    },
+    isLoadingLabels() {
+      return this.$apollo.queries.searchLabels.loading;
+    },
+    labelsList() {
+      return this.searchLabels?.map(({ id, title, color }) => ({
+        value: id,
+        text: title,
+        color,
+      }));
+    },
+    labelsWidget() {
+      return this.workItem?.widgets?.find(isLabelsWidget);
+    },
+    localLabels() {
+      return this.labelsWidget?.labels?.nodes || [];
+    },
+    itemValues() {
+      return this.localLabels.map(({ id }) => id);
+    },
+    allowsScopedLabels() {
+      return this.labelsWidget?.allowsScopedLabels;
+    },
+  },
+  apollo: {
+    workItem: {
+      query() {
+        return this.isGroup ? groupWorkItemByIidQuery : workItemByIidQuery;
+      },
+      variables() {
+        return {
+          fullPath: this.fullPath,
+          iid: this.workItemIid,
+        };
+      },
+      update(data) {
+        return data.workspace?.workItems?.nodes[0] || {};
+      },
+      skip() {
+        return !this.workItemIid;
+      },
+      error() {
+        this.$emit('error', i18n.fetchError);
+      },
+    },
+    searchLabels: {
+      query() {
+        return this.isGroup ? groupLabelsQuery : projectLabelsQuery;
+      },
+      variables() {
+        return {
+          fullPath: this.fullPath,
+          searchTerm: this.searchTerm,
+        };
+      },
+      skip() {
+        return !this.searchStarted;
+      },
+      update(data) {
+        return data.workspace?.labels?.nodes;
+      },
+      error() {
+        this.$emit('error', I18N_WORK_ITEM_ERROR_FETCHING_LABELS);
+      },
+    },
+  },
+  methods: {
+    onDropdownShown() {
+      this.searchTerm = '';
+      this.searchStarted = true;
+    },
+    search(searchTerm) {
+      this.searchTerm = searchTerm;
+      this.searchStarted = true;
+    },
+    removeLabel({ id }) {
+      this.removeLabelIds.push(id);
+      this.updateLabels();
+    },
+    updateLabel(labels) {
+      this.removeLabelIds = difference(this.itemValues, labels);
+      this.addLabelIds = difference(labels, this.itemValues);
+    },
+    async updateLabels(labels) {
+      this.searchTerm = '';
+      this.updateInProgress = true;
+
+      if (labels && labels.length === 0) {
+        this.removeLabelIds = this.itemValues;
+        this.addLabelIds = [];
+      }
+
+      try {
+        const {
+          data: {
+            workItemUpdate: { errors },
+          },
+        } = await this.$apollo.mutate({
+          mutation: updateWorkItemMutation,
+          variables: {
+            input: {
+              id: this.workItemId,
+              labelsWidget: {
+                addLabelIds: this.addLabelIds,
+                removeLabelIds: this.removeLabelIds,
+              },
+            },
+          },
+        });
+
+        if (errors.length > 0) {
+          this.throwUpdateError();
+          return;
+        }
+        this.addLabelIds = [];
+        this.removeLabelIds = [];
+
+        this.track('updated_labels');
+      } catch {
+        this.throwUpdateError();
+      } finally {
+        this.updateInProgress = false;
+      }
+    },
+    scopedLabel(label) {
+      return this.allowsScopedLabels && isScopedLabel(label);
+    },
+    isSelected(id) {
+      return this.itemValues.includes(id) || this.addLabelIds.includes(id);
+    },
+    throwUpdateError() {
+      this.$emit('error', i18n.updateError);
+      this.addLabelIds = [];
+      this.removeLabelIds = [];
+    },
+    labelFilterUrl(label) {
+      return `${this.issuesListPath}?label_name[]=${encodeURIComponent(label.title)}`;
+    },
+  },
+};
+</script>
+
+<template>
+  <work-item-sidebar-dropdown-widget-with-edit
+    :dropdown-label="__('Labels')"
+    :can-update="canUpdate"
+    dropdown-name="label"
+    :loading="isLoadingLabels"
+    :list-items="labelsList"
+    :item-value="itemValues"
+    :update-in-progress="updateInProgress"
+    :toggle-dropdown-text="dropdownText"
+    :header-text="__('Select label')"
+    :reset-button-label="__('Clear')"
+    :multi-select="true"
+    data-testid="work-item-labels-with-edit"
+    @dropdownShown="onDropdownShown"
+    @searchStarted="search"
+    @updateValue="updateLabels"
+    @updateSelected="updateLabel"
+  >
+    <template #list-item="{ item }">
+      <span>
+        <span
+          :style="{ background: item.color }"
+          :class="{ 'gl-border gl-border-white': isSelected(item.value) }"
+          class="gl-display-inline-block gl-rounded-base gl-mr-1 gl-w-5 gl-h-3 gl-vertical-align-middle gl-mt-n1"
+        ></span>
+        {{ item.text }}
+      </span>
+    </template>
+    <template #readonly>
+      <gl-label
+        v-for="label in localLabels"
+        :key="label.id"
+        class="gl-mr-2 gl-mb-2"
+        :title="label.title"
+        :description="label.description"
+        :background-color="label.color"
+        :scoped="scopedLabel(label)"
+        :show-close-button="canUpdate"
+        :target="labelFilterUrl(label)"
+        @close="removeLabel(label)"
+      />
+    </template>
+  </work-item-sidebar-dropdown-widget-with-edit>
+</template>
diff --git a/ee/spec/frontend/work_items/components/work_item_labels_spec.js b/ee/spec/frontend/work_items/components/work_item_labels_inline_spec.js
similarity index 88%
rename from ee/spec/frontend/work_items/components/work_item_labels_spec.js
rename to ee/spec/frontend/work_items/components/work_item_labels_inline_spec.js
index e01ef1bf60c95..c4547f3130c1f 100644
--- a/ee/spec/frontend/work_items/components/work_item_labels_spec.js
+++ b/ee/spec/frontend/work_items/components/work_item_labels_inline_spec.js
@@ -5,12 +5,12 @@ import VueApollo from 'vue-apollo';
 import createMockApollo from 'helpers/mock_apollo_helper';
 import waitForPromises from 'helpers/wait_for_promises';
 import workItemByIidQuery from '~/work_items/graphql/work_item_by_iid.query.graphql';
-import WorkItemLabels from '~/work_items/components/work_item_labels.vue';
+import WorkItemLabelsInline from '~/work_items/components/work_item_labels_inline.vue';
 import { workItemByIidResponseFactory } from 'jest/work_items/mock_data';
 
 Vue.use(VueApollo);
 
-describe('WorkItemLabels component', () => {
+describe('WorkItemLabelsInline component', () => {
   let wrapper;
 
   const findScopedLabel = () =>
@@ -20,7 +20,7 @@ describe('WorkItemLabels component', () => {
     canUpdate = true,
     workItemQueryHandler = jest.fn().mockResolvedValue(workItemByIidResponseFactory()),
   } = {}) => {
-    wrapper = mount(WorkItemLabels, {
+    wrapper = mount(WorkItemLabelsInline, {
       apolloProvider: createMockApollo([[workItemByIidQuery, workItemQueryHandler]]),
       provide: {
         isGroup: false,
diff --git a/ee/spec/frontend/work_items/components/work_item_labels_with_edit_spec.js b/ee/spec/frontend/work_items/components/work_item_labels_with_edit_spec.js
new file mode 100644
index 0000000000000..308d25269ee48
--- /dev/null
+++ b/ee/spec/frontend/work_items/components/work_item_labels_with_edit_spec.js
@@ -0,0 +1,56 @@
+import { GlLabel } from '@gitlab/ui';
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import createMockApollo from 'helpers/mock_apollo_helper';
+import waitForPromises from 'helpers/wait_for_promises';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import workItemByIidQuery from '~/work_items/graphql/work_item_by_iid.query.graphql';
+import WorkItemLabelsWithEdit from '~/work_items/components/work_item_labels_with_edit.vue';
+import { workItemByIidResponseFactory } from 'jest/work_items/mock_data';
+
+Vue.use(VueApollo);
+
+const workItemId = 'gid://gitlab/WorkItem/1';
+
+describe('WorkItemLabelsWithEdit component', () => {
+  let wrapper;
+
+  const createComponent = ({
+    canUpdate = true,
+    isGroup = false,
+    workItemQueryHandler = jest.fn().mockResolvedValue(workItemByIidResponseFactory()),
+    workItemIid = '1',
+    fullPath = 'test-project-path',
+    issuesListPath = 'test-project-path/issues',
+  } = {}) => {
+    wrapper = shallowMountExtended(WorkItemLabelsWithEdit, {
+      apolloProvider: createMockApollo([[workItemByIidQuery, workItemQueryHandler]]),
+      provide: {
+        isGroup,
+        issuesListPath,
+      },
+      propsData: {
+        fullPath,
+        workItemId,
+        workItemIid,
+        canUpdate,
+        workItemType: 'epic',
+      },
+    });
+  };
+
+  const findScopedLabel = () =>
+    wrapper.findAllComponents(GlLabel).filter((label) => label.props('scoped'));
+
+  describe('allows scoped labels', () => {
+    it.each([true, false])('= %s', async (allowsScopedLabels) => {
+      const workItemQueryHandler = jest
+        .fn()
+        .mockResolvedValue(workItemByIidResponseFactory({ allowsScopedLabels }));
+      createComponent({ workItemQueryHandler });
+      await waitForPromises();
+
+      expect(findScopedLabel().exists()).toBe(allowsScopedLabels);
+    });
+  });
+});
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index 92b0537814c29..a1efcc3ffaa33 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -322,6 +322,11 @@ msgid_plural "%d jobs"
 msgstr[0] ""
 msgstr[1] ""
 
+msgid "%d label"
+msgid_plural "%d labels"
+msgstr[0] ""
+msgstr[1] ""
+
 msgid "%d layer"
 msgid_plural "%d layers"
 msgstr[0] ""
@@ -32822,6 +32827,9 @@ msgstr ""
 msgid "No label"
 msgstr ""
 
+msgid "No labels"
+msgstr ""
+
 msgid "No labels found"
 msgstr ""
 
diff --git a/spec/features/projects/work_items/work_item_spec.rb b/spec/features/projects/work_items/work_item_spec.rb
index b23d7375e725b..cb5214b81574f 100644
--- a/spec/features/projects/work_items/work_item_spec.rb
+++ b/spec/features/projects/work_items/work_item_spec.rb
@@ -166,11 +166,17 @@
         wait_for_all_requests
       end
 
-      it 'assignees input field is disabled' do
+      it 'disabled the assignees input field' do
         within('[data-testid="work-item-assignees-input"]') do
           expect(page).to have_field(type: 'text', disabled: true)
         end
       end
+
+      it 'disables the labels input field' do
+        within('[data-testid="work-item-labels-input"]') do
+          expect(page).to have_field(type: 'text', disabled: true)
+        end
+      end
     end
 
     context 'when work_items_mvc_2 is enabled' do
@@ -181,16 +187,16 @@
         wait_for_all_requests
       end
 
-      it 'assignees edit button is not visible' do
+      it 'hides the assignees edit button' do
         within('[data-testid="work-item-assignees-with-edit"]') do
           expect(page).not_to have_button('Edit')
         end
       end
-    end
 
-    it 'labels input field is disabled' do
-      within('[data-testid="work-item-labels-input"]') do
-        expect(page).to have_field(type: 'text', disabled: true)
+      it 'hides the labels edit button' do
+        within('[data-testid="work-item-labels-with-edit"]') do
+          expect(page).not_to have_button('Edit')
+        end
       end
     end
   end
diff --git a/spec/frontend/work_items/components/work_item_attributes_wrapper_spec.js b/spec/frontend/work_items/components/work_item_attributes_wrapper_spec.js
index 5dfe27c41817b..c6c88a5d5fd81 100644
--- a/spec/frontend/work_items/components/work_item_attributes_wrapper_spec.js
+++ b/spec/frontend/work_items/components/work_item_attributes_wrapper_spec.js
@@ -4,7 +4,8 @@ import Participants from '~/sidebar/components/participants/participants.vue';
 import WorkItemAssigneesWithEdit from '~/work_items/components/work_item_assignees_with_edit.vue';
 import WorkItemDueDateInline from '~/work_items/components/work_item_due_date_inline.vue';
 import WorkItemDueDateWithEdit from '~/work_items/components/work_item_due_date_with_edit.vue';
-import WorkItemLabels from '~/work_items/components/work_item_labels.vue';
+import WorkItemLabelsInline from '~/work_items/components/work_item_labels_inline.vue';
+import WorkItemLabelsWithEdit from '~/work_items/components/work_item_labels_with_edit.vue';
 import WorkItemMilestoneInline from '~/work_items/components/work_item_milestone_inline.vue';
 import WorkItemMilestoneWithEdit from '~/work_items/components/work_item_milestone_with_edit.vue';
 import WorkItemParentInline from '~/work_items/components/work_item_parent_inline.vue';
@@ -27,7 +28,8 @@ describe('WorkItemAttributesWrapper component', () => {
   const findWorkItemAssignees = () => wrapper.findComponent(WorkItemAssigneesWithEdit);
   const findWorkItemDueDate = () => wrapper.findComponent(WorkItemDueDateWithEdit);
   const findWorkItemDueDateInline = () => wrapper.findComponent(WorkItemDueDateInline);
-  const findWorkItemLabels = () => wrapper.findComponent(WorkItemLabels);
+  const findWorkItemLabelsInline = () => wrapper.findComponent(WorkItemLabelsInline);
+  const findWorkItemLabels = () => wrapper.findComponent(WorkItemLabelsWithEdit);
   const findWorkItemMilestone = () => wrapper.findComponent(WorkItemMilestoneWithEdit);
   const findWorkItemMilestoneInline = () => wrapper.findComponent(WorkItemMilestoneInline);
   const findWorkItemParentInline = () => wrapper.findComponent(WorkItemParentInline);
@@ -89,6 +91,26 @@ describe('WorkItemAttributesWrapper component', () => {
 
       expect(findWorkItemLabels().exists()).toBe(exists);
     });
+
+    it.each`
+      description                                                   | labelsWidgetInlinePresent | labelsWidgetWithEditPresent | workItemsMvc2FlagEnabled
+      ${'renders WorkItemLabels when workItemsMvc2 enabled'}        | ${false}                  | ${true}                     | ${true}
+      ${'renders WorkItemLabelsInline when workItemsMvc2 disabled'} | ${true}                   | ${false}                    | ${false}
+    `(
+      '$description',
+      async ({
+        labelsWidgetInlinePresent,
+        labelsWidgetWithEditPresent,
+        workItemsMvc2FlagEnabled,
+      }) => {
+        createComponent({ workItemsMvc2: workItemsMvc2FlagEnabled });
+
+        await waitForPromises();
+
+        expect(findWorkItemLabels().exists()).toBe(labelsWidgetWithEditPresent);
+        expect(findWorkItemLabelsInline().exists()).toBe(labelsWidgetInlinePresent);
+      },
+    );
   });
 
   describe('dates widget', () => {
diff --git a/spec/frontend/work_items/components/work_item_labels_spec.js b/spec/frontend/work_items/components/work_item_labels_inline_spec.js
similarity index 97%
rename from spec/frontend/work_items/components/work_item_labels_spec.js
rename to spec/frontend/work_items/components/work_item_labels_inline_spec.js
index d7bebac6dbdf0..047698e3a73e1 100644
--- a/spec/frontend/work_items/components/work_item_labels_spec.js
+++ b/spec/frontend/work_items/components/work_item_labels_inline_spec.js
@@ -10,7 +10,7 @@ import projectLabelsQuery from '~/sidebar/components/labels/labels_select_widget
 import updateWorkItemMutation from '~/work_items/graphql/update_work_item.mutation.graphql';
 import groupWorkItemByIidQuery from '~/work_items/graphql/group_work_item_by_iid.query.graphql';
 import workItemByIidQuery from '~/work_items/graphql/work_item_by_iid.query.graphql';
-import WorkItemLabels from '~/work_items/components/work_item_labels.vue';
+import WorkItemLabelsInline from '~/work_items/components/work_item_labels_inline.vue';
 import { i18n, I18N_WORK_ITEM_ERROR_FETCHING_LABELS } from '~/work_items/constants';
 import {
   groupWorkItemByIidResponseFactory,
@@ -18,13 +18,14 @@ import {
   mockLabels,
   workItemByIidResponseFactory,
   updateWorkItemMutationResponse,
+  groupLabelsResponse,
 } from '../mock_data';
 
 Vue.use(VueApollo);
 
 const workItemId = 'gid://gitlab/WorkItem/1';
 
-describe('WorkItemLabels component', () => {
+describe('WorkItemLabelsInline component', () => {
   let wrapper;
 
   const findTokenSelector = () => wrapper.findComponent(GlTokenSelector);
@@ -39,7 +40,7 @@ describe('WorkItemLabels component', () => {
     .fn()
     .mockResolvedValue(groupWorkItemByIidResponseFactory({ labels: null }));
   const projectLabelsQueryHandler = jest.fn().mockResolvedValue(projectLabelsResponse);
-  const groupLabelsQueryHandler = jest.fn().mockResolvedValue(projectLabelsResponse);
+  const groupLabelsQueryHandler = jest.fn().mockResolvedValue(groupLabelsResponse);
   const successUpdateWorkItemMutationHandler = jest
     .fn()
     .mockResolvedValue(updateWorkItemMutationResponse);
@@ -53,7 +54,7 @@ describe('WorkItemLabels component', () => {
     updateWorkItemMutationHandler = successUpdateWorkItemMutationHandler,
     workItemIid = '1',
   } = {}) => {
-    wrapper = mountExtended(WorkItemLabels, {
+    wrapper = mountExtended(WorkItemLabelsInline, {
       apolloProvider: createMockApollo([
         [workItemByIidQuery, workItemQueryHandler],
         [groupWorkItemByIidQuery, groupWorkItemQuerySuccess],
@@ -152,7 +153,7 @@ describe('WorkItemLabels component', () => {
     await waitForPromises();
 
     expect(findSkeletonLoader().exists()).toBe(false);
-    expect(findTokenSelector().props('dropdownItems')).toHaveLength(2);
+    expect(findTokenSelector().props('dropdownItems')).toHaveLength(3);
   });
 
   it.each([true, false])(
diff --git a/spec/frontend/work_items/components/work_item_labels_with_edit_spec.js b/spec/frontend/work_items/components/work_item_labels_with_edit_spec.js
new file mode 100644
index 0000000000000..25bbac20b393c
--- /dev/null
+++ b/spec/frontend/work_items/components/work_item_labels_with_edit_spec.js
@@ -0,0 +1,395 @@
+import { GlLabel } from '@gitlab/ui';
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import createMockApollo from 'helpers/mock_apollo_helper';
+import waitForPromises from 'helpers/wait_for_promises';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import { mockTracking } from 'helpers/tracking_helper';
+import {
+  TRACKING_CATEGORY_SHOW,
+  I18N_WORK_ITEM_ERROR_FETCHING_LABELS,
+} from '~/work_items/constants';
+import groupLabelsQuery from '~/sidebar/components/labels/labels_select_widget/graphql/group_labels.query.graphql';
+import projectLabelsQuery from '~/sidebar/components/labels/labels_select_widget/graphql/project_labels.query.graphql';
+import updateWorkItemMutation from '~/work_items/graphql/update_work_item.mutation.graphql';
+import groupWorkItemByIidQuery from '~/work_items/graphql/group_work_item_by_iid.query.graphql';
+import workItemByIidQuery from '~/work_items/graphql/work_item_by_iid.query.graphql';
+import WorkItemLabelsWithEdit from '~/work_items/components/work_item_labels_with_edit.vue';
+import WorkItemSidebarDropdownWidgetWithEdit from '~/work_items/components/shared/work_item_sidebar_dropdown_widget_with_edit.vue';
+import {
+  groupWorkItemByIidResponseFactory,
+  projectLabelsResponse,
+  groupLabelsResponse,
+  getProjectLabelsResponse,
+  mockLabels,
+  workItemByIidResponseFactory,
+  updateWorkItemMutationResponseFactory,
+  updateWorkItemMutationErrorResponse,
+} from '../mock_data';
+
+Vue.use(VueApollo);
+
+const workItemId = 'gid://gitlab/WorkItem/1';
+
+describe('WorkItemLabelsWithEdit component', () => {
+  let wrapper;
+
+  const label1Id = mockLabels[0].id;
+  const label2Id = mockLabels[1].id;
+  const label3Id = mockLabels[2].id;
+
+  const workItemQuerySuccess = jest
+    .fn()
+    .mockResolvedValue(workItemByIidResponseFactory({ labels: null }));
+  const workItemQueryWithLabelsHandler = jest
+    .fn()
+    .mockResolvedValue(workItemByIidResponseFactory({ labels: mockLabels }));
+  const workItemQueryWithFewLabelsHandler = jest
+    .fn()
+    .mockResolvedValue(workItemByIidResponseFactory({ labels: [mockLabels[0], mockLabels[1]] }));
+  const groupWorkItemQuerySuccess = jest
+    .fn()
+    .mockResolvedValue(groupWorkItemByIidResponseFactory({ labels: null }));
+  const projectLabelsQueryHandler = jest.fn().mockResolvedValue(projectLabelsResponse);
+  const groupLabelsQueryHandler = jest.fn().mockResolvedValue(groupLabelsResponse);
+  const errorHandler = jest.fn().mockRejectedValue('Error');
+  const successUpdateWorkItemMutationHandler = jest
+    .fn()
+    .mockResolvedValue(updateWorkItemMutationResponseFactory({ labels: [mockLabels[0]] }));
+  const successRemoveLabelWorkItemMutationHandler = jest
+    .fn()
+    .mockResolvedValue(updateWorkItemMutationResponseFactory({ labels: [mockLabels[0]] }));
+  const successRemoveAllLabelWorkItemMutationHandler = jest
+    .fn()
+    .mockResolvedValue(updateWorkItemMutationResponseFactory({ labels: [] }));
+  const successAddRemoveLabelWorkItemMutationHandler = jest.fn().mockResolvedValue(
+    updateWorkItemMutationResponseFactory({
+      labels: [mockLabels[0], mockLabels[2]],
+    }),
+  );
+
+  const createComponent = ({
+    canUpdate = true,
+    isGroup = false,
+    workItemQueryHandler = workItemQuerySuccess,
+    searchQueryHandler = projectLabelsQueryHandler,
+    updateWorkItemMutationHandler = successUpdateWorkItemMutationHandler,
+    workItemIid = '1',
+  } = {}) => {
+    wrapper = shallowMountExtended(WorkItemLabelsWithEdit, {
+      apolloProvider: createMockApollo([
+        [workItemByIidQuery, workItemQueryHandler],
+        [groupWorkItemByIidQuery, groupWorkItemQuerySuccess],
+        [projectLabelsQuery, searchQueryHandler],
+        [groupLabelsQuery, groupLabelsQueryHandler],
+        [updateWorkItemMutation, updateWorkItemMutationHandler],
+      ]),
+      provide: {
+        isGroup,
+        issuesListPath: 'test-project-path/issues',
+      },
+      propsData: {
+        workItemId,
+        workItemIid,
+        canUpdate,
+        fullPath: 'test-project-path',
+        workItemType: 'Task',
+      },
+    });
+  };
+
+  const findWorkItemSidebarDropdownWidget = () =>
+    wrapper.findComponent(WorkItemSidebarDropdownWidgetWithEdit);
+  const findAllLabels = () => wrapper.findAllComponents(GlLabel);
+  const findRegularLabel = () => findAllLabels().at(0);
+  const findLabelWithDescription = () => findAllLabels().at(2);
+
+  const showDropdown = () => {
+    findWorkItemSidebarDropdownWidget().vm.$emit('dropdownShown');
+  };
+
+  const updateLabels = (labels) => {
+    findWorkItemSidebarDropdownWidget().vm.$emit('updateSelected', labels);
+    findWorkItemSidebarDropdownWidget().vm.$emit('updateValue', labels);
+  };
+
+  const getMutationInput = (addLabelIds, removeLabelIds) => {
+    return {
+      input: {
+        id: workItemId,
+        labelsWidget: {
+          addLabelIds,
+          removeLabelIds,
+        },
+      },
+    };
+  };
+
+  const expectDropdownCountToBe = (count, toggleDropdownText) => {
+    expect(findWorkItemSidebarDropdownWidget().props('itemValue')).toHaveLength(count);
+    expect(findWorkItemSidebarDropdownWidget().props('toggleDropdownText')).toBe(
+      toggleDropdownText,
+    );
+  };
+
+  it('renders the work item sidebar dropdown widget with default props', () => {
+    createComponent();
+
+    expect(findWorkItemSidebarDropdownWidget().props()).toMatchObject({
+      dropdownLabel: 'Labels',
+      canUpdate: true,
+      dropdownName: 'label',
+      updateInProgress: false,
+      toggleDropdownText: 'No labels',
+      headerText: 'Select label',
+      resetButtonLabel: 'Clear',
+      multiSelect: true,
+      itemValue: [],
+    });
+    expect(findAllLabels()).toHaveLength(0);
+  });
+
+  it('renders the labels when they are already present', async () => {
+    createComponent({
+      workItemQueryHandler: workItemQueryWithLabelsHandler,
+    });
+
+    await waitForPromises();
+
+    expect(workItemQueryWithLabelsHandler).toHaveBeenCalled();
+    expect(groupWorkItemQuerySuccess).not.toHaveBeenCalled();
+
+    expect(findWorkItemSidebarDropdownWidget().props('itemValue')).toStrictEqual([
+      label1Id,
+      label2Id,
+      label3Id,
+    ]);
+    expect(findAllLabels()).toHaveLength(3);
+    expect(findRegularLabel().props()).toMatchObject({
+      backgroundColor: '#f00',
+      title: 'Label 1',
+      target: 'test-project-path/issues?label_name[]=Label%201',
+      scoped: false,
+      showCloseButton: true,
+    });
+    expect(findLabelWithDescription().props('description')).toBe('Label 3 description');
+  });
+
+  it('renders the labels without close button when canUpdate is false', async () => {
+    createComponent({
+      workItemQueryHandler: workItemQueryWithLabelsHandler,
+      canUpdate: false,
+    });
+
+    await waitForPromises();
+
+    expect(findWorkItemSidebarDropdownWidget().props('canUpdate')).toBe(false);
+    expect(findRegularLabel().props('showCloseButton')).toBe(false);
+  });
+
+  it.each`
+    expectedAssertion                                                  | searchTerm   | handler                                                                   | result
+    ${'when dropdown is shown'}                                        | ${''}        | ${projectLabelsQueryHandler}                                              | ${3}
+    ${'when correct input is entered'}                                 | ${'Label 1'} | ${jest.fn().mockResolvedValue(getProjectLabelsResponse([mockLabels[0]]))} | ${1}
+    ${'and shows no matching results when incorrect input is entered'} | ${'Label 2'} | ${jest.fn().mockResolvedValue(getProjectLabelsResponse([]))}              | ${0}
+  `('calls search label query $expectedAssertion', async ({ searchTerm, result, handler }) => {
+    createComponent({
+      searchQueryHandler: handler,
+    });
+
+    showDropdown();
+    await findWorkItemSidebarDropdownWidget().vm.$emit('searchStarted', searchTerm);
+
+    expect(findWorkItemSidebarDropdownWidget().props('loading')).toBe(true);
+
+    await waitForPromises();
+
+    expect(findWorkItemSidebarDropdownWidget().props('listItems')).toHaveLength(result);
+    expect(handler).toHaveBeenCalledWith({
+      fullPath: 'test-project-path',
+      searchTerm,
+    });
+    expect(groupLabelsQueryHandler).not.toHaveBeenCalled();
+    expect(findWorkItemSidebarDropdownWidget().props('loading')).toBe(false);
+  });
+
+  it('emits error event if search query fails', async () => {
+    createComponent({ searchQueryHandler: errorHandler });
+    showDropdown();
+    await waitForPromises();
+
+    expect(wrapper.emitted('error')).toEqual([[I18N_WORK_ITEM_ERROR_FETCHING_LABELS]]);
+  });
+
+  it('update labels when labels are added', async () => {
+    createComponent({
+      workItemQueryHandler: workItemQuerySuccess,
+      updateWorkItemMutationHandler: successUpdateWorkItemMutationHandler,
+    });
+
+    await waitForPromises();
+
+    showDropdown();
+
+    expectDropdownCountToBe(0, 'No labels');
+
+    updateLabels([label1Id]);
+
+    await waitForPromises();
+
+    expectDropdownCountToBe(1, '1 label');
+    expect(successUpdateWorkItemMutationHandler).toHaveBeenCalledWith(
+      getMutationInput([label1Id], []),
+    );
+  });
+
+  it('update labels when labels are removed', async () => {
+    createComponent({
+      workItemQueryHandler: workItemQueryWithLabelsHandler,
+      updateWorkItemMutationHandler: successRemoveLabelWorkItemMutationHandler,
+    });
+
+    await waitForPromises();
+
+    showDropdown();
+
+    expectDropdownCountToBe(3, '3 labels');
+
+    updateLabels([label1Id]);
+
+    await waitForPromises();
+
+    expectDropdownCountToBe(1, '1 label');
+    expect(successRemoveLabelWorkItemMutationHandler).toHaveBeenCalledWith(
+      getMutationInput([], [label2Id, label3Id]),
+    );
+  });
+
+  it('update labels when labels are added or removed at same time', async () => {
+    createComponent({
+      workItemQueryHandler: workItemQueryWithFewLabelsHandler,
+      updateWorkItemMutationHandler: successAddRemoveLabelWorkItemMutationHandler,
+    });
+
+    await waitForPromises();
+
+    showDropdown();
+
+    expectDropdownCountToBe(2, '2 labels');
+
+    updateLabels([label1Id, label3Id]);
+
+    await waitForPromises();
+
+    expectDropdownCountToBe(2, '2 labels');
+    expect(successAddRemoveLabelWorkItemMutationHandler).toHaveBeenCalledWith(
+      getMutationInput([label3Id], [label2Id]),
+    );
+  });
+
+  it('clears all labels when updateValue has no labels', async () => {
+    createComponent({
+      workItemQueryHandler: workItemQueryWithLabelsHandler,
+      updateWorkItemMutationHandler: successRemoveAllLabelWorkItemMutationHandler,
+    });
+
+    await waitForPromises();
+
+    showDropdown();
+
+    expectDropdownCountToBe(3, '3 labels');
+
+    findWorkItemSidebarDropdownWidget().vm.$emit('updateValue', []);
+
+    await waitForPromises();
+
+    expectDropdownCountToBe(0, 'No labels');
+    expect(successRemoveAllLabelWorkItemMutationHandler).toHaveBeenCalledWith(
+      getMutationInput([], [label1Id, label2Id, label3Id]),
+    );
+  });
+
+  describe('tracking', () => {
+    let trackingSpy;
+
+    beforeEach(() => {
+      createComponent();
+      trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn);
+    });
+
+    afterEach(() => {
+      trackingSpy = null;
+    });
+
+    it('tracks editing the labels on dropdown widget updateValue', async () => {
+      showDropdown();
+      updateLabels([label1Id]);
+
+      await waitForPromises();
+
+      expect(trackingSpy).toHaveBeenCalledWith(TRACKING_CATEGORY_SHOW, 'updated_labels', {
+        category: TRACKING_CATEGORY_SHOW,
+        label: 'item_label',
+        property: 'type_Task',
+      });
+    });
+  });
+
+  it.each`
+    errorType          | expectedErrorMessage                                                      | failureHandler
+    ${'graphql error'} | ${'Something went wrong while updating the work item. Please try again.'} | ${jest.fn().mockResolvedValue(updateWorkItemMutationErrorResponse)}
+    ${'network error'} | ${'Something went wrong while updating the work item. Please try again.'} | ${jest.fn().mockRejectedValue(new Error())}
+  `(
+    'emits an error when there is a $errorType',
+    async ({ expectedErrorMessage, failureHandler }) => {
+      createComponent({
+        updateWorkItemMutationHandler: failureHandler,
+      });
+
+      updateLabels([label1Id]);
+
+      await waitForPromises();
+
+      expect(wrapper.emitted('error')).toEqual([[expectedErrorMessage]]);
+    },
+  );
+
+  it('skips calling the work item query when missing workItemIid', async () => {
+    createComponent({ workItemIid: '' });
+
+    await waitForPromises();
+
+    expect(workItemQuerySuccess).not.toHaveBeenCalled();
+  });
+
+  it('skips calling the group work item query when missing workItemIid', async () => {
+    createComponent({ isGroup: true, workItemIid: '' });
+
+    await waitForPromises();
+
+    expect(groupWorkItemQuerySuccess).not.toHaveBeenCalled();
+  });
+
+  describe('when group context', () => {
+    beforeEach(async () => {
+      createComponent({ isGroup: true });
+
+      await waitForPromises();
+    });
+
+    it('skips calling the project work item query', () => {
+      expect(workItemQuerySuccess).not.toHaveBeenCalled();
+    });
+
+    it('calls the group work item query', () => {
+      expect(groupWorkItemQuerySuccess).toHaveBeenCalled();
+    });
+
+    it('calls the group labels query on search', async () => {
+      showDropdown();
+      await waitForPromises();
+
+      expect(groupLabelsQueryHandler).toHaveBeenCalled();
+    });
+  });
+});
diff --git a/spec/frontend/work_items/mock_data.js b/spec/frontend/work_items/mock_data.js
index 0962bed9a4cab..84f540835b534 100644
--- a/spec/frontend/work_items/mock_data.js
+++ b/spec/frontend/work_items/mock_data.js
@@ -36,6 +36,14 @@ export const mockLabels = [
     color: '#b00',
     textColor: '#00b',
   },
+  {
+    __typename: 'Label',
+    id: 'gid://gitlab/Label/3',
+    title: 'Label 3',
+    description: 'Label 3 description',
+    color: '#fff',
+    textColor: '#000',
+  },
 ];
 
 export const mockMilestone = {
@@ -2101,6 +2109,30 @@ export const projectLabelsResponse = {
   },
 };
 
+export const groupLabelsResponse = {
+  data: {
+    workspace: {
+      id: '1',
+      __typename: 'Group',
+      labels: {
+        nodes: mockLabels,
+      },
+    },
+  },
+};
+
+export const getProjectLabelsResponse = (labels) => ({
+  data: {
+    workspace: {
+      id: '1',
+      __typename: 'Project',
+      labels: {
+        nodes: labels,
+      },
+    },
+  },
+});
+
 export const mockIterationWidgetResponse = {
   description: 'Iteration description',
   dueDate: '2022-07-19',
diff --git a/spec/support/shared_examples/features/work_items_shared_examples.rb b/spec/support/shared_examples/features/work_items_shared_examples.rb
index 4f36d8a046c74..fdd6dd163f41b 100644
--- a/spec/support/shared_examples/features/work_items_shared_examples.rb
+++ b/spec/support/shared_examples/features/work_items_shared_examples.rb
@@ -294,91 +294,159 @@ def click_reply_and_enter_slash
   let(:label_title_selector) { '[data-testid="labels-title"]' }
   let(:labels_input_selector) { '[data-testid="work-item-labels-input"]' }
 
-  it 'successfully assigns a label' do
-    find(labels_input_selector).fill_in(with: label.title)
-    wait_for_requests
-    # submit and simulate blur to save
-    send_keys(:enter)
-    find(label_title_selector).click
-    wait_for_requests
+  context 'when work_items_mvc_2 is disabled' do
+    include_context 'with work_items_mvc_2', false
 
-    expect(work_item.labels).to include(label)
-  end
+    it 'successfully assigns a label' do
+      find(labels_input_selector).fill_in(with: label.title)
+      wait_for_requests
+      # submit and simulate blur to save
+      send_keys(:enter)
+      find(label_title_selector).click
+      wait_for_requests
 
-  it 'successfully assigns multiple labels' do
-    label2 = create(:label, project: project, title: "testing-label-2")
+      expect(work_item.labels).to include(label)
+    end
 
-    find(labels_input_selector).fill_in(with: label.title)
-    wait_for_requests
-    send_keys(:enter)
+    it 'successfully assigns multiple labels' do
+      label2 = create(:label, project: project, title: "testing-label-2")
 
-    find(labels_input_selector).fill_in(with: label2.title)
-    wait_for_requests
-    send_keys(:enter)
+      find(labels_input_selector).fill_in(with: label.title)
+      wait_for_requests
+      send_keys(:enter)
 
-    find(label_title_selector).click
-    wait_for_requests
+      find(labels_input_selector).fill_in(with: label2.title)
+      wait_for_requests
+      send_keys(:enter)
 
-    expect(work_item.labels).to include(label)
-    expect(work_item.labels).to include(label2)
-  end
+      find(label_title_selector).click
+      wait_for_requests
 
-  it 'removes all labels on clear all button click' do
-    find(labels_input_selector).fill_in(with: label.title)
-    wait_for_requests
-    send_keys(:enter)
-    find(label_title_selector).click
-    wait_for_requests
+      expect(work_item.labels).to include(label)
+      expect(work_item.labels).to include(label2)
+    end
 
-    expect(work_item.labels).to include(label)
+    it 'removes all labels on clear all button click' do
+      find(labels_input_selector).fill_in(with: label.title)
+      wait_for_requests
+      send_keys(:enter)
+      find(label_title_selector).click
+      wait_for_requests
 
-    within(labels_input_selector) do
-      find('input').click
-      click_button 'Clear all'
+      expect(work_item.labels).to include(label)
+
+      within(labels_input_selector) do
+        find('input').click
+        click_button 'Clear all'
+      end
+      find(label_title_selector).click
+      wait_for_requests
+
+      expect(work_item.labels).not_to include(label)
     end
-    find(label_title_selector).click
-    wait_for_requests
 
-    expect(work_item.labels).not_to include(label)
-  end
+    it 'removes label on clicking badge cross button' do
+      find(labels_input_selector).fill_in(with: label.title)
+      wait_for_requests
+      send_keys(:enter)
+      find(label_title_selector).click
+      wait_for_requests
 
-  it 'removes label on clicking badge cross button' do
-    find(labels_input_selector).fill_in(with: label.title)
-    wait_for_requests
-    send_keys(:enter)
-    find(label_title_selector).click
-    wait_for_requests
+      expect(page).to have_text(label.title)
 
-    expect(page).to have_text(label.title)
+      within(labels_input_selector) do
+        click_button 'Remove label'
+      end
+      find(label_title_selector).click
+      wait_for_requests
 
-    within(labels_input_selector) do
-      click_button 'Remove label'
+      expect(work_item.labels).not_to include(label)
     end
-    find(label_title_selector).click
-    wait_for_requests
 
-    expect(work_item.labels).not_to include(label)
+    it 'updates the labels in real-time' do
+      Capybara::Session.new(:other_session)
+
+      using_session :other_session do
+        visit work_items_path
+        expect(page).not_to have_text(label.title)
+      end
+
+      find(labels_input_selector).fill_in(with: label.title)
+      wait_for_requests
+      send_keys(:enter)
+      find(label_title_selector).click
+      wait_for_requests
+
+      expect(page).to have_text(label.title)
+
+      using_session :other_session do
+        wait_for_requests
+        expect(page).to have_text(label.title)
+      end
+    end
   end
 
-  it 'updates the labels in real-time' do
-    Capybara::Session.new(:other_session)
+  context 'when work_items_mvc_2 is enabled' do
+    let(:work_item_labels_selector) { '[data-testid="work-item-labels-with-edit"]' }
+
+    include_context 'with work_items_mvc_2', true
+
+    it 'successfully applies the label by searching' do
+      expect(work_item.reload.labels).not_to include(label)
 
-    using_session :other_session do
-      visit work_items_path
-      expect(page).not_to have_text(label.title)
+      find_and_click_edit(work_item_labels_selector)
+
+      select_listbox_item(label.title)
+
+      find("body").click
+      wait_for_all_requests
+
+      expect(work_item.reload.labels).to include(label)
+      within(work_item_labels_selector) do
+        expect(page).to have_link(label.title)
+      end
     end
 
-    find(labels_input_selector).fill_in(with: label.title)
-    wait_for_requests
-    send_keys(:enter)
-    find(label_title_selector).click
-    wait_for_requests
+    it 'successfully removes all users on clear all button click' do
+      expect(work_item.reload.labels).not_to include(label)
 
-    expect(page).to have_text(label.title)
+      find_and_click_edit(work_item_labels_selector)
 
-    using_session :other_session do
+      select_listbox_item(label.title)
+
+      find("body").click
       wait_for_requests
-      expect(page).to have_text(label.title)
+
+      expect(work_item.reload.labels).to include(label)
+
+      find_and_click_edit(work_item_labels_selector)
+
+      find_and_click_clear(work_item_labels_selector)
+      wait_for_all_requests
+
+      expect(work_item.reload.labels).not_to include(label)
+    end
+
+    it 'updates the assignee in real-time' do
+      Capybara::Session.new(:other_session)
+
+      using_session :other_session do
+        visit work_items_path
+        expect(work_item.reload.labels).not_to include(label)
+      end
+
+      find_and_click_edit(work_item_labels_selector)
+
+      select_listbox_item(label.title)
+
+      find("body").click
+      wait_for_all_requests
+
+      expect(work_item.reload.labels).to include(label)
+
+      using_session :other_session do
+        expect(work_item.reload.labels).to include(label)
+      end
     end
   end
 end
-- 
GitLab