Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

Form Panels

Keep on Learning!

If you liked what you've learned so far, dive in!
Subscribe to get access to this tutorial plus
video, code and script downloads.

Start your All-Access Pass
Buy just this tutorial for $12.00

With a Subscription, click any sentence in the script to jump to that part of the video!

Login Subscribe

Last topic! We made it! And our admin is getting super customized. For this final trick, I want to look closer at the form. Almost all of this is controlled by the Field configuration. Each field corresponds to a Symfony form type... and then EasyAdmin renders those fields through the form system. It really is that simple.

Custom Form Theme

EasyAdmin comes with a custom form theme. So if you wanted to, for example, make a text type field look different in EasyAdmin, you could create a custom form theme template. This theme can be added to the $crud object in configureCrud(). Down here, for example, we could say ->addFormTheme() to add our form theme template to just one CRUD controller... or you could put this in the dashboard to apply everywhere.

Form Panel

But, apart from a custom form theme, there are a few other ways that EasyAdmin allows us to control what this page looks like... which, right now, is just a long list of fields.

Over in QuestionCrudController, up in configureFields()... here we go... right before the askedBy field, add yield FormField::. So we're starting like normal, but instead of saying new, say addPanel('Details').

... lines 1 - 28
class QuestionCrudController extends AbstractCrudController
{
... lines 31 - 112
public function configureFields(string $pageName): iterable
{
... lines 115 - 140
yield FormField::addPanel('Details');
... lines 142 - 161
}
... lines 163 - 225
}

Watch what this does! Refresh and... cool! "Asked By" and "Answers" appear under this "Details" header. That's because, as you can see, askedBy and answers are the two fields that appear after the addPanel() call. And because the rest of these fields are not under a panel, they just... kind of appear at the bottom, which works, but doesn't look the greatest.

So, when I use addPanel(), I put everything under a panel. Right after IdField, which isn't going to appear on the form, say FormField::addPanel('Basic Data'). Oh! And let me make sure I don't forget to yield that.

... lines 1 - 112
public function configureFields(string $pageName): iterable
{
... lines 115 - 116
yield FormField::addPanel('Basic Data');
... lines 118 - 162
}
... lines 164 - 228

Thanks to this... awesome! We have a "Basic Data" panel, all of the fields below that, then the second panel down here.

Customizing the Panels

These panels have a few methods on them. One of the most useful is ->collapsible(). Make this panel collapsible... and the other as well.

... lines 1 - 112
public function configureFields(string $pageName): iterable
{
... lines 115 - 116
yield FormField::addPanel('Basic Data')
->collapsible();
... lines 119 - 142
yield FormField::addPanel('Details')
->collapsible();
... lines 145 - 164
}
... lines 166 - 230

I bet you can guess what this does. Yep! We get a nice way to collapse each section.

