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()