Commit 9f0083a9 authored by Vinnie Okada's avatar Vinnie Okada

Add task lists to issues and merge requests

Make the Markdown parser recognize "[x]" or "[ ]" at the beginning of a
list item and turn it into a checkbox input.  Users who can modify the
issue or MR can toggle the checkboxes directly or edit the Markdown to
manage the tasks.  Task status is also displayed in the MR and issue
lists.
parent ff435000
...@@ -6,4 +6,28 @@ class Issue ...@@ -6,4 +6,28 @@ class Issue
$(".issue-box .inline-update").on "change", "#issue_assignee_id", -> $(".issue-box .inline-update").on "change", "#issue_assignee_id", ->
$(this).submit() $(this).submit()
if $("a.btn-close").length
$("li.task-list-item input:checkbox").prop("disabled", false)
$(".task-list-item input:checkbox").on "click", ->
is_checked = $(this).prop("checked")
if $(this).is(":checked")
state_event = "task_check"
else
state_event = "task_uncheck"
mr_url = $("form.edit-issue").first().attr("action")
mr_num = mr_url.match(/\d+$/)
task_num = 0
$("li.task-list-item input:checkbox").each( (index, e) =>
if e == this
task_num = index + 1
)
$.ajax
type: "PATCH"
url: mr_url
data: "issue[state_event]=" + state_event +
"&issue[task_num]=" + task_num
@Issue = Issue @Issue = Issue
...@@ -17,6 +17,8 @@ class MergeRequest ...@@ -17,6 +17,8 @@ class MergeRequest
disableButtonIfEmptyField '#commit_message', '.accept_merge_request' disableButtonIfEmptyField '#commit_message', '.accept_merge_request'
if $("a.close-mr-link").length
$("li.task-list-item input:checkbox").prop("disabled", false)
# Local jQuery finder # Local jQuery finder
$: (selector) -> $: (selector) ->
...@@ -72,6 +74,27 @@ class MergeRequest ...@@ -72,6 +74,27 @@ class MergeRequest
this.$('.remove_source_branch_in_progress').hide() this.$('.remove_source_branch_in_progress').hide()
this.$('.remove_source_branch_widget.failed').show() this.$('.remove_source_branch_widget.failed').show()
this.$(".task-list-item input:checkbox").on "click", ->
is_checked = $(this).prop("checked")
if $(this).is(":checked")
state_event = "task_check"
else
state_event = "task_uncheck"
mr_url = $("form.edit-merge_request").first().attr("action")
mr_num = mr_url.match(/\d+$/)
task_num = 0
$("li.task-list-item input:checkbox").each( (index, e) =>
if e == this
task_num = index + 1
)
$.ajax
type: "PATCH"
url: mr_url
data: "merge_request[state_event]=" + state_event +
"&merge_request[task_num]=" + task_num
activateTab: (action) -> activateTab: (action) ->
this.$('.merge-request-tabs li').removeClass 'active' this.$('.merge-request-tabs li').removeClass 'active'
this.$('.tab-content').hide() this.$('.tab-content').hide()
......
...@@ -356,3 +356,6 @@ table { ...@@ -356,3 +356,6 @@ table {
font-size: 42px; font-size: 42px;
} }
.task-status {
margin-left: 10px;
}
...@@ -122,3 +122,7 @@ ul.bordered-list { ...@@ -122,3 +122,7 @@ ul.bordered-list {
} }
} }
} }
li.task-list-item {
list-style-type: none;
}
...@@ -152,7 +152,7 @@ class Projects::IssuesController < Projects::ApplicationController ...@@ -152,7 +152,7 @@ class Projects::IssuesController < Projects::ApplicationController
def issue_params def issue_params
params.require(:issue).permit( params.require(:issue).permit(
:title, :assignee_id, :position, :description, :title, :assignee_id, :position, :description,
:milestone_id, :state_event, label_ids: [] :milestone_id, :state_event, :task_num, label_ids: []
) )
end end
end end
...@@ -250,7 +250,7 @@ class Projects::MergeRequestsController < Projects::ApplicationController ...@@ -250,7 +250,7 @@ class Projects::MergeRequestsController < Projects::ApplicationController
params.require(:merge_request).permit( params.require(:merge_request).permit(
:title, :assignee_id, :source_project_id, :source_branch, :title, :assignee_id, :source_project_id, :source_branch,
:target_project_id, :target_branch, :milestone_id, :target_project_id, :target_branch, :milestone_id,
:state_event, :description, label_ids: [] :state_event, :description, :task_num, label_ids: []
) )
end end
end end
# Contains functionality for objects that can have task lists in their
# descriptions. Task list items can be added with Markdown like "* [x] Fix
# bugs".
#
# Used by MergeRequest and Issue
module Taskable
TASK_PATTERN_MD = /^(?<bullet> *[*-] *)\[(?<checked>[ xX])\]/.freeze
TASK_PATTERN_HTML = /^<li>\[(?<checked>[ xX])\]/.freeze
# Change the state of a task list item for this Taskable. Edit the object's
# description by finding the nth task item and changing its checkbox
# placeholder to "[x]" if +checked+ is true, or "[ ]" if it's false.
# Note: task numbering starts with 1
def update_nth_task(n, checked)
index = 0
check_char = checked ? 'x' : ' '
# Do this instead of using #gsub! so that ActiveRecord detects that a field
# has changed.
self.description = self.description.gsub(TASK_PATTERN_MD) do |match|
index += 1
case index
when n then "#{$LAST_MATCH_INFO[:bullet]}[#{check_char}]"
else match
end
end
save
end
# Return true if this object's description has any task list items.
def tasks?
description && description.match(TASK_PATTERN_MD)
end
# Return a string that describes the current state of this Taskable's task
# list items, e.g. "20 tasks (12 done, 8 unfinished)"
def task_status
return nil unless description
num_tasks = 0
num_done = 0
description.scan(TASK_PATTERN_MD) do
num_tasks += 1
num_done += 1 unless $LAST_MATCH_INFO[:checked] == ' '
end
"#{num_tasks} tasks (#{num_done} done, #{num_tasks - num_done} unfinished)"
end
end
...@@ -23,6 +23,7 @@ require 'file_size_validator' ...@@ -23,6 +23,7 @@ require 'file_size_validator'
class Issue < ActiveRecord::Base class Issue < ActiveRecord::Base
include Issuable include Issuable
include InternalId include InternalId
include Taskable
ActsAsTaggableOn.strict_case_match = true ActsAsTaggableOn.strict_case_match = true
......
...@@ -25,6 +25,7 @@ require Rails.root.join("lib/static_model") ...@@ -25,6 +25,7 @@ require Rails.root.join("lib/static_model")
class MergeRequest < ActiveRecord::Base class MergeRequest < ActiveRecord::Base
include Issuable include Issuable
include Taskable
include InternalId include InternalId
belongs_to :target_project, foreign_key: :target_project_id, class_name: "Project" belongs_to :target_project, foreign_key: :target_project_id, class_name: "Project"
......
...@@ -8,9 +8,14 @@ module Issues ...@@ -8,9 +8,14 @@ module Issues
Issues::ReopenService.new(project, current_user, {}).execute(issue) Issues::ReopenService.new(project, current_user, {}).execute(issue)
when 'close' when 'close'
Issues::CloseService.new(project, current_user, {}).execute(issue) Issues::CloseService.new(project, current_user, {}).execute(issue)
when 'task_check'
issue.update_nth_task(params[:task_num].to_i, true)
when 'task_uncheck'
issue.update_nth_task(params[:task_num].to_i, false)
end end
if params.present? && issue.update_attributes(params.except(:state_event)) if params.present? && issue.update_attributes(params.except(:state_event,
:task_num))
issue.reset_events_cache issue.reset_events_cache
if issue.previous_changes.include?('milestone_id') if issue.previous_changes.include?('milestone_id')
...@@ -28,5 +33,12 @@ module Issues ...@@ -28,5 +33,12 @@ module Issues
issue issue
end end
private
def update_task(issue, params, checked)
issue.update_nth_task(params[:task_num].to_i, checked)
params.except!(:task_num)
end
end end
end end
...@@ -17,9 +17,15 @@ module MergeRequests ...@@ -17,9 +17,15 @@ module MergeRequests
MergeRequests::ReopenService.new(project, current_user, {}).execute(merge_request) MergeRequests::ReopenService.new(project, current_user, {}).execute(merge_request)
when 'close' when 'close'
MergeRequests::CloseService.new(project, current_user, {}).execute(merge_request) MergeRequests::CloseService.new(project, current_user, {}).execute(merge_request)
when 'task_check'
merge_request.update_nth_task(params[:task_num].to_i, true)
when 'task_uncheck'
merge_request.update_nth_task(params[:task_num].to_i, false)
end end
if params.present? && merge_request.update_attributes(params.except(:state_event)) if params.present? && merge_request.update_attributes(
params.except(:state_event, :task_num)
)
merge_request.reset_events_cache merge_request.reset_events_cache
if merge_request.previous_changes.include?('milestone_id') if merge_request.previous_changes.include?('milestone_id')
......
...@@ -26,6 +26,10 @@ ...@@ -26,6 +26,10 @@
%span %span
%i.fa.fa-clock-o %i.fa.fa-clock-o
= issue.milestone.title = issue.milestone.title
- if issue.tasks?
%span.task-status
= issue.task_status
.pull-right .pull-right
%small updated #{time_ago_with_tooltip(issue.updated_at, 'bottom', 'issue_update_ago')} %small updated #{time_ago_with_tooltip(issue.updated_at, 'bottom', 'issue_update_ago')}
......
...@@ -48,7 +48,7 @@ ...@@ -48,7 +48,7 @@
.description .description
.wiki .wiki
= preserve do = preserve do
= markdown @issue.description = markdown(@issue.description, parse_tasks: true)
.context .context
%cite.cgray %cite.cgray
= render partial: 'issue_context', locals: { issue: @issue } = render partial: 'issue_context', locals: { issue: @issue }
......
...@@ -27,7 +27,9 @@ ...@@ -27,7 +27,9 @@
%span %span
%i.fa.fa-clock-o %i.fa.fa-clock-o
= merge_request.milestone.title = merge_request.milestone.title
- if merge_request.tasks?
%span.task-status
= merge_request.task_status
.pull-right .pull-right
%small updated #{time_ago_with_tooltip(merge_request.updated_at, 'bottom', 'merge_request_updated_ago')} %small updated #{time_ago_with_tooltip(merge_request.updated_at, 'bottom', 'merge_request_updated_ago')}
......
...@@ -18,7 +18,7 @@ ...@@ -18,7 +18,7 @@
.description .description
.wiki .wiki
= preserve do = preserve do
= markdown @merge_request.description = markdown(@merge_request.description, parse_tasks: true)
.context .context
%cite.cgray %cite.cgray
......
...@@ -33,6 +33,11 @@ module Gitlab ...@@ -33,6 +33,11 @@ module Gitlab
attr_reader :html_options attr_reader :html_options
def gfm_with_tasks(text, project = @project, html_options = {})
text = gfm(text, project, html_options)
parse_tasks(text)
end
# Public: Parse the provided text with GitLab-Flavored Markdown # Public: Parse the provided text with GitLab-Flavored Markdown
# #
# text - the source text # text - the source text
...@@ -265,5 +270,24 @@ module Gitlab ...@@ -265,5 +270,24 @@ module Gitlab
) )
link_to("#{prefix_text}##{identifier}", url, options) link_to("#{prefix_text}##{identifier}", url, options)
end end
# Turn list items that start with "[ ]" into HTML checkbox inputs.
def parse_tasks(text)
li_tag = '<li class="task-list-item">'
unchecked_box = '<input type="checkbox" value="on" disabled />'
checked_box = unchecked_box.sub(/\/>$/, 'checked="checked" />')
# Regexp captures don't seem to work when +text+ is an
# ActiveSupport::SafeBuffer, hence the `String.new`
String.new(text).gsub(Taskable::TASK_PATTERN_HTML) do
checked = $LAST_MATCH_INFO[:checked].downcase == 'x'
if checked
"#{li_tag}#{checked_box}"
else
"#{li_tag}#{unchecked_box}"
end
end
end
end end
end end
...@@ -47,6 +47,10 @@ class Redcarpet::Render::GitlabHTML < Redcarpet::Render::HTML ...@@ -47,6 +47,10 @@ class Redcarpet::Render::GitlabHTML < Redcarpet::Render::HTML
unless @template.instance_variable_get("@project_wiki") || @project.nil? unless @template.instance_variable_get("@project_wiki") || @project.nil?
full_document = h.create_relative_links(full_document) full_document = h.create_relative_links(full_document)
end end
h.gfm(full_document) if @options[:parse_tasks]
h.gfm_with_tasks(full_document)
else
h.gfm(full_document)
end
end end
end 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