From 5c20470b280c46da09cf080a41263d6c427a235c Mon Sep 17 00:00:00 2001
From: Deepika Guliani <dguliani@gitlab.com>
Date: Wed, 20 Dec 2023 07:05:48 +0000
Subject: [PATCH] Add editable title and description

- Behind :work_items_mvc_2 feature flag
---
 .../components/work_item_description.vue      | 58 ++++++++++--
 .../work_item_description_rendered.vue        | 26 +++++-
 .../components/work_item_detail.vue           | 90 +++++++++++++++++-
 .../components/work_item_title_with_edit.vue  | 43 +++++++++
 .../stylesheets/page_bundles/work_items.scss  |  9 +-
 .../work_item_description_rendered_spec.js    | 15 ++-
 .../components/work_item_description_spec.js  | 36 ++++++++
 .../components/work_item_detail_spec.js       | 64 +++++++++++++
 .../work_item_with_title_edit_spec.js         | 59 ++++++++++++
 .../features/work_items_shared_examples.rb    | 92 +++++++++++--------
 10 files changed, 438 insertions(+), 54 deletions(-)
 create mode 100644 app/assets/javascripts/work_items/components/work_item_title_with_edit.vue
 create mode 100644 spec/frontend/work_items/components/work_item_with_title_edit_spec.js

diff --git a/app/assets/javascripts/work_items/components/work_item_description.vue b/app/assets/javascripts/work_items/components/work_item_description.vue
index 77c573b47e4c6..4301dcca30bfd 100644
--- a/app/assets/javascripts/work_items/components/work_item_description.vue
+++ b/app/assets/javascripts/work_items/components/work_item_description.vue
@@ -40,12 +40,27 @@ export default {
       type: String,
       required: true,
     },
