diff --git a/app/assets/javascripts/content_editor/extensions/task_item.js b/app/assets/javascripts/content_editor/extensions/task_item.js index 849fd55034e468c231c5c32200f4ef12714eb1fb..1e19878be9b070df0b3c2cccf24eecd8d0ac4eab 100644 --- a/app/assets/javascripts/content_editor/extensions/task_item.js +++ b/app/assets/javascripts/content_editor/extensions/task_item.js @@ -19,9 +19,17 @@ export default TaskItem.extend({ return checkbox?.checked; }, - renderHTML: (attributes) => ({ - 'data-checked': attributes.checked, - }), + renderHTML: (attributes) => attributes.checked && { 'data-checked': true }, + keepOnSplit: false, + }, + inapplicable: { + default: false, + parseHTML: (element) => { + const checkbox = element.querySelector('input[type=checkbox].task-list-item-checkbox'); + + return typeof checkbox?.dataset.inapplicable !== 'undefined'; + }, + renderHTML: (attributes) => attributes.inapplicable && { 'data-inapplicable': true }, keepOnSplit: false, }, }; @@ -33,6 +41,24 @@ export default TaskItem.extend({ tag: 'li.task-list-item', priority: PARSE_HTML_PRIORITY_HIGHEST, }, + { + tag: 'li.task-list-item.inapplicable s', + skip: true, + priority: PARSE_HTML_PRIORITY_HIGHEST, + }, ]; }, + + addNodeView() { + const nodeView = this.parent?.(); + return ({ node, ...args }) => { + const nodeViewInstance = nodeView({ node, ...args }); + + if (node.attrs.inapplicable) { + nodeViewInstance.dom.querySelector('input[type=checkbox]').disabled = true; + } + + return nodeViewInstance; + }; + }, }); diff --git a/app/assets/javascripts/content_editor/services/markdown_serializer.js b/app/assets/javascripts/content_editor/services/markdown_serializer.js index 972b4acf523e2fbee802418d4cec9cc6fd569fac..3b759de57f2c7c39650df171f9cbfb5e5a991847 100644 --- a/app/assets/javascripts/content_editor/services/markdown_serializer.js +++ b/app/assets/javascripts/content_editor/services/markdown_serializer.js @@ -228,7 +228,11 @@ const defaultSerializerConfig = { [TableHeader.name]: renderTableCell, [TableRow.name]: renderTableRow, [TaskItem.name]: preserveUnchanged((state, node) => { - state.write(`[${node.attrs.checked ? 'x' : ' '}] `); + let symbol = ' '; + if (node.attrs.inapplicable) symbol = '~'; + else if (node.attrs.checked) symbol = 'x'; + + state.write(`[${symbol}] `); if (!node.textContent) state.write(' '); state.renderContent(node); }), diff --git a/app/assets/stylesheets/components/content_editor.scss b/app/assets/stylesheets/components/content_editor.scss index 97f2add4e7771947259d1b2b2d9673d63f84ac4a..f412926ca02b5263e10de58535f4b5eb557ea19d 100644 --- a/app/assets/stylesheets/components/content_editor.scss +++ b/app/assets/stylesheets/components/content_editor.scss @@ -149,6 +149,11 @@ padding: $gl-spacing-scale-1 $gl-spacing-scale-3 0 0; margin: 0; } + + &[data-inapplicable] * { + text-decoration: line-through; + color: $gl-text-color-disabled; + } } } diff --git a/glfm_specification/output_example_snapshots/html.yml b/glfm_specification/output_example_snapshots/html.yml index ca51c26a760bfebbed0ab4ac2a55d66807754ff8..de8467b318839256f9e2fe188ae6a9ad31950dbf 100644 --- a/glfm_specification/output_example_snapshots/html.yml +++ b/glfm_specification/output_example_snapshots/html.yml @@ -7639,7 +7639,7 @@ <task-button></task-button><input type="checkbox" class="task-list-item-checkbox" disabled> incomplete</li> </ul> wysiwyg: |- - <ul dir="auto" start="1" parens="false" bullet="*" data-type="taskList"><li dir="auto" data-checked="false" data-type="taskItem"><label><input type="checkbox"><span></span></label><div><p dir="auto">incomplete</p></div></li></ul> + <ul dir="auto" start="1" parens="false" bullet="*" data-type="taskList"><li dir="auto" data-type="taskItem"><label><input type="checkbox"><span></span></label><div><p dir="auto">incomplete</p></div></li></ul> 07_01_00__gitlab_official_specification_markdown__task_list_items__002: canonical: | <ul> @@ -8477,7 +8477,7 @@ <task-button></task-button><input type="checkbox" class="task-list-item-checkbox" disabled> example</li> </ol> wysiwyg: |- - <ol dir="auto" start="1" parens="false" bullet="*" data-type="taskList"><li dir="auto" data-checked="true" data-type="taskItem"><label><input type="checkbox" checked="checked"><span></span></label><div><p dir="auto">hello</p></div></li><li dir="auto" data-checked="true" data-type="taskItem"><label><input type="checkbox" checked="checked"><span></span></label><div><p dir="auto">world</p></div></li><li dir="auto" data-checked="false" data-type="taskItem"><label><input type="checkbox"><span></span></label><div><p dir="auto">example</p></div></li></ol> + <ol dir="auto" start="1" parens="false" bullet="*" data-type="taskList"><li dir="auto" data-checked="true" data-type="taskItem"><label><input type="checkbox" checked="checked"><span></span></label><div><p dir="auto">hello</p></div></li><li dir="auto" data-checked="true" data-type="taskItem"><label><input type="checkbox" checked="checked"><span></span></label><div><p dir="auto">world</p></div></li><li dir="auto" data-type="taskItem"><label><input type="checkbox"><span></span></label><div><p dir="auto">example</p></div></li></ol> 08_04_46__gitlab_internal_extension_markdown__migrated_golden_master_examples__reference_for_project_wiki__001: canonical: | TODO: Write canonical HTML for this example @@ -8828,7 +8828,7 @@ <task-button></task-button><input type="checkbox" class="task-list-item-checkbox" checked disabled> bar</li> </ul> wysiwyg: |- - <ul dir="auto" start="1" parens="false" bullet="*" data-type="taskList"><li dir="auto" data-checked="false" data-type="taskItem"><label><input type="checkbox"><span></span></label><div><p dir="auto">foo</p></div></li><li dir="auto" data-checked="true" data-type="taskItem"><label><input type="checkbox" checked="checked"><span></span></label><div><p dir="auto">bar</p></div></li></ul> + <ul dir="auto" start="1" parens="false" bullet="*" data-type="taskList"><li dir="auto" data-type="taskItem"><label><input type="checkbox"><span></span></label><div><p dir="auto">foo</p></div></li><li dir="auto" data-checked="true" data-type="taskItem"><label><input type="checkbox" checked="checked"><span></span></label><div><p dir="auto">bar</p></div></li></ul> 09_04_00__gfm_undocumented_extensions_and_more_robust_test__task_lists__002: canonical: | <ul> diff --git a/glfm_specification/output_example_snapshots/prosemirror_json.yml b/glfm_specification/output_example_snapshots/prosemirror_json.yml index 95e7003a202affa32d91e17a734bf228f8b55649..084b97e6be43ce7fa6d972de4f539f92071ff601 100644 --- a/glfm_specification/output_example_snapshots/prosemirror_json.yml +++ b/glfm_specification/output_example_snapshots/prosemirror_json.yml @@ -20562,7 +20562,8 @@ { "type": "taskItem", "attrs": { - "checked": false + "checked": false, + "inapplicable": false }, "content": [ { @@ -20596,7 +20597,8 @@ { "type": "taskItem", "attrs": { - "checked": true + "checked": true, + "inapplicable": false }, "content": [ { @@ -21270,7 +21272,8 @@ { "type": "taskItem", "attrs": { - "checked": true + "checked": true, + "inapplicable": false }, "content": [ { @@ -21292,7 +21295,8 @@ { "type": "taskItem", "attrs": { - "checked": true + "checked": true, + "inapplicable": false }, "content": [ { @@ -21314,7 +21318,8 @@ { "type": "taskItem", "attrs": { - "checked": true + "checked": true, + "inapplicable": false }, "content": [ { @@ -23006,7 +23011,8 @@ { "type": "taskItem", "attrs": { - "checked": true + "checked": true, + "inapplicable": false }, "content": [ { @@ -23023,7 +23029,8 @@ { "type": "taskItem", "attrs": { - "checked": true + "checked": true, + "inapplicable": false }, "content": [ { @@ -23040,7 +23047,8 @@ { "type": "taskItem", "attrs": { - "checked": false + "checked": false, + "inapplicable": false }, "content": [ { @@ -23913,7 +23921,8 @@ { "type": "taskItem", "attrs": { - "checked": false + "checked": false, + "inapplicable": false }, "content": [ { @@ -23930,7 +23939,8 @@ { "type": "taskItem", "attrs": { - "checked": true + "checked": true, + "inapplicable": false }, "content": [ { diff --git a/spec/frontend/content_editor/extensions/task_item_spec.js b/spec/frontend/content_editor/extensions/task_item_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..a38a68112cdf9efd0593f278a9afbe81f5b4a61d --- /dev/null +++ b/spec/frontend/content_editor/extensions/task_item_spec.js @@ -0,0 +1,115 @@ +import TaskList from '~/content_editor/extensions/task_list'; +import TaskItem from '~/content_editor/extensions/task_item'; +import { createTestEditor, createDocBuilder } from '../test_utils'; + +describe('content_editor/extensions/task_item', () => { + let tiptapEditor; + let doc; + let p; + let taskList; + let taskItem; + + beforeEach(() => { + tiptapEditor = createTestEditor({ extensions: [TaskList, TaskItem] }); + + ({ + builders: { doc, p, taskList, taskItem }, + } = createDocBuilder({ + tiptapEditor, + names: { + taskItem: { nodeType: TaskItem.name }, + taskList: { nodeType: TaskList.name }, + }, + })); + }); + + it('renders a regular task item for non-inapplicable items', () => { + const initialDoc = doc(taskList(taskItem(p('foo')))); + + tiptapEditor.commands.setContent(initialDoc.toJSON()); + + expect(tiptapEditor.view.dom.querySelector('li')).toMatchInlineSnapshot(` + <li + data-checked="false" + dir="auto" + > + <label> + <input + type="checkbox" + /> + <span /> + </label> + <div> + <p + dir="auto" + > + foo + </p> + </div> + </li> + `); + }); + + it('renders task item as disabled if it is inapplicable', () => { + const initialDoc = doc(taskList(taskItem({ inapplicable: true }, p('foo')))); + + tiptapEditor.commands.setContent(initialDoc.toJSON()); + + expect(tiptapEditor.view.dom.querySelector('li')).toMatchInlineSnapshot(` + <li + data-checked="false" + data-inapplicable="true" + dir="auto" + > + <label> + <input + disabled="" + type="checkbox" + /> + <span /> + </label> + <div> + <p + dir="auto" + > + foo + </p> + </div> + </li> + `); + }); + + it('ignores any <s> tags in the task item', () => { + tiptapEditor.commands.setContent(` + <ul dir="auto" class="task-list"> + <li class="task-list-item inapplicable"> + <input disabled="" data-inapplicable="" class="task-list-item-checkbox" type="checkbox"> + <s>foo</s> + </li> + </ul> + `); + + expect(tiptapEditor.view.dom.querySelector('li')).toMatchInlineSnapshot(` + <li + data-checked="false" + data-inapplicable="true" + dir="auto" + > + <label> + <input + disabled="" + type="checkbox" + /> + <span /> + </label> + <div> + <p + dir="auto" + > + foo + </p> + </div> + </li> + `); + }); +}); diff --git a/spec/frontend/content_editor/services/markdown_serializer_spec.js b/spec/frontend/content_editor/services/markdown_serializer_spec.js index c329a12bcc4aff008ed0e638f57920c468ab60fc..93e10cf9f5b9d45bdaf52b61dbb4736784732d12 100644 --- a/spec/frontend/content_editor/services/markdown_serializer_spec.js +++ b/spec/frontend/content_editor/services/markdown_serializer_spec.js @@ -660,6 +660,24 @@ var a = 0; ); }); + it('correctly serializes a task list with inapplicable items', () => { + expect( + serialize( + taskList( + taskItem({ checked: true }, paragraph('list item 1')), + taskItem({ checked: true, inapplicable: true }, paragraph('list item 2')), + taskItem(paragraph('list item 3')), + ), + ), + ).toBe( + ` +* [x] list item 1 +* [~] list item 2 +* [ ] list item 3 + `.trim(), + ); + }); + it('correctly serializes bullet task list with different bullet styles', () => { expect( serialize(