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:
parent
f915bba966
commit
838b96eb14
10 changed files with 629 additions and 22 deletions
34
migrations/Version20260517222701.php
Normal file
34
migrations/Version20260517222701.php
Normal 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)');
|
||||||
|
}
|
||||||
|
}
|
||||||
70
public/js/admin/article-type-attr-sync.js
Normal file
70
public/js/admin/article-type-attr-sync.js
Normal 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();
|
||||||
|
});
|
||||||
|
}());
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
62
src/Domain/Article/ArticleTypeAttribute.php
Normal file
62
src/Domain/Article/ArticleTypeAttribute.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
53
tests/Unit/Domain/Article/ArticleTypeAttributeTest.php
Normal file
53
tests/Unit/Domain/Article/ArticleTypeAttributeTest.php
Normal 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
156
tests/Unit/Domain/Article/ArticleTypeRequiredAttributesTest.php
Normal file
156
tests/Unit/Domain/Article/ArticleTypeRequiredAttributesTest.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
67
tests/Unit/Domain/Article/AttributeDefinitionTest.php
Normal file
67
tests/Unit/Domain/Article/AttributeDefinitionTest.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue