From c4f964c224ecb66d5e0426687f98986fc24f1d0c Mon Sep 17 00:00:00 2001 From: jeremie Date: Wed, 25 Mar 2026 15:51:04 +0100 Subject: [PATCH 1/7] 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} +
+ From e64ede09f332b38fdbc3efa154b56f9908dc7d86 Mon Sep 17 00:00:00 2001 From: jeremie Date: Fri, 17 Apr 2026 10:27:48 +0200 Subject: [PATCH 2/7] Align code with latest review state of the main PR --- demoextrafield/demoextrafield.php | 63 ++++++++++++++----------------- 1 file changed, 28 insertions(+), 35 deletions(-) diff --git a/demoextrafield/demoextrafield.php b/demoextrafield/demoextrafield.php index 65916f3..88fa039 100644 --- a/demoextrafield/demoextrafield.php +++ b/demoextrafield/demoextrafield.php @@ -9,6 +9,7 @@ use PrestaShop\PrestaShop\Adapter\ContainerFinder; use PrestaShop\PrestaShop\Core\ExtraProperty\ExtraPropertyOptions; use PrestaShop\PrestaShop\Core\ExtraProperty\ExtraPropertyScope; +use PrestaShop\PrestaShop\Core\ExtraProperty\ExtraPropertySqlIndex; use PrestaShop\PrestaShop\Core\ExtraProperty\ExtraPropertyType; use PrestaShop\PrestaShop\Core\ExtraProperty\Storage\ExtraPropertyValueProviderInterface; use PrestaShopBundle\Form\Admin\Sell\Discount\DiscountSupplierType; @@ -92,12 +93,11 @@ public function install(): bool titleDomain: self::TRANSLATION_DOMAIN, descriptionWording: 'Indicates whether the product is dangerous', descriptionDomain: self::TRANSLATION_DOMAIN, - symfonyFieldType: CheckboxType::class, + formFieldType: CheckboxType::class, validator: 'isBool', - displayFront: false, displayApi: true, - displayBo: true, - propertyPath: 'options.extra_properties', + displayForm: true, + formPosition: 'options.extra_properties', displayGrid: true, gridPosition: 'quantity' ) @@ -120,12 +120,11 @@ public function install(): bool titleDomain: self::TRANSLATION_DOMAIN, descriptionWording: 'Video URL per language', descriptionDomain: self::TRANSLATION_DOMAIN, - sqlIndex: 'unique', - symfonyFieldType: UrlType::class, + sqlIndex: ExtraPropertySqlIndex::Unique, + formFieldType: UrlType::class, validator: 'isUrl', - displayFront: true, displayApi: true, - displayBo: true, + displayForm: true, displayGrid: false ) ); @@ -147,12 +146,11 @@ public function install(): bool titleDomain: self::TRANSLATION_DOMAIN, descriptionWording: 'Custom date per shop', descriptionDomain: self::TRANSLATION_DOMAIN, - sqlIndex: 'key', - symfonyFieldType: DatePickerType::class, + sqlIndex: ExtraPropertySqlIndex::Key, + formFieldType: DatePickerType::class, validator: 'isDate', - displayFront: false, displayApi: true, - displayBo: true, + displayForm: true, displayGrid: true, gridPosition: 3 ) @@ -179,11 +177,10 @@ public function install(): bool titleDomain: self::TRANSLATION_DOMAIN, descriptionWording: 'Color associated with the category', descriptionDomain: self::TRANSLATION_DOMAIN, - symfonyFieldType: ColorType::class, + formFieldType: ColorType::class, validator: 'isColor', - displayFront: true, displayApi: true, - displayBo: true, + displayForm: true, displayGrid: true ) ); @@ -205,11 +202,10 @@ public function install(): bool titleDomain: self::TRANSLATION_DOMAIN, descriptionWording: 'Free note displayed in BO, API and FO', descriptionDomain: self::TRANSLATION_DOMAIN, - symfonyFieldType: FormattedTextareaType::class, + formFieldType: FormattedTextareaType::class, validator: 'isCleanHtml', - displayFront: true, displayApi: true, - displayBo: true, + displayForm: true, displayGrid: false ) ); @@ -231,11 +227,10 @@ public function install(): bool titleDomain: self::TRANSLATION_DOMAIN, descriptionWording: 'Select a PrestaShop supplier', descriptionDomain: self::TRANSLATION_DOMAIN, - symfonyFieldType: DiscountSupplierType::class, + formFieldType: DiscountSupplierType::class, validator: 'isUnsignedId', - displayFront: true, displayApi: true, - displayBo: true, + displayForm: true, displayGrid: true ) ); @@ -261,11 +256,10 @@ public function install(): bool titleDomain: self::TRANSLATION_DOMAIN, descriptionWording: 'Maximum customer credit amount', descriptionDomain: self::TRANSLATION_DOMAIN, - symfonyFieldType: MoneyType::class, + formFieldType: MoneyType::class, validator: 'isPrice', - displayFront: false, displayApi: true, - displayBo: true, + displayForm: true, displayGrid: true ) ); @@ -287,11 +281,10 @@ public function install(): bool titleDomain: self::TRANSLATION_DOMAIN, descriptionWording: 'Free JSON for customer metadata', descriptionDomain: self::TRANSLATION_DOMAIN, - symfonyFieldType: TextareaType::class, + formFieldType: TextareaType::class, validator: 'isJson', - displayFront: true, displayApi: true, - displayBo: true, + displayForm: true, displayGrid: false ) ); @@ -324,16 +317,16 @@ 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('product', 'video_link', ExtraPropertyScope::Lang, $dropColumn); + $this->unregisterExtraProperty('product', 'is_dangerous', ExtraPropertyScope::Common, $dropColumn); + $this->unregisterExtraProperty('product', 'custom_date', ExtraPropertyScope::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('category', 'theme_color', ExtraPropertyScope::Common, $dropColumn); + $this->unregisterExtraProperty('category', 'marketing_note', ExtraPropertyScope::Common, $dropColumn); + $this->unregisterExtraProperty('category', 'id_supplier', ExtraPropertyScope::Common, $dropColumn); - $this->unregisterExtraProperty('customer', 'credit_limit', 'common', $dropColumn); - $this->unregisterExtraProperty('customer', 'extra_json', 'common', $dropColumn); + $this->unregisterExtraProperty('customer', 'credit_limit', ExtraPropertyScope::Common, $dropColumn); + $this->unregisterExtraProperty('customer', 'extra_json', ExtraPropertyScope::Common, $dropColumn); return parent::uninstall(); } From 51cc9b0ffa927be500513b92fcead3fb5a2465ed Mon Sep 17 00:00:00 2001 From: jeremie Date: Fri, 24 Apr 2026 10:10:56 +0200 Subject: [PATCH 3/7] Add LazyArray access example --- demoextrafield/demoextrafield.php | 87 ++++++++++++++++--- .../views/templates/hook/product_footer.tpl | 17 ++++ 2 files changed, 93 insertions(+), 11 deletions(-) create mode 100644 demoextrafield/views/templates/hook/product_footer.tpl diff --git a/demoextrafield/demoextrafield.php b/demoextrafield/demoextrafield.php index 88fa039..af56519 100644 --- a/demoextrafield/demoextrafield.php +++ b/demoextrafield/demoextrafield.php @@ -11,7 +11,7 @@ use PrestaShop\PrestaShop\Core\ExtraProperty\ExtraPropertyScope; use PrestaShop\PrestaShop\Core\ExtraProperty\ExtraPropertySqlIndex; use PrestaShop\PrestaShop\Core\ExtraProperty\ExtraPropertyType; -use PrestaShop\PrestaShop\Core\ExtraProperty\Storage\ExtraPropertyValueProviderInterface; +use PrestaShop\PrestaShop\Core\ExtraProperty\Storage\ExtraPropertyReaderInterface; use PrestaShopBundle\Form\Admin\Sell\Discount\DiscountSupplierType; use PrestaShopBundle\Form\Admin\Type\DatePickerType; use PrestaShopBundle\Form\Admin\Type\FormattedTextareaType; @@ -161,6 +161,30 @@ public function install(): bool return false; } + // Product (common) : date_last_seen + // Demo field: updated on every FO product page view via hookDisplayFooterProduct. + $productDateLastSeenRegistered = $this->registerExtraProperty( + 'product', + 'date_last_seen', + new ExtraPropertyOptions( + type: ExtraPropertyType::Date, + scope: ExtraPropertyScope::Common, + nullable: true, + titleWording: 'Date last seen', + titleDomain: self::TRANSLATION_DOMAIN, + descriptionWording: 'Last time this product page was viewed', + descriptionDomain: self::TRANSLATION_DOMAIN, + displayApi: true, + displayForm: true, + displayGrid: false + ) + ); + if (!$productDateLastSeenRegistered) { + $this->_errors[] = 'Failed to register Product extra field "date_last_seen" (scope: common).'; + + return false; + } + /** * CATEGORY extra fields */ @@ -297,9 +321,10 @@ public function install(): bool $hooksRegistered = $this->registerHook('displayProductAdditionalInfo') && $this->registerHook('displayCartExtraProductInfo') && $this->registerHook('displayHeaderCategory') - && $this->registerHook('displayCustomerAccountTop'); + && $this->registerHook('displayCustomerAccountTop') + && $this->registerHook('displayFooterProduct'); if (!$hooksRegistered) { - $this->_errors[] = 'Failed to register one or more hooks (displayProductAdditionalInfo, displayCartExtraProductInfo, displayHeaderCategory, displayCustomerAccountTop).'; + $this->_errors[] = 'Failed to register one or more hooks.'; return false; } @@ -320,6 +345,7 @@ public function uninstall(): bool $this->unregisterExtraProperty('product', 'video_link', ExtraPropertyScope::Lang, $dropColumn); $this->unregisterExtraProperty('product', 'is_dangerous', ExtraPropertyScope::Common, $dropColumn); $this->unregisterExtraProperty('product', 'custom_date', ExtraPropertyScope::Shop, $dropColumn); + $this->unregisterExtraProperty('product', 'date_last_seen', ExtraPropertyScope::Common, $dropColumn); $this->unregisterExtraProperty('category', 'theme_color', ExtraPropertyScope::Common, $dropColumn); $this->unregisterExtraProperty('category', 'marketing_note', ExtraPropertyScope::Common, $dropColumn); @@ -357,6 +383,42 @@ public function hookDisplayProductAdditionalInfo(array $params): string return $this->display(__FILE__, 'views/templates/hook/product_additional_info.tpl'); } + /** + * Front Office hook (product page footer). + * + * Demo: reads date_last_seen from the Product ObjectModel, displays it, then updates it. + * + * Two equivalent syntaxes are shown; both use the ExtraPropertiesBag via __get: + * $product->extra_properties['demoextrafield_date_last_seen'] // ArrayAccess (preferred) + * $product->getExtraProperty('demoextrafield', 'date_last_seen') // convenience method + */ + public function hookDisplayFooterProduct(array $params): string + { + $productId = (int) ($params['product']['id_product'] ?? 0); + if ($productId <= 0) { + return ''; + } + + $product = new Product($productId); + if (!Validate::isLoadedObject($product)) { + return ''; + } + + $now = date('Y-m-d H:i:s'); + + // ArrayAccess on the ExtraPropertiesBag (flat column name = module_name . '_' . field_name) + $dateLastSeen = $product->extra_properties['demoextrafield_date_last_seen']; + $product->extra_properties['demoextrafield_date_last_seen'] = $now; + $product->update(); + + $this->context->smarty->assign([ + 'dateLastSeen' => $dateLastSeen, + 'dateLastSeenUpdated' => $now, + ]); + + return $this->display(__FILE__, 'views/templates/hook/product_footer.tpl'); + } + /** * Front Office hook (cart). * Displays this module extra fields for products in cart. @@ -422,20 +484,18 @@ public function hookDisplayCustomerAccountTop(): string try { $containerFinder = new ContainerFinder($this->context); - /** @var ExtraPropertyValueProviderInterface $extraPropertyValueProvider */ - $extraPropertyValueProvider = $containerFinder->getContainer()->get(ExtraPropertyValueProviderInterface::class); - } catch (Throwable $e) { + /** @var ExtraPropertyReaderInterface $extraPropertyReader */ + $extraPropertyReader = $containerFinder->getContainer()->get(ExtraPropertyReaderInterface::class); + } catch (Throwable) { return ''; } - $extraProperties = $extraPropertyValueProvider->getExtraProperties( + $extraProperties = $extraPropertyReader->getExtraProperties( 'customer', 'id_customer', (int) $customer->id, (int) $this->context->language->id, - (int) $this->context->shop->id, - true, - true + (int) $this->context->shop->id ); $moduleExtras = $extraProperties[$this->name] ?? []; @@ -467,6 +527,8 @@ protected function registerTranslationWordings(): void $this->trans('Video URL per language', [], $domain); $this->trans('Custom date', [], $domain); $this->trans('Custom date per shop', [], $domain); + $this->trans('Date last seen', [], $domain); + $this->trans('Last time this product page was viewed', [], $domain); // Category $this->trans('Theme color', [], $domain); @@ -486,6 +548,9 @@ protected function registerTranslationWordings(): void $this->trans('Extra fields (demoextrafield)', [], $domain); $this->trans('Entity', [], $domain); $this->trans('No extra fields found for this module.', [], $domain); + $this->trans('Date last seen (extra field demo)', [], $domain); + $this->trans('Previous value', [], $domain); + $this->trans('Updated to', [], $domain); + $this->trans('Never seen before', [], $domain); } } - diff --git a/demoextrafield/views/templates/hook/product_footer.tpl b/demoextrafield/views/templates/hook/product_footer.tpl new file mode 100644 index 0000000..beba2c8 --- /dev/null +++ b/demoextrafield/views/templates/hook/product_footer.tpl @@ -0,0 +1,17 @@ +
+

