Personnaliser Ibexa Commerce - Attributs produit personnalisés

Comment créer un nouveau type d'attribut produit dans Ibexa Commerce ?
Ibexa Commerce New custom attributs

16 août 2024

Durant la réalisation d'un projet client en Ibexa Commerce, j'ai eu besoin de rajouter un attribut discriminant sur un type de produit pour contenir un simple texte. Sauf que Ibexa Commerce n'inclut pas nativement ce type d'attribut.

Si l'attribut concernait le produit et non les variantes, j'aurais simplement pu remplacer l'attribut par un champ de contenu, vu que les produits sont des contenus. Pour les variantes en revanche, je suis limité par les types d'attributs existants.

J'ai donc entrepris d'examiner le code d'Ibexa Commerce pour comprendre comment sont gérés les types d'attributs, et pouvoir créer le mien. Au final, cela consiste en une table pour stocker les données, quelques classes PHP, et un peu de configuration de services.

Déclaration

Pour commencer, un peu de configuration pour déclarer le type d'attribut. Dans votre configuration de services :

   

services:
   app.commerce.attribute_type.string:
       class: Ibexa\ProductCatalog\Local\Repository\Attribute\AttributeType
       arguments:
           $identifier: string
       tags:
           -   name: ibexa.product_catalog.attribute_type
               alias: string 
   

On déclare simplement un nouveau type d'attribut avec comme identifiant string. Cet identifiant sera réutilisé pour faire le lien avec les autres services utilisés par notre type d'attribut.

Table

Ibexa Commerce enregistre les attributs des produits dans les tables ibexa_product_specification_attribute_*, une par type d'attribut. Je vais donc faire de même et créer une table ibexa_product_specification_attribute_string.

Voici la migration Doctrine associée :

   

<?php 
declare(strict_types=1); 
namespace DoctrineMigrations; 
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration; 
final class Version20240122141124 extends AbstractMigration
{
   public function up(Schema $schema): void
   {
       $this->addSql("CREATE TABLE ibexa_product_specification_attribute_string (id INT NOT NULL PRIMARY KEY, value VARCHAR(255) NULL, CONSTRAINT ibexa_product_specification_attribute_string_fk FOREIGN KEY (id) REFERENCES ibexa_product_specification_attribute (id) ON UPDATE CASCADE ON DELETE CASCADE);");
       $this->addSql("CREATE INDEX ibexa_product_specification_attribute_string_value_idx ON ibexa_product_specification_attribute_string (value);");
   } 
   public function down(Schema $schema): void
   {
       $this->addSql("DROP TABLE ibexa_product_specification_attribute_string");
   }
} 
   

Persistence

Maintenant que j'ai ma table, il faut indiquer à Ibexa comment stocker mes valeurs. Tout d'abord, je créé une classe pour représenter la structure de la table :

   

<?php 
declare(strict_types=1); 
namespace App\Commerce\Attribute\String; 
use Doctrine\DBAL\Types\Types;
use Ibexa\Contracts\ProductCatalog\Local\Attribute\StorageDefinitionInterface; 
class StringStorageDefinition implements StorageDefinitionInterface
{
   // Liste de colonnes (hors id) avec leur type
   public function getColumns(): array
   {
       return [
           'value' => Types::TEXT,
       ];
   } 
   // Nom de la table
   public function getTableName(): string
   {
       return 'ibexa_product_specification_attribute_string';
   }
} 
   

Puis une classe pour faire convertir les données de la table en données applicatives et inversement :

   

<?php 
declare(strict_types=1); 
namespace App\Commerce\Attribute\String; 
use Ibexa\Contracts\ProductCatalog\Local\Attribute\StorageConverterInterface; 
class StringStorageConverter implements StorageConverterInterface
{
   // On lit simplement le contenu de la colonne value
   public function fromPersistence(array $data)
   {
       return $data['value'];
   } 
   // On met la valeur applicative dans la colonne value
   public function toPersistence($value): array
   {
       return [
           'value' => $value,
       ];
   }
} 
   

Et pour finir, un peu de configuration :

   

services:
    App\Commerce\Attribute\String\StringStorageConverter:
       tags:
           -   name: ibexa.product_catalog.attribute.storage_converter
               type: string # L'identifiant de notre type 
   App\Commerce\Attribute\String\StringStorageDefinition:
       tags:
           -   name: ibexa.product_catalog.attribute.storage_definition
               type: string # L'identifiant de notre type 
   

Formulaire d'édition

Je crée une classe pour construire le formulaire d'édition :

   

<?php 
declare(strict_types=1); 
namespace App\Commerce\Attribute\String; 
use Ibexa\Bundle\ProductCatalog\Validator\Constraints\AttributeValue;
use Ibexa\Contracts\ProductCatalog\Local\Attribute\ValueFormMapperInterface;
use Ibexa\Contracts\ProductCatalog\Values\AttributeDefinitionAssignmentInterface;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Validator\Constraints as Assert; 
class StringValueFormMapper implements ValueFormMapperInterface
{
   public function createValueForm(
       string $name,
       FormBuilderInterface $builder,
       AttributeDefinitionAssignmentInterface $assignment,
       array $context = []
   ): void {
       $definition = $assignment->getAttributeDefinition(); 
       // Les options sont presque copiées/collées telles qu'elles de la classe 
       // native IntegerValueFormMapper
       $options = [
           'disabled' => $context['translation_mode'] ?? false,
           'label' => $definition->getName(),
           'block_prefix' => 'string_attribute_value',
           'required' => $assignment->isRequired(),
           'constraints' => [
               new AttributeValue([
                   'definition' => $definition,
               ]),
           ],
       ]; 
       if ($assignment->isRequired()) {
           $options['constraints'][] = new Assert\NotBlank();
       } 
       // J'ajoute un champ texte, comme ma valeur est une simple string
       $builder->add($name, TextType::class, $options);
   }
} 
   

Et encore une fois, un peu de configuration :

   

services:
   App\Commerce\Attribute\String\StringValueFormMapper:
       tags:
           -   name: ibexa.product_catalog.attribute.form_mapper.value
               type: string # L'identifiant de notre type 
   

Affichage

Et enfin la dernière étape, je crée une classe pour indiquer à Ibexa comment formater cette valeur pour affichage :

   

<?php 
declare(strict_types=1); 
namespace App\Commerce\Attribute\String; 
use Ibexa\Contracts\ProductCatalog\Local\Attribute\ValueFormatterInterface;
use Ibexa\Contracts\ProductCatalog\Values\AttributeInterface; 
class StringValueFormatter implements ValueFormatterInterface
{
   public function formatValue(AttributeInterface $attribute, array $parameters = []): ?string
   {
       // Comme ma valeur est déjà une chaîne, je la retourne telle qu'elle
       return $attribute->getValue();
   }
} 
   

Et le dernier morceau de configuration :

   

services:
   App\Commerce\Attribute\String\StringValueFormatter:
       tags:
           -   name: ibexa.product_catalog.attribute.formatter.value
               type: string # L'identifiant de notre type 
   

Conclusion

Malgré l'absence de documentation sur le sujet, le code associé est simple et clair. Dans mon cas, j'ai simplement copié/collé le code associé au type Integer et je l'ai adapté pour une chaîne. Pour un type plus complexe, j'aurais pu m'inspirer du type SingleMeasurement, qui stocke à la fois une valeur numérique, et une unité.