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 +   + = icon('calendar') + = issue.due_date.to_s(:medium) - if issue.labels.any?   - 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