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.
This commit is contained in:
parent
ff43500024
commit
9f0083a96c
17 changed files with 167 additions and 8 deletions
|
@ -6,4 +6,28 @@ class Issue
|
|||
$(".issue-box .inline-update").on "change", "#issue_assignee_id", ->
|
||||
$(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
|
||||
|
|
|
@ -17,6 +17,8 @@ class MergeRequest
|
|||
|
||||
disableButtonIfEmptyField '#commit_message', '.accept_merge_request'
|
||||
|
||||
if $("a.close-mr-link").length
|
||||
$("li.task-list-item input:checkbox").prop("disabled", false)
|
||||
|
||||
# Local jQuery finder
|
||||
$: (selector) ->
|
||||
|
@ -72,6 +74,27 @@ class MergeRequest
|
|||
this.$('.remove_source_branch_in_progress').hide()
|
||||
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) ->
|
||||
this.$('.merge-request-tabs li').removeClass 'active'
|
||||
this.$('.tab-content').hide()
|
||||
|
|
|
@ -356,3 +356,6 @@ table {
|
|||
font-size: 42px;
|
||||
}
|
||||
|
||||
.task-status {
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
|
|
@ -122,3 +122,7 @@ ul.bordered-list {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
li.task-list-item {
|
||||
list-style-type: none;
|
||||
}
|
||||
|
|
|
@ -152,7 +152,7 @@ class Projects::IssuesController < Projects::ApplicationController
|
|||
def issue_params
|
||||
params.require(:issue).permit(
|
||||
:title, :assignee_id, :position, :description,
|
||||
:milestone_id, :state_event, label_ids: []
|
||||
:milestone_id, :state_event, :task_num, label_ids: []
|
||||
)
|
||||
end
|
||||
end
|
||||
|
|
|
@ -250,7 +250,7 @@ class Projects::MergeRequestsController < Projects::ApplicationController
|
|||
params.require(:merge_request).permit(
|
||||
:title, :assignee_id, :source_project_id, :source_branch,
|
||||
:target_project_id, :target_branch, :milestone_id,
|
||||
:state_event, :description, label_ids: []
|
||||
:state_event, :description, :task_num, label_ids: []
|
||||
)
|
||||
end
|
||||
end
|
||||
|
|
51
app/models/concerns/taskable.rb
Normal file
51
app/models/concerns/taskable.rb
Normal file
|
@ -0,0 +1,51 @@
|
|||
# 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'
|
|||
class Issue < ActiveRecord::Base
|
||||
include Issuable
|
||||
include InternalId
|
||||
include Taskable
|
||||
|
||||
ActsAsTaggableOn.strict_case_match = true
|
||||
|
||||
|
|
|
@ -25,6 +25,7 @@ require Rails.root.join("lib/static_model")
|
|||
|
||||
class MergeRequest < ActiveRecord::Base
|
||||
include Issuable
|
||||
include Taskable
|
||||
include InternalId
|
||||
|
||||
belongs_to :target_project, foreign_key: :target_project_id, class_name: "Project"
|
||||
|
|
|
@ -8,9 +8,14 @@ module Issues
|
|||
Issues::ReopenService.new(project, current_user, {}).execute(issue)
|
||||
when 'close'
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
if issue.previous_changes.include?('milestone_id')
|
||||
|
@ -28,5 +33,12 @@ module Issues
|
|||
|
||||
issue
|
||||
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
|
||||
|
|
|
@ -17,9 +17,15 @@ module MergeRequests
|
|||
MergeRequests::ReopenService.new(project, current_user, {}).execute(merge_request)
|
||||
when 'close'
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
if merge_request.previous_changes.include?('milestone_id')
|
||||
|
|
|
@ -26,6 +26,10 @@
|
|||
%span
|
||||
%i.fa.fa-clock-o
|
||||
= issue.milestone.title
|
||||
- if issue.tasks?
|
||||
%span.task-status
|
||||
= issue.task_status
|
||||
|
||||
.pull-right
|
||||
%small updated #{time_ago_with_tooltip(issue.updated_at, 'bottom', 'issue_update_ago')}
|
||||
|
||||
|
|
|
@ -48,7 +48,7 @@
|
|||
.description
|
||||
.wiki
|
||||
= preserve do
|
||||
= markdown @issue.description
|
||||
= markdown(@issue.description, parse_tasks: true)
|
||||
.context
|
||||
%cite.cgray
|
||||
= render partial: 'issue_context', locals: { issue: @issue }
|
||||
|
|
|
@ -27,7 +27,9 @@
|
|||
%span
|
||||
%i.fa.fa-clock-o
|
||||
= merge_request.milestone.title
|
||||
|
||||
- if merge_request.tasks?
|
||||
%span.task-status
|
||||
= merge_request.task_status
|
||||
|
||||
.pull-right
|
||||
%small updated #{time_ago_with_tooltip(merge_request.updated_at, 'bottom', 'merge_request_updated_ago')}
|
||||
|
|
|
@ -18,7 +18,7 @@
|
|||
.description
|
||||
.wiki
|
||||
= preserve do
|
||||
= markdown @merge_request.description
|
||||
= markdown(@merge_request.description, parse_tasks: true)
|
||||
|
||||
.context
|
||||
%cite.cgray
|
||||
|
|
|
@ -33,6 +33,11 @@ module Gitlab
|
|||
|
||||
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
|
||||
#
|
||||
# text - the source text
|
||||
|
@ -265,5 +270,24 @@ module Gitlab
|
|||
)
|
||||
link_to("#{prefix_text}##{identifier}", url, options)
|
||||
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
|
||||
|
|
|
@ -47,6 +47,10 @@ class Redcarpet::Render::GitlabHTML < Redcarpet::Render::HTML
|
|||
unless @template.instance_variable_get("@project_wiki") || @project.nil?
|
||||
full_document = h.create_relative_links(full_document)
|
||||
end
|
||||
h.gfm(full_document)
|
||||
if @options[:parse_tasks]
|
||||
h.gfm_with_tasks(full_document)
|
||||
else
|
||||
h.gfm(full_document)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
Loading…
Reference in a new issue