From 2e9a6710e841b2153a03b44b0a5577a31cffb2a4 Mon Sep 17 00:00:00 2001
From: Andreas Fernandez <a.fernandez@scripting-base.de>
Date: Tue, 23 Aug 2022 13:32:44 +0200
Subject: [PATCH] [BUGFIX] List invalid field in FormEngine review

The FormEngine review module broke with the introduction of Bootstrap 5
due to changed API of its Popover module.

Our internal Popover wrapper is now used to render the list of failed
fields again. This also introduces another bugfix in the Popover wrapper
which updates the Popover's content once it was rendered already.

Additionally, a nasty workaround is added: previously, the field id was
stored as a data attribute to the list item in the validation list.
Somewhere in the Popover > Bootstrap > PopperJS handling, those custom
data attributes are removed before rendering the HTML. To circumvent
this issue, the field is now added as last element in the `classList`
DOMTokenList for later use.

Resolves: #94774
Releases: main, 11.5
Change-Id: I00d8196102f08548c8ec3433c1d0f0c8c7f05d04
Reviewed-on: https://review.typo3.org/c/Packages/TYPO3.CMS/+/75536
Tested-by: Benjamin Franzke <bfr@qbus.de>
Tested-by: core-ci <typo3@b13.com>
Tested-by: Jochen <rothjochen@gmail.com>
Tested-by: Andreas Fernandez <a.fernandez@scripting-base.de>
Reviewed-by: Benjamin Franzke <bfr@qbus.de>
Reviewed-by: Jochen <rothjochen@gmail.com>
Reviewed-by: Andreas Fernandez <a.fernandez@scripting-base.de>
---
 .../TypeScript/backend/form-engine-review.ts  | 68 ++++++-------------
 Build/Sources/TypeScript/backend/popover.ts   | 30 ++++----
 .../Public/JavaScript/form-engine-review.js   |  2 +-
 .../Resources/Public/JavaScript/popover.js    |  2 +-
 4 files changed, 40 insertions(+), 62 deletions(-)

diff --git a/Build/Sources/TypeScript/backend/form-engine-review.ts b/Build/Sources/TypeScript/backend/form-engine-review.ts
index ea3ee64c6abf..063a9eabf18c 100644
--- a/Build/Sources/TypeScript/backend/form-engine-review.ts
+++ b/Build/Sources/TypeScript/backend/form-engine-review.ts
@@ -15,6 +15,8 @@ import 'bootstrap';
 import $ from 'jquery';
 import FormEngine from '@typo3/backend/form-engine';
 import '@typo3/backend/element/icon-element';
+import Popover from './popover';
+import {Popover as BootstrapPopover} from 'bootstrap';
 
 /**
  * Module: @typo3/backend/form-engine-review
@@ -26,17 +28,12 @@ class FormEngineReview {
   /**
    * Class for the toggle button
    */
-  private toggleButtonClass: string;
-
-  /**
-   * Class for field list items
-   */
-  private fieldListItemClass: string;
+  private readonly toggleButtonClass: string = 't3js-toggle-review-panel';
 
   /**
    * Class of FormEngine labels
    */
-  private labelSelector: string;
+  private readonly labelSelector: string = '.t3js-formengine-label';
 
   /**
    * Fetches all fields that have a failed validation
@@ -62,12 +59,7 @@ class FormEngineReview {
       $('<typo3-backend-icon/>', {identifier: 'actions-info', size: 'small'}),
     );
 
-    $button.popover({
-      container: 'body',
-      html: true,
-      placement: 'bottom',
-    });
-
+    Popover.popover($button);
     $leastButtonBar.prepend($button);
   }
 
@@ -75,10 +67,6 @@ class FormEngineReview {
    * The constructor, set the class properties default values
    */
   constructor() {
-    this.toggleButtonClass = 't3js-toggle-review-panel';
-    this.fieldListItemClass = 't3js-field-item';
-    this.labelSelector = '.t3js-formengine-label';
-
     this.initialize();
   }
 
@@ -92,7 +80,6 @@ class FormEngineReview {
     $((): void => {
       FormEngineReview.attachButtonToModuleHeader(me);
     });
-    $document.on('click', '.' + this.fieldListItemClass, this.switchToField);
     $document.on('t3-formengine-postfieldvalidation', this.checkForReviewableField);
   }
 