{l s='Date last seen (extra field demo)' d='Modules.Demoextrafield.Admin'}

+
    +
  • + {l s='Previous value' d='Modules.Demoextrafield.Admin'}: + {if $dateLastSeen} + {$dateLastSeen|escape:'htmlall':'UTF-8'} + {else} + {l s='Never seen before' d='Modules.Demoextrafield.Admin'} + {/if} +
  • +
  • + {l s='Updated to' d='Modules.Demoextrafield.Admin'}: + {$dateLastSeenUpdated|escape:'htmlall':'UTF-8'} +
  • +
+
From 40d3b586a8f201808c61888abb54380f6b822c5f Mon Sep 17 00:00:00 2001 From: jeremie Date: Thu, 28 May 2026 11:54:48 +0200 Subject: [PATCH 4/7] Updated and more complete examples for demoextrafield --- demoextrafield/demoextrafield.php | 441 +++++++++--------- .../ModulesDemoextrafieldAdmin.fr-FR.xlf | 202 +++++--- .../fr-FR/ModulesDemoextrafieldMain.fr-FR.xlf | 15 + .../templates/hook/_extra_properties.tpl | 26 ++ .../hook/cart_extra_product_info.tpl | 15 +- .../views/templates/hook/category_header.tpl | 17 +- .../templates/hook/customer_account_top.tpl | 19 +- .../hook/product_additional_info.tpl | 24 +- 8 files changed, 431 insertions(+), 328 deletions(-) create mode 100644 demoextrafield/translations/fr-FR/ModulesDemoextrafieldMain.fr-FR.xlf create mode 100644 demoextrafield/views/templates/hook/_extra_properties.tpl diff --git a/demoextrafield/demoextrafield.php b/demoextrafield/demoextrafield.php index af56519..8b03492 100644 --- a/demoextrafield/demoextrafield.php +++ b/demoextrafield/demoextrafield.php @@ -1,4 +1,5 @@ version = '1.0.0'; $this->author = 'PrestaShop'; $this->need_instance = 0; - $this->ps_versions_compliancy = ['min' => '9.1.0', 'max' => '9.9.99']; + $this->ps_versions_compliancy = ['min' => '9.2.0', 'max' => '9.9.99']; parent::__construct(); @@ -60,22 +60,9 @@ public function __construct() 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)); + return false; } - // 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 */ @@ -85,25 +72,24 @@ public function install(): bool 'product', 'is_dangerous', new ExtraPropertyOptions( - type: ExtraPropertyType::Bool, + type: ExtraPropertyType::BOOL, scope: ExtraPropertyScope::Common, nullable: false, defaultValue: 0, - titleWording: 'Dangerous product', - titleDomain: self::TRANSLATION_DOMAIN, - descriptionWording: 'Indicates whether the product is dangerous', + labelWording: $this->trans('Dangerous product', [], 'Modules.Demoextrafield.Admin', 'en'), + labelDomain: self::TRANSLATION_DOMAIN, + descriptionWording: $this->trans('Indicates whether the product is dangerous', [], 'Modules.Demoextrafield.Admin', 'en'), descriptionDomain: self::TRANSLATION_DOMAIN, formFieldType: CheckboxType::class, validator: 'isBool', displayApi: true, displayForm: true, formPosition: 'options.extra_properties', - displayGrid: true, - gridPosition: 'quantity' + associatedGrids: ['product.reference'], ) ); if (!$productDangerousRegistered) { - $this->_errors[] = 'Failed to register Product extra field "is_dangerous" (scope: common).'; + $this->_errors[] = $this->trans('Failed to register Product extra field "is_dangerous" (scope: common).', [], 'Modules.Demoextrafield.Admin'); return false; } @@ -113,23 +99,22 @@ public function install(): bool 'product', 'video_link', new ExtraPropertyOptions( - type: ExtraPropertyType::String, + type: ExtraPropertyType::STRING, scope: ExtraPropertyScope::Lang, nullable: true, - titleWording: 'Video link', - titleDomain: self::TRANSLATION_DOMAIN, - descriptionWording: 'Video URL per language', + labelWording: $this->trans('Video link', [], 'Modules.Demoextrafield.Admin', 'en'), + labelDomain: self::TRANSLATION_DOMAIN, + descriptionWording: $this->trans('Video URL per language', [], 'Modules.Demoextrafield.Admin', 'en'), descriptionDomain: self::TRANSLATION_DOMAIN, sqlIndex: ExtraPropertySqlIndex::Unique, formFieldType: UrlType::class, validator: 'isUrl', displayApi: true, displayForm: true, - displayGrid: false ) ); if (!$productVideoLinkRegistered) { - $this->_errors[] = 'Failed to register Product extra field "video_link" (scope: lang).'; + $this->_errors[] = $this->trans('Failed to register Product extra field "video_link" (scope: lang).', [], 'Modules.Demoextrafield.Admin'); return false; } @@ -139,48 +124,89 @@ public function install(): bool 'product', 'custom_date', new ExtraPropertyOptions( - type: ExtraPropertyType::Date, + type: ExtraPropertyType::DATE, scope: ExtraPropertyScope::Shop, nullable: true, - titleWording: 'Custom date', - titleDomain: self::TRANSLATION_DOMAIN, - descriptionWording: 'Custom date per shop', + labelWording: $this->trans('Custom date', [], 'Modules.Demoextrafield.Admin', 'en'), + labelDomain: self::TRANSLATION_DOMAIN, + descriptionWording: $this->trans('Custom date per shop', [], 'Modules.Demoextrafield.Admin', 'en'), descriptionDomain: self::TRANSLATION_DOMAIN, sqlIndex: ExtraPropertySqlIndex::Key, formFieldType: DatePickerType::class, validator: 'isDate', displayApi: true, displayForm: true, - displayGrid: true, - gridPosition: 3 + associatedGrids: ['product.final_price_tax_excluded:before'], ) ); if (!$productCustomDateRegistered) { - $this->_errors[] = 'Failed to register Product extra field "custom_date" (scope: shop).'; + $this->_errors[] = $this->trans('Failed to register Product extra field "custom_date" (scope: shop).', [], 'Modules.Demoextrafield.Admin'); return false; } // Product (common) : date_last_seen - // Demo field: updated on every FO product page view via hookDisplayFooterProduct. + // Auto-updated on each FO product page view (hookDisplayFooterProduct). + // displayForm: false → read-only for merchants; visible in the product grid and via API. $productDateLastSeenRegistered = $this->registerExtraProperty( 'product', 'date_last_seen', new ExtraPropertyOptions( - type: ExtraPropertyType::Date, + type: ExtraPropertyType::DATE, scope: ExtraPropertyScope::Common, nullable: true, - titleWording: 'Date last seen', - titleDomain: self::TRANSLATION_DOMAIN, - descriptionWording: 'Last time this product page was viewed', + labelWording: $this->trans('Date last seen', [], 'Modules.Demoextrafield.Admin', 'en'), + labelDomain: self::TRANSLATION_DOMAIN, + descriptionWording: $this->trans('Last time this product page was viewed', [], 'Modules.Demoextrafield.Admin', 'en'), descriptionDomain: self::TRANSLATION_DOMAIN, displayApi: true, - displayForm: true, - displayGrid: false + displayForm: false, + associatedGrids: ['product'], ) ); if (!$productDateLastSeenRegistered) { - $this->_errors[] = 'Failed to register Product extra field "date_last_seen" (scope: common).'; + $this->_errors[] = $this->trans('Failed to register Product extra field "date_last_seen" (scope: common).', [], 'Modules.Demoextrafield.Admin'); + + return false; + } + + // Product (common) : packaging_type + // Demonstrates: CHOICE type, enumValues, formOptions (dropdown choices). + // enumValues constrains the allowed DB values; formOptions drives the Symfony ChoiceType widget. + // formRequired: false + nullable: true + placeholder → the "—" option represents "no selection"; + // the field can be left empty and the empty value passes server-side validation. + // (For a truly required field, omit the placeholder and set formRequired: true — NotBlank is added automatically.) + $productPackagingTypeRegistered = $this->registerExtraProperty( + 'product', + 'packaging_type', + new ExtraPropertyOptions( + type: ExtraPropertyType::CHOICE, + scope: ExtraPropertyScope::Common, + enumValues: ['standard', 'gift', 'bulk'], + nullable: true, + defaultValue: null, + labelWording: $this->trans('Packaging type', [], 'Modules.Demoextrafield.Admin', 'en'), + labelDomain: self::TRANSLATION_DOMAIN, + descriptionWording: $this->trans('Selectable packaging type for this product', [], 'Modules.Demoextrafield.Admin', 'en'), + descriptionDomain: self::TRANSLATION_DOMAIN, + formFieldType: ChoiceType::class, + formOptions: [ + 'choices' => [ + 'Standard' => 'standard', + 'Gift box' => 'gift', + 'Bulk' => 'bulk', + ], + 'placeholder' => '—', + ], + formRequired: false, + displayApi: true, + displayForm: true, + displayFront: true, + associatedGrids: ['product'], + ) + ); + if (!$productPackagingTypeRegistered) { + $this->_errors[] = $this->trans('Failed to register Product extra field "packaging_type" (scope: common).', [], 'Modules.Demoextrafield.Admin'); return false; } @@ -190,26 +216,31 @@ public function install(): bool */ // Category (common) : theme_color + // Demonstrates: formRequired: true → the form modifier automatically adds a NotBlank + // constraint at build time (server-side enforcement, not just the HTML required attribute). + // No need to put constraints in formOptions — formOptions is persisted as JSON and cannot + // hold Constraint objects. $categoryThemeColorRegistered = $this->registerExtraProperty( 'category', 'theme_color', new ExtraPropertyOptions( - type: ExtraPropertyType::String, + type: ExtraPropertyType::STRING, scope: ExtraPropertyScope::Common, nullable: true, - titleWording: 'Theme color', - titleDomain: self::TRANSLATION_DOMAIN, - descriptionWording: 'Color associated with the category', + labelWording: $this->trans('Theme color', [], 'Modules.Demoextrafield.Admin', 'en'), + labelDomain: self::TRANSLATION_DOMAIN, + descriptionWording: $this->trans('Color associated with the category (required)', [], 'Modules.Demoextrafield.Admin', 'en'), descriptionDomain: self::TRANSLATION_DOMAIN, formFieldType: ColorType::class, + formRequired: true, validator: 'isColor', displayApi: true, displayForm: true, - displayGrid: true + associatedGrids: ['category'] ) ); if (!$categoryThemeColorRegistered) { - $this->_errors[] = 'Failed to register Category extra field "theme_color" (scope: common).'; + $this->_errors[] = $this->trans('Failed to register Category extra field "theme_color" (scope: common).', [], 'Modules.Demoextrafield.Admin'); return false; } @@ -219,22 +250,21 @@ public function install(): bool 'category', 'marketing_note', new ExtraPropertyOptions( - type: ExtraPropertyType::Html, + type: ExtraPropertyType::HTML, scope: ExtraPropertyScope::Common, nullable: true, - titleWording: 'Marketing note', - titleDomain: self::TRANSLATION_DOMAIN, - descriptionWording: 'Free note displayed in BO, API and FO', + labelWording: $this->trans('Marketing note', [], 'Modules.Demoextrafield.Admin', 'en'), + labelDomain: self::TRANSLATION_DOMAIN, + descriptionWording: $this->trans('Free note displayed in BO, API and FO', [], 'Modules.Demoextrafield.Admin', 'en'), descriptionDomain: self::TRANSLATION_DOMAIN, formFieldType: FormattedTextareaType::class, validator: 'isCleanHtml', displayApi: true, displayForm: true, - displayGrid: false ) ); if (!$categoryMarketingNoteRegistered) { - $this->_errors[] = 'Failed to register Category extra field "marketing_note" (scope: common).'; + $this->_errors[] = $this->trans('Failed to register Category extra field "marketing_note" (scope: common).', [], 'Modules.Demoextrafield.Admin'); return false; } @@ -244,22 +274,22 @@ public function install(): bool 'category', 'id_supplier', new ExtraPropertyOptions( - type: ExtraPropertyType::Int, + type: ExtraPropertyType::INT, scope: ExtraPropertyScope::Common, nullable: true, - titleWording: 'Default supplier', - titleDomain: self::TRANSLATION_DOMAIN, - descriptionWording: 'Select a PrestaShop supplier', + labelWording: $this->trans('Default supplier', [], 'Modules.Demoextrafield.Admin', 'en'), + labelDomain: self::TRANSLATION_DOMAIN, + descriptionWording: $this->trans('Select a PrestaShop supplier', [], 'Modules.Demoextrafield.Admin', 'en'), descriptionDomain: self::TRANSLATION_DOMAIN, formFieldType: DiscountSupplierType::class, validator: 'isUnsignedId', displayApi: true, displayForm: true, - displayGrid: true + associatedGrids: ['category'] ) ); if (!$categorySupplierRegistered) { - $this->_errors[] = 'Failed to register Category extra field "id_supplier" (scope: common).'; + $this->_errors[] = $this->trans('Failed to register Category extra field "id_supplier" (scope: common).', [], 'Modules.Demoextrafield.Admin'); return false; } @@ -273,22 +303,22 @@ public function install(): bool 'customer', 'credit_limit', new ExtraPropertyOptions( - type: ExtraPropertyType::Float, + type: ExtraPropertyType::FLOAT, scope: ExtraPropertyScope::Common, nullable: true, - titleWording: 'Credit limit', - titleDomain: self::TRANSLATION_DOMAIN, - descriptionWording: 'Maximum customer credit amount', + labelWording: $this->trans('Credit limit', [], 'Modules.Demoextrafield.Admin', 'en'), + labelDomain: self::TRANSLATION_DOMAIN, + descriptionWording: $this->trans('Maximum customer credit amount', [], 'Modules.Demoextrafield.Admin', 'en'), descriptionDomain: self::TRANSLATION_DOMAIN, formFieldType: MoneyType::class, validator: 'isPrice', displayApi: true, displayForm: true, - displayGrid: true + associatedGrids: ['customer'] ) ); if (!$customerCreditLimitRegistered) { - $this->_errors[] = 'Failed to register Customer extra field "credit_limit" (scope: common).'; + $this->_errors[] = $this->trans('Failed to register Customer extra field "credit_limit" (scope: common).', [], 'Modules.Demoextrafield.Admin'); return false; } @@ -298,22 +328,92 @@ public function install(): bool 'customer', 'extra_json', new ExtraPropertyOptions( - type: ExtraPropertyType::Json, + type: ExtraPropertyType::JSON, scope: ExtraPropertyScope::Common, nullable: true, - titleWording: 'Metadata JSON', - titleDomain: self::TRANSLATION_DOMAIN, - descriptionWording: 'Free JSON for customer metadata', + labelWording: $this->trans('Metadata JSON', [], 'Modules.Demoextrafield.Admin', 'en'), + labelDomain: self::TRANSLATION_DOMAIN, + descriptionWording: $this->trans('Free JSON for customer metadata', [], 'Modules.Demoextrafield.Admin', 'en'), descriptionDomain: self::TRANSLATION_DOMAIN, formFieldType: TextareaType::class, validator: 'isJson', displayApi: true, displayForm: true, - displayGrid: false ) ); if (!$customerExtraJsonRegistered) { - $this->_errors[] = 'Failed to register Customer extra field "extra_json" (scope: common).'; + $this->_errors[] = $this->trans('Failed to register Customer extra field "extra_json" (scope: common).', [], 'Modules.Demoextrafield.Admin'); + + return false; + } + + // Customer (common) : internal_note + // Demonstrates: displayFront: false — the field appears in BO form and API but is + // never returned by ExtraPropertiesLazyArray::getValues() on the front office. + $customerInternalNoteRegistered = $this->registerExtraProperty( + 'customer', + 'internal_note', + new ExtraPropertyOptions( + type: ExtraPropertyType::STRING, + scope: ExtraPropertyScope::Common, + nullable: true, + labelWording: $this->trans('Internal note', [], 'Modules.Demoextrafield.Admin', 'en'), + labelDomain: self::TRANSLATION_DOMAIN, + descriptionWording: $this->trans('Merchant-only note — never exposed on the front office', [], 'Modules.Demoextrafield.Admin', 'en'), + descriptionDomain: self::TRANSLATION_DOMAIN, + formFieldType: TextareaType::class, + displayApi: true, + displayForm: true, + displayFront: false, + ) + ); + if (!$customerInternalNoteRegistered) { + $this->_errors[] = $this->trans('Failed to register Customer extra field "internal_note" (scope: common).', [], 'Modules.Demoextrafield.Admin'); + + return false; + } + + /** + * ADDRESS extra fields + * + * Demo case: gridId ('manufacturer_address') differs from entity name ('address'). + * This validates that getDefinitionCollectionByGridId() correctly decouples + * the grid identifier from the entity table name. + * + * Note on displayForm: + * The form modifier resolves extra fields using the form type's block prefix as the entity + * name. ManufacturerAddressType has block_prefix='manufacturer_address', but the entity + * table is 'address'. Because block_prefix ≠ entity_name, the form modifier cannot find + * definitions registered for 'address' when building the 'manufacturer_address' form. + * displayForm is therefore set to false: the field is intentionally grid-only here. + * (Forms where block_prefix == entity_name — e.g. product, customer, category — work + * correctly without this constraint.) + */ + + // Address (common) : delivery_note + // Shows in the manufacturer address grid (Catalog > Brands > Addresses) after 'city'. + $addressDeliveryNoteRegistered = $this->registerExtraProperty( + 'address', + 'delivery_note', + new ExtraPropertyOptions( + type: ExtraPropertyType::STRING, + scope: ExtraPropertyScope::Common, + nullable: true, + size: 255, + labelWording: $this->trans('Delivery note', [], 'Modules.Demoextrafield.Admin', 'en'), + labelDomain: self::TRANSLATION_DOMAIN, + descriptionWording: $this->trans('Free delivery note attached to this address', [], 'Modules.Demoextrafield.Admin', 'en'), + descriptionDomain: self::TRANSLATION_DOMAIN, + formFieldType: TextareaType::class, + validator: 'isGenericName', + displayApi: true, + displayForm: false, + // gridId 'manufacturer_address' ≠ entity 'address' — decoupling test. + associatedGrids: ['manufacturer_address.city'], + ) + ); + if (!$addressDeliveryNoteRegistered) { + $this->_errors[] = $this->trans('Failed to register Address extra field "delivery_note" (scope: common).', [], 'Modules.Demoextrafield.Admin'); return false; } @@ -324,7 +424,7 @@ public function install(): bool && $this->registerHook('displayCustomerAccountTop') && $this->registerHook('displayFooterProduct'); if (!$hooksRegistered) { - $this->_errors[] = 'Failed to register one or more hooks.'; + $this->_errors[] = $this->trans('Failed to register one or more hooks.', [], 'Modules.Demoextrafield.Admin'); return false; } @@ -342,19 +442,24 @@ public function uninstall(): bool // false = keep columns in DB after uninstall $dropColumn = true; - $this->unregisterExtraProperty('product', 'video_link', ExtraPropertyScope::Lang, $dropColumn); - $this->unregisterExtraProperty('product', 'is_dangerous', ExtraPropertyScope::Common, $dropColumn); - $this->unregisterExtraProperty('product', 'custom_date', ExtraPropertyScope::Shop, $dropColumn); - $this->unregisterExtraProperty('product', 'date_last_seen', ExtraPropertyScope::Common, $dropColumn); + return + $this->unregisterExtraProperty('product', 'video_link', ExtraPropertyScope::Lang, $dropColumn) + && $this->unregisterExtraProperty('product', 'is_dangerous', ExtraPropertyScope::Common, $dropColumn) + && $this->unregisterExtraProperty('product', 'custom_date', ExtraPropertyScope::Shop, $dropColumn) + && $this->unregisterExtraProperty('product', 'date_last_seen', ExtraPropertyScope::Common, $dropColumn) + && $this->unregisterExtraProperty('product', 'packaging_type', ExtraPropertyScope::Common, $dropColumn) + + && $this->unregisterExtraProperty('category', 'theme_color', ExtraPropertyScope::Common, $dropColumn) + && $this->unregisterExtraProperty('category', 'marketing_note', ExtraPropertyScope::Common, $dropColumn) + && $this->unregisterExtraProperty('category', 'id_supplier', ExtraPropertyScope::Common, $dropColumn) - $this->unregisterExtraProperty('category', 'theme_color', ExtraPropertyScope::Common, $dropColumn); - $this->unregisterExtraProperty('category', 'marketing_note', ExtraPropertyScope::Common, $dropColumn); - $this->unregisterExtraProperty('category', 'id_supplier', ExtraPropertyScope::Common, $dropColumn); + && $this->unregisterExtraProperty('customer', 'credit_limit', ExtraPropertyScope::Common, $dropColumn) + && $this->unregisterExtraProperty('customer', 'extra_json', ExtraPropertyScope::Common, $dropColumn) + && $this->unregisterExtraProperty('customer', 'internal_note', ExtraPropertyScope::Common, $dropColumn) - $this->unregisterExtraProperty('customer', 'credit_limit', ExtraPropertyScope::Common, $dropColumn); - $this->unregisterExtraProperty('customer', 'extra_json', ExtraPropertyScope::Common, $dropColumn); + && $this->unregisterExtraProperty('address', 'delivery_note', ExtraPropertyScope::Common, $dropColumn) - return parent::uninstall(); + && parent::uninstall(); } /** @@ -363,23 +468,6 @@ public function uninstall(): bool */ 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'); } @@ -388,9 +476,7 @@ public function hookDisplayProductAdditionalInfo(array $params): string * * Demo: reads date_last_seen from the Product ObjectModel, displays it, then updates it. * - * Two equivalent syntaxes are shown; both use the ExtraPropertiesBag via __get: - * $product->extra_properties['demoextrafield_date_last_seen'] // ArrayAccess (preferred) - * $product->getExtraProperty('demoextrafield', 'date_last_seen') // convenience method + * Access is grouped by module: $product->extra_properties['module']['field'] */ public function hookDisplayFooterProduct(array $params): string { @@ -406,9 +492,8 @@ public function hookDisplayFooterProduct(array $params): string $now = date('Y-m-d H:i:s'); - // ArrayAccess on the ExtraPropertiesBag (flat column name = module_name . '_' . field_name) - $dateLastSeen = $product->extra_properties['demoextrafield_date_last_seen']; - $product->extra_properties['demoextrafield_date_last_seen'] = $now; + $dateLastSeen = $product->extra_properties['demoextrafield']['date_last_seen']; + $product->extra_properties['demoextrafield']['date_last_seen'] = $now; $product->update(); $this->context->smarty->assign([ @@ -422,25 +507,12 @@ public function hookDisplayFooterProduct(array $params): string /** * Front Office hook (cart). * Displays this module extra fields for products in cart. + * + * $params['product'] is the product LazyArray passed by the cart template. */ 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, - ]); + $this->context->smarty->assign('product', $params['product'] ?? []); return $this->display(__FILE__, 'views/templates/hook/cart_extra_product_info.tpl'); } @@ -451,106 +523,47 @@ public function hookDisplayCartExtraProductInfo(array $params): string */ 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. + * + * --- Why we load extra properties manually here --- + * + * Extra properties are natively carried by every ObjectModel subclass (Customer, Product, + * Category…). In PHP, $customer->extra_properties['module']['field'] works on any instance. + * + * In FO Smarty templates however, entities are presented through LazyArrays + * (ProductLazyArray, CategoryLazyArray, OrderLazyArray…). AbstractLazyArray exposes the + * `extraProperties` key, so `$product.extraProperties.mymodule.myfield` works in templates. + * + * Customer is the exception: the Smarty `$customer` variable is a plain PHP array built + * by FrontController::getTemplateVarCustomer() via objectPresenter->present(). It is not + * a LazyArray, so `$customer.extraProperties` does not exist. + * + * Solution for entities without a native LazyArray: fetch the values explicitly in the + * hook handler using ExtraPropertiesLazyArray::fromObjectModelClass(), then assign them + * to Smarty under a dedicated variable. + * + * Note: getValues() already filters out fields with displayFront=false + * (here, 'internal_note' field is therefore intentionally absent from the output). */ public function hookDisplayCustomerAccountTop(): string { - $customer = $this->context->customer; - if (!$customer instanceof Customer || (int) $customer->id <= 0) { + $customerId = (int) $this->context->customer->id; + if ($customerId <= 0) { return ''; } - try { - $containerFinder = new ContainerFinder($this->context); - /** @var ExtraPropertyReaderInterface $extraPropertyReader */ - $extraPropertyReader = $containerFinder->getContainer()->get(ExtraPropertyReaderInterface::class); - } catch (Throwable) { - return ''; - } + $extraPropertiesByModule = ExtraPropertiesLazyArray::fromObjectModelClass( + Customer::class, + $customerId + )->getValues(); - $extraProperties = $extraPropertyReader->getExtraProperties( - 'customer', - 'id_customer', - (int) $customer->id, - (int) $this->context->language->id, - (int) $this->context->shop->id - ); - - $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, - ]); + // Wrap as ['extraProperties' => ...] so _extra_properties.tpl can be reused as-is. + $this->context->smarty->assign('customerExtraData', ['extraProperties' => $extraPropertiesByModule]); 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); - $this->trans('Date last seen', [], $domain); - $this->trans('Last time this product page was viewed', [], $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); - $this->trans('Date last seen (extra field demo)', [], $domain); - $this->trans('Previous value', [], $domain); - $this->trans('Updated to', [], $domain); - $this->trans('Never seen before', [], $domain); - } } diff --git a/demoextrafield/translations/fr-FR/ModulesDemoextrafieldAdmin.fr-FR.xlf b/demoextrafield/translations/fr-FR/ModulesDemoextrafieldAdmin.fr-FR.xlf index 82fc3b3..26fddcf 100644 --- a/demoextrafield/translations/fr-FR/ModulesDemoextrafieldAdmin.fr-FR.xlf +++ b/demoextrafield/translations/fr-FR/ModulesDemoextrafieldAdmin.fr-FR.xlf @@ -1,87 +1,175 @@ - + - + - - Dangerous product - Produit dangereux - - - Indicates whether the product is dangerous - Indique si le produit est dangereux - - - Video link - Lien vidéo + + Color associated with the category (required) + Couleur associée à la catégorie (obligatoire) - - Video URL per language - URL de vidéo par langue + + Credit limit + Limite de crédit - + Custom date Date personnalisée - + Custom date per shop Date personnalisée par boutique - - - Theme color - Couleur de thème + + Dangerous product + Produit dangereux - - Color associated with the category - Couleur associée à la catégorie + + Date last seen + Date de dernière consultation - - Marketing note - Note marketing + + Date last seen (extra field demo) + Date de dernière consultation (démo champ supplémentaire) + + + Default supplier + Fournisseur par défaut + + + Delivery note + Note de livraison + + + Failed to register Address extra field "delivery_note" (scope: common). + Échec de l’enregistrement du champ supplémentaire Adresse "delivery_note" (portée : common). + + + Failed to register Category extra field "id_supplier" (scope: common). + Échec de l’enregistrement du champ supplémentaire Catégorie "id_supplier" (portée : common). + + + Failed to register Category extra field "marketing_note" (scope: common). + Échec de l’enregistrement du champ supplémentaire Catégorie "marketing_note" (portée : common). + + + Failed to register Category extra field "theme_color" (scope: common). + Échec de l’enregistrement du champ supplémentaire Catégorie "theme_color" (portée : common). + + + Failed to register Customer extra field "credit_limit" (scope: common). + Échec de l’enregistrement du champ supplémentaire Client "credit_limit" (portée : common). + + + Failed to register Customer extra field "extra_json" (scope: common). + Échec de l’enregistrement du champ supplémentaire Client "extra_json" (portée : common). + + + Failed to register Customer extra field "internal_note" (scope: common). + Échec de l’enregistrement du champ supplémentaire Client "internal_note" (portée : common). + + + Failed to register Product extra field "custom_date" (scope: shop). + Échec de l’enregistrement du champ supplémentaire Produit "custom_date" (portée : shop). + + + Failed to register Product extra field "date_last_seen" (scope: common). + Échec de l’enregistrement du champ supplémentaire Produit "date_last_seen" (portée : shop). + + + Failed to register Product extra field "is_dangerous" (scope: common). + Échec de l’enregistrement du champ supplémentaire Produit "is_dangerous" (portée : shop). + + + Failed to register Product extra field "packaging_type" (scope: common). + Échec de l’enregistrement du champ supplémentaire Produit "packaging_type" (portée : shop). + + + Failed to register Product extra field "video_link" (scope: lang). + Échec de l’enregistrement du champ supplémentaire Produit "video_link" (portée : lang). - + + Failed to register one or more hooks. + Échec de l’enregistrement d’un ou plusieurs hooks. + + + Free JSON for customer metadata + JSON libre pour les métadonnées client + + + Free delivery note attached to this address + Note de livraison libre associée à cette adresse + + 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 + + Indicates whether the product is dangerous + Indique si le produit est dangereux - - Select a PrestaShop supplier - Sélectionnez un fournisseur PrestaShop + + Internal note + Note interne - - - Credit limit - Limite de crédit + + Last time this product page was viewed + Dernière consultation de cette fiche produit + + + Marketing note + Note marketing - + Maximum customer credit amount - Montant maximum de crédit client + Montant maximal de crédit client - + + Merchant-only note — never exposed on the front office + Note réservée au marchand — jamais affichée sur le front-office + + Metadata JSON - JSON de métadonnées + JSON des métadonnées - - Free JSON for customer metadata - JSON libre pour les métadonnées client + + Never seen before + Jamais consulté auparavant + + + Packaging type + Type d’emballage + + + Previous value + Valeur précédente + + + Select a PrestaShop supplier + Sélectionnez un fournisseur PrestaShop - - - Extra fields (demoextrafield) - Champs extra (demoextrafield) + + Selectable packaging type for this product + Type d’emballage sélectionnable pour ce produit - - Entity - Entité + + Theme color + Couleur du thème + + + Updated to + Mis à jour vers + + + Video URL per language + URL de la vidéo par langue + + + Video link + Lien vidéo - - No extra fields found for this module. - Aucun champ extra trouvé pour ce module. + + ⚠ This product is marked as dangerous. + ⚠ Ce produit est marqué comme dangereux. - diff --git a/demoextrafield/translations/fr-FR/ModulesDemoextrafieldMain.fr-FR.xlf b/demoextrafield/translations/fr-FR/ModulesDemoextrafieldMain.fr-FR.xlf new file mode 100644 index 0000000..f647c11 --- /dev/null +++ b/demoextrafield/translations/fr-FR/ModulesDemoextrafieldMain.fr-FR.xlf @@ -0,0 +1,15 @@ + + + + + + Extra fields (demoextrafield) + Champs extra (demoextrafield) + + + No extra fields found for this module. + Aucun champ extra pour ce module. + + + + diff --git a/demoextrafield/views/templates/hook/_extra_properties.tpl b/demoextrafield/views/templates/hook/_extra_properties.tpl new file mode 100644 index 0000000..d4632ce --- /dev/null +++ b/demoextrafield/views/templates/hook/_extra_properties.tpl @@ -0,0 +1,26 @@ +{* + Displays all extra fields registered by this module for a given entity. + + Usage: {include file='./_extra_properties.tpl' objectModel=$product} + where $objectModel is a LazyArray (or any array) exposing extraProperties.{moduleName}.{fieldName}. + + Note on lang-scoped fields (scope="lang"): + The ExtraPropertyReader translates the per-language array into a single scalar value for the + current storefront language before returning it. No special handling is needed here. + + Note on displayFront=false fields: + Fields registered with displayFront=false are filtered out by ExtraPropertiesLazyArray::getValues() + before this template is reached. They will never appear here even if they exist in the database. +*} +{if empty($objectModel.extraProperties.demoextrafield)} +

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

