Prestashop : Ajouter des champs à une catégorie

Ce tutoriel est compatible avec les versions de Prestashop suivantes :
1.7.6 1.7.7 1.7.8 +

Cette article inaugure une nouvelle série d’articles à venir sur comment étendre les entités Prestashop sans surcharges.
C’est connu depuis très longtemps qu’il est déconseillé d’utiliser les surcharges pour ajouter des champs, pour autant il n’y avait pas forcément d’autres solutions, c’est de moins en moins vrai 🙂

Ce tutoriel est uniquement compatible avec les versions de Prestashop supérieures à la 1.7.6 ( Lorsque la page de gestion admin est passée sous symfony )
Mais le hook essentiel filterCategoryContent est présent depuis la version 1.7.1
La même logique avec une autre gestion dans l’administration est donc possible.

Pour l’exemple nous allons donc créer un module hh_categoryfields qui va ajouter 3 champs aux catégories :

  • Code erp , type texte commun à toutes les langues et tous les sites
  • Description SEO , type texte avec possibilité de le traduire pour chaque langue installée dans chaque boutique
  • Image : Image commune à toutes les langues et tous les sites.

 

Ajout des champs dans le formulaire d’édition admin

Pour l’ajout des champs dans le formulaire d’administration j’ai déjà détaillé le concept de fonctionnement dans l’article : https://www.h-hennes.fr/blog/2019/08/05/prestashop-1-7-ajouter-des-champs-dans-un-formulaire-dadministration/

Le module sera donc greffé sur les hooks suivants :

  • hookActionCategoryFormBuilderModifier
  • hookActionAfterCreateCategoryFormHandler
  • hookActionAfterUpdateCategoryFormHandler

Le code complet sera disponible en fin d’article

Gestion des champs

Ces champs seront gérés via une classe Spécifique qui hérite de la classe ObjectModel de Prestashop ( j’ai choisi de faire simple et ne pas faire ce développement via symfony )
Dont voici donc son code qui est très basique.
J’ai uniquement ajouté des fonctions  pour gérer l’installation / désinstallation en créant les tables mysql et les dossiers nécessaires au bon fonctionnement du module

<?php
use Symfony\Component\Filesystem\Exception\IOException;
use Symfony\Component\Filesystem\Filesystem;
 
if (!defined('_PS_VERSION_')) {
    exit;
}
 
class CategoryFields extends ObjectModel
{
    /** @var int Object id */
    public $id;
    /** @var int Identifiant de la catégorie prestashop */
    public $id_category;
    /** @var string Code erp de la catégorie */
    public $code;
    /** @var string image additionnelle */
    public $image_field;
    /** @var string  Champ langue */
    public $description_seo;
 
    public static $definition = [
        'table' => 'hh_category_field',
        'primary' => 'id_category_extra',
        'multilang' => true,
        'multilang_shop' => true,
        'fields' => [
            'id_category' => ['type' => self::TYPE_INT, 'validate' => 'isInt', 'length' => 10],
            'code' => ['type' => self::TYPE_STRING, 'validate' => 'isCleanHtml', 'length' => 50],
            'image_field' => ['type' => self::TYPE_STRING, 'validate' => 'isCleanHtml', 'length' => 255],
            'description_seo' => ['type' => self::TYPE_HTML, 'validate' => 'isCleanHtml', 'lang' => true]
        ]
    ];
 
    /**
     * Récupération de l'identifiant de l'entité via l'identifiant de catégorie
     * @param int $id_category
     * @return false|string|null
     */
    public static function getIdByCategoryId(int $id_category)
    {
        return Db::getInstance()->getValue(
            (new DbQuery())
                ->select(self::$definition['primary'])
                ->from(self::$definition['table'])
                ->where('id_category=' . $id_category)
        );
    }
 
