diff --git a/app/assets/javascripts/work_items/components/item_state.vue b/app/assets/javascripts/work_items/components/item_state.vue deleted file mode 100644 index 2100cc67c8c0e28117118888e9afa0f2a82371dc..0000000000000000000000000000000000000000 --- a/app/assets/javascripts/work_items/components/item_state.vue +++ /dev/null @@ -1,71 +0,0 @@ -<script> -import { GlFormGroup, GlFormSelect } from '@gitlab/ui'; -import { __ } from '~/locale'; -import { STATE_OPEN, STATE_CLOSED } from '../constants'; - -export default { - i18n: { - status: __('Status'), - }, - states: [ - { - value: STATE_OPEN, - text: __('Open'), - }, - { - value: STATE_CLOSED, - text: __('Closed'), - }, - ], - components: { - GlFormGroup, - GlFormSelect, - }, - props: { - state: { - type: String, - required: true, - }, - disabled: { - type: Boolean, - required: false, - default: false, - }, - }, - computed: { - currentState() { - return this.$options.states[this.state]; - }, - }, - methods: { - setState(newState) { - if (newState !== this.state) { - this.$emit('changed', newState); - } - }, - }, - labelId: 'work-item-state-select', -}; -</script> - -<template> - <gl-form-group - :label="$options.i18n.status" - :label-for="$options.labelId" - label-cols="3" - label-cols-lg="2" - label-class="gl-pb-0! gl-overflow-wrap-break work-item-field-label" - class="gl-align-items-center" - > - <gl-form-select - :id="$options.labelId" - :value="state" - :options="$options.states" - :disabled="disabled" - data-testid="work-item-state-select" - class="hide-unfocused-input-decoration work-item-field-value gl-w-auto gl-pl-4 gl-my-1" - :class="{ 'gl-bg-transparent! gl-cursor-text!': disabled }" - @change="setState" - /> - </gl-form-group> -</template> diff --git a/app/assets/javascripts/work_items/components/notes/work_item_add_note.vue b/app/assets/javascripts/work_items/components/notes/work_item_add_note.vue index c330eccb1868c1f6fe99381b8f1c27d314929e94..66ad3d50287728386564a5c330eb4d38d8b8183f 100644 --- a/app/assets/javascripts/work_items/components/notes/work_item_add_note.vue +++ b/app/assets/javascripts/work_items/components/notes/work_item_add_note.vue @@ -265,6 +265,7 @@ export default { :comment-button-text="commentButtonText" @submitForm="updateWorkItem" @cancelEditing="cancelEditing" + @error="$emit('error', $event)" /> <textarea v-else diff --git a/app/assets/javascripts/work_items/components/notes/work_item_comment_form.vue b/app/assets/javascripts/work_items/components/notes/work_item_comment_form.vue index c317ec487324d10b2293de17182f1d144e8e5623..b143c52901433fb9c90fb7eb96e144661fb3d7ba 100644 --- a/app/assets/javascripts/work_items/components/notes/work_item_comment_form.vue +++ b/app/assets/javascripts/work_items/components/notes/work_item_comment_form.vue @@ -1,22 +1,13 @@ <script> import { GlButton, GlFormCheckbox, GlIcon, GlTooltipDirective } from '@gitlab/ui'; -import * as Sentry from '@sentry/browser'; import { helpPagePath } from '~/helpers/help_page_helper'; -import { s__, __, sprintf } from '~/locale'; +import { s__, __ } from '~/locale'; import Tracking from '~/tracking'; -import { - I18N_WORK_ITEM_ERROR_UPDATING, - sprintfWorkItem, - STATE_OPEN, - STATE_EVENT_REOPEN, - STATE_EVENT_CLOSE, - TRACKING_CATEGORY_SHOW, - i18n, -} from '~/work_items/constants'; +import { STATE_OPEN, TRACKING_CATEGORY_SHOW } from '~/work_items/constants'; import { getDraft, clearDraft, updateDraft } from '~/lib/utils/autosave'; import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal'; import MarkdownEditor from '~/vue_shared/components/markdown/markdown_editor.vue'; -import { getUpdateWorkItemMutation } from '~/work_items/components/update_work_item'; +import WorkItemStateToggleButton from '~/work_items/components/work_item_state_toggle_button.vue'; export default { i18n: { @@ -25,6 +16,7 @@ export default { 'Notes|Internal notes are only visible to members with the role of Reporter or higher', ), addInternalNote: __('Add internal note'), + cancelButtonText: __('Cancel'), }, constantOptions: { markdownDocsPath: helpPagePath('user/markdown'), @@ -34,6 +26,7 @@ export default { MarkdownEditor, GlFormCheckbox, GlIcon, + WorkItemStateToggleButton, }, directives: { GlTooltip: GlTooltipDirective, @@ -123,14 +116,6 @@ export default { isWorkItemOpen() { return this.workItemState === STATE_OPEN; }, - toggleWorkItemStateText() { - return this.isWorkItemOpen - ? sprintf(__('Close %{workItemType}'), { workItemType: this.workItemType.toLowerCase() }) - : sprintf(__('Reopen %{workItemType}'), { workItemType: this.workItemType.toLowerCase() }); - }, - cancelButtonText() { - return this.isNewDiscussion ? this.toggleWorkItemStateText : __('Cancel'); - }, commentButtonTextComputed() { return this.isNoteInternal ? this.$options.i18n.addInternalNote : this.commentButtonText; }, @@ -166,48 +151,6 @@ export default { this.$emit('cancelEditing'); clearDraft(this.autosaveKey); }, - async toggleWorkItemState() { - const input = { - id: this.workItemId, - stateEvent: this.isWorkItemOpen ? STATE_EVENT_CLOSE : STATE_EVENT_REOPEN, - }; - - this.updateInProgress = true; - - try { - this.track('updated_state'); - - const { mutation, variables } = getUpdateWorkItemMutation({ - workItemParentId: this.workItemParentId, - input, - }); - - const { data } = await this.$apollo.mutate({ - mutation, - variables, - }); - - const errors = data.workItemUpdate?.errors; - - if (errors?.length) { - this.$emit('error', i18n.updateError); - } - } catch (error) { - const msg = sprintfWorkItem(I18N_WORK_ITEM_ERROR_UPDATING, this.workItemType); - - this.$emit('error', msg); - Sentry.captureException(error); - } - - this.updateInProgress = false; - }, - cancelButtonAction() { - if (this.isNewDiscussion) { - this.toggleWorkItemState(); - } else { - this.cancelEditing(); - } - }, }, }; </script> @@ -257,13 +200,23 @@ export default { @click="$emit('submitForm', { commentText, isNoteInternal })" >{{ commentButtonTextComputed }} </gl-button> + <work-item-state-toggle-button + v-if="isNewDiscussion" + class="gl-ml-3" + :work-item-id="workItemId" + :work-item-state="workItemState" + :work-item-type="workItemType" + can-update + @error="$emit('error', $event)" + /> <gl-button + v-else data-testid="cancel-button" category="primary" class="gl-ml-3" :loading="updateInProgress" - @click="cancelButtonAction" - >{{ cancelButtonText }} + @click="cancelEditing" + >{{ $options.i18n.cancelButtonText }} </gl-button> </form> </div> diff --git a/app/assets/javascripts/work_items/components/work_item_attributes_wrapper.vue b/app/assets/javascripts/work_items/components/work_item_attributes_wrapper.vue index c727075eaac8671a111382d27f2d34d22ad6b61d..139f0f7919c95c17db2a8efef226f601fd385c38 100644 --- a/app/assets/javascripts/work_items/components/work_item_attributes_wrapper.vue +++ b/app/assets/javascripts/work_items/components/work_item_attributes_wrapper.vue @@ -11,7 +11,6 @@ import { WIDGET_TYPE_START_AND_DUE_DATE, WIDGET_TYPE_WEIGHT, } from '../constants'; -import WorkItemState from './work_item_state.vue'; import WorkItemDueDate from './work_item_due_date.vue'; import WorkItemAssignees from './work_item_assignees.vue'; import WorkItemLabels from './work_item_labels.vue'; @@ -23,7 +22,6 @@ export default { WorkItemMilestone, WorkItemAssignees, WorkItemDueDate, - WorkItemState, WorkItemWeight: () => import('ee_component/work_items/components/work_item_weight.vue'), WorkItemProgress: () => import('ee_component/work_items/components/work_item_progress.vue'), WorkItemIteration: () => import('ee_component/work_items/components/work_item_iteration.vue'), @@ -97,12 +95,6 @@ export default { <template> <div class="work-item-attributes-wrapper"> - <work-item-state - :work-item="workItem" - :work-item-parent-id="workItemParentId" - :can-update="canUpdate" - @error="$emit('error', $event)" - /> <work-item-assignees v-if="workItemAssignees" :can-update="canUpdate" diff --git a/app/assets/javascripts/work_items/components/work_item_created_updated.vue b/app/assets/javascripts/work_items/components/work_item_created_updated.vue index 78a86aa49a46a811cc355f0e19ccb0c622e08292..af5293ebe9128c1415d3b7619c9598cfb0e883f9 100644 --- a/app/assets/javascripts/work_items/components/work_item_created_updated.vue +++ b/app/assets/javascripts/work_items/components/work_item_created_updated.vue @@ -2,6 +2,7 @@ import { GlAvatarLink, GlSprintf } from '@gitlab/ui'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; +import WorkItemStateBadge from '~/work_items/components/work_item_state_badge.vue'; import workItemByIidQuery from '../graphql/work_item_by_iid.query.graphql'; export default { @@ -9,6 +10,7 @@ export default { GlAvatarLink, GlSprintf, TimeAgoTooltip, + WorkItemStateBadge, }, inject: ['fullPath'], props: { @@ -31,6 +33,9 @@ export default { authorId() { return getIdFromGraphQLId(this.author.id); }, + workItemState() { + return this.workItem?.state; + }, }, apollo: { workItem: { @@ -54,7 +59,8 @@ export default { <template> <div class="gl-mb-3"> - <span data-testid="work-item-created"> + <work-item-state-badge v-if="workItemState" :work-item-state="workItemState" /> + <span data-testid="work-item-created" class="gl-vertical-align-middle"> <gl-sprintf v-if="author.name" :message="__('Created %{timeAgo} by %{author}')"> <template #timeAgo> <time-ago-tooltip :time="createdAt" /> 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 dc4065f981211748c613612c61692fe63df53ec5..238df236b6bfc0e5e2c6c4f34fb85269ad94a915 100644 --- a/app/assets/javascripts/work_items/components/work_item_detail.vue +++ b/app/assets/javascripts/work_items/components/work_item_detail.vue @@ -49,6 +49,7 @@ import WorkItemDescription from './work_item_description.vue'; import WorkItemNotes from './work_item_notes.vue'; import WorkItemDetailModal from './work_item_detail_modal.vue'; import WorkItemAwardEmoji from './work_item_award_emoji.vue'; +import WorkItemStateToggleButton from './work_item_state_toggle_button.vue'; export default { i18n, @@ -57,6 +58,7 @@ export default { }, isLoggedIn: isLoggedIn(), components: { + WorkItemStateToggleButton, GlAlert, GlBadge, GlButton, @@ -445,6 +447,14 @@ export default { class="gl-mr-3 gl-cursor-help" >{{ __('Confidential') }}</gl-badge > + <work-item-state-toggle-button + v-if="canUpdate" + :work-item-id="workItem.id" + :work-item-state="workItem.state" + :work-item-parent-id="workItemParentId" + :work-item-type="workItemType" + @error="updateError = $event" + /> <work-item-todos v-if="showWorkItemCurrentUserTodos" :work-item-id="workItem.id" diff --git a/app/assets/javascripts/work_items/components/work_item_state_badge.vue b/app/assets/javascripts/work_items/components/work_item_state_badge.vue new file mode 100644 index 0000000000000000000000000000000000000000..1d1bc7352b1c5d4407dbffbc5e9faf54fb9ae8c4 --- /dev/null +++ b/app/assets/javascripts/work_items/components/work_item_state_badge.vue @@ -0,0 +1,41 @@ +<script> +import { GlBadge } from '@gitlab/ui'; +import { __ } from '~/locale'; +import { STATE_OPEN } from '../constants'; + +export default { + components: { + GlBadge, + }, + props: { + workItemState: { + type: String, + required: true, + }, + }, + computed: { + isWorkItemOpen() { + return this.workItemState === STATE_OPEN; + }, + stateText() { + return this.isWorkItemOpen ? __('Open') : __('Closed'); + }, + workItemStateIcon() { + return this.isWorkItemOpen ? 'issue-open-m' : 'issue-close'; + }, + workItemStateVariant() { + return this.isWorkItemOpen ? 'success' : 'info'; + }, + }, +}; +</script> + +<template> + <gl-badge + :icon="workItemStateIcon" + :variant="workItemStateVariant" + class="gl-mr-2 gl-vertical-align-middle" + > + {{ stateText }} + </gl-badge> +</template> diff --git a/app/assets/javascripts/work_items/components/work_item_state.vue b/app/assets/javascripts/work_items/components/work_item_state_toggle_button.vue similarity index 58% rename from app/assets/javascripts/work_items/components/work_item_state.vue rename to app/assets/javascripts/work_items/components/work_item_state_toggle_button.vue index 3880ae25c8cac9f7edd8d0aaa9b432ab24e3e2ae..0ea308454663825413a7b9fbc8497e1bdc0d161b 100644 --- a/app/assets/javascripts/work_items/components/work_item_state.vue +++ b/app/assets/javascripts/work_items/components/work_item_state_toggle_button.vue @@ -1,26 +1,35 @@ <script> +import { GlButton } from '@gitlab/ui'; import * as Sentry from '@sentry/browser'; import Tracking from '~/tracking'; +import { __, sprintf } from '~/locale'; +import { capitalizeFirstCharacter } from '~/lib/utils/text_utility'; +import { getUpdateWorkItemMutation } from '~/work_items/components/update_work_item'; import { sprintfWorkItem, I18N_WORK_ITEM_ERROR_UPDATING, STATE_OPEN, - STATE_CLOSED, STATE_EVENT_CLOSE, STATE_EVENT_REOPEN, TRACKING_CATEGORY_SHOW, } from '../constants'; -import { getUpdateWorkItemMutation } from './update_work_item'; -import ItemState from './item_state.vue'; export default { components: { - ItemState, + GlButton, }, mixins: [Tracking.mixin()], props: { - workItem: { - type: Object, + workItemState: { + type: String, + required: true, + }, + workItemId: { + type: String, + required: true, + }, + workItemType: { + type: String, required: true, }, workItemParentId: { @@ -28,11 +37,6 @@ export default { required: false, default: null, }, - canUpdate: { - type: Boolean, - required: false, - default: false, - }, }, data() { return { @@ -40,8 +44,16 @@ export default { }; }, computed: { - workItemType() { - return this.workItem.workItemType?.name; + isWorkItemOpen() { + return this.workItemState === STATE_OPEN; + }, + toggleWorkItemStateText() { + const baseText = this.isWorkItemOpen + ? __('Close %{workItemType}') + : __('Reopen %{workItemType}'); + return capitalizeFirstCharacter( + sprintf(baseText, { workItemType: this.workItemType.toLowerCase() }), + ); }, tracking() { return { @@ -52,25 +64,10 @@ export default { }, }, methods: { - updateWorkItemState(newState) { - const stateEventMap = { - [STATE_OPEN]: STATE_EVENT_REOPEN, - [STATE_CLOSED]: STATE_EVENT_CLOSE, - }; - - const stateEvent = stateEventMap[newState]; - - this.updateWorkItem(stateEvent); - }, - - async updateWorkItem(updatedState) { - if (!updatedState) { - return; - } - + async updateWorkItem() { const input = { - id: this.workItem.id, - stateEvent: updatedState, + id: this.workItemId, + stateEvent: this.isWorkItemOpen ? STATE_EVENT_CLOSE : STATE_EVENT_REOPEN, }; this.updateInProgress = true; @@ -107,10 +104,10 @@ export default { </script> <template> - <item-state - v-if="workItem.state" - :state="workItem.state" - :disabled="updateInProgress || !canUpdate" - @changed="updateWorkItemState" - /> + <gl-button + :loading="updateInProgress" + data-testid="work-item-state-toggle" + @click="updateWorkItem" + >{{ toggleWorkItemStateText }}</gl-button + > </template> diff --git a/ee/spec/features/projects/work_items/okr_spec.rb b/ee/spec/features/projects/work_items/okr_spec.rb index b6f6a1333b8f0cfa955d640343114ded3051adf0..30d7c23a34ca9237a5fb81ca197687e9cc16a785 100644 --- a/ee/spec/features/projects/work_items/okr_spec.rb +++ b/ee/spec/features/projects/work_items/okr_spec.rb @@ -99,7 +99,7 @@ let(:work_item) { objective } - it_behaves_like 'work items status' + it_behaves_like 'work items toggle status button' it_behaves_like 'work items assignees' it_behaves_like 'work items labels' it_behaves_like 'work items progress' @@ -295,7 +295,7 @@ let(:work_item) { key_result } - it_behaves_like 'work items status' + it_behaves_like 'work items toggle status button' it_behaves_like 'work items assignees' it_behaves_like 'work items labels' it_behaves_like 'work items progress' diff --git a/spec/features/projects/work_items/work_item_spec.rb b/spec/features/projects/work_items/work_item_spec.rb index e996a76b1c5697ac5413ee1af1d790686a50fe70..618d3e2efd0e3b1a68a51bc960e610f3269277fd 100644 --- a/spec/features/projects/work_items/work_item_spec.rb +++ b/spec/features/projects/work_items/work_item_spec.rb @@ -40,7 +40,7 @@ end it_behaves_like 'work items title' - it_behaves_like 'work items status' + it_behaves_like 'work items toggle status button' it_behaves_like 'work items assignees' it_behaves_like 'work items labels' it_behaves_like 'work items comments', :issue diff --git a/spec/frontend/work_items/components/item_state_spec.js b/spec/frontend/work_items/components/item_state_spec.js deleted file mode 100644 index c3bdbfe030e2d7038eeed4ddb8c981cf8dc2271f..0000000000000000000000000000000000000000 --- a/spec/frontend/work_items/components/item_state_spec.js +++ /dev/null @@ -1,66 +0,0 @@ -import { GlFormSelect } from '@gitlab/ui'; -import { mount } from '@vue/test-utils'; -import { STATE_OPEN, STATE_CLOSED } from '~/work_items/constants'; -import ItemState from '~/work_items/components/item_state.vue'; - -describe('ItemState', () => { - let wrapper; - - const findLabel = () => wrapper.find('label').text(); - const findFormSelect = () => wrapper.findComponent(GlFormSelect); - const selectedValue = () => wrapper.find('option:checked').element.value; - - const clickOpen = () => wrapper.findAll('option').at(0).setSelected(); - - const createComponent = ({ state = STATE_OPEN, disabled = false } = {}) => { - wrapper = mount(ItemState, { - propsData: { - state, - disabled, - }, - }); - }; - - it('renders label and dropdown', () => { - createComponent(); - - expect(findLabel()).toBe('Status'); - expect(selectedValue()).toBe(STATE_OPEN); - }); - - it('renders dropdown for closed', () => { - createComponent({ state: STATE_CLOSED }); - - expect(selectedValue()).toBe(STATE_CLOSED); - }); - - it('emits changed event', async () => { - createComponent({ state: STATE_CLOSED }); - - await clickOpen(); - - expect(wrapper.emitted('changed')).toEqual([[STATE_OPEN]]); - }); - - it('does not emits changed event if clicking selected value', async () => { - createComponent({ state: STATE_OPEN }); - - await clickOpen(); - - expect(wrapper.emitted('changed')).toBeUndefined(); - }); - - describe('form select disabled prop', () => { - describe.each` - description | disabled | value - ${'when not disabled'} | ${false} | ${undefined} - ${'when disabled'} | ${true} | ${'disabled'} - `('$description', ({ disabled, value }) => { - it(`renders form select component with disabled=${value}`, () => { - createComponent({ disabled }); - - expect(findFormSelect().attributes('disabled')).toBe(value); - }); - }); - }); -}); diff --git a/spec/frontend/work_items/components/notes/work_item_add_note_spec.js b/spec/frontend/work_items/components/notes/work_item_add_note_spec.js index e6d20dcb0d9bb99e6912540d42c9a8421a272e5f..4b1b7b27ad966338d214e63c01a8d5901f95a11a 100644 --- a/spec/frontend/work_items/components/notes/work_item_add_note_spec.js +++ b/spec/frontend/work_items/components/notes/work_item_add_note_spec.js @@ -247,6 +247,14 @@ describe('Work item add note', () => { expect(clearDraft).toHaveBeenCalledWith('gid://gitlab/WorkItem/1-comment'); }); + + it('emits error to parent when the comment form emits error', async () => { + await createComponent({ isEditing: true, signedIn: true }); + const error = 'error'; + findCommentForm().vm.$emit('error', error); + + expect(wrapper.emitted('error')).toEqual([[error]]); + }); }); }); diff --git a/spec/frontend/work_items/components/notes/work_item_comment_form_spec.js b/spec/frontend/work_items/components/notes/work_item_comment_form_spec.js index 6c00d52aac5bfe039d35fd88f20a6ae1d3c8ac80..dd88f34ae4fbe612ffec049a426c5f1d85b41163 100644 --- a/spec/frontend/work_items/components/notes/work_item_comment_form_spec.js +++ b/spec/frontend/work_items/components/notes/work_item_comment_form_spec.js @@ -6,18 +6,11 @@ import { createMockDirective } from 'helpers/vue_mock_directive'; import waitForPromises from 'helpers/wait_for_promises'; import * as autosave from '~/lib/utils/autosave'; import { ESC_KEY, ENTER_KEY } from '~/lib/utils/keys'; -import { - STATE_OPEN, - STATE_CLOSED, - STATE_EVENT_REOPEN, - STATE_EVENT_CLOSE, -} from '~/work_items/constants'; +import { STATE_OPEN } from '~/work_items/constants'; import * as confirmViaGlModal from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal'; import WorkItemCommentForm from '~/work_items/components/notes/work_item_comment_form.vue'; import MarkdownEditor from '~/vue_shared/components/markdown/markdown_editor.vue'; -import createMockApollo from 'helpers/mock_apollo_helper'; -import updateWorkItemMutation from '~/work_items/graphql/update_work_item.mutation.graphql'; -import { updateWorkItemMutationResponse, workItemQueryResponse } from 'jest/work_items/mock_data'; +import WorkItemStateToggleButton from '~/work_items/components/work_item_state_toggle_button.vue'; Vue.use(VueApollo); @@ -44,8 +37,7 @@ describe('Work item comment form component', () => { const findConfirmButton = () => wrapper.find('[data-testid="confirm-button"]'); const findInternalNoteCheckbox = () => wrapper.findComponent(GlFormCheckbox); const findInternalNoteTooltipIcon = () => wrapper.findComponent(GlIcon); - - const mutationSuccessHandler = jest.fn().mockResolvedValue(updateWorkItemMutationResponse); + const findWorkItemToggleStateButton = () => wrapper.findComponent(WorkItemStateToggleButton); const createComponent = ({ isSubmitting = false, @@ -53,10 +45,8 @@ describe('Work item comment form component', () => { isNewDiscussion = false, workItemState = STATE_OPEN, workItemType = 'Task', - mutationHandler = mutationSuccessHandler, } = {}) => { wrapper = shallowMount(WorkItemCommentForm, { - apolloProvider: createMockApollo([[updateWorkItemMutation, mutationHandler]]), propsData: { workItemState, workItemId, @@ -205,61 +195,20 @@ describe('Work item comment form component', () => { }); describe('when used as a top level/is a new discussion', () => { - describe('cancel button text', () => { - it.each` - workItemState | workItemType | buttonText - ${STATE_OPEN} | ${'Task'} | ${'Close task'} - ${STATE_CLOSED} | ${'Task'} | ${'Reopen task'} - ${STATE_OPEN} | ${'Objective'} | ${'Close objective'} - ${STATE_CLOSED} | ${'Objective'} | ${'Reopen objective'} - ${STATE_OPEN} | ${'Key result'} | ${'Close key result'} - ${STATE_CLOSED} | ${'Key result'} | ${'Reopen key result'} - `( - 'is "$buttonText" when "$workItemType" state is "$workItemState"', - ({ workItemState, workItemType, buttonText }) => { - createComponent({ isNewDiscussion: true, workItemState, workItemType }); - - expect(findCancelButton().text()).toBe(buttonText); - }, - ); - }); - - describe('Close/reopen button click', () => { - it.each` - workItemState | stateEvent - ${STATE_OPEN} | ${STATE_EVENT_CLOSE} - ${STATE_CLOSED} | ${STATE_EVENT_REOPEN} - `( - 'calls mutation with "$stateEvent" when workItemState is "$workItemState"', - async ({ workItemState, stateEvent }) => { - createComponent({ isNewDiscussion: true, workItemState }); - - findCancelButton().vm.$emit('click'); - - await waitForPromises(); - - expect(mutationSuccessHandler).toHaveBeenCalledWith({ - input: { - id: workItemQueryResponse.data.workItem.id, - stateEvent, - }, - }); - }, + it('emits an error message when the mutation was unsuccessful', async () => { + createComponent({ + isNewDiscussion: true, + }); + findWorkItemToggleStateButton().vm.$emit( + 'error', + 'Something went wrong while updating the task. Please try again.', ); - it('emits an error message when the mutation was unsuccessful', async () => { - createComponent({ - isNewDiscussion: true, - mutationHandler: jest.fn().mockRejectedValue('Error!'), - }); - findCancelButton().vm.$emit('click'); - - await waitForPromises(); + await waitForPromises(); - expect(wrapper.emitted('error')).toEqual([ - ['Something went wrong while updating the task. Please try again.'], - ]); - }); + expect(wrapper.emitted('error')).toEqual([ + ['Something went wrong while updating the task. Please try again.'], + ]); }); }); diff --git a/spec/frontend/work_items/components/work_item_attributes_wrapper_spec.js b/spec/frontend/work_items/components/work_item_attributes_wrapper_spec.js index ba9af7b2b6862e148d69bfaf826485f33493b72a..8b7e04854af13edaabc87f863e6446a2ebb20a29 100644 --- a/spec/frontend/work_items/components/work_item_attributes_wrapper_spec.js +++ b/spec/frontend/work_items/components/work_item_attributes_wrapper_spec.js @@ -1,7 +1,6 @@ import { shallowMount } from '@vue/test-utils'; import WorkItemAssignees from '~/work_items/components/work_item_assignees.vue'; import WorkItemDueDate from '~/work_items/components/work_item_due_date.vue'; -import WorkItemState from '~/work_items/components/work_item_state.vue'; import WorkItemLabels from '~/work_items/components/work_item_labels.vue'; import WorkItemMilestone from '~/work_items/components/work_item_milestone.vue'; @@ -13,7 +12,6 @@ describe('WorkItemAttributesWrapper component', () => { const workItemQueryResponse = workItemResponseFactory({ canUpdate: true, canDelete: true }); - const findWorkItemState = () => wrapper.findComponent(WorkItemState); const findWorkItemDueDate = () => wrapper.findComponent(WorkItemDueDate); const findWorkItemAssignees = () => wrapper.findComponent(WorkItemAssignees); const findWorkItemLabels = () => wrapper.findComponent(WorkItemLabels); @@ -40,14 +38,6 @@ describe('WorkItemAttributesWrapper component', () => { }); }; - describe('work item state', () => { - it('renders the work item state', () => { - createComponent(); - - expect(findWorkItemState().exists()).toBe(true); - }); - }); - describe('assignees widget', () => { it('renders assignees component when widget is returned from the API', () => { createComponent(); 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 7ceae935d2d719745a206fd886f99fdc5afb1258..84d9dba93aebc6d1caeb77af32d8cbf8b640068e 100644 --- a/spec/frontend/work_items/components/work_item_detail_spec.js +++ b/spec/frontend/work_items/components/work_item_detail_spec.js @@ -24,6 +24,7 @@ import WorkItemTitle from '~/work_items/components/work_item_title.vue'; import WorkItemTree from '~/work_items/components/work_item_links/work_item_tree.vue'; import WorkItemNotes from '~/work_items/components/work_item_notes.vue'; import WorkItemDetailModal from '~/work_items/components/work_item_detail_modal.vue'; +import WorkItemStateToggleButton from '~/work_items/components/work_item_state_toggle_button.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'; @@ -47,6 +48,10 @@ describe('WorkItemDetail component', () => { Vue.use(VueApollo); const workItemQueryResponse = workItemByIidResponseFactory({ canUpdate: true, canDelete: true }); + const workItemQueryResponseWithCannotUpdate = workItemByIidResponseFactory({ + canUpdate: false, + canDelete: false, + }); const workItemQueryResponseWithoutParent = workItemByIidResponseFactory({ parent: null, canUpdate: true, @@ -82,6 +87,7 @@ describe('WorkItemDetail component', () => { const findWorkItemTwoColumnViewContainer = () => wrapper.findByTestId('work-item-overview'); const findRightSidebar = () => wrapper.findByTestId('work-item-overview-right-sidebar'); const triggerPageScroll = () => findIntersectionObserver().vm.$emit('disappear'); + const findWorkItemStateToggleButton = () => wrapper.findComponent(WorkItemStateToggleButton); const createComponent = ({ isModal = false, @@ -194,6 +200,25 @@ describe('WorkItemDetail component', () => { }); }); + describe('work item state toggle button', () => { + describe.each` + description | canUpdate + ${'when user cannot update'} | ${false} + ${'when user can update'} | ${true} + `('$description', ({ canUpdate }) => { + it(`${canUpdate ? 'is rendered' : 'is not rendered'}`, async () => { + createComponent({ + handler: canUpdate + ? jest.fn().mockResolvedValue(workItemQueryResponse) + : jest.fn().mockResolvedValue(workItemQueryResponseWithCannotUpdate), + }); + await waitForPromises(); + + expect(findWorkItemStateToggleButton().exists()).toBe(canUpdate); + }); + }); + }); + describe('close button', () => { describe('when isModal prop is false', () => { it('does not render', async () => { diff --git a/spec/frontend/work_items/components/work_item_state_badge_spec.js b/spec/frontend/work_items/components/work_item_state_badge_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..888d712cc5ae1e52a9538c9281573428fa87566c --- /dev/null +++ b/spec/frontend/work_items/components/work_item_state_badge_spec.js @@ -0,0 +1,32 @@ +import { GlBadge } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import { STATE_OPEN, STATE_CLOSED } from '~/work_items/constants'; +import WorkItemStateBadge from '~/work_items/components/work_item_state_badge.vue'; + +describe('WorkItemStateBadge', () => { + let wrapper; + + const createComponent = ({ workItemState = STATE_OPEN } = {}) => { + wrapper = shallowMount(WorkItemStateBadge, { + propsData: { + workItemState, + }, + }); + }; + const findStatusBadge = () => wrapper.findComponent(GlBadge); + + it.each` + state | icon | stateText | variant + ${STATE_OPEN} | ${'issue-open-m'} | ${'Open'} | ${'success'} + ${STATE_CLOSED} | ${'issue-close'} | ${'Closed'} | ${'info'} + `( + 'renders icon as "$icon" and text as "$stateText" when the work item state is "$state"', + ({ state, icon, stateText, variant }) => { + createComponent({ workItemState: state }); + + expect(findStatusBadge().props('icon')).toBe(icon); + expect(findStatusBadge().props('variant')).toBe(variant); + expect(findStatusBadge().text()).toBe(stateText); + }, + ); +}); diff --git a/spec/frontend/work_items/components/work_item_state_spec.js b/spec/frontend/work_items/components/work_item_state_toggle_button_spec.js similarity index 60% rename from spec/frontend/work_items/components/work_item_state_spec.js rename to spec/frontend/work_items/components/work_item_state_toggle_button_spec.js index d1262057c730c1c03ba5f77b7e364616d9f30e34..c0b206e5da454b49d316151e5a74e1589010935c 100644 --- a/spec/frontend/work_items/components/work_item_state_spec.js +++ b/spec/frontend/work_items/components/work_item_state_toggle_button_spec.js @@ -1,11 +1,11 @@ +import { GlButton } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import Vue from 'vue'; import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; import { mockTracking } from 'helpers/tracking_helper'; import waitForPromises from 'helpers/wait_for_promises'; -import ItemState from '~/work_items/components/item_state.vue'; -import WorkItemState from '~/work_items/components/work_item_state.vue'; +import WorkItemStateToggleButton from '~/work_items/components/work_item_state_toggle_button.vue'; import { STATE_OPEN, STATE_CLOSED, @@ -16,59 +16,58 @@ import { import updateWorkItemMutation from '~/work_items/graphql/update_work_item.mutation.graphql'; import { updateWorkItemMutationResponse, workItemQueryResponse } from '../mock_data'; -describe('WorkItemState component', () => { +describe('Work Item State toggle button component', () => { let wrapper; Vue.use(VueApollo); const mutationSuccessHandler = jest.fn().mockResolvedValue(updateWorkItemMutationResponse); - const findItemState = () => wrapper.findComponent(ItemState); + const findStateToggleButton = () => wrapper.findComponent(GlButton); + + const { id } = workItemQueryResponse.data.workItem; const createComponent = ({ - state = STATE_OPEN, mutationHandler = mutationSuccessHandler, canUpdate = true, + workItemState = STATE_OPEN, + workItemType = 'Task', } = {}) => { - const { id, workItemType } = workItemQueryResponse.data.workItem; - wrapper = shallowMount(WorkItemState, { + wrapper = shallowMount(WorkItemStateToggleButton, { apolloProvider: createMockApollo([[updateWorkItemMutation, mutationHandler]]), propsData: { - workItem: { - id, - state, - workItemType, - }, + workItemId: id, + workItemState, + workItemType, canUpdate, }, }); }; - it('renders state', () => { - createComponent(); - - expect(findItemState().props('state')).toBe(workItemQueryResponse.data.workItem.state); - }); - - describe('item state disabled prop', () => { - describe.each` - description | canUpdate | value - ${'when cannot update'} | ${false} | ${true} - ${'when can update'} | ${true} | ${false} - `('$description', ({ canUpdate, value }) => { - it(`renders item state component with disabled=${value}`, () => { - createComponent({ canUpdate }); - - expect(findItemState().props('disabled')).toBe(value); - }); - }); + describe('work item State button text', () => { + it.each` + workItemState | workItemType | buttonText + ${STATE_OPEN} | ${'Task'} | ${'Close task'} + ${STATE_CLOSED} | ${'Task'} | ${'Reopen task'} + ${STATE_OPEN} | ${'Objective'} | ${'Close objective'} + ${STATE_CLOSED} | ${'Objective'} | ${'Reopen objective'} + ${STATE_OPEN} | ${'Key result'} | ${'Close key result'} + ${STATE_CLOSED} | ${'Key result'} | ${'Reopen key result'} + `( + 'is "$buttonText" when "$workItemType" state is "$workItemState"', + ({ workItemState, workItemType, buttonText }) => { + createComponent({ workItemState, workItemType }); + + expect(findStateToggleButton().text()).toBe(buttonText); + }, + ); }); describe('when updating the state', () => { it('calls a mutation', () => { createComponent(); - findItemState().vm.$emit('changed', STATE_CLOSED); + findStateToggleButton().vm.$emit('click'); expect(mutationSuccessHandler).toHaveBeenCalledWith({ input: { @@ -80,10 +79,10 @@ describe('WorkItemState component', () => { it('calls a mutation with REOPEN', () => { createComponent({ - state: STATE_CLOSED, + workItemState: STATE_CLOSED, }); - findItemState().vm.$emit('changed', STATE_OPEN); + findStateToggleButton().vm.$emit('click'); expect(mutationSuccessHandler).toHaveBeenCalledWith({ input: { @@ -96,7 +95,7 @@ describe('WorkItemState component', () => { it('emits an error message when the mutation was unsuccessful', async () => { createComponent({ mutationHandler: jest.fn().mockRejectedValue('Error!') }); - findItemState().vm.$emit('changed', STATE_CLOSED); + findStateToggleButton().vm.$emit('click'); await waitForPromises(); expect(wrapper.emitted('error')).toEqual([ @@ -109,7 +108,7 @@ describe('WorkItemState component', () => { createComponent(); - findItemState().vm.$emit('changed', STATE_CLOSED); + findStateToggleButton().vm.$emit('click'); await waitForPromises(); expect(trackingSpy).toHaveBeenCalledWith(TRACKING_CATEGORY_SHOW, 'updated_state', { 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 4c15b68245843db51d00a56ade0318a4effc1c28..97407d93cb02d02c3d848c07e0975dbb185a03ae 100644 --- a/spec/support/shared_examples/features/work_items_shared_examples.rb +++ b/spec/support/shared_examples/features/work_items_shared_examples.rb @@ -15,17 +15,17 @@ end end -RSpec.shared_examples 'work items status' do - let(:state_selector) { '[data-testid="work-item-state-select"]' } +RSpec.shared_examples 'work items toggle status button' do + let(:state_button) { '[data-testid="work-item-state-toggle"]' } it 'successfully shows and changes the status of the work item' do - expect(find(state_selector)).to have_content 'Open' + expect(find(state_button, match: :first)).to have_content 'Close' - find(state_selector).select("Closed") + find(state_button, match: :first).click wait_for_requests - expect(find(state_selector)).to have_content 'Closed' + expect(find(state_button, match: :first)).to have_content 'Reopen' expect(work_item.reload.state).to eq('closed') end end