+{else} +
    + {foreach from=$objectModel.extraProperties.demoextrafield key=fieldName item=fieldValue} +
  • + {$fieldName|escape:'htmlall':'UTF-8'}: + {$fieldValue|escape:'htmlall':'UTF-8'} +
  • + {/foreach} +
+{/if} diff --git a/demoextrafield/views/templates/hook/cart_extra_product_info.tpl b/demoextrafield/views/templates/hook/cart_extra_product_info.tpl index e70ea34..b25a251 100644 --- a/demoextrafield/views/templates/hook/cart_extra_product_info.tpl +++ b/demoextrafield/views/templates/hook/cart_extra_product_info.tpl @@ -1,17 +1,6 @@
- {$demoExtraFieldTitle|escape:'htmlall':'UTF-8'} +

{l s='Extra fields (demoextrafield)' d='Modules.Demoextrafield.Main'}

- {if empty($moduleExtras)} -
{l s='No extra fields found for this module.' d='Modules.Demoextrafield.Admin'}
- {else} -
    - {foreach from=$moduleExtras key=fieldName item=fieldValue} -
  • - {$fieldName|escape:'htmlall':'UTF-8'}: - {$fieldValue|escape:'htmlall':'UTF-8'} -
  • - {/foreach} -
- {/if} + {include file='./_extra_properties.tpl' objectModel=$product}
diff --git a/demoextrafield/views/templates/hook/category_header.tpl b/demoextrafield/views/templates/hook/category_header.tpl index 9f53c18..e47e9a1 100644 --- a/demoextrafield/views/templates/hook/category_header.tpl +++ b/demoextrafield/views/templates/hook/category_header.tpl @@ -1,18 +1,5 @@
-

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

-

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

+

{l s='Extra fields (demoextrafield)' d='Modules.Demoextrafield.Main'}

- {if empty($moduleExtras)} -

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

- {else} -
    - {foreach from=$moduleExtras key=fieldName item=fieldValue} -
  • - {$fieldName|escape:'htmlall':'UTF-8'}: - {$fieldValue|escape:'htmlall':'UTF-8'} -
  • - {/foreach} -
- {/if} + {include file='./_extra_properties.tpl' objectModel=$category}
- diff --git a/demoextrafield/views/templates/hook/customer_account_top.tpl b/demoextrafield/views/templates/hook/customer_account_top.tpl index f0f6e4e..1498930 100644 --- a/demoextrafield/views/templates/hook/customer_account_top.tpl +++ b/demoextrafield/views/templates/hook/customer_account_top.tpl @@ -1,18 +1,7 @@
-

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

-

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

+

{l s='Extra fields (demoextrafield)' d='Modules.Demoextrafield.Main'}

- {if empty($moduleExtras)} -

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

- {else} -
    - {foreach from=$moduleExtras key=fieldName item=fieldValue} -
  • - {$fieldName|escape:'htmlall':'UTF-8'}: - {$fieldValue|escape:'htmlall':'UTF-8'} -
  • - {/foreach} -
