Commit 337585de authored by Kushal Pandya's avatar Kushal Pandya

Show expired milestone with different text color

Shows expired milestones within the dropdown with different
text color and "(expired)" string suffixed.
parent 37d86926
......@@ -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)
......
......@@ -356,6 +356,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 ""
......@@ -482,6 +485,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