Commit ad919b0f authored by Jacques Erasmus's avatar Jacques Erasmus

Merge branch '328787-fe-allow-users-to-edit-labels-of-a-jira-issue-on-details-page' into 'master'

FE: Allow users to edit labels of a Jira issue on details page

See merge request gitlab-org/gitlab!65298
parents a3415e03 7a96479b
...@@ -48,6 +48,12 @@ export default { ...@@ -48,6 +48,12 @@ export default {
} }
return this.labels; return this.labels;
}, },
showDropdownFooter() {
return (
(this.isDropdownVariantSidebar || this.isDropdownVariantEmbedded) &&
(this.allowLabelCreate || this.labelsManagePath)
);
},
showNoMatchingResultsMessage() { showNoMatchingResultsMessage() {
return Boolean(this.searchKey) && this.visibleLabels.length === 0; return Boolean(this.searchKey) && this.visibleLabels.length === 0;
}, },
...@@ -192,11 +198,7 @@ export default { ...@@ -192,11 +198,7 @@ export default {
</li> </li>
</ul> </ul>
</div> </div>
<div <div v-if="showDropdownFooter" class="dropdown-footer" data-testid="dropdown-footer">
v-if="isDropdownVariantSidebar || isDropdownVariantEmbedded"
class="dropdown-footer"
data-testid="dropdown-footer"
>
<ul class="list-unstyled"> <ul class="list-unstyled">
<li v-if="allowLabelCreate"> <li v-if="allowLabelCreate">
<gl-link <gl-link
...@@ -206,7 +208,7 @@ export default { ...@@ -206,7 +208,7 @@ export default {
{{ footerCreateLabelTitle }} {{ footerCreateLabelTitle }}
</gl-link> </gl-link>
</li> </li>
<li> <li v-if="labelsManagePath">
<gl-link <gl-link
:href="labelsManagePath" :href="labelsManagePath"
class="gl-display-flex flex-row text-break-word label-item" class="gl-display-flex flex-row text-break-word label-item"
......
---
name: jira_issue_details_edit_labels
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/65298
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/335069
milestone: '14.1'
type: development
group: group::ecosystem
default_enabled: false
...@@ -16,11 +16,22 @@ export const fetchIssueStatuses = () => { ...@@ -16,11 +16,22 @@ export const fetchIssueStatuses = () => {
}); });
}; };
export const updateIssue = (issue, { status }) => { export const updateIssue = (issue, { labels = [], status = undefined }) => {
// We are using mock call here which should become a backend call // We are using mock call here which should become a backend call
return new Promise((resolve) => { return new Promise((resolve) => {
setTimeout(() => { setTimeout(() => {
resolve({ ...issue, status }); const addedLabels = labels.filter((label) => label.set);
const removedLabelsIds = labels.filter((label) => !label.set).map((label) => label.id);
const finalLabels = [...issue.labels, ...addedLabels].filter(
(label) => !removedLabelsIds.includes(label.id),
);
resolve({
...issue,
...(status ? { status } : {}),
labels: finalLabels,
});
}, 1000); }, 1000);
}); });
}; };
...@@ -41,6 +41,7 @@ export default { ...@@ -41,6 +41,7 @@ export default {
return { return {
isLoading: true, isLoading: true,
isLoadingStatus: false, isLoadingStatus: false,
isUpdatingLabels: false,
isUpdatingStatus: false, isUpdatingStatus: false,
errorMessage: null, errorMessage: null,
issue: {}, issue: {},
...@@ -84,6 +85,23 @@ export default { ...@@ -84,6 +85,23 @@ export default {
return `jira_note_${id}`; return `jira_note_${id}`;
}, },
onIssueLabelsUpdated(labels) {
this.isUpdatingLabels = true;
updateIssue(this.issue, { labels })
.then((response) => {
this.issue.labels = response.labels;
})
.catch(() => {
createFlash({
message: s__(
'JiraService|Failed to update Jira issue labels. View the issue in Jira, or reload the page.',
),
});
})
.finally(() => {
this.isUpdatingLabels = false;
});
},
onIssueStatusFetch() { onIssueStatusFetch() {
this.isLoadingStatus = true; this.isLoadingStatus = true;
fetchIssueStatuses() fetchIssueStatuses()
...@@ -104,8 +122,8 @@ export default { ...@@ -104,8 +122,8 @@ export default {
onIssueStatusUpdated(status) { onIssueStatusUpdated(status) {
this.isUpdatingStatus = true; this.isUpdatingStatus = true;
updateIssue(this.issue, { status }) updateIssue(this.issue, { status })
.then(() => { .then((response) => {
this.issue = { ...this.issue, status }; this.issue.status = response.status;
}) })
.catch(() => { .catch(() => {
createFlash({ createFlash({
...@@ -161,8 +179,10 @@ export default { ...@@ -161,8 +179,10 @@ export default {
:sidebar-expanded="sidebarExpanded" :sidebar-expanded="sidebarExpanded"
:issue="issue" :issue="issue"
:is-loading-status="isLoadingStatus" :is-loading-status="isLoadingStatus"
:is-updating-labels="isUpdatingLabels"
:is-updating-status="isUpdatingStatus" :is-updating-status="isUpdatingStatus"
:statuses="statuses" :statuses="statuses"
@issue-labels-updated="onIssueLabelsUpdated"
@issue-status-fetch="onIssueStatusFetch" @issue-status-fetch="onIssueStatusFetch"
@issue-status-updated="onIssueStatusUpdated" @issue-status-updated="onIssueStatusUpdated"
/> />
......
...@@ -19,6 +19,9 @@ export default { ...@@ -19,6 +19,9 @@ export default {
}, },
mixins: [glFeatureFlagsMixin()], mixins: [glFeatureFlagsMixin()],
inject: { inject: {
issueLabelsPath: {
default: null,
},
issuesListPath: { issuesListPath: {
default: null, default: null,
}, },
...@@ -37,6 +40,11 @@ export default { ...@@ -37,6 +40,11 @@ export default {
required: false, required: false,
default: false, default: false,
}, },
isUpdatingLabels: {
type: Boolean,
required: false,
default: false,
},
isUpdatingStatus: { isUpdatingStatus: {
type: Boolean, type: Boolean,
required: false, required: false,
...@@ -48,6 +56,11 @@ export default { ...@@ -48,6 +56,11 @@ export default {
default: () => [], default: () => [],
}, },
}, },
data() {
return {
isEditingLabels: false,
};
},
computed: { computed: {
assignee() { assignee() {
// Jira issues have at most 1 assignee // Jira issues have at most 1 assignee
...@@ -56,6 +69,9 @@ export default { ...@@ -56,6 +69,9 @@ export default {
reference() { reference() {
return this.issue.references?.relative; return this.issue.references?.relative;
}, },
canUpdateLabels() {
return this.glFeatures.jiraIssueDetailsEditLabels;
},
canUpdateStatus() { canUpdateStatus() {
return this.glFeatures.jiraIssueDetailsEditStatus; return this.glFeatures.jiraIssueDetailsEditStatus;
}, },
...@@ -75,6 +91,10 @@ export default { ...@@ -75,6 +91,10 @@ export default {
toggleSidebar() { toggleSidebar() {
this.sidebarToggleEl.dispatchEvent(new Event('click')); this.sidebarToggleEl.dispatchEvent(new Event('click'));
}, },
afterSidebarTransitioned(callback) {
// Wait for sidebar expand animation to complete
this.sidebarEl.addEventListener('transitionend', callback, { once: true });
},
expandSidebarAndOpenDropdown(dropdownRef = null) { expandSidebarAndOpenDropdown(dropdownRef = null) {
// Expand the sidebar if not already expanded. // Expand the sidebar if not already expanded.
if (!this.sidebarExpanded) { if (!this.sidebarExpanded) {
...@@ -82,17 +102,23 @@ export default { ...@@ -82,17 +102,23 @@ export default {
} }
if (dropdownRef) { if (dropdownRef) {
// Wait for sidebar expand animation to complete this.afterSidebarTransitioned(() => {
// before revealing the dropdown. dropdownRef.expand();
this.sidebarEl.addEventListener( });
'transitionend',
() => {
dropdownRef.expand();
},
{ once: true },
);
} }
}, },
onIssueLabelsClose() {
this.isEditingLabels = false;
},
onIssueLabelsToggle() {
this.expandSidebarAndOpenDropdown();
this.afterSidebarTransitioned(() => {
this.isEditingLabels = true;
});
},
onIssueLabelsUpdated(labels) {
this.$emit('issue-labels-updated', labels);
},
onIssueStatusFetch() { onIssueStatusFetch() {
this.$emit('issue-status-fetch'); this.$emit('issue-status-fetch');
}, },
...@@ -122,11 +148,19 @@ export default { ...@@ -122,11 +148,19 @@ export default {
@issue-field-updated="onIssueStatusUpdated" @issue-field-updated="onIssueStatusUpdated"
/> />
<labels-select <labels-select
:allow-label-edit="canUpdateLabels"
:allow-multiselect="true"
:selected-labels="issue.labels" :selected-labels="issue.labels"
:labels-fetch-path="issueLabelsPath"
:labels-filter-base-path="issuesListPath" :labels-filter-base-path="issuesListPath"
:labels-filter-param="$options.labelsFilterParam" :labels-filter-param="$options.labelsFilterParam"
:labels-select-in-progress="isUpdatingLabels"
:is-editing="isEditingLabels"
variant="sidebar" variant="sidebar"
class="block labels js-labels-block" class="block labels js-labels-block"
@onDropdownClose="onIssueLabelsClose"
@toggleCollapse="onIssueLabelsToggle"
@updateSelectedLabels="onIssueLabelsUpdated"
> >
{{ __('None') }} {{ __('None') }}
</labels-select> </labels-select>
......
...@@ -9,11 +9,12 @@ export default function initJiraIssueShow({ mountPointSelector }) { ...@@ -9,11 +9,12 @@ export default function initJiraIssueShow({ mountPointSelector }) {
return null; return null;
} }
const { issuesShowPath, issuesListPath } = mountPointEl.dataset; const { issueLabelsPath, issuesShowPath, issuesListPath } = mountPointEl.dataset;
return new Vue({ return new Vue({
el: mountPointEl, el: mountPointEl,
provide: { provide: {
issueLabelsPath,
issuesShowPath, issuesShowPath,
issuesListPath, issuesListPath,
}, },
......
...@@ -15,6 +15,7 @@ module Projects ...@@ -15,6 +15,7 @@ module Projects
before_action :check_feature_enabled! before_action :check_feature_enabled!
before_action only: :show do before_action only: :show do
push_frontend_feature_flag(:jira_issue_details_edit_status, project, default_enabled: :yaml) push_frontend_feature_flag(:jira_issue_details_edit_status, project, default_enabled: :yaml)
push_frontend_feature_flag(:jira_issue_details_edit_labels, project, default_enabled: :yaml)
end end
rescue_from ::Projects::Integrations::Jira::IssuesFinder::IntegrationError, with: :render_integration_error rescue_from ::Projects::Integrations::Jira::IssuesFinder::IntegrationError, with: :render_integration_error
...@@ -44,6 +45,11 @@ module Projects ...@@ -44,6 +45,11 @@ module Projects
end end
end end
def labels
# This implementation is just to mock the endpoint, to be implemented https://gitlab.com/gitlab-org/gitlab/-/issues/330778
render json: issue_json[:labels]
end
private private
def visitor_id def visitor_id
......
...@@ -53,6 +53,7 @@ module EE ...@@ -53,6 +53,7 @@ module EE
def jira_issues_show_data def jira_issues_show_data
{ {
issue_labels_path: labels_project_integrations_jira_issue_path(@project, params[:id]),
issues_show_path: project_integrations_jira_issue_path(@project, params[:id], format: :json), issues_show_path: project_integrations_jira_issue_path(@project, params[:id], format: :json),
issues_list_path: project_integrations_jira_issues_path(@project) issues_list_path: project_integrations_jira_issues_path(@project)
} }
......
...@@ -32,6 +32,7 @@ module Integrations ...@@ -32,6 +32,7 @@ module Integrations
expose :labels do |jira_issue| expose :labels do |jira_issue|
jira_issue.labels.map do |name| jira_issue.labels.map do |name|
{ {
id: name,
title: name, title: name,
name: name, name: name,
color: '#0052CC', color: '#0052CC',
......
...@@ -116,7 +116,11 @@ constraints(::Constraints::ProjectUrlConstrainer.new) do ...@@ -116,7 +116,11 @@ constraints(::Constraints::ProjectUrlConstrainer.new) do
namespace :integrations do namespace :integrations do
namespace :jira do namespace :jira do
resources :issues, only: [:index, :show] resources :issues, only: [:index, :show] do
member do
get :labels
end
end
end end
end end
......
...@@ -120,6 +120,22 @@ describe('JiraIssuesShow', () => { ...@@ -120,6 +120,22 @@ describe('JiraIssuesShow', () => {
await waitForPromises(); await waitForPromises();
}); });
it('updates issue labels on issue-labels-updated', async () => {
const updateIssueSpy = jest.spyOn(JiraIssuesShowApi, 'updateIssue').mockResolvedValue();
const labels = [{ id: 'ecosystem' }];
findJiraIssueSidebar().vm.$emit('issue-labels-updated', labels);
await wrapper.vm.$nextTick();
expect(updateIssueSpy).toHaveBeenCalledWith(expect.any(Object), { labels });
expect(findJiraIssueSidebar().props('isUpdatingLabels')).toBe(true);
await waitForPromises();
expect(findJiraIssueSidebar().props('isUpdatingLabels')).toBe(false);
});
it('fetches issue statuses on issue-status-fetch', async () => { it('fetches issue statuses on issue-status-fetch', async () => {
const fetchIssueStatusesSpy = jest const fetchIssueStatusesSpy = jest
.spyOn(JiraIssuesShowApi, 'fetchIssueStatuses') .spyOn(JiraIssuesShowApi, 'fetchIssueStatuses')
......
...@@ -101,7 +101,11 @@ RSpec.describe EE::IntegrationsHelper do ...@@ -101,7 +101,11 @@ RSpec.describe EE::IntegrationsHelper do
end end
it 'includes Jira issues show data' do it 'includes Jira issues show data' do
is_expected.to include(:issues_show_path) is_expected.to include(
issue_labels_path: "/#{project.full_path}/-/integrations/jira/issues/FE-1/labels",
issues_show_path: "/#{project.full_path}/-/integrations/jira/issues/FE-1.json",
issues_list_path: "/#{project.full_path}/-/integrations/jira/issues"
)
end end
end end
......
...@@ -86,6 +86,7 @@ RSpec.describe Integrations::JiraSerializers::IssueDetailEntity do ...@@ -86,6 +86,7 @@ RSpec.describe Integrations::JiraSerializers::IssueDetailEntity do
state: 'closed', state: 'closed',
labels: [ labels: [
{ {
id: 'backend',
title: 'backend', title: 'backend',
name: 'backend', name: 'backend',
color: '#0052CC', color: '#0052CC',
......
...@@ -53,6 +53,7 @@ RSpec.describe Integrations::JiraSerializers::IssueEntity do ...@@ -53,6 +53,7 @@ RSpec.describe Integrations::JiraSerializers::IssueEntity do
status: 'To Do', status: 'To Do',
labels: [ labels: [
{ {
id: 'backend',
title: 'backend', title: 'backend',
name: 'backend', name: 'backend',
color: '#0052CC', color: '#0052CC',
......
...@@ -18513,6 +18513,9 @@ msgstr "" ...@@ -18513,6 +18513,9 @@ msgstr ""
msgid "JiraService|Failed to load Jira issue. View the issue in Jira, or reload the page." msgid "JiraService|Failed to load Jira issue. View the issue in Jira, or reload the page."
msgstr "" msgstr ""
msgid "JiraService|Failed to update Jira issue labels. 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." msgid "JiraService|Failed to update Jira issue status. View the issue in Jira, or reload the page."
msgstr "" msgstr ""
......
...@@ -54,7 +54,6 @@ describe('DropdownContentsLabelsView', () => { ...@@ -54,7 +54,6 @@ describe('DropdownContentsLabelsView', () => {
afterEach(() => { afterEach(() => {
wrapper.destroy(); wrapper.destroy();
wrapper = null;
}); });
const findDropdownContent = () => wrapper.find('[data-testid="dropdown-content"]'); const findDropdownContent = () => wrapper.find('[data-testid="dropdown-content"]');
...@@ -381,6 +380,15 @@ describe('DropdownContentsLabelsView', () => { ...@@ -381,6 +380,15 @@ describe('DropdownContentsLabelsView', () => {
expect(findDropdownFooter().exists()).toBe(false); expect(findDropdownFooter().exists()).toBe(false);
}); });
it('does not render footer list items when `allowLabelCreate` is false and `labelsManagePath` is null', () => {
createComponent({
...mockConfig,
allowLabelCreate: false,
labelsManagePath: null,
});
expect(findDropdownFooter().exists()).toBe(false);
});
it('renders footer list items when `state.variant` is "embedded"', () => { it('renders footer list items when `state.variant` is "embedded"', () => {
expect(findDropdownFooter().exists()).toBe(true); expect(findDropdownFooter().exists()).toBe(true);
}); });
......
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