- {% set placeholder = t('action.choose_file', {}, 'EasyAdminBundle') %}
- {% set title = '' %}
- {% set filesLabel = 'files'|trans({}, 'EasyAdminBundle') %}
- {% if currentFiles %}
- {% if multiple %}
- {% set placeholder = currentFiles|length ~ ' ' ~ filesLabel %}
- {% else %}
- {% set placeholder = (currentFiles|first).filename %}
- {% set title = (currentFiles|first).mTime|date %}
- {% endif %}
+ {% set isFlysystem = flysystem_url_prefix is defined and flysystem_url_prefix is not null %}
+
+
- {{ form_errors(form.file) }}
-{% endblock %}
-{% block TODO_ea_fileupload_widget %}
- {% set placeholder = '' %}
- {% set title = '' %}
- {% set filesLabel = 'files'|trans({}, 'EasyAdminBundle') %}
- {% if currentFiles %}
- {% if multiple %}
- {% set placeholder = currentFiles|length ~ ' ' ~ filesLabel %}
- {% else %}
- {% set placeholder = (currentFiles|first).filename %}
- {% set title = (currentFiles|first).mTime|date %}
- {% endif %}
- {% endif %}
-
-
-
-
{{ form_errors(form.file) }}
{% endblock %}
diff --git a/tests/Unit/Field/Configurator/FileConfiguratorFlysystemTest.php b/tests/Unit/Field/Configurator/FileConfiguratorFlysystemTest.php
new file mode 100644
index 0000000000..b12e4b72f3
--- /dev/null
+++ b/tests/Unit/Field/Configurator/FileConfiguratorFlysystemTest.php
@@ -0,0 +1,320 @@
+filesystem = $this->createMock(FilesystemOperator::class);
+
+ $locator = $this->createMock(ContainerInterface::class);
+ $locator->method('has')->willReturnCallback(static fn (string $id) => 'default.storage' === $id);
+ $locator->method('get')->willReturnCallback(fn (string $id) => match ($id) {
+ 'default.storage' => $this->filesystem,
+ default => throw new \RuntimeException("Unknown storage: $id"),
+ });
+
+ $this->configurator = new FileConfigurator('/project', $locator);
+ }
+
+ // --- URL generation (INDEX page) ---
+
+ public function testSingleFileUrlWithPrefix(): void
+ {
+ $field = FileField::new('photo')
+ ->setFlysystemStorage('default.storage')
+ ->setFlysystemUrlPrefix('https://cdn.example.com')
+ ->setUploadDir('photos/');
+ $field->getAsDto()->setValue('photos/cat.jpg');
+
+ $dto = $this->configure($field);
+
+ $this->assertSame('https://cdn.example.com/photos/cat.jpg', $dto->getFormattedValue());
+ }
+
+ public function testMultipleFilesUrlWithPrefix(): void
+ {
+ $field = FileField::new('photos')
+ ->setFlysystemStorage('default.storage')
+ ->setFlysystemUrlPrefix('https://cdn.example.com')
+ ->setUploadDir('photos/');
+ $field->getAsDto()->setValue(['a.jpg', 'b.jpg']);
+
+ $dto = $this->configure($field);
+
+ $this->assertSame([
+ 'https://cdn.example.com/a.jpg',
+ 'https://cdn.example.com/b.jpg',
+ ], $dto->getFormattedValue());
+ }
+
+ public function testNullValueSetsEmptyTemplate(): void
+ {
+ $field = FileField::new('photo')
+ ->setFlysystemStorage('default.storage')
+ ->setFlysystemUrlPrefix('https://cdn.example.com')
+ ->setUploadDir('photos/');
+ $field->getAsDto()->setValue(null);
+
+ $dto = $this->configure($field);
+
+ $this->assertNull($dto->getFormattedValue());
+ $this->assertSame('label/empty', $dto->getTemplateName());
+ }
+
+ public function testAbsoluteUrlReturnedAsIs(): void
+ {
+ $field = FileField::new('photo')
+ ->setFlysystemStorage('default.storage')
+ ->setFlysystemUrlPrefix('https://cdn.example.com')
+ ->setUploadDir('photos/');
+ $field->getAsDto()->setValue('https://other.com/image.jpg');
+
+ $dto = $this->configure($field);
+
+ $this->assertSame('https://other.com/image.jpg', $dto->getFormattedValue());
+ }
+
+ public function testTrailingSlashNormalization(): void
+ {
+ $field = FileField::new('photo')
+ ->setFlysystemStorage('default.storage')
+ ->setFlysystemUrlPrefix('https://cdn.example.com/')
+ ->setUploadDir('photos/');
+ $field->getAsDto()->setValue('photos/cat.jpg');
+
+ $dto = $this->configure($field);
+
+ $this->assertSame('https://cdn.example.com/photos/cat.jpg', $dto->getFormattedValue());
+ }
+
+ // --- Form type options (EDIT page) ---
+
+ public function testUploadDirIsFlysystemPath(): void
+ {
+ $field = FileField::new('photo')
+ ->setFlysystemStorage('default.storage')
+ ->setFlysystemUrlPrefix('https://cdn.example.com')
+ ->setUploadDir('photos');
+
+ $dto = $this->configure($field, Crud::PAGE_EDIT);
+
+ $this->assertSame('photos/', $dto->getFormTypeOption('upload_dir'));
+ }
+
+ public function testFlysystemStorageOption(): void
+ {
+ $field = FileField::new('photo')
+ ->setFlysystemStorage('default.storage')
+ ->setFlysystemUrlPrefix('https://cdn.example.com')
+ ->setUploadDir('photos/');
+
+ $dto = $this->configure($field, Crud::PAGE_EDIT);
+
+ $this->assertSame($this->filesystem, $dto->getFormTypeOption('flysystem_storage'));
+ }
+
+ public function testFlysystemUrlPrefixPassedThrough(): void
+ {
+ $field = FileField::new('photo')
+ ->setFlysystemStorage('default.storage')
+ ->setFlysystemUrlPrefix('https://cdn.example.com')
+ ->setUploadDir('photos/');
+
+ $dto = $this->configure($field, Crud::PAGE_EDIT);
+
+ $this->assertSame('https://cdn.example.com', $dto->getFormTypeOption('flysystem_url_prefix'));
+ }
+
+ public function testDownloadPathSetToNull(): void
+ {
+ $field = FileField::new('photo')
+ ->setFlysystemStorage('default.storage')
+ ->setFlysystemUrlPrefix('https://cdn.example.com')
+ ->setUploadDir('photos/');
+
+ $dto = $this->configure($field, Crud::PAGE_EDIT);
+
+ $this->assertNull($dto->getFormTypeOption('download_path'));
+ }
+
+ public function testUploadCallablesAreSet(): void
+ {
+ $field = FileField::new('photo')
+ ->setFlysystemStorage('default.storage')
+ ->setFlysystemUrlPrefix('https://cdn.example.com')
+ ->setUploadDir('photos/');
+
+ $dto = $this->configure($field, Crud::PAGE_EDIT);
+
+ $this->assertIsCallable($dto->getFormTypeOption('upload_new'));
+ $this->assertIsCallable($dto->getFormTypeOption('upload_delete'));
+ $this->assertIsCallable($dto->getFormTypeOption('upload_validate'));
+ }
+
+ // --- upload_new callable ---
+
+ public function testUploadNewCallsWriteStream(): void
+ {
+ $this->filesystem->expects($this->once())
+ ->method('writeStream')
+ ->with(
+ 'photos/report.pdf',
+ $this->isType('resource')
+ );
+
+ $field = FileField::new('photo')
+ ->setFlysystemStorage('default.storage')
+ ->setFlysystemUrlPrefix('https://cdn.example.com')
+ ->setUploadDir('photos/');
+
+ $dto = $this->configure($field, Crud::PAGE_EDIT);
+
+ $tmpFile = tempnam(sys_get_temp_dir(), 'ea_test_');
+ file_put_contents($tmpFile, 'test content');
+
+ $uploaded = new UploadedFile($tmpFile, 'report.pdf', 'application/pdf', null, true);
+
+ $uploadNew = $dto->getFormTypeOption('upload_new');
+ $uploadNew($uploaded, 'photos/', 'report.pdf');
+
+ @unlink($tmpFile);
+ }
+
+ // --- upload_delete callable ---
+
+ public function testUploadDeleteCallsDelete(): void
+ {
+ $this->filesystem->expects($this->once())
+ ->method('delete')
+ ->with('photos/cat.jpg');
+
+ $field = FileField::new('photo')
+ ->setFlysystemStorage('default.storage')
+ ->setFlysystemUrlPrefix('https://cdn.example.com')
+ ->setUploadDir('photos/');
+
+ $dto = $this->configure($field, Crud::PAGE_EDIT);
+
+ $uploadDelete = $dto->getFormTypeOption('upload_delete');
+ $uploadDelete(new FlysystemFile('photos/cat.jpg'));
+ }
+
+ public function testUploadDeleteSwallowsExceptions(): void
+ {
+ $this->filesystem->method('delete')
+ ->willThrowException(new \RuntimeException('Network error'));
+
+ $field = FileField::new('photo')
+ ->setFlysystemStorage('default.storage')
+ ->setFlysystemUrlPrefix('https://cdn.example.com')
+ ->setUploadDir('photos/');
+
+ $dto = $this->configure($field, Crud::PAGE_EDIT);
+
+ $uploadDelete = $dto->getFormTypeOption('upload_delete');
+ // Should not throw
+ $uploadDelete(new FlysystemFile('photos/cat.jpg'));
+
+ $this->addToAssertionCount(1);
+ }
+
+ // --- upload_validate callable ---
+
+ public function testUploadValidateFileDoesNotExistReturnsUnchanged(): void
+ {
+ $this->filesystem->method('fileExists')->willReturn(false);
+
+ $field = FileField::new('photo')
+ ->setFlysystemStorage('default.storage')
+ ->setFlysystemUrlPrefix('https://cdn.example.com')
+ ->setUploadDir('photos/');
+
+ $dto = $this->configure($field, Crud::PAGE_EDIT);
+
+ $validate = $dto->getFormTypeOption('upload_validate');
+ $this->assertSame('doc.pdf', $validate('doc.pdf'));
+ }
+
+ public function testUploadValidateFileExistsAppendsIndex(): void
+ {
+ $this->filesystem->method('fileExists')
+ ->willReturnCallback(static fn (string $path) => match ($path) {
+ 'doc.pdf' => true,
+ 'doc_1.pdf' => false,
+ default => false,
+ });
+
+ $field = FileField::new('photo')
+ ->setFlysystemStorage('default.storage')
+ ->setFlysystemUrlPrefix('https://cdn.example.com')
+ ->setUploadDir('photos/');
+
+ $dto = $this->configure($field, Crud::PAGE_EDIT);
+
+ $validate = $dto->getFormTypeOption('upload_validate');
+ $this->assertSame('doc_1.pdf', $validate('doc.pdf'));
+ }
+
+ public function testUploadValidatePreservesDirectoryPrefix(): void
+ {
+ $this->filesystem->method('fileExists')
+ ->willReturnCallback(static fn (string $path) => match ($path) {
+ 'uploads/doc.pdf' => true,
+ 'uploads/doc_1.pdf' => false,
+ default => false,
+ });
+
+ $field = FileField::new('photo')
+ ->setFlysystemStorage('default.storage')
+ ->setFlysystemUrlPrefix('https://cdn.example.com')
+ ->setUploadDir('photos/');
+
+ $dto = $this->configure($field, Crud::PAGE_EDIT);
+
+ $validate = $dto->getFormTypeOption('upload_validate');
+ $this->assertSame('uploads/doc_1.pdf', $validate('uploads/doc.pdf'));
+ }
+
+ // --- Error paths ---
+
+ public function testFlysystemStorageNotFoundInLocatorThrows(): void
+ {
+ $field = FileField::new('photo')
+ ->setFlysystemStorage('unknown.storage')
+ ->setFlysystemUrlPrefix('https://cdn.example.com')
+ ->setUploadDir('photos/');
+
+ $this->expectException(\InvalidArgumentException::class);
+ $this->configure($field);
+ }
+
+ public function testNullLocatorThrows(): void
+ {
+ $configurator = new FileConfigurator('/project', null);
+
+ $field = FileField::new('photo')
+ ->setFlysystemStorage('default.storage')
+ ->setFlysystemUrlPrefix('https://cdn.example.com')
+ ->setUploadDir('photos/');
+
+ $this->configurator = $configurator;
+
+ $this->expectException(\InvalidArgumentException::class);
+ $this->configure($field);
+ }
+}
diff --git a/tests/Unit/Field/FileFieldTest.php b/tests/Unit/Field/FileFieldTest.php
new file mode 100644
index 0000000000..4913be2777
--- /dev/null
+++ b/tests/Unit/Field/FileFieldTest.php
@@ -0,0 +1,362 @@
+configurator = new class implements FieldConfiguratorInterface {
+ public function supports(FieldDto $field, EntityDto $entityDto): bool
+ {
+ return FileField::class === $field->getFieldFqcn();
+ }
+
+ public function configure(FieldDto $field, EntityDto $entityDto, AdminContext $context): void
+ {
+ // no-op for basic option testing
+ }
+ };
+ }
+
+ public function testDefaultOptions(): void
+ {
+ $field = FileField::new('document');
+ $fieldDto = $this->configure($field);
+
+ self::assertNull($fieldDto->getCustomOption(FileField::OPTION_BASE_PATH));
+ self::assertNull($fieldDto->getCustomOption(FileField::OPTION_UPLOAD_DIR));
+ self::assertSame('[name].[extension]', $fieldDto->getCustomOption(FileField::OPTION_UPLOADED_FILE_NAME_PATTERN));
+ self::assertSame(FileUploadType::class, $fieldDto->getFormType());
+ self::assertStringContainsString('field-file', $fieldDto->getCssClass());
+ }
+
+ public function testDefaultFileConstraints(): void
+ {
+ $field = FileField::new('document');
+ $fieldDto = $this->configure($field);
+
+ $constraints = $fieldDto->getCustomOption(FileField::OPTION_FILE_CONSTRAINTS);
+ self::assertIsArray($constraints);
+ self::assertCount(0, $constraints);
+ }
+
+ public function testFieldWithNullValue(): void
+ {
+ $field = FileField::new('document');
+ $field->setValue(null);
+ $fieldDto = $this->configure($field);
+
+ self::assertNull($fieldDto->getValue());
+ }
+
+ public function testFieldWithFilename(): void
+ {
+ $field = FileField::new('document');
+ $field->setValue('report.pdf');
+ $fieldDto = $this->configure($field);
+
+ self::assertSame('report.pdf', $fieldDto->getValue());
+ }
+
+ public function testSetBasePath(): void
+ {
+ $field = FileField::new('document');
+ $field->setBasePath('/uploads/files/');
+ $fieldDto = $this->configure($field);
+
+ self::assertSame('/uploads/files/', $fieldDto->getCustomOption(FileField::OPTION_BASE_PATH));
+ }
+
+ public function testSetUploadDir(): void
+ {
+ $field = FileField::new('document');
+ $field->setUploadDir('public/uploads/files/');
+ $fieldDto = $this->configure($field);
+
+ self::assertSame('public/uploads/files/', $fieldDto->getCustomOption(FileField::OPTION_UPLOAD_DIR));
+ }
+
+ public function testSetUploadedFileNamePatternWithString(): void
+ {
+ $field = FileField::new('document');
+ $field->setUploadedFileNamePattern('[year]/[month]/[slug].[extension]');
+ $fieldDto = $this->configure($field);
+
+ self::assertSame('[year]/[month]/[slug].[extension]', $fieldDto->getCustomOption(FileField::OPTION_UPLOADED_FILE_NAME_PATTERN));
+ }
+
+ public function testSetUploadedFileNamePatternWithClosure(): void
+ {
+ $pattern = static fn ($file) => 'custom_'.$file->getFilename();
+ $field = FileField::new('document');
+ $field->setUploadedFileNamePattern($pattern);
+ $fieldDto = $this->configure($field);
+
+ self::assertSame($pattern, $fieldDto->getCustomOption(FileField::OPTION_UPLOADED_FILE_NAME_PATTERN));
+ }
+
+ public function testSetFileConstraintsWithSingleConstraint(): void
+ {
+ $constraint = new File(maxSize: '10M');
+
+ $field = FileField::new('document');
+ $field->setFileConstraints($constraint);
+ $fieldDto = $this->configure($field);
+
+ self::assertSame($constraint, $fieldDto->getCustomOption(FileField::OPTION_FILE_CONSTRAINTS));
+ }
+
+ public function testSetFileConstraintsWithMultipleConstraints(): void
+ {
+ $constraints = [
+ new File(maxSize: '10M'),
+ new NotNull(),
+ ];
+ $field = FileField::new('document');
+ $field->setFileConstraints($constraints);
+ $fieldDto = $this->configure($field);
+
+ self::assertSame($constraints, $fieldDto->getCustomOption(FileField::OPTION_FILE_CONSTRAINTS));
+ }
+
+ public function testUploadPatternPlaceholders(): void
+ {
+ $patterns = [
+ '[DD]',
+ '[MM]',
+ '[YYYY]',
+ '[YY]',
+ '[hh]',
+ '[mm]',
+ '[ss]',
+ '[timestamp]',
+ '[name]',
+ '[slug]',
+ '[extension]',
+ '[contenthash]',
+ '[randomhash]',
+ '[uuid]',
+ '[ulid]',
+ ];
+
+ foreach ($patterns as $pattern) {
+ $field = FileField::new('document');
+ $field->setUploadedFileNamePattern($pattern);
+ $fieldDto = $this->configure($field);
+
+ self::assertSame($pattern, $fieldDto->getCustomOption(FileField::OPTION_UPLOADED_FILE_NAME_PATTERN));
+ }
+ }
+
+ public function testComplexUploadPattern(): void
+ {
+ $pattern = '[YYYY]/[MM]/[DD]/[slug]-[contenthash].[extension]';
+ $field = FileField::new('document');
+ $field->setUploadedFileNamePattern($pattern);
+ $fieldDto = $this->configure($field);
+
+ self::assertSame($pattern, $fieldDto->getCustomOption(FileField::OPTION_UPLOADED_FILE_NAME_PATTERN));
+ }
+
+ public function testSetFileAccept(): void
+ {
+ $field = FileField::new('document');
+ $field->mimeTypes('.pdf,.docx,.xlsx');
+ $fieldDto = $this->configure($field);
+
+ self::assertSame('.pdf,.docx,.xlsx', $fieldDto->getCustomOption(FileField::OPTION_MIME_TYPES));
+ }
+
+ public function testDefaultAcceptIsNull(): void
+ {
+ $field = FileField::new('document');
+ $fieldDto = $this->configure($field);
+
+ self::assertNull($fieldDto->getCustomOption(FileField::OPTION_MIME_TYPES));
+ }
+
+ public function testSetFileAcceptWithMimeTypes(): void
+ {
+ $field = FileField::new('document');
+ $field->mimeTypes('image/*,application/pdf');
+ $fieldDto = $this->configure($field);
+
+ self::assertSame('image/*,application/pdf', $fieldDto->getCustomOption(FileField::OPTION_MIME_TYPES));
+ }
+
+ public function testSetFileAcceptWithMixedTokens(): void
+ {
+ $field = FileField::new('document');
+ $field->mimeTypes('.pdf, image/*, .docx');
+ $fieldDto = $this->configure($field);
+
+ self::assertSame('.pdf, image/*, .docx', $fieldDto->getCustomOption(FileField::OPTION_MIME_TYPES));
+ }
+
+ public function testMimeTypesWithErrorMessage(): void
+ {
+ $field = FileField::new('document');
+ $field->mimeTypes('.pdf,.docx', 'Only PDF and Word files are allowed (got {{ type }})');
+ $fieldDto = $this->configure($field);
+
+ self::assertSame('.pdf,.docx', $fieldDto->getCustomOption(FileField::OPTION_MIME_TYPES));
+ self::assertSame('Only PDF and Word files are allowed (got {{ type }})', $fieldDto->getCustomOption(FileField::OPTION_MIME_TYPES_MESSAGE));
+ }
+
+ public function testDefaultMimeTypesMessageIsNull(): void
+ {
+ $field = FileField::new('document');
+ $fieldDto = $this->configure($field);
+
+ self::assertNull($fieldDto->getCustomOption(FileField::OPTION_MIME_TYPES_MESSAGE));
+ }
+
+ public function testSetMaxSize(): void
+ {
+ $field = FileField::new('document');
+ $field->maxSize('10M');
+ $fieldDto = $this->configure($field);
+
+ self::assertSame('10M', $fieldDto->getCustomOption(FileField::OPTION_MAX_SIZE));
+ self::assertNull($fieldDto->getCustomOption(FileField::OPTION_MAX_SIZE_MESSAGE));
+ }
+
+ public function testSetMaxSizeWithInteger(): void
+ {
+ $field = FileField::new('document');
+ $field->maxSize(1048576);
+ $fieldDto = $this->configure($field);
+
+ self::assertSame(1048576, $fieldDto->getCustomOption(FileField::OPTION_MAX_SIZE));
+ }
+
+ public function testSetMaxSizeWithErrorMessage(): void
+ {
+ $field = FileField::new('document');
+ $field->maxSize('5M', 'File {{ name }} is too large ({{ size }} {{ suffix }})');
+ $fieldDto = $this->configure($field);
+
+ self::assertSame('5M', $fieldDto->getCustomOption(FileField::OPTION_MAX_SIZE));
+ self::assertSame('File {{ name }} is too large ({{ size }} {{ suffix }})', $fieldDto->getCustomOption(FileField::OPTION_MAX_SIZE_MESSAGE));
+ }
+
+ public function testDefaultMaxSizeIsNull(): void
+ {
+ $field = FileField::new('document');
+ $fieldDto = $this->configure($field);
+
+ self::assertNull($fieldDto->getCustomOption(FileField::OPTION_MAX_SIZE));
+ self::assertNull($fieldDto->getCustomOption(FileField::OPTION_MAX_SIZE_MESSAGE));
+ }
+
+ public function testDefaultViewable(): void
+ {
+ $field = FileField::new('document');
+ $fieldDto = $this->configure($field);
+
+ self::assertTrue($fieldDto->getCustomOption(FileField::OPTION_VIEWABLE));
+ }
+
+ public function testIsViewableFalse(): void
+ {
+ $field = FileField::new('document');
+ $field->isViewable(false);
+ $fieldDto = $this->configure($field);
+
+ self::assertFalse($fieldDto->getCustomOption(FileField::OPTION_VIEWABLE));
+ }
+
+ public function testDefaultDownloadable(): void
+ {
+ $field = FileField::new('document');
+ $fieldDto = $this->configure($field);
+
+ self::assertTrue($fieldDto->getCustomOption(FileField::OPTION_DOWNLOADABLE));
+ }
+
+ public function testIsDownloadableFalse(): void
+ {
+ $field = FileField::new('document');
+ $field->isDownloadable(false);
+ $fieldDto = $this->configure($field);
+
+ self::assertFalse($fieldDto->getCustomOption(FileField::OPTION_DOWNLOADABLE));
+ }
+
+ public function testDefaultReplacedFileBehavior(): void
+ {
+ $field = FileField::new('document');
+ $fieldDto = $this->configure($field);
+
+ self::assertSame(ReplacedFileBehavior::DELETE, $fieldDto->getCustomOption(FileField::OPTION_REPLACED_FILE_BEHAVIOR));
+ }
+
+ public function testDeleteReplacedFile(): void
+ {
+ $field = FileField::new('document');
+ $field->deleteReplacedFile();
+ $fieldDto = $this->configure($field);
+
+ self::assertSame(ReplacedFileBehavior::DELETE, $fieldDto->getCustomOption(FileField::OPTION_REPLACED_FILE_BEHAVIOR));
+ }
+
+ public function testKeepReplacedFile(): void
+ {
+ $field = FileField::new('document');
+ $field->keepReplacedFile();
+ $fieldDto = $this->configure($field);
+
+ self::assertSame(ReplacedFileBehavior::KEEP, $fieldDto->getCustomOption(FileField::OPTION_REPLACED_FILE_BEHAVIOR));
+ }
+
+ public function testKeepReplacedFileOrFail(): void
+ {
+ $field = FileField::new('document');
+ $field->keepReplacedFileOrFail();
+ $fieldDto = $this->configure($field);
+
+ self::assertSame(ReplacedFileBehavior::KEEP_OR_FAIL, $fieldDto->getCustomOption(FileField::OPTION_REPLACED_FILE_BEHAVIOR));
+ }
+
+ public function testDefaultDeletable(): void
+ {
+ $field = FileField::new('document');
+ $fieldDto = $this->configure($field);
+
+ self::assertTrue($fieldDto->getCustomOption(FileField::OPTION_DELETABLE));
+ }
+
+ public function testIsDeletableFalse(): void
+ {
+ $field = FileField::new('document');
+ $field->isDeletable(false);
+ $fieldDto = $this->configure($field);
+
+ self::assertFalse($fieldDto->getCustomOption(FileField::OPTION_DELETABLE));
+ }
+
+ public function testIsDeletableTrue(): void
+ {
+ $field = FileField::new('document');
+ $field->isDeletable(false);
+ $field->isDeletable(true);
+ $fieldDto = $this->configure($field);
+
+ self::assertTrue($fieldDto->getCustomOption(FileField::OPTION_DELETABLE));
+ }
+}
diff --git a/tests/Unit/Field/ImageFieldTest.php b/tests/Unit/Field/ImageFieldTest.php
index 80a5f1cd63..e00347b455 100644
--- a/tests/Unit/Field/ImageFieldTest.php
+++ b/tests/Unit/Field/ImageFieldTest.php
@@ -2,6 +2,7 @@
namespace EasyCorp\Bundle\EasyAdminBundle\Tests\Unit\Field;
+use EasyCorp\Bundle\EasyAdminBundle\Config\Option\ReplacedFileBehavior;
use EasyCorp\Bundle\EasyAdminBundle\Context\AdminContext;
use EasyCorp\Bundle\EasyAdminBundle\Contracts\Field\FieldConfiguratorInterface;
use EasyCorp\Bundle\EasyAdminBundle\Dto\EntityDto;
@@ -172,6 +173,13 @@ public function testUploadPatternPlaceholders(): void
{
// test various placeholders that can be used
$patterns = [
+ '[DD]',
+ '[MM]',
+ '[YYYY]',
+ '[YY]',
+ '[hh]',
+ '[mm]',
+ '[ss]',
'[day]',
'[month]',
'[year]',
@@ -182,6 +190,8 @@ public function testUploadPatternPlaceholders(): void
'[contenthash]',
'[randomhash]',
'[uuid]',
+ '[uuid32]',
+ '[uuid58]',
'[ulid]',
];
@@ -196,11 +206,171 @@ public function testUploadPatternPlaceholders(): void
public function testComplexUploadPattern(): void
{
- $pattern = '[year]/[month]/[day]/[slug]-[contenthash].[extension]';
+ $pattern = '[YYYY]/[MM]/[DD]/[slug]-[contenthash].[extension]';
$field = ImageField::new('image');
$field->setUploadedFileNamePattern($pattern);
$fieldDto = $this->configure($field);
self::assertSame($pattern, $fieldDto->getCustomOption(ImageField::OPTION_UPLOADED_FILE_NAME_PATTERN));
}
+
+ public function testDefaultMimeTypes(): void
+ {
+ $field = ImageField::new('image');
+ $fieldDto = $this->configure($field);
+
+ self::assertSame('image/*', $fieldDto->getCustomOption(ImageField::OPTION_MIME_TYPES));
+ }
+
+ public function testMimeTypesWithErrorMessage(): void
+ {
+ $field = ImageField::new('image');
+ $field->mimeTypes('image/png,image/jpeg', 'Only PNG and JPEG images are allowed (got {{ type }})');
+ $fieldDto = $this->configure($field);
+
+ self::assertSame('image/png,image/jpeg', $fieldDto->getCustomOption(ImageField::OPTION_MIME_TYPES));
+ self::assertSame('Only PNG and JPEG images are allowed (got {{ type }})', $fieldDto->getCustomOption(ImageField::OPTION_MIME_TYPES_MESSAGE));
+ }
+
+ public function testDefaultMimeTypesMessageIsNull(): void
+ {
+ $field = ImageField::new('image');
+ $fieldDto = $this->configure($field);
+
+ self::assertNull($fieldDto->getCustomOption(ImageField::OPTION_MIME_TYPES_MESSAGE));
+ }
+
+ public function testSetMaxSize(): void
+ {
+ $field = ImageField::new('image');
+ $field->maxSize('5M');
+ $fieldDto = $this->configure($field);
+
+ self::assertSame('5M', $fieldDto->getCustomOption(ImageField::OPTION_MAX_SIZE));
+ self::assertNull($fieldDto->getCustomOption(ImageField::OPTION_MAX_SIZE_MESSAGE));
+ }
+
+ public function testSetMaxSizeWithInteger(): void
+ {
+ $field = ImageField::new('image');
+ $field->maxSize(2097152);
+ $fieldDto = $this->configure($field);
+
+ self::assertSame(2097152, $fieldDto->getCustomOption(ImageField::OPTION_MAX_SIZE));
+ }
+
+ public function testSetMaxSizeWithErrorMessage(): void
+ {
+ $field = ImageField::new('image');
+ $field->maxSize('2M', 'Image {{ name }} is too large ({{ size }} {{ suffix }})');
+ $fieldDto = $this->configure($field);
+
+ self::assertSame('2M', $fieldDto->getCustomOption(ImageField::OPTION_MAX_SIZE));
+ self::assertSame('Image {{ name }} is too large ({{ size }} {{ suffix }})', $fieldDto->getCustomOption(ImageField::OPTION_MAX_SIZE_MESSAGE));
+ }
+
+ public function testDefaultMaxSizeIsNull(): void
+ {
+ $field = ImageField::new('image');
+ $fieldDto = $this->configure($field);
+
+ self::assertNull($fieldDto->getCustomOption(ImageField::OPTION_MAX_SIZE));
+ self::assertNull($fieldDto->getCustomOption(ImageField::OPTION_MAX_SIZE_MESSAGE));
+ }
+
+ public function testDefaultViewable(): void
+ {
+ $field = ImageField::new('image');
+ $fieldDto = $this->configure($field);
+
+ self::assertTrue($fieldDto->getCustomOption(ImageField::OPTION_VIEWABLE));
+ }
+
+ public function testIsViewableFalse(): void
+ {
+ $field = ImageField::new('image');
+ $field->isViewable(false);
+ $fieldDto = $this->configure($field);
+
+ self::assertFalse($fieldDto->getCustomOption(ImageField::OPTION_VIEWABLE));
+ }
+
+ public function testDefaultDownloadable(): void
+ {
+ $field = ImageField::new('image');
+ $fieldDto = $this->configure($field);
+
+ self::assertTrue($fieldDto->getCustomOption(ImageField::OPTION_DOWNLOADABLE));
+ }
+
+ public function testIsDownloadableFalse(): void
+ {
+ $field = ImageField::new('image');
+ $field->isDownloadable(false);
+ $fieldDto = $this->configure($field);
+
+ self::assertFalse($fieldDto->getCustomOption(ImageField::OPTION_DOWNLOADABLE));
+ }
+
+ public function testDefaultReplacedFileBehavior(): void
+ {
+ $field = ImageField::new('image');
+ $fieldDto = $this->configure($field);
+
+ self::assertSame(ReplacedFileBehavior::DELETE, $fieldDto->getCustomOption(ImageField::OPTION_REPLACED_FILE_BEHAVIOR));
+ }
+
+ public function testDeleteReplacedFile(): void
+ {
+ $field = ImageField::new('image');
+ $field->deleteReplacedFile();
+ $fieldDto = $this->configure($field);
+
+ self::assertSame(ReplacedFileBehavior::DELETE, $fieldDto->getCustomOption(ImageField::OPTION_REPLACED_FILE_BEHAVIOR));
+ }
+
+ public function testKeepReplacedFile(): void
+ {
+ $field = ImageField::new('image');
+ $field->keepReplacedFile();
+ $fieldDto = $this->configure($field);
+
+ self::assertSame(ReplacedFileBehavior::KEEP, $fieldDto->getCustomOption(ImageField::OPTION_REPLACED_FILE_BEHAVIOR));
+ }
+
+ public function testKeepReplacedFileOrFail(): void
+ {
+ $field = ImageField::new('image');
+ $field->keepReplacedFileOrFail();
+ $fieldDto = $this->configure($field);
+
+ self::assertSame(ReplacedFileBehavior::KEEP_OR_FAIL, $fieldDto->getCustomOption(ImageField::OPTION_REPLACED_FILE_BEHAVIOR));
+ }
+
+ public function testDefaultDeletable(): void
+ {
+ $field = ImageField::new('image');
+ $fieldDto = $this->configure($field);
+
+ self::assertTrue($fieldDto->getCustomOption(ImageField::OPTION_DELETABLE));
+ }
+
+ public function testIsDeletableFalse(): void
+ {
+ $field = ImageField::new('image');
+ $field->isDeletable(false);
+ $fieldDto = $this->configure($field);
+
+ self::assertFalse($fieldDto->getCustomOption(ImageField::OPTION_DELETABLE));
+ }
+
+ public function testIsDeletableTrue(): void
+ {
+ $field = ImageField::new('image');
+ $field->isDeletable(false);
+ $field->isDeletable(true);
+ $fieldDto = $this->configure($field);
+
+ self::assertTrue($fieldDto->getCustomOption(ImageField::OPTION_DELETABLE));
+ }
}
diff --git a/tests/Unit/Form/DataTransformer/StringToFileTransformerFlysystemTest.php b/tests/Unit/Form/DataTransformer/StringToFileTransformerFlysystemTest.php
new file mode 100644
index 0000000000..8a13ce2e9f
--- /dev/null
+++ b/tests/Unit/Form/DataTransformer/StringToFileTransformerFlysystemTest.php
@@ -0,0 +1,181 @@
+ $f->getClientOriginalName(),
+ uploadValidate: $uploadValidate ?? static fn (string $filename): string => $filename,
+ multiple: $multiple,
+ flysystemStorage: $filesystem,
+ );
+ }
+
+ // --- transform() tests ---
+
+ public function testTransformNullReturnsNull(): void
+ {
+ $fs = $this->createMock(FilesystemOperator::class);
+
+ $this->assertNull($this->createTransformer($fs)->transform(null));
+ }
+
+ public function testTransformStringFileExistsReturnsFlysystemFileWithSize(): void
+ {
+ $fs = $this->createMock(FilesystemOperator::class);
+ $fs->method('fileExists')->with('photos/cat.jpg')->willReturn(true);
+ $fs->method('fileSize')->with('photos/cat.jpg')->willReturn(1234);
+
+ $result = $this->createTransformer($fs)->transform('photos/cat.jpg');
+
+ $this->assertInstanceOf(FlysystemFile::class, $result);
+ $this->assertSame('photos/cat.jpg', $result->getPathname());
+ $this->assertSame(1234, $result->getSize());
+ }
+
+ public function testTransformStringFileExistsFileSizeThrowsReturnsFlysystemFileWithNullSize(): void
+ {
+ $fs = $this->createMock(FilesystemOperator::class);
+ $fs->method('fileExists')->with('photos/cat.jpg')->willReturn(true);
+ $fs->method('fileSize')->willThrowException(new \RuntimeException('Cannot read size'));
+
+ $result = $this->createTransformer($fs)->transform('photos/cat.jpg');
+
+ $this->assertInstanceOf(FlysystemFile::class, $result);
+ $this->assertSame('photos/cat.jpg', $result->getPathname());
+ $this->assertNull($result->getSize());
+ }
+
+ public function testTransformStringFileDoesNotExistReturnsNull(): void
+ {
+ $fs = $this->createMock(FilesystemOperator::class);
+ $fs->method('fileExists')->with('photos/missing.jpg')->willReturn(false);
+
+ $this->assertNull($this->createTransformer($fs)->transform('photos/missing.jpg'));
+ }
+
+ public function testTransformStringFileExistsThrowsReturnsNull(): void
+ {
+ $fs = $this->createMock(FilesystemOperator::class);
+ $fs->method('fileExists')->willThrowException(new \RuntimeException('Connection error'));
+
+ $this->assertNull($this->createTransformer($fs)->transform('photos/cat.jpg'));
+ }
+
+ public function testTransformExistingFlysystemFileReturnedAsIs(): void
+ {
+ $fs = $this->createMock(FilesystemOperator::class);
+ $existing = new FlysystemFile('photos/cat.jpg', null, 999);
+
+ $result = $this->createTransformer($fs)->transform($existing);
+
+ $this->assertSame($existing, $result);
+ }
+
+ public function testTransformMultipleModeWithArrayOfStrings(): void
+ {
+ $fs = $this->createMock(FilesystemOperator::class);
+ $fs->method('fileExists')->willReturn(true);
+ $fs->method('fileSize')->willReturn(100);
+
+ $result = $this->createTransformer($fs, multiple: true)->transform(['a.jpg', 'b.jpg']);
+
+ $this->assertCount(2, $result);
+ $this->assertInstanceOf(FlysystemFile::class, $result[0]);
+ $this->assertSame('a.jpg', $result[0]->getPathname());
+ $this->assertInstanceOf(FlysystemFile::class, $result[1]);
+ $this->assertSame('b.jpg', $result[1]->getPathname());
+ }
+
+ // --- reverseTransform() tests ---
+
+ public function testReverseTransformNullReturnsEmptyString(): void
+ {
+ $fs = $this->createMock(FilesystemOperator::class);
+
+ $this->assertSame('', $this->createTransformer($fs)->reverseTransform(null));
+ }
+
+ public function testReverseTransformNullReturnsEmptyArrayWhenMultiple(): void
+ {
+ $fs = $this->createMock(FilesystemOperator::class);
+
+ $this->assertSame([], $this->createTransformer($fs, multiple: true)->reverseTransform(null));
+ }
+
+ public function testReverseTransformFlysystemFileReturnsPathname(): void
+ {
+ $fs = $this->createMock(FilesystemOperator::class);
+ $file = new FlysystemFile('photos/cat.jpg');
+
+ $result = $this->createTransformer($fs)->reverseTransform($file);
+
+ $this->assertSame('photos/cat.jpg', $result);
+ }
+
+ public function testReverseTransformValidUploadedFileCallsCallables(): void
+ {
+ $fs = $this->createMock(FilesystemOperator::class);
+
+ $tmpFile = tempnam(sys_get_temp_dir(), 'ea_test_');
+ file_put_contents($tmpFile, 'test content');
+
+ $uploaded = new UploadedFile($tmpFile, 'report.pdf', 'application/pdf', null, true);
+
+ $filenameCalled = false;
+ $validateCalled = false;
+
+ $transformer = $this->createTransformer(
+ $fs,
+ uploadFilename: static function (UploadedFile $f) use (&$filenameCalled): string {
+ $filenameCalled = true;
+
+ return 'custom_'.$f->getClientOriginalName();
+ },
+ uploadValidate: static function (string $filename) use (&$validateCalled): string {
+ $validateCalled = true;
+
+ return $filename;
+ },
+ );
+
+ $result = $transformer->reverseTransform($uploaded);
+
+ $this->assertTrue($filenameCalled);
+ $this->assertTrue($validateCalled);
+ $this->assertSame('custom_report.pdf', $result);
+
+ @unlink($tmpFile);
+ }
+
+ public function testReverseTransformInvalidUploadedFileThrows(): void
+ {
+ $fs = $this->createMock(FilesystemOperator::class);
+
+ $tmpFile = tempnam(sys_get_temp_dir(), 'ea_test_');
+ file_put_contents($tmpFile, 'test');
+
+ // error code 1 = UPLOAD_ERR_INI_SIZE → invalid
+ $uploaded = new UploadedFile($tmpFile, 'report.pdf', 'application/pdf', \UPLOAD_ERR_INI_SIZE, false);
+
+ $this->expectException(TransformationFailedException::class);
+ $this->createTransformer($fs)->reverseTransform($uploaded);
+
+ @unlink($tmpFile);
+ }
+}
diff --git a/tests/Unit/Form/Type/FileUploadTypeFlysystemTest.php b/tests/Unit/Form/Type/FileUploadTypeFlysystemTest.php
new file mode 100644
index 0000000000..ca640a3427
--- /dev/null
+++ b/tests/Unit/Form/Type/FileUploadTypeFlysystemTest.php
@@ -0,0 +1,121 @@
+projectDir = sys_get_temp_dir().'/ea_test_'.bin2hex(random_bytes(4));
+ mkdir($this->projectDir.'/public/uploads/files', 0777, true);
+ parent::setUp();
+ }
+
+ protected function tearDown(): void
+ {
+ parent::tearDown();
+
+ if (is_dir($this->projectDir)) {
+ (new Filesystem())->remove($this->projectDir);
+ }
+ }
+
+ protected function getExtensions(): array
+ {
+ $type = new FileUploadType($this->projectDir, new Filesystem());
+ $validator = Validation::createValidator();
+
+ return [
+ new PreloadedExtension([$type], []),
+ new ValidatorExtension($validator),
+ ];
+ }
+
+ public function testUploadDirNormalizerSkipsLocalCheckForFlysystem(): void
+ {
+ $fs = $this->createMock(FilesystemOperator::class);
+
+ // This should not throw even though the directory doesn't exist locally
+ $form = $this->factory->create(FileUploadType::class, null, [
+ 'upload_dir' => 'remote/uploads',
+ 'flysystem_storage' => $fs,
+ ]);
+
+ $this->assertSame('remote/uploads/', $form->getConfig()->getOption('upload_dir'));
+ }
+
+ public function testUploadDirNormalizerAddsTrailingSlashForFlysystem(): void
+ {
+ $fs = $this->createMock(FilesystemOperator::class);
+
+ $form = $this->factory->create(FileUploadType::class, null, [
+ 'upload_dir' => 'remote/uploads',
+ 'flysystem_storage' => $fs,
+ ]);
+
+ $this->assertStringEndsWith('/', $form->getConfig()->getOption('upload_dir'));
+ }
+
+ public function testKeepOrFailFlysystemFileDoesNotExistReturnsFilename(): void
+ {
+ $fs = $this->createMock(FilesystemOperator::class);
+ $fs->method('fileExists')->willReturn(false);
+
+ $form = $this->factory->create(FileUploadType::class, null, [
+ 'upload_dir' => 'remote/uploads',
+ 'flysystem_storage' => $fs,
+ 'replaced_file_behavior' => ReplacedFileBehavior::KEEP_OR_FAIL,
+ ]);
+
+ // The KEEP_OR_FAIL callable is wired into the StringToFileTransformer in buildForm.
+ // Test it via the model transformer: reverseTransform an UploadedFile → should return the filename.
+ $tmpFile = tempnam(sys_get_temp_dir(), 'ea_test_');
+ file_put_contents($tmpFile, 'test');
+ $uploaded = new UploadedFile($tmpFile, 'test.pdf', 'application/pdf', null, true);
+
+ $transformers = $form->getConfig()->getModelTransformers();
+ $result = $transformers[0]->reverseTransform($uploaded);
+
+ $this->assertSame('test.pdf', $result);
+
+ @unlink($tmpFile);
+ }
+
+ public function testKeepOrFailFlysystemFileExistsThrows(): void
+ {
+ $fs = $this->createMock(FilesystemOperator::class);
+ $fs->method('fileExists')->willReturn(true);
+
+ $form = $this->factory->create(FileUploadType::class, null, [
+ 'upload_dir' => 'remote/uploads',
+ 'flysystem_storage' => $fs,
+ 'replaced_file_behavior' => ReplacedFileBehavior::KEEP_OR_FAIL,
+ ]);
+
+ $tmpFile = tempnam(sys_get_temp_dir(), 'ea_test_');
+ file_put_contents($tmpFile, 'test');
+ $uploaded = new UploadedFile($tmpFile, 'test.pdf', 'application/pdf', null, true);
+
+ $transformers = $form->getConfig()->getModelTransformers();
+
+ $this->expectException(TransformationFailedException::class);
+ try {
+ $transformers[0]->reverseTransform($uploaded);
+ } finally {
+ @unlink($tmpFile);
+ }
+ }
+}
diff --git a/tests/Unit/Form/Type/Model/FlysystemFileTest.php b/tests/Unit/Form/Type/Model/FlysystemFileTest.php
new file mode 100644
index 0000000000..c24b288ae8
--- /dev/null
+++ b/tests/Unit/Form/Type/Model/FlysystemFileTest.php
@@ -0,0 +1,44 @@
+assertSame('photos/cat.jpg', $file->getPathname());
+ }
+
+ public function testGetFilenameExplicit(): void
+ {
+ $file = new FlysystemFile('photos/cat.jpg', 'my-cat.jpg');
+
+ $this->assertSame('my-cat.jpg', $file->getFilename());
+ }
+
+ public function testGetFilenameDerivedFromPath(): void
+ {
+ $file = new FlysystemFile('photos/cat.jpg');
+
+ $this->assertSame('cat.jpg', $file->getFilename());
+ }
+
+ public function testGetSize(): void
+ {
+ $file = new FlysystemFile('photos/cat.jpg', null, 1234);
+
+ $this->assertSame(1234, $file->getSize());
+ }
+
+ public function testGetSizeReturnsNullByDefault(): void
+ {
+ $file = new FlysystemFile('photos/cat.jpg');
+
+ $this->assertNull($file->getSize());
+ }
+}
diff --git a/tests/Unit/Twig/EasyAdminTwigExtensionTest.php b/tests/Unit/Twig/EasyAdminTwigExtensionTest.php
index 5b3fe37690..e90f7065fb 100644
--- a/tests/Unit/Twig/EasyAdminTwigExtensionTest.php
+++ b/tests/Unit/Twig/EasyAdminTwigExtensionTest.php
@@ -68,21 +68,21 @@ public function testFileSize(int $bytes, string $expected): void
public static function provideValuesForFileSize(): iterable
{
- yield [0, '0B'];
- yield [1, '1B'];
- yield [1023, '1023B'];
- yield [1024, '1K'];
- yield [999_900, '976K'];
- yield [1024 ** 2 - 100, '1023K'];
- yield [1024 ** 2, '1M'];
- yield [1024 ** 2 + 100, '1M'];
- yield [1024 ** 3 - 1, '1023M'];
- yield [1024 ** 3, '1G'];
- yield [1024 ** 3 + 1, '1G'];
- yield [1024 ** 4, '1T'];
- yield [1024 ** 5, '1P'];
- yield [1024 ** 6, '1E'];
- yield [\PHP_INT_MAX, '8E'];
+ yield [0, '0 B'];
+ yield [1, '1 B'];
+ yield [1023, '1023 B'];
+ yield [1024, '1 KB'];
+ yield [999_900, '976.5 KB'];
+ yield [1024 ** 2 - 100, '1023.9 KB'];
+ yield [1024 ** 2, '1 MB'];
+ yield [1024 ** 2 + 100, '1 MB'];
+ yield [1024 ** 3 - 1, '1024 MB'];
+ yield [1024 ** 3, '1 GB'];
+ yield [1024 ** 3 + 1, '1 GB'];
+ yield [1024 ** 4, '1 TB'];
+ yield [1024 ** 5, '1 PB'];
+ yield [1024 ** 6, '1 EB'];
+ yield [\PHP_INT_MAX, '8 EB'];
}
public static function provideValuesForRepresentAsString(): iterable
diff --git a/translations/EasyAdminBundle.en.php b/translations/EasyAdminBundle.en.php
index 2dee724542..01a5a0ac83 100644
--- a/translations/EasyAdminBundle.en.php
+++ b/translations/EasyAdminBundle.en.php
@@ -54,6 +54,7 @@
'remove_item' => 'Remove the item',
'choose_file' => 'Choose file',
'close' => 'Close',
+ 'download' => 'Download',
'create' => 'Create',
'create_and_add_another' => 'Create and add another',
'create_and_continue' => 'Create and continue editing',
@@ -147,6 +148,12 @@
'general_500' => 'An internal error occurred while processing your request.',
],
+ 'file_upload' => [
+ 'add_file' => 'Add file',
+ 'add_files' => 'Add files',
+ 'clear_all' => 'Clear all',
+ ],
+
'autocomplete' => [
'no-results-found' => 'No results found',
'no-more-results' => 'No more results',