diff --git a/development/components/extra-properties/_index.md b/development/components/extra-properties/_index.md new file mode 100644 index 0000000000..1a39527e22 --- /dev/null +++ b/development/components/extra-properties/_index.md @@ -0,0 +1,302 @@ +--- +title: Extra properties +menuTitle: Extra properties +chapter: true +--- + +# Extra properties {{< minver v="9.2" title="true" >}} + +Extra properties let you attach typed fields to existing PrestaShop entities (Product, Category, Customer, Order, Combination, and more) without creating your own tables, overriding core classes or writing persistence code. You declare a field once, and PrestaShop stores it, validates it and exposes it in the Back Office forms and grids, on the Front Office and in the Admin API. + +Typical use cases: + +- A module needs a "Dangerous product" flag on products, shown in the product form and grid. +- A theme needs a per-language video link or a color per category, read from Smarty templates. +- An ERP integration needs a per-store note on combinations, readable and writable through the Admin API. +- A merchant needs an internal reference on customers, created from the Back Office without any code. + +{{% notice info %}} +The extra properties code is marked as `@experimental` in PrestaShop 9.2. This leaves room for a backward compatibility break if there is absolutely no other option. See [Changes in PrestaShop 9.2.x]({{< relref "/9/modules/core-updates/9.2.md" >}}). +{{% /notice %}} + +A definition can be owned by a module, registered from its `install()` method, or by the core, created by a merchant from the Back Office: + +- [Register extra properties from a module]({{< relref "/9/development/components/extra-properties/register-from-module" >}}) +- [Manage extra properties from the Back Office]({{< relref "/9/development/components/extra-properties/register-from-back-office" >}}) + +The rest of this page describes the behavior shared by both. + +## Storage + +Extra properties use **column-based storage**: each property is one SQL column, not a row in a generic key/value (EAV) table. Values are typed by the database, can be indexed, and are joined 1:1 with the entity, so grids can filter and sort on them. + +### Value tables + +PrestaShop creates the value tables the first time a property is registered on an entity, one table per scope. `{entity}` is the physical table of the entity (see [Supported entities](#supported-entities-and-naming)). + +| Scope | Table | One value per | Mirrors the primary key of | +|-------|-------|---------------|----------------------------| +| `common` | `{entity}_extra` | entity | `{entity}` | +| `lang` | `{entity}_extra_lang` | entity and language | `{entity}_lang` | +| `shop` | `{entity}_extra_shop` | entity and store | `{entity}_shop` | + +Each value table copies the primary key of its base table. For example, `product_extra_lang` has the same primary key as `product_lang` (`id_product`, `id_shop`, `id_lang`), while `contact_extra_lang` only has `id_contact` and `id_lang` because `contact_lang` has no `id_shop` column. + +Choose the scope like you would for a native field: + +- `common`: a single value shared by all languages and stores, like a product reference. +- `lang`: a translated value, like a product name. The entity must have a `{entity}_lang` table. +- `shop`: a value that can differ per store, like a product price. The entity must have a `{entity}_shop` table. + +Each property adds one column named `{module}_{property}` (or `{property}` for core-owned properties) to the table of its scope. Hyphens are converted to underscores. When the column is dropped and only primary key columns remain, the table itself is dropped. + +### Definition registry + +Definitions are stored in two tables created at install time: + +| Table | Content | +|-------|---------| +| `extra_property_definition` | One row per property: entity, owning module (`NULL` for core-owned properties), property name, type, scope, index, size, default value, constraints, placements, labels. Unique on `(entity_name, module_name, property_name)`. | +| `extra_property_definition_shop` | The stores a definition is restricted to. No row means no explicit restriction (see [Multistore](#multistore)). | + +The `constraints` column holds the [constraint DSL](#validation) as text. `nullable` and the choice values are not stored in the registry: they are read from the live column definition. + +## Types + +| Type (`ExtraPropertyType`) | SQL column | Default BO form type | BO grid column | +|------|------------|----------------------|----------------| +| `INT` | `INT(11)` | `IntegerType` | `DataColumn` | +| `BOOL` | `TINYINT(1) UNSIGNED` | `SwitchType` | `ToggleColumn` | +| `STRING` | `VARCHAR(size)`, 255 by default | `TextType` | `DataColumn` | +| `FLOAT` | `DECIMAL(20,6)` | `NumberType` (scale 6) | `DataColumn` | +| `DATE` | `DATETIME` | `DatePickerType` | `DateTimeColumn` | +| `HTML` | `TEXT` | `FormattedTextareaType` | `DataColumn` | +| `JSON` | `LONGTEXT` | `TextareaType` | Not displayed | +| `CHOICE` | `ENUM(...)` of the choice values, `VARCHAR(64)` without values | `ChoiceType` built from the choice values | `DataColumn` | + +- The form type can be replaced by any Symfony form type (`formType`), with extra options (`formOptions`). The grid column type always follows the property type. +- `JSON` values are stored encoded and returned decoded: PHP code and Smarty templates get arrays, the Admin API returns JSON objects. +- `HTML` and `JSON` columns cannot carry an SQL `DEFAULT` clause. Their default value is kept in the registry and applied when values are read. +- A `BOOL` grid column can be toggled from the grid. The toggle requires the edit permission on the entity's Back Office page. + +## Where values surface + +Values are always grouped by owning module, then by property name. Core-owned properties use the `_core` key. + +### PHP (ObjectModel) + +Every `ObjectModel` exposes an `extra_properties` bag, loaded lazily on first access. Values you set are validated and saved by `add()`, `update()` and `save()`. + +```php +$product = new Product($productId); +$isDangerous = $product->extra_properties['demoextraproperty']['is_dangerous']; + +$product->extra_properties['demoextraproperty']['is_dangerous'] = true; +$product->update(); +``` + +For `lang` properties, an object loaded without a language returns an array keyed by language ID, and an object loaded with a language returns a single value for that language. + +On the Front Office, the bag only contains properties with `displayFront` enabled. In the Back Office, the Admin API and the CLI, it contains all properties. Any other entry point counts as Front Office: the legacy webservice, and crons or module scripts run over HTTP, only see `displayFront` properties. + +### Front Office templates + +Templates read the same bag as PHP, with the same `displayFront` filtering. Only the first level of the Smarty syntax depends on the variable: + +- **A presented array** uses the dot syntax: `{$product.extra_properties.demoextraproperty.video_link}`. The key is added by the presenters of Product, Category, Manufacturer, Supplier, Store, Order (also on the order detail page), Order return and Cart, and by `ObjectPresenter`, which presents for example the `$customer` global, CMS pages and categories, carriers and order addresses. +- **A raw `ObjectModel`** assigned to Smarty uses the object syntax for the first level: `{$customerObjectModel->extra_properties.demoextraproperty.credit_limit}`. Any entity works this way, presented or not. + +```smarty +{$product.extra_properties.demoextraproperty.video_link|escape:'htmlall':'UTF-8'} +{$cart.extra_properties.demoextraproperty.delivery_note|escape:'htmlall':'UTF-8'} +{$customer.extra_properties.demoextraproperty.credit_limit|escape:'htmlall':'UTF-8'} +``` + +A variable built by hand as an array, without a presenter, has no `extra_properties` key: load the `ObjectModel` instead. `lang` values are already resolved to the current language. + +{{% notice note %}} +The key is `extra_properties` (snake_case) in PHP and Smarty. The camelCase `extraProperties` spelling is only used in Admin API payloads. +{{% /notice %}} + +### Back Office forms + +A property is added to the forms listed in `associatedForms`. Each entry uses the format `formId[:path[:before|after]]`, where `formId` is the block prefix of the form type (`product`, `category`, `customer`...): + +| Entry | Placement | +|-------|-----------| +| `product` | A dedicated "Extra fields" tab on tabbed forms, or the end of the form on simple forms | +| `product:options` | Appended inside the `options` container | +| `product:options.suppliers:before` | Before the `suppliers` field of the `options` container | +| `customer:email:after` | After the `email` field | + +Every path segment must exist in the form. Each `formId` can appear only once. When `associatedForms` is empty, the property is not added to any form. + +Fields are added to forms built by the [identifiable object form builder]({{< relref "/9/development/architecture/migration-guide/forms/CRUD-forms" >}}), which covers the migrated Symfony pages. `lang` properties are rendered with a `TranslatableType`. The submitted values are saved with the entity. + +### Back Office grids + +A property is added as a column to the grids listed in `associatedGrids`. Each entry uses the format `gridId[:columnId[:before|after]]`, where `gridId` is the [grid definition]({{< relref "/9/development/components/grid" >}}) ID: + +| Entry | Placement | +|-------|-----------| +| `product` | Before the `actions` column, or at the end of the grid | +| `product:reference` | After the `reference` column | +| `product:final_price_tax_excluded:before` | Before the `final_price_tax_excluded` column | + +The column can be filtered and sorted. `JSON` properties are never displayed in grids. The grid ID does not have to match the entity name: for example, an `address` property can be placed on the `manufacturer_address` grid. + +### Admin API + +A property is exposed on the [Admin API]({{< relref "/9/admin-api" >}}) operations listed in `associatedApis`. Each entry is the operation URI template, with an optional list of HTTP methods: `uriTemplate[:METHOD[,METHOD...]]`. + +| Entry | Matches | +|-------|---------| +| `/products/{productId}` | Every method of the single product operations | +| `/products` | The product list (and product creation) | +| `/products/{productId}:GET,PATCH` | Only reading and partially updating a product | + +The URI template must match the operation exactly: nothing is inferred from the entity name, so a property never appears on a resource it does not target. + +On a single item, values are returned in a nested `extraProperties` object. `lang` values are keyed by locale, and `shop` values are resolved for the store of the request: + +```json +{ + "productId": 42, + "extraProperties": { + "demoextraproperty": { + "is_dangerous": true, + "video_link": { + "en-US": "https://example.com/video", + "fr-FR": "https://example.com/fr/video" + } + } + } +} +``` + +On a list, each item carries the value inline, at its root, under the field name `extra_{module}_{property}`, for the language of the request: + +```json +{ + "productId": 42, + "name": "Mug", + "extra_demoextraproperty_is_dangerous": true +} +``` + +`POST`, `PUT` and `PATCH` requests accept the same `extraProperties` object. Only the properties that target the operation are written, and `lang` values are keyed by locale. Invalid values return a `422` response whose violation paths look like `extraProperties..`, merged with the violations of the resource itself. The OpenAPI documentation of each operation describes its `extraProperties` object, including default values and required properties. + +## Validation + +Values are validated on every write path: ObjectModel save, Back Office forms and Admin API. Validation has two layers: + +- **Type compatibility** is always enforced, even without declared constraints: an `INT` must be an integer (`"1.5"` is refused), a `DATE` must be `Y-m-d` or `Y-m-d H:i:s`, a `CHOICE` must be one of the choice values, a `JSON` must be valid JSON. Values are never silently coerced. +- **Constraints** are Symfony validation constraints declared on the definition, run before the type check. + +Modules pass constraint objects. The registry stores them, and the Back Office and Admin API exchange them, as a text DSL with one constraint per line (or comma-separated): + +```text +NotBlank +Length(min: 2, max: 64) +GreaterThan(5) +Choice(['standard', 'gift', 'bulk']) +All[ Url, NotBlank ] +TypedRegex('generic_name') +``` + +A quoted value is always a string, an unquoted numeric value is a number, and `true`, `false` and `null` are literals. Composite constraints list their children between brackets. + +Only an allowlist of constraints is accepted: + +| Group | Constraints | +|-------|-------------| +| Presence | `NotBlank`, `NotNull`, `Blank`, `IsNull` | +| String and format | `Email`, `Url`, `Json`, `Uuid`, `Ulid`, `Ip`, `Cidr`, `Hostname`, `CssColor`, `NoSuspiciousCharacters`, `Length`, `Regex` | +| Date and time | `Date`, `DateTime`, `Time`, `Timezone` | +| Numbers | `Positive`, `PositiveOrZero`, `Negative`, `NegativeOrZero`, `Luhn`, `Range`, `DivisibleBy` | +| Comparison | `EqualTo`, `NotEqualTo`, `IdenticalTo`, `NotIdenticalTo`, `LessThan`, `LessThanOrEqual`, `GreaterThan`, `GreaterThanOrEqual` | +| Boolean | `IsTrue`, `IsFalse` | +| Identifiers | `Iban`, `Bic`, `Isbn`, `Issn`, `Isin`, `CardScheme` | +| Locale | `Country`, `Language`, `Locale`, `Currency` | +| Parametric | `Choice`, `Count`, `Type` | +| Composite | `All`, `AtLeastOneOf`, `Collection`, `Sequentially` | +| PrestaShop | `TypedRegex`, `DefaultLanguage`, `CleanHtml` | + +Options that Symfony evaluates at validation time (`callback`, `normalizer`, any `*PropertyPath` option) and the `groups` and `payload` options are refused. + +For `lang` properties, wrap per-language rules in `All` (for example `All[ Url ]`). Constraints that check the whole array, like `DefaultLanguage`, are declared without `All`. + +{{% notice warning %}} +`required` only marks the field as required in the Back Office form (HTML `required` attribute) and in the Admin API OpenAPI schema. It does not add any server-side check. To reject empty values, add a `NotBlank` constraint. +{{% /notice %}} + +## Default values + +`defaultValue` is typed like the property and checked against its type at registration. It is used: + +- as the SQL `DEFAULT` of the column (except for `HTML` and `JSON`), +- as the value returned for an entity that has no stored value yet, on every read path (PHP, Front Office, Back Office forms and grids, Admin API), +- to prefill the field on Back Office creation forms (except for `lang` properties). + +## Multistore + +In a [multistore]({{< relref "/9/development/multistore" >}}) shop, two independent mechanisms apply. + +**Values** follow the shop context of the request, like native fields: + +- Writes in a store group or "all stores" context are copied to every store of the context for tables that carry `id_shop`. For `shop` properties, only the stores the entity is associated with (in `{entity}_shop`) are updated, unless the stores are named explicitly. +- Reads in a store group or "all stores" context use one representative store: the default store when it is part of the context, otherwise the store with the lowest ID. + +**Definitions** can be restricted to some stores. A restricted definition only surfaces (Front Office, forms, grids, Admin API) in those stores. Without an explicit restriction: + +- a core-owned definition is available in all stores, +- a module-owned definition follows the stores where its module is enabled. + +The restriction only applies when multistore is used (feature enabled and more than one store). It changes where a definition is visible, not how values are stored: a restricted `common` property still has a single value. + +## Supported entities and naming + +Any entity whose table exists can receive extra properties, with or without an `ObjectModel`. `lang` requires a `{entity}_lang` table and `shop` requires a `{entity}_shop` table. Registration fails otherwise. + +The entity name is normalized to snake_case, and PrestaShop resolves the physical table from the `ObjectModel` definition, or uses the entity name when there is none. Some entities have a logical name that differs from their table: + +| Entity name to use | Also accepted | Physical table | +|--------------------|---------------|----------------| +| `order` | `orders` | `orders` | +| `combination` | `product_attribute`, `ProductAttribute` | `product_attribute` | +| `discount` | `cart_rule` | `cart_rule` | +| `catalog_price_rule` | `specific_price_rule` | `specific_price_rule` | +| `cms_page` | `cms` | `cms` | +| `cms_page_category` | `cms_category` | `cms_category` | +| `credit_slip` | `order_slip` | `order_slip` | +| `title` | `gender` | `gender` | +| `sql_request` | `request_sql` | `request_sql` | +| `language` | `lang` | `lang` | +| `connection` | `connections` | `connections` | +| `webservice_key` | `webservice_account` | `webservice_account` | +| `order_return` | `merchandise_return` | `order_return` | + +All spellings of an entity resolve to the same definition and storage. For a third-party `ObjectModel` whose entity name differs from its table, pass `tableName` explicitly. + +## Limitations + +- **Order grid**: the order grid paginates by ID first, so extra columns cannot be joined. Columns placed on it render empty and their filters do not work ([#42536](https://github.com/PrestaShop/PrestaShop/issues/42536)). Do not declare an `order` grid placement. +- **Cart values at checkout**: cart values are not copied to the order. A module that needs them after the purchase must copy them onto the order, for example at `actionValidateOrder` (see [demoextraproperty](https://github.com/PrestaShop/example-modules/blob/master/demoextraproperty/demoextraproperty.php)). +- **Entities without `{entity}_shop`**: entities bound to a store through a plain `id_shop` column, like `orders` or `cart`, only accept `common` properties (`cart` has no `cart_lang` table either). +- **Legacy pages**: Back Office forms and grids are only extended on pages migrated to Symfony. + +## In this section + +{{% children /%}} + +## Related reading + +- [Admin API]({{< relref "/9/admin-api" >}}) +- [Admin API multi-shop context]({{< relref "/9/admin-api/multi-shop" >}}) +- [Grid component]({{< relref "/9/development/components/grid" >}}) +- [Form component]({{< relref "/9/development/components/form" >}}) +- [Hook component]({{< relref "/9/development/components/hook" >}}) +- [ObjectModel]({{< relref "/9/development/components/database/objectmodel" >}}) +- [Multistore]({{< relref "/9/development/multistore" >}}) +- [Creating a module]({{< relref "/9/modules/creation" >}}) +- [demoextraproperty example module](https://github.com/PrestaShop/example-modules/tree/master/demoextraproperty) diff --git a/development/components/extra-properties/img/extra-properties-edit-field-definition.png b/development/components/extra-properties/img/extra-properties-edit-field-definition.png new file mode 100644 index 0000000000..f15fd40192 Binary files /dev/null and b/development/components/extra-properties/img/extra-properties-edit-field-definition.png differ diff --git a/development/components/extra-properties/img/extra-properties-field-definition.png b/development/components/extra-properties/img/extra-properties-field-definition.png new file mode 100644 index 0000000000..e0eb2125f2 Binary files /dev/null and b/development/components/extra-properties/img/extra-properties-field-definition.png differ diff --git a/development/components/extra-properties/img/extra-properties-grid.png b/development/components/extra-properties/img/extra-properties-grid.png new file mode 100644 index 0000000000..c3564328e2 Binary files /dev/null and b/development/components/extra-properties/img/extra-properties-grid.png differ diff --git a/development/components/extra-properties/img/extra-properties-labels.png b/development/components/extra-properties/img/extra-properties-labels.png new file mode 100644 index 0000000000..2a1d3a1e26 Binary files /dev/null and b/development/components/extra-properties/img/extra-properties-labels.png differ diff --git a/development/components/extra-properties/img/extra-properties-placement.png b/development/components/extra-properties/img/extra-properties-placement.png new file mode 100644 index 0000000000..af2d452993 Binary files /dev/null and b/development/components/extra-properties/img/extra-properties-placement.png differ diff --git a/development/components/extra-properties/img/extra-properties-validation.png b/development/components/extra-properties/img/extra-properties-validation.png new file mode 100644 index 0000000000..77588e058f Binary files /dev/null and b/development/components/extra-properties/img/extra-properties-validation.png differ diff --git a/development/components/extra-properties/img/extra-properties-view-module-owned.png b/development/components/extra-properties/img/extra-properties-view-module-owned.png new file mode 100644 index 0000000000..e02ff6b454 Binary files /dev/null and b/development/components/extra-properties/img/extra-properties-view-module-owned.png differ diff --git a/development/components/extra-properties/img/extra-properties-visibility.png b/development/components/extra-properties/img/extra-properties-visibility.png new file mode 100644 index 0000000000..cc23cc46c6 Binary files /dev/null and b/development/components/extra-properties/img/extra-properties-visibility.png differ diff --git a/development/components/extra-properties/register-from-back-office.md b/development/components/extra-properties/register-from-back-office.md new file mode 100644 index 0000000000..1c92ad9112 --- /dev/null +++ b/development/components/extra-properties/register-from-back-office.md @@ -0,0 +1,164 @@ +--- +title: Manage extra properties from the Back Office +menuTitle: Manage from the Back Office +weight: 20 +--- + +# Manage extra properties from the Back Office {{< minver v="9.2" title="true" >}} + +Merchants and integrators can create extra properties without code from **Advanced Parameters > Extra Properties** (`AdminExtraPropertyDefinitions` tab, route `admin_extra_property_definitions_index`). Properties created there are core-owned: they have no owning module and their values are grouped under the `_core` key (see [Where values surface]({{< relref "/9/development/components/extra-properties#where-values-surface" >}})). + +The same definitions can be managed programmatically through the [Admin API](#manage-definitions-through-the-admin-api). + +## Listing + +The page lists every definition, core-owned and module-owned. + +![Extra property definitions grid with its row actions](../img/extra-properties-grid.png) + +| Column | Content | +|--------|---------| +| ID | Definition ID | +| Entity | Logical entity name | +| Module | Owning module, empty for core-owned definitions | +| Property name | Property identifier | +| Type | Property type | +| Scope | `common`, `lang` or `shop` | +| Front | Whether the value is exposed on the Front Office (`displayFront`) | +| Store(s) | Stores the definition is restricted to, or "All stores". Only displayed when multistore is used. | + +In a multistore shop, the grid follows the shop context: in "All stores" it lists every definition, in a store or group context it lists the definitions available there. + +Row actions depend on the owner: + +- Core-owned rows have **Edit**, **Delete** and **Delete + drop column**. +- Module-owned rows have **View** only: the module remains the source of truth and must register or unregister them itself (see [Register extra properties from a module]({{< relref "/9/development/components/extra-properties/register-from-module" >}})). + +**Delete** removes the definition and keeps the column and its values. **Delete + drop column** also drops the column and all its values. The same two options are available as bulk actions. Module-owned rows in a bulk selection are skipped and reported as errors, the others are deleted. + +## Create a property + +Click **Add new extra property**. The form is split into five cards. + +### Field definition + +![Field definition card](../img/extra-properties-field-definition.png) + +| Field | Description | +|-------|-------------| +| Entity name | Entity or `ObjectModel` table name (`product`, `customer`, `order`, `combination`...). See [Supported entities]({{< relref "/9/development/components/extra-properties#supported-entities-and-naming" >}}). | +| Property name | Identifier used in the storage column. Letters, digits and underscores. | +| Field type | One of the [types]({{< relref "/9/development/components/extra-properties#types" >}}). | +| Scope | `common`, `lang` or `shop`. See [Storage]({{< relref "/9/development/components/extra-properties#storage" >}}). | +| SQL index | No index, index or unique index. Can be changed at any time. | +| Size | `VARCHAR` length for the text type, 255 by default. | +| Nullable | Whether the column accepts `NULL`. | +| Choice values | One value per line, for the choice list type. | +| Default value | See [Default values]({{< relref "/9/development/components/extra-properties#default-values" >}}). | + +### Visibility + +![Visibility card](../img/extra-properties-visibility.png) + +| Field | Description | +|-------|-------------| +| Display in front-office | Exposes the value on the Front Office (`displayFront`). | +| Required | Marks the field as required in Back Office forms and in the Admin API schema. See [Validation]({{< relref "/9/development/components/extra-properties#validation" >}}). | +| Store association | Only displayed when multistore is used. Stores the definition is restricted to. Leave empty to make it available in all stores. See [Multistore]({{< relref "/9/development/components/extra-properties#multistore" >}}). | + +### Labels and descriptions + +![Labels and descriptions card](../img/extra-properties-labels.png) + +| Field | Description | +|-------|-------------| +| Label wording | Label in Back Office forms and grids. Required when a form or grid placement is set. | +| Label domain | Translation domain of the label (for example `Admin.Catalog.Feature`). | +| Description wording | Help text displayed under the form field. | +| Description domain | Translation domain of the description. | + +### Validation + +![Validation card](../img/extra-properties-validation.png) + +The constraint builder adds Symfony constraints one row at a time, among the [allowed constraints]({{< relref "/9/development/components/extra-properties#validation" >}}). Each row is checked with the same parser as the registry, so an unknown constraint or an invalid option is reported on the row. For `lang` properties, rows placed in the per-language zone are applied to each language value (they are stored as one `All[...]` constraint). + +### Placement + +![Placement card](../img/extra-properties-placement.png) + +| Field | Description | +|-------|-------------| +| Forms | Back Office forms where the field is added. For each form, pick a field and a position (automatic, before or after). See [Back Office forms]({{< relref "/9/development/components/extra-properties#back-office-forms" >}}). | +| Grids | Back Office grids where the column is added, with an optional column and position. See [Back Office grids]({{< relref "/9/development/components/extra-properties#back-office-grids" >}}). | +| Admin API | Admin API operations exposing the value. See [Admin API]({{< relref "/9/development/components/extra-properties#admin-api" >}}). | +| Advanced form > Symfony form type | Form type FQCN replacing the default form type of the type. | +| Advanced form > Form options | Options passed to the form type, as a JSON object. | + +The form and the field lists are built from the forms and grids detected in the shop. An ID that is not listed can still be entered manually. + +## Edit a core-owned property + +Entity name, property name, type, scope and default value cannot be changed after creation: changing them would move or rewrite the stored data. + +![Field definition card when editing a property](../img/extra-properties-edit-field-definition.png) + +Size, nullable and choice values can be edited, but only in a direction that keeps the stored data: size increase, `NOT NULL` to `NULL`, new choice values. A destructive change is refused on save. To apply it, delete the property with **Delete + drop column** and create it again. + +## View a module-owned property + +The **View** action displays the definition of a module-owned property with all its fields disabled. + +![View page of a module-owned property in a multistore shop](../img/extra-properties-view-module-owned.png) + +In a multistore shop, the page also has a **Store association** field, the only setting a merchant can change on a module-owned definition. Leave it empty to follow the stores where the module is enabled. A module re-registering its property on upgrade does not overwrite this choice (see [Restrict a property to some stores]({{< relref "/9/development/components/extra-properties/register-from-module#restrict-a-property-to-some-stores" >}})). + +## Hooks + +The page is built with the [Grid]({{< relref "/9/development/components/grid" >}}) and [Form]({{< relref "/9/development/components/form" >}}) components. Modules can alter the listing with the grid hooks: + +- [actionExtraPropertyDefinitionGridDefinitionModifier]({{< relref "/9/modules/concepts/hooks/list-of-hooks/actionExtraPropertyDefinitionGridDefinitionModifier" >}}) +- [actionExtraPropertyDefinitionGridQueryBuilderModifier]({{< relref "/9/modules/concepts/hooks/list-of-hooks/actionExtraPropertyDefinitionGridQueryBuilderModifier" >}}) +- [actionExtraPropertyDefinitionGridDataModifier]({{< relref "/9/modules/concepts/hooks/list-of-hooks/actionExtraPropertyDefinitionGridDataModifier" >}}) +- [actionExtraPropertyDefinitionGridFilterFormModifier]({{< relref "/9/modules/concepts/hooks/list-of-hooks/actionExtraPropertyDefinitionGridFilterFormModifier" >}}) +- [actionExtraPropertyDefinitionGridPresenterModifier]({{< relref "/9/modules/concepts/hooks/list-of-hooks/actionExtraPropertyDefinitionGridPresenterModifier" >}}) + +The create and edit form dispatches the generic form hooks with the `ExtraPropertyDefinition` form name, for example `actionExtraPropertyDefinitionFormBuilderModifier` (see [action\FormBuilderModifier]({{< relref "/9/modules/concepts/hooks/list-of-hooks/actionFormBuilderModifier" >}})) and `actionAfterCreateExtraPropertyDefinitionFormHandler` (see [actionAfterCreate\FormHandler]({{< relref "/9/modules/concepts/hooks/list-of-hooks/actionAfterCreateFormHandler" >}})). + +## Manage definitions through the Admin API + +The `ps_apiresources` module exposes the definitions on the [Admin API]({{< relref "/9/admin-api" >}}) from PrestaShop 9.2.0. The endpoints follow the same rules as the Back Office page: module-owned definitions only accept a store association change. + +| Method | Endpoint | Scope | Description | +|--------|----------|-------|-------------| +| `GET` | `/extra-property-definitions` | `extra_property_definition_read` | List definitions | +| `GET` | `/extra-property-definitions/{extraPropertyDefinitionId}` | `extra_property_definition_read` | Get a definition | +| `POST` | `/extra-property-definitions` | `extra_property_definition_write` | Create a core-owned definition | +| `PATCH` | `/extra-property-definitions/{extraPropertyDefinitionId}` | `extra_property_definition_write` | Update a definition | +| `DELETE` | `/extra-property-definitions/{extraPropertyDefinitionId}` | `extra_property_definition_write` | Delete a definition. Send `{"dropColumn": true}` to also drop the column. | +| `DELETE` | `/extra-property-definitions/bulk-delete` | `extra_property_definition_write` | Delete several definitions (`extraPropertyDefinitionIds`, optional `dropColumn`). Per-item failures return a `207` response. | + +Constraints are exchanged as the [constraint DSL]({{< relref "/9/development/components/extra-properties#validation" >}}) string, types, scopes and indexes as their lowercase values (`string`, `lang`, `unique`), and the store restriction as `shopIds`. Refused definitions (unknown entity, scope conflict, destructive change, invalid default value or constraints) return a `422` response. + +```json +{ + "entityName": "product", + "propertyName": "internal_code", + "type": "string", + "scope": "common", + "size": 64, + "displayFront": false, + "constraints": "Length(max: 64)", + "associatedForms": ["product"], + "associatedGrids": ["product:reference"], + "associatedApis": ["/products/{productId}"], + "labelWording": "Internal code", + "labelDomain": "Admin.Catalog.Feature" +} +``` + +## Related reading + +- [Extra properties]({{< relref "/9/development/components/extra-properties" >}}) +- [Register extra properties from a module]({{< relref "/9/development/components/extra-properties/register-from-module" >}}) +- [Admin API]({{< relref "/9/admin-api" >}}) diff --git a/development/components/extra-properties/register-from-module.md b/development/components/extra-properties/register-from-module.md new file mode 100644 index 0000000000..6c0dc84a74 --- /dev/null +++ b/development/components/extra-properties/register-from-module.md @@ -0,0 +1,281 @@ +--- +title: Register extra properties from a module +menuTitle: Register from a module +weight: 10 +--- + +# Register extra properties from a module {{< minver v="9.2" title="true" >}} + +A module declares its extra properties with `Module::registerExtraProperty()`, usually in `install()`, and removes them with `Module::unregisterExtraProperty()` in `uninstall()`. The storage, the Back Office integration and the Admin API exposure are handled by the core, as described in [Extra properties]({{< relref "/9/development/components/extra-properties" >}}). + +The [demoextraproperty](https://github.com/PrestaShop/example-modules/tree/master/demoextraproperty) example module registers properties on products, categories, customers, addresses, CMS pages, carts, orders and combinations, with every scope and most types. All examples on this page come from it. + +## Register a property + +Build an `ExtraPropertyDefinition` and pass it to `registerExtraProperty()`. Named arguments are recommended: the constructor has many optional parameters. + +```php +use PrestaShop\PrestaShop\Core\ExtraProperty\Definition\ExtraPropertyDefinition; +use PrestaShop\PrestaShop\Core\ExtraProperty\Definition\ExtraPropertyScope; +use PrestaShop\PrestaShop\Core\ExtraProperty\Definition\ExtraPropertyType; +use PrestaShopBundle\Form\Admin\Type\SwitchType; + +public function install(): bool +{ + if (!parent::install()) { + return false; + } + + $registered = $this->registerExtraProperty( + new ExtraPropertyDefinition( + entityName: 'product', + propertyName: 'is_dangerous', + type: ExtraPropertyType::BOOL, + scope: ExtraPropertyScope::COMMON, + defaultValue: 0, + nullable: false, + displayFront: true, + associatedApis: ['/products', '/products/{productId}'], + associatedForms: ['product:options.suppliers:before'], + associatedGrids: ['product:reference'], + formType: SwitchType::class, + labelWording: 'Dangerous product', + labelDomain: 'Modules.Demoextraproperty.Admin', + descriptionWording: 'Indicates whether the product is dangerous', + descriptionDomain: 'Modules.Demoextraproperty.Admin', + ) + ); + if (!$registered) { + return false; + } + + return true; +} +``` + +See the full [install() method](https://github.com/PrestaShop/example-modules/blob/master/demoextraproperty/demoextraproperty.php) of demoextraproperty for `lang`, `shop`, `CHOICE`, `JSON` and `HTML` properties with constraints. + +The module name is filled in automatically from `$this->name`. The property name is unique per entity and module, across scopes. + +### Constructor arguments + +| Argument | Type | Default | Description | +|----------|------|---------|-------------| +| `entityName` | `string` | Required | Entity the property belongs to (`product`, `order`, `combination`...). Normalized to snake_case. See [Supported entities]({{< relref "/9/development/components/extra-properties#supported-entities-and-naming" >}}). | +| `propertyName` | `string` | Required | Property identifier. Letters, digits, `_` and `-`. The storage column `{module}_{property}` must not exceed 64 characters. | +| `type` | `ExtraPropertyType` | `STRING` | `INT`, `BOOL`, `STRING`, `FLOAT`, `DATE`, `HTML`, `JSON` or `CHOICE`. See [Types]({{< relref "/9/development/components/extra-properties#types" >}}). | +| `scope` | `ExtraPropertyScope` | `COMMON` | `COMMON`, `LANG` or `SHOP`. See [Storage]({{< relref "/9/development/components/extra-properties#storage" >}}). | +| `moduleName` | `?string` | `null` | Owning module. Filled in by `registerExtraProperty()` when `null`. | +| `enumValues` | `?list` | `null` | Allowed values of a `CHOICE` property, stored as an SQL `ENUM`. | +| `defaultValue` | `int\|float\|string\|bool\|null` | `null` | Default value, checked against the type. See [Default values]({{< relref "/9/development/components/extra-properties#default-values" >}}). | +| `nullable` | `bool` | `true` | `NULL` or `NOT NULL` column. | +| `required` | `bool` | `false` | Marks the field as required in Back Office forms and in the Admin API schema. See [Validation]({{< relref "/9/development/components/extra-properties#validation" >}}). | +| `size` | `?int` | `null` | `VARCHAR` length of a `STRING` property. 255 when `null`. | +| `sqlIndex` | `ExtraPropertySqlIndex` | `NONE` | Index on the storage column: `NONE`, `KEY` or `UNIQUE`. | +| `displayFront` | `bool` | `false` | Exposes the value on the Front Office (presenters and `ObjectModel` bags). Every entry point other than the Back Office, the Admin API and the CLI counts as Front Office, including the legacy webservice. | +| `associatedForms` | `?list` | `null` | Back Office form placements, `formId[:path[:before\|after]]`. See [Back Office forms]({{< relref "/9/development/components/extra-properties#back-office-forms" >}}). | +| `associatedGrids` | `?list` | `null` | Back Office grid placements, `gridId[:columnId[:before\|after]]`. See [Back Office grids]({{< relref "/9/development/components/extra-properties#back-office-grids" >}}). | +| `associatedApis` | `?list` | `null` | Admin API operations, `uriTemplate[:METHOD[,METHOD...]]`. See [Admin API]({{< relref "/9/development/components/extra-properties#admin-api" >}}). | +| `formType` | `?string` | `null` | Symfony form type FQCN replacing the default form type of the type. | +| `formOptions` | `?array` | `null` | Options passed to the form type. Stored as JSON, so they cannot contain objects. | +| `constraints` | `?list` | `null` | Symfony constraints, limited to the [allowed constraints]({{< relref "/9/development/components/extra-properties#validation" >}}). | +| `labelWording` | `?string` | `null` | Label in Back Office forms and grids. Required when `associatedForms` or `associatedGrids` is set. | +| `labelDomain` | `?string` | `null` | Translation domain of the label. Must belong to the module (`Modules..*`). | +| `descriptionWording` | `?string` | `null` | Help text displayed under the Back Office form field. | +| `descriptionDomain` | `?string` | `null` | Translation domain of the description. Must belong to the module. | +| `multiShop` | `?bool` | `null` | Internal: read from the storage table when definitions are loaded. Do not set it. | +| `associatedShopIds` | `?list` | `null` | Stores the definition is restricted to. See [Restrict a property to some stores](#restrict-a-property-to-some-stores). | +| `tableName` | `?string` | `null` | Physical table, when it cannot be resolved from the entity name (third-party `ObjectModel` whose entity name differs from its table). | +| `primaryKeyName` | `?string` | `null` | Primary key column. Resolved from the `ObjectModel` definition, then `id_{entity}`. | +| `controllerName` | `?string` | `null` | Back Office controller used as permission subject (grid toggle). Resolved from the entity name. Set it only when the entity's Back Office tab does not follow the `Admin{Entities}` convention. | + +The constructor throws an `InvalidExtraPropertyDefinitionException` when the definition is malformed: invalid identifier, invalid placement entry, duplicated form or grid ID, missing `labelWording` while a form or grid placement is set, a domain that does not belong to the module. + +{{% notice tip %}} +Labels and descriptions are translated from the Back Office translation interface. For the wordings to be listed there, also call `$this->trans()` with the same wording and domain somewhere in the module code, and ship them in the module translation files. +{{% /notice %}} + +### Handle registration failures + +Like other `register*()` methods, `registerExtraProperty()` returns `false` on failure and adds the reason to `$this->_errors`, where the module manager displays it. A failed registration persists nothing: no definition row, no column. + +To react to a specific reason, call the registry service directly. It throws an `ExtraPropertyRegistryException` whose code gives the reason: + +```php +use PrestaShop\PrestaShop\Core\ExtraProperty\Definition\ExtraPropertyRegistryInterface; +use PrestaShop\PrestaShop\Core\ExtraProperty\Exception\ExtraPropertyRegistryException; + +try { + $this->get(ExtraPropertyRegistryInterface::class)->register($definition->withModuleName($this->name)); +} catch (ExtraPropertyRegistryException $e) { + if ($e->getCode() === ExtraPropertyRegistryException::SCOPE_CONFLICT) { + // ... + } +} +``` + +| Code | Constant | Reason | +|------|----------|--------| +| 1 | `BASE_TABLE_NOT_FOUND` | The entity table does not exist, or the `{entity}_lang` / `{entity}_shop` table required by the scope does not exist. | +| 2 | `SCOPE_CONFLICT` | The property is already registered on this entity with another scope. | +| 3 | `DESTRUCTIVE_SCHEMA_CHANGE` | The change could lose stored data. See [Update a property](#update-a-property). | +| 4 | `PERSISTENCE_FAILURE` | Writing or deleting the definition row failed. | +| 5 | `SCHEMA_FAILURE` | Creating, altering or dropping a storage table or column failed. | +| 6 | `INVALID_FORM_OPTIONS` | `formType` and `formOptions` cannot build a form field. `getErrors()` lists each invalid option. | +| 7 | `UNKNOWN_SHOP` | `associatedShopIds` contains a store ID that does not exist. | +| 8 | `STORAGE_CONFLICT` | Another definition, under another entity name, already stores its values in the same column. | +| 9 | `INVALID_DEFAULT_VALUE` | `defaultValue` does not fit the type (non-numeric `INT` or `FLOAT`, invalid `DATE`, value outside the `CHOICE` values, invalid `JSON`). | +| 10 | `INVALID_CONSTRAINTS` | A constraint, an option or a value cannot be stored in the constraint DSL. | + +## Unregister a property + +Pass a definition identifying the property: entity and property name are enough, since a property is unique per entity and module. + +```php +public function uninstall(): bool +{ + return $this->unregisterExtraProperty(new ExtraPropertyDefinition('product', 'is_dangerous'), false) + && parent::uninstall(); +} +``` + +The second argument, `$dropData`, controls the storage column: + +| `$dropData` | Effect | +|-------------|--------| +| `false` (default) | Only the definition row is deleted. The column and its values stay in the database: reinstalling the module and registering the same property makes the values available again. | +| `true` | The column and all its values are dropped. The value table is dropped when no property column remains. | + +`unregisterExtraProperty()` returns `false` and adds the reason to `$this->_errors` on failure. Unregistering a property that is not registered does nothing and returns `true`. Uninstalling a module does not unregister its properties automatically: see the [uninstall() method](https://github.com/PrestaShop/example-modules/blob/master/demoextraproperty/demoextraproperty.php) of demoextraproperty. + +## Update a property + +Calling `registerExtraProperty()` for a property that already exists updates its definition. You can do it from a module [upgrade script]({{< relref "/9/modules/creation/enabling-auto-update" >}}). + +Labels, placements, `displayFront`, `required`, form type and options, constraints and index can always be changed. Storage changes are applied to the live column only when they cannot lose data: + +| Accepted | Refused with `DESTRUCTIVE_SCHEMA_CHANGE` | +|----------|------------------------------------------| +| Default value change | Type change | +| `STRING` size increase | Physical table change | +| `NOT NULL` to `NULL` | `STRING` size decrease | +| New `CHOICE` values | `NULL` to `NOT NULL` | +| | Removed `CHOICE` value, or switch between choice values and no choice values | + +A scope change is refused earlier, with `SCOPE_CONFLICT`. + +To apply a refused change, unregister the property with `$dropData = true`, then register it again. The stored values are lost: migrate them yourself if you need them. + +### Restrict a property to some stores + +`associatedShopIds` restricts where the definition is available in a [multistore]({{< relref "/9/development/components/extra-properties#multistore" >}}) shop. It has three states when you register: + +| Value | Effect on the stored restriction | +|-------|----------------------------------| +| `null` (default) | Left untouched. Re-registering on upgrade does not overwrite a restriction set by the merchant in the Back Office. | +| `[]` | Cleared: the definition follows the stores where the module is enabled. | +| `[1, 3]` | Replaced by this list. | + +## Read and write values + +Values are grouped by module name and property name everywhere. The surfaces are described in [Where values surface]({{< relref "/9/development/components/extra-properties#where-values-surface" >}}). + +In PHP, read and write through the `ObjectModel` bag, then save the object. For example, demoextraproperty copies a cart note onto the order when the order is validated, because cart values are not kept after checkout: + +```php +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['demoextraproperty']['delivery_note']; + if (!is_string($deliveryNote) || '' === $deliveryNote) { + return; + } + + $order->extra_properties['demoextraproperty']['delivery_note'] = $deliveryNote; + $order->update(); +} +``` + +`JSON` properties accept a PHP array: it is encoded for storage and decoded on read (see `hookDisplayCustomerAccountTop` in [demoextraproperty.php](https://github.com/PrestaShop/example-modules/blob/master/demoextraproperty/demoextraproperty.php)). + +In Front Office templates, read the presented entity: + +```smarty +{if $product.extra_properties.demoextraproperty.is_dangerous|intval} +

