From 3afd08170da6f003b4f11ae1a3f737b14dedec40 Mon Sep 17 00:00:00 2001
From: Mehmet Beydogan <mehmet.beydogan@gmail.com>
Date: Thu, 10 Mar 2016 16:26:56 +0200
Subject: [PATCH] Add due_date:time field to Issue model Add due_date text
 field to sidebar issue#show Add ability sorting issues by due date ASC and
 DESC Add ability to filtering issues by No Due Date, Any Due Date, Due to
 tomorrow, Due in this week options Add handling issue due_date field for
 MergeRequest Update CHANGELOG Fix ambigous match for issues#show sidebar Fix
 SCREAMING_SNAKE_CASE offenses for due date contants Add specs for due date
 sorting and filtering on issues

---
 CHANGELOG                                     |  1 +
 app/controllers/projects/issues_controller.rb |  2 +-
 app/finders/issuable_finder.rb                | 22 +++++++
 app/helpers/issues_helper.rb                  | 11 ++++
 app/helpers/sorting_helper.rb                 | 18 ++++++
 app/models/concerns/issuable.rb               |  2 +
 app/models/concerns/sortable.rb               |  4 ++
 app/models/issue.rb                           |  3 +
 app/views/projects/issues/_issue.html.haml    |  4 ++
 app/views/shared/_sort_dropdown.html.haml     |  4 ++
 app/views/shared/issuable/_filter.html.haml   |  7 +++
 app/views/shared/issuable/_sidebar.html.haml  | 25 ++++++++
 .../20160310124959_add_due_date_to_issues.rb  |  5 ++
 db/schema.rb                                  | 15 +++++
 spec/features/issues_spec.rb                  | 63 +++++++++++++++++++
 15 files changed, 185 insertions(+), 1 deletion(-)
 create mode 100644 db/migrate/20160310124959_add_due_date_to_issues.rb

diff --git a/CHANGELOG b/CHANGELOG
index 9cfa04b1e63..b876e027132 100644
--- a/CHANGELOG
+++ b/CHANGELOG
@@ -267,6 +267,7 @@ v 8.5.7
 
 v 8.5.6
   - Obtain a lease before querying LDAP
+  - Add ability set due date to issues, sort and filter issues by due date
 
 v 8.5.5
   - Ensure removing a project removes associated Todo entries
diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb
index e86428147ef..b96ab91c17d 100644
--- a/app/controllers/projects/issues_controller.rb
+++ b/app/controllers/projects/issues_controller.rb
@@ -192,7 +192,7 @@ class Projects::IssuesController < Projects::ApplicationController
   def issue_params
     params.require(:issue).permit(
       :title, :assignee_id, :position, :description, :confidential,
-      :milestone_id, :state_event, :task_num, label_ids: []
+      :milestone_id, :due_date, :state_event, :task_num, label_ids: []
     )
   end
 
diff --git a/app/finders/issuable_finder.rb b/app/finders/issuable_finder.rb
index 5eb1d3f5aac..5e08193b5cf 100644
--- a/app/finders/issuable_finder.rb
+++ b/app/finders/issuable_finder.rb
@@ -39,6 +39,7 @@ class IssuableFinder
     items = by_assignee(items)
     items = by_author(items)
     items = by_label(items)
+    items = by_due_date(items)
     sort(items)
   end
 
@@ -112,6 +113,14 @@ class IssuableFinder
       end
   end
 
+  def due_date?
+    params[:due_date].present?
+  end
+
+  def filter_by_no_due_date?
+    due_date? && params[:due_date] == Issue::NO_DUE_DATE[1]
+  end
+
   def labels?
     params[:label_name].present?
   end
@@ -283,6 +292,19 @@ class IssuableFinder
     items.distinct
   end
 
+  def by_due_date(items)
+    if due_date?
+      if filter_by_no_due_date?
+        items = items.no_due_date
+      else
+        items = items.has_due_date
+        # Must use issues prefix to avoid ambiguous match with Milestone#due_date
+        items = items.where("issues.due_date > ? AND issues.due_date <= ?", Date.today, params[:due_date])
+      end
+    end
+    items
+  end
+
   def label_names
     params[:label_name].split(',')
   end
diff --git a/app/helpers/issues_helper.rb b/app/helpers/issues_helper.rb
index 4cb8adcebad..2a193e12ec9 100644
--- a/app/helpers/issues_helper.rb
+++ b/app/helpers/issues_helper.rb
@@ -172,6 +172,17 @@ module IssuesHelper
     end.to_h
   end
 
+  def due_date_options
+    options = [
+      ["Due to tomorrow", 1.day.from_now.to_date],
+      ["Due in this week", 1.week.from_now.to_date]
+    ]
+    options.unshift(Issue::ANY_DUE_DATE)
+    options.unshift(Issue::NO_DUE_DATE)
+    options_for_select(options, params[:due_date])
+  end
+
+
   # Required for Banzai::Filter::IssueReferenceFilter
   module_function :url_for_issue
 end
diff --git a/app/helpers/sorting_helper.rb b/app/helpers/sorting_helper.rb
index 2f2d2721d6d..624cb7bb847 100644
--- a/app/helpers/sorting_helper.rb
+++ b/app/helpers/sorting_helper.rb
@@ -8,6 +8,8 @@ module SortingHelper
       sort_value_oldest_created => sort_title_oldest_created,
       sort_value_milestone_soon => sort_title_milestone_soon,
       sort_value_milestone_later => sort_title_milestone_later,
+      sort_value_due_date_soon => sort_title_due_date_soon,
+      sort_value_due_date_later => sort_title_due_date_later,
       sort_value_largest_repo => sort_title_largest_repo,
       sort_value_recently_signin => sort_title_recently_signin,
       sort_value_oldest_signin => sort_title_oldest_signin,
@@ -50,6 +52,14 @@ module SortingHelper
     'Milestone due later'
   end
 
+  def sort_title_due_date_soon
+    'Due date soon'
+  end
+
+  def sort_title_due_date_later
+    'Due date due later'
+  end
+
   def sort_title_name
     'Name'
   end
@@ -98,6 +108,14 @@ module SortingHelper
     'milestone_due_desc'
   end
 
+  def sort_value_due_date_soon
+    'due_date_asc'
+  end
+
+  def sort_value_due_date_later
+    'due_date_desc'
+  end
+
   def sort_value_name
     'name_asc'
   end
diff --git a/app/models/concerns/issuable.rb b/app/models/concerns/issuable.rb
index afa2ca039ae..691b7e104e4 100644
--- a/app/models/concerns/issuable.rb
+++ b/app/models/concerns/issuable.rb
@@ -39,6 +39,8 @@ module Issuable
     scope :order_milestone_due_asc, -> { joins(:milestone).reorder('milestones.due_date ASC, milestones.id ASC') }
     scope :with_label, ->(title) { joins(:labels).where(labels: { title: title }) }
     scope :without_label, -> { joins("LEFT OUTER JOIN label_links ON label_links.target_type = '#{name}' AND label_links.target_id = #{table_name}.id").where(label_links: { id: nil }) }
+    scope :has_due_date, ->{ where("issues.due_date IS NOT NULL") }
+    scope :no_due_date, ->{ where("issues.due_date IS NULL")}
 
     scope :join_project, -> { joins(:project) }
     scope :references_project, -> { references(:project) }
diff --git a/app/models/concerns/sortable.rb b/app/models/concerns/sortable.rb
index 8b47b9e0abd..c88a8f5ceb8 100644
--- a/app/models/concerns/sortable.rb
+++ b/app/models/concerns/sortable.rb
@@ -18,6 +18,8 @@ module Sortable
     scope :order_updated_asc, -> { reorder(updated_at: :asc) }
     scope :order_name_asc, -> { reorder(name: :asc) }
     scope :order_name_desc, -> { reorder(name: :desc) }
+    scope :due_date_asc, -> { reorder(due_date: :asc) }
+    scope :due_date_desc, -> { reorder("due_date IS NULL, due_date DESC") }
   end
 
   module ClassMethods
@@ -31,6 +33,8 @@ module Sortable
       when 'created_desc' then order_created_desc
       when 'id_desc' then order_id_desc
       when 'id_asc' then order_id_asc
+      when 'due_date_asc' then due_date_asc
+      when 'due_date_desc' then due_date_desc
       else
         all
       end
diff --git a/app/models/issue.rb b/app/models/issue.rb
index a009e235b37..ee5be904330 100644
--- a/app/models/issue.rb
+++ b/app/models/issue.rb
@@ -28,6 +28,9 @@ class Issue < ActiveRecord::Base
   include Sortable
   include Taskable
 
+  NO_DUE_DATE = ['No Due Date', '0']
+  ANY_DUE_DATE = ['Any Due Date', '']
+
   ActsAsTaggableOn.strict_case_match = true
 
   belongs_to :project
diff --git a/app/views/projects/issues/_issue.html.haml b/app/views/projects/issues/_issue.html.haml
index 7a8009f6da4..c4feb6d3e18 100644
--- a/app/views/projects/issues/_issue.html.haml
+++ b/app/views/projects/issues/_issue.html.haml
@@ -48,6 +48,10 @@
       = link_to namespace_project_issues_path(issue.project.namespace, issue.project, milestone_title: issue.milestone.title) do
         = icon('clock-o')
         = issue.milestone.title
