From 83f887d31b54dcebfc642c8a0839a32be11eb16b Mon Sep 17 00:00:00 2001
From: Coung Ngo <cngo@gitlab.com>
Date: Thu, 30 Jun 2022 09:20:53 +0000
Subject: [PATCH] Add ability to edit work items weight widget

The weight widget updates are only persisted in the frontend Apollo
Cache for now. When the backend mutation for updating the weight has been
created, the weight widget will be updated again to use this mutation.
---
 .../work_items/components/item_title.vue      |   9 +-
 .../components/work_item_detail.vue           |   9 +-
 .../components/work_item_weight.vue           | 130 ++++++++++++++-
 .../local_update_work_item.mutation.graphql   |   2 +-
 .../work_items/graphql/provider.js            |  23 ++-
 .../work_items/graphql/typedefs.graphql       |   5 +-
 app/assets/javascripts/work_items/index.js    |   1 +
 .../stylesheets/_page_specific_files.scss     |   1 -
 .../stylesheets/page_bundles/work_items.scss  |  15 ++
 app/assets/stylesheets/pages/work_items.scss  |   4 -
 app/views/projects/issues/show.html.haml      |   1 +
 app/views/projects/work_items/index.html.haml |   1 +
 config/application.rb                         |   1 +
 .../work_items/components/item_title_spec.js  |   2 -
 .../components/work_item_weight_spec.js       | 154 ++++++++++++++++--
 15 files changed, 307 insertions(+), 51 deletions(-)
 create mode 100644 app/assets/stylesheets/page_bundles/work_items.scss
 delete mode 100644 app/assets/stylesheets/pages/work_items.scss

diff --git a/app/assets/javascripts/work_items/components/item_title.vue b/app/assets/javascripts/work_items/components/item_title.vue
index 19fbad4eaa33..1cdc9c28f052 100644
--- a/app/assets/javascripts/work_items/components/item_title.vue
+++ b/app/assets/javascripts/work_items/components/item_title.vue
@@ -1,5 +1,4 @@
 <script>
