From 377a61e7fde079b3fec3a679c4675520c3c07edf Mon Sep 17 00:00:00 2001
From: Andreas Fernandez <a.fernandez@scripting-base.de>
Date: Fri, 24 Feb 2023 13:14:19 +0100
Subject: [PATCH] [BUGFIX] Avoid timing issue when loading LiveSearch form

There was a timing issue related to the LiveSearch form.
`Modal.advanced()` invokes an AJAX request, while the event handler for
`typo3-modal-shown` expects that the request sent a response.

This might not be the case when either the network is slow or animations
are disabled as the event is always faster dispatched than the request
can send a response.

The generic handling is now moved into an `ajaxCallback`, executed when
fetching the data finished. To be able to still focus the search field
and mark its content, event handlers for 'modal-loaded' and
'typo3-modal-shown' events are installed, as both events may be
dispatched in any order.

Resolves: #100025
Releases: main
Change-Id: I8aca8ba330af55ce915a414dc5a52ba6eb2af1f1
Reviewed-on: https://review.typo3.org/c/Packages/TYPO3.CMS/+/77941
Tested-by: core-ci <typo3@b13.com>
Tested-by: Georg Ringer <georg.ringer@gmail.com>
Reviewed-by: Georg Ringer <georg.ringer@gmail.com>
---
 .../TypeScript/backend/toolbar/live-search.ts | 118 +++++++++++-------
 .../Public/JavaScript/toolbar/live-search.js  |   2 +-
 2 files changed, 71 insertions(+), 49 deletions(-)

diff --git a/Build/Sources/TypeScript/backend/toolbar/live-search.ts b/Build/Sources/TypeScript/backend/toolbar/live-search.ts
index 6add4599e150..0057614ed3dd 100644
--- a/Build/Sources/TypeScript/backend/toolbar/live-search.ts
+++ b/Build/Sources/TypeScript/backend/toolbar/live-search.ts
@@ -72,7 +72,7 @@ class LiveSearch {
       .map((item: [string, string]): SearchOption => {
         const trimmedKey = item[0].replace('livesearch-option-', '');
         const [key, value] = trimmedKey.split('-', 2);
-        return { key, value };
+        return { key, value }
       });
 
     const searchOptions = this.composeSearchOptions(persistedSearchOptions);
@@ -87,61 +87,83 @@ class LiveSearch {
       content: url.toString(),
       title: lll('labels.search'),
       severity: SeverityEnum.notice,
-      size: Modal.sizes.medium
-    });
+      size: Modal.sizes.medium,
+      ajaxCallback: (): void => {
+        const liveSearchContainer = modal.querySelector('typo3-backend-live-search')
+        const searchField = liveSearchContainer.querySelector('input[type="search"]') as HTMLInputElement;
+        const searchForm = searchField.closest('form');
+
+        new RegularEvent('submit', (e: SubmitEvent): void => {
+          e.preventDefault();
+
+          const formData = new FormData(searchForm);
+          this.search(formData).then((): void => {
+            const query = formData.get('query').toString();
+            BrowserSession.set('livesearch-term', query);
+          });
+          const optionCounterElement = searchForm.querySelector('[data-active-options-counter]') as HTMLElement;
+          const count = parseInt(optionCounterElement.dataset.activeOptionsCounter, 10);
+          optionCounterElement.querySelector('output').textContent = count.toString(10);
+          optionCounterElement.classList.toggle('hidden', count === 0);
+        }).bindTo(searchForm);
+
+        searchField.clearable({
+          onClear: (): void => {
+            searchForm.requestSubmit();
+          },
+        });
 
-    modal.addEventListener('typo3-modal-shown', () => {
-      const liveSearchContainer = modal.querySelector('typo3-backend-live-search');
-      const searchField = liveSearchContainer.querySelector('input[type="search"]') as HTMLInputElement;
-      const searchForm = searchField.closest('form');
+        const searchResultContainer: ResultContainer = document.querySelector('typo3-backend-live-search-result-container') as ResultContainer;
+        new RegularEvent('live-search:item-chosen', (): void => {
+          Modal.dismiss();
+        }).bindTo(searchResultContainer);
 
-      new RegularEvent('submit', (e: SubmitEvent): void => {
-        e.preventDefault();
+        new RegularEvent('typo3:live-search:option-invoked', (e: CustomEvent): void => {
+          const optionCounterElement = searchForm.querySelector('[data-active-options-counter]') as HTMLElement;
+          let count = parseInt(optionCounterElement.dataset.activeOptionsCounter, 10);
+          count = e.detail.active ? count + 1 : count - 1;
 
-        const formData = new FormData(searchForm);
-        this.search(formData).then((): void => {
-          const query = formData.get('query').toString();
-          BrowserSession.set('livesearch-term', query);
-        });
-        const optionCounterElement = searchForm.querySelector('[data-active-options-counter]') as HTMLElement;
-        const count = parseInt(optionCounterElement.dataset.activeOptionsCounter, 10);
-        optionCounterElement.querySelector('output').textContent = count.toString(10);
-        optionCounterElement.classList.toggle('hidden', count === 0);
-      }).bindTo(searchForm);
-
-      searchField.clearable({
-        onClear: (): void => {
-          searchForm.requestSubmit();
-        },
-      });
-      searchField.focus();
-      searchField.select();
-
-      const searchResultContainer: ResultContainer = document.querySelector('typo3-backend-live-search-result-container') as ResultContainer;
-      new RegularEvent('live-search:item-chosen', (): void => {
-        Modal.dismiss();
-      }).bindTo(searchResultContainer);
+          // Update data attribute only, the visible text content is updated in the submit handler
+          optionCounterElement.dataset.activeOptionsCounter = count.toString(10);
+        }).bindTo(liveSearchContainer);
 
-      new RegularEvent('typo3:live-search:option-invoked', (e: CustomEvent): void => {
-        const optionCounterElement = searchForm.querySelector('[data-active-options-counter]') as HTMLElement;
-        let count = parseInt(optionCounterElement.dataset.activeOptionsCounter, 10);
-        count = e.detail.active ? count + 1 : count - 1;
+        new RegularEvent('hide.bs.dropdown', (): void => {
+          searchForm.requestSubmit();
+        }).bindTo(modal.querySelector(Identifiers.searchOptionDropdownToggle));
 
-        // Update data attribute only, the visible text content is updated in the submit handler
-        optionCounterElement.dataset.activeOptionsCounter = count.toString(10);
-      }).bindTo(liveSearchContainer);
+        new DebounceEvent('input', (): void => {
+          searchForm.requestSubmit();
+        }).bindTo(searchField);
 
-      new RegularEvent('hide.bs.dropdown', (): void => {
-        searchForm.requestSubmit();
-      }).bindTo(modal.querySelector(Identifiers.searchOptionDropdownToggle));
+        new RegularEvent('keydown', this.handleKeyDown).bindTo(searchField);
 
-      new DebounceEvent('input', (): void => {
         searchForm.requestSubmit();
-      }).bindTo(searchField);
-
-      new RegularEvent('keydown', this.handleKeyDown).bindTo(searchField);
+      }
+    });
 
