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('&nbsp;');
       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