feat: unified preview eye icon component, enhanced search & preview

- 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>
This commit is contained in:
2026-02-20 21:33:14 +00:00
parent a7fbfa372c
commit 55e3135903
23 changed files with 2683 additions and 266 deletions

761
ENTITY-SELECTOR.md Normal file
View File

@@ -0,0 +1,761 @@
# 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

View File

@@ -799,6 +799,10 @@
.entity-selector-trait.single-mode .target-block-container {
display: block;
}
.target-conditions-trait.single-mode .entity-selector-tabs-row .target-block-tabs,
.entity-selector-trait.single-mode .entity-selector-tabs-row .target-block-tabs {
display: flex;
}
.target-conditions-trait .header-actions,
.entity-selector-trait .header-actions {
@@ -1749,6 +1753,19 @@
align-items: center;
gap: 0.5rem;
}
.target-conditions-trait .dropdown-item.is-combination,
.entity-selector-trait .dropdown-item.is-combination {
padding-left: 28px;
}
.target-conditions-trait .dropdown-item.is-combination .result-name,
.entity-selector-trait .dropdown-item.is-combination .result-name {
font-size: 0.9em;
}
.target-conditions-trait .dropdown-item.is-parent-product,
.entity-selector-trait .dropdown-item.is-parent-product {
background: #f8fafc;
font-weight: 500;
}
.target-conditions-trait .no-results,
.entity-selector-trait .no-results {
display: flex;
@@ -3041,46 +3058,9 @@ body > .target-search-dropdown .filter-group-toggle .toggle-name,
}
body > .target-search-dropdown .filter-group-toggle .toggle-count,
.target-search-dropdown .filter-group-toggle .toggle-count {
display: inline-flex;
align-items: center;
gap: 0.125rem;
color: #6c757d;
font-size: 0.65rem;
}
body > .target-search-dropdown .filter-group-toggle .toggle-count i,
.target-search-dropdown .filter-group-toggle .toggle-count i {
font-size: 10px;
color: #25b9d7;
}
body > .target-search-dropdown .filter-group-toggle .toggle-count.clickable,
.target-search-dropdown .filter-group-toggle .toggle-count.clickable {
cursor: pointer;
padding: 0.125rem 0.25rem;
border-radius: 0.2rem;
transition: all 0.15s ease-in-out;
}
body > .target-search-dropdown .filter-group-toggle .toggle-count.clickable:hover,
.target-search-dropdown .filter-group-toggle .toggle-count.clickable:hover {
background: rgba(37, 185, 215, 0.1);
color: #25b9d7;
}
body > .target-search-dropdown .filter-group-toggle .toggle-count.clickable:hover i,
.target-search-dropdown .filter-group-toggle .toggle-count.clickable:hover i {
color: #25b9d7;
}
body > .target-search-dropdown .filter-group-toggle .toggle-count.clickable.popover-open,
.target-search-dropdown .filter-group-toggle .toggle-count.clickable.popover-open {
background: #25b9d7;
color: #ffffff;
}
body > .target-search-dropdown .filter-group-toggle .toggle-count.clickable.popover-open i,
.target-search-dropdown .filter-group-toggle .toggle-count.clickable.popover-open i {
color: #ffffff;
}
body > .target-search-dropdown .filter-group-toggle .toggle-count.clickable.loading i,
.target-search-dropdown .filter-group-toggle .toggle-count.clickable.loading i {
animation: spin 0.6s linear infinite;
}
body > .target-search-dropdown .filter-chip, body > .target-search-dropdown .filter-attr-chip,
body > .target-search-dropdown .filter-feat-chip,
.target-search-dropdown .filter-chip,
@@ -3201,6 +3181,81 @@ body > .target-search-dropdown .filter-chip.active .chip-count,
.target-search-dropdown .active.filter-feat-chip .chip-count {
color: rgba(255, 255, 255, 0.8);
}
body > .target-search-dropdown .filter-chip-wrapper,
.target-search-dropdown .filter-chip-wrapper {
display: inline-flex;
align-items: stretch;
border-radius: 0.2rem;
overflow: hidden;
}
body > .target-search-dropdown .filter-chip-wrapper .filter-chip,
body > .target-search-dropdown .filter-chip-wrapper .filter-group-toggle,
.target-search-dropdown .filter-chip-wrapper .filter-chip,
.target-search-dropdown .filter-chip-wrapper .filter-attr-chip,
.target-search-dropdown .filter-chip-wrapper .filter-feat-chip,
.target-search-dropdown .filter-chip-wrapper .filter-group-toggle {
border-radius: 0.2rem 0 0 0.2rem;
}
body > .target-search-dropdown .filter-chip-wrapper .chip-preview-btn,
.target-search-dropdown .filter-chip-wrapper .chip-preview-btn {
padding: 0;
margin: 0;
background: none;
border: none;
cursor: pointer;
font: inherit;
color: inherit;
}
body > .target-search-dropdown .filter-chip-wrapper .chip-preview-btn:focus,
.target-search-dropdown .filter-chip-wrapper .chip-preview-btn:focus {
outline: none;
}
body > .target-search-dropdown .filter-chip-wrapper .chip-preview-btn,
.target-search-dropdown .filter-chip-wrapper .chip-preview-btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0 0.375rem;
font-size: 10px;
color: #6c757d;
background: #f1f5f9;
border-left: 1px solid #dee2e6;
border-radius: 0 0.2rem 0.2rem 0;
cursor: pointer;
transition: all 0.15s ease-in-out;
}
body > .target-search-dropdown .filter-chip-wrapper .chip-preview-btn:hover,
.target-search-dropdown .filter-chip-wrapper .chip-preview-btn:hover {
background: rgba(37, 185, 215, 0.1);
color: #25b9d7;
}
body > .target-search-dropdown .filter-chip-wrapper .chip-preview-btn.popover-open,
.target-search-dropdown .filter-chip-wrapper .chip-preview-btn.popover-open {
background: #25b9d7;
color: #ffffff;
}
body > .target-search-dropdown .filter-chip-wrapper .chip-preview-btn.loading i,
.target-search-dropdown .filter-chip-wrapper .chip-preview-btn.loading i {
animation: spin 0.6s linear infinite;
}
body > .target-search-dropdown .filter-chip-wrapper .filter-chip:last-child,
body > .target-search-dropdown .filter-chip-wrapper .filter-group-toggle:last-child,
.target-search-dropdown .filter-chip-wrapper .filter-chip:last-child,
.target-search-dropdown .filter-chip-wrapper .filter-attr-chip:last-child,
.target-search-dropdown .filter-chip-wrapper .filter-feat-chip:last-child,
.target-search-dropdown .filter-chip-wrapper .filter-group-toggle:last-child {
border-radius: 0.2rem;
}
body > .target-search-dropdown .filter-chip-wrapper .filter-group-toggle.active + .chip-preview-btn,
.target-search-dropdown .filter-chip-wrapper .filter-group-toggle.active + .chip-preview-btn {
border-left-color: #25b9d7;
background: rgba(37, 185, 215, 0.05);
}
body > .target-search-dropdown .filter-chip-wrapper .filter-group-toggle.has-selection + .chip-preview-btn,
.target-search-dropdown .filter-chip-wrapper .filter-group-toggle.has-selection + .chip-preview-btn {
border-left-color: #28a745;
background: rgba(40, 167, 69, 0.03);
}
body > .target-search-dropdown .dropdown-content,
.target-search-dropdown .dropdown-content {
max-height: 400px;
@@ -4982,6 +5037,16 @@ body > .target-search-dropdown .dropdown-item:not(:last-child) {
.entity-selector-trait .chip-name {
word-break: break-word;
}
.target-conditions-trait .chip-attrs,
.entity-selector-trait .chip-attrs {
font-size: 0.85em;
opacity: 0.7;
margin-left: 2px;
}
.target-conditions-trait .chip-attrs::before,
.entity-selector-trait .chip-attrs::before {
content: "— ";
}
.target-conditions-trait .chip-remove,
.entity-selector-trait .chip-remove {
padding: 0;
@@ -7070,6 +7135,42 @@ body > .target-search-dropdown .dropdown-item:not(:last-child) {
background-repeat: no-repeat;
background-size: 1.25em 1.25em;
}
.target-conditions-trait[data-mode=single] .groups-container,
.target-conditions-trait .mode-single .groups-container,
.entity-selector-trait[data-mode=single] .groups-container,
.entity-selector-trait .mode-single .groups-container {
padding: 0;
}
.target-conditions-trait[data-mode=single] .group-body,
.target-conditions-trait .mode-single .group-body,
.entity-selector-trait[data-mode=single] .group-body,
.entity-selector-trait .mode-single .group-body {
padding: 0;
}
.target-conditions-trait[data-mode=single] .group-include,
.target-conditions-trait .mode-single .group-include,
.entity-selector-trait[data-mode=single] .group-include,
.entity-selector-trait .mode-single .group-include {
padding: 0.5rem;
margin-bottom: 0;
background: transparent;
border: none;
border-radius: 0;
}
.target-conditions-trait[data-mode=single] .selection-group,
.target-conditions-trait .mode-single .selection-group,
.entity-selector-trait[data-mode=single] .selection-group,
.entity-selector-trait .mode-single .selection-group {
background: transparent;
border: none;
margin-bottom: 0;
}
.target-conditions-trait[data-mode=single] .group-header,
.target-conditions-trait .mode-single .group-header,
.entity-selector-trait[data-mode=single] .group-header,
.entity-selector-trait .mode-single .group-header {
display: none;
}
.target-conditions-trait .condition-match-count,
.entity-selector-trait .condition-match-count {
display: inline-flex;
@@ -8182,6 +8283,20 @@ body > .target-search-dropdown .dropdown-item:not(:last-child) {
right: 20px;
transform: none;
}
.target-preview-popover.position-above::before,
.target-list-preview-popover.position-above::before {
top: auto;
bottom: -8px;
border-top: 8px solid #dee2e6;
border-bottom: 0;
}
.target-preview-popover.position-above::after,
.target-list-preview-popover.position-above::after {
top: auto;
bottom: -6px;
border-top: 6px solid #ffffff;
border-bottom: 0;
}
.preview-header {
display: flex;

File diff suppressed because one or more lines are too long

View File

@@ -272,9 +272,9 @@
!$(e.target).closest('.group-count-badge').length &&
!$(e.target).closest('.group-modifiers').length &&
!$(e.target).closest('.group-preview-badge').length &&
!$(e.target).closest('.toggle-count.clickable').length &&
!$(e.target).closest('.trait-total-count').length &&
!$(e.target).closest('.chip-preview-holidays').length) {
!$(e.target).closest('.chip-preview-holidays').length &&
!$(e.target).closest('.chip-preview-btn').length) {
self.hidePreviewPopover();
// Also close holiday popover
$('.holiday-preview-popover').remove();
@@ -317,6 +317,31 @@
}
});
// Filter chip/group preview eye button (unified handler)
this.$wrapper.on('click', '.chip-preview-btn', function(e) {
e.preventDefault();
e.stopPropagation();
var $btn = $(this);
if ($btn.hasClass('popover-open')) {
self.hidePreviewPopover();
} else {
var valueId = $btn.data('id');
var valueType = $btn.data('type');
var valueName = $btn.data('name');
var groupId = $btn.data('groupId');
if (valueId) {
// Value-level preview (specific attribute/feature value)
self.showFilterValuePreviewPopover($btn, valueId, valueType, valueName, groupId);
} else if (groupId) {
// Group-level preview (entire attribute/feature group)
self.showFilterGroupPreviewPopover($btn, groupId, valueType, valueName);
}
}
});
// Group-level collapse toggle (click on group header or toggle icon)
this.$wrapper.on('click', '.group-header', function(e) {
if ($(e.target).closest('.btn-remove-group, .group-name-input').length) {
@@ -1196,7 +1221,7 @@
if (isSelected) {
// Remove from pending selections
self.pendingSelections = self.pendingSelections.filter(function(s) {
return parseInt(s.id, 10) !== parseInt(id, 10);
return String(s.id) !== String(id);
});
self.removeSelection($picker, id);
$item.toggleClass('selected');
@@ -1223,7 +1248,7 @@
} else {
// Add to pending selections
var exists = self.pendingSelections.some(function(s) {
return parseInt(s.id, 10) === parseInt(id, 10);
return String(s.id) === String(id);
});
if (!exists) {
self.pendingSelections.push({ id: id, name: name, data: $item.data() });
@@ -1248,7 +1273,7 @@
// Also remove from pending selections if dropdown is open
if (self.pendingSelections) {
self.pendingSelections = self.pendingSelections.filter(function(s) {
return parseInt(s.id, 10) !== parseInt(id, 10);
return String(s.id) !== String(id);
});
}
@@ -1375,7 +1400,7 @@
// Add to pending selections for Save button
var exists = self.pendingSelections.some(function(s) {
return parseInt(s.id, 10) === parseInt(id, 10);
return String(s.id) === String(id);
});
if (!exists) {
self.pendingSelections.push({
@@ -1990,10 +2015,6 @@
// Toggle filter group - show values
this.$dropdown.on('click', '.filter-group-toggle', function(e) {
// Ignore clicks on the preview badge
if ($(e.target).closest('.toggle-count.clickable').length) {
return;
}
e.preventDefault();
var $btn = $(this);
var groupId = $btn.data('group-id');
@@ -2010,20 +2031,26 @@
}
});
// Filter group toggle count badge click for preview popover
this.$dropdown.on('click', '.filter-group-toggle .toggle-count.clickable', function(e) {
e.stopPropagation();
// Filter preview eye button (dropdown-level, since dropdown is appended to body)
this.$dropdown.on('click', '.chip-preview-btn', function(e) {
e.preventDefault();
e.stopPropagation();
var $badge = $(this);
var groupId = $badge.data('groupId');
var groupType = $badge.data('type');
var groupName = $badge.data('groupName');
var $btn = $(this);
if ($badge.hasClass('popover-open')) {
if ($btn.hasClass('popover-open')) {
self.hidePreviewPopover();
} else {
self.showFilterGroupPreviewPopover($badge, groupId, groupType, groupName);
var valueId = $btn.data('id');
var valueType = $btn.data('type');
var valueName = $btn.data('name');
var groupId = $btn.data('groupId');
if (valueId) {
self.showFilterValuePreviewPopover($btn, valueId, valueType, valueName, groupId);
} else if (groupId) {
self.showFilterGroupPreviewPopover($btn, groupId, valueType, valueName);
}
}
});
@@ -2182,13 +2209,13 @@
// If already pinned, unpin and close
if ($wrapper.hasClass('pinned')) {
$wrapper.removeClass('pinned');
$wrapper.find('.material-icons').text('info_outline');
$wrapper.find('.material-icons').text('info');
$('.mpr-tooltip-fixed.pinned').remove();
return;
}
// Close any other pinned tooltips
$('.mpr-info-wrapper.pinned').removeClass('pinned').find('.material-icons').text('info_outline');
$('.mpr-info-wrapper.pinned').removeClass('pinned').find('.material-icons').text('info');
$('.mpr-tooltip-fixed').remove();
var content = $wrapper.attr('data-tooltip');
@@ -2208,7 +2235,7 @@
// Close button click
$closeBtn.on('click', function() {
$wrapper.removeClass('pinned');
$wrapper.find('.material-icons').text('info_outline');
$wrapper.find('.material-icons').text('info');
$tooltip.remove();
});
@@ -2716,6 +2743,11 @@
sort_dir: this.currentSort ? this.currentSort.dir : 'ASC'
};
// Add product_selection_level if set
if (this.config.productSelectionLevel && this.config.productSelectionLevel !== 'product') {
requestData.product_selection_level = this.config.productSelectionLevel;
}
// Add refine query if present
if (this.refineQuery) {
requestData.refine = this.refineQuery;
@@ -3043,6 +3075,8 @@
var isSelected = selectedIds.indexOf(String(item.id)) !== -1;
var itemClass = 'dropdown-item' + (isSelected ? ' selected' : '');
if (item.type === 'product') itemClass += ' result-item-product';
if (item.is_combination) itemClass += ' is-combination';
if (item.is_parent) itemClass += ' is-parent-product';
html += '<div class="' + itemClass + '" ';
html += 'data-id="' + self.escapeAttr(item.id) + '" ';
@@ -3050,6 +3084,7 @@
if (item.image) html += ' data-image="' + self.escapeAttr(item.image) + '"';
if (item.subtitle) html += ' data-subtitle="' + self.escapeAttr(item.subtitle) + '"';
if (item.iso_code) html += ' data-iso="' + self.escapeAttr(item.iso_code) + '"';
if (item.attributes) html += ' data-attributes="' + self.escapeAttr(item.attributes) + '"';
html += '>';
html += '<span class="result-checkbox"><i class="icon-check"></i></span>';
@@ -3786,6 +3821,7 @@
if (!this.$dropdown || !this.filterableData) return;
var self = this;
var previewLabel = self.config.trans && self.config.trans.preview || 'Preview';
// Render attribute group toggle buttons
var $attrContainer = this.$dropdown.find('.filter-attributes-container');
@@ -3793,12 +3829,17 @@
if (this.filterableData.attributes && this.filterableData.attributes.length > 0) {
this.filterableData.attributes.forEach(function(group) {
var html = '<button type="button" class="filter-group-toggle" data-group-id="' + group.id + '" data-type="attribute" data-group-name="' + self.escapeAttr(group.name) + '">';
var html = '<span class="filter-chip-wrapper">';
html += '<button type="button" class="filter-group-toggle" data-group-id="' + group.id + '" data-type="attribute" data-group-name="' + self.escapeAttr(group.name) + '">';
html += '<span class="toggle-name">' + group.name + '</span>';
if (group.count !== undefined) {
html += '<span class="toggle-count clickable" data-group-id="' + group.id + '" data-type="attribute" data-group-name="' + self.escapeAttr(group.name) + '"><i class="icon-eye"></i> ' + group.count + '</span>';
html += '<span class="toggle-count">(' + group.count + ')</span>';
}
html += '</button>';
html += '<button type="button" class="chip-preview-btn" data-group-id="' + group.id + '" data-type="attribute" data-name="' + self.escapeAttr(group.name) + '" title="' + previewLabel + '">';
html += '<i class="icon-eye"></i>';
html += '</button>';
html += '</span>';
$attrContainer.append(html);
});
this.$dropdown.find('.filter-row-attributes').show();
@@ -3810,12 +3851,17 @@
if (this.filterableData.features && this.filterableData.features.length > 0) {
this.filterableData.features.forEach(function(group) {
var html = '<button type="button" class="filter-group-toggle" data-group-id="' + group.id + '" data-type="feature" data-group-name="' + self.escapeAttr(group.name) + '">';
var html = '<span class="filter-chip-wrapper">';
html += '<button type="button" class="filter-group-toggle" data-group-id="' + group.id + '" data-type="feature" data-group-name="' + self.escapeAttr(group.name) + '">';
html += '<span class="toggle-name">' + group.name + '</span>';
if (group.count !== undefined) {
html += '<span class="toggle-count clickable" data-group-id="' + group.id + '" data-type="feature" data-group-name="' + self.escapeAttr(group.name) + '"><i class="icon-eye"></i> ' + group.count + '</span>';
html += '<span class="toggle-count">(' + group.count + ')</span>';
}
html += '</button>';
html += '<button type="button" class="chip-preview-btn" data-group-id="' + group.id + '" data-type="feature" data-name="' + self.escapeAttr(group.name) + '" title="' + previewLabel + '">';
html += '<i class="icon-eye"></i>';
html += '</button>';
html += '</span>';
$featContainer.append(html);
});
this.$dropdown.find('.filter-row-features').show();
@@ -3853,6 +3899,7 @@
var colorStyle = val.color ? ' style="--chip-color: ' + val.color + '"' : '';
var colorClass = val.color ? ' has-color' : '';
html += '<span class="filter-chip-wrapper">';
html += '<button type="button" class="filter-chip ' + chipClass + activeClass + colorClass + '" data-id="' + val.id + '" data-group-id="' + groupId + '"' + colorStyle + '>';
if (val.color) {
html += '<span class="chip-color-dot"></span>';
@@ -3862,6 +3909,10 @@
html += '<span class="chip-count">(' + val.count + ')</span>';
}
html += '</button>';
html += '<button type="button" class="chip-preview-btn" data-id="' + val.id + '" data-group-id="' + groupId + '" data-type="' + type + '" data-name="' + self.escapeAttr(val.name) + '" title="' + (self.config.trans && self.config.trans.preview || 'Preview') + '">';
html += '<i class="icon-eye"></i>';
html += '</button>';
html += '</span>';
});
$valuesContainer.html(html);
@@ -4378,16 +4429,20 @@
console.log('[EntitySelector] Making bulk AJAX request for entities:', JSON.stringify(bulkRequest));
// Single bulk AJAX call for all entity types
var bulkAjaxData = {
ajax: 1,
action: 'getTargetEntitiesByIdsBulk',
trait: 'EntitySelector',
entities: JSON.stringify(bulkRequest)
};
if (self.config.productSelectionLevel && self.config.productSelectionLevel !== 'product') {
bulkAjaxData.product_selection_level = self.config.productSelectionLevel;
}
$.ajax({
url: self.config.ajaxUrl,
type: 'POST',
dataType: 'json',
data: {
ajax: 1,
action: 'getTargetEntitiesByIdsBulk',
trait: 'EntitySelector',
entities: JSON.stringify(bulkRequest)
},
data: bulkAjaxData,
success: function(response) {
console.log('[EntitySelector] AJAX response:', response);
if (!response.success || !response.entities) {
@@ -4706,18 +4761,22 @@
// Handle entity_search type - load via AJAX
var searchEntity = $picker.attr('data-search-entity') || blockType;
var pickerAjaxData = {
ajax: 1,
action: 'getTargetEntitiesByIds',
trait: 'EntitySelector',
entity_type: searchEntity,
ids: JSON.stringify(values)
};
if (this.config.productSelectionLevel && this.config.productSelectionLevel !== 'product') {
pickerAjaxData.product_selection_level = this.config.productSelectionLevel;
}
$.ajax({
url: this.config.ajaxUrl,
type: 'POST',
dataType: 'json',
data: {
ajax: 1,
action: 'getTargetEntitiesByIds',
trait: 'EntitySelector',
entity_type: searchEntity,
ids: JSON.stringify(values)
},
data: pickerAjaxData,
success: function(response) {
if (response.success && response.entities) {
// Track which IDs were actually found (entities may have been deleted)
@@ -7167,7 +7226,8 @@
clearValidationError: function() {
this.$wrapper.removeClass('has-validation-error');
this.$wrapper.find('.trait-validation-error').remove();
}
},
};
})(jQuery);
@@ -7760,7 +7820,7 @@
class: 'mpr-info-wrapper',
'data-tooltip': helpContent
});
$infoWrapper.append($('<i>', { class: 'material-icons', text: 'info_outline' }));
$infoWrapper.append($('<i>', { class: 'material-icons', text: 'info' }));
$placeholder.append($infoWrapper);
}
},
@@ -8240,23 +8300,8 @@
});
}
// Position popover below badge
var badgeOffset = $badge.offset();
var badgeHeight = $badge.outerHeight();
var badgeWidth = $badge.outerWidth();
var popoverWidth = $popover.outerWidth();
var leftPos = badgeOffset.left + (badgeWidth / 2) - (popoverWidth / 2);
var minLeft = 10;
var maxLeft = $(window).width() - popoverWidth - 10;
leftPos = Math.max(minLeft, Math.min(leftPos, maxLeft));
$popover.css({
position: 'absolute',
top: badgeOffset.top + badgeHeight + 8,
left: leftPos,
zIndex: 10000
});
// Position popover relative to badge (handles viewport overflow)
this.positionPopover($popover, $badge);
// Show with transition
$popover.addClass('show');
@@ -8264,6 +8309,61 @@
return $popover;
},
/**
* Position a popover relative to a trigger element.
* Handles horizontal and vertical viewport overflow.
* @param {jQuery} $popover - The popover element
* @param {jQuery} $trigger - The trigger element to position against
* @param {number} [zIndex=10000] - Optional z-index
*/
positionPopover: function($popover, $trigger, zIndex) {
var triggerRect = $trigger[0].getBoundingClientRect();
var scrollTop = $(window).scrollTop();
var scrollLeft = $(window).scrollLeft();
var popoverWidth = $popover.outerWidth();
var popoverHeight = $popover.outerHeight();
var windowWidth = $(window).width();
var windowHeight = $(window).height();
var gap = 8;
// Horizontal: center on trigger, then clamp to viewport
var left = triggerRect.left + scrollLeft + (triggerRect.width / 2) - (popoverWidth / 2);
left = Math.max(10, Math.min(left, windowWidth - popoverWidth - 10));
// Vertical: prefer below, flip above if it would overflow
var top;
var positionAbove = false;
if (triggerRect.bottom + popoverHeight + gap > windowHeight - 10) {
// Position above the trigger
top = triggerRect.top + scrollTop - popoverHeight - gap;
positionAbove = true;
} else {
// Position below the trigger
top = triggerRect.bottom + scrollTop + gap;
}
$popover.css({
position: 'absolute',
top: top,
left: left,
zIndex: zIndex || 10000
});
// Toggle arrow direction class
$popover.toggleClass('position-above', positionAbove);
// Determine horizontal arrow position class
var triggerCenter = triggerRect.left + (triggerRect.width / 2);
var popoverLeft = left;
var popoverCenter = popoverLeft + (popoverWidth / 2);
$popover.removeClass('position-left position-right');
if (triggerCenter - popoverLeft < popoverWidth * 0.3) {
$popover.addClass('position-right');
} else if (triggerCenter - popoverLeft > popoverWidth * 0.7) {
$popover.addClass('position-left');
}
},
/**
* Update popover after loading more items
*/
@@ -9228,6 +9328,144 @@
});
},
// =========================================================================
// FILTER VALUE PREVIEW (individual attribute/feature value chip)
// =========================================================================
/**
* Show preview popover for a specific filter value (attribute or feature value)
* @param {jQuery} $badge - The preview button on the chip
* @param {number} valueId - The attribute/feature value ID
* @param {string} valueType - 'attribute' or 'feature'
* @param {string} valueName - Display name of the value
* @param {number} groupId - The parent group ID
*/
showFilterValuePreviewPopover: function($badge, valueId, valueType, valueName, groupId) {
var self = this;
this.hidePreviewPopover();
$badge.addClass('popover-open loading');
this.$activeBadge = $badge;
$.ajax({
url: this.config.ajaxUrl,
type: 'POST',
dataType: 'json',
data: {
ajax: 1,
action: 'previewFilterValueProducts',
trait: 'EntitySelector',
value_id: valueId,
value_type: valueType,
group_id: groupId,
limit: 10
},
success: function(response) {
$badge.removeClass('loading');
if (response.success) {
self.createPreviewPopover({
$badge: $badge,
items: response.items || [],
totalCount: response.count || 0,
hasMore: response.hasMore || false,
entityLabel: 'products',
previewType: 'filter-value',
context: { valueId: valueId, valueType: valueType, groupId: groupId, valueName: valueName },
onLoadMore: function($btn) {
self.loadMoreFilterValueItems($btn);
},
onFilter: function(query) {
self.filterFilterValueItems(query);
}
});
} else {
$badge.removeClass('popover-open');
self.$activeBadge = null;
}
},
error: function() {
$badge.removeClass('loading popover-open');
self.$activeBadge = null;
}
});
},
/**
* AJAX filter handler for filter value preview
*/
filterFilterValueItems: function(query) {
var self = this;
var ctx = this.previewContext;
if (!ctx || !ctx.valueId) {
self.showFilterLoading(false);
return;
}
$.ajax({
url: this.config.ajaxUrl,
type: 'POST',
dataType: 'json',
data: {
ajax: 1,
action: 'previewFilterValueProducts',
trait: 'EntitySelector',
value_id: ctx.valueId,
value_type: ctx.valueType,
group_id: ctx.groupId,
filter: query,
limit: 20
},
success: function(response) {
self.updatePreviewPopoverFiltered(response);
},
error: function() {
self.showFilterLoading(false);
}
});
},
loadMoreFilterValueItems: function($btn) {
var self = this;
var ctx = this.previewContext;
if (!ctx || !ctx.valueId) return;
var loadCount = this.previewLoadCount || 20;
var ajaxData = {
ajax: 1,
action: 'previewFilterValueProducts',
trait: 'EntitySelector',
value_id: ctx.valueId,
value_type: ctx.valueType,
group_id: ctx.groupId,
limit: self.previewLoadedCount + loadCount
};
if (self.previewCurrentFilter) {
ajaxData.filter = self.previewCurrentFilter;
}
$.ajax({
url: this.config.ajaxUrl,
type: 'POST',
dataType: 'json',
data: ajaxData,
success: function(response) {
if (response.success) {
self.previewTotalCount = response.count;
self.updatePreviewPopover(response.items || [], response.hasMore);
}
},
error: function() {
$btn.removeClass('loading');
$btn.find('i').removeClass('icon-spinner icon-spin').addClass('icon-plus');
}
});
},
// =========================================================================
// CATEGORY ITEMS PREVIEW (products/pages in a category)
// =========================================================================
@@ -9565,28 +9803,9 @@
self.switchToBlock(blockType);
});
// Position popover
// Position popover relative to badge (handles viewport overflow)
$('body').append($popover);
var badgeOffset = $badge.offset();
var badgeHeight = $badge.outerHeight();
var popoverWidth = $popover.outerWidth();
$popover.css({
position: 'absolute',
top: badgeOffset.top + badgeHeight + 5,
left: badgeOffset.left - (popoverWidth / 2) + ($badge.outerWidth() / 2),
zIndex: 10000
});
// Adjust if off screen
var windowWidth = $(window).width();
var popoverRight = $popover.offset().left + popoverWidth;
if (popoverRight > windowWidth - 10) {
$popover.css('left', windowWidth - popoverWidth - 10);
}
if ($popover.offset().left < 10) {
$popover.css('left', 10);
}
this.positionPopover($popover, $badge);
$popover.hide().fadeIn(150);
},

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -422,16 +422,20 @@
console.log('[EntitySelector] Making bulk AJAX request for entities:', JSON.stringify(bulkRequest));
// Single bulk AJAX call for all entity types
var bulkAjaxData = {
ajax: 1,
action: 'getTargetEntitiesByIdsBulk',
trait: 'EntitySelector',
entities: JSON.stringify(bulkRequest)
};
if (self.config.productSelectionLevel && self.config.productSelectionLevel !== 'product') {
bulkAjaxData.product_selection_level = self.config.productSelectionLevel;
}
$.ajax({
url: self.config.ajaxUrl,
type: 'POST',
dataType: 'json',
data: {
ajax: 1,
action: 'getTargetEntitiesByIdsBulk',
trait: 'EntitySelector',
entities: JSON.stringify(bulkRequest)
},
data: bulkAjaxData,
success: function(response) {
console.log('[EntitySelector] AJAX response:', response);
if (!response.success || !response.entities) {
@@ -750,18 +754,22 @@
// Handle entity_search type - load via AJAX
var searchEntity = $picker.attr('data-search-entity') || blockType;
var pickerAjaxData = {
ajax: 1,
action: 'getTargetEntitiesByIds',
trait: 'EntitySelector',
entity_type: searchEntity,
ids: JSON.stringify(values)
};
if (this.config.productSelectionLevel && this.config.productSelectionLevel !== 'product') {
pickerAjaxData.product_selection_level = this.config.productSelectionLevel;
}
$.ajax({
url: this.config.ajaxUrl,
type: 'POST',
dataType: 'json',
data: {
ajax: 1,
action: 'getTargetEntitiesByIds',
trait: 'EntitySelector',
entity_type: searchEntity,
ids: JSON.stringify(values)
},
data: pickerAjaxData,
success: function(response) {
if (response.success && response.entities) {
// Track which IDs were actually found (entities may have been deleted)

View File

@@ -107,9 +107,9 @@
!$(e.target).closest('.group-count-badge').length &&
!$(e.target).closest('.group-modifiers').length &&
!$(e.target).closest('.group-preview-badge').length &&
!$(e.target).closest('.toggle-count.clickable').length &&
!$(e.target).closest('.trait-total-count').length &&
!$(e.target).closest('.chip-preview-holidays').length) {
!$(e.target).closest('.chip-preview-holidays').length &&
!$(e.target).closest('.chip-preview-btn').length) {
self.hidePreviewPopover();
// Also close holiday popover
$('.holiday-preview-popover').remove();
@@ -152,6 +152,31 @@
}
});
// Filter chip/group preview eye button (unified handler)
this.$wrapper.on('click', '.chip-preview-btn', function(e) {
e.preventDefault();
e.stopPropagation();
var $btn = $(this);
if ($btn.hasClass('popover-open')) {
self.hidePreviewPopover();
} else {
var valueId = $btn.data('id');
var valueType = $btn.data('type');
var valueName = $btn.data('name');
var groupId = $btn.data('groupId');
if (valueId) {
// Value-level preview (specific attribute/feature value)
self.showFilterValuePreviewPopover($btn, valueId, valueType, valueName, groupId);
} else if (groupId) {
// Group-level preview (entire attribute/feature group)
self.showFilterGroupPreviewPopover($btn, groupId, valueType, valueName);
}
}
});
// Group-level collapse toggle (click on group header or toggle icon)
this.$wrapper.on('click', '.group-header', function(e) {
if ($(e.target).closest('.btn-remove-group, .group-name-input').length) {
@@ -1031,7 +1056,7 @@
if (isSelected) {
// Remove from pending selections
self.pendingSelections = self.pendingSelections.filter(function(s) {
return parseInt(s.id, 10) !== parseInt(id, 10);
return String(s.id) !== String(id);
});
self.removeSelection($picker, id);
$item.toggleClass('selected');
@@ -1058,7 +1083,7 @@
} else {
// Add to pending selections
var exists = self.pendingSelections.some(function(s) {
return parseInt(s.id, 10) === parseInt(id, 10);
return String(s.id) === String(id);
});
if (!exists) {
self.pendingSelections.push({ id: id, name: name, data: $item.data() });
@@ -1083,7 +1108,7 @@
// Also remove from pending selections if dropdown is open
if (self.pendingSelections) {
self.pendingSelections = self.pendingSelections.filter(function(s) {
return parseInt(s.id, 10) !== parseInt(id, 10);
return String(s.id) !== String(id);
});
}
@@ -1210,7 +1235,7 @@
// Add to pending selections for Save button
var exists = self.pendingSelections.some(function(s) {
return parseInt(s.id, 10) === parseInt(id, 10);
return String(s.id) === String(id);
});
if (!exists) {
self.pendingSelections.push({
@@ -1825,10 +1850,6 @@
// Toggle filter group - show values
this.$dropdown.on('click', '.filter-group-toggle', function(e) {
// Ignore clicks on the preview badge
if ($(e.target).closest('.toggle-count.clickable').length) {
return;
}
e.preventDefault();
var $btn = $(this);
var groupId = $btn.data('group-id');
@@ -1845,20 +1866,26 @@
}
});
// Filter group toggle count badge click for preview popover
this.$dropdown.on('click', '.filter-group-toggle .toggle-count.clickable', function(e) {
e.stopPropagation();
// Filter preview eye button (dropdown-level, since dropdown is appended to body)
this.$dropdown.on('click', '.chip-preview-btn', function(e) {
e.preventDefault();
e.stopPropagation();
var $badge = $(this);
var groupId = $badge.data('groupId');
var groupType = $badge.data('type');
var groupName = $badge.data('groupName');
var $btn = $(this);
if ($badge.hasClass('popover-open')) {
if ($btn.hasClass('popover-open')) {
self.hidePreviewPopover();
} else {
self.showFilterGroupPreviewPopover($badge, groupId, groupType, groupName);
var valueId = $btn.data('id');
var valueType = $btn.data('type');
var valueName = $btn.data('name');
var groupId = $btn.data('groupId');
if (valueId) {
self.showFilterValuePreviewPopover($btn, valueId, valueType, valueName, groupId);
} else if (groupId) {
self.showFilterGroupPreviewPopover($btn, groupId, valueType, valueName);
}
}
});
@@ -2017,13 +2044,13 @@
// If already pinned, unpin and close
if ($wrapper.hasClass('pinned')) {
$wrapper.removeClass('pinned');
$wrapper.find('.material-icons').text('info_outline');
$wrapper.find('.material-icons').text('info');
$('.mpr-tooltip-fixed.pinned').remove();
return;
}
// Close any other pinned tooltips
$('.mpr-info-wrapper.pinned').removeClass('pinned').find('.material-icons').text('info_outline');
$('.mpr-info-wrapper.pinned').removeClass('pinned').find('.material-icons').text('info');
$('.mpr-tooltip-fixed').remove();
var content = $wrapper.attr('data-tooltip');
@@ -2043,7 +2070,7 @@
// Close button click
$closeBtn.on('click', function() {
$wrapper.removeClass('pinned');
$wrapper.find('.material-icons').text('info_outline');
$wrapper.find('.material-icons').text('info');
$tooltip.remove();
});

View File

@@ -228,6 +228,7 @@
if (!this.$dropdown || !this.filterableData) return;
var self = this;
var previewLabel = self.config.trans && self.config.trans.preview || 'Preview';
// Render attribute group toggle buttons
var $attrContainer = this.$dropdown.find('.filter-attributes-container');
@@ -235,12 +236,17 @@
if (this.filterableData.attributes && this.filterableData.attributes.length > 0) {
this.filterableData.attributes.forEach(function(group) {
var html = '<button type="button" class="filter-group-toggle" data-group-id="' + group.id + '" data-type="attribute" data-group-name="' + self.escapeAttr(group.name) + '">';
var html = '<span class="filter-chip-wrapper">';
html += '<button type="button" class="filter-group-toggle" data-group-id="' + group.id + '" data-type="attribute" data-group-name="' + self.escapeAttr(group.name) + '">';
html += '<span class="toggle-name">' + group.name + '</span>';
if (group.count !== undefined) {
html += '<span class="toggle-count clickable" data-group-id="' + group.id + '" data-type="attribute" data-group-name="' + self.escapeAttr(group.name) + '"><i class="icon-eye"></i> ' + group.count + '</span>';
html += '<span class="toggle-count">(' + group.count + ')</span>';
}
html += '</button>';
html += '<button type="button" class="chip-preview-btn" data-group-id="' + group.id + '" data-type="attribute" data-name="' + self.escapeAttr(group.name) + '" title="' + previewLabel + '">';
html += '<i class="icon-eye"></i>';
html += '</button>';
html += '</span>';
$attrContainer.append(html);
});
this.$dropdown.find('.filter-row-attributes').show();
@@ -252,12 +258,17 @@
if (this.filterableData.features && this.filterableData.features.length > 0) {
this.filterableData.features.forEach(function(group) {
var html = '<button type="button" class="filter-group-toggle" data-group-id="' + group.id + '" data-type="feature" data-group-name="' + self.escapeAttr(group.name) + '">';
var html = '<span class="filter-chip-wrapper">';
html += '<button type="button" class="filter-group-toggle" data-group-id="' + group.id + '" data-type="feature" data-group-name="' + self.escapeAttr(group.name) + '">';
html += '<span class="toggle-name">' + group.name + '</span>';
if (group.count !== undefined) {
html += '<span class="toggle-count clickable" data-group-id="' + group.id + '" data-type="feature" data-group-name="' + self.escapeAttr(group.name) + '"><i class="icon-eye"></i> ' + group.count + '</span>';
html += '<span class="toggle-count">(' + group.count + ')</span>';
}
html += '</button>';
html += '<button type="button" class="chip-preview-btn" data-group-id="' + group.id + '" data-type="feature" data-name="' + self.escapeAttr(group.name) + '" title="' + previewLabel + '">';
html += '<i class="icon-eye"></i>';
html += '</button>';
html += '</span>';
$featContainer.append(html);
});
this.$dropdown.find('.filter-row-features').show();
@@ -295,6 +306,7 @@
var colorStyle = val.color ? ' style="--chip-color: ' + val.color + '"' : '';
var colorClass = val.color ? ' has-color' : '';
html += '<span class="filter-chip-wrapper">';
html += '<button type="button" class="filter-chip ' + chipClass + activeClass + colorClass + '" data-id="' + val.id + '" data-group-id="' + groupId + '"' + colorStyle + '>';
if (val.color) {
html += '<span class="chip-color-dot"></span>';
@@ -304,6 +316,10 @@
html += '<span class="chip-count">(' + val.count + ')</span>';
}
html += '</button>';
html += '<button type="button" class="chip-preview-btn" data-id="' + val.id + '" data-group-id="' + groupId + '" data-type="' + type + '" data-name="' + self.escapeAttr(val.name) + '" title="' + (self.config.trans && self.config.trans.preview || 'Preview') + '">';
html += '<i class="icon-eye"></i>';
html += '</button>';
html += '</span>';
});
$valuesContainer.html(html);

View File

@@ -1852,7 +1852,8 @@
clearValidationError: function() {
this.$wrapper.removeClass('has-validation-error');
this.$wrapper.find('.trait-validation-error').remove();
}
},
};
})(jQuery);

View File

@@ -586,7 +586,7 @@
class: 'mpr-info-wrapper',
'data-tooltip': helpContent
});
$infoWrapper.append($('<i>', { class: 'material-icons', text: 'info_outline' }));
$infoWrapper.append($('<i>', { class: 'material-icons', text: 'info' }));
$placeholder.append($infoWrapper);
}
},

View File

@@ -192,23 +192,8 @@
});
}
// Position popover below badge
var badgeOffset = $badge.offset();
var badgeHeight = $badge.outerHeight();
var badgeWidth = $badge.outerWidth();
var popoverWidth = $popover.outerWidth();
var leftPos = badgeOffset.left + (badgeWidth / 2) - (popoverWidth / 2);
var minLeft = 10;
var maxLeft = $(window).width() - popoverWidth - 10;
leftPos = Math.max(minLeft, Math.min(leftPos, maxLeft));
$popover.css({
position: 'absolute',
top: badgeOffset.top + badgeHeight + 8,
left: leftPos,
zIndex: 10000
});
// Position popover relative to badge (handles viewport overflow)
this.positionPopover($popover, $badge);
// Show with transition
$popover.addClass('show');
@@ -216,6 +201,61 @@
return $popover;
},
/**
* Position a popover relative to a trigger element.
* Handles horizontal and vertical viewport overflow.
* @param {jQuery} $popover - The popover element
* @param {jQuery} $trigger - The trigger element to position against
* @param {number} [zIndex=10000] - Optional z-index
*/
positionPopover: function($popover, $trigger, zIndex) {
var triggerRect = $trigger[0].getBoundingClientRect();
var scrollTop = $(window).scrollTop();
var scrollLeft = $(window).scrollLeft();
var popoverWidth = $popover.outerWidth();
var popoverHeight = $popover.outerHeight();
var windowWidth = $(window).width();
var windowHeight = $(window).height();
var gap = 8;
// Horizontal: center on trigger, then clamp to viewport
var left = triggerRect.left + scrollLeft + (triggerRect.width / 2) - (popoverWidth / 2);
left = Math.max(10, Math.min(left, windowWidth - popoverWidth - 10));
// Vertical: prefer below, flip above if it would overflow
var top;
var positionAbove = false;
if (triggerRect.bottom + popoverHeight + gap > windowHeight - 10) {
// Position above the trigger
top = triggerRect.top + scrollTop - popoverHeight - gap;
positionAbove = true;
} else {
// Position below the trigger
top = triggerRect.bottom + scrollTop + gap;
}
$popover.css({
position: 'absolute',
top: top,
left: left,
zIndex: zIndex || 10000
});
// Toggle arrow direction class
$popover.toggleClass('position-above', positionAbove);
// Determine horizontal arrow position class
var triggerCenter = triggerRect.left + (triggerRect.width / 2);
var popoverLeft = left;
var popoverCenter = popoverLeft + (popoverWidth / 2);
$popover.removeClass('position-left position-right');
if (triggerCenter - popoverLeft < popoverWidth * 0.3) {
$popover.addClass('position-right');
} else if (triggerCenter - popoverLeft > popoverWidth * 0.7) {
$popover.addClass('position-left');
}
},
/**
* Update popover after loading more items
*/
@@ -1180,6 +1220,144 @@
});
},
// =========================================================================
// FILTER VALUE PREVIEW (individual attribute/feature value chip)
// =========================================================================
/**
* Show preview popover for a specific filter value (attribute or feature value)
* @param {jQuery} $badge - The preview button on the chip
* @param {number} valueId - The attribute/feature value ID
* @param {string} valueType - 'attribute' or 'feature'
* @param {string} valueName - Display name of the value
* @param {number} groupId - The parent group ID
*/
showFilterValuePreviewPopover: function($badge, valueId, valueType, valueName, groupId) {
var self = this;
this.hidePreviewPopover();
$badge.addClass('popover-open loading');
this.$activeBadge = $badge;
$.ajax({
url: this.config.ajaxUrl,
type: 'POST',
dataType: 'json',
data: {
ajax: 1,
action: 'previewFilterValueProducts',
trait: 'EntitySelector',
value_id: valueId,
value_type: valueType,
group_id: groupId,
limit: 10
},
success: function(response) {
$badge.removeClass('loading');
if (response.success) {
self.createPreviewPopover({
$badge: $badge,
items: response.items || [],
totalCount: response.count || 0,
hasMore: response.hasMore || false,
entityLabel: 'products',
previewType: 'filter-value',
context: { valueId: valueId, valueType: valueType, groupId: groupId, valueName: valueName },
onLoadMore: function($btn) {
self.loadMoreFilterValueItems($btn);
},
onFilter: function(query) {
self.filterFilterValueItems(query);
}
});
} else {
$badge.removeClass('popover-open');
self.$activeBadge = null;
}
},
error: function() {
$badge.removeClass('loading popover-open');
self.$activeBadge = null;
}
});
},
/**
* AJAX filter handler for filter value preview
*/
filterFilterValueItems: function(query) {
var self = this;
var ctx = this.previewContext;
if (!ctx || !ctx.valueId) {
self.showFilterLoading(false);
return;
}
$.ajax({
url: this.config.ajaxUrl,
type: 'POST',
dataType: 'json',
data: {
ajax: 1,
action: 'previewFilterValueProducts',
trait: 'EntitySelector',
value_id: ctx.valueId,
value_type: ctx.valueType,
group_id: ctx.groupId,
filter: query,
limit: 20
},
success: function(response) {
self.updatePreviewPopoverFiltered(response);
},
error: function() {
self.showFilterLoading(false);
}
});
},
loadMoreFilterValueItems: function($btn) {
var self = this;
var ctx = this.previewContext;
if (!ctx || !ctx.valueId) return;
var loadCount = this.previewLoadCount || 20;
var ajaxData = {
ajax: 1,
action: 'previewFilterValueProducts',
trait: 'EntitySelector',
value_id: ctx.valueId,
value_type: ctx.valueType,
group_id: ctx.groupId,
limit: self.previewLoadedCount + loadCount
};
if (self.previewCurrentFilter) {
ajaxData.filter = self.previewCurrentFilter;
}
$.ajax({
url: this.config.ajaxUrl,
type: 'POST',
dataType: 'json',
data: ajaxData,
success: function(response) {
if (response.success) {
self.previewTotalCount = response.count;
self.updatePreviewPopover(response.items || [], response.hasMore);
}
},
error: function() {
$btn.removeClass('loading');
$btn.find('i').removeClass('icon-spinner icon-spin').addClass('icon-plus');
}
});
},
// =========================================================================
// CATEGORY ITEMS PREVIEW (products/pages in a category)
// =========================================================================
@@ -1517,28 +1695,9 @@
self.switchToBlock(blockType);
});
// Position popover
// Position popover relative to badge (handles viewport overflow)
$('body').append($popover);
var badgeOffset = $badge.offset();
var badgeHeight = $badge.outerHeight();
var popoverWidth = $popover.outerWidth();
$popover.css({
position: 'absolute',
top: badgeOffset.top + badgeHeight + 5,
left: badgeOffset.left - (popoverWidth / 2) + ($badge.outerWidth() / 2),
zIndex: 10000
});
// Adjust if off screen
var windowWidth = $(window).width();
var popoverRight = $popover.offset().left + popoverWidth;
if (popoverRight > windowWidth - 10) {
$popover.css('left', windowWidth - popoverWidth - 10);
}
if ($popover.offset().left < 10) {
$popover.css('left', 10);
}
this.positionPopover($popover, $badge);
$popover.hide().fadeIn(150);
},

