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 {
}
return this.labels;
},
showDropdownFooter() {
return (
(this.isDropdownVariantSidebar || this.isDropdownVariantEmbedded) &&
(this.allowLabelCreate || this.labelsManagePath)
);
},
showNoMatchingResultsMessage() {
return Boolean(this.searchKey) && this.visibleLabels.length === 0;
},
......@@ -192,11 +198,7 @@ export default {
</li>
</ul>
</div>
<div
v-if="isDropdownVariantSidebar || isDropdownVariantEmbedded"
class="dropdown-footer"
data-testid="dropdown-footer"
>
<div v-if="showDropdownFooter" class="dropdown-footer" data-testid="dropdown-footer">
<ul class="list-unstyled">
<li v-if="allowLabelCreate">
<gl-link
......@@ -206,7 +208,7 @@ export default {
{{ footerCreateLabelTitle }}
</gl-link>
</li>
<li>
<li v-if="labelsManagePath">
<gl-link
:href="labelsManagePath"
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 = () => {
});
};
export const updateIssue = (issue, { status }) => {
export const updateIssue = (issue, { labels = [], status = undefined }) => {
// We are using mock call here which should become a backend call
return new Promise((resolve) => {
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);
});
};
......@@ -41,6 +41,7 @@ export default {
return {
isLoading: true,
isLoadingStatus: false,
isUpdatingLabels: false,
isUpdatingStatus: false,
errorMessage: null,
issue: {},
......@@ -84,6 +85,23 @@ export default {
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() {
this.isLoadingStatus = true;
fetchIssueStatuses()
......@@ -104,8 +122,8 @@ export default {
onIssueStatusUpdated(status) {
this.isUpdatingStatus = true;
updateIssue(this.issue, { status })
.then(() => {
this.issue = { ...this.issue, status };
.then((response) => {
this.issue.status = response.status;
})
.catch(() => {
createFlash({
......@@ -161,8 +179,10 @@ export default {
:sidebar-expanded="sidebarExpanded"
:issue="issue"
:is-loading-status="isLoadingStatus"
:is-updating-labels="isUpdatingLabels"
:is-updating-status="isUpdatingStatus"
:statuses="statuses"
@issue-labels-updated="onIssueLabelsUpdated"
@issue-status-fetch="onIssueStatusFetch"
@issue-status-updated="onIssueStatusUpdated"
/>
......
......@@ -19,6 +19,9 @@ export default {
},
mixins: [glFeatureFlagsMixin()],
inject: {
issueLabelsPath: {
default: null,
},
issuesListPath: {
default: null,
},
......@@ -37,6 +40,11 @@ export default {
required: false,
default: false,
},
isUpdatingLabels: {
type: Boolean,
required: false,
default: false,
},
isUpdatingStatus: {
type: Boolean,
required: false,
......@@ -48,6 +56,11 @@ export default {
default: () => [],
},
},
data() {
return {
isEditingLabels: false,
};
},
computed: {
assignee() {
// Jira issues have at most 1 assignee
......@@ -56,6 +69,9 @@ export default {
reference() {
return this.issue.references?.relative;
},
canUpdateLabels() {
return this.glFeatures.jiraIssueDetailsEditLabels;
},
canUpdateStatus() {
return this.glFeatures.jiraIssueDetailsEditStatus;
},
......@@ -75,6 +91,10 @@ export default {
toggleSidebar() {
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) {
// Expand the sidebar if not already expanded.
if (!this.sidebarExpanded) {
......@@ -82,17 +102,23 @@ export default {
}
if (dropdownRef) {
// Wait for sidebar expand animation to complete
// before revealing the dropdown.
this.sidebarEl.addEventListener(
'transitionend',
() => {
dropdownRef.expand();
},
{ once: true },
);
this.afterSidebarTransitioned(() => {
dropdownRef.expand();
});
}
},
onIssueLabelsClose() {
this.isEditingLabels = false;
},
onIssueLabelsToggle() {
this.expandSidebarAndOpenDropdown();
this.afterSidebarTransitioned(() => {
this.isEditingLabels = true;
});
},
onIssueLabelsUpdated(labels) {
this.$emit('issue-labels-updated', labels);
},
onIssueStatusFetch() {
this.$emit('issue-status-fetch');
},
......@@ -122,11 +148,19 @@ export default {
@issue-field-updated="onIssueStatusUpdated"
/>
<labels-select
:allow-label-edit="canUpdateLabels"
:allow-multiselect="true"
:selected-labels="issue.labels"
:labels-fetch-path="issueLabelsPath"
:labels-filter-base-path="issuesListPath"
:labels-filter-param="$options.labelsFilterParam"
:labels-select-in-progress="isUpdatingLabels"
:is-editing="isEditingLabels"
variant="sidebar"
class="block labels js-labels-block"
@onDropdownClose="onIssueLabelsClose"
@toggleCollapse="onIssueLabelsToggle"
@updateSelectedLabels="onIssueLabelsUpdated"
>
{{ __('None') }}
</labels-select>
......
......@@ -9,11 +9,12 @@ export default function initJiraIssueShow({ mountPointSelector }) {
return null;
}
const { issuesShowPath, issuesListPath } = mountPointEl.dataset;
const { issueLabelsPath, issuesShowPath, issuesListPath } = mountPointEl.dataset;
return new Vue({
el: mountPointEl,
provide: {
issueLabelsPath,
issuesShowPath,
issuesListPath,
},
......
......@@ -15,6 +15,7 @@ module Projects
before_action :check_feature_enabled!
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_labels, project, default_enabled: :yaml)
end
rescue_from ::Projects::Integrations::Jira::IssuesFinder::IntegrationError, with: :render_integration_error
......@@ -44,6 +45,11 @@ module Projects
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
def visitor_id
......
......@@ -53,6 +53,7 @@ module EE
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_list_path: project_integrations_jira_issues_path(@project)
}
......
......@@ -32,6 +32,7 @@ module Integrations
expose :labels do |jira_issue|
jira_issue.labels.map do |name|
{
id: name,
title: name,
name: name,
color: '#0052CC',
......
......@@ -116,7 +116,11 @@ constraints(::Constraints::ProjectUrlConstrainer.new) do
namespace :integrations do
namespace :jira do
resources :issues, only: [:index, :show]
resources :issues, only: [:index, :show] do
member do
get :labels
end
end
end
end
......
......@@ -120,6 +120,22 @@ describe('JiraIssuesShow', () => {
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 () => {
const fetchIssueStatusesSpy = jest
.spyOn(JiraIssuesShowApi, 'fetchIssueStatuses')
......
......@@ -101,7 +101,11 @@ RSpec.describe EE::IntegrationsHelper do
end
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
......
......@@ -86,6 +86,7 @@ RSpec.describe Integrations::JiraSerializers::IssueDetailEntity do
state: 'closed',
labels: [
{
id: 'backend',
title: 'backend',
name: 'backend',
color: '#0052CC',
......
......@@ -53,6 +53,7 @@ RSpec.describe Integrations::JiraSerializers::IssueEntity do
status: 'To Do',
labels: [
{
id: 'backend',
title: 'backend',
name: 'backend',
color: '#0052CC',
......
......@@ -18513,6 +18513,9 @@ 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 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."
msgstr ""
......
......@@ -54,7 +54,6 @@ describe('DropdownContentsLabelsView', () => {
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
const findDropdownContent = () => wrapper.find('[data-testid="dropdown-content"]');
......@@ -381,6 +380,15 @@ describe('DropdownContentsLabelsView', () => {
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"', () => {
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