diff --git a/app/assets/javascripts/due_date_select.js.es6 b/app/assets/javascripts/due_date_select.js.es6 index fd7f961aab9..e84f5ac9183 100644 --- a/app/assets/javascripts/due_date_select.js.es6 +++ b/app/assets/javascripts/due_date_select.js.es6 @@ -145,25 +145,19 @@ class DueDateSelectors { constructor() { - this.initMilestoneDueDate(); + this.initMilestoneDatePicker(); this.initIssuableSelect(); } - initMilestoneDueDate() { - const $datePicker = $('.datepicker'); + initMilestoneDatePicker() { + $('.datepicker').datepicker({ + dateFormat: 'yy-mm-dd' + }); - if ($datePicker.length) { - 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) => { + $('.js-clear-due-date,.js-clear-start-date').on('click', (e) => { e.preventDefault(); - $.datepicker._clearDate($datePicker); + const datepicker = $(e.target).siblings('.datepicker'); + $.datepicker._clearDate(datepicker); }); } diff --git a/app/assets/stylesheets/framework/issue_box.scss b/app/assets/stylesheets/framework/issue_box.scss index ba3930e03bd..ff6f316d576 100644 --- a/app/assets/stylesheets/framework/issue_box.scss +++ b/app/assets/stylesheets/framework/issue_box.scss @@ -39,4 +39,8 @@ &.status-box-expired { background: #cea61b; } + + &.status-box-upcoming { + background: #8f8f8f; + } } diff --git a/app/controllers/groups/milestones_controller.rb b/app/controllers/groups/milestones_controller.rb index 506484932cc..24ec4eec3f2 100644 --- a/app/controllers/groups/milestones_controller.rb +++ b/app/controllers/groups/milestones_controller.rb @@ -67,7 +67,7 @@ class Groups::MilestonesController < Groups::ApplicationController end 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 def milestone_path(title) diff --git a/app/controllers/projects/milestones_controller.rb b/app/controllers/projects/milestones_controller.rb index ff63f22cb5b..be52b0fa7cf 100644 --- a/app/controllers/projects/milestones_controller.rb +++ b/app/controllers/projects/milestones_controller.rb @@ -112,6 +112,6 @@ class Projects::MilestonesController < Projects::ApplicationController end 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 diff --git a/app/helpers/issues_helper.rb b/app/helpers/issues_helper.rb index 1644c346dd8..a8a49e43b05 100644 --- a/app/helpers/issues_helper.rb +++ b/app/helpers/issues_helper.rb @@ -64,6 +64,8 @@ module IssuesHelper 'status-box-merged' elsif item.closed? 'status-box-closed' + elsif item.respond_to?(:upcoming?) && item.upcoming? + 'status-box-upcoming' else 'status-box-open' end diff --git a/app/helpers/milestones_helper.rb b/app/helpers/milestones_helper.rb index 83a2a4ad3ec..729928ce1dd 100644 --- a/app/helpers/milestones_helper.rb +++ b/app/helpers/milestones_helper.rb @@ -86,6 +86,30 @@ module MilestonesHelper days = milestone.remaining_days content = content_tag(:strong, days) 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 diff --git a/app/models/concerns/milestoneish.rb b/app/models/concerns/milestoneish.rb index 7bcc78247ba..e65fc9eaa09 100644 --- a/app/models/concerns/milestoneish.rb +++ b/app/models/concerns/milestoneish.rb @@ -23,7 +23,31 @@ module Milestoneish (due_date - Date.today).to_i 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) issues.visible_to_user(user) 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 diff --git a/app/models/global_milestone.rb b/app/models/global_milestone.rb index cde4a568577..b01607dcda9 100644 --- a/app/models/global_milestone.rb +++ b/app/models/global_milestone.rb @@ -28,26 +28,16 @@ class GlobalMilestone @title.to_slug.normalize.to_s end - def expired? - if due_date - due_date.past? - else - false - end - end - def projects @projects ||= Project.for_milestones(milestones.select(:id)) end def state - state = milestones.map { |milestone| milestone.state } - - if state.count('closed') == state.size - 'closed' - else - 'active' + milestones.each do |milestone| + return 'active' if milestone.state != 'closed' end + + 'closed' end def active? @@ -81,18 +71,15 @@ class GlobalMilestone @due_date = if @milestones.all? { |x| x.due_date == @milestones.first.due_date } @milestones.first.due_date - else - nil 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)}" + def start_date + return @start_date if defined?(@start_date) + + @start_date = + if @milestones.all? { |x| x.start_date == @milestones.first.start_date } + @milestones.first.start_date end - end end end diff --git a/app/models/milestone.rb b/app/models/milestone.rb index 23aecbfa3a6..c774e69080c 100644 --- a/app/models/milestone.rb +++ b/app/models/milestone.rb @@ -29,6 +29,7 @@ class Milestone < ActiveRecord::Base validates :title, presence: true, uniqueness: { scope: :project_id } 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 @@ -131,24 +132,6 @@ class Milestone < ActiveRecord::Base self.title 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? active? && issues.opened.count.zero? end @@ -212,4 +195,10 @@ class Milestone < ActiveRecord::Base def sanitize_title(value) CGI.unescape_html(Sanitize.clean(value.to_s)) 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 diff --git a/app/views/groups/milestones/new.html.haml b/app/views/groups/milestones/new.html.haml index 0dfaf743992..63cadfca530 100644 --- a/app/views/groups/milestones/new.html.haml +++ b/app/views/groups/milestones/new.html.haml @@ -36,19 +36,8 @@ = f.collection_select :project_ids, @group.projects.non_archived, :id, :name, { selected: @group.projects.non_archived.pluck(:id) }, required: true, multiple: true, class: 'select2' - .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" + = render "shared/milestones/form_dates", f: f .form-actions = f.submit 'Create Milestone', class: "btn-create btn" = 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())); diff --git a/app/views/projects/milestones/_form.html.haml b/app/views/projects/milestones/_form.html.haml index cbf1ba04170..513710e8e66 100644 --- a/app/views/projects/milestones/_form.html.haml +++ b/app/views/projects/milestones/_form.html.haml @@ -14,12 +14,7 @@ = render 'projects/notes/hints' .clearfix .error-alert - .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 + = render "shared/milestones/form_dates", f: f .form-actions - if @milestone.new_record? diff --git a/app/views/projects/milestones/show.html.haml b/app/views/projects/milestones/show.html.haml index e01aca3dda6..c3a6096aa54 100644 --- a/app/views/projects/milestones/show.html.haml +++ b/app/views/projects/milestones/show.html.haml @@ -10,15 +10,17 @@ Closed - elsif @milestone.expired? Past due + - elsif @milestone.upcoming? + Upcoming - else Open .header-text-content %span.identifier Milestone ##{@milestone.iid} - - if @milestone.expires_at + - if @milestone.due_date || @milestone.start_date %span.creator · - = @milestone.expires_at + = milestone_date_range(@milestone) .milestone-buttons - if can?(current_user, :admin_milestone, @project) - if @milestone.active? diff --git a/app/views/shared/_milestone_expired.html.haml b/app/views/shared/_milestone_expired.html.haml index b8eef15fbec..5e9007aaaac 100644 --- a/app/views/shared/_milestone_expired.html.haml +++ b/app/views/shared/_milestone_expired.html.haml @@ -1,5 +1,7 @@ - if milestone.expired? and not milestone.closed? %span.cred (Expired) -- if milestone.expires_at +- if milestone.upcoming? + %span.clgray (Upcoming) +- if milestone.due_date || milestone.start_date %span - = milestone.expires_at + = milestone_date_range(milestone) diff --git a/app/views/shared/milestones/_form_dates.html.haml b/app/views/shared/milestones/_form_dates.html.haml new file mode 100644 index 00000000000..748b10a1298 --- /dev/null +++ b/app/views/shared/milestones/_form_dates.html.haml @@ -0,0 +1,15 @@ +.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(); diff --git a/app/views/shared/milestones/_top.html.haml b/app/views/shared/milestones/_top.html.haml index 548215243db..497446c1ef3 100644 --- a/app/views/shared/milestones/_top.html.haml +++ b/app/views/shared/milestones/_top.html.haml @@ -12,10 +12,10 @@ Open %span.identifier Milestone #{milestone.title} - - if milestone.expires_at + - if milestone.due_date || milestone.start_date %span.creator · - = milestone.expires_at + = milestone_date_range(milestone) - if group .pull-right - if can?(current_user, :admin_milestones, group) diff --git a/changelogs/unreleased/milestone_start_date.yml b/changelogs/unreleased/milestone_start_date.yml new file mode 100644 index 00000000000..39ac1344329 --- /dev/null +++ b/changelogs/unreleased/milestone_start_date.yml @@ -0,0 +1,4 @@ +--- +title: Add a starting date to milestones +merge_request: +author: diff --git a/db/migrate/20161115173905_add_start_date_to_milestones.rb b/db/migrate/20161115173905_add_start_date_to_milestones.rb new file mode 100644 index 00000000000..413733b8db7 --- /dev/null +++ b/db/migrate/20161115173905_add_start_date_to_milestones.rb @@ -0,0 +1,12 @@ +# 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 diff --git a/db/schema.rb b/db/schema.rb index 6b28e80f01d..b3c49b52597 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -720,6 +720,7 @@ ActiveRecord::Schema.define(version: 20161118183841) do t.integer "iid" t.text "title_html" t.text "description_html" + t.date "start_date" end add_index "milestones", ["description"], name: "index_milestones_on_description_trigram", using: :gin, opclasses: {"description"=>"gin_trgm_ops"} diff --git a/doc/api/milestones.md b/doc/api/milestones.md index 28e4b3105a6..12497acff98 100644 --- a/doc/api/milestones.md +++ b/doc/api/milestones.md @@ -35,6 +35,7 @@ Example Response: "title": "10.0", "description": "Version", "due_date": "2013-11-29", + "start_date": "2013-11-10", "state": "active", "updated_at": "2013-10-02T09:24:18Z", "created_at": "2013-10-02T09:24:18Z" @@ -70,6 +71,7 @@ Parameters: - `title` (required) - The title of an milestone - `description` (optional) - The description of the milestone - `due_date` (optional) - The due date of the milestone +- `start_date` (optional) - The start date of the milestone ## Edit milestone @@ -86,6 +88,7 @@ Parameters: - `title` (optional) - The title of a milestone - `description` (optional) - The description of a 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) ## Get all issues assigned to a single milestone diff --git a/lib/api/entities.rb b/lib/api/entities.rb index 33cb6fd3704..7a724487e02 100644 --- a/lib/api/entities.rb +++ b/lib/api/entities.rb @@ -210,6 +210,7 @@ module API class Milestone < ProjectEntity expose :due_date + expose :start_date end class Issue < ProjectEntity diff --git a/lib/api/milestones.rb b/lib/api/milestones.rb index 29bf73934d2..50d6109be3d 100644 --- a/lib/api/milestones.rb +++ b/lib/api/milestones.rb @@ -14,7 +14,8 @@ module API params :optional_params do 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 diff --git a/spec/features/milestone_spec.rb b/spec/features/milestone_spec.rb index b8c838bf7ab..a2e40546588 100644 --- a/spec/features/milestone_spec.rb +++ b/spec/features/milestone_spec.rb @@ -14,12 +14,17 @@ feature 'Milestone', feature: true do feature 'Create a milestone' do scenario 'shows an informative message for a new milestone' do visit new_namespace_project_milestone_path(project.namespace, project) + page.within '.milestone-form' do 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 + find('input[name="commit"]').click 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 diff --git a/spec/helpers/milestones_helper_spec.rb b/spec/helpers/milestones_helper_spec.rb index 28c2268f8d0..ea744dbb629 100644 --- a/spec/helpers/milestones_helper_spec.rb +++ b/spec/helpers/milestones_helper_spec.rb @@ -1,6 +1,25 @@ require 'spec_helper' 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 let(:project) { FactoryGirl.create(:project) } let(:counts) { helper.milestone_counts(project.milestones) } diff --git a/spec/lib/gitlab/import_export/safe_model_attributes.yml b/spec/lib/gitlab/import_export/safe_model_attributes.yml index d6807941b31..78d6b2c5032 100644 --- a/spec/lib/gitlab/import_export/safe_model_attributes.yml +++ b/spec/lib/gitlab/import_export/safe_model_attributes.yml @@ -78,6 +78,7 @@ Milestone: - project_id - description - due_date +- start_date - created_at - updated_at - state diff --git a/spec/models/concerns/milestoneish_spec.rb b/spec/models/concerns/milestoneish_spec.rb index b7e973798a3..0e097559b59 100644 --- a/spec/models/concerns/milestoneish_spec.rb +++ b/spec/models/concerns/milestoneish_spec.rb @@ -115,4 +115,24 @@ describe Milestone, 'Milestoneish' do expect(milestone.percent_complete(admin)).to eq 60 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 diff --git a/spec/models/milestone_spec.rb b/spec/models/milestone_spec.rb index 33fe22dd98c..a4bfe851dfb 100644 --- a/spec/models/milestone_spec.rb +++ b/spec/models/milestone_spec.rb @@ -1,11 +1,6 @@ require 'spec_helper' 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 before do allow(subject).to receive(:set_iid).and_return(false) @@ -13,6 +8,20 @@ describe Milestone, models: true do it { is_expected.to validate_presence_of(:title) } 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 let(:milestone) { create(:milestone) } @@ -58,18 +67,6 @@ describe Milestone, models: true do 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 context "expired" do before do @@ -88,6 +85,18 @@ describe Milestone, models: true do 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 before do allow(milestone).to receive_messages( diff --git a/spec/requests/api/milestones_spec.rb b/spec/requests/api/milestones_spec.rb index 5d7b39e71b8..b0946a838a1 100644 --- a/spec/requests/api/milestones_spec.rb +++ b/spec/requests/api/milestones_spec.rb @@ -92,13 +92,14 @@ describe API::API, api: true do expect(json_response['description']).to be_nil 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), - 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(json_response['description']).to eq('release') expect(json_response['due_date']).to eq('2013-03-02') + expect(json_response['start_date']).to eq('2013-02-02') end it 'returns a 400 error if title is missing' do