View File

@@ -40,6 +40,11 @@
sort_dir: this.currentSort ? this.currentSort.dir : 'ASC'
};
// Add product_selection_level if set
if (this.config.productSelectionLevel && this.config.productSelectionLevel !== 'product') {
requestData.product_selection_level = this.config.productSelectionLevel;
}
// Add refine query if present
if (this.refineQuery) {
requestData.refine = this.refineQuery;
@@ -367,6 +372,8 @@
var isSelected = selectedIds.indexOf(String(item.id)) !== -1;
var itemClass = 'dropdown-item' + (isSelected ? ' selected' : '');
if (item.type === 'product') itemClass += ' result-item-product';
if (item.is_combination) itemClass += ' is-combination';
if (item.is_parent) itemClass += ' is-parent-product';
html += '<div class="' + itemClass + '" ';
html += 'data-id="' + self.escapeAttr(item.id) + '" ';
@@ -374,6 +381,7 @@
if (item.image) html += ' data-image="' + self.escapeAttr(item.image) + '"';
if (item.subtitle) html += ' data-subtitle="' + self.escapeAttr(item.subtitle) + '"';
if (item.iso_code) html += ' data-iso="' + self.escapeAttr(item.iso_code) + '"';
if (item.attributes) html += ' data-attributes="' + self.escapeAttr(item.attributes) + '"';
html += '>';
html += '<span class="result-checkbox"><i class="icon-check"></i></span>';

View File

@@ -343,6 +343,16 @@
word-break: break-word;
}
.chip-attrs {
font-size: 0.85em;
opacity: 0.7;
margin-left: 2px;
&::before {
content: '';
}
}
.chip-remove {
@include button-reset;
display: flex;

View File

@@ -359,6 +359,20 @@
gap: $es-spacing-sm;
}
// Combination-level search results ('both' mode)
.dropdown-item.is-combination {
padding-left: 28px;
.result-name {
font-size: 0.9em;
}
}
.dropdown-item.is-parent-product {
background: $es-slate-50;
font-weight: $es-font-weight-medium;
}
// No results state
.no-results {
display: flex;
@@ -1290,50 +1304,9 @@ body > .target-search-dropdown,
font-weight: $es-font-weight-medium;
}
// Count with eye icon (like group-count-badge)
.toggle-count {
display: inline-flex;
align-items: center;
gap: 0.125rem;
color: $es-text-muted;
font-size: 0.65rem;
i {
font-size: 10px;
color: $es-primary;
}
// Clickable preview badge
&.clickable {
cursor: pointer;
padding: 0.125rem 0.25rem;
border-radius: $es-radius-sm;
transition: all $es-transition-fast;
&:hover {
background: rgba($es-primary, 0.1);
color: $es-primary;
i {
color: $es-primary;
}
}
&.popover-open {
background: $es-primary;
color: $es-white;
i {
color: $es-white;
}
}
&.loading {
i {
animation: spin 0.6s linear infinite;
}
}
}
}
}
@@ -1426,6 +1399,67 @@ body > .target-search-dropdown,
color: rgba(255, 255, 255, 0.8);
}
// Filter chip wrapper (chip/toggle + preview button)
.filter-chip-wrapper {
display: inline-flex;
align-items: stretch;
border-radius: $es-radius-sm;
overflow: hidden;
// Left element gets left-only border-radius
.filter-chip,
.filter-group-toggle {
border-radius: $es-radius-sm 0 0 $es-radius-sm;
}
// Preview eye button — unified for both value chips and group toggles
.chip-preview-btn {
@include button-reset;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0 0.375rem;
font-size: 10px;
color: $es-text-muted;
background: $es-slate-100;
border-left: 1px solid $es-border-color;
border-radius: 0 $es-radius-sm $es-radius-sm 0;
cursor: pointer;
transition: all $es-transition-fast;
&:hover {
background: rgba($es-primary, 0.1);
color: $es-primary;
}
&.popover-open {
background: $es-primary;
color: $es-white;
}
&.loading i {
animation: spin 0.6s linear infinite;
}
}
// When no preview button, restore full border-radius
.filter-chip:last-child,
.filter-group-toggle:last-child {
border-radius: $es-radius-sm;
}
// Group toggle active/has-selection states propagate to wrapper border
.filter-group-toggle.active + .chip-preview-btn {
border-left-color: $es-primary;
background: rgba($es-primary, 0.05);
}
.filter-group-toggle.has-selection + .chip-preview-btn {
border-left-color: $es-success;
background: rgba($es-success, 0.03);
}
}
// Dropdown content
.dropdown-content {
max-height: 400px;

View File

@@ -368,6 +368,7 @@
// Single mode specific styles
.target-conditions-trait.single-mode,
.entity-selector-trait.single-mode {
// Hide tabs in standalone layout (has separate header, 1 tab is redundant)
.target-block-tabs {
display: none;
}
@@ -375,6 +376,11 @@
.target-block-container {
display: block;
}
// In form-content layout, always show tabs — they serve as the block title
.entity-selector-tabs-row .target-block-tabs {
display: flex;
}
}
// Header action buttons

View File

@@ -931,6 +931,36 @@
background-size: 1.25em 1.25em;
}
// Single mode — strip padding, borders, backgrounds for clean single-selection UI
&[data-mode=single],
.mode-single {
.groups-container {
padding: 0;
}
.group-body {
padding: 0;
}
.group-include {
padding: 0.5rem;
margin-bottom: 0;
background: transparent;
border: none;
border-radius: 0;
}
.selection-group {
background: transparent;
border: none;
margin-bottom: 0;
}
.group-header {
display: none;
}
}
// Condition match count badge
.condition-match-count {
display: inline-flex;

View File

@@ -66,6 +66,23 @@
transform: none;
}
}
// Positioned above trigger - arrow pointing down
&.position-above {
&::before {
top: auto;
bottom: -8px;
border-top: 8px solid $es-border-color;
border-bottom: 0;
}
&::after {
top: auto;
bottom: -6px;
border-top: 6px solid $es-white;
border-bottom: 0;
}
}
}
// =============================================================================

View File

@@ -156,8 +156,11 @@ trait EntitySelector
}
/**
* Get base path for target conditions assets from vendor package
* Assets are loaded directly from the package - no manual copying needed
* Get base path for entity selector assets from the vendor package.
* Auto-detects the correct path via reflection — no manual copying needed.
*
* DO NOT override this method. DO NOT copy CSS/JS to views/ directories.
* The sync script (sync-entity-selector.sh) enforces single-source assets.
*
* @return string URL path ending with /
*/
@@ -172,8 +175,17 @@ trait EntitySelector
$assetsDir = $packageDir . '/assets/';
// Convert filesystem path to URL path relative to PS root
$psRoot = _PS_ROOT_DIR_ . '/';
$relativePath = str_replace($psRoot, '', $assetsDir);
// Use realpath() on PS root to handle symlinks consistently
$psRoot = realpath(_PS_ROOT_DIR_) . '/';
$assetsReal = realpath($packageDir . '/assets');
$relativePath = $assetsReal ? str_replace($psRoot, '', $assetsReal . '/') : null;
// If symlink resolution caused the path to escape PS root, fall back to module URI
if (!$relativePath || strpos($relativePath, '/') === 0 || strpos($relativePath, '..') !== false) {
// Use module's web-accessible path as base
$modulePath = $this->module->getPathUri();
return $modulePath . 'vendor/myprestarocks/prestashop-entity-selector/assets/';
}
return __PS_BASE_URI__ . $relativePath;
}
@@ -275,6 +287,9 @@ trait EntitySelector
case 'previewFilterGroupProducts':
$this->ajaxPreviewFilterGroupProducts();
return true;
case 'previewFilterValueProducts':
$this->ajaxPreviewFilterValueProducts();
return true;
case 'previewCategoryProducts':
$this->ajaxPreviewCategoryProducts();
return true;
@@ -837,6 +852,114 @@ trait EntitySelector
}
}
/**
* AJAX: Preview products for a specific attribute/feature value
* Used by filter chip eye button
*/
protected function ajaxPreviewFilterValueProducts()
{
$valueId = (int) Tools::getValue('value_id');
$valueType = Tools::getValue('value_type'); // 'attribute' or 'feature'
$groupId = (int) Tools::getValue('group_id');
$limit = (int) Tools::getValue('limit', 10);
$filter = Tools::getValue('filter', '');
if (!$valueId || !in_array($valueType, ['attribute', 'feature'])) {
die(json_encode([
'success' => false,
'error' => 'Invalid parameters'
]));
}
$idLang = (int) Context::getContext()->language->id;
$idShop = (int) Context::getContext()->shop->id;
try {
$db = Db::getInstance();
if ($valueType === 'attribute') {
$sql = new DbQuery();
$sql->select('DISTINCT p.id_product');
$sql->from('product', 'p');
$sql->innerJoin('product_shop', 'ps', 'ps.id_product = p.id_product AND ps.id_shop = ' . $idShop);
$sql->innerJoin('product_attribute', 'pa', 'pa.id_product = p.id_product');
$sql->innerJoin('product_attribute_combination', 'pac', 'pac.id_product_attribute = pa.id_product_attribute');
$sql->where('pac.id_attribute = ' . $valueId);
$sql->where('ps.active = 1');
} else {
$sql = new DbQuery();
$sql->select('DISTINCT p.id_product');
$sql->from('product', 'p');
$sql->innerJoin('product_shop', 'ps', 'ps.id_product = p.id_product AND ps.id_shop = ' . $idShop);
$sql->innerJoin('feature_product', 'fp', 'fp.id_product = p.id_product');
$sql->where('fp.id_feature_value = ' . $valueId);
$sql->where('ps.active = 1');
}
$results = $db->executeS($sql);
$productIds = array_column($results, 'id_product');
if (!empty($filter) && !empty($productIds)) {
$productIds = $this->getEntityPreviewHandler()->filterProductIdsByQuery($productIds, $filter, $idLang, $idShop);
}
$totalCount = count($productIds);
$previewIds = array_slice($productIds, 0, $limit);
$items = [];
if (!empty($previewIds)) {
$productSql = new DbQuery();
$productSql->select('p.id_product, pl.name, p.reference, i.id_image, m.name as manufacturer');
$productSql->from('product', 'p');
$productSql->innerJoin('product_lang', 'pl', 'pl.id_product = p.id_product AND pl.id_lang = ' . $idLang . ' AND pl.id_shop = ' . $idShop);
$productSql->leftJoin('image', 'i', 'i.id_product = p.id_product AND i.cover = 1');
$productSql->leftJoin('manufacturer', 'm', 'm.id_manufacturer = p.id_manufacturer');
$productSql->where('p.id_product IN (' . implode(',', array_map('intval', $previewIds)) . ')');
$products = $db->executeS($productSql);
$productsById = [];
foreach ($products as $product) {
$imageUrl = null;
if ($product['id_image']) {
$imageUrl = Context::getContext()->link->getImageLink(
Tools::link_rewrite($product['name']),
$product['id_product'] . '-' . $product['id_image'],
'small_default'
);
}
$productsById[(int) $product['id_product']] = [
'id' => (int) $product['id_product'],
'name' => $product['name'],
'reference' => $product['reference'],
'manufacturer' => $product['manufacturer'],
'image' => $imageUrl
];
}
foreach ($previewIds as $id) {
if (isset($productsById[(int) $id])) {
$items[] = $productsById[(int) $id];
}
}
}
die(json_encode([
'success' => true,
'items' => $items,
'count' => $totalCount,
'hasMore' => $totalCount > count($items)
]));
} catch (\Exception $e) {
die(json_encode([
'success' => false,
'error' => $e->getMessage()
]));
}
}
/**
* AJAX: Preview products in a category
* Used by tree view product count click
@@ -1279,6 +1402,8 @@ trait EntitySelector
// Sorting
'sort_by' => $sortBy,
'sort_dir' => $sortDir,
// Product selection level
'product_selection_level' => Tools::getValue('product_selection_level', 'product'),
];
// Delegate to EntitySearchEngine
@@ -1302,6 +1427,7 @@ trait EntitySelector
protected function ajaxGetTargetEntitiesByIds()
{
$entityType = Tools::getValue('entity_type', '');
$productSelectionLevel = Tools::getValue('product_selection_level', 'product');
$ids = Tools::getValue('ids', '');
if (is_string($ids)) {
$ids = json_decode($ids, true);
@@ -1311,7 +1437,7 @@ trait EntitySelector
}
// Delegate to EntitySearchEngine
$entities = $this->getEntitySearchEngine()->getByIds($entityType, $ids);
$entities = $this->getEntitySearchEngine()->getByIds($entityType, $ids, $productSelectionLevel);
$this->ajaxDie(json_encode([
'success' => true,
@@ -1327,7 +1453,7 @@ trait EntitySelector
protected function ajaxGetTargetEntitiesByIdsBulk()
{
$entitiesParam = Tools::getValue('entities', '');
\PrestaShopLogger::addLog('[EntitySelector] ajaxGetTargetEntitiesByIdsBulk called, raw param: ' . substr($entitiesParam, 0, 500), 1);
$productSelectionLevel = Tools::getValue('product_selection_level', 'product');
if (is_string($entitiesParam)) {
$entitiesParam = json_decode($entitiesParam, true);
@@ -1336,21 +1462,20 @@ trait EntitySelector
$entitiesParam = [];
}
\PrestaShopLogger::addLog('[EntitySelector] Parsed entities param: ' . json_encode($entitiesParam), 1);
$result = [];
$searchEngine = $this->getEntitySearchEngine();
foreach ($entitiesParam as $entityType => $ids) {
if (!is_array($ids) || empty($ids)) {
\PrestaShopLogger::addLog('[EntitySelector] Skipping entityType=' . $entityType . ' - empty or not array', 1);
continue;
}
// Deduplicate and sanitize IDs
$ids = array_unique(array_map('intval', $ids));
\PrestaShopLogger::addLog('[EntitySelector] Fetching entityType=' . $entityType . ' ids=' . implode(',', $ids), 1);
$result[$entityType] = $searchEngine->getByIds($entityType, $ids);
\PrestaShopLogger::addLog('[EntitySelector] Got ' . count($result[$entityType]) . ' results for ' . $entityType, 1);
// For combination-level products, keep string IDs (c:32, p:17); otherwise intval
if ($entityType === 'products' && ($productSelectionLevel === 'combination' || $productSelectionLevel === 'both')) {
$ids = array_unique(array_map('strval', $ids));
} else {
$ids = array_unique(array_map('intval', $ids));
}
$result[$entityType] = $searchEngine->getByIds($entityType, $ids, $productSelectionLevel);
}
$this->ajaxDie(json_encode([

View File

@@ -305,6 +305,19 @@ class EntityPreviewHandler
return [];
}
// Detect prefixed IDs (c:32, p:17) — route to combination-aware preview
$hasPrefixed = false;
foreach ($productIds as $id) {
if (is_string($id) && (strpos($id, 'c:') === 0 || strpos($id, 'p:') === 0)) {
$hasPrefixed = true;
break;
}
}
if ($hasPrefixed) {
return $this->getProductPreviewDataPrefixed($productIds, $idLang, $idShop);
}
$sql = new DbQuery();
$sql->select('p.id_product, pl.name, p.reference, p.price, p.active');
$sql->select('sa.quantity, m.name AS manufacturer_name');
@@ -353,6 +366,130 @@ class EntityPreviewHandler
return $ordered;
}
/**
* Get preview data for prefixed IDs (c:32, p:17).
* Splits into combination IDs and product IDs, fetches data for each.
*
* @param array $prefixedIds Prefixed string IDs
* @param int $idLang Language ID
* @param int $idShop Shop ID
* @return array Formatted preview items in original order
*/
protected function getProductPreviewDataPrefixed(array $prefixedIds, $idLang, $idShop)
{
$comboIds = [];
$productIds = [];
foreach ($prefixedIds as $prefixedId) {
$prefixedId = (string) $prefixedId;
if (strpos($prefixedId, 'c:') === 0) {
$comboIds[] = (int) substr($prefixedId, 2);
} elseif (strpos($prefixedId, 'p:') === 0) {
$productIds[] = (int) substr($prefixedId, 2);
} else {
$productIds[] = (int) $prefixedId;
}
}
$db = Db::getInstance();
$imageType = $this->getProductImageType();
$itemsByKey = [];
// Fetch combination data
if (!empty($comboIds)) {
$sql = 'SELECT pa.id_product_attribute, pa.id_product, pl.name, p.reference AS product_reference,
pa.reference AS combo_reference, p.price, p.active,
IFNULL(sa_c.quantity, 0) AS quantity,
m.name AS manufacturer_name, cl.name AS category_name,
i.id_image, pai.id_image AS combo_id_image,
GROUP_CONCAT(CONCAT(agl.name, \': \', al.name) ORDER BY ag.position ASC SEPARATOR \', \') AS attributes
FROM ' . _DB_PREFIX_ . 'product_attribute pa
INNER JOIN ' . _DB_PREFIX_ . 'product p ON p.id_product = pa.id_product
INNER JOIN ' . _DB_PREFIX_ . 'product_attribute_shop pas ON pas.id_product_attribute = pa.id_product_attribute AND pas.id_shop = ' . (int) $idShop . '
INNER JOIN ' . _DB_PREFIX_ . 'product_lang pl ON pl.id_product = p.id_product AND pl.id_lang = ' . (int) $idLang . ' AND pl.id_shop = ' . (int) $idShop . '
INNER JOIN ' . _DB_PREFIX_ . 'product_attribute_combination pac ON pac.id_product_attribute = pa.id_product_attribute
INNER JOIN ' . _DB_PREFIX_ . 'attribute a ON a.id_attribute = pac.id_attribute
INNER JOIN ' . _DB_PREFIX_ . 'attribute_lang al ON al.id_attribute = a.id_attribute AND al.id_lang = ' . (int) $idLang . '
INNER JOIN ' . _DB_PREFIX_ . 'attribute_group ag ON ag.id_attribute_group = a.id_attribute_group
INNER JOIN ' . _DB_PREFIX_ . 'attribute_group_lang agl ON agl.id_attribute_group = a.id_attribute_group AND agl.id_lang = ' . (int) $idLang . '
LEFT JOIN ' . _DB_PREFIX_ . 'manufacturer m ON m.id_manufacturer = p.id_manufacturer
LEFT JOIN ' . _DB_PREFIX_ . 'category_lang cl ON cl.id_category = p.id_category_default AND cl.id_lang = ' . (int) $idLang . ' AND cl.id_shop = ' . (int) $idShop . '
LEFT JOIN ' . _DB_PREFIX_ . 'stock_available sa_c ON sa_c.id_product = pa.id_product AND sa_c.id_product_attribute = pa.id_product_attribute AND sa_c.id_shop = ' . (int) $idShop . '
LEFT JOIN ' . _DB_PREFIX_ . 'image i ON i.id_product = p.id_product AND i.cover = 1
LEFT JOIN ' . _DB_PREFIX_ . 'product_attribute_image pai ON pai.id_product_attribute = pa.id_product_attribute
WHERE pa.id_product_attribute IN (' . implode(',', array_map('intval', $comboIds)) . ')
GROUP BY pa.id_product_attribute';
$rows = $db->executeS($sql);
if ($rows) {
foreach ($rows as $row) {
$key = 'c:' . (int) $row['id_product_attribute'];
$imageId = !empty($row['combo_id_image']) ? $row['combo_id_image'] : ($row['id_image'] ?? null);
$ref = $row['combo_reference'] ?: $row['product_reference'];
$itemsByKey[$key] = [
'id' => $key,
'name' => $row['name'] . ', ' . $row['attributes'],
'reference' => $ref,
'price' => (float) $row['price'],
'quantity' => (int) $row['quantity'],
'active' => (bool) $row['active'],
'manufacturer' => $row['manufacturer_name'],
'category' => $row['category_name'],
'image' => $imageId ? $this->getProductImageUrl($row['id_product'], $imageId, $imageType) : null,
'attributes' => $row['attributes'],
'isCombination' => true,
];
}
}
}
// Fetch plain product data
if (!empty($productIds)) {
$sql = new DbQuery();
$sql->select('p.id_product, pl.name, p.reference, p.price, p.active');
$sql->select('sa.quantity, m.name AS manufacturer_name');
$sql->select('cl.name AS category_name, i.id_image');
$sql->from('product', 'p');
$sql->innerJoin('product_shop', 'ps', 'ps.id_product = p.id_product AND ps.id_shop = ' . (int) $idShop);
$sql->leftJoin('product_lang', 'pl', 'pl.id_product = p.id_product AND pl.id_lang = ' . (int) $idLang . ' AND pl.id_shop = ' . (int) $idShop);
$sql->leftJoin('manufacturer', 'm', 'm.id_manufacturer = p.id_manufacturer');
$sql->leftJoin('category_lang', 'cl', 'cl.id_category = p.id_category_default AND cl.id_lang = ' . (int) $idLang . ' AND cl.id_shop = ' . (int) $idShop);
$sql->leftJoin('stock_available', 'sa', 'sa.id_product = p.id_product AND sa.id_product_attribute = 0 AND sa.id_shop = ' . (int) $idShop);
$sql->leftJoin('image', 'i', 'i.id_product = p.id_product AND i.cover = 1');
$sql->where('p.id_product IN (' . implode(',', array_map('intval', $productIds)) . ')');
$rows = $db->executeS($sql);
if ($rows) {
foreach ($rows as $row) {
$key = 'p:' . (int) $row['id_product'];
$itemsByKey[$key] = [
'id' => $key,
'name' => $row['name'],
'reference' => $row['reference'],
'price' => (float) $row['price'],
'quantity' => (int) $row['quantity'],
'active' => (bool) $row['active'],
'manufacturer' => $row['manufacturer_name'],
'category' => $row['category_name'],
'image' => $row['id_image'] ? $this->getProductImageUrl($row['id_product'], $row['id_image'], $imageType) : null,
];
}
}
}
// Return in original order
$ordered = [];
foreach ($prefixedIds as $id) {
$key = (string) $id;
if (isset($itemsByKey[$key])) {
$ordered[] = $itemsByKey[$key];
}
}
return $ordered;
}
/**
* Get product image type for thumbnails
*

View File

@@ -58,6 +58,12 @@ class EntitySearchEngine
*/
public function search($entityType, $query, $limit = 20, $offset = 0, array $filters = [])
{
// product_selection_level: route product searches to combination methods
$psl = $filters['product_selection_level'] ?? 'product';
if ($entityType === 'products' && ($psl === 'combination' || $psl === 'both')) {
return $this->searchProductCombinations($query, $this->idLang, $this->idShop, $limit, $offset, $filters);
}
$method = 'searchTarget' . $this->camelCase($entityType);
if (method_exists($this, $method)) {
@@ -77,6 +83,11 @@ class EntitySearchEngine
*/
public function count($entityType, $query, array $filters = [])
{
$psl = $filters['product_selection_level'] ?? 'product';
if ($entityType === 'products' && ($psl === 'combination' || $psl === 'both')) {
return $this->countProductCombinations($query, $this->idLang, $this->idShop, $filters);
}
$method = 'countTarget' . $this->camelCase($entityType);
if (method_exists($this, $method)) {
@@ -93,8 +104,13 @@ class EntitySearchEngine
* @param array $ids Entity IDs
* @return array Entities data
*/
public function getByIds($entityType, array $ids)
public function getByIds($entityType, array $ids, $productSelectionLevel = 'product')
{
// If product type with combination-level IDs (prefixed strings), use combination handler
if ($entityType === 'products' && ($productSelectionLevel === 'combination' || $productSelectionLevel === 'both')) {
return $this->getProductCombinationsByIds($ids, $this->idLang, $this->idShop);
}
$method = 'getTarget' . $this->camelCase($entityType) . 'ByIds';
if (method_exists($this, $method)) {
@@ -414,6 +430,492 @@ class EntitySearchEngine
return $ordered;
}
// =========================================================================
// PRODUCT COMBINATIONS (product_selection_level = combination)
// =========================================================================
/**
* Search products at combination level.
* Products WITH combinations return one result per combination (id = "c:{id_product_attribute}").
* Simple products (no combinations) return with id = "p:{id_product}".
*
* @param string $query Search query
* @param int $idLang Language ID
* @param int $idShop Shop ID
* @param int $limit Results limit
* @param int $offset Results offset
* @param array $filters Additional filters
* @return array Search results
*/
public function searchProductCombinations($query, $idLang, $idShop, $limit = 20, $offset = 0, array $filters = [])
{
$db = Db::getInstance();
$escapedQuery = !empty($query) ? $this->escapePattern($query) : '';
// Step 1: Find matching products (same query as searchTargetProducts but no limit — we paginate the flat result)
$sqlProducts = new DbQuery();
$sqlProducts->select('DISTINCT p.id_product, pl.name, p.reference, ps.price AS base_price, ps.active,
m.name AS manufacturer_name, cl.name AS category_name, i.id_image,
IFNULL(sa.quantity, 0) AS stock_qty');
// Sales
$sqlProducts->select('(SELECT COALESCE(SUM(od.product_quantity), 0) FROM ' . _DB_PREFIX_ . 'order_detail od
INNER JOIN ' . _DB_PREFIX_ . 'orders o ON o.id_order = od.id_order
WHERE od.product_id = p.id_product AND o.valid = 1) AS sales_qty');
$sqlProducts->from('product', 'p');
$sqlProducts->innerJoin('product_shop', 'ps', 'ps.id_product = p.id_product AND ps.id_shop = ' . (int) $idShop);
$sqlProducts->leftJoin('product_lang', 'pl', 'pl.id_product = p.id_product AND pl.id_lang = ' . (int) $idLang . ' AND pl.id_shop = ' . (int) $idShop);
$sqlProducts->leftJoin('manufacturer', 'm', 'm.id_manufacturer = p.id_manufacturer');
$sqlProducts->leftJoin('category_lang', 'cl', 'cl.id_category = p.id_category_default AND cl.id_lang = ' . (int) $idLang . ' AND cl.id_shop = ' . (int) $idShop);
$sqlProducts->leftJoin('stock_available', 'sa', 'sa.id_product = p.id_product AND sa.id_product_attribute = 0 AND sa.id_shop = ' . (int) $idShop);
$sqlProducts->leftJoin('image', 'i', 'i.id_product = p.id_product AND i.cover = 1');
if (!empty($escapedQuery)) {
$sqlProducts->where('(pl.name LIKE \'%' . $escapedQuery . '%\' OR p.reference LIKE \'%' . $escapedQuery . '%\' OR p.id_product = ' . (int) $query . ')');
}
$this->applyProductFilters($sqlProducts, $filters, $idLang, $idShop);
$sqlProducts->orderBy('pl.name ASC');
$matchingProducts = $db->executeS($sqlProducts);
if (!$matchingProducts) {
return [];
}
$productIds = array_column($matchingProducts, 'id_product');
$productMap = [];
foreach ($matchingProducts as $p) {
$productMap[(int) $p['id_product']] = $p;
}
// Step 2: Get all combinations with stock, image, attributes
$sqlCombos = 'SELECT pa.id_product_attribute, pa.id_product, pa.reference AS combo_reference,
pas.price AS combo_price_impact,
IFNULL(sa_c.quantity, 0) AS combo_stock,
pai.id_image AS combo_id_image,
GROUP_CONCAT(CONCAT(agl.name, \': \', al.name) ORDER BY ag.position ASC SEPARATOR \', \') AS attributes
FROM ' . _DB_PREFIX_ . 'product_attribute pa
INNER JOIN ' . _DB_PREFIX_ . 'product_attribute_shop pas ON pas.id_product_attribute = pa.id_product_attribute AND pas.id_shop = ' . (int) $idShop . '
INNER JOIN ' . _DB_PREFIX_ . 'product_attribute_combination pac ON pac.id_product_attribute = pa.id_product_attribute
INNER JOIN ' . _DB_PREFIX_ . 'attribute a ON a.id_attribute = pac.id_attribute
INNER JOIN ' . _DB_PREFIX_ . 'attribute_lang al ON al.id_attribute = a.id_attribute AND al.id_lang = ' . (int) $idLang . '
INNER JOIN ' . _DB_PREFIX_ . 'attribute_group ag ON ag.id_attribute_group = a.id_attribute_group
INNER JOIN ' . _DB_PREFIX_ . 'attribute_group_lang agl ON agl.id_attribute_group = a.id_attribute_group AND agl.id_lang = ' . (int) $idLang . '
LEFT JOIN ' . _DB_PREFIX_ . 'stock_available sa_c ON sa_c.id_product = pa.id_product AND sa_c.id_product_attribute = pa.id_product_attribute AND sa_c.id_shop = ' . (int) $idShop . '
LEFT JOIN ' . _DB_PREFIX_ . 'product_attribute_image pai ON pai.id_product_attribute = pa.id_product_attribute
WHERE pa.id_product IN (' . implode(',', array_map('intval', $productIds)) . ')';
// Filter combinations by selected attribute values
$comboAttrIds = $this->extractAttributeFilterIds($filters);
if (!empty($comboAttrIds)) {
$sqlCombos .= ' AND EXISTS (
SELECT 1 FROM ' . _DB_PREFIX_ . 'product_attribute_combination pac_f
WHERE pac_f.id_product_attribute = pa.id_product_attribute
AND pac_f.id_attribute IN (' . implode(',', $comboAttrIds) . ')
)';
}
$sqlCombos .= ' GROUP BY pa.id_product_attribute
ORDER BY pa.id_product, pa.id_product_attribute';
$combinations = $db->executeS($sqlCombos);
// Group combinations by product
$combosByProduct = [];
if ($combinations) {
foreach ($combinations as $combo) {
$combosByProduct[(int) $combo['id_product']][] = $combo;
}
}
// Step 3: Build flat result list with full product data
$imageType = $this->getProductImageType();
$context = Context::getContext();
$locale = $context->getCurrentLocale();
$currency = $context->currency;
$isBothMode = ($filters['product_selection_level'] ?? 'combination') === 'both';
$allResults = [];
foreach ($productIds as $idProduct) {
$idProduct = (int) $idProduct;
$product = $productMap[$idProduct];
if (!empty($combosByProduct[$idProduct])) {
// In 'both' mode, add the base product entry first
if ($isBothMode) {
$basePrice = (float) ($product['base_price'] ?? 0);
$stockQty = (int) ($product['stock_qty'] ?? 0);
$regularPriceFormatted = $locale->formatPrice($basePrice, $currency->iso_code);
$finalPrice = \Product::getPriceStatic($idProduct, true, null, 2, null, false, true, 1, false, null, null, null, $dummyNull, true, true, $context);
$finalPriceFormatted = $locale->formatPrice($finalPrice, $currency->iso_code);
$hasDiscount = ($basePrice > 0 && $finalPrice < $basePrice);
$stockStatus = $stockQty <= 0 ? 'out_of_stock' : ($stockQty <= 5 ? 'low_stock' : 'in_stock');
$allResults[] = [
'id' => 'p:' . $idProduct,
'type' => 'product',
'name' => $product['name'],
'subtitle' => $product['reference'] ? 'Ref: ' . $product['reference'] : null,
'reference' => $product['reference'],
'is_combination' => false,
'is_parent' => true,
'combination_count' => count($combosByProduct[$idProduct]),
'id_product' => $idProduct,
'active' => (bool) $product['active'],
'manufacturer' => $product['manufacturer_name'],
'category' => $product['category_name'],
'image' => $product['id_image'] ? $this->getProductImageUrl($idProduct, $product['id_image'], $imageType) : null,
'regular_price_formatted' => $regularPriceFormatted,
'price_formatted' => $finalPriceFormatted,
'has_discount' => $hasDiscount,
'stock_qty' => $stockQty,
'stock_status' => $stockStatus,
'sales_qty' => (int) ($product['sales_qty'] ?? 0),
];
}
// Product HAS combinations — one entry per combination
foreach ($combosByProduct[$idProduct] as $combo) {
$idPA = (int) $combo['id_product_attribute'];
$comboStock = (int) $combo['combo_stock'];
// Price: use Product::getPrice with combination ID for accurate price
$comboPrice = \Product::getPriceStatic($idProduct, true, $idPA, 2, null, false, true, 1, false, null, null, null, $dummyNull, true, true, $context);
$comboPriceFormatted = $locale->formatPrice($comboPrice, $currency->iso_code);
// Regular price (without reduction)
$comboRegularPrice = \Product::getPriceStatic($idProduct, true, $idPA, 2, null, false, false, 1, false, null, null, null, $dummyNull, true, true, $context);
$comboRegularPriceFormatted = $locale->formatPrice($comboRegularPrice, $currency->iso_code);
$hasDiscount = ($comboRegularPrice > $comboPrice);
// Image: combination image if exists, otherwise product cover
$imageId = !empty($combo['combo_id_image']) ? $combo['combo_id_image'] : ($product['id_image'] ?? null);
// Stock status
$stockStatus = 'in_stock';
if ($comboStock <= 0) {
$stockStatus = 'out_of_stock';
} elseif ($comboStock <= 5) {
$stockStatus = 'low_stock';
}
// Name includes attributes: "Product name, Attr1: Val1, Attr2: Val2"
$fullName = $product['name'] . ', ' . $combo['attributes'];
$ref = $combo['combo_reference'] ?: $product['reference'];
$allResults[] = [
'id' => 'c:' . $idPA,
'type' => 'product',
'name' => $fullName,
'subtitle' => $ref ? 'Ref: ' . $ref : null,
'reference' => $ref,
'attributes' => $combo['attributes'],
'is_combination' => true,
'id_product' => $idProduct,
'id_product_attribute' => $idPA,
'active' => (bool) $product['active'],
'manufacturer' => $product['manufacturer_name'],
'category' => $product['category_name'],
'image' => $imageId ? $this->getProductImageUrl($idProduct, $imageId, $imageType) : null,
'regular_price_formatted' => $comboRegularPriceFormatted,
'price_formatted' => $comboPriceFormatted,
'has_discount' => $hasDiscount,
'stock_qty' => $comboStock,
'stock_status' => $stockStatus,
'sales_qty' => (int) ($product['sales_qty'] ?? 0),
];
}
} else {
// Simple product — no combinations
// Skip simple products when attribute filter is active (they can't match)
if (!empty($comboAttrIds)) {
continue;
}
$basePrice = (float) ($product['base_price'] ?? 0);
$stockQty = (int) ($product['stock_qty'] ?? 0);
$salesQty = (int) ($product['sales_qty'] ?? 0);
$regularPriceFormatted = $locale->formatPrice($basePrice, $currency->iso_code);
$finalPrice = \Product::getPriceStatic($idProduct, true, null, 2, null, false, true, 1, false, null, null, null, $dummyNull, true, true, $context);
$finalPriceFormatted = $locale->formatPrice($finalPrice, $currency->iso_code);
$hasDiscount = ($basePrice > 0 && $finalPrice < $basePrice);
$stockStatus = 'in_stock';
if ($stockQty <= 0) {
$stockStatus = 'out_of_stock';
} elseif ($stockQty <= 5) {
$stockStatus = 'low_stock';
}
$allResults[] = [
'id' => 'p:' . $idProduct,
'type' => 'product',
'name' => $product['name'],
'subtitle' => $product['reference'] ? 'Ref: ' . $product['reference'] : null,
'reference' => $product['reference'],
'is_combination' => false,
'id_product' => $idProduct,
'active' => (bool) $product['active'],
'manufacturer' => $product['manufacturer_name'],
'category' => $product['category_name'],
'image' => $product['id_image'] ? $this->getProductImageUrl($idProduct, $product['id_image'], $imageType) : null,
'regular_price_formatted' => $regularPriceFormatted,
'price_formatted' => $finalPriceFormatted,
'has_discount' => $hasDiscount,
'stock_qty' => $stockQty,
'stock_status' => $stockStatus,
'sales_qty' => $salesQty,
];
}
}
// Apply offset/limit to the flat list
return array_slice($allResults, (int) $offset, (int) $limit);
}
/**
* Count products at combination level.
* Each combination counts as 1. Simple products count as 1.
*
* @param string $query Search query
* @param int $idLang Language ID
* @param int $idShop Shop ID
* @param array $filters Additional filters
* @return int Total count
*/
public function countProductCombinations($query, $idLang, $idShop, array $filters = [])
{
$db = Db::getInstance();
$escapedQuery = !empty($query) ? $this->escapePattern($query) : '';
// Get matching product IDs
$sqlProducts = new DbQuery();
$sqlProducts->select('DISTINCT p.id_product');
$sqlProducts->from('product', 'p');
$sqlProducts->innerJoin('product_shop', 'ps', 'ps.id_product = p.id_product AND ps.id_shop = ' . (int) $idShop);
$sqlProducts->leftJoin('product_lang', 'pl', 'pl.id_product = p.id_product AND pl.id_lang = ' . (int) $idLang . ' AND pl.id_shop = ' . (int) $idShop);
if (!empty($escapedQuery)) {
$sqlProducts->where('(pl.name LIKE \'%' . $escapedQuery . '%\' OR p.reference LIKE \'%' . $escapedQuery . '%\' OR p.id_product = ' . (int) $query . ')');
}
$this->applyProductFilters($sqlProducts, $filters, $idLang, $idShop);
$matchingProducts = $db->executeS($sqlProducts);
if (!$matchingProducts) {
return 0;
}
$productIds = array_column($matchingProducts, 'id_product');
// Count combinations for these products
$sqlComboCount = 'SELECT COUNT(*) FROM ' . _DB_PREFIX_ . 'product_attribute pa
INNER JOIN ' . _DB_PREFIX_ . 'product_attribute_shop pas ON pas.id_product_attribute = pa.id_product_attribute AND pas.id_shop = ' . (int) $idShop . '
WHERE pa.id_product IN (' . implode(',', array_map('intval', $productIds)) . ')';
// Filter combination count by selected attribute values
$comboAttrIds = $this->extractAttributeFilterIds($filters);
if (!empty($comboAttrIds)) {
$sqlComboCount .= ' AND EXISTS (
SELECT 1 FROM ' . _DB_PREFIX_ . 'product_attribute_combination pac_f
WHERE pac_f.id_product_attribute = pa.id_product_attribute
AND pac_f.id_attribute IN (' . implode(',', $comboAttrIds) . ')
)';
}
$comboCount = (int) $db->getValue($sqlComboCount);
// Count simple products (those with no combinations)
// Simple products only included when no attribute filter is active
// (simple products have no combinations, so they can't match attribute filters)
$simpleCount = 0;
if (empty($comboAttrIds)) {
$sqlSimpleCount = 'SELECT COUNT(*) FROM (' . $sqlProducts->build() . ') AS matched_products
WHERE matched_products.id_product NOT IN (
SELECT DISTINCT pa2.id_product FROM ' . _DB_PREFIX_ . 'product_attribute pa2
INNER JOIN ' . _DB_PREFIX_ . 'product_attribute_shop pas2 ON pas2.id_product_attribute = pa2.id_product_attribute AND pas2.id_shop = ' . (int) $idShop . '
)';
$simpleCount = (int) $db->getValue($sqlSimpleCount);
}
// In 'both' mode, also count parent products (products that have combinations)
$isBothMode = ($filters['product_selection_level'] ?? 'combination') === 'both';
$parentCount = 0;
if ($isBothMode) {
$sqlParentCount = 'SELECT COUNT(DISTINCT pa3.id_product) FROM ' . _DB_PREFIX_ . 'product_attribute pa3
INNER JOIN ' . _DB_PREFIX_ . 'product_attribute_shop pas3 ON pas3.id_product_attribute = pa3.id_product_attribute AND pas3.id_shop = ' . (int) $idShop . '
WHERE pa3.id_product IN (' . implode(',', array_map('intval', $productIds)) . ')';
$parentCount = (int) $db->getValue($sqlParentCount);
}
return $comboCount + $simpleCount + $parentCount;
}
/**
* Get product combinations by prefixed IDs.
* Handles "c:{id_product_attribute}" and "p:{id_product}" formats.
*
* @param array $ids Prefixed string IDs
* @param int $idLang Language ID
* @param int $idShop Shop ID
* @return array Entities data
*/
public function getProductCombinationsByIds(array $ids, $idLang, $idShop = null)
{
if (empty($ids)) {
return [];
}
$idShop = $idShop ?: $this->idShop;
$db = Db::getInstance();
// Parse prefixed IDs
$comboIds = [];
$productIds = [];
foreach ($ids as $prefixedId) {
$prefixedId = (string) $prefixedId;
if (strpos($prefixedId, 'c:') === 0) {
$comboIds[] = (int) substr($prefixedId, 2);
} elseif (strpos($prefixedId, 'p:') === 0) {
$productIds[] = (int) substr($prefixedId, 2);
} else {
// Fallback: treat as product ID
$productIds[] = (int) $prefixedId;
}
}
$imageType = $this->getProductImageType();
$context = Context::getContext();
$locale = $context->getCurrentLocale();
$currency = $context->currency;
$results = [];
// Fetch combinations with full data
if (!empty($comboIds)) {
$sqlCombos = 'SELECT pa.id_product_attribute, pa.id_product, pa.reference AS combo_reference,
pl.name, p.reference AS product_reference, i.id_image,
IFNULL(sa_c.quantity, 0) AS combo_stock,
pai.id_image AS combo_id_image,
GROUP_CONCAT(CONCAT(agl.name, \': \', al.name) ORDER BY ag.position ASC SEPARATOR \', \') AS attributes
FROM ' . _DB_PREFIX_ . 'product_attribute pa
INNER JOIN ' . _DB_PREFIX_ . 'product p ON p.id_product = pa.id_product
INNER JOIN ' . _DB_PREFIX_ . 'product_attribute_shop pas ON pas.id_product_attribute = pa.id_product_attribute AND pas.id_shop = ' . (int) $idShop . '
INNER JOIN ' . _DB_PREFIX_ . 'product_lang pl ON pl.id_product = p.id_product AND pl.id_lang = ' . (int) $idLang . ' AND pl.id_shop = ' . (int) $idShop . '
INNER JOIN ' . _DB_PREFIX_ . 'product_attribute_combination pac ON pac.id_product_attribute = pa.id_product_attribute
INNER JOIN ' . _DB_PREFIX_ . 'attribute a ON a.id_attribute = pac.id_attribute
INNER JOIN ' . _DB_PREFIX_ . 'attribute_lang al ON al.id_attribute = a.id_attribute AND al.id_lang = ' . (int) $idLang . '
INNER JOIN ' . _DB_PREFIX_ . 'attribute_group ag ON ag.id_attribute_group = a.id_attribute_group
INNER JOIN ' . _DB_PREFIX_ . 'attribute_group_lang agl ON agl.id_attribute_group = a.id_attribute_group AND agl.id_lang = ' . (int) $idLang . '
LEFT JOIN ' . _DB_PREFIX_ . 'stock_available sa_c ON sa_c.id_product = pa.id_product AND sa_c.id_product_attribute = pa.id_product_attribute AND sa_c.id_shop = ' . (int) $idShop . '
LEFT JOIN ' . _DB_PREFIX_ . 'product_attribute_image pai ON pai.id_product_attribute = pa.id_product_attribute
LEFT JOIN ' . _DB_PREFIX_ . 'image i ON i.id_product = p.id_product AND i.cover = 1
WHERE pa.id_product_attribute IN (' . implode(',', array_map('intval', $comboIds)) . ')
GROUP BY pa.id_product_attribute';
$comboRows = $db->executeS($sqlCombos);
if ($comboRows) {
foreach ($comboRows as $row) {
$idPA = (int) $row['id_product_attribute'];
$idProduct = (int) $row['id_product'];
$key = 'c:' . $idPA;
// Price
$comboPrice = \Product::getPriceStatic($idProduct, true, $idPA, 2, null, false, true, 1, false, null, null, null, $dummyNull, true, true, $context);
$comboRegularPrice = \Product::getPriceStatic($idProduct, true, $idPA, 2, null, false, false, 1, false, null, null, null, $dummyNull, true, true, $context);
$hasDiscount = ($comboRegularPrice > $comboPrice);
// Image: combination image if exists, otherwise product cover
$imageId = !empty($row['combo_id_image']) ? $row['combo_id_image'] : ($row['id_image'] ?? null);
// Stock
$comboStock = (int) $row['combo_stock'];
$stockStatus = $comboStock <= 0 ? 'out_of_stock' : ($comboStock <= 5 ? 'low_stock' : 'in_stock');
$ref = $row['combo_reference'] ?: $row['product_reference'];
$results[$key] = [
'id' => $key,
'type' => 'product',
'name' => $row['name'] . ', ' . $row['attributes'],
'subtitle' => $ref ? 'Ref: ' . $ref : null,
'reference' => $ref,
'attributes' => $row['attributes'],
'is_combination' => true,
'id_product' => $idProduct,
'id_product_attribute' => $idPA,
'image' => $imageId ? $this->getProductImageUrl($idProduct, $imageId, $imageType) : null,
'regular_price_formatted' => $locale->formatPrice($comboRegularPrice, $currency->iso_code),
'price_formatted' => $locale->formatPrice($comboPrice, $currency->iso_code),
'has_discount' => $hasDiscount,
'stock_qty' => $comboStock,
'stock_status' => $stockStatus,
];
}
}
}
// Fetch simple products with full data
if (!empty($productIds)) {
$sqlProducts = new DbQuery();
$sqlProducts->select('p.id_product, pl.name, p.reference, ps.price AS base_price,
IFNULL(sa.quantity, 0) AS stock_qty, i.id_image');
$sqlProducts->from('product', 'p');
$sqlProducts->innerJoin('product_shop', 'ps', 'ps.id_product = p.id_product AND ps.id_shop = ' . (int) $idShop);
$sqlProducts->leftJoin('product_lang', 'pl', 'pl.id_product = p.id_product AND pl.id_lang = ' . (int) $idLang . ' AND pl.id_shop = ' . (int) $idShop);
$sqlProducts->leftJoin('stock_available', 'sa', 'sa.id_product = p.id_product AND sa.id_product_attribute = 0 AND sa.id_shop = ' . (int) $idShop);
$sqlProducts->leftJoin('image', 'i', 'i.id_product = p.id_product AND i.cover = 1');
$sqlProducts->where('p.id_product IN (' . implode(',', array_map('intval', $productIds)) . ')');
$productRows = $db->executeS($sqlProducts);
if ($productRows) {
foreach ($productRows as $row) {
$idProduct = (int) $row['id_product'];
$key = 'p:' . $idProduct;
$stockQty = (int) $row['stock_qty'];
$finalPrice = \Product::getPriceStatic($idProduct, true, null, 2, null, false, true, 1, false, null, null, null, $dummyNull, true, true, $context);
$regularPrice = (float) $row['base_price'];
$hasDiscount = ($regularPrice > 0 && $finalPrice < $regularPrice);
$stockStatus = $stockQty <= 0 ? 'out_of_stock' : ($stockQty <= 5 ? 'low_stock' : 'in_stock');
$results[$key] = [
'id' => $key,
'type' => 'product',
'name' => $row['name'],
'subtitle' => $row['reference'] ? 'Ref: ' . $row['reference'] : null,
'reference' => $row['reference'],
'is_combination' => false,
'id_product' => $idProduct,
'image' => $row['id_image'] ? $this->getProductImageUrl($idProduct, $row['id_image'], $imageType) : null,
'regular_price_formatted' => $locale->formatPrice($regularPrice, $currency->iso_code),
'price_formatted' => $locale->formatPrice($finalPrice, $currency->iso_code),
'has_discount' => $hasDiscount,
'stock_qty' => $stockQty,
'stock_status' => $stockStatus,
];
}
}
}
// Return in original order
$ordered = [];
foreach ($ids as $originalId) {
$key = (string) $originalId;
if (isset($results[$key])) {
$ordered[] = $results[$key];
} elseif (isset($results['p:' . $key])) {
// Plain integer ID → stored as "p:{id}" (simple product fallback)
$ordered[] = $results['p:' . $key];
} elseif (isset($results['c:' . $key])) {
$ordered[] = $results['c:' . $key];
}
}
return $ordered;
}
/**
* Apply product-specific filters to query
*
@@ -451,23 +953,40 @@ class EntitySearchEngine
$sql->where('ps.active = 1');
}
// Attribute filter
// Attribute filter — supports flat array [id1, id2] or grouped [{id_attribute_group, values}]
if (!empty($filters['attributes'])) {
foreach ($filters['attributes'] as $attrFilter) {
if (!empty($attrFilter['values'])) {
$attrIds = array_map('intval', $attrFilter['values']);
$sql->innerJoin('product_attribute_combination', 'pac_' . (int) $attrFilter['id_attribute_group'], 'pac_' . (int) $attrFilter['id_attribute_group'] . '.id_attribute IN (' . implode(',', $attrIds) . ')');
$sql->innerJoin('product_attribute', 'pa_' . (int) $attrFilter['id_attribute_group'], 'pa_' . (int) $attrFilter['id_attribute_group'] . '.id_product_attribute = pac_' . (int) $attrFilter['id_attribute_group'] . '.id_product_attribute AND pa_' . (int) $attrFilter['id_attribute_group'] . '.id_product = p.id_product');
$first = reset($filters['attributes']);
if (is_numeric($first)) {
// Flat array of attribute value IDs (from filter chips)
$attrIds = array_map('intval', $filters['attributes']);
$sql->innerJoin('product_attribute_combination', 'pac_filter', 'pac_filter.id_attribute IN (' . implode(',', $attrIds) . ')');
$sql->innerJoin('product_attribute', 'pa_filter', 'pa_filter.id_product_attribute = pac_filter.id_product_attribute AND pa_filter.id_product = p.id_product');
} else {
// Grouped format: [{id_attribute_group: X, values: [1,2,3]}, ...]
foreach ($filters['attributes'] as $attrFilter) {
if (!empty($attrFilter['values'])) {
$attrIds = array_map('intval', $attrFilter['values']);
$sql->innerJoin('product_attribute_combination', 'pac_' . (int) $attrFilter['id_attribute_group'], 'pac_' . (int) $attrFilter['id_attribute_group'] . '.id_attribute IN (' . implode(',', $attrIds) . ')');
$sql->innerJoin('product_attribute', 'pa_' . (int) $attrFilter['id_attribute_group'], 'pa_' . (int) $attrFilter['id_attribute_group'] . '.id_product_attribute = pac_' . (int) $attrFilter['id_attribute_group'] . '.id_product_attribute AND pa_' . (int) $attrFilter['id_attribute_group'] . '.id_product = p.id_product');
}
}
}
}
// Feature filter
// Feature filter — supports flat array [id1, id2] or grouped [{id_feature, values}]
if (!empty($filters['features'])) {
foreach ($filters['features'] as $featFilter) {
if (!empty($featFilter['values'])) {
$featIds = array_map('intval', $featFilter['values']);
$sql->innerJoin('feature_product', 'fp_' . (int) $featFilter['id_feature'], 'fp_' . (int) $featFilter['id_feature'] . '.id_product = p.id_product AND fp_' . (int) $featFilter['id_feature'] . '.id_feature_value IN (' . implode(',', $featIds) . ')');
$first = reset($filters['features']);
if (is_numeric($first)) {
// Flat array of feature value IDs (from filter chips)
$featIds = array_map('intval', $filters['features']);
$sql->innerJoin('feature_product', 'fp_filter', 'fp_filter.id_product = p.id_product AND fp_filter.id_feature_value IN (' . implode(',', $featIds) . ')');
} else {
// Grouped format: [{id_feature: X, values: [1,2,3]}, ...]
foreach ($filters['features'] as $featFilter) {
if (!empty($featFilter['values'])) {
$featIds = array_map('intval', $featFilter['values']);
$sql->innerJoin('feature_product', 'fp_' . (int) $featFilter['id_feature'], 'fp_' . (int) $featFilter['id_feature'] . '.id_product = p.id_product AND fp_' . (int) $featFilter['id_feature'] . '.id_feature_value IN (' . implode(',', $featIds) . ')');
}
}
}
}
@@ -481,6 +1000,33 @@ class EntitySearchEngine
}
}
/**
* Extract flat array of attribute value IDs from filters.
* Handles both flat [id1, id2] and grouped [{id_attribute_group, values}] formats.
*
* @param array $filters
* @return array Integer attribute IDs
*/
protected function extractAttributeFilterIds(array $filters)
{
if (empty($filters['attributes'])) {
return [];
}
$first = reset($filters['attributes']);
if (is_numeric($first)) {
return array_map('intval', $filters['attributes']);
}
$ids = [];
foreach ($filters['attributes'] as $attrFilter) {
if (!empty($attrFilter['values'])) {
$ids = array_merge($ids, array_map('intval', $attrFilter['values']));
}
}
return $ids;
}
/**
* Get product image type for thumbnails
*
@@ -3469,6 +4015,130 @@ class EntitySearchEngine
return $ordered;
}
// =========================================================================
// MPR MATERIALS (module-specific entity type)
// =========================================================================
/**
* Search materials
*/
public function searchTargetMprMaterials($query, $idLang, $idShop, $limit = 20, $offset = 0, array $filters = [])
{
$db = Db::getInstance();
$escapedQuery = $this->escapePattern($query);
$sql = new DbQuery();
$sql->select('m.id_material, m.reference, m.unit, m.unit_cost, m.active,
ml.name, ml.description,
IFNULL(SUM(ms.quantity), 0) AS stock_qty');
$sql->from('mprwarehouserevolution_material', 'm');
$sql->leftJoin('mprwarehouserevolution_material_lang', 'ml', 'ml.id_material = m.id_material AND ml.id_lang = ' . (int) $idLang);
$sql->leftJoin('mprwarehouserevolution_material_stock', 'ms', 'ms.id_material = m.id_material');
if ($query !== '') {
$sql->where('(ml.name LIKE \'%' . $escapedQuery . '%\' OR m.reference LIKE \'%' . $escapedQuery . '%\' OR m.barcode LIKE \'%' . $escapedQuery . '%\')');
}
$sql->groupBy('m.id_material');
$sql->orderBy('ml.name ASC');
$sql->limit($limit, $offset);
$rows = $db->executeS($sql);
if (!$rows) {
return [];
}
$results = [];
foreach ($rows as $row) {
$stockQty = (int) $row['stock_qty'];
$stockStatus = $stockQty <= 0 ? 'out_of_stock' : ($stockQty <= (int) ($row['min_stock'] ?? 5) ? 'low_stock' : 'in_stock');
$results[] = [
'id' => (int) $row['id_material'],
'type' => 'mpr_material',
'name' => $row['name'],
'subtitle' => $row['reference'] ? 'Ref: ' . $row['reference'] : null,
'reference' => $row['reference'],
'stock_qty' => $stockQty,
'stock_status' => $stockStatus,
'active' => (bool) $row['active'],
];
}
return $results;
}
/**
* Count materials matching query
*/
public function countTargetMprMaterials($query, $idLang, $idShop, array $filters = [])
{
$escapedQuery = $this->escapePattern($query);
$sql = new DbQuery();
$sql->select('COUNT(DISTINCT m.id_material)');
$sql->from('mprwarehouserevolution_material', 'm');
$sql->leftJoin('mprwarehouserevolution_material_lang', 'ml', 'ml.id_material = m.id_material AND ml.id_lang = ' . (int) $idLang);
if ($query !== '') {
$sql->where('(ml.name LIKE \'%' . $escapedQuery . '%\' OR m.reference LIKE \'%' . $escapedQuery . '%\' OR m.barcode LIKE \'%' . $escapedQuery . '%\')');
}
return (int) Db::getInstance()->getValue($sql);
}
/**
* Get materials by IDs
*/
public function getTargetMprMaterialsByIds(array $ids, $idLang, $idShop = null)
{
if (empty($ids)) {
return [];
}
$db = Db::getInstance();
$sql = new DbQuery();
$sql->select('m.id_material, m.reference, m.unit, m.unit_cost, m.active,
ml.name, ml.description,
IFNULL(SUM(ms.quantity), 0) AS stock_qty');
$sql->from('mprwarehouserevolution_material', 'm');
$sql->leftJoin('mprwarehouserevolution_material_lang', 'ml', 'ml.id_material = m.id_material AND ml.id_lang = ' . (int) ($idLang ?: $this->idLang));
$sql->leftJoin('mprwarehouserevolution_material_stock', 'ms', 'ms.id_material = m.id_material');
$sql->where('m.id_material IN (' . implode(',', array_map('intval', $ids)) . ')');
$sql->groupBy('m.id_material');
$rows = $db->executeS($sql);
if (!$rows) {
return [];
}
$materials = [];
foreach ($rows as $row) {
$stockQty = (int) $row['stock_qty'];
$materials[(int) $row['id_material']] = [
'id' => (int) $row['id_material'],
'type' => 'mpr_material',
'name' => $row['name'],
'subtitle' => $row['reference'] ? 'Ref: ' . $row['reference'] : null,
'reference' => $row['reference'],
'stock_qty' => $stockQty,
'stock_status' => $stockQty <= 0 ? 'out_of_stock' : 'in_stock',
'active' => (bool) $row['active'],
];
}
// Return in original order
$ordered = [];
foreach ($ids as $id) {
if (isset($materials[(int) $id])) {
$ordered[] = $materials[(int) $id];
}
}
return $ordered;
}
// =========================================================================
// FILTERABLE DATA (for UI dropdowns)
// =========================================================================

View File

@@ -121,6 +121,7 @@ class EntitySelectorRenderer
'show_cms' => true,
'show_cms_categories' => true,
'combination_mode' => 'products',
'product_selection_level' => 'product',
'mode' => 'multi',
'blocks' => [],
'customBlocks' => [],
@@ -139,6 +140,9 @@ class EntitySelectorRenderer
if (is_string($savedData)) {
$savedData = json_decode($savedData, true) ?: [];
}
if (!is_array($savedData)) {
$savedData = [];
}
// Determine which block is active
$enabledBlocks = [];
@@ -1542,6 +1546,7 @@ class EntitySelectorRenderer
],
'methodHelp' => $this->getAllMethodHelpContent(),
'combinationMode' => $config['combination_mode'] ?? 'products',
'productSelectionLevel' => $config['product_selection_level'] ?? 'product',
'emptyMeansAll' => $config['empty_means_all'] ?? true,
];
}

View File

@@ -176,7 +176,18 @@ class ProductConditionResolver
return $this->getAllActiveProductIds();
case 'specific':
return is_array($values) ? array_map('intval', $values) : [];
if (!is_array($values)) {
return [];
}
// Support prefixed string IDs (c:32, p:17) — return as-is
$hasPrefixed = false;
foreach ($values as $v) {
if (is_string($v) && (strpos($v, 'c:') === 0 || strpos($v, 'p:') === 0)) {
$hasPrefixed = true;
break;
}
}
return $hasPrefixed ? array_map('strval', $values) : array_map('intval', $values);
case 'by_category':
return $this->getProductIdsByCategories($values);
@@ -2049,4 +2060,62 @@ class ProductConditionResolver
'combinations' => $allCombinations,
];
}
/**
* Expand product IDs to combination-level prefixed IDs.
* Products WITH combinations → "c:{id_product_attribute}" per combination.
* Products WITHOUT combinations (simple) → "p:{id_product}".
*
* @param array $productIds Plain integer product IDs
* @param string $mode 'combination' (only combos + simple) or 'both' (parent + combos + simple)
* @return array Prefixed string IDs
*/
public function expandToCombinations(array $productIds, $mode = 'combination')
{
if (empty($productIds)) {
return [];
}
$db = Db::getInstance();
$idShop = $this->idShop;
// Get all combinations for these products
$sql = 'SELECT pa.id_product_attribute, pa.id_product
FROM ' . _DB_PREFIX_ . 'product_attribute pa
INNER JOIN ' . _DB_PREFIX_ . 'product_attribute_shop pas
ON pas.id_product_attribute = pa.id_product_attribute AND pas.id_shop = ' . (int) $idShop . '
WHERE pa.id_product IN (' . implode(',', array_map('intval', $productIds)) . ')
ORDER BY pa.id_product, pa.id_product_attribute';
$rows = $db->executeS($sql);
// Group combinations by product
$combosByProduct = [];
if ($rows) {
foreach ($rows as $row) {
$combosByProduct[(int) $row['id_product']][] = (int) $row['id_product_attribute'];
}
}
$result = [];
foreach ($productIds as $idProduct) {
$idProduct = (int) $idProduct;
if (!empty($combosByProduct[$idProduct])) {
// Product has combinations
if ($mode === 'both') {
// Include parent product entry
$result[] = 'p:' . $idProduct;
}
foreach ($combosByProduct[$idProduct] as $idPA) {
$result[] = 'c:' . $idPA;
}
} else {
// Simple product — no combinations
$result[] = 'p:' . $idProduct;
}
}
return $result;
}
}