EasyAdminBundle icon indicating copy to clipboard operation
EasyAdminBundle copied to clipboard

ImageField file name pattern with subfolders: admin interface loses the folders and edit ability

Open Boldewyn opened this issue 1 year ago • 1 comments

Describe the bug

The context is a simple media entity with a file field and an accompanying ImageField in its admin interface. Consider this entity:

<?php // src/Entity/Media.php

namespace App\Entity;

use Doctrine\ORM\Mapping as ORM;
use App\Repository\MediaRepository;

#[ORM\Entity(repositoryClass: MediaRepository::class)]
class Media
{

    const UPLOAD_PATH = 'uploads/media/landingpages';

    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    private ?int $id = null;

    #[ORM\Column(length: 1024)]
    private ?string $file = null;

    public function getId(): ?int
    {
        return $this->id;
    }

    public function getFile(): ?string
    {
        return $this->file;
    }

    public function setFile(string $file): self
    {
        $this->file = $file;

        return $this;
    }

}

and this controller:

<?php // src/Controller/Admin/MediaCrudController.php

namespace App\Controller\Admin;

use App\Entity\Media;
use Doctrine\ORM\EntityManagerInterface;
use EasyCorp\Bundle\EasyAdminBundle\Config\Crud;
use Symfony\Component\HttpKernel\KernelInterface;
use EasyCorp\Bundle\EasyAdminBundle\Config\Action;
use EasyCorp\Bundle\EasyAdminBundle\Field\IdField;
use EasyCorp\Bundle\EasyAdminBundle\Config\Actions;
use EasyCorp\Bundle\EasyAdminBundle\Field\ImageField;
use Symfony\Component\HttpFoundation\File\UploadedFile;
use EasyCorp\Bundle\EasyAdminBundle\Controller\AbstractCrudController;

class MediaCrudController extends AbstractCrudController
{

    private $projectDir;

    public function __construct(KernelInterface $kernel)
    {
        $this->projectDir = $kernel->getProjectDir();
    }

    public static function getEntityFqcn(): string
    {
        return Media::class;
    }

    public function configureCrud(Crud $crud): Crud
    {
        return $crud
            ->showEntityActionsInlined();
    }

    public function configureActions(Actions $actions): Actions
    {
        return $actions->add(Crud::PAGE_EDIT, Action::INDEX)->add(Crud::PAGE_NEW, Action::INDEX);
    }

    public function configureFields(string $pageName): iterable
    {
        yield IdField::new('id')->hideOnForm();

        yield ImageField::new('file')
            ->setLabel('Mediendatei')
            ->setBasePath(Media::UPLOAD_PATH)
            ->setUploadDir('public/'.Media::UPLOAD_PATH)
            ->setFormTypeOption('upload_new', function (UploadedFile $file, string $uploadDir, string $fileName) {
                if (($extraDirs = dirname($fileName)) !== '.') {
                    $uploadDir .= trim($extraDirs);
                    if (!file_exists($uploadDir)) {
                        mkdir($uploadDir, 0750, true);
                    }
                }
                return $file->move($uploadDir, $fileName);
            })
            ->setUploadedFileNamePattern('[year]/[month]/[day]/[slug]-[timestamp]-[randomhash].[extension]');
    }

}

and for a complete example this repo:

<?php // src/Repository/MediaRepository.php

namespace App\Repository;

use App\Entity\Media;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;

/**
 * @extends ServiceEntityRepository<Media>
 */
class MediaRepository extends ServiceEntityRepository
{
    public function __construct(ManagerRegistry $registry)
    {
        parent::__construct($registry, Media::class);
    }

    public function save(Media $entity, bool $flush = false): void
    {
        $this->getEntityManager()->persist($entity);

        if ($flush) {
            $this->getEntityManager()->flush();
        }
    }

    public function remove(Media $entity, bool $flush = false): void
    {
        $this->getEntityManager()->remove($entity);

        if ($flush) {
            $this->getEntityManager()->flush();
        }
    }

}

The code is more or less directly copied from the Symfony Cast at https://symfonycasts.com/screencast/easyadminbundle/upload .

Then create a Media instance and upload a file. This works like a charm and on the overview page the preview is shown with the correct path.

The problem arises when trying to edit this media object again. (Think editing a hypothetical “title” field or so.) The form features a file upload field that is marked as required. This means one is forced to re-upload the file. Removing the required attribute leads to an error on saving because of the NOT NULL constraint on the file column. Trying to work around the problem by introducing a hidden field on client side with JS falls short, because the field loses its sub-directory information when being rendered and shows only the basename:

screenshot of admin interface with file input field marked as required and only basename of file is shown

Note that the placeholder has no [year]/[month]/[day]/ prefix set.

Unfortunately, this makes setUploadedFileNamePattern() with subfolders unusable as far as we can tell.

It seems that this issue might be related to #4822 and #4004.

To Reproduce

$ symfony new fileissue
$ cd fileissue
$ composer require easycorp/easyadmin symfony/mime
$ symfony console make:admin:dashboard
$ # copy over the files from above
$ # start DB
$ symfony console doctrine:schema:create
$ symfony server:start

EasyAdmin v4.7.0 is used.

Boldewyn avatar Jul 06 '23 15:07 Boldewyn

for real no answer on this?

agaktr avatar Nov 21 '23 16:11 agaktr

Hey, we encountered the exact same problem, here's our workaround :

We used the getEntityChangeSet of UnitOfWork in the updateEntity function to know what values changed, and forced the previous value of our "driversLicenseUri" field (the equivalent of your "file" field) under certain conditions

// In the CrudController
private function startsWithDatePattern($string)
{
   // For a [year]/[month]/[day] subdirectory pattern
    $pattern = '/^\d{4}\/\d{2}\/\d{2}/';

    if (preg_match($pattern, $string)) {
        return true;
    }

    return false;
}

public function updateEntity(EntityManagerInterface $entityManager, $entityInstance): void
{
    $uok = $entityManager->getUnitOfWork();
    $uok->computeChangeSet(
        $entityManager->getClassMetadata(MembershipRequest::class),
        $entityInstance
    );

    $changeSet = $uok->getEntityChangeSet($entityInstance);

    parent::updateEntity($entityManager, $entityInstance);

    if (array_key_exists('driversLicenseUri', $changeSet)) {
        $before = $changeSet['driversLicenseUri'][0];
        $after = $changeSet['driversLicenseUri'][1];

        if (
            !is_null($before)
            && is_string($before)
            && $this->startsWithDatePattern($before)
            && !is_null($after)
            && is_string($after)
            && !$this->startsWithDatePattern($after)) {
            $entityInstance->setDriversLicenseUri($before);
        } elseif (is_null($after)) {
            $entityInstance->setDriversLicenseUri(null);
        }
    }

    parent::updateEntity($entityManager, $entityInstance);
}

It seems crazy to me that this pretty big bug still remains today ... Hope this helps

hugo-fasone avatar Mar 01 '24 13:03 hugo-fasone