Commit 13ffb66e authored by Justin Ho Tuan Duong's avatar Justin Ho Tuan Duong Committed by Sean McGivern

Allow users to edit the status of a Jira issue on details page

parent 719697bd
---
name: jira_issue_details_edit_status
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/60092
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/330628
milestone: '14.1'
type: development
group: group::ecosystem
default_enabled: false
......@@ -5,3 +5,22 @@ export const fetchIssue = async (issuePath) => {
return data;
});
};
export const fetchIssueStatuses = () => {
// We are using mock data here which should come from the backend
return new Promise((resolve) => {
setTimeout(() => {
// eslint-disable-next-line @gitlab/require-i18n-strings
resolve([{ title: 'In Progress' }, { title: 'Done' }]);
}, 1000);
});
};
export const updateIssue = (issue, { status }) => {
// We are using mock call here which should become a backend call
return new Promise((resolve) => {
setTimeout(() => {
resolve({ ...issue, status });
}, 1000);
});
};
......@@ -7,9 +7,11 @@ import {
GlBadge,
GlTooltipDirective as GlTooltip,
} from '@gitlab/ui';
import { fetchIssue } from 'ee/integrations/jira/issues_show/api';
import { fetchIssue, fetchIssueStatuses, updateIssue } from 'ee/integrations/jira/issues_show/api';
import JiraIssueSidebar from 'ee/integrations/jira/issues_show/components/sidebar/jira_issues_sidebar_root.vue';
import { issueStates, issueStateLabels } from 'ee/integrations/jira/issues_show/constants';
import createFlash from '~/flash';
import IssuableShow from '~/issuable_show/components/issuable_show_root.vue';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import { s__ } from '~/locale';
......@@ -38,8 +40,11 @@ export default {
data() {
return {
isLoading: true,
isLoadingStatus: false,
isUpdatingStatus: false,
errorMessage: null,
issue: {},
statuses: [],
};
},
computed: {
......@@ -78,6 +83,41 @@ export default {
jiraIssueCommentId(id) {
return `jira_note_${id}`;
},
onIssueStatusFetch() {
this.isLoadingStatus = true;
fetchIssueStatuses()
.then((response) => {
this.statuses = response;
})
.catch(() => {
createFlash({
message: s__(
'JiraService|Failed to load Jira issue statuses. View the issue in Jira, or reload the page.',
),
});
})
.finally(() => {
this.isLoadingStatus = false;
});
},
onIssueStatusUpdated(status) {
this.isUpdatingStatus = true;
updateIssue(this.issue, { status })
.then(() => {
this.issue = { ...this.issue, status };
})
.catch(() => {
createFlash({
message: s__(
'JiraService|Failed to update Jira issue status. View the issue in Jira, or reload the page.',
),
});
})
.finally(() => {
this.isUpdatingStatus = false;
});
},
},
};
</script>
......@@ -117,7 +157,15 @@ export default {
<template #status-badge>{{ statusBadgeText }}</template>
<template #right-sidebar-items="{ sidebarExpanded }">
<jira-issue-sidebar :sidebar-expanded="sidebarExpanded" :issue="issue" />
<jira-issue-sidebar
:sidebar-expanded="sidebarExpanded"
:issue="issue"
:is-loading-status="isLoadingStatus"
:is-updating-status="isUpdatingStatus"
:statuses="statuses"
@issue-status-fetch="onIssueStatusFetch"
@issue-status-updated="onIssueStatusUpdated"
/>
</template>
<template #discussion>
......
<script>
import { GlIcon, GlTooltipDirective } from '@gitlab/ui';
import { __ } from '~/locale';
import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue';
import IssueFieldDropdown from './issue_field_dropdown.vue';
export default {
directives: {
......@@ -8,12 +10,50 @@ export default {
},
components: {
GlIcon,
IssueFieldDropdown,
SidebarEditableItem,
},
provide() {
return {
isClassicSidebar: true,
canUpdate: this.canUpdate,
};
},
props: {
canUpdate: {
type: Boolean,
required: false,
default: false,
},
dropdownEmpty: {
type: String,
required: false,
default: null,
},
dropdownTitle: {
type: String,
required: false,
default: null,
},
icon: {
type: String,
required: true,
},
items: {
type: Array,
required: false,
default: () => [],
},
loading: {
type: Boolean,
required: false,
default: false,
},
updating: {
type: Boolean,
required: false,
default: false,
},
title: {
type: String,
required: true,
......@@ -44,20 +84,58 @@ export default {
i18n: {
none: __('None'),
},
methods: {
showDropdown() {
this.$refs.dropdown.showDropdown();
this.$emit('issue-field-fetch');
},
expandSidebarAndOpenDropdown() {
this.$emit('expand-sidebar', this.$refs.editableItem);
},
onIssueFieldUpdated(value) {
this.$emit('issue-field-updated', value);
},
},
};
</script>
<template>
<div class="block">
<div v-gl-tooltip="tooltipProps" class="sidebar-collapsed-icon" data-testid="field-collapsed">
<gl-icon :name="icon" />
</div>
<sidebar-editable-item
ref="editableItem"
:loading="updating"
:title="title"
@open="showDropdown"
>
<template #collapsed>
<div
v-gl-tooltip="tooltipProps"
class="sidebar-collapsed-icon"
data-testid="field-collapsed"
@click="expandSidebarAndOpenDropdown"
>
<gl-icon :name="icon" />
</div>
<div class="hide-collapsed">
<div class="value" data-testid="field-value">
<span :class="valueClass">{{ valueWithFallback }}</span>
</div>
</div>
</template>
<div class="hide-collapsed">
<div class="title" data-testid="field-title">{{ title }}</div>
<div class="value">
<span :class="valueClass" data-testid="field-value">{{ valueWithFallback }}</span>
</div>
</div>
<template #default>
<issue-field-dropdown
v-if="canUpdate"
ref="dropdown"
:empty-text="dropdownEmpty"
:items="items"
:loading="loading"
:text="valueWithFallback"
:title="dropdownTitle"
@issue-field-updated="onIssueFieldUpdated"
/>
</template>
</sidebar-editable-item>
</div>
</template>
<script>
import { GlDropdown, GlDropdownItem, GlDropdownText, GlLoadingIcon } from '@gitlab/ui';
export default {
components: {
GlDropdown,
GlDropdownItem,
GlDropdownText,
GlLoadingIcon,
},
props: {
emptyText: {
type: String,
required: false,
default: null,
},
items: {
type: Array,
required: false,
default: () => [],
},
loading: {
type: Boolean,
required: false,
default: true,
},
text: {
type: String,
required: false,
default: null,
},
title: {
type: String,
required: false,
default: null,
},
},
computed: {
noItems() {
return this.items.length === 0;
},
},
methods: {
showDropdown() {
this.$refs.dropdown.show();
},
selectItem(item) {
this.$emit('issue-field-updated', item.title);
},
},
};
</script>
<template>
<gl-dropdown ref="dropdown" :text="text" :header-text="title" block lazy>
<div v-if="loading" class="gl-h-13">
<gl-loading-icon size="md" />
</div>
<div v-else>
<gl-dropdown-text v-if="noItems">{{ emptyText }}</gl-dropdown-text>
<gl-dropdown-item v-for="item in items" :key="item.title" @click="selectItem(item)">
{{ item.title }}
</gl-dropdown-item>
</div>
</gl-dropdown>
</template>
<script>
import { labelsFilterParam } from 'ee/integrations/jira/issues_show/constants';
import { __ } from '~/locale';
import { __, s__ } from '~/locale';
import CopyableField from '~/vue_shared/components/sidebar/copyable_field.vue';
import LabelsSelect from '~/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import Assignee from './assignee.vue';
import IssueDueDate from './issue_due_date.vue';
import IssueField from './issue_field.vue';
......@@ -16,6 +17,7 @@ export default {
CopyableField,
LabelsSelect,
},
mixins: [glFeatureFlagsMixin()],
inject: {
issuesListPath: {
default: null,
......@@ -30,6 +32,21 @@ export default {
type: Object,
required: true,
},
isLoadingStatus: {
type: Boolean,
required: false,
default: false,
},
isUpdatingStatus: {
type: Boolean,
required: false,
default: false,
},
statuses: {
type: Array,
required: false,
default: () => [],
},
},
computed: {
assignee() {
......@@ -39,12 +56,50 @@ export default {
reference() {
return this.issue.references?.relative;
},
canUpdateStatus() {
return this.glFeatures.jiraIssueDetailsEditStatus;
},
},
labelsFilterParam,
i18n: {
statusTitle: __('Status'),
statusDropdownEmpty: s__('JiraService|No available statuses'),
statusDropdownTitle: __('Change status'),
referenceName: __('Reference'),
},
mounted() {
this.sidebarEl = document.querySelector('aside.right-sidebar');
this.sidebarToggleEl = document.querySelector('.js-toggle-right-sidebar-button');
},
methods: {
toggleSidebar() {
this.sidebarToggleEl.dispatchEvent(new Event('click'));
},
expandSidebarAndOpenDropdown(dropdownRef = null) {
// Expand the sidebar if not already expanded.
if (!this.sidebarExpanded) {
this.toggleSidebar();
}
if (dropdownRef) {
// Wait for sidebar expand animation to complete
// before revealing the dropdown.
this.sidebarEl.addEventListener(
'transitionend',
() => {
dropdownRef.expand();
},
{ once: true },
);
}
},
onIssueStatusFetch() {
this.$emit('issue-status-fetch');
},
onIssueStatusUpdated(status) {
this.$emit('issue-status-updated', status);
},
},
};
</script>
......@@ -52,7 +107,20 @@ export default {
<div>
<assignee class="block" :assignee="assignee" />
<issue-due-date :due-date="issue.dueDate" />
<issue-field icon="progress" :title="$options.i18n.statusTitle" :value="issue.status" />
<issue-field
icon="progress"
:can-update="canUpdateStatus"
:dropdown-title="$options.i18n.statusDropdownTitle"
:dropdown-empty="$options.i18n.statusDropdownEmpty"
:items="statuses"
:loading="isLoadingStatus"
:title="$options.i18n.statusTitle"
:updating="isUpdatingStatus"
:value="issue.status"
@expand-sidebar="expandSidebarAndOpenDropdown"
@issue-field-fetch="onIssueStatusFetch"
@issue-field-updated="onIssueStatusUpdated"
/>
<labels-select
:selected-labels="issue.labels"
:labels-filter-base-path="issuesListPath"
......
......@@ -13,6 +13,9 @@ module Projects
name: 'i_ecosystem_jira_service_list_issues'
before_action :check_feature_enabled!
before_action only: :show do
push_frontend_feature_flag(:jira_issue_details_edit_status, project, default_enabled: :yaml)
end
rescue_from ::Projects::Integrations::Jira::IssuesFinder::IntegrationError, with: :render_integration_error
rescue_from ::Projects::Integrations::Jira::IssuesFinder::RequestError, with: :render_request_error
......
import { GlAlert, GlLoadingIcon } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import MockAdapter from 'axios-mock-adapter';
import * as JiraIssuesShowApi from 'ee/integrations/jira/issues_show/api';
import JiraIssuesShow from 'ee/integrations/jira/issues_show/components/jira_issues_show_root.vue';
import JiraIssueSidebar from 'ee/integrations/jira/issues_show/components/sidebar/jira_issues_sidebar_root.vue';
import { issueStates } from 'ee/integrations/jira/issues_show/constants';
import waitForPromises from 'helpers/wait_for_promises';
import IssuableHeader from '~/issuable_show/components/issuable_header.vue';
import IssuableShow from '~/issuable_show/components/issuable_show_root.vue';
import IssuableSidebar from '~/issuable_sidebar/components/issuable_sidebar_root.vue';
import axios from '~/lib/utils/axios_utils';
import { mockJiraIssue } from '../mock_data';
......@@ -18,14 +22,16 @@ describe('JiraIssuesShow', () => {
const findGlAlert = () => wrapper.findComponent(GlAlert);
const findGlLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
const findIssuableShow = () => wrapper.findComponent(IssuableShow);
const findJiraIssueSidebar = () => wrapper.findComponent(JiraIssueSidebar);
const findIssuableShowStatusBadge = () =>
wrapper.findComponent(IssuableHeader).find('[data-testid="status"]');
const createComponent = () => {
wrapper = shallowMount(JiraIssuesShow, {
stubs: {
IssuableShow,
IssuableHeader,
IssuableShow,
IssuableSidebar,
},
provide: {
issuesShowPath: mockJiraIssuesShowPath,
......@@ -39,11 +45,7 @@ describe('JiraIssuesShow', () => {
afterEach(() => {
mockAxios.restore();
if (wrapper) {
wrapper.destroy();
wrapper = null;
}
wrapper.destroy();
});
describe('when issue is loading', () => {
......@@ -109,4 +111,45 @@ describe('JiraIssuesShow', () => {
expect(findIssuableShowStatusBadge().text()).toBe(badgeText);
});
});
describe('JiraIssueSidebar events', () => {
beforeEach(async () => {
mockAxios.onGet(mockJiraIssuesShowPath).replyOnce(200, mockJiraIssue);
createComponent();
await waitForPromises();
});
it('fetches issue statuses on issue-status-fetch', async () => {
const fetchIssueStatusesSpy = jest
.spyOn(JiraIssuesShowApi, 'fetchIssueStatuses')
.mockResolvedValue();
findJiraIssueSidebar().vm.$emit('issue-status-fetch');
await wrapper.vm.$nextTick();
expect(fetchIssueStatusesSpy).toHaveBeenCalled();
expect(findJiraIssueSidebar().props('isLoadingStatus')).toBe(true);
await waitForPromises();
expect(findJiraIssueSidebar().props('isLoadingStatus')).toBe(false);
});
it('updates issue status on issue-status-updated', async () => {
const updateIssueSpy = jest.spyOn(JiraIssuesShowApi, 'updateIssue').mockResolvedValue();
const status = 'In Review';
findJiraIssueSidebar().vm.$emit('issue-status-updated', status);
await wrapper.vm.$nextTick();
expect(updateIssueSpy).toHaveBeenCalledWith(expect.any(Object), { status });
expect(findJiraIssueSidebar().props('isUpdatingStatus')).toBe(true);
await waitForPromises();
expect(findJiraIssueSidebar().props('isUpdatingStatus')).toBe(false);
});
});
});
import { GlDropdownItem, GlLoadingIcon } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import IssueFieldDropdown from 'ee/integrations/jira/issues_show/components/sidebar/issue_field_dropdown.vue';
import { mockJiraIssueStatuses } from '../../mock_data';
describe('IssueFieldDropdown', () => {
let wrapper;
const emptyText = 'empty text';
const defaultProps = {
emptyText,
text: 'issue field text',
title: 'issue field header text',
};
const createComponent = ({ props = {} } = {}) => {
wrapper = shallowMount(IssueFieldDropdown, {
propsData: { ...defaultProps, ...props },
});
};
afterEach(() => {
wrapper.destroy();
});
const findAllGlDropdownItems = () => wrapper.findAllComponents(GlDropdownItem);
const findGlLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
it.each`
loading | items
${true} | ${[]}
${true} | ${mockJiraIssueStatuses}
${false} | ${[]}
${false} | ${mockJiraIssueStatuses}
`('with loading = $loading, items = $items', ({ loading, items }) => {
createComponent({
props: {
loading,
items,
},
});
expect(findGlLoadingIcon().exists()).toBe(loading);
if (!loading) {
if (items.length) {
findAllGlDropdownItems().wrappers.forEach((itemWrapper, index) => {
expect(itemWrapper.text()).toBe(mockJiraIssueStatuses[index].title);
});
} else {
expect(wrapper.text()).toBe(emptyText);
}
}
});
});
import { GlIcon } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import { GlButton, GlIcon } from '@gitlab/ui';
import IssueField from 'ee/integrations/jira/issues_show/components/sidebar/issue_field.vue';
import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue';
describe('IssueField', () => {
let wrapper;
......@@ -15,37 +16,44 @@ describe('IssueField', () => {
};
const createComponent = ({ props = {} } = {}) => {
wrapper = extendedWrapper(
shallowMount(IssueField, {
propsData: { ...defaultProps, ...props },
directives: {
GlTooltip: createMockDirective(),
},
}),
);
wrapper = shallowMountExtended(IssueField, {
directives: {
GlTooltip: createMockDirective(),
},
propsData: { ...defaultProps, ...props },
stubs: {
SidebarEditableItem,
},
});
};
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
const findFieldTitle = () => wrapper.findByTestId('field-title');
const findFieldValue = () => wrapper.findByTestId('field-value');
const findEditableItem = () => wrapper.findComponent(SidebarEditableItem);
const findEditButton = () => wrapper.findComponent(GlButton);
const findFieldCollapsed = () => wrapper.findByTestId('field-collapsed');
const findFieldCollapsedTooltip = () => getBinding(findFieldCollapsed().element, 'gl-tooltip');
const findFieldValue = () => wrapper.findByTestId('field-value');
const findGlIcon = () => wrapper.findComponent(GlIcon);
it('renders title', () => {
createComponent();
describe('template', () => {
beforeEach(() => {
createComponent();
});
expect(findFieldTitle().text()).toBe(defaultProps.title);
});
it('renders title', () => {
expect(findEditableItem().props('title')).toBe(defaultProps.title);
});
it('renders GlIcon (when collapsed)', () => {
createComponent();
it('renders GlIcon (when collapsed)', () => {
expect(findGlIcon().props('name')).toBe(defaultProps.icon);
});
expect(findGlIcon().props('name')).toBe(defaultProps.icon);
it('does not render "Edit" button', () => {
expect(findEditButton().exists()).toBe(false);
});
});
describe('without value prop', () => {
......@@ -53,7 +61,7 @@ describe('IssueField', () => {
createComponent();
});
it('renders fallback value with "no-value" class', () => {
it('falls back to "None"', () => {
expect(findFieldValue().text()).toBe('None');
});
......@@ -74,7 +82,7 @@ describe('IssueField', () => {
});
});
it('renders value', () => {
it('renders the value', () => {
expect(findFieldValue().text()).toBe(value);
});
......@@ -85,4 +93,25 @@ describe('IssueField', () => {
expect(tooltip.value.title).toBe(value);
});
});
describe('with canUpdate = true', () => {
beforeEach(() => {
createComponent({
props: { canUpdate: true },
});
});
it('renders "Edit" button', () => {
expect(findEditButton().text()).toBe('Edit');
});
it('emits "issue-field-fetch" when dropdown is opened', () => {
wrapper.vm.$refs.dropdown.showDropdown = jest.fn();
findEditableItem().vm.$emit('open');
expect(wrapper.vm.$refs.dropdown.showDropdown).toHaveBeenCalled();
expect(wrapper.emitted('issue-field-fetch')).toHaveLength(1);
});
});
});
......@@ -25,10 +25,7 @@ describe('JiraIssuesSidebar', () => {
};
afterEach(() => {
if (wrapper) {
wrapper.destroy();
wrapper = null;
}
wrapper.destroy();
});
const findLabelsSelect = () => wrapper.findComponent(LabelsSelect);
......
......@@ -41,3 +41,5 @@ export const mockJiraIssueComment = {
},
id: 10000,
};
export const mockJiraIssueStatuses = [{ title: 'In Progress' }, { title: 'Done' }];
......@@ -18394,9 +18394,15 @@ msgstr ""
msgid "JiraService|Events for %{noteable_model_name} are disabled."
msgstr ""
msgid "JiraService|Failed to load Jira issue statuses. View the issue in Jira, or reload the page."
msgstr ""
msgid "JiraService|Failed to load Jira issue. View the issue in Jira, or reload the page."
msgstr ""
msgid "JiraService|Failed to update Jira issue status. View the issue in Jira, or reload the page."
msgstr ""
msgid "JiraService|Fetch issue types for this Jira project"
msgstr ""
......@@ -18445,6 +18451,9 @@ msgstr ""
msgid "JiraService|Move to Done"
msgstr ""
msgid "JiraService|No available statuses"
msgstr ""
msgid "JiraService|Not all data may be displayed here. To view more details or make changes to this issue, go to %{linkStart}Jira%{linkEnd}."
msgstr ""
......
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