- Unify filter group and filter value preview icons into shared .filter-chip-wrapper + .chip-preview-btn component pattern - Remove old .toggle-count.clickable inline eye icon approach - Add dropdown-level event handler for preview buttons (dropdown appended to body, needs separate delegation) - Enhanced EntitySearchEngine with improved product condition resolution and preview data - Add EntityPreviewHandler for richer preview popovers - Various SCSS improvements for chips, groups, and list-preview Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
28 KiB
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
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
$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
{
"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
// 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
- User selects "Combination" from the method dropdown
- UI loads all attribute groups via AJAX (
getAttributeGroups) - User clicks a group → lazy-loads values via AJAX (
getAttributeValues) - User selects attribute values (tile-based UI with select-all/clear/search)
- 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
{
"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)
// 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
$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:
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
[
'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.
// 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:
'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:
// 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
// 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)
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
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.
$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': queryps_product_attributejoined withps_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_levelto 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 respectproduct_selection_levelto return the right ID type- For
by_categoryin combination mode: return allid_product_attributefor products in that category - For
specificin combination mode: directly storeid_product_attributevalues
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_productfor 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" → returnsid_productproduct_selection_level = 'combination'+specific= "Search and pick individual combinations" → returnsid_product_attributeproduct_selection_level = 'combination'+by_category= "All combinations of products in Hoodies category" → returnsid_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