diff --git a/app/assets/javascripts/work_items/components/item_title.vue b/app/assets/javascripts/work_items/components/item_title.vue index 19fbad4eaa3313b55b7e2dacf5b2baa90c6f2c3c..1cdc9c28f05255fde95e538062b917a77fb4af8c 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 606d65a08f8431fb2a36cb06dead2d87d0d39e6e..f52b3bf549e8b0fc105650f9abebf265a39ab28f 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 72e678151e91ba7068227cf94318159f9ea9ff2f..30e2c1e56b865501835702eff02592100fdaac20 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 0d31ecef6f8256213b18bf30e4a83752497fae20..43c92cf89eccdd2a205759962a3bea7ad659cba8 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 9266b4cdccba3e79c588cd4700d17e186f814f0d..80d8c98e75db783e84dd623af041ca046fbbdb13 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 de4bdad565964322c00af8b953ad9cacf2240e98..71ac263a02ebb57ee52e7ed6bbd4bfabd0bb6830 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 33e28831b5449a56aafdf1f05932957436c0d794..6437df597b47011d93b875696fad22ec80735904 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 cf4a415446e066ade264865621028481d8d88465..be72ec334658090e60e09b7582f1adb384247b61 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 0000000000000000000000000000000000000000..af019fb091bb3c9666399f3b3462a2b44d1eaeed --- /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 b98f55df1ed01fc3ffc0b1e0847197c6b53eab84..0000000000000000000000000000000000000000 --- 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 3572d1d6556cfb287218becd03046b7a19bcdc1a..06c422fc4d69d33bd1b1e3fd75efa621df6cb505 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 1f36afc48aa9835bd460678034933be4263962b5..d8b6ae96826a039811f79d12dcc0478188320fc5 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 5e18d5fdd969f86a3a03517f1becc9a01eac27d1..6b4a55d6d05c4b9a7364db692b469926426f4631 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 2c3f6ef8634c4f3b46099bf55aa83763bd883a6e..a55f448c9a208ab462f2c98f446e5a1c601078e9 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 80a1d032ad72c4e3015948a7a45ccde8cd3171ad..c3bbea26cda7c43a31b1b2a826fa15b934442b62 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', + }); }); }); });