# PrestaShop Entity Selector — Architecture & Developer Guide ## Overview The Entity Selector is a Composer package (`myprestarocks/prestashop-entity-selector`) providing a universal entity targeting widget for PrestaShop admin controllers. It supports group-based selection with include/exclude logic, live preview, and 18 entity types with 130+ selection methods. **Package:** `vendor/myprestarocks/prestashop-entity-selector/` **Namespace:** `MyPrestaRocks\EntitySelector` --- ## Architecture ### File Structure ``` src/ ├── EntitySelector.php # Main trait (orchestrator, AJAX routing, asset loading) ├── ScheduleConditions.php # Scheduling trait (datetime, weekly, holidays) ├── HolidayProviderInterface.php # Holiday provider contract └── EntitySelector/ ├── EntitySelectorRenderer.php # HTML rendering, layouts, block/group UI ├── EntitySearchEngine.php # AJAX search, count, getByIds per entity type ├── ProductConditionResolver.php # Product ID resolution for all 40+ methods ├── EntityQueryHandler.php # Non-product entity ID resolution └── EntityPreviewHandler.php # Preview data generation for all types assets/ ├── css/admin/ │ ├── entity-selector.css # Compiled from SCSS │ └── mpr-modal.css └── js/admin/ ├── entity-selector.js # Main JS (chips, search, serialization, UI) ├── entity-selector.min.js ├── entity-list-preview.js └── schedule-conditions.js sources/ ├── scss/ # SCSS source files └── js/admin/entity-selector/ # JS source (compiled via gulp) ├── _core.js ├── _events.js ├── _dropdown.js ├── _search.js ├── _groups.js ├── _chips.js ├── _methods.js ├── _preview.js ├── _filters.js └── _utils.js ``` ### Component Roles | Component | Role | |-----------|------| | **EntitySelector** (trait) | Orchestrator. Mixes into admin controllers. Routes AJAX, initializes assets, lazy-loads helpers. | | **EntitySelectorRenderer** | Generates all HTML. Handles 3 layout modes, block tabs, group UI, value pickers. | | **EntitySearchEngine** | Handles `searchTargetEntities` AJAX. Dispatches to `searchTarget{EntityType}()` methods. Returns `{id, name, reference, subtitle, active, image}`. | | **ProductConditionResolver** | Resolves product selection methods to product IDs. 40+ methods from `by_category` to `by_isbn_pattern`. Also handles combination attribute resolution. | | **EntityQueryHandler** | Resolves selection methods for non-product entities (categories, manufacturers, customers, etc.). | | **EntityPreviewHandler** | Generates rich preview data (name, image, reference, attributes) for displaying matched items. | | **entity-selector.js** | Client-side: chips, search dropdowns, serialization, value pickers, keyboard shortcuts, pagination. | ### Data Flow ``` Controller (uses EntitySelector trait) │ ├── setMedia() → initEntitySelector() → loads CSS + JS │ ├── renderForm() → renderEntitySelectorHtml($config, $savedData) │ └── EntitySelectorRenderer → HTML with blocks, groups, pickers │ ├── ajaxProcess*() → handleEntitySelectorAjax() │ ├── searchTargetEntities → EntitySearchEngine::search() │ ├── getTargetEntitiesByIds → EntitySearchEngine::getByIds() │ ├── countConditionMatches → ProductConditionResolver::getIdsByMethod() │ ├── previewConditionItems → EntityPreviewHandler::getProductPreviewData() │ ├── previewGroupItems → resolve + apply modifiers + preview │ └── ... (20+ AJAX actions) │ └── postProcess() → parse JSON from hidden input → resolve IDs → save ``` --- ## Usage ### Basic Integration ```php use MyPrestaRocks\EntitySelector\EntitySelector; class AdminMyController extends ModuleAdminController { use EntitySelector; public function setMedia($isNewTheme = false) { parent::setMedia($isNewTheme); $this->initEntitySelector(); // loads CSS + JS } // AJAX handler — route entity selector requests public function ajaxProcessEntitySelector() { $this->handleEntitySelectorAjax(); } // Render the widget public function renderForm() { $html = $this->getEntitySelectorRenderer()->renderEntitySelectorHtml( $config, // widget configuration $savedData // previously saved selection data ); // embed $html in your form } // Required: translate strings for the widget protected function transEntitySelector($string) { return $this->module->l($string, 'EntitySelector'); } } ``` ### Configuration Options ```php $config = [ // Identity 'id' => 'my-selector', // unique DOM ID 'name' => 'entity_selector', // form field name 'name_prefix' => 'target_', // prefix for generated field names // Labels 'title' => 'Target Selection', 'subtitle' => 'Define which items to target', // Which built-in entity blocks to show 'show_products' => true, 'show_categories' => true, 'show_manufacturers' => true, 'show_suppliers' => true, 'show_cms' => true, 'show_cms_categories' => true, // Mode 'mode' => 'multi', // 'multi' = multiple groups, 'single' = one group only 'combination_mode' => 'products', // 'products', 'combinations', or 'toggle' (for by_combination method) 'empty_means_all' => false, // if true, empty selection = all items 'required' => false, // validation: must have at least one selection 'required_message' => '', // custom validation message // UI 'show_modifiers' => true, // show limit/sort per group 'show_all_toggle' => false, // show "select all" toggle // Layout: 'standalone' (full widget), 'form-group' (PS form-group), 'form-content' (content only) 'layout' => 'standalone', // Custom entity blocks (see Custom Entity Types section) 'customBlocks' => [], // Override default block definitions 'blocks' => [], ]; ``` ### Saved Data Format ```json { "products": { "groups": [ { "name": "Group 1", "include": { "method": "by_category", "values": [3, 5, 8] }, "excludes": [ { "method": "specific", "values": [42, 99] } ], "modifiers": { "limit": 10, "sort_by": "sales", "sort_dir": "DESC" } } ] }, "categories": { "groups": [ { "include": { "method": "all", "values": [] }, "excludes": [] } ] } } ``` ### Group Logic - **Within a group:** Include MINUS Exclude (set difference) - **Between groups:** OR (union) - **Between blocks:** AND (intersection) ### Resolving IDs from Saved Data ```php // Get product IDs from saved selection $data = json_decode(Tools::getValue('entity_selector'), true); $groups = $data['products']['groups'] ?? []; $resolver = $this->getProductConditionResolver(); $productIds = []; foreach ($groups as $group) { // Resolve include $includeIds = $resolver->getIdsByMethod($group['include']['method'], $group['include']['values']); // Resolve excludes foreach ($group['excludes'] ?? [] as $exclude) { $excludeIds = $resolver->getIdsByMethod($exclude['method'], $exclude['values']); $includeIds = array_diff($includeIds, $excludeIds); } // Apply modifiers if (!empty($group['modifiers'])) { $includeIds = $resolver->applyModifiers($includeIds, $group['modifiers']); } $productIds = array_merge($productIds, $includeIds); } $productIds = array_unique($productIds); ``` --- ## Entity Types & Selection Methods ### Products (40+ methods) **By Entity:** | Method | Value Type | Returns | |--------|-----------|---------| | `all` | none | All active products | | `specific` | entity_search (products) | Selected product IDs | | `by_category` | entity_search (categories) | Products in categories | | `by_manufacturer` | entity_search (manufacturers) | Products by manufacturer | | `by_supplier` | entity_search (suppliers) | Products by supplier | | `by_tag` | entity_search (tags) | Products by tag | | `by_attribute` | entity_search (attributes) | Products with attribute(s) | | `by_feature` | entity_search (features) | Products with feature(s) | | `by_combination` | combination_attributes | Products/combinations by attribute groups | **By Property:** | Method | Value Type | Options | |--------|-----------|---------| | `by_condition` | multi_select_tiles | new, used, refurbished | | `by_visibility` | multi_select_tiles | both, catalog, search, none | | `by_active_status` | multi_select_tiles | active, inactive | | `by_stock_status` | multi_select_tiles | in_stock, out_of_stock, low_stock | | `by_on_sale` | boolean | Has on-sale flag | | `by_has_specific_price` | boolean | Has active specific price | | `by_is_virtual` | boolean | Is virtual product | | `by_is_pack` | boolean | Is pack product | | `by_has_combinations` | boolean | Has combinations/variants | | `by_available_for_order` | boolean | Available for order | | `by_online_only` | boolean | Online only | | `by_has_related` | boolean | Has accessories/related | | `by_has_customization` | boolean | Has customization fields | | `by_has_attachments` | boolean | Has attachments | | `by_has_additional_shipping` | boolean | Has additional shipping cost | | `by_out_of_stock_behavior` | multi_select_tiles | deny, allow, default | | `by_delivery_time` | multi_select_tiles | none, default, specific | | `by_carrier_restriction` | multi_select_tiles | restricted, all | **By Text Pattern:** | Method | Value Type | |--------|-----------| | `by_name_pattern` | pattern | | `by_reference_pattern` | pattern | | `by_description_pattern` | pattern | | `by_long_description_pattern` | pattern | | `by_ean13_pattern` | pattern | | `by_upc_pattern` | pattern | | `by_isbn_pattern` | pattern | | `by_mpn_pattern` | pattern | | `by_meta_title_pattern` | pattern | | `by_meta_description_pattern` | pattern | **By Range:** | Method | Value Type | |--------|-----------| | `by_id_range` | numeric_range | | `by_price_range` | numeric_range | | `by_weight_range` | numeric_range | | `by_quantity_range` | numeric_range | | `by_position_range` | numeric_range | | `by_date_added` | date_range | | `by_date_updated` | date_range | ### Other Entities (summary) | Entity | Key Methods | |--------|-------------| | Categories | all, specific, by_name_pattern, by_product_count, by_depth_level, by_active_status | | Manufacturers | all, specific, by_name_pattern, by_product_count, by_active_status | | Suppliers | all, specific, by_name_pattern, by_product_count, by_active_status | | CMS Pages | all, specific, by_cms_category, by_name_pattern, by_active_status, by_indexable | | CMS Categories | all, specific, by_name_pattern, by_active_status, by_page_count | | Employees | all, specific, by_profile, by_name_pattern, by_active_status | | Customers | all, specific, by_group, by_name_pattern, by_email_pattern, by_company, by_order_count, by_turnover, by_active_status, by_newsletter, by_guest | | Customer Groups | all, specific, by_name_pattern, by_price_display | | Carriers | all, specific, by_name_pattern, by_active_status, by_shipping_handling, by_free_shipping, by_zone, by_customer_group, by_price_range, by_weight_range | | Zones | all, specific, by_name_pattern, by_active_status | | Countries | all, specific, by_zone, by_name_pattern, by_active_status, by_contains_states, by_need_zip_code, by_zip_format, by_need_identification | | Currencies | all, specific, by_name_pattern, by_active_status | | Languages | all, specific, by_name_pattern, by_active_status, by_rtl | | Shops | all, specific, by_name_pattern, by_active_status | | Profiles | all, specific, by_name_pattern | | Order States | all, specific, by_name_pattern, by_paid, by_shipped, by_delivery | | Taxes | all, specific, by_name_pattern, by_rate_range, by_active_status | --- ## Value Types | Type | UI Component | Input Format | Example | |------|-------------|-------------|---------| | `none` | No input | `[]` | "All products" | | `entity_search` | Search dropdown + chips | `[id1, id2, ...]` | Specific products, By category | | `pattern` | Text input + case toggle | `[{"pattern": "blue*", "caseSensitive": false}]` | Name pattern | | `multi_select_tiles` | Toggle tile buttons | `["new", "used"]` | Condition | | `select` | Single dropdown | `"value"` | Active status | | `numeric_range` | Min/Max inputs | `{"min": 10, "max": 50}` | Price range | | `multi_numeric_range` | Multiple ranges | `[{"min": 0, "max": 10}, ...]` | Price bands | | `date_range` | Date pickers | `{"from": "2024-01-01", "to": "2024-12-31"}` | Date added | | `boolean` | Flag (no input) | `[true]` | Has combinations | | `combination_attributes` | Attribute group picker | `{"mode": "products", "attributes": {"1": [3,4]}}` | By combination | --- ## Combination Attributes (by_combination) The `by_combination` selection method allows targeting products by their attribute combinations. This is a **selection method** within the products block, not a separate entity type. ### How It Works 1. User selects "Combination" from the method dropdown 2. UI loads all attribute groups via AJAX (`getAttributeGroups`) 3. User clicks a group → lazy-loads values via AJAX (`getAttributeValues`) 4. User selects attribute values (tile-based UI with select-all/clear/search) 5. Logic: **OR within a group** (Red OR Blue), **AND between groups** (Color AND Size) ### Combination Modes (`combination_mode` config) | Mode | Behavior | Returns | |------|----------|---------| | `'products'` (default) | Attribute selection filters products. Returns product IDs. | `id_product[]` | | `'combinations'` | Returns exact combination IDs (product_attribute). | `id_product_attribute[]` | | `'toggle'` | Shows radio toggle letting user choose per-selection. | Either type | ### Data Format ```json { "method": "by_combination", "values": { "mode": "products", "attributes": { "1": [3, 4], "2": [7, 8, 9] } } } ``` Where `"1"` is `id_attribute_group` and `[3, 4]` are `id_attribute` values within that group. ### Backend Resolution (ProductConditionResolver) ```php // ProductConditionResolver::getProductIdsByCombinationAttributes($values) // mode = 'products': SELECT DISTINCT pa.id_product FROM ... // mode = 'combinations': SELECT DISTINCT pa.id_product_attribute FROM ... // Uses correlated subqueries for AND-logic between groups, OR within groups ``` --- ## Custom Entity Blocks Custom entity blocks allow modules to add their own entity types (e.g., materials, suppliers, warehouses) to the entity selector widget. ### Registration via `customBlocks` config ```php $config['customBlocks'] = [ 'mpr_materials' => [ 'label' => 'Materials', 'entity_label' => 'material', 'entity_label_plural' => 'materials', 'icon' => 'icon-cubes', 'search_entity' => 'mpr_materials', // entity type key for search routing 'selection_methods' => [ 'all' => [ 'label' => 'All materials', 'icon' => 'icon-asterisk', 'value_type' => 'none', 'group' => '', ], 'specific' => [ 'label' => 'Specific materials', 'icon' => 'icon-cube', 'value_type' => 'entity_search', 'search_entity' => 'mpr_materials', 'group' => 'by_entity', ], ], ], ]; ``` ### Search Handler Implementation The `EntitySearchEngine` routes searches via `searchTarget{CamelCase}()`. For custom entities, you need to either: **Option A:** Subclass EntitySearchEngine and add your search methods, then override the getter in your controller. **Option B:** Override `ajaxSearchTargetEntities()` in your controller to intercept custom entity types before delegating to the engine: ```php protected function ajaxSearchTargetEntities() { $entityType = Tools::getValue('entity_type', ''); // Handle custom entity types if ($entityType === 'mpr_materials') { $query = trim(Tools::getValue('q', '')); $limit = (int) Tools::getValue('limit', 20); $offset = (int) Tools::getValue('offset', 0); // Your search logic $results = $this->searchMaterials($query, $limit, $offset); $total = $this->countMaterials($query); $this->ajaxDie(json_encode([ 'success' => true, 'results' => $results, // [{id, name, subtitle?, active?, image?}, ...] 'total' => $total, 'offset' => $offset, 'limit' => $limit, ])); return; } // Fall back to built-in handler parent::ajaxSearchTargetEntities(); } ``` Similarly override `ajaxGetTargetEntitiesByIds()` and `ajaxGetTargetEntitiesByIdsBulk()` for chip restoration on form load. ### Search Result Format ```php [ 'id' => (int) 42, 'name' => 'Display Name', // required 'subtitle' => 'Reference or info', // optional, shown below name 'active' => true, // optional, dim if false 'image' => 'https://...', // optional, thumbnail URL 'reference' => 'REF-001', // optional, shown as badge ] ``` --- ## Layout Modes ### `standalone` (default) Full widget with header, collapse toggle, tabs, groups. Best for dedicated pages or modals. ### `form-group` Wraps widget in PrestaShop's standard `form-group` structure (`col-lg-3` label + `col-lg-9` content). Best for embedding in existing forms. ### `form-content` Just the content area (no wrapper). For when you have an existing `form-group` and need only the inner widget. Collapsed by default. ```php // Standalone $config['layout'] = 'standalone'; // Inside a form $config['layout'] = 'form-group'; // Content only (collapsed by default) $config['layout'] = 'form-content'; $config['collapsed'] = false; // override default collapse ``` --- ## AJAX Actions All AJAX actions route through `handleEntitySelectorAjax()` with `trait=EntitySelector`. | Action | Purpose | Key Params | |--------|---------|-----------| | `searchTargetEntities` | Search entities by type | entity_type, q, limit, offset, filters | | `getTargetEntitiesByIds` | Get entity by ID (chip restore) | entity_type, ids | | `getTargetEntitiesByIdsBulk` | Bulk get entities by IDs | entities (JSON object) | | `countConditionMatches` | Count matching items for a method | block_type, method, values | | `countConditionMatchesBulk` | Bulk count multiple conditions | conditions (JSON) | | `previewConditionItems` | Preview items for a condition | block_type, method, values, limit | | `previewGroupItems` | Preview group with modifiers | block_type, group_data, limit | | `countGroupItems` | Count items in a group | block_type, group_data | | `previewEntitySelector` | Preview full entity selection | conditions, block_type, limit | | `previewEntitySelectorBulk` | Bulk preview all entity types | conditions (JSON) | | `getAttributeGroups` | List attribute groups | — | | `getAttributeValues` | Get values for an attribute group | group_id | | `getFeatureGroups` | List feature groups | — | | `getCategoryTree` | Category tree hierarchy | — | | `countPatternMatches` | Count pattern matches | pattern, field, entity_type | | `previewPatternMatches` | Preview pattern matches | pattern, entity_type, limit | --- ## Group Modifiers Available per group to limit and sort results: ```php 'modifiers' => [ 'limit' => 5, // max items from this group 'sort_by' => 'sales', // sort field 'sort_dir' => 'DESC', // sort direction ] ``` **Sort fields for products:** name, price, date_add, position, quantity, reference, id, sales --- ## JavaScript API ### Configuration Object The JS receives config from PHP via data attributes: ```javascript // Config keys available in JS this.config = { id: 'selector-id', ajaxUrl: '...', trans: { /* all translation strings */ }, methodHelp: { /* help content per method */ }, combinationMode: 'products', // 'products', 'combinations', 'toggle' emptyMeansAll: false, }; ``` ### Events ```javascript // Selection changed $(document).on('entitySelector:change', function(e, data) { }); // Block tab switched $(document).on('entitySelector:tabChange', function(e, blockType) { }); // Group added/removed $(document).on('entitySelector:groupAdd', function(e, groupData) { }); $(document).on('entitySelector:groupRemove', function(e, groupIndex) { }); ``` ### Keyboard Shortcuts | Key | Action | |-----|--------| | Ctrl+A | Select all in dropdown | | Ctrl+D | Deselect all | | Esc | Close dropdown | | Enter | Confirm selection | --- ## Schedule Conditions (separate trait) ```php use MyPrestaRocks\EntitySelector\ScheduleConditions; class AdminMyController extends ModuleAdminController { use EntitySelector; use ScheduleConditions; public function setMedia($isNewTheme = false) { parent::setMedia($isNewTheme); $this->initEntitySelector(); $this->initScheduleConditions(); } } ``` Configuration: datetime range, weekly schedule (per-day hour ranges), holiday exclusions by country. --- ## Building from Source ```bash npm install npm run build # compile SCSS → CSS, concatenate JS npm run watch # watch mode ``` --- ## Planned Feature: Product Selection Level > **Status: Planned — Not Yet Implemented** ### Problem Currently, the entity selector's product search always returns **base products**. When a user searches for "Hummingbird sweater", they get one result regardless of how many combinations (Size: S/M/L/XL × Color: White/Black) the product has. For many use cases, users need to target **specific combinations** rather than entire products: - **Stock management:** Set stock per combination (Size S, Color White) - **Discounts:** Apply discount to specific variants - **Product links:** Link specific combinations to materials ### Solution: `product_selection_level` Config A new config option that changes how the products block fundamentally works — not just the `by_combination` selection method, but how **all** product-related operations (search, display, selection, preview) behave. ```php $config['product_selection_level'] = 'product'; // default — current behavior $config['product_selection_level'] = 'combination'; // combination mode $config['product_selection_level'] = 'both'; // combined mode ``` ### Three Modes #### Mode 1: `product` (Default — Current Behavior) Search results show base products. Returns `id_product`. The product "Hummingbird sweater" appears as one item regardless of combinations. **Use case:** Product links, category assignments, anywhere you target an entire product. **Search result:** ``` [x] Hummingbird sweater REF-001 ``` **Returns:** `id_product = 1` #### Mode 2: `combination` For products **with combinations**, each combination appears as a separate selectable item. The base product itself is NOT shown. Products without combinations (simple products) still appear as-is. **Use case:** Stock management, per-variant pricing, anything that targets individual variants. **Search result for "Hummingbird sweater" (4 combinations):** ``` [x] Hummingbird sweater — Size: S, Color: White REF-001 [x] Hummingbird sweater — Size: S, Color: Black REF-001 [x] Hummingbird sweater — Size: M, Color: White REF-001 [x] Hummingbird sweater — Size: M, Color: Black REF-001 ``` **Search result for "Brown bear notebook" (simple product, no combinations):** ``` [x] Brown bear notebook REF-002 ``` **Returns:** `id_product_attribute` for combination products, `id_product` for simple products (needs encoding, e.g., negative ID or composite key). #### Mode 3: `both` (Combined) Both the full product AND each individual combination appear as selectable items. Users choose what they need — the entire product or specific variants. **Use case:** Discounts (apply to whole product OR specific combinations), flexible targeting. **Search result for "Hummingbird sweater":** ``` [x] Hummingbird sweater REF-001 [entire product] [x] — Size: S, Color: White [x] — Size: S, Color: Black [x] — Size: M, Color: White [x] — Size: M, Color: Black ``` **Returns:** Mix of `id_product` (for whole-product selections) and `id_product_attribute` (for specific combinations). ### Implementation Areas This feature requires changes across the full stack: #### 1. EntitySearchEngine — `searchTargetProducts()` - When `product_selection_level = 'combination'`: query `ps_product_attribute` joined with `ps_product_attribute_combination` + attribute names - Return combination-level results: `{id: pa.id_product_attribute, name: "Product — Attr1: Val1, Attr2: Val2", ...}` - Simple products (no combinations) still returned with `id_product` #### 2. EntitySelectorRenderer — Config Propagation - Pass `product_selection_level` to JS config - Adapt chip display for combination items (show attribute string) #### 3. EntityPreviewHandler — Combination Previews - `getCombinationPreviewData()` already exists — extend for search-based previews - Show attribute values, per-combination stock/price if relevant #### 4. entity-selector.js — UI Adaptation - Chip display: show attribute string for combination items - Search result rendering: indent combinations under base product (mode 3) - Data serialization: encode combination IDs distinctly from product IDs #### 5. ProductConditionResolver — All Selection Methods - `getIdsByMethod()` must respect `product_selection_level` to return the right ID type - For `by_category` in combination mode: return all `id_product_attribute` for products in that category - For `specific` in combination mode: directly store `id_product_attribute` values #### 6. ID Encoding Strategy Need a way to distinguish product IDs from combination IDs in the same value space: - **Option A:** Encode as strings: `"p:1"` (product), `"c:42"` (combination) - **Option B:** Encode as negative/positive: positive = `id_product_attribute`, negative = `-id_product` for simple products - **Option C:** Always store tuples: `{id_product, id_product_attribute}` — 0 means "entire product" ### Relationship to `by_combination` / `combination_mode` `by_combination` is a **selection method** (one of 40+ ways to filter products). It uses attribute group tiles to filter products by their combination attributes, controlled by `combination_mode`. `product_selection_level` is a **block-level config** that changes how the entire products block works — how search results appear, what IDs are stored in chips, what previews show. It affects ALL selection methods, not just `by_combination`. They are complementary: - `product_selection_level = 'product'` + `by_combination` = "Find products that have Red + XL combination" → returns `id_product` - `product_selection_level = 'combination'` + `specific` = "Search and pick individual combinations" → returns `id_product_attribute` - `product_selection_level = 'combination'` + `by_category` = "All combinations of products in Hoodies category" → returns `id_product_attribute[]` --- ## Requirements - PrestaShop 1.7.x, 8.x, or 9.x - PHP 7.1+ - Node.js 16+ (for building from source) ## License Proprietary — MyPrestaRocks