- {/if} + {* customerExtraData is built in hookDisplayCustomerAccountTop from ExtraPropertiesLazyArray. + internal_note has displayFront=false so it is absent from this output even though it exists in DB. *} + {include file='./_extra_properties.tpl' objectModel=$customerExtraData}
- diff --git a/demoextrafield/views/templates/hook/product_additional_info.tpl b/demoextrafield/views/templates/hook/product_additional_info.tpl index f1cec75..8619ff0 100644 --- a/demoextrafield/views/templates/hook/product_additional_info.tpl +++ b/demoextrafield/views/templates/hook/product_additional_info.tpl @@ -1,18 +1,14 @@
-

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

-

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

+

{l s='Extra fields (demoextrafield)' d='Modules.Demoextrafield.Main'}

- {if empty($moduleExtras)} -

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

- {else} -
    - {foreach from=$moduleExtras key=fieldName item=fieldValue} -
  • - {$fieldName|escape:'htmlall':'UTF-8'}: - {$fieldValue|escape:'htmlall':'UTF-8'} -
  • - {/foreach} -
+ {* 1. Generic loop — iterates over all fields registered by this module. *} + {include file='./_extra_properties.tpl' objectModel=$product} + + {* 2. Named access — read a specific field directly without looping. + Useful when you need to act on a known field (conditional display, formatting, etc.). *} + {if $product.extraProperties.demoextrafield.is_dangerous|intval} +

