Commit d3469f2a authored by Kushal Pandya's avatar Kushal Pandya

Merge branch '36427-edit-health-status-in-sidebar' into 'master'

Add ability to edit health status in Issue sidebar

See merge request gitlab-org/gitlab!25371
parents 8de22a56 d61e1021
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 { ...@@ -18,7 +18,7 @@ export default class SidebarService {
this.moveIssueEndpoint = endpointMap.moveIssueEndpoint; this.moveIssueEndpoint = endpointMap.moveIssueEndpoint;
this.projectsAutocompleteEndpoint = endpointMap.projectsAutocompleteEndpoint; this.projectsAutocompleteEndpoint = endpointMap.projectsAutocompleteEndpoint;
this.fullPath = endpointMap.fullPath; this.fullPath = endpointMap.fullPath;
this.id = endpointMap.id; this.iid = endpointMap.iid;
SidebarService.singleton = this; SidebarService.singleton = this;
} }
...@@ -37,7 +37,7 @@ export default class SidebarService { ...@@ -37,7 +37,7 @@ export default class SidebarService {
: sidebarDetailsQuery, : sidebarDetailsQuery,
variables: { variables: {
fullPath: this.fullPath, fullPath: this.fullPath,
iid: this.id.toString(), iid: this.iid.toString(),
}, },
}), }),
]); ]);
...@@ -47,6 +47,17 @@ export default class SidebarService { ...@@ -47,6 +47,17 @@ export default class SidebarService {
return axios.put(this.endpoint, { [key]: data }); 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) { getProjectsAutocomplete(searchTerm) {
return axios.get(this.projectsAutocompleteEndpoint, { return axios.get(this.projectsAutocompleteEndpoint, {
params: { params: {
......
...@@ -20,7 +20,7 @@ export default class SidebarMediator { ...@@ -20,7 +20,7 @@ export default class SidebarMediator {
moveIssueEndpoint: options.moveIssueEndpoint, moveIssueEndpoint: options.moveIssueEndpoint,
projectsAutocompleteEndpoint: options.projectsAutocompleteEndpoint, projectsAutocompleteEndpoint: options.projectsAutocompleteEndpoint,
fullPath: options.fullPath, fullPath: options.fullPath,
id: options.id, iid: options.iid,
}); });
SidebarMediator.singleton = this; SidebarMediator.singleton = this;
} }
......
...@@ -463,7 +463,7 @@ module IssuablesHelper ...@@ -463,7 +463,7 @@ module IssuablesHelper
currentUser: issuable[:current_user], currentUser: issuable[:current_user],
rootPath: root_path, rootPath: root_path,
fullPath: issuable[:project_full_path], fullPath: issuable[:project_full_path],
id: issuable[:id], iid: issuable[:iid],
timeTrackingLimitToHours: Gitlab::CurrentSettings.time_tracking_limit_to_hours timeTrackingLimitToHours: Gitlab::CurrentSettings.time_tracking_limit_to_hours
} }
end end
......
<script> <script>
import Flash from '~/flash';
import { __ } from '~/locale';
import Status from './status.vue'; import Status from './status.vue';
export default { export default {
...@@ -14,9 +16,21 @@ 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> </script>
<template> <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> </template>
<script> <script>
import { GlIcon, GlLoadingIcon, GlTooltip } from '@gitlab/ui'; import {
GlButton,
GlFormGroup,
GlFormRadioGroup,
GlIcon,
GlLoadingIcon,
GlTooltip,
} from '@gitlab/ui';
import { s__ } from '~/locale'; import { s__ } from '~/locale';
import { healthStatusColorMap, healthStatusTextMap } from '../../constants'; import { healthStatusColorMap, healthStatusTextMap } from '../../constants';
export default { export default {
components: { components: {
GlButton,
GlIcon, GlIcon,
GlLoadingIcon, GlLoadingIcon,
GlFormGroup,
GlFormRadioGroup,
GlTooltip, GlTooltip,
}, },
props: { props: {
isEditable: {
type: Boolean,
required: false,
default: false,
},
isFetching: { isFetching: {
type: Boolean, type: Boolean,
required: false, required: false,
...@@ -21,6 +36,16 @@ export default { ...@@ -21,6 +36,16 @@ export default {
default: '', default: '',
}, },
}, },
data() {
return {
isFormShowing: false,
selectedStatus: this.status,
statusOptions: Object.keys(healthStatusTextMap).map(key => ({
value: key,
text: healthStatusTextMap[key],
})),
};
},
computed: { computed: {
statusText() { statusText() {
return this.status ? healthStatusTextMap[this.status] : s__('Sidebar|None'); return this.status ? healthStatusTextMap[this.status] : s__('Sidebar|None');
...@@ -38,13 +63,31 @@ export default { ...@@ -38,13 +63,31 @@ export default {
return tooltipText; 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> </script>
<template> <template>
<div class="block"> <div class="block">
<div ref="status" class="sidebar-collapsed-icon"> <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" /> <gl-loading-icon v-if="isFetching" />
<p v-else class="collapse-truncated-title px-1">{{ statusText }}</p> <p v-else class="collapse-truncated-title px-1">{{ statusText }}</p>
...@@ -54,7 +97,46 @@ export default { ...@@ -54,7 +97,46 @@ export default {
</gl-tooltip> </gl-tooltip>
<div class="hide-collapsed"> <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" /> <gl-loading-icon v-if="isFetching" :inline="true" />
<p v-else class="value m-0" :class="{ 'no-value': !status }"> <p v-else class="value m-0" :class="{ 'no-value': !status }">
......
import Store from 'ee/sidebar/stores/sidebar_store'; import Store from 'ee/sidebar/stores/sidebar_store';
import CESidebarMediator from '~/sidebar/sidebar_mediator'; import CESidebarMediator from '~/sidebar/sidebar_mediator';
import updateStatusMutation from '~/sidebar/queries/updateStatus.mutation.graphql';
export default class SidebarMediator extends CESidebarMediator { export default class SidebarMediator extends CESidebarMediator {
initSingleton(options) { initSingleton(options) {
...@@ -27,4 +28,15 @@ export default class SidebarMediator extends CESidebarMediator { ...@@ -27,4 +28,15 @@ export default class SidebarMediator extends CESidebarMediator {
throw err; 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 { ...@@ -20,6 +20,10 @@ export default class SidebarStore extends CESidebarStore {
this.status = data?.project?.issue?.healthStatus; this.status = data?.project?.issue?.healthStatus;
} }
setStatus(status) {
this.status = status;
}
setWeightData({ weight }) { setWeightData({ weight }) {
this.isFetching.weight = false; this.isFetching.weight = false;
this.weight = typeof weight === 'number' ? Number(weight) : null; this.weight = typeof weight === 'number' ? Number(weight) : null;
......
...@@ -3,22 +3,33 @@ import SidebarStatus from 'ee/sidebar/components/status/sidebar_status.vue'; ...@@ -3,22 +3,33 @@ import SidebarStatus from 'ee/sidebar/components/status/sidebar_status.vue';
import Status from 'ee/sidebar/components/status/status.vue'; import Status from 'ee/sidebar/components/status/status.vue';
describe('SidebarStatus', () => { describe('SidebarStatus', () => {
it('renders Status component', () => { const mediator = {
const mediator = { store: {
store: { isFetching: {
isFetching: { status: true,
status: true,
},
status: '',
}, },
}; status: '',
},
};
const wrapper = shallowMount(SidebarStatus, { const handleFormSubmissionMock = jest.fn();
propsData: {
mediator, const wrapper = shallowMount(SidebarStatus, {
}, propsData: {
}); mediator,
},
methods: {
handleFormSubmission: handleFormSubmissionMock,
},
});
it('renders Status component', () => {
expect(wrapper.contains(Status)).toBe(true); 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 { GlFormRadioGroup, GlLoadingIcon, GlTooltip } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils'; import { mount, shallowMount } from '@vue/test-utils';
import Vue from 'vue';
import Status from 'ee/sidebar/components/status/status.vue'; import Status from 'ee/sidebar/components/status/status.vue';
import { healthStatus, healthStatusColorMap, healthStatusTextMap } from 'ee/sidebar/constants'; import { healthStatus, healthStatusColorMap, healthStatusTextMap } from 'ee/sidebar/constants';
...@@ -9,6 +10,14 @@ const getTooltipText = wrapper => wrapper.find(GlTooltip).text(); ...@@ -9,6 +10,14 @@ const getTooltipText = wrapper => wrapper.find(GlTooltip).text();
const getStatusIconCssClasses = wrapper => wrapper.find('[name="severity-low"]').classes(); 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', () => { describe('Status', () => {
let wrapper; let wrapper;
...@@ -18,6 +27,12 @@ describe('Status', () => { ...@@ -18,6 +27,12 @@ describe('Status', () => {
}); });
} }
function mountStatus(propsData) {
wrapper = mount(Status, {
propsData,
});
}
afterEach(() => { afterEach(() => {
wrapper.destroy(); wrapper.destroy();
}); });
...@@ -29,7 +44,7 @@ describe('Status', () => { ...@@ -29,7 +44,7 @@ describe('Status', () => {
}); });
describe('loading icon', () => { describe('loading icon', () => {
it('shows loader while retrieving data', () => { it('is displayed when retrieving data', () => {
const props = { const props = {
isFetching: true, isFetching: true,
}; };
...@@ -39,7 +54,7 @@ describe('Status', () => { ...@@ -39,7 +54,7 @@ describe('Status', () => {
expect(wrapper.contains(GlLoadingIcon)).toBe(true); expect(wrapper.contains(GlLoadingIcon)).toBe(true);
}); });
it('does not show loader when not retrieving data', () => { it('is hidden when not retrieving data', () => {
const props = { const props = {
isFetching: false, isFetching: false,
}; };
...@@ -50,6 +65,28 @@ describe('Status', () => { ...@@ -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('status text', () => {
describe('when no value is provided for status', () => { describe('when no value is provided for status', () => {
beforeEach(() => { beforeEach(() => {
...@@ -91,4 +128,118 @@ describe('Status', () => { ...@@ -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', () => { ...@@ -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', () => { describe('setWeightData', () => {
beforeEach(() => { beforeEach(() => {
expect(store.weight).toBe(null); expect(store.weight).toBe(null);
......
...@@ -3,6 +3,7 @@ import CESidebarMediator from '~/sidebar/sidebar_mediator'; ...@@ -3,6 +3,7 @@ import CESidebarMediator from '~/sidebar/sidebar_mediator';
import CESidebarStore from '~/sidebar/stores/sidebar_store'; import CESidebarStore from '~/sidebar/stores/sidebar_store';
import SidebarService from '~/sidebar/services/sidebar_service'; import SidebarService from '~/sidebar/services/sidebar_service';
import Mock from './ee_mock_data'; import Mock from './ee_mock_data';
import waitForPromises from 'spec/helpers/wait_for_promises';
describe('EE Sidebar mediator', () => { describe('EE Sidebar mediator', () => {
let mediator; let mediator;
...@@ -26,4 +27,28 @@ describe('EE Sidebar mediator', () => { ...@@ -26,4 +27,28 @@ describe('EE Sidebar mediator', () => {
expect(mediator.store.weight).toBe(mockData.weight); expect(mediator.store.weight).toBe(mockData.weight);
expect(mediator.store.status).toBe(mockGraphQlData.project.issue.healthStatus); 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);
});
});
}); });
...@@ -3715,6 +3715,9 @@ msgstr "" ...@@ -3715,6 +3715,9 @@ msgstr ""
msgid "Choose which shards you wish to synchronize to this secondary node." msgid "Choose which shards you wish to synchronize to this secondary node."
msgstr "" msgstr ""
msgid "Choose which status most accurately reflects the current state of this issue:"
msgstr ""
msgid "CiStatusLabel|canceled" msgid "CiStatusLabel|canceled"
msgstr "" msgstr ""
...@@ -7963,6 +7966,9 @@ msgstr "" ...@@ -7963,6 +7966,9 @@ msgstr ""
msgid "Error occurred when toggling the notification subscription" msgid "Error occurred when toggling the notification subscription"
msgstr "" msgstr ""
msgid "Error occurred while updating the issue status"
msgstr ""
msgid "Error occurred while updating the issue weight" msgid "Error occurred while updating the issue weight"
msgstr "" msgstr ""
......
...@@ -204,7 +204,7 @@ const mockData = { ...@@ -204,7 +204,7 @@ const mockData = {
}, },
rootPath: '/', rootPath: '/',
fullPath: '/gitlab-org/gitlab-shell', fullPath: '/gitlab-org/gitlab-shell',
id: 1, iid: 1,
}, },
time: { time: {
time_estimate: 3600, 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