What else can we tweak? How about ->setIcon('fa fa-info')... or ->setHelp('Additional Details)?

Oh, I actually meant to put this down on the other panel, so let me grab this... find that other panel... here we go... and paste.

... lines 1 - 112
public function configureFields(string $pageName): iterable
{
... lines 115 - 142
yield FormField::addPanel('Details')
->collapsible()
->setIcon('fa fa-info')
->setHelp('Additional Details');
... lines 147 - 166
}
... lines 168 - 232

Let's check it out! Nice! The second panel has an icon and some sub-text.

By the way, the changes we're making not only affect the form page, but also the Detail page. Go check out the Detail page for one of these. Yup! The same organization is happening here, which is nice.

Form Tabs

If you want to organize things even a bit more, instead of panels, you can use tabs. Change addPanel() to addTab(). And... repeat that below: addTab().

... lines 1 - 112
public function configureFields(string $pageName): iterable
{
... lines 115 - 116
yield FormField::addTab('Basic Data')
... lines 118 - 142
yield FormField::addTab('Details')
... lines 144 - 166
}
... lines 168 - 232

When we refresh now... yup! Each shows up as a separate tab. But the ->collapsible() doesn't really make sense anymore. It is still being called, but it doesn't do anything. So, remove that.

Fixing the Icon on the Tab

Oh, and we also lost our icon! We added an fa fa-info icon... but it's not showing! Or is it? If you look closely, there's some extra space. Inspect element on that. There is an icon! But... it looks... weird. It has an extra fa-fa for some reason.

We can fix this by changing the icon to, simply, info. This is... sort of a bug. Or, it's at least inconsistent. When we use tabs, EasyAdmin adds the fa- for us. So all we need is info. Watch: when I refresh... there! fa-info... and now the icon shows up!

... lines 1 - 112
public function configureFields(string $pageName): iterable
{
... lines 115 - 142
yield FormField::addTab('Details')
->setIcon('info')
... lines 145 - 165
}
... lines 167 - 231

Form Columns

The last thing we can do, instead of having this long list of fields, is to put the fields next to each other. We do this by controlling the columns on this page.

To show this off, move the name field above slug. Yup, got it. And now let's see if we can put these fields next to each other. We're using bootstrap, which means there are 12 invisible columns on each page. So, on name, say ->setColumns(5)... and on slug, do the same thing: ->setColumns(5).

... lines 1 - 112
public function configureFields(string $pageName): iterable
{
... lines 115 - 119
yield Field::new('name')
... line 121
->setColumns(5);
yield Field::new('slug')
... lines 124 - 128
->setColumns(5);
... lines 130 - 167
}
... lines 169 - 233

We could use 6 to take up all of the space, but I'll stick with 5 and give it some room. Refresh now and... very nice! The fields float next to each other. This is a great way to help this page... make a bit more sense.

And... that's it, friends! We are done! This was fun! We should do it again sometime. I love EasyAdmin, and we here at SymfonyCasts are super proud of the admin section we built with it... which includes a lot of custom stuff. Let us know what you're building! And as always, we're here for you down in the Comments section with any questions, ideas, or delicious recipes that you might have.

All right friends, see you next time!

Leave a comment!

29
Login or Register to join the conversation

GG

EasyAdmin has come a long way (glad to see the yaml config gone)

2 Reply

Hey Edin,

Yeah, agree, now it's much flexible with PHP config. And thanks to IDE we have autocomplete now :)

Cheers!

Reply

Finally! the last tutorial of EasyAdmin Course! Many thanks for your great efforts!

2 Reply

Hey Lubna,

Yes, you made it! Congratulations! 🎉 I really happy to hear you liked the course ;)

Cheers!

Reply
Eric Avatar

I'd like to organize my edit/show-Page in three 'logical' columns. Just like tabs but without the need to click to switch between them. setColumns() is available on addPanel() and addTab() but unfortunately does nothing. If I just added setColumns() to all of my fields, I'd have to reorder them in the code to get them to appear in logical colums. That's somewhat inconsistent IMHO.
Is there an easy way to achieve what I'm trying to do or do I have to create a custom twig template for the page? And if so, how would I do that?

1 Reply

Hey @Eric!

Yea... there ARE some features like this that will work on one page, but not on another. For example, I think panels are something that only works on the "form" pages. Also, the "detail" page "edit" pages are rendered with quite a different mechanism.

Let's look at some details - it might help :).

A) For the edit page, the entire form is actually just rendered with form(form): https://github.com/EasyCorp/EasyAdminBundle/blob/022358a1b0c4e59fb3cae7d743e8a5c9e3195722/src/Resources/views/crud/edit.html.twig#L61

How does that translate into a system that can render tabs and panels? It's entirely done with EA's custom form theme. You can see a BUNCH of panel and tab logic inside of it: https://github.com/EasyCorp/EasyAdminBundle/blob/4.x/src/Resources/views/crud/form_theme.html.twig#L405-L524

B) For the show/detail page, it's a bit more straightforward: it's rendered in a normal template, and has support for tabs: https://github.com/EasyCorp/EasyAdminBundle/blob/4.x/src/Resources/views/crud/detail.html.twig#L56-L60

Within the tabs, it actually DOES also support panels: https://github.com/EasyCorp/EasyAdminBundle/blob/4.x/src/Resources/views/crud/detail.html.twig#L101-L127

However, I can't remember exactly how this all works, how you activate it exactly, and what it can and can't do. BUT, my hope is that seeing these core templates might help you debug things: it DOES seem like it's possible to use panels and tabs on the detail page.

Let me know what you find out or if I can help dig a bit deeper.

Cheers!

Reply
EricSod Avatar

Everything worked in this course. Thank you all so much. It was a fantastic learning experience.

Simply for the record, on this last chapter, if I use addTab() instead of addPanel(), the question edit pages errors with

