Commit d61e1021 authored by Coung Ngo's avatar Coung Ngo

Add ability to edit issue health status

Added ability to edit the health status of an issue in its sidebar.
The health status feature is behind a feature flag
parent 7b4fd170
mutation ($projectPath: ID!, $iid: String!, $healthStatus: HealthStatus) {
updateIssue(input: { projectPath: $projectPath, iid: $iid, healthStatus: $healthStatus}) {
issue {
healthStatus
}
}
}
......@@ -18,7 +18,7 @@ export default class SidebarService {
this.moveIssueEndpoint = endpointMap.moveIssueEndpoint;
this.projectsAutocompleteEndpoint = endpointMap.projectsAutocompleteEndpoint;
this.fullPath = endpointMap.fullPath;
this.id = endpointMap.id;
this.iid = endpointMap.iid;
SidebarService.singleton = this;
}
......@@ -37,7 +37,7 @@ export default class SidebarService {
: sidebarDetailsQuery,
variables: {
fullPath: this.fullPath,
iid: this.id.toString(),
iid: this.iid.toString(),
},
}),
]);
......@@ -47,6 +47,17 @@ export default class SidebarService {
return axios.put(this.endpoint, { [key]: data });
}
updateWithGraphQl(mutation, variables) {
return gqClient.mutate({
mutation,
variables: {
...variables,
projectPath: this.fullPath,
iid: this.iid.toString(),
},
});
}
getProjectsAutocomplete(searchTerm) {
return axios.get(this.projectsAutocompleteEndpoint, {
params: {
......
......@@ -20,7 +20,7 @@ export default class SidebarMediator {
moveIssueEndpoint: options.moveIssueEndpoint,
projectsAutocompleteEndpoint: options.projectsAutocompleteEndpoint,
fullPath: options.fullPath,
id: options.id,
iid: options.iid,
});
SidebarMediator.singleton = this;
}
......
......@@ -463,7 +463,7 @@ module IssuablesHelper
currentUser: issuable[:current_user],
rootPath: root_path,
fullPath: issuable[:project_full_path],
id: issuable[:id],
iid: issuable[:iid],
timeTrackingLimitToHours: Gitlab::CurrentSettings.time_tracking_limit_to_hours
}
end
......
<script>
import Flash from '~/flash';
import { __ } from '~/locale';
import Status from './status.vue';
export default {
......@@ -14,9 +16,21 @@ export default {
},
},
},
methods: {
handleFormSubmission(status) {
this.mediator.updateStatus(status).catch(() => {
Flash(__('Error occurred while updating the issue status'));
});
},
},
};
</script>
<template>
<status :is-fetching="mediator.store.isFetching.status" :status="mediator.store.status" />
<status
:is-editable="mediator.store.editable"
:is-fetching="mediator.store.isFetching.status"
:status="mediator.store.status"
@onFormSubmit="handleFormSubmission"
/>
</template>
<script>
import { GlIcon, GlLoadingIcon, GlTooltip } from '@gitlab/ui';
import {
GlButton,
GlFormGroup,
GlFormRadioGroup,
GlIcon,
GlLoadingIcon,
GlTooltip,
} from '@gitlab/ui';
import { s__ } from '~/locale';
import { healthStatusColorMap, healthStatusTextMap } from '../../constants';
export default {
components: {
GlButton,
GlIcon,
GlLoadingIcon,
GlFormGroup,
GlFormRadioGroup,
GlTooltip,
},
props: {
isEditable: {
type: Boolean,
required: false,
default: false,
},
isFetching: {
type: Boolean,
required: false,
......@@ -21,6 +36,16 @@ export default {
default: '',
},
},
data() {
return {
isFormShowing: false,
selectedStatus: this.status,
statusOptions: Object.keys(healthStatusTextMap).map(key => ({
value: key,
text: healthStatusTextMap[key],
})),
};
},
computed: {
statusText() {
return this.status ? healthStatusTextMap[this.status] : s__('Sidebar|None');
......@@ -38,13 +63,31 @@ export default {
return tooltipText;
},
},
watch: {
status(status) {
this.selectedStatus = status;
},
},
methods: {
handleFormSubmission() {
this.$emit('onFormSubmit', this.selectedStatus);
this.hideForm();
},
hideForm() {
this.isFormShowing = false;
this.$refs.editButton.focus();
},
toggleFormDropdown() {
this.isFormShowing = !this.isFormShowing;
},
},
};
</script>
<template>
<div class="block">
<div ref="status" class="sidebar-collapsed-icon">
<gl-icon name="status" :size="14" />
<gl-icon name="status-health" :size="14" />
<gl-loading-icon v-if="isFetching" />
<p v-else class="collapse-truncated-title px-1">{{ statusText }}</p>
......@@ -54,7 +97,46 @@ export default {
</gl-tooltip>
<div class="hide-collapsed">
<p class="title">{{ s__('Sidebar|Status') }}</p>
<p class="title d-flex justify-content-between">
{{ s__('Sidebar|Status') }}
<a
v-if="isEditable"
ref="editButton"
class="btn-link"
href="#"
@click="toggleFormDropdown"
@keydown.esc="hideForm"
>
{{ __('Edit') }}
</a>
</p>
<div v-if="isFormShowing" class="dropdown show">
<form class="dropdown-menu p-3" @submit.prevent="handleFormSubmission">
<p>
{{
__('Choose which status most accurately reflects the current state of this issue:')
}}
</p>
<gl-form-group>
<gl-form-radio-group
v-model="selectedStatus"
:checked="selectedStatus"
:options="statusOptions"
stacked
@keydown.esc.native="hideForm"
/>
</gl-form-group>
<gl-form-group class="mb-0">
<gl-button type="button" class="append-right-10" @click="hideForm">
{{ __('Cancel') }}
</gl-button>
<gl-button type="submit" variant="success">
{{ __('Save') }}
</gl-button>
</gl-form-group>
</form>
</div>
<gl-loading-icon v-if="isFetching" :inline="true" />
<p v-else class="value m-0" :class="{ 'no-value': !status }">
......
import Store from 'ee/sidebar/stores/sidebar_store';
import CESidebarMediator from '~/sidebar/sidebar_mediator';
import updateStatusMutation from '~/sidebar/queries/updateStatus.mutation.graphql';
export default class SidebarMediator extends CESidebarMediator {
initSingleton(options) {
......@@ -27,4 +28,15 @@ export default class SidebarMediator extends CESidebarMediator {
throw err;
});
}
updateStatus(healthStatus) {
this.store.setFetchingState('status', true);
return this.service
.updateWithGraphQl(updateStatusMutation, { healthStatus })
.then(({ data }) => this.store.setStatus(data?.updateIssue?.issue?.healthStatus))
.catch(error => {
throw error;
})
.finally(() => this.store.setFetchingState('status', false));
}
}
......@@ -20,6 +20,10 @@ export default class SidebarStore extends CESidebarStore {
this.status = data?.project?.issue?.healthStatus;
}
setStatus(status) {
this.status = status;
}
setWeightData({ weight }) {
this.isFetching.weight = false;
this.weight = typeof weight === 'number' ? Number(weight) : null;
......
......@@ -3,22 +3,33 @@ import SidebarStatus from 'ee/sidebar/components/status/sidebar_status.vue';
import Status from 'ee/sidebar/components/status/status.vue';
describe('SidebarStatus', () => {
it('renders Status component', () => {
const mediator = {
store: {
isFetching: {
status: true,
},
status: '',
const mediator = {
store: {
isFetching: {
status: true,
},
};
status: '',
},
};
const wrapper = shallowMount(SidebarStatus, {
propsData: {
mediator,
},
});
const handleFormSubmissionMock = jest.fn();
const wrapper = shallowMount(SidebarStatus, {
propsData: {
mediator,
},
methods: {
handleFormSubmission: handleFormSubmissionMock,
},
});
it('renders Status component', () => {
expect(wrapper.contains(Status)).toBe(true);
});
it('calls handleFormSubmission when receiving an onFormSubmit event from Status component', () => {
wrapper.find(Status).vm.$emit('onFormSubmit', 'onTrack');
expect(handleFormSubmissionMock).toHaveBeenCalledWith('onTrack');
});
});
import { GlLoadingIcon, GlTooltip } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import { GlFormRadioGroup, GlLoadingIcon, GlTooltip } from '@gitlab/ui';
import { mount, shallowMount } from '@vue/test-utils';
import Vue from 'vue';
import Status from 'ee/sidebar/components/status/status.vue';
import { healthStatus, healthStatusColorMap, healthStatusTextMap } from 'ee/sidebar/constants';
......@@ -9,6 +10,14 @@ const getTooltipText = wrapper => wrapper.find(GlTooltip).text();
const getStatusIconCssClasses = wrapper => wrapper.find('[name="severity-low"]').classes();
const getEditButton = wrapper => wrapper.find({ ref: 'editButton' });
const getEditForm = wrapper => wrapper.find('form');
const getRadioInputs = wrapper => wrapper.findAll('input[type="radio"]');
const getRadioComponent = wrapper => wrapper.find(GlFormRadioGroup);
describe('Status', () => {
let wrapper;
......@@ -18,6 +27,12 @@ describe('Status', () => {
});
}
function mountStatus(propsData) {
wrapper = mount(Status, {
propsData,
});
}
afterEach(() => {
wrapper.destroy();
});
......@@ -29,7 +44,7 @@ describe('Status', () => {
});
describe('loading icon', () => {
it('shows loader while retrieving data', () => {
it('is displayed when retrieving data', () => {
const props = {
isFetching: true,
};
......@@ -39,7 +54,7 @@ describe('Status', () => {
expect(wrapper.contains(GlLoadingIcon)).toBe(true);
});
it('does not show loader when not retrieving data', () => {
it('is hidden when not retrieving data', () => {
const props = {
isFetching: false,
};
......@@ -50,6 +65,28 @@ describe('Status', () => {
});
});
describe('edit button', () => {
it('is displayed when user can edit', () => {
const props = {
isEditable: true,
};
shallowMountStatus(props);
expect(getEditButton(wrapper).exists()).toBe(true);
});
it('is hidden when user cannot edit', () => {
const props = {
isEditable: false,
};
shallowMountStatus(props);
expect(getEditButton(wrapper).exists()).toBe(false);
});
});
describe('status text', () => {
describe('when no value is provided for status', () => {
beforeEach(() => {
......@@ -91,4 +128,118 @@ describe('Status', () => {
});
});
});
describe('status edit form', () => {
it('is hidden by default', () => {
const props = {
isEditable: true,
};
shallowMountStatus(props);
expect(getEditForm(wrapper).exists()).toBe(false);
});
describe('when hidden', () => {
beforeEach(() => {
const props = {
isEditable: true,
};
shallowMountStatus(props);
});
it('shows the form when the Edit button is clicked', () => {
getEditButton(wrapper).trigger('click');
return Vue.nextTick().then(() => {
expect(getEditForm(wrapper).exists()).toBe(true);
});
});
});
describe('when visible', () => {
beforeEach(() => {
const props = {
isEditable: true,
};
shallowMountStatus(props);
wrapper.setData({ isFormShowing: true });
});
it('shows text to ask the user to pick an option', () => {
const message =
'Choose which status most accurately reflects the current state of this issue:';
expect(
getEditForm(wrapper)
.find('p')
.text(),
).toContain(message);
});
it('hides form when the Edit button is clicked', () => {
getEditButton(wrapper).trigger('click');
return Vue.nextTick().then(() => {
expect(getEditForm(wrapper).exists()).toBe(false);
});
});
it('hides form when the Cancel button is clicked', () => {
const button = getEditForm(wrapper).find('[type="button"]');
button.vm.$emit('click');
return Vue.nextTick().then(() => {
expect(getEditForm(wrapper).exists()).toBe(false);
});
});
it('hides form when the form is submitted', () => {
getEditForm(wrapper).trigger('submit');
return Vue.nextTick().then(() => {
expect(getEditForm(wrapper).exists()).toBe(false);
});
});
});
describe('radio buttons', () => {
beforeEach(() => {
const props = {
isEditable: true,
};
mountStatus(props);
wrapper.setData({ isFormShowing: true });
});
it('shows 3 radio buttons', () => {
expect(getRadioInputs(wrapper).length).toBe(3);
});
// Test that "On track", "Needs attention", and "At risk" are displayed
it.each(Object.values(healthStatusTextMap))('shows "%s" text', statusText => {
expect(getRadioComponent(wrapper).text()).toContain(statusText);
});
// Test that "onTrack", "needsAttention", and "atRisk" values are emitted when form is submitted
it.each(Object.values(healthStatus))(
'emits onFormSubmit event with argument "%s" when user selects the option and submits form',
status => {
getEditForm(wrapper)
.find(`input[value="${status}"]`)
.trigger('click');
return Vue.nextTick().then(() => {
getEditForm(wrapper).trigger('submit');
expect(wrapper.emitted().onFormSubmit[0]).toEqual([status]);
});
},
);
});
});
});
......@@ -35,6 +35,16 @@ describe('EE Sidebar store', () => {
});
});
describe('setStatus', () => {
it('sets status', () => {
expect(store.status).toEqual('');
const status = 'onTrack';
store.setStatus(status);
expect(store.status).toEqual(status);
});
});
describe('setWeightData', () => {
beforeEach(() => {
expect(store.weight).toBe(null);
......
......@@ -3,6 +3,7 @@ import CESidebarMediator from '~/sidebar/sidebar_mediator';
import CESidebarStore from '~/sidebar/stores/sidebar_store';
import SidebarService from '~/sidebar/services/sidebar_service';
import Mock from './ee_mock_data';
import waitForPromises from 'spec/helpers/wait_for_promises';
describe('EE Sidebar mediator', () => {
let mediator;
......@@ -26,4 +27,28 @@ describe('EE Sidebar mediator', () => {
expect(mediator.store.weight).toBe(mockData.weight);
expect(mediator.store.status).toBe(mockGraphQlData.project.issue.healthStatus);
});
it('updates status when updateStatus is called', () => {
const healthStatus = 'onTrack';
spyOn(mediator.service, 'updateWithGraphQl').and.returnValue(
Promise.resolve({
data: {
updateIssue: {
issue: {
healthStatus,
},
},
},
}),
);
expect(mediator.store.status).toBe('');
mediator.updateStatus(healthStatus);
return waitForPromises().then(() => {
expect(mediator.store.status).toBe(healthStatus);
});
});
});
......@@ -3721,6 +3721,9 @@ msgstr ""
msgid "Choose which shards you wish to synchronize to this secondary node."
msgstr ""
msgid "Choose which status most accurately reflects the current state of this issue:"
msgstr ""
msgid "CiStatusLabel|canceled"
msgstr ""
......@@ -7960,6 +7963,9 @@ msgstr ""
msgid "Error occurred when toggling the notification subscription"
msgstr ""
msgid "Error occurred while updating the issue status"
msgstr ""
msgid "Error occurred while updating the issue weight"
msgstr ""
......
......@@ -204,7 +204,7 @@ const mockData = {
},
rootPath: '/',
fullPath: '/gitlab-org/gitlab-shell',
id: 1,
iid: 1,
},
time: {
time_estimate: 3600,
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment