From 838b96eb14ba20a46be36644e81ec7a903b19fac Mon Sep 17 00:00:00 2001 From: Simon Kuehn Date: Sun, 17 May 2026 22:43:42 +0000 Subject: [PATCH] feat: required/optional attribute sections in ArticleType form Promote article_type_attributes join table to ArticleTypeAttribute entity with a required boolean flag. ArticleType gains virtual form properties (requiredAttributeDefs / optionalAttributeDefs) reconciled via applyAttributeAssignments() on persist/update. Admin form shows two Tom Select multi-selects; a small JS module (article-type-attr-sync.js) listens for ea.autocomplete.connect events and keeps the two lists mutually exclusive in real time. Co-Authored-By: Claude Sonnet 4.6 --- migrations/Version20260517222701.php | 34 ++++ public/js/admin/article-type-attr-sync.js | 70 ++++++++ src/Domain/Article/ArticleType.php | 127 +++++++++++++- src/Domain/Article/ArticleTypeAttribute.php | 62 +++++++ src/Domain/Article/AttributeDefinition.php | 5 + .../Admin/ArticleTypeCrudController.php | 63 +++++-- .../Article/ArticleTypeAttributeTest.php | 53 ++++++ .../ArticleTypeRequiredAttributesTest.php | 156 ++++++++++++++++++ .../Article/AttributeDefinitionTest.php | 67 ++++++++ .../Admin/ArticleTypeCrudControllerTest.php | 14 +- 10 files changed, 629 insertions(+), 22 deletions(-) create mode 100644 migrations/Version20260517222701.php create mode 100644 public/js/admin/article-type-attr-sync.js create mode 100644 src/Domain/Article/ArticleTypeAttribute.php create mode 100644 tests/Unit/Domain/Article/ArticleTypeAttributeTest.php create mode 100644 tests/Unit/Domain/Article/ArticleTypeRequiredAttributesTest.php create mode 100644 tests/Unit/Domain/Article/AttributeDefinitionTest.php diff --git a/migrations/Version20260517222701.php b/migrations/Version20260517222701.php new file mode 100644 index 0000000..17b0c27 --- /dev/null +++ b/migrations/Version20260517222701.php @@ -0,0 +1,34 @@ +addSql('ALTER TABLE app.article_type_attributes ADD COLUMN id UUID DEFAULT gen_random_uuid() NOT NULL'); + $this->addSql('ALTER TABLE app.article_type_attributes ADD COLUMN required BOOLEAN NOT NULL DEFAULT FALSE'); + $this->addSql('ALTER TABLE app.article_type_attributes DROP CONSTRAINT article_type_attributes_pkey'); + $this->addSql('ALTER TABLE app.article_type_attributes ADD PRIMARY KEY (id)'); + $this->addSql('CREATE UNIQUE INDEX uniq_article_type_attribute ON app.article_type_attributes (article_type_id, attribute_definition_id)'); + } + + public function down(Schema $schema): void + { + $this->addSql('DROP INDEX app.uniq_article_type_attribute'); + $this->addSql('ALTER TABLE app.article_type_attributes DROP CONSTRAINT article_type_attributes_pkey'); + $this->addSql('ALTER TABLE app.article_type_attributes DROP COLUMN id'); + $this->addSql('ALTER TABLE app.article_type_attributes DROP COLUMN required'); + $this->addSql('ALTER TABLE app.article_type_attributes ADD PRIMARY KEY (article_type_id, attribute_definition_id)'); + } +} diff --git a/public/js/admin/article-type-attr-sync.js b/public/js/admin/article-type-attr-sync.js new file mode 100644 index 0000000..d3eb8c6 --- /dev/null +++ b/public/js/admin/article-type-attr-sync.js @@ -0,0 +1,70 @@ +(function () { + 'use strict'; + + const instances = {}; + + function getKey(el) { + if (el.id.endsWith('_requiredAttributeDefs')) return 'required'; + if (el.id.endsWith('_optionalAttributeDefs')) return 'optional'; + return null; + } + + function setupSync() { + const req = instances.required; + const opt = instances.optional; + if (!req || !opt) return; + + // Initial state: strip items already selected in the other list + Object.keys(req.items).forEach(function (value) { + if (opt.options[value]) { + opt.removeOption(value); + opt.refreshOptions(false); + } + }); + Object.keys(opt.items).forEach(function (value) { + if (req.options[value]) { + req.removeOption(value); + req.refreshOptions(false); + } + }); + + // Required → hide from Optional, restore on deselect + req.on('item_add', function (value) { + if (opt.options[value]) { + opt.removeOption(value); + opt.refreshOptions(false); + } + }); + req.on('item_remove', function (value) { + var option = req.options[value]; + if (option && !opt.options[value]) { + opt.addOption(option); + opt.refreshOptions(false); + } + }); + + // Optional → hide from Required, restore on deselect + opt.on('item_add', function (value) { + if (req.options[value]) { + req.removeOption(value); + req.refreshOptions(false); + } + }); + opt.on('item_remove', function (value) { + var option = opt.options[value]; + if (option && !req.options[value]) { + req.addOption(option); + req.refreshOptions(false); + } + }); + } + + document.addEventListener('ea.autocomplete.connect', function (event) { + var el = event.target; + var key = getKey(el); + if (!key) return; + + instances[key] = event.detail.tomSelect; + setupSync(); + }); +}()); diff --git a/src/Domain/Article/ArticleType.php b/src/Domain/Article/ArticleType.php index a25fa99..8ae957d 100644 --- a/src/Domain/Article/ArticleType.php +++ b/src/Domain/Article/ArticleType.php @@ -20,16 +20,21 @@ class ArticleType #[ORM\Column(type: 'string', length: 255, unique: true)] private string $name; - /** @var Collection */ - #[ORM\ManyToMany(targetEntity: AttributeDefinition::class)] - #[ORM\JoinTable(name: 'article_type_attributes', schema: 'app')] - private Collection $attributeDefinitions; + /** @var Collection */ + #[ORM\OneToMany(targetEntity: ArticleTypeAttribute::class, mappedBy: 'articleType', cascade: ['persist', 'remove'], orphanRemoval: true)] + private Collection $attributeAssignments; + + /** @var list|null pending from form — applied by applyAttributeAssignments() */ + private ?array $pendingRequired = null; + + /** @var list|null */ + private ?array $pendingOptional = null; public function __construct(string $name) { $this->id = Uuid::v7(); $this->name = $name; - $this->attributeDefinitions = new ArrayCollection(); + $this->attributeAssignments = new ArrayCollection(); } public function getId(): Uuid @@ -47,21 +52,125 @@ class ArticleType $this->name = $name; } + /** @return Collection */ + public function getAttributeAssignments(): Collection + { + return $this->attributeAssignments; + } + + /** All attribute definitions regardless of required flag — used by pipeline agents. */ /** @return Collection */ public function getAttributeDefinitions(): Collection { - return $this->attributeDefinitions; + return $this->attributeAssignments->map( + fn (ArticleTypeAttribute $a) => $a->getAttributeDefinition() + ); } + /** @return Collection */ + public function getRequiredAttributeDefinitions(): Collection + { + return $this->attributeAssignments + ->filter(fn (ArticleTypeAttribute $a) => $a->isRequired()) + ->map(fn (ArticleTypeAttribute $a) => $a->getAttributeDefinition()); + } + + // ------------------------------------------------------------------------- + // Virtual form properties — collected by the admin form, applied on persist + // ------------------------------------------------------------------------- + + /** @return Collection */ + public function getRequiredAttributeDefs(): Collection + { + return $this->attributeAssignments + ->filter(fn (ArticleTypeAttribute $a) => $a->isRequired()) + ->map(fn (ArticleTypeAttribute $a) => $a->getAttributeDefinition()); + } + + /** @param iterable $defs */ + public function setRequiredAttributeDefs(iterable $defs): void + { + $this->pendingRequired = $defs instanceof Collection ? $defs->toArray() : \iterator_to_array($defs, false); + } + + /** @return Collection */ + public function getOptionalAttributeDefs(): Collection + { + return $this->attributeAssignments + ->filter(fn (ArticleTypeAttribute $a) => !$a->isRequired()) + ->map(fn (ArticleTypeAttribute $a) => $a->getAttributeDefinition()); + } + + /** @param iterable $defs */ + public function setOptionalAttributeDefs(iterable $defs): void + { + $this->pendingOptional = $defs instanceof Collection ? $defs->toArray() : \iterator_to_array($defs, false); + } + + /** + * Reconcile attributeAssignments from pending form data. + * Call this in persistEntity / updateEntity before flushing. + */ + public function applyAttributeAssignments(): void + { + if ($this->pendingRequired === null && $this->pendingOptional === null) { + return; + } + + $required = $this->pendingRequired ?? $this->getRequiredAttributeDefs()->toArray(); + $optional = $this->pendingOptional ?? $this->getOptionalAttributeDefs()->toArray(); + + // desired: defId => required bool + $desired = []; + foreach ($required as $def) { + $desired[$def->getId()->toRfc4122()] = ['def' => $def, 'required' => true]; + } + foreach ($optional as $def) { + $desired[$def->getId()->toRfc4122()] = ['def' => $def, 'required' => false]; + } + + // Update or remove existing assignments + foreach ($this->attributeAssignments as $assignment) { + $id = $assignment->getAttributeDefinition()->getId()->toRfc4122(); + if (isset($desired[$id])) { + $assignment->setRequired($desired[$id]['required']); + unset($desired[$id]); + } else { + $this->attributeAssignments->removeElement($assignment); + } + } + + // Add new assignments + foreach ($desired as $item) { + $this->attributeAssignments->add(new ArticleTypeAttribute($this, $item['def'], $item['required'])); + } + + $this->pendingRequired = null; + $this->pendingOptional = null; + } + + // ------------------------------------------------------------------------- + // Programmatic helpers (used by ArticleTypeService and tests) + // ------------------------------------------------------------------------- + public function addAttributeDefinition(AttributeDefinition $def): void { - if (!$this->attributeDefinitions->contains($def)) { - $this->attributeDefinitions->add($def); + foreach ($this->attributeAssignments as $assignment) { + if ($assignment->getAttributeDefinition() === $def) { + return; + } } + $this->attributeAssignments->add(new ArticleTypeAttribute($this, $def)); } public function removeAttributeDefinition(AttributeDefinition $def): void { - $this->attributeDefinitions->removeElement($def); + foreach ($this->attributeAssignments as $assignment) { + if ($assignment->getAttributeDefinition() === $def) { + $this->attributeAssignments->removeElement($assignment); + + return; + } + } } } diff --git a/src/Domain/Article/ArticleTypeAttribute.php b/src/Domain/Article/ArticleTypeAttribute.php new file mode 100644 index 0000000..72cafe7 --- /dev/null +++ b/src/Domain/Article/ArticleTypeAttribute.php @@ -0,0 +1,62 @@ +id = Uuid::v7(); + $this->articleType = $articleType; + $this->attributeDefinition = $attributeDefinition; + $this->required = $required; + } + + public function getId(): Uuid + { + return $this->id; + } + + public function getArticleType(): ArticleType + { + return $this->articleType; + } + + public function getAttributeDefinition(): AttributeDefinition + { + return $this->attributeDefinition; + } + + public function isRequired(): bool + { + return $this->required; + } + + public function setRequired(bool $required): void + { + $this->required = $required; + } +} diff --git a/src/Domain/Article/AttributeDefinition.php b/src/Domain/Article/AttributeDefinition.php index 44600b5..eef5a69 100644 --- a/src/Domain/Article/AttributeDefinition.php +++ b/src/Domain/Article/AttributeDefinition.php @@ -60,6 +60,11 @@ class AttributeDefinition return $this->type; } + public function setType(AttributeType $type): void + { + $this->type = $type; + } + public function getUnit(): ?string { return $this->unit; diff --git a/src/Infrastructure/Http/Controller/Admin/ArticleTypeCrudController.php b/src/Infrastructure/Http/Controller/Admin/ArticleTypeCrudController.php index 5f8c2c8..a88802b 100644 --- a/src/Infrastructure/Http/Controller/Admin/ArticleTypeCrudController.php +++ b/src/Infrastructure/Http/Controller/Admin/ArticleTypeCrudController.php @@ -5,13 +5,16 @@ declare(strict_types=1); namespace App\Infrastructure\Http\Controller\Admin; use App\Domain\Article\ArticleType; -use EasyCorp\Bundle\EasyAdminBundle\Config\Action; -use EasyCorp\Bundle\EasyAdminBundle\Config\Actions; +use App\Domain\Article\AttributeDefinition; +use Doctrine\ORM\EntityManagerInterface; +use EasyCorp\Bundle\EasyAdminBundle\Config\Assets; use EasyCorp\Bundle\EasyAdminBundle\Config\Crud; use EasyCorp\Bundle\EasyAdminBundle\Controller\AbstractCrudController; -use EasyCorp\Bundle\EasyAdminBundle\Field\AssociationField; +use EasyCorp\Bundle\EasyAdminBundle\Field\Field; use EasyCorp\Bundle\EasyAdminBundle\Field\IdField; +use EasyCorp\Bundle\EasyAdminBundle\Field\IntegerField; use EasyCorp\Bundle\EasyAdminBundle\Field\TextField; +use Symfony\Bridge\Doctrine\Form\Type\EntityType; /** @extends AbstractCrudController */ final class ArticleTypeCrudController extends AbstractCrudController @@ -26,23 +29,61 @@ final class ArticleTypeCrudController extends AbstractCrudController return $crud->setEntityLabelInSingular('Article Type')->setEntityLabelInPlural('Article Types'); } + public function configureAssets(Assets $assets): Assets + { + return $assets->addJsFile('js/admin/article-type-attr-sync.js'); + } + public function createEntity(string $entityFqcn): ArticleType { return new ArticleType(''); } - public function configureActions(Actions $actions): Actions - { - return $actions; - } - public function configureFields(string $pageName): iterable { yield IdField::new('id')->hideOnForm(); yield TextField::new('name', 'Name'); - yield AssociationField::new('attributeDefinitions', 'Attributes') - ->setFormTypeOptions(['by_reference' => false]) - ->autocomplete() + yield IntegerField::new('attributeAssignments', '# Attributes') + ->formatValue(static fn (mixed $v): int => is_countable($v) ? count($v) : 0) + ->hideOnForm() + ->setSortable(false); + + yield Field::new('requiredAttributeDefs', 'Required Attributes') + ->setFormType(EntityType::class) + ->setFormTypeOptions([ + 'class' => AttributeDefinition::class, + 'multiple' => true, + 'choice_label' => 'name', + 'required' => false, + 'by_reference' => false, + 'attr' => ['data-ea-widget' => 'ea-autocomplete'], + ]) + ->hideOnIndex(); + + yield Field::new('optionalAttributeDefs', 'Optional Attributes') + ->setFormType(EntityType::class) + ->setFormTypeOptions([ + 'class' => AttributeDefinition::class, + 'multiple' => true, + 'choice_label' => 'name', + 'required' => false, + 'by_reference' => false, + 'attr' => ['data-ea-widget' => 'ea-autocomplete'], + ]) ->hideOnIndex(); } + + public function persistEntity(EntityManagerInterface $entityManager, mixed $entityInstance): void + { + \assert($entityInstance instanceof ArticleType); + $entityInstance->applyAttributeAssignments(); + parent::persistEntity($entityManager, $entityInstance); + } + + public function updateEntity(EntityManagerInterface $entityManager, mixed $entityInstance): void + { + \assert($entityInstance instanceof ArticleType); + $entityInstance->applyAttributeAssignments(); + parent::updateEntity($entityManager, $entityInstance); + } } diff --git a/tests/Unit/Domain/Article/ArticleTypeAttributeTest.php b/tests/Unit/Domain/Article/ArticleTypeAttributeTest.php new file mode 100644 index 0000000..ca94676 --- /dev/null +++ b/tests/Unit/Domain/Article/ArticleTypeAttributeTest.php @@ -0,0 +1,53 @@ +type = new ArticleType('Laptop'); + $this->def = new AttributeDefinition('RAM', AttributeType::Select); + } + + public function testNotRequiredByDefault(): void + { + $assignment = new ArticleTypeAttribute($this->type, $this->def); + + self::assertFalse($assignment->isRequired()); + } + + public function testCanBeCreatedAsRequired(): void + { + $assignment = new ArticleTypeAttribute($this->type, $this->def, required: true); + + self::assertTrue($assignment->isRequired()); + } + + public function testSetRequired(): void + { + $assignment = new ArticleTypeAttribute($this->type, $this->def); + $assignment->setRequired(true); + + self::assertTrue($assignment->isRequired()); + } + + public function testReferencesCorrectTypeAndDefinition(): void + { + $assignment = new ArticleTypeAttribute($this->type, $this->def); + + self::assertSame($this->type, $assignment->getArticleType()); + self::assertSame($this->def, $assignment->getAttributeDefinition()); + } +} diff --git a/tests/Unit/Domain/Article/ArticleTypeRequiredAttributesTest.php b/tests/Unit/Domain/Article/ArticleTypeRequiredAttributesTest.php new file mode 100644 index 0000000..fb829b9 --- /dev/null +++ b/tests/Unit/Domain/Article/ArticleTypeRequiredAttributesTest.php @@ -0,0 +1,156 @@ +type = new ArticleType('Laptop'); + $this->ram = new AttributeDefinition('RAM', AttributeType::Select); + $this->cpu = new AttributeDefinition('CPU', AttributeType::String); + $this->color = new AttributeDefinition('Color', AttributeType::String); + } + + public function testGetAttributeDefinitionsReturnsAll(): void + { + $this->type->addAttributeDefinition($this->ram); + $this->type->addAttributeDefinition($this->cpu); + + self::assertCount(2, $this->type->getAttributeDefinitions()); + } + + public function testGetRequiredAttributeDefinitionsReturnsOnlyRequired(): void + { + $this->type->addAttributeDefinition($this->ram); + $this->type->addAttributeDefinition($this->cpu); + $this->type->addAttributeDefinition($this->color); + + // mark RAM and CPU required + foreach ($this->type->getAttributeAssignments() as $a) { + if ($a->getAttributeDefinition() === $this->ram || $a->getAttributeDefinition() === $this->cpu) { + $a->setRequired(true); + } + } + + $required = $this->type->getRequiredAttributeDefinitions(); + self::assertCount(2, $required); + self::assertContains($this->ram, $required->toArray()); + self::assertContains($this->cpu, $required->toArray()); + self::assertNotContains($this->color, $required->toArray()); + } + + public function testAddAttributeDefinitionIdempotent(): void + { + $this->type->addAttributeDefinition($this->ram); + $this->type->addAttributeDefinition($this->ram); + + self::assertCount(1, $this->type->getAttributeDefinitions()); + } + + public function testRemoveAttributeDefinition(): void + { + $this->type->addAttributeDefinition($this->ram); + $this->type->addAttributeDefinition($this->cpu); + $this->type->removeAttributeDefinition($this->ram); + + $defs = $this->type->getAttributeDefinitions()->toArray(); + self::assertCount(1, $defs); + self::assertNotContains($this->ram, $defs); + self::assertContains($this->cpu, $defs); + } + + public function testNoRequiredAttributesReturnsEmpty(): void + { + $this->type->addAttributeDefinition($this->ram); + + self::assertCount(0, $this->type->getRequiredAttributeDefinitions()); + } + + public function testApplyAttributeAssignmentsCreatesAssignments(): void + { + $this->type->setRequiredAttributeDefs([$this->ram, $this->cpu]); + $this->type->setOptionalAttributeDefs([$this->color]); + $this->type->applyAttributeAssignments(); + + self::assertCount(3, $this->type->getAttributeDefinitions()); + $required = $this->type->getRequiredAttributeDefinitions()->toArray(); + self::assertCount(2, $required); + self::assertContains($this->ram, $required); + self::assertContains($this->cpu, $required); + self::assertNotContains($this->color, $required); + } + + public function testApplyAttributeAssignmentsIsNoopWhenNothingPending(): void + { + $this->type->addAttributeDefinition($this->ram); + $this->type->applyAttributeAssignments(); + + self::assertCount(1, $this->type->getAttributeDefinitions()); + } + + public function testApplyAttributeAssignmentsUpdatesRequiredFlag(): void + { + $this->type->addAttributeDefinition($this->ram); + self::assertCount(0, $this->type->getRequiredAttributeDefinitions()); + + $this->type->setRequiredAttributeDefs([$this->ram]); + $this->type->setOptionalAttributeDefs([]); + $this->type->applyAttributeAssignments(); + + $required = $this->type->getRequiredAttributeDefinitions()->toArray(); + self::assertCount(1, $required); + self::assertContains($this->ram, $required); + } + + public function testApplyAttributeAssignmentsRemovesUnlistedAttributes(): void + { + $this->type->setRequiredAttributeDefs([$this->ram, $this->cpu]); + $this->type->setOptionalAttributeDefs([]); + $this->type->applyAttributeAssignments(); + self::assertCount(2, $this->type->getAttributeDefinitions()); + + $this->type->setRequiredAttributeDefs([$this->ram]); + $this->type->setOptionalAttributeDefs([]); + $this->type->applyAttributeAssignments(); + + $defs = $this->type->getAttributeDefinitions()->toArray(); + self::assertCount(1, $defs); + self::assertContains($this->ram, $defs); + self::assertNotContains($this->cpu, $defs); + } + + public function testApplyAttributeAssignmentsSwapsRequiredAndOptional(): void + { + // Set ram=required, cpu=optional + $this->type->setRequiredAttributeDefs([$this->ram]); + $this->type->setOptionalAttributeDefs([$this->cpu]); + $this->type->applyAttributeAssignments(); + + // Swap: ram=optional, cpu=required + $this->type->setRequiredAttributeDefs([$this->cpu]); + $this->type->setOptionalAttributeDefs([$this->ram]); + $this->type->applyAttributeAssignments(); + + $required = $this->type->getRequiredAttributeDefinitions()->toArray(); + self::assertContains($this->cpu, $required); + self::assertNotContains($this->ram, $required); + + $optional = $this->type->getOptionalAttributeDefs()->toArray(); + self::assertContains($this->ram, $optional); + self::assertNotContains($this->cpu, $optional); + } +} diff --git a/tests/Unit/Domain/Article/AttributeDefinitionTest.php b/tests/Unit/Domain/Article/AttributeDefinitionTest.php new file mode 100644 index 0000000..d29f57f --- /dev/null +++ b/tests/Unit/Domain/Article/AttributeDefinitionTest.php @@ -0,0 +1,67 @@ +getName()); + self::assertSame(AttributeType::Select, $def->getType()); + self::assertNull($def->getUnit()); + self::assertNull($def->getOptions()); + } + + /** + * @dataProvider allAttributeTypes + */ + public function testSetTypeMutatesType(AttributeType $type): void + { + $def = new AttributeDefinition('Field', AttributeType::String); + $def->setType($type); + + self::assertSame($type, $def->getType()); + } + + /** @return array */ + public static function allAttributeTypes(): array + { + return array_combine( + array_map(fn (AttributeType $t) => $t->value, AttributeType::cases()), + array_map(fn (AttributeType $t) => [$t], AttributeType::cases()), + ); + } + + public function testSetNameAndUnit(): void + { + $def = new AttributeDefinition('Weight', AttributeType::Float); + $def->setName('Mass'); + $def->setUnit('kg'); + + self::assertSame('Mass', $def->getName()); + self::assertSame('kg', $def->getUnit()); + } + + public function testSetOptions(): void + { + $def = new AttributeDefinition('Grade', AttributeType::Select); + $def->setOptions(['A', 'B', 'C']); + + self::assertSame(['A', 'B', 'C'], $def->getOptions()); + } + + public function testToStringReturnsName(): void + { + $def = new AttributeDefinition('CPU', AttributeType::String); + + self::assertSame('CPU', (string) $def); + } +} diff --git a/tests/Unit/Infrastructure/Admin/ArticleTypeCrudControllerTest.php b/tests/Unit/Infrastructure/Admin/ArticleTypeCrudControllerTest.php index 5b0f7ad..8f7da34 100644 --- a/tests/Unit/Infrastructure/Admin/ArticleTypeCrudControllerTest.php +++ b/tests/Unit/Infrastructure/Admin/ArticleTypeCrudControllerTest.php @@ -30,12 +30,22 @@ final class ArticleTypeCrudControllerTest extends TestCase self::assertSame('', $entity->getName()); } - public function testConfigureFieldsContainsNameAndAttributes(): void + public function testConfigureFieldsContainsExpectedProperties(): void { $fields = iterator_to_array($this->controller->configureFields('new')); $names = array_map(fn ($f) => $f->getAsDto()->getProperty(), $fields); self::assertContains('name', $names); - self::assertContains('attributeDefinitions', $names); + self::assertContains('requiredAttributeDefs', $names); + self::assertContains('optionalAttributeDefs', $names); + } + + public function testConfigureFieldsForIndexContainsNameAndCount(): void + { + $fields = iterator_to_array($this->controller->configureFields('index')); + $names = array_map(fn ($f) => $f->getAsDto()->getProperty(), $fields); + + self::assertContains('name', $names); + self::assertContains('attributeAssignments', $names); } }