The "edit" page of "App\Controller\Admin\QuestionCrudController" uses tabs to display its fields, but the following fields don't belong to any tab: . Use "FormField::addTab('...')" to add a tab before those fields.

I even swapped in the finish code from the course download code. /Downloads/code-easyadminbundle/finish/src/Controller/Admin/QuestionCrudController.php

Any ideas on what the problem is? Thanks again.

1 Reply

Hey EricSod!

Everything worked in this course. Thank you all so much. It was a fantastic learning experience.

Yay! Thanks for the kind words!

Simply for the record, on this last chapter, if I use addTab() instead of addPanel(), the question edit pages errors with

Hmm. That smells "careless" on my part. Yes, I see it too. The fix is simple: move yield FormField::addTab('Basic Data') to the TOP of configureFields(), so above yield IdField::new('id').

That makes more sense conceptually: you need to always start with a "tab" so that every field is in a tab. But, 2 notes about this:

1) Even though moving addTab() to the top looks better, strictly speaking, it shouldn't be necessary since the IdField is NOT shown on the edit page. So, though it's minor, I'd consider that a small bug in EasyAdmin.

2) The error is terrible :). This is, again, I think a bit of a bug in EasyAdmin. You can see this error for 2 possible reasons: (A) you forgot to put a few fields inside of a tab or (B) your first field is not a tab. The error message is written for situation (A), but not for situation (B), hence the unhelpful error.

Anyways, thanks for mentioning this - it was an oversight on my part originally!

Cheers!

1 Reply
EricSod Avatar

Making yield FormField::addTab('Basic Data'); the first line in configureFields() method was all it took to fix it. Yes, it doesn't read well that IdField is now below FormField::addTab(), when IdField doesn't print. Anyway, thanks for the follow up.

2 Reply
CDesign Avatar

Just an FYI.. I ran into the 'fields don't belong to any tab' error as well, and moving the Basic Data tab above Id fixes it. But that change needs to be made to the 'finish' version of the course code.

And Thx for a fantastic tutorial! This is one of your best!!

Reply
Nuno F. Avatar
Nuno F. Avatar Nuno F. | posted 1 year ago

Hello and Thank you for that tutorial... Very useful to take advantage of it.

Let me ask you something you didn't talk about.

Let me assume that we have a Crud for Courses and Some Participants (based on a OneToMany Relation).
I want to build a fully operational CourseParticipants Crud that is called by an Action 'participants' on Courses Crud (With index, show , edit, create and delete features)
but maintaining the parent course id on every pages inside it.?

So I don't want to open index of all participants but only those belonging to that course. And when I create a new one, i want to set internaly the parent course.

How do you think it is the best way to solve that?

Cheers!!!!!!

1 Reply

Hey Nuno F.!

That's a really interesting situation! Hmm. The use-case makes sense... and fun! Here's what I'm thinking:

A) So we will have CourseParticipantCrudController... and it will be a normal controller except that we want to read a "parent course" so that we can do things like (i) only show participants for that course and (ii) auto-set the course when a new CourseParticipant is created. To do this, when the user arrives at the CRUD, there will need to be an extra ?course=# in the URL where # is the id of the course. When you link to the CourseParticipantCrudController, you should be able to add this when using the AdminUrlGenerator:


$targetUrl = $adminUrlGenerator
    ->setController(CourseParticipantCrudController::class)
    ->setAction(Crud::PAGE_INDEX)
    // this is the key: it should add the ?course=# to the URL
    ->set('course', $course->getId())
    ->generateUrl();

B) Ok, now we have (hopefully) arrived at the INDEX action for CourseParticipantCrudController. Our first job is to read that and filter the CourseParticipant. You should be able to do that by overriding createIndexQueryBuilder() in your CRUD controller. Autowire the RequestStack into a __construct() method of your CourseParticipantCrudController, then use that to read the ?course=# from the URL and modify the query. Yay! Our list should now filter by the correct Course

C) But what happens when the user clicks a link inside of CourseParticipantCrudController - like the "New" link? We need that URL to keep the ?course=# on it. And... I think that this will happen automatically. The AdminUrlGenerator reads all of the current query parameters and keeps them when generating URLs. If I'm wrong about this, let me know. There is a solution, but it's more complex.

