# 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
