diff --git a/.eslint_todo/vue-no-unused-properties.mjs b/.eslint_todo/vue-no-unused-properties.mjs index 48b6d32b0b22ee8e838acc898ddc25319874bde6..7419aafde70b4532b7ed74b66e100fedc749f3ed 100644 --- a/.eslint_todo/vue-no-unused-properties.mjs +++ b/.eslint_todo/vue-no-unused-properties.mjs @@ -609,7 +609,6 @@ export default { 'ee/app/assets/javascripts/work_items/components/work_item_links/work_item_rolled_up_health_status.vue', 'ee/app/assets/javascripts/work_items/components/work_item_progress.vue', 'ee/app/assets/javascripts/work_items/components/work_item_rolledup_dates.vue', - 'ee/app/assets/javascripts/work_items/components/work_item_weight.vue', 'ee/app/assets/javascripts/workspaces/common/components/workspaces_list/workspaces_table.vue', 'ee/app/assets/javascripts/workspaces/dropdown_group/components/workspace_dropdown_item.vue', 'ee/app/assets/javascripts/workspaces/user/pages/list.vue', diff --git a/app/assets/javascripts/work_items/components/shared/work_item_sidebar_widget.vue b/app/assets/javascripts/work_items/components/shared/work_item_sidebar_widget.vue index fd438a5073e6d4cafbfbe7e7bc357215495f6dc3..a4767e4e6bb730863b253c01524108ee17e546db 100644 --- a/app/assets/javascripts/work_items/components/shared/work_item_sidebar_widget.vue +++ b/app/assets/javascripts/work_items/components/shared/work_item_sidebar_widget.vue @@ -1,11 +1,12 @@ <script> -import { GlButton, GlOutsideDirective as Outside } from '@gitlab/ui'; +import { GlButton, GlLoadingIcon, GlOutsideDirective as Outside } from '@gitlab/ui'; import { Mousetrap } from '~/lib/mousetrap'; import { keysFor, SIDEBAR_CLOSE_WIDGET } from '~/behaviors/shortcuts/keybindings'; export default { components: { GlButton, + GlLoadingIcon, }, directives: { Outside, @@ -56,6 +57,7 @@ export default { <h3 class="gl-heading-5 gl-mb-0"> <slot name="title"></slot> </h3> + <gl-loading-icon v-if="isUpdating" /> <gl-button v-if="canUpdate && !isEditing" key="edit-button" diff --git a/ee/app/assets/javascripts/work_items/components/work_item_weight.vue b/ee/app/assets/javascripts/work_items/components/work_item_weight.vue index d8624a10c982950c46925f42d4af55caa67fbc6b..036d99515190fb8636aed0dab996f611fbf086df 100644 --- a/ee/app/assets/javascripts/work_items/components/work_item_weight.vue +++ b/ee/app/assets/javascripts/work_items/components/work_item_weight.vue @@ -1,26 +1,25 @@ <script> -import { GlButton, GlForm, GlFormInput, GlLoadingIcon, GlTooltipDirective } from '@gitlab/ui'; +import { GlButton, GlFormInput, GlTooltipDirective } from '@gitlab/ui'; import * as Sentry from '~/sentry/sentry_browser_wrapper'; import Tracking from '~/tracking'; import { - sprintfWorkItem, I18N_WORK_ITEM_ERROR_UPDATING, + sprintfWorkItem, TRACKING_CATEGORY_SHOW, } from '~/work_items/constants'; import updateNewWorkItemMutation from '~/work_items/graphql/update_new_work_item.mutation.graphql'; import updateWorkItemMutation from '~/work_items/graphql/update_work_item.mutation.graphql'; import { newWorkItemId } from '~/work_items/utils'; +import WorkItemSidebarWidget from '~/work_items/components/shared/work_item_sidebar_widget.vue'; export default { - inputId: 'weight-widget-input', directives: { GlTooltip: GlTooltipDirective, }, components: { + WorkItemSidebarWidget, GlButton, - GlForm, GlFormInput, - GlLoadingIcon, }, mixins: [Tracking.mixin()], inject: ['hasIssueWeightsFeature'], @@ -42,10 +41,6 @@ export default { type: String, required: true, }, - workItemIid: { - type: String, - required: true, - }, workItemType: { type: String, required: true, @@ -53,9 +48,7 @@ export default { }, data() { return { - isEditing: false, - clickingClearButton: false, - workItem: {}, + dirtyWeight: this.widget.weight, isUpdating: false, }; }, @@ -69,6 +62,7 @@ export default { showRemoveWeight() { return this.hasWeight && !this.isUpdating; }, + // eslint-disable-next-line vue/no-unused-properties tracking() { return { category: TRACKING_CATEGORY_SHOW, @@ -88,27 +82,19 @@ export default { }, }, methods: { - blurInput() { - this.$refs.input.$el.blur(); - }, - handleFocus() { - this.isEditing = true; + clearWeight(stopEditing) { + this.dirtyWeight = ''; + stopEditing(); + this.updateWeight(); }, - updateWeightFromInput(event) { - if (event.target.value === '') { - this.updateWeight(null); + updateWeight() { + if (!this.canUpdate) { return; } - const weight = Number(event.target.value); - this.updateWeight(weight); - }, - updateWeight(weight) { - if (this.clickingClearButton) return; - if (!this.canUpdate) return; + const newWeight = this.dirtyWeight === '' ? null : Number(this.dirtyWeight); - if (this.weight === weight) { - this.isEditing = false; + if (this.weight === newWeight) { return; } @@ -123,13 +109,12 @@ export default { input: { workItemType: this.workItemType, fullPath: this.fullPath, - weight, + weight: newWeight, }, }, }); this.isUpdating = false; - this.isEditing = false; return; } @@ -140,7 +125,7 @@ export default { input: { id: this.workItemId, weightWidget: { - weight, + weight: newWeight, }, }, }, @@ -157,7 +142,6 @@ export default { }) .finally(() => { this.isUpdating = false; - this.isEditing = false; }); }, }, @@ -165,74 +149,49 @@ export default { </script> <template> - <div v-if="displayWeightWidget" data-testid="work-item-weight"> - <div class="gl-flex gl-items-center gl-justify-between"> - <!-- hide header when editing, since we then have a form label. Keep it reachable for screenreader nav --> - <h3 :class="{ 'gl-sr-only': isEditing }" class="gl-heading-5 !gl-mb-0"> - {{ __('Weight') }} - </h3> - <gl-button - v-if="canUpdate && !isEditing" - data-testid="edit-weight" - category="tertiary" - size="small" - @click="isEditing = true" - >{{ __('Edit') }}</gl-button - > - </div> - <gl-form v-if="isEditing" @submit.prevent="blurInput"> - <div class="gl-flex gl-items-center"> - <label :for="$options.inputId" class="gl-mb-0">{{ __('Weight') }}</label> - <gl-loading-icon v-if="isUpdating" size="sm" inline class="gl-ml-3" /> - <gl-button - data-testid="apply-weight" - category="tertiary" - size="small" - class="gl-ml-auto" - :disabled="isUpdating" - @click="isEditing = false" - >{{ __('Apply') }}</gl-button - > - </div> - <!-- wrapper for the form input so the borders fit inside the sidebar --> + <work-item-sidebar-widget + v-if="displayWeightWidget" + :can-update="canUpdate" + :is-updating="isUpdating" + data-testid="work-item-weight" + @stopEditing="updateWeight" + > + <template #title> + {{ __('Weight') }} + </template> + <template #content> + <template v-if="hasWeight"> + {{ weight }} + </template> + <span v-else class="gl-text-subtle"> + {{ __('None') }} + </span> + </template> + <template #editing-content="{ stopEditing }"> <div class="gl-relative gl-px-2"> <gl-form-input - :id="$options.inputId" - ref="input" + v-model="dirtyWeight" + autofocus min="0" - class="hide-unfocused-input-decoration gl-block" - type="number" - :disabled="isUpdating" :placeholder="__('Enter a number')" - :value="weight" - autofocus - @blur="updateWeightFromInput" - @focus="handleFocus" - @keydown.exact.esc.stop="blurInput" + type="number" + :aria-label="__('Enter a number')" + @keydown.enter="stopEditing" + @keydown.exact.esc.stop="stopEditing" /> <gl-button v-if="showRemoveWeight" v-gl-tooltip - data-testid="remove-weight" - variant="default" + class="gl-absolute gl-right-7 gl-top-2" category="tertiary" - size="small" - name="clear" icon="clear" - class="gl-clear-icon-button gl-absolute gl-right-7 gl-top-2" + size="small" :title="__('Remove weight')" :aria-label="__('Remove weight')" - @mousedown="clickingClearButton = true" - @mouseup="clickingClearButton = false" - @click="updateWeight(null)" + data-testid="remove-weight" + @click="clearWeight(stopEditing)" /> </div> - </gl-form> - <template v-else-if="hasWeight"> - <div>{{ weight }}</div> - </template> - <template v-else> - <div class="gl-text-subtle">{{ __('None') }}</div> </template> - </div> + </work-item-sidebar-widget> </template> diff --git a/ee/spec/frontend/work_items/components/work_item_weight_spec.js b/ee/spec/frontend/work_items/components/work_item_weight_spec.js index 2dbd70ded2a23ec08367c7f0cdf13847ffd470fd..479ed5034dc8b013a33413435758cd9c9e6b0cc0 100644 --- a/ee/spec/frontend/work_items/components/work_item_weight_spec.js +++ b/ee/spec/frontend/work_items/components/work_item_weight_spec.js @@ -1,14 +1,16 @@ -import { GlForm, GlFormInput, GlLoadingIcon } from '@gitlab/ui'; +import { GlFormInput } from '@gitlab/ui'; import Vue, { nextTick } from 'vue'; import VueApollo from 'vue-apollo'; import WorkItemWeight from 'ee/work_items/components/work_item_weight.vue'; import createMockApollo from 'helpers/mock_apollo_helper'; import { mockTracking } from 'helpers/tracking_helper'; -import { mountExtended } from 'helpers/vue_test_utils_helper'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import waitForPromises from 'helpers/wait_for_promises'; import { TRACKING_CATEGORY_SHOW } from '~/work_items/constants'; import updateWorkItemMutation from '~/work_items/graphql/update_work_item.mutation.graphql'; import { updateWorkItemMutationResponse } from 'jest/work_items/mock_data'; +import WorkItemSidebarWidget from '~/work_items/components/shared/work_item_sidebar_widget.vue'; +import { ENTER_KEY, ESC_KEY } from '~/lib/utils/keys'; describe('WorkItemWeight component', () => { Vue.use(VueApollo); @@ -16,145 +18,60 @@ describe('WorkItemWeight component', () => { let wrapper; const workItemId = 'gid://gitlab/WorkItem/1'; - const defaultWorkItemType = 'Task'; const findHeader = () => wrapper.find('h3'); - const findEditButton = () => wrapper.find('[data-testid="edit-weight"]'); - const findApplyButton = () => wrapper.find('[data-testid="apply-weight"]'); - const findLabel = () => wrapper.find('label'); - const findForm = () => wrapper.findComponent(GlForm); + const findEditButton = () => wrapper.findByTestId('edit-button'); + const findApplyButton = () => wrapper.findByTestId('apply-button'); const findInput = () => wrapper.findComponent(GlFormInput); - const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); - const findClearButton = () => wrapper.find('[data-testid="remove-weight"]'); + const findClearButton = () => wrapper.findByTestId('remove-weight'); const createComponent = ({ canUpdate = true, - fullPath = 'gitlab-org/gitlab', hasIssueWeightsFeature = true, isEditing = false, weight = null, editable = true, - workItemIid = '1', - workItemType = defaultWorkItemType, mutationHandler = jest.fn().mockResolvedValue(updateWorkItemMutationResponse), } = {}) => { - wrapper = mountExtended(WorkItemWeight, { + wrapper = shallowMountExtended(WorkItemWeight, { apolloProvider: createMockApollo([[updateWorkItemMutation, mutationHandler]]), propsData: { canUpdate, - fullPath, + fullPath: 'gitlab-org/gitlab', widget: { weight, widgetDefinition: { editable }, }, workItemId, - workItemIid, - workItemType, + workItemType: 'Task', }, provide: { hasIssueWeightsFeature, }, + stubs: { + WorkItemSidebarWidget, + }, }); if (isEditing) { - findEditButton().trigger('click'); + findEditButton().vm.$emit('click'); } }; describe('rendering widget', () => { it('renders nothing if license not available', async () => { createComponent({ hasIssueWeightsFeature: false }); - await nextTick(); expect(findHeader().exists()).toBe(false); - expect(findForm().exists()).toBe(false); }); // 'editable' property means if it's available for that work item type it('renders nothing if not editable', async () => { createComponent({ editable: false }); - await nextTick(); expect(findHeader().exists()).toBe(false); - expect(findForm().exists()).toBe(false); - }); - }); - - describe('label', () => { - it('shows header when not editing', () => { - createComponent(); - - expect(findHeader().exists()).toBe(true); - expect(findHeader().classes('gl-sr-only')).toBe(false); - expect(findLabel().exists()).toBe(false); - }); - - it('shows label and hides header while editing', async () => { - createComponent({ isEditing: true }); - - await nextTick(); - - expect(findLabel().exists()).toBe(true); - expect(findHeader().classes('gl-sr-only')).toBe(true); - }); - - it('shows loading spinner while updating', async () => { - createComponent({ - isEditing: true, - weight: 0, - canUpdate: true, - }); - - await nextTick(); - - findInput().setValue('1'); - findInput().trigger('blur'); - - await nextTick(); - - expect(findLoadingIcon().exists()).toBe(true); - - await waitForPromises(); - - expect(findLoadingIcon().exists()).toBe(false); - }); - }); - - describe('edit button', () => { - it('is not shown if user cannot edit', () => { - createComponent({ canUpdate: false }); - - expect(findEditButton().exists()).toBe(false); - }); - - it('is shown if user can edit', () => { - createComponent({ canUpdate: true }); - - expect(findEditButton().exists()).toBe(true); - }); - - it('triggers edit mode on click', async () => { - createComponent(); - - findEditButton().trigger('click'); - - await nextTick(); - - expect(findLabel().exists()).toBe(true); - expect(findForm().exists()).toBe(true); - }); - - it('is replaced by Apply button while editing', async () => { - createComponent(); - - findEditButton().trigger('click'); - - await nextTick(); - - expect(findEditButton().exists()).toBe(false); - expect(findApplyButton().exists()).toBe(true); }); }); @@ -173,36 +90,22 @@ describe('WorkItemWeight component', () => { }); }); - describe('form', () => { - it('is not shown while not editing', async () => { - await createComponent(); - - expect(findForm().exists()).toBe(false); - }); - - it('is shown while editing', async () => { - await createComponent({ isEditing: true }); - - expect(findForm().exists()).toBe(true); - }); - }); - describe('weight input', () => { it('is not shown while not editing', async () => { - await createComponent(); + createComponent(); + await nextTick(); expect(findInput().exists()).toBe(false); }); - it('has weight-y attributes', async () => { - await createComponent({ isEditing: true }); + it('renders when editing', async () => { + createComponent({ isEditing: true }); + await nextTick(); - expect(findInput().attributes()).toEqual( - expect.objectContaining({ - min: '0', - type: 'number', - }), - ); + expect(findInput().attributes()).toMatchObject({ + min: '0', + type: 'number', + }); }); it('clear button triggers mutation', async () => { @@ -213,10 +116,9 @@ describe('WorkItemWeight component', () => { mutationHandler: mutationSpy, canUpdate: true, }); - await nextTick(); - findClearButton().trigger('click'); + findClearButton().vm.$emit('click'); expect(mutationSpy).toHaveBeenCalledWith({ input: { @@ -236,11 +138,10 @@ describe('WorkItemWeight component', () => { mutationHandler: mutationSpy, canUpdate: true, }); - await nextTick(); - findInput().setValue('1'); - findInput().trigger('blur'); + findInput().vm.$emit('input', '1'); + findInput().vm.$emit('keydown', new KeyboardEvent('keydown', { key: ENTER_KEY })); expect(mutationSpy).toHaveBeenCalledWith({ input: { @@ -252,36 +153,19 @@ describe('WorkItemWeight component', () => { }); }); - it('is disabled while updating, and removed after', async () => { + it('does not call a mutation to update the weight when the input value is the same', async () => { + const mutationSpy = jest.fn().mockResolvedValue(updateWorkItemMutationResponse); createComponent({ isEditing: true, weight: 0, + mutationHandler: mutationSpy, canUpdate: true, }); - - await nextTick(); - - findInput().setValue('1'); - findInput().trigger('blur'); - - await nextTick(); - - expect(findInput().attributes('disabled')).toBe('disabled'); - - await waitForPromises(); - - expect(findInput().exists()).toBe(false); - }); - - it('does not call a mutation to update the weight when the input value is the same', async () => { - const mutationSpy = jest.fn().mockResolvedValue(updateWorkItemMutationResponse); - createComponent({ isEditing: true, mutationHandler: mutationSpy, canUpdate: true }); - await nextTick(); - findInput().trigger('blur'); + findInput().vm.$emit('keydown', new KeyboardEvent('keydown', { key: ESC_KEY })); - expect(mutationSpy).not.toHaveBeenCalledWith(); + expect(mutationSpy).not.toHaveBeenCalled(); }); it('emits an error when there is a GraphQL error', async () => { @@ -298,12 +182,10 @@ describe('WorkItemWeight component', () => { mutationHandler: jest.fn().mockResolvedValue(response), canUpdate: true, }); - await nextTick(); - findInput().setValue('1'); - findInput().trigger('blur'); - + findInput().vm.$emit('input', '1'); + findApplyButton().vm.$emit('click'); await waitForPromises(); expect(wrapper.emitted('error')).toEqual([ @@ -317,12 +199,10 @@ describe('WorkItemWeight component', () => { mutationHandler: jest.fn().mockRejectedValue(new Error()), canUpdate: true, }); - await nextTick(); - findInput().setValue('1'); - findInput().trigger('blur'); - + findInput().vm.$emit('input', '1'); + findApplyButton().vm.$emit('click'); await waitForPromises(); expect(wrapper.emitted('error')).toEqual([ @@ -333,11 +213,10 @@ describe('WorkItemWeight component', () => { it('tracks updating the weight', async () => { const trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); createComponent({ isEditing: true, canUpdate: true }); - await nextTick(); - findInput().setValue('1'); - findInput().trigger('blur'); + findInput().vm.$emit('input', '1'); + findApplyButton().vm.$emit('click'); expect(trackingSpy).toHaveBeenCalledWith(TRACKING_CATEGORY_SHOW, 'updated_weight', { category: TRACKING_CATEGORY_SHOW, diff --git a/spec/frontend/work_items/components/shared/work_item_sidebar_widget_spec.js b/spec/frontend/work_items/components/shared/work_item_sidebar_widget_spec.js index 68794022a1a3bfa43ba42003cd379461bfe69266..78ec7608aa5d0c983a0e07776d4a92a0a6bc0d79 100644 --- a/spec/frontend/work_items/components/shared/work_item_sidebar_widget_spec.js +++ b/spec/frontend/work_items/components/shared/work_item_sidebar_widget_spec.js @@ -1,3 +1,4 @@ +import { GlLoadingIcon } from '@gitlab/ui'; import { nextTick } from 'vue'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import { Mousetrap } from '~/lib/mousetrap'; @@ -111,11 +112,17 @@ describe('WorkItemSidebarWidget component', () => { }); describe('when updating', () => { - it('renders Edit button as disabled', () => { + beforeEach(() => { createComponent({ canUpdate: true, isUpdating: true }); + }); + it('renders Edit button as disabled', () => { expect(findEditButton().props('disabled')).toBe(true); }); + + it('shows loading icon', () => { + expect(wrapper.findComponent(GlLoadingIcon).exists()).toBe(true); + }); }); });