Verified Commit 2ae3db67 authored by Dave Long's avatar Dave Long
Browse files

fix: #3622958 Target can be null in htmx-assets.js

By: fathershawn
By: godotislate
(cherry picked from commit 0af7577c)
parent 2509cbe2
Loading
Loading
Loading
Loading
Loading
+6 −1
Changes for core/misc/htmx/htmx-assets.js: 6 added lines, 1 removed line.
Original line number Diff line number Diff line
@@ -84,6 +84,9 @@
      const { ctx } = detail;
      const url = new URL(ctx.request.action, document.location.href);
      const drupalIdentifier = (element) => {
        if (!(element instanceof Element)) {
          return '';
        }
        if (element?.name) {
          return `${element.tagName.toLowerCase()}[name="${encodeURI(element.name)}"]`;
        }
@@ -163,9 +166,11 @@
        if (
          task.swapSpec.style === 'none' ||
          task.swapSpec.style.includes('after') ||
          task.swapSpec.style.includes('before')
          task.swapSpec.style.includes('before') ||
          !(task.target instanceof Element)
        ) {
          // Swap style does not remove elements
          // or target is not an Element, so no processing is required.
          continue;
        }
        processTargets.add(behaviorTarget(task.target));
+70 −0
Changes for core/modules/system/tests/modules/test_htmx/src/Controller/HtmxFragmentController.php: 70 added lines, 0 removed lines.
Original line number Diff line number Diff line
<?php

declare(strict_types=1);

namespace Drupal\test_htmx\Controller;

use Drupal\Core\Controller\ControllerBase;
use Drupal\Core\Routing\Attribute\Route;

/**
 * Returns responses for HTMX Test Fixtures routes.
 */
#[Route(
  name: 'test_htmx.',
  requirements: ['_permission' => 'access content'],
  defaults: ['_title' => 'Htmx Fragment'],
)]
final class HtmxFragmentController extends ControllerBase {

  /**
   * Builds the partial response.
   */
  #[Route(
    path: '/test-htmx/htmx-fragment',
    name: 'htmx_fragment',
    defaults: [
      'target' => '[data-drupal-wrapper-selector="edit-partial-replace"]',
      'swap' => 'outerHTML',
    ]
  )]
  #[Route(
    path: '/test-htmx/htmx-fragment-delete',
    name: 'htmx_fragment_delete',
    defaults: [
      'target' => '[data-drupal-wrapper-selector="edit-partial-replace"]',
      'swap' => 'delete',
    ]
  )]
  public function buildPartial(string $target, string $swap): array {
    return [
      '#type' => 'inline_template',
      '#template' => <<<HTMX_PARTIAL
<hx-partial hx-target="{{ target }}" hx-swap="{{ swap }}">
  {{item}}
</hx-partial>
HTMX_PARTIAL,
      '#context' => [
        'target' => $target,
        'swap' => $swap,
        'item' => $swap === 'delete' ? '' : [
          '#type' => 'html_tag',
          '#tag' => 'p',
          '#value' => 'Inserted by htmx!',
        ],
      ],
    ];
  }

  #[Route(
    path: '/test-htmx/htmx-fragment-missing',
    name: 'htmx_fragment_missing',
  )]
  public function buildDeleteInsertPartial(): array {
    return [
      'missing' => $this->buildPartial('div.does-not-exist', 'outerHTML'),
      'insert' => $this->buildPartial('[data-drupal-wrapper-selector="edit-partial-replace"]', 'outerHTML'),
    ];
  }

}
+43 −0
Changes for core/modules/system/tests/modules/test_htmx/src/Form/HtmxTestForm.php: 43 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -67,6 +67,49 @@ public function buildForm(array $form, FormStateInterface $form_state, ?string $
      ->swapOob(TRUE)
      ->applyTo($form['data'], '#wrapper_attributes');

    $form['bad_swap_target'] = [
      '#type' => 'checkbox',
      '#title' => 'Target does not exist',
    ];
    (new Htmx())
      ->get($formUrl)
      ->select('select')
      ->swap('innerHTML')
      ->target('div.bad-target')
      ->applyTo($form['bad_swap_target']);
    // Test hx-replace fragments.
    $partialReplace = Url::fromRoute('test_htmx.htmx_fragment');
    $form['partial-replace'] = [
      '#type' => 'item',
      '#title' => 'Replacement target',
    ];
    $form['partial'] = [
      '#type' => 'checkbox',
      '#title' => 'Replace via partial',
    ];
    (new Htmx())
      ->get($partialReplace)
      ->onlyMainContent()
      ->applyTo($form['partial']);
    $partialMissingTarget = Url::fromRoute('test_htmx.htmx_fragment_missing');
    $form['partial_missing'] = [
      '#type' => 'checkbox',
      '#title' => 'Target does not exist',
    ];
    (new Htmx())
      ->get($partialMissingTarget)
      ->onlyMainContent()
      ->applyTo($form['partial_missing']);
    $partialDelete = Url::fromRoute('test_htmx.htmx_fragment_delete');
    $form['partial_delete'] = [
      '#type' => 'checkbox',
      '#title' => 'Delete via partial',
    ];
    (new Htmx())
      ->get($partialDelete)
      ->onlyMainContent()
      ->applyTo($form['partial_delete']);

    $push = FALSE;
    if ($this->getHtmxTriggerName() === 'type') {
      $form['data']['#markup'] = '';
+70 −2
Changes for core/tests/Drupal/FunctionalJavascriptTests/Core/Htmx/HtmxDynamicFormTest.php: 70 added lines, 2 removed lines.
Original line number Diff line number Diff line
@@ -36,7 +36,7 @@ public function testHtmxForm(): void {
      'access content',
    ]));
    // Check that the data is empty on load.
    $this->drupalGet('/htmx-test-attachments/form-builder-test/');
    $this->drupalGet('htmx-test-attachments/form-builder-test/');
    $page = $this->getSession()->getPage();
    $this->assertTrue($this->assertSession()->optionExists('selected', '- None -')->isSelected());
    $this->assertSession()->pageTextNotContains('Data is');
