Motivation

ManagedFile::uploadAjaxCallback() renders a status_messages element into the replaced widget's #prefix and ships it inside the insert AJAX command. It should use the AJAX MessageCommand instead, which has been introduced with Drupal 8.8 - see change record.

This solves a couple of things:

1. Atm, this server-rendered messages use AJAX insert command and thus go via XSS filtering, stripping things like Olivero's SVG message icon. There is dedicated bug report for this #3457067: Status messages rendered in managed file form elements filter HTML too aggressively, which this issue would resolve.

2. Accessibility: Messages rendered via the AJAX message command are properly using aria live announcements, previously it relied on inline markup adding a role="alert" markup or similar. Live announcements is the way to do this right.

3. Flexible placement: Atm errors appear inside the file widget, between the Save button and the field, instead of the theme's message region where every other message goes. The MessageCommand supports both, via the wrapperQuerySelector - so we can decide what is preferred UX wise. Note: For better backwards compatibility, I've kept it within the widget in the attached MR.

4. By giving the theme the control to render the message, it allows decoupled rendering to render it nicely also. -> I'm using this for making support of ajax file uploads in Drupal-forms embedded in lupus-decoupled frontends nice.

Steps to reproduce

  • Drupal 11.4, standard install, Olivero, content type with a file field.
  • On the node form, upload a file exceeding the limit.
  • The error renders inside the widget and the page message region stays empty.

Proposed resolution

Adopt the AJAX MessageCommand.

User interface changes

When uploading a file via the core #managed_file element and errors occur, the error messages are rendered via the themes' implementation of the MessageCommand, not via server-rendered messages. This might be slightly different, depending on the theme.

Introduced terminology

-

API changes

-

Data model changes

-

Issue fork drupal-3618230

Command icon Show commands

Start within a Git clone of the project using the version control instructions.

Or, if you do not have SSH keys set up on git.drupalcode.org:

Comments

fago created an issue. See original summary.

fago’s picture

Issue summary: View changes
fago’s picture

Issue summary: View changes
Issue tags: +Accessibility

fago’s picture

Status: Active » Needs review

MR implemented, ready and tested successfully.

fago’s picture

Issue summary: View changes
fago’s picture

StatusFileSize
new67.45 KB

For better BC I decided to show the message where it has been shown so far, within the widget area and implemented it like this.

The result shows like this now:

Screenshot of Drupal show managed file error

fago’s picture

Issue summary: View changes

adding my use-case to render messages in a decoupled frontend nicely, as another motivation for this.

mgifford’s picture

Looks good @fago - thanks.

I added some AI generated tests as well as the logs of running https://www.guidepup.dev to see the changes for some assistive tech.

kentr’s picture

Status: Needs review » Postponed (maintainer needs more info)

Olivero has been removed from core: #3595089: Remove the Olivero theme.

Does this problem occur in the Default Admin theme?

mgifford’s picture

Status: Postponed (maintainer needs more info) » Active

Yes, it still appears with the Default Admin Theme.

A AI summer is here:
https://github.com/mgifford/drupal-core-lab/blob/main/reports/issues/361...

mgifford’s picture

I failed to add core/modules/file/tests/src/FunctionalJavascript/FileUploadAjaxMessageTest.php to the MR. Probably best.

<?php

declare(strict_types=1);

namespace Drupal\Tests\file\FunctionalJavascript;

use Drupal\FunctionalJavascriptTests\WebDriverTestBase;
use Drupal\Tests\file\Functional\FileFieldCreationTrait;
use Drupal\Tests\TestFileCreationTrait;
use PHPUnit\Framework\Attributes\Group;
use PHPUnit\Framework\Attributes\RunTestsInSeparateProcesses;

/**
 * Tests the messages of a file widget's AJAX upload in the admin theme.
 */
#[Group('file')]
#[RunTestsInSeparateProcesses]
class FileUploadAjaxMessageTest extends WebDriverTestBase {

  use FileFieldCreationTrait;
  use TestFileCreationTrait;

  /**
   * {@inheritdoc}
   */
  protected static $modules = ['node', 'file'];

  /**
   * {@inheritdoc}
   */
  protected $defaultTheme = 'default_admin';

  /**
   * The name of the file field on the article form.
   */
  protected string $fieldName;

