diff --git a/demoextrafield/README.md b/demoextrafield/README.md index 5e5290d4..a1db5216 100644 --- a/demoextrafield/README.md +++ b/demoextrafield/README.md @@ -6,8 +6,8 @@ This module demonstrates how to use **native extra fields** (custom fields) in P 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, …) +- Registering extra fields on multiple entities (Product, Category, Customer, Address, CMS, Cart, Order, Combination) +- Covering multiple **scopes** (`common`, `lang`, `shop`) and **types** (bool, date, money, html, json, url, choice, …) - 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 @@ -19,6 +19,8 @@ It focuses on: - `is_dangerous` (scope: `common`, type: bool) - `video_link` (scope: `lang`, type: string/url) - `custom_date` (scope: `shop`, type: date) +- `date_last_seen` (scope: `common`, type: date — written by a FO hook, no form) +- `packaging_type` (scope: `common`, type: choice) ### Category (`category`) @@ -29,7 +31,47 @@ It focuses on: ### Customer (`customer`) - `credit_limit` (scope: `common`, type: float / money) -- `extra_json` (scope: `common`, type: json) +- `extra_json` (scope: `common`, type: json) — the JSON showcase: the module **writes a PHP + structure** (the core encodes it for storage) and **reads back the decoded structure** + (iterated in the my-account template) +- `internal_note` (scope: `common`, type: string, `displayFront: false` — never reaches the FO) + +### Address (`address`) + +- `delivery_note` (scope: `common`, type: string — grid placement on the manufacturer addresses grid) + +### CMS (`cms`) — manual form integration (no `associatedForms`) + +- `promo_banner` (scope: `lang`, type: string) +- `revision_code` (scope: `common`, type: string) + +### Cart (`cart`) — a COMMON-only entity + +- `delivery_note` (scope: `common`, type: string) — seeded by `actionCartSave`, displayed on the + checkout summary (`displayCheckoutSummaryTop`), and **copied onto the order** at validation + (`actionValidateOrder`): the cart dies at the end of checkout, so cart values that must + survive the purchase have to be copied to the order by the module. +- The cart has no `cart_lang` / `cart_shop` base table (its `id_lang`/`id_shop` are plain + columns), so `lang` and `shop` scopes are rejected at registration for this entity. + +### Order (`order`) + +- `delivery_note` (scope: `common`, type: string) — filled from the cart at order validation, + displayed on the customer's order detail page (`displayOrderDetail`). Registered with the + natural entity name `order`: the core resolves the physical table (`orders`) and primary + key (`id_order`) from the ObjectModel. No order-grid placement on purpose: the order grid + uses id-first pagination, which the generic grid integration cannot join yet (core issue + [#42536](https://github.com/PrestaShop/PrestaShop/issues/42536)). + +### Combination (`combination`) + +- `ean_verified` (scope: `common`, type: bool) — exposed on the Admin API combinations list + (`/products/{productId}/combinations`) +- `restock_note` (scope: `shop`, type: string) — one value per store + +Registered with the natural entity name `combination` (the `Combination`, +`product_attribute` and `ProductAttribute` spellings work identically): the core resolves +the physical table (`product_attribute`) and primary key (`id_product_attribute`). ## How to test @@ -81,9 +123,28 @@ This module impacts both Back Office and Front Office. - My account page: `displayCustomerAccountTop` +### Cart & Order + +- Add any product to the cart: `actionCartSave` seeds the cart `delivery_note` (once). +- Open the cart / checkout: the note is displayed above the cart summary + (`displayCheckoutSummaryTop`, read from `{$cart.extra_properties.demoextrafield.delivery_note}`). +- Place the order: `actionValidateOrder` copies the note onto the order. +- In the customer account, open the order detail page: the copied note is displayed + (`displayOrderDetail`). + +### Combination + +- Edit a product's combinations: `ean_verified` / `restock_note` are stored per combination + (`restock_note` per store). +- Admin API: `GET /products/{productId}/combinations` returns `extra_demoextrafield_ean_verified` + inline on each item. + ### Where to find values in FO templates -On the Front Office, the module displays **only the values stored for this module**, under `extraProperties['demoextrafield']`. +On the Front Office, the module displays **only the values stored for this module**, under the +`extra_properties['demoextrafield']` key (snake_case — Smarty/presenter surfaces always use +`extra_properties`; the camelCase `extraProperties` spelling exists only in the Admin API JSON). +JSON-typed fields come back as **decoded structures** (arrays), not raw JSON strings. ## Translation note (Back Office) diff --git a/demoextrafield/demoextrafield.php b/demoextrafield/demoextrafield.php index d23387ec..f0fa2a8f 100644 --- a/demoextrafield/demoextrafield.php +++ b/demoextrafield/demoextrafield.php @@ -503,6 +503,120 @@ enumValues: ['standard', 'gift', 'bulk'], return false; } + /** + * CART extra field — the cart is a COMMON-only entity: it has no cart_lang / + * cart_shop base table (its id_lang / id_shop are plain columns), so LANG and + * SHOP scopes are rejected at registration. No form/grid/API placements exist + * for the cart either; the value is written by a hook and displayed on checkout. + */ + + // Cart (common) : delivery_note + $cartDeliveryNoteRegistered = $this->registerExtraProperty( + new ExtraPropertyDefinition( + entityName: 'cart', + propertyName: 'delivery_note', + type: ExtraPropertyType::STRING, + scope: ExtraPropertyScope::COMMON, + nullable: true, + displayFront: true, + formType: TextareaType::class, + labelWording: 'Delivery note', + labelDomain: self::TRANSLATION_DOMAIN, + descriptionWording: 'Delivery instructions attached to the cart during checkout', + descriptionDomain: self::TRANSLATION_DOMAIN, + ) + ); + if (!$cartDeliveryNoteRegistered) { + $this->_errors[] = $this->trans('Failed to register Cart extra field "delivery_note" (scope: common).', [], 'Modules.Demoextrafield.Admin'); + + return false; + } + + /** + * ORDER extra field — registered with the natural entity name 'order': the core + * resolves the physical table ('orders') and the primary key ('id_order') from + * the Order ObjectModel. COMMON is the only supported scope (no orders_lang / + * orders_shop tables). Deliberately NO order-grid placement: the order grid uses + * id-first pagination, which the generic grid integration cannot join yet (core + * issue #42536) — the column would render empty. + */ + + // Order (common) : delivery_note — filled from the cart's note at order validation. + $orderDeliveryNoteRegistered = $this->registerExtraProperty( + new ExtraPropertyDefinition( + entityName: 'order', + propertyName: 'delivery_note', + type: ExtraPropertyType::STRING, + scope: ExtraPropertyScope::COMMON, + nullable: true, + displayFront: true, + formType: TextareaType::class, + labelWording: 'Delivery note (copied from the cart)', + labelDomain: self::TRANSLATION_DOMAIN, + descriptionWording: 'Delivery instructions copied from the cart when the order is validated', + descriptionDomain: self::TRANSLATION_DOMAIN, + ) + ); + if (!$orderDeliveryNoteRegistered) { + $this->_errors[] = $this->trans('Failed to register Order extra field "delivery_note" (scope: common).', [], 'Modules.Demoextrafield.Admin'); + + return false; + } + + /** + * COMBINATION extra fields — registered with the natural entity name + * 'combination' (the 'Combination'/'product_attribute'/'ProductAttribute' + * spellings work identically): the core resolves the physical table + * ('product_attribute') and the primary key ('id_product_attribute'). All three + * scopes are available (product_attribute_lang / product_attribute_shop exist). + */ + + // Combination (common) : ean_verified — exposed on the combinations API list. + $combinationEanVerifiedRegistered = $this->registerExtraProperty( + new ExtraPropertyDefinition( + entityName: 'combination', + propertyName: 'ean_verified', + type: ExtraPropertyType::BOOL, + scope: ExtraPropertyScope::COMMON, + defaultValue: false, + nullable: false, + displayFront: true, + associatedApis: ['/products/{productId}/combinations'], + formType: SwitchType::class, + labelWording: 'EAN verified', + labelDomain: self::TRANSLATION_DOMAIN, + descriptionWording: 'Whether the combination EAN has been verified by the merchant', + descriptionDomain: self::TRANSLATION_DOMAIN, + ) + ); + if (!$combinationEanVerifiedRegistered) { + $this->_errors[] = $this->trans('Failed to register Combination extra field "ean_verified" (scope: common).', [], 'Modules.Demoextrafield.Admin'); + + return false; + } + + // Combination (shop) : restock_note — one value per store. + $combinationRestockNoteRegistered = $this->registerExtraProperty( + new ExtraPropertyDefinition( + entityName: 'combination', + propertyName: 'restock_note', + type: ExtraPropertyType::STRING, + scope: ExtraPropertyScope::SHOP, + nullable: true, + displayFront: false, + formType: TextareaType::class, + labelWording: 'Restock note (per store)', + labelDomain: self::TRANSLATION_DOMAIN, + descriptionWording: 'Internal restocking note, stored per store', + descriptionDomain: self::TRANSLATION_DOMAIN, + ) + ); + if (!$combinationRestockNoteRegistered) { + $this->_errors[] = $this->trans('Failed to register Combination extra field "restock_note" (scope: shop).', [], 'Modules.Demoextrafield.Admin'); + + return false; + } + $hooksRegistered = $this->registerHook('displayProductAdditionalInfo') && $this->registerHook('displayCartExtraProductInfo') && $this->registerHook('displayHeaderCategory') @@ -512,7 +626,11 @@ enumValues: ['standard', 'gift', 'bulk'], && $this->registerHook('actionCmsPageFormDataProviderData') && $this->registerHook('actionAfterCreateCmsPageFormHandler') && $this->registerHook('actionAfterUpdateCmsPageFormHandler') - && $this->registerHook('displayCMSDisputeInformation'); + && $this->registerHook('displayCMSDisputeInformation') + && $this->registerHook('displayCheckoutSummaryTop') + && $this->registerHook('actionCartSave') + && $this->registerHook('actionValidateOrder') + && $this->registerHook('displayOrderDetail'); if (!$hooksRegistered) { $this->_errors[] = $this->trans('Failed to register one or more hooks.', [], 'Modules.Demoextrafield.Admin'); @@ -554,6 +672,11 @@ public function uninstall(): bool && $this->unregisterExtraProperty(new ExtraPropertyDefinition('cms', 'promo_banner'), $dropColumn) && $this->unregisterExtraProperty(new ExtraPropertyDefinition('cms', 'revision_code'), $dropColumn) + && $this->unregisterExtraProperty(new ExtraPropertyDefinition('cart', 'delivery_note'), $dropColumn) + && $this->unregisterExtraProperty(new ExtraPropertyDefinition('order', 'delivery_note'), $dropColumn) + && $this->unregisterExtraProperty(new ExtraPropertyDefinition('combination', 'ean_verified'), $dropColumn) + && $this->unregisterExtraProperty(new ExtraPropertyDefinition('combination', 'restock_note'), $dropColumn) + && $this->unregisterHook('displayProductAdditionalInfo') && $this->unregisterHook('displayCartExtraProductInfo') && $this->unregisterHook('displayHeaderCategory') @@ -564,6 +687,10 @@ public function uninstall(): bool && $this->unregisterHook('actionAfterCreateCmsPageFormHandler') && $this->unregisterHook('actionAfterUpdateCmsPageFormHandler') && $this->unregisterHook('displayCMSDisputeInformation') + && $this->unregisterHook('displayCheckoutSummaryTop') + && $this->unregisterHook('actionCartSave') + && $this->unregisterHook('actionValidateOrder') + && $this->unregisterHook('displayOrderDetail') && parent::uninstall(); } @@ -674,6 +801,99 @@ public function hookDisplayHeaderCategory(): string return $this->display(__FILE__, 'views/templates/hook/category_header.tpl'); } + /** + * Action hook — fires on every cart save. + * + * Demo of the CART extra field write path: seeds the delivery_note the first time the + * cart is persisted (a real module would set it from a checkout form field). Writing + * through $cart->update() re-triggers actionCartSave, hence the re-entrancy guard. + */ + public function hookActionCartSave(): void + { + static $seeding = false; + if ($seeding) { + return; + } + + $cart = $this->context->cart; + if (!Validate::isLoadedObject($cart)) { + return; + } + + $existingNote = $cart->extra_properties['demoextrafield']['delivery_note']; + if (is_string($existingNote) && '' !== $existingNote) { + return; + } + + $seeding = true; + try { + $cart->extra_properties['demoextrafield']['delivery_note'] = sprintf( + 'Leave the parcel at the pickup point (demo note seeded on %s).', + date('Y-m-d H:i') + ); + $cart->update(); + } finally { + $seeding = false; + } + } + + /** + * Front Office hook (checkout / cart summary). + * + * Displays the cart delivery_note read through the presented cart: `$cart` is a Smarty + * global on every FO page (a CartLazyArray), and the extra properties are exposed under + * its `extra_properties` key — snake_case, like every Smarty surface (the camelCase + * `extraProperties` spelling belongs to the Admin API JSON only). + */ + public function hookDisplayCheckoutSummaryTop(array $params): string + { + return $this->display(__FILE__, 'views/templates/hook/checkout_summary_top.tpl'); + } + + /** + * Action hook — fires when an order is validated. + * + * The cart dies at the end of checkout (its cookie is dropped once the order exists), + * so a cart-level value that must stay visible after the purchase has to be copied + * onto the order. This is the intended pattern: the module owns the copy. + * + * Also a live demo of the entity-name resolution: the definition was registered as + * 'order' while the ObjectModel writes through its physical table 'orders' / + * primary key 'id_order'. + */ + public function hookActionValidateOrder(array $params): void + { + $cart = $params['cart'] ?? null; + $order = $params['order'] ?? null; + if (!$cart instanceof Cart || !$order instanceof Order || (int) $order->id <= 0) { + return; + } + + $deliveryNote = $cart->extra_properties['demoextrafield']['delivery_note']; + if (!is_string($deliveryNote) || '' === $deliveryNote) { + return; + } + + $order->extra_properties['demoextrafield']['delivery_note'] = $deliveryNote; + $order->update(); + } + + /** + * Front Office hook (order detail page, customer account). + * Displays the order's extra fields (e.g. the delivery_note copied from the cart). + */ + public function hookDisplayOrderDetail(array $params): string + { + $order = $params['order'] ?? null; + if (!$order instanceof Order || (int) $order->id <= 0) { + return ''; + } + + $this->context->smarty->assign('orderObjectModel', $order); + + return $this->display(__FILE__, 'views/templates/hook/order_detail.tpl'); + } + /** * Front Office hook (customer my-account page). * @@ -697,6 +917,18 @@ public function hookDisplayCustomerAccountTop(): string return ''; } + // JSON showcase: write a real PHP structure — the writer json_encodes it for + // storage, the constraint (Assert\Json) validates the encoded string, and reads + // give the decoded structure back (iterable in the template below). + $extraJson = $customer->extra_properties['demoextrafield']['extra_json']; + if (empty($extraJson)) { + $customer->extra_properties['demoextrafield']['extra_json'] = [ + 'loyalty' => ['points' => 0, 'tier' => 'bronze'], + 'preferences' => ['newsletter' => true], + ]; + $customer->update(); + } + $this->context->smarty->assign('customerObjectModel', $customer); return $this->display(__FILE__, 'views/templates/hook/customer_account_top.tpl'); diff --git a/demoextrafield/views/templates/hook/_extra_properties.tpl b/demoextrafield/views/templates/hook/_extra_properties.tpl index a3dcad6a..d93f4630 100644 --- a/demoextrafield/views/templates/hook/_extra_properties.tpl +++ b/demoextrafield/views/templates/hook/_extra_properties.tpl @@ -11,6 +11,10 @@ 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 json-typed fields (type="json"): + Reads return the DECODED structure (a PHP array), not the raw JSON string — hence the + is_array branch below, which iterates the first level and prints deeper levels re-encoded. + Note on displayFront=false fields: Filtering is native — they never reach this template. Presenter lazy arrays ($product, $category…) are built with forFrontOffice: true, and ObjectModel bags ($customer->extra_properties) @@ -20,7 +24,23 @@ {foreach from=$objectModel->extra_properties.demoextrafield key=fieldName item=fieldValue}
  • {$fieldName|escape:'htmlall':'UTF-8'}: - {$fieldValue|escape:'htmlall':'UTF-8'} + {if is_array($fieldValue)} + {* JSON field: the decoded structure is iterable as-is. *} + + {else} + {$fieldValue|escape:'htmlall':'UTF-8'} + {/if}
  • {foreachelse}
  • {l s='No extra fields found for this module.' d='Modules.Demoextrafield.Main'}
  • diff --git a/demoextrafield/views/templates/hook/checkout_summary_top.tpl b/demoextrafield/views/templates/hook/checkout_summary_top.tpl new file mode 100644 index 00000000..25d54271 --- /dev/null +++ b/demoextrafield/views/templates/hook/checkout_summary_top.tpl @@ -0,0 +1,14 @@ +{* + Cart extra field on the checkout summary. + + $cart is the presented CartLazyArray, a Smarty global on every FO page. Extra properties + are exposed under its `extra_properties` key (snake_case — the camelCase `extraProperties` + spelling exists only in the Admin API JSON). displayFront filtering is native: a + displayFront=false cart field would never reach this template. +*} +{if isset($cart.extra_properties.demoextrafield.delivery_note) && $cart.extra_properties.demoextrafield.delivery_note} +
    +

    {l s='Delivery note (demoextrafield)' d='Modules.Demoextrafield.Main'}

    +

    {$cart.extra_properties.demoextrafield.delivery_note|escape:'htmlall':'UTF-8'}

    +
    +{/if} diff --git a/demoextrafield/views/templates/hook/order_detail.tpl b/demoextrafield/views/templates/hook/order_detail.tpl new file mode 100644 index 00000000..7fc57d38 --- /dev/null +++ b/demoextrafield/views/templates/hook/order_detail.tpl @@ -0,0 +1,12 @@ +{* + Order extra fields on the customer's order detail page. + + $orderObjectModel is the raw Order ObjectModel (assigned in hookDisplayOrderDetail): the + bag resolves the entity through its physical table (orders / id_order) even though the + definition was registered as 'order'. The delivery_note was copied from the cart by + hookActionValidateOrder — the intended pattern for cart values that must survive checkout. +*} +
    +

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

    + {include file='./_extra_properties.tpl' objectModel=$orderObjectModel} +