+    - if issue.due_date
+      &nbsp;
+      = icon('calendar')
+      = issue.due_date.to_s(:medium)
     - if issue.labels.any?
       &nbsp;
       - issue.labels.each do |label|
diff --git a/app/views/shared/_sort_dropdown.html.haml b/app/views/shared/_sort_dropdown.html.haml
index e3a6a5a68b6..80971309da7 100644
--- a/app/views/shared/_sort_dropdown.html.haml
+++ b/app/views/shared/_sort_dropdown.html.haml
@@ -20,6 +20,10 @@
         = sort_title_milestone_soon
       = link_to page_filter_path(sort: sort_value_milestone_later) do
         = sort_title_milestone_later
+      = link_to page_filter_path(sort: sort_value_due_date_soon) do
+        = sort_title_due_date_soon if controller_name == "issues"
+      = link_to page_filter_path(sort: sort_value_due_date_later) do
+        = sort_title_due_date_later if controller_name == "issues"
       = link_to page_filter_path(sort: sort_value_upvotes) do
         = sort_title_upvotes
       = link_to page_filter_path(sort: sort_value_downvotes) do
diff --git a/app/views/shared/issuable/_filter.html.haml b/app/views/shared/issuable/_filter.html.haml
index ade0a56b2e7..f832f430b2b 100644
--- a/app/views/shared/issuable/_filter.html.haml
+++ b/app/views/shared/issuable/_filter.html.haml
@@ -23,6 +23,13 @@
 
         .filter-item.inline.labels-filter
           = render "shared/issuable/label_dropdown"
+
+        - if controller.controller_name == 'issues'
+          .filter-item.inline.due_date-filter
+            = select_tag('due_date', due_date_options,
+              class: 'select2 trigger-submit', include_blank: true,
+              data: {placeholder: 'Due Date'})
+
         .pull-right
           = render 'shared/sort_dropdown'
 
diff --git a/app/views/shared/issuable/_sidebar.html.haml b/app/views/shared/issuable/_sidebar.html.haml
index 03a615d191c..fb2c727d57a 100644
--- a/app/views/shared/issuable/_sidebar.html.haml
+++ b/app/views/shared/issuable/_sidebar.html.haml
@@ -74,6 +74,31 @@
         .selectbox.hide-collapsed
           = f.hidden_field 'milestone_id', value: issuable.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.to_ability_name}[milestone_id]", project_id: @project.id, issuable_id: issuable.id, milestones: namespace_project_milestones_path(@project.namespace, @project, :json), ability_name: issuable.to_ability_name, issue_update: issuable_json_path(issuable), use_id: true }})
+      - if issuable.has_attribute? :due_date
+        .block.due_date
+          .sidebar-collapsed-icon
+            = icon('calendar')
+            %span
+              - if issuable.due_date
+                = icon('calendar')
+                = issuable.due_date.to_s(:medium)
+              - else
+                .light None
+          .title.hide-collapsed
+            %label
+              Due Date
+            - if can?(current_user, :"admin_#{issuable.to_ability_name}", @project)
+              .pull-right
+                = link_to 'Edit', '#', class: 'edit-link'
+          .value.hide-collapsed
+            - if issuable.due_date
+              = icon('calendar')
+              = issuable.due_date.to_s(:medium)
+            - else
+              .light None
+          .selectbox.hide-collapsed
+            = f.text_field :due_date
+            = hidden_field_tag :issuable_context
 
       - if issuable.project.labels.any?
         .block.labels
diff --git a/db/migrate/20160310124959_add_due_date_to_issues.rb b/db/migrate/20160310124959_add_due_date_to_issues.rb
new file mode 100644
index 00000000000..c232387a6f3
--- /dev/null
+++ b/db/migrate/20160310124959_add_due_date_to_issues.rb
@@ -0,0 +1,5 @@
+class AddDueDateToIssues < ActiveRecord::Migration
+  def change
+    add_column :issues, :due_date, :date
+  end
+end
diff --git a/db/schema.rb b/db/schema.rb
index d82c8c1e257..699a99c0743 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -366,6 +366,19 @@ ActiveRecord::Schema.define(version: 20160419120017) do
   add_index "emails", ["email"], name: "index_emails_on_email", unique: true, using: :btree
   add_index "emails", ["user_id"], name: "index_emails_on_user_id", using: :btree
 