D) We have now arrived on the "new" action and we STILL have the ?course=# on the URL. We re doing GREAT. To automatically set the course on the CourseParticipant when it saves, I would override persistEntity() in your controller. Once again, use the RequestStack you autowired in (B) to read the ?course=# from the URL, query for that Course, then set it onto your CourseParticipant. Call parent::persistEntity($entityManager, $entityInstance) so that it can finish saving.

There is a decent chance that I forgot a detail - but let me know! This is a very cool use-case for EasyAdmin - I'd love to know if it works.

Cheers!

Reply
Nuno F. Avatar
Nuno F. Avatar Nuno F. | weaverryan | posted 1 year ago | edited

Hello Ryan,

And thanks for your fast answer
I have chosen quite the same process.
but i were right about C) the url looses all the parameters. I could get it from 'referrer' param but I prefer update the actions :


    public function configureActions(Actions $actions): Actions
    {
        $menuIdx = $this->requestStack->getCurrentRequest()->get('menuIdx');
        return $actions
            ->update(Crud::PAGE_INDEX, 'edit', function(Action $e) use ($menuIdx) {
               return $e->linkToUrl(
                   $this->adminUrlGenerator->setAction('edit')
                           ->setController(self::class)
                           ->set('menuIdx', $menuIdx)->generateUrl()
               );
            })
            ->update(Crud::PAGE_INDEX, 'new', function(Action $e) use ($menuIdx) {
               return $e->linkToUrl(
                   $this->adminUrlGenerator->setAction('new')
                           ->setController(self::class)
                           ->set('menuIdx', $menuIdx)->generateUrl()
               );
            });
    }

I have tested on Create and Update everything works nice.

By the way, I have also made some changes on the configCrud to have a nice custom title made from the current course.

Nothing can stop us now.
Again thanks a lot for your help and keep going.

Nuno

1 Reply

Hey Nuno!

Thanks for letting me know that the parameters were in fact NOT sticky. And what a great solution you found to re-add it - thanks for sharing that!

> Nothing can stop us now.

That's right 😎

Cheers!

Reply

Hello everyone and thank you to the SymfonyCast team for this series published to learn how to use the EasyAdmin Bundle as a pro.
I'm a French speaker and I used deepl to translate this text.
My problem isn't directly related to the series, but to an application I'm developing. The relevant parts of my code are shown below. In my TerminologyCrudController, I have two AssociationFields (englishTerm and frenchTerm) which I have processed in accordance with the documentation. Both the "englishTerm" and "frenchTerm" associations use the "renderAsEmbeddedForm" function to refer to the "EnglishCrudController", which in turn contains two "CollectionFields". When I run this code, everything works normally, except for the button linked to the "CollectionField". Nothing happens when I click on either of them.
Is there something I've missed or that I'm not doing correctly? Thanks for your help.

// Entity Terminology

<?php

namespace App\Entity;

use App\Repository\TerminologyRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;