  /**
   * {@inheritdoc}
   */
  protected function setUp(): void {
    parent::setUp();
    $this->fieldName = $this->randomMachineName();
    $this->drupalCreateContentType(['type' => 'article', 'name' => 'Article']);
    // A size limit the server checks. A wrong extension would be rejected by
    // file.js in the browser, without an AJAX request.
    $this->createFileField($this->fieldName, 'node', 'article', [], [
      'file_extensions' => 'txt',
      'max_filesize' => '1 KB',
    ]);
    $this->drupalLogin($this->drupalCreateUser([
      'access content',
      'create article content',
    ]));
  }

  /**
   * Uploads a text file into the field and waits for the AJAX response.
   *
   * @param bool $too_big
   *   TRUE for a file over the 1 KB limit (rejected by the server), FALSE for
   *   a small one (accepted).
   */
  protected function uploadTestFile(bool $too_big): void {
    $uri = $too_big ? $this->generateFile('too_big', 64, 40, 'text') : $this->generateFile('small', 16, 2, 'text');
    $path = $this->container->get('file_system')->realpath($uri);
    $this->getSession()->getPage()->attachFileToField('files[' . $this->fieldName . '_0]', $path);
    $this->assertSession()->assertWaitOnAjaxRequest();
  }

  /**
   * Waits until the size error is shown a given number of times.
   *
   * Counts the error text, not a class: the server-side and JavaScript
   * message templates of a theme can use different classes.
   *
   * @param int $count
   *   The expected number of times the error is shown.
   *
   * @return int
   *   The number of times it is shown.
   */
  protected function waitForErrorCount(int $count): int {
    $page = $this->getSession()->getPage();
    $shown = fn() => substr_count($page->getText(), 'exceeding the maximum file size');
    $page->waitFor(10, fn() => $shown() === $count);
    return $shown();
  }

  /**
   * Tests the error's place, and that a later upload replaces or clears it.
   */
  public function testErrorIsReplaced(): void {
    $this->drupalGet('node/add/article');
    $this->uploadTestFile(TRUE);
    $this->assertSame(1, $this->waitForErrorCount(1));

    // The error is shown inside the widget, not in the page's message area.
    $widget = $this->assertSession()->elementExists('css', '[data-drupal-selector="edit-' . str_replace('_', '-', $this->fieldName) . '-wrapper"]');
    $this->assertStringContainsString('exceeding the maximum file size', $widget->getText());

    // A second rejected upload shows one error, not two.
    $this->uploadTestFile(TRUE);
    $this->assertSame(1, $this->waitForErrorCount(1));

    // An accepted upload removes the error.
    $this->uploadTestFile(FALSE);
    $this->assertSession()->waitForElement('named', ['id_or_name', $this->fieldName . '_0_remove_button']);
    $this->assertSame(0, $this->waitForErrorCount(0));
  }

  /**
   * Tests that the error is announced to assistive technology.
   */
  public function testErrorIsAnnounced(): void {
    $this->drupalGet('node/add/article');
    $this->uploadTestFile(TRUE);
    $this->waitForErrorCount(1);

    // Drupal.announce() writes into #drupal-live-announce after a short delay.
    // The region is visually hidden, so its text is read with JavaScript.
    $script = "(document.getElementById('drupal-live-announce') || {}).textContent || ''";
    $announced = $this->getSession()->getPage()->waitFor(10, fn() => str_contains($this->getSession()->evaluateScript($script), 'exceeding the maximum file size'));
    $this->assertTrue($announced, 'The upload error is announced through Drupal.announce().');
  }

  /**
   * Tests that the theme's message markup is not filtered out.
   *
   * The theme renders a dismiss button in each message. Messages placed in
   * the replaced markup lose it to the admin XSS filter.
   */
  public function testErrorKeepsThemeMarkup(): void {
    $this->drupalGet('node/add/article');
    $this->uploadTestFile(TRUE);
    $this->assertSame(1, $this->waitForErrorCount(1));
    $error = $this->getSession()->getPage()->find('css', '.messages--error');
    $this->assertNotNull($error);
    $this->assertNotNull($error->find('css', 'button.js-message-button-hide'), 'The error keeps its dismiss button.');
  }

}