@@ -91,7 +91,7 @@ public function testWrapperFormat(): void {
    $this->drupalLogin($this->drupalCreateUser([
      'access content',
    ]));
    $this->drupalGet('/htmx-test-attachments/wrapper');
    $this->drupalGet('htmx-test-attachments/wrapper');
    $button = $this->assertSession()->elementExists('css', '[name="replace"]');
    // Should be present in the HTMX markup on the button.
    $this->assertSession()->elementExists('css', '[data-hx-drupal-only-main-content]');
@@ -102,4 +102,72 @@ public function testWrapperFormat(): void {
    $this->assertSession()->elementExists('css', '.htmx-test-flag');
  }

  /**
   * Verify that a bad swap target selector does not cause an error.
   */
  public function testMissingSwapTarget(): void {
    $this->drupalLogin($this->drupalCreateUser([
      'access content',
    ]));
    $this->drupalGet('htmx-test-attachments/form-builder-test/');
    $checkbox = $this->assertSession()->elementExists('css', '[name="bad_swap_target"]');
    // Selector used in the htmx target attribute.
    $this->assertSession()->elementNotExists('css', 'div.bad-target');
    $checkbox->click();
    $this->assertSession()->assertWaitOnAjaxRequest();
  }

  /**
   * Verify that response with two partials: one bad, one good.
   *
   * The first partial is missing a target, the second is good. The missing
   * target should not cause an error, execution should continue, and the good
   * partial should be applied.
   */
  public function testMissingPartialTarget(): void {
    $this->drupalLogin($this->drupalCreateUser([
      'access content',
    ]));
    $this->drupalGet('htmx-test-attachments/form-builder-test/');
    $checkbox = $this->assertSession()->elementExists('css', '[name="partial_missing"]');
    // Selector used in the htmx target attribute.
    $this->assertSession()->elementNotExists('css', 'div.does-not-exist');
    $checkbox->click();
    $this->assertSession()->assertWaitOnAjaxRequest();
    $this->assertSession()->elementNotExists('css', '[data-drupal-wrapper-selector="edit-partial-replace"]');
    $this->assertSession()->pageTextContainsOnce('Inserted by htmx!');
  }

  /**
   * Verify that an hx-partial response replaces the target.
   */
  public function testPartialTarget(): void {
    $this->drupalLogin($this->drupalCreateUser([
      'access content',
    ]));
    $this->drupalGet('htmx-test-attachments/form-builder-test/');
    $this->assertSession()->elementExists('css', '[data-drupal-wrapper-selector="edit-partial-replace"]');
    $this->assertSession()->pageTextNotContains('Inserted by htmx!');
    $checkbox = $this->assertSession()->elementExists('css', '[name="partial"]');
    $checkbox->click();
    $this->assertSession()->assertWaitOnAjaxRequest();
    $this->assertSession()->elementNotExists('css', '[data-drupal-wrapper-selector="edit-partial-replace"]');
    $this->assertSession()->pageTextContainsOnce('Inserted by htmx!');
  }

  /**
   * Verify that an hx-partial response with a delete swap removes the target.
   */
  public function testPartialDelete(): void {
    $this->drupalLogin($this->drupalCreateUser([
      'access content',
    ]));
    $this->drupalGet('htmx-test-attachments/form-builder-test/');
    $this->assertSession()->elementExists('css', '[data-drupal-wrapper-selector="edit-partial-replace"]');
    $checkbox = $this->assertSession()->elementExists('css', '[name="partial_delete"]');
    $checkbox->click();
    $this->assertSession()->assertWaitOnAjaxRequest();
    $this->assertSession()->elementNotExists('css', '[data-drupal-wrapper-selector="edit-partial-replace"]');
  }

}