From c4f964c224ecb66d5e0426687f98986fc24f1d0c Mon Sep 17 00:00:00 2001 From: jeremie Date: Wed, 25 Mar 2026 15:51:04 +0100 Subject: [PATCH] demo for native extra fields --- demoextrafield/README.md | 108 ++++ demoextrafield/config_fr.xml | 11 + demoextrafield/demoextrafield.php | 498 ++++++++++++++++++ demoextrafield/index.php | 13 + .../ModulesDemoextrafieldAdmin.fr-FR.xlf | 87 +++ .../hook/cart_extra_product_info.tpl | 17 + .../views/templates/hook/category_header.tpl | 18 + .../templates/hook/customer_account_top.tpl | 18 + .../hook/product_additional_info.tpl | 18 + 9 files changed, 788 insertions(+) create mode 100644 demoextrafield/README.md create mode 100644 demoextrafield/config_fr.xml create mode 100644 demoextrafield/demoextrafield.php create mode 100644 demoextrafield/index.php create mode 100644 demoextrafield/translations/fr-FR/ModulesDemoextrafieldAdmin.fr-FR.xlf create mode 100644 demoextrafield/views/templates/hook/cart_extra_product_info.tpl create mode 100644 demoextrafield/views/templates/hook/category_header.tpl create mode 100644 demoextrafield/views/templates/hook/customer_account_top.tpl create mode 100644 demoextrafield/views/templates/hook/product_additional_info.tpl diff --git a/demoextrafield/README.md b/demoextrafield/README.md new file mode 100644 index 0000000..5e5290d --- /dev/null +++ b/demoextrafield/README.md @@ -0,0 +1,108 @@ +# Demo extra fields + +## About + +This module demonstrates how to use **native extra fields** (custom fields) in PrestaShop (9.2+ ?). + +It focuses on: + +- Registering extra fields on multiple entities (Product, Category, Customer) +- Covering multiple **scopes** (`common`, `lang`, `shop`) and **types** (bool, date, money, html, json, url, …) +- Unregistering extra fields on uninstall (including dropping the SQL storage columns) +- Rendering the stored values on the Front Office using hooks +- Making Back Office translation strings visible in the translation interface + +## What it registers + +### Product (`product`) + +- `is_dangerous` (scope: `common`, type: bool) +- `video_link` (scope: `lang`, type: string/url) +- `custom_date` (scope: `shop`, type: date) + +### Category (`category`) + +- `theme_color` (scope: `common`, type: string/color) +- `marketing_note` (scope: `common`, type: html) +- `id_supplier` (scope: `common`, type: int / supplier selector) + +### Customer (`customer`) + +- `credit_limit` (scope: `common`, type: float / money) +- `extra_json` (scope: `common`, type: json) + +## How to test + +This module impacts both Back Office and Front Office. + +### Product + +**Back Office grid** + +- Adds a **"Dangerous product"** field displayed after **"Quantity"**. +- Adds a **"Custom date"** field displayed at the end of the grid. +- Toggling **"Dangerous product"** persists the value. + +**Back Office form** + +- Extra fields are grouped into a dedicated **"Extra fields"** tab. +- Except **"Dangerous product"**, which is displayed at the end of the **"Options"** tab. + +**Front Office hooks** + +- Product page: `displayProductAdditionalInfo` +- Cart: `displayCartExtraProductInfo` + +### Category + +**Back Office grid** + +- Adds **Theme color** and **Marketing note** at the end of the grid. + +**Back Office form** + +- Adds **Theme color** and **Marketing note** to the form. + +**Front Office hooks** + +- Category listing page: `displayHeaderCategory` + +### Customer + +**Back Office grid** + +- Adds **Credit limit** in the grid. + +**Back Office form** + +- Adds **Credit limit** and **Metadata JSON** to the form. + +**Front Office hooks** + +- My account page: `displayCustomerAccountTop` + +### Where to find values in FO templates + +On the Front Office, the module displays **only the values stored for this module**, under `extraProperties['demoextrafield']`. + +## Translation note (Back Office) + +Each extra field has a **title** and a **description** meant to be displayed in Back Office. +The system stores the source wording and its translation domain (for the default language), then translations are managed through PrestaShop Back Office. + +To make those strings appear in the Back Office translation interface, two conditions must be met: + +1. The strings must be declared in PHP via `$this->trans(...)` (see `demoextrafield::registerTranslationWordings()`). +2. The same source strings must exist at least once in an XLF file shipped by the module (see `translations/fr-FR/ModulesDemoextrafieldAdmin.fr-FR.xlf`). + +## Supported PrestaShop versions + +Compatible with 9.2 ? and above versions. + +## How to install + +1. Download or clone the module into the `modules` directory of your PrestaShop installation +2. Install the module: + - from Back Office in Module Manager + - or using the command `php ./bin/console prestashop:module install demoextrafield` + diff --git a/demoextrafield/config_fr.xml b/demoextrafield/config_fr.xml new file mode 100644 index 0000000..7e62df5 --- /dev/null +++ b/demoextrafield/config_fr.xml @@ -0,0 +1,11 @@ + + + demoextrafield + + + + + + 0 + 0 + \ No newline at end of file diff --git a/demoextrafield/demoextrafield.php b/demoextrafield/demoextrafield.php new file mode 100644 index 0000000..65916f3 --- /dev/null +++ b/demoextrafield/demoextrafield.php @@ -0,0 +1,498 @@ +name = 'demoextrafield'; + $this->tab = 'administration'; + $this->version = '1.0.0'; + $this->author = 'PrestaShop'; + $this->need_instance = 0; + $this->ps_versions_compliancy = ['min' => '9.1.0', 'max' => '9.9.99']; + + parent::__construct(); + + $this->displayName = 'Demo native extra fields'; + $this->description = 'Example module showing how to register and display native extra fields.'; + } + + /** + * Install: + * - registers extra fields for product/category/customer, + * - registers a few FO hooks to display values. + */ + public function install(): bool + { + if (!parent::install()) { + $errors = array_filter($this->getErrors()); + $details = empty($errors) ? 'no legacy errors were provided' : implode(' | ', $errors); + + throw new Exception(sprintf('demoextrafield: parent::install() failed (%s).', $details)); + } + + // IMPORTANT NOTE ON TRANSLATION: + // Each extra field has a title + description meant to be displayed in Back Office. + // We store the source wording + its translation domain (for the default language), then + // PrestaShop handles translations for active languages via the BO translation system. + // + // For those strings to show up in the BO translation interface, two conditions must be met: + // - the strings must be declared in PHP via $this->trans(...), + // - the same source strings must exist at least once in an XLF file shipped by the module. + $this->registerTranslationWordings(); + + /** + * PRODUCT extra fields + */ + + // Product (common) : is_dangerous + $productDangerousRegistered = $this->registerExtraProperty( + 'product', + 'is_dangerous', + new ExtraPropertyOptions( + type: ExtraPropertyType::Bool, + scope: ExtraPropertyScope::Common, + nullable: false, + defaultValue: 0, + titleWording: 'Dangerous product', + titleDomain: self::TRANSLATION_DOMAIN, + descriptionWording: 'Indicates whether the product is dangerous', + descriptionDomain: self::TRANSLATION_DOMAIN, + symfonyFieldType: CheckboxType::class, + validator: 'isBool', + displayFront: false, + displayApi: true, + displayBo: true, + propertyPath: 'options.extra_properties', + displayGrid: true, + gridPosition: 'quantity' + ) + ); + if (!$productDangerousRegistered) { + $this->_errors[] = 'Failed to register Product extra field "is_dangerous" (scope: common).'; + + return false; + } + + // Product (lang) : video_link + $productVideoLinkRegistered = $this->registerExtraProperty( + 'product', + 'video_link', + new ExtraPropertyOptions( + type: ExtraPropertyType::String, + scope: ExtraPropertyScope::Lang, + nullable: true, + titleWording: 'Video link', + titleDomain: self::TRANSLATION_DOMAIN, + descriptionWording: 'Video URL per language', + descriptionDomain: self::TRANSLATION_DOMAIN, + sqlIndex: 'unique', + symfonyFieldType: UrlType::class, + validator: 'isUrl', + displayFront: true, + displayApi: true, + displayBo: true, + displayGrid: false + ) + ); + if (!$productVideoLinkRegistered) { + $this->_errors[] = 'Failed to register Product extra field "video_link" (scope: lang).'; + + return false; + } + + // Product (shop) : custom_date + $productCustomDateRegistered = $this->registerExtraProperty( + 'product', + 'custom_date', + new ExtraPropertyOptions( + type: ExtraPropertyType::Date, + scope: ExtraPropertyScope::Shop, + nullable: true, + titleWording: 'Custom date', + titleDomain: self::TRANSLATION_DOMAIN, + descriptionWording: 'Custom date per shop', + descriptionDomain: self::TRANSLATION_DOMAIN, + sqlIndex: 'key', + symfonyFieldType: DatePickerType::class, + validator: 'isDate', + displayFront: false, + displayApi: true, + displayBo: true, + displayGrid: true, + gridPosition: 3 + ) + ); + if (!$productCustomDateRegistered) { + $this->_errors[] = 'Failed to register Product extra field "custom_date" (scope: shop).'; + + return false; + } + + /** + * CATEGORY extra fields + */ + + // Category (common) : theme_color + $categoryThemeColorRegistered = $this->registerExtraProperty( + 'category', + 'theme_color', + new ExtraPropertyOptions( + type: ExtraPropertyType::String, + scope: ExtraPropertyScope::Common, + nullable: true, + titleWording: 'Theme color', + titleDomain: self::TRANSLATION_DOMAIN, + descriptionWording: 'Color associated with the category', + descriptionDomain: self::TRANSLATION_DOMAIN, + symfonyFieldType: ColorType::class, + validator: 'isColor', + displayFront: true, + displayApi: true, + displayBo: true, + displayGrid: true + ) + ); + if (!$categoryThemeColorRegistered) { + $this->_errors[] = 'Failed to register Category extra field "theme_color" (scope: common).'; + + return false; + } + + // Category (common) : marketing_note + $categoryMarketingNoteRegistered = $this->registerExtraProperty( + 'category', + 'marketing_note', + new ExtraPropertyOptions( + type: ExtraPropertyType::Html, + scope: ExtraPropertyScope::Common, + nullable: true, + titleWording: 'Marketing note', + titleDomain: self::TRANSLATION_DOMAIN, + descriptionWording: 'Free note displayed in BO, API and FO', + descriptionDomain: self::TRANSLATION_DOMAIN, + symfonyFieldType: FormattedTextareaType::class, + validator: 'isCleanHtml', + displayFront: true, + displayApi: true, + displayBo: true, + displayGrid: false + ) + ); + if (!$categoryMarketingNoteRegistered) { + $this->_errors[] = 'Failed to register Category extra field "marketing_note" (scope: common).'; + + return false; + } + + // Category (common) : id_supplier + $categorySupplierRegistered = $this->registerExtraProperty( + 'category', + 'id_supplier', + new ExtraPropertyOptions( + type: ExtraPropertyType::Int, + scope: ExtraPropertyScope::Common, + nullable: true, + titleWording: 'Default supplier', + titleDomain: self::TRANSLATION_DOMAIN, + descriptionWording: 'Select a PrestaShop supplier', + descriptionDomain: self::TRANSLATION_DOMAIN, + symfonyFieldType: DiscountSupplierType::class, + validator: 'isUnsignedId', + displayFront: true, + displayApi: true, + displayBo: true, + displayGrid: true + ) + ); + if (!$categorySupplierRegistered) { + $this->_errors[] = 'Failed to register Category extra field "id_supplier" (scope: common).'; + + return false; + } + + /** + * CUSTOMER extra fields + */ + + // Customer (common) : credit_limit + $customerCreditLimitRegistered = $this->registerExtraProperty( + 'customer', + 'credit_limit', + new ExtraPropertyOptions( + type: ExtraPropertyType::Float, + scope: ExtraPropertyScope::Common, + nullable: true, + titleWording: 'Credit limit', + titleDomain: self::TRANSLATION_DOMAIN, + descriptionWording: 'Maximum customer credit amount', + descriptionDomain: self::TRANSLATION_DOMAIN, + symfonyFieldType: MoneyType::class, + validator: 'isPrice', + displayFront: false, + displayApi: true, + displayBo: true, + displayGrid: true + ) + ); + if (!$customerCreditLimitRegistered) { + $this->_errors[] = 'Failed to register Customer extra field "credit_limit" (scope: common).'; + + return false; + } + + // Customer (common) : extra_json + $customerExtraJsonRegistered = $this->registerExtraProperty( + 'customer', + 'extra_json', + new ExtraPropertyOptions( + type: ExtraPropertyType::Json, + scope: ExtraPropertyScope::Common, + nullable: true, + titleWording: 'Metadata JSON', + titleDomain: self::TRANSLATION_DOMAIN, + descriptionWording: 'Free JSON for customer metadata', + descriptionDomain: self::TRANSLATION_DOMAIN, + symfonyFieldType: TextareaType::class, + validator: 'isJson', + displayFront: true, + displayApi: true, + displayBo: true, + displayGrid: false + ) + ); + if (!$customerExtraJsonRegistered) { + $this->_errors[] = 'Failed to register Customer extra field "extra_json" (scope: common).'; + + return false; + } + + $hooksRegistered = $this->registerHook('displayProductAdditionalInfo') + && $this->registerHook('displayCartExtraProductInfo') + && $this->registerHook('displayHeaderCategory') + && $this->registerHook('displayCustomerAccountTop'); + if (!$hooksRegistered) { + $this->_errors[] = 'Failed to register one or more hooks (displayProductAdditionalInfo, displayCartExtraProductInfo, displayHeaderCategory, displayCustomerAccountTop).'; + + return false; + } + + return true; + } + + /** + * Uninstall: + * - unregisters all extra fields, + * - drops SQL storage columns. + */ + public function uninstall(): bool + { + // false = keep columns in DB after uninstall + $dropColumn = true; + + $this->unregisterExtraProperty('product', 'video_link', 'lang', $dropColumn); + $this->unregisterExtraProperty('product', 'is_dangerous', 'common', $dropColumn); + $this->unregisterExtraProperty('product', 'custom_date', 'shop', $dropColumn); + + $this->unregisterExtraProperty('category', 'theme_color', 'common', $dropColumn); + $this->unregisterExtraProperty('category', 'marketing_note', 'common', $dropColumn); + $this->unregisterExtraProperty('category', 'id_supplier', 'common', $dropColumn); + + $this->unregisterExtraProperty('customer', 'credit_limit', 'common', $dropColumn); + $this->unregisterExtraProperty('customer', 'extra_json', 'common', $dropColumn); + + return parent::uninstall(); + } + + /** + * Front Office hook (product page). + * Displays this module extra fields from the product LazyArray. + */ + public function hookDisplayProductAdditionalInfo(array $params): string + { + $product = $params['product'] ?? null; + if (!$product instanceof ArrayAccess || (int) ($product['id_product'] ?? 0) <= 0) { + return ''; + } + + $moduleExtras = $product['extraProperties'][$this->name] ?? []; + if (!is_array($moduleExtras) && !$moduleExtras instanceof ArrayAccess) { + $moduleExtras = []; + } + + $this->context->smarty->assign([ + 'demoExtraFieldTitle' => $this->trans('Extra fields (demoextrafield)', [], self::TRANSLATION_DOMAIN), + 'entityLabel' => $this->trans('Entity', [], self::TRANSLATION_DOMAIN), + 'entityName' => 'product', + 'moduleExtras' => $moduleExtras, + ]); + + return $this->display(__FILE__, 'views/templates/hook/product_additional_info.tpl'); + } + + /** + * Front Office hook (cart). + * Displays this module extra fields for products in cart. + */ + public function hookDisplayCartExtraProductInfo(array $params): string + { + $product = $params['product'] ?? null; + if (!$product instanceof ArrayAccess || (int) ($product['id_product'] ?? 0) <= 0) { + return ''; + } + + $moduleExtras = $product['extraProperties'][$this->name] ?? []; + if (!is_array($moduleExtras) && !$moduleExtras instanceof ArrayAccess) { + $moduleExtras = []; + } + + $this->context->smarty->assign([ + 'demoExtraFieldTitle' => $this->trans('Extra fields (demoextrafield)', [], self::TRANSLATION_DOMAIN), + 'entityLabel' => $this->trans('Entity', [], self::TRANSLATION_DOMAIN), + 'entityName' => 'product', + 'moduleExtras' => $moduleExtras, + ]); + + return $this->display(__FILE__, 'views/templates/hook/cart_extra_product_info.tpl'); + } + + /** + * Front Office hook (category listing page). + * Displays this module extra fields from the category LazyArray. + */ + public function hookDisplayHeaderCategory(): string + { + $category = $this->context->smarty->getTemplateVars('category'); + if (!$category instanceof ArrayAccess || (int) ($category['id_category'] ?? 0) <= 0) { + return ''; + } + + $moduleExtras = $category['extraProperties'][$this->name] ?? []; + if (!is_array($moduleExtras) && !$moduleExtras instanceof ArrayAccess) { + $moduleExtras = []; + } + + $this->context->smarty->assign([ + 'demoExtraFieldTitle' => $this->trans('Extra fields (demoextrafield)', [], self::TRANSLATION_DOMAIN), + 'entityLabel' => $this->trans('Entity', [], self::TRANSLATION_DOMAIN), + 'entityName' => 'category', + 'moduleExtras' => $moduleExtras, + ]); + + return $this->display(__FILE__, 'views/templates/hook/category_header.tpl'); + } + + /** + * Front Office hook (customer my-account page). + * Displays this module extra fields for current customer. + */ + public function hookDisplayCustomerAccountTop(): string + { + $customer = $this->context->customer; + if (!$customer instanceof Customer || (int) $customer->id <= 0) { + return ''; + } + + try { + $containerFinder = new ContainerFinder($this->context); + /** @var ExtraPropertyValueProviderInterface $extraPropertyValueProvider */ + $extraPropertyValueProvider = $containerFinder->getContainer()->get(ExtraPropertyValueProviderInterface::class); + } catch (Throwable $e) { + return ''; + } + + $extraProperties = $extraPropertyValueProvider->getExtraProperties( + 'customer', + 'id_customer', + (int) $customer->id, + (int) $this->context->language->id, + (int) $this->context->shop->id, + true, + true + ); + + $moduleExtras = $extraProperties[$this->name] ?? []; + if (!is_array($moduleExtras) && !$moduleExtras instanceof ArrayAccess) { + $moduleExtras = []; + } + + $this->context->smarty->assign([ + 'demoExtraFieldTitle' => $this->trans('Extra fields (demoextrafield)', [], self::TRANSLATION_DOMAIN), + 'entityLabel' => $this->trans('Entity', [], self::TRANSLATION_DOMAIN), + 'entityName' => 'customer', + 'moduleExtras' => $moduleExtras, + ]); + + return $this->display(__FILE__, 'views/templates/hook/customer_account_top.tpl'); + } + + /** + * Declares translation wordings so BO extraction can index them. + */ + protected function registerTranslationWordings(): void + { + $domain = self::TRANSLATION_DOMAIN; + + // Product + $this->trans('Dangerous product', [], $domain); + $this->trans('Indicates whether the product is dangerous', [], $domain); + $this->trans('Video link', [], $domain); + $this->trans('Video URL per language', [], $domain); + $this->trans('Custom date', [], $domain); + $this->trans('Custom date per shop', [], $domain); + + // Category + $this->trans('Theme color', [], $domain); + $this->trans('Color associated with the category', [], $domain); + $this->trans('Marketing note', [], $domain); + $this->trans('Free note displayed in BO, API and FO', [], $domain); + $this->trans('Default supplier', [], $domain); + $this->trans('Select a PrestaShop supplier', [], $domain); + + // Customer + $this->trans('Credit limit', [], $domain); + $this->trans('Maximum customer credit amount', [], $domain); + $this->trans('Metadata JSON', [], $domain); + $this->trans('Free JSON for customer metadata', [], $domain); + + // Front templates + $this->trans('Extra fields (demoextrafield)', [], $domain); + $this->trans('Entity', [], $domain); + $this->trans('No extra fields found for this module.', [], $domain); + } +} + diff --git a/demoextrafield/index.php b/demoextrafield/index.php new file mode 100644 index 0000000..a9295d4 --- /dev/null +++ b/demoextrafield/index.php @@ -0,0 +1,13 @@ + + + + + + Dangerous product + Produit dangereux + + + Indicates whether the product is dangerous + Indique si le produit est dangereux + + + Video link + Lien vidéo + + + Video URL per language + URL de vidéo par langue + + + Custom date + Date personnalisée + + + Custom date per shop + Date personnalisée par boutique + + + + Theme color + Couleur de thème + + + Color associated with the category + Couleur associée à la catégorie + + + Marketing note + Note marketing + + + Free note displayed in BO, API and FO + Note libre affichée dans le BO, l’API et le FO + + + Default supplier + Fournisseur par défaut + + + Select a PrestaShop supplier + Sélectionnez un fournisseur PrestaShop + + + + Credit limit + Limite de crédit + + + Maximum customer credit amount + Montant maximum de crédit client + + + Metadata JSON + JSON de métadonnées + + + Free JSON for customer metadata + JSON libre pour les métadonnées client + + + + Extra fields (demoextrafield) + Champs extra (demoextrafield) + + + Entity + Entité + + + No extra fields found for this module. + Aucun champ extra trouvé pour ce module. + + + + + diff --git a/demoextrafield/views/templates/hook/cart_extra_product_info.tpl b/demoextrafield/views/templates/hook/cart_extra_product_info.tpl new file mode 100644 index 0000000..e70ea34 --- /dev/null +++ b/demoextrafield/views/templates/hook/cart_extra_product_info.tpl @@ -0,0 +1,17 @@ +
+ {$demoExtraFieldTitle|escape:'htmlall':'UTF-8'} + + {if empty($moduleExtras)} +
{l s='No extra fields found for this module.' d='Modules.Demoextrafield.Admin'}
+ {else} + + {/if} +
+ diff --git a/demoextrafield/views/templates/hook/category_header.tpl b/demoextrafield/views/templates/hook/category_header.tpl new file mode 100644 index 0000000..9f53c18 --- /dev/null +++ b/demoextrafield/views/templates/hook/category_header.tpl @@ -0,0 +1,18 @@ +
+

