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 <noreply@anthropic.com>
This commit is contained in:
Simon Kuehn 2026-05-17 22:43:42 +00:00
parent f915bba966
commit 838b96eb14
10 changed files with 629 additions and 22 deletions

View file

@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260517222701 extends AbstractMigration
{
public function getDescription(): string
{
return 'Promote article_type_attributes join table to entity with id + required flag';
}
public function up(Schema $schema): void
{
$this->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)');
}
}

View file

@ -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();
});
}());

View file

@ -20,16 +20,21 @@ class ArticleType
#[ORM\Column(type: 'string', length: 255, unique: true)] #[ORM\Column(type: 'string', length: 255, unique: true)]
private string $name; private string $name;
/** @var Collection<int, AttributeDefinition> */ /** @var Collection<int, ArticleTypeAttribute> */
#[ORM\ManyToMany(targetEntity: AttributeDefinition::class)] #[ORM\OneToMany(targetEntity: ArticleTypeAttribute::class, mappedBy: 'articleType', cascade: ['persist', 'remove'], orphanRemoval: true)]
#[ORM\JoinTable(name: 'article_type_attributes', schema: 'app')] private Collection $attributeAssignments;
private Collection $attributeDefinitions;
/** @var list<AttributeDefinition>|null pending from form — applied by applyAttributeAssignments() */
private ?array $pendingRequired = null;
/** @var list<AttributeDefinition>|null */
private ?array $pendingOptional = null;
public function __construct(string $name) public function __construct(string $name)
{ {
$this->id = Uuid::v7(); $this->id = Uuid::v7();
$this->name = $name; $this->name = $name;
$this->attributeDefinitions = new ArrayCollection(); $this->attributeAssignments = new ArrayCollection();
} }
public function getId(): Uuid public function getId(): Uuid
@ -47,21 +52,125 @@ class ArticleType
$this->name = $name; $this->name = $name;
} }
/** @return Collection<int, ArticleTypeAttribute> */
public function getAttributeAssignments(): Collection
{
return $this->attributeAssignments;
}
/** All attribute definitions regardless of required flag — used by pipeline agents. */
/** @return Collection<int, AttributeDefinition> */ /** @return Collection<int, AttributeDefinition> */
public function getAttributeDefinitions(): Collection public function getAttributeDefinitions(): Collection
{ {
return $this->attributeDefinitions; return $this->attributeAssignments->map(
fn (ArticleTypeAttribute $a) => $a->getAttributeDefinition()
);
} }
/** @return Collection<int, AttributeDefinition> */
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<int, AttributeDefinition> */
public function getRequiredAttributeDefs(): Collection
{
return $this->attributeAssignments
->filter(fn (ArticleTypeAttribute $a) => $a->isRequired())
->map(fn (ArticleTypeAttribute $a) => $a->getAttributeDefinition());
}
/** @param iterable<AttributeDefinition> $defs */
public function setRequiredAttributeDefs(iterable $defs): void
{
$this->pendingRequired = $defs instanceof Collection ? $defs->toArray() : \iterator_to_array($defs, false);
}
/** @return Collection<int, AttributeDefinition> */
public function getOptionalAttributeDefs(): Collection
{
return $this->attributeAssignments
->filter(fn (ArticleTypeAttribute $a) => !$a->isRequired())
->map(fn (ArticleTypeAttribute $a) => $a->getAttributeDefinition());
}
/** @param iterable<AttributeDefinition> $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 public function addAttributeDefinition(AttributeDefinition $def): void
{ {
if (!$this->attributeDefinitions->contains($def)) { foreach ($this->attributeAssignments as $assignment) {
$this->attributeDefinitions->add($def); if ($assignment->getAttributeDefinition() === $def) {
return;
} }
} }
$this->attributeAssignments->add(new ArticleTypeAttribute($this, $def));
}
public function removeAttributeDefinition(AttributeDefinition $def): void public function removeAttributeDefinition(AttributeDefinition $def): void
{ {
$this->attributeDefinitions->removeElement($def); foreach ($this->attributeAssignments as $assignment) {
if ($assignment->getAttributeDefinition() === $def) {
$this->attributeAssignments->removeElement($assignment);
return;
}
}
} }
} }

View file

@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
namespace App\Domain\Article;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Uid\Uuid;
#[ORM\Entity]
#[ORM\Table(name: 'article_type_attributes', schema: 'app')]
#[ORM\UniqueConstraint(columns: ['article_type_id', 'attribute_definition_id'])]
class ArticleTypeAttribute
{
#[ORM\Id]
#[ORM\Column(type: 'uuid')]
private Uuid $id;
#[ORM\ManyToOne(targetEntity: ArticleType::class, inversedBy: 'attributeAssignments')]
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
private ArticleType $articleType;
#[ORM\ManyToOne(targetEntity: AttributeDefinition::class)]
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
private AttributeDefinition $attributeDefinition;
#[ORM\Column(type: 'boolean')]
private bool $required = false;
public function __construct(ArticleType $articleType, AttributeDefinition $attributeDefinition, bool $required = false)
{
$this->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;
}
}

View file

@ -60,6 +60,11 @@ class AttributeDefinition
return $this->type; return $this->type;
} }
public function setType(AttributeType $type): void
{
$this->type = $type;
}
public function getUnit(): ?string public function getUnit(): ?string
{ {
return $this->unit; return $this->unit;

View file

@ -5,13 +5,16 @@ declare(strict_types=1);
namespace App\Infrastructure\Http\Controller\Admin; namespace App\Infrastructure\Http\Controller\Admin;
use App\Domain\Article\ArticleType; use App\Domain\Article\ArticleType;
use EasyCorp\Bundle\EasyAdminBundle\Config\Action; use App\Domain\Article\AttributeDefinition;
use EasyCorp\Bundle\EasyAdminBundle\Config\Actions; use Doctrine\ORM\EntityManagerInterface;
use EasyCorp\Bundle\EasyAdminBundle\Config\Assets;
use EasyCorp\Bundle\EasyAdminBundle\Config\Crud; use EasyCorp\Bundle\EasyAdminBundle\Config\Crud;
use EasyCorp\Bundle\EasyAdminBundle\Controller\AbstractCrudController; 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\IdField;
use EasyCorp\Bundle\EasyAdminBundle\Field\IntegerField;
use EasyCorp\Bundle\EasyAdminBundle\Field\TextField; use EasyCorp\Bundle\EasyAdminBundle\Field\TextField;
use Symfony\Bridge\Doctrine\Form\Type\EntityType;
/** @extends AbstractCrudController<ArticleType> */ /** @extends AbstractCrudController<ArticleType> */
final class ArticleTypeCrudController extends AbstractCrudController final class ArticleTypeCrudController extends AbstractCrudController
@ -26,23 +29,61 @@ final class ArticleTypeCrudController extends AbstractCrudController
return $crud->setEntityLabelInSingular('Article Type')->setEntityLabelInPlural('Article Types'); 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 public function createEntity(string $entityFqcn): ArticleType
{ {
return new ArticleType(''); return new ArticleType('');
} }
public function configureActions(Actions $actions): Actions
{
return $actions;
}
public function configureFields(string $pageName): iterable public function configureFields(string $pageName): iterable
{ {
yield IdField::new('id')->hideOnForm(); yield IdField::new('id')->hideOnForm();
yield TextField::new('name', 'Name'); yield TextField::new('name', 'Name');
yield AssociationField::new('attributeDefinitions', 'Attributes') yield IntegerField::new('attributeAssignments', '# Attributes')
->setFormTypeOptions(['by_reference' => false]) ->formatValue(static fn (mixed $v): int => is_countable($v) ? count($v) : 0)
->autocomplete() ->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(); ->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);
}
} }