    /**
     * Installation du modèle
     * A ajouter dans l'installation du module
     */
    public static function installSql(): bool
    {
        try {
            //Création de la table avec les champs communs
            $createTable = Db::getInstance()->execute(
                "CREATE TABLE IF NOT EXISTS `"._DB_PREFIX_."hh_category_field`(
                `id_category_extra` int(10)  NOT NULL AUTO_INCREMENT,
                `id_category` INT(10) NOT NULL,
                `code` VARCHAR (50),
                `image_field` VARCHAR (50),
                PRIMARY KEY (`id_category_extra`)
                ) ENGINE=InnoDB DEFAULT CHARSET=UTF8;"
            );
            //Création de la table des langues
            $createTableLang = Db::getInstance()->execute(
                "CREATE TABLE IF NOT EXISTS `"._DB_PREFIX_."hh_category_field_lang`(
                `id_category_extra` int(10)  NOT NULL AUTO_INCREMENT,
                `id_shop` INT(10) NOT NULL DEFAULT '1',
                `id_lang` INT(10) NOT NULL,
                `description_seo` TEXT,
                PRIMARY KEY (`id_category_extra`,`id_shop`,`id_lang`)
                ) ENGINE=InnoDB DEFAULT CHARSET=UTF8;"
            );
        } catch (PrestaShopException $e) {
            return false;
        }
 
        return $createTable && $createTableLang;
    }
 
    /**
     * Suppression des tables du modules
     * @return bool
     */
    public static function uninstallSql()
    {
        return Db::getInstance()->execute("DROP TABLE IF EXISTS "._DB_PREFIX_."hh_category_field")
            && Db::getInstance()->execute("DROP TABLE IF EXISTS "._DB_PREFIX_."hh_category_field_lang");
    }
 
    /**
     * Création du dossier pour envoyer les images du modules
     * ( Dans le dossier img de prestashop )
     * @return bool
     */
    public static function createImageDirectory(): bool
    {
        $fileSystem = new Filesystem();
        $imageDir = _PS_IMG_DIR_ . 'modules/hh_categoryfields/';
        if (!$fileSystem->exists($imageDir)) {
            try {
                $fileSystem->mkdir($imageDir);
            } catch (IOException $e) {
                return false;
            }
        }
        return true;
    }
 
    /**
     * Suppression du dossier pour envoyer les images du modules
     * ( Dans le dossier img de prestashop )
     * @return bool
     */
    public static function removeImageDirectory(): bool
    {
        $fileSystem = new Filesystem();
        $imageDir = _PS_IMG_DIR_ . 'modules/hh_categoryfields/';
        if (!$fileSystem->exists($imageDir)) {
            try {
                $fileSystem->remove($imageDir);
            } catch (IOException $e) {
                return false;
            }
        }
        return true;
    }
}

Affichage des informations en front

L’assignation des informations sur la page des catégories en front est gérée via le hook filterCategoryContent.
Celui-ci permets de rendre disponible les informations dans l’objet catégorie sur le front office.
Voici un code simplifié par rapport à celui du module pour voir le concept

/**
     * Ajout de contenu de catégorie sans surcharge
     * @param array $params
     * @return array
     */
    public function hookFilterCategoryContent(array $params)
    {
        //Ajout des valeur dans le tableau "object" qui correspond à la catégorie
        $params['object']['code'] = 'Code';
        $params['object']['description_seo'] = 'Description seo';
        return [
            'object' => $params['object']
        ];
    }

Les valeurs définies sont ensuite directement disponible dans le template front de la catégorie. themes/yourtheme/templates/catalog/listing/category.tpl
On peut donc par exemple rajouter le code suivant qui se chargera d’afficher nos nouvelles propriétés :

{*
    On rajoute tous nos champs spéciaux dans le footer de la catégorie pour l'exemple
    Mais l'avantage du hook filterCategoryContent est que le contenu est disponible partout sur la page dans n'importe quel block
*}
{block name='product_list_bottom'}
    {*Contenu du parent*}
    {include file='catalog/_partials/products-bottom.tpl' listing=$listing}
    
{$category|dump} {if isset($category.code)}

Code Erp : {$category.code}

{/if} {if isset($category.description_seo)}
{$category.description_seo}
{/if} {if isset($category.image_field)} {$category.name} {/if}
{/block}

Limites et restrictions

L’approche sans les surcharges ne permets malheureusement pas encore de gérer tous les cas.
Il n’est pas encore possible de rendre ces champs disponibles dans la liste des sous-catégories, ni dans l’api de prestashop.

Code complet

Voici le code complet de la classe de gestion du module.
La classe de gestion des champs est disponible plus haut.

 
<?php
if (!defined('_PS_VERSION_')) {
    exit;
}
 
require_once __DIR__ . '/classes/CategoryFields.php';
 
 
class Hh_categoryfields extends Module
{
    public function __construct()
    {
        $this->name = 'hh_categoryfields';
        $this->tab = 'others';
        $this->version = '0.1.0';
        $this->author = 'hhennes';
        $this->bootstrap = true;
        parent::__construct();
 
        $this->displayName = $this->l('Hh Category Fields');
        $this->description = $this->l('POC : Add category fields without override');
    }
 
    /**
     * Installation du module
     * @return bool
     */
    public function install()
    {
        if (!parent::install()
            || !$this->registerHook([
                'actionCategoryFormBuilderModifier',
                'actionAfterCreateCategoryFormHandler',
                'actionAfterUpdateCategoryFormHandler',
                'filterCategoryContent',
            ])
            || !CategoryFields::installSql()
            || !CategoryFields::createImageDirectory()
        ) {
            return false;
        }
        return true;
    }
 
    /**
     * Désinstallation du module
     * @return bool
     */
    public function uninstall()
    {
        if (
            !parent::uninstall()
            || !CategoryFields::uninstallSql()
            || !CategoryFields::removeImageDirectory()
        ) {
            return false;
        }
        return true;
    }
 
    /**
     * Ajout de contenu de catégorie sans surcharge
     * @param array $params
     * @return array
     */
    public function hookFilterCategoryContent(array $params)
    {
        $additional = $this->getCustomCategoryFields($params['object']['id']);
        if (count($additional)) {
            $params['object'] = array_merge($params['object'], $additional);
            return [
                'object' => $params['object']
            ];
        }
    }
 
    /**
     * Récupération des informations spécifiques de la catégorie
     *
     * @param int $id_category
     * @return array
     * @throws PrestaShopDatabaseException
     * @throws PrestaShopException
     */
    protected function getCustomCategoryFields(int $id_category): array
    {
        $return = [];
        $idCategoryField = CategoryFields::getIdByCategoryId($id_category);
        if ($idCategoryField) {
            $categoryField = new CategoryFields($idCategoryField,$this->context->language->id);
            $presenter = new \PrestaShop\PrestaShop\Adapter\Presenter\Object\ObjectPresenter();
            $return = $presenter->present($categoryField);
            $return['image_field'] = $this->getUploadUrl().$return['image_field'];
            //suppression des champs techniques
            unset($return['id_category']);
            unset($return['id']);
        }
        return $return;
    }
 
    /**
     * Modification du formulaire de la catégorie
     * @param array $params
     */
    public function hookActionCategoryFormBuilderModifier(array $params)
    {
        //Pour l'envoi du fichier : regarder ici : https://devdocs.prestashop.com/1.7/modules/sample-modules/extending-sf-form-with-upload-image-field/#introduction
        try {
            //Récupération des informations des champs custom
            $customFieldsValues = $this->getCustomFieldsValue($params['id']);
            $locales = $this->get('prestashop.adapter.legacy.context')->getLanguages();
 
            /** @var \Symfony\Component\Form\FormBuilder $formBuilder */
            $formBuilder = $params['form_builder'];
 
            //Ajout des champs dans le formulaire d'édition des catégories
 
            //Champ standard
            $formBuilder->add(
                $this->name . '_code',
                \Symfony\Component\Form\Extension\Core\Type\TextType::class,
                [
                    'label' => $this->l('Erp Code'),
                    'required' => false,
                    'constraints' => [
                        new \Symfony\Component\Validator\Constraints\Length([
                            'max' => 20,
                            'maxMessage' => $this->l('Max caracters allowed : 20'),
                        ]),
                    ],
                    'data' => $customFieldsValues['code'],
                    'help' => $this->name . ' :' . $this->l('This is the erp code')
                ]
            )
                //Champs langue ( qui gère le multi-shop )
                ->add(
                    $this->name . '_description_seo',
                    \PrestaShopBundle\Form\Admin\Type\TranslatableType::class,
                    [
                        'locales' => $locales,
                        'type' => \Symfony\Component\Form\Extension\Core\Type\TextareaType::class,
                        'label' => $this->l('Description Seo'),
                        'required' => false,
                        'data' => $customFieldsValues['description_seo'],
                        'help' => $this->name . ' :' . $this->l('Seo description for categories'),
                    ]
                )
                //Champ image
                ->add('image_file_upload', \Symfony\Component\Form\Extension\Core\Type\FileType::class, [
                    'label' => $this->l('Additional image'),
                    'required' => false,
                    'help' => $this->name . ' :' . $this->l('Additional image')
                ]);
 
 
            //Dans le cas ou l'image est définie
            //fonctionnement très basique checkbox pour la supprimer
            $categoryDatas = $this->getCustomFieldsValue($params['id']);
            if (!empty($categoryDatas['image_field'])) {
                $formBuilder
                    ->add($this->name . '_delete_current_image', \Symfony\Component\Form\Extension\Core\Type\CheckboxType::class, [
                        'label' => $this->l('Delete Current Image ?'),
                        'required' => false,
                        'help' => sprintf(
                            $this->l('current file %s'),
                            $this->getUploadUrl() . $categoryDatas['image_field']
                        ),
                    ]);
            }
 
            $formBuilder->setData($params['data']);
        } catch ( Exception $e){
            $this->log('Error :'.$e->getMessage());
            $this->log('Error string :'.$e->getTraceAsString());
        }
    }
 
