Skip to content

Commit 2464af1

Browse files
committed
fixing FieldFactory
1 parent 0bf9b81 commit 2464af1

File tree

9 files changed

+199
-41
lines changed

9 files changed

+199
-41
lines changed

src/Factory/EntityFactory.php

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -38,17 +38,20 @@ public function __construct(FieldFactory $fieldFactory, ActionFactory $actionFac
3838
$this->eventDispatcher = $eventDispatcher;
3939
}
4040

41-
public function processFields(EntityDto $entityDto, FieldCollection $fields): void
41+
public function processFields(EntityDto|EntityCollection $entities, FieldCollection $fields): void
4242
{
43-
$this->fieldFactory->processFields($entityDto, $fields);
43+
if ($entities instanceof EntityDto) {
44+
$collection = EntityCollection::new([]);
45+
$collection->set($entities);
46+
$entities = $collection;
47+
}
48+
49+
$this->fieldFactory->processFields($entities, $fields);
4450
}
4551

4652
public function processFieldsForAll(EntityCollection $entities, FieldCollection $fields): void
4753
{
48-
foreach ($entities as $entity) {
49-
$this->processFields($entity, clone $fields);
50-
$entities->set($entity);
51-
}
54+
$this->processFields($entities, $fields);
5255
}
5356

5457
public function processActions(EntityDto $entityDto, ActionConfigDto $actionConfigDto): void

src/Factory/FieldFactory.php

Lines changed: 40 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
namespace EasyCorp\Bundle\EasyAdminBundle\Factory;
44

