Commit 82d8c74f authored by Robert Speicher's avatar Robert Speicher Committed by Yorick Peterse

Merge branch 'due-date-frontend' into 'master'

Add due date to issues

Closes: #12709

See merge request !3614
parent 7046aedd
......@@ -16,6 +16,7 @@ v 8.7.0 (unreleased)
- Load award emoji images separately unless opening the full picker. Saves several hundred KBs of data for most pages. (Connor Shea)
- Do not include award_emojis in issue and merge_request comment_count !3610 (Lucas Charles)
- Restrict user profiles when public visibility level is restricted.
- Add ability set due date to issues, sort and filter issues by due date (Mehmet Beydogan)
- All images in discussions and wikis now link to their source files !3464 (Connor Shea).
- Return status code 303 after a branch DELETE operation to avoid project deletion (Stan Hu)
- Add setting for customizing the list of trusted proxies !3524
......
class @DueDateSelect
constructor: ->
$loading = $('.js-issuable-update .due_date')
.find('.block-loading')
.hide()
$('.js-due-date-select').each (i, dropdown) ->
$dropdown = $(dropdown)
$dropdownParent = $dropdown.closest('.dropdown')
$datePicker = $dropdownParent.find('.js-due-date-calendar')
$block = $dropdown.closest('.block')
$selectbox = $dropdown.closest('.selectbox')
$value = $block.find('.value')
$sidebarValue = $('.js-due-date-sidebar-value', $block)
fieldName = $dropdown.data('field-name')
abilityName = $dropdown.data('ability-name')
issueUpdateURL = $dropdown.data('issue-update')
$dropdown.glDropdown(
hidden: ->
$selectbox.hide()
$value.removeAttr('style')
)
addDueDate = ->
# Create the post date
value = $("input[name='#{fieldName}']").val()
date = new Date value.replace(new RegExp('-', 'g'), ',')
mediumDate = $.datepicker.formatDate 'M d, yy', date
data = {}
data[abilityName] = {}
data[abilityName].due_date = value
$.ajax(
type: 'PUT'
url: issueUpdateURL
data: data
beforeSend: ->
$loading.fadeIn()
$dropdown.trigger('loading.gl.dropdown')
$selectbox.hide()
$value.removeAttr('style')
$value.html(mediumDate)
$sidebarValue.html(mediumDate)
).done (data) ->
$dropdown.trigger('loaded.gl.dropdown')
$dropdown.dropdown('toggle')
$loading.fadeOut()
$datePicker.datepicker(
dateFormat: 'yy-mm-dd',
defaultDate: $("input[name='#{fieldName}']").val()
altField: "input[name='#{fieldName}']"
onSelect: ->
addDueDate()
)
$(document)
.off 'click', '.ui-datepicker-header a'
.on 'click', '.ui-datepicker-header a', (e) ->
e.stopImmediatePropagation()
......@@ -33,7 +33,6 @@ class @IssuableContext
$block.find('.dropdown-menu-toggle').trigger 'click'
, 0
$(".right-sidebar").niceScroll()
initParticipants: ->
......
......@@ -248,7 +248,7 @@
.dropdown-title {
position: relative;
padding: 0 25px 15px;
padding: 0 25px 10px;
margin: 0 10px 10px;
font-weight: 600;
line-height: 1;
......@@ -278,7 +278,7 @@
right: 5px;
width: 20px;
height: 20px;
top: -1px;
top: -3px;
}
.dropdown-menu-back {
......@@ -358,6 +358,13 @@
border-top: 1px solid $dropdown-divider-color;
}
.dropdown-due-date-footer {
padding-top: 0;
margin-left: 10px;
margin-right: 10px;
border-top: 0;
}
.dropdown-footer-list {
font-size: 14px;
......@@ -395,3 +402,122 @@
height: 15px;
border-radius: $border-radius-base;
}
.dropdown-menu-due-date {
.dropdown-content {
max-height: 230px;
}
.ui-widget {
table {
margin: 0;
}
&.ui-datepicker-inline {
padding: 0 10px;
border: 0;
width: 100%;
}
.ui-datepicker-header {
padding: 0 8px 10px;
border: 0;
.ui-icon {
background: none;
font-size: 20px;
text-indent: 0;
&:before {
display: block;
position: relative;
top: -2px;
color: $dropdown-title-btn-color;
font: normal normal normal 14px/1 FontAwesome;
font-size: inherit;
text-rendering: auto;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
}
}
.ui-state-active,
.ui-state-hover {
color: $md-link-color;
background-color: $calendar-hover-bg;
}
.ui-datepicker-prev,
.ui-datepicker-next {
top: 0;
height: 15px;
cursor: pointer;
&:hover {
background-color: transparent;
border: 0;
.ui-icon:before {
color: $md-link-color;
}
}
}
.ui-datepicker-prev {
left: 0;
.ui-icon:before {
content: '\f104';
text-align: left;
}
}
.ui-datepicker-next {
right: 0;
.ui-icon:before {
content: '\f105';
text-align: right;
}
}
td {
padding: 0;
border: 1px solid $calendar-border-color;
&:first-child {
border-left: 0;
}
&:last-child {
border-right: 0;
}
a {
line-height: 17px;
border: 0;
border-radius: 0;
}
}
.ui-datepicker-title {
color: $gl-gray;
font-size: 15px;
line-height: 1;
font-weight: normal;
}
}
th {
padding: 2px 0;
color: $calendar-header-color;
font-weight: normal;
text-transform: lowercase;
border-top: 1px solid $calendar-border-color;
}
.ui-datepicker-unselectable {
background-color: $calendar-unselectable-bg;
}
}
......@@ -241,3 +241,8 @@ $note-form-border-color: #e5e5e5;
$note-toolbar-color: #959494;
$zen-control-hover-color: #111;
$calendar-header-color: #b8b8b8;
$calendar-hover-bg: #ecf3fe;
$calendar-border-color: rgba(#000, .1);
$calendar-unselectable-bg: #faf9f9;
......@@ -242,7 +242,7 @@
}
}
.btn {
.issuable-pager {
background: $gray-normal;
border: 1px solid $border-gray-normal;
&:hover {
......@@ -251,7 +251,7 @@
}
}
a:not(.btn) {
a:not(.issuable-pager) {
&:hover {
color: $md-link-color;
text-decoration: none;
......
......@@ -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
......
......@@ -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
......@@ -283,6 +284,42 @@ class IssuableFinder
items.distinct
end
def by_due_date(items)
if due_date?
if filter_by_no_due_date?
items = items.without_due_date
elsif filter_by_overdue?
items = items.due_before(Date.today)
elsif filter_by_due_this_week?
items = items.due_between(Date.today.beginning_of_week, Date.today.end_of_week)
elsif filter_by_due_this_month?
items = items.due_between(Date.today.beginning_of_month, Date.today.end_of_month)
end
end
items
end
def filter_by_no_due_date?
due_date? && params[:due_date] == Issue::NoDueDate.name
end
def filter_by_overdue?
due_date? && params[:due_date] == Issue::Overdue.name
end
def filter_by_due_this_week?
due_date? && params[:due_date] == Issue::DueThisWeek.name
end
def filter_by_due_this_month?
due_date? && params[:due_date] == Issue::DueThisMonth.name
end
def due_date?
params[:due_date].present? && klass.column_names.include?('due_date')
end
def label_names
params[:label_name].split(',')
end
......
......@@ -131,7 +131,7 @@ module IssuesHelper
class: "icon emoji-icon emoji-#{unicode}",
title: name,
data: data
else
else
# Emoji icons displayed separately, used for the awards already given
# to an issue or merge request.
content_tag :img, "",
......@@ -172,6 +172,18 @@ module IssuesHelper
end.to_h
end
def due_date_options
options = [
Issue::AnyDueDate,
Issue::NoDueDate,
Issue::DueThisWeek,
Issue::DueThisMonth,
Issue::Overdue
]
options_from_collection_for_select(options, 'name', 'title', params[:due_date])
end
# Required for Banzai::Filter::IssueReferenceFilter
module_function :url_for_issue
end
......@@ -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 soon'
end
def sort_title_due_date_later
'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
......
......@@ -28,6 +28,13 @@ class Issue < ActiveRecord::Base
include Sortable
include Taskable
DueDateStruct = Struct.new(:title, :name).freeze
NoDueDate = DueDateStruct.new('No Due Date', '0').freeze
AnyDueDate = DueDateStruct.new('Any Due Date', '').freeze
Overdue = DueDateStruct.new('Overdue', 'overdue').freeze
DueThisWeek = DueDateStruct.new('Due This Week', 'week').freeze
DueThisMonth = DueDateStruct.new('Due This Month', 'month').freeze
ActsAsTaggableOn.strict_case_match = true
belongs_to :project
......@@ -39,6 +46,13 @@ class Issue < ActiveRecord::Base
scope :open_for, ->(user) { opened.assigned_to(user) }
scope :in_projects, ->(project_ids) { where(project_id: project_ids) }
scope :without_due_date, -> { where(due_date: nil) }
scope :due_before, ->(date) { where('issues.due_date < ?', date) }
scope :due_between, ->(from_date, to_date) { where('issues.due_date >= ?', from_date).where('issues.due_date <= ?', to_date) }
scope :order_due_date_asc, -> { reorder('issues.due_date IS NULL, issues.due_date ASC') }
scope :order_due_date_desc, -> { reorder('issues.due_date IS NULL, issues.due_date DESC') }
state_machine :state, initial: :opened do
event :close do
transition [:reopened, :opened] => :closed
......@@ -82,6 +96,15 @@ class Issue < ActiveRecord::Base
@link_reference_pattern ||= super("issues", /(?<issue>\d+)/)
end
def self.sort(method)
case method.to_s
when 'due_date_asc' then order_due_date_asc
when 'due_date_desc' then order_due_date_desc
else
super
end
end
def to_reference(from_project = nil)
reference = "#{self.class.reference_prefix}#{iid}"
......@@ -169,4 +192,8 @@ class Issue < ActiveRecord::Base
self.related_branches(current_user).empty? &&
self.closed_by_merge_requests(current_user).empty?
end
def overdue?
due_date.try(:past?) || false
end
end
......@@ -48,6 +48,11 @@
= 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
%span{class: "#{'cred' if issue.overdue?}"}
&nbsp;
= icon('calendar')
= issue.due_date.to_s(:medium)
- if issue.labels.any?
&nbsp;
- issue.labels.each do |label|
......
......@@ -20,6 +20,11 @@
= sort_title_milestone_soon
= link_to page_filter_path(sort: sort_value_milestone_later) do
= sort_title_milestone_later
- if controller.controller_name == 'issues' || controller.action_name == 'issues'
= link_to page_filter_path(sort: sort_value_due_date_soon) do
= sort_title_due_date_soon
= link_to page_filter_path(sort: sort_value_due_date_later) do
= sort_title_due_date_later
= link_to page_filter_path(sort: sort_value_upvotes) do
= sort_title_upvotes
= link_to page_filter_path(sort: sort_value_downvotes) do
......
......@@ -23,6 +23,7 @@
.filter-item.inline.labels-filter
= render "shared/issuable/label_dropdown"
.pull-right
= render 'shared/sort_dropdown'
......
......@@ -10,14 +10,14 @@
= sidebar_gutter_toggle_icon
.issuable-nav.hide-collapsed.pull-right.btn-group{role: 'group', "aria-label" => '...'}
- if prev_issuable = prev_issuable_for(issuable)
= link_to 'Prev', [@project.namespace.becomes(Namespace), @project, prev_issuable], class: 'btn btn-default prev-btn'
= link_to 'Prev', [@project.namespace.becomes(Namespace), @project, prev_issuable], class: 'btn btn-default prev-btn issuable-pager'
- else
%a.btn.btn-default.disabled{href: '#'}
%a.btn.btn-default.issuable-pager.disabled{href: '#'}
Prev
- if next_issuable = next_issuable_for(issuable)
= link_to 'Next', [@project.namespace.becomes(Namespace), @project, next_issuable], class: 'btn btn-default next-btn'
= link_to 'Next', [@project.namespace.becomes(Namespace), @project, next_issuable], class: 'btn btn-default next-btn issuable-pager'
- else
%a.btn.btn-default.disabled{href: '#'}
%a.btn.btn-default.issuable-pager.disabled{href: '#'}
Next
= form_for [@project.namespace.becomes(Namespace), @project, issuable], remote: true, html: {class: 'issuable-context-form inline-update js-issuable-update'} do |f|
......@@ -58,7 +58,7 @@
- if issuable.milestone
= issuable.milestone.title
- else
No
None
.title.hide-collapsed
Milestone
= icon('spinner spin', class: 'block-loading')
......@@ -75,6 +75,34 @@
= 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.js-due-date-sidebar-value
= issuable.due_date.try(:to_s, :medium) || 'None'
.title.hide-collapsed
Due date
= icon('spinner spin', class: 'block-loading')
- if can?(current_user, :"admin_#{issuable.to_ability_name}", @project)
= link_to 'Edit', '#', class: 'edit-link pull-right'
.value.bold.hide-collapsed
- if issuable.due_date
= issuable.due_date.to_s(:medium)
- else
.light None
- if can?(current_user, :"admin_#{issuable.to_ability_name}", @project)
.selectbox.hide-collapsed
= f.hidden_field :due_date, value: issuable.due_date
.dropdown
%button.dropdown-menu-toggle.js-due-date-select{ type: 'button', data: { toggle: 'dropdown', field_name: "#{issuable.to_ability_name}[due_date]", ability_name: issuable.to_ability_name, issue_update: issuable_json_path(issuable) } }
%span.dropdown-toggle-text Due date
= icon('chevron-down')
.dropdown-menu.dropdown-menu-due-date
= dropdown_title('Due date')
= dropdown_content do
.js-due-date-calendar
- if issuable.project.labels.any?
.block.labels
.sidebar-collapsed-icon
......@@ -154,3 +182,4 @@
new IssuableContext('#{escape_javascript(current_user.to_json(only: [:username, :id, :name]))}');
new Subscription('.subscription')
new Sidebar();
new DueDateSelect();
class AddDueDateToIssues < ActiveRecord::Migration
def change
add_column :issues, :due_date, :date
add_index :issues, :due_date
end
end
......@@ -422,6 +422,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 +432,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
......
......@@ -112,7 +112,7 @@ describe 'Issues', feature: true do
end
describe 'filter issue' do
titles = ['foo','bar','baz']
titles = %w[foo bar baz]
titles.each_with_index do |title, index|
let!(title.to_sym) do
create(:issue, title: title,
......@@ -153,8 +153,94 @@ describe 'Issues', feature: true do
expect(first_issue).to include('baz')
end
describe 'sorting by due date' do
before do
foo.update(due_date: 1.day.from_now)
bar.update(due_date: 6.days.from_now)
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 do
foo.update(due_date: 1.day.from_now)
bar.update(due_date: 6.days.from_now)
end
it 'filters by none' do
visit namespace_project_issues_path(project.namespace, project, due_date: Issue::NoDueDate.name)
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::AnyDueDate.name)
expect(page).to have_content('foo')
expect(page).to have_content('bar')
expect(page).to have_content('baz')
end
it 'filters by due this week' do
foo.update(due_date: Date.today.beginning_of_week + 2.days)
bar.update(due_date: Date.today.end_of_week)
baz.update(due_date: Date.today - 8.days)
visit namespace_project_issues_path(project.namespace, project, due_date: Issue::DueThisWeek.name)
expect(page).to have_content('foo')
expect(page).to have_content('bar')
expect(page).not_to have_content('baz')
end
it 'filters by due this month' do
foo.update(due_date: Date.today.beginning_of_month + 2.days)
bar.update(due_date: Date.today.end_of_month)
baz.update(due_date: Date.today - 50.days)
visit namespace_project_issues_path(project.namespace, project, due_date: Issue::DueThisMonth.name)
expect(page).to have_content('foo')
expect(page).to have_content('bar')
expect(page).not_to have_content('baz')
end
it 'filters by overdue' do
foo.update(due_date: Date.today + 2.days)
bar.update(due_date: Date.today + 20.days)
baz.update(due_date: Date.yesterday)
visit namespace_project_issues_path(project.namespace, project, due_date: Issue::Overdue.name)
expect(page).not_to have_content('foo')
expect(page).not_to have_content('bar')
expect(page).to have_content('baz')
end
end
describe 'sorting by milestone' do
before :each do
before do
foo.milestone = newer_due_milestone
foo.save
bar.milestone = later_due_milestone
......@@ -177,7 +263,7 @@ describe 'Issues', feature: true do
describe 'combine filter and sort' do
let(:user2) { create(:user) }
before :each do
before do
foo.assignee = user2
foo.save
bar.assignee = user2
......@@ -224,7 +310,7 @@ describe 'Issues', feature: true do
let(:guest) { create(:user) }
before :each do
before do
project.team << [[guest], :guest]
end
......@@ -267,7 +353,7 @@ describe 'Issues', feature: true do
context 'by unauthorized user' do
let(:guest) { create(:user) }
before :each do
before do
project.team << [guest, :guest]
issue.milestone = milestone
issue.save
......@@ -285,7 +371,7 @@ describe 'Issues', feature: true do
describe 'removing assignee' do
let(:user2) { create(:user) }
before :each do
before do
issue.assignee = user2
issue.save
end
......
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