From 0ae74677b279495b19d5f22965b59b2cd0c0285b Mon Sep 17 00:00:00 2001 From: Matt Westcott Date: Mon, 20 May 2024 18:17:11 +0100 Subject: [PATCH] Refactor BoundWidget to accept an iterable of elements --- .../src/entrypoints/admin/telepath/widgets.js | 52 ++++++++++++++----- .../admin/telepath/widgets.test.js | 2 +- client/src/utils/runInlineScripts.ts | 33 +++++++----- docs/releases/6.1.md | 2 +- 4 files changed, 61 insertions(+), 28 deletions(-) diff --git a/client/src/entrypoints/admin/telepath/widgets.js b/client/src/entrypoints/admin/telepath/widgets.js index d4a88cad2d..0981c0b5fb 100644 --- a/client/src/entrypoints/admin/telepath/widgets.js +++ b/client/src/entrypoints/admin/telepath/widgets.js @@ -3,18 +3,38 @@ import { runInlineScripts } from '../../../utils/runInlineScripts'; class BoundWidget { constructor( - element, + elementOrNodeList, name, idForLabel, initialState, parentCapabilities, options, ) { + // if elementOrNodeList not iterable, it must be a single element + const nodeList = elementOrNodeList.forEach + ? elementOrNodeList + : [elementOrNodeList]; + + // look for an input element with the given name, as either a direct element of nodeList + // or a descendant const selector = `:is(input,select,textarea,button)[name="${name}"]`; - // find, including element itself - this.input = element.matches(selector) - ? element - : element.querySelector(selector); + + for (let i = 0; i < nodeList.length; i += 1) { + const element = nodeList[i]; + if (element.nodeType === Node.ELEMENT_NODE) { + if (element.matches(selector)) { + this.input = element; + break; + } else { + const input = element.querySelector(selector); + if (input) { + this.input = input; + break; + } + } + } + } + this.idForLabel = idForLabel; this.setState(initialState); this.parentCapabilities = parentCapabilities || new Map(); @@ -71,27 +91,33 @@ class Widget { const html = this.html.replace(/__NAME__/g, name).replace(/__ID__/g, id); const idForLabel = this.idPattern.replace(/__ID__/g, id); - /* write the HTML into a temp container to parse it into an element */ + /* write the HTML into a temp container to parse it into a node list */ const tempContainer = document.createElement('div'); tempContainer.innerHTML = html.trim(); - const dom = tempContainer.firstChild; + const childNodes = Array.from(tempContainer.childNodes); + + /* replace the placeholder with the new nodes */ + placeholder.replaceWith(...childNodes); + + const childElements = childNodes.filter( + (node) => node.nodeType === Node.ELEMENT_NODE, + ); /* execute any scripts in the new element(s) */ - runInlineScripts(tempContainer); - - /* replace the placeholder with the new element(s) */ - placeholder.replaceWith(...tempContainer.childNodes); + childElements.forEach((element) => { + runInlineScripts(element); + }); // Add any extra attributes we received to the first element of the widget if (typeof options?.attributes === 'object') { Object.entries(options.attributes).forEach(([key, value]) => { - dom.setAttribute(key, value); + childElements[0].setAttribute(key, value); }); } // eslint-disable-next-line new-cap return new this.boundWidgetClass( - dom, + childElements.length === 1 ? childElements[0] : childNodes, name, idForLabel, initialState, diff --git a/client/src/entrypoints/admin/telepath/widgets.test.js b/client/src/entrypoints/admin/telepath/widgets.test.js index f2551a44b0..4b045122e8 100644 --- a/client/src/entrypoints/admin/telepath/widgets.test.js +++ b/client/src/entrypoints/admin/telepath/widgets.test.js @@ -133,7 +133,7 @@ describe('telepath: wagtail.widgets.Widget with multiple top-level nodes', () => widgetDef = window.telepath.unpack({ _type: 'wagtail.widgets.Widget', _args: [ - '', + '', '__ID__', ], }); diff --git a/client/src/utils/runInlineScripts.ts b/client/src/utils/runInlineScripts.ts index 902bf44afe..5d0a1ad83f 100644 --- a/client/src/utils/runInlineScripts.ts +++ b/client/src/utils/runInlineScripts.ts @@ -1,20 +1,27 @@ /** * Runs any inline scripts contained within the given DOM element or fragment. */ +const runScript = (script: HTMLScriptElement) => { + if (!script.type || script.type === 'application/javascript') { + const newScript = document.createElement('script'); + Array.from(script.attributes).forEach((key) => + newScript.setAttribute(key.nodeName, key.nodeValue || ''), + ); + newScript.text = script.text; + script.replaceWith(newScript); + } +}; + const runInlineScripts = (element: HTMLElement | DocumentFragment) => { - const scripts = element.querySelectorAll( - 'script:not([src])', - ) as NodeListOf; - scripts.forEach((script) => { - if (!script.type || script.type === 'application/javascript') { - const newScript = document.createElement('script'); - Array.from(script.attributes).forEach((key) => - newScript.setAttribute(key.nodeName, key.nodeValue || ''), - ); - newScript.text = script.text; - script.replaceWith(newScript); - } - }); + const selector = 'script:not([src])'; + if (element instanceof HTMLElement && element.matches(selector)) { + runScript(element as HTMLScriptElement); + } else { + const scripts = element.querySelectorAll( + selector, + ) as NodeListOf; + scripts.forEach(runScript); + } }; export { runInlineScripts }; diff --git a/docs/releases/6.1.md b/docs/releases/6.1.md index ea6bfb49b3..59c669ebfb 100644 --- a/docs/releases/6.1.md +++ b/docs/releases/6.1.md @@ -351,7 +351,7 @@ In the new approach, we no longer need to attach an inline script but instead us ### Removal of jQuery from base client-side Widget and BoundWidget classes -The JavaScript base classes `Widget` and `BoundWidget` that provide client-side access to form widgets (see [](streamfield_widget_api)) no longer use jQuery. The `element` argument passed to the `BoundWidget` constructor, and the `input` property of `BoundWidget`, are now native DOM elements rather than jQuery collections. User code that extends these classes should be updated accordingly. +The JavaScript base classes `Widget` and `BoundWidget` that provide client-side access to form widgets (see [](streamfield_widget_api)) no longer use jQuery. The `input` property of `BoundWidget` (previously a jQuery collection) is now a native DOM element, and the `element` argument passed to the `BoundWidget` constructor (previously a jQuery collection) is now passed as a native DOM element if the HTML representation consists of a single element, and an iterable of elements (`NodeList` or array) otherwise. User code that extends these classes should be updated accordingly. ### `window.URLify` deprecated