#[ORM\Entity(repositoryClass: TerminologyRepository::class)]
#[ORM\HasLifecycleCallbacks]
class Terminology
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    private ?int $id = null;

    #[ORM\Column]
    private ?\DateTimeImmutable $createdAt = null;

    #[ORM\Column(nullable: true)]
    private ?\DateTimeImmutable $updatedAt = null;

    #[ORM\OneToOne(mappedBy: 'term', cascade: ['persist'])]
    private ?EnglishTerm $englishTerm = null;

    #[ORM\OneToOne(mappedBy: 'term', cascade: ['persist'])]
    private ?FrenchTerm $frenchTerm = null;

    #[ORM\ManyToOne(inversedBy: 'refTerminologies')]
    private ?Bibliography $reference = null;

    #[ORM\Column]
    private ?bool $isApproved = false;

    #[ORM\ManyToOne(inversedBy: 'terminologies')]
    private ?User $updatedBy = null;

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

    public function getCreatedAt(): ?\DateTimeImmutable
    {
        return $this->createdAt;
    }

    public function setCreatedAt(\DateTimeImmutable $createdAt): static
    {
        $this->createdAt = $createdAt;

        return $this;
    }

    public function getUpdatedAt(): ?\DateTimeImmutable
    {
        return $this->updatedAt;
    }

    public function setUpdatedAt(?\DateTimeImmutable $updatedAt): static
    {
        $this->updatedAt = $updatedAt;

        return $this;
    }

    public function getEnglishTerm(): ?EnglishTerm
    {
        return $this->englishTerm;
    }

    public function setEnglishTerm(EnglishTerm $englishTerm): static
    {
        // set the owning side of the relation if necessary
        if ($englishTerm->getTerm() !== $this) {
            $englishTerm->setTerm($this);
        }

        $this->englishTerm = $englishTerm;

        return $this;
    }

    public function getFrenchTerm(): ?FrenchTerm
    {
        return $this->frenchTerm;
    }

    public function setFrenchTerm(FrenchTerm $frenchTerm): static
    {
        // set the owning side of the relation if necessary
        if ($frenchTerm->getTerm() !== $this) {
            $frenchTerm->setTerm($this);
        }

        $this->frenchTerm = $frenchTerm;

        return $this;
    }

    #[ORM\PrePersist]
    #[ORM\PreUpdate]
    public function updatedTimestamps(): void
    {
        $this->setUpdatedAt(new \DateTimeImmutable());

        if ($this->getCreatedAt() == null) {
            $this->setCreatedAt(new \DateTimeImmutable());
        }
    }

    public function getReference(): ?Bibliography
    {
        return $this->reference;
    }

    public function setReference(?Bibliography $reference): static
    {
        $this->reference = $reference;

        return $this;
    }

    public function isIsApproved(): ?bool
    {
        return $this->isApproved;
    }

    public function setIsApproved(bool $isApproved): static
    {
        $this->isApproved = $isApproved;

        return $this;
    }

    public function getUpdatedBy(): ?User
    {
        return $this->updatedBy;
    }

    public function setUpdatedBy(?User $updatedBy): static
    {
        $this->updatedBy = $updatedBy;

        return $this;
    }
}
// entity EnglishTerm

<?php

namespace App\Entity;

use App\Repository\EnglishTermRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;

#[ORM\Entity(repositoryClass: EnglishTermRepository::class)]
class EnglishTerm
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    private ?int $id = null;
    #[ORM\Column(length: 255)]
    private ?string $content = null;

    #[ORM\Column(length: 255, nullable: true)]
    private ?string $definition = null;

    #[ORM\OneToOne(inversedBy: 'englishTerm')]
    #[ORM\JoinColumn(nullable: false)]
    private ?Terminology $term = null;

    #[ORM\ManyToMany(targetEntity: SubDomainEnglish::class, inversedBy: 'englishTerms')]
    private Collection $domain;

    #[ORM\OneToMany(mappedBy: 'englishTerm', targetEntity: ContextEnglish::class, cascade: ['persist'])]
    private Collection $context;

    #[ORM\OneToMany(mappedBy: 'englishTerm', targetEntity: NoteEnglish::class, cascade: ['persist'])]
    private Collection $note;

    public function __construct()
    {
        $this->domain = new ArrayCollection();
        $this->context = new ArrayCollection();
        $this->note = new ArrayCollection();
    }

    public function __toString(): string
    {
        return $this->content;
    }

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

    public function getContent(): ?string
    {
        return $this->content;
    }

    public function setContent(string $content): static
    {
        $this->content = $content;

        return $this;
    }

    public function getDefinition(): ?string
    {
        return $this->definition;
    }

    public function setDefinition(?string $definition): static
    {
        $this->definition = $definition;

        return $this;
    }

    public function getTerm(): ?Terminology
    {
        return $this->term;
    }

    public function setTerm(Terminology $term): static
    {
        $this->term = $term;

        return $this;
    }

    /**
     * @return Collection<int, SubDomainEnglish>
     */
    public function getDomain(): Collection
    {
        return $this->domain;
    }

    public function addDomain(SubDomainEnglish $domain): static
    {
        if (!$this->domain->contains($domain)) {
            $this->domain->add($domain);
        }

        return $this;
    }

    public function removeDomain(SubDomainEnglish $domain): static
    {
        $this->domain->removeElement($domain);

        return $this;
    }

    /**
     * @return Collection<int, ContextEnglish>
     */
    public function getContext(): Collection
    {
        return $this->context;
    }

    public function addContext(ContextEnglish $context): static
    {
        if (!$this->context->contains($context)) {
            $this->context->add($context);
            $context->setEnglishTerm($this);
        }

        return $this;
    }

    public function removeContext(ContextEnglish $context): static
    {
        if ($this->context->removeElement($context)) {
            // set the owning side to null (unless already changed)
            if ($context->getEnglishTerm() === $this) {
                $context->setEnglishTerm(null);
            }
        }

        return $this;
    }

    /**
     * @return Collection<int, NoteEnglish>
     */
    public function getNote(): Collection
    {
        return $this->note;
    }

    public function addNote(NoteEnglish $note): static
    {
        if (!$this->note->contains($note)) {
            $this->note->add($note);
            $note->setEnglishTerm($this);
        }

        return $this;
    }

    public function removeNote(NoteEnglish $note): static
    {
        if ($this->note->removeElement($note)) {
            // set the owning side to null (unless already changed)
            if ($note->getEnglishTerm() === $this) {
                $note->setEnglishTerm(null);
            }
        }

        return $this;
    }
}