+  create_table "emoji_awards", force: :cascade do |t|
+    t.string   "name"
+    t.integer  "user_id"
+    t.integer  "awardable_id"
+    t.string   "awardable_type"
+    t.datetime "created_at"
+    t.datetime "updated_at"
+  end
+
+  add_index "emoji_awards", ["awardable_id"], name: "index_emoji_awards_on_awardable_id", using: :btree
+  add_index "emoji_awards", ["awardable_type"], name: "index_emoji_awards_on_awardable_type", using: :btree
+  add_index "emoji_awards", ["user_id"], name: "index_emoji_awards_on_user_id", using: :btree
+
   create_table "events", force: :cascade do |t|
     t.string   "target_type"
     t.integer  "target_id"
@@ -422,6 +435,7 @@ ActiveRecord::Schema.define(version: 20160419120017) do
     t.integer  "moved_to_id"
     t.boolean  "confidential",  default: false
     t.datetime "deleted_at"
+    t.date     "due_date"
   end
 
   add_index "issues", ["assignee_id"], name: "index_issues_on_assignee_id", using: :btree
@@ -431,6 +445,7 @@ ActiveRecord::Schema.define(version: 20160419120017) do
   add_index "issues", ["created_at"], name: "index_issues_on_created_at", using: :btree
   add_index "issues", ["deleted_at"], name: "index_issues_on_deleted_at", using: :btree
   add_index "issues", ["description"], name: "index_issues_on_description_trigram", using: :gin, opclasses: {"description"=>"gin_trgm_ops"}
+  add_index "issues", ["due_date"], name: "index_issues_on_due_date", using: :btree
   add_index "issues", ["milestone_id"], name: "index_issues_on_milestone_id", using: :btree
   add_index "issues", ["project_id", "iid"], name: "index_issues_on_project_id_and_iid", unique: true, using: :btree
   add_index "issues", ["project_id"], name: "index_issues_on_project_id", using: :btree
diff --git a/spec/features/issues_spec.rb b/spec/features/issues_spec.rb
index 35c8f93abc1..ac54a0c2719 100644
--- a/spec/features/issues_spec.rb
+++ b/spec/features/issues_spec.rb
@@ -153,6 +153,69 @@ describe 'Issues', feature: true do
       expect(first_issue).to include('baz')
     end
 
+    describe 'sorting by due date' do
+      before :each do
+        foo.due_date = 1.day.from_now
+        foo.save
+        bar.due_date = 6.days.from_now
+        bar.save
+      end
+
+      it 'sorts by recently due date' do
+        visit namespace_project_issues_path(project.namespace, project, sort: sort_value_due_date_soon)
+        expect(first_issue).to include('foo')
+      end
+
+      it 'sorts by least recently due date' do
+        visit namespace_project_issues_path(project.namespace, project, sort: sort_value_due_date_later)
+        expect(first_issue).to include('bar')
+      end
+
+      it 'sorts by least recently due date by excluding nil due dates' do
+        bar.update(due_date: nil)
+        visit namespace_project_issues_path(project.namespace, project, sort: sort_value_due_date_later)
+        expect(first_issue).to include('foo')
+      end
+    end
+
+    describe 'filtering by due date' do
+      before :each do
+        foo.due_date = 1.day.from_now
+        foo.save
+        bar.due_date = 6.days.from_now
+        bar.save
+      end
+
+      it 'filters by none' do
+        visit namespace_project_issues_path(project.namespace, project, due_date: Issue::NO_DUE_DATE[1])
+        expect(page).not_to have_content("foo")
+        expect(page).not_to have_content("bar")
+        expect(page).to have_content("baz")
+      end
+
+      it 'filters by any' do
+        visit namespace_project_issues_path(project.namespace, project, due_date: Issue::ANY_DUE_DATE[1])
+        expect(page).to have_content("foo")
+        expect(page).to have_content("bar")
+        expect(page).to have_content("baz")
+      end
+
+      it 'filters by due to tomorrow' do
+        visit namespace_project_issues_path(project.namespace, project, due_date: Date.tomorrow.to_s)
+        expect(page).to have_content("foo")
+        expect(page).not_to have_content("bar")
+        expect(page).not_to have_content("baz")
+      end
+
+      it 'filters by due in this week' do
+        visit namespace_project_issues_path(project.namespace, project, due_date: 7.days.from_now.to_date.to_s)
+        expect(page).to have_content("foo")
+        expect(page).to have_content("bar")
+        expect(page).not_to have_content("baz")
+      end
+
+    end
+
     describe 'sorting by milestone' do
       before :each do
         foo.milestone = newer_due_milestone
-- 
GitLab