    /**
     * Action effectuée après la création d'une catégorie
     * @param array $params
     * @return void
     */
    public function hookActionAfterCreateCategoryFormHandler(array $params): void
    {
        $this->updateData($params['id'], $params['form_data']);
    }
 
    /**
     * Action effectuée après la mise à jour d'une catégorie
     * @param array $params
     * @return void
     */
    public function hookActionAfterUpdateCategoryFormHandler(array $params): void
    {
        $this->updateData($params['id'], $params['form_data']);
    }
 
    /**
     * Récupération des informations spécifique de l'objet
     * @param int $id_category
     * @return array
     */
    protected function getCustomFieldsValue(int $id_category): array
    {
        try {
            $idCategoryField = CategoryFields::getIdByCategoryId($id_category);
            $categoryField = new CategoryFields($idCategoryField);
            return [
                'code' => $categoryField->code,
                'description_seo' => $categoryField->description_seo,
                'image_field' => $categoryField->image_field
            ];
        } catch (PrestaShopException $e) {
            $this->log($e->getMessage());
            return [
                'code' => '',
                'description_seo' => [],
                'image_field' => ''
            ];
        }
    }
 
    /**
     * Fonction qui va effectuer la mise à jour
     * @param int $id_category
     * @param array $data
     * @return void
     */
    protected function updateData(int $id_category, array $data): void
    {
        try {
 
            $idCategoryField = CategoryFields::getIdByCategoryId($id_category);
            $categoryField = new CategoryFields($idCategoryField);
            $categoryField->id_category = $id_category;
 
            foreach ($data as $key => $value) {
                if (strpos($key, $this->name) !== false) {
                    $objectKey = str_replace($this->name . '_', '', $key);
                    $categoryField->$objectKey = $value;
                }
            }
 
            /** @var \Symfony\Component\HttpFoundation\File\UploadedFile $uploadedFile */
            $uploadedFile = $data['image_file_upload'];
 
            //Gestion de la suppression de l'image
            if (array_key_exists($this->name . '_delete_current_image', $data)
                && !$uploadedFile instanceof \Symfony\Component\HttpFoundation\File\UploadedFile
            ) {
                $this->deleteCurrentImage($categoryField->image_field);
                unset($data[$this->name . '_image_field']);
                $categoryField->image_field = '';
            }
 
            //Gestion de l'envoi de l'image ( simplifiée )
            //@todo : Tester sur les versions 1.7.6 / 1.7.7 / 1.7.8
            if ($uploadedFile instanceof \Symfony\Component\HttpFoundation\File\UploadedFile) {
                if ($uploadedFile->isValid()) {
                    $this->uploadFile($uploadedFile);
                    $categoryField->image_field = $uploadedFile->getClientOriginalName();
                }
            }
            $categoryField->save();
        } catch (Exception $e) {
            $this->log($e->getMessage());
        }
    }
 
    /**
     * Fonction (très basique et sans vérif ) d'envoi du fichier
     *
     * @param \Symfony\Component\HttpFoundation\File\UploadedFile $uploadedFile
     * @return bool
     */
    protected function uploadFile(\Symfony\Component\HttpFoundation\File\UploadedFile $uploadedFile): bool
    {
        try {
            //Bonne pratique : on gère l'envoi des images dans le dossier "img" de prestashop et pas dans le module
            $uploadDir = $this->getUploadDir();
            $uploadedFile->move($uploadDir, $uploadedFile->getClientOriginalName());
        } catch (Exception $e) {
            return false;
        }
        return true;
    }
 
    /**
     * Suppression d'une image existante
     * @param string $imageName
     * @return bool
     */
    protected function deleteCurrentImage(string $imageName): bool
    {
        if (is_file($this->getUploadDir() . $imageName)) {
            return unlink($this->getUploadDir() . $imageName);
        }
 
        return true;
    }
 
    /**
     * Récupération du dossier d'envoi des images
     * @return string
     */
    protected function getUploadDir(): string
    {
        return _PS_IMG_DIR_ . 'modules/' . $this->name . '/';
    }
 
    /**
     * Récupération de l'url des images
     * @return string
     */
    protected function getUploadUrl(): string
    {
        $uriPath = 'img/modules/' . $this->name . '/';
        return $this->context->link->getBaseLink() . $uriPath;
    }
 
    /**
     * Fonction basique de log
     * @param string $message
     * @return void
     */
    protected function log($message): void
    {
        file_put_contents(
            dirname(__FILE__) . 'debug.log',
            date('Y-m-d H:i:s') . $message . "\n",
            FILE_APPEND
        );
    }
}

Laisser un commentaire

Votre adresse e-mail ne sera pas publiée. Les champs obligatoires sont indiqués avec *