Chapters
-
Course Code
Subscribe to download the code!
Subscribe to download the code!
-
This Video
Subscribe to download the video!
Subscribe to download the video!
-
Subtitles
Subscribe to download the subtitles!
Subscribe to download the subtitles!
-
Course Script
Subscribe to download the script!
Subscribe to download the script!
Form Panels
Scroll down to the script below, click on any sentence (including terminal blocks) to jump to that spot in the video!
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.
With a Subscription, click any sentence in the script to jump to that part of the video!
Login SubscribeLast 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')
.
Show Lines
|
// ... lines 1 - 28 |
class QuestionCrudController extends AbstractCrudController | |
{ | |
Show Lines
|
// ... lines 31 - 112 |
public function configureFields(string $pageName): iterable | |
{ | |
Show Lines
|
// ... lines 115 - 140 |
yield FormField::addPanel('Details'); | |
Show Lines
|
// ... lines 142 - 161 |
} | |
Show Lines
|
// ... 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.
Show Lines
|
// ... lines 1 - 112 |
public function configureFields(string $pageName): iterable | |
{ | |
Show Lines
|
// ... lines 115 - 116 |
yield FormField::addPanel('Basic Data'); | |
Show Lines
|
// ... lines 118 - 162 |
} | |
Show Lines
|
// ... 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.
Show Lines
|
// ... lines 1 - 112 |
public function configureFields(string $pageName): iterable | |
{ | |
Show Lines
|
// ... lines 115 - 116 |
yield FormField::addPanel('Basic Data') | |
->collapsible(); | |
Show Lines
|
// ... lines 119 - 142 |
yield FormField::addPanel('Details') | |
->collapsible(); | |
Show Lines
|
// ... lines 145 - 164 |
} | |
Show Lines
|
// ... 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.
Show Lines
|
// ... lines 1 - 112 |
public function configureFields(string $pageName): iterable | |
{ | |
Show Lines
|
// ... lines 115 - 142 |
yield FormField::addPanel('Details') | |
->collapsible() | |
->setIcon('fa fa-info') | |
->setHelp('Additional Details'); | |
Show Lines
|
// ... lines 147 - 166 |
} | |
Show Lines
|
// ... 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()
.
Show Lines
|
// ... lines 1 - 112 |
public function configureFields(string $pageName): iterable | |
{ | |
Show Lines
|
// ... lines 115 - 116 |
yield FormField::addTab('Basic Data') | |
Show Lines
|
// ... lines 118 - 142 |
yield FormField::addTab('Details') | |
Show Lines
|
// ... lines 144 - 166 |
} | |
Show Lines
|
// ... 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!
Show Lines
|
// ... lines 1 - 112 |
public function configureFields(string $pageName): iterable | |
{ | |
Show Lines
|
// ... lines 115 - 142 |
yield FormField::addTab('Details') | |
->setIcon('info') | |
Show Lines
|
// ... lines 145 - 165 |
} | |
Show Lines
|
// ... 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)
.
Show Lines
|
// ... lines 1 - 112 |
public function configureFields(string $pageName): iterable | |
{ | |
Show Lines
|
// ... lines 115 - 119 |
yield Field::new('name') | |
Show Lines
|
// ... line 121 |
->setColumns(5); | |
yield Field::new('slug') | |
Show Lines
|
// ... lines 124 - 128 |
->setColumns(5); | |
Show Lines
|
// ... lines 130 - 167 |
} | |
Show Lines
|
// ... 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!
39 Comments
Hey Edin,
Yeah, agree, now it's much flexible with PHP config. And thanks to IDE we have autocomplete now :)
Cheers!
Hey Lubna,
Yes, you made it! Congratulations! 🎉 I really happy to hear you liked the course ;)
Cheers!
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?
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!
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.
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!
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.
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!!
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!!!!!!
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!
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
Excuse me How do you inject the requestStack on the function
$menuIdx = $this->requestStack->getCurrentRequest()->get('menuIdx');
I need to inject the request but maybe it works with requestStack. Thank you
Hey,
In an EasyAdmin controller, you can inject external services by listing them to the getSubscribedServices()
method. Like this:
public static function getSubscribedServices(): array
{
return array_merge(parent::getSubscribedServices(), [
// your service FQNS
]);
}
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!
Thanks for this awesome course. EasyAdmin looks pretty amazing. But i think what's really missing is a repeater component, so you can have master/detail forms. Like for example having a form on the top for creating an order and then a repeater at the bottom to add multiple order line items.
Hey @Saturn93!
Sorry for my very slow reply! I agree - EasyAdmin is awesome, but there are some super slick details like this that would make it 10x more awesome. These are tricky to implement, but maybe they'll show up some time :).
Cheers!
Sorry for the inconvenience, can someone tell me how to access the server parameters (Example: COMPUTERNAME or REMOTE_ADDR) and session variables (Example: _security.last_username) in an EasyAdmin CRUD. Both in the CRUD configuration of the fields and/or in any custom event that you develop.
Hey @rbthaofic,
You can do it from the Request object, find the best way to inject it, e.g. via method injection in a custom action method and then $request->server->get('REMOTE_ADDR')
, or do dump($request->server->all())
to dump all the server data.
Cheers!
Thank you for your answer, very kind of you, I'm new to symfony, could you please give me an example or explain how to do what is indicated (via method injection in a custom action method), I really don't know how to inject the request, If I suspected that it was the ideal or correct way to do it with the request, but I couldn't inject it into my function, when I do the dump it goes to null.
public function configureFields(string $pageName,request $request = null): iterable
{
dump($request);
die();
$fields =
[IdField::new('id')->setFormTypeOption('disabled', true)->onlyOnIndex(),
AvatarField::new('avatar')->formatValue(static function ($value, ?User $user) {return $user?->getAvatarUrl();})->hideOnForm(),
EmailField::new('email')->setColumns(5),
TextField::new('fullName')->hideOnForm()->setLabel('FullName'),
ImageField::new('avatar')->setBasePath('uploads/avatars')->setUploadDir('public/uploads/avatars')->setUploadedFileNamePattern('[slug]-[timestamp].[extension]') - >onlyOnForms()
->setColumns(5),
TextField::new('names')->onlyOnForms()->setColumns(5),
TextField::new('lastname')->onlyOnForms()->setColumns(5),
AssociationField::new('state')->setRequired(true)->setColumns(5),
DateTimeField::new('Date_Creation')->setFormTypeOptions(['html5' => true,'years' => range(date('Y'), (intval(date('Y')) + 5)), 'widget' => 'single_text',
])->hideOnForm()->setFormat('full'),
TextField::new('User_creation')->onlyOnDetail()->setColumns(5),
TextField::new('creation_host')->onlyOnDetail()->setColumns(5),
TextField::new('ip_creation')->onlyOnDetail()->setColumns(5),
AssociationField::new('creation_channel')->onlyOnDetail()->setColumns(5),];
$bangetype=(['ROLE_SUPER_ADMIN' => 'primary', 'ROLE_ADMIN' => 'success', 'ROLE_MODERATOR' => 'info' , 'ROLE_USER' => 'light']);
$roles = ['ROLE_SUPER_ADMIN', 'ROLE_ADMIN', 'ROLE_MODERATOR', 'ROLE_USER'];
$rolleschoice = ChoiceField::new('roles')
->setChoices(array_combine($roles,$roles))
->allowMultipleChoices()
->renderExpanded()
->renderAsBadges($bangetype)
->setRequired(true)
->setColumns(5);
$password = TextField::new('password')
->setFormType(RepeatedType::class)
->setFormTypeOptions([
'type' => PasswordType::class,
'first_options' => ['label' => 'Key'],
'second_options' => ['label' => 'Enter the Password again'],
'mapped' => false,
])
->setRequired($pageName === Raw::PAGE_NEW)
->onlyOnForms();
// if (Crud::PAGE_NEW === $pageName) {
// yield TextField::new('plainPassword')->setPassword();
// }
//yield DateField::new('Creation_Date')->hideOnForm();
$fields[] = $rolleschoice;
$fields[] = $password;
return $fields;
}
Thank you
Hey,
In EasyAdmin you can't inject the Request object directly into a controller's method. You can get it from the RequestStack, or from the $adminContext->getRequest()
(EasyCorp\Bundle\EasyAdminBundle\Context\AdminContext
)
Cheers!
I follow your advice and I already found how to do it, thank you very much. Now is working the way I wanted.
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);
}
}
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!
Hi @weaverryan, thanks for replying.
For your first question, No, I don't have any JavaScript errors. And for the second, Yes, the button works normally.
Hmm, ok. My theory is that there is a bug in EasyAdmin where it is not properly detecting that a CollectionField is being used on this page (because it is embedded down a few levels) and so the JavaScript needed to power this isn't being added to the page. I believe the JavaScript file is this - https://github.com/EasyCorp/EasyAdminBundle/blob/4.x/assets/js/field-collection.js (or, more precisely, it will probably have a filename more like this https://github.com/EasyCorp/EasyAdminBundle/blob/4.x/src/Resources/public/field-collection.8ea41328.js ). You could check to see if that's on the page.
Again, I'm doing some guessing about the cause of the problem and I could definitely be wrong - I'm not too familiar with the JavaScript in this area.
Cheers!
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?
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!
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!
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!
Thank you very much for the clarification!
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
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!
MolloKhan, thanks :)
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.
Thanks a lot ! What a great Job!
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.
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;
}
`- 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 doFormField::addPanel('Basic Data')->addCssClass('col-md-9')
Love these tips - thanks for sharing them julien_bonnier!!
"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.4.5
"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
}
}
GG
EasyAdmin has come a long way (glad to see the yaml config gone)