From 55fa10f872f537dba52b59db34f35283ecc11eb0 Mon Sep 17 00:00:00 2001
From: Kushal Pandya <kushalspandya@gmail.com>
Date: Wed, 8 Dec 2021 11:51:33 +0000
Subject: [PATCH] Add support for editing feature title

---
 .../work_items/components/item_title.vue      | 71 +++++++++++++++++++
 .../work_items/graphql/resolvers.js           | 25 +++++++
 .../work_items/graphql/typedefs.graphql       | 10 +++
 .../graphql/update_work_item.mutation.graphql | 18 +++++
 .../work_items/pages/create_work_item.vue     | 16 ++---
 .../work_items/pages/work_item_root.vue       | 38 ++++++++--
 app/assets/stylesheets/framework/common.scss  |  7 ++
 locale/gitlab.pot                             |  5 +-
 .../work_items/components/item_title_spec.js  | 56 +++++++++++++++
 spec/frontend/work_items/mock_data.js         | 19 +++++
 .../work_items/pages/create_work_item_spec.js |  8 ++-
 .../work_items/pages/work_item_root_spec.js   | 31 +++++++-
 12 files changed, 283 insertions(+), 21 deletions(-)
 create mode 100644 app/assets/javascripts/work_items/components/item_title.vue
 create mode 100644 app/assets/javascripts/work_items/graphql/update_work_item.mutation.graphql
 create mode 100644 spec/frontend/work_items/components/item_title_spec.js

diff --git a/app/assets/javascripts/work_items/components/item_title.vue b/app/assets/javascripts/work_items/components/item_title.vue
new file mode 100644
index 0000000000000..5e9e50a94f074
--- /dev/null
+++ b/app/assets/javascripts/work_items/components/item_title.vue
@@ -0,0 +1,71 @@
+<script>
+import { escape } from 'lodash';
+import { __ } from '~/locale';
+
+export default {
+  props: {
+    initialTitle: {
+      type: String,
+      required: false,
+      default: '',
+    },
+    placeholder: {
+      type: String,
+      required: false,
+      default: __('Add a title...'),
+    },
+    disabled: {
+      type: Boolean,
+      required: false,
+      default: false,
+    },
+  },
+  data() {
+    return {
+      title: this.initialTitle,
+    };
+  },
+  methods: {
+    getSanitizedTitle(inputEl) {
+      const { innerText } = inputEl;
+      return escape(innerText);
+    },
+    handleBlur({ target }) {
+      this.$emit('title-changed', this.getSanitizedTitle(target));
+    },
+    handleInput({ target }) {
+      this.$emit('title-input', this.getSanitizedTitle(target));
+    },
+    handleSubmit() {
+      this.$refs.titleEl.blur();
+    },
+  },
+};
+</script>
+
+<template>
+  <h2
+    class="gl-font-weight-normal gl-sm-font-weight-bold gl-my-5 gl-display-inline-block"
+    :class="{ 'gl-cursor-not-allowed': disabled }"
+    data-testid="title"
+    aria-labelledby="item-title"
+  >
+    <span
+      id="item-title"
+      ref="titleEl"
+      role="textbox"
+      :aria-label="__('Title')"
+      :data-placeholder="placeholder"
+      :contenteditable="!disabled"
+      class="gl-pseudo-placeholder"
+      @blur="handleBlur"
+      @keyup="handleInput"
+      @keydown.enter.exact="handleSubmit"
+      @keydown.ctrl.u.prevent
+      @keydown.meta.u.prevent
+      @keydown.ctrl.b.prevent
+      @keydown.meta.b.prevent
+      >{{ title }}</span
+    >
+  </h2>
+</template>
diff --git a/app/assets/javascripts/work_items/graphql/resolvers.js b/app/assets/javascripts/work_items/graphql/resolvers.js
index bab0147f4b8db..8005f334314cb 100644
--- a/app/assets/javascripts/work_items/graphql/resolvers.js
+++ b/app/assets/javascripts/work_items/graphql/resolvers.js
@@ -29,5 +29,30 @@ export const resolvers = {
         workItem,
       };
     },
