Commit dd5a87ca authored by Florie Guibert's avatar Florie Guibert Committed by Natalia Tepluhina

Milestone widget for Issue and MR page sidebars [RUN AS-IF-FOSS]

parent c2c448c0
......@@ -29,6 +29,7 @@ export default {
issuableAttributesQueries,
i18n: {
[IssuableAttributeType.Milestone]: __('Milestone'),
expired: __('(expired)'),
none: __('None'),
},
directives: {
......@@ -74,9 +75,14 @@ export default {
type: String,
required: true,
validator(value) {
return value === IssuableType.Issue;
return [IssuableType.Issue, IssuableType.MergeRequest].includes(value);
},
},
icon: {
type: String,
required: false,
default: undefined,
},
},
apollo: {
currentAttribute: {
......@@ -172,6 +178,9 @@ export default {
attributeTypeTitle() {
return this.$options.i18n[this.issuableAttribute];
},
attributeTypeIcon() {
return this.icon || this.issuableAttribute;
},
i18n() {
return {
noAttribute: sprintf(s__('DropdownWidget|No %{issuableAttribute}'), {
......@@ -224,7 +233,8 @@ export default {
variables: {
fullPath: this.workspacePath,
attributeId:
this.issuableAttribute === IssuableAttributeType.Milestone
this.issuableAttribute === IssuableAttributeType.Milestone &&
this.issuableType === IssuableType.Issue
? getIdFromGraphQLId(attributeId)
: attributeId,
iid: this.iid,
......@@ -255,6 +265,11 @@ export default {
attributeId === this.currentAttribute?.id || (!this.currentAttribute?.id && !attributeId)
);
},
isAttributeOverdue(attribute) {
return this.issuableAttribute === IssuableAttributeType.Milestone
? attribute?.expired
: false;
},
showDropdown() {
this.$refs.newDropdown.show();
},
......@@ -284,8 +299,10 @@ export default {
>
<template #collapsed>
<div v-if="isClassicSidebar" v-gl-tooltip class="sidebar-collapsed-icon">
<gl-icon :size="16" :aria-label="attributeTypeTitle" :name="issuableAttribute" />
<span class="collapse-truncated-title">{{ attributeTitle }}</span>
<gl-icon :size="16" :aria-label="attributeTypeTitle" :name="attributeTypeIcon" />
<span class="collapse-truncated-title">
{{ attributeTitle }}
</span>
</div>
<div
:data-testid="`select-${issuableAttribute}`"
......@@ -308,6 +325,7 @@ export default {
:data-qa-selector="`${issuableAttribute}_link`"
>
{{ attributeTitle }}
<span v-if="isAttributeOverdue(currentAttribute)">{{ $options.i18n.expired }}</span>
</gl-link>
</slot>
</div>
......@@ -357,6 +375,7 @@ export default {
@click="updateAttribute(attrItem.id)"
>
{{ attrItem.title }}
<span v-if="isAttributeOverdue(attrItem)">{{ $options.i18n.expired }}</span>
</gl-dropdown-item>
</slot>
</template>
......
......@@ -12,6 +12,7 @@ import issueDueDateQuery from '~/sidebar/queries/issue_due_date.query.graphql';
import issueReferenceQuery from '~/sidebar/queries/issue_reference.query.graphql';
import issueSubscribedQuery from '~/sidebar/queries/issue_subscribed.query.graphql';
import issueTimeTrackingQuery from '~/sidebar/queries/issue_time_tracking.query.graphql';
import mergeRequestMilestone from '~/sidebar/queries/merge_request_milestone.query.graphql';
import mergeRequestReferenceQuery from '~/sidebar/queries/merge_request_reference.query.graphql';
import mergeRequestSubscribed from '~/sidebar/queries/merge_request_subscribed.query.graphql';
import mergeRequestTimeTrackingQuery from '~/sidebar/queries/merge_request_time_tracking.query.graphql';
......@@ -24,6 +25,7 @@ import updateEpicSubscriptionMutation from '~/sidebar/queries/update_epic_subscr
import updateIssueConfidentialMutation from '~/sidebar/queries/update_issue_confidential.mutation.graphql';
import updateIssueDueDateMutation from '~/sidebar/queries/update_issue_due_date.mutation.graphql';
import updateIssueSubscriptionMutation from '~/sidebar/queries/update_issue_subscription.mutation.graphql';
import mergeRequestMilestoneMutation from '~/sidebar/queries/update_merge_request_milestone.mutation.graphql';
import updateMergeRequestSubscriptionMutation from '~/sidebar/queries/update_merge_request_subscription.mutation.graphql';
import updateAlertAssigneesMutation from '~/vue_shared/alert_details/graphql/mutations/alert_set_assignees.mutation.graphql';
import getAlertAssignees from '~/vue_shared/components/sidebar/queries/get_alert_assignees.query.graphql';
......@@ -171,12 +173,19 @@ export const issuableMilestoneQueries = {
query: projectIssueMilestoneQuery,
mutation: projectIssueMilestoneMutation,
},
[IssuableType.MergeRequest]: {
query: mergeRequestMilestone,
mutation: mergeRequestMilestoneMutation,
},
};
export const milestonesQueries = {
[IssuableType.Issue]: {
query: projectMilestonesQuery,
},
[IssuableType.MergeRequest]: {
query: projectMilestonesQuery,
},
};
export const IssuableAttributeType = {
......
......@@ -18,6 +18,7 @@ import SidebarConfidentialityWidget from '~/sidebar/components/confidential/side
import SidebarDueDateWidget from '~/sidebar/components/date/sidebar_date_widget.vue';
import SidebarParticipantsWidget from '~/sidebar/components/participants/sidebar_participants_widget.vue';
import SidebarReferenceWidget from '~/sidebar/components/reference/sidebar_reference_widget.vue';
import SidebarDropdownWidget from '~/sidebar/components/sidebar_dropdown_widget.vue';
import { apolloProvider } from '~/sidebar/graphql';
import trackShowInviteMemberLink from '~/sidebar/track_invite_members';
import Translate from '../vue_shared/translate';
......@@ -29,6 +30,7 @@ import SidebarReviewers from './components/reviewers/sidebar_reviewers.vue';
import SidebarSeverity from './components/severity/sidebar_severity.vue';
import SidebarSubscriptionsWidget from './components/subscriptions/sidebar_subscriptions_widget.vue';
import SidebarTimeTracking from './components/time_tracking/sidebar_time_tracking.vue';
import { IssuableAttributeType } from './constants';
import SidebarMoveIssue from './lib/sidebar_move_issue';
Vue.use(Translate);
......@@ -154,7 +156,8 @@ function mountReviewersComponent(mediator) {
issuableIid: String(iid),
projectPath: fullPath,
field: el.dataset.field,
issuableType: isInIssuePage() || isInDesignPage() ? 'issue' : 'merge_request',
issuableType:
isInIssuePage() || isInDesignPage() ? IssuableType.Issue : IssuableType.MergeRequest,
},
}),
});
......@@ -166,6 +169,40 @@ function mountReviewersComponent(mediator) {
}
}
function mountMilestoneSelect() {
const el = document.querySelector('.js-milestone-select');
if (!el) {
return false;
}
const { canEdit, projectPath, issueIid } = el.dataset;
return new Vue({
el,
apolloProvider,
components: {
SidebarDropdownWidget,
},
provide: {
canUpdate: parseBoolean(canEdit),
isClassicSidebar: true,
},
render: (createElement) =>
createElement('sidebar-dropdown-widget', {
props: {
attrWorkspacePath: projectPath,
workspacePath: projectPath,
iid: issueIid,
issuableType:
isInIssuePage() || isInDesignPage() ? IssuableType.Issue : IssuableType.MergeRequest,
issuableAttribute: IssuableAttributeType.Milestone,
icon: 'clock',
},
}),
});
}
export function mountSidebarLabels() {
const el = document.querySelector('.js-sidebar-labels');
......@@ -466,6 +503,7 @@ export function mountSidebar(mediator) {
mountAssigneesComponentDeprecated(mediator);
}
mountReviewersComponent(mediator);
mountMilestoneSelect();
mountConfidentialComponent(mediator);
mountDueDateComponent(mediator);
mountReferenceComponent(mediator);
......
#import "./milestone.fragment.graphql"
query mergeRequestMilestone($fullPath: ID!, $iid: String!) {
workspace: project(fullPath: $fullPath) {
__typename
issuable: mergeRequest(iid: $iid) {
__typename
id
attribute: milestone {
...MilestoneFragment
}
}
}
}
......@@ -2,4 +2,5 @@ fragment MilestoneFragment on Milestone {
id
title
webUrl: webPath
expired
}
......@@ -11,6 +11,7 @@ mutation projectIssueMilestoneMutation($fullPath: ID!, $iid: String!, $attribute
title
id
state
expired
}
}
}
......
......@@ -3,7 +3,13 @@
query projectMilestones($fullPath: ID!, $title: String, $state: MilestoneStateEnum) {
workspace: project(fullPath: $fullPath) {
__typename
attributes: milestones(searchTitle: $title, state: $state) {
attributes: milestones(
searchTitle: $title
state: $state
sort: EXPIRED_LAST_DUE_DATE_ASC
first: 20
includeAncestors: true
) {
nodes {
...MilestoneFragment
state
......
mutation mergeRequestSetMilestone($fullPath: ID!, $iid: String!, $attributeId: ID) {
issuableSetAttribute: mergeRequestSetMilestone(
input: { projectPath: $fullPath, iid: $iid, milestoneId: $attributeId }
) {
__typename
errors
issuable: mergeRequest {
__typename
id
attribute: milestone {
title
id
state
}
}
}
}
......@@ -34,31 +34,8 @@
= render_if_exists 'shared/issuable/sidebar_item_epic', issuable_sidebar: issuable_sidebar, group_path: @project.group.full_path, project_path: issuable_sidebar[:project_full_path], issue_iid: issuable_sidebar[:iid], issuable_type: issuable_type
- if issuable_sidebar[:supports_milestone]
- milestone = issuable_sidebar[:milestone] || {}
.block.milestone{ :class => ("gl-border-b-0!" if issuable_sidebar[:supports_iterations]), data: { qa_selector: 'milestone_block' } }
.sidebar-collapsed-icon.has-tooltip{ title: sidebar_milestone_tooltip_label(milestone), data: { container: 'body', html: 'true', placement: 'left', boundary: 'viewport' } }
= sprite_icon('clock')
%span.milestone-title.collapse-truncated-title
- if milestone.present?
= milestone[:title]
- else
= _('None')
.hide-collapsed.gl-line-height-20.gl-mb-2.gl-text-gray-900{ data: { testid: "milestone_title" } }
= _('Milestone')
= loading_icon(css_class: 'gl-vertical-align-text-bottom hidden block-loading')
- if can_edit_issuable
= link_to _('Edit'), '#', class: 'js-sidebar-dropdown-toggle edit-link float-right', data: { qa_selector: "edit_milestone_link", track_label: "right_sidebar", track_property: "milestone", track_event: "click_edit_button", track_value: "" }
.value.hide-collapsed
- if milestone.present?
- milestone_title = milestone[:expired] ? _("%{milestone_name} (Past due)").html_safe % { milestone_name: milestone[:title] } : milestone[:title]
= link_to milestone_title, milestone[:web_url], class: "bold has-tooltip", title: sidebar_milestone_remaining_days(milestone), data: { container: "body", html: 'true', boundary: 'viewport', qa_selector: 'milestone_link', qa_title: milestone[:title] }
- else
%span.no-value
= _('None')
.selectbox.hide-collapsed
= f.hidden_field 'milestone_id', value: milestone[:id], id: nil
= dropdown_tag('Milestone', options: { title: _('Assign milestone'), toggle_class: 'js-milestone-select js-extra-options', filter: true, dropdown_class: 'dropdown-menu-selectable', placeholder: _('Search milestones'), data: { show_no: true, field_name: "#{issuable_type}[milestone_id]", project_id: issuable_sidebar[:project_id], issuable_id: issuable_sidebar[:id], ability_name: issuable_type, issue_update: issuable_sidebar[:issuable_json_path], use_id: true, default_no: true, selected: milestone[:title], null_default: true, display: 'static' }})
.js-milestone-select{ data: { can_edit: can_edit_issuable.to_s, project_path: issuable_sidebar[:project_full_path], issue_iid: issuable_sidebar[:iid] } }
- if @project.group.present? && issuable_sidebar[:supports_iterations]
.block{ class: 'gl-pt-0!', data: { qa_selector: 'iteration_container' } }
......
......@@ -708,9 +708,6 @@ msgstr ""
msgid "%{message} showing first %{warnings_displayed}"
msgstr ""
msgid "%{milestone_name} (Past due)"
msgstr ""
msgid "%{milestone} (expired)"
msgstr ""
......@@ -1097,6 +1094,9 @@ msgstr ""
msgid "(deleted)"
msgstr ""
msgid "(expired)"
msgstr ""
msgid "(leave blank if you don't want to change it)"
msgstr ""
......
......@@ -40,16 +40,22 @@ module QA
base.view 'app/views/shared/issuable/_sidebar.html.haml' do
element :assignee_block
element :edit_milestone_link
element :milestone_block
element :milestone_link
end
base.view 'app/assets/javascripts/sidebar/components/sidebar_dropdown_widget.vue' do
element :milestone_link, 'data-qa-selector="`${issuableAttribute}_link`"' # rubocop:disable QA/ElementWithPattern
end
base.view 'app/assets/javascripts/sidebar/components/sidebar_editable_item.vue' do
element :edit_link
end
end
def assign_milestone(milestone)
click_element(:edit_milestone_link)
within_element(:milestone_block) do
click_link("#{milestone.title}")
click_element(:edit_link)
click_on(milestone.title)
end
wait_until(reload: false) do
......@@ -89,7 +95,7 @@ module QA
def has_milestone?(milestone_title)
wait_milestone_block_finish_loading do
has_element?(:milestone_link, title: milestone_title)
has_element?(:milestone_link, text: milestone_title)
end
end
......
......@@ -259,37 +259,35 @@ RSpec.describe 'Issue Sidebar' do
end
context 'editing issue milestone', :js do
let_it_be(:milestone_expired) { create(:milestone, project: project, due_date: 5.days.ago) }
let_it_be(:milestone_expired) { create(:milestone, project: project, title: 'Foo - expired', due_date: 5.days.ago) }
let_it_be(:milestone_no_duedate) { create(:milestone, project: project, title: 'Foo - No due date') }
let_it_be(:milestone1) { create(:milestone, project: project, title: 'Milestone-1', due_date: 20.days.from_now) }
let_it_be(:milestone2) { create(:milestone, project: project, title: 'Milestone-2', due_date: 15.days.from_now) }
let_it_be(:milestone3) { create(:milestone, project: project, title: 'Milestone-3', due_date: 10.days.from_now) }
before do
page.within('[data-testid="milestone_title"]') do
click_on 'Edit'
page.within('.block.milestone') do
click_button 'Edit'
end
wait_for_all_requests
end
it 'shows milestons list in the dropdown' do
page.within('.block.milestone .dropdown-content') do
it 'shows milestones list in the dropdown' do
page.within('.block.milestone') do
# 5 milestones + "No milestone" = 6 items
expect(page.find('ul')).to have_selector('li[data-milestone-id]', count: 6)
expect(page.find('.gl-new-dropdown-contents')).to have_selector('li.gl-new-dropdown-item', count: 6)
end
end
it 'shows expired milestone at the bottom of the list' do
page.within('.block.milestone .dropdown-content ul') do
it 'shows expired milestone at the bottom of the list and milestone due earliest at the top of the list', :aggregate_failures do
page.within('.block.milestone .gl-new-dropdown-contents') do
expect(page.find('li:last-child')).to have_content milestone_expired.title
end
end
it 'shows milestone due earliest at the top of the list' do
page.within('.block.milestone .dropdown-content ul') do
expect(page.all('li[data-milestone-id]')[1]).to have_content milestone3.title
expect(page.all('li[data-milestone-id]')[2]).to have_content milestone2.title
expect(page.all('li[data-milestone-id]')[3]).to have_content milestone1.title
expect(page.all('li[data-milestone-id]')[4]).to have_content milestone_no_duedate.title
expect(page.all('li.gl-new-dropdown-item')[1]).to have_content milestone3.title
expect(page.all('li.gl-new-dropdown-item')[2]).to have_content milestone2.title
expect(page.all('li.gl-new-dropdown-item')[3]).to have_content milestone1.title
expect(page.all('li.gl-new-dropdown-item')[4]).to have_content milestone_no_duedate.title
end
end
end
......
......@@ -333,37 +333,40 @@ RSpec.describe "Issues > User edits issue", :js do
describe 'update milestone' do
context 'by authorized user' do
it 'allows user to select unassigned' do
it 'allows user to select no milestone' do
visit project_issue_path(project, issue)
wait_for_requests
page.within('.milestone') do
expect(page).to have_content "None"
end
page.within('.block.milestone') do
expect(page).to have_content 'None'
click_button 'Edit'
wait_for_requests
click_button 'No milestone'
wait_for_requests
find('.block.milestone .edit-link').click
sleep 2 # wait for ajax stuff to complete
first('.dropdown-content li').click
sleep 2
page.within('.milestone') do
expect(page).to have_content 'None'
end
end
it 'allows user to de-select milestone' do
visit project_issue_path(project, issue)
wait_for_requests
page.within('.milestone') do
click_link 'Edit'
click_link milestone.title
click_button 'Edit'
wait_for_requests
click_button milestone.title
page.within '.value' do
page.within '[data-testid="select-milestone"]' do
expect(page).to have_content milestone.title
end
click_link 'Edit'
click_link milestone.title
click_button 'Edit'
wait_for_requests
click_button 'No milestone'
page.within '.value' do
page.within '[data-testid="select-milestone"]' do
expect(page).to have_content 'None'
end
end
......@@ -371,16 +374,17 @@ RSpec.describe "Issues > User edits issue", :js do
it 'allows user to search milestone' do
visit project_issue_path(project_with_milestones, issue_with_milestones)
wait_for_requests
page.within('.milestone') do
click_link 'Edit'
click_button 'Edit'
wait_for_requests
# We need to enclose search string in quotes for exact match as all the milestone titles
# within tests are prefixed with `My title`.
find('.dropdown-input-field', visible: true).send_keys "\"#{milestones[0].title}\""
find('.gl-form-input', visible: true).send_keys "\"#{milestones[0].title}\""
wait_for_requests
page.within '.dropdown-content' do
page.within '.gl-new-dropdown-contents' do
expect(page).to have_content milestones[0].title
end
end
......
......@@ -530,6 +530,7 @@ export const mockMilestone1 = {
title: 'Foobar Milestone',
webUrl: 'http://gdk.test:3000/groups/gitlab-org/-/milestones/1',
state: 'active',
expired: false,
};
export const mockMilestone2 = {
......@@ -538,6 +539,7 @@ export const mockMilestone2 = {
title: 'Awesome Milestone',
webUrl: 'http://gdk.test:3000/groups/gitlab-org/-/milestones/2',
state: 'active',
expired: false,
};
export const mockProjectMilestonesResponse = {
......@@ -571,6 +573,7 @@ export const mockMilestoneMutationResponse = {
id: 'gid://gitlab/Milestone/2',
title: 'Awesome Milestone',
state: 'active',
expired: false,
__typename: 'Milestone',
},
__typename: 'Issue',
......
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