+    disableInlineEditing: {
+      type: Boolean,
+      required: false,
+      default: false,
+    },
+    editMode: {
+      type: Boolean,
+      required: false,
+      default: false,
+    },
+    updateInProgress: {
+      type: Boolean,
+      required: false,
+      default: false,
+    },
   },
   markdownDocsPath: helpPagePath('user/markdown'),
   data() {
     return {
       workItem: {},
-      isEditing: false,
+      isEditing: this.editMode,
       isSubmitting: false,
       isSubmittingWithKeydown: false,
       descriptionText: '',
@@ -126,6 +141,26 @@ export default {
     autocompleteDataSources() {
       return autocompleteDataSources(this.fullPath, this.workItem.iid);
     },
+    saveButtonText() {
+      return this.editMode ? __('Save changes') : __('Save');
+    },
+    formGroupClass() {
+      return {
+        'gl-border-t gl-pt-6': !this.disableInlineEditing,
+        'gl-mb-5 common-note-form': true,
+      };
+    },
+  },
+  watch: {
+    updateInProgress(newValue) {
+      this.isSubmitting = newValue;
+    },
+    editMode(newValue) {
+      this.isEditing = newValue;
+      if (newValue) {
+        this.startEditing();
+      }
+    },
   },
   methods: {
     checkForConflicts() {
@@ -159,6 +194,7 @@ export default {
       }
 
       this.isEditing = false;
+      this.$emit('cancelEditing');
       clearDraft(this.autosaveKey);
     },
     onInput() {
@@ -175,6 +211,11 @@ export default {
         this.isSubmittingWithKeydown = true;
       }
 
+      if (this.disableInlineEditing) {
+        this.$emit('updateWorkItem');
+        return;
+      }
+
       this.isSubmitting = true;
 
       try {
@@ -210,6 +251,9 @@ export default {
     },
     setDescriptionText(newText) {
       this.descriptionText = newText;
+      if (this.disableInlineEditing) {
+        this.$emit('updateDraft', this.descriptionText);
+      }
       updateDraft(this.autosaveKey, this.descriptionText);
     },
     handleDescriptionTextUpdated(newText) {
@@ -224,12 +268,13 @@ export default {
   <div>
     <gl-form v-if="isEditing" @submit.prevent="updateWorkItem" @reset.prevent="cancelEditing">
       <gl-form-group
-        class="gl-mb-5 gl-border-t gl-pt-6 common-note-form"
+        :class="formGroupClass"
         :label="__('Description')"
+        :label-sr-only="disableInlineEditing"
         label-for="work-item-description"
       >
         <markdown-editor
-          class="gl-my-5"
+          class="gl-mb-5"
           :value="descriptionText"
           :render-markdown-path="markdownPreviewPath"
           :markdown-docs-path="$options.markdownDocsPath"
@@ -285,9 +330,9 @@ export default {
               :loading="isSubmitting"
               data-testid="save-description"
               type="submit"
-              >{{ __('Save') }}
+              >{{ saveButtonText }}
             </gl-button>
-            <gl-button category="tertiary" class="gl-ml-3" data-testid="cancel" type="reset"
+            <gl-button category="secondary" class="gl-ml-3" data-testid="cancel" type="reset"
               >{{ __('Cancel') }}
             </gl-button>
           </template>
@@ -296,13 +341,14 @@ export default {
     </gl-form>
     <work-item-description-rendered
       v-else
+      :disable-inline-editing="disableInlineEditing"
       :work-item-description="workItemDescription"
       :can-edit="canEdit"
       @startEditing="startEditing"
       @descriptionUpdated="handleDescriptionTextUpdated"
     />
     <edited-at
-      v-if="lastEditedAt"
+      v-if="lastEditedAt && !editMode"
       :updated-at="lastEditedAt"
       :updated-by-name="lastEditedByName"
       :updated-by-path="lastEditedByPath"
diff --git a/app/assets/javascripts/work_items/components/work_item_description_rendered.vue b/app/assets/javascripts/work_items/components/work_item_description_rendered.vue
index 124e05db431a1..1699f6c419e3a 100644
--- a/app/assets/javascripts/work_items/components/work_item_description_rendered.vue
+++ b/app/assets/javascripts/work_items/components/work_item_description_rendered.vue
@@ -22,6 +22,16 @@ export default {
       type: Boolean,
       required: true,
     },
+    disableInlineEditing: {
+      type: Boolean,
+      required: false,
+      default: false,
+    },
+  },
+  data() {
+    return {
+      checkboxes: [],
+    };
   },
   computed: {
     descriptionText() {
@@ -33,6 +43,12 @@ export default {
     descriptionEmpty() {
       return this.descriptionHtml?.trim() === '';
     },
+    showEmptyDescription() {
+      return this.descriptionEmpty && !this.disableInlineEditing;
+    },
+    showEditButton() {
+      return this.canEdit && !this.disableInlineEditing;
+    },
   },
   watch: {
     descriptionHtml: {
@@ -96,9 +112,11 @@ export default {
 <template>
   <div class="gl-mb-5">
     <div class="gl-display-inline-flex gl-align-items-center gl-mb-3">
-      <label class="d-block col-form-label gl-mr-5">{{ __('Description') }}</label>
+      <label v-if="!disableInlineEditing" class="d-block col-form-label gl-mr-5">{{
+        __('Description')
+      }}</label>
       <gl-button
-        v-if="canEdit"
+        v-if="showEditButton"
         v-gl-tooltip
         class="gl-ml-auto"
         icon="pencil"
@@ -109,9 +127,9 @@ export default {
       />
     </div>
 
-    <div v-if="descriptionEmpty" class="gl-text-secondary gl-mb-5">{{ __('None') }}</div>
+    <div v-if="showEmptyDescription" class="gl-text-secondary gl-mb-5">{{ __('None') }}</div>
     <div
-      v-else
+      v-else-if="!descriptionEmpty"
       ref="gfm-content"
       v-safe-html="descriptionHtml"
       class="md gl-mb-5 gl-min-h-8 gl-clearfix"
diff --git a/app/assets/javascripts/work_items/components/work_item_detail.vue b/app/assets/javascripts/work_items/components/work_item_detail.vue
index b74cbc8537993..93f552bfa4c2a 100644
--- a/app/assets/javascripts/work_items/components/work_item_detail.vue
+++ b/app/assets/javascripts/work_items/components/work_item_detail.vue
@@ -2,6 +2,7 @@
 import { isEmpty } from 'lodash';
 import { GlAlert, GlSkeletonLoader, GlButton, GlTooltipDirective, GlEmptyState } from '@gitlab/ui';
 import noAccessSvg from '@gitlab/svgs/dist/illustrations/analytics/no-access.svg?raw';
+import * as Sentry from '~/sentry/sentry_browser_wrapper';
 import { s__ } from '~/locale';
 import { getParameterByName, updateHistory, setUrlParams } from '~/lib/utils/url_utility';
 import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
@@ -41,6 +42,7 @@ import WorkItemAwardEmoji from './work_item_award_emoji.vue';
 import WorkItemRelationships from './work_item_relationships/work_item_relationships.vue';
 import WorkItemStickyHeader from './work_item_sticky_header.vue';
 import WorkItemAncestors from './work_item_ancestors/work_item_ancestors.vue';
+import WorkItemTitleWithEdit from './work_item_title_with_edit.vue';
 
 export default {
   i18n,
@@ -67,6 +69,7 @@ export default {
     WorkItemRelationships,
     WorkItemStickyHeader,
     WorkItemAncestors,
+    WorkItemTitleWithEdit,
   },
   mixins: [glFeatureFlagMixin()],
   inject: ['fullPath', 'isGroup', 'reportAbusePath'],
@@ -94,6 +97,8 @@ export default {
       reportedUrl: '',
       reportedUserId: 0,
       isStickyHeaderShowing: false,
+      editMode: false,
+      draftData: {},
     };
   },
   apollo: {
@@ -219,7 +224,7 @@ export default {
       };
     },
     showIntersectionObserver() {
-      return !this.isModal && this.workItemsMvc2Enabled;
+      return !this.isModal && this.workItemsMvc2Enabled && !this.editMode;
     },
     hasLinkedWorkItems() {
       return this.glFeatures.linkedWorkItems;
@@ -233,13 +238,15 @@ export default {
     titleClassHeader() {
       return {
         'gl-sm-display-none!': this.parentWorkItem,
-        'gl-w-full': !this.parentWorkItem,
+        'gl-w-full': !this.parentWorkItem && !this.editMode,
+        'editable-wi-title': this.editMode && !this.parentWorkItem,
       };
     },
     titleClassComponent() {
       return {
         'gl-sm-display-block!': !this.parentWorkItem,
         'gl-display-none gl-sm-display-block!': this.parentWorkItem,
+        'gl-mt-3 editable-wi-title': this.workItemsMvc2Enabled,
       };
     },
     headerWrapperClass() {
@@ -258,6 +265,9 @@ export default {
     }
   },
   methods: {
+    enableEditMode() {
+      this.editMode = true;
+    },
     isWidgetPresent(type) {
       return this.workItem.widgets?.find((widget) => widget.type === type);
     },
@@ -349,6 +359,45 @@ export default {
         this.isStickyHeaderShowing = true;
       }
     },
+    updateDraft(type, value) {
+      this.draftData[type] = value;
+    },
+    async updateWorkItem() {
+      this.updateInProgress = true;
+      try {
+        const {
+          data: { workItemUpdate },
+        } = await this.$apollo.mutate({
+          mutation: updateWorkItemMutation,
+          variables: {
+            input: {
+              id: this.workItem.id,
+              title: this.draftData.title,
+              descriptionWidget: {
+                description: this.draftData.description,
+              },
+            },
+          },
+        });
+
+        const { errors } = workItemUpdate;
+
+        if (errors?.length) {
+          this.updateError = errors.join('\n');
+          throw new Error(this.updateError);
+        }
+
+        this.editMode = false;
+      } catch (error) {
+        Sentry.captureException(error);
+      } finally {
+        this.updateInProgress = false;
+      }
+    },
+    cancelEditing() {
+      this.draftData = {};
+      this.editMode = false;
+    },
   },
   WORK_ITEM_TYPE_VALUE_OBJECTIVE,
   WORKSPACE_PROJECT,
@@ -388,8 +437,16 @@ export default {
             :class="titleClassHeader"
             data-testid="work-item-type"
           >
+            <work-item-title-with-edit
+              v-if="workItem.title && workItemsMvc2Enabled"
+              ref="title"
+              class="gl-mt-3 gl-sm-display-block!"
+              :is-editing="editMode"
+              :title="workItem.title"
+              @updateDraft="updateDraft('title', $event)"
+            />
             <work-item-title
-              v-if="workItem.title"
+              v-else-if="workItem.title"
               ref="title"
               class="gl-sm-display-block!"
               :work-item-id="workItem.id"
@@ -402,6 +459,14 @@ export default {
           <div
             class="detail-page-header-actions gl-display-flex gl-align-self-start gl-ml-auto gl-gap-3"
           >
+            <gl-button
+              v-if="workItemsMvc2Enabled && !editMode"
+              category="secondary"
+              data-testid="work-item-edit-form-button"
+              @click="enableEditMode"
+            >
+              {{ __('Edit') }}
+            </gl-button>
             <work-item-todos
               v-if="showWorkItemCurrentUserTodos"
               :work-item-id="workItem.id"
@@ -441,8 +506,16 @@ export default {
           />
         </div>
         <div>
+          <work-item-title-with-edit
+            v-if="workItem.title && workItemsMvc2Enabled && parentWorkItem"
+            ref="title"
+            :is-editing="editMode"
+            :class="titleClassComponent"
+            :title="workItem.title"
+            @updateDraft="updateDraft('title', $event)"
+          />
           <work-item-title
-            v-if="workItem.title && parentWorkItem"
+            v-else-if="workItem.title && parentWorkItem"
             ref="title"
             :class="titleClassComponent"
             :work-item-id="workItem.id"
@@ -453,6 +526,7 @@ export default {
             @error="updateError = $event"
           />
           <work-item-created-updated
+            v-if="!editMode"
             :full-path="fullPath"
             :work-item-iid="workItemIid"
             :update-in-progress="updateInProgress"
@@ -490,10 +564,16 @@ export default {
             />
             <work-item-description
               v-if="hasDescriptionWidget"
+              :class="workItemsMvc2Enabled ? '' : 'gl-pt-5'"
+              :disable-inline-editing="workItemsMvc2Enabled"
+              :edit-mode="editMode"
               :full-path="fullPath"
               :work-item-id="workItem.id"
               :work-item-iid="workItem.iid"
-              class="gl-pt-5"
+              :update-in-progress="updateInProgress"
+              @updateWorkItem="updateWorkItem"
+              @updateDraft="updateDraft('description', $event)"
+              @cancelEditing="cancelEditing"
               @error="updateError = $event"
             />
             <work-item-award-emoji
diff --git a/app/assets/javascripts/work_items/components/work_item_title_with_edit.vue b/app/assets/javascripts/work_items/components/work_item_title_with_edit.vue
new file mode 100644
index 0000000000000..02ed25f98e4f9
--- /dev/null
+++ b/app/assets/javascripts/work_items/components/work_item_title_with_edit.vue
@@ -0,0 +1,43 @@
+<script>
+import { GlFormGroup, GlFormInput } from '@gitlab/ui';
+import { __ } from '~/locale';
+
+export default {
+  components: {
+    GlFormGroup,
+    GlFormInput,
+  },
+  i18n: {
+    titleLabel: __('Title (required)'),
+  },
+  props: {
+    title: {
+      type: String,
+      required: true,
+    },
+    isEditing: {
+      type: Boolean,
+      required: false,
+      default: false,
+    },
+  },
+};
+</script>
+
+<template>
+  <gl-form-group v-if="isEditing" :label="$options.i18n.titleLabel" label-for="work-item-title">
+    <gl-form-input
+      id="work-item-title"
+      class="gl-w-full"
+      :value="title"
+      @change="$emit('updateDraft', $event)"
+    />
+  </gl-form-group>
+  <h1
+    v-else
+    data-testid="work-item-title"
+    class="gl-w-full gl-font-weight-normal gl-sm-font-weight-bold gl-mb-1 gl-mt-0 gl-font-size-h-display"
+  >
+    {{ title }}
+  </h1>
+</template>
diff --git a/app/assets/stylesheets/page_bundles/work_items.scss b/app/assets/stylesheets/page_bundles/work_items.scss
index b9ab2450ff921..5b354f3575cfa 100644
--- a/app/assets/stylesheets/page_bundles/work_items.scss
+++ b/app/assets/stylesheets/page_bundles/work_items.scss
@@ -4,6 +4,7 @@
 $work-item-field-inset-shadow: inset 0 0 0 $gl-border-size-1 var(--gray-200, $gray-200) !important;
 $work-item-overview-right-sidebar-width: 23rem;
 $work-item-sticky-header-height: 52px;
+$work-item-overview-gap-width: 2rem;
 
 .gl-token-selector-token-container {
   display: flex;
@@ -146,7 +147,7 @@ $work-item-sticky-header-height: 52px;
   @include media-breakpoint-up(md) {
     display: grid;
     grid-template-columns: 1fr $work-item-overview-right-sidebar-width;
-    gap: 2rem;
+    gap: $work-item-overview-gap-width;
   }
 }
 
@@ -216,6 +217,12 @@ $work-item-sticky-header-height: 52px;
   }
 }
 
+.editable-wi-title {
+  width: 100%;
+  @include media-breakpoint-up(md) {
+    width: calc(100% - #{$work-item-overview-right-sidebar-width} - #{$work-item-overview-gap-width});
+  }
+}
 // Disclosure hierarchy component, used for Ancestors widget
 
 $disclosure-hierarchy-chevron-dimension: 1.2rem;
diff --git a/spec/frontend/work_items/components/work_item_description_rendered_spec.js b/spec/frontend/work_items/components/work_item_description_rendered_spec.js
index 4f1d49ee2e5f5..c4c88c7643f45 100644
--- a/spec/frontend/work_items/components/work_item_description_rendered_spec.js
+++ b/spec/frontend/work_items/components/work_item_description_rendered_spec.js
@@ -20,11 +20,13 @@ describe('WorkItemDescription', () => {
   const createComponent = ({
     workItemDescription = defaultWorkItemDescription,
     canEdit = false,
+    disableInlineEditing = false,
   } = {}) => {
     wrapper = shallowMount(WorkItemDescriptionRendered, {
       propsData: {
         workItemDescription,
         canEdit,
+        disableInlineEditing,
       },
     });
   };
@@ -81,8 +83,8 @@ describe('WorkItemDescription', () => {
   });
 
   describe('Edit button', () => {
-    it('is not visible when canUpdate = false', async () => {
-      await createComponent({
+    it('is not visible when canUpdate = false', () => {
+      createComponent({
         canUpdate: false,
       });
 
@@ -100,5 +102,14 @@ describe('WorkItemDescription', () => {
 
       expect(wrapper.emitted('startEditing')).toEqual([[]]);
     });
+
+    it('is not visible when `disableInlineEditing` is true and the user can edit', () => {
+      createComponent({
+        disableInlineEditing: true,
+        canEdit: true,
+      });
+
+      expect(findEditButton().exists()).toBe(false);
+    });
   });
 });
diff --git a/spec/frontend/work_items/components/work_item_description_spec.js b/spec/frontend/work_items/components/work_item_description_spec.js
index 1d25bb74986f0..3b137008b5b9c 100644
--- a/spec/frontend/work_items/components/work_item_description_spec.js
+++ b/spec/frontend/work_items/components/work_item_description_spec.js
@@ -56,6 +56,8 @@ describe('WorkItemDescription', () => {
     isEditing = false,
     isGroup = false,
     workItemIid = '1',
+    disableInlineEditing = false,
+    editMode = false,
   } = {}) => {
     workItemResponseHandler = jest.fn().mockResolvedValue(workItemResponse);
     groupWorkItemResponseHandler = jest
@@ -73,6 +75,8 @@ describe('WorkItemDescription', () => {
         fullPath: 'test-project-path',
         workItemId: id,
         workItemIid,
+        disableInlineEditing,
+        editMode,
       },
       provide: {
         isGroup,
@@ -283,4 +287,36 @@ describe('WorkItemDescription', () => {
       expect(groupWorkItemResponseHandler).toHaveBeenCalled();
     });
   });
+
+  describe('when inline editing is disabled', () => {
+    describe('when edit mode is inactive', () => {
+      beforeEach(() => {
+        createComponent({ disableInlineEditing: true });
+      });
+
+      it('passes the correct props for work item rendered description', () => {
+        expect(findRenderedDescription().props('disableInlineEditing')).toBe(true);
+      });
+
+      it('does not show edit mode of markdown editor in default mode', () => {
+        expect(findMarkdownEditor().exists()).toBe(false);
+      });
+    });
+
+    describe('when edit mode is active', () => {
+      beforeEach(() => {
+        createComponent({ disableInlineEditing: true, editMode: true });
+      });
+
+      it('shows markdown editor in edit mode only when the correct props are passed', () => {
+        expect(findMarkdownEditor().exists()).toBe(true);
+      });
+
+      it('emits the `updateDraft` event when clicked on submit button in edit mode', () => {
+        const updatedDesc = 'updated desc with inline editing disabled';
+        findMarkdownEditor().vm.$emit('input', updatedDesc);
+        expect(wrapper.emitted('updateDraft')).toEqual([[updatedDesc]]);
+      });
+    });
+  });
 });
diff --git a/spec/frontend/work_items/components/work_item_detail_spec.js b/spec/frontend/work_items/components/work_item_detail_spec.js
index d63bb94c3f0c6..e43c4d3c74d93 100644
--- a/spec/frontend/work_items/components/work_item_detail_spec.js
+++ b/spec/frontend/work_items/components/work_item_detail_spec.js
@@ -19,6 +19,7 @@ import WorkItemRelationships from '~/work_items/components/work_item_relationshi
 import WorkItemNotes from '~/work_items/components/work_item_notes.vue';
 import WorkItemDetailModal from '~/work_items/components/work_item_detail_modal.vue';
 import WorkItemStickyHeader from '~/work_items/components/work_item_sticky_header.vue';
+import WorkItemTitleWithEdit from '~/work_items/components/work_item_title_with_edit.vue';
 import AbuseCategorySelector from '~/abuse_reports/components/abuse_category_selector.vue';
 import WorkItemTodos from '~/work_items/components/work_item_todos.vue';
 import { i18n } from '~/work_items/constants';
@@ -81,6 +82,8 @@ describe('WorkItemDetail component', () => {
   const findStickyHeader = () => wrapper.findComponent(WorkItemStickyHeader);
   const findWorkItemTwoColumnViewContainer = () => wrapper.findByTestId('work-item-overview');
   const findRightSidebar = () => wrapper.findByTestId('work-item-overview-right-sidebar');
+  const findEditButton = () => wrapper.findByTestId('work-item-edit-form-button');
+  const findWorkItemTitleWithEdit = () => wrapper.findComponent(WorkItemTitleWithEdit);
 
   const createComponent = ({
     isGroup = false,
@@ -686,4 +689,65 @@ describe('WorkItemDetail component', () => {
       });
     });
   });
+
+  describe('edit button for work item title and description', () => {
+    describe('when `workItemsMvc2Enabled` is false', () => {
+      beforeEach(async () => {
+        createComponent({ workItemsMvc2Enabled: false });
+        await waitForPromises();
+      });
+
+      it('does not show the edit button', () => {
+        expect(findEditButton().exists()).toBe(false);
+      });
+
+      it('renders the work item title inline editable component', () => {
+        expect(findWorkItemTitle().exists()).toBe(true);
+      });
+
+      it('does not render the work item title with edit component', () => {
+        expect(findWorkItemTitleWithEdit().exists()).toBe(false);
+      });
+    });
+
+    describe('when `workItemsMvc2Enabled` is true', () => {
+      beforeEach(async () => {
+        createComponent({ workItemsMvc2Enabled: true });
+        await waitForPromises();
+      });
+
+      it('shows the edit button', () => {
+        expect(findEditButton().exists()).toBe(true);
+      });
+
+      it('does not render the work item title inline editable component', () => {
+        expect(findWorkItemTitle().exists()).toBe(false);
+      });
+
+      it('renders the work item title with edit component', () => {
+        expect(findWorkItemTitleWithEdit().exists()).toBe(true);
+        expect(findWorkItemTitleWithEdit().props('isEditing')).toBe(false);
+      });
+
+      it('work item description is not shown in edit mode by default', () => {
+        expect(findWorkItemDescription().props('editMode')).toBe(false);
+      });
+
+      describe('when edit is clicked', () => {
+        beforeEach(async () => {
+          findEditButton().vm.$emit('click');
+          await nextTick();
+        });
+
+        it('work item title component shows in edit mode', () => {
+          expect(findWorkItemTitleWithEdit().props('isEditing')).toBe(true);
+        });
+
+        it('work item description component shows in edit mode', () => {
+          expect(findWorkItemDescription().props('disableInlineEditing')).toBe(true);
+          expect(findWorkItemDescription().props('editMode')).toBe(true);
+        });
+      });
+    });
+  });
 });
diff --git a/spec/frontend/work_items/components/work_item_with_title_edit_spec.js b/spec/frontend/work_items/components/work_item_with_title_edit_spec.js
new file mode 100644
index 0000000000000..db9551b6ec36b
--- /dev/null
+++ b/spec/frontend/work_items/components/work_item_with_title_edit_spec.js
@@ -0,0 +1,59 @@
+import { GlFormGroup, GlFormInput } from '@gitlab/ui';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import WorkItemTitleWithEdit from '~/work_items/components/work_item_title_with_edit.vue';
+
+describe('Work Item title with edit', () => {
+  let wrapper;
+  const mockTitle = 'Work Item title';
+
+  const createComponent = ({ isEditing = false } = {}) => {
+    wrapper = shallowMountExtended(WorkItemTitleWithEdit, {
+      propsData: {
+        title: mockTitle,
+        isEditing,
+      },
+    });
+  };
+
+  const findTitle = () => wrapper.findByTestId('work-item-title');
+  const findEditableTitleForm = () => wrapper.findComponent(GlFormGroup);
+  const findEditableTitleInput = () => wrapper.findComponent(GlFormInput);
+
+  describe('Default mode', () => {
+    beforeEach(() => {
+      createComponent();
+    });
+
+    it('renders title', () => {
+      expect(findTitle().exists()).toBe(true);
+      expect(findTitle().text()).toBe(mockTitle);
+    });
+
+    it('does not render edit mode', () => {
+      expect(findEditableTitleForm().exists()).toBe(false);
+    });
+  });
+
+  describe('Edit mode', () => {
+    beforeEach(() => {
+      createComponent({ isEditing: true });
+    });
+
+    it('does not render read only title', () => {
+      expect(findTitle().exists()).toBe(false);
+    });
+
+    it('renders the editable title with label', () => {
+      expect(findEditableTitleForm().exists()).toBe(true);
+      expect(findEditableTitleForm().attributes('label')).toBe(
+        WorkItemTitleWithEdit.i18n.titleLabel,
+      );
+    });
+
+    it('emits `updateDraft` event on change of the input', () => {
+      findEditableTitleInput().vm.$emit('change', 'updated title');
+
+      expect(wrapper.emitted('updateDraft')).toEqual([['updated title']]);
+    });
+  });
+});
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 3dfd7604914e3..a5b467da45deb 100644
--- a/spec/support/shared_examples/features/work_items_shared_examples.rb
+++ b/spec/support/shared_examples/features/work_items_shared_examples.rb
@@ -3,6 +3,13 @@
 RSpec.shared_examples 'work items title' do
   let(:title_selector) { '[data-testid="work-item-title"]' }
 
+  before do
+    stub_feature_flags(work_items_mvc_2: false)
+
+    page.refresh
+    wait_for_all_requests
+  end
+
   it 'successfully shows and changes the title of the work item' do
     expect(work_item.reload.title).to eq work_item.title
 
@@ -299,54 +306,67 @@ def click_reply_and_enter_slash
 end
 
 RSpec.shared_examples 'work items description' do
-  it 'shows GFM autocomplete', :aggregate_failures do
-    click_button "Edit description"
-    fill_in _('Description'), with: "@#{user.username}"
+  context 'for work_items_mvc_2 FF' do
+    [true, false].each do |work_items_mvc_2_flag| # rubocop:disable RSpec/UselessDynamicDefinition -- check it for both off and on
+      let(:edit_button) { work_items_mvc_2_flag ? 'Edit' : 'Edit description' }
 
-    page.within('.atwho-container') do
-      expect(page).to have_text(user.name)
-    end
-  end
+      before do
+        stub_feature_flags(work_items_mvc_2: work_items_mvc_2_flag)
+
+        page.refresh
+        wait_for_all_requests
+      end
 
-  it 'autocompletes available quick actions', :aggregate_failures do
-    click_button "Edit description"
-    fill_in _('Description'), with: '/'
+      it 'shows GFM autocomplete', :aggregate_failures do
+        click_button edit_button
+        fill_in _('Description'), with: "@#{user.username}"
 
-    page.within('#at-view-commands') do
-      expect(page).to have_text("title")
-      expect(page).to have_text("shrug")
-      expect(page).to have_text("tableflip")
-      expect(page).to have_text("close")
-      expect(page).to have_text("cc")
-    end
-  end
+        page.within('.atwho-container') do
+          expect(page).to have_text(user.name)
+        end
+      end
 
-  context 'on conflict' do
-    let_it_be(:other_user) { create(:user) }
-    let(:expected_warning) { 'Someone edited the description at the same time you did.' }
+      it 'autocompletes available quick actions', :aggregate_failures do
+        click_button edit_button
+        fill_in _('Description'), with: '/'
 
-    before do
-      project.add_developer(other_user)
-    end
+        page.within('#at-view-commands') do
+          expect(page).to have_text("title")
+          expect(page).to have_text("shrug")
+          expect(page).to have_text("tableflip")
+          expect(page).to have_text("close")
+          expect(page).to have_text("cc")
+        end
+      end
 
-    it 'shows conflict message when description changes', :aggregate_failures do
-      click_button "Edit description"
+      context 'on conflict' do
+        let_it_be(:other_user) { create(:user) }
+        let(:expected_warning) { 'Someone edited the description at the same time you did.' }
 
-      ::WorkItems::UpdateService.new(
-        container: work_item.project,
-        current_user: other_user,
-        params: { description: "oh no!" }
-      ).execute(work_item)
+        before do
+          project.add_developer(other_user)
+        end
 
-      wait_for_requests
+        it 'shows conflict message when description changes', :aggregate_failures do
+          click_button edit_button
+
+          ::WorkItems::UpdateService.new(
+            container: work_item.project,
+            current_user: other_user,
+            params: { description: "oh no!" }
+          ).execute(work_item)
 
-      fill_in _('Description'), with: 'oh yeah!'
+          wait_for_requests
 
-      expect(page).to have_text(expected_warning)
+          fill_in _('Description'), with: 'oh yeah!'
 
-      click_button s_('WorkItem|Save and overwrite')
+          expect(page).to have_text(expected_warning)
 
-      expect(page.find('[data-testid="work-item-description"]')).to have_text("oh yeah!")
+          click_button s_('WorkItem|Save and overwrite')
+
+          expect(page.find('[data-testid="work-item-description"]')).to have_text("oh yeah!")
+        end
+      end
     end
   end
 end
-- 
GitLab