+
+    updateWorkItem(_, { input }, { cache }) {
+      const workItemTitle = {
+        __typename: 'TitleWidget',
+        type: 'TITLE',
+        enabled: true,
+        contentText: input.title,
+      };
+      const workItem = {
+        __typename: 'WorkItem',
+        type: 'FEATURE',
+        id: input.id,
+        widgets: {
+          __typename: 'WorkItemWidgetConnection',
+          nodes: [workItemTitle],
+        },
+      };
+
+      cache.writeQuery({ query: workItemQuery, variables: { id: input.id }, data: { workItem } });
+
+      return {
+        __typename: 'UpdateWorkItemPayload',
+        workItem,
+      };
+    },
   },
 };
diff --git a/app/assets/javascripts/work_items/graphql/typedefs.graphql b/app/assets/javascripts/work_items/graphql/typedefs.graphql
index 2a9cd52c18ec8..dd7ea7c26cc2a 100644
--- a/app/assets/javascripts/work_items/graphql/typedefs.graphql
+++ b/app/assets/javascripts/work_items/graphql/typedefs.graphql
@@ -37,14 +37,24 @@ type CreateWorkItemInput {
   title: String!
 }
 
+type UpdateWorkItemInput {
+  id: ID!
+  title: String
+}
+
 type CreateWorkItemPayload {
   workItem: WorkItem!
 }
 
+type UpdateWorkItemPayload {
+  workItem: WorkItem!
+}
+
 extend type Query {
   workItem(id: ID!): WorkItem!
 }
 
 extend type Mutation {
   createWorkItem(input: CreateWorkItemInput!): CreateWorkItemPayload!
+  updateWorkItem(input: UpdateWorkItemInput!): UpdateWorkItemPayload!
 }
diff --git a/app/assets/javascripts/work_items/graphql/update_work_item.mutation.graphql b/app/assets/javascripts/work_items/graphql/update_work_item.mutation.graphql
new file mode 100644
index 0000000000000..fc140954fbe42
--- /dev/null
+++ b/app/assets/javascripts/work_items/graphql/update_work_item.mutation.graphql
@@ -0,0 +1,18 @@
+#import './widget.fragment.graphql'
+
+mutation updateWorkItem($input: UpdateWorkItemInput) {
+  updateWorkItem(input: $input) @client {
+    workItem {
+      id
+      type
+      widgets {
+        nodes {
+          ...WidgetBase
+          ... on TitleWidget {
+            contentText
+          }
+        }
+      }
+    }
+  }
+}
diff --git a/app/assets/javascripts/work_items/pages/create_work_item.vue b/app/assets/javascripts/work_items/pages/create_work_item.vue
index 190e50f903c2e..43cbee019c1ca 100644
--- a/app/assets/javascripts/work_items/pages/create_work_item.vue
+++ b/app/assets/javascripts/work_items/pages/create_work_item.vue
@@ -2,10 +2,13 @@
 import { GlButton, GlAlert } from '@gitlab/ui';
 import createWorkItemMutation from '../graphql/create_work_item.mutation.graphql';
 
+import ItemTitle from '../components/item_title.vue';
+
 export default {
   components: {
     GlButton,
     GlAlert,
+    ItemTitle,
   },
   data() {
     return {
@@ -37,6 +40,9 @@ export default {
         this.error = true;
       }
     },
+    handleTitleInput(title) {
+      this.title = title;
+    },
   },
 };
 </script>
