From 0c216da437748bdde11cc8d5ed6b39184fe6fd95 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20H=C3=A4u=C3=9Fler?= Date: Tue, 12 May 2026 11:06:19 +0200 Subject: [PATCH] [!!!][FEATURE] Support `multiple` attribute in `FileUpload` element --- .../Sets/Example/TypoScript/form.typoscript | 33 +++++ .../Private/Partials/form-field-checkbox.hbs | 10 ++ .../Partials/form-field-file-preview.hbs | 14 +++ .../Private/Partials/form-field-file.hbs | 35 ++++++ .../Builder/FileUploadViewModelBuilder.php | 118 ++++++++++++++++-- .../ViewModel/FileResourceViewModel.php | 7 +- Documentation/DeveloperCorner/ViewModels.rst | 20 +-- composer-dependency-analyser.php | 1 + phpstan-baseline.neon | 18 +++ 9 files changed, 235 insertions(+), 21 deletions(-) create mode 100644 Build/Packages/hbs-forms-example/Resources/Private/Partials/form-field-checkbox.hbs create mode 100644 Build/Packages/hbs-forms-example/Resources/Private/Partials/form-field-file-preview.hbs create mode 100644 Build/Packages/hbs-forms-example/Resources/Private/Partials/form-field-file.hbs diff --git a/Build/Packages/hbs-forms-example/Configuration/Sets/Example/TypoScript/form.typoscript b/Build/Packages/hbs-forms-example/Configuration/Sets/Example/TypoScript/form.typoscript index 5e14dc5..07420f3 100644 --- a/Build/Packages/hbs-forms-example/Configuration/Sets/Example/TypoScript/form.typoscript +++ b/Build/Packages/hbs-forms-example/Configuration/Sets/Example/TypoScript/form.typoscript @@ -13,6 +13,9 @@ plugin.tx_form { action = HBS_TAG action.attribute = action + + enctype = HBS_TAG + enctype.attribute = enctype } fields = HBS_RENDERABLES @@ -64,6 +67,35 @@ plugin.tx_form { type = hidden } + FileUpload < .Text + FileUpload { + template = @form-field-file + + accept = HBS_TAG + accept.attribute = accept + + multiple = HBS_TAG + multiple.attribute = multiple + + resource = HBS_PROPERTY + resource { + subject = viewModel + path = children?[resource?] + } + + resourcePointerFields = HBS_PROPERTY + resourcePointerFields { + subject = viewModel + path = children?[resourcePointerFields?] + } + + deleteCheckboxes = HBS_PROPERTY + deleteCheckboxes { + subject = viewModel + path = children?[deleteCheckboxes?] + } + } + Honeypot { content = HBS_PASSTHROUGH } @@ -87,6 +119,7 @@ plugin.tx_form { label.path = label value = HBS_FORM_VALUE + value.output = PROCESSED_VALUE } # Disable fieldsets diff --git a/Build/Packages/hbs-forms-example/Resources/Private/Partials/form-field-checkbox.hbs b/Build/Packages/hbs-forms-example/Resources/Private/Partials/form-field-checkbox.hbs new file mode 100644 index 0000000..22fd56e --- /dev/null +++ b/Build/Packages/hbs-forms-example/Resources/Private/Partials/form-field-checkbox.hbs @@ -0,0 +1,10 @@ +{{#unless disableContainer}}