+ {l s='⚠ This product is marked as dangerous.' d='Modules.Demoextrafield.Admin'} +

{/if}
- From 341b928be86337681b2ed30550efe13b2761b959 Mon Sep 17 00:00:00 2001 From: Jonathan LELIEVRE Date: Fri, 29 May 2026 15:28:02 +0200 Subject: [PATCH 5/7] Update templates to use fixed extra_properties on AbstractLazyArray --- demoextrafield/config.xml | 11 +++++++++++ .../views/templates/hook/_extra_properties.tpl | 6 +++--- .../views/templates/hook/product_additional_info.tpl | 2 +- 3 files changed, 15 insertions(+), 4 deletions(-) create mode 100644 demoextrafield/config.xml diff --git a/demoextrafield/config.xml b/demoextrafield/config.xml new file mode 100644 index 0000000..7e62df5 --- /dev/null +++ b/demoextrafield/config.xml @@ -0,0 +1,11 @@ + + + demoextrafield + + + + + + 0 + 0 + \ No newline at end of file diff --git a/demoextrafield/views/templates/hook/_extra_properties.tpl b/demoextrafield/views/templates/hook/_extra_properties.tpl index d4632ce..68c31d9 100644 --- a/demoextrafield/views/templates/hook/_extra_properties.tpl +++ b/demoextrafield/views/templates/hook/_extra_properties.tpl @@ -2,7 +2,7 @@ Displays all extra fields registered by this module for a given entity. Usage: {include file='./_extra_properties.tpl' objectModel=$product} - where $objectModel is a LazyArray (or any array) exposing extraProperties.{moduleName}.{fieldName}. + where $objectModel is a LazyArray (or any array) exposing extra_properties.{moduleName}.{fieldName}. Note on lang-scoped fields (scope="lang"): The ExtraPropertyReader translates the per-language array into a single scalar value for the @@ -12,11 +12,11 @@ Fields registered with displayFront=false are filtered out by ExtraPropertiesLazyArray::getValues() before this template is reached. They will never appear here even if they exist in the database. *} -{if empty($objectModel.extraProperties.demoextrafield)} +{if empty($objectModel.extra_properties.demoextrafield)}

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

