EasyAdminBundle
EasyAdminBundle copied to clipboard
AWS S3 image is not showing on edit
I have an entity that stores images in s3. This is how I modified my field:
ImageField::new('mainImage', 'Slika Poduzeća')
->setBasePath($this->getBaseUrlForS3())
->setUploadDir('') // Dummy - unused we use s3
->setRequired(false)
->setHelp('Glavna slika poduzeća/logo (opcionalno)')
->setUploadedFileNamePattern('[randomhash].[extension]') // Customize the pattern
->setFormTypeOption('upload_new', function (UploadedFile $file, string $uploadDir, string $fileName) {
$filePath = 'pages/' . $fileName;
$this->s3Service->uploadFile($file, $filePath);
return $filePath;
})
->setFormTypeOption('upload_delete', function (string $filePath) {
// Delete the file from S3 via S3Service
$this->s3Service->deleteFile($filePath);
})
->setFormTypeOption('mapped', true)
->setFormTypeOption('data_class', null) // Ensure that the field gets populated with the existing value
Saving works nicely, image is shown on both detail and list page, but when I click on edit, the image is not shown in the image field + if I just save then, it gets deleted like I removed it. Anyone able to tell me why and how to fix this?
Same problem for me 🤔
Any updates?
You can create your own FileField, I will post example
// FileField.php
<?php
declare(strict_types=1);
namespace App\UI\Admin\Field;
use App\UI\Admin\Form\Type\FileUploadType;
use EasyCorp\Bundle\EasyAdminBundle\Config\Asset;
use EasyCorp\Bundle\EasyAdminBundle\Config\Option\TextAlign;
use EasyCorp\Bundle\EasyAdminBundle\Contracts\Field\FieldInterface;
use EasyCorp\Bundle\EasyAdminBundle\Field\FieldTrait;
use Symfony\Contracts\Translation\TranslatableInterface;
class FileField implements FieldInterface
{
use FieldTrait;
public const OPTION_UPLOAD_DIR = 'uploadDir';
public const OPTION_UPLOADED_FILE_NAME_PATTERN = 'uploadedFileNamePattern';
/**
* @param TranslatableInterface|string|false|null $label
*/
public static function new(string $propertyName, $label = null): self
{
return (new self())
->setProperty($propertyName)
->setLabel($label)
->setTemplateName('crud/field/image')
->setFormType(FileUploadType::class)
->addCssClass('field-image')
->addJsFiles(Asset::fromEasyAdminAssetPackage('field-image.js'), Asset::fromEasyAdminAssetPackage('field-file-upload.js'))
->setDefaultColumns('col-md-7 col-xxl-5')
->setTextAlign(TextAlign::CENTER)
->setCustomOption(self::OPTION_UPLOAD_DIR, null)
->setCustomOption(self::OPTION_UPLOADED_FILE_NAME_PATTERN, '[uuid].[slug].[extension]');
}
/**
* Relative to project's root directory (e.g. use 'public/uploads/' for `<your-project-dir>/public/uploads/`)
* Default upload dir: `<your-project-dir>/public/uploads/images/`.
*/
public function setUploadDir(string $uploadDirPath): self
{
$this->setCustomOption(self::OPTION_UPLOAD_DIR, $uploadDirPath);
return $this;
}
/**
* @param string|\Closure $patternOrCallable
*
* If it's a string, uploaded files will be renamed according to the given pattern.
* The pattern can include the following special values:
* [day] [month] [year] [timestamp]
* [name] [slug] [extension] [contenthash]
* [randomhash] [uuid] [ulid]
* (e.g. [year]/[month]/[day]/[slug]-[contenthash].[extension])
*
* If it's a callable, you will be passed the Symfony's UploadedFile instance and you must
* return a string with the new filename.
* (e.g. fn(UploadedFile $file) => sprintf('upload_%d_%s.%s', random_int(1, 999), $file->getFilename(), $file->guessExtension()))
*/
public function setUploadedFileNamePattern($patternOrCallable): self
{
$this->setCustomOption(self::OPTION_UPLOADED_FILE_NAME_PATTERN, $patternOrCallable);
return $this;
}
}
// FileConfigurator.php
<?php
namespace App\UI\Admin\Field\Configurator;
use App\UI\Admin\Field\FileField;
use EasyCorp\Bundle\EasyAdminBundle\Context\AdminContext;
use EasyCorp\Bundle\EasyAdminBundle\Contracts\Field\FieldConfiguratorInterface;
use EasyCorp\Bundle\EasyAdminBundle\Dto\EntityDto;
use EasyCorp\Bundle\EasyAdminBundle\Dto\FieldDto;
final class FileConfigurator 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
{
$field->setFormTypeOption('upload_filename', $field->getCustomOption(FileField::OPTION_UPLOADED_FILE_NAME_PATTERN));
$field->setFormTypeOption('upload_dir', $field->getCustomOption(FileField::OPTION_UPLOAD_DIR));
}
}
// FileUploadType.php
<?php
declare(strict_types=1);
namespace App\UI\Admin\Form\Type;
use App\Infrastructure\File\AwsS3FileUploader;
use App\UI\Admin\Form\DataTransformer\StringToFileTransformer;
use EasyCorp\Bundle\EasyAdminBundle\Form\Type\Model\FileUploadState;
use Psr\Log\LoggerInterface;
use Ramsey\Uuid\Uuid;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\DataMapperInterface;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\Extension\Core\Type\FileType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\Form\FormView;
use Symfony\Component\HttpFoundation\File\File;
use Symfony\Component\HttpFoundation\File\UploadedFile;
use Symfony\Component\OptionsResolver\Exception\InvalidArgumentException;
use Symfony\Component\OptionsResolver\Options;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\String\Slugger\AsciiSlugger;
use Symfony\Component\Uid\Ulid;
/**
* @template T of FileUploadState
*
* @extends AbstractType<T>
*/
class FileUploadType extends AbstractType implements DataMapperInterface
{
public function __construct(
private readonly LoggerInterface $logger,
private readonly AwsS3FileUploader $uploader,
) {
}
public function buildForm(
FormBuilderInterface $builder,
array $options,
): void {
$uploadDir = $options['upload_dir'];
$uploadFilename = $options['upload_filename'];
$uploadValidate = $options['upload_validate'];
$allowAdd = $options['allow_add'];
unset(
$options['upload_dir'],
$options['upload_new'],
$options['upload_delete'],
$options['upload_filename'],
$options['upload_validate'],
$options['download_path'],
$options['allow_add'],
$options['allow_delete'],
$options['compound'],
);
$builder->add('file', FileType::class, $options);
$builder->add('delete', CheckboxType::class, ['required' => false]);
$builder->setDataMapper($this);
$builder->setAttribute('state', new FileUploadState($allowAdd));
$builder->addModelTransformer(
new StringToFileTransformer(
$this->logger,
$this->uploader,
$uploadDir,
$uploadFilename,
$uploadValidate,
$options['multiple'],
),
);
}
public function buildView(
FormView $view,
FormInterface $form,
array $options,
): void {
/** @var FileUploadState $state */
$state = $form->getConfig()->getAttribute('state');
if ([] === ($currentFiles = $state->getCurrentFiles())) {
$data = $form->getNormData();
if (null !== $data && [] !== $data) {
$currentFiles = \is_array($data) ? $data : [$data];
foreach ($currentFiles as $i => $file) {
if ($file instanceof UploadedFile) {
unset($currentFiles[$i]);
}
}
}
}
$view->vars['currentFiles'] = $currentFiles;
$view->vars['multiple'] = $options['multiple'];
$view->vars['allow_add'] = $options['allow_add'];
$view->vars['allow_delete'] = $options['allow_delete'];
$view->vars['download_path'] = $options['download_path'];
$view->vars['required'] = $options['required'];
}
public function configureOptions(OptionsResolver $resolver): void
{
$uploader = $this->uploader;
$uploadNew = static function (
UploadedFile $file,
string $uploadDir,
string $fileName,
) use ($uploader): void {
$uploader->upload($file, $uploadDir . $fileName);
};
$uploadDelete = static function (File $file, string $uploadDir) use (
$uploader,
): void {
$uploader->remove($uploadDir . $file->getFilename());
};
$uploadFilename = static function (UploadedFile $file): string {
return $file->getClientOriginalName();
};
$uploadValidate = static function (
string $filename,
string $uploadDir,
) use ($uploader): string {
if (!$uploader->exists($uploadDir . $filename)) {
return $filename;
}
$index = 1;
/** @var array{dirname: string, extension: string, filename: string} $pathInfo */
$pathInfo = pathinfo($filename);
while (
$uploader->exists(
$filename = sprintf(
'%s%s/%s_%d.%s',
$uploadDir,
$pathInfo['dirname'],
$pathInfo['filename'],
$index,
$pathInfo['extension'],
),
)
) {
$index++;
}
return $filename;
};
$downloadPath = fn (Options $options) => $uploader->getPath(
$options['upload_dir'],
);
$allowAdd = static fn (Options $options) => $options['multiple'];
$dataClass = static fn (Options $options) => $options['multiple']
? null
: File::class;
$emptyData = static fn (Options $options) => $options['multiple']
? []
: null;
$resolver->setDefaults([
'upload_dir' => '/upload/',
'upload_new' => $uploadNew,
'upload_delete' => $uploadDelete,
'upload_filename' => $uploadFilename,
'upload_validate' => $uploadValidate,
'download_path' => $downloadPath,
'allow_add' => $allowAdd,
'allow_delete' => true,
'data_class' => $dataClass,
'empty_data' => $emptyData,
'multiple' => false,
'required' => false,
'error_bubbling' => false,
'allow_file_upload' => true,
]);
$resolver->setAllowedTypes('upload_dir', 'string');
$resolver->setAllowedTypes('upload_new', 'callable');
$resolver->setAllowedTypes('upload_delete', 'callable');
$resolver->setAllowedTypes('upload_filename', ['string', 'callable']);
$resolver->setAllowedTypes('upload_validate', 'callable');
$resolver->setAllowedTypes('download_path', ['null', 'string']);
$resolver->setAllowedTypes('allow_add', 'bool');
$resolver->setAllowedTypes('allow_delete', 'bool');
$resolver->setAllowedTypes('required', 'bool');
$resolver->setNormalizer('upload_filename', static function (
Options $options,
$fileNamePatternOrCallable,
) {
if (\is_callable($fileNamePatternOrCallable)) {
return $fileNamePatternOrCallable;
}
return static function (UploadedFile $file) use (
$fileNamePatternOrCallable,
) {
return strtr($fileNamePatternOrCallable, [
'[contenthash]' => sha1_file($file->getRealPath()),
'[day]' => date('d'),
'[extension]' => $file->guessClientExtension(),
'[month]' => date('m'),
'[name]' => pathinfo(
$file->getClientOriginalName(),
\PATHINFO_FILENAME,
),
'[randomhash]' => bin2hex(random_bytes(20)),
'[slug]' => new AsciiSlugger()
->slug(
pathinfo(
$file->getClientOriginalName(),
\PATHINFO_FILENAME,
),
)
->lower()
->toString(),
'[timestamp]' => time(),
'[uuid]' => Uuid::uuid4(),
'[ulid]' => new Ulid(),
'[year]' => date('Y'),
]);
};
});
$resolver->setNormalizer('allow_add', static function (
Options $options,
string $value,
): bool {
if ((bool) $value && !$options['multiple']) {
throw new InvalidArgumentException('Setting "allow_add" option to "true" when "multiple" option is "false" is not supported.');
}
return (bool) $value;
});
}
public function getBlockPrefix(): string
{
return 'ea_fileupload';
}
/**
* @param \Traversable<mixed, FormInterface<FileUploadState>> $forms
*/
public function mapDataToForms(mixed $currentFiles, $forms): void
{
/** @var FormInterface<FileUploadState> $fileForm */
$fileForm = current(iterator_to_array($forms));
$fileForm->setData($currentFiles);
}
/**
* @param \Traversable<mixed, FormInterface<FileUploadState>> $forms
*/
public function mapFormsToData($forms, mixed &$currentFiles): void
{
/** @var FormInterface<FileUploadState>[] $children */
$children = iterator_to_array($forms);
/** @var UploadedFile[] $uploadedFiles */
$uploadedFiles = $children['file']->getData();
/** @var FormInterface<FileUploadState> $form */
$form = $children['file']->getParent();
/** @var FileUploadState $state */
$state = $form->getConfig()->getAttribute('state');
$state->setCurrentFiles($currentFiles);
$state->setUploadedFiles($uploadedFiles);
/** @var bool $delete */
$delete = $children['delete']->getData();
$state->setDelete($delete);
if (!$state->isModified()) {
return;
}
if ($state->isAddAllowed() && !$state->isDelete()) {
$currentFiles = array_merge($currentFiles, $uploadedFiles);
} else {
$currentFiles = $uploadedFiles;
}
}
}
// StringToFileTransformer.php
<?php
declare(strict_types=1);
namespace App\UI\Admin\Form\DataTransformer;
use App\Infrastructure\File\AwsS3FileUploader;
use Psr\Log\LoggerInterface;
use Symfony\Component\Form\DataTransformerInterface;
use Symfony\Component\Form\Exception\TransformationFailedException;
use Symfony\Component\HttpFoundation\File\File;
use Symfony\Component\HttpFoundation\File\UploadedFile;
/**
* @implements DataTransformerInterface<mixed, mixed>
*/
class StringToFileTransformer implements DataTransformerInterface
{
public function __construct(
private readonly LoggerInterface $logger,
private readonly AwsS3FileUploader $uploader,
private readonly string $uploadDir,
private readonly mixed $uploadFilename,
private readonly mixed $uploadValidate,
private readonly bool $multiple,
) {
}
public function transform(mixed $value): mixed
{
if (null === $value || [] === $value) {
return null;
}
if (!$this->multiple) {
return $this->doTransform($value);
}
if (!\is_array($value)) {
throw new TransformationFailedException('Expected an array or null.');
}
return array_map([$this, 'doTransform'], $value);
}
public function reverseTransform(mixed $value): mixed
{
if (null === $value || [] === $value) {
return null;
}
if (!$this->multiple) {
return $this->doReverseTransform($value);
}
if (!\is_array($value)) {
throw new TransformationFailedException('Expected an array or null.');
}
return array_map([$this, 'doReverseTransform'], $value);
}
private function doTransform(mixed $value): ?File
{
if (null === $value) {
return null;
}
if ($value instanceof File) {
return $value;
}
if (!\is_string($value)) {
throw new TransformationFailedException('Expected a string or null.');
}
$value = mb_substr($value, mb_strpos($value, $this->uploadDir) + mb_strlen($this->uploadDir), mb_strlen($value));
if (!is_dir(dirname("/tmp/$value"))) {
mkdir(dirname("/tmp/$value"), 0755, true);
}
$file = fopen("/tmp/$value", 'w');
if (false === $file) {
$this->logger->error('Unable to open file ' . $value);
throw new \Exception('Unable to open file ' . $value);
}
fwrite($file, 'placeholder');
fclose($file);
return new File("/tmp/$value");
// This code below loads images from S3, and this may be unnecessary, since we can just return a placeholder, not the binary of the file, which is not required for the file upload field or easy admin to work.
// This was done due to optimisation, since images are loaded synchronously, and not started to be loaded at the same time simutaneously, this can result in ~2s of load time for 5/10 files, each file downloads ~50ms-150ms.
// $remotePath = $this->uploadDir . $value;
// try {
// $file = fopen("/tmp/$value", 'w');
// if (false === $file) {
// throw new \Exception('Unable to open file ' . $value);
// }
// $resource = $this->uploader->download($remotePath);
// while (!feof($resource) && false !== ($buffer = fread($resource, 8192))) {
// fwrite($file, $buffer);
// }
// fclose($resource);
// fclose($file);
// return new File("/tmp/$value");
// } catch (\Exception $exception) {
// $this->logger->error('Unable to retrieve file ' . $remotePath . ' with exception [' . $exception->getMessage() . ']');
// }
// return null;
}
private function doReverseTransform(mixed $value): ?string
{
if (null === $value) {
return null;
}
if ($value instanceof UploadedFile) {
if (!$value->isValid()) {
throw new TransformationFailedException($value->getErrorMessage());
}
$filename = ($this->uploadFilename)($value);
return $this->uploader->getPath($this->uploadDir . ($this->uploadValidate)($filename, $this->uploadDir));
}
if ($value instanceof File) {
return $this->uploader->getPath($this->uploadDir . $value->getFilename());
}
throw new TransformationFailedException('Expected an instance of File or null.');
}
}
You need also to create abstract controller
<?php
declare(strict_types=1);
namespace App\UI\Admin\Controller;
use EasyCorp\Bundle\EasyAdminBundle\Config\Crud;
use EasyCorp\Bundle\EasyAdminBundle\Form\Type\Model\FileUploadState;
use Symfony\Component\Form\FormInterface;
use function Symfony\Component\String\u;
/**
* @template T of object
*
* @extends \EasyCorp\Bundle\EasyAdminBundle\Controller\AbstractCrudController<T>
*/
abstract class AbstractCrudController extends \EasyCorp\Bundle\EasyAdminBundle\Controller\AbstractCrudController
{
/**
* @param FormInterface<FileUploadState> $form
*/
public function processUploadedFiles(FormInterface $form): void
{
foreach ($form as $child) {
$config = $child->getConfig();
$type = $config->getType()->getInnerType();
$c = get_class($type);
if (
!(
$type instanceof \EasyCorp\Bundle\EasyAdminBundle\Form\Type\FileUploadType
|| $type instanceof \App\UI\Admin\Form\Type\FileUploadType
)
) {
if ($config->getCompound()) {
$this->processUploadedFiles($child);
}
continue;
}
/** @var FileUploadState $state */
$state = $config->getAttribute('state');
if (!$state->isModified()) {
continue;
}
$uploadDelete = $config->getOption('upload_delete');
$uploadDir = $config->getOption('upload_dir');
if (
$state->hasCurrentFiles()
&& ($state->isDelete()
|| (!$state->isAddAllowed() && $state->hasUploadedFiles()))
) {
foreach ($state->getCurrentFiles() as $file) {
$uploadDelete($file, $uploadDir);
}
$state->setCurrentFiles([]);
}
$filePaths = (array) $child->getData();
$uploadNew = $config->getOption('upload_new');
foreach ($state->getUploadedFiles() as $index => $file) {
$fileName = u($filePaths[$index]);
$fileName = $fileName
->slice(
$fileName->indexOf($uploadDir) + mb_strlen($uploadDir),
$fileName->length()
)
->toString();
$uploadNew($file, $uploadDir, $fileName);
}
}
}
}
Usage:
FileField::new("issueDescription.logoPath")->setUploadDir(
"public/files/issue/issue-description/logo/",
),