55
use Doctrine\DBAL\Types\Types;
6+
use EasyCorp\Bundle\EasyAdminBundle\Collection\EntityCollection;
67
use EasyCorp\Bundle\EasyAdminBundle\Collection\FieldCollection;
78
use EasyCorp\Bundle\EasyAdminBundle\Config\Crud;
89
use EasyCorp\Bundle\EasyAdminBundle\Contracts\Field\FieldConfiguratorInterface;
@@ -71,8 +72,15 @@ public function __construct(
7172
{
7273
}
7374

74-
public function processFields(EntityDto $entityDto, FieldCollection $fields): void
75+
public function processFields(EntityCollection $entities, FieldCollection $fields): void
7576
{
77+
if (0 === $entities->count()) {
78+
return;
79+
}
80+
81+
// something that can be and should be done only once for all entities
82+
$entityDto = clone $entities->first();
83+
$entityDto->setInstance(null);
7684
$this->preProcessFields($fields, $entityDto);
7785

7886
$context = $this->adminContextProvider->getContext();
@@ -94,14 +102,6 @@ public function processFields(EntityDto $entityDto, FieldCollection $fields): vo
94102
continue;
95103
}
96104

97-
// when creating new entities with "useEntryCrudForm" on an edit page we must
98-
// explicitly check for the "new" page because $currentPage will be "edit"
99-
if ((null === $entityDto->getInstance()) && !$fieldDto->isDisplayedOn(Crud::PAGE_NEW)) {
100-
$fields->unset($fieldDto);
101-
102-
continue;
103-
}
104-
105105
foreach ($this->fieldConfigurators as $configurator) {
106106
if (!$configurator->supports($fieldDto, $entityDto)) {
107107
continue;
@@ -123,12 +123,42 @@ public function processFields(EntityDto $entityDto, FieldCollection $fields): vo
123123

124124
$fields->set($fieldDto);
125125
}
126+
unset($entityDto, $fieldDto);
126127

127128
if (!$fields->isEmpty()) {
128129
$this->fieldLayoutFactory->createLayout($fields, $this->adminContextProvider->getContext()?->getCrud()?->getCurrentPage() ?? Crud::PAGE_INDEX);
129130
}
130131

131-
$entityDto->setFields($fields);
132+
$originalFields = $fields;
133+
foreach ($entities as $entityDto) {
134+
$fields = clone $originalFields;
135+
foreach ($fields as $fieldDto) {
136+
if (false === $this->authorizationChecker->isGranted(Permission::EA_VIEW_FIELD, $fieldDto)) {
137+
$fieldDto->markAsInaccessible();
138+
139+
continue;
140+
}
141+
142+
// when creating new entities with "useEntryCrudForm" on an edit page we must
143+
// explicitly check for the "new" page because $currentPage will be "edit"
144+
if ((null === $entityDto->getInstance()) && !$fieldDto->isDisplayedOn(Crud::PAGE_NEW)) {
145+
$fieldDto->markAsInaccessible();
146+
147+
continue;
148+
}
149+
150+
foreach ($this->fieldConfigurators as $configurator) {
151+
if (!$configurator->supports($fieldDto, $entityDto)) {
152+
continue;
153+
}
154+
155+
// @phpstan-ignore-next-line argument.type
156+
$configurator->configure($fieldDto, $entityDto, $context);
157+
}
158+
}
159+
160+
$entityDto->setFields($fields);
161+
}
132162
}
133163

134164
private function preProcessFields(FieldCollection $fields, EntityDto $entityDto): void

src/Field/Configurator/CommonPostConfigurator.php

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,12 @@ private function buildFormattedValueOption(mixed $value, FieldDto $field, Entity
4848
return $value;
4949
}
5050

51-
$formatted = $callable($field->getValue(), $entityDto->getInstance());
51+
$fieldValue = $field->getValue();
52+
if (null === $fieldValue) {
53+
return $value;
54+
}
55+
56+
$formatted = $callable($fieldValue, $entityDto->getInstance());
5257

5358
// if the callable returns a string, wrap it in a Twig Markup to render the
5459
// HTML and CSS/JS elements that it might contain

src/Field/Configurator/CommonPreConfigurator.php

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -45,12 +45,13 @@ public function configure(FieldDto $field, EntityDto $entityDto, AdminContext $c
4545

4646
// if a field already has set a value, someone has written something to
4747
// it (as a virtual field or overwrite); don't modify the value in that case
48-
$isReadable = true;
4948
if (null === $value = $field->getValue()) {
5049
try {
5150
$value = null === $entityDto->getInstance() ? null : $this->propertyAccessor->getValue($entityDto->getInstance(), $field->getProperty());
5251
} catch (AccessException|UnexpectedTypeException) {
53-
$isReadable = false;
52+
if (!$field->isFormLayoutField()) {
53+
$field->markAsInaccessible();
54+
}
5455
}
5556

5657
$field->setValue($value);
@@ -72,7 +73,7 @@ public function configure(FieldDto $field, EntityDto $entityDto, AdminContext $c
7273
$isVirtual = $this->buildVirtualOption($field, $entityDto);
7374
$field->setVirtual($isVirtual);
7475

75-
$templatePath = $this->buildTemplatePathOption($context, $field, $entityDto, $isReadable);
76+
$templatePath = $this->buildTemplatePathOption($context, $field, $entityDto);
7677
$field->setTemplatePath($templatePath);
7778

7879
$doctrineMetadata = $entityDto->hasProperty($field->getProperty()) ? $entityDto->getPropertyMetadata($field->getProperty())->all() : [];
@@ -164,14 +165,10 @@ private function buildVirtualOption(FieldDto $field, EntityDto $entityDto): bool
164165
return !$entityDto->hasProperty($field->getProperty());
165166
}
166167

167-
private function buildTemplatePathOption(AdminContext $adminContext, FieldDto $field, EntityDto $entityDto, bool $isReadable): string
168+
private function buildTemplatePathOption(AdminContext $adminContext, FieldDto $field, EntityDto $entityDto): string
168169
{
169-
if (null !== $templatePath = $field->getTemplatePath()) {
170-
return $templatePath;
171-
}
172-
173170
// if field has a value set, don't display it as inaccessible (needed e.g. for virtual fields)
174-
if (!$isReadable && null === $field->getValue()) {
171+
if (!$field->isAccessible() && null === $field->getValue()) {
175172
return $adminContext->getTemplatePath('label/inaccessible');
176173
}
177174

src/Form/Type/CrudFormType.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,10 @@ public function buildForm(FormBuilderInterface $builder, array $options): void
4747

4848
/** @var FieldDto $fieldDto */
4949
foreach ($entityDto->getFields() as $fieldDto) {
50+
if (!$fieldDto->isAccessible()) {
51+
continue;
52+
}
53+
5054
$formFieldOptions = $fieldDto->getFormTypeOptions();
5155

5256
// the names of embedded Doctrine entities contain dots, which are not allowed

templates/crud/index.html.twig

Lines changed: 10 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,7 @@
106106

107107
{% set ea_sort_asc = constant('EasyCorp\\Bundle\\EasyAdminBundle\\Config\\Option\\SortOrder::ASC') %}
108108
{% set ea_sort_desc = constant('EasyCorp\\Bundle\\EasyAdminBundle\\Config\\Option\\SortOrder::DESC') %}
109-
{% for field in fields|filter(f => f.isAccessible) ?? [] %}
109+
{% for field in fields %}
110110
{% set is_searchable = null == ea.crud.searchFields or field.property in ea.crud.searchFields %}
111111
{% set is_sorting_field = ea.search.isSortingField(field.property) %}
112112
{% set next_sort_direction = is_sorting_field ? (ea.search.sortDirection(field.property) == ea_sort_desc ? ea_sort_asc : ea_sort_desc) : ea_sort_desc %}
@@ -150,15 +150,15 @@
150150
{% endif %}
151151

152152
{% for field in entity.fields %}
153-
{% if not field.isAccessible %}
154-
<twig:ea:Icon name="internal:lock" class="mr-1" />
155-
{% else %}
156-
{% set is_searchable = null == ea.crud.searchFields or field.property in ea.crud.searchFields %}
157-
158-
<td data-column="{{ field.property }}" data-label="{{ field.label|trans|e('html') }}" class="{{ is_searchable ? 'searchable' }} {{ field.property == sort_field_name ? 'sorted' }} text-{{ field.textAlign }} {{ field.cssClass }}" dir="{{ ea.i18n.textDirection }}" {% for name, value in field.htmlAttributes %}{{ name }}="{{ value|e('html_attr') }}" {% endfor %}>
153+
{% set is_searchable = null == ea.crud.searchFields or field.property in ea.crud.searchFields %}
154+
<td data-column="{{ field.property }}" data-label="{{ field.label|trans|e('html') }}" class="{{ is_searchable ? 'searchable' }} {{ field.property == sort_field_name ? 'sorted' }} text-{{ field.textAlign }} {{ field.cssClass }}" dir="{{ ea.i18n.textDirection }}" {% for name, value in field.htmlAttributes %}{{ name }}="{{ value|e('html_attr') }}" {% endfor %}>
155+
{{ dump(field, entity) }}
156+
{% if field.isAccessible %}
159157
{{ include(field.templatePath, { field: field, entity: entity }, with_context = false) }}
160-
</td>
161-
{% endif %}
158+
{% else %}
159+
<twig:ea:Icon name="internal:lock" class="mr-1" />
160+
{% endif %}
161+
</td>
162162
{% endfor %}
163163

164164
{% block entity_actions %}
@@ -197,12 +197,7 @@
197197
{% block table_body_empty %}
198198
{% for i in 1..14 %}
199199
<tr class="empty-row">
200-
<td><span></span></td>
201-
<td><span></span></td>
202-
<td><span></span></td>
203-
<td><span></span></td>
204-
<td><span></span></td>
205-
<td><span></span></td>
200+
<td colspan="6"><span></span></td>
206201
</tr>
207202

208203
{% if 3 == loop.index %}

tests/Orm/CustomerSortTest.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ public function testSorting(array $query, ?\Closure $sortFunction, string $expec
5757

5858
// Assert
5959
$this->assertResponseIsSuccessful();
60+
6061
$this->assertSelectorTextSame('th.header-for-field-association > a', 'Bills');
6162
$this->assertSelectorExists('th.header-for-field-association span.icon svg');
6263

tests/Security/VoterTest.php

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
<?php
2+
3+
namespace EasyCorp\Bundle\EasyAdminBundle\Tests\Security;
4+
5+
use Doctrine\ORM\EntityRepository;
6+
use EasyCorp\Bundle\EasyAdminBundle\Test\AbstractCrudTestCase;
7+
use EasyCorp\Bundle\EasyAdminBundle\Tests\TestApplication\Controller\DashboardController;
8+
use EasyCorp\Bundle\EasyAdminBundle\Tests\TestApplication\Controller\Sort\WebsiteCrudController;
9+
use EasyCorp\Bundle\EasyAdminBundle\Tests\TestApplication\Entity\Website;
10+
use EasyCorp\Bundle\EasyAdminBundle\Tests\TestApplication\Security\CustomVoter;
11+
use Symfony\Component\Security\Core\Authorization\Strategy\UnanimousStrategy;
12+
13+
class VoterTest extends AbstractCrudTestCase
14+
{
15+
/**
16+
* @var EntityRepository
17+
*/
18+
private $repository;
19+
20+
protected function getControllerFqcn(): string
21+
{
22+
return WebsiteCrudController::class;
23+
}
24+
25+
protected function getDashboardFqcn(): string
26+
{
27+
return DashboardController::class;
28+
}
29+
30+
protected function setUp(): void
31+
{
32+
parent::setUp();
33+
$this->client->followRedirects();
34+
$this->repository = $this->entityManager->getRepository(Website::class);
35+
}
36+
37+
public function testingVoter(): void
38+
{
39+
// Arrange
40+
$entities = $this->repository->findAll();
41+
42+
/** @var CustomVoter $voter */
43+
$voter = $this->getContainer()->get(CustomVoter::class);
44+
$voter->ban($entities[0], 'name');
45+
46+
// UnanimousStrategy
47+
$accessDecisionManager = $this->getContainer()->get('security.access.decision_manager');
48+
\Closure::bind(function () {
49+
$this->strategy = new UnanimousStrategy();
50+
}, $accessDecisionManager, $accessDecisionManager)();
51+
52+
// Act
53+
$crawler = $this->client->request('GET', $this->generateIndexUrl());
54+
55+
// Assert
56+
$this->assertResponseIsSuccessful();
57+
$this->assertSelectorExists('th[data-column="pages"]');
58+
$this->assertSelectorExists('th[data-column="name"]');
59+
}
60+
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
<?php
2+
3+
namespace EasyCorp\Bundle\EasyAdminBundle\Tests\TestApplication\Security;
4+
5+
use EasyCorp\Bundle\EasyAdminBundle\Dto\EntityDto;
6+
use EasyCorp\Bundle\EasyAdminBundle\Dto\FieldDto;
7+
use EasyCorp\Bundle\EasyAdminBundle\Security\Permission;
8+
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
9+
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
10+
11+
/**
12+
* Custom voter that allows to ban an entity and its property from being processed.
13+
*
14+
* This is only for testing purposes, to simulate the behavior of the FieldFactory::processFields() method when
15+
* EA_VIEW_FIELD will return FALSE, on first entity, which is used to build table theaders.
16+
*/
17+
class CustomVoter extends Voter
18+
{
19+
private ?object $banEntity = null;
20+
private ?string $banEntityProperty = null;
21+
private bool $enabled = false;
22+
23+
protected function supports(string $attribute, mixed $subject): bool
24+
{
25+
return $this->enabled && Permission::exists($attribute);
26+
}
27+
28+
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool
29+
{
30+
static $entities = [];
31+
32+
if ($subject instanceof EntityDto && $subject->getInstance()) {
33+
$entities[] = $subject;
34+
}
35+
36+
foreach ($entities as $entityDto) {
37+
if ($this->banEntity && $entityDto->getInstance() === $this->banEntity) {
38+
// imitate the behavior of FieldFactory::processFields()
39+
// which removes the field from the entity if it is not granted
40+
if ($subject instanceof FieldDto) {
41+
if ($fieldDto = $entityDto->getFields()?->getByProperty($this->banEntityProperty)) {
42+
$entityDto->getFields()->unset($fieldDto);
43+
}
44+
}
45+
}
46+
}
47+
48+
return true;
49+
}
50+
51+
public function ban(object $entity, ?string $propertyName = null): void
52+
{
53+
$this->banEntity = $entity;
54+
$this->banEntityProperty = $propertyName;
55+
$this->enabled = true;
56+
}
57+
58+
public function removeBan(): void
59+
{
60+
$this->banEntity = null;
61+
$this->banEntityProperty = null;
62+
}
63+
}

0 commit comments

Comments
 (0)