-import { escape } from 'lodash';
 import { __ } from '~/locale';
 
 export default {
@@ -21,15 +20,11 @@ export default {
     },
   },
   methods: {
-    getSanitizedTitle(inputEl) {
-      const { innerText } = inputEl;
-      return escape(innerText);
-    },
     handleBlur({ target }) {
-      this.$emit('title-changed', this.getSanitizedTitle(target));
+      this.$emit('title-changed', target.innerText);
     },
     handleInput({ target }) {
-      this.$emit('title-input', this.getSanitizedTitle(target));
+      this.$emit('title-input', target.innerText);
     },
     handleSubmit() {
       this.$refs.titleEl.blur();
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 606d65a08f84..f52b3bf549e8 100644
--- a/app/assets/javascripts/work_items/components/work_item_detail.vue
+++ b/app/assets/javascripts/work_items/components/work_item_detail.vue
@@ -142,7 +142,14 @@ export default {
           :work-item-id="workItem.id"
           :assignees="workItemAssignees.nodes"
         />
-        <work-item-weight v-if="workItemWeight" :weight="workItemWeight.weight" />
+        <work-item-weight
+          v-if="workItemWeight"
+          class="gl-mb-5"
+          :can-update="canUpdate"
+          :weight="workItemWeight.weight"
+          :work-item-id="workItem.id"
+          :work-item-type="workItemType"
+        />
       </template>
       <work-item-description
         v-if="hasDescriptionWidget"
diff --git a/app/assets/javascripts/work_items/components/work_item_weight.vue b/app/assets/javascripts/work_items/components/work_item_weight.vue
index 72e678151e91..30e2c1e56b86 100644
--- a/app/assets/javascripts/work_items/components/work_item_weight.vue
+++ b/app/assets/javascripts/work_items/components/work_item_weight.vue
@@ -1,28 +1,142 @@
 <script>
+import { GlForm, GlFormGroup, GlFormInput } from '@gitlab/ui';
 import { __ } from '~/locale';
+import Tracking from '~/tracking';
+import { TRACKING_CATEGORY_SHOW } from '../constants';
+import localUpdateWorkItemMutation from '../graphql/local_update_work_item.mutation.graphql';
+
+/* eslint-disable @gitlab/require-i18n-strings */
+const allowedKeys = [
+  'Alt',
+  'ArrowDown',
+  'ArrowLeft',
+  'ArrowRight',
+  'ArrowUp',
+  'Backspace',
+  'Control',
+  'Delete',
+  'End',
+  'Enter',
+  'Home',
+  'Meta',
+  'PageDown',
+  'PageUp',
+  'Tab',
+  '0',
+  '1',
+  '2',
+  '3',
+  '4',
+  '5',
+  '6',
+  '7',
+  '8',
+  '9',
+];
+/* eslint-enable @gitlab/require-i18n-strings */
 
 export default {
+  inputId: 'weight-widget-input',
+  components: {
+    GlForm,
+    GlFormGroup,
+    GlFormInput,
+  },
+  mixins: [Tracking.mixin()],
   inject: ['hasIssueWeightsFeature'],
   props: {
+    canUpdate: {
+      type: Boolean,
+      required: false,
+      default: false,
+    },
     weight: {
       type: Number,
       required: false,
       default: undefined,
     },
+    workItemId: {
+      type: String,
+      required: true,
+    },
+    workItemType: {
+      type: String,
+      required: true,
+    },
+  },
+  data() {
+    return {
+      isEditing: false,
+    };
   },
   computed: {
-    weightText() {
-      return this.weight ?? __('None');
+    placeholder() {
+      return this.canUpdate && this.isEditing ? __('Enter a number') : __('None');
+    },
+    tracking() {
+      return {
+        category: TRACKING_CATEGORY_SHOW,
+        label: 'item_weight',
+        property: `type_${this.workItemType}`,
+      };
+    },
+    type() {
+      return this.canUpdate && this.isEditing ? 'number' : 'text';
+    },
+  },
+  methods: {
+    blurInput() {
+      this.$refs.input.$el.blur();
+    },
+    handleFocus() {
+      this.isEditing = true;
+    },
+    handleKeydown(event) {
+      if (!allowedKeys.includes(event.key)) {
+        event.preventDefault();
+      }
+    },
+    updateWeight(event) {
+      this.isEditing = false;
+      this.track('updated_weight');
+      this.$apollo.mutate({
+        mutation: localUpdateWorkItemMutation,
+        variables: {
+          input: {
+            id: this.workItemId,
+            weight: event.target.value === '' ? null : Number(event.target.value),
+          },
+        },
+      });
     },
   },
 };
 </script>
 
 <template>
-  <div v-if="hasIssueWeightsFeature" class="gl-mb-5 form-row">
-    <span class="gl-font-weight-bold col-lg-2 col-3 gl-overflow-wrap-break">{{
-      __('Weight')
-    }}</span>
-    <span class="gl-ml-5">{{ weightText }}</span>
-  </div>
+  <gl-form v-if="hasIssueWeightsFeature" @submit.prevent="blurInput">
+    <gl-form-group
+      class="gl-align-items-center"
+      :label="__('Weight')"
+      :label-for="$options.inputId"
+      label-class="gl-pb-0! gl-overflow-wrap-break"
+      label-cols="3"
+      label-cols-lg="2"
+    >
+      <gl-form-input
+        :id="$options.inputId"
+        ref="input"
+        min="0"
+        :placeholder="placeholder"
+        :readonly="!canUpdate"
+        size="sm"
+        :type="type"
+        :value="weight"
+        @blur="updateWeight"
+        @focus="handleFocus"
+        @keydown="handleKeydown"
+        @keydown.exact.esc.stop="blurInput"
+      />
+    </gl-form-group>
+  </gl-form>
 </template>
diff --git a/app/assets/javascripts/work_items/graphql/local_update_work_item.mutation.graphql b/app/assets/javascripts/work_items/graphql/local_update_work_item.mutation.graphql
index 0d31ecef6f82..43c92cf89ecc 100644
--- a/app/assets/javascripts/work_items/graphql/local_update_work_item.mutation.graphql
+++ b/app/assets/javascripts/work_items/graphql/local_update_work_item.mutation.graphql
@@ -1,6 +1,6 @@
 #import "./work_item.fragment.graphql"
 
-mutation localUpdateWorkItem($input: LocalWorkItemAssigneesInput) {
+mutation localUpdateWorkItem($input: LocalUpdateWorkItemInput) {
   localUpdateWorkItem(input: $input) @client {
     workItem {
       ...WorkItem
diff --git a/app/assets/javascripts/work_items/graphql/provider.js b/app/assets/javascripts/work_items/graphql/provider.js
index 9266b4cdccba..80d8c98e75db 100644
--- a/app/assets/javascripts/work_items/graphql/provider.js
+++ b/app/assets/javascripts/work_items/graphql/provider.js
@@ -2,7 +2,7 @@ import produce from 'immer';
 import Vue from 'vue';
 import VueApollo from 'vue-apollo';
 import createDefaultClient from '~/lib/graphql';
-import { WIDGET_TYPE_ASSIGNEE } from '../constants';
+import { WIDGET_TYPE_ASSIGNEE, WIDGET_TYPE_WEIGHT } from '../constants';
 import typeDefs from './typedefs.graphql';
 import workItemQuery from './work_item.query.graphql';
 
@@ -10,7 +10,7 @@ export const temporaryConfig = {
   typeDefs,
   cacheConfig: {
     possibleTypes: {
-      LocalWorkItemWidget: ['LocalWorkItemAssignees'],
+      LocalWorkItemWidget: ['LocalWorkItemAssignees', 'LocalWorkItemWeight'],
     },
     typePolicies: {
       WorkItem: {
@@ -46,7 +46,7 @@ export const temporaryConfig = {
                   {
                     __typename: 'LocalWorkItemWeight',
                     type: 'WEIGHT',
-                    weight: 0,
+                    weight: null,
                   },
                 ]
               );
@@ -67,10 +67,19 @@ export const resolvers = {
       });
 
       const data = produce(sourceData, (draftData) => {
-        const assigneesWidget = draftData.workItem.mockWidgets.find(
-          (widget) => widget.type === WIDGET_TYPE_ASSIGNEE,
-        );
-        assigneesWidget.nodes = [...input.assignees];
+        if (input.assignees) {
+          const assigneesWidget = draftData.workItem.mockWidgets.find(
+            (widget) => widget.type === WIDGET_TYPE_ASSIGNEE,
+          );
+          assigneesWidget.nodes = [...input.assignees];
+        }
+
+        if (input.weight != null) {
+          const weightWidget = draftData.workItem.mockWidgets.find(
+            (widget) => widget.type === WIDGET_TYPE_WEIGHT,
+          );
+          weightWidget.weight = input.weight;
+        }
       });
 
       cache.writeQuery({
diff --git a/app/assets/javascripts/work_items/graphql/typedefs.graphql b/app/assets/javascripts/work_items/graphql/typedefs.graphql
index de4bdad56596..71ac263a02eb 100644
--- a/app/assets/javascripts/work_items/graphql/typedefs.graphql
+++ b/app/assets/javascripts/work_items/graphql/typedefs.graphql
@@ -21,9 +21,10 @@ extend type WorkItem {
   mockWidgets: [LocalWorkItemWidget]
 }
 
-type LocalWorkItemAssigneesInput {
+input LocalUpdateWorkItemInput {
   id: WorkItemID!
   assignees: [UserCore!]
+  weight: Int
 }
 
 type LocalWorkItemPayload {
@@ -32,5 +33,5 @@ type LocalWorkItemPayload {
 }
 
 extend type Mutation {
-  localUpdateWorkItem(input: LocalWorkItemAssigneesInput!): LocalWorkItemPayload
+  localUpdateWorkItem(input: LocalUpdateWorkItemInput!): LocalWorkItemPayload
 }
diff --git a/app/assets/javascripts/work_items/index.js b/app/assets/javascripts/work_items/index.js
index 33e28831b544..6437df597b47 100644
--- a/app/assets/javascripts/work_items/index.js
+++ b/app/assets/javascripts/work_items/index.js
@@ -10,6 +10,7 @@ export const initWorkItemsRoot = () => {
 
   return new Vue({
     el,
+    name: 'WorkItemsRoot',
     router: createRouter(el.dataset.fullPath),
     apolloProvider: createApolloProvider(),
     provide: {
diff --git a/app/assets/stylesheets/_page_specific_files.scss b/app/assets/stylesheets/_page_specific_files.scss
index cf4a415446e0..be72ec334658 100644
--- a/app/assets/stylesheets/_page_specific_files.scss
+++ b/app/assets/stylesheets/_page_specific_files.scss
@@ -32,4 +32,3 @@
 @import './pages/storage_quota';
 @import './pages/tree';
 @import './pages/users';
-@import './pages/work_items';
diff --git a/app/assets/stylesheets/page_bundles/work_items.scss b/app/assets/stylesheets/page_bundles/work_items.scss
new file mode 100644
index 000000000000..af019fb091bb
--- /dev/null
+++ b/app/assets/stylesheets/page_bundles/work_items.scss
@@ -0,0 +1,15 @@
+@import 'mixins_and_variables_and_functions';
+
+.gl-token-selector-token-container {
+  display: flex;
+  align-items: center;
+}
+
+#weight-widget-input:not(:hover, :focus),
+#weight-widget-input[readonly] {
+  box-shadow: inset 0 0 0 $gl-border-size-1 var(--white, $white);
+}
+
+#weight-widget-input[readonly] {
+  background-color: var(--white, $white);
+}
diff --git a/app/assets/stylesheets/pages/work_items.scss b/app/assets/stylesheets/pages/work_items.scss
deleted file mode 100644
index b98f55df1ed0..000000000000
--- a/app/assets/stylesheets/pages/work_items.scss
+++ /dev/null
@@ -1,4 +0,0 @@
-.gl-token-selector-token-container {
-  display: flex;
-  align-items: center;
-}
diff --git a/app/views/projects/issues/show.html.haml b/app/views/projects/issues/show.html.haml
index 3572d1d6556c..06c422fc4d69 100644
--- a/app/views/projects/issues/show.html.haml
+++ b/app/views/projects/issues/show.html.haml
@@ -3,6 +3,7 @@
 - breadcrumb_title @issue.to_reference
 - page_title "#{@issue.title} (#{@issue.to_reference})", _("Issues")
 - add_page_specific_style 'page_bundles/issues_show'
+- add_page_specific_style 'page_bundles/work_items'
 
 = render 'projects/issuable/show', issuable: @issue, api_awards_path: award_emoji_issue_api_path(@issue)
 = render 'projects/invite_members_modal', project: @project
diff --git a/app/views/projects/work_items/index.html.haml b/app/views/projects/work_items/index.html.haml
index 1f36afc48aa9..d8b6ae96826a 100644
--- a/app/views/projects/work_items/index.html.haml
+++ b/app/views/projects/work_items/index.html.haml
@@ -1,3 +1,4 @@
 - page_title s_('WorkItem|Work Items')
+- add_page_specific_style 'page_bundles/work_items'
 
 #js-work-items{ data: work_items_index_data(@project) }
diff --git a/config/application.rb b/config/application.rb
index 5e18d5fdd969..6b4a55d6d05c 100644
--- a/config/application.rb
+++ b/config/application.rb
@@ -304,6 +304,7 @@ class Application < Rails::Application
     config.assets.precompile << "page_bundles/terms.css"
     config.assets.precompile << "page_bundles/todos.css"
     config.assets.precompile << "page_bundles/wiki.css"
+    config.assets.precompile << "page_bundles/work_items.css"
     config.assets.precompile << "page_bundles/xterm.css"
     config.assets.precompile << "lazy_bundles/cropper.css"
     config.assets.precompile << "lazy_bundles/select2.css"
diff --git a/spec/frontend/work_items/components/item_title_spec.js b/spec/frontend/work_items/components/item_title_spec.js
index 2c3f6ef8634c..a55f448c9a20 100644
--- a/spec/frontend/work_items/components/item_title_spec.js
+++ b/spec/frontend/work_items/components/item_title_spec.js
@@ -1,5 +1,4 @@
 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));
@@ -51,6 +50,5 @@ describe('ItemTitle', () => {
     await findInputEl().trigger(sourceEvent);
 
     expect(wrapper.emitted(eventName)).toBeTruthy();
-    expect(escape).toHaveBeenCalledWith(mockUpdatedTitle);
   });
 });
diff --git a/spec/frontend/work_items/components/work_item_weight_spec.js b/spec/frontend/work_items/components/work_item_weight_spec.js
index 80a1d032ad72..c3bbea26cda7 100644
--- a/spec/frontend/work_items/components/work_item_weight_spec.js
+++ b/spec/frontend/work_items/components/work_item_weight_spec.js
@@ -1,21 +1,51 @@
-import { shallowMount } from '@vue/test-utils';
+import { GlForm, GlFormInput } from '@gitlab/ui';
+import { nextTick } from 'vue';
+import { mockTracking } from 'helpers/tracking_helper';
+import { mountExtended } from 'helpers/vue_test_utils_helper';
+import { __ } from '~/locale';
 import WorkItemWeight from '~/work_items/components/work_item_weight.vue';
+import { TRACKING_CATEGORY_SHOW } from '~/work_items/constants';
+import localUpdateWorkItemMutation from '~/work_items/graphql/local_update_work_item.mutation.graphql';
 
-describe('WorkItemAssignees component', () => {
+describe('WorkItemWeight component', () => {
   let wrapper;
 
-  const createComponent = ({ weight, hasIssueWeightsFeature = true } = {}) => {
-    wrapper = shallowMount(WorkItemWeight, {
+  const mutateSpy = jest.fn();
+  const workItemId = 'gid://gitlab/WorkItem/1';
+  const workItemType = 'Task';
+
+  const findForm = () => wrapper.findComponent(GlForm);
+  const findInput = () => wrapper.findComponent(GlFormInput);
+
+  const createComponent = ({
+    canUpdate = false,
+    hasIssueWeightsFeature = true,
+    isEditing = false,
+    weight,
+  } = {}) => {
+    wrapper = mountExtended(WorkItemWeight, {
       propsData: {
+        canUpdate,
         weight,
+        workItemId,
+        workItemType,
       },
       provide: {
         hasIssueWeightsFeature,
       },
+      mocks: {
+        $apollo: {
+          mutate: mutateSpy,
+        },
+      },
     });
+
+    if (isEditing) {
+      findInput().vm.$emit('focus');
+    }
   };
 
-  describe('weight licensed feature', () => {
+  describe('`issue_weights` licensed feature', () => {
     describe.each`
       description             | hasIssueWeightsFeature | exists
       ${'when available'}     | ${true}                | ${true}
@@ -24,23 +54,111 @@ describe('WorkItemAssignees component', () => {
       it(hasIssueWeightsFeature ? 'renders component' : 'does not render component', () => {
         createComponent({ hasIssueWeightsFeature });
 
-        expect(wrapper.find('div').exists()).toBe(exists);
+        expect(findForm().exists()).toBe(exists);
       });
     });
   });
 
-  describe('weight text', () => {
-    describe.each`
-      description       | weight       | text
-      ${'renders 1'}    | ${1}         | ${'1'}
-      ${'renders 0'}    | ${0}         | ${'0'}
-      ${'renders None'} | ${null}      | ${'None'}
-      ${'renders None'} | ${undefined} | ${'None'}
-    `('when weight is $weight', ({ description, weight, text }) => {
-      it(description, () => {
-        createComponent({ weight });
-
-        expect(wrapper.text()).toContain(text);
+  describe('weight input', () => {
+    it('has "Weight" label', () => {
+      createComponent();
+
+      expect(wrapper.findByLabelText(__('Weight')).exists()).toBe(true);
+    });
+
+    describe('placeholder attribute', () => {
+      describe.each`
+        description                             | isEditing | canUpdate | value
+        ${'when not editing and cannot update'} | ${false}  | ${false}  | ${__('None')}
+        ${'when editing and cannot update'}     | ${true}   | ${false}  | ${__('None')}
+        ${'when not editing and can update'}    | ${false}  | ${true}   | ${__('None')}
+        ${'when editing and can update'}        | ${true}   | ${true}   | ${__('Enter a number')}
+      `('$description', ({ isEditing, canUpdate, value }) => {
+        it(`has a value of "${value}"`, async () => {
+          createComponent({ canUpdate, isEditing });
+          await nextTick();
+
+          expect(findInput().attributes('placeholder')).toBe(value);
+        });
+      });
+    });
+
+    describe('readonly attribute', () => {
+      describe.each`
+        description             | canUpdate | value
+        ${'when cannot update'} | ${false}  | ${'readonly'}
+        ${'when can update'}    | ${true}   | ${undefined}
+      `('$description', ({ canUpdate, value }) => {
+        it(`renders readonly=${value}`, () => {
+          createComponent({ canUpdate });
+
+          expect(findInput().attributes('readonly')).toBe(value);
+        });
+      });
+    });
+
+    describe('type attribute', () => {
+      describe.each`
+        description                             | isEditing | canUpdate | type
+        ${'when not editing and cannot update'} | ${false}  | ${false}  | ${'text'}
+        ${'when editing and cannot update'}     | ${true}   | ${false}  | ${'text'}
+        ${'when not editing and can update'}    | ${false}  | ${true}   | ${'text'}
+        ${'when editing and can update'}        | ${true}   | ${true}   | ${'number'}
+      `('$description', ({ isEditing, canUpdate, type }) => {
+        it(`has a value of "${type}"`, async () => {
+          createComponent({ canUpdate, isEditing });
+          await nextTick();
+
+          expect(findInput().attributes('type')).toBe(type);
+        });
+      });
+    });
+
+    describe('value attribute', () => {
+      describe.each`
+        weight       | value
+        ${1}         | ${'1'}
+        ${0}         | ${'0'}
+        ${null}      | ${''}
+        ${undefined} | ${''}
+      `('when `weight` prop is "$weight"', ({ weight, value }) => {
+        it(`value is "${value}"`, () => {
+          createComponent({ weight });
+
+          expect(findInput().element.value).toBe(value);
+        });
+      });
+    });
+
+    describe('when blurred', () => {
+      it('calls a mutation to update the weight', () => {
+        const weight = 0;
+        createComponent({ isEditing: true, weight });
+
+        findInput().trigger('blur');
+
+        expect(mutateSpy).toHaveBeenCalledWith({
+          mutation: localUpdateWorkItemMutation,
+          variables: {
+            input: {
+              id: workItemId,
+              weight,
+            },
+          },
+        });
+      });
+
+      it('tracks updating the weight', () => {
+        const trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn);
+        createComponent();
+
+        findInput().trigger('blur');
+
+        expect(trackingSpy).toHaveBeenCalledWith(TRACKING_CATEGORY_SHOW, 'updated_weight', {
+          category: TRACKING_CATEGORY_SHOW,
+          label: 'item_weight',
+          property: 'type_Task',
+        });
       });
     });
   });
-- 
GitLab