Commit 0e908820 authored by Phil Hughes's avatar Phil Hughes

Merge branch '196066-add-milestone-expired-info' into 'master'

Show expired milestones at the bottom of the list within dropdown

Closes #196066

See merge request gitlab-org/gitlab!35595
parents 4e2a38e8 337585de
......@@ -9,6 +9,7 @@ const Api = {
groupsPath: '/api/:version/groups.json',
groupPath: '/api/:version/groups/:id',
groupMembersPath: '/api/:version/groups/:id/members',
groupMilestonesPath: '/api/:version/groups/:id/milestones',
subgroupsPath: '/api/:version/groups/:id/subgroups',
namespacesPath: '/api/:version/namespaces.json',
groupProjectsPath: '/api/:version/groups/:id/projects.json',
......@@ -98,6 +99,14 @@ const Api = {
return axios.get(url).then(({ data }) => data);
},
groupMilestones(groupId, params = {}) {
const url = Api.buildUrl(Api.groupMilestonesPath).replace(':id', encodeURIComponent(groupId));
return axios.get(url, {
params,
});
},
// Return namespaces list. Filtered by query
namespaces(query, callback) {
const url = Api.buildUrl(Api.namespacesPath);
......@@ -262,10 +271,12 @@ const Api = {
});
},
projectMilestones(id) {
projectMilestones(id, params = {}) {
const url = Api.buildUrl(Api.projectMilestonesPath).replace(':id', encodeURIComponent(id));
return axios.get(url);
return axios.get(url, {
params,
});
},
mergeRequests(params = {}) {
......
......@@ -25,10 +25,6 @@ export default {
type: Boolean,
required: true,
},
milestonePath: {
type: String,
required: true,
},
labelsPath: {
type: String,
required: true,
......@@ -201,7 +197,6 @@ export default {
:collapse-scope="isNewForm"
:board="board"
:can-admin-board="canAdminBoard"
:milestone-path="milestonePath"
:labels-path="labelsPath"
:enable-scoped-labels="enableScopedLabels"
:project-id="projectId"
......
......@@ -36,10 +36,6 @@ export default {
type: Object,
required: true,
},
milestonePath: {
type: String,
required: true,
},
throttleDuration: {
type: Number,
default: 200,
......@@ -335,7 +331,6 @@ export default {
<board-form
v-if="currentPage"
:milestone-path="milestonePath"
:labels-path="labelsPath"
:project-id="projectId"
:group-id="groupId"
......
......@@ -17,10 +17,6 @@ export default {
type: Number,
required: true,
},
milestonePath: {
type: String,
required: true,
},
labelPath: {
type: String,
required: true,
......
......@@ -38,10 +38,6 @@ export default {
type: Number,
required: true,
},
milestonePath: {
type: String,
required: true,
},
labelPath: {
type: String,
required: true,
......@@ -149,11 +145,7 @@ export default {
class="add-issues-modal d-flex position-fixed position-top-0 position-bottom-0 position-left-0 position-right-0 h-100"
>
<div class="add-issues-container d-flex flex-column m-auto rounded">
<modal-header
:project-id="projectId"
:milestone-path="milestonePath"
:label-path="labelPath"
/>
<modal-header :project-id="projectId" :label-path="labelPath" />
<modal-list
v-if="!loading && showList && !filterLoading"
:issue-link-base="issueLinkBase"
......
......@@ -27,7 +27,7 @@ export default () => {
hasMissingBoards: parseBoolean(dataset.hasMissingBoards),
canAdminBoard: parseBoolean(dataset.canAdminBoard),
multipleIssueBoardsAvailable: parseBoolean(dataset.multipleIssueBoardsAvailable),
projectId: Number(dataset.projectId),
projectId: dataset.projectId ? Number(dataset.projectId) : 0,
groupId: Number(dataset.groupId),
scopedIssueBoardFeatureEnabled: parseBoolean(dataset.scopedIssueBoardFeatureEnabled),
weights: JSON.parse(dataset.weights),
......
......@@ -4,10 +4,11 @@
import $ from 'jquery';
import { template, escape } from 'lodash';
import { __ } from '~/locale';
import { __, sprintf } from '~/locale';
import '~/gl_dropdown';
import Api from '~/api';
import axios from './lib/utils/axios_utils';
import { timeFor } from './lib/utils/datetime_utility';
import { timeFor, parsePikadayDate, dateInWords } from './lib/utils/datetime_utility';
import ModalStore from './boards/stores/modal_store';
import boardsStore, {
boardStoreIssueSet,
......@@ -34,10 +35,10 @@ export default class MilestoneSelect {
$els.each((i, dropdown) => {
let milestoneLinkNoneTemplate,
milestoneLinkTemplate,
milestoneExpiredLinkTemplate,
selectedMilestone,
selectedMilestoneDefault;
const $dropdown = $(dropdown);
const milestonesUrl = $dropdown.data('milestones');
const issueUpdateURL = $dropdown.data('issueUpdate');
const showNo = $dropdown.data('showNo');
const showAny = $dropdown.data('showAny');
......@@ -63,58 +64,101 @@ export default class MilestoneSelect {
milestoneLinkTemplate = template(
'<a href="<%- web_url %>" class="bold has-tooltip" data-container="body" title="<%- remaining %>"><%- title %></a>',
);
milestoneExpiredLinkTemplate = template(
'<a href="<%- web_url %>" class="bold has-tooltip" data-container="body" title="<%- remaining %>"><%- title %> (Past due)</a>',
);
milestoneLinkNoneTemplate = `<span class="no-value">${__('None')}</span>`;
}
return $dropdown.glDropdown({
showMenuAbove,
data: (term, callback) =>
axios.get(milestonesUrl).then(({ data }) => {
const extraOptions = [];
if (showAny) {
extraOptions.push({
id: null,
name: null,
title: __('Any milestone'),
});
}
if (showNo) {
extraOptions.push({
id: -1,
name: __('No milestone'),
title: __('No milestone'),
});
}
if (showUpcoming) {
extraOptions.push({
id: -2,
name: '#upcoming',
title: __('Upcoming'),
});
}
if (showStarted) {
extraOptions.push({
id: -3,
name: '#started',
title: __('Started'),
});
}
if (extraOptions.length) {
extraOptions.push({ type: 'divider' });
}
data: (term, callback) => {
let contextId = $dropdown.get(0).dataset.projectId;
let getMilestones = Api.projectMilestones;
callback(extraOptions.concat(data));
if (showMenuAbove) {
$dropdown.data('glDropdown').positionMenuAbove();
}
$(`[data-milestone-id="${escape(selectedMilestone)}"] > a`).addClass('is-active');
}),
renderRow: milestone => `
<li data-milestone-id="${escape(milestone.name)}">
if (!contextId) {
contextId = $dropdown.get(0).dataset.groupId;
getMilestones = Api.groupMilestones;
}
// We don't use $.data() as it caches initial value and never updates!
return getMilestones(contextId, { state: 'active' })
.then(({ data }) =>
data
.map(m => ({
...m,
// Public API includes `title` instead of `name`.
name: m.title,
}))
.sort((mA, mB) => {
// Move all expired milestones to the bottom.
if (mA.expired) {
return 1;
}
if (mB.expired) {
return -1;
}
return 0;
}),
)
.then(data => {
const extraOptions = [];
if (showAny) {
extraOptions.push({
id: null,
name: null,
title: __('Any milestone'),
});
}
if (showNo) {
extraOptions.push({
id: -1,
name: __('No milestone'),
title: __('No milestone'),
});
}
if (showUpcoming) {
extraOptions.push({
id: -2,
name: '#upcoming',
title: __('Upcoming'),
});
}
if (showStarted) {
extraOptions.push({
id: -3,
name: '#started',
title: __('Started'),
});
}
if (extraOptions.length) {
extraOptions.push({ type: 'divider' });
}
callback(extraOptions.concat(data));
if (showMenuAbove) {
$dropdown.data('glDropdown').positionMenuAbove();
}
$(`[data-milestone-id="${selectedMilestone}"] > a`).addClass('is-active');
});
},
renderRow: milestone => {
const milestoneName = milestone.title || milestone.name;
let milestoneDisplayName = escape(milestoneName);
if (milestone.expired) {
milestoneDisplayName = sprintf(__('%{milestone} (expired)'), {
milestone: milestoneDisplayName,
});
}
return `
<li data-milestone-id="${escape(milestoneName)}">
<a href='#' class='dropdown-menu-milestone-link'>
${escape(milestone.title)}
${milestoneDisplayName}
</a>
</li>
`,
`;
},
filterable: true,
search: {
fields: ['title'],
......@@ -149,7 +193,7 @@ export default class MilestoneSelect {
selectedMilestone = $dropdown[0].dataset.selected || selectedMilestoneDefault;
}
$('a.is-active', $el).removeClass('is-active');
$(`[data-milestone-id="${escape(selectedMilestone)}"] > a`, $el).addClass('is-active');
$(`[data-milestone-id="${selectedMilestone}"] > a`, $el).addClass('is-active');
},
vue: $dropdown.hasClass('js-issue-board-sidebar'),
clicked: clickEvent => {
......@@ -237,7 +281,16 @@ export default class MilestoneSelect {
if (data.milestone != null) {
data.milestone.remaining = timeFor(data.milestone.due_date);
data.milestone.name = data.milestone.title;
$value.html(milestoneLinkTemplate(data.milestone));
$value.html(
data.milestone.expired
? milestoneExpiredLinkTemplate({
...data.milestone,
remaining: sprintf(__('%{due_date} (Past due)'), {
due_date: dateInWords(parsePikadayDate(data.milestone.due_date)),
}),
})
: milestoneLinkTemplate(data.milestone),
);
return $sidebarCollapsedValue
.attr(
'data-original-title',
......
......@@ -18,7 +18,8 @@
.dropdown
%button.dropdown-menu-toggle.js-milestone-select.js-issue-board-sidebar{ type: "button", data: { toggle: "dropdown", show_no: "true", field_name: "issue[milestone_id]", milestones: milestones_filter_path(format: :json), ability_name: "issue", use_id: "true", default_no: "true" },
":data-selected" => "milestoneTitle",
":data-issuable-id" => "issue.iid" }
":data-issuable-id" => "issue.iid",
":data-project-id" => "issue.project_id" }
= _("Milestone")
= icon("chevron-down")
.dropdown-menu.dropdown-select.dropdown-menu-selectable
......
......@@ -45,7 +45,8 @@
= 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?
= 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] }
- 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')
......
---
title: Show expired milestones at the bottom of the list within dropdown
merge_request: 35595
author:
type: changed
......@@ -54,6 +54,7 @@ Example Response:
"state": "active",
"updated_at": "2013-10-02T09:24:18Z",
"created_at": "2013-10-02T09:24:18Z",
"expired": false,
"web_url": "https://gitlab.com/groups/gitlab-org/-/milestones/42"
}
]
......
......@@ -51,7 +51,8 @@ Example Response:
"start_date": "2013-11-10",
"state": "active",
"updated_at": "2013-10-02T09:24:18Z",
"created_at": "2013-10-02T09:24:18Z"
"created_at": "2013-10-02T09:24:18Z",
"expired": false
}
]
```
......
......@@ -27,10 +27,6 @@ export default {
type: Object,
required: true,
},
milestonePath: {
type: String,
required: true,
},
labelsPath: {
type: String,
required: true,
......@@ -106,7 +102,8 @@ export default {
<div v-if="!collapseScope || expanded">
<board-milestone-select
:board="board"
:milestone-path="milestonePath"
:group-id="groupId"
:project-id="projectId"
:can-edit="canAdminBoard"
/>
......
......@@ -15,9 +15,15 @@ export default {
type: Object,
required: true,
},
milestonePath: {
type: String,
required: true,
groupId: {
type: Number,
required: false,
default: 0,
},
projectId: {
type: Number,
required: false,
default: 0,
},
canEdit: {
type: Boolean,
......@@ -84,7 +90,8 @@ export default {
<button
ref="dropdownButton"
:data-selected="selected"
:data-milestones="milestonePath"
:data-project-id="projectId"
:data-group-id="groupId"
:data-show-no="true"
:data-show-any="true"
:data-show-started="true"
......
{
"type": "object",
"properties" : {
"properties": {
"id": { "type": "integer" },
"iid": { "type": "integer" },
"project_id": { "type": ["integer", "null"] },
......@@ -12,6 +12,7 @@
"updated_at": { "type": "string" },
"start_date": { "type": ["date", "null"] },
"due_date": { "type": ["date", "null"] },
"expired": { "type": ["boolean", "null"] },
"web_url": { "type": "string" }
},
"additionalProperties": false
......
......@@ -14,7 +14,6 @@ describe('BoardScope', () => {
labels: [],
assignee: {},
},
milestonePath: `${TEST_HOST}/milestones`,
labelsPath: `${TEST_HOST}/labels`,
};
......
import Vue from 'vue';
import MockAdapater from 'axios-mock-adapter';
import Api from '~/api';
import MilestoneSelect from 'ee/boards/components/milestone_select.vue';
import { boardObj } from 'jest/boards/mock_data';
import axios from '~/lib/utils/axios_utils';
import IssuableContext from '~/issuable_context';
let vm;
......@@ -21,12 +20,16 @@ const milestone = {
id: 1,
title: 'first milestone',
name: 'first milestone',
due_date: '2015-05-05',
expired: true,
};
const milestone2 = {
id: 2,
title: 'second milestone',
name: 'second milestone',
due_date: null,
expired: false,
};
describe('Milestone select component', () => {
......@@ -40,7 +43,8 @@ describe('Milestone select component', () => {
vm = new Component({
propsData: {
board: boardObj,
milestonePath: '/test/issue-boards/milestones.json',
groupId: 2,
projectId: 2,
canEdit: true,
},
}).$mount('.test-container');
......@@ -92,15 +96,8 @@ describe('Milestone select component', () => {
});
describe('clicking dropdown items', () => {
let mock;
beforeEach(() => {
mock = new MockAdapater(axios);
mock.onGet('/test/issue-boards/milestones.json').reply(200, [milestone, milestone2]);
});
afterEach(() => {
mock.restore();
jest.spyOn(Api, 'projectMilestones').mockResolvedValue({ data: [milestone, milestone2] });
});
it('sets Any milestone', async done => {
......@@ -147,9 +144,10 @@ describe('Milestone select component', () => {
});
setImmediate(() => {
expect(activeDropdownItem(0)).toEqual('first milestone');
expect(selectedText()).toEqual('first milestone');
expect(vm.board.milestone).toEqual(milestone);
// "second milestone" is not expired, hence it shows up to the top.
expect(activeDropdownItem(0)).toBe('second milestone');
expect(selectedText()).toBe('second milestone');
expect(vm.board.milestone).toEqual(milestone2);
done();
});
});
......
......@@ -10,6 +10,7 @@ module API
expose :state, :created_at, :updated_at
expose :due_date
expose :start_date
expose :expired?, as: :expired
expose :web_url do |milestone, _options|
Gitlab::UrlBuilder.build(milestone)
......
......@@ -353,6 +353,9 @@ msgstr ""
msgid "%{description}- Sentry event: %{errorUrl}- First seen: %{firstSeen}- Last seen: %{lastSeen} %{countLabel}: %{count}%{userCountLabel}: %{userCount}"
msgstr ""
msgid "%{due_date} (Past due)"
msgstr ""
msgid "%{duration}ms"
msgstr ""
......@@ -479,6 +482,12 @@ msgstr ""
msgid "%{mergeLength}/%{usersLength} can merge"
msgstr ""
msgid "%{milestone_name} (Past due)"
msgstr ""
msgid "%{milestone} (expired)"
msgstr ""
msgid "%{mrText}, this issue will be closed automatically."
msgstr ""
......
......@@ -12,11 +12,13 @@
"updated_at": { "type": "date" },
"start_date": { "type": "date" },
"due_date": { "type": "date" },
"expired": { "type": ["boolean", "null"] },
"web_url": { "type": "string" }
},
"required": [
"id", "iid", "title", "description", "state",
"state", "created_at", "updated_at", "start_date", "due_date"
"state", "created_at", "updated_at", "start_date",
"due_date", "expired"
],
"additionalProperties": false
}
......@@ -12,6 +12,7 @@
"updated_at": { "type": "date" },
"start_date": { "type": "date" },
"due_date": { "type": "date" },
"expired": { "type": ["boolean", "null"] },
"web_url": { "type": "string" },
"issue_stats": {
"required": ["total", "closed"],
......@@ -24,7 +25,8 @@
},
"required": [
"id", "iid", "title", "description", "state",
"state", "created_at", "updated_at", "start_date", "due_date", "issue_stats"
"state", "created_at", "updated_at", "start_date",
"due_date", "expired", "issue_stats"
],
"additionalProperties": false
}
......@@ -96,6 +96,29 @@ describe('Api', () => {
});
});
describe('groupMilestones', () => {
it('fetches group milestones', done => {
const groupId = 1;
const options = { state: 'active' };
const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/groups/1/milestones`;
mock.onGet(expectedUrl).reply(200, [
{
id: 1,
title: 'milestone1',
state: 'active',
},
]);
Api.groupMilestones(groupId, options)
.then(({ data }) => {
expect(data.length).toBe(1);
expect(data[0].title).toBe('milestone1');
})
.then(done)
.catch(done.fail);
});
});
describe('namespaces', () => {
it('fetches namespaces', done => {
const query = 'dummy query';
......@@ -296,6 +319,29 @@ describe('Api', () => {
});
});
describe('projectMilestones', () => {
it('fetches project milestones', done => {
const projectId = 1;
const options = { state: 'active' };
const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/1/milestones`;
mock.onGet(expectedUrl).reply(200, [
{
id: 1,
title: 'milestone1',
state: 'active',
},
]);
Api.projectMilestones(projectId, options)
.then(({ data }) => {
expect(data.length).toBe(1);
expect(data[0].title).toBe('milestone1');
})
.then(done)
.catch(done.fail);
});
});
describe('newLabel', () => {
it('creates a new label', done => {
const namespace = 'some namespace';
......
......@@ -10,7 +10,6 @@ describe('board_form.vue', () => {
const propsData = {
canAdminBoard: false,
labelsPath: `${gl.TEST_HOST}/labels/path`,
milestonePath: `${gl.TEST_HOST}/milestone/path`,
};
const findModal = () => wrapper.find(DeprecatedModal);
......
......@@ -81,7 +81,6 @@ describe('BoardsSelector', () => {
assignee_id: null,
labels: [],
},
milestonePath: `${TEST_HOST}/milestone/path`,
boardBaseUrl: `${TEST_HOST}/board/base/url`,
hasMissingBoards: false,
canAdminBoard: 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