@@ -46,15 +52,7 @@ export default {
     <gl-alert v-if="error" variant="danger" @dismiss="error = false">{{
       __('Something went wrong when creating a work item. Please try again')
     }}</gl-alert>
-    <label for="title" class="gl-sr-only">{{ __('Title') }}</label>
-    <input
-      id="title"
-      v-model.trim="title"
-      type="text"
-      class="gl-font-size-h-display gl-font-weight-bold gl-my-5 gl-border-none gl-w-full gl-pl-2"
-      data-testid="title-input"
-      :placeholder="__('Add a title…')"
-    />
+    <item-title data-testid="title-input" @title-input="handleTitleInput" />
     <div class="gl-bg-gray-10 gl-py-5 gl-px-6">
       <gl-button
         variant="confirm"
diff --git a/app/assets/javascripts/work_items/pages/work_item_root.vue b/app/assets/javascripts/work_items/pages/work_item_root.vue
index 493ee0aba01a7..479274baf3a0d 100644
--- a/app/assets/javascripts/work_items/pages/work_item_root.vue
+++ b/app/assets/javascripts/work_items/pages/work_item_root.vue
@@ -1,8 +1,16 @@
 <script>
+import { GlAlert } from '@gitlab/ui';
 import workItemQuery from '../graphql/work_item.query.graphql';
+import updateWorkItemMutation from '../graphql/update_work_item.mutation.graphql';
 import { widgetTypes } from '../constants';
 
+import ItemTitle from '../components/item_title.vue';
+
 export default {
+  components: {
+    ItemTitle,
+    GlAlert,
+  },
   props: {
     id: {
       type: String,
@@ -12,6 +20,7 @@ export default {
   data() {
     return {
       workItem: null,
+      error: false,
     };
   },
   apollo: {
@@ -29,20 +38,39 @@ export default {
       return this.workItem?.widgets?.nodes?.find((widget) => widget.type === widgetTypes.title);
     },
   },
+  methods: {
+    async updateWorkItem(title) {
+      try {
+        await this.$apollo.mutate({
+          mutation: updateWorkItemMutation,
+          variables: {
+            input: {
+              id: this.id,
+              title,
+            },
+          },
+        });
+      } catch {
+        this.error = true;
+      }
+    },
+  },
 };
 </script>
 
 <template>
   <section>
+    <gl-alert v-if="error" variant="danger" @dismiss="error = false">{{
+      __('Something went wrong while updating work item. Please try again')
+    }}</gl-alert>
     <!-- Title widget placeholder -->
     <div>
-      <h2
+      <item-title
         v-if="titleWidgetData"
-        class="gl-font-weight-normal gl-sm-font-weight-bold gl-my-5"
+        :initial-title="titleWidgetData.contentText"
         data-testid="title"
-      >
-        {{ titleWidgetData.contentText }}
-      </h2>
+        @title-changed="updateWorkItem"
+      />
     </div>
   </section>
 </template>
diff --git a/app/assets/stylesheets/framework/common.scss b/app/assets/stylesheets/framework/common.scss
index 354d2737894b5..36a0d3ca3caa3 100644
--- a/app/assets/stylesheets/framework/common.scss
+++ b/app/assets/stylesheets/framework/common.scss
@@ -479,6 +479,13 @@ img.emoji {
   border-top: 1px solid $border-color;
 }
 
+.gl-pseudo-placeholder:empty::before {
+  content: attr(data-placeholder);
+  font-weight: $gl-font-weight-normal;
+  color: $gl-text-color-secondary;
+  cursor: text;
+}
+
 /**
 🚨 Do not use these classes — they clash with the Gitlab UI design system and will be removed. 🚨
 See https://gitlab.com/gitlab-org/gitlab/issues/36857 for more details.
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index d666d45d2379e..e2c192b421391 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -2036,7 +2036,7 @@ msgstr ""
 msgid "Add a task list"
 msgstr ""
 
-msgid "Add a title…"
+msgid "Add a title..."
 msgstr ""
 
 msgid "Add a to do"
@@ -32840,6 +32840,9 @@ msgstr ""
 msgid "Something went wrong while updating assignees"
 msgstr ""
 
+msgid "Something went wrong while updating work item. Please try again"
+msgstr ""
+
 msgid "Something went wrong while updating your list settings"
 msgstr ""
 
diff --git a/spec/frontend/work_items/components/item_title_spec.js b/spec/frontend/work_items/components/item_title_spec.js
new file mode 100644
index 0000000000000..0f6e7091c592f
--- /dev/null
+++ b/spec/frontend/work_items/components/item_title_spec.js
@@ -0,0 +1,56 @@
+import { shallowMount } from '@vue/test-utils';
+import { escape } from 'lodash';
+import ItemTitle from '~/work_items/components/item_title.vue';
+
+jest.mock('lodash/escape', () => jest.fn((fn) => fn));
+
+const createComponent = ({ initialTitle = 'Sample title', disabled = false } = {}) =>
+  shallowMount(ItemTitle, {
+    propsData: {
+      initialTitle,
+      disabled,
+    },
+  });
+
+describe('ItemTitle', () => {
+  let wrapper;
+  const mockUpdatedTitle = 'Updated title';
+  const findInputEl = () => wrapper.find('span#item-title');
+
+  beforeEach(() => {
+    wrapper = createComponent();
+  });
+
+  afterEach(() => {
+    wrapper.destroy();
+  });
+
+  it('renders title contents', () => {
+    expect(findInputEl().attributes()).toMatchObject({
+      'data-placeholder': 'Add a title...',
+      contenteditable: 'true',
+    });
+    expect(findInputEl().text()).toBe('Sample title');
+  });
+
+  it('renders title contents with editing disabled', () => {
+    wrapper = createComponent({
+      disabled: true,
+    });
+
+    expect(wrapper.classes()).toContain('gl-cursor-not-allowed');
+    expect(findInputEl().attributes('contenteditable')).toBe('false');
+  });
+
+  it.each`
+    eventName          | sourceEvent
+    ${'title-changed'} | ${'blur'}
+    ${'title-input'}   | ${'keyup'}
+  `('emits "$eventName" event on input $sourceEvent', async ({ eventName, sourceEvent }) => {
+    findInputEl().element.innerText = mockUpdatedTitle;
+    await findInputEl().trigger(sourceEvent);
+
+    expect(wrapper.emitted(eventName)).toBeTruthy();
+    expect(escape).toHaveBeenCalledWith(mockUpdatedTitle);
+  });
+});
diff --git a/spec/frontend/work_items/mock_data.js b/spec/frontend/work_items/mock_data.js
index efb4aa2feb2fd..c8d46b5188818 100644
--- a/spec/frontend/work_items/mock_data.js
+++ b/spec/frontend/work_items/mock_data.js
@@ -15,3 +15,22 @@ export const workItemQueryResponse = {
     },
   },
 };
+
+export const updateWorkItemMutationResponse = {
+  __typename: 'UpdateWorkItemPayload',
+  workItem: {
+    __typename: 'WorkItem',
+    id: '1',
+    widgets: {
+      __typename: 'WorkItemWidgetConnection',
+      nodes: [
+        {
+          __typename: 'TitleWidget',
+          type: 'TITLE',
+          enabled: true,
+          contentText: 'Updated title',
+        },
+      ],
+    },
+  },
+};
diff --git a/spec/frontend/work_items/pages/create_work_item_spec.js b/spec/frontend/work_items/pages/create_work_item_spec.js
index 180f61f559f90..71e153d30c370 100644
--- a/spec/frontend/work_items/pages/create_work_item_spec.js
+++ b/spec/frontend/work_items/pages/create_work_item_spec.js
@@ -5,6 +5,7 @@ import { shallowMount } from '@vue/test-utils';
 import createMockApollo from 'helpers/mock_apollo_helper';
 import waitForPromises from 'helpers/wait_for_promises';
 import CreateWorkItem from '~/work_items/pages/create_work_item.vue';
+import ItemTitle from '~/work_items/components/item_title.vue';
 import { resolvers } from '~/work_items/graphql/resolvers';
 
 Vue.use(VueApollo);
@@ -14,9 +15,9 @@ describe('Create work item component', () => {
   let fakeApollo;
 
   const findAlert = () => wrapper.findComponent(GlAlert);
+  const findTitleInput = () => wrapper.findComponent(ItemTitle);
   const findCreateButton = () => wrapper.find('[data-testid="create-button"]');
   const findCancelButton = () => wrapper.find('[data-testid="cancel-button"]');
-  const findTitleInput = () => wrapper.find('[data-testid="title-input"]');
 
   const createComponent = ({ data = {} } = {}) => {
     fakeApollo = createMockApollo([], resolvers);
@@ -70,9 +71,10 @@ describe('Create work item component', () => {
   });
 
   describe('when title input field has a text', () => {
-    beforeEach(() => {
+    beforeEach(async () => {
+      const mockTitle = 'Test title';
       createComponent();
-      findTitleInput().setValue('Test title');
+      await findTitleInput().vm.$emit('title-input', mockTitle);
     });
 
     it('renders a non-disabled Create button', () => {
diff --git a/spec/frontend/work_items/pages/work_item_root_spec.js b/spec/frontend/work_items/pages/work_item_root_spec.js
index ea76e2628d312..02795751f334c 100644
--- a/spec/frontend/work_items/pages/work_item_root_spec.js
+++ b/spec/frontend/work_items/pages/work_item_root_spec.js
@@ -2,8 +2,12 @@ import Vue from 'vue';
 import { shallowMount } from '@vue/test-utils';
 import VueApollo from 'vue-apollo';
 import createMockApollo from 'helpers/mock_apollo_helper';
+import waitForPromises from 'helpers/wait_for_promises';
 import workItemQuery from '~/work_items/graphql/work_item.query.graphql';
+import updateWorkItemMutation from '~/work_items/graphql/update_work_item.mutation.graphql';
 import WorkItemsRoot from '~/work_items/pages/work_item_root.vue';
+import ItemTitle from '~/work_items/components/item_title.vue';
+import { resolvers } from '~/work_items/graphql/resolvers';
 import { workItemQueryResponse } from '../mock_data';
 
 Vue.use(VueApollo);
@@ -14,10 +18,10 @@ describe('Work items root component', () => {
   let wrapper;
   let fakeApollo;
 
-  const findTitle = () => wrapper.find('[data-testid="title"]');
+  const findTitle = () => wrapper.findComponent(ItemTitle);
 
   const createComponent = ({ queryResponse = workItemQueryResponse } = {}) => {
-    fakeApollo = createMockApollo();
+    fakeApollo = createMockApollo([], resolvers);
     fakeApollo.clients.defaultClient.cache.writeQuery({
       query: workItemQuery,
       variables: {
@@ -43,7 +47,28 @@ describe('Work items root component', () => {
     createComponent();
 
     expect(findTitle().exists()).toBe(true);
-    expect(findTitle().text()).toBe('Test');
+    expect(findTitle().props('initialTitle')).toBe('Test');
+  });
+
+  it('updates the title when it is edited', async () => {
+    createComponent();
+    jest.spyOn(wrapper.vm.$apollo, 'mutate');
+    const mockUpdatedTitle = 'Updated title';
+
+    await findTitle().vm.$emit('title-changed', mockUpdatedTitle);
+
+    expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({
+      mutation: updateWorkItemMutation,
+      variables: {
+        input: {
+          id: WORK_ITEM_ID,
+          title: mockUpdatedTitle,
+        },
+      },
+    });
+
+    await waitForPromises();
+    expect(findTitle().props('initialTitle')).toBe(mockUpdatedTitle);
   });
 
   it('does not render the title if title is not in the widgets list', () => {
-- 
GitLab