diff --git a/app/assets/javascripts/work_items/components/item_state.vue b/app/assets/javascripts/work_items/components/item_state.vue new file mode 100644 index 0000000000000000000000000000000000000000..0b6c1a75bb2a8e43d481498c9e62dab00dd40561 --- /dev/null +++ b/app/assets/javascripts/work_items/components/item_state.vue @@ -0,0 +1,62 @@ +<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, + }, + loading: { + 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"> + <gl-form-select + :id="$options.labelId" + :value="state" + :options="$options.states" + :disabled="loading" + class="gl-w-auto" + @change="setState" + /> + </gl-form-group> +</template> 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 f2fb1e3ccbc4cea44abfd2de06be27469e752031..449e17e155c8bf7d09b8fe09e29dadc9108b2950 100644 --- a/app/assets/javascripts/work_items/components/work_item_detail.vue +++ b/app/assets/javascripts/work_items/components/work_item_detail.vue @@ -3,6 +3,7 @@ import { GlAlert } from '@gitlab/ui'; import { i18n } from '../constants'; import workItemQuery from '../graphql/work_item.query.graphql'; import workItemTitleSubscription from '../graphql/work_item_title.subscription.graphql'; +import WorkItemState from './work_item_state.vue'; import WorkItemTitle from './work_item_title.vue'; export default { @@ -10,6 +11,7 @@ export default { components: { GlAlert, WorkItemTitle, + WorkItemState, }, props: { workItemId: { @@ -49,6 +51,9 @@ export default { }, }, computed: { + workItemLoading() { + return this.$apollo.queries.workItem.loading; + }, workItemType() { return this.workItem.workItemType?.name; }, @@ -63,11 +68,12 @@ export default { </gl-alert> <work-item-title - :loading="$apollo.queries.workItem.loading" + :loading="workItemLoading" :work-item-id="workItem.id" :work-item-title="workItem.title" :work-item-type="workItemType" @error="error = $event" /> + <work-item-state :loading="workItemLoading" :work-item="workItem" @error="error = $event" /> </section> </template> diff --git a/app/assets/javascripts/work_items/components/work_item_state.vue b/app/assets/javascripts/work_items/components/work_item_state.vue new file mode 100644 index 0000000000000000000000000000000000000000..ad92d077b25a4c082d49595bce884df7eec52612 --- /dev/null +++ b/app/assets/javascripts/work_items/components/work_item_state.vue @@ -0,0 +1,104 @@ +<script> +import { GlLoadingIcon } from '@gitlab/ui'; +import * as Sentry from '@sentry/browser'; +import Tracking from '~/tracking'; +import { + i18n, + STATE_OPEN, + STATE_CLOSED, + STATE_EVENT_CLOSE, + STATE_EVENT_REOPEN, +} from '../constants'; +import updateWorkItemMutation from '../graphql/update_work_item.mutation.graphql'; +import ItemState from './item_state.vue'; + +export default { + components: { + GlLoadingIcon, + ItemState, + }, + mixins: [Tracking.mixin()], + props: { + loading: { + type: Boolean, + required: false, + default: false, + }, + workItem: { + type: Object, + required: true, + }, + }, + data() { + return { + updateInProgress: false, + }; + }, + computed: { + workItemType() { + return this.workItem.workItemType?.name; + }, + tracking() { + return { + category: 'workItems:show', + label: 'item_state', + property: `type_${this.workItemType}`, + }; + }, + }, + methods: { + async updateWorkItemState(newState) { + const stateEventMap = { + [STATE_OPEN]: STATE_EVENT_REOPEN, + [STATE_CLOSED]: STATE_EVENT_CLOSE, + }; + + const stateEvent = stateEventMap[newState]; + + await this.updateWorkItem(stateEvent); + }, + async updateWorkItem(updatedState) { + if (!updatedState) { + return; + } + + this.updateInProgress = true; + + try { + this.track('updated_state'); + + const { + data: { workItemUpdate }, + } = await this.$apollo.mutate({ + mutation: updateWorkItemMutation, + variables: { + input: { + id: this.workItem.id, + stateEvent: updatedState, + }, + }, + }); + + if (workItemUpdate?.errors?.length) { + throw new Error(workItemUpdate.errors[0]); + } + } catch (error) { + this.$emit('error', i18n.updateError); + Sentry.captureException(error); + } + + this.updateInProgress = false; + }, + }, +}; +</script> + +<template> + <gl-loading-icon v-if="loading" class="gl-mt-3" size="md" /> + <item-state + v-else-if="workItem.state" + :state="workItem.state" + :loading="updateInProgress" + @changed="updateWorkItemState" + /> +</template> diff --git a/app/assets/javascripts/work_items/constants.js b/app/assets/javascripts/work_items/constants.js index d3bcaf0f95f4da1d8b3489aab805313cef640841..a942c3f280d0a3ea4cf0b8852c8f2ffe73f00470 100644 --- a/app/assets/javascripts/work_items/constants.js +++ b/app/assets/javascripts/work_items/constants.js @@ -1,5 +1,11 @@ import { s__ } from '~/locale'; +export const STATE_OPEN = 'OPEN'; +export const STATE_CLOSED = 'CLOSED'; + +export const STATE_EVENT_REOPEN = 'REOPEN'; +export const STATE_EVENT_CLOSE = 'CLOSE'; + export const i18n = { fetchError: s__('WorkItem|Something went wrong when fetching the work item. Please try again.'), updateError: s__('WorkItem|Something went wrong while updating the work item. Please try again.'), diff --git a/app/assets/javascripts/work_items/graphql/work_item.fragment.graphql b/app/assets/javascripts/work_items/graphql/work_item.fragment.graphql index 2707d6bb790d93355b6c276592ee694e969403f3..4ad75425893c40e1abb32c680892d2a7a3e9e812 100644 --- a/app/assets/javascripts/work_items/graphql/work_item.fragment.graphql +++ b/app/assets/javascripts/work_items/graphql/work_item.fragment.graphql @@ -1,6 +1,7 @@ fragment WorkItem on WorkItem { id title + state workItemType { id name diff --git a/app/assets/javascripts/work_items/pages/create_work_item.vue b/app/assets/javascripts/work_items/pages/create_work_item.vue index a95da80ac957194580b38580cfdbf07e9729bcc4..2e59912e64002efc3b250b000c7b5bd0f8317065 100644 --- a/app/assets/javascripts/work_items/pages/create_work_item.vue +++ b/app/assets/javascripts/work_items/pages/create_work_item.vue @@ -115,7 +115,7 @@ export default { }, }, update(store, { data: { workItemCreate } }) { - const { id, title, workItemType } = workItemCreate.workItem; + const { id, title, workItemType, state } = workItemCreate.workItem; store.writeQuery({ query: workItemQuery, @@ -127,6 +127,7 @@ export default { __typename: 'WorkItem', id, title, + state, workItemType, }, }, diff --git a/spec/frontend/work_items/components/item_state_spec.js b/spec/frontend/work_items/components/item_state_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..79b76f3c0614436b086e9a217b40539d3b72b4cf --- /dev/null +++ b/spec/frontend/work_items/components/item_state_spec.js @@ -0,0 +1,54 @@ +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 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, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + 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(); + }); +}); diff --git a/spec/frontend/work_items/components/work_item_state_spec.js b/spec/frontend/work_items/components/work_item_state_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..a59dea3100614d7b317cbf26b91f0e0cc0d23d53 --- /dev/null +++ b/spec/frontend/work_items/components/work_item_state_spec.js @@ -0,0 +1,134 @@ +import { GlLoadingIcon } 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 { + i18n, + STATE_OPEN, + STATE_CLOSED, + STATE_EVENT_CLOSE, + STATE_EVENT_REOPEN, +} from '~/work_items/constants'; +import updateWorkItemMutation from '~/work_items/graphql/update_work_item.mutation.graphql'; +import { updateWorkItemMutationResponse, workItemQueryResponse } from '../mock_data'; + +describe('WorkItemState component', () => { + let wrapper; + + Vue.use(VueApollo); + + const mutationSuccessHandler = jest.fn().mockResolvedValue(updateWorkItemMutationResponse); + + const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); + const findItemState = () => wrapper.findComponent(ItemState); + + const createComponent = ({ + state = STATE_OPEN, + loading = false, + mutationHandler = mutationSuccessHandler, + } = {}) => { + const { id, workItemType } = workItemQueryResponse.data.workItem; + wrapper = shallowMount(WorkItemState, { + apolloProvider: createMockApollo([[updateWorkItemMutation, mutationHandler]]), + propsData: { + loading, + workItem: { + id, + state, + workItemType, + }, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + describe('when loading', () => { + beforeEach(() => { + createComponent({ loading: true }); + }); + + it('renders loading spinner', () => { + expect(findLoadingIcon().exists()).toBe(true); + }); + + it('does not render state', () => { + expect(findItemState().exists()).toBe(false); + }); + }); + + describe('when loaded', () => { + beforeEach(() => { + createComponent({ loading: false }); + }); + + it('does not render loading spinner', () => { + expect(findLoadingIcon().exists()).toBe(false); + }); + + it('renders state', () => { + expect(findItemState().props('state')).toBe(workItemQueryResponse.data.workItem.state); + }); + }); + + describe('when updating the state', () => { + it('calls a mutation', () => { + createComponent(); + + findItemState().vm.$emit('changed', STATE_CLOSED); + + expect(mutationSuccessHandler).toHaveBeenCalledWith({ + input: { + id: workItemQueryResponse.data.workItem.id, + stateEvent: STATE_EVENT_CLOSE, + }, + }); + }); + + it('calls a mutation with REOPEN', () => { + createComponent({ + state: STATE_CLOSED, + }); + + findItemState().vm.$emit('changed', STATE_OPEN); + + expect(mutationSuccessHandler).toHaveBeenCalledWith({ + input: { + id: workItemQueryResponse.data.workItem.id, + stateEvent: STATE_EVENT_REOPEN, + }, + }); + }); + + it('emits an error message when the mutation was unsuccessful', async () => { + createComponent({ mutationHandler: jest.fn().mockRejectedValue('Error!') }); + + findItemState().vm.$emit('changed', STATE_CLOSED); + await waitForPromises(); + + expect(wrapper.emitted('error')).toEqual([[i18n.updateError]]); + }); + + it('tracks editing the state', async () => { + const trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); + + createComponent(); + + findItemState().vm.$emit('changed', STATE_CLOSED); + await waitForPromises(); + + expect(trackingSpy).toHaveBeenCalledWith('workItems:show', 'updated_state', { + category: 'workItems:show', + label: 'item_state', + property: 'type_Task', + }); + }); + }); +}); diff --git a/spec/frontend/work_items/mock_data.js b/spec/frontend/work_items/mock_data.js index 722e1708c153205a8fb254ded847c7b24a01e283..1b2944b60780294fc4e1c7cf50ab4350af1229d7 100644 --- a/spec/frontend/work_items/mock_data.js +++ b/spec/frontend/work_items/mock_data.js @@ -4,6 +4,7 @@ export const workItemQueryResponse = { __typename: 'WorkItem', id: 'gid://gitlab/WorkItem/1', title: 'Test', + state: 'OPEN', workItemType: { __typename: 'WorkItemType', id: 'gid://gitlab/WorkItems::Type/5', @@ -21,6 +22,7 @@ export const updateWorkItemMutationResponse = { __typename: 'WorkItem', id: 'gid://gitlab/WorkItem/1', title: 'Updated title', + state: 'OPEN', workItemType: { __typename: 'WorkItemType', id: 'gid://gitlab/WorkItems::Type/5', @@ -53,6 +55,7 @@ export const createWorkItemMutationResponse = { __typename: 'WorkItem', id: 'gid://gitlab/WorkItem/1', title: 'Updated title', + state: 'OPEN', workItemType: { __typename: 'WorkItemType', id: 'gid://gitlab/WorkItems::Type/5',