@@ -109,34 +96,25 @@ class FormEngineReview {
 
       $invalidFields.each(function(this: Element): void {
         const $field: any = $(this);
-        const $input: any = $field.find('[data-formengine-validation-rules]');
-        let inputId: any = $input.attr('id');
-
-        if (typeof inputId === 'undefined') {
-          inputId = $input.parent().children('[id]').first().attr('id');
-        }
-
-        $list.append(
-          $('<a />', {
-            'class': 'list-group-item ' + me.fieldListItemClass,
-            'data-field-id': inputId,
-            'href': '#',
-          }).text($field.find(me.labelSelector).text()),
-        );
+        const $input: JQuery = $field.find('[data-formengine-validation-rules]');
+
+        const link = document.createElement('a');
+        link.classList.add('list-group-item');
+        link.href = '#';
+        link.textContent = $field.find(me.labelSelector).text();
+        link.addEventListener('click', (e: Event) => me.switchToField(e, $input));
+
+        $list.append(link);
       });
 
       $toggleButton.removeClass('hidden');
-
-      // bootstrap has no official API to update the content of a popover w/o destroying it
-      const $popover: any = $toggleButton.data('bs.popover');
-      if ($popover) {
-        $popover.options.html = true;
-        $popover.options.content = $list.wrapAll('<div>').parent().html();
-        $popover.setContent($popover.$tip);
-        $popover.$tip.addClass($popover.options.placement);
-      }
+      Popover.setOptions($toggleButton, <BootstrapPopover.Options>{
+        html: true,
+        content: $list[0]
+      });
     } else {
-      $toggleButton.addClass('hidden').popover('hide');
+      $toggleButton.addClass('hidden');
+      Popover.hide($toggleButton);
     }
   }
 
