Repository: plone/mockup (language: python)

Issue:
required `pat-tinymce` leads to unsaveable forms.
### Steps to reproduce:

1. create a `required` `schema.Text` field with `pat-tinymce` enabled (eg. `plone.app.z3cform.widgets.richtext.RichTextWidget`)
2. go to the editform and save it empty
3. the backend validation comes back with error
4. fill out value in tiny
5. save button is still deactivated, because changeing the tiny value doesn't trigger `pat-validation` to re-validate -> you cannot save form.

This is due to the new z3cform textarea implementation which introduces the required parameter for the richtext field textarea now. In Plone 6.0 the plone.app.textfield markup ignored this parameter and only validated required in the backend.

So thats further why pat-validation deactivates the save button now if a required richtextfield has no input.

### Possible solution

try to refactor pat-tinymce to save the value on blur event which would trigger pat-validation again and should fix this problem.


Discussion / hints:

Fix patch:
diff --git a/src/pat/tinymce/tinymce--implementation.js b/src/pat/tinymce/tinymce--implementation.js
index 16dc0c649..1ffde6dbd 100644
--- a/src/pat/tinymce/tinymce--implementation.js
+++ b/src/pat/tinymce/tinymce--implementation.js
@@ -1,5 +1,6 @@
 import $ from "jquery";
 import I18n from "../../core/i18n";
+import events from "@patternslib/patternslib/src/core/events";
 import logger from "@patternslib/patternslib/src/core/logging";
 import _t from "../../core/i18n-wrapper";
 import utils from "../../core/utils";
@@ -10,6 +11,7 @@ let LinkModal = null;
 
 export default class TinyMCE {
     constructor(el, options) {
+        this.el = el;
         this.$el = $(el);
         this.options = options;
     }
@@ -145,8 +147,8 @@ export default class TinyMCE {
         if (theme && theme == 'dark') {
             css = 'oxide-dark';
         }
-        import (`tinymce/skins/ui/${css}/content.css`);
-        import (`tinymce/skins/ui/${css}/skin.css`);
+        import(`tinymce/skins/ui/${css}/content.css`);
+        import(`tinymce/skins/ui/${css}/skin.css`);
 
         const tinymce = (await import("tinymce/tinymce")).default;
         await import("tinymce/models/dom");
@@ -210,6 +212,7 @@ export default class TinyMCE {
             if (self.tiny === undefined || self.tiny === null) {
                 self.tiny = editor;
             }
+
         };
         tinyOptions["setup"] = (editor) => {
             editor.ui.registry.addMenuButton("inserttable", {
@@ -232,6 +235,14 @@ export default class TinyMCE {
                     ]);
                 },
             });
+            // handle 'change' event to ensure correct validation (eg. required textfield)
+            // eslint-disable-next-line no-unused-vars
+            editor.on("change", (e) => {
+                // setting tiny content manually
+                this.el.value = editor.getContent();
+                // dispatch "change" event for pat-validation
+                this.el.dispatchEvent(events.change_event());
+            });
         };
 
         await self.initLanguage();
@@ -305,33 +316,21 @@ export default class TinyMCE {
             return url;
         }
 
-        // BBB: TinyMCE 6 has renamed toolbar and menuitem plugins.
+        // BBB: TinyMCE 6+ has renamed toolbar and menuitem plugins.
         // map them here until they are updated in Plone's configuration:
         // menu: "formats" -> "styles"