//Entity NoteEnglish

<?php

namespace App\Entity;

use App\Repository\NoteEnglishRepository;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;

#[ORM\Entity(repositoryClass: NoteEnglishRepository::class)]
class NoteEnglish
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    private ?int $id = null;

    #[ORM\Column(type: Types::TEXT)]
    #[Assert\NotBlank()]
    #[Assert\Length(min: 30)]
    private ?string $content = null;

    #[ORM\ManyToOne(inversedBy: 'noteEnglishes')]
    #[ORM\JoinColumn(nullable: false)]
    private ?Bibliography $reference = null;

    #[ORM\ManyToOne(inversedBy: 'note')]
    private ?EnglishTerm $englishTerm = null;

    public function __toString(): string
    {
        return $this->content;
    }

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

    public function getContent(): ?string
    {
        return $this->content;
    }

    public function setContent(string $content): static
    {
        $this->content = $content;

        return $this;
    }

    public function getReference(): ?Bibliography
    {
        return $this->reference;
    }

    public function setReference(?Bibliography $reference): static
    {
        $this->reference = $reference;

        return $this;
    }

    public function getEnglishTerm(): ?EnglishTerm
    {
        return $this->englishTerm;
    }

    public function setEnglishTerm(?EnglishTerm $englishTerm): static
    {
        $this->englishTerm = $englishTerm;

        return $this;
    }
}
// Entity ContextEnglish

<?php

namespace App\Entity;

use App\Repository\ContextEnglishRepository;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;

#[ORM\Entity(repositoryClass: ContextEnglishRepository::class)]
class ContextEnglish
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    private ?int $id = null;

    #[ORM\Column(type: Types::TEXT)]
    #[Assert\NotBlank()]
    #[Assert\Length(min: 30)]
    private ?string $content = null;

    #[ORM\ManyToOne(inversedBy: 'contextEnglishes')]
    #[ORM\JoinColumn(nullable: false)]
    private ?Bibliography $reference = null;

    #[ORM\ManyToOne(inversedBy: 'context')]
    private ?EnglishTerm $englishTerm = null;

    public function __toString(): string
    {
        return $this->content;
    }

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

    public function getContent(): ?string
    {
        return $this->content;
    }

    public function setContent(string $content): static
    {
        $this->content = $content;

        return $this;
    }

    public function getReference(): ?Bibliography
    {
        return $this->reference;
    }

    public function setReference(?Bibliography $reference): static
    {
        $this->reference = $reference;

        return $this;
    }

    public function getEnglishTerm(): ?EnglishTerm
    {
        return $this->englishTerm;
    }

    public function setEnglishTerm(?EnglishTerm $englishTerm): static
    {
        $this->englishTerm = $englishTerm;

        return $this;
    }
}
// Crud TerminologyCrudController

<?php

namespace App\Controller\Admin;

use App\Entity\Terminology;
use EasyCorp\Bundle\EasyAdminBundle\Controller\AbstractCrudController;
use EasyCorp\Bundle\EasyAdminBundle\Field\AssociationField;
use EasyCorp\Bundle\EasyAdminBundle\Field\BooleanField;
use EasyCorp\Bundle\EasyAdminBundle\Field\FormField;
use EasyCorp\Bundle\EasyAdminBundle\Field\IdField;

class TerminologyCrudController extends AbstractCrudController
{
    public static function getEntityFqcn(): string
    {
        return Terminology::class;
    }