View file

@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Domain\Article;
use App\Domain\Article\ArticleType;
use App\Domain\Article\ArticleTypeAttribute;
use App\Domain\Article\AttributeDefinition;
use App\Domain\Article\AttributeType;
use PHPUnit\Framework\TestCase;
final class ArticleTypeAttributeTest extends TestCase
{
private ArticleType $type;
private AttributeDefinition $def;
protected function setUp(): void
{
$this->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());
}
}

View file

@ -0,0 +1,156 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Domain\Article;
use App\Domain\Article\ArticleType;
use App\Domain\Article\ArticleTypeAttribute;
use App\Domain\Article\AttributeDefinition;
use App\Domain\Article\AttributeType;
use PHPUnit\Framework\TestCase;
final class ArticleTypeRequiredAttributesTest extends TestCase
{
private ArticleType $type;
private AttributeDefinition $ram;
private AttributeDefinition $cpu;
private AttributeDefinition $color;
protected function setUp(): void
{
$this->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);
}
}

View file

@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Domain\Article;
use App\Domain\Article\AttributeDefinition;
use App\Domain\Article\AttributeType;
use PHPUnit\Framework\TestCase;
final class AttributeDefinitionTest extends TestCase
{
public function testConstructorSetsNameAndType(): void
{
$def = new AttributeDefinition('RAM', AttributeType::Select);
self::assertSame('RAM', $def->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<string, array{AttributeType}> */
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);
}
}

View file

@ -30,12 +30,22 @@ final class ArticleTypeCrudControllerTest extends TestCase
self::assertSame('', $entity->getName()); self::assertSame('', $entity->getName());
} }
public function testConfigureFieldsContainsNameAndAttributes(): void public function testConfigureFieldsContainsExpectedProperties(): void
{ {
$fields = iterator_to_array($this->controller->configureFields('new')); $fields = iterator_to_array($this->controller->configureFields('new'));
$names = array_map(fn ($f) => $f->getAsDto()->getProperty(), $fields); $names = array_map(fn ($f) => $f->getAsDto()->getProperty(), $fields);
self::assertContains('name', $names); 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);
} }
} }