{l s='This product is marked as dangerous.' d='Modules.Demoextraproperty.Main'}

+{/if} +``` + +The [templates of demoextraproperty](https://github.com/PrestaShop/example-modules/tree/master/demoextraproperty/views/templates/hook) show the named access, a loop over all properties of a module ([_extra_properties.tpl](https://github.com/PrestaShop/example-modules/blob/master/demoextraproperty/views/templates/hook/_extra_properties.tpl)), the cart on the checkout summary and the order detail page. + +Through the Admin API, send the `extraProperties` object with a `POST`, `PUT` or `PATCH` request on an operation listed in `associatedApis`: + +```json +{ + "extraProperties": { + "demoextraproperty": { + "is_dangerous": true, + "video_link": { "en-US": "https://example.com/video" } + } + } +} +``` + +### Integrate a property manually in a form + +A property without `associatedForms` is not added to any form. You can still add the field yourself with the [form hooks]({{< relref "/9/development/components/form" >}}) and save the value through the `ObjectModel` bag. demoextraproperty does this for CMS pages with `actionCmsPageFormBuilderModifier`, `actionCmsPageFormDataProviderData` and `actionAfterCreateCmsPageFormHandler` / `actionAfterUpdateCmsPageFormHandler`. + +## Migrate from a custom extra table + +A module that stored additional fields in its own table can move them to extra properties from an upgrade script: register the property, then copy the data into the value table. + +The value table is `{entity}_extra` (or `_extra_lang`, `_extra_shop`, depending on the scope), and the column is named `{module}_{property}`, hyphens replaced by underscores. The table already exists after registration and may contain rows for other properties, so use `ON DUPLICATE KEY UPDATE`: + +```php +// upgrade/upgrade-2.0.0.php +use PrestaShop\PrestaShop\Core\ExtraProperty\Definition\ExtraPropertyDefinition; +use PrestaShop\PrestaShop\Core\ExtraProperty\Definition\ExtraPropertyType; + +function upgrade_module_2_0_0(Module $module): bool +{ + $registered = $module->registerExtraProperty(new ExtraPropertyDefinition( + entityName: 'product', + propertyName: 'internal_code', + type: ExtraPropertyType::STRING, + size: 64, + associatedForms: ['product'], + labelWording: 'Internal code', + labelDomain: 'Modules.Mymodule.Admin', + )); + if (!$registered) { + return false; + } + + // Storage column: {module}_{property} = mymodule_internal_code + $copied = Db::getInstance()->execute( + 'INSERT INTO `' . _DB_PREFIX_ . 'product_extra` (`id_product`, `mymodule_internal_code`) + SELECT `id_product`, `internal_code` FROM `' . _DB_PREFIX_ . 'mymodule_product` + ON DUPLICATE KEY UPDATE `mymodule_internal_code` = VALUES(`mymodule_internal_code`)' + ); + + return $copied && Db::getInstance()->execute('DROP TABLE IF EXISTS `' . _DB_PREFIX_ . 'mymodule_product`'); +} +``` + +For a `lang` or `shop` property, copy every primary key column of the value table (for example `id_product`, `id_shop` and `id_lang` for `product_extra_lang`). + +{{% notice warning %}} +Values inserted with SQL skip validation. Make sure the data you copy fits the type and the constraints of the property. +{{% /notice %}} + +## Related reading + +- [Extra properties]({{< relref "/9/development/components/extra-properties" >}}) +- [Manage extra properties from the Back Office]({{< relref "/9/development/components/extra-properties/register-from-back-office" >}}) +- [Enabling the Auto-Update]({{< relref "/9/modules/creation/enabling-auto-update" >}}) +- [demoextraproperty example module](https://github.com/PrestaShop/example-modules/tree/master/demoextraproperty) diff --git a/modules/core-updates/9.2.md b/modules/core-updates/9.2.md index d394eae70d..45616445c7 100644 --- a/modules/core-updates/9.2.md +++ b/modules/core-updates/9.2.md @@ -37,9 +37,9 @@ A module can register a new field on any supported entity. Each field has its ow This is achieved with no overrides and no extra wiring. The system provides built-in support for multistore, multilang, Back Office forms and grids, Front Office access, and the Admin API. -{{% notice info %}} We are finalizing the Extra Properties system during the beta period and aim to deliver a stable feature. The code is marked as `@experimental`: this leaves room to introduce a backward compatibility break if there is absolutely no other option, but we have anticipated a wide range of scenarios specifically to avoid that. Detailed developer documentation will be added as the implementation is finalized. {{% /notice %}} +{{% notice info %}} We are finalizing the Extra Properties system during the beta period and aim to deliver a stable feature. The code is marked as `@experimental`: this leaves room to introduce a backward compatibility break if there is absolutely no other option, but we have anticipated a wide range of scenarios specifically to avoid that. See the [Extra properties]({{< relref "/9/development/components/extra-properties" >}}) developer documentation. {{% /notice %}} -For a working example of registering an extra field, see the [demoextrafield](https://github.com/PrestaShop/example-modules/tree/master/demoextrafield) example module. +For a working example of registering extra properties, see the [demoextraproperty](https://github.com/PrestaShop/example-modules/tree/master/demoextraproperty) example module. You can follow the development and share feedback on the dedicated GitHub issue: [Extra Properties](https://github.com/PrestaShop/PrestaShop/issues/41422).