Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 65 additions & 4 deletions demoextrafield/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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`)

Expand All @@ -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

Expand Down Expand Up @@ -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)

Expand Down
234 changes: 233 additions & 1 deletion demoextrafield/demoextrafield.php
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand All @@ -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');

Expand Down Expand Up @@ -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')
Expand All @@ -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();
}
Expand Down Expand Up @@ -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).
*
Expand All @@ -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');
Expand Down
Loading