    public function configureFields(string $pageName): iterable
    {
        //Tab Term in English
        yield FormField::addTab('Term in english');
        yield IdField::new('id')->onlyOnIndex();
        yield AssociationField::new('englishTerm')
            ->renderAsEmbeddedForm(EnglishTermCrudController::class)
            ->setFormTypeOption('by_reference', false)
            ->setColumns(6);

        //Tab Term in French
        yield FormField::addTab('Term in french');
        yield AssociationField::new('frenchTerm')
            ->renderAsEmbeddedForm(FrenchTermCrudController::class)
            ->setFormTypeOption('by_reference', false)
            ->setColumns(6);

        //Tab Reference
        yield FormField::addTab('Reference');
        yield AssociationField::new('reference', "Bibliography's reference")
            ->setColumns(6);

        //Tab Approve
        yield FormField::addTab('Approve');
        yield BooleanField::new('isApproved')
            ->setColumns(6)
            ->renderAsSwitch(false);
    }
}
// Crud EnglishTermCrudController

<?php

namespace App\Controller\Admin;

use App\Entity\EnglishTerm;
use EasyCorp\Bundle\EasyAdminBundle\Controller\AbstractCrudController;
use EasyCorp\Bundle\EasyAdminBundle\Field\AssociationField;
use EasyCorp\Bundle\EasyAdminBundle\Field\CollectionField;
use EasyCorp\Bundle\EasyAdminBundle\Field\Field;
use EasyCorp\Bundle\EasyAdminBundle\Field\FormField;

class EnglishTermCrudController extends AbstractCrudController
{
    public static function getEntityFqcn(): string
    {
        return EnglishTerm::class;
    }

    public function configureFields(string $pageName): iterable
    {
        yield AssociationField::new('domain', 'Domain in English')
            ->setColumns(6);
        yield FormField::addRow();
        yield Field::new('content', 'Term in English')
            ->setColumns(6);
        yield Field::new('definition', 'Definition in English')
            ->setColumns(6);
        yield CollectionField::new('context', 'Usage Context')
            ->setEntryIsComplex()
            ->setColumns(6);
        yield CollectionField::new('note', 'Information Note')
            ->setColumns(6);
    }
}
Reply

Hi @Diarill!

Apologies for my slow reply - I've been deep in a tutorial!

I'm a French speaker and I used deepl to translate this text.

It sounds great - it's very cool that we can communicate :)

Ok, so it sounds like you have a fairly complex setup: the frenchTerm field is an AssociationField rendered as an embedded form... and that embedded form contains some CollectionField entries on it. I have not, personally, done anything this deep. And it's possible that EasyAdmin isn't setup to go this deep. It sounds a bit like some JavaScript might be missing. A few questions:

A) On the page, when you click the button, are there any JavaScript errors?
B) If you go to the FrenchTermCrudController admin section directly, does the button work there?

Cheers!

Reply

Thanks for replying @weaverryan.
For your first question, No, I don't have any JavaScript errors. And for the second, Yes, the button works normally.

Reply
Jimmeak Avatar

Hey Symfony Cast Team, thanks for the amazing tutorial. I love how you start with such a simple thing and create something so complex and useful.

Is there any chance we would also get rid of the ugly looking urls? Or is there a way how to use Symfony router to create some nice url /admin/article/{slug?}, but really works with the ugly url at the background?

Reply

Hey Jimmeak,

Thank you for your kind words about this tutorial! We're really happy you like it :)

About your question, I'm not sure that will ever happen in the EasyAdmin, the main idea is that it's an admin, i.e. internal thing, so in theory, you don't need friendly URLs e.g. for SEO as you do for the front-end. It's just and admin thing, and only admins have access to it. Based on this, the bundle chose this strategy with query params URLs for both simplicity and flexibility, it's the key concept of the bundle's architecture. Also, key features of this bundle like filters, etc. are based on the simple query params.

You may try to rewrite the URLs in a nicer way somehow, but I've never tried this and cannot give you any hints on this, sorry.

Cheers!

1 Reply

Hello! A little bit question please!
To create "Edit Profile" page for my users (no Admin role permission). Are there any way to use EasyAdmin or it works only for Admin role?

Many thanks in advance!

Reply

Hey Lubna!

