From 9a7dfaed20d56f4579de8434eb78f7690cc1cef4 Mon Sep 17 00:00:00 2001 From: "Shawn P. Duncan" <code@sd.shawnduncan.org> Date: Fri, 25 Apr 2025 07:43:33 -0400 Subject: [PATCH 01/15] Add javascript processor for htmx assets --- core/.eslintrc.json | 1 + core/core.libraries.yml | 11 +++ core/misc/htmx-assets.js | 182 ++++++++++++++++++++++++++++++++++++ core/misc/htmx-behaviors.js | 38 ++++++++ 4 files changed, 232 insertions(+) create mode 100644 core/misc/htmx-assets.js create mode 100644 core/misc/htmx-behaviors.js diff --git a/core/.eslintrc.json b/core/.eslintrc.json index 6a6916b1a2c0..7b1ed46abeca 100644 --- a/core/.eslintrc.json +++ b/core/.eslintrc.json @@ -21,6 +21,7 @@ "_": true, "Cookies": true, "Backbone": true, + "htmx": true, "loadjs": true, "Shepherd": true, "Sortable": true, diff --git a/core/core.libraries.yml b/core/core.libraries.yml index 5ec851e898e7..24600c25a26b 100644 --- a/core/core.libraries.yml +++ b/core/core.libraries.yml @@ -614,6 +614,17 @@ drupal.form: - core/drupal.debounce - core/once +drupal.htmx: + version: VERSION + js: + misc/htmx-assets.js: {} + misc/htmx-behaviors.js: {} + dependencies: + - core/drupal + - core/drupalSettings + - core/htmx + - core/loadjs + drupal.machine-name: version: VERSION js: diff --git a/core/misc/htmx-assets.js b/core/misc/htmx-assets.js new file mode 100644 index 000000000000..5829a4a682fb --- /dev/null +++ b/core/misc/htmx-assets.js @@ -0,0 +1,182 @@ +/** + * @file + * Adds needed assets to the current page. + */ + +// JavaScript should be made compatible with libraries other than jQuery by +// wrapping it in an anonymous closure. +(function (Drupal, drupalSettings, once, loadjs, htmx) { + /** + * Helper function to merge two objects recursively. + * + * @param current + * The object to receive the merged values. + * @param sources + * The objects to merge into current. + * + * @return object + * The merged object. + * + * @see https://youmightnotneedjquery.com/#deep_extend + */ + function mergeSettings(current, ...sources) { + if (!current) { + return {}; + } + + sources + .filter((obj) => Boolean(obj)) + .forEach((obj) => { + Object.entries(obj).forEach(([key, value]) => { + switch (Object.prototype.toString.call(value)) { + case '[object Object]': + current[key] = current[key] || {}; + current[key] = mergeSettings(current[key], value); + break; + + case '[object Array]': + current[key] = mergeSettings(new Array(value.length), value); + break; + + default: + current[key] = value; + } + }); + }); + + return current; + } + + /** + * Function to process and send the current ajax page state with each request. + * + * @param configRequestEvent + * HTMX event for request configuration. + */ + function htmxDrupalData(configRequestEvent) { + const url = new URL(configRequestEvent.detail.path, document.location.href); + const origin = document.location.origin; + const sameHost = origin === url.origin; + if (sameHost) { + // We only need to add this data for htmx requests back to the site. + configRequestEvent.detail.headers['HX-Page-State'] = + drupalSettings.ajaxPageState.libraries; + // HTMX sets these header values using #id values. + // Swap in drupal data selectors as #id values are altered to be unique. + configRequestEvent.detail.headers['HX-Target'] = + configRequestEvent.detail.target.dataset.drupalSelector; + configRequestEvent.detail.headers['HX-Trigger'] = + configRequestEvent.detail.elt.dataset.drupalSelector; + } + } + + /** + * Function to obtain, process, and attach new assets. + * + * @param {event} oobSwapEvent + * The event fired by HTMX. + */ + function htmxDrupalAssetProcessor(oobSwapEvent) { + // Find any inserted assets. + const assetsTag = oobSwapEvent.detail.target.querySelector( + 'script[data-drupal-selector="drupal-htmx-assets"]', + ); + if (!(assetsTag instanceof HTMLElement)) { + return; + } + // Parse assets and initialize container variables. + const assets = JSON.parse(assetsTag.textContent); + let cssItems = []; + let scriptItems = []; + let scriptBottomItems = []; + let paths = []; + if (assets.constructor.name === 'Object') { + // Variable assets should have properties 'styles', 'scripts, + // 'scripts_bottom', 'settings'. See + // HtmxResponseAttachmentsProcessor::processAssetLibraries + cssItems = new Map( + assets.styles.map((item) => [`css!${item['#attributes'].href}`, item]), + ); + scriptItems = new Map( + assets.scripts.map((item) => [item['#attributes'].src, item]), + ); + scriptBottomItems = new Map( + assets.scripts_bottom.map((item) => [item['#attributes'].src, item]), + ); + mergeSettings(drupalSettings, assets.settings); + } + // Load CSS. + if (cssItems instanceof Map && cssItems.size > 0) { + // Fetch in parallel but load in sequence. + cssItems.forEach((value, key) => paths.push(key)); + loadjs(paths, { + async: false, + before(path, linkElement) { + const item = cssItems.get(path); + Object.entries(item['#attributes']).forEach( + ([attributeKey, attributeValue]) => { + linkElement.setAttribute(attributeKey, attributeValue); + }, + ); + }, + }); + } + /* By default, loadjs appends the script to the head. When scripts + * are loaded via HTMX, their location has no impact on + * functionality. But, since Drupal loaded scripts can choose + * their parent element, we provide that option here for the sake of + * consistency. + */ + if (scriptItems instanceof Map && scriptItems.size > 0) { + paths = []; + scriptItems.forEach((value, key) => paths.push(key)); + // Load head JS. + // Fetch in parallel but load in sequence. + loadjs(paths, { + async: false, + before(path, scriptElement) { + const item = scriptItems.get(path); + Object.entries(item['#attributes']).forEach( + ([attributeKey, attributeValue]) => { + scriptElement.setAttribute(attributeKey, attributeValue); + }, + ); + }, + }); + } + if (scriptBottomItems instanceof Map && scriptBottomItems.size > 0) { + paths = []; + scriptBottomItems.forEach((value, key) => paths.push(key)); + // Fetch in parallel but load in sequence. + loadjs(paths, { + async: false, + before(path, scriptElement) { + const item = scriptBottomItems.get(path); + Object.entries(item['#attributes']).forEach( + ([attributeKey, attributeValue]) => { + scriptElement.setAttribute(attributeKey, attributeValue); + }, + ); + document.body.appendChild(scriptElement); + // Return false to bypass loadjs' default DOM insertion + // mechanism. + return false; + }, + }); + } + // Any assets are now processed. Remove the found asset tag. + htmx.remove(assetsTag); + } + + /* Initialize listeners. */ + + Drupal.behaviors.htmxAssets = { + attach: () => { + if (!once('htmxAssets', 'html').length) { + return; + } + window.addEventListener('htmx:configRequest', htmxDrupalData); + window.addEventListener('htmx:oobAfterSwap', htmxDrupalAssetProcessor); + }, + }; +})(Drupal, drupalSettings, once, loadjs, htmx); diff --git a/core/misc/htmx-behaviors.js b/core/misc/htmx-behaviors.js new file mode 100644 index 000000000000..3da0288b3e60 --- /dev/null +++ b/core/misc/htmx-behaviors.js @@ -0,0 +1,38 @@ +/** + * @file + * Connect Drupal.behaviors to htmx inserted content. + */ +(function (Drupal, once, htmx, drupalSettings) { + function htmxDrupalBehaviors(htmxLoadEvent) { + // The attachBehaviors method searches within the context. + // We need to go up one level so that the loaded content is processed + // completely. + Drupal.attachBehaviors( + htmxLoadEvent.detail.elt?.parentElement, + drupalSettings, + ); + } + + /* Initialize listeners. */ + + Drupal.behaviors.htmxBehaviors = { + attach: () => { + if (!once('htmxBehaviors', 'html').length) { + return; + } + window.addEventListener('htmx:load', htmxDrupalBehaviors); + window.addEventListener('htmx:oobAfterSwap', htmxDrupalBehaviors); + }, + }; + + /** + * Also send new markup through htmx processing. + */ + Drupal.behaviors.htmxProcess = { + attach(context) { + if (typeof htmx === 'object' && htmx.hasOwnProperty('process')) { + htmx.process(context); + } + }, + }; +})(Drupal, once, htmx, drupalSettings); -- GitLab From 56e23719dce85a30d408cd477be5aafb1a18622b Mon Sep 17 00:00:00 2001 From: Shawn Duncan <duncans3@mskcc.org> Date: Fri, 25 Apr 2025 12:00:53 -0400 Subject: [PATCH 02/15] Add backend processing for assets on HTMX requests. --- .../HtmxResponseSubscriber.php | 58 ++++++++++ .../HtmxResponseAttachmentsProcessor.php | 108 ++++++++++++++++++ 2 files changed, 166 insertions(+) create mode 100644 core/lib/Drupal/Core/EventSubscriber/HtmxResponseSubscriber.php create mode 100644 core/lib/Drupal/Core/Render/Hypermedia/HtmxResponseAttachmentsProcessor.php diff --git a/core/lib/Drupal/Core/EventSubscriber/HtmxResponseSubscriber.php b/core/lib/Drupal/Core/EventSubscriber/HtmxResponseSubscriber.php new file mode 100644 index 000000000000..46768cac81ad --- /dev/null +++ b/core/lib/Drupal/Core/EventSubscriber/HtmxResponseSubscriber.php @@ -0,0 +1,58 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Core\EventSubscriber; + +use Drupal\Core\Render\AttachmentsResponseProcessorInterface; +use Drupal\Core\Render\HtmlResponse; +use Symfony\Component\EventDispatcher\EventSubscriberInterface; +use Symfony\Component\HttpFoundation\RequestStack; +use Symfony\Component\HttpKernel\Event\ResponseEvent; +use Symfony\Component\HttpKernel\KernelEvents; + +/** + * Adds assets on responses to HTMX requests. + * + * @see \Drupal\Core\Ajax\HtmxResponseAttachmentsProcessor + */ +final class HtmxResponseSubscriber implements EventSubscriberInterface { + + /** + * Constructs a HtmxResponseSubscriber object. + */ + public function __construct( + private readonly RequestStack $requestStack, + private readonly AttachmentsResponseProcessorInterface $attachmentsProcessor, + ) {} + + /** + * Add assemble and attachments add HTMX attributes. + * + * @see \Drupal\Core\EventSubscriber\HtmlResponseSubscriber::onRespond + */ + public function onRespond(ResponseEvent $event): void { + $response = $event->getResponse(); + $requestHeaders = $this->requestStack->getCurrentRequest()->headers; + if (!($response instanceof HtmlResponse && $requestHeaders->has('HX-Request'))) { + // Only operate on HTML responses from an HTMX request. + return; + } + $processedResponse = $this->attachmentsProcessor->processAttachments($response); + if (!($processedResponse instanceof HtmlResponse)) { + throw new \TypeError("ResponseEvent::setResponse() requires an HtmlResponse object"); + } + $event->setResponse($processedResponse); + } + + /** + * {@inheritdoc} + */ + public static function getSubscribedEvents(): array { + return [ + // Our method needs to run before the HtmlResponseSubscriber(weight 0). + KernelEvents::RESPONSE => ['onRespond', 100], + ]; + } + +} diff --git a/core/lib/Drupal/Core/Render/Hypermedia/HtmxResponseAttachmentsProcessor.php b/core/lib/Drupal/Core/Render/Hypermedia/HtmxResponseAttachmentsProcessor.php new file mode 100644 index 000000000000..fedeeba6871e --- /dev/null +++ b/core/lib/Drupal/Core/Render/Hypermedia/HtmxResponseAttachmentsProcessor.php @@ -0,0 +1,108 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Core\Render\Hypermedia; + +use Drupal\Component\Serialization\Json; +use Drupal\Component\Utility\UrlHelper; +use Drupal\Core\Asset\AttachedAssetsInterface; +use Drupal\Core\Render\AttachmentsInterface; +use Drupal\Core\Render\HtmlResponse; +use Drupal\Core\Render\HtmlResponseAttachmentsProcessor; + +/** + * Prepares attachment for HTMX powered responses. + * + * Extends the HTML response processor encode attachment data. + * + * @see \Drupal\Core\EventSubscriber\HtmxResponseSubscriber + * @see \Drupal\Core\Render\HtmlResponseAttachmentsProcessor::processAttachments + * @see core/misc/htmx-behaviors.js + */ +class HtmxResponseAttachmentsProcessor extends HtmlResponseAttachmentsProcessor { + + /** + * {@inheritdoc} + */ + public function processAttachments(AttachmentsInterface $response): HtmlResponse { + $processed = parent::processAttachments($response); + if (!($processed instanceof HtmlResponse)) { + // Something has gone wrong. We sent an HtmlResponse that also + // implemented AttachmentInterface and should have received the same back. + throw new \TypeError("HtmlResponseAttachmentsProcessor::processAttachments should return an HtmlResponse."); + } + return $processed; + } + + /** + * {@inheritdoc} + */ + protected function processAssetLibraries(AttachedAssetsInterface $assets, array $placeholders) { + $request = $this->requestStack->getCurrentRequest(); + if ($request->headers->has('HX-Page-State')) { + $uncompressed = UrlHelper::uncompressQueryParameter($request->headers->get('HX-Page-State')); + $libraries = explode(',', $uncompressed); + $assets->setAlreadyLoadedLibraries($libraries); + } + $settings = []; + $variables = []; + $maintenance_mode = defined('MAINTENANCE_MODE') || \Drupal::state()->get('system.maintenance_mode'); + + // Print styles - if present. + if (isset($placeholders['styles'])) { + // Optimize CSS if necessary, but only during normal site operation. + $optimize_css = !$maintenance_mode && $this->config->get('css.preprocess'); + $variables['styles'] = $this->cssCollectionRenderer->render($this->assetResolver->getCssAssets($assets, $optimize_css, $this->languageManager->getCurrentLanguage())); + } + + // Copy and adjust parent::processAssetLibraries to adjust and + // remove drupalSettings in line with + // AjaxResponseAttachmentsProcessor::buildAttachmentsCommands + // Print scripts - if any are present. + if (isset($placeholders['scripts']) || isset($placeholders['scripts_bottom'])) { + // Optimize JS if necessary, but only during normal site operation. + $optimize_js = !$maintenance_mode && $this->config->get('js.preprocess'); + [$js_assets_header, $js_assets_footer] = $this->assetResolver + ->getJsAssets($assets, $optimize_js, $this->languageManager->getCurrentLanguage()); + if (isset($js_assets_header['drupalSettings'])) { + $settings = $js_assets_header['drupalSettings']['data']; + unset($js_assets_header['drupalSettings']); + } + if (isset($js_assets_footer['drupalSettings'])) { + $settings = $js_assets_footer['drupalSettings']['data']; + unset($js_assets_footer['drupalSettings']); + } + if (empty($settings)) { + $settings = $assets->getSettings(); + } + $variables['scripts'] = $this->jsCollectionRenderer->render($js_assets_header); + $variables['scripts_bottom'] = $this->jsCollectionRenderer->render($js_assets_footer); + unset($settings['path']); + $variables['settings'] = $settings; + } + /* + * @todo Update to use HtmxAttribute::swapOob('beforeend:body') + */ + return [ + 'styles' => [], + 'scripts' => [], + 'scripts_bottom' => [ + '#type' => 'container', + '#attributes' => [ + 'data-hx-swap-oob' => 'beforeend:body' + ], + 'libraries' => [ + '#type' => 'html_tag', + '#tag' => 'script', + '#attributes' => [ + 'type' => 'application/json', + 'data-drupal-selector' => 'drupal-htmx-assets', + ], + '#value' => Json::encode($variables), + ], + ], + ]; + } + +} -- GitLab From a763c62dbb84196e0de8fef74f1f2836fa3f084b Mon Sep 17 00:00:00 2001 From: "Shawn P. Duncan" <code@sd.shawnduncan.org> Date: Sat, 26 Apr 2025 08:06:19 -0400 Subject: [PATCH 03/15] missing dependency --- core/core.libraries.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/core/core.libraries.yml b/core/core.libraries.yml index 24600c25a26b..19be4912dee6 100644 --- a/core/core.libraries.yml +++ b/core/core.libraries.yml @@ -624,6 +624,7 @@ drupal.htmx: - core/drupalSettings - core/htmx - core/loadjs + - core/once drupal.machine-name: version: VERSION -- GitLab From e75f82491a0280254a3511700ed12b4814c58268 Mon Sep 17 00:00:00 2001 From: "Shawn P. Duncan" <code@sd.shawnduncan.org> Date: Sat, 26 Apr 2025 09:22:16 -0400 Subject: [PATCH 04/15] Add services to core.services.yml --- core/core.services.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/core/core.services.yml b/core/core.services.yml index 4372e6512c3e..c27b5cba167c 100644 --- a/core/core.services.yml +++ b/core/core.services.yml @@ -1400,6 +1400,14 @@ services: html_response.subscriber: class: Drupal\Core\EventSubscriber\HtmlResponseSubscriber arguments: ['@html_response.attachments_processor'] + htmx_response.attachments_processor: + class: Drupal\Core\Render\Hypermedia\HtmxResponseAttachmentsProcessor + parent: html_response.attachments_processor + htmx.response_subscriber: + class: Drupal\Core\EventSubscriber\HtmxResponseSubscriber + arguments: [ '@request_stack', '@htmx_response.attachments_processor' ] + tags: + - { name: event_subscriber } finish_response_subscriber: class: Drupal\Core\EventSubscriber\FinishResponseSubscriber arguments: ['@language_manager', '@config.factory', '@page_cache_request_policy', '@page_cache_response_policy', '@cache_contexts_manager', '@datetime.time', '%http.response.debug_cacheability_headers%'] -- GitLab From c693567cfd8da5352ecc56951c89bb16630cef44 Mon Sep 17 00:00:00 2001 From: Shawn Duncan <duncans3@mskcc.org> Date: Tue, 29 Apr 2025 13:40:53 -0400 Subject: [PATCH 05/15] Add automated tests --- .../test_htmx/js/reveal-merged-settings.js | 27 +++++++ .../HtmxTestAttachmentsController.php | 79 +++++++++++++++++++ .../modules/test_htmx/test_htmx.info.yml | 5 ++ .../modules/test_htmx/test_htmx.libraries.yml | 7 ++ .../modules/test_htmx/test_htmx.routing.yml | 17 ++++ .../Tests/htmx/htmxAssetLoadTest.js | 44 +++++++++++ .../TestSite/HtmxAssetLoadTestSetup.php | 45 +++++++++++ 7 files changed, 224 insertions(+) create mode 100644 core/modules/system/tests/modules/test_htmx/js/reveal-merged-settings.js create mode 100644 core/modules/system/tests/modules/test_htmx/src/Controller/HtmxTestAttachmentsController.php create mode 100644 core/modules/system/tests/modules/test_htmx/test_htmx.info.yml create mode 100644 core/modules/system/tests/modules/test_htmx/test_htmx.libraries.yml create mode 100644 core/modules/system/tests/modules/test_htmx/test_htmx.routing.yml create mode 100644 core/tests/Drupal/Nightwatch/Tests/htmx/htmxAssetLoadTest.js create mode 100644 core/tests/Drupal/TestSite/HtmxAssetLoadTestSetup.php diff --git a/core/modules/system/tests/modules/test_htmx/js/reveal-merged-settings.js b/core/modules/system/tests/modules/test_htmx/js/reveal-merged-settings.js new file mode 100644 index 000000000000..2018775c3267 --- /dev/null +++ b/core/modules/system/tests/modules/test_htmx/js/reveal-merged-settings.js @@ -0,0 +1,27 @@ +/** + * @file + * Display merged value from drupalSettings for testing. + */ +(function (Drupal) { + /** + * Tests that settings were merged. + * Tests that behaviors are attached to htmx inserted content. + * + * @see \Drupal\htmx_test\Controller\HtmxTestAttachmentsController::replace + */ + Drupal.behaviors.revealMerged_settings = { + attach(context, settings) { + const dropButtonContainer = context.querySelector( + 'div.dropbutton-wrapper', + ); + if ( + dropButtonContainer instanceof HTMLElement && + Object.hasOwn(settings, 'htmxTest') + ) { + dropButtonContainer.append( + `Settings found: ${drupalSettings.htmxTest}`, + ); + } + }, + }; +})(Drupal); diff --git a/core/modules/system/tests/modules/test_htmx/src/Controller/HtmxTestAttachmentsController.php b/core/modules/system/tests/modules/test_htmx/src/Controller/HtmxTestAttachmentsController.php new file mode 100644 index 000000000000..8f53aa480cc2 --- /dev/null +++ b/core/modules/system/tests/modules/test_htmx/src/Controller/HtmxTestAttachmentsController.php @@ -0,0 +1,79 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\test_htmx\Controller; + +use Drupal\Core\Controller\ControllerBase; +use Drupal\Core\Url; +use Drupal\htmx\Template\HtmxAttribute; + +/** + * Returns responses for HTMX Test Attachments routes. + */ +final class HtmxTestAttachmentsController extends ControllerBase { + + /** + * Builds the response. + * + * @return mixed[] + * A render array. + */ + public function page(): array { +// $htmx = new HtmxAttribute(['class' => ['button'], 'name' => 'replace']); +// $htmx->get(Url::fromRoute('htmx_test_attachments.replace')) +// ->select('div.dropbutton-wrapper') +// ->swap('afterend'); + $url = Url::fromRoute('test_htmx.attachments.replace'); + $build['content'] = [ + '#type' => 'container', + '#attached' => [ + 'library' => [ + 'core/drupal.htmx', + 'test_htmx/attachments', + ], + ], + '#attributes' => [ + 'class' => ['htmx-test-container'], + ], + 'button' => [ + '#type' => 'inline_template', + '#template' => '<button class="button" name="replace" data-hx-get="{{- url -}}" data-hx-select="div.dropbutton-wrapper" data-hx-swap="afterend">Click this</button>', + '#context' => [ + 'url' => $url, + ], + ], + ]; + + return $build; + } + + /** + * Builds the HTMX response. + * + * @return mixed[] + * A render array. + */ + public function replace(): array { + $build['content'] = [ + '#type' => 'dropbutton', + '#dropbutton_type' => 'small', + '#links' => [ + 'simple_form' => [ + 'title' => $this->t('Sample link 1'), + 'url' => Url::fromRoute('system.timezone'), + ], + 'demo' => [ + 'title' => $this->t('Sample link 2'), + 'url' => Url::fromRoute('<front>'), + ], + ], + '#attached' => [ + 'drupalSettings' => ['htmxTest' => 'success'], + ], + ]; + + return $build; + } + +} diff --git a/core/modules/system/tests/modules/test_htmx/test_htmx.info.yml b/core/modules/system/tests/modules/test_htmx/test_htmx.info.yml new file mode 100644 index 000000000000..754f482385dc --- /dev/null +++ b/core/modules/system/tests/modules/test_htmx/test_htmx.info.yml @@ -0,0 +1,5 @@ +name: 'HTMX Test Fixtures' +type: module +description: 'Test fixtures for HTMX integration' +package: Test +core_version_requirement: ^11 diff --git a/core/modules/system/tests/modules/test_htmx/test_htmx.libraries.yml b/core/modules/system/tests/modules/test_htmx/test_htmx.libraries.yml new file mode 100644 index 000000000000..b5cdc377af61 --- /dev/null +++ b/core/modules/system/tests/modules/test_htmx/test_htmx.libraries.yml @@ -0,0 +1,7 @@ +attachments: + version: VERSION + js: + js/reveal-merged-settings.js: {} + dependencies: + - core/drupal + - core/drupalSettings diff --git a/core/modules/system/tests/modules/test_htmx/test_htmx.routing.yml b/core/modules/system/tests/modules/test_htmx/test_htmx.routing.yml new file mode 100644 index 000000000000..b6a82293bffc --- /dev/null +++ b/core/modules/system/tests/modules/test_htmx/test_htmx.routing.yml @@ -0,0 +1,17 @@ +test_htmx.attachments.page: + path: '/htmx-test-attachments/page' + defaults: + _title: 'Page' + _controller: '\Drupal\test_htmx\Controller\HtmxTestAttachmentsController::page' + requirements: + _permission: 'access content' + +test_htmx.attachments.replace: + path: '/htmx-test-attachments/replace' + defaults: + _title: 'Dropbutton' + _controller: '\Drupal\test_htmx\Controller\HtmxTestAttachmentsController::replace' + requirements: + _permission: 'access content' + options: + _htmx_route: 'true' diff --git a/core/tests/Drupal/Nightwatch/Tests/htmx/htmxAssetLoadTest.js b/core/tests/Drupal/Nightwatch/Tests/htmx/htmxAssetLoadTest.js new file mode 100644 index 000000000000..1f17416694e4 --- /dev/null +++ b/core/tests/Drupal/Nightwatch/Tests/htmx/htmxAssetLoadTest.js @@ -0,0 +1,44 @@ +// The javascript that creates dropbuttons is not present on the /page at +// initial load. If the once data property is added then the JS was loaded +// and triggered on the inserted content. +// @see \Drupal\test_htmx\Controller\HtmxTestAttachmentsController +// @see core/modules/system/tests/modules/test_htmx/js/reveal-merged-settings.js +module.exports = { + '@tags': ['core', 'htmx'], + before(browser) { + browser.drupalInstall({ + setupFile: 'core/tests/Drupal/TestSite/HtmxAssetLoadTestSetup.php', + }); + }, + afterEach(browser) { + browser.drupalLogAndEnd({ onlyOnError: true }); + }, + after(browser) { + browser.drupalUninstall(); + }, + + 'Asset Load': (browser) => { + // Load the route htmx will request and confirm the markup we will be looking + // for is present in the source markup. + browser + .drupalRelativeURL('/htmx-test-attachments/replace') + .waitForElementVisible('body', 1000) + .assert.elementPresent('script[src*="dropbutton.js"]'); + // Now load the page with the htmx enhanced button and verify the absence + // of the markup to be inserted. Click the button + // and check for inserted javascript, markup, and output that verifies + // merged settings. + browser + .drupalRelativeURL('/htmx-test-attachments/page') + .waitForElementVisible('body', 1000) + .assert.not.elementPresent('script[src*="dropbutton.js"]') + .waitForElementVisible('button[name="replace"]', 1000) + .pause(1000) + .click('button[name="replace"]') + .waitForElementVisible('div.dropbutton-wrapper', 60000) + .waitForElementVisible('div[data-once="dropbutton"]', 60000) + .assert.elementPresent('script[src*="dropbutton.js"]') + .pause(1000) + .assert.textContains('div.dropbutton-wrapper', 'Settings found: success'); + }, +}; diff --git a/core/tests/Drupal/TestSite/HtmxAssetLoadTestSetup.php b/core/tests/Drupal/TestSite/HtmxAssetLoadTestSetup.php new file mode 100644 index 000000000000..1746cf3d948b --- /dev/null +++ b/core/tests/Drupal/TestSite/HtmxAssetLoadTestSetup.php @@ -0,0 +1,45 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\TestSite; + +use Drupal\Core\Extension\ModuleInstallerInterface; +use Drupal\Core\Extension\ThemeInstallerInterface; +use Drupal\TestSite\TestSetupInterface; +use Drupal\Tests\user\Traits\UserCreationTrait; +use Drupal\user\Entity\Role; +use Drupal\user\RoleInterface; + +/** + * Setup file used by tests/src/Nightwatch/Tests/htmxAssetLoadTest.js. + * + * @see \Drupal\Tests\Scripts\TestSiteApplicationTest + */ +class HtmxAssetLoadTestSetup implements TestSetupInterface { + + use UserCreationTrait; + + /** + * {@inheritdoc} + */ + public function setup() { + // Install Olivero and set it as the default theme. + $theme_installer = \Drupal::service('theme_installer'); + assert($theme_installer instanceof ThemeInstallerInterface); + $theme_installer->install(['olivero'], TRUE); + $system_theme_config = \Drupal::configFactory()->getEditable('system.theme'); + $system_theme_config->set('default', 'olivero')->save(); + + // Install required modules. + $module_installer = \Drupal::service('module_installer'); + assert($module_installer instanceof ModuleInstallerInterface); + $module_installer->install(['system']); + $module_installer->install(['test_htmx']); + + // Insure correct permissions. + $this->grantPermissions(Role::load(RoleInterface::ANONYMOUS_ID), ['access content']); + + } + +} -- GitLab From e0fe8c59611363bda989f1c5cc7043d3776cba7b Mon Sep 17 00:00:00 2001 From: Shawn Duncan <duncans3@mskcc.org> Date: Tue, 29 Apr 2025 13:58:35 -0400 Subject: [PATCH 06/15] phpcs lint --- .../Render/Hypermedia/HtmxResponseAttachmentsProcessor.php | 4 ++-- .../src/Controller/HtmxTestAttachmentsController.php | 5 ----- core/tests/Drupal/TestSite/HtmxAssetLoadTestSetup.php | 1 - 3 files changed, 2 insertions(+), 8 deletions(-) diff --git a/core/lib/Drupal/Core/Render/Hypermedia/HtmxResponseAttachmentsProcessor.php b/core/lib/Drupal/Core/Render/Hypermedia/HtmxResponseAttachmentsProcessor.php index fedeeba6871e..f81ad577f1be 100644 --- a/core/lib/Drupal/Core/Render/Hypermedia/HtmxResponseAttachmentsProcessor.php +++ b/core/lib/Drupal/Core/Render/Hypermedia/HtmxResponseAttachmentsProcessor.php @@ -82,7 +82,7 @@ protected function processAssetLibraries(AttachedAssetsInterface $assets, array $variables['settings'] = $settings; } /* - * @todo Update to use HtmxAttribute::swapOob('beforeend:body') + * @todo Update to use `#htmx` render array property when available. */ return [ 'styles' => [], @@ -90,7 +90,7 @@ protected function processAssetLibraries(AttachedAssetsInterface $assets, array 'scripts_bottom' => [ '#type' => 'container', '#attributes' => [ - 'data-hx-swap-oob' => 'beforeend:body' + 'data-hx-swap-oob' => 'beforeend:body', ], 'libraries' => [ '#type' => 'html_tag', diff --git a/core/modules/system/tests/modules/test_htmx/src/Controller/HtmxTestAttachmentsController.php b/core/modules/system/tests/modules/test_htmx/src/Controller/HtmxTestAttachmentsController.php index 8f53aa480cc2..e72d92239ef8 100644 --- a/core/modules/system/tests/modules/test_htmx/src/Controller/HtmxTestAttachmentsController.php +++ b/core/modules/system/tests/modules/test_htmx/src/Controller/HtmxTestAttachmentsController.php @@ -6,7 +6,6 @@ use Drupal\Core\Controller\ControllerBase; use Drupal\Core\Url; -use Drupal\htmx\Template\HtmxAttribute; /** * Returns responses for HTMX Test Attachments routes. @@ -20,10 +19,6 @@ final class HtmxTestAttachmentsController extends ControllerBase { * A render array. */ public function page(): array { -// $htmx = new HtmxAttribute(['class' => ['button'], 'name' => 'replace']); -// $htmx->get(Url::fromRoute('htmx_test_attachments.replace')) -// ->select('div.dropbutton-wrapper') -// ->swap('afterend'); $url = Url::fromRoute('test_htmx.attachments.replace'); $build['content'] = [ '#type' => 'container', diff --git a/core/tests/Drupal/TestSite/HtmxAssetLoadTestSetup.php b/core/tests/Drupal/TestSite/HtmxAssetLoadTestSetup.php index 1746cf3d948b..23a31f40f7c3 100644 --- a/core/tests/Drupal/TestSite/HtmxAssetLoadTestSetup.php +++ b/core/tests/Drupal/TestSite/HtmxAssetLoadTestSetup.php @@ -6,7 +6,6 @@ use Drupal\Core\Extension\ModuleInstallerInterface; use Drupal\Core\Extension\ThemeInstallerInterface; -use Drupal\TestSite\TestSetupInterface; use Drupal\Tests\user\Traits\UserCreationTrait; use Drupal\user\Entity\Role; use Drupal\user\RoleInterface; -- GitLab From c5ebe71ae82aba090dc62e470fa4a87b294d69bd Mon Sep 17 00:00:00 2001 From: Shawn Duncan <duncans3@mskcc.org> Date: Tue, 29 Apr 2025 14:46:46 -0400 Subject: [PATCH 07/15] phpstan lint --- .../Nightwatch/Tests/htmx/htmxAssetLoadTest.js | 8 +++++--- .../tests/Drupal/TestSite/HtmxAssetLoadTestSetup.php | 12 +----------- 2 files changed, 6 insertions(+), 14 deletions(-) diff --git a/core/tests/Drupal/Nightwatch/Tests/htmx/htmxAssetLoadTest.js b/core/tests/Drupal/Nightwatch/Tests/htmx/htmxAssetLoadTest.js index 1f17416694e4..5892cdba046e 100644 --- a/core/tests/Drupal/Nightwatch/Tests/htmx/htmxAssetLoadTest.js +++ b/core/tests/Drupal/Nightwatch/Tests/htmx/htmxAssetLoadTest.js @@ -6,9 +6,11 @@ module.exports = { '@tags': ['core', 'htmx'], before(browser) { - browser.drupalInstall({ - setupFile: 'core/tests/Drupal/TestSite/HtmxAssetLoadTestSetup.php', - }); + browser + .drupalInstall({ + setupFile: 'core/tests/Drupal/TestSite/HtmxAssetLoadTestSetup.php', + installProfile: 'minimal', + }); }, afterEach(browser) { browser.drupalLogAndEnd({ onlyOnError: true }); diff --git a/core/tests/Drupal/TestSite/HtmxAssetLoadTestSetup.php b/core/tests/Drupal/TestSite/HtmxAssetLoadTestSetup.php index 23a31f40f7c3..03bb3fbdb113 100644 --- a/core/tests/Drupal/TestSite/HtmxAssetLoadTestSetup.php +++ b/core/tests/Drupal/TestSite/HtmxAssetLoadTestSetup.php @@ -6,9 +6,6 @@ use Drupal\Core\Extension\ModuleInstallerInterface; use Drupal\Core\Extension\ThemeInstallerInterface; -use Drupal\Tests\user\Traits\UserCreationTrait; -use Drupal\user\Entity\Role; -use Drupal\user\RoleInterface; /** * Setup file used by tests/src/Nightwatch/Tests/htmxAssetLoadTest.js. @@ -17,12 +14,10 @@ */ class HtmxAssetLoadTestSetup implements TestSetupInterface { - use UserCreationTrait; - /** * {@inheritdoc} */ - public function setup() { + public function setup(): void { // Install Olivero and set it as the default theme. $theme_installer = \Drupal::service('theme_installer'); assert($theme_installer instanceof ThemeInstallerInterface); @@ -33,12 +28,7 @@ public function setup() { // Install required modules. $module_installer = \Drupal::service('module_installer'); assert($module_installer instanceof ModuleInstallerInterface); - $module_installer->install(['system']); $module_installer->install(['test_htmx']); - - // Insure correct permissions. - $this->grantPermissions(Role::load(RoleInterface::ANONYMOUS_ID), ['access content']); - } } -- GitLab From 3140982197c0c7fd855ae3cf92343cdfae9fbd23 Mon Sep 17 00:00:00 2001 From: Shawn Duncan <duncans3@mskcc.org> Date: Tue, 29 Apr 2025 14:56:48 -0400 Subject: [PATCH 08/15] js lint --- .../Drupal/Nightwatch/Tests/htmx/htmxAssetLoadTest.js | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/core/tests/Drupal/Nightwatch/Tests/htmx/htmxAssetLoadTest.js b/core/tests/Drupal/Nightwatch/Tests/htmx/htmxAssetLoadTest.js index 5892cdba046e..a933062d1808 100644 --- a/core/tests/Drupal/Nightwatch/Tests/htmx/htmxAssetLoadTest.js +++ b/core/tests/Drupal/Nightwatch/Tests/htmx/htmxAssetLoadTest.js @@ -6,11 +6,10 @@ module.exports = { '@tags': ['core', 'htmx'], before(browser) { - browser - .drupalInstall({ - setupFile: 'core/tests/Drupal/TestSite/HtmxAssetLoadTestSetup.php', - installProfile: 'minimal', - }); + browser.drupalInstall({ + setupFile: 'core/tests/Drupal/TestSite/HtmxAssetLoadTestSetup.php', + installProfile: 'minimal', + }); }, afterEach(browser) { browser.drupalLogAndEnd({ onlyOnError: true }); -- GitLab From aa584191c091cb60cb1d003bf44effa055461915 Mon Sep 17 00:00:00 2001 From: "Shawn P. Duncan" <code@sd.shawnduncan.org> Date: Wed, 30 Apr 2025 06:30:36 -0400 Subject: [PATCH 09/15] simplify nightwatch test for asset integration. --- .../test_htmx/js/reveal-merged-settings.js | 27 ------------------- .../HtmxTestAttachmentsController.php | 4 --- .../modules/test_htmx/test_htmx.libraries.yml | 7 ----- .../Tests/htmx/htmxAssetLoadTest.js | 5 +--- 4 files changed, 1 insertion(+), 42 deletions(-) delete mode 100644 core/modules/system/tests/modules/test_htmx/js/reveal-merged-settings.js delete mode 100644 core/modules/system/tests/modules/test_htmx/test_htmx.libraries.yml diff --git a/core/modules/system/tests/modules/test_htmx/js/reveal-merged-settings.js b/core/modules/system/tests/modules/test_htmx/js/reveal-merged-settings.js deleted file mode 100644 index 2018775c3267..000000000000 --- a/core/modules/system/tests/modules/test_htmx/js/reveal-merged-settings.js +++ /dev/null @@ -1,27 +0,0 @@ -/** - * @file - * Display merged value from drupalSettings for testing. - */ -(function (Drupal) { - /** - * Tests that settings were merged. - * Tests that behaviors are attached to htmx inserted content. - * - * @see \Drupal\htmx_test\Controller\HtmxTestAttachmentsController::replace - */ - Drupal.behaviors.revealMerged_settings = { - attach(context, settings) { - const dropButtonContainer = context.querySelector( - 'div.dropbutton-wrapper', - ); - if ( - dropButtonContainer instanceof HTMLElement && - Object.hasOwn(settings, 'htmxTest') - ) { - dropButtonContainer.append( - `Settings found: ${drupalSettings.htmxTest}`, - ); - } - }, - }; -})(Drupal); diff --git a/core/modules/system/tests/modules/test_htmx/src/Controller/HtmxTestAttachmentsController.php b/core/modules/system/tests/modules/test_htmx/src/Controller/HtmxTestAttachmentsController.php index e72d92239ef8..c90a7aec7dfd 100644 --- a/core/modules/system/tests/modules/test_htmx/src/Controller/HtmxTestAttachmentsController.php +++ b/core/modules/system/tests/modules/test_htmx/src/Controller/HtmxTestAttachmentsController.php @@ -25,7 +25,6 @@ public function page(): array { '#attached' => [ 'library' => [ 'core/drupal.htmx', - 'test_htmx/attachments', ], ], '#attributes' => [ @@ -63,9 +62,6 @@ public function replace(): array { 'url' => Url::fromRoute('<front>'), ], ], - '#attached' => [ - 'drupalSettings' => ['htmxTest' => 'success'], - ], ]; return $build; diff --git a/core/modules/system/tests/modules/test_htmx/test_htmx.libraries.yml b/core/modules/system/tests/modules/test_htmx/test_htmx.libraries.yml deleted file mode 100644 index b5cdc377af61..000000000000 --- a/core/modules/system/tests/modules/test_htmx/test_htmx.libraries.yml +++ /dev/null @@ -1,7 +0,0 @@ -attachments: - version: VERSION - js: - js/reveal-merged-settings.js: {} - dependencies: - - core/drupal - - core/drupalSettings diff --git a/core/tests/Drupal/Nightwatch/Tests/htmx/htmxAssetLoadTest.js b/core/tests/Drupal/Nightwatch/Tests/htmx/htmxAssetLoadTest.js index a933062d1808..55b409872394 100644 --- a/core/tests/Drupal/Nightwatch/Tests/htmx/htmxAssetLoadTest.js +++ b/core/tests/Drupal/Nightwatch/Tests/htmx/htmxAssetLoadTest.js @@ -27,8 +27,7 @@ module.exports = { .assert.elementPresent('script[src*="dropbutton.js"]'); // Now load the page with the htmx enhanced button and verify the absence // of the markup to be inserted. Click the button - // and check for inserted javascript, markup, and output that verifies - // merged settings. + // and check for inserted javascript and markup. browser .drupalRelativeURL('/htmx-test-attachments/page') .waitForElementVisible('body', 1000) @@ -39,7 +38,5 @@ module.exports = { .waitForElementVisible('div.dropbutton-wrapper', 60000) .waitForElementVisible('div[data-once="dropbutton"]', 60000) .assert.elementPresent('script[src*="dropbutton.js"]') - .pause(1000) - .assert.textContains('div.dropbutton-wrapper', 'Settings found: success'); }, }; -- GitLab From 173572f42db08f99bea37e82df7bba3e03117352 Mon Sep 17 00:00:00 2001 From: "Shawn P. Duncan" <code@sd.shawnduncan.org> Date: Wed, 30 Apr 2025 06:46:19 -0400 Subject: [PATCH 10/15] js lint - missing semicolon after simplifying. --- core/tests/Drupal/Nightwatch/Tests/htmx/htmxAssetLoadTest.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/tests/Drupal/Nightwatch/Tests/htmx/htmxAssetLoadTest.js b/core/tests/Drupal/Nightwatch/Tests/htmx/htmxAssetLoadTest.js index 55b409872394..ff75fe000cd2 100644 --- a/core/tests/Drupal/Nightwatch/Tests/htmx/htmxAssetLoadTest.js +++ b/core/tests/Drupal/Nightwatch/Tests/htmx/htmxAssetLoadTest.js @@ -37,6 +37,6 @@ module.exports = { .click('button[name="replace"]') .waitForElementVisible('div.dropbutton-wrapper', 60000) .waitForElementVisible('div[data-once="dropbutton"]', 60000) - .assert.elementPresent('script[src*="dropbutton.js"]') + .assert.elementPresent('script[src*="dropbutton.js"]'); }, }; -- GitLab From 170b4796ecb3dc031f6ba97ba29a84e462847990 Mon Sep 17 00:00:00 2001 From: Shawn Duncan <duncans3@mskcc.org> Date: Wed, 30 Apr 2025 15:41:16 -0400 Subject: [PATCH 11/15] Updates based on MR comments --- core/core.libraries.yml | 4 +- core/core.services.yml | 1 + .../HtmxResponseSubscriber.php | 21 ++++- .../HtmxResponseAttachmentsProcessor.php | 13 --- .../HtmxResponseAttachmentsProcessor.php | 89 +++++++++++++++++++ core/misc/{ => htmx}/htmx-assets.js | 0 core/misc/{ => htmx}/htmx-behaviors.js | 18 +--- .../modules/test_htmx/test_htmx.info.yml | 2 +- 8 files changed, 115 insertions(+), 33 deletions(-) create mode 100644 core/lib/Drupal/Core/Render/Hypermedia/ProxyClass/Render/Hypermedia/HtmxResponseAttachmentsProcessor.php rename core/misc/{ => htmx}/htmx-assets.js (100%) rename core/misc/{ => htmx}/htmx-behaviors.js (58%) diff --git a/core/core.libraries.yml b/core/core.libraries.yml index 19be4912dee6..255fc911ec60 100644 --- a/core/core.libraries.yml +++ b/core/core.libraries.yml @@ -617,8 +617,8 @@ drupal.form: drupal.htmx: version: VERSION js: - misc/htmx-assets.js: {} - misc/htmx-behaviors.js: {} + misc/htmx/htmx-assets.js: {} + misc/htmx/htmx-behaviors.js: {} dependencies: - core/drupal - core/drupalSettings diff --git a/core/core.services.yml b/core/core.services.yml index c27b5cba167c..0a97bd5c1dfa 100644 --- a/core/core.services.yml +++ b/core/core.services.yml @@ -1403,6 +1403,7 @@ services: htmx_response.attachments_processor: class: Drupal\Core\Render\Hypermedia\HtmxResponseAttachmentsProcessor parent: html_response.attachments_processor + lazy: true htmx.response_subscriber: class: Drupal\Core\EventSubscriber\HtmxResponseSubscriber arguments: [ '@request_stack', '@htmx_response.attachments_processor' ] diff --git a/core/lib/Drupal/Core/EventSubscriber/HtmxResponseSubscriber.php b/core/lib/Drupal/Core/EventSubscriber/HtmxResponseSubscriber.php index 46768cac81ad..857cdebdeb24 100644 --- a/core/lib/Drupal/Core/EventSubscriber/HtmxResponseSubscriber.php +++ b/core/lib/Drupal/Core/EventSubscriber/HtmxResponseSubscriber.php @@ -4,19 +4,21 @@ namespace Drupal\Core\EventSubscriber; +use Drupal\Component\Utility\Html; use Drupal\Core\Render\AttachmentsResponseProcessorInterface; use Drupal\Core\Render\HtmlResponse; use Symfony\Component\EventDispatcher\EventSubscriberInterface; use Symfony\Component\HttpFoundation\RequestStack; +use Symfony\Component\HttpKernel\Event\RequestEvent; use Symfony\Component\HttpKernel\Event\ResponseEvent; use Symfony\Component\HttpKernel\KernelEvents; /** * Adds assets on responses to HTMX requests. * - * @see \Drupal\Core\Ajax\HtmxResponseAttachmentsProcessor + * @see \Drupal\Core\Render\Hypermedia\HtmxResponseAttachmentsProcessor */ -final class HtmxResponseSubscriber implements EventSubscriberInterface { +class HtmxResponseSubscriber implements EventSubscriberInterface { /** * Constructs a HtmxResponseSubscriber object. @@ -45,6 +47,20 @@ public function onRespond(ResponseEvent $event): void { $event->setResponse($processedResponse); } + /** + * Sets \Drupal\Component\Utility\Html::$isAjax to TRUE. + * + * @param \Symfony\Component\HttpKernel\Event\RequestEvent $event + * The request event, which contains the current request. + */ + public function onRequest(RequestEvent $event) { + $requestHeaders = $event->getRequest()->headers; + // Pass to the Html class that the current request is an Ajax request. + if ($requestHeaders->has('HX-Request')) { + Html::setIsAjax(TRUE); + } + } + /** * {@inheritdoc} */ @@ -52,6 +68,7 @@ public static function getSubscribedEvents(): array { return [ // Our method needs to run before the HtmlResponseSubscriber(weight 0). KernelEvents::RESPONSE => ['onRespond', 100], + KernelEvents::REQUEST => ['onRequest', 50], ]; } diff --git a/core/lib/Drupal/Core/Render/Hypermedia/HtmxResponseAttachmentsProcessor.php b/core/lib/Drupal/Core/Render/Hypermedia/HtmxResponseAttachmentsProcessor.php index f81ad577f1be..8493c1c1b367 100644 --- a/core/lib/Drupal/Core/Render/Hypermedia/HtmxResponseAttachmentsProcessor.php +++ b/core/lib/Drupal/Core/Render/Hypermedia/HtmxResponseAttachmentsProcessor.php @@ -22,19 +22,6 @@ */ class HtmxResponseAttachmentsProcessor extends HtmlResponseAttachmentsProcessor { - /** - * {@inheritdoc} - */ - public function processAttachments(AttachmentsInterface $response): HtmlResponse { - $processed = parent::processAttachments($response); - if (!($processed instanceof HtmlResponse)) { - // Something has gone wrong. We sent an HtmlResponse that also - // implemented AttachmentInterface and should have received the same back. - throw new \TypeError("HtmlResponseAttachmentsProcessor::processAttachments should return an HtmlResponse."); - } - return $processed; - } - /** * {@inheritdoc} */ diff --git a/core/lib/Drupal/Core/Render/Hypermedia/ProxyClass/Render/Hypermedia/HtmxResponseAttachmentsProcessor.php b/core/lib/Drupal/Core/Render/Hypermedia/ProxyClass/Render/Hypermedia/HtmxResponseAttachmentsProcessor.php new file mode 100644 index 000000000000..b8a9cd3cc108 --- /dev/null +++ b/core/lib/Drupal/Core/Render/Hypermedia/ProxyClass/Render/Hypermedia/HtmxResponseAttachmentsProcessor.php @@ -0,0 +1,89 @@ +<?php +// phpcs:ignoreFile + +/** + * This file was generated via php core/scripts/generate-proxy-class.php 'Drupal\Core\Render\Hypermedia\HtmxResponseAttachmentsProcessor' "core/lib/Drupal/Core/Render/Hypermedia". + */ + +namespace Drupal\Core\ProxyClass\Render\Hypermedia { + + /** + * Provides a proxy class for \Drupal\Core\Render\Hypermedia\HtmxResponseAttachmentsProcessor. + * + * @see \Drupal\Component\ProxyBuilder + */ + class HtmxResponseAttachmentsProcessor implements \Drupal\Core\Render\AttachmentsResponseProcessorInterface + { + + use \Drupal\Core\DependencyInjection\DependencySerializationTrait; + + /** + * The id of the original proxied service. + * + * @var string + */ + protected $drupalProxyOriginalServiceId; + + /** + * The real proxied service, after it was lazy loaded. + * + * @var \Drupal\Core\Render\Hypermedia\HtmxResponseAttachmentsProcessor + */ + protected $service; + + /** + * The service container. + * + * @var \Symfony\Component\DependencyInjection\ContainerInterface + */ + protected $container; + + /** + * Constructs a ProxyClass Drupal proxy object. + * + * @param \Symfony\Component\DependencyInjection\ContainerInterface $container + * The container. + * @param string $drupal_proxy_original_service_id + * The service ID of the original service. + */ + public function __construct(\Symfony\Component\DependencyInjection\ContainerInterface $container, $drupal_proxy_original_service_id) + { + $this->container = $container; + $this->drupalProxyOriginalServiceId = $drupal_proxy_original_service_id; + } + + /** + * Lazy loads the real service from the container. + * + * @return object + * Returns the constructed real service. + */ + protected function lazyLoadItself() + { + if (!isset($this->service)) { + $this->service = $this->container->get($this->drupalProxyOriginalServiceId); + } + + return $this->service; + } + + /** + * {@inheritdoc} + */ + public function processAttachments(\Drupal\Core\Render\AttachmentsInterface $response) + { + return $this->lazyLoadItself()->processAttachments($response); + } + + /** + * {@inheritdoc} + */ + public static function formatHttpHeaderAttributes(array $attributes = array ( + )) + { + \Drupal\Core\Render\HtmlResponseAttachmentsProcessor::formatHttpHeaderAttributes($attributes); + } + + } + +} diff --git a/core/misc/htmx-assets.js b/core/misc/htmx/htmx-assets.js similarity index 100% rename from core/misc/htmx-assets.js rename to core/misc/htmx/htmx-assets.js diff --git a/core/misc/htmx-behaviors.js b/core/misc/htmx/htmx-behaviors.js similarity index 58% rename from core/misc/htmx-behaviors.js rename to core/misc/htmx/htmx-behaviors.js index 3da0288b3e60..6986d4c79531 100644 --- a/core/misc/htmx-behaviors.js +++ b/core/misc/htmx/htmx-behaviors.js @@ -14,25 +14,13 @@ } /* Initialize listeners. */ - - Drupal.behaviors.htmxBehaviors = { - attach: () => { - if (!once('htmxBehaviors', 'html').length) { - return; - } - window.addEventListener('htmx:load', htmxDrupalBehaviors); - window.addEventListener('htmx:oobAfterSwap', htmxDrupalBehaviors); - }, - }; + window.addEventListener('htmx:load', htmxDrupalBehaviors); + window.addEventListener('htmx:oobAfterSwap', htmxDrupalBehaviors); /** * Also send new markup through htmx processing. */ Drupal.behaviors.htmxProcess = { - attach(context) { - if (typeof htmx === 'object' && htmx.hasOwnProperty('process')) { - htmx.process(context); - } - }, + attach: htmx.process, }; })(Drupal, once, htmx, drupalSettings); diff --git a/core/modules/system/tests/modules/test_htmx/test_htmx.info.yml b/core/modules/system/tests/modules/test_htmx/test_htmx.info.yml index 754f482385dc..713f8a551a5b 100644 --- a/core/modules/system/tests/modules/test_htmx/test_htmx.info.yml +++ b/core/modules/system/tests/modules/test_htmx/test_htmx.info.yml @@ -1,5 +1,5 @@ name: 'HTMX Test Fixtures' type: module description: 'Test fixtures for HTMX integration' -package: Test +package: Testing core_version_requirement: ^11 -- GitLab From e8eb11dff0b608e115fea14e4454d6608a410116 Mon Sep 17 00:00:00 2001 From: Shawn Duncan <duncans3@mskcc.org> Date: Thu, 1 May 2025 14:19:21 -0400 Subject: [PATCH 12/15] Use a service closure --- core/core.services.yml | 3 +- .../HtmxResponseSubscriber.php | 10 ++- .../HtmxResponseAttachmentsProcessor.php | 89 ------------------- 3 files changed, 8 insertions(+), 94 deletions(-) delete mode 100644 core/lib/Drupal/Core/Render/Hypermedia/ProxyClass/Render/Hypermedia/HtmxResponseAttachmentsProcessor.php diff --git a/core/core.services.yml b/core/core.services.yml index 0a97bd5c1dfa..13e74fb5c003 100644 --- a/core/core.services.yml +++ b/core/core.services.yml @@ -1403,10 +1403,9 @@ services: htmx_response.attachments_processor: class: Drupal\Core\Render\Hypermedia\HtmxResponseAttachmentsProcessor parent: html_response.attachments_processor - lazy: true htmx.response_subscriber: class: Drupal\Core\EventSubscriber\HtmxResponseSubscriber - arguments: [ '@request_stack', '@htmx_response.attachments_processor' ] + arguments: [ '@request_stack', !service_closure Use '@htmx_response.attachments_processor' ] tags: - { name: event_subscriber } finish_response_subscriber: diff --git a/core/lib/Drupal/Core/EventSubscriber/HtmxResponseSubscriber.php b/core/lib/Drupal/Core/EventSubscriber/HtmxResponseSubscriber.php index 857cdebdeb24..2ec01b9e5dff 100644 --- a/core/lib/Drupal/Core/EventSubscriber/HtmxResponseSubscriber.php +++ b/core/lib/Drupal/Core/EventSubscriber/HtmxResponseSubscriber.php @@ -24,8 +24,8 @@ class HtmxResponseSubscriber implements EventSubscriberInterface { * Constructs a HtmxResponseSubscriber object. */ public function __construct( - private readonly RequestStack $requestStack, - private readonly AttachmentsResponseProcessorInterface $attachmentsProcessor, + protected readonly RequestStack $requestStack, + protected \Closure $attachmentsProcessor, ) {} /** @@ -40,13 +40,17 @@ public function onRespond(ResponseEvent $event): void { // Only operate on HTML responses from an HTMX request. return; } - $processedResponse = $this->attachmentsProcessor->processAttachments($response); + $processedResponse = $this->getAttachementsProcessor()->processAttachments($response); if (!($processedResponse instanceof HtmlResponse)) { throw new \TypeError("ResponseEvent::setResponse() requires an HtmlResponse object"); } $event->setResponse($processedResponse); } + protected function getAttachementsProcessor(): AttachmentsResponseProcessorInterface { + return ($this->attachmentsProcessor)(); + } + /** * Sets \Drupal\Component\Utility\Html::$isAjax to TRUE. * diff --git a/core/lib/Drupal/Core/Render/Hypermedia/ProxyClass/Render/Hypermedia/HtmxResponseAttachmentsProcessor.php b/core/lib/Drupal/Core/Render/Hypermedia/ProxyClass/Render/Hypermedia/HtmxResponseAttachmentsProcessor.php deleted file mode 100644 index b8a9cd3cc108..000000000000 --- a/core/lib/Drupal/Core/Render/Hypermedia/ProxyClass/Render/Hypermedia/HtmxResponseAttachmentsProcessor.php +++ /dev/null @@ -1,89 +0,0 @@ -<?php -// phpcs:ignoreFile - -/** - * This file was generated via php core/scripts/generate-proxy-class.php 'Drupal\Core\Render\Hypermedia\HtmxResponseAttachmentsProcessor' "core/lib/Drupal/Core/Render/Hypermedia". - */ - -namespace Drupal\Core\ProxyClass\Render\Hypermedia { - - /** - * Provides a proxy class for \Drupal\Core\Render\Hypermedia\HtmxResponseAttachmentsProcessor. - * - * @see \Drupal\Component\ProxyBuilder - */ - class HtmxResponseAttachmentsProcessor implements \Drupal\Core\Render\AttachmentsResponseProcessorInterface - { - - use \Drupal\Core\DependencyInjection\DependencySerializationTrait; - - /** - * The id of the original proxied service. - * - * @var string - */ - protected $drupalProxyOriginalServiceId; - - /** - * The real proxied service, after it was lazy loaded. - * - * @var \Drupal\Core\Render\Hypermedia\HtmxResponseAttachmentsProcessor - */ - protected $service; - - /** - * The service container. - * - * @var \Symfony\Component\DependencyInjection\ContainerInterface - */ - protected $container; - - /** - * Constructs a ProxyClass Drupal proxy object. - * - * @param \Symfony\Component\DependencyInjection\ContainerInterface $container - * The container. - * @param string $drupal_proxy_original_service_id - * The service ID of the original service. - */ - public function __construct(\Symfony\Component\DependencyInjection\ContainerInterface $container, $drupal_proxy_original_service_id) - { - $this->container = $container; - $this->drupalProxyOriginalServiceId = $drupal_proxy_original_service_id; - } - - /** - * Lazy loads the real service from the container. - * - * @return object - * Returns the constructed real service. - */ - protected function lazyLoadItself() - { - if (!isset($this->service)) { - $this->service = $this->container->get($this->drupalProxyOriginalServiceId); - } - - return $this->service; - } - - /** - * {@inheritdoc} - */ - public function processAttachments(\Drupal\Core\Render\AttachmentsInterface $response) - { - return $this->lazyLoadItself()->processAttachments($response); - } - - /** - * {@inheritdoc} - */ - public static function formatHttpHeaderAttributes(array $attributes = array ( - )) - { - \Drupal\Core\Render\HtmlResponseAttachmentsProcessor::formatHttpHeaderAttributes($attributes); - } - - } - -} -- GitLab From 4dfed48b4bf8cb7fa5e4ed109c63088433ff2ed8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9odore=20BIADALA?= <theodore@biadala.net> Date: Fri, 2 May 2025 10:54:44 +0200 Subject: [PATCH 13/15] fix declaration --- core/core.services.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/core.services.yml b/core/core.services.yml index 13e74fb5c003..ce288b667a3a 100644 --- a/core/core.services.yml +++ b/core/core.services.yml @@ -1405,7 +1405,7 @@ services: parent: html_response.attachments_processor htmx.response_subscriber: class: Drupal\Core\EventSubscriber\HtmxResponseSubscriber - arguments: [ '@request_stack', !service_closure Use '@htmx_response.attachments_processor' ] + arguments: [ '@request_stack', !service_closure '@htmx_response.attachments_processor' ] tags: - { name: event_subscriber } finish_response_subscriber: -- GitLab From 043e531dce9da8b2f0bd69353ce1483f45c35bf0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9odore=20BIADALA?= <theodore@biadala.net> Date: Fri, 2 May 2025 11:00:10 +0200 Subject: [PATCH 14/15] don't use inline template for the test button --- .../HtmxTestAttachmentsController.php | 22 +++++++++++++------ .../Tests/htmx/htmxAssetLoadTest.js | 4 ++-- 2 files changed, 17 insertions(+), 9 deletions(-) diff --git a/core/modules/system/tests/modules/test_htmx/src/Controller/HtmxTestAttachmentsController.php b/core/modules/system/tests/modules/test_htmx/src/Controller/HtmxTestAttachmentsController.php index c90a7aec7dfd..b100d8adbea5 100644 --- a/core/modules/system/tests/modules/test_htmx/src/Controller/HtmxTestAttachmentsController.php +++ b/core/modules/system/tests/modules/test_htmx/src/Controller/HtmxTestAttachmentsController.php @@ -20,6 +20,20 @@ final class HtmxTestAttachmentsController extends ControllerBase { */ public function page(): array { $url = Url::fromRoute('test_htmx.attachments.replace'); + $build['replace'] = [ + '#type' => 'button', + '#attributes' => [ + 'name' => 'replace', + 'data-hx-get' => $url->toString(), + 'data-hx-select' => 'div.dropbutton-wrapper', + 'data-hx-target' => '[data-drupal-htmx-target]', + ], + '#value' => 'Click this', + '#context' => [ + 'url' => $url, + ], + ]; + $build['content'] = [ '#type' => 'container', '#attached' => [ @@ -28,15 +42,9 @@ public function page(): array { ], ], '#attributes' => [ + 'data-drupal-htmx-target' => TRUE, 'class' => ['htmx-test-container'], ], - 'button' => [ - '#type' => 'inline_template', - '#template' => '<button class="button" name="replace" data-hx-get="{{- url -}}" data-hx-select="div.dropbutton-wrapper" data-hx-swap="afterend">Click this</button>', - '#context' => [ - 'url' => $url, - ], - ], ]; return $build; diff --git a/core/tests/Drupal/Nightwatch/Tests/htmx/htmxAssetLoadTest.js b/core/tests/Drupal/Nightwatch/Tests/htmx/htmxAssetLoadTest.js index ff75fe000cd2..8af529d826e1 100644 --- a/core/tests/Drupal/Nightwatch/Tests/htmx/htmxAssetLoadTest.js +++ b/core/tests/Drupal/Nightwatch/Tests/htmx/htmxAssetLoadTest.js @@ -32,9 +32,9 @@ module.exports = { .drupalRelativeURL('/htmx-test-attachments/page') .waitForElementVisible('body', 1000) .assert.not.elementPresent('script[src*="dropbutton.js"]') - .waitForElementVisible('button[name="replace"]', 1000) + .waitForElementVisible('[name="replace"]', 1000) .pause(1000) - .click('button[name="replace"]') + .click('[name="replace"]') .waitForElementVisible('div.dropbutton-wrapper', 60000) .waitForElementVisible('div[data-once="dropbutton"]', 60000) .assert.elementPresent('script[src*="dropbutton.js"]'); -- GitLab From a636ed3546ad290b47c44ef852b2b802e671d030 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9odore=20BIADALA?= <theodore@biadala.net> Date: Fri, 2 May 2025 11:04:51 +0200 Subject: [PATCH 15/15] simplify the behaviors init --- core/core.libraries.yml | 3 +-- core/misc/htmx/htmx-behaviors.js | 28 +++++++++++++++++----------- 2 files changed, 18 insertions(+), 13 deletions(-) diff --git a/core/core.libraries.yml b/core/core.libraries.yml index 255fc911ec60..48eb3b17a230 100644 --- a/core/core.libraries.yml +++ b/core/core.libraries.yml @@ -620,11 +620,10 @@ drupal.htmx: misc/htmx/htmx-assets.js: {} misc/htmx/htmx-behaviors.js: {} dependencies: + - core/htmx - core/drupal - core/drupalSettings - - core/htmx - core/loadjs - - core/once drupal.machine-name: version: VERSION diff --git a/core/misc/htmx/htmx-behaviors.js b/core/misc/htmx/htmx-behaviors.js index 6986d4c79531..90a44426736d 100644 --- a/core/misc/htmx/htmx-behaviors.js +++ b/core/misc/htmx/htmx-behaviors.js @@ -2,25 +2,31 @@ * @file * Connect Drupal.behaviors to htmx inserted content. */ -(function (Drupal, once, htmx, drupalSettings) { - function htmxDrupalBehaviors(htmxLoadEvent) { - // The attachBehaviors method searches within the context. - // We need to go up one level so that the loaded content is processed - // completely. +(function (Drupal, htmx, drupalSettings) { + + + // @todo replace with a custom event that will trigger once all the assets + // have been loaded. + htmx.on('htmx:load', (event) => { Drupal.attachBehaviors( - htmxLoadEvent.detail.elt?.parentElement, + // The attachBehaviors method searches within the context. + // We need to go up one level so that the loaded content is processed + // completely. + event.detail.elt?.parentElement, drupalSettings, ); - } + }); - /* Initialize listeners. */ - window.addEventListener('htmx:load', htmxDrupalBehaviors); - window.addEventListener('htmx:oobAfterSwap', htmxDrupalBehaviors); + // When htmx removes elements from the DOM, make sure they're detached first. + htmx.on('htmx:beforeSwap', (event) => { + Drupal.detachBehaviors(event.detail.elt, drupalSettings, 'unload'); + }); /** + * @todo move that to ajax.js this is not necessary since htmx initializes the markup it adds to the page automatically. * Also send new markup through htmx processing. */ Drupal.behaviors.htmxProcess = { attach: htmx.process, }; -})(Drupal, once, htmx, drupalSettings); +})(Drupal, htmx, drupalSettings); -- GitLab