diff --git a/app/assets/javascripts/behaviors/markdown/nodes/task_list_item.js b/app/assets/javascripts/behaviors/markdown/nodes/task_list_item.js index 56c2b17286da74b9d8dce9a5c8a7f105c20c733c..8932385565ecf322409faf58c5b514a12da9d27e 100644 --- a/app/assets/javascripts/behaviors/markdown/nodes/task_list_item.js +++ b/app/assets/javascripts/behaviors/markdown/nodes/task_list_item.js @@ -12,8 +12,8 @@ export default class TaskListItem extends Node { get schema() { return { attrs: { - done: { - default: false, + state: { + default: null, }, }, defining: true, @@ -25,7 +25,13 @@ export default class TaskListItem extends Node { tag: 'li.task-list-item', getAttrs: (el) => { const checkbox = el.querySelector('input[type=checkbox].task-list-item-checkbox'); - return { done: checkbox && checkbox.checked }; + if (checkbox?.matches('.indeterminate')) { + return { state: 'indeterminate' }; + } else if (checkbox?.checked) { + return { state: 'done' }; + } + + return {}; }, }, ], @@ -35,7 +41,12 @@ export default class TaskListItem extends Node { { class: 'task-list-item' }, [ 'input', - { type: 'checkbox', class: 'task-list-item-checkbox', checked: node.attrs.done }, + { + type: 'checkbox', + class: 'task-list-item-checkbox', + checked: node.attrs.state === 'done', + indeterminate: node.attrs.state === 'indeterminate', + }, ], ['div', { class: 'todo-content' }, 0], ]; @@ -44,7 +55,17 @@ export default class TaskListItem extends Node { } toMarkdown(state, node) { - state.write(`[${node.attrs.done ? 'x' : ' '}] `); + switch (node.attrs.state) { + case 'done': + state.write('[x] '); + break; + case 'indeterminate': + state.write('[-] '); + break; + default: + state.write('[ ] '); + break; + } state.renderContent(node); } } diff --git a/app/assets/javascripts/task_list.js b/app/assets/javascripts/task_list.js index 81d9d9d37a707e6f4b70c20e20ce10e78b72afb4..6384969f987ff8c77665053d4b20ddf81b437485 100644 --- a/app/assets/javascripts/task_list.js +++ b/app/assets/javascripts/task_list.js @@ -40,20 +40,33 @@ export default class TaskList { taskListField.value = taskListField.dataset.value; }); - $(this.taskListContainerSelector).taskList('enable'); - $(document).on('tasklist:changed', this.taskListContainerSelector, this.updateHandler); + this.enable(); } getTaskListTarget(e) { return e && e.currentTarget ? $(e.currentTarget) : $(this.taskListContainerSelector); } + updateIndeterminateTaskListItems(e) { + this.getTaskListTarget(e) + .find('.task-list-item-checkbox.indeterminate') + .prop('indeterminate', true) + .prop('disabled', true); + } + disableTaskListItems(e) { this.getTaskListTarget(e).taskList('disable'); + this.updateIndeterminateTaskListItems(); } enableTaskListItems(e) { this.getTaskListTarget(e).taskList('enable'); + this.updateIndeterminateTaskListItems(); + } + + enable() { + this.enableTaskListItems(); + $(document).on('tasklist:changed', this.taskListContainerSelector, this.updateHandler); } disable() { diff --git a/changelogs/unreleased/indeterminate-checkbox-gfm.yml b/changelogs/unreleased/indeterminate-checkbox-gfm.yml new file mode 100644 index 0000000000000000000000000000000000000000..d1b086959e4978a109bf20c065d23b9b8370ec92 --- /dev/null +++ b/changelogs/unreleased/indeterminate-checkbox-gfm.yml @@ -0,0 +1,5 @@ +--- +title: Render `[-]` as an indeterminate checkbox in GitLab Flavored Markdown +merge_request: 43208 +author: Ethan Reesor (@firelizzard) +type: added diff --git a/doc/user/img/completed_tasks_v13_3.png b/doc/user/img/completed_tasks_v13_3.png deleted file mode 100644 index 31e051852cbc408d18199ce657ad5d64b33c3b95..0000000000000000000000000000000000000000 Binary files a/doc/user/img/completed_tasks_v13_3.png and /dev/null differ diff --git a/doc/user/img/completed_tasks_v13_5.png b/doc/user/img/completed_tasks_v13_5.png new file mode 100644 index 0000000000000000000000000000000000000000..7316069177efcd24ccf2107d7f9e2f23176e7b5b Binary files /dev/null and b/doc/user/img/completed_tasks_v13_5.png differ diff --git a/doc/user/markdown.md b/doc/user/markdown.md index be6e483aa545c1e1d97d79d4f0782b2fd3666ceb..ee8159f43c559d832bd62f8148491385f496c574 100644 --- a/doc/user/markdown.md +++ b/doc/user/markdown.md @@ -459,30 +459,39 @@ In addition to this, links to some objects are also recognized and formatted. So ### Task lists +> Indeterminate checkboxes [introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/43208) in GitLab 13.5. + If this section is not rendered correctly, [view it in GitLab itself](https://gitlab.com/gitlab-org/gitlab/blob/master/doc/user/markdown.md#task-lists). -You can add task lists anywhere Markdown is supported, but you can only "click" -to toggle the boxes if they are in issues, merge requests, or comments. In other -places you must edit the Markdown manually to change the status by adding or -removing an `x` within the square brackets. +You can add task lists anywhere Markdown is supported, but you can only click to +toggle the boxes if they are in issues, merge requests, epics, or comments. In +other places you must edit the Markdown manually to change the status by adding +or removing an `x` within the square brackets. + +Besides complete and incomplete, tasks can also be indeterminate. An +indeterminate checkbox cannot be modified; clicking on an indeterminate checkbox +in an issue, merge request, or comment will have no effect. To create a task list, add a specially-formatted Markdown list. You can use either unordered or ordered lists: ```markdown - [x] Completed task +- [-] Indeterminate task - [ ] Incomplete task - - [ ] Sub-task 1 - - [x] Sub-task 2 + - [x] Sub-task 1 + - [-] Sub-task 2 - [ ] Sub-task 3 1. [x] Completed task +1. [-] Indeterminate task 1. [ ] Incomplete task - 1. [ ] Sub-task 1 - 1. [x] Sub-task 2 + 1. [x] Sub-task 1 + 1. [-] Sub-task 2 + 1. [ ] Sub-task 3 ``` - + ### Table of contents diff --git a/lib/banzai/filter/indeterminate_checkbox_filter.rb b/lib/banzai/filter/indeterminate_checkbox_filter.rb new file mode 100644 index 0000000000000000000000000000000000000000..81a38a2ca364a9a8e6fd95fc47c53a414a710c88 --- /dev/null +++ b/lib/banzai/filter/indeterminate_checkbox_filter.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +require "nokogiri" + +module Banzai + module Filter + class IndeterminateCheckboxFilter < HTML::Pipeline::Filter + SELECTOR = "li:contains('[-]')" + REGEX = /^(\s*)\[-\]/.freeze + + BOX = Nokogiri::HTML::DocumentFragment.parse( + '' + ).children.first.freeze + + def call + return doc unless doc.at(SELECTOR) + + doc.css(SELECTOR).each do |li| + next unless li.content.match(REGEX) + + text = li.children.first + next unless text.is_a?(Nokogiri::XML::Text) + next unless text.content.match(REGEX) + + text.add_previous_sibling(BOX.dup) + text.content = text.content.sub(REGEX, '\1') + + li.add_class('task-list-item') + li.parent.add_class('task-list') + end + + doc + end + end + end +end diff --git a/lib/banzai/pipeline/gfm_pipeline.rb b/lib/banzai/pipeline/gfm_pipeline.rb index 344afc9b33c63eac5ad6da2a396420239a9676fe..4da59eb76947703d2041c4f7bfca96851a048f52 100644 --- a/lib/banzai/pipeline/gfm_pipeline.rb +++ b/lib/banzai/pipeline/gfm_pipeline.rb @@ -35,8 +35,9 @@ def self.filters *reference_filters, Filter::EmojiFilter, Filter::TaskListFilter, + Filter::IndeterminateCheckboxFilter, Filter::InlineDiffFilter, - Filter::SetDirectionFilter + Filter::SetDirectionFilter, ] end diff --git a/spec/features/markdown/copy_as_gfm_spec.rb b/spec/features/markdown/copy_as_gfm_spec.rb index c9dc764f93bb8ab4c6f271d951f54c18db1b1aab..1373431b1c3f1a91b20748e3278cf7e89d70a6c8 100644 --- a/spec/features/markdown/copy_as_gfm_spec.rb +++ b/spec/features/markdown/copy_as_gfm_spec.rb @@ -614,6 +614,11 @@ def foo | b | 0 | 1 | GFM ) + + verify( + 'IndeterminateCheckboxFilter', + '* [-] foo' + ) end alias_method :gfm_to_html, :markdown diff --git a/spec/lib/banzai/filter/indeterminate_checkbox_filter_spec.rb b/spec/lib/banzai/filter/indeterminate_checkbox_filter_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..aa486191db43487cad226936fa71914a53ec1491 --- /dev/null +++ b/spec/lib/banzai/filter/indeterminate_checkbox_filter_spec.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +require 'fast_spec_helper' +require 'html/pipeline' +require 'support/helpers/filter_spec_helper' + +RSpec.describe Banzai::Filter::IndeterminateCheckboxFilter, lib: true do + include FilterSpecHelper + shared_examples 'a valid indeterminate task list item' do |html| + it "behaves correctly for `#{html}`" do + filtered = filter("