Commit 4646d453 authored by Sean McGivern's avatar Sean McGivern

Merge branch 'milestone_start_date' into 'master'

Add a starting date to milestones

Closes https://gitlab.com/gitlab-org/gitlab-ce/issues/23704

See merge request !7484
parents 1a45de3d 3789cfe0
...@@ -145,25 +145,19 @@ ...@@ -145,25 +145,19 @@
class DueDateSelectors { class DueDateSelectors {
constructor() { constructor() {
this.initMilestoneDueDate(); this.initMilestoneDatePicker();
this.initIssuableSelect(); this.initIssuableSelect();
} }
initMilestoneDueDate() { initMilestoneDatePicker() {
const $datePicker = $('.datepicker'); $('.datepicker').datepicker({
dateFormat: 'yy-mm-dd'
});
if ($datePicker.length) { $('.js-clear-due-date,.js-clear-start-date').on('click', (e) => {
const $dueDate = $('#milestone_due_date');
$datePicker.datepicker({
dateFormat: 'yy-mm-dd',
onSelect: (dateText, inst) => {
$dueDate.val(dateText);
}
}).datepicker('setDate', $.datepicker.parseDate('yy-mm-dd', $dueDate.val()));
}
$('.js-clear-due-date').on('click', (e) => {
e.preventDefault(); e.preventDefault();
$.datepicker._clearDate($datePicker); const datepicker = $(e.target).siblings('.datepicker');
$.datepicker._clearDate(datepicker);
}); });
} }
......
...@@ -39,4 +39,8 @@ ...@@ -39,4 +39,8 @@
&.status-box-expired { &.status-box-expired {
background: #cea61b; background: #cea61b;
} }
&.status-box-upcoming {
background: #8f8f8f;
}
} }
...@@ -67,7 +67,7 @@ class Groups::MilestonesController < Groups::ApplicationController ...@@ -67,7 +67,7 @@ class Groups::MilestonesController < Groups::ApplicationController
end end
def milestone_params def milestone_params
params.require(:milestone).permit(:title, :description, :due_date, :state_event) params.require(:milestone).permit(:title, :description, :start_date, :due_date, :state_event)
end end
def milestone_path(title) def milestone_path(title)
......
...@@ -112,6 +112,6 @@ class Projects::MilestonesController < Projects::ApplicationController ...@@ -112,6 +112,6 @@ class Projects::MilestonesController < Projects::ApplicationController
end end
def milestone_params def milestone_params
params.require(:milestone).permit(:title, :description, :due_date, :state_event) params.require(:milestone).permit(:title, :description, :start_date, :due_date, :state_event)
end end
end end
...@@ -64,6 +64,8 @@ module IssuesHelper ...@@ -64,6 +64,8 @@ module IssuesHelper
'status-box-merged' 'status-box-merged'
elsif item.closed? elsif item.closed?
'status-box-closed' 'status-box-closed'
elsif item.respond_to?(:upcoming?) && item.upcoming?
'status-box-upcoming'
else else
'status-box-open' 'status-box-open'
end end
......
...@@ -86,6 +86,30 @@ module MilestonesHelper ...@@ -86,6 +86,30 @@ module MilestonesHelper
days = milestone.remaining_days days = milestone.remaining_days
content = content_tag(:strong, days) content = content_tag(:strong, days)
content << " #{'day'.pluralize(days)} remaining" content << " #{'day'.pluralize(days)} remaining"
elsif milestone.upcoming?
content_tag(:strong, 'Upcoming')
elsif milestone.start_date && milestone.start_date.past?
days = milestone.elapsed_days
content = content_tag(:strong, days)
content << " #{'day'.pluralize(days)} elapsed"
end
end
def milestone_date_range(milestone)
if milestone.start_date && milestone.due_date
"#{milestone.start_date.to_s(:medium)} - #{milestone.due_date.to_s(:medium)}"
elsif milestone.due_date
if milestone.due_date.past?
"expired on #{milestone.due_date.to_s(:medium)}"
else
"expires on #{milestone.due_date.to_s(:medium)}"
end
elsif milestone.start_date
if milestone.start_date.past?
"started on #{milestone.start_date.to_s(:medium)}"
else
"starts on #{milestone.start_date.to_s(:medium)}"
end
end end
end end
end end
...@@ -23,7 +23,31 @@ module Milestoneish ...@@ -23,7 +23,31 @@ module Milestoneish
(due_date - Date.today).to_i (due_date - Date.today).to_i
end end
def elapsed_days
return 0 if !start_date || start_date.future?
(Date.today - start_date).to_i
end
def issues_visible_to_user(user = nil) def issues_visible_to_user(user = nil)
issues.visible_to_user(user) issues.visible_to_user(user)
end end
def upcoming?
start_date && start_date.future?
end
def expires_at
if due_date
if due_date.past?
"expired on #{due_date.to_s(:medium)}"
else
"expires on #{due_date.to_s(:medium)}"
end
end
end
def expired?
due_date && due_date.past?
end
end end
...@@ -28,26 +28,16 @@ class GlobalMilestone ...@@ -28,26 +28,16 @@ class GlobalMilestone
@title.to_slug.normalize.to_s @title.to_slug.normalize.to_s
end end
def expired?
if due_date
due_date.past?
else
false
end
end
def projects def projects
@projects ||= Project.for_milestones(milestones.select(:id)) @projects ||= Project.for_milestones(milestones.select(:id))
end end
def state def state
state = milestones.map { |milestone| milestone.state } milestones.each do |milestone|
return 'active' if milestone.state != 'closed'
if state.count('closed') == state.size
'closed'
else
'active'
end end
'closed'
end end
def active? def active?
...@@ -81,18 +71,15 @@ class GlobalMilestone ...@@ -81,18 +71,15 @@ class GlobalMilestone
@due_date = @due_date =
if @milestones.all? { |x| x.due_date == @milestones.first.due_date } if @milestones.all? { |x| x.due_date == @milestones.first.due_date }
@milestones.first.due_date @milestones.first.due_date
else
nil
end end
end end
def expires_at def start_date
if due_date return @start_date if defined?(@start_date)
if due_date.past?
"expired on #{due_date.to_s(:medium)}" @start_date =
else if @milestones.all? { |x| x.start_date == @milestones.first.start_date }
"expires on #{due_date.to_s(:medium)}" @milestones.first.start_date
end end
end
end end
end end
...@@ -29,6 +29,7 @@ class Milestone < ActiveRecord::Base ...@@ -29,6 +29,7 @@ class Milestone < ActiveRecord::Base
validates :title, presence: true, uniqueness: { scope: :project_id } validates :title, presence: true, uniqueness: { scope: :project_id }
validates :project, presence: true validates :project, presence: true
validate :start_date_should_be_less_than_due_date, if: Proc.new { |m| m.start_date.present? && m.due_date.present? }
strip_attributes :title strip_attributes :title
...@@ -131,24 +132,6 @@ class Milestone < ActiveRecord::Base ...@@ -131,24 +132,6 @@ class Milestone < ActiveRecord::Base
self.title self.title
end end
def expired?
if due_date
due_date.past?
else
false
end
end
def expires_at
if due_date
if due_date.past?
"expired on #{due_date.to_s(:medium)}"
else
"expires on #{due_date.to_s(:medium)}"
end
end
end
def can_be_closed? def can_be_closed?
active? && issues.opened.count.zero? active? && issues.opened.count.zero?
end end
...@@ -212,4 +195,10 @@ class Milestone < ActiveRecord::Base ...@@ -212,4 +195,10 @@ class Milestone < ActiveRecord::Base
def sanitize_title(value) def sanitize_title(value)
CGI.unescape_html(Sanitize.clean(value.to_s)) CGI.unescape_html(Sanitize.clean(value.to_s))
end end
def start_date_should_be_less_than_due_date
if due_date <= start_date
errors.add(:start_date, "Can't be greater than due date")
end
end
end end
...@@ -36,19 +36,8 @@ ...@@ -36,19 +36,8 @@
= f.collection_select :project_ids, @group.projects.non_archived, :id, :name, = f.collection_select :project_ids, @group.projects.non_archived, :id, :name,
{ selected: @group.projects.non_archived.pluck(:id) }, required: true, multiple: true, class: 'select2' { selected: @group.projects.non_archived.pluck(:id) }, required: true, multiple: true, class: 'select2'
.col-md-6 = render "shared/milestones/form_dates", f: f
.form-group
= f.label :due_date, "Due Date", class: "control-label"
.col-sm-10
= f.text_field :due_date, class: "datepicker form-control", placeholder: "Select due date"
.form-actions .form-actions
= f.submit 'Create Milestone', class: "btn-create btn" = f.submit 'Create Milestone', class: "btn-create btn"
= link_to "Cancel", group_milestones_path(@group), class: "btn btn-cancel" = link_to "Cancel", group_milestones_path(@group), class: "btn btn-cancel"
:javascript
$(".datepicker").datepicker({
dateFormat: "yy-mm-dd",
onSelect: function(dateText, inst) { $("#milestone_due_date").val(dateText) }
}).datepicker("setDate", $.datepicker.parseDate('yy-mm-dd', $('#milestone_due_date').val()));
...@@ -14,12 +14,7 @@ ...@@ -14,12 +14,7 @@
= render 'projects/notes/hints' = render 'projects/notes/hints'
.clearfix .clearfix
.error-alert .error-alert
.col-md-6 = render "shared/milestones/form_dates", f: f
.form-group
= f.label :due_date, "Due Date", class: "control-label"
.col-sm-10
= f.text_field :due_date, class: "datepicker form-control", placeholder: "Select due date"
%a.inline.prepend-top-5.js-clear-due-date{ href: "#" } Clear due date
.form-actions .form-actions
- if @milestone.new_record? - if @milestone.new_record?
......
...@@ -10,15 +10,17 @@ ...@@ -10,15 +10,17 @@
Closed Closed
- elsif @milestone.expired? - elsif @milestone.expired?
Past due Past due
- elsif @milestone.upcoming?
Upcoming
- else - else
Open Open
.header-text-content .header-text-content
%span.identifier %span.identifier
Milestone ##{@milestone.iid} Milestone ##{@milestone.iid}
- if @milestone.expires_at - if @milestone.due_date || @milestone.start_date
%span.creator %span.creator
&middot; &middot;
= @milestone.expires_at = milestone_date_range(@milestone)
.milestone-buttons .milestone-buttons
- if can?(current_user, :admin_milestone, @project) - if can?(current_user, :admin_milestone, @project)
- if @milestone.active? - if @milestone.active?
......
- if milestone.expired? and not milestone.closed? - if milestone.expired? and not milestone.closed?
%span.cred (Expired) %span.cred (Expired)
- if milestone.expires_at - if milestone.upcoming?
%span.clgray (Upcoming)
- if milestone.due_date || milestone.start_date
%span %span
= milestone.expires_at = milestone_date_range(milestone)
.col-md-6
.form-group
= f.label :start_date, "Start Date", class: "control-label"
.col-sm-10
= f.text_field :start_date, class: "datepicker form-control", placeholder: "Select start date"
%a.inline.prepend-top-5.js-clear-start-date{ href: "#" } Clear start date
.col-md-6
.form-group
= f.label :due_date, "Due Date", class: "control-label"
.col-sm-10
= f.text_field :due_date, class: "datepicker form-control", placeholder: "Select due date"
%a.inline.prepend-top-5.js-clear-due-date{ href: "#" } Clear due date
:javascript
new gl.DueDateSelectors();
...@@ -12,10 +12,10 @@ ...@@ -12,10 +12,10 @@
Open Open
%span.identifier %span.identifier
Milestone #{milestone.title} Milestone #{milestone.title}
- if milestone.expires_at - if milestone.due_date || milestone.start_date
%span.creator %span.creator
&middot; &middot;
= milestone.expires_at = milestone_date_range(milestone)
- if group - if group
.pull-right .pull-right
- if can?(current_user, :admin_milestones, group) - if can?(current_user, :admin_milestones, group)
......
---
title: Add a starting date to milestones
merge_request:
author:
# See http://doc.gitlab.com/ce/development/migration_style_guide.html
# for more information on how to write migrations for GitLab.
class AddStartDateToMilestones < ActiveRecord::Migration
include Gitlab::Database::MigrationHelpers
DOWNTIME = false
def change
add_column :milestones, :start_date, :date
end
end
...@@ -720,6 +720,7 @@ ActiveRecord::Schema.define(version: 20161118183841) do ...@@ -720,6 +720,7 @@ ActiveRecord::Schema.define(version: 20161118183841) do
t.integer "iid" t.integer "iid"
t.text "title_html" t.text "title_html"
t.text "description_html" t.text "description_html"
t.date "start_date"
end end
add_index "milestones", ["description"], name: "index_milestones_on_description_trigram", using: :gin, opclasses: {"description"=>"gin_trgm_ops"} add_index "milestones", ["description"], name: "index_milestones_on_description_trigram", using: :gin, opclasses: {"description"=>"gin_trgm_ops"}
......
...@@ -35,6 +35,7 @@ Example Response: ...@@ -35,6 +35,7 @@ Example Response:
"title": "10.0", "title": "10.0",
"description": "Version", "description": "Version",
"due_date": "2013-11-29", "due_date": "2013-11-29",
"start_date": "2013-11-10",
"state": "active", "state": "active",
"updated_at": "2013-10-02T09:24:18Z", "updated_at": "2013-10-02T09:24:18Z",
"created_at": "2013-10-02T09:24:18Z" "created_at": "2013-10-02T09:24:18Z"
...@@ -70,6 +71,7 @@ Parameters: ...@@ -70,6 +71,7 @@ Parameters:
- `title` (required) - The title of an milestone - `title` (required) - The title of an milestone
- `description` (optional) - The description of the milestone - `description` (optional) - The description of the milestone
- `due_date` (optional) - The due date of the milestone - `due_date` (optional) - The due date of the milestone
- `start_date` (optional) - The start date of the milestone
## Edit milestone ## Edit milestone
...@@ -86,6 +88,7 @@ Parameters: ...@@ -86,6 +88,7 @@ Parameters:
- `title` (optional) - The title of a milestone - `title` (optional) - The title of a milestone
- `description` (optional) - The description of a milestone - `description` (optional) - The description of a milestone
- `due_date` (optional) - The due date of the milestone - `due_date` (optional) - The due date of the milestone
- `start_date` (optional) - The start date of the milestone
- `state_event` (optional) - The state event of the milestone (close|activate) - `state_event` (optional) - The state event of the milestone (close|activate)
## Get all issues assigned to a single milestone ## Get all issues assigned to a single milestone
......
...@@ -210,6 +210,7 @@ module API ...@@ -210,6 +210,7 @@ module API
class Milestone < ProjectEntity class Milestone < ProjectEntity
expose :due_date expose :due_date
expose :start_date
end end
class Issue < ProjectEntity class Issue < ProjectEntity
......
...@@ -14,7 +14,8 @@ module API ...@@ -14,7 +14,8 @@ module API
params :optional_params do params :optional_params do
optional :description, type: String, desc: 'The description of the milestone' optional :description, type: String, desc: 'The description of the milestone'
optional :due_date, type: String, desc: 'The due date of the milestone' optional :due_date, type: String, desc: 'The due date of the milestone. The ISO 8601 date format (%Y-%m-%d)'
optional :start_date, type: String, desc: 'The start date of the milestone. The ISO 8601 date format (%Y-%m-%d)'
end end
end end
......
...@@ -14,12 +14,17 @@ feature 'Milestone', feature: true do ...@@ -14,12 +14,17 @@ feature 'Milestone', feature: true do
feature 'Create a milestone' do feature 'Create a milestone' do
scenario 'shows an informative message for a new milestone' do scenario 'shows an informative message for a new milestone' do
visit new_namespace_project_milestone_path(project.namespace, project) visit new_namespace_project_milestone_path(project.namespace, project)
page.within '.milestone-form' do page.within '.milestone-form' do
fill_in "milestone_title", with: '8.7' fill_in "milestone_title", with: '8.7'
fill_in "milestone_start_date", with: '2016-11-16'
fill_in "milestone_due_date", with: '2016-12-16'
end end
find('input[name="commit"]').click find('input[name="commit"]').click
expect(find('.alert-success')).to have_content('Assign some issues to this milestone.') expect(find('.alert-success')).to have_content('Assign some issues to this milestone.')
expect(page).to have_content('Nov 16, 2016 - Dec 16, 2016')
end end
end end
......
require 'spec_helper' require 'spec_helper'
describe MilestonesHelper do describe MilestonesHelper do
describe "#milestone_date_range" do
def result_for(*args)
milestone_date_range(build(:milestone, *args))
end
let(:yesterday) { Date.yesterday }
let(:tomorrow) { yesterday + 2 }
let(:format) { '%b %-d, %Y' }
let(:yesterday_formatted) { yesterday.strftime(format) }
let(:tomorrow_formatted) { tomorrow.strftime(format) }
it { expect(result_for(due_date: nil, start_date: nil)).to be_nil }
it { expect(result_for(due_date: tomorrow)).to eq("expires on #{tomorrow_formatted}") }
it { expect(result_for(due_date: yesterday)).to eq("expired on #{yesterday_formatted}") }
it { expect(result_for(start_date: tomorrow)).to eq("starts on #{tomorrow_formatted}") }
it { expect(result_for(start_date: yesterday)).to eq("started on #{yesterday_formatted}") }
it { expect(result_for(start_date: yesterday, due_date: tomorrow)).to eq("#{yesterday_formatted} - #{tomorrow_formatted}") }
end
describe '#milestone_counts' do describe '#milestone_counts' do
let(:project) { FactoryGirl.create(:project) } let(:project) { FactoryGirl.create(:project) }
let(:counts) { helper.milestone_counts(project.milestones) } let(:counts) { helper.milestone_counts(project.milestones) }
......
...@@ -78,6 +78,7 @@ Milestone: ...@@ -78,6 +78,7 @@ Milestone:
- project_id - project_id
- description - description
- due_date - due_date
- start_date
- created_at - created_at
- updated_at - updated_at
- state - state
......
...@@ -115,4 +115,24 @@ describe Milestone, 'Milestoneish' do ...@@ -115,4 +115,24 @@ describe Milestone, 'Milestoneish' do
expect(milestone.percent_complete(admin)).to eq 60 expect(milestone.percent_complete(admin)).to eq 60
end end
end end
describe '#elapsed_days' do
it 'shows 0 if no start_date set' do
milestone = build(:milestone)
expect(milestone.elapsed_days).to eq(0)
end
it 'shows 0 if start_date is a future' do
milestone = build(:milestone, start_date: Time.now + 2.days)
expect(milestone.elapsed_days).to eq(0)
end
it 'shows correct amount of days' do
milestone = build(:milestone, start_date: Time.now - 2.days)
expect(milestone.elapsed_days).to eq(2)
end
end
end end
require 'spec_helper' require 'spec_helper'
describe Milestone, models: true do describe Milestone, models: true do
describe "Associations" do
it { is_expected.to belong_to(:project) }
it { is_expected.to have_many(:issues) }
end
describe "Validation" do describe "Validation" do
before do before do
allow(subject).to receive(:set_iid).and_return(false) allow(subject).to receive(:set_iid).and_return(false)
...@@ -13,6 +8,20 @@ describe Milestone, models: true do ...@@ -13,6 +8,20 @@ describe Milestone, models: true do
it { is_expected.to validate_presence_of(:title) } it { is_expected.to validate_presence_of(:title) }
it { is_expected.to validate_presence_of(:project) } it { is_expected.to validate_presence_of(:project) }
describe 'start_date' do
it 'adds an error when start_date is greated then due_date' do
milestone = build(:milestone, start_date: Date.tomorrow, due_date: Date.yesterday)
expect(milestone).not_to be_valid
expect(milestone.errors[:start_date]).to include("Can't be greater than due date")
end
end
end
describe "Associations" do
it { is_expected.to belong_to(:project) }
it { is_expected.to have_many(:issues) }
end end
let(:milestone) { create(:milestone) } let(:milestone) { create(:milestone) }
...@@ -58,18 +67,6 @@ describe Milestone, models: true do ...@@ -58,18 +67,6 @@ describe Milestone, models: true do
end end
end end
describe "#expires_at" do
it "is nil when due_date is unset" do
milestone.update_attributes(due_date: nil)
expect(milestone.expires_at).to be_nil
end
it "is not nil when due_date is set" do
milestone.update_attributes(due_date: Date.tomorrow)
expect(milestone.expires_at).to be_present
end
end
describe '#expired?' do describe '#expired?' do
context "expired" do context "expired" do
before do before do
...@@ -88,6 +85,18 @@ describe Milestone, models: true do ...@@ -88,6 +85,18 @@ describe Milestone, models: true do
end end
end end
describe '#upcoming?' do
it 'returns true' do
milestone = build(:milestone, start_date: Time.now + 1.month)
expect(milestone.upcoming?).to be_truthy
end
it 'returns false' do
milestone = build(:milestone, start_date: Date.today.prev_year)
expect(milestone.upcoming?).to be_falsey
end
end
describe '#percent_complete' do describe '#percent_complete' do
before do before do
allow(milestone).to receive_messages( allow(milestone).to receive_messages(
......
...@@ -92,13 +92,14 @@ describe API::API, api: true do ...@@ -92,13 +92,14 @@ describe API::API, api: true do
expect(json_response['description']).to be_nil expect(json_response['description']).to be_nil
end end
it 'creates a new project milestone with description and due date' do it 'creates a new project milestone with description and dates' do
post api("/projects/#{project.id}/milestones", user), post api("/projects/#{project.id}/milestones", user),
title: 'new milestone', description: 'release', due_date: '2013-03-02' title: 'new milestone', description: 'release', due_date: '2013-03-02', start_date: '2013-02-02'
expect(response).to have_http_status(201) expect(response).to have_http_status(201)
expect(json_response['description']).to eq('release') expect(json_response['description']).to eq('release')
expect(json_response['due_date']).to eq('2013-03-02') expect(json_response['due_date']).to eq('2013-03-02')
expect(json_response['start_date']).to eq('2013-02-02')
end end
it 'returns a 400 error if title is missing' do it 'returns a 400 error if title is missing' do
......
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