diff --git a/asset/css/file-element.less b/asset/css/file-element.less new file mode 100644 index 00000000..9d76d037 --- /dev/null +++ b/asset/css/file-element.less @@ -0,0 +1,40 @@ +form .uploaded-files { + list-style-type: none; + padding: 0; + margin: 0; + + > li:not(:last-of-type) { + margin-bottom: .5em; + } + + button[type="submit"].remove-uploaded-file { + .icon { + font-size: 1.2em; + } + + &:focus, &:hover { + cursor: pointer; + + .icon { + color: red; + } + } + } + + // text-overflow: ellipsis layout rules, yes, exclusively + > li { + display: flex; + + > button[type="submit"].remove-uploaded-file { + display: inline-flex; + flex: 1 1 auto; + width: 0; + + > span { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + } + } +} diff --git a/composer.json b/composer.json index 2cf61f4d..31f2bf37 100644 --- a/composer.json +++ b/composer.json @@ -10,11 +10,10 @@ }, "require": { "php": ">=7.2", + "ext-fileinfo": "*", "ipl/stdlib": ">=0.12.0", "ipl/validator": "dev-master", - "psr/http-message": "~1.0" - }, - "require-dev": { + "psr/http-message": "~1.0", "guzzlehttp/psr7": "^1" }, "autoload": { diff --git a/src/FormElement/FileElement.php b/src/FormElement/FileElement.php index aa736a4d..2f3e26ac 100644 --- a/src/FormElement/FileElement.php +++ b/src/FormElement/FileElement.php @@ -2,21 +2,41 @@ namespace ipl\Html\FormElement; +use GuzzleHttp\Psr7\UploadedFile; +use InvalidArgumentException; use ipl\Html\Attributes; +use ipl\Html\Form; +use ipl\Html\HtmlDocument; +use ipl\Html\HtmlElement; +use ipl\Html\Text; +use ipl\I18n\Translation; use ipl\Validator\FileValidator; use ipl\Validator\ValidatorChain; +use ipl\Web\Widget\Icon; use Psr\Http\Message\UploadedFileInterface; use ipl\Html\Common\MultipleAttribute; +use function ipl\Stdlib\get_php_type; + class FileElement extends InputElement { use MultipleAttribute; + use Translation; protected $type = 'file'; /** @var UploadedFileInterface|UploadedFileInterface[] */ protected $value; + /** @var UploadedFileInterface[] Files that are stored on disk */ + protected $files = []; + + /** @var string[] Files to be removed from disk */ + protected $filesToRemove = []; + + /** @var ?string The path where to store the file contents */ + protected $destination; + /** @var int The default maximum file size */ protected static $defaultMaxFileSize; @@ -27,6 +47,30 @@ public function __construct($name, $attributes = null) parent::__construct($name, $attributes); } + /** + * Set the path where to store the file contents + * + * @param string $path + * + * @return $this + */ + public function setDestination(string $path): self + { + $this->destination = $path; + + return $this; + } + + /** + * Get the path where file contents are stored + * + * @return ?string + */ + public function getDestination(): ?string + { + return $this->destination; + } + public function getValueAttribute() { // Value attributes of file inputs are set only client-side. @@ -43,21 +87,166 @@ public function getNameAttribute() public function hasValue() { if ($this->value === null) { - return false; - } + $files = $this->loadFiles(); + if (empty($files)) { + return false; + } - $file = $this->value; + if (! $this->isMultiple()) { + $files = $files[0]; + } - if ($this->isMultiple()) { - return $file[0]->getError() !== UPLOAD_ERR_NO_FILE; + $this->value = $files; } - return $file->getError() !== UPLOAD_ERR_NO_FILE; + return $this->value !== null; } public function getValue() { - return $this->hasValue() ? $this->value : null; + if (! $this->hasValue()) { + return null; + } + + if (! $this->hasFiles()) { + $files = $this->value; + if (! $this->isMultiple()) { + $files = [$files]; + } + + $storedFiles = $this->storeFiles(...$files); + if (! $this->isMultiple()) { + $storedFiles = $storedFiles[0]; + } + + $this->value = $storedFiles; + } + + return $this->value; + } + + public function setValue($value) + { + if (! empty($value)) { + $fileToTest = $value; + if ($this->isMultiple()) { + $fileToTest = $value[0]; + } + + if (! $fileToTest instanceof UploadedFileInterface) { + throw new InvalidArgumentException( + sprintf('%s is not an uploaded file', get_php_type($fileToTest)) + ); + } + + if ($fileToTest->getError() === UPLOAD_ERR_NO_FILE && ! $fileToTest->getClientFilename()) { + $value = null; + } + } else { + $value = null; + } + + return parent::setValue($value); + } + + /** + * Get whether there are any files stored on disk + * + * @return bool + */ + protected function hasFiles(): bool + { + return $this->destination !== null && reset($this->files); + } + + /** + * Load and return all files stored on disk + * + * @return UploadedFileInterface[] + */ + protected function loadFiles(): array + { + if (empty($this->files) || $this->destination === null) { + return []; + } + + foreach ($this->files as $name => $_) { + $filePath = $this->getFilePath($name); + if (! is_readable($filePath) || ! is_file($filePath)) { + // If one file isn't accessible, none is + return []; + } + + if (in_array($name, $this->filesToRemove, true)) { + @unlink($filePath); + } else { + $this->files[$name] = new UploadedFile( + $filePath, + filesize($filePath), + 0, + $name, + mime_content_type($filePath) + ); + } + } + + $this->files = array_diff_key($this->files, array_flip($this->filesToRemove)); + + return array_values($this->files); + } + + /** + * Store the given files on disk + * + * @param UploadedFileInterface ...$files + * + * @return UploadedFileInterface[] + */ + protected function storeFiles(UploadedFileInterface ...$files): array + { + if ($this->destination === null || ! is_writable($this->destination)) { + return $files; + } + + foreach ($files as $file) { + $name = $file->getClientFilename(); + $path = $this->getFilePath($name); + + $file->moveTo($path); + + // Re-created to ensure moveTo() still works if called externally + $this->files[$name] = new UploadedFile( + $path, + $file->getSize(), + 0, + $name, + $file->getClientMediaType() + ); + } + + return array_values($this->files); + } + + /** + * Get the file path on disk of the given file + * + * @param string $name + * + * @return string + */ + protected function getFilePath(string $name): string + { + return implode(DIRECTORY_SEPARATOR, [$this->destination, sha1($name)]); + } + + public function onRegistered(Form $form) + { + $chosenFiles = (array) $form->getPopulatedValue('chosen_file_' . $this->getName(), []); + foreach ($chosenFiles as $chosenFile) { + $this->files[$chosenFile] = null; + } + + $this->filesToRemove = (array) $form->getPopulatedValue('remove_file_' . $this->getName(), []); } protected function addDefaultValidators(ValidatorChain $chain): void @@ -79,6 +268,7 @@ protected function registerAttributeCallbacks(Attributes $attributes) { parent::registerAttributeCallbacks($attributes); $this->registerMultipleAttributeCallback($attributes); + $this->getAttributes()->registerAttributeCallback('destination', null, [$this, 'setDestination']); } /** @@ -159,4 +349,47 @@ protected static function getUploadMaxFilesize(): string { return ini_get('upload_max_filesize') ?: '2M'; } + + protected function assemble() + { + $doc = new HtmlDocument(); + if ($this->hasFiles()) { + foreach ($this->files as $file) { + $doc->addHtml(new HiddenElement('chosen_file_' . $this->getNameAttribute(), [ + 'value' => $file->getClientFilename() + ])); + } + + $this->prependWrapper($doc); + } + } + + public function renderUnwrapped() + { + if (! $this->hasValue() || ! $this->hasFiles()) { + return parent::renderUnwrapped(); + } + + $uploadedFiles = new HtmlElement('ul', Attributes::create(['class' => 'uploaded-files'])); + foreach ($this->files as $file) { + $uploadedFiles->addHtml(new HtmlElement( + 'li', + null, + (new ButtonElement('remove_file_' . $this->getNameAttribute(), Attributes::create([ + 'type' => 'submit', + 'formnovalidate' => true, + 'class' => 'remove-uploaded-file', + 'value' => $file->getClientFilename(), + 'title' => sprintf($this->translate('Remove file "%s"'), $file->getClientFilename()) + ])))->addHtml(new HtmlElement( + 'span', + null, + new Icon('remove'), + Text::create($file->getClientFilename()) + )) + )); + } + + return $uploadedFiles->render(); + } } diff --git a/tests/FormElement/FileElementTest.php b/tests/FormElement/FileElementTest.php index f90df45a..4be80eeb 100644 --- a/tests/FormElement/FileElementTest.php +++ b/tests/FormElement/FileElementTest.php @@ -4,12 +4,15 @@ use GuzzleHttp\Psr7\ServerRequest; use GuzzleHttp\Psr7\UploadedFile; +use GuzzleHttp\Psr7\Utils; +use InvalidArgumentException; use ipl\Html\Form; use ipl\Html\FormElement\FileElement; use ipl\I18n\NoopTranslator; use ipl\I18n\StaticTranslator; use ipl\Tests\Html\Lib\FileElementWithAdjustableConfig; use ipl\Tests\Html\TestCase; +use Psr\Http\Message\StreamInterface; class FileElementTest extends TestCase { @@ -33,6 +36,27 @@ public function testRendering() $this->assertHtml('', $file); } + public function testSetValueAcceptsEmptyValues() + { + $file = new FileElement('test_file'); + $file->setValue(null); + + $this->assertNull($file->getValue()); + + $file->setValue([]); + + $this->assertNull($file->getValue()); + } + + public function testValuePopulationOnlyWorksWithUploadedFiles() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('string is not an uploaded file'); + + $file = new FileElement('test_file'); + $file->setValue('lorem ipsum dolorem'); + } + public function testUploadedFiles() { $fileToUpload = new UploadedFile( @@ -54,20 +78,207 @@ public function testUploadedFiles() $this->assertSame($form->getValue('test_file'), $fileToUpload); } - public function testMutipleAttributeAlsoChangesNameAttribute() + public function testUploadedFileIsPreservedAcrossRequests() { - $file = new FileElement('test_file', ['multiple' => true]); + $createForm = function () { + return new class extends Form { + protected function assemble() + { + $this->addElement('file', 'test', [ + 'destination' => sys_get_temp_dir() + ]); + } + }; + }; + + // User submits a form after choosing a file + + $firstFile = new UploadedFile( + Utils::streamFor('lorem ipsum dolorem'), + 19, + 0, + 'test.txt', + 'text/plain' + ); - $this->assertHtml('', $file); - $this->assertSame($file->getName(), 'test_file'); + $firstRequest = (new ServerRequest('POST', ServerRequest::getUriFromGlobals())) + ->withUploadedFiles(['test' => $firstFile]) + ->withParsedBody([]); + + $firstForm = $createForm(); + $firstForm->handleRequest($firstRequest); + + $this->assertSame( + 'test.txt', + $firstForm->getElement('test')->getValue()->getClientFilename() + ); + + // User interacts with an autosubmit element and did not choose the file again + + // In this case, the browser sends an empty form part + $secondFile = new UploadedFile(null, 0, UPLOAD_ERR_NO_FILE); + + $secondRequest = (new ServerRequest('POST', ServerRequest::getUriFromGlobals())) + // But since the file element injects a hidden input, the file name is preserved + ->withUploadedFiles(['test' => $secondFile, 'chosen_file_test' => 'test.txt']) + ->withParsedBody([]); + + $secondForm = $createForm(); + $secondForm->handleRequest($secondRequest); + + $this->assertSame( + 'lorem ipsum dolorem', + $secondForm->getElement('test')->getValue()->getStream()->getContents() + ); + + // The user may also remove a file after choosing it + + $thirdRequest = (new ServerRequest('POST', ServerRequest::getUriFromGlobals())) + ->withUploadedFiles(['chosen_file_test' => 'test.txt', 'remove_file_test' => 'test.txt']) + ->withParsedBody([]); + + $thirdForm = $createForm(); + $thirdForm->handleRequest($thirdRequest); + + $this->assertNull($thirdForm->getValue('test')); } - public function testValueAttributeIsNotRendered() + public function testUploadedFileCanBeMoved() { - $file = new FileElement('test_file'); + $form = new class extends Form { + protected function assemble() + { + $this->addElement('file', 'test'); + } + }; + + $firstFile = new UploadedFile( + Utils::streamFor('lorem ipsum dolorem'), + 19, + 0, + 'test.txt', + 'text/plain' + ); + + $firstRequest = (new ServerRequest('POST', ServerRequest::getUriFromGlobals())) + ->withUploadedFiles(['test' => $firstFile]) + ->withParsedBody([]); + + $form->handleRequest($firstRequest); + + $filePath = implode(DIRECTORY_SEPARATOR, [sys_get_temp_dir(), 'test.txt']); - $file->setValue('test'); - $this->assertHtml('', $file); + $form->getValue('test')->moveTo($filePath); + + $this->assertFileExists($filePath); + } + + public function testUploadedFileCanBeStreamed() + { + $form = new class extends Form { + protected function assemble() + { + $this->addElement('file', 'test'); + } + }; + + $firstFile = new UploadedFile( + Utils::streamFor('lorem ipsum dolorem'), + 19, + 0, + 'test.txt', + 'text/plain' + ); + + $firstRequest = (new ServerRequest('POST', ServerRequest::getUriFromGlobals())) + ->withUploadedFiles(['test' => $firstFile]) + ->withParsedBody([]); + + $form->handleRequest($firstRequest); + + $stream = $form->getValue('test')->getStream(); + + $this->assertInstanceOf(StreamInterface::class, $stream); + + $this->assertSame( + 'lorem ipsum dolorem', + $stream->getContents() + ); + } + + public function testPreservedFileCanBeMoved() + { + $form = new class extends Form { + protected function assemble() + { + $this->addElement('file', 'test', [ + 'destination' => sys_get_temp_dir() + ]); + } + }; + + $firstFile = new UploadedFile( + Utils::streamFor('lorem ipsum dolorem'), + 19, + 0, + 'test.txt', + 'text/plain' + ); + + $firstRequest = (new ServerRequest('POST', ServerRequest::getUriFromGlobals())) + ->withUploadedFiles(['test' => $firstFile]) + ->withParsedBody([]); + + $form->handleRequest($firstRequest); + + $filePath = implode(DIRECTORY_SEPARATOR, [sys_get_temp_dir(), 'test.txt']); + + $form->getValue('test')->moveTo($filePath); + + $this->assertFileExists($filePath); + } + + public function testPreservedFileCanBeStreamed() + { + $form = new class extends Form { + protected function assemble() + { + $this->addElement('file', 'test', [ + 'destination' => sys_get_temp_dir() + ]); + } + }; + + $firstFile = new UploadedFile( + Utils::streamFor('lorem ipsum dolorem'), + 19, + 0, + 'test.txt', + 'text/plain' + ); + + $firstRequest = (new ServerRequest('POST', ServerRequest::getUriFromGlobals())) + ->withUploadedFiles(['test' => $firstFile]) + ->withParsedBody([]); + + $form->handleRequest($firstRequest); + + $stream = $form->getValue('test')->getStream(); + + $this->assertInstanceOf(StreamInterface::class, $stream); + + $this->assertSame( + 'lorem ipsum dolorem', + $stream->getContents() + ); + } + + public function testMutipleAttributeAlsoChangesNameAttribute() + { + $file = new FileElement('test_file', ['multiple' => true]); + + $this->assertHtml('', $file); + $this->assertSame($file->getName(), 'test_file'); } public function testDefaultMaxFileSizeAsBytesIsParsedCorrectly()