-        if(tinyOptions?.menu?.format) {
+        if (tinyOptions?.menu?.format) {
             tinyOptions.menu.format.items = tinyOptions.menu.format.items.replace('formats', 'styles');
         }
         // toolbar: "styleselect" -> "styles"
-        if(tinyOptions?.toolbar) {
+        if (tinyOptions?.toolbar) {
             tinyOptions.toolbar = tinyOptions.toolbar.replace('styleselect', 'styles');
         }
 
         tinymce.init(tinyOptions);
         self.tiny = tinymce.get(self.tinyId);
-
-        /* tiny really should be doing this by default
-         * but this fixes overlays not saving data */
-        var $form = self.$el.parents("form");
-        $form.on("submit", function () {
-            if (self.options.inline === true) {
-                // save back from contenteditable to textarea
-                self.$el.val(self.tiny.getContent());
-            } else {
-                // normal case
-                self.tiny.save();
-            }
-        });
     }
+
     destroy() {
         if (this.tiny) {
             if (this.options.inline === true) {


Test patch:
diff --git a/src/pat/tinymce/tinymce.test.js b/src/pat/tinymce/tinymce.test.js
index 8b9ca40aa..8bbcb72af 100644
--- a/src/pat/tinymce/tinymce.test.js
+++ b/src/pat/tinymce/tinymce.test.js
@@ -14,9 +14,15 @@ var createTinymce = async function (options) {
     );
 };
 
+const registry_scan = async () => {
+    registry.scan(document.body);
+    await utils.timeout(10);
+};
+
 describe("TinyMCE", function () {
     afterEach(function () {
-        $("body").empty();
+        document.body.innerHTML = "";
+        tinymce.activeEditor?.remove();
         this.server.restore();
     });
 
@@ -111,27 +117,19 @@ describe("TinyMCE", function () {
     });
 
     it("creates tinymce", async function () {
-        var $el = $(
-            "<div>" + '  <textarea class="pat-tinymce">' + "  </textarea>" + "</div>"
-        ).appendTo("body");
-        registry.scan($el);
-        await utils.timeout(10);
-        expect($el.children().length).toBeGreaterThan(1);
-        tinymce.get(0).remove();
+        document.body.innerHTML = `
+            <div><textarea class="pat-tinymce"></textarea></div>
+            <div><input type="submit" value="save"></div>
+        `;
+        await registry_scan();
+        expect(document.querySelectorAll(".tox-tinymce").length).toBe(1);
     });
 
     it.skip("maintains an initial textarea value", async function () {
-        var $el = $(
-            "<div>" +
-                '  <textarea class="pat-tinymce">' +
-                "    foobar" +
-                "  </textarea>" +
-                "</div>"
-        ).appendTo("body");
-        registry.scan($el);
-        await utils.timeout(10);
-        expect(tinymce.get(0).getContent()).toEqual("<p>foobar</p>");
-        tinymce.get(0).remove();
+        document.body.innerHTML = `<div><textarea class="pat-tinymce"><p>foobar</p></textarea></div>`;
+        await registry_scan();
+        var activeTiny = tinymce.activeEditor;
+        expect(activeTiny.getContent()).toEqual("<p>foobar</p>");
     });
 
     it("loads buttons for plugins", async function () {
@@ -144,7 +142,6 @@ describe("TinyMCE", function () {
         expect(tinymce.get(0).options.get('plugins')).toContain("plonelink");
         expect(tinymce.get(0).options.get('toolbar')).toContain("plonelink");
         expect(tinymce.get(0).options.get('toolbar')).toContain("ploneimage");
-        tinymce.get(0).remove();
     });
 
     it.skip("on form submit, save data to form", async function () {
@@ -252,6 +249,35 @@ describe("TinyMCE", function () {
         );
     });
 
+    it("test inline tinyMCE", async function () {
+        document.body.innerHTML = `
+            <textarea class="pat-tinymce" data-pat-tinymce='{"inline": true}'></textarea>
+            <input type="submit" value="save">
+        `;
+        await registry_scan();
+
+        var el = document.querySelector("textarea");
+        var id = el.id;
+
+        var edit_el = document.getElementById(`${id}-editable`);
+        var activeEditor = tinymce.activeEditor;
+
+        // check, if everything is in place
+        expect(edit_el.nodeName).toEqual("DIV");
+        expect(activeEditor.getContent()).toEqual(el.innerHTML);
+
+        // check, if changes are correct on element blur
+        activeEditor.focus();
+        var changed_txt = "changed contents";
+        edit_el.innerHTML = changed_txt;
+        document.querySelector("[type='submit']").focus();
+        await utils.timeout(5);
+
+        // TODO: need to figure out how to track changes with the new "change"
+        // event when focus is moved away
+        //expect(el.value).toEqual(changed_txt);
+    });
+
     it.skip("test add link", async function () {
         var pattern = await createTinymce({
             prependToUrl: "resolveuid/",
@@ -277,37 +303,39 @@ describe("TinyMCE", function () {
         var modal = pattern.instance.linkModal;
         modal.linkType = "external";
         modal.linkTypes.external.getEl().attr("value", "http://foobar");
-        expect(pattern.instance.linkModal.getLinkUrl()).to.equal("http://foobar");
+        expect(modal.getLinkUrl()).to.equal("http://foobar");
     });
 
-    // it("test add email link", function () {
-    //     var pattern = createTinymce();
-    //     pattern.addLinkClicked();
-    //     pattern.linkModal.linkType = "email";
-    //     pattern.linkModal.linkTypes.email.getEl().attr("value", "foo@bar.com");
-    //     expect(pattern.linkModal.getLinkUrl()).to.equal("mailto:foo@bar.com");
-    // });
+    it.skip("test add email link", async function () {
+        var pattern = await createTinymce();
+        pattern.instance.addLinkClicked();
+        var modal = pattern.instance.linkModal;
+        modal.linkType = "email";
+        modal.linkTypes.email.getEl().attr("value", "foo@bar.com");
+        expect(modal.getLinkUrl()).to.equal("mailto:foo@bar.com");
+    });
 
-    // it("test add image link", function () {
-    //     var pattern = createTinymce({
-    //         prependToUrl: "resolveuid/",
-    //         linkAttribute: "UID",
-    //         prependToScalePart: "/@@images/image/",
-    //     });
-    //     pattern.addImageClicked();
-    //     pattern.imageModal.linkTypes.image.getEl().select2("data", {
-    //         UID: "foobar",
-    //         portal_type: "Document",
-    //         Title: "Foobar",
-    //         path: "/foobar",
-    //     });
+    it.skip("test add image link", async function () {
+        var pattern = await createTinymce({
+            prependToUrl: "resolveuid/",
+            linkAttribute: "UID",
+            prependToScalePart: "/@@images/image/",
+        });
+        pattern.instance.addImageClicked();
+        var modal = pattern.instance.imageModal;
+        modal.linkTypes.image.getEl().select2("data", {
+            UID: "foobar",
+            portal_type: "Document",
+            Title: "Foobar",
+            path: "/foobar",
+        });
 
-    //     pattern.imageModal.linkType = "image";
-    //     pattern.imageModal.$scale.find('[value="thumb"]')[0].selected = true;
-    //     expect(pattern.imageModal.getLinkUrl()).to.equal(
-    //         "resolveuid/foobar/@@images/image/thumb"
-    //     );
-    // });
+        modal.linkType = "image";
+        modal.$scale.find('[value="thumb"]')[0].selected = true;
+        expect(modal.getLinkUrl()).to.equal(
+            "resolveuid/foobar/@@images/image/thumb"
+        );
+    });
 
     // it("test add image link upload", function () {
     //     var $el = $(
@@ -636,36 +664,4 @@ describe("TinyMCE", function () {
     //     }, 100);
     // });
 
-    it("test inline tinyMCE roundtrip", async function () {
-        var $container = $(
-            "<form>" +
-                '<textarea class="pat-tinymce" data-pat-tinymce=\'{"inline": true}\'>' +
-                "<h1>just testing</h1>" +
-                "</textarea>" +
-                "</form>"
-        ).appendTo("body");
-        registry.scan($container);
-        await utils.timeout(10);
-
-        var $el = $container.find("textarea");
-        var id = $el.attr("id");
-
-        var $editable = $container.find("#" + id + "-editable");
-
-        // check, if everything is in place
-        expect($editable.is("div")).toEqual(true);
-        expect($editable.html()).toEqual($el.val());
-
-        // check, if changes are submitted on form submit
-        var changed_txt = "changed contents";
-        $editable.html(changed_txt);
-
-        // Avoid error when running tests: "Some of your tests did a full page reload!"
-        $container.on("submit", function (e) {
-            e.preventDefault();
-        });
-        $container.trigger("submit");
-        expect($el.val()).toEqual(changed_txt);
-        tinymce.get(0).remove();
-    });
 });