{{/unless}} + + + {{> '@form-label' id=id label=label}} +{{#unless disableContainer}}

{{/unless}} diff --git a/Build/Packages/hbs-forms-example/Resources/Private/Partials/form-field-file-preview.hbs b/Build/Packages/hbs-forms-example/Resources/Private/Partials/form-field-file-preview.hbs new file mode 100644 index 0000000..565893c --- /dev/null +++ b/Build/Packages/hbs-forms-example/Resources/Private/Partials/form-field-file-preview.hbs @@ -0,0 +1,14 @@ +

+ {{resource.originalResource.originalFile.name}} +
+ + {{#if deleteCheckbox}} + {{> '@form-field-checkbox' + disableContainer=true + id=(join ../id "-remove-" index) + label="Delete file" + name=deleteCheckbox.element.name + value=deleteCheckbox.element.value + }} + {{/if}} +

diff --git a/Build/Packages/hbs-forms-example/Resources/Private/Partials/form-field-file.hbs b/Build/Packages/hbs-forms-example/Resources/Private/Partials/form-field-file.hbs new file mode 100644 index 0000000..5cba1ad --- /dev/null +++ b/Build/Packages/hbs-forms-example/Resources/Private/Partials/form-field-file.hbs @@ -0,0 +1,35 @@ +{{#unless disableContainer}}

{{/unless}} + {{> '@form-label' id=id label=label}} + + + + {{#if resourcePointerFields}} + {{#each resourcePointerFields}} + + {{/each}} + {{/if}} +{{#unless disableContainer}}

{{/unless}} + +{{#if resource.resource}} + {{#each resource.resource}} + {{! Multiple file uploads (TYPO3 >= v14)}} + {{> '@form-field-file-preview' + resource=this + deleteCheckbox=(lookup ../deleteCheckboxes.children @index) + index=@index + }} + {{else}} + {{! Single file upload}} + {{> '@form-field-file-preview' + resource=resource.resource + deleteCheckbox=(lookup ../deleteCheckboxes.children 0) + index=0 + }} + {{/each}} +{{/if}} diff --git a/Classes/Domain/ViewModel/Builder/FileUploadViewModelBuilder.php b/Classes/Domain/ViewModel/Builder/FileUploadViewModelBuilder.php index 6e94414..6c192e5 100644 --- a/Classes/Domain/ViewModel/Builder/FileUploadViewModelBuilder.php +++ b/Classes/Domain/ViewModel/Builder/FileUploadViewModelBuilder.php @@ -18,9 +18,12 @@ namespace CPSIT\Typo3HandlebarsForms\Domain\ViewModel\Builder; use CPSIT\Typo3HandlebarsForms\Domain; +use CPSIT\Typo3HandlebarsForms\Fluid\ViewHelperInvoker; +use TYPO3\CMS\Core; use TYPO3\CMS\Extbase; use TYPO3\CMS\Fluid; use TYPO3\CMS\Form; +use TYPO3Fluid\Fluid as FluidStandalone; /** * FileUploadViewModelBuilder @@ -37,31 +40,47 @@ final class FileUploadViewModelBuilder extends AbstractViewModelBuilder 'ImageUpload', ]; + private readonly Core\Information\Typo3Version $typo3Version; + + public function __construct(ViewHelperInvoker $viewHelperInvoker) + { + parent::__construct($viewHelperInvoker); + + $this->typo3Version = new Core\Information\Typo3Version(); + } + public function renderRenderable( Form\Domain\Model\Renderable\RootRenderableInterface $renderable, Fluid\Core\Rendering\RenderingContext $renderingContext, ): Domain\ViewModel\ViewModelCollection|Domain\ViewModel\ViewHelperContainedViewModel { $resource = null; $resourceVariableName = 'resource'; + $arguments = [ + 'property' => $renderable->getIdentifier(), + 'as' => $resourceVariableName, + 'id' => $renderable->getUniqueIdentifier(), + 'class' => $renderable->getProperties()['elementClassAttribute'] ?? null, + 'errorClass' => $renderable->getProperties()['elementErrorClassAttribute'] ?? null, + 'additionalAttributes' => $this->renderAdditionalAttributes($renderable, $renderingContext), + 'accept' => $renderable->getProperties()['allowedMimeTypes'] ?? null, + ]; + + // @todo Remove condition once support for TYPO3 v13 is dropped + if ($this->typo3Version->getMajorVersion() >= 14) { + $arguments['multiple'] = $renderable->getProperties()['multiple'] ?? false; + } + $result = $this->viewHelperInvoker->invoke( $renderingContext, Form\ViewHelpers\Form\UploadedResourceViewHelper::class, - [ - 'property' => $renderable->getIdentifier(), - 'as' => $resourceVariableName, - 'id' => $renderable->getUniqueIdentifier(), - 'class' => $renderable->getProperties()['elementClassAttribute'] ?? null, - 'errorClass' => $renderable->getProperties()['elementErrorClassAttribute'] ?? null, - 'additionalAttributes' => $this->renderAdditionalAttributes($renderable, $renderingContext), - 'accept' => $renderable->getProperties()['allowedMimeTypes'] ?? null, - ], + $arguments, static function () use ($renderingContext, &$resource, $resourceVariableName) { $resource = $renderingContext->getVariableProvider()->get($resourceVariableName); }, ); $inputViewModel = new Domain\ViewModel\ViewHelperContainedViewModel($renderable, $result); - if (!($resource instanceof Extbase\Domain\Model\FileReference)) { + if (!$this->isValidResource($resource)) { return $inputViewModel; } @@ -72,9 +91,86 @@ static function () use ($renderingContext, &$resource, $resourceVariableName) { ]; if ($hiddenFields !== []) { - $viewModels['resourcePointerField'] = new Domain\ViewModel\StandaloneTagViewModel($renderable, $hiddenFields[0]); + $viewModels['resourcePointerFields'] = $this->buildResourcePointerFields($renderable, $hiddenFields); + } + + // @todo Remove first condition once support for TYPO3 v13 is dropped + if ($this->typo3Version->getMajorVersion() >= 14 && (bool)($renderable->getProperties()['allowRemoval'] ?? false)) { + $viewModels['deleteCheckboxes'] = $this->buildDeleteCheckboxes($renderable, $renderingContext, $resource); } return new Domain\ViewModel\ViewModelCollection($renderable, $viewModels); } + + /** + * @param list $hiddenFields + */ + private function buildResourcePointerFields( + Form\Domain\Model\FormElements\FileUpload $renderable, + array $hiddenFields, + ): Domain\ViewModel\ViewModelCollection { + $resourcePointerFields = []; + + foreach ($hiddenFields as $hiddenField) { + $resourcePointerFields[] = new Domain\ViewModel\StandaloneTagViewModel($renderable, $hiddenField); + } + + return new Domain\ViewModel\ViewModelCollection($renderable, $resourcePointerFields); + } + + /** + * @param Extbase\Domain\Model\FileReference|Extbase\Persistence\ObjectStorage $resource + */ + private function buildDeleteCheckboxes( + Form\Domain\Model\FormElements\FileUpload $renderable, + Fluid\Core\Rendering\RenderingContext $renderingContext, + Extbase\Domain\Model\FileReference|Extbase\Persistence\ObjectStorage $resource, + ): Domain\ViewModel\ViewModelCollection { + if (!is_iterable($resource)) { + $resource = [$resource]; + } + + $viewModels = []; + $index = 0; + + // Map file references to view models + foreach ($resource as $fileReference) { + $result = $this->viewHelperInvoker->invoke( + $renderingContext, + Form\ViewHelpers\Form\UploadDeleteCheckboxViewHelper::class, + [ + 'property' => $renderable->getIdentifier(), + 'fileReference' => $fileReference, + 'fileIndex' => $index++, + ], + ); + + $viewModels[] = Domain\ViewModel\FormFieldViewModel::forLabelAndElement( + $fileReference->getOriginalResource()->getOriginalFile()->getName(), + new Domain\ViewModel\ViewHelperContainedViewModel($renderable, $result), + ); + } + + return new Domain\ViewModel\ViewModelCollection($renderable, $viewModels); + } + + /** + * @phpstan-assert-if-true Extbase\Domain\Model\FileReference|Extbase\Persistence\ObjectStorage $resource + */ + private function isValidResource(mixed $resource): bool + { + if ($resource instanceof Extbase\Domain\Model\FileReference) { + return true; + } + + // @todo Combine with previous condition once support for TYPO3 v13 is dropped + if ($this->typo3Version->getMajorVersion() >= 14 + && $resource instanceof Extbase\Persistence\ObjectStorage + && count($resource) > 0 + ) { + return true; + } + + return false; + } } diff --git a/Classes/Domain/ViewModel/FileResourceViewModel.php b/Classes/Domain/ViewModel/FileResourceViewModel.php index ee18851..01fd94d 100644 --- a/Classes/Domain/ViewModel/FileResourceViewModel.php +++ b/Classes/Domain/ViewModel/FileResourceViewModel.php @@ -28,12 +28,17 @@ * @license GPL-2.0-or-later * * @extends \ArrayObject + * + * @phpstan-type FileResource Core\Resource\File|Core\Resource\FileReference|Extbase\Domain\Model\File|Extbase\Domain\Model\FileReference */ final class FileResourceViewModel extends \ArrayObject implements ViewModel { + /** + * @param FileResource|Extbase\Persistence\ObjectStorage $resource + */ public function __construct( public readonly Form\Domain\Model\Renderable\RootRenderableInterface $renderable, - public readonly Core\Resource\File|Core\Resource\FileReference|Extbase\Domain\Model\File|Extbase\Domain\Model\FileReference $resource, + public readonly Core\Resource\File|Core\Resource\FileReference|Extbase\Domain\Model\File|Extbase\Domain\Model\FileReference|Extbase\Persistence\ObjectStorage $resource, ) { parent::__construct(['resource' => $this->resource]); } diff --git a/Documentation/DeveloperCorner/ViewModels.rst b/Documentation/DeveloperCorner/ViewModels.rst index 263d359..c9156b0 100644 --- a/Documentation/DeveloperCorner/ViewModels.rst +++ b/Documentation/DeveloperCorner/ViewModels.rst @@ -86,15 +86,17 @@ Supported renderables \(a) :php:`ViewModelCollection` If uploaded resource can be resolved. Contains two or three view models: - +------------------------+--------------------------------+--------------------------------------------------------------------------------------------------+ - | **Name** | **Type** | **Description** | - +------------------------+--------------------------------+--------------------------------------------------------------------------------------------------+ - | `uploadField` | `ViewHelperContainedViewModel` | Contains result from `` view helper invocation for password field. | - +------------------------+--------------------------------+--------------------------------------------------------------------------------------------------+ - | `resource` | `FileResourceViewModel` | References file upload, which is an instance of `FileReference` or `PseudoFileReference`. | - +------------------------+--------------------------------+--------------------------------------------------------------------------------------------------+ - | `resourcePointerField` | `StandaloneTagViewModel` | Optional. References hidden `` field with resource pointer, if available. | - +------------------------+--------------------------------+--------------------------------------------------------------------------------------------------+ + +-------------------------+---------------------------------------------------------+---------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ + | **Name** | **Type** | **Description** | + +-------------------------+---------------------------------------------------------+---------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ + | `uploadField` | `ViewHelperContainedViewModel` | Contains result from `` view helper invocation for password field. | + +-------------------------+---------------------------------------------------------+---------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ + | `resource` | `FileResourceViewModel` | References file upload, which is an instance of `FileReference`, `PseudoFileReference` or `ObjectStorage` (TYPO3 >= v14). | + +-------------------------+---------------------------------------------------------+---------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ + | `resourcePointerFields` | `ViewModelCollection` of `StandaloneTagViewModel` | Optional. References hidden `` fields with resource pointers, if available. | + +-------------------------+---------------------------------------------------------+---------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ + | `deleteCheckboxes` | `ViewModelCollection` of `ViewHelperContainedViewModel` | Optional and TYPO3 >= v14 only. Contains results from `` view helper invocations which allow to delete existing file uploads on submit. | + +-------------------------+---------------------------------------------------------+---------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ \(b) :php:`ViewHelperContainedViewModel` If uploaded resource cannot be resolved. Contains result from `` diff --git a/composer-dependency-analyser.php b/composer-dependency-analyser.php index 7842810..357a9f4 100644 --- a/composer-dependency-analyser.php +++ b/composer-dependency-analyser.php @@ -27,6 +27,7 @@ ->ignoreUnknownClasses([ // @todo Remove once support for TYPO3 v13 is dropped Form\Event\BeforeRenderableIsRenderedEvent::class, + Form\ViewHelpers\Form\UploadDeleteCheckboxViewHelper::class, Frontend\ContentObject\RegisterStack::class, ]) ; diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 8c98635..706f165 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -105,3 +105,21 @@ parameters: identifier: class.notFound count: 1 path: Classes/Domain/Renderer/HandlebarsFormRenderer.php + + - + rawMessage: Class TYPO3\CMS\Form\ViewHelpers\Form\UploadDeleteCheckboxViewHelper not found. + identifier: class.notFound + count: 1 + path: Classes/Domain/ViewModel/Builder/FileUploadViewModelBuilder.php + + - + rawMessage: 'Parameter #2 $resource of class CPSIT\Typo3HandlebarsForms\Domain\ViewModel\FileResourceViewModel constructor expects TYPO3\CMS\Core\Resource\File|TYPO3\CMS\Core\Resource\FileReference|TYPO3\CMS\Extbase\Domain\Model\File|TYPO3\CMS\Extbase\Domain\Model\FileReference|TYPO3\CMS\Extbase\Persistence\ObjectStorage, TYPO3\CMS\Extbase\Domain\Model\FileReference|TYPO3\CMS\Extbase\Persistence\ObjectStorage given.' + identifier: argument.type + count: 1 + path: Classes/Domain/ViewModel/Builder/FileUploadViewModelBuilder.php + + - + rawMessage: 'Parameter #2 $viewHelperClassName of method CPSIT\Typo3HandlebarsForms\Fluid\ViewHelperInvoker::invoke() expects class-string, string given.' + identifier: argument.type + count: 1 + path: Classes/Domain/ViewModel/Builder/FileUploadViewModelBuilder.php