Hmm. This might technically be possible... but in practice, no, EasyAdmin isn't really meant for this. If you did do this, your users would suddenly be in the admin "layout" when going to their edit profile page - so it'll be a weird experience. Better to just build a normal page for this using a Symfony form :).

Cheers!

1 Reply

Thank you very much for the clarification!

Reply
Sergey-P Avatar

Hello! Is it possible to put two entities on one page? For example, in the first tab, I want to add one entity, and in the second tab another one

Reply

Hey Sergey,

It should be possible if you create your own templates and Crud controller, but I don't think it will just work out of the box

Cheers!

Reply
Sergey-P Avatar

MolloKhan, thanks :)

Reply

Finally! Many thanks for your great efforts!
I really enjoy learning the EasyAdmin 4 course.

Now, I will continue learning the other course of Symfony 6 track.

Reply
VickaB Avatar

Thanks a lot ! What a great Job!

Reply

Thanks for this GREAT tutorial!

Two things I found out digging in the code that I think will make the admin looks even better. Thought I would share it here in case it could help others.

  1. I <i>really </i>don't like the fact that the admin content section is not taking the whole window this can be fixed easily in the DashboardController like this.
    `
    public function configureDashboard(): Dashboard
    {

     $dashboard = Dashboard::new()
         ->setTitle('Cauldron Overflow Admin')
     ;
     $dashboard->getAsDto()->setContentWidth(Crud::LAYOUT_CONTENT_FULL);
    
     return $dashboard;
    

    }
    `

  2. In case you want to have panel split into columns (kinda like in the Wordpress admin), you can't use FormField::addPanel('Basic Data')->setColumns($int) on panels but you can do FormField::addPanel('Basic Data')->addCssClass('col-md-9')
Reply

Love these tips - thanks for sharing them julien_bonnier!!

2 Reply
Cat in space

"Houston: no signs of life"
Start the conversation!

What PHP libraries does this tutorial use?

// composer.json
{
    "require": {
        "php": ">=8.1.0",
        "ext-ctype": "*",
        "ext-iconv": "*",
        "composer/package-versions-deprecated": "^1.11", // 1.11.99.4
        "doctrine/doctrine-bundle": "^2.1", // 2.5.5
        "doctrine/doctrine-migrations-bundle": "^3.0", // 3.2.1
        "doctrine/orm": "^2.7", // 2.10.4
        "easycorp/easyadmin-bundle": "^4.0", // v4.0.2
        "handcraftedinthealps/goodby-csv": "^1.4", // 1.4.0
        "knplabs/knp-markdown-bundle": "dev-symfony6", // dev-symfony6
        "knplabs/knp-time-bundle": "^1.11", // 1.17.0
        "sensio/framework-extra-bundle": "^6.0", // v6.2.5
        "stof/doctrine-extensions-bundle": "^1.4", // v1.7.0
        "symfony/asset": "6.0.*", // v6.0.1
        "symfony/console": "6.0.*", // v6.0.2
        "symfony/dotenv": "6.0.*", // v6.0.2
        "symfony/flex": "^2.0.0", // v2.0.1
        "symfony/framework-bundle": "6.0.*", // v6.0.2
        "symfony/mime": "6.0.*", // v6.0.2
        "symfony/monolog-bundle": "^3.0", // v3.7.1
        "symfony/runtime": "6.0.*", // v6.0.0
        "symfony/security-bundle": "6.0.*", // v6.0.2
        "symfony/stopwatch": "6.0.*", // v6.0.0
        "symfony/twig-bundle": "6.0.*", // v6.0.1
        "symfony/ux-chartjs": "^2.0", // v2.0.1
        "symfony/webpack-encore-bundle": "^1.7", // v1.13.2
        "symfony/yaml": "6.0.*", // v6.0.2
        "twig/extra-bundle": "^2.12|^3.0", // v3.3.7
        "twig/twig": "^2.12|^3.0" // v3.3.7
    },
    "require-dev": {
        "doctrine/doctrine-fixtures-bundle": "^3.3", // 3.4.1
        "symfony/debug-bundle": "6.0.*", // v6.0.2
        "symfony/maker-bundle": "^1.15", // v1.36.4
        "symfony/var-dumper": "6.0.*", // v6.0.2
        "symfony/web-profiler-bundle": "6.0.*", // v6.0.2
        "zenstruck/foundry": "^1.1" // v1.16.0
    }
}
userVoice