From fbd1f1fa6aa1f568c6a11f569b86bee8ab7466b1 Mon Sep 17 00:00:00 2001 From: Himanshu Kapoor <info@fleon.org> Date: Wed, 10 Jan 2024 13:38:23 +0000 Subject: [PATCH] Add support for inapplicable task items in RTE In rich text editor, add support for rendering inapplicable task items from markdown content in the plain text editor. Changelog: added --- .../content_editor/extensions/task_item.js | 32 ++++- .../services/markdown_serializer.js | 6 +- .../components/content_editor.scss | 5 + .../output_example_snapshots/html.yml | 6 +- .../prosemirror_json.yml | 30 +++-- .../extensions/task_item_spec.js | 115 ++++++++++++++++++ .../services/markdown_serializer_spec.js | 18 +++ 7 files changed, 195 insertions(+), 17 deletions(-) create mode 100644 spec/frontend/content_editor/extensions/task_item_spec.js diff --git a/app/assets/javascripts/content_editor/extensions/task_item.js b/app/assets/javascripts/content_editor/extensions/task_item.js index 849fd55034e46..1e19878be9b07 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 972b4acf523e2..3b759de57f2c7 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 97f2add4e7771..f412926ca02b5 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 ca51c26a760bf..de8467b318839 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 95e7003a202af..084b97e6be43c 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 0000000000000..a38a68112cdf9 --- /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 c329a12bcc4af..93e10cf9f5b9d 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( -- GitLab