-      searchForm.requestSubmit();
+    /**
+     * The events `modal-loaded` and `typo3-modal-shown` are dispatched in any order, therefore we have to listen to
+     * both events to handle search field focus. Unfortunately, there's currently a bug that makes it impossible using
+     * Promises  instead, which would be much better: https://forge.typo3.org/issues/100026
+     *
+     * Once the aforementioned issue is fixed, we may use this instead:
+     *
+     * ```
+     * Promise.all([
+     *   new Promise(res1 => modal.addEventListener('modal-loaded', res1)),
+     *   new Promise(res2 => modal.addEventListener('typo3-modal-shown', res2))
+     * ]).then((): void => {
+     *   // do stuff here
+     * });
+     */
+    ['modal-loaded', 'typo3-modal-shown'].forEach((eventToListenOn: string) => {
+      modal.addEventListener(eventToListenOn, () => {
+        const searchField = modal.querySelector('input[type="search"]') as HTMLInputElement|null;
+        if (searchField !== null) {
+          searchField.focus();
+          searchField.select();
+        }
+      });
     });
   }
 
@@ -171,7 +193,7 @@ class LiveSearch {
     }
 
     this.updateSearchResults(resultSet);
-  };
+  }
 
   private handleKeyDown(e: KeyboardEvent): void {
     if (e.key !== 'ArrowDown') {
diff --git a/typo3/sysext/backend/Resources/Public/JavaScript/toolbar/live-search.js b/typo3/sysext/backend/Resources/Public/JavaScript/toolbar/live-search.js
index ee1173571afc..870a94d115c3 100644
--- a/typo3/sysext/backend/Resources/Public/JavaScript/toolbar/live-search.js
+++ b/typo3/sysext/backend/Resources/Public/JavaScript/toolbar/live-search.js
@@ -10,4 +10,4 @@
  *
  * The TYPO3 project - inspiring people to share!
  */
-import{lll}from"@typo3/core/lit-helper.js";import Modal from"@typo3/backend/modal.js";import"@typo3/backend/element/icon-element.js";import"@typo3/backend/input/clearable.js";import"@typo3/backend/live-search/element/search-option-item.js";import"@typo3/backend/live-search/element/show-all.js";import"@typo3/backend/live-search/live-search-shortcut.js";import DocumentService from"@typo3/core/document-service.js";import RegularEvent from"@typo3/core/event/regular-event.js";import DebounceEvent from"@typo3/core/event/debounce-event.js";import{SeverityEnum}from"@typo3/backend/enum/severity.js";import AjaxRequest from"@typo3/core/ajax/ajax-request.js";import BrowserSession from"@typo3/backend/storage/browser-session.js";import{componentName as resultContainerComponentName}from"@typo3/backend/live-search/element/result/result-container.js";var Identifiers;!function(e){e.toolbarItem=".t3js-topbar-button-search",e.searchOptionDropdownToggle=".t3js-search-provider-dropdown-toggle"}(Identifiers||(Identifiers={}));class LiveSearch{constructor(){this.search=async e=>{let t=null;if(""!==e.get("query").toString()){document.querySelector(resultContainerComponentName).loading=!0;const o=await new AjaxRequest(TYPO3.settings.ajaxUrls.livesearch).post(e);t=await o.raw().json()}this.updateSearchResults(t)},DocumentService.ready().then((()=>{this.registerEvents()}))}registerEvents(){new RegularEvent("click",(()=>{this.openSearchModal()})).delegateTo(document,Identifiers.toolbarItem),new RegularEvent("typo3:live-search:trigger-open",(()=>{Modal.currentModal||this.openSearchModal()})).bindTo(document)}openSearchModal(){const e=new URL(TYPO3.settings.ajaxUrls.livesearch_form,window.location.origin);e.searchParams.set("query",BrowserSession.get("livesearch-term")??"");const t=Object.entries(BrowserSession.getByPrefix("livesearch-option-")).filter((e=>"1"===e[1])).map((e=>{const t=e[0].replace("livesearch-option-",""),[o,r]=t.split("-",2);return{key:o,value:r}})),o=this.composeSearchOptions(t);for(const[t,r]of Object.entries(o))for(const o of r)e.searchParams.append(`${t}[]`,o);const r=Modal.advanced({type:Modal.types.ajax,content:e.toString(),title:lll("labels.search"),severity:SeverityEnum.notice,size:Modal.sizes.medium});r.addEventListener("typo3-modal-shown",(()=>{const e=r.querySelector("typo3-backend-live-search"),t=e.querySelector('input[type="search"]'),o=t.closest("form");new RegularEvent("submit",(e=>{e.preventDefault();const t=new FormData(o);this.search(t).then((()=>{const e=t.get("query").toString();BrowserSession.set("livesearch-term",e)}));const r=o.querySelector("[data-active-options-counter]"),n=parseInt(r.dataset.activeOptionsCounter,10);r.querySelector("output").textContent=n.toString(10),r.classList.toggle("hidden",0===n)})).bindTo(o),t.clearable({onClear:()=>{o.requestSubmit()}}),t.focus(),t.select();const n=document.querySelector("typo3-backend-live-search-result-container");new RegularEvent("live-search:item-chosen",(()=>{Modal.dismiss()})).bindTo(n),new RegularEvent("typo3:live-search:option-invoked",(e=>{const t=o.querySelector("[data-active-options-counter]");let r=parseInt(t.dataset.activeOptionsCounter,10);r=e.detail.active?r+1:r-1,t.dataset.activeOptionsCounter=r.toString(10)})).bindTo(e),new RegularEvent("hide.bs.dropdown",(()=>{o.requestSubmit()})).bindTo(r.querySelector(Identifiers.searchOptionDropdownToggle)),new DebounceEvent("input",(()=>{o.requestSubmit()})).bindTo(t),new RegularEvent("keydown",this.handleKeyDown).bindTo(t),o.requestSubmit()}))}composeSearchOptions(e){const t={};return e.forEach((e=>{void 0===t[e.key]&&(t[e.key]=[]),t[e.key].push(e.value)})),t}handleKeyDown(e){if("ArrowDown"!==e.key)return;e.preventDefault();document.querySelector("typo3-backend-live-search").querySelector("typo3-backend-live-search-result-item")?.focus()}updateSearchResults(e){document.querySelector("typo3-backend-live-search-show-all").parentElement.hidden=null===e||0===e.length;const t=document.querySelector("typo3-backend-live-search-result-container");t.results=e,t.loading=!1}}export default top.TYPO3.LiveSearch??new LiveSearch;
\ No newline at end of file
+import{lll}from"@typo3/core/lit-helper.js";import Modal from"@typo3/backend/modal.js";import"@typo3/backend/element/icon-element.js";import"@typo3/backend/input/clearable.js";import"@typo3/backend/live-search/element/search-option-item.js";import"@typo3/backend/live-search/element/show-all.js";import"@typo3/backend/live-search/live-search-shortcut.js";import DocumentService from"@typo3/core/document-service.js";import RegularEvent from"@typo3/core/event/regular-event.js";import DebounceEvent from"@typo3/core/event/debounce-event.js";import{SeverityEnum}from"@typo3/backend/enum/severity.js";import AjaxRequest from"@typo3/core/ajax/ajax-request.js";import BrowserSession from"@typo3/backend/storage/browser-session.js";import{componentName as resultContainerComponentName}from"@typo3/backend/live-search/element/result/result-container.js";var Identifiers;!function(e){e.toolbarItem=".t3js-topbar-button-search",e.searchOptionDropdownToggle=".t3js-search-provider-dropdown-toggle"}(Identifiers||(Identifiers={}));class LiveSearch{constructor(){this.search=async e=>{let t=null;if(""!==e.get("query").toString()){document.querySelector(resultContainerComponentName).loading=!0;const o=await new AjaxRequest(TYPO3.settings.ajaxUrls.livesearch).post(e);t=await o.raw().json()}this.updateSearchResults(t)},DocumentService.ready().then((()=>{this.registerEvents()}))}registerEvents(){new RegularEvent("click",(()=>{this.openSearchModal()})).delegateTo(document,Identifiers.toolbarItem),new RegularEvent("typo3:live-search:trigger-open",(()=>{Modal.currentModal||this.openSearchModal()})).bindTo(document)}openSearchModal(){const e=new URL(TYPO3.settings.ajaxUrls.livesearch_form,window.location.origin);e.searchParams.set("query",BrowserSession.get("livesearch-term")??"");const t=Object.entries(BrowserSession.getByPrefix("livesearch-option-")).filter((e=>"1"===e[1])).map((e=>{const t=e[0].replace("livesearch-option-",""),[o,r]=t.split("-",2);return{key:o,value:r}})),o=this.composeSearchOptions(t);for(const[t,r]of Object.entries(o))for(const o of r)e.searchParams.append(`${t}[]`,o);const r=Modal.advanced({type:Modal.types.ajax,content:e.toString(),title:lll("labels.search"),severity:SeverityEnum.notice,size:Modal.sizes.medium,ajaxCallback:()=>{const e=r.querySelector("typo3-backend-live-search"),t=e.querySelector('input[type="search"]'),o=t.closest("form");new RegularEvent("submit",(e=>{e.preventDefault();const t=new FormData(o);this.search(t).then((()=>{const e=t.get("query").toString();BrowserSession.set("livesearch-term",e)}));const r=o.querySelector("[data-active-options-counter]"),n=parseInt(r.dataset.activeOptionsCounter,10);r.querySelector("output").textContent=n.toString(10),r.classList.toggle("hidden",0===n)})).bindTo(o),t.clearable({onClear:()=>{o.requestSubmit()}});const n=document.querySelector("typo3-backend-live-search-result-container");new RegularEvent("live-search:item-chosen",(()=>{Modal.dismiss()})).bindTo(n),new RegularEvent("typo3:live-search:option-invoked",(e=>{const t=o.querySelector("[data-active-options-counter]");let r=parseInt(t.dataset.activeOptionsCounter,10);r=e.detail.active?r+1:r-1,t.dataset.activeOptionsCounter=r.toString(10)})).bindTo(e),new RegularEvent("hide.bs.dropdown",(()=>{o.requestSubmit()})).bindTo(r.querySelector(Identifiers.searchOptionDropdownToggle)),new DebounceEvent("input",(()=>{o.requestSubmit()})).bindTo(t),new RegularEvent("keydown",this.handleKeyDown).bindTo(t),o.requestSubmit()}});["modal-loaded","typo3-modal-shown"].forEach((e=>{r.addEventListener(e,(()=>{const e=r.querySelector('input[type="search"]');null!==e&&(e.focus(),e.select())}))}))}composeSearchOptions(e){const t={};return e.forEach((e=>{void 0===t[e.key]&&(t[e.key]=[]),t[e.key].push(e.value)})),t}handleKeyDown(e){if("ArrowDown"!==e.key)return;e.preventDefault();document.querySelector("typo3-backend-live-search").querySelector("typo3-backend-live-search-result-item")?.focus()}updateSearchResults(e){document.querySelector("typo3-backend-live-search-show-all").parentElement.hidden=null===e||0===e.length;const t=document.querySelector("typo3-backend-live-search-result-container");t.results=e,t.loading=!1}}export default top.TYPO3.LiveSearch??new LiveSearch;
\ No newline at end of file
-- 
GitLab