@@ -145,12 +123,10 @@ class FormEngineReview {
    *
    * @param {Event} e
    */
-  public switchToField = (e: Event): void => {
+  public switchToField = (e: Event, $referenceField: JQuery): void => {
     e.preventDefault();
 
-    const $listItem: any = $(e.currentTarget);
-    const referenceFieldId: string = $listItem.data('fieldId');
-    const $referenceField: any = $('#' + referenceFieldId);
+    const listItem: HTMLElement = e.currentTarget as HTMLElement;
 
     // iterate possibly nested tab panels
     $referenceField.parents('[id][role="tabpanel"]').each(function(this: Element): void {
diff --git a/Build/Sources/TypeScript/backend/popover.ts b/Build/Sources/TypeScript/backend/popover.ts
index 36d14aaec101..0144cc5b8dc4 100644
--- a/Build/Sources/TypeScript/backend/popover.ts
+++ b/Build/Sources/TypeScript/backend/popover.ts
@@ -66,16 +66,24 @@ class Popover {
   public setOptions($element: JQuery, options?: BootstrapPopover.Options): void {
     options = options || <BootstrapPopover.Options>{};
     options.html = true;
-    const title: string|(() => void) = options.title || $element.data('title') || '';
-    const content: string|(() => void) = options.content || $element.data('bs-content') || '';
+    const title: string = options.title || $element.data('title') || '';
+    const content: string = options.content || $element.data('bs-content') || '';
     $element
       .attr('data-bs-original-title', (title as string))
       .attr('data-bs-content', (content as string))
       .attr('data-bs-placement', 'auto')
 
+    delete options.title;
+    delete options.content;
     $.each(options, (key, value) => {
       this.setOption($element, key, value);
     });
+
+    const popover = $element.data('typo3.bs.popover');
+    popover.setContent({
+      '.popover-header': title,
+      '.popover-body': content
+    });
   }
 
   // noinspection JSMethodCanBeStatic
@@ -87,18 +95,12 @@ class Popover {
    * @param {String} value
    */
   public setOption($element: JQuery, key: string, value: string): void {
-    if (key === 'content') {
-      const popover = $element.data('typo3.bs.popover');
-      popover._config.content = value;
-      popover.setContent(popover.tip);
-    } else {
-      $element.each((i, el) => {
-        const popover = $(el).data('typo3.bs.popover');
-        if (popover) {
-          popover._config[key] = value;
-        }
-      });
-    }
+    $element.each((i, el) => {
+      const popover = $(el).data('typo3.bs.popover');
+      if (popover) {
+        popover._config[key] = value;
+      }
+    });
   }
 
   // noinspection JSMethodCanBeStatic
diff --git a/typo3/sysext/backend/Resources/Public/JavaScript/form-engine-review.js b/typo3/sysext/backend/Resources/Public/JavaScript/form-engine-review.js
index 97a314affdea..8e7ce4854bc9 100644
--- a/typo3/sysext/backend/Resources/Public/JavaScript/form-engine-review.js
+++ b/typo3/sysext/backend/Resources/Public/JavaScript/form-engine-review.js
@@ -10,4 +10,4 @@
  *
  * The TYPO3 project - inspiring people to share!
  */
-import"bootstrap";import $ from"jquery";import FormEngine from"@typo3/backend/form-engine.js";import"@typo3/backend/element/icon-element.js";class FormEngineReview{constructor(){this.checkForReviewableField=()=>{const t=this,e=FormEngineReview.findInvalidField(),i=$("."+this.toggleButtonClass);if(e.length>0){const n=$("<div />",{class:"list-group"});e.each((function(){const e=$(this),i=e.find("[data-formengine-validation-rules]");let o=i.attr("id");void 0===o&&(o=i.parent().children("[id]").first().attr("id")),n.append($("<a />",{class:"list-group-item "+t.fieldListItemClass,"data-field-id":o,href:"#"}).text(e.find(t.labelSelector).text()))})),i.removeClass("hidden");const o=i.data("bs.popover");o&&(o.options.html=!0,o.options.content=n.wrapAll("<div>").parent().html(),o.setContent(o.$tip),o.$tip.addClass(o.options.placement))}else i.addClass("hidden").popover("hide")},this.switchToField=t=>{t.preventDefault();const e=$(t.currentTarget).data("fieldId"),i=$("#"+e);i.parents('[id][role="tabpanel"]').each((function(){$('[aria-controls="'+$(this).attr("id")+'"]').tab("show")})),i.focus()},this.toggleButtonClass="t3js-toggle-review-panel",this.fieldListItemClass="t3js-field-item",this.labelSelector=".t3js-formengine-label",this.initialize()}static findInvalidField(){return $(document).find(".tab-content ."+FormEngine.Validation.errorClass)}static attachButtonToModuleHeader(t){const e=$(".t3js-module-docheader-bar-buttons").children().last().find('[role="toolbar"]'),i=$("<a />",{class:"btn btn-danger btn-sm hidden "+t.toggleButtonClass,href:"#",title:TYPO3.lang["buttons.reviewFailedValidationFields"]}).append($("<typo3-backend-icon/>",{identifier:"actions-info",size:"small"}));i.popover({container:"body",html:!0,placement:"bottom"}),e.prepend(i)}initialize(){const t=this,e=$(document);$((()=>{FormEngineReview.attachButtonToModuleHeader(t)})),e.on("click","."+this.fieldListItemClass,this.switchToField),e.on("t3-formengine-postfieldvalidation",this.checkForReviewableField)}}export default new FormEngineReview;
\ No newline at end of file
+import"bootstrap";import $ from"jquery";import FormEngine from"@typo3/backend/form-engine.js";import"@typo3/backend/element/icon-element.js";import Popover from"@typo3/backend/popover.js";class FormEngineReview{constructor(){this.toggleButtonClass="t3js-toggle-review-panel",this.labelSelector=".t3js-formengine-label",this.checkForReviewableField=()=>{const e=this,t=FormEngineReview.findInvalidField(),i=$("."+this.toggleButtonClass);if(t.length>0){const o=$("<div />",{class:"list-group"});t.each((function(){const t=$(this),i=t.find("[data-formengine-validation-rules]"),n=document.createElement("a");n.classList.add("list-group-item"),n.href="#",n.textContent=t.find(e.labelSelector).text(),n.addEventListener("click",(t=>e.switchToField(t,i))),o.append(n)})),i.removeClass("hidden"),Popover.setOptions(i,{html:!0,content:o[0]})}else i.addClass("hidden"),Popover.hide(i)},this.switchToField=(e,t)=>{e.preventDefault();e.currentTarget;t.parents('[id][role="tabpanel"]').each((function(){$('[aria-controls="'+$(this).attr("id")+'"]').tab("show")})),t.focus()},this.initialize()}static findInvalidField(){return $(document).find(".tab-content ."+FormEngine.Validation.errorClass)}static attachButtonToModuleHeader(e){const t=$(".t3js-module-docheader-bar-buttons").children().last().find('[role="toolbar"]'),i=$("<a />",{class:"btn btn-danger btn-sm hidden "+e.toggleButtonClass,href:"#",title:TYPO3.lang["buttons.reviewFailedValidationFields"]}).append($("<typo3-backend-icon/>",{identifier:"actions-info",size:"small"}));Popover.popover(i),t.prepend(i)}initialize(){const e=this,t=$(document);$((()=>{FormEngineReview.attachButtonToModuleHeader(e)})),t.on("t3-formengine-postfieldvalidation",this.checkForReviewableField)}}export default new FormEngineReview;
\ No newline at end of file
diff --git a/typo3/sysext/backend/Resources/Public/JavaScript/popover.js b/typo3/sysext/backend/Resources/Public/JavaScript/popover.js
index b8a9dc2e62c2..84e98a95d8e5 100644
--- a/typo3/sysext/backend/Resources/Public/JavaScript/popover.js
+++ b/typo3/sysext/backend/Resources/Public/JavaScript/popover.js
@@ -10,4 +10,4 @@
  *
  * The TYPO3 project - inspiring people to share!
  */
-import $ from"jquery";import{Popover as BootstrapPopover}from"bootstrap";class Popover{constructor(){this.DEFAULT_SELECTOR='[data-bs-toggle="popover"]',this.initialize()}initialize(t){t=t||this.DEFAULT_SELECTOR,$(t).each(((t,o)=>{const e=new BootstrapPopover(o);$(o).data("typo3.bs.popover",e)}))}popover(t){t.each(((t,o)=>{const e=new BootstrapPopover(o);$(o).data("typo3.bs.popover",e)}))}setOptions(t,o){(o=o||{}).html=!0;const e=o.title||t.data("title")||"",a=o.content||t.data("bs-content")||"";t.attr("data-bs-original-title",e).attr("data-bs-content",a).attr("data-bs-placement","auto"),$.each(o,((o,e)=>{this.setOption(t,o,e)}))}setOption(t,o,e){if("content"===o){const o=t.data("typo3.bs.popover");o._config.content=e,o.setContent(o.tip)}else t.each(((t,a)=>{const p=$(a).data("typo3.bs.popover");p&&(p._config[o]=e)}))}show(t){t.each(((t,o)=>{const e=$(o).data("typo3.bs.popover");e&&e.show()}))}hide(t){t.each(((t,o)=>{const e=$(o).data("typo3.bs.popover");e&&e.hide()}))}destroy(t){t.each(((t,o)=>{const e=$(o).data("typo3.bs.popover");e&&e.dispose()}))}toggle(t){t.each(((t,o)=>{const e=$(o).data("typo3.bs.popover");e&&e.toggle()}))}update(t){t.data("typo3.bs.popover")._popper.update()}}export default new Popover;
\ No newline at end of file
+import $ from"jquery";import{Popover as BootstrapPopover}from"bootstrap";class Popover{constructor(){this.DEFAULT_SELECTOR='[data-bs-toggle="popover"]',this.initialize()}initialize(t){t=t||this.DEFAULT_SELECTOR,$(t).each(((t,o)=>{const e=new BootstrapPopover(o);$(o).data("typo3.bs.popover",e)}))}popover(t){t.each(((t,o)=>{const e=new BootstrapPopover(o);$(o).data("typo3.bs.popover",e)}))}setOptions(t,o){(o=o||{}).html=!0;const e=o.title||t.data("title")||"",p=o.content||t.data("bs-content")||"";t.attr("data-bs-original-title",e).attr("data-bs-content",p).attr("data-bs-placement","auto"),delete o.title,delete o.content,$.each(o,((o,e)=>{this.setOption(t,o,e)}));t.data("typo3.bs.popover").setContent({".popover-header":e,".popover-body":p})}setOption(t,o,e){t.each(((t,p)=>{const a=$(p).data("typo3.bs.popover");a&&(a._config[o]=e)}))}show(t){t.each(((t,o)=>{const e=$(o).data("typo3.bs.popover");e&&e.show()}))}hide(t){t.each(((t,o)=>{const e=$(o).data("typo3.bs.popover");e&&e.hide()}))}destroy(t){t.each(((t,o)=>{const e=$(o).data("typo3.bs.popover");e&&e.dispose()}))}toggle(t){t.each(((t,o)=>{const e=$(o).data("typo3.bs.popover");e&&e.toggle()}))}update(t){t.data("typo3.bs.popover")._popper.update()}}export default new Popover;
\ No newline at end of file
-- 
GitLab