{$demoExtraFieldTitle|escape:'htmlall':'UTF-8'}

+

{$entityLabel|escape:'htmlall':'UTF-8'}: {$entityName|escape:'htmlall':'UTF-8'}

+ + {if empty($moduleExtras)} +

{l s='No extra fields found for this module.' d='Modules.Demoextrafield.Admin'}

+ {else} + + {/if} +
+ diff --git a/demoextrafield/views/templates/hook/customer_account_top.tpl b/demoextrafield/views/templates/hook/customer_account_top.tpl new file mode 100644 index 0000000..f0f6e4e --- /dev/null +++ b/demoextrafield/views/templates/hook/customer_account_top.tpl @@ -0,0 +1,18 @@ +
+

{$demoExtraFieldTitle|escape:'htmlall':'UTF-8'}

+

{$entityLabel|escape:'htmlall':'UTF-8'}: {$entityName|escape:'htmlall':'UTF-8'}

+ + {if empty($moduleExtras)} +

{l s='No extra fields found for this module.' d='Modules.Demoextrafield.Admin'}

+ {else} + + {/if} +
+ diff --git a/demoextrafield/views/templates/hook/product_additional_info.tpl b/demoextrafield/views/templates/hook/product_additional_info.tpl new file mode 100644 index 0000000..f1cec75 --- /dev/null +++ b/demoextrafield/views/templates/hook/product_additional_info.tpl @@ -0,0 +1,18 @@ +
+

{$demoExtraFieldTitle|escape:'htmlall':'UTF-8'}

+

{$entityLabel|escape:'htmlall':'UTF-8'}: {$entityName|escape:'htmlall':'UTF-8'}

+ + {if empty($moduleExtras)} +

{l s='No extra fields found for this module.' d='Modules.Demoextrafield.Admin'}

+ {else} + + {/if} +
+