From 7fd4d66c466530279e6050855f71bcf646ba5301 Mon Sep 17 00:00:00 2001
From: Pieter Frenssen <pieter@frenssen.be>
Date: Fri, 31 Jan 2025 16:02:32 +0200
Subject: [PATCH 01/20] Support file uploads in the schema.

---
 graphql/webform.base.graphqls | 15 +++++++++++++++
 1 file changed, 15 insertions(+)

diff --git a/graphql/webform.base.graphqls b/graphql/webform.base.graphqls
index 2b04f26..8e78259 100644
--- a/graphql/webform.base.graphqls
+++ b/graphql/webform.base.graphqls
@@ -1,9 +1,11 @@
+scalar WebformSubmissionFileUpload
 scalar WebformSubmissionValue
 
 type Mutation {
   submitWebform(
     id: String!
     elements: [WebformSubmissionElement]
+    files: [WebformSubmissionFile]
     sourceEntityId: String
     sourceEntityType: String
   ): WebformSubmissionResult!
@@ -52,3 +54,16 @@ input WebformSubmissionElement {
   """
   value: WebformSubmissionValue!
 }
+
+input WebformSubmissionFile {
+  """
+  The name of the webform element.
+  """
+  element: String!
+
+  """
+  The file to upload. This is usually null and the file is appended via
+  multipart formdata maps.
+  """
+  file: WebformSubmissionFileUpload!
+}
-- 
GitLab


From b0d03909263fb4e2ba8f1bff0b334853d3d5ec09 Mon Sep 17 00:00:00 2001
From: Pieter Frenssen <pieter@frenssen.be>
Date: Mon, 3 Feb 2025 15:21:27 +0200
Subject: [PATCH 02/20] Start porting the file upload handling from the PoC.

---
 graphql/webform.base.graphqls                 |   4 +-
 .../GraphQL/DataProducer/WebformSubmit.php    |  33 +++-
 tests/queries/submission_with_file_upload.gql |  18 +++
 .../FormSubmissionWithFileUploadTest.php      | 145 ++++++++++++++++++
 4 files changed, 196 insertions(+), 4 deletions(-)
 create mode 100644 tests/queries/submission_with_file_upload.gql
 create mode 100644 tests/src/Kernel/Mutation/FormSubmissionWithFileUploadTest.php

diff --git a/graphql/webform.base.graphqls b/graphql/webform.base.graphqls
index 8e78259..508cf59 100644
--- a/graphql/webform.base.graphqls
+++ b/graphql/webform.base.graphqls
@@ -1,11 +1,11 @@
-scalar WebformSubmissionFileUpload
+scalar WebformSubmissionUploadedFile
 scalar WebformSubmissionValue
 
 type Mutation {
   submitWebform(
     id: String!
     elements: [WebformSubmissionElement]
-    files: [WebformSubmissionFile]
+    files: [WebformSubmissionUploadedFile]
     sourceEntityId: String
     sourceEntityType: String
   ): WebformSubmissionResult!
diff --git a/src/Plugin/GraphQL/DataProducer/WebformSubmit.php b/src/Plugin/GraphQL/DataProducer/WebformSubmit.php
index d3339da..9ca6ac9 100644
--- a/src/Plugin/GraphQL/DataProducer/WebformSubmit.php
+++ b/src/Plugin/GraphQL/DataProducer/WebformSubmit.php
@@ -40,6 +40,9 @@ use Symfony\Component\DependencyInjection\ContainerInterface;
  *     "elements" = @ContextDefinition("any",
  *       label = @Translation("Array of WebformSubmissionElement objects.")
  *     ),
+ *     "files" = @ContextDefinition("any",
+ *       label = @Translation("Array of WebformSubmissionFile objects.")
+ *     ),
  *     "sourceEntityType" = @ContextDefinition("string",
  *       label = @Translation("Source entity type"),
  *       required = FALSE
@@ -109,6 +112,8 @@ class WebformSubmit extends DataProducerPluginBase implements ContainerFactoryPl
    *   The Webform ID for which to create a submission.
    * @param array $elements
    *   Submitted values for Webform elements.
+   * @param array $files
+   *   Submitted file uploads.
    * @param string|null $sourceEntityType
    *   The entity type of the entity that is the source of the submission.
    * @param string|null $sourceEntityId
@@ -121,9 +126,12 @@ class WebformSubmit extends DataProducerPluginBase implements ContainerFactoryPl
    */
   public function resolve(
     string $id,
+// @todo Investigate this comment. There seems to be no suppression of coding
+// standards warnings.
     // The GraphQL field context is only populated if it is at the end of the
     // argument list. Suppress the coding standards warning for this.
     array $elements,
+    array $files,
     ?string $sourceEntityType,
     ?string $sourceEntityId,
     FieldContext $field,
@@ -131,7 +139,7 @@ class WebformSubmit extends DataProducerPluginBase implements ContainerFactoryPl
     // Create context and execute validation and submission in render context to
     // capture caching metadata.
     $renderContext = new RenderContext();
-    $renderResult = $this->renderer->executeInRenderContext($renderContext, function () use ($id, $elements, $field, $sourceEntityId, $sourceEntityType) {
+    $renderResult = $this->renderer->executeInRenderContext($renderContext, function () use ($id, $elements, $files, $field, $sourceEntityId, $sourceEntityType) {
       // Create the result object.
       $result = new WebformSubmissionResult();
 
@@ -172,10 +180,24 @@ class WebformSubmit extends DataProducerPluginBase implements ContainerFactoryPl
         $dataElements[$element['element']] = $element['value'];
       }
 
+      // Handle file uploads. This will add validation messages if they happen.
+      $filesMapped = $this->handleFileElements($webform, $files, $result);
+
+      // Check if any errors or validations happened during file upload
+      // handling.
+      if (!$result->isValid()) {
+        return $result;
+      }
+
+      // Build form data for file elements.
+// @todo This method is missing. We have no multiple elements.
+// @todo This method has been replaced with the simple foreach() loop just above.
+      $fileElements = $this->buildFormData($filesMapped, $elementsWithMultiple);
+
       // Build the form data object for the form submission.
       $formData = [
         'webform_id' => $id,
-        'data' => $dataElements,
+        'data' => array_merge($dataElements, $fileElements),
       ];
 
       // Only include the source entity type and ID if both are provided.
@@ -276,7 +298,10 @@ class WebformSubmit extends DataProducerPluginBase implements ContainerFactoryPl
       /** @var \Drupal\webform\Plugin\WebformElement\WebformManagedFileBase $element */
       $element = $this->elementManager->getElementInstance($configuration);
 
+      $element->prepare($configuration);
+
       // Get file validation and upload settings.
+// @todo This? Or the one below?
       $fileExtensions = $element->getElementProperty($configuration, 'file_extensions');
       $maxFilesize = $element->getElementProperty($configuration, 'max_filesize');
       $uriScheme = $element->getElementProperty($configuration, 'uri_scheme');
@@ -296,6 +321,10 @@ class WebformSubmit extends DataProducerPluginBase implements ContainerFactoryPl
         $elementsMultiple[] = $elementKey;
       }
 
+// @todo This? Or the one above?
+      $maxFilesize = $configuration['#upload_validators']['file_validate_size'][0];
+      $fileExtensions = $configuration['#upload_validators']['file_validate_extensions'][0];
+
       // Perform the upload.
       $upload = $this->fileUpload->saveFileUpload($item['file'], [
         'file_extensions' => $fileExtensions,
diff --git a/tests/queries/submission_with_file_upload.gql b/tests/queries/submission_with_file_upload.gql
new file mode 100644
index 0000000..2c6166e
--- /dev/null
+++ b/tests/queries/submission_with_file_upload.gql
@@ -0,0 +1,18 @@
+mutation submit(
+  $id: String!,
+  $elements: [WebformSubmissionElement]!,
+  $files: [WebformSubmissionUploadedFile]!,
+) {
+  submitWebform(
+    id: $id,
+    elements: $elements,
+    files: $files,
+  ) {
+    submission {
+      id
+      webform {
+        id
+      }
+    }
+  }
+}
diff --git a/tests/src/Kernel/Mutation/FormSubmissionWithFileUploadTest.php b/tests/src/Kernel/Mutation/FormSubmissionWithFileUploadTest.php
new file mode 100644
index 0000000..a99d84e
--- /dev/null
+++ b/tests/src/Kernel/Mutation/FormSubmissionWithFileUploadTest.php
@@ -0,0 +1,145 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\Tests\graphql_webform\Kernel\Mutation;
+
+use Drupal\Core\File\FileSystemInterface;
+use Drupal\file\FileInterface;
+use Drupal\file\FileStorageInterface;
+use Symfony\Component\HttpFoundation\Request;
+
+/**
+ * Test file uploads with graphql.
+ *
+ * @group graphql_webform
+ */
+final class FormSubmissionWithFileUploadTest extends FormSubmissionTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $modules = [
+    'file',
+  ];
+
+  /**
+   * The file storage.
+   *
+   * @var \Drupal\file\FileStorageInterface|null
+   */
+  protected ?FileStorageInterface $fileStorage;
+
+  /**
+   * The file system.
+   *
+   * @var \Drupal\Core\File\FileSystemInterface|null
+   */
+  protected ?FileSystemInterface $fileSystem;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp(): void {
+    parent::setUp();
+
+    $this->installEntitySchema('file');
+
+    $this->fileStorage = $this->container->get('entity_type.manager')->getStorage('file');
+    $this->fileSystem = $this->container->get('file_system');
+  }
+
+  /**
+   * Tests uploading a file through the webformFileUpload mutation.
+   */
+  public function testFileUpload() {
+    // We are pretending to upload a file into temporary storage. Ensure a file
+    // is there because the Symfony UploadedFile component will check that.
+    $file = $this->fileSystem->getTempDirectory() . '/graphql_webform_upload_test.txt';
+    touch($file);
+
+    // Create a post request with file contents.
+    $request = Request::create('/graphql', 'POST', [
+      'query' => $this->getQueryFromFile('submission_with_file_upload.gql'),
+      'variables' => [
+        // When using multipart form uploads, the variable holding the files has
+        // to be declared NULL.
+        'files' => NULL,
+        'webform_element_id' => 'file_upload',
+        'id' => 'graphql_webform_test_form',
+      ],
+      // Then map the name used for the file upload to the variable.
+      // @todo How to handle multiple files?
+      'map' => [
+        'test' => ['variables.files'],
+      ],
+    ], [], [
+      'test' => [
+        'name' => 'test.txt',
+        'type' => 'text/plain',
+        'size' => 42,
+        'tmp_name' => $file,
+        'error' => UPLOAD_ERR_OK,
+      ],
+    ]);
+
+    $request->headers->add(['content-type' => 'multipart/form-data']);
+    $response = $this->container->get('http_kernel')->handle($request);
+    $result = json_decode($response->getContent());
+
+    // Check that the file ID was returned.
+    $returned_fid = $result->data->webformFileUpload->fid ?? NULL;
+    $this->assertIsInt($returned_fid);
+
+    // Check that the file entity was created.
+    $file = $this->fileStorage->load($returned_fid);
+    $this->assertInstanceOf(FileInterface::class, $file);
+    $this->assertEquals('test.txt', $file->getFilename());
+    $this->assertEquals('text/plain', $file->getMimeType());
+  }
+
+}
+
+namespace Drupal\graphql_webform\Plugin\GraphQL\Mutations;
+
+/**
+ * Mock the PHP function is_uploaded_file().
+ *
+ * Since we are not *really* uploading a file through the webserver, PHP will
+ * not recognize the file as an uploaded file. We mock the function to return
+ * TRUE for our test file.
+ *
+ * @param string $filename
+ *   The filename being checked.
+ *
+ * @return bool
+ *   Will return TRUE for our test file.
+ */
+function is_uploaded_file($filename) {
+  $temp_dir = \Drupal::service('file_system')->getTempDirectory();
+  $test_file = $temp_dir . '/graphql_webform_upload_test.txt';
+  return $filename === $test_file;
+}
+
+namespace Drupal\Core\File;
+
+/**
+ * Mock the PHP function move_uploaded_file().
+ *
+ * Since we are not *really* uploading a file through the webserver, PHP will
+ * refuse to move the file and will return FALSE. We mock the function to return
+ * TRUE for our test file.
+ *
+ * @param string $filename
+ *   The filename being moved.
+ * @param string $destination
+ *   The destination path.
+ *
+ * @return bool
+ *   Will return TRUE for our test file.
+ */
+function move_uploaded_file($filename, $destination) {
+  $temp_dir = \Drupal::service('file_system')->getTempDirectory();
+  $test_file = $temp_dir . '/graphql_webform_upload_test.txt';
+  return $filename === $test_file;
+}
-- 
GitLab


From 163f909aa50a14a336597963bf940fb1888bd35f Mon Sep 17 00:00:00 2001
From: Pieter Frenssen <pieter@frenssen.be>
Date: Tue, 4 Feb 2025 14:56:31 +0200
Subject: [PATCH 03/20] Work on test.

---
 graphql/webform.base.graphqls                         |  6 +++---
 tests/queries/submission_with_file_upload.gql         |  2 +-
 .../Mutation/FormSubmissionWithFileUploadTest.php     | 11 ++++++-----
 3 files changed, 10 insertions(+), 9 deletions(-)

diff --git a/graphql/webform.base.graphqls b/graphql/webform.base.graphqls
index 508cf59..0241391 100644
--- a/graphql/webform.base.graphqls
+++ b/graphql/webform.base.graphqls
@@ -1,11 +1,11 @@
-scalar WebformSubmissionUploadedFile
+scalar Upload
 scalar WebformSubmissionValue
 
 type Mutation {
   submitWebform(
     id: String!
     elements: [WebformSubmissionElement]
-    files: [WebformSubmissionUploadedFile]
+    files: [WebformSubmissionFile]
     sourceEntityId: String
     sourceEntityType: String
   ): WebformSubmissionResult!
@@ -65,5 +65,5 @@ input WebformSubmissionFile {
   The file to upload. This is usually null and the file is appended via
   multipart formdata maps.
   """
-  file: WebformSubmissionFileUpload!
+  file: Upload!
 }
diff --git a/tests/queries/submission_with_file_upload.gql b/tests/queries/submission_with_file_upload.gql
index 2c6166e..e4b326c 100644
--- a/tests/queries/submission_with_file_upload.gql
+++ b/tests/queries/submission_with_file_upload.gql
@@ -1,7 +1,7 @@
 mutation submit(
   $id: String!,
   $elements: [WebformSubmissionElement]!,
-  $files: [WebformSubmissionUploadedFile]!,
+  $files: [WebformSubmissionFile]!,
 ) {
   submitWebform(
     id: $id,
diff --git a/tests/src/Kernel/Mutation/FormSubmissionWithFileUploadTest.php b/tests/src/Kernel/Mutation/FormSubmissionWithFileUploadTest.php
index a99d84e..30c2d2e 100644
--- a/tests/src/Kernel/Mutation/FormSubmissionWithFileUploadTest.php
+++ b/tests/src/Kernel/Mutation/FormSubmissionWithFileUploadTest.php
@@ -59,22 +59,23 @@ final class FormSubmissionWithFileUploadTest extends FormSubmissionTestBase {
     touch($file);
 
     // Create a post request with file contents.
-    $request = Request::create('/graphql', 'POST', [
+    $request = Request::create('/graphql/test', 'POST', [
       'query' => $this->getQueryFromFile('submission_with_file_upload.gql'),
       'variables' => [
         // When using multipart form uploads, the variable holding the files has
         // to be declared NULL.
-        'files' => NULL,
+        'files' => [],
+        'elements' => [],
         'webform_element_id' => 'file_upload',
         'id' => 'graphql_webform_test_form',
       ],
       // Then map the name used for the file upload to the variable.
       // @todo How to handle multiple files?
       'map' => [
-        'test' => ['variables.files'],
+        'file' => ['variables.files.0'],
       ],
-    ], [], [
-      'test' => [
+    ], [], files: [
+      'file' => [
         'name' => 'test.txt',
         'type' => 'text/plain',
         'size' => 42,
-- 
GitLab


From 05da548a49c7d9c207cff9365935f633d63e8e1b Mon Sep 17 00:00:00 2001
From: Pieter Frenssen <pieter@frenssen.be>
Date: Wed, 5 Feb 2025 14:05:44 +0200
Subject: [PATCH 04/20] Test that an error is returned when trying to associate
 an uploaded file with the wrong element.

---
 graphql/webform.base.graphqls                 |  2 +-
 .../GraphQL/DataProducer/WebformSubmit.php    | 11 ++-
 .../SchemaExtension/WebformExtension.php      |  1 +
 tests/queries/submission_with_file_upload.gql |  5 ++
 .../FormSubmissionWithFileUploadTest.php      | 78 +++++++++++++------
 5 files changed, 70 insertions(+), 27 deletions(-)

diff --git a/graphql/webform.base.graphqls b/graphql/webform.base.graphqls
index 0241391..f53d89c 100644
--- a/graphql/webform.base.graphqls
+++ b/graphql/webform.base.graphqls
@@ -65,5 +65,5 @@ input WebformSubmissionFile {
   The file to upload. This is usually null and the file is appended via
   multipart formdata maps.
   """
-  file: Upload!
+  file: Upload
 }
diff --git a/src/Plugin/GraphQL/DataProducer/WebformSubmit.php b/src/Plugin/GraphQL/DataProducer/WebformSubmit.php
index 9ca6ac9..52a9ad8 100644
--- a/src/Plugin/GraphQL/DataProducer/WebformSubmit.php
+++ b/src/Plugin/GraphQL/DataProducer/WebformSubmit.php
@@ -185,6 +185,9 @@ class WebformSubmit extends DataProducerPluginBase implements ContainerFactoryPl
 
       // Check if any errors or validations happened during file upload
       // handling.
+      // @todo Maybe we should not exit early here but also perform the
+      //   remaining validation so the GraphQL client can see all errors at
+      //   once.
       if (!$result->isValid()) {
         return $result;
       }
@@ -274,7 +277,7 @@ class WebformSubmit extends DataProducerPluginBase implements ContainerFactoryPl
 
     // Contains the mapped submission values. The original $files array
     // contains the actual UploadedFile object. The mapped item contains the
-    // FID, if upload was successfull.
+    // FID, if upload was successful.
     $filesMapped = [];
 
     $elementsMultiple = [];
@@ -285,10 +288,10 @@ class WebformSubmit extends DataProducerPluginBase implements ContainerFactoryPl
     foreach ($files as $item) {
       $elementKey = $item['element'];
 
-      // Check if the element is actually a managed file.
+      // Check if the element is actually a managed file element.
       if (!in_array($elementKey, $managedFileElements)) {
-        $validation = new WebformSubmissionValidationError(['Given element ID is not a managed file element.'], $item['element']);
-        $result->addValidationError($validation);
+        $error = sprintf('Files cannot be uploaded to the %s element since it is not a managed file element.', $item['element']);
+        $result->addError($error);
         continue;
       }
 
diff --git a/src/Plugin/GraphQL/SchemaExtension/WebformExtension.php b/src/Plugin/GraphQL/SchemaExtension/WebformExtension.php
index 1f7de8f..bb73323 100644
--- a/src/Plugin/GraphQL/SchemaExtension/WebformExtension.php
+++ b/src/Plugin/GraphQL/SchemaExtension/WebformExtension.php
@@ -375,6 +375,7 @@ class WebformExtension extends SdlSchemaExtensionPluginBase {
       $builder->produce('webform_submit')
         ->map('id', $builder->fromArgument('id'))
         ->map('elements', $builder->fromArgument('elements'))
+        ->map('files', $builder->fromArgument('files'))
         ->map('sourceEntityId', $builder->fromArgument('sourceEntityId'))
         ->map('sourceEntityType', $builder->fromArgument('sourceEntityType'))
     ));
diff --git a/tests/queries/submission_with_file_upload.gql b/tests/queries/submission_with_file_upload.gql
index e4b326c..3f37a8d 100644
--- a/tests/queries/submission_with_file_upload.gql
+++ b/tests/queries/submission_with_file_upload.gql
@@ -8,6 +8,11 @@ mutation submit(
     elements: $elements,
     files: $files,
   ) {
+    errors
+    validationErrors {
+      element
+      messages
+    }
     submission {
       id
       webform {
diff --git a/tests/src/Kernel/Mutation/FormSubmissionWithFileUploadTest.php b/tests/src/Kernel/Mutation/FormSubmissionWithFileUploadTest.php
index 30c2d2e..0074273 100644
--- a/tests/src/Kernel/Mutation/FormSubmissionWithFileUploadTest.php
+++ b/tests/src/Kernel/Mutation/FormSubmissionWithFileUploadTest.php
@@ -49,6 +49,31 @@ final class FormSubmissionWithFileUploadTest extends FormSubmissionTestBase {
     $this->fileSystem = $this->container->get('file_system');
   }
 
+  /**
+   * Uploading a file using a wrong element name should return an error.
+   */
+  public function testUploadingFileToWrongElement() {
+    $query = $this->getQueryFromFile('submission_with_file_upload.gql');
+    $variables = [
+      'elements' => [],
+      'files' => [
+        // Try to upload to a non-file upload element.
+        (object) ['element' => 'checkboxes', 'file' => NULL],
+      ],
+      'id' => 'graphql_webform_test_form',
+    ];
+
+    $this->assertResults($query, $variables, [
+      'submitWebform' => [
+        'errors' => [
+          'Files cannot be uploaded to the checkboxes element since it is not a managed file element.',
+        ],
+        'validationErrors' => [],
+        'submission' => NULL,
+      ],
+    ]);
+  }
+
   /**
    * Tests uploading a file through the webformFileUpload mutation.
    */
@@ -59,35 +84,44 @@ final class FormSubmissionWithFileUploadTest extends FormSubmissionTestBase {
     touch($file);
 
     // Create a post request with file contents.
-    $request = Request::create('/graphql/test', 'POST', [
-      'query' => $this->getQueryFromFile('submission_with_file_upload.gql'),
-      'variables' => [
-        // When using multipart form uploads, the variable holding the files has
-        // to be declared NULL.
-        'files' => [],
-        'elements' => [],
-        'webform_element_id' => 'file_upload',
-        'id' => 'graphql_webform_test_form',
-      ],
-      // Then map the name used for the file upload to the variable.
-      // @todo How to handle multiple files?
-      'map' => [
-        'file' => ['variables.files.0'],
+    $request = Request::create(
+      uri: '/graphql/test',
+      method: 'POST',
+      parameters: [
+        'query' => $this->getQueryFromFile('submission_with_file_upload.gql'),
+        'variables' => [
+          'elements' => [],
+          'files' => [
+            // When using multipart form uploads, the parameter holding the file
+            // has to be declared NULL.
+            (object) ['element' => 'file_upload', 'file' => NULL],
+          ],
+          'id' => 'graphql_webform_test_form',
+        ],
+        // The 'map' parameter is used to map uploaded files to variables.
+        // @see https://github.com/jaydenseric/graphql-multipart-request-spec
+        'map' => [
+          '0' => ['variables.files.0.file'],
+        ],
       ],
-    ], [], files: [
-      'file' => [
-        'name' => 'test.txt',
-        'type' => 'text/plain',
-        'size' => 42,
-        'tmp_name' => $file,
-        'error' => UPLOAD_ERR_OK,
+      files: [
+        '0' => [
+          'name' => 'graphql_webform_upload_test.txt',
+          'type' => 'text/plain',
+          'size' => 42,
+          'tmp_name' => $file,
+          'error' => UPLOAD_ERR_OK,
+        ],
       ],
-    ]);
+    );
 
     $request->headers->add(['content-type' => 'multipart/form-data']);
     $response = $this->container->get('http_kernel')->handle($request);
     $result = json_decode($response->getContent());
 
+    // The result should not have any errors.
+    $this->assertObjectNotHasProperty('errors', $result);
+
     // Check that the file ID was returned.
     $returned_fid = $result->data->webformFileUpload->fid ?? NULL;
     $this->assertIsInt($returned_fid);
-- 
GitLab


From dca1bcc0fc9c485890d975daf1cf7300a9357101 Mon Sep 17 00:00:00 2001
From: Pieter Frenssen <pieter@frenssen.be>
Date: Wed, 5 Feb 2025 16:06:20 +0200
Subject: [PATCH 05/20] Update documentation.

---
 .../GraphQL/DataProducer/WebformSubmit.php     | 18 ++++++++----------
 .../FormSubmissionWithFileUploadTest.php       |  3 +--
 2 files changed, 9 insertions(+), 12 deletions(-)

diff --git a/src/Plugin/GraphQL/DataProducer/WebformSubmit.php b/src/Plugin/GraphQL/DataProducer/WebformSubmit.php
index 52a9ad8..0e86720 100644
--- a/src/Plugin/GraphQL/DataProducer/WebformSubmit.php
+++ b/src/Plugin/GraphQL/DataProducer/WebformSubmit.php
@@ -281,6 +281,7 @@ class WebformSubmit extends DataProducerPluginBase implements ContainerFactoryPl
     $filesMapped = [];
 
     $elementsMultiple = [];
+    // @todo This array is unused.
     $elementsSingle = [];
 
     $input = [];
@@ -290,27 +291,23 @@ class WebformSubmit extends DataProducerPluginBase implements ContainerFactoryPl
 
       // Check if the element is actually a managed file element.
       if (!in_array($elementKey, $managedFileElements)) {
-        $error = sprintf('Files cannot be uploaded to the %s element since it is not a managed file element.', $item['element']);
+        $error = sprintf('Files cannot be uploaded to the "%s" element since it is not a managed file element.', $item['element']);
         $result->addError($error);
         continue;
       }
 
-      // Get the configuration for the element.
+      // Get the initialized render element.
       $configuration = $webform->getElement($item['element']);
-
       /** @var \Drupal\webform\Plugin\WebformElement\WebformManagedFileBase $element */
       $element = $this->elementManager->getElementInstance($configuration);
-
       $element->prepare($configuration);
 
       // Get file validation and upload settings.
-// @todo This? Or the one below?
-      $fileExtensions = $element->getElementProperty($configuration, 'file_extensions');
-      $maxFilesize = $element->getElementProperty($configuration, 'max_filesize');
       $uriScheme = $element->getElementProperty($configuration, 'uri_scheme');
       $multiple = $element->getElementProperty($configuration, 'multiple');
 
       if ($multiple === FALSE || $multiple === NULL || $multiple === 1) {
+        // @todo This variable is unused.
         $elementsSingle[] = $elementKey;
 
         // Check if we already have one file for this element.
@@ -324,9 +321,10 @@ class WebformSubmit extends DataProducerPluginBase implements ContainerFactoryPl
         $elementsMultiple[] = $elementKey;
       }
 
-// @todo This? Or the one above?
-      $maxFilesize = $configuration['#upload_validators']['file_validate_size'][0];
-      $fileExtensions = $configuration['#upload_validators']['file_validate_extensions'][0];
+      // The methods to retrieve the file size and allowed extensions are
+      // protected, so we get them from the render array instead.
+      // @see \Drupal\webform\Plugin\WebformElement\WebformManagedFileBase::getFileExtensions()
+      // @see \Drupal\webform\Plugin\WebformElement\WebformManagedFileBase::getMaxFileSize()
 
       // Perform the upload.
       $upload = $this->fileUpload->saveFileUpload($item['file'], [
diff --git a/tests/src/Kernel/Mutation/FormSubmissionWithFileUploadTest.php b/tests/src/Kernel/Mutation/FormSubmissionWithFileUploadTest.php
index 0074273..b954659 100644
--- a/tests/src/Kernel/Mutation/FormSubmissionWithFileUploadTest.php
+++ b/tests/src/Kernel/Mutation/FormSubmissionWithFileUploadTest.php
@@ -66,7 +66,7 @@ final class FormSubmissionWithFileUploadTest extends FormSubmissionTestBase {
     $this->assertResults($query, $variables, [
       'submitWebform' => [
         'errors' => [
-          'Files cannot be uploaded to the checkboxes element since it is not a managed file element.',
+          'Files cannot be uploaded to the "checkboxes" element since it is not a managed file element.',
         ],
         'validationErrors' => [],
         'submission' => NULL,
@@ -132,7 +132,6 @@ final class FormSubmissionWithFileUploadTest extends FormSubmissionTestBase {
     $this->assertEquals('test.txt', $file->getFilename());
     $this->assertEquals('text/plain', $file->getMimeType());
   }
-
 }
 
 namespace Drupal\graphql_webform\Plugin\GraphQL\Mutations;
-- 
GitLab


From b2152715bda3b70fcfda571d325e3e665414b40c Mon Sep 17 00:00:00 2001
From: Pieter Frenssen <pieter@frenssen.be>
Date: Wed, 5 Feb 2025 21:44:11 +0200
Subject: [PATCH 06/20] Work on the test for the happy path.

---
 composer.json                                 |   2 +-
 .../GraphQL/DataProducer/WebformSubmit.php    | 123 +++++++++++++++++-
 ...form.webform.graphql_webform_test_form.yml |   3 +-
 .../FormSubmissionWithFileUploadTest.php      |  41 ++++--
 4 files changed, 147 insertions(+), 22 deletions(-)

diff --git a/composer.json b/composer.json
index 678a96f..30dbb95 100644
--- a/composer.json
+++ b/composer.json
@@ -11,7 +11,7 @@
     "require": {
         "php": "^8.1",
         "drupal/graphql": "^4.6",
-        "drupal/webform": "^6.0",
+        "drupal/webform": "^6.3@beta",
         "symfony/string": "^6 || ^7"
     },
     "require-dev": {
diff --git a/src/Plugin/GraphQL/DataProducer/WebformSubmit.php b/src/Plugin/GraphQL/DataProducer/WebformSubmit.php
index 0e86720..e33258b 100644
--- a/src/Plugin/GraphQL/DataProducer/WebformSubmit.php
+++ b/src/Plugin/GraphQL/DataProducer/WebformSubmit.php
@@ -195,7 +195,7 @@ class WebformSubmit extends DataProducerPluginBase implements ContainerFactoryPl
       // Build form data for file elements.
 // @todo This method is missing. We have no multiple elements.
 // @todo This method has been replaced with the simple foreach() loop just above.
-      $fileElements = $this->buildFormData($filesMapped, $elementsWithMultiple);
+      $fileElements = $this->buildFormData($filesMapped, []);
 
       // Build the form data object for the form submission.
       $formData = [
@@ -241,6 +241,112 @@ class WebformSubmit extends DataProducerPluginBase implements ContainerFactoryPl
     return $renderResult;
   }
 
+  /**
+   * Build the form submission data from the WebformSubmissionElement array.
+   *
+   * To keep frontend implementations easy the submission values are provided
+   * as an array of elements with their input name and value as keys.
+   * Using this we can simulate a POST body request by combining everything to
+   * a query string and letting PHP do the parsing.
+   *
+   * The input array might look like this:
+   * [
+   *   [
+   *     "element" => "message",
+   *     "value" => "Hello",
+   *   ],
+   *   [
+   *     "element" => "users[0][name]",
+   *     "value" => "John",
+   *   ],
+   *   [
+   *     "element" => "users[0][email]",
+   *     "value" => "foobar@domain.com",
+   *   ],
+   *   [
+   *     "element" => "users[1][name]",
+   *     "value" => "Wayne",
+   *   ],
+   *   [
+   *     "element" => "users[1][email]",
+   *     "value" => "baz@domain.com",
+   *   ],
+   * ]
+   *
+   * This is first converted to a query string:
+   *
+   * message=Hello&users[0][name]=John&users[0][email]=foobar@domain.com&users[1][name]=Wayne&users[1][email]=baz@domain.com
+   *
+   * And then parsed and converted to an array:
+   *
+   * [
+   *   "message" => "Hello",
+   *   "users" => [
+   *     [
+   *       "name" => "John",
+   *       "email" => "foobar@domain.com",
+   *     ],
+   *     [
+   *       "name" => "Wayne",
+   *       "email" => "baz@domain.com",
+   *     ]
+   *   ],
+   * ]
+   *
+   * This array can now be used to create a webform submission.
+   *
+   * @param array $items
+   *   The WebformSubmissionElement array.
+   * @param array $elementsWithMultiple
+   *   The keys of elements that support multiple values.
+   *
+   * @return array
+   *   The webform submission data.
+   */
+  private function buildFormData(array $items, array $elementsWithMultiple): array {
+    // Collect all query values.
+    $parts = [];
+
+    $nameCount = [];
+
+    foreach ($items as $item) {
+      $name = $item['element'];
+
+      // Assume the key to be the name of the element by default.
+      $key = $name;
+
+      // If the key is for an element that expects multiple values we have
+      // to make sure to provide an array at the end. The fragile webform code
+      // just assumes it's an array without checking.
+      if (in_array($name, $elementsWithMultiple)) {
+        if (empty($nameCount[$name])) {
+          $nameCount[$name] = 0;
+        }
+
+        // Get the current count.
+        $count = $nameCount[$name];
+
+        // Build the key using the current count for this element.
+        $key = $name . "[$count]";
+
+        // Increment the count for the next item.
+        $nameCount[$name] = $count + 1;
+      }
+
+      // e.g. textfield=My+Text or checkboxes_field[0]=option_1.
+      $parts[] = $key . '=' . urlencode($item['value']);
+    }
+
+    // Create the query string.
+    $str = implode('&', $parts);
+
+    // Parse the query string to an array.
+    $result = [];
+    parse_str($str, $result);
+
+    return $result;
+  }
+
   /**
    * Populates errors on the result as WebformSubmissionValidationError objects.
    *
@@ -297,14 +403,15 @@ class WebformSubmit extends DataProducerPluginBase implements ContainerFactoryPl
       }
 
       // Get the initialized render element.
-      $configuration = $webform->getElement($item['element']);
-      /** @var \Drupal\webform\Plugin\WebformElement\WebformManagedFileBase $element */
-      $element = $this->elementManager->getElementInstance($configuration);
-      $element->prepare($configuration);
+      $element = $webform->getElement($item['element']);
+      /** @var \Drupal\webform\Plugin\WebformElement\WebformManagedFileBase $instance */
+      $instance = $this->elementManager->getElementInstance($element);
+      $instance->prepare($element);
 
       // Get file validation and upload settings.
-      $uriScheme = $element->getElementProperty($configuration, 'uri_scheme');
-      $multiple = $element->getElementProperty($configuration, 'multiple');
+      $uriScheme = $instance->getElementProperty($element, 'uri_scheme');
+      $multiple = $instance->getElementProperty($element, 'multiple');
+      $multiple = $instance->hasMultipleValues($element);
 
       if ($multiple === FALSE || $multiple === NULL || $multiple === 1) {
         // @todo This variable is unused.
@@ -325,6 +432,8 @@ class WebformSubmit extends DataProducerPluginBase implements ContainerFactoryPl
       // protected, so we get them from the render array instead.
       // @see \Drupal\webform\Plugin\WebformElement\WebformManagedFileBase::getFileExtensions()
       // @see \Drupal\webform\Plugin\WebformElement\WebformManagedFileBase::getMaxFileSize()
+      $maxFilesize = $element['#upload_validators']['FileSizeLimit']['fileLimit'];
+      $fileExtensions = $element['#upload_validators']['FileExtension']['extensions'];
 
       // Perform the upload.
       $upload = $this->fileUpload->saveFileUpload($item['file'], [
diff --git a/tests/modules/graphql_webform_test/config/install/webform.webform.graphql_webform_test_form.yml b/tests/modules/graphql_webform_test/config/install/webform.webform.graphql_webform_test_form.yml
index 6ebbd17..1407657 100644
--- a/tests/modules/graphql_webform_test/config/install/webform.webform.graphql_webform_test_form.yml
+++ b/tests/modules/graphql_webform_test/config/install/webform.webform.graphql_webform_test_form.yml
@@ -102,7 +102,8 @@ elements: |-
   audio_files:
     '#type': webform_audio_file
     '#title': 'Audio files'
-    '#multiple': true
+    '#multiple': 2
+    '#max_filesize': '1'
     '#file_extensions': ''
   select_with_custom_empty_option:
     '#type': select
diff --git a/tests/src/Kernel/Mutation/FormSubmissionWithFileUploadTest.php b/tests/src/Kernel/Mutation/FormSubmissionWithFileUploadTest.php
index b954659..c09f1e9 100644
--- a/tests/src/Kernel/Mutation/FormSubmissionWithFileUploadTest.php
+++ b/tests/src/Kernel/Mutation/FormSubmissionWithFileUploadTest.php
@@ -5,8 +5,10 @@ declare(strict_types=1);
 namespace Drupal\Tests\graphql_webform\Kernel\Mutation;
 
 use Drupal\Core\File\FileSystemInterface;
+use Drupal\file\Entity\File;
 use Drupal\file\FileInterface;
 use Drupal\file\FileStorageInterface;
+use Drupal\webform\Entity\WebformSubmission;
 use Symfony\Component\HttpFoundation\Request;
 
 /**
@@ -44,6 +46,7 @@ final class FormSubmissionWithFileUploadTest extends FormSubmissionTestBase {
     parent::setUp();
 
     $this->installEntitySchema('file');
+    $this->installSchema('file', ['file_usage']);
 
     $this->fileStorage = $this->container->get('entity_type.manager')->getStorage('file');
     $this->fileSystem = $this->container->get('file_system');
@@ -90,11 +93,11 @@ final class FormSubmissionWithFileUploadTest extends FormSubmissionTestBase {
       parameters: [
         'query' => $this->getQueryFromFile('submission_with_file_upload.gql'),
         'variables' => [
-          'elements' => [],
+          'elements' => [(object) ['element' => 'required_text_field', 'value' => 'A value.']],
           'files' => [
             // When using multipart form uploads, the parameter holding the file
             // has to be declared NULL.
-            (object) ['element' => 'file_upload', 'file' => NULL],
+            ['element' => 'file_upload', 'file' => NULL],
           ],
           'id' => 'graphql_webform_test_form',
         ],
@@ -119,22 +122,34 @@ final class FormSubmissionWithFileUploadTest extends FormSubmissionTestBase {
     $response = $this->container->get('http_kernel')->handle($request);
     $result = json_decode($response->getContent());
 
-    // The result should not have any errors.
-    $this->assertObjectNotHasProperty('errors', $result);
+    $this->assertNotEmpty($result->data->submitWebform ?? [], 'The response contains information about the submission.');
+    $metadata = $result->data->submitWebform;
 
-    // Check that the file ID was returned.
-    $returned_fid = $result->data->webformFileUpload->fid ?? NULL;
-    $this->assertIsInt($returned_fid);
+    $this->assertEmpty($metadata->errors, 'There are no errors.');
+    $this->assertEmpty($metadata->validationErrors, 'There are no validation errors.');
 
-    // Check that the file entity was created.
-    $file = $this->fileStorage->load($returned_fid);
-    $this->assertInstanceOf(FileInterface::class, $file);
-    $this->assertEquals('test.txt', $file->getFilename());
-    $this->assertEquals('text/plain', $file->getMimeType());
+    $submissionId = $metadata->submission->id;
+    $this->assertIsInt($submissionId, 'A submission ID is returned.');
+
+    // Check that the submission entity was created.
+    $submission = WebformSubmission::load($submissionId);
+    $this->assertInstanceOf(WebformSubmission::class, $submission, 'A submission entity was created.');
+
+    // Check that the file was associated with the submission.
+    $returnedFileIds = $submission->getElementData('file_upload');
+    $this->assertIsArray($returnedFileIds, 'An array of file IDs is associated with the webform submission.');
+    $this->assertCount(1, $returnedFileIds, 'A single file is associated with the webform submission.');
+
+    $fileId = reset($returnedFileIds);
+    $file = File::load($fileId);
+    $this->assertInstanceOf(FileInterface::class, $file, 'A file entity was created.');
+    $this->assertEquals('graphql_webform_upload_test.txt', $file->getFilename(), 'The file has the correct filename.');
+    $this->assertEquals('text/plain', $file->getMimeType(), 'The file has the correct MIME type.');
+    $this->assertEquals(0, $file->getSize(), 'The file has a size of 0 bytes.');
   }
 }
 
-namespace Drupal\graphql_webform\Plugin\GraphQL\Mutations;
+namespace Symfony\Component\HttpFoundation\File;
 
 /**
  * Mock the PHP function is_uploaded_file().
-- 
GitLab


From 61ef710241ec43bbb13e69aa6b8b2db9174532a0 Mon Sep 17 00:00:00 2001
From: Pieter Frenssen <pieter@frenssen.be>
Date: Wed, 5 Feb 2025 21:47:41 +0200
Subject: [PATCH 07/20] Rename the Upload scalar to avoid conflicts with other
 extensions which might want to upload stuff.

---
 graphql/webform.base.graphqls | 6 +++---
 1 file changed, 3 insertions(+), 3 deletions(-)

diff --git a/graphql/webform.base.graphqls b/graphql/webform.base.graphqls
index f53d89c..7ced00a 100644
--- a/graphql/webform.base.graphqls
+++ b/graphql/webform.base.graphqls
@@ -1,4 +1,4 @@
-scalar Upload
+scalar WebformSubmissionFileUpload
 scalar WebformSubmissionValue
 
 type Mutation {
@@ -63,7 +63,7 @@ input WebformSubmissionFile {
 
   """
   The file to upload. This is usually null and the file is appended via
-  multipart formdata maps.
+  multipart form data maps.
   """
-  file: Upload
+  file: WebformSubmissionFileUpload
 }
-- 
GitLab


From e2e6969145472131b6a74fa5321739d02c23ef88 Mon Sep 17 00:00:00 2001
From: Pieter Frenssen <pieter@frenssen.be>
Date: Thu, 6 Feb 2025 12:33:34 +0200
Subject: [PATCH 08/20] Complete the test for the happy path.

---
 .../FormSubmissionWithFileUploadTest.php      | 116 ++++++++++++++----
 1 file changed, 91 insertions(+), 25 deletions(-)

diff --git a/tests/src/Kernel/Mutation/FormSubmissionWithFileUploadTest.php b/tests/src/Kernel/Mutation/FormSubmissionWithFileUploadTest.php
index c09f1e9..d7d3300 100644
--- a/tests/src/Kernel/Mutation/FormSubmissionWithFileUploadTest.php
+++ b/tests/src/Kernel/Mutation/FormSubmissionWithFileUploadTest.php
@@ -4,6 +4,7 @@ declare(strict_types=1);
 
 namespace Drupal\Tests\graphql_webform\Kernel\Mutation;
 
+use Drupal\Core\DependencyInjection\ContainerBuilder;
 use Drupal\Core\File\FileSystemInterface;
 use Drupal\file\Entity\File;
 use Drupal\file\FileInterface;
@@ -52,6 +53,33 @@ final class FormSubmissionWithFileUploadTest extends FormSubmissionTestBase {
     $this->fileSystem = $this->container->get('file_system');
   }
 
+  /**
+   * {@inheritdoc}
+   */
+  public function register(ContainerBuilder $container) {
+    parent::register($container);
+
+    $container->register('stream_wrapper.private', 'Drupal\Core\StreamWrapper\PrivateStream')
+      ->addTag('stream_wrapper', ['scheme' => 'private']);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUpFilesystem() {
+    $public_file_directory = $this->siteDirectory . '/files';
+    $private_file_directory = $this->siteDirectory . '/private';
+
+    mkdir($this->siteDirectory, 0775);
+    mkdir($this->siteDirectory . '/files', 0775);
+    mkdir($this->siteDirectory . '/private', 0775);
+    mkdir($this->siteDirectory . '/files/config/sync', 0775, TRUE);
+
+    $this->setSetting('file_public_path', $public_file_directory);
+    $this->setSetting('file_private_path', $private_file_directory);
+    $this->setSetting('config_sync_directory', $this->siteDirectory . '/files/config/sync');
+  }
+
   /**
    * Uploading a file using a wrong element name should return an error.
    */
@@ -81,10 +109,14 @@ final class FormSubmissionWithFileUploadTest extends FormSubmissionTestBase {
    * Tests uploading a file through the webformFileUpload mutation.
    */
   public function testFileUpload() {
-    // We are pretending to upload a file into temporary storage. Ensure a file
-    // is there because the Symfony UploadedFile component will check that.
-    $file = $this->fileSystem->getTempDirectory() . '/graphql_webform_upload_test.txt';
-    touch($file);
+    // Create some test files to upload.
+    foreach (['txt', 'mp3', 'ogg'] as $extension) {
+      $file = $this->fileSystem->getTempDirectory() . '/graphql_webform_upload_test.' . $extension;
+      // We are pretending to upload a file into temporary storage. Ensure the
+      // file exists because the Symfony UploadedFile component will check that.
+      touch($file);
+      $files[] = $file;
+    }
 
     // Create a post request with file contents.
     $request = Request::create(
@@ -98,6 +130,8 @@ final class FormSubmissionWithFileUploadTest extends FormSubmissionTestBase {
             // When using multipart form uploads, the parameter holding the file
             // has to be declared NULL.
             ['element' => 'file_upload', 'file' => NULL],
+            ['element' => 'audio_files', 'file' => NULL],
+            ['element' => 'audio_files', 'file' => NULL],
           ],
           'id' => 'graphql_webform_test_form',
         ],
@@ -105,14 +139,30 @@ final class FormSubmissionWithFileUploadTest extends FormSubmissionTestBase {
         // @see https://github.com/jaydenseric/graphql-multipart-request-spec
         'map' => [
           '0' => ['variables.files.0.file'],
+          '1' => ['variables.files.1.file'],
+          '2' => ['variables.files.2.file'],
         ],
       ],
       files: [
         '0' => [
-          'name' => 'graphql_webform_upload_test.txt',
+          'name' => 'test.txt',
           'type' => 'text/plain',
-          'size' => 42,
-          'tmp_name' => $file,
+          'size' => 0,
+          'tmp_name' => $files[0],
+          'error' => UPLOAD_ERR_OK,
+        ],
+        '1' => [
+          'name' => 'audio_file.mp3',
+          'type' => 'audio/mpeg',
+          'size' => 0,
+          'tmp_name' => $files[1],
+          'error' => UPLOAD_ERR_OK,
+        ],
+        '2' => [
+          'name' => 'audio_file.ogg',
+          'type' => 'audio/ogg',
+          'size' => 0,
+          'tmp_name' => $files[2],
           'error' => UPLOAD_ERR_OK,
         ],
       ],
@@ -123,29 +173,45 @@ final class FormSubmissionWithFileUploadTest extends FormSubmissionTestBase {
     $result = json_decode($response->getContent());
 
     $this->assertNotEmpty($result->data->submitWebform ?? [], 'The response contains information about the submission.');
-    $metadata = $result->data->submitWebform;
+    $resultData = $result->data->submitWebform;
 
-    $this->assertEmpty($metadata->errors, 'There are no errors.');
-    $this->assertEmpty($metadata->validationErrors, 'There are no validation errors.');
+    $this->assertEmpty($resultData->errors, 'There are no errors.');
+    $this->assertEmpty($resultData->validationErrors, 'There are no validation errors.');
 
-    $submissionId = $metadata->submission->id;
+    $submissionId = $resultData->submission->id;
     $this->assertIsInt($submissionId, 'A submission ID is returned.');
 
     // Check that the submission entity was created.
     $submission = WebformSubmission::load($submissionId);
     $this->assertInstanceOf(WebformSubmission::class, $submission, 'A submission entity was created.');
 
-    // Check that the file was associated with the submission.
-    $returnedFileIds = $submission->getElementData('file_upload');
-    $this->assertIsArray($returnedFileIds, 'An array of file IDs is associated with the webform submission.');
-    $this->assertCount(1, $returnedFileIds, 'A single file is associated with the webform submission.');
-
-    $fileId = reset($returnedFileIds);
-    $file = File::load($fileId);
-    $this->assertInstanceOf(FileInterface::class, $file, 'A file entity was created.');
-    $this->assertEquals('graphql_webform_upload_test.txt', $file->getFilename(), 'The file has the correct filename.');
-    $this->assertEquals('text/plain', $file->getMimeType(), 'The file has the correct MIME type.');
-    $this->assertEquals(0, $file->getSize(), 'The file has a size of 0 bytes.');
+    // Check that the expected files are associated with the submission.
+    $expectedUploads = [
+      'file_upload' => [
+        ['filename' => 'test.txt', 'mime' => 'text/plain'],
+      ],
+      'audio_files' => [
+        ['filename' => 'audio_file.mp3', 'mime' => 'audio/mpeg'],
+        ['filename' => 'audio_file.ogg', 'mime' => 'audio/ogg'],
+      ],
+    ];
+    foreach ($expectedUploads as $elementName => $expectedFiles) {
+      $returnedFileIds = (array) $submission->getElementData($elementName);
+      $this->assertNotEmpty($returnedFileIds, 'One or more file IDs are associated with the webform submission.');
+      $this->assertCount(count($expectedFiles), $returnedFileIds, 'The correct number of files is associated with the webform submission.');
+      $expectedFilenames = array_column($expectedFiles, 'filename');
+      $expectedMimeTypes = array_column($expectedFiles, 'mime');
+      $expectedScheme = $elementName === 'file_upload' ? 'public' : 'private';
+      foreach ($expectedFiles as $i => $expectedFile) {
+        $file = File::load($returnedFileIds[$i]);
+        $this->assertInstanceOf(FileInterface::class, $file, sprintf('A file entity was created for upload #%d of element "%s".', $i, $elementName));
+        $this->assertEquals($expectedFilenames[$i], $file->getFilename(), sprintf('The file for upload #%d of element "%s" has the correct filename "%s".', $i, $elementName, $expectedFilenames[$i]));
+        $this->assertEquals($expectedMimeTypes[$i], $file->getMimeType(), sprintf('The file for upload #%d of element "%s" has the correct MIME type "%s".', $i, $elementName, $expectedMimeTypes[$i]));
+        $this->assertEquals(0, $file->getSize(), sprintf('The file for upload #%d of element "%s" has a size of 0 bytes.', $i, $elementName));
+        $actualScheme = parse_url($file->getFileUri(), PHP_URL_SCHEME);
+        $this->assertEquals($expectedScheme, $actualScheme, sprintf('The file for upload #%d of element "%s" is saved in the %s filesystem.', $i, $elementName, $expectedScheme));
+      }
+    }
   }
 }
 
@@ -156,7 +222,7 @@ namespace Symfony\Component\HttpFoundation\File;
  *
  * Since we are not *really* uploading a file through the webserver, PHP will
  * not recognize the file as an uploaded file. We mock the function to return
- * TRUE for our test file.
+ * TRUE for our test files.
  *
  * @param string $filename
  *   The filename being checked.
@@ -166,8 +232,8 @@ namespace Symfony\Component\HttpFoundation\File;
  */
 function is_uploaded_file($filename) {
   $temp_dir = \Drupal::service('file_system')->getTempDirectory();
-  $test_file = $temp_dir . '/graphql_webform_upload_test.txt';
-  return $filename === $test_file;
+  $prefix = $temp_dir . '/graphql_webform_upload_test.';
+  return str_starts_with($filename, $prefix);
 }
 
 namespace Drupal\Core\File;
-- 
GitLab


From 38ff968844769e74ebffb0c299298365f80da5d9 Mon Sep 17 00:00:00 2001
From: Pieter Frenssen <pieter@frenssen.be>
Date: Thu, 6 Feb 2025 15:18:50 +0200
Subject: [PATCH 09/20] Allow to filter validation errors by element.

---
 src/Model/WebformSubmissionResult.php         | 11 ++-
 .../src/Unit/WebformSubmissionResultTest.php  | 71 +++++++++++++++++++
 2 files changed, 80 insertions(+), 2 deletions(-)
 create mode 100644 tests/src/Unit/WebformSubmissionResultTest.php

diff --git a/src/Model/WebformSubmissionResult.php b/src/Model/WebformSubmissionResult.php
index 28efb43..65cdc2a 100644
--- a/src/Model/WebformSubmissionResult.php
+++ b/src/Model/WebformSubmissionResult.php
@@ -87,11 +87,18 @@ class WebformSubmissionResult {
   /**
    * Returns the list of validation errors.
    *
+   * @param string|null $elementId
+   *   The ID of the Webform element the messages refer to. If omitted, all
+   *   validation errors are returned.
+   *
    * @return \Drupal\graphql_webform\Model\WebformSubmissionValidationError[]
    *   The validation errors.
    */
-  public function getValidationErrors(): array {
-    return $this->validationErrors;
+  public function getValidationErrors(?string $elementId = NULL): array {
+    if (empty($elementId)) {
+      return $this->validationErrors;
+    }
+    return array_values(array_filter($this->validationErrors, static fn (WebformSubmissionValidationError $error): bool => $error->getElementId() === $elementId));
   }
 
   /**
diff --git a/tests/src/Unit/WebformSubmissionResultTest.php b/tests/src/Unit/WebformSubmissionResultTest.php
new file mode 100644
index 0000000..3ecf334
--- /dev/null
+++ b/tests/src/Unit/WebformSubmissionResultTest.php
@@ -0,0 +1,71 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\Tests\graphql_webform\Unit\Model;
+
+use Drupal\graphql_webform\Model\WebformSubmissionResult;
+use Drupal\graphql_webform\Model\WebformSubmissionValidationError;
+use Drupal\Tests\UnitTestCase;
+use Drupal\webform\WebformSubmissionInterface;
+
+/**
+ * @coversDefaultClass \Drupal\graphql_webform\Model\WebformSubmissionResult
+ * @group graphql_webform
+ */
+class WebformSubmissionResultTest extends UnitTestCase {
+
+  /**
+   * @covers ::setSubmission
+   * @covers ::getSubmission
+   */
+  public function testSubmission() {
+    $submission = $this->createMock(WebformSubmissionInterface::class);
+    $result = new WebformSubmissionResult();
+    $result->setSubmission($submission);
+    $this->assertSame($submission, $result->getSubmission());
+  }
+
+  /**
+   * @covers ::addError
+   * @covers ::getErrors
+   */
+  public function testAddError() {
+    $result = new WebformSubmissionResult();
+    $result->addError('Test error');
+    $this->assertEquals(['Test error'], $result->getErrors());
+  }
+
+  /**
+   * @covers ::addValidationError
+   * @covers ::getValidationErrors
+   */
+  public function testAddValidationError() {
+    $result = new WebformSubmissionResult();
+    $validationError = new WebformSubmissionValidationError(['Validation error'], 'element_id');
+    $validationError2 = new WebformSubmissionValidationError(['Validation error 2'], 'element_id2');
+    $result->addValidationError($validationError);
+    $this->assertEquals([$validationError], $result->getValidationErrors());
+    $result->addValidationError($validationError2);
+    $this->assertEquals([$validationError, $validationError2], $result->getValidationErrors());
+    $this->assertEquals([$validationError], $result->getValidationErrors('element_id'));
+    $this->assertEquals([$validationError2], $result->getValidationErrors('element_id2'));
+  }
+
+  /**
+   * @covers ::isValid
+   */
+  public function testIsValid() {
+    $result = new WebformSubmissionResult();
+    $this->assertTrue($result->isValid());
+
+    $result->addError('Test error');
+    $this->assertFalse($result->isValid());
+
+    $result = new WebformSubmissionResult();
+    $validationError = new WebformSubmissionValidationError(['Validation error'], 'element_id');
+    $result->addValidationError($validationError);
+    $this->assertFalse($result->isValid());
+  }
+
+}
-- 
GitLab


From 4dd2faad01cb72a724888b805b7cb2b25b77d6f7 Mon Sep 17 00:00:00 2001
From: Pieter Frenssen <pieter@frenssen.be>
Date: Thu, 6 Feb 2025 15:47:47 +0200
Subject: [PATCH 10/20] Improve DX of adding validation errors. Do not require
 an object to be instantiated.

---
 src/Model/WebformSubmissionResult.php         | 40 +++++++++-----
 .../GraphQL/DataProducer/WebformSubmit.php    | 15 ++---
 .../src/Unit/WebformSubmissionResultTest.php  | 55 +++++++++++++++----
 3 files changed, 75 insertions(+), 35 deletions(-)

diff --git a/src/Model/WebformSubmissionResult.php b/src/Model/WebformSubmissionResult.php
index 65cdc2a..bc09f35 100644
--- a/src/Model/WebformSubmissionResult.php
+++ b/src/Model/WebformSubmissionResult.php
@@ -87,28 +87,42 @@ class WebformSubmissionResult {
   /**
    * Returns the list of validation errors.
    *
-   * @param string|null $elementId
-   *   The ID of the Webform element the messages refer to. If omitted, all
-   *   validation errors are returned.
-   *
    * @return \Drupal\graphql_webform\Model\WebformSubmissionValidationError[]
    *   The validation errors.
    */
-  public function getValidationErrors(?string $elementId = NULL): array {
-    if (empty($elementId)) {
-      return $this->validationErrors;
-    }
-    return array_values(array_filter($this->validationErrors, static fn (WebformSubmissionValidationError $error): bool => $error->getElementId() === $elementId));
+  public function getValidationErrors(): array {
+    return $this->validationErrors;
+  }
+
+  /**
+   * Returns the validation error that corresponds to the given element.
+   *
+   * @param string|NULL $elementId
+   *   The ID of the Webform element for which to return the validation error,
+   *   or NULL to return the general validation error.
+   *
+   * @return \Drupal\graphql_webform\Model\WebformSubmissionValidationError
+   *   The validation error. If no error exists for the given element ID yet, an
+   *   empty error object is returned.
+   */
+  public function getValidationError(?string $elementId): WebformSubmissionValidationError {
+    return $this->validationErrors[$elementId] ?? new WebformSubmissionValidationError([], $elementId);
   }
 
   /**
    * Adds a validation error.
    *
-   * @param \Drupal\graphql_webform\Model\WebformSubmissionValidationError $validation_error
-   *   The validation error to add.
+   * @param string $errorMessage
+   * *   The error message to add.
+   * @param string|null $elementId
+   *   The ID of the Webform element the error refers to, or NULL if the error
+   *   is not element-specific.
    */
-  public function addValidationError(WebformSubmissionValidationError $validation_error): static {
-    $this->validationErrors[] = $validation_error;
+  public function addValidationError(string $errorMessage, ?string $elementId): static {
+    $validationError = $this->getValidationError($elementId);
+    $validationError->addMessage($errorMessage);
+    $this->validationErrors[$elementId] = $validationError;
+
     return $this;
   }
 
diff --git a/src/Plugin/GraphQL/DataProducer/WebformSubmit.php b/src/Plugin/GraphQL/DataProducer/WebformSubmit.php
index e33258b..5a35e7b 100644
--- a/src/Plugin/GraphQL/DataProducer/WebformSubmit.php
+++ b/src/Plugin/GraphQL/DataProducer/WebformSubmit.php
@@ -13,7 +13,6 @@ use Drupal\graphql\GraphQL\Execution\FieldContext;
 use Drupal\graphql\GraphQL\Utility\FileUpload;
 use Drupal\graphql\Plugin\GraphQL\DataProducer\DataProducerPluginBase;
 use Drupal\graphql_webform\Model\WebformSubmissionResult;
-use Drupal\graphql_webform\Model\WebformSubmissionValidationError;
 use Drupal\webform\Element\Webform;
 use Drupal\webform\Plugin\WebformElementManagerInterface;
 use Drupal\webform\WebformEntityStorageInterface;
@@ -356,11 +355,11 @@ class WebformSubmit extends DataProducerPluginBase implements ContainerFactoryPl
    *   The result object to populate.
    */
   protected static function handleFormErrors(array $errors, WebformSubmissionResult $result): void {
-    foreach ($errors as $element => $message) {
+    foreach ($errors as $elementId => $message) {
       // The Webform module returns error messages intended to be shown in a web
       // page. Strip HTML tags to make them suitable for general use.
       $message = strip_tags((string) $message);
-      $result->addValidationError(new WebformSubmissionValidationError([$message], $element));
+      $result->addValidationError($message, $elementId);
     }
   }
 
@@ -419,8 +418,7 @@ class WebformSubmit extends DataProducerPluginBase implements ContainerFactoryPl
 
         // Check if we already have one file for this element.
         if (!empty($filesMapped[$elementKey])) {
-          $validation = new WebformSubmissionValidationError(['Only one file is allowed.'], $item['element']);
-          $result->addValidationError($validation);
+          $result->addValidationError('Only one file is allowed', $item['element']);
           continue;
         }
       }
@@ -448,19 +446,16 @@ class WebformSubmit extends DataProducerPluginBase implements ContainerFactoryPl
 
       // Convert the upload violations to validation errors.
       if (!empty($violations)) {
-        $validation = new WebformSubmissionValidationError([], $item['element']);
         foreach ($violations as $violation) {
-          $validation->addMessage($violation['message']);
+          $result->addValidationError($violation['message'], $item['element']);
         }
-        $result->addValidationError($validation);
         continue;
       }
 
       $file = $upload->getFileEntity();
       // If there is no file and no validation errors, add an unexpected error.
       if (empty($file) && $result->isValid()) {
-        $validation = new WebformSubmissionValidationError(['Unexpected error while uploading file.'], $item['element']);
-        $result->addValidationError($validation);
+        $result->addValidationError('Unexpected error while uploading file.', $item['element']);
         continue;
       }
 
diff --git a/tests/src/Unit/WebformSubmissionResultTest.php b/tests/src/Unit/WebformSubmissionResultTest.php
index 3ecf334..31b3203 100644
--- a/tests/src/Unit/WebformSubmissionResultTest.php
+++ b/tests/src/Unit/WebformSubmissionResultTest.php
@@ -4,9 +4,8 @@ declare(strict_types=1);
 
 namespace Drupal\Tests\graphql_webform\Unit\Model;
 
-use Drupal\graphql_webform\Model\WebformSubmissionResult;
-use Drupal\graphql_webform\Model\WebformSubmissionValidationError;
 use Drupal\Tests\UnitTestCase;
+use Drupal\graphql_webform\Model\WebformSubmissionResult;
 use Drupal\webform\WebformSubmissionInterface;
 
 /**
@@ -38,18 +37,47 @@ class WebformSubmissionResultTest extends UnitTestCase {
 
   /**
    * @covers ::addValidationError
+   * @covers ::getValidationError
    * @covers ::getValidationErrors
    */
   public function testAddValidationError() {
     $result = new WebformSubmissionResult();
-    $validationError = new WebformSubmissionValidationError(['Validation error'], 'element_id');
-    $validationError2 = new WebformSubmissionValidationError(['Validation error 2'], 'element_id2');
-    $result->addValidationError($validationError);
-    $this->assertEquals([$validationError], $result->getValidationErrors());
-    $result->addValidationError($validationError2);
-    $this->assertEquals([$validationError, $validationError2], $result->getValidationErrors());
-    $this->assertEquals([$validationError], $result->getValidationErrors('element_id'));
-    $this->assertEquals([$validationError2], $result->getValidationErrors('element_id2'));
+    $this->assertEmpty($result->getValidationErrors(), 'A new result does not have validation errors.');
+
+    // Add a first validation error.
+    $result->addValidationError('Validation error', 'element_id');
+    $errors = $result->getValidationErrors();
+    $this->assertCount(1, $errors);
+    $returnedError = reset($errors);
+    $this->assertEquals(['Validation error'], $returnedError->getMessages());
+    $this->assertEquals('element_id', $returnedError->getElementId());
+
+    // Add another validation error for the same element.
+    $result->addValidationError('Another validation error', 'element_id');
+    $errors = $result->getValidationErrors();
+    $this->assertCount(1, $errors);
+    $returnedError = reset($errors);
+    $this->assertEquals(['Validation error', 'Another validation error'], $returnedError->getMessages());
+    $this->assertEquals('element_id', $returnedError->getElementId());
+
+    // Add a validation error for a different element.
+    $result->addValidationError('Yet another validation error', 'another_element_id');
+    $errors = $result->getValidationErrors();
+    $this->assertCount(2, $errors);
+    $error_element_1 = $result->getValidationError('element_id');
+    $this->assertEquals(['Validation error', 'Another validation error'], $error_element_1->getMessages());
+    $this->assertEquals('element_id', $error_element_1->getElementId());
+    $error_element_2 = $result->getValidationError('another_element_id');
+    $this->assertEquals(['Yet another validation error'], $error_element_2->getMessages());
+    $this->assertEquals('another_element_id', $error_element_2->getElementId());
+
+    // Add a validation error without an element ID.
+    $result->addValidationError('Validation error without element ID', NULL);
+    $errors = $result->getValidationErrors();
+    $this->assertCount(3, $errors);
+    $error_no_element = $result->getValidationError(NULL);
+    $this->assertEquals(['Validation error without element ID'], $error_no_element->getMessages());
+    $this->assertNull($error_no_element->getElementId());
   }
 
   /**
@@ -63,8 +91,11 @@ class WebformSubmissionResultTest extends UnitTestCase {
     $this->assertFalse($result->isValid());
 
     $result = new WebformSubmissionResult();
-    $validationError = new WebformSubmissionValidationError(['Validation error'], 'element_id');
-    $result->addValidationError($validationError);
+    $result->addValidationError('Validation error', 'element_id');
+    $this->assertFalse($result->isValid());
+
+    $result = new WebformSubmissionResult();
+    $result->addValidationError('Validation error', NULL);
     $this->assertFalse($result->isValid());
   }
 
-- 
GitLab


From 4712144e84f21e614a41620743f895b88eca7615 Mon Sep 17 00:00:00 2001
From: Pieter Frenssen <pieter@frenssen.be>
Date: Thu, 6 Feb 2025 16:35:38 +0200
Subject: [PATCH 11/20] Test that validation errors are returned for disallowed
 extensions and oversized files.

---
 .../FormSubmissionWithFileUploadTest.php      | 109 ++++++++++++++++++
 1 file changed, 109 insertions(+)

diff --git a/tests/src/Kernel/Mutation/FormSubmissionWithFileUploadTest.php b/tests/src/Kernel/Mutation/FormSubmissionWithFileUploadTest.php
index d7d3300..9518bf9 100644
--- a/tests/src/Kernel/Mutation/FormSubmissionWithFileUploadTest.php
+++ b/tests/src/Kernel/Mutation/FormSubmissionWithFileUploadTest.php
@@ -59,6 +59,8 @@ final class FormSubmissionWithFileUploadTest extends FormSubmissionTestBase {
   public function register(ContainerBuilder $container) {
     parent::register($container);
 
+    // Register the private stream wrapper so we can test whether files can be
+    // uploaded to the private filesystem as part of a webform submission.
     $container->register('stream_wrapper.private', 'Drupal\Core\StreamWrapper\PrivateStream')
       ->addTag('stream_wrapper', ['scheme' => 'private']);
   }
@@ -67,6 +69,7 @@ final class FormSubmissionWithFileUploadTest extends FormSubmissionTestBase {
    * {@inheritdoc}
    */
   protected function setUpFilesystem() {
+    // Set up the private filesystem in addition to the public filesystem.
     $public_file_directory = $this->siteDirectory . '/files';
     $private_file_directory = $this->siteDirectory . '/private';
 
@@ -213,6 +216,110 @@ final class FormSubmissionWithFileUploadTest extends FormSubmissionTestBase {
       }
     }
   }
+
+  /**
+   * Test uploading a file with some validation errors.
+   *
+   * - The file_upload file is using the .webp extension which is not allowed.
+   * - The first of the audio_files files is larger than the 1MB limit.
+   * - The second of the audio_files files is using the .flac extension which is not allowed.
+   */
+  public function testFileUploadWithValidationErrors() {
+    // Create some test files to upload.
+    $files = [];
+    foreach (['webp', 'mp3', 'flac'] as $extension) {
+      $files[$extension] = $this->fileSystem->getTempDirectory() . '/graphql_webform_upload_test.' . $extension;
+      touch($files[$extension]);
+      // Create a file with a size of 1.000001MB. This is intended to trigger a
+      // validation error for the audio_files element which is limited to 1MB.
+      if ($extension === 'mp3') {
+        file_put_contents($files[$extension], str_repeat('a', 1024 * 1024 + 1));
+      }
+    }
+
+    $request = Request::create(
+      uri: '/graphql/test',
+      method: 'POST',
+      parameters: [
+        'query' => $this->getQueryFromFile('submission_with_file_upload.gql'),
+        'variables' => [
+          'elements' => [
+            (object) [
+              'element' => 'required_text_field',
+              'value' => 'A value.'
+            ]
+          ],
+          'files' => [
+            ['element' => 'file_upload', 'file' => NULL],
+            ['element' => 'audio_files', 'file' => NULL],
+            ['element' => 'audio_files', 'file' => NULL],
+          ],
+          'id' => 'graphql_webform_test_form',
+        ],
+        'map' => [
+          '0' => ['variables.files.0.file'],
+          '1' => ['variables.files.1.file'],
+          '2' => ['variables.files.2.file'],
+        ],
+      ],
+      files: [
+        '0' => [
+          'name' => 'test.webp',
+          'type' => 'image/webp',
+          'size' => 0,
+          'tmp_name' => $files['webp'],
+          'error' => UPLOAD_ERR_OK,
+        ],
+        '1' => [
+          'name' => 'audio_file.mp3',
+          'type' => 'audio/mpeg',
+          'size' => 1024 * 1024 + 1,
+          'tmp_name' => $files['mp3'],
+          'error' => UPLOAD_ERR_OK,
+        ],
+        '2' => [
+          'name' => 'audio_file.flac',
+          'type' => 'audio/flac',
+          'size' => 0,
+          'tmp_name' => $files['flac'],
+          'error' => UPLOAD_ERR_OK,
+        ],
+      ],
+    );
+
+    $request->headers->add(['content-type' => 'multipart/form-data']);
+    $response = $this->container->get('http_kernel')->handle($request);
+    $result = json_decode($response->getContent());
+
+    $this->assertNotEmpty($result->data->submitWebform ?? [], 'The response contains information about the submission.');
+    $resultData = $result->data->submitWebform;
+
+    $this->assertEmpty($resultData->errors, 'There are no errors.');
+    // $this->assertCount(3, $resultData->validationErrors, 'There are 3 validation errors.');
+    $expectedValidationErrors = [
+      'file_upload' => [
+        'Only files with the following extensions are allowed: <em class="placeholder">gif jpg png txt</em>.'
+      ],
+      'audio_files' => [
+        'The file is <em class="placeholder">1 MB</em> exceeding the maximum file size of <em class="placeholder">1 MB</em>.',
+        'Only files with the following extensions are allowed: <em class="placeholder">mp3 ogg wav</em>.',
+      ],
+    ];
+    foreach ($expectedValidationErrors as $elementName => $expectedMessages) {
+      $elementErrors = array_filter($resultData->validationErrors, static fn ($error): bool => $error->element === $elementName);
+      $this->assertCount(1, $elementErrors, sprintf('The validation errors for the "%s" element are grouped together.', $elementName));
+      $elementError = reset($elementErrors);
+      $this->assertEquals($elementName, $elementError->element, sprintf('The validation error for the "%s" element has the correct element ID.', $elementName));
+      $this->assertCount(count($expectedMessages), $elementError->messages, sprintf('There are %d validation messages for the "%s" element.', count($expectedMessages), $elementName));
+      foreach ($expectedMessages as $i => $expectedMessage) {
+        $this->assertEquals($expectedMessage, $elementError->messages[$i], sprintf('Validation message #%d for the "%s" element is correct.', $i, $elementName));
+      }
+    }
+
+    $this->assertEmpty($resultData->submission, 'No submission was returned.');
+    $this->assertEmpty(WebformSubmission::loadMultiple(), 'No submission entity was created.');
+  }
+
 }
 
 namespace Symfony\Component\HttpFoundation\File;
@@ -254,6 +361,8 @@ namespace Drupal\Core\File;
  *   Will return TRUE for our test file.
  */
 function move_uploaded_file($filename, $destination) {
+  // @todo
+  throw new \NotImplementedException();
   $temp_dir = \Drupal::service('file_system')->getTempDirectory();
   $test_file = $temp_dir . '/graphql_webform_upload_test.txt';
   return $filename === $test_file;
-- 
GitLab


From 7232703056849144956aaefa97a3d867549bf74c Mon Sep 17 00:00:00 2001
From: Pieter Frenssen <pieter@frenssen.be>
Date: Thu, 6 Feb 2025 16:49:09 +0200
Subject: [PATCH 12/20] Clean up files created during tests so they don't
 interfere with subsequent tests.

---
 .../FormSubmissionWithFileUploadTest.php      | 44 ++++++++++++-------
 1 file changed, 29 insertions(+), 15 deletions(-)

diff --git a/tests/src/Kernel/Mutation/FormSubmissionWithFileUploadTest.php b/tests/src/Kernel/Mutation/FormSubmissionWithFileUploadTest.php
index 9518bf9..efd21d3 100644
--- a/tests/src/Kernel/Mutation/FormSubmissionWithFileUploadTest.php
+++ b/tests/src/Kernel/Mutation/FormSubmissionWithFileUploadTest.php
@@ -28,18 +28,19 @@ final class FormSubmissionWithFileUploadTest extends FormSubmissionTestBase {
 
   /**
    * The file storage.
-   *
-   * @var \Drupal\file\FileStorageInterface|null
    */
   protected ?FileStorageInterface $fileStorage;
 
   /**
    * The file system.
-   *
-   * @var \Drupal\Core\File\FileSystemInterface|null
    */
   protected ?FileSystemInterface $fileSystem;
 
+  /**
+   * An array of test files.
+   */
+  protected array $files = [];
+
   /**
    * {@inheritdoc}
    */
@@ -53,6 +54,20 @@ final class FormSubmissionWithFileUploadTest extends FormSubmissionTestBase {
     $this->fileSystem = $this->container->get('file_system');
   }
 
+  /**
+   * {@inheritdoc}
+   */
+  protected function tearDown(): void {
+    // Clean up files that were created during the test.
+    foreach ($this->files as $file) {
+      if (file_exists($file)) {
+        unlink($file);
+      }
+    }
+
+    parent::tearDown();
+  }
+
   /**
    * {@inheritdoc}
    */
@@ -118,7 +133,7 @@ final class FormSubmissionWithFileUploadTest extends FormSubmissionTestBase {
       // We are pretending to upload a file into temporary storage. Ensure the
       // file exists because the Symfony UploadedFile component will check that.
       touch($file);
-      $files[] = $file;
+      $this->files[$extension] = $file;
     }
 
     // Create a post request with file contents.
@@ -151,21 +166,21 @@ final class FormSubmissionWithFileUploadTest extends FormSubmissionTestBase {
           'name' => 'test.txt',
           'type' => 'text/plain',
           'size' => 0,
-          'tmp_name' => $files[0],
+          'tmp_name' => $this->files['txt'],
           'error' => UPLOAD_ERR_OK,
         ],
         '1' => [
           'name' => 'audio_file.mp3',
           'type' => 'audio/mpeg',
           'size' => 0,
-          'tmp_name' => $files[1],
+          'tmp_name' => $this->files['mp3'],
           'error' => UPLOAD_ERR_OK,
         ],
         '2' => [
           'name' => 'audio_file.ogg',
           'type' => 'audio/ogg',
           'size' => 0,
-          'tmp_name' => $files[2],
+          'tmp_name' => $this->files['ogg'],
           'error' => UPLOAD_ERR_OK,
         ],
       ],
@@ -226,14 +241,13 @@ final class FormSubmissionWithFileUploadTest extends FormSubmissionTestBase {
    */
   public function testFileUploadWithValidationErrors() {
     // Create some test files to upload.
-    $files = [];
     foreach (['webp', 'mp3', 'flac'] as $extension) {
-      $files[$extension] = $this->fileSystem->getTempDirectory() . '/graphql_webform_upload_test.' . $extension;
-      touch($files[$extension]);
+      $this->files[$extension] = $this->fileSystem->getTempDirectory() . '/graphql_webform_upload_test.' . $extension;
+      touch($this->files[$extension]);
       // Create a file with a size of 1.000001MB. This is intended to trigger a
       // validation error for the audio_files element which is limited to 1MB.
       if ($extension === 'mp3') {
-        file_put_contents($files[$extension], str_repeat('a', 1024 * 1024 + 1));
+        file_put_contents($this->files[$extension], str_repeat('a', 1024 * 1024 + 1));
       }
     }
 
@@ -267,21 +281,21 @@ final class FormSubmissionWithFileUploadTest extends FormSubmissionTestBase {
           'name' => 'test.webp',
           'type' => 'image/webp',
           'size' => 0,
-          'tmp_name' => $files['webp'],
+          'tmp_name' => $this->files['webp'],
           'error' => UPLOAD_ERR_OK,
         ],
         '1' => [
           'name' => 'audio_file.mp3',
           'type' => 'audio/mpeg',
           'size' => 1024 * 1024 + 1,
-          'tmp_name' => $files['mp3'],
+          'tmp_name' => $this->files['mp3'],
           'error' => UPLOAD_ERR_OK,
         ],
         '2' => [
           'name' => 'audio_file.flac',
           'type' => 'audio/flac',
           'size' => 0,
-          'tmp_name' => $files['flac'],
+          'tmp_name' => $this->files['flac'],
           'error' => UPLOAD_ERR_OK,
         ],
       ],
-- 
GitLab


From f789f828aeaa2311cced382e037f66f532739310 Mon Sep 17 00:00:00 2001
From: Pieter Frenssen <pieter@frenssen.be>
Date: Thu, 6 Feb 2025 21:50:27 +0200
Subject: [PATCH 13/20] Improve test coverage.

---
 ...form.webform.graphql_webform_test_form.yml |   1 -
 .../FormSubmissionWithFileUploadTest.php      | 174 ++++++++++++++++--
 2 files changed, 154 insertions(+), 21 deletions(-)

diff --git a/tests/modules/graphql_webform_test/config/install/webform.webform.graphql_webform_test_form.yml b/tests/modules/graphql_webform_test/config/install/webform.webform.graphql_webform_test_form.yml
index 1407657..46d743d 100644
--- a/tests/modules/graphql_webform_test/config/install/webform.webform.graphql_webform_test_form.yml
+++ b/tests/modules/graphql_webform_test/config/install/webform.webform.graphql_webform_test_form.yml
@@ -55,7 +55,6 @@ elements: |-
   file_upload:
     '#type': managed_file
     '#title': 'File upload'
-    '#multiple': true
     '#description': Description
     '#help_title': 'Help title'
     '#help': 'Help text'
diff --git a/tests/src/Kernel/Mutation/FormSubmissionWithFileUploadTest.php b/tests/src/Kernel/Mutation/FormSubmissionWithFileUploadTest.php
index efd21d3..e5d30b5 100644
--- a/tests/src/Kernel/Mutation/FormSubmissionWithFileUploadTest.php
+++ b/tests/src/Kernel/Mutation/FormSubmissionWithFileUploadTest.php
@@ -71,7 +71,7 @@ final class FormSubmissionWithFileUploadTest extends FormSubmissionTestBase {
   /**
    * {@inheritdoc}
    */
-  public function register(ContainerBuilder $container) {
+  public function register(ContainerBuilder $container): void {
     parent::register($container);
 
     // Register the private stream wrapper so we can test whether files can be
@@ -83,7 +83,7 @@ final class FormSubmissionWithFileUploadTest extends FormSubmissionTestBase {
   /**
    * {@inheritdoc}
    */
-  protected function setUpFilesystem() {
+  protected function setUpFilesystem(): void {
     // Set up the private filesystem in addition to the public filesystem.
     $public_file_directory = $this->siteDirectory . '/files';
     $private_file_directory = $this->siteDirectory . '/private';
@@ -101,7 +101,7 @@ final class FormSubmissionWithFileUploadTest extends FormSubmissionTestBase {
   /**
    * Uploading a file using a wrong element name should return an error.
    */
-  public function testUploadingFileToWrongElement() {
+  public function testUploadingFileToWrongElement(): void {
     $query = $this->getQueryFromFile('submission_with_file_upload.gql');
     $variables = [
       'elements' => [],
@@ -117,7 +117,14 @@ final class FormSubmissionWithFileUploadTest extends FormSubmissionTestBase {
         'errors' => [
           'Files cannot be uploaded to the "checkboxes" element since it is not a managed file element.',
         ],
-        'validationErrors' => [],
+        'validationErrors' => [
+          [
+            'element' => 'required_text_field',
+            'messages' => [
+              'This field is required because it is important.',
+            ],
+          ],
+        ],
         'submission' => NULL,
       ],
     ]);
@@ -126,7 +133,7 @@ final class FormSubmissionWithFileUploadTest extends FormSubmissionTestBase {
   /**
    * Tests uploading a file through the webformFileUpload mutation.
    */
-  public function testFileUpload() {
+  public function testFileUpload(): void {
     // Create some test files to upload.
     foreach (['txt', 'mp3', 'ogg'] as $extension) {
       $file = $this->fileSystem->getTempDirectory() . '/graphql_webform_upload_test.' . $extension;
@@ -186,9 +193,7 @@ final class FormSubmissionWithFileUploadTest extends FormSubmissionTestBase {
       ],
     );
 
-    $request->headers->add(['content-type' => 'multipart/form-data']);
-    $response = $this->container->get('http_kernel')->handle($request);
-    $result = json_decode($response->getContent());
+    $result = $this->executeMultiPartRequest($request);
 
     $this->assertNotEmpty($result->data->submitWebform ?? [], 'The response contains information about the submission.');
     $resultData = $result->data->submitWebform;
@@ -233,13 +238,9 @@ final class FormSubmissionWithFileUploadTest extends FormSubmissionTestBase {
   }
 
   /**
-   * Test uploading a file with some validation errors.
-   *
-   * - The file_upload file is using the .webp extension which is not allowed.
-   * - The first of the audio_files files is larger than the 1MB limit.
-   * - The second of the audio_files files is using the .flac extension which is not allowed.
+   * Tests uploading files that do not meet the validation criteria.
    */
-  public function testFileUploadWithValidationErrors() {
+  public function testFileUploadWithValidationErrors(): void {
     // Create some test files to upload.
     foreach (['webp', 'mp3', 'flac'] as $extension) {
       $this->files[$extension] = $this->fileSystem->getTempDirectory() . '/graphql_webform_upload_test.' . $extension;
@@ -301,9 +302,7 @@ final class FormSubmissionWithFileUploadTest extends FormSubmissionTestBase {
       ],
     );
 
-    $request->headers->add(['content-type' => 'multipart/form-data']);
-    $response = $this->container->get('http_kernel')->handle($request);
-    $result = json_decode($response->getContent());
+    $result = $this->executeMultiPartRequest($request);
 
     $this->assertNotEmpty($result->data->submitWebform ?? [], 'The response contains information about the submission.');
     $resultData = $result->data->submitWebform;
@@ -319,6 +318,144 @@ final class FormSubmissionWithFileUploadTest extends FormSubmissionTestBase {
         'Only files with the following extensions are allowed: <em class="placeholder">mp3 ogg wav</em>.',
       ],
     ];
+    $this->assertValidationErrors($result, $expectedValidationErrors);
+
+    $this->assertEmpty($resultData->submission, 'No submission was returned.');
+    $this->assertEmpty(WebformSubmission::loadMultiple(), 'No submission entity was created.');
+  }
+
+  /**
+   * Tests uploading too many files.
+   *
+   * The 'file_upload' field only accepts a single file, and the 'audio_files'
+   * field accepts up to two files. We will attempt to upload more.
+   */
+  public function testUploadTooManyFiles(): void {
+    // Create some test files to upload.
+    foreach (['txt', 'jpg', 'mp3', 'ogg', 'wav'] as $extension) {
+      $this->files[$extension] = $this->fileSystem->getTempDirectory() . '/graphql_webform_upload_test.' . $extension;
+      touch($this->files[$extension]);
+    }
+
+    $request = Request::create(
+      uri: '/graphql/test',
+      method: 'POST',
+      parameters: [
+        'query' => $this->getQueryFromFile('submission_with_file_upload.gql'),
+        'variables' => [
+          'elements' => [
+            (object) [
+              'element' => 'required_text_field',
+              'value' => 'A value.'
+            ]
+          ],
+          'files' => [
+            ['element' => 'file_upload', 'file' => NULL],
+            ['element' => 'file_upload', 'file' => NULL],
+            ['element' => 'audio_files', 'file' => NULL],
+            ['element' => 'audio_files', 'file' => NULL],
+            ['element' => 'audio_files', 'file' => NULL],
+          ],
+          'id' => 'graphql_webform_test_form',
+        ],
+        'map' => [
+          '0' => ['variables.files.0.file'],
+          '1' => ['variables.files.1.file'],
+          '2' => ['variables.files.2.file'],
+          '3' => ['variables.files.3.file'],
+          '4' => ['variables.files.4.file'],
+        ],
+      ],
+      files: [
+        '0' => [
+          'name' => 'test.txt',
+          'type' => 'text/plain',
+          'size' => 0,
+          'tmp_name' => $this->files['txt'],
+          'error' => UPLOAD_ERR_OK,
+        ],
+        '1' => [
+          'name' => 'test.jpg',
+          'type' => 'image/jpeg',
+          'size' => 0,
+          'tmp_name' => $this->files['jpg'],
+          'error' => UPLOAD_ERR_OK,
+        ],
+        '2' => [
+          'name' => 'audio_file.mp3',
+          'type' => 'audio/mpeg',
+          'size' => 0,
+          'tmp_name' => $this->files['mp3'],
+          'error' => UPLOAD_ERR_OK,
+        ],
+        '3' => [
+          'name' => 'audio_file.ogg',
+          'type' => 'audio/ogg',
+          'size' => 0,
+          'tmp_name' => $this->files['ogg'],
+          'error' => UPLOAD_ERR_OK,
+        ],
+        '4' => [
+          'name' => 'audio_file.wav',
+          'type' => 'audio/wav',
+          'size' => 0,
+          'tmp_name' => $this->files['wav'],
+          'error' => UPLOAD_ERR_OK,
+        ],
+      ],
+    );
+
+    $result = $this->executeMultiPartRequest($request);
+
+    $this->assertNotEmpty($result->data->submitWebform ?? [], 'The response contains information about the submission.');
+    $resultData = $result->data->submitWebform;
+
+    $this->assertEmpty($resultData->errors, 'There are no errors.');
+
+    $expectedValidationErrors = [
+      'file_upload' => [
+        'Only one file can be uploaded.',
+      ],
+      'audio_files' => [
+        'The number of files uploaded exceeds the maximum of 2.',
+      ],
+    ];
+    $this->assertValidationErrors($result, $expectedValidationErrors);
+
+    $this->assertEmpty($resultData->submission, 'No submission was returned.');
+    $this->assertEmpty(WebformSubmission::loadMultiple(), 'No submission entity was created.');
+    $this->assertEmpty($this->fileStorage->loadMultiple(), 'No file entities were created.');
+  }
+
+  /**
+   * Executes a request and returns the response.
+   *
+   * @param \Symfony\Component\HttpFoundation\Request $request
+   *   The request to execute.
+   *
+   * @return object
+   *   The response object.
+   */
+  protected function executeMultiPartRequest(Request $request): object {
+    $request->headers->add(['content-type' => 'multipart/form-data']);
+    $response = $this->container->get('http_kernel')->handle($request);
+    return json_decode($response->getContent());
+  }
+
+  /**
+   * Asserts that the validation errors in the response are as expected.
+   *
+   * @param object $result
+   *   The response object.
+   * @param array $expectedValidationErrors
+   *   An array of expected validation errors. The keys are the element names,
+   *   and the values are arrays of expected validation messages.
+   */
+  protected function assertValidationErrors(object $result, array $expectedValidationErrors): void {
+    $this->assertNotEmpty($result->data->submitWebform ?? [], 'The response contains information about the submission.');
+    $resultData = $result->data->submitWebform;
+    $this->assertIsArray($resultData->validationErrors, 'The response contains validation errors.');
+
     foreach ($expectedValidationErrors as $elementName => $expectedMessages) {
       $elementErrors = array_filter($resultData->validationErrors, static fn ($error): bool => $error->element === $elementName);
       $this->assertCount(1, $elementErrors, sprintf('The validation errors for the "%s" element are grouped together.', $elementName));
@@ -329,9 +466,6 @@ final class FormSubmissionWithFileUploadTest extends FormSubmissionTestBase {
         $this->assertEquals($expectedMessage, $elementError->messages[$i], sprintf('Validation message #%d for the "%s" element is correct.', $i, $elementName));
       }
     }
-
-    $this->assertEmpty($resultData->submission, 'No submission was returned.');
-    $this->assertEmpty(WebformSubmission::loadMultiple(), 'No submission entity was created.');
   }
 
 }
-- 
GitLab


From d3b80aaaad15e171dbd3e49870a225c80bb2fe89 Mon Sep 17 00:00:00 2001
From: Pieter Frenssen <pieter@frenssen.be>
Date: Thu, 6 Feb 2025 21:52:05 +0200
Subject: [PATCH 14/20] Simplify and streamline implementation.

---
 .../GraphQL/DataProducer/WebformSubmit.php    | 291 +++++-------------
 1 file changed, 82 insertions(+), 209 deletions(-)

diff --git a/src/Plugin/GraphQL/DataProducer/WebformSubmit.php b/src/Plugin/GraphQL/DataProducer/WebformSubmit.php
index 5a35e7b..1bc3291 100644
--- a/src/Plugin/GraphQL/DataProducer/WebformSubmit.php
+++ b/src/Plugin/GraphQL/DataProducer/WebformSubmit.php
@@ -9,6 +9,7 @@ use Drupal\Core\Entity\EntityTypeManagerInterface;
 use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
 use Drupal\Core\Render\RenderContext;
 use Drupal\Core\Render\RendererInterface;
+use Drupal\file\FileInterface;
 use Drupal\graphql\GraphQL\Execution\FieldContext;
 use Drupal\graphql\GraphQL\Utility\FileUpload;
 use Drupal\graphql\Plugin\GraphQL\DataProducer\DataProducerPluginBase;
@@ -125,10 +126,6 @@ class WebformSubmit extends DataProducerPluginBase implements ContainerFactoryPl
    */
   public function resolve(
     string $id,
-// @todo Investigate this comment. There seems to be no suppression of coding
-// standards warnings.
-    // The GraphQL field context is only populated if it is at the end of the
-    // argument list. Suppress the coding standards warning for this.
     array $elements,
     array $files,
     ?string $sourceEntityType,
@@ -179,27 +176,16 @@ class WebformSubmit extends DataProducerPluginBase implements ContainerFactoryPl
         $dataElements[$element['element']] = $element['value'];
       }
 
-      // Handle file uploads. This will add validation messages if they happen.
-      $filesMapped = $this->handleFileElements($webform, $files, $result);
-
-      // Check if any errors or validations happened during file upload
-      // handling.
-      // @todo Maybe we should not exit early here but also perform the
-      //   remaining validation so the GraphQL client can see all errors at
-      //   once.
-      if (!$result->isValid()) {
-        return $result;
+      // Handle file uploads. This will add validation errors if necessary.
+      $createdFileIds = $this->handleFileElements($webform, $files, $result);
+      foreach ($createdFileIds as $elementKey => $fileIds) {
+        $dataElements[$elementKey] = $fileIds;
       }
 
-      // Build form data for file elements.
-// @todo This method is missing. We have no multiple elements.
-// @todo This method has been replaced with the simple foreach() loop just above.
-      $fileElements = $this->buildFormData($filesMapped, []);
-
       // Build the form data object for the form submission.
       $formData = [
         'webform_id' => $id,
-        'data' => array_merge($dataElements, $fileElements),
+        'data' => $dataElements,
       ];
 
       // Only include the source entity type and ID if both are provided.
@@ -240,112 +226,6 @@ class WebformSubmit extends DataProducerPluginBase implements ContainerFactoryPl
     return $renderResult;
   }
 
-  /**
-   * Build the form submission data from the WebformSubmissionElement array.
-   *
-   * To keep frontend implementations easy the submission values are provided
-   * as an array of elements with their input name and value as keys.
-   * Using this we can simulate a POST body request by combining everything to
-   * a query string and letting PHP do the parsing.
-   *
-   * The input array might look like this:
-   * [
-   *   [
-   *     "element" => "message",
-   *     "value" => "Hello",
-   *   ],
-   *   [
-   *     "element" => "users[0][name]",
-   *     "value" => "John",
-   *   ],
-   *   [
-   *     "element" => "users[0][email]",
-   *     "value" => "foobar@domain.com",
-   *   ],
-   *   [
-   *     "element" => "users[1][name]",
-   *     "value" => "Wayne",
-   *   ],
-   *   [
-   *     "element" => "users[1][email]",
-   *     "value" => "baz@domain.com",
-   *   ],
-   * ]
-   *
-   * This is first converted to a query string:
-   *
-   * message=Hello&users[0][name]=John&users[0][email]=foobar@domain.com&users[1][name]=Wayne&users[1][email]=baz@domain.com
-   *
-   * And then parsed and converted to an array:
-   *
-   * [
-   *   "message" => "Hello",
-   *   "users" => [
-   *     [
-   *       "name" => "John",
-   *       "email" => "foobar@domain.com",
-   *     ],
-   *     [
-   *       "name" => "Wayne",
-   *       "email" => "baz@domain.com",
-   *     ]
-   *   ],
-   * ]
-   *
-   * This array can now be used to create a webform submission.
-   *
-   * @param array $items
-   *   The WebformSubmissionElement array.
-   * @param array $elementsWithMultiple
-   *   The keys of elements that support multiple values.
-   *
-   * @return array
-   *   The webform submission data.
-   */
-  private function buildFormData(array $items, array $elementsWithMultiple): array {
-    // Collect all query values.
-    $parts = [];
-
-    $nameCount = [];
-
-    foreach ($items as $item) {
-      $name = $item['element'];
-
-      // Assume the key to be the name of the element by default.
-      $key = $name;
-
-      // If the key is for an element that expects multiple values we have
-      // to make sure to provide an array at the end. The fragile webform code
-      // just assumes it's an array without checking.
-      if (in_array($name, $elementsWithMultiple)) {
-        if (empty($nameCount[$name])) {
-          $nameCount[$name] = 0;
-        }
-
-        // Get the current count.
-        $count = $nameCount[$name];
-
-        // Build the key using the current count for this element.
-        $key = $name . "[$count]";
-
-        // Increment the count for the next item.
-        $nameCount[$name] = $count + 1;
-      }
-
-      // e.g. textfield=My+Text or checkboxes_field[0]=option_1.
-      $parts[] = $key . '=' . urlencode($item['value']);
-    }
-
-    // Create the query string.
-    $str = implode('&', $parts);
-
-    // Parse the query string to an array.
-    $result = [];
-    parse_str($str, $result);
-
-    return $result;
-  }
-
   /**
    * Populates errors on the result as WebformSubmissionValidationError objects.
    *
@@ -364,125 +244,118 @@ class WebformSubmit extends DataProducerPluginBase implements ContainerFactoryPl
   }
 
   /**
-   * Handle possible file uploads.
+   * Handles possible file uploads.
    *
    * @param \Drupal\webform\WebformInterface $webform
    *   The Webform entity.
-   * @param array $files
-   *   The array of WebformSubmissionFile items.
+   * @param array $uploadedFiles
+   *   The array of files that were uploaded in the form submission.
    * @param \Drupal\graphql_webform\Model\WebformSubmissionResult $result
    *   The result object to populate with validation errors.
    *
    * @return array
-   *   The mapped array of WebformSubmissionElement items.
+   *   An array of successfully uploaded file IDs, grouped by element key.
    */
-  protected function handleFileElements(WebformInterface $webform, array $files, WebformSubmissionResult $result): array {
-    // Array of all the managed file element keys.
-    $managedFileElements = array_keys($webform->getElementsManagedFiles());
-
-    // Contains the mapped submission values. The original $files array
-    // contains the actual UploadedFile object. The mapped item contains the
-    // FID, if upload was successful.
-    $filesMapped = [];
-
-    $elementsMultiple = [];
-    // @todo This array is unused.
-    $elementsSingle = [];
+  protected function handleFileElements(WebformInterface $webform, array $uploadedFiles, WebformSubmissionResult $result): array {
+    // Group the files by element so we can process each element separately.
+    $filesByElement = [];
+    foreach ($uploadedFiles as $file) {
+      $filesByElement[$file['element']][] = $file['file'];
+    }
 
-    $input = [];
+    // Retrieve all the managed file elements so we can check if the uploaded
+    // files are correctly associated with a managed file element.
+    $managedFileElements = array_keys($webform->getElementsManagedFiles());
 
-    foreach ($files as $item) {
-      $elementKey = $item['element'];
+    // Track the File entities that were created by valid uploads.
+    $createdFiles = [];
 
-      // Check if the element is actually a managed file element.
+    foreach ($filesByElement as $elementKey => $files) {
+      // Check if the element is actually a managed file element. If not, report
+      // this as a generic error rather than a validation error. This is
+      // probably not the end user's fault but rather a logic error in the code
+      // of the GraphQL client.
       if (!in_array($elementKey, $managedFileElements)) {
-        $error = sprintf('Files cannot be uploaded to the "%s" element since it is not a managed file element.', $item['element']);
+        $error = sprintf('Files cannot be uploaded to the "%s" element since it is not a managed file element.', $elementKey);
         $result->addError($error);
         continue;
       }
 
-      // Get the initialized render element.
-      $element = $webform->getElement($item['element']);
+      // Get the initialized render element and the plugin instance that
+      // represents an OOP interface to the element.
+      $element = $webform->getElement($elementKey);
       /** @var \Drupal\webform\Plugin\WebformElement\WebformManagedFileBase $instance */
       $instance = $this->elementManager->getElementInstance($element);
       $instance->prepare($element);
 
-      // Get file validation and upload settings.
-      $uriScheme = $instance->getElementProperty($element, 'uri_scheme');
-      $multiple = $instance->getElementProperty($element, 'multiple');
-      $multiple = $instance->hasMultipleValues($element);
-
-      if ($multiple === FALSE || $multiple === NULL || $multiple === 1) {
-        // @todo This variable is unused.
-        $elementsSingle[] = $elementKey;
-
-        // Check if we already have one file for this element.
-        if (!empty($filesMapped[$elementKey])) {
-          $result->addValidationError('Only one file is allowed', $item['element']);
+      // Get the number of allowed files for this element.
+      $allowedNumberOfFiles = $instance->hasMultipleValues($element);
+
+      // Don't check the number of uploaded files if an unlimited number of
+      // files is allowed.
+      if ($allowedNumberOfFiles !== TRUE) {
+        $allowedNumberOfFiles = is_numeric($allowedNumberOfFiles) ? (int) $allowedNumberOfFiles : 1;
+        if (count($files) > $allowedNumberOfFiles) {
+          $message = match ($allowedNumberOfFiles) {
+            1 => 'Only one file can be uploaded.',
+            default => sprintf('The number of files uploaded exceeds the maximum of %d.', $allowedNumberOfFiles),
+          };
+          $result->addValidationError($message, $elementKey);
           continue;
         }
       }
-      else {
-        $elementsMultiple[] = $elementKey;
-      }
 
-      // The methods to retrieve the file size and allowed extensions are
-      // protected, so we get them from the render array instead.
+      // Retrieve the validation criteria for the file upload element. Note that
+      // the methods to retrieve the file size and allowed extensions are
+      // protected, so we need to get them from the render array instead.
       // @see \Drupal\webform\Plugin\WebformElement\WebformManagedFileBase::getFileExtensions()
       // @see \Drupal\webform\Plugin\WebformElement\WebformManagedFileBase::getMaxFileSize()
+      $uriScheme = $instance->getElementProperty($element, 'uri_scheme');
       $maxFilesize = $element['#upload_validators']['FileSizeLimit']['fileLimit'];
       $fileExtensions = $element['#upload_validators']['FileExtension']['extensions'];
 
-      // Perform the upload.
-      $upload = $this->fileUpload->saveFileUpload($item['file'], [
-        'file_extensions' => $fileExtensions,
-        'max_filesize' => $maxFilesize,
-        'uri_scheme' => $uriScheme,
-        'file_directory' => 'webform',
-      ]);
-
-      // Get the upload violations.
-      $violations = $upload->getViolations();
-
-      // Convert the upload violations to validation errors.
-      if (!empty($violations)) {
-        foreach ($violations as $violation) {
-          $result->addValidationError($violation['message'], $item['element']);
+      // Attempt to save the uploaded files, leveraging the validation checks of
+      // the built-in file upload service from the GraphQL module.
+      foreach ($files as $file) {
+        $uploadResponse = $this->fileUpload->saveFileUpload($file, [
+          'file_extensions' => $fileExtensions,
+          'max_filesize' => $maxFilesize,
+          'uri_scheme' => $uriScheme,
+          'file_directory' => 'webform',
+        ]);
+
+        // If there are any violations, add them as validation errors.
+        $violations = $uploadResponse->getViolations();
+        if (!empty($violations)) {
+          foreach ($violations as $violation) {
+            $result->addValidationError($violation['message'], $elementKey);
+          }
+          continue;
         }
-        continue;
-      }
 
-      $file = $upload->getFileEntity();
-      // If there is no file and no validation errors, add an unexpected error.
-      if (empty($file) && $result->isValid()) {
-        $result->addValidationError('Unexpected error while uploading file.', $item['element']);
-        continue;
-      }
-
-      // Upload and saving was successful. Add the mapped form item, with the
-      // FID as the value.
-      $filesMapped[$elementKey][] = $file->id();
-    }
+        $file = $uploadResponse->getFileEntity();
+        // At this point we should have a file. If we don't, inform the client
+        // that the file was not uploaded.
+        // @todo If this ever happens in practice, we should probably log this.
+        if (!$file instanceof FileInterface) {
+          $result->addValidationError('Unexpected error occurred while uploading file. Please try again later.', $elementKey);
+          continue;
+        }
 
-    foreach ($filesMapped as $elementKey => $fileIds) {
-      // Element supports multiple uploads.
-      if (in_array($elementKey, $elementsMultiple)) {
-        foreach ($fileIds as $index => $id) {
-          $input[] = [
-            'element' => $elementKey . "[$index]",
-            'value' => $id,
-          ];
+        // The webform module expects elements that support multiple values to
+        // have their values passed as an array, and single value elements to be
+        // passed as a scalar.
+        // @see \Drupal\webform\WebformSubmissionStorage::saveData()
+        if ($allowedNumberOfFiles === 1) {
+          $createdFiles[$elementKey] = $file->id();
+        }
+        else {
+          $createdFiles[$elementKey][] = $file->id();
         }
-      }
-      else {
-        $input[] = [
-          'element' => $elementKey,
-          'value' => $fileIds[0],
-        ];
       }
     }
 
-    return $input;
+    return $createdFiles;
   }
 
   /**
-- 
GitLab


From 7701495eb7c303bc29963b6b54ec96ed9dd9eaf2 Mon Sep 17 00:00:00 2001
From: Pieter Frenssen <pieter@frenssen.be>
Date: Thu, 6 Feb 2025 22:00:26 +0200
Subject: [PATCH 15/20] Clean up.

---
 .../WebformFileUploadOutputWrapper.php        |  49 ----
 .../File/WebformFileUploadOutputFid.php       |  34 ---
 .../GraphQL/Mutations/WebformFileUpload.php   | 258 ------------------
 .../GraphQL/Types/WebformFileUploadOutput.php |  30 --
 tests/queries/create_file.gql                 |   7 -
 tests/src/Kernel/Element/CreateFileTest.php   | 145 ----------
 .../FormSubmissionWithFileUploadTest.php      |  25 --
 7 files changed, 548 deletions(-)
 delete mode 100644 src/GraphQL/WebformFileUploadOutputWrapper.php
 delete mode 100644 src/Plugin/GraphQL/Fields/File/WebformFileUploadOutputFid.php
 delete mode 100644 src/Plugin/GraphQL/Mutations/WebformFileUpload.php
 delete mode 100644 src/Plugin/GraphQL/Types/WebformFileUploadOutput.php
 delete mode 100644 tests/queries/create_file.gql
 delete mode 100644 tests/src/Kernel/Element/CreateFileTest.php

diff --git a/src/GraphQL/WebformFileUploadOutputWrapper.php b/src/GraphQL/WebformFileUploadOutputWrapper.php
deleted file mode 100644
index e69c327..0000000
--- a/src/GraphQL/WebformFileUploadOutputWrapper.php
+++ /dev/null
@@ -1,49 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-namespace Drupal\graphql_webform\GraphQL;
-
-use Drupal\Core\Entity\EntityInterface;
-use Drupal\graphql_core\GraphQL\EntityCrudOutputWrapper;
-use Symfony\Component\Validator\ConstraintViolationListInterface;
-
-/**
- * Extends EntityCrudOutputWrapper to add fid (File id) to the helper class.
- */
-class WebformFileUploadOutputWrapper extends EntityCrudOutputWrapper {
-
-  /**
-   * Constructs a new WebformFileUploadOutputWrapper object.
-   *
-   * @param \Drupal\Core\Entity\EntityInterface|null $entity
-   *   The entity object that has been created or NULL if creation failed.
-   * @param int|null $fid
-   *   The file ID, or NULL if the file was not created.
-   * @param \Symfony\Component\Validator\ConstraintViolationListInterface|null $violations
-   *   The validation errors that occurred during creation or NULL if validation
-   *   succeeded.
-   * @param array|null $errors
-   *   An array of non validation error messages. Can be used to provide
-   *   additional error messages e.g. for access restrictions.
-   */
-  public function __construct(
-    ?EntityInterface $entity = NULL,
-    protected ?int $fid = NULL,
-    ?ConstraintViolationListInterface $violations = NULL,
-    ?array $errors = NULL
-  ) {
-    parent::__construct($entity, $violations, $errors);
-  }
-
-  /**
-   * Returns the fid.
-   *
-   * @return int|null
-   *   The file id or NULL if creation failed.
-   */
-  public function getFid(): ?int {
-    return $this->fid;
-  }
-
-}
diff --git a/src/Plugin/GraphQL/Fields/File/WebformFileUploadOutputFid.php b/src/Plugin/GraphQL/Fields/File/WebformFileUploadOutputFid.php
deleted file mode 100644
index 15a4a86..0000000
--- a/src/Plugin/GraphQL/Fields/File/WebformFileUploadOutputFid.php
+++ /dev/null
@@ -1,34 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-namespace Drupal\graphql_webform\Plugin\GraphQL\Fields\File;
-
-use Drupal\graphql\GraphQL\Execution\ResolveContext;
-use Drupal\graphql\Plugin\GraphQL\Fields\FieldPluginBase;
-use Drupal\graphql_webform\GraphQL\WebformFileUploadOutputWrapper;
-use GraphQL\Type\Definition\ResolveInfo;
-
-/**
- * Retrieve date max property from Date form element.
- *
- * @GraphQLField(
- *   secure = true,
- *   parents = {"WebformFileUploadOutput"},
- *   id = "webform_file_upload_fid",
- *   name = "fid",
- *   type = "Int",
- * )
- */
-class WebformFileUploadOutputFid extends FieldPluginBase {
-
-  /**
-   * {@inheritdoc}
-   */
-  public function resolveValues($value, array $args, ResolveContext $context, ResolveInfo $info) {
-    if ($value instanceof WebformFileUploadOutputWrapper) {
-      yield $value->getFid();
-    }
-  }
-
-}
diff --git a/src/Plugin/GraphQL/Mutations/WebformFileUpload.php b/src/Plugin/GraphQL/Mutations/WebformFileUpload.php
deleted file mode 100644
index fbb8794..0000000
--- a/src/Plugin/GraphQL/Mutations/WebformFileUpload.php
+++ /dev/null
@@ -1,258 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-namespace Drupal\graphql_webform\Plugin\GraphQL\Mutations;
-
-use Drupal\Core\DependencyInjection\DependencySerializationTrait;
-use Drupal\Core\Entity\EntityTypeManagerInterface;
-use Drupal\Core\File\FileSystemInterface;
-use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
-use Drupal\Core\Session\AccountProxyInterface;
-use Drupal\Core\StringTranslation\StringTranslationTrait;
-use Drupal\graphql\GraphQL\Execution\ResolveContext;
-use Drupal\graphql\Plugin\GraphQL\Mutations\MutationPluginBase;
-use Drupal\graphql_webform\GraphQL\WebformFileUploadOutputWrapper;
-use Drupal\webform\Entity\Webform;
-use GraphQL\Type\Definition\ResolveInfo;
-use Symfony\Component\DependencyInjection\ContainerInterface;
-use Symfony\Component\HttpFoundation\File\UploadedFile;
-use Symfony\Component\Mime\MimeTypeGuesserInterface;
-
-/**
- * Uploads a file to a webform element.
- *
- * @todo Add the whole range of file upload validations from file_save_upload().
- *
- * @GraphQLMutation(
- *   id = "webform_file_upload",
- *   secure = "false",
- *   name = "webformFileUpload",
- *   type = "WebformFileUploadOutput",
- *   arguments = {
- *     "file" = "Upload!",
- *     "id" = "String!",
- *     "webform_element_id" = "String!"
- *   }
- * )
- */
-class WebformFileUpload extends MutationPluginBase implements ContainerFactoryPluginInterface {
-  use DependencySerializationTrait;
-  use StringTranslationTrait;
-
-  /**
-   * The entity type manager.
-   *
-   * @var \Drupal\Core\Entity\EntityTypeManagerInterface
-   */
-  protected $entityTypeManager;
-
-  /**
-   * The current user.
-   *
-   * @var \Drupal\Core\Session\AccountProxyInterface
-   */
-  protected $currentUser;
-
-  /**
-   * The mime type guesser service.
-   *
-   * @var \Symfony\Component\Mime\MimeTypeGuesserInterface
-   */
-  protected $mimeTypeGuesser;
-
-  /**
-   * The file system service.
-   *
-   * @var \Drupal\Core\File\FileSystemInterface
-   */
-  protected $fileSystem;
-
-  /**
-   * {@inheritdoc}
-   */
-  public function __construct(
-    array $configuration,
-    $pluginId,
-    $pluginDefinition,
-    EntityTypeManagerInterface $entityTypeManager,
-    AccountProxyInterface $currentUser,
-    MimeTypeGuesserInterface $mimeTypeGuesser,
-    FileSystemInterface $fileSystem
-  ) {
-    parent::__construct($configuration, $pluginId, $pluginDefinition);
-    $this->entityTypeManager = $entityTypeManager;
-    $this->currentUser = $currentUser;
-    $this->mimeTypeGuesser = $mimeTypeGuesser;
-    $this->fileSystem = $fileSystem;
-  }
-
-  /**
-   * {@inheritdoc}
-   */
-  public static function create(ContainerInterface $container, array $configuration, $pluginId, $pluginDefinition) {
-    return new static(
-      $configuration,
-      $pluginId,
-      $pluginDefinition,
-      $container->get('entity_type.manager'),
-      $container->get('current_user'),
-      $container->get('file.mime_type.guesser'),
-      $container->get('file_system')
-    );
-  }
-
-  /**
-   * {@inheritdoc}
-   */
-  public function resolve($value, array $args, ResolveContext $context, ResolveInfo $info) {
-    /** @var \Symfony\Component\HttpFoundation\File\UploadedFile $file */
-    $file = $args['file'];
-
-    // Do not proceed if file argument is invalid.
-    if (!($file instanceof UploadedFile)) {
-      return new WebformFileUploadOutputWrapper(NULL, NULL, NULL, [
-        'File argument is invalid. Expected \Symfony\Component\HttpFoundation\File\UploadedFile.',
-      ]);
-    }
-
-    // Check for file upload errors and return FALSE for this file if a lower
-    // level system error occurred.
-    //
-    // @see http://php.net/manual/features.file-upload.errors.php.
-    switch ($file->getError()) {
-      case UPLOAD_ERR_INI_SIZE:
-      case UPLOAD_ERR_FORM_SIZE:
-        $max_filesize = \Drupal::config('webform.settings')->get('file.default_max_filesize') ?: Environment::getUploadMaxSize();
-        return new WebformFileUploadOutputWrapper(NULL, NULL, NULL, [
-          $this->t('The file %file could not be saved because it exceeds %maxsize, the maximum allowed size for uploads.', [
-            '%file' => $file->getFilename(),
-            '%maxsize' => format_size($max_filesize),
-          ]),
-        ]);
-
-      case UPLOAD_ERR_PARTIAL:
-      case UPLOAD_ERR_NO_FILE:
-        return new WebformFileUploadOutputWrapper(NULL, NULL, NULL, [
-          $this->t('The file %file could not be saved because the upload did not complete.', [
-            '%file' => $file->getFilename(),
-          ]),
-        ]);
-
-      case UPLOAD_ERR_OK:
-        // Final check that this is a valid upload, if it isn't, use the
-        // default error handler.
-        if (is_uploaded_file($file->getRealPath())) {
-          break;
-        }
-
-      default:
-        // Unknown error.
-        return new WebformFileUploadOutputWrapper(NULL, NULL, NULL, [
-          $this->t('The file %file could not be saved. An unknown error has occurred.', [
-            '%file' => $file->getFilename(),
-          ]),
-        ]);
-    }
-
-    // Load the webform.
-    $webform = Webform::load($args['id']);
-
-    // Validate the if id argument is valid.
-    if (!$webform) {
-      return new WebformFileUploadOutputWrapper(NULL, NULL, NULL, [
-        $this->t('The webform %webform_id does not exist.', [
-          '%webform_id' => $args['id'],
-        ]),
-      ]);
-    }
-
-    // Get the webform element.
-    $file_element = $webform->getElement($args['webform_element_id']);
-    if (!$file_element) {
-      return new WebformFileUploadOutputWrapper(NULL, NULL, NULL, [
-        $this->t('The webform_element_id %webform_element_id does not exist.', [
-          '%webform_element_id' => $args['webform_element_id'],
-        ]),
-      ]);
-    }
-
-    $filename = $file->getClientOriginalName();
-    $mime = $this->mimeTypeGuesser->guessMimeType($filename);
-    $scheme = $file_element['#uri_scheme'] ?? 'private';
-
-    $upload_location = $scheme . '://webform/' . $webform->id() . '/_sid_';
-
-    // Make sure the upload location exists and is writable.
-    $this->fileSystem->prepareDirectory($upload_location, FileSystemInterface::CREATE_DIRECTORY | FileSystemInterface::MODIFY_PERMISSIONS);
-
-    $destination = $this->fileSystem->getDestinationFilename("{$upload_location}/{$filename}", FileSystemInterface::EXISTS_RENAME);
-
-    // Begin building file entity.
-    $values = [
-      'uid' => $this->currentUser->id(),
-      'status' => 0,
-      'filename' => $filename,
-      'uri' => $destination,
-      'filesize' => $file->getSize(),
-      'filemime' => $mime,
-    ];
-
-    $storage = $this->entityTypeManager->getStorage('file');
-    /** @var \Drupal\file\FileInterface $entity */
-    $entity = $storage->create($values);
-
-    // Validate the file name length.
-    if ($errors = file_validate($entity, ['file_validate_name_length' => []])) {
-      return new WebformFileUploadOutputWrapper(NULL, NULL, NULL, [
-        $this->t('The specified file %name could not be uploaded.', [
-          '%file' => $filename,
-        ]),
-      ]);
-    }
-
-    // Validate allowed extensions.
-    if ($file_element['#file_extensions']) {
-      $allowed_extensions = $file_element['#file_extensions'];
-    }
-    else {
-      $file_type = str_replace('webform_', '', $file_element['#type']);
-      $allowed_extensions = \Drupal::config('webform.settings')->get("file.default_{$file_type}_extensions");
-    }
-    $errors = file_validate_extensions($entity, $allowed_extensions);
-    if ($errors) {
-      return new WebformFileUploadOutputWrapper(NULL, NULL, NULL, $errors);
-    }
-
-    // Move uploaded files from PHP's upload_tmp_dir to Drupal's temporary
-    // directory. This overcomes open_basedir restrictions for future file
-    // operations.
-    if (!$this->fileSystem->moveUploadedFile($file->getRealPath(), $entity->getFileUri())) {
-      return new WebformFileUploadOutputWrapper(NULL, NULL, NULL, [
-        $this->t('Could not move uploaded file %name.', [
-          '%file' => $file->getFilename(),
-        ]),
-      ]);
-    }
-
-    // Set the permissions on the new file.
-    $this->fileSystem->chmod($entity->getFileUri());
-
-    // Update the filename with any changes as a result of security or renaming
-    // due to an existing file.
-    $entity->setFilename(\Drupal::service('file_system')->basename($destination));
-
-    // Validate the entity values.
-    if (($violations = $entity->validate()) && $violations->count()) {
-      return new WebformFileUploadOutputWrapper(NULL, NULL, $violations);
-    }
-
-    // If we reached this point, we can save the file.
-    if (($status = $entity->save()) && $status === SAVED_NEW) {
-      return new WebformFileUploadOutputWrapper($entity, $entity->id(), NULL, []);
-    }
-
-    return NULL;
-  }
-
-}
diff --git a/src/Plugin/GraphQL/Types/WebformFileUploadOutput.php b/src/Plugin/GraphQL/Types/WebformFileUploadOutput.php
deleted file mode 100644
index 7f2dff2..0000000
--- a/src/Plugin/GraphQL/Types/WebformFileUploadOutput.php
+++ /dev/null
@@ -1,30 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-namespace Drupal\graphql_webform\Plugin\GraphQL\Types;
-
-use Drupal\graphql\GraphQL\Execution\ResolveContext;
-use Drupal\graphql\Plugin\GraphQL\Types\TypePluginBase;
-use Drupal\graphql_webform\GraphQL\WebformFileUploadOutputWrapper;
-use GraphQL\Type\Definition\ResolveInfo;
-
-/**
- * A GraphQL type for a WebformFileUploadOutputWrapper object.
- *
- * @GraphQLType(
- *   id = "webform_file_upload_output",
- *   name = "WebformFileUploadOutput",
- *   interfaces = {"EntityCrudOutput"}
- * )
- */
-class WebformFileUploadOutput extends TypePluginBase {
-
-  /**
-   * {@inheritdoc}
-   */
-  public function applies($object, ResolveContext $context, ResolveInfo $info) {
-    return ($object instanceof WebformFileUploadOutputWrapper);
-  }
-
-}
diff --git a/tests/queries/create_file.gql b/tests/queries/create_file.gql
deleted file mode 100644
index b8bba21..0000000
--- a/tests/queries/create_file.gql
+++ /dev/null
@@ -1,7 +0,0 @@
-mutation createFile($file: Upload!, $id: String!, $webform_element_id: String!){
-  webformFileUpload(file: $file, id: $id, webform_element_id: $webform_element_id) {
-    ... on WebformFileUploadOutput {
-      fid
-    }
-  }
-}
diff --git a/tests/src/Kernel/Element/CreateFileTest.php b/tests/src/Kernel/Element/CreateFileTest.php
deleted file mode 100644
index 153b34e..0000000
--- a/tests/src/Kernel/Element/CreateFileTest.php
+++ /dev/null
@@ -1,145 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-namespace Drupal\Tests\graphql_webform\Kernel\Element;
-
-use Drupal\Core\File\FileSystemInterface;
-use Drupal\Tests\graphql_webform\Kernel\GraphQLWebformKernelTestBase;
-use Drupal\file\FileInterface;
-use Drupal\file\FileStorageInterface;
-use Symfony\Component\HttpFoundation\Request;
-
-/**
- * Tests uploading a file for a webform.
- *
- * @group graphql_webform
- */
-class CreateFileTest extends GraphQLWebformKernelTestBase {
-
-  /**
-   * {@inheritdoc}
-   */
-  protected static $modules = [
-    'file',
-  ];
-
-  /**
-   * The file storage.
-   *
-   * @var \Drupal\file\FileStorageInterface|null
-   */
-  protected ?FileStorageInterface $fileStorage;
-
-  /**
-   * The file system.
-   *
-   * @var \Drupal\Core\File\FileSystemInterface|null
-   */
-  protected ?FileSystemInterface $fileSystem;
-
-  /**
-   * {@inheritdoc}
-   */
-  protected function setUp(): void {
-    parent::setUp();
-
-    $this->installEntitySchema('file');
-
-    $this->fileStorage = $this->container->get('entity_type.manager')->getStorage('file');
-    $this->fileSystem = $this->container->get('file_system');
-  }
-
-  /**
-   * Tests uploading a file through the webformFileUpload mutation.
-   */
-  public function testFileUpload() {
-    $this->markTestSkipped('To be ported to GraphQL 4.x.');
-    // We are pretending to upload a file into temporary storage. Ensure a file
-    // is there because the Symfony UploadedFile component will check that.
-    $file = $this->fileSystem->getTempDirectory() . '/graphql_webform_upload_test.txt';
-    touch($file);
-
-    // Create a post request with file contents.
-    $request = Request::create('/graphql', 'POST', [
-      'query' => $this->getQueryFromFile('create_file.gql'),
-      // The variable has to be declared null.
-      'variables' => [
-        'file' => NULL,
-        'webform_element_id' => 'file_upload',
-        'id' => 'graphql_webform_test_form',
-      ],
-      // Then map the file upload name to the variable.
-      'map' => [
-        'test' => ['variables.file'],
-      ],
-    ], [], [
-      'test' => [
-        'name' => 'test.txt',
-        'type' => 'text/plain',
-        'size' => 42,
-        'tmp_name' => $file,
-        'error' => UPLOAD_ERR_OK,
-      ],
-    ]);
-
-    $request->headers->add(['content-type' => 'multipart/form-data']);
-    $response = $this->container->get('http_kernel')->handle($request);
-    $result = json_decode($response->getContent());
-
-    // Check that the file ID was returned.
-    $returned_fid = $result->data->webformFileUpload->fid ?? NULL;
-    $this->assertIsInt($returned_fid);
-
-    // Check that the file entity was created.
-    $file = $this->fileStorage->load($returned_fid);
-    $this->assertInstanceOf(FileInterface::class, $file);
-    $this->assertEquals('test.txt', $file->getFilename());
-    $this->assertEquals('text/plain', $file->getMimeType());
-  }
-
-}
-
-namespace Drupal\graphql_webform\Plugin\GraphQL\Mutations;
-
-/**
- * Mock the PHP function is_uploaded_file().
- *
- * Since we are not *really* uploading a file through the webserver, PHP will
- * not recognize the file as an uploaded file. We mock the function to return
- * TRUE for our test file.
- *
- * @param string $filename
- *   The filename being checked.
- *
- * @return bool
- *   Will return TRUE for our test file.
- */
-function is_uploaded_file($filename) {
-  $temp_dir = \Drupal::service('file_system')->getTempDirectory();
-  $test_file = $temp_dir . '/graphql_webform_upload_test.txt';
-  return $filename === $test_file;
-}
-
-namespace Drupal\Core\File;
-
-/**
- * Mock the PHP function move_uploaded_file().
- *
- * Since we are not *really* uploading a file through the webserver, PHP will
- * refuse to move the file and will return FALSE. We mock the function to return
- * TRUE for our test file.
- *
- * @param string $filename
- *   The filename being moved.
- * @param string $destination
- *   The destination path.
- *
- * @return bool
- *   Will return TRUE for our test file.
- */
-function move_uploaded_file($filename, $destination) {
-  $temp_dir = \Drupal::service('file_system')->getTempDirectory();
-  $test_file = $temp_dir . '/graphql_webform_upload_test.txt';
-  return $filename === $test_file;
-}
diff --git a/tests/src/Kernel/Mutation/FormSubmissionWithFileUploadTest.php b/tests/src/Kernel/Mutation/FormSubmissionWithFileUploadTest.php
index e5d30b5..73bb79e 100644
--- a/tests/src/Kernel/Mutation/FormSubmissionWithFileUploadTest.php
+++ b/tests/src/Kernel/Mutation/FormSubmissionWithFileUploadTest.php
@@ -490,28 +490,3 @@ function is_uploaded_file($filename) {
   $prefix = $temp_dir . '/graphql_webform_upload_test.';
   return str_starts_with($filename, $prefix);
 }
-
-namespace Drupal\Core\File;
-
-/**
- * Mock the PHP function move_uploaded_file().
- *
- * Since we are not *really* uploading a file through the webserver, PHP will
- * refuse to move the file and will return FALSE. We mock the function to return
- * TRUE for our test file.
- *
- * @param string $filename
- *   The filename being moved.
- * @param string $destination
- *   The destination path.
- *
- * @return bool
- *   Will return TRUE for our test file.
- */
-function move_uploaded_file($filename, $destination) {
-  // @todo
-  throw new \NotImplementedException();
-  $temp_dir = \Drupal::service('file_system')->getTempDirectory();
-  $test_file = $temp_dir . '/graphql_webform_upload_test.txt';
-  return $filename === $test_file;
-}
-- 
GitLab


From ff1c893c7836487f67e1ec825e438ad314c33a9a Mon Sep 17 00:00:00 2001
From: Pieter Frenssen <pieter@frenssen.be>
Date: Fri, 7 Feb 2025 07:18:23 +0200
Subject: [PATCH 16/20] Address test failures.

---
 .cspell-project-words.txt                     |  1 +
 src/Model/WebformSubmissionResult.php         |  4 +--
 .../GraphQL/DataProducer/WebformSubmit.php    |  6 ++--
 .../SchemaExtension/WebformExtension.php      | 10 ++-----
 ...form.webform.graphql_webform_test_form.yml |  3 +-
 tests/src/Kernel/Element/ManagedFileTest.php  | 23 +++++++--------
 tests/src/Kernel/Element/MarkupTest.php       |  2 +-
 .../src/Kernel/Element/MultipleValuesTest.php | 23 ++-------------
 .../FormSubmissionWithFileUploadTest.php      | 28 +++++++++----------
 9 files changed, 36 insertions(+), 64 deletions(-)

diff --git a/.cspell-project-words.txt b/.cspell-project-words.txt
index 4b16590..524c6fe 100644
--- a/.cspell-project-words.txt
+++ b/.cspell-project-words.txt
@@ -1,3 +1,4 @@
+flac
 ~flexbox
 ~flexboxes
 graphqls
diff --git a/src/Model/WebformSubmissionResult.php b/src/Model/WebformSubmissionResult.php
index bc09f35..44c13bb 100644
--- a/src/Model/WebformSubmissionResult.php
+++ b/src/Model/WebformSubmissionResult.php
@@ -97,7 +97,7 @@ class WebformSubmissionResult {
   /**
    * Returns the validation error that corresponds to the given element.
    *
-   * @param string|NULL $elementId
+   * @param string|null $elementId
    *   The ID of the Webform element for which to return the validation error,
    *   or NULL to return the general validation error.
    *
@@ -113,7 +113,7 @@ class WebformSubmissionResult {
    * Adds a validation error.
    *
    * @param string $errorMessage
-   * *   The error message to add.
+   *   The error message to add.
    * @param string|null $elementId
    *   The ID of the Webform element the error refers to, or NULL if the error
    *   is not element-specific.
diff --git a/src/Plugin/GraphQL/DataProducer/WebformSubmit.php b/src/Plugin/GraphQL/DataProducer/WebformSubmit.php
index 1bc3291..5e2731a 100644
--- a/src/Plugin/GraphQL/DataProducer/WebformSubmit.php
+++ b/src/Plugin/GraphQL/DataProducer/WebformSubmit.php
@@ -38,10 +38,12 @@ use Symfony\Component\DependencyInjection\ContainerInterface;
  *       label = @Translation("Webform ID")
  *     ),
  *     "elements" = @ContextDefinition("any",
- *       label = @Translation("Array of WebformSubmissionElement objects.")
+ *       label = @Translation("Array of WebformSubmissionElement objects."),
+ *       multiple = TRUE
  *     ),
  *     "files" = @ContextDefinition("any",
- *       label = @Translation("Array of WebformSubmissionFile objects.")
+ *       label = @Translation("Array of WebformSubmissionFile objects."),
+ *       multiple = TRUE
  *     ),
  *     "sourceEntityType" = @ContextDefinition("string",
  *       label = @Translation("Source entity type"),
diff --git a/src/Plugin/GraphQL/SchemaExtension/WebformExtension.php b/src/Plugin/GraphQL/SchemaExtension/WebformExtension.php
index bb73323..0f02c59 100644
--- a/src/Plugin/GraphQL/SchemaExtension/WebformExtension.php
+++ b/src/Plugin/GraphQL/SchemaExtension/WebformExtension.php
@@ -298,16 +298,10 @@ class WebformExtension extends SdlSchemaExtensionPluginBase {
         }
 
         // Consider the other possible sources for the max filesize: the PHP
-        // limit and the default webform setting.
+        // limit and the (optional) default webform setting.
         $sizes = array_filter([
           $size,
-          // Ignore the PHP limit if a default max filesize is set on the form.
-          // This follows the functionality of the Webform module, but in case
-          // the PHP limit is lower than the default max filesize, the reported
-          // value will be incorrect.
-          // @todo Change this behavior when the bug is fixed in the Webform
-          //   module.
-          // @see https://www.drupal.org/project/webform/issues/3482402
+          Environment::getUploadMaxSize(),
           $this->getWebformSetting('file.default_max_filesize') ?: Environment::getUploadMaxSize(),
         ]);
 
diff --git a/tests/modules/graphql_webform_test/config/install/webform.webform.graphql_webform_test_form.yml b/tests/modules/graphql_webform_test/config/install/webform.webform.graphql_webform_test_form.yml
index 46d743d..b1ea531 100644
--- a/tests/modules/graphql_webform_test/config/install/webform.webform.graphql_webform_test_form.yml
+++ b/tests/modules/graphql_webform_test/config/install/webform.webform.graphql_webform_test_form.yml
@@ -59,7 +59,7 @@ elements: |-
     '#help_title': 'Help title'
     '#help': 'Help text'
     '#file_placeholder': Placeholder
-    '#max_filesize': '2'
+    '#max_filesize': '1'
     '#file_extensions': 'gif jpg png txt'
     '#uri_scheme': public
   radios:
@@ -102,7 +102,6 @@ elements: |-
     '#type': webform_audio_file
     '#title': 'Audio files'
     '#multiple': 2
-    '#max_filesize': '1'
     '#file_extensions': ''
   select_with_custom_empty_option:
     '#type': select
diff --git a/tests/src/Kernel/Element/ManagedFileTest.php b/tests/src/Kernel/Element/ManagedFileTest.php
index 3d970b8..7eab946 100644
--- a/tests/src/Kernel/Element/ManagedFileTest.php
+++ b/tests/src/Kernel/Element/ManagedFileTest.php
@@ -39,7 +39,7 @@ class ManagedFileTest extends GraphQLWebformKernelTestBase {
             'key' => 'file_upload',
             'type' => 'managed_file',
             'title' => 'File upload',
-            'description' => "Description<br />Unlimited number of files can be uploaded to this field.<br />$human_readable_file_limit limit.<br />Allowed types: gif jpg png txt.\n",
+            'description' => "Description<br />One file only.<br />$human_readable_file_limit limit.<br />Allowed types: gif jpg png txt.\n",
           ],
           11 => [
             '__typename' => 'WebformElementWebformAudioFile',
@@ -48,7 +48,7 @@ class ManagedFileTest extends GraphQLWebformKernelTestBase {
             'key' => 'audio_files',
             'type' => 'webform_audio_file',
             'title' => 'Audio files',
-            'description' => "Unlimited number of files can be uploaded to this field.<br />$human_readable_audio_file_limit limit.<br />Allowed types: mp3 ogg wav.\n",
+            'description' => "Maximum 2 files.<br />$human_readable_audio_file_limit limit.<br />Allowed types: mp3 ogg wav.\n",
           ],
         ],
       ],
@@ -72,27 +72,24 @@ class ManagedFileTest extends GraphQLWebformKernelTestBase {
       'global limit in bytes' => [
         // Webform global file limit set to 3 MB.
         3145728,
-        // The managed file upload is limited to 2 MB on element level.
-        2097152,
+        // The managed file upload is limited to 1 MB on element level.
+        1048576,
         // The audio file upload cannot exceed the global webform limit of 3 MB.
         3145728,
       ],
       'global limit exceeding PHP limit' => [
         // Webform global file limit set to 8 MB.
         8388608,
-        // The managed file upload is limited to 2 MB on element level.
-        2097152,
-        // The audio file upload will report the global file limit (even though
-        // the PHP limit is lower and should be used).
-        // @todo Fix this once the Webform module respects the PHP limit.
-        // @see https://www.drupal.org/project/webform/issues/3482402
-        8388608,
+        // The managed file upload is limited to 1 MB on element level.
+        1048576,
+        // The audio file upload cannot exceed the PHP limit of 4 MB.
+        4194304,
       ],
       'empty global limit' => [
         // Webform global file limit not set.
         NULL,
-        // The managed file upload is limited to 2 MB on element level.
-        2097152,
+        // The managed file upload is limited to 1 MB on element level.
+        1048576,
         // The audio file upload cannot exceed the PHP limit of 4 MB.
         4194304,
       ],
diff --git a/tests/src/Kernel/Element/MarkupTest.php b/tests/src/Kernel/Element/MarkupTest.php
index 77ec7ac..ae5e72f 100644
--- a/tests/src/Kernel/Element/MarkupTest.php
+++ b/tests/src/Kernel/Element/MarkupTest.php
@@ -4,8 +4,8 @@ declare(strict_types=1);
 
 namespace Drupal\Tests\graphql_webform\Kernel\Element;
 
-use Drupal\filter\Entity\FilterFormat;
 use Drupal\Tests\graphql_webform\Kernel\GraphQLWebformKernelTestBase;
+use Drupal\filter\Entity\FilterFormat;
 
 /**
  * Tests for the WebformElementMarkup type.
diff --git a/tests/src/Kernel/Element/MultipleValuesTest.php b/tests/src/Kernel/Element/MultipleValuesTest.php
index 1e550b8..2e55da6 100644
--- a/tests/src/Kernel/Element/MultipleValuesTest.php
+++ b/tests/src/Kernel/Element/MultipleValuesTest.php
@@ -64,30 +64,11 @@ class MultipleValuesTest extends GraphQLWebformKernelTestBase {
             ],
           ],
           4 => ['multipleValues' => NULL],
-          6 => [
-            'multipleValues' => [
-              'limit' => -1,
-              'message' => NULL,
-              'headerLabel' => NULL,
-              'minItems' => NULL,
-              'emptyItems' => NULL,
-              'addMore' => NULL,
-              'addMoreItems' => NULL,
-              'addMoreButtonLabel' => NULL,
-              'addMoreInput' => NULL,
-              'addMoreInputLabel' => NULL,
-              'itemLabel' => NULL,
-              'noItemsMessage' => NULL,
-              'sorting' => NULL,
-              'operations' => NULL,
-              'add' => NULL,
-              'remove' => NULL,
-            ],
-          ],
+          6 => ['multipleValues' => NULL],
           8 => ['multipleValues' => NULL],
           11 => [
             'multipleValues' => [
-              'limit' => -1,
+              'limit' => 2,
               'message' => NULL,
               'headerLabel' => NULL,
               'minItems' => NULL,
diff --git a/tests/src/Kernel/Mutation/FormSubmissionWithFileUploadTest.php b/tests/src/Kernel/Mutation/FormSubmissionWithFileUploadTest.php
index 73bb79e..f62952d 100644
--- a/tests/src/Kernel/Mutation/FormSubmissionWithFileUploadTest.php
+++ b/tests/src/Kernel/Mutation/FormSubmissionWithFileUploadTest.php
@@ -242,12 +242,12 @@ final class FormSubmissionWithFileUploadTest extends FormSubmissionTestBase {
    */
   public function testFileUploadWithValidationErrors(): void {
     // Create some test files to upload.
-    foreach (['webp', 'mp3', 'flac'] as $extension) {
+    foreach (['txt', 'mp3', 'flac'] as $extension) {
       $this->files[$extension] = $this->fileSystem->getTempDirectory() . '/graphql_webform_upload_test.' . $extension;
       touch($this->files[$extension]);
       // Create a file with a size of 1.000001MB. This is intended to trigger a
-      // validation error for the audio_files element which is limited to 1MB.
-      if ($extension === 'mp3') {
+      // validation error for the file_upload element which is limited to 1MB.
+      if ($extension === 'txt') {
         file_put_contents($this->files[$extension], str_repeat('a', 1024 * 1024 + 1));
       }
     }
@@ -261,8 +261,8 @@ final class FormSubmissionWithFileUploadTest extends FormSubmissionTestBase {
           'elements' => [
             (object) [
               'element' => 'required_text_field',
-              'value' => 'A value.'
-            ]
+              'value' => 'A value.',
+            ],
           ],
           'files' => [
             ['element' => 'file_upload', 'file' => NULL],
@@ -279,16 +279,16 @@ final class FormSubmissionWithFileUploadTest extends FormSubmissionTestBase {
       ],
       files: [
         '0' => [
-          'name' => 'test.webp',
-          'type' => 'image/webp',
-          'size' => 0,
-          'tmp_name' => $this->files['webp'],
+          'name' => 'test.txt',
+          'type' => 'text/plain',
+          'size' => 1024 * 1024 + 1,
+          'tmp_name' => $this->files['txt'],
           'error' => UPLOAD_ERR_OK,
         ],
         '1' => [
           'name' => 'audio_file.mp3',
           'type' => 'audio/mpeg',
-          'size' => 1024 * 1024 + 1,
+          'size' => 0,
           'tmp_name' => $this->files['mp3'],
           'error' => UPLOAD_ERR_OK,
         ],
@@ -308,13 +308,11 @@ final class FormSubmissionWithFileUploadTest extends FormSubmissionTestBase {
     $resultData = $result->data->submitWebform;
 
     $this->assertEmpty($resultData->errors, 'There are no errors.');
-    // $this->assertCount(3, $resultData->validationErrors, 'There are 3 validation errors.');
     $expectedValidationErrors = [
       'file_upload' => [
-        'Only files with the following extensions are allowed: <em class="placeholder">gif jpg png txt</em>.'
+        'The file is <em class="placeholder">1 MB</em> exceeding the maximum file size of <em class="placeholder">1 MB</em>.',
       ],
       'audio_files' => [
-        'The file is <em class="placeholder">1 MB</em> exceeding the maximum file size of <em class="placeholder">1 MB</em>.',
         'Only files with the following extensions are allowed: <em class="placeholder">mp3 ogg wav</em>.',
       ],
     ];
@@ -346,8 +344,8 @@ final class FormSubmissionWithFileUploadTest extends FormSubmissionTestBase {
           'elements' => [
             (object) [
               'element' => 'required_text_field',
-              'value' => 'A value.'
-            ]
+              'value' => 'A value.',
+            ],
           ],
           'files' => [
             ['element' => 'file_upload', 'file' => NULL],
-- 
GitLab


From d41ba2aff735ac006bae1c9c02cfefd85356a787 Mon Sep 17 00:00:00 2001
From: Pieter Frenssen <pieter@frenssen.be>
Date: Fri, 7 Feb 2025 08:07:11 +0200
Subject: [PATCH 17/20] Review code and improve where possible.

---
 .../GraphQL/DataProducer/WebformSubmit.php    | 28 ++++++++++---------
 .../FormSubmissionWithFileUploadTest.php      | 12 ++++----
 2 files changed, 21 insertions(+), 19 deletions(-)

diff --git a/src/Plugin/GraphQL/DataProducer/WebformSubmit.php b/src/Plugin/GraphQL/DataProducer/WebformSubmit.php
index 5e2731a..e97b2bd 100644
--- a/src/Plugin/GraphQL/DataProducer/WebformSubmit.php
+++ b/src/Plugin/GraphQL/DataProducer/WebformSubmit.php
@@ -269,14 +269,15 @@ class WebformSubmit extends DataProducerPluginBase implements ContainerFactoryPl
     // files are correctly associated with a managed file element.
     $managedFileElements = array_keys($webform->getElementsManagedFiles());
 
-    // Track the File entities that were created by valid uploads.
-    $createdFiles = [];
+    // Keep track of File entity IDs that were created by valid uploads. This
+    // will be returned at the end of the method.
+    $fileIds = [];
 
     foreach ($filesByElement as $elementKey => $files) {
       // Check if the element is actually a managed file element. If not, report
       // this as a generic error rather than a validation error. This is
-      // probably not the end user's fault but rather a logic error in the code
-      // of the GraphQL client.
+      // probably not the end user's mistake but rather a bug in the GraphQL
+      // client code.
       if (!in_array($elementKey, $managedFileElements)) {
         $error = sprintf('Files cannot be uploaded to the "%s" element since it is not a managed file element.', $elementKey);
         $result->addError($error);
@@ -284,7 +285,7 @@ class WebformSubmit extends DataProducerPluginBase implements ContainerFactoryPl
       }
 
       // Get the initialized render element and the plugin instance that
-      // represents an OOP interface to the element.
+      // represents an OOP interface to the element configuration.
       $element = $webform->getElement($elementKey);
       /** @var \Drupal\webform\Plugin\WebformElement\WebformManagedFileBase $instance */
       $instance = $this->elementManager->getElementInstance($element);
@@ -317,7 +318,7 @@ class WebformSubmit extends DataProducerPluginBase implements ContainerFactoryPl
       $fileExtensions = $element['#upload_validators']['FileExtension']['extensions'];
 
       // Attempt to save the uploaded files, leveraging the validation checks of
-      // the built-in file upload service from the GraphQL module.
+      // the file upload service from the GraphQL module.
       foreach ($files as $file) {
         $uploadResponse = $this->fileUpload->saveFileUpload($file, [
           'file_extensions' => $fileExtensions,
@@ -326,7 +327,7 @@ class WebformSubmit extends DataProducerPluginBase implements ContainerFactoryPl
           'file_directory' => 'webform',
         ]);
 
-        // If there are any violations, add them as validation errors.
+        // If there are any violations, add them as form validation errors.
         $violations = $uploadResponse->getViolations();
         if (!empty($violations)) {
           foreach ($violations as $violation) {
@@ -344,20 +345,21 @@ class WebformSubmit extends DataProducerPluginBase implements ContainerFactoryPl
           continue;
         }
 
-        // The webform module expects elements that support multiple values to
-        // have their values passed as an array, and single value elements to be
-        // passed as a scalar.
+        // The webform module expects that values submitted for multi-value
+        // elements are passed as arrays, while values for single value elements
+        // are passed as a scalar. Avoid array to string conversion errors by
+        // casting the file ID accordingly.
         // @see \Drupal\webform\WebformSubmissionStorage::saveData()
         if ($allowedNumberOfFiles === 1) {
-          $createdFiles[$elementKey] = $file->id();
+          $fileIds[$elementKey] = $file->id();
         }
         else {
-          $createdFiles[$elementKey][] = $file->id();
+          $fileIds[$elementKey][] = $file->id();
         }
       }
     }
 
-    return $createdFiles;
+    return $fileIds;
   }
 
   /**
diff --git a/tests/src/Kernel/Mutation/FormSubmissionWithFileUploadTest.php b/tests/src/Kernel/Mutation/FormSubmissionWithFileUploadTest.php
index f62952d..0e6b533 100644
--- a/tests/src/Kernel/Mutation/FormSubmissionWithFileUploadTest.php
+++ b/tests/src/Kernel/Mutation/FormSubmissionWithFileUploadTest.php
@@ -13,7 +13,7 @@ use Drupal\webform\Entity\WebformSubmission;
 use Symfony\Component\HttpFoundation\Request;
 
 /**
- * Test file uploads with graphql.
+ * Test file uploads with GraphQL.
  *
  * @group graphql_webform
  */
@@ -107,7 +107,7 @@ final class FormSubmissionWithFileUploadTest extends FormSubmissionTestBase {
       'elements' => [],
       'files' => [
         // Try to upload to a non-file upload element.
-        (object) ['element' => 'checkboxes', 'file' => NULL],
+        ['element' => 'checkboxes', 'file' => NULL],
       ],
       'id' => 'graphql_webform_test_form',
     ];
@@ -131,7 +131,7 @@ final class FormSubmissionWithFileUploadTest extends FormSubmissionTestBase {
   }
 
   /**
-   * Tests uploading a file through the webformFileUpload mutation.
+   * Tests uploading a file as part of a webform submission.
    */
   public function testFileUpload(): void {
     // Create some test files to upload.
@@ -143,7 +143,7 @@ final class FormSubmissionWithFileUploadTest extends FormSubmissionTestBase {
       $this->files[$extension] = $file;
     }
 
-    // Create a post request with file contents.
+    // Create a POST request with the files attached as multipart form data.
     $request = Request::create(
       uri: '/graphql/test',
       method: 'POST',
@@ -160,7 +160,7 @@ final class FormSubmissionWithFileUploadTest extends FormSubmissionTestBase {
           ],
           'id' => 'graphql_webform_test_form',
         ],
-        // The 'map' parameter is used to map uploaded files to variables.
+        // The 'map' parameter is used to map attached files to variables.
         // @see https://github.com/jaydenseric/graphql-multipart-request-spec
         'map' => [
           '0' => ['variables.files.0.file'],
@@ -481,7 +481,7 @@ namespace Symfony\Component\HttpFoundation\File;
  *   The filename being checked.
  *
  * @return bool
- *   Will return TRUE for our test file.
+ *   Will return TRUE for our test files.
  */
 function is_uploaded_file($filename) {
   $temp_dir = \Drupal::service('file_system')->getTempDirectory();
-- 
GitLab


From e5e9b117e9d2186e8134a8641cfced1689c5ac36 Mon Sep 17 00:00:00 2001
From: Pieter Frenssen <pieter@frenssen.be>
Date: Fri, 7 Feb 2025 08:28:57 +0200
Subject: [PATCH 18/20] Update documentation.

---
 README.md | 67 ++++++++++++++++++++++++++++++++++---------------------
 1 file changed, 42 insertions(+), 25 deletions(-)

diff --git a/README.md b/README.md
index 950ea5b..d1bc70e 100644
--- a/README.md
+++ b/README.md
@@ -1,8 +1,8 @@
 # GraphQL Webform Drupal module
 A module to integrate webform with the graphql module.
 
-IMPORTANT: This is a module under active development and it does not support all 
-webform features and elements yet. Feel free to raise Feature Requests and 
+IMPORTANT: This is a module under active development and it does not support all
+webform features and elements yet. Feel free to raise Feature Requests and
 to contribute :)
 
 ## Pre-Requisites
@@ -205,34 +205,51 @@ submission failed because the form is closed.
 
 
 ### Create a webform submission when webform contains File elements
-If the webform contains a File field, you need to submit/create the file before 
-creating the submission itself. There is a `webformFileUpload` mutation 
-available.
+If the webform contains managed file fields, you can attach the uploaded files
+by using a multipart form request and including a 'map' field that maps the
+uploaded files to the webform elements.
 
-    mutation createFile($file: Upload!){
-      webformFileUpload(file: $file, id:"contact", webform_element_id: "upload_your_file") {
-        errors
-        violations
-        entity {
-          entityId
-        }
-        ... on WebformFileUploadOutput {
-      	  fid
-      	}
-      }
-    }
+For more information on how to upload files using GraphQL, see the
+[GraphQL multipart request specification](https://github.com/jaydenseric/graphql-multipart-request-spec).
 
+```
+mutation submitMyForm(
+  $id: String!,
+  $elements: [WebformSubmissionElement]!,
+  $files: [WebformSubmissionFile]!,
+) {
+  submitWebform(
+    id: $id,
+    elements: $elements,
+    files: $files,
+  ) {
+    errors
+    validationErrors {
+      element
+      messages
+    }
+    submission {
+      id
+    }
+  }
+}
+```
 
-As the result you can check for errors, violations, entity and entityId. You can 
-query for `entity > entityId` or `fid` to get the file id that was just created. 
-`fid` is a necessary GraphQL field for cases where the graphql is performed by 
-anonymous users and the file has been uploaded to the private folder.
-
-When you get the fid (e.g. 1910) you then update the `$values` variable with it:
+Example CURL request. Note that the value of the `file` field is `null` in the
+`variables` object. It will be replaced by the file linked in the `map` object.
 
-    {
-      "values": "{\"id\":\"contact\",\"subject\":\"This is the subject\",\"message\":\"Hey, I have a question\",\"date_of_birth\":\"05\/01\/1991\",\"email\":\"email@example.com\",\"upload_your_file\":\"1910\"}"
+```
+curl localhost:3001/graphql \
+  -F operations='{
+    "query": "mutation ($id: String!, $files: [WebformSubmissionFile]!) { submitWebform(id: $id, files: $files) { errors validationErrors { element messages } submission { id } } }",
+    "variables": {
+      "id": "my_webform",
+      "files": [{"element": "my_file_element", "file": null}]
     }
+  }' \
+  -F map='{"0": ["variables.files.0.file"]}' \
+  -F 0=@a.txt
+```
 
 ### Create a webform submission with a source entity
 If the webform supports a source entity, you can pass in the variables with the
-- 
GitLab


From 4244f61e4466311aa59c1e9a8454de95aa9f499b Mon Sep 17 00:00:00 2001
From: Pieter Frenssen <pieter@frenssen.be>
Date: Fri, 7 Feb 2025 15:23:32 +0200
Subject: [PATCH 19/20] Rename the method to get or create a validation error
 so that it is clear what is returned.

---
 src/Model/WebformSubmissionResult.php          | 4 ++--
 tests/src/Unit/WebformSubmissionResultTest.php | 8 ++++----
 2 files changed, 6 insertions(+), 6 deletions(-)

diff --git a/src/Model/WebformSubmissionResult.php b/src/Model/WebformSubmissionResult.php
index 44c13bb..7b2ab2d 100644
--- a/src/Model/WebformSubmissionResult.php
+++ b/src/Model/WebformSubmissionResult.php
@@ -105,7 +105,7 @@ class WebformSubmissionResult {
    *   The validation error. If no error exists for the given element ID yet, an
    *   empty error object is returned.
    */
-  public function getValidationError(?string $elementId): WebformSubmissionValidationError {
+  public function getOrCreateValidationError(?string $elementId): WebformSubmissionValidationError {
     return $this->validationErrors[$elementId] ?? new WebformSubmissionValidationError([], $elementId);
   }
 
@@ -119,7 +119,7 @@ class WebformSubmissionResult {
    *   is not element-specific.
    */
   public function addValidationError(string $errorMessage, ?string $elementId): static {
-    $validationError = $this->getValidationError($elementId);
+    $validationError = $this->getOrCreateValidationError($elementId);
     $validationError->addMessage($errorMessage);
     $this->validationErrors[$elementId] = $validationError;
 
diff --git a/tests/src/Unit/WebformSubmissionResultTest.php b/tests/src/Unit/WebformSubmissionResultTest.php
index 31b3203..fede644 100644
--- a/tests/src/Unit/WebformSubmissionResultTest.php
+++ b/tests/src/Unit/WebformSubmissionResultTest.php
@@ -37,7 +37,7 @@ class WebformSubmissionResultTest extends UnitTestCase {
 
   /**
    * @covers ::addValidationError
-   * @covers ::getValidationError
+   * @covers ::getOrCreateValidationError
    * @covers ::getValidationErrors
    */
   public function testAddValidationError() {
@@ -64,10 +64,10 @@ class WebformSubmissionResultTest extends UnitTestCase {
     $result->addValidationError('Yet another validation error', 'another_element_id');
     $errors = $result->getValidationErrors();
     $this->assertCount(2, $errors);
-    $error_element_1 = $result->getValidationError('element_id');
+    $error_element_1 = $result->getOrCreateValidationError('element_id');
     $this->assertEquals(['Validation error', 'Another validation error'], $error_element_1->getMessages());
     $this->assertEquals('element_id', $error_element_1->getElementId());
-    $error_element_2 = $result->getValidationError('another_element_id');
+    $error_element_2 = $result->getOrCreateValidationError('another_element_id');
     $this->assertEquals(['Yet another validation error'], $error_element_2->getMessages());
     $this->assertEquals('another_element_id', $error_element_2->getElementId());
 
@@ -75,7 +75,7 @@ class WebformSubmissionResultTest extends UnitTestCase {
     $result->addValidationError('Validation error without element ID', NULL);
     $errors = $result->getValidationErrors();
     $this->assertCount(3, $errors);
-    $error_no_element = $result->getValidationError(NULL);
+    $error_no_element = $result->getOrCreateValidationError(NULL);
     $this->assertEquals(['Validation error without element ID'], $error_no_element->getMessages());
     $this->assertNull($error_no_element->getElementId());
   }
-- 
GitLab


From 9735f78765b2b1d405687549d0a7df2f0891ba96 Mon Sep 17 00:00:00 2001
From: Pieter Frenssen <pieter@frenssen.be>
Date: Fri, 7 Feb 2025 15:23:55 +0200
Subject: [PATCH 20/20] Add a method to check if a validation error exists for
 a given element.

---
 src/Model/WebformSubmissionResult.php          | 14 ++++++++++++++
 tests/src/Unit/WebformSubmissionResultTest.php | 15 +++++++++++++++
 2 files changed, 29 insertions(+)

diff --git a/src/Model/WebformSubmissionResult.php b/src/Model/WebformSubmissionResult.php
index 7b2ab2d..69ac6a4 100644
--- a/src/Model/WebformSubmissionResult.php
+++ b/src/Model/WebformSubmissionResult.php
@@ -109,6 +109,20 @@ class WebformSubmissionResult {
     return $this->validationErrors[$elementId] ?? new WebformSubmissionValidationError([], $elementId);
   }
 
+  /**
+   * Returns whether a validation error exists for the given element.
+   *
+   * @param string|null $elementId
+   *   The ID of the Webform element to check for a validation error, or NULL to
+   *   check for a general validation error.
+   *
+   * @return bool
+   *   TRUE if a validation error exists for the given element.
+   */
+  public function hasValidationError(?string $elementId): bool {
+    return isset($this->validationErrors[$elementId]);
+  }
+
   /**
    * Adds a validation error.
    *
diff --git a/tests/src/Unit/WebformSubmissionResultTest.php b/tests/src/Unit/WebformSubmissionResultTest.php
index fede644..3a3f361 100644
--- a/tests/src/Unit/WebformSubmissionResultTest.php
+++ b/tests/src/Unit/WebformSubmissionResultTest.php
@@ -39,10 +39,13 @@ class WebformSubmissionResultTest extends UnitTestCase {
    * @covers ::addValidationError
    * @covers ::getOrCreateValidationError
    * @covers ::getValidationErrors
+   * @covers ::hasValidationError
    */
   public function testAddValidationError() {
     $result = new WebformSubmissionResult();
     $this->assertEmpty($result->getValidationErrors(), 'A new result does not have validation errors.');
+    $this->assertFalse($result->hasValidationError('element_id'), 'A new result does not have validation errors for an element.');
+    $this->assertFalse($result->hasValidationError(NULL), 'A new result does not have a general validation error.');
 
     // Add a first validation error.
     $result->addValidationError('Validation error', 'element_id');
@@ -51,6 +54,9 @@ class WebformSubmissionResultTest extends UnitTestCase {
     $returnedError = reset($errors);
     $this->assertEquals(['Validation error'], $returnedError->getMessages());
     $this->assertEquals('element_id', $returnedError->getElementId());
+    $this->assertTrue($result->hasValidationError('element_id'), 'The result has a validation error for the element.');
+    $this->assertFalse($result->hasValidationError('another_element_id'), 'The result does not have a validation error for another element.');
+    $this->assertFalse($result->hasValidationError(NULL), 'The result does not have a general validation error.');
 
     // Add another validation error for the same element.
     $result->addValidationError('Another validation error', 'element_id');
@@ -59,6 +65,9 @@ class WebformSubmissionResultTest extends UnitTestCase {
     $returnedError = reset($errors);
     $this->assertEquals(['Validation error', 'Another validation error'], $returnedError->getMessages());
     $this->assertEquals('element_id', $returnedError->getElementId());
+    $this->assertTrue($result->hasValidationError('element_id'), 'The result has a validation error for the element.');
+    $this->assertFalse($result->hasValidationError('another_element_id'), 'The result does not have a validation error for another element.');
+    $this->assertFalse($result->hasValidationError(NULL), 'The result does not have a general validation error.');
 
     // Add a validation error for a different element.
     $result->addValidationError('Yet another validation error', 'another_element_id');
@@ -70,6 +79,9 @@ class WebformSubmissionResultTest extends UnitTestCase {
     $error_element_2 = $result->getOrCreateValidationError('another_element_id');
     $this->assertEquals(['Yet another validation error'], $error_element_2->getMessages());
     $this->assertEquals('another_element_id', $error_element_2->getElementId());
+    $this->assertTrue($result->hasValidationError('element_id'), 'The result has a validation error for the first element.');
+    $this->assertTrue($result->hasValidationError('another_element_id'), 'The result has a validation error for the second element.');
+    $this->assertFalse($result->hasValidationError(NULL), 'The result does not have a general validation error.');
 
     // Add a validation error without an element ID.
     $result->addValidationError('Validation error without element ID', NULL);
@@ -78,6 +90,9 @@ class WebformSubmissionResultTest extends UnitTestCase {
     $error_no_element = $result->getOrCreateValidationError(NULL);
     $this->assertEquals(['Validation error without element ID'], $error_no_element->getMessages());
     $this->assertNull($error_no_element->getElementId());
+    $this->assertTrue($result->hasValidationError('element_id'), 'The result has a validation error for the first element.');
+    $this->assertTrue($result->hasValidationError('another_element_id'), 'The result has a validation error for the second element.');
+    $this->assertTrue($result->hasValidationError(NULL), 'The result has a general validation error.');
   }
 
   /**
-- 
GitLab