{else}
    - {foreach from=$objectModel.extraProperties.demoextrafield key=fieldName item=fieldValue} + {foreach from=$objectModel.extra_properties.demoextrafield key=fieldName item=fieldValue}
  • {$fieldName|escape:'htmlall':'UTF-8'}: {$fieldValue|escape:'htmlall':'UTF-8'} diff --git a/demoextrafield/views/templates/hook/product_additional_info.tpl b/demoextrafield/views/templates/hook/product_additional_info.tpl index 8619ff0..9bca1e4 100644 --- a/demoextrafield/views/templates/hook/product_additional_info.tpl +++ b/demoextrafield/views/templates/hook/product_additional_info.tpl @@ -6,7 +6,7 @@ {* 2. Named access — read a specific field directly without looping. Useful when you need to act on a known field (conditional display, formatting, etc.). *} - {if $product.extraProperties.demoextrafield.is_dangerous|intval} + {if $product.extra_properties.demoextrafield.is_dangerous|intval}

    {l s='⚠ This product is marked as dangerous.' d='Modules.Demoextrafield.Admin'}

    From 93e8561f238b04612ede7ef1c1f47d6b92605e86 Mon Sep 17 00:00:00 2001 From: jeremie Date: Tue, 2 Jun 2026 15:33:23 +0200 Subject: [PATCH 6/7] demoextrafield: code update after review 5 --- demoextrafield/demoextrafield.php | 101 +++++++++++++++--------------- 1 file changed, 49 insertions(+), 52 deletions(-) diff --git a/demoextrafield/demoextrafield.php b/demoextrafield/demoextrafield.php index 8b03492..caa9422 100644 --- a/demoextrafield/demoextrafield.php +++ b/demoextrafield/demoextrafield.php @@ -7,7 +7,7 @@ declare(strict_types=1); -use PrestaShop\PrestaShop\Core\ExtraProperty\ExtraPropertyOptions; +use PrestaShop\PrestaShop\Core\ExtraProperty\Definition\ExtraPropertyDefinition; use PrestaShop\PrestaShop\Core\ExtraProperty\ExtraPropertyScope; use PrestaShop\PrestaShop\Core\ExtraProperty\ExtraPropertySqlIndex; use PrestaShop\PrestaShop\Core\ExtraProperty\ExtraPropertyType; @@ -71,9 +71,9 @@ public function install(): bool $productDangerousRegistered = $this->registerExtraProperty( 'product', 'is_dangerous', - new ExtraPropertyOptions( + new ExtraPropertyDefinition( type: ExtraPropertyType::BOOL, - scope: ExtraPropertyScope::Common, + scope: ExtraPropertyScope::COMMON, nullable: false, defaultValue: 0, labelWording: $this->trans('Dangerous product', [], 'Modules.Demoextrafield.Admin', 'en'), @@ -83,8 +83,7 @@ public function install(): bool formFieldType: CheckboxType::class, validator: 'isBool', displayApi: true, - displayForm: true, - formPosition: 'options.extra_properties', + associatedForms: ['product.options.extra_properties'], associatedGrids: ['product.reference'], ) ); @@ -98,19 +97,19 @@ public function install(): bool $productVideoLinkRegistered = $this->registerExtraProperty( 'product', 'video_link', - new ExtraPropertyOptions( + new ExtraPropertyDefinition( type: ExtraPropertyType::STRING, - scope: ExtraPropertyScope::Lang, + scope: ExtraPropertyScope::LANG, nullable: true, labelWording: $this->trans('Video link', [], 'Modules.Demoextrafield.Admin', 'en'), labelDomain: self::TRANSLATION_DOMAIN, descriptionWording: $this->trans('Video URL per language', [], 'Modules.Demoextrafield.Admin', 'en'), descriptionDomain: self::TRANSLATION_DOMAIN, - sqlIndex: ExtraPropertySqlIndex::Unique, + sqlIndex: ExtraPropertySqlIndex::UNIQUE, formFieldType: UrlType::class, validator: 'isUrl', displayApi: true, - displayForm: true, + associatedForms: ['product'], ) ); if (!$productVideoLinkRegistered) { @@ -123,19 +122,19 @@ public function install(): bool $productCustomDateRegistered = $this->registerExtraProperty( 'product', 'custom_date', - new ExtraPropertyOptions( + new ExtraPropertyDefinition( type: ExtraPropertyType::DATE, - scope: ExtraPropertyScope::Shop, + scope: ExtraPropertyScope::SHOP, nullable: true, labelWording: $this->trans('Custom date', [], 'Modules.Demoextrafield.Admin', 'en'), labelDomain: self::TRANSLATION_DOMAIN, descriptionWording: $this->trans('Custom date per shop', [], 'Modules.Demoextrafield.Admin', 'en'), descriptionDomain: self::TRANSLATION_DOMAIN, - sqlIndex: ExtraPropertySqlIndex::Key, + sqlIndex: ExtraPropertySqlIndex::KEY, formFieldType: DatePickerType::class, validator: 'isDate', displayApi: true, - displayForm: true, + associatedForms: ['product'], associatedGrids: ['product.final_price_tax_excluded:before'], ) ); @@ -151,16 +150,15 @@ public function install(): bool $productDateLastSeenRegistered = $this->registerExtraProperty( 'product', 'date_last_seen', - new ExtraPropertyOptions( + new ExtraPropertyDefinition( type: ExtraPropertyType::DATE, - scope: ExtraPropertyScope::Common, + scope: ExtraPropertyScope::COMMON, nullable: true, labelWording: $this->trans('Date last seen', [], 'Modules.Demoextrafield.Admin', 'en'), labelDomain: self::TRANSLATION_DOMAIN, descriptionWording: $this->trans('Last time this product page was viewed', [], 'Modules.Demoextrafield.Admin', 'en'), descriptionDomain: self::TRANSLATION_DOMAIN, displayApi: true, - displayForm: false, associatedGrids: ['product'], ) ); @@ -179,9 +177,9 @@ public function install(): bool $productPackagingTypeRegistered = $this->registerExtraProperty( 'product', 'packaging_type', - new ExtraPropertyOptions( + new ExtraPropertyDefinition( type: ExtraPropertyType::CHOICE, - scope: ExtraPropertyScope::Common, + scope: ExtraPropertyScope::COMMON, enumValues: ['standard', 'gift', 'bulk'], nullable: true, defaultValue: null, @@ -200,8 +198,8 @@ enumValues: ['standard', 'gift', 'bulk'], ], formRequired: false, displayApi: true, - displayForm: true, displayFront: true, + associatedForms: ['product'], associatedGrids: ['product'], ) ); @@ -223,9 +221,9 @@ enumValues: ['standard', 'gift', 'bulk'], $categoryThemeColorRegistered = $this->registerExtraProperty( 'category', 'theme_color', - new ExtraPropertyOptions( + new ExtraPropertyDefinition( type: ExtraPropertyType::STRING, - scope: ExtraPropertyScope::Common, + scope: ExtraPropertyScope::COMMON, nullable: true, labelWording: $this->trans('Theme color', [], 'Modules.Demoextrafield.Admin', 'en'), labelDomain: self::TRANSLATION_DOMAIN, @@ -235,7 +233,7 @@ enumValues: ['standard', 'gift', 'bulk'], formRequired: true, validator: 'isColor', displayApi: true, - displayForm: true, + associatedForms: ['category', 'root_category'], associatedGrids: ['category'] ) ); @@ -249,9 +247,9 @@ enumValues: ['standard', 'gift', 'bulk'], $categoryMarketingNoteRegistered = $this->registerExtraProperty( 'category', 'marketing_note', - new ExtraPropertyOptions( + new ExtraPropertyDefinition( type: ExtraPropertyType::HTML, - scope: ExtraPropertyScope::Common, + scope: ExtraPropertyScope::COMMON, nullable: true, labelWording: $this->trans('Marketing note', [], 'Modules.Demoextrafield.Admin', 'en'), labelDomain: self::TRANSLATION_DOMAIN, @@ -260,7 +258,7 @@ enumValues: ['standard', 'gift', 'bulk'], formFieldType: FormattedTextareaType::class, validator: 'isCleanHtml', displayApi: true, - displayForm: true, + associatedForms: ['category'], ) ); if (!$categoryMarketingNoteRegistered) { @@ -273,9 +271,9 @@ enumValues: ['standard', 'gift', 'bulk'], $categorySupplierRegistered = $this->registerExtraProperty( 'category', 'id_supplier', - new ExtraPropertyOptions( + new ExtraPropertyDefinition( type: ExtraPropertyType::INT, - scope: ExtraPropertyScope::Common, + scope: ExtraPropertyScope::COMMON, nullable: true, labelWording: $this->trans('Default supplier', [], 'Modules.Demoextrafield.Admin', 'en'), labelDomain: self::TRANSLATION_DOMAIN, @@ -284,7 +282,7 @@ enumValues: ['standard', 'gift', 'bulk'], formFieldType: DiscountSupplierType::class, validator: 'isUnsignedId', displayApi: true, - displayForm: true, + associatedForms: ['category'], associatedGrids: ['category'] ) ); @@ -302,9 +300,9 @@ enumValues: ['standard', 'gift', 'bulk'], $customerCreditLimitRegistered = $this->registerExtraProperty( 'customer', 'credit_limit', - new ExtraPropertyOptions( + new ExtraPropertyDefinition( type: ExtraPropertyType::FLOAT, - scope: ExtraPropertyScope::Common, + scope: ExtraPropertyScope::COMMON, nullable: true, labelWording: $this->trans('Credit limit', [], 'Modules.Demoextrafield.Admin', 'en'), labelDomain: self::TRANSLATION_DOMAIN, @@ -313,7 +311,7 @@ enumValues: ['standard', 'gift', 'bulk'], formFieldType: MoneyType::class, validator: 'isPrice', displayApi: true, - displayForm: true, + associatedForms: ['customer'], associatedGrids: ['customer'] ) ); @@ -327,9 +325,9 @@ enumValues: ['standard', 'gift', 'bulk'], $customerExtraJsonRegistered = $this->registerExtraProperty( 'customer', 'extra_json', - new ExtraPropertyOptions( + new ExtraPropertyDefinition( type: ExtraPropertyType::JSON, - scope: ExtraPropertyScope::Common, + scope: ExtraPropertyScope::COMMON, nullable: true, labelWording: $this->trans('Metadata JSON', [], 'Modules.Demoextrafield.Admin', 'en'), labelDomain: self::TRANSLATION_DOMAIN, @@ -338,7 +336,7 @@ enumValues: ['standard', 'gift', 'bulk'], formFieldType: TextareaType::class, validator: 'isJson', displayApi: true, - displayForm: true, + associatedForms: ['customer'], ) ); if (!$customerExtraJsonRegistered) { @@ -353,9 +351,9 @@ enumValues: ['standard', 'gift', 'bulk'], $customerInternalNoteRegistered = $this->registerExtraProperty( 'customer', 'internal_note', - new ExtraPropertyOptions( + new ExtraPropertyDefinition( type: ExtraPropertyType::STRING, - scope: ExtraPropertyScope::Common, + scope: ExtraPropertyScope::COMMON, nullable: true, labelWording: $this->trans('Internal note', [], 'Modules.Demoextrafield.Admin', 'en'), labelDomain: self::TRANSLATION_DOMAIN, @@ -363,8 +361,8 @@ enumValues: ['standard', 'gift', 'bulk'], descriptionDomain: self::TRANSLATION_DOMAIN, formFieldType: TextareaType::class, displayApi: true, - displayForm: true, displayFront: false, + associatedForms: ['customer'], ) ); if (!$customerInternalNoteRegistered) { @@ -395,9 +393,9 @@ enumValues: ['standard', 'gift', 'bulk'], $addressDeliveryNoteRegistered = $this->registerExtraProperty( 'address', 'delivery_note', - new ExtraPropertyOptions( + new ExtraPropertyDefinition( type: ExtraPropertyType::STRING, - scope: ExtraPropertyScope::Common, + scope: ExtraPropertyScope::COMMON, nullable: true, size: 255, labelWording: $this->trans('Delivery note', [], 'Modules.Demoextrafield.Admin', 'en'), @@ -407,7 +405,6 @@ enumValues: ['standard', 'gift', 'bulk'], formFieldType: TextareaType::class, validator: 'isGenericName', displayApi: true, - displayForm: false, // gridId 'manufacturer_address' ≠ entity 'address' — decoupling test. associatedGrids: ['manufacturer_address.city'], ) @@ -443,21 +440,21 @@ public function uninstall(): bool $dropColumn = true; return - $this->unregisterExtraProperty('product', 'video_link', ExtraPropertyScope::Lang, $dropColumn) - && $this->unregisterExtraProperty('product', 'is_dangerous', ExtraPropertyScope::Common, $dropColumn) - && $this->unregisterExtraProperty('product', 'custom_date', ExtraPropertyScope::Shop, $dropColumn) - && $this->unregisterExtraProperty('product', 'date_last_seen', ExtraPropertyScope::Common, $dropColumn) - && $this->unregisterExtraProperty('product', 'packaging_type', ExtraPropertyScope::Common, $dropColumn) + $this->unregisterExtraProperty('product', 'video_link', ExtraPropertyScope::LANG, $dropColumn) + && $this->unregisterExtraProperty('product', 'is_dangerous', ExtraPropertyScope::COMMON, $dropColumn) + && $this->unregisterExtraProperty('product', 'custom_date', ExtraPropertyScope::SHOP, $dropColumn) + && $this->unregisterExtraProperty('product', 'date_last_seen', ExtraPropertyScope::COMMON, $dropColumn) + && $this->unregisterExtraProperty('product', 'packaging_type', ExtraPropertyScope::COMMON, $dropColumn) - && $this->unregisterExtraProperty('category', 'theme_color', ExtraPropertyScope::Common, $dropColumn) - && $this->unregisterExtraProperty('category', 'marketing_note', ExtraPropertyScope::Common, $dropColumn) - && $this->unregisterExtraProperty('category', 'id_supplier', ExtraPropertyScope::Common, $dropColumn) + && $this->unregisterExtraProperty('category', 'theme_color', ExtraPropertyScope::COMMON, $dropColumn) + && $this->unregisterExtraProperty('category', 'marketing_note', ExtraPropertyScope::COMMON, $dropColumn) + && $this->unregisterExtraProperty('category', 'id_supplier', ExtraPropertyScope::COMMON, $dropColumn) - && $this->unregisterExtraProperty('customer', 'credit_limit', ExtraPropertyScope::Common, $dropColumn) - && $this->unregisterExtraProperty('customer', 'extra_json', ExtraPropertyScope::Common, $dropColumn) - && $this->unregisterExtraProperty('customer', 'internal_note', ExtraPropertyScope::Common, $dropColumn) + && $this->unregisterExtraProperty('customer', 'credit_limit', ExtraPropertyScope::COMMON, $dropColumn) + && $this->unregisterExtraProperty('customer', 'extra_json', ExtraPropertyScope::COMMON, $dropColumn) + && $this->unregisterExtraProperty('customer', 'internal_note', ExtraPropertyScope::COMMON, $dropColumn) - && $this->unregisterExtraProperty('address', 'delivery_note', ExtraPropertyScope::Common, $dropColumn) + && $this->unregisterExtraProperty('address', 'delivery_note', ExtraPropertyScope::COMMON, $dropColumn) && parent::uninstall(); } From 5d5cb8499e2b0a4809c4436a9dd7e4564d4c3754 Mon Sep 17 00:00:00 2001 From: jeremie Date: Thu, 4 Jun 2026 17:23:52 +0200 Subject: [PATCH 7/7] FIX demoextrafield : smarty variable as snake_case --- demoextrafield/demoextrafield.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/demoextrafield/demoextrafield.php b/demoextrafield/demoextrafield.php index caa9422..743773d 100644 --- a/demoextrafield/demoextrafield.php +++ b/demoextrafield/demoextrafield.php @@ -558,8 +558,8 @@ public function hookDisplayCustomerAccountTop(): string $customerId )->getValues(); - // Wrap as ['extraProperties' => ...] so _extra_properties.tpl can be reused as-is. - $this->context->smarty->assign('customerExtraData', ['extraProperties' => $extraPropertiesByModule]); + // Wrap as ['extra_properties' => ...] so _extra_properties.tpl can be reused as-is. + $this->context->smarty->assign('customerExtraData', ['extra_properties' => $extraPropertiesByModule]); return $this->display(__FILE__, 'views/templates/hook/customer_account_top.tpl'); }