Add hierarchical tree view for category selection
Features: - Tree view mode for categories with expand/collapse - Product count badges with clickable preview popover - Select parent with all children button - Client-side tree filtering (refine search) - Keyboard shortcuts: Ctrl+A (select all), Ctrl+D (clear) - View mode switching between tree/list/columns - Tree view as default for categories, respects user preference Backend: - Add previewCategoryProducts and previewCategoryPages AJAX handlers - Support pagination and filtering in category previews Styling: - Consistent count-badge styling across tree and other views - Loading and popover-open states for count badges Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
937
README.md
937
README.md
@@ -1,574 +1,449 @@
|
||||
# PrestaShop Condition Traits
|
||||
# PrestaShop Entity Selector
|
||||
|
||||
A collection of reusable PHP traits for PrestaShop admin controllers that provide powerful UI widgets for targeting and scheduling.
|
||||
|
||||
## Available Traits
|
||||
|
||||
| Trait | Purpose |
|
||||
|-------|---------|
|
||||
| **TargetConditions** | Multi-criteria targeting (countries, currencies, carriers, groups, languages) |
|
||||
| **ScheduleConditions** | Time-based scheduling (datetime range, weekly schedule, holiday exclusions) |
|
||||
|
||||
## Installation
|
||||
|
||||
### Via Composer
|
||||
|
||||
```bash
|
||||
composer require myprestarocks/prestashop-target-conditions
|
||||
```
|
||||
|
||||
### Manual Installation
|
||||
|
||||
Copy the package to your module's vendor directory:
|
||||
```
|
||||
modules/your_module/vendor/myprestarocks/prestashop-target-conditions/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# TargetConditions Trait
|
||||
|
||||
A powerful multi-criteria targeting widget. Allows users to define conditions based on countries, currencies, carriers, customer groups, and languages.
|
||||
|
||||
## Basic Usage
|
||||
|
||||
### 1. Include the Trait in Your Admin Controller
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
use MyPrestaRocks\TargetConditions\TargetConditions;
|
||||
|
||||
class AdminYourModuleController extends ModuleAdminController
|
||||
{
|
||||
use TargetConditions;
|
||||
|
||||
// Your controller code...
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Register Assets in setMedia()
|
||||
|
||||
```php
|
||||
public function setMedia($isNewTheme = false)
|
||||
{
|
||||
parent::setMedia($isNewTheme);
|
||||
|
||||
// Register the target conditions assets
|
||||
$this->registerTargetConditionsAssets();
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Add the Widget to Your Form
|
||||
|
||||
In your form rendering method (e.g., `renderForm()`), add the widget:
|
||||
|
||||
```php
|
||||
public function renderForm()
|
||||
{
|
||||
// Get existing restrictions from database (JSON string or array)
|
||||
$restrictions = $this->object->restrictions ?? '[]';
|
||||
|
||||
$this->fields_form = [
|
||||
'legend' => [
|
||||
'title' => $this->l('Configuration'),
|
||||
],
|
||||
'input' => [
|
||||
[
|
||||
'type' => 'free',
|
||||
'label' => $this->l('Target Conditions'),
|
||||
'name' => 'restrictions_widget',
|
||||
'desc' => $this->l('Define where this item should be available'),
|
||||
],
|
||||
],
|
||||
// ... other fields
|
||||
];
|
||||
|
||||
// Render the widget HTML
|
||||
$this->fields_value['restrictions_widget'] = $this->renderTargetConditionsHtml(
|
||||
'restrictions', // Field name for form submission
|
||||
$restrictions, // Current value (JSON or array)
|
||||
[
|
||||
// Configuration options (see below)
|
||||
]
|
||||
);
|
||||
|
||||
return parent::renderForm();
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Process the Submitted Data
|
||||
|
||||
```php
|
||||
public function postProcess()
|
||||
{
|
||||
if (Tools::isSubmit('submitYourForm')) {
|
||||
$restrictions = Tools::getValue('restrictions');
|
||||
|
||||
// The value is already JSON-encoded from the widget
|
||||
// Store it in your database
|
||||
$this->object->restrictions = $restrictions;
|
||||
$this->object->save();
|
||||
}
|
||||
|
||||
return parent::postProcess();
|
||||
}
|
||||
```
|
||||
|
||||
## Configuration Options
|
||||
|
||||
The `renderTargetConditionsHtml()` method accepts a configuration array as the third parameter:
|
||||
|
||||
```php
|
||||
$this->renderTargetConditionsHtml('field_name', $value, [
|
||||
// Available condition groups to show
|
||||
// Default: all groups enabled
|
||||
'groups' => [
|
||||
'countries' => true, // Country selection
|
||||
'currencies' => true, // Currency selection
|
||||
'carriers' => true, // Carrier/shipping method selection
|
||||
'groups' => true, // Customer group selection
|
||||
'languages' => true, // Language selection
|
||||
],
|
||||
|
||||
// Empty selection behavior
|
||||
// true = empty means "all included" (available everywhere)
|
||||
// false = empty means "none selected" (not available anywhere)
|
||||
'empty_means_all' => true,
|
||||
|
||||
// Show "All X" toggle badges
|
||||
// When set, displays toggle buttons showing selection state
|
||||
'show_all_toggle' => [
|
||||
'empty' => $this->l('Available Everywhere'), // Label when nothing selected
|
||||
'selected' => $this->l('Restricted'), // Label when items selected
|
||||
],
|
||||
|
||||
// Show group modifiers (Limit/Sort controls)
|
||||
// true = show limit dropdown and sort controls per group
|
||||
// false = hide modifiers (cleaner UI for targeting contexts)
|
||||
'show_modifiers' => true,
|
||||
|
||||
// Pre-filter available options based on external restrictions
|
||||
// Useful when parent entity has its own restrictions
|
||||
'restrictions' => [
|
||||
'countries' => ['FR', 'DE', 'PL'], // Only show these countries
|
||||
'currencies' => ['EUR', 'PLN'], // Only show these currencies
|
||||
// ... etc
|
||||
],
|
||||
|
||||
// Custom labels for condition groups
|
||||
'labels' => [
|
||||
'countries' => $this->l('Countries'),
|
||||
'currencies' => $this->l('Currencies'),
|
||||
'carriers' => $this->l('Carriers'),
|
||||
'groups' => $this->l('Customer Groups'),
|
||||
'languages' => $this->l('Languages'),
|
||||
],
|
||||
]);
|
||||
```
|
||||
|
||||
## Use Case Examples
|
||||
|
||||
### Payment Method Targeting
|
||||
|
||||
Restrict payment methods to specific countries/currencies:
|
||||
|
||||
```php
|
||||
$this->renderTargetConditionsHtml('restrictions', $paymentOption->restrictions, [
|
||||
'groups' => [
|
||||
'countries' => true,
|
||||
'currencies' => true,
|
||||
'carriers' => false, // Not relevant for payments
|
||||
'groups' => true,
|
||||
'languages' => false,
|
||||
],
|
||||
'empty_means_all' => true,
|
||||
'show_modifiers' => false, // No need for limit/sort on targeting
|
||||
'show_all_toggle' => [
|
||||
'empty' => $this->l('Available Everywhere'),
|
||||
'selected' => $this->l('Restricted'),
|
||||
],
|
||||
]);
|
||||
```
|
||||
|
||||
### Product Selection Widget
|
||||
|
||||
Select products with quantity limits:
|
||||
|
||||
```php
|
||||
$this->renderTargetConditionsHtml('selected_products', $rule->products, [
|
||||
'groups' => [
|
||||
'products' => true,
|
||||
],
|
||||
'empty_means_all' => false, // Must explicitly select products
|
||||
'show_modifiers' => true, // Allow limiting quantity per selection
|
||||
]);
|
||||
```
|
||||
|
||||
### Shipping Rules
|
||||
|
||||
Define carrier availability by zone:
|
||||
|
||||
```php
|
||||
$this->renderTargetConditionsHtml('availability', $carrier->zones, [
|
||||
'groups' => [
|
||||
'countries' => true,
|
||||
'groups' => true, // Different rates for customer groups
|
||||
],
|
||||
'empty_means_all' => true,
|
||||
'show_all_toggle' => [
|
||||
'empty' => $this->l('Ships Everywhere'),
|
||||
'selected' => $this->l('Limited Zones'),
|
||||
],
|
||||
]);
|
||||
```
|
||||
|
||||
## Data Format
|
||||
|
||||
The widget stores data as a JSON object with the following structure:
|
||||
|
||||
```json
|
||||
{
|
||||
"countries": {
|
||||
"items": ["FR", "DE", "PL"],
|
||||
"limit": 0,
|
||||
"sort": "default"
|
||||
},
|
||||
"currencies": {
|
||||
"items": ["EUR", "PLN"],
|
||||
"limit": 0,
|
||||
"sort": "default"
|
||||
},
|
||||
"carriers": {
|
||||
"items": [],
|
||||
"limit": 0,
|
||||
"sort": "default"
|
||||
},
|
||||
"groups": {
|
||||
"items": [1, 2, 3],
|
||||
"limit": 0,
|
||||
"sort": "default"
|
||||
},
|
||||
"languages": {
|
||||
"items": [],
|
||||
"limit": 0,
|
||||
"sort": "default"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Field Descriptions
|
||||
|
||||
- **items**: Array of selected IDs or ISO codes
|
||||
- **limit**: Maximum items to use (0 = no limit)
|
||||
- **sort**: Sort order for items ("default", "asc", "desc", "random")
|
||||
|
||||
## Selection Logic
|
||||
|
||||
### When `empty_means_all` is `true` (default):
|
||||
- Empty selection = item available for ALL values in that group
|
||||
- Selection made = item restricted to ONLY selected values
|
||||
|
||||
### When `empty_means_all` is `false`:
|
||||
- Empty selection = item NOT available (nothing selected)
|
||||
- Selection made = item available for selected values only
|
||||
|
||||
### Restriction Inheritance
|
||||
|
||||
When using the `restrictions` config option, the widget will only display options that match the parent restrictions. This is useful for hierarchical configurations where child items cannot exceed parent permissions.
|
||||
|
||||
Example: A payment method restricted to France and Germany cannot have a sub-option available in Poland.
|
||||
|
||||
---
|
||||
|
||||
# ScheduleConditions Trait
|
||||
|
||||
A time-based scheduling widget with visual timeline controls. Allows users to define when something should be active using datetime ranges, weekly schedules, and public holiday exclusions.
|
||||
A reusable entity selection component for PrestaShop admin interfaces. Provides a flexible, searchable UI for targeting entities with multiple selection methods, grouping, include/exclude logic, and preview functionality.
|
||||
|
||||
## Features
|
||||
|
||||
- **Datetime Range**: Specific start/end period with datetime pickers
|
||||
- **Weekly Schedule**: Per-day time ranges with visual timeline sliders
|
||||
- **Public Holiday Exclusions**: Optional integration with holiday providers
|
||||
- **18 Entity Types** - Products, categories, customers, carriers, and more
|
||||
- **130+ Selection Methods** - Filter by properties, patterns, ranges, related entities
|
||||
- **Include/Exclude Logic** - Complex targeting with exceptions
|
||||
- **Grouping** - Multiple selection groups with AND/OR logic
|
||||
- **Live Preview** - See matching items in real-time
|
||||
- **Modifiers** - Limit and sort results per group
|
||||
|
||||
## Basic Usage
|
||||
---
|
||||
|
||||
### 1. Include the Trait
|
||||
## Supported Entities
|
||||
|
||||
| Entity | Icon | Methods | Description |
|
||||
|--------|------|---------|-------------|
|
||||
| Products | `icon-cube` | 30+ | Full product targeting with attributes, features, conditions |
|
||||
| Categories | `icon-folder-open` | 6 | Product categories |
|
||||
| Manufacturers | `icon-building` | 5 | Brand/manufacturer targeting |
|
||||
| Suppliers | `icon-truck` | 5 | Supplier targeting |
|
||||
| CMS Pages | `icon-file-text-o` | 6 | Static content pages |
|
||||
| CMS Categories | `icon-folder-o` | 5 | CMS category groupings |
|
||||
| Employees | `icon-user-secret` | 5 | Back-office users |
|
||||
| Customers | `icon-users` | 12 | Front-office customers |
|
||||
| Customer Groups | `icon-group` | 4 | Customer segmentation groups |
|
||||
| Carriers | `icon-truck` | 10 | Shipping carriers |
|
||||
| Zones | `icon-globe` | 4 | Geographic zones |
|
||||
| Countries | `icon-flag` | 9 | Country targeting |
|
||||
| Currencies | `icon-money` | 4 | Currency selection |
|
||||
| Languages | `icon-language` | 5 | Language targeting |
|
||||
| Shops | `icon-shopping-cart` | 4 | Multi-store shop selection |
|
||||
| Profiles | `icon-key` | 3 | Employee access profiles |
|
||||
| Order States | `icon-tasks` | 6 | Order status targeting |
|
||||
| Taxes | `icon-money` | 5 | Tax rule selection |
|
||||
|
||||
---
|
||||
|
||||
## Products - Selection Methods
|
||||
|
||||
### All Products
|
||||
| Method | Description | Value Type |
|
||||
|--------|-------------|------------|
|
||||
| `all` | All products (no criteria) | none |
|
||||
|
||||
### By Entity
|
||||
| Method | Description | Value Type |
|
||||
|--------|-------------|------------|
|
||||
| `specific` | Specific individual products | entity_search |
|
||||
| `by_category` | Products in category(ies) | entity_search |
|
||||
| `by_manufacturer` | Products by manufacturer | entity_search |
|
||||
| `by_supplier` | Products by supplier | entity_search |
|
||||
| `by_tag` | Products by tag | entity_search |
|
||||
| `by_attribute` | Products with attribute(s) | entity_search |
|
||||
| `by_feature` | Products with feature(s) | entity_search |
|
||||
| `by_combination` | Specific combinations | combination_attributes |
|
||||
|
||||
### By Property
|
||||
| Method | Description | Options |
|
||||
|--------|-------------|---------|
|
||||
| `by_condition` | Product condition | New, Used, Refurbished |
|
||||
| `by_visibility` | Visibility setting | Everywhere, Catalog only, Search only, Nowhere |
|
||||
| `by_active_status` | Active status | Active, Inactive |
|
||||
| `by_stock_status` | Stock status | In stock, Out of stock, Low stock |
|
||||
| `by_on_sale` | On sale flag | Yes, No |
|
||||
| `by_has_specific_price` | Has discount | Yes, No |
|
||||
| `by_is_virtual` | Virtual product | Yes, No |
|
||||
| `by_is_pack` | Pack product | Yes, No |
|
||||
| `by_has_combinations` | Has combinations | Yes, No |
|
||||
| `by_available_for_order` | Available for order | Yes, No |
|
||||
| `by_online_only` | Online only | Yes, No |
|
||||
| `by_has_related` | Has related products | Yes, No |
|
||||
| `by_has_customization` | Has customization | Yes, No |
|
||||
| `by_has_attachments` | Has attachments | Yes, No |
|
||||
| `by_out_of_stock_behavior` | Out of stock behavior | Deny orders, Allow orders, Use default |
|
||||
| `by_delivery_time` | Delivery time setting | None, Default, Specific |
|
||||
| `by_has_additional_shipping` | Additional shipping cost | Yes, No |
|
||||
| `by_carrier_restriction` | Carrier restriction | Has restriction, All carriers |
|
||||
|
||||
### By Text Pattern
|
||||
| Method | Description | Value Type |
|
||||
|--------|-------------|------------|
|
||||
| `by_name_pattern` | Product name contains | pattern |
|
||||
| `by_reference_pattern` | Reference contains | pattern |
|
||||
| `by_description_pattern` | Short description contains | pattern |
|
||||
| `by_long_description_pattern` | Long description contains | pattern |
|
||||
| `by_ean13_pattern` | EAN-13 contains | pattern |
|
||||
| `by_upc_pattern` | UPC contains | pattern |
|
||||
| `by_isbn_pattern` | ISBN contains | pattern |
|
||||
| `by_mpn_pattern` | MPN contains | pattern |
|
||||
| `by_meta_title_pattern` | Meta title contains | pattern |
|
||||
| `by_meta_description_pattern` | Meta description contains | pattern |
|
||||
|
||||
### By Range
|
||||
| Method | Description | Value Type |
|
||||
|--------|-------------|------------|
|
||||
| `by_id_range` | Product ID range | numeric_range |
|
||||
| `by_price_range` | Price range | numeric_range |
|
||||
| `by_weight_range` | Weight range | numeric_range |
|
||||
| `by_quantity_range` | Stock quantity range | numeric_range |
|
||||
| `by_position_range` | Position range | numeric_range |
|
||||
| `by_date_added` | Date added range | date_range |
|
||||
| `by_date_updated` | Date modified range | date_range |
|
||||
|
||||
---
|
||||
|
||||
## Categories - Selection Methods
|
||||
|
||||
| Method | Description | Value Type |
|
||||
|--------|-------------|------------|
|
||||
| `all` | All categories | none |
|
||||
| `specific` | Specific category(ies) | entity_search |
|
||||
| `by_name_pattern` | Name contains | pattern |
|
||||
| `by_product_count` | Product count range | numeric_range |
|
||||
| `by_depth_level` | Depth level range | numeric_range |
|
||||
| `by_active_status` | Active status | select |
|
||||
|
||||
---
|
||||
|
||||
## Manufacturers - Selection Methods
|
||||
|
||||
| Method | Description | Value Type |
|
||||
|--------|-------------|------------|
|
||||
| `all` | All manufacturers | none |
|
||||
| `specific` | Specific manufacturer(s) | entity_search |
|
||||
| `by_name_pattern` | Name contains | pattern |
|
||||
| `by_product_count` | Product count range | numeric_range |
|
||||
| `by_active_status` | Active status | select |
|
||||
|
||||
---
|
||||
|
||||
## Suppliers - Selection Methods
|
||||
|
||||
| Method | Description | Value Type |
|
||||
|--------|-------------|------------|
|
||||
| `all` | All suppliers | none |
|
||||
| `specific` | Specific supplier(s) | entity_search |
|
||||
| `by_name_pattern` | Name contains | pattern |
|
||||
| `by_product_count` | Product count range | numeric_range |
|
||||
| `by_active_status` | Active status | select |
|
||||
|
||||
---
|
||||
|
||||
## CMS Pages - Selection Methods
|
||||
|
||||
| Method | Description | Value Type |
|
||||
|--------|-------------|------------|
|
||||
| `all` | All CMS pages | none |
|
||||
| `specific` | Specific page(s) | entity_search |
|
||||
| `by_cms_category` | Pages in CMS category | entity_search |
|
||||
| `by_name_pattern` | Title contains | pattern |
|
||||
| `by_active_status` | Active status | select |
|
||||
| `by_indexable` | Indexable status | select |
|
||||
|
||||
---
|
||||
|
||||
## Customers - Selection Methods
|
||||
|
||||
| Method | Description | Value Type |
|
||||
|--------|-------------|------------|
|
||||
| `all` | All customers | none |
|
||||
| `specific` | Specific customer(s) | entity_search |
|
||||
| `by_group` | By customer group | entity_search |
|
||||
| `by_name_pattern` | Name contains | pattern |
|
||||
| `by_email_pattern` | Email contains | pattern |
|
||||
| `by_company` | Has company | select |
|
||||
| `by_company_pattern` | Company name contains | pattern |
|
||||
| `by_address_count` | Address count range | numeric_range |
|
||||
| `by_order_count` | Order count range | numeric_range |
|
||||
| `by_turnover` | Total spent range | numeric_range |
|
||||
| `by_active_status` | Active status | select |
|
||||
| `by_newsletter` | Newsletter subscription | select |
|
||||
| `by_guest` | Guest or registered | select |
|
||||
|
||||
---
|
||||
|
||||
## Carriers - Selection Methods
|
||||
|
||||
| Method | Description | Value Type |
|
||||
|--------|-------------|------------|
|
||||
| `all` | All carriers | none |
|
||||
| `specific` | Specific carrier(s) | entity_search |
|
||||
| `by_name_pattern` | Name contains | pattern |
|
||||
| `by_active_status` | Active status | select |
|
||||
| `by_shipping_handling` | Handling fee | select |
|
||||
| `by_free_shipping` | Free shipping | select |
|
||||
| `by_zone` | By zone | entity_search |
|
||||
| `by_customer_group` | By customer group | entity_search |
|
||||
| `by_price_range` | Shipping price range | numeric_range |
|
||||
| `by_weight_range` | Max weight range | numeric_range |
|
||||
|
||||
---
|
||||
|
||||
## Countries - Selection Methods
|
||||
|
||||
| Method | Description | Value Type |
|
||||
|--------|-------------|------------|
|
||||
| `all` | All countries | none |
|
||||
| `specific` | Specific country(ies) | entity_search |
|
||||
| `by_zone` | By zone | entity_search |
|
||||
| `by_name_pattern` | Name contains | pattern |
|
||||
| `by_active_status` | Active status | select |
|
||||
| `by_contains_states` | Has states | select |
|
||||
| `by_need_zip_code` | Requires ZIP | select |
|
||||
| `by_zip_format` | ZIP format contains | pattern |
|
||||
| `by_need_identification` | Requires ID number | select |
|
||||
|
||||
---
|
||||
|
||||
## Other Entities
|
||||
|
||||
### Customer Groups
|
||||
| Method | Value Type |
|
||||
|--------|------------|
|
||||
| `all`, `specific`, `by_name_pattern`, `by_price_display` | various |
|
||||
|
||||
### Zones
|
||||
| Method | Value Type |
|
||||
|--------|------------|
|
||||
| `all`, `specific`, `by_name_pattern`, `by_active_status` | various |
|
||||
|
||||
### Currencies
|
||||
| Method | Value Type |
|
||||
|--------|------------|
|
||||
| `all`, `specific`, `by_name_pattern`, `by_active_status` | various |
|
||||
|
||||
### Languages
|
||||
| Method | Value Type |
|
||||
|--------|------------|
|
||||
| `all`, `specific`, `by_name_pattern`, `by_active_status`, `by_rtl` | various |
|
||||
|
||||
### Shops
|
||||
| Method | Value Type |
|
||||
|--------|------------|
|
||||
| `all`, `specific`, `by_name_pattern`, `by_active_status` | various |
|
||||
|
||||
### Profiles
|
||||
| Method | Value Type |
|
||||
|--------|------------|
|
||||
| `all`, `specific`, `by_name_pattern` | various |
|
||||
|
||||
### Employees
|
||||
| Method | Value Type |
|
||||
|--------|------------|
|
||||
| `all`, `specific`, `by_profile`, `by_name_pattern`, `by_active_status` | various |
|
||||
|
||||
### Order States
|
||||
| Method | Value Type |
|
||||
|--------|------------|
|
||||
| `all`, `specific`, `by_name_pattern`, `by_paid`, `by_shipped`, `by_delivery` | various |
|
||||
|
||||
### Taxes
|
||||
| Method | Value Type |
|
||||
|--------|------------|
|
||||
| `all`, `specific`, `by_name_pattern`, `by_rate_range`, `by_active_status` | various |
|
||||
|
||||
### CMS Categories
|
||||
| Method | Value Type |
|
||||
|--------|------------|
|
||||
| `all`, `specific`, `by_name_pattern`, `by_active_status`, `by_page_count` | various |
|
||||
|
||||
---
|
||||
|
||||
## Value Types
|
||||
|
||||
| Type | UI Component | Description |
|
||||
|------|--------------|-------------|
|
||||
| `none` | No input | Used for "All" methods |
|
||||
| `entity_search` | Searchable dropdown with chips | Select related entities |
|
||||
| `pattern` | Text input | Text/regex pattern matching |
|
||||
| `multi_select_tiles` | Toggle buttons | Multiple Yes/No options |
|
||||
| `select` | Single dropdown | Single option selection |
|
||||
| `numeric_range` | Min/Max inputs | Numeric range filtering |
|
||||
| `multi_numeric_range` | Multiple ranges | Multiple numeric ranges |
|
||||
| `date_range` | Date pickers | Date range filtering |
|
||||
| `combination_attributes` | Attribute selector | Product combination selection |
|
||||
|
||||
---
|
||||
|
||||
## Usage
|
||||
|
||||
### PHP Integration
|
||||
|
||||
```php
|
||||
<?php
|
||||
use MyPrestaRocks\EntitySelector\EntitySelector;
|
||||
|
||||
use MyPrestaRocks\TargetConditions\ScheduleConditions;
|
||||
|
||||
class AdminYourModuleController extends ModuleAdminController
|
||||
class AdminMyController extends ModuleAdminController
|
||||
{
|
||||
use ScheduleConditions;
|
||||
use EntitySelector;
|
||||
|
||||
// Your controller code...
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Initialize Assets in setMedia()
|
||||
|
||||
```php
|
||||
public function setMedia($isNewTheme = false)
|
||||
{
|
||||
parent::setMedia($isNewTheme);
|
||||
|
||||
// Initialize schedule conditions (loads CSS + JS)
|
||||
$this->initScheduleConditions();
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Render the Widget
|
||||
|
||||
```php
|
||||
public function renderForm()
|
||||
{
|
||||
// Get saved schedule data (JSON string from database)
|
||||
$scheduleJson = $this->object->schedule ?? '{}';
|
||||
$scheduleData = json_decode($scheduleJson, true) ?: [];
|
||||
|
||||
$this->fields_form = [
|
||||
'legend' => ['title' => $this->l('Configuration')],
|
||||
'input' => [
|
||||
[
|
||||
'type' => 'free',
|
||||
'label' => $this->l('Schedule'),
|
||||
'name' => 'schedule_widget',
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
// Render schedule widget
|
||||
$this->fields_value['schedule_widget'] = $this->renderScheduleConditionsHtml(
|
||||
[
|
||||
'id' => 'my-schedule',
|
||||
'name_prefix' => 'schedule_',
|
||||
'title' => $this->l('Availability Schedule'),
|
||||
'subtitle' => $this->l('Define when this should be active'),
|
||||
'show_datetime_range' => true,
|
||||
'show_weekly_schedule' => true,
|
||||
'show_holiday_exclusions' => true,
|
||||
],
|
||||
$scheduleData
|
||||
);
|
||||
|
||||
return parent::renderForm();
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Process Form Submission
|
||||
|
||||
```php
|
||||
public function postProcess()
|
||||
{
|
||||
if (Tools::isSubmit('submitYourForm')) {
|
||||
// Parse schedule data from form
|
||||
$scheduleData = $this->parseScheduleConditionsFromPost('schedule_');
|
||||
|
||||
// Serialize to JSON for storage
|
||||
$this->object->schedule = $this->serializeScheduleConditions($scheduleData);
|
||||
$this->object->save();
|
||||
public function setMedia($isNewTheme = false)
|
||||
{
|
||||
parent::setMedia($isNewTheme);
|
||||
$this->initEntitySelector();
|
||||
}
|
||||
|
||||
return parent::postProcess();
|
||||
public function renderForm()
|
||||
{
|
||||
$html = $this->renderEntitySelectorHtml(
|
||||
[
|
||||
'id' => 'my-selector',
|
||||
'blocks' => ['products', 'categories'],
|
||||
'show_modifiers' => true,
|
||||
],
|
||||
$savedData
|
||||
);
|
||||
// Add $html to your form
|
||||
}
|
||||
|
||||
public function ajaxProcessEntitySelector()
|
||||
{
|
||||
$this->handleEntitySelectorAjax();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 5. Evaluate Schedule at Runtime
|
||||
### Configuration Options
|
||||
|
||||
```php
|
||||
// Check if current time matches the schedule
|
||||
$scheduleData = $this->parseScheduleConditions($object->schedule);
|
||||
|
||||
if ($this->evaluateScheduleConditions($scheduleData)) {
|
||||
// Schedule allows - item is active
|
||||
} else {
|
||||
// Schedule blocks - item is inactive
|
||||
}
|
||||
|
||||
// Or check against a specific timestamp
|
||||
$futureTimestamp = strtotime('+1 day');
|
||||
$willBeActive = $this->evaluateScheduleConditions($scheduleData, $futureTimestamp);
|
||||
```
|
||||
|
||||
## Configuration Options
|
||||
|
||||
```php
|
||||
$this->renderScheduleConditionsHtml([
|
||||
// Unique ID for the widget
|
||||
'id' => 'schedule-conditions',
|
||||
|
||||
// Form field name prefix
|
||||
'name_prefix' => 'schedule_',
|
||||
|
||||
// Header text
|
||||
'title' => 'Schedule',
|
||||
'subtitle' => 'Define when this should be active',
|
||||
|
||||
// Which sections to show
|
||||
'show_datetime_range' => true, // Start/end datetime pickers
|
||||
'show_weekly_schedule' => true, // Per-day time range sliders
|
||||
'show_holiday_exclusions' => true, // Holiday exclusion controls
|
||||
$this->renderEntitySelectorHtml([
|
||||
'id' => 'selector-id', // Unique ID
|
||||
'blocks' => ['products'], // Which entity tabs to show
|
||||
'mode' => 'multi', // 'single' or 'multi' group mode
|
||||
'show_modifiers' => true, // Show limit/sort per group
|
||||
'show_preview' => true, // Show preview button
|
||||
'default_method' => 'all', // Default selection method
|
||||
], $savedData);
|
||||
```
|
||||
|
||||
### JavaScript Events
|
||||
|
||||
```javascript
|
||||
// Listen for selection changes
|
||||
$(document).on('entitySelector:change', function(e, data) {
|
||||
console.log('Selection changed:', data);
|
||||
});
|
||||
|
||||
// Get current selection
|
||||
var data = window.EntitySelector.serialize('#my-selector');
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Data Format
|
||||
|
||||
The schedule data is stored as JSON:
|
||||
Selection data is stored as JSON:
|
||||
|
||||
```json
|
||||
{
|
||||
"enabled": true,
|
||||
"datetime_start": "2024-12-24 08:00:00",
|
||||
"datetime_end": "2024-12-24 18:00:00",
|
||||
"weekly_schedule": {
|
||||
"0": {"enabled": false, "start": 0, "end": 1440},
|
||||
"1": {"enabled": true, "start": 540, "end": 1020},
|
||||
"2": {"enabled": true, "start": 540, "end": 1020},
|
||||
"3": {"enabled": true, "start": 540, "end": 1020},
|
||||
"4": {"enabled": true, "start": 540, "end": 1020},
|
||||
"5": {"enabled": true, "start": 540, "end": 1020},
|
||||
"6": {"enabled": false, "start": 0, "end": 1440}
|
||||
},
|
||||
"exclude_holidays": true,
|
||||
"holiday_countries": [1, 14, 17]
|
||||
}
|
||||
```
|
||||
|
||||
### Field Descriptions
|
||||
|
||||
- **enabled**: Whether scheduling is active (false = always active)
|
||||
- **datetime_start/end**: ISO datetime strings for absolute date range
|
||||
- **weekly_schedule**: Per-day configuration (0=Sunday, 1=Monday, etc.)
|
||||
- **enabled**: Whether this day is active
|
||||
- **start/end**: Minutes from midnight (0-1440, e.g., 540 = 09:00)
|
||||
- **exclude_holidays**: Whether to exclude public holidays
|
||||
- **holiday_countries**: Country IDs to check for holidays
|
||||
|
||||
## Public Holiday Integration
|
||||
|
||||
To enable holiday exclusions, implement the `HolidayProviderInterface`:
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
use MyPrestaRocks\TargetConditions\HolidayProviderInterface;
|
||||
|
||||
class MyHolidayProvider implements HolidayProviderInterface
|
||||
{
|
||||
/**
|
||||
* Check if a date is a holiday for any of the given countries
|
||||
*/
|
||||
public static function isHoliday($date, array $countryIds)
|
||||
{
|
||||
// Your implementation
|
||||
return Db::getInstance()->getValue('
|
||||
SELECT 1 FROM ps_holidays
|
||||
WHERE holiday_date = "' . pSQL($date) . '"
|
||||
AND id_country IN (' . implode(',', array_map('intval', $countryIds)) . ')
|
||||
') ? true : false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of country IDs that have holidays configured
|
||||
*/
|
||||
public static function getCountriesWithHolidays()
|
||||
{
|
||||
return array_column(
|
||||
Db::getInstance()->executeS('SELECT DISTINCT id_country FROM ps_holidays'),
|
||||
'id_country'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get holidays within a date range
|
||||
*/
|
||||
public static function getHolidaysInRange(array $countryIds, $startDate, $endDate)
|
||||
{
|
||||
return Db::getInstance()->executeS('
|
||||
SELECT holiday_date, display_name, country_iso
|
||||
FROM ps_holidays h
|
||||
JOIN ps_country c ON c.id_country = h.id_country
|
||||
WHERE h.id_country IN (' . implode(',', array_map('intval', $countryIds)) . ')
|
||||
AND holiday_date BETWEEN "' . pSQL($startDate) . '" AND "' . pSQL($endDate) . '"
|
||||
');
|
||||
"products": {
|
||||
"groups": [
|
||||
{
|
||||
"name": "Group 1",
|
||||
"include": {
|
||||
"method": "by_category",
|
||||
"values": [3, 5, 8]
|
||||
},
|
||||
"excludes": [
|
||||
{
|
||||
"method": "specific",
|
||||
"values": [42, 99]
|
||||
}
|
||||
],
|
||||
"limit": 10,
|
||||
"sort": "bestsellers"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Then override the provider method in your controller:
|
||||
|
||||
```php
|
||||
protected function getScheduleHolidayProvider()
|
||||
{
|
||||
return 'MyHolidayProvider';
|
||||
}
|
||||
```
|
||||
|
||||
## Helper Methods
|
||||
|
||||
```php
|
||||
// Get human-readable description of schedule
|
||||
$description = $this->getScheduleDescription($scheduleData);
|
||||
// Returns: "Dec 24 08:00 to Dec 24 18:00 • Weekdays only • Excl. holidays (3 countries)"
|
||||
|
||||
// Format minutes to time string
|
||||
$time = $this->formatMinutesToTime(540); // Returns "09:00"
|
||||
|
||||
// Parse time string to minutes
|
||||
$minutes = $this->parseTimeToMinutes("09:00"); // Returns 540
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# Assets
|
||||
## File Structure
|
||||
|
||||
Both traits share a common CSS file with trait-specific styling:
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `assets/css/admin/condition-traits.css` | Shared styling for both traits |
|
||||
| `assets/js/admin/target-conditions.js` | TargetConditions widget functionality |
|
||||
| `assets/js/admin/schedule-conditions.js` | ScheduleConditions widget functionality |
|
||||
|
||||
## CSS Classes Reference
|
||||
|
||||
### TargetConditions
|
||||
|
||||
```css
|
||||
.condition-trait { } /* Main wrapper */
|
||||
.condition-group { } /* Individual group container */
|
||||
.condition-group-header { } /* Group header with label */
|
||||
.condition-group-items { } /* Items container */
|
||||
.condition-item { } /* Individual selectable item */
|
||||
.condition-item.selected { } /* Selected state */
|
||||
.group-modifiers { } /* Limit/sort controls container */
|
||||
.condition-trait-toggle { } /* All/restricted toggle badge */
|
||||
```
|
||||
|
||||
### ScheduleConditions
|
||||
|
||||
```css
|
||||
.schedule-conditions { } /* Main wrapper */
|
||||
.schedule-section { } /* Section container */
|
||||
.schedule-datetime-range { } /* Datetime range section */
|
||||
.schedule-weekly { } /* Weekly schedule section */
|
||||
.schedule-holidays { } /* Holiday exclusions section */
|
||||
.day-timeline { } /* Individual day row */
|
||||
.timeline-track { } /* Time range slider track */
|
||||
.timeline-range { } /* Active time range bar */
|
||||
.timeline-handle { } /* Draggable handles */
|
||||
prestashop-entity-selector/
|
||||
├── src/
|
||||
│ ├── EntitySelector.php # Main trait (orchestrator)
|
||||
│ └── EntitySelector/
|
||||
│ ├── ProductConditionResolver.php
|
||||
│ ├── EntityQueryHandler.php
|
||||
│ ├── EntitySelectorRenderer.php
|
||||
│ ├── EntitySearchEngine.php
|
||||
│ ├── EntityPreviewHandler.php
|
||||
│ └── MethodHelpProvider.php
|
||||
├── sources/
|
||||
│ ├── scss/
|
||||
│ │ ├── main.scss
|
||||
│ │ ├── _variables.scss
|
||||
│ │ ├── _mixins.scss
|
||||
│ │ └── components/
|
||||
│ │ ├── _entity-selector.scss
|
||||
│ │ ├── _dropdown.scss
|
||||
│ │ ├── _chips.scss
|
||||
│ │ ├── _groups.scss
|
||||
│ │ ├── _modal.scss
|
||||
│ │ └── ...
|
||||
│ └── js/admin/entity-selector/
|
||||
│ ├── _core.js
|
||||
│ ├── _events.js
|
||||
│ ├── _dropdown.js
|
||||
│ ├── _search.js
|
||||
│ ├── _groups.js
|
||||
│ ├── _chips.js
|
||||
│ ├── _methods.js
|
||||
│ ├── _preview.js
|
||||
│ ├── _filters.js
|
||||
│ └── _utils.js
|
||||
├── assets/
|
||||
│ ├── css/admin/entity-selector.css
|
||||
│ └── js/admin/
|
||||
│ ├── entity-selector.js
|
||||
│ └── entity-selector.min.js
|
||||
├── gulpfile.js
|
||||
├── package.json
|
||||
└── README.md
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# Requirements
|
||||
## Building
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
npm install
|
||||
|
||||
# Build CSS and JS
|
||||
npm run build
|
||||
|
||||
# Watch for changes during development
|
||||
npm run watch
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Requirements
|
||||
|
||||
- PrestaShop 1.7.x or 8.x
|
||||
- PHP 7.1+
|
||||
- Node.js 16+ (for building)
|
||||
|
||||
# License
|
||||
---
|
||||
|
||||
MIT License
|
||||
## License
|
||||
|
||||
Proprietary - MyPrestaRocks
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
2
assets/js/admin/entity-selector.min.js
vendored
2
assets/js/admin/entity-selector.min.js
vendored
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -11,7 +11,8 @@
|
||||
}
|
||||
],
|
||||
"require": {
|
||||
"php": ">=7.1"
|
||||
"php": ">=7.1",
|
||||
"myprestarocks/prestashop-admin": "^1.0"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
|
||||
@@ -23,6 +23,7 @@ const paths = {
|
||||
'sources/js/admin/entity-selector/_groups.js',
|
||||
'sources/js/admin/entity-selector/_methods.js',
|
||||
'sources/js/admin/entity-selector/_preview.js',
|
||||
'sources/js/admin/entity-selector/_tree.js',
|
||||
'sources/js/admin/entity-selector/_core.js'
|
||||
],
|
||||
scheduleConditions: [
|
||||
|
||||
@@ -89,48 +89,190 @@
|
||||
|
||||
updateChipsVisibility: function($chips) {
|
||||
var self = this;
|
||||
var trans = this.config.trans || {};
|
||||
var $picker = $chips.closest('.value-picker');
|
||||
var $allChips = $chips.find('.entity-chip');
|
||||
var totalCount = $allChips.length;
|
||||
var $toggle = $chips.find('.chips-show-more-toggle');
|
||||
var isExpanded = $chips.hasClass('chips-expanded');
|
||||
var trans = this.config.trans || {};
|
||||
|
||||
// Remove existing toggle if present
|
||||
$toggle.remove();
|
||||
|
||||
if (totalCount <= this.maxVisibleChips) {
|
||||
// All chips visible, no toggle needed
|
||||
$allChips.removeClass('chip-hidden');
|
||||
$chips.removeClass('chips-expanded chips-collapsed');
|
||||
// If no chips, remove the wrapper entirely
|
||||
var $existingWrapper = $chips.closest('.chips-wrapper');
|
||||
if (totalCount === 0) {
|
||||
if ($existingWrapper.length) {
|
||||
// Move chips out of wrapper before removing
|
||||
$existingWrapper.before($chips);
|
||||
$existingWrapper.remove();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// We have more than maxVisibleChips
|
||||
var hiddenCount = totalCount - this.maxVisibleChips;
|
||||
// Ensure chips wrapper structure exists
|
||||
this.ensureChipsWrapper($chips);
|
||||
|
||||
if (isExpanded) {
|
||||
// Show all chips
|
||||
$allChips.removeClass('chip-hidden');
|
||||
var $wrapper = $chips.closest('.chips-wrapper');
|
||||
var $toolbar = $wrapper.find('.chips-toolbar');
|
||||
var $loadMore = $wrapper.find('.chips-load-more');
|
||||
|
||||
// Add collapse toggle
|
||||
var collapseText = trans.show_less || 'Show less';
|
||||
$chips.append('<span class="chips-show-more-toggle chips-collapse-toggle">' +
|
||||
'<i class="icon-chevron-up"></i> ' + collapseText + '</span>');
|
||||
} else {
|
||||
// Hide chips beyond maxVisibleChips
|
||||
$allChips.each(function(index) {
|
||||
if (index >= self.maxVisibleChips) {
|
||||
$(this).addClass('chip-hidden');
|
||||
// Get current search filter
|
||||
var searchTerm = $toolbar.find('.chips-search-input').val() || '';
|
||||
searchTerm = searchTerm.toLowerCase().trim();
|
||||
|
||||
// Filter and paginate chips
|
||||
var visibleCount = 0;
|
||||
var filteredCount = 0;
|
||||
var isExpanded = $chips.hasClass('chips-expanded');
|
||||
var maxVisible = isExpanded ? 999999 : (this.maxVisibleChips || 12);
|
||||
|
||||
$allChips.each(function() {
|
||||
var $chip = $(this);
|
||||
var chipName = ($chip.find('.chip-name').text() || '').toLowerCase();
|
||||
var matchesFilter = !searchTerm || chipName.indexOf(searchTerm) !== -1;
|
||||
|
||||
$chip.removeClass('chip-filtered-out chip-paginated-out');
|
||||
|
||||
if (!matchesFilter) {
|
||||
$chip.addClass('chip-filtered-out');
|
||||
} else {
|
||||
filteredCount++;
|
||||
if (filteredCount > maxVisible) {
|
||||
$chip.addClass('chip-paginated-out');
|
||||
} else {
|
||||
$(this).removeClass('chip-hidden');
|
||||
visibleCount++;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Update toolbar (always show when we have chips)
|
||||
$toolbar.addClass('has-chips');
|
||||
this.updateChipsToolbar($toolbar, totalCount, filteredCount, searchTerm);
|
||||
|
||||
// Update load more button
|
||||
var hiddenByPagination = filteredCount - visibleCount;
|
||||
if (hiddenByPagination > 0 && !isExpanded) {
|
||||
var moreText = (trans.show_more || 'Show {count} more').replace('{count}', hiddenByPagination);
|
||||
$loadMore.html(
|
||||
'<button type="button" class="btn-load-more">' +
|
||||
'<i class="icon-chevron-down"></i> ' + moreText +
|
||||
'</button>'
|
||||
).show();
|
||||
} else if (isExpanded && filteredCount > (this.maxVisibleChips || 12)) {
|
||||
var lessText = trans.show_less || 'Show less';
|
||||
$loadMore.html(
|
||||
'<button type="button" class="btn-load-more">' +
|
||||
'<i class="icon-chevron-up"></i> ' + lessText +
|
||||
'</button>'
|
||||
).show();
|
||||
} else {
|
||||
$loadMore.hide();
|
||||
}
|
||||
},
|
||||
|
||||
ensureChipsWrapper: function($chips) {
|
||||
// Check if already wrapped
|
||||
if ($chips.closest('.chips-wrapper').length) {
|
||||
return;
|
||||
}
|
||||
|
||||
var trans = this.config.trans || {};
|
||||
var $picker = $chips.closest('.value-picker');
|
||||
|
||||
// Create wrapper structure - simple inline toolbar
|
||||
var wrapperHtml = '<div class="chips-wrapper">' +
|
||||
'<div class="chips-toolbar">' +
|
||||
'<i class="icon-search"></i>' +
|
||||
'<input type="text" class="chips-search-input" placeholder="' + (trans.filter || 'Filter') + '...">' +
|
||||
'<span class="chips-count"></span>' +
|
||||
'<button type="button" class="btn-chips-clear" title="' + (trans.clear_all || 'Clear all') + '">' +
|
||||
'<i class="icon-trash"></i> <span class="clear-text">' + (trans.clear_all || 'Clear all') + '</span>' +
|
||||
'</button>' +
|
||||
'</div>' +
|
||||
'<div class="chips-load-more" style="display:none;"></div>' +
|
||||
'</div>';
|
||||
|
||||
var $wrapper = $(wrapperHtml);
|
||||
|
||||
// Insert wrapper before chips and move chips inside
|
||||
$chips.before($wrapper);
|
||||
$wrapper.find('.chips-toolbar').after($chips);
|
||||
$wrapper.append($wrapper.find('.chips-load-more'));
|
||||
|
||||
// Bind toolbar events
|
||||
this.bindChipsToolbarEvents($wrapper);
|
||||
},
|
||||
|
||||
bindChipsToolbarEvents: function($wrapper) {
|
||||
var self = this;
|
||||
var $chips = $wrapper.find('.entity-chips');
|
||||
var searchTimeout;
|
||||
|
||||
// Search input
|
||||
$wrapper.on('input', '.chips-search-input', function() {
|
||||
clearTimeout(searchTimeout);
|
||||
searchTimeout = setTimeout(function() {
|
||||
// Collapse when searching to show filtered results from start
|
||||
$chips.removeClass('chips-expanded');
|
||||
self.updateChipsVisibility($chips);
|
||||
}, 150);
|
||||
});
|
||||
|
||||
// Clear all button
|
||||
$wrapper.on('click', '.btn-chips-clear', function() {
|
||||
var searchTerm = $wrapper.find('.chips-search-input').val() || '';
|
||||
var $chipsToRemove;
|
||||
|
||||
if (searchTerm.trim()) {
|
||||
// Remove only filtered (visible) chips
|
||||
$chipsToRemove = $chips.find('.entity-chip:not(.chip-filtered-out)');
|
||||
} else {
|
||||
// Remove all chips
|
||||
$chipsToRemove = $chips.find('.entity-chip');
|
||||
}
|
||||
|
||||
$chipsToRemove.each(function() {
|
||||
$(this).find('.chip-remove').trigger('click');
|
||||
});
|
||||
|
||||
// Add expand toggle
|
||||
var moreText = (trans.show_more || 'Show {count} more').replace('{count}', hiddenCount);
|
||||
$chips.addClass('chips-collapsed').removeClass('chips-expanded');
|
||||
$chips.append('<span class="chips-show-more-toggle chips-expand-toggle">' +
|
||||
'<i class="icon-chevron-down"></i> ' + moreText + '</span>');
|
||||
// Clear search
|
||||
$wrapper.find('.chips-search-input').val('');
|
||||
self.updateChipsVisibility($chips);
|
||||
});
|
||||
|
||||
// Load more / show less
|
||||
$wrapper.on('click', '.btn-load-more', function() {
|
||||
if ($chips.hasClass('chips-expanded')) {
|
||||
$chips.removeClass('chips-expanded');
|
||||
} else {
|
||||
$chips.addClass('chips-expanded');
|
||||
}
|
||||
self.updateChipsVisibility($chips);
|
||||
});
|
||||
},
|
||||
|
||||
updateChipsToolbar: function($toolbar, totalCount, filteredCount, searchTerm) {
|
||||
var trans = this.config.trans || {};
|
||||
var $count = $toolbar.find('.chips-count');
|
||||
var $clearBtn = $toolbar.find('.btn-chips-clear');
|
||||
var $clearText = $clearBtn.find('.clear-text');
|
||||
|
||||
// Update count display
|
||||
if (searchTerm) {
|
||||
$count.addClass('has-filter').html(
|
||||
'<span class="count-filtered">' + filteredCount + '</span>' +
|
||||
'<span class="count-separator">/</span>' +
|
||||
'<span class="count-total">' + totalCount + '</span>'
|
||||
);
|
||||
$clearText.text((trans.clear || 'Clear') + ' ' + filteredCount);
|
||||
} else {
|
||||
$count.removeClass('has-filter').html(totalCount);
|
||||
$clearText.text(trans.clear_all || 'Clear all');
|
||||
}
|
||||
|
||||
// Show/hide clear button
|
||||
if (searchTerm && filteredCount === 0) {
|
||||
$clearBtn.hide();
|
||||
} else if (totalCount > 0) {
|
||||
$clearBtn.show();
|
||||
} else {
|
||||
$clearBtn.hide();
|
||||
}
|
||||
},
|
||||
|
||||
@@ -172,7 +314,10 @@
|
||||
}
|
||||
});
|
||||
|
||||
// Now load all entities in bulk for each entity type
|
||||
// Build bulk request: { entityType: [uniqueIds], ... }
|
||||
var bulkRequest = {};
|
||||
var hasEntities = false;
|
||||
|
||||
Object.keys(entitiesToLoad).forEach(function(entityType) {
|
||||
var data = entitiesToLoad[entityType];
|
||||
if (data.ids.length === 0) return;
|
||||
@@ -182,69 +327,85 @@
|
||||
return arr.indexOf(id) === index;
|
||||
});
|
||||
|
||||
$.ajax({
|
||||
url: self.config.ajaxUrl,
|
||||
type: 'POST',
|
||||
dataType: 'json',
|
||||
data: {
|
||||
ajax: 1,
|
||||
action: 'getTargetEntitiesByIds',
|
||||
trait: 'EntitySelector',
|
||||
entity_type: entityType,
|
||||
ids: JSON.stringify(uniqueIds)
|
||||
},
|
||||
success: function(response) {
|
||||
if (response.success && response.entities) {
|
||||
// Build a map of id -> entity for quick lookup
|
||||
var entityMap = {};
|
||||
response.entities.forEach(function(entity) {
|
||||
entityMap[entity.id] = entity;
|
||||
});
|
||||
bulkRequest[entityType] = uniqueIds;
|
||||
hasEntities = true;
|
||||
});
|
||||
|
||||
// Update each picker that requested this entity type
|
||||
data.pickers.forEach(function(pickerData) {
|
||||
var $picker = pickerData.$picker;
|
||||
var $chips = $picker.find('.entity-chips');
|
||||
var $dataInput = $picker.find('.include-values-data, .exclude-values-data');
|
||||
var validIds = [];
|
||||
// Skip AJAX if no entities to load
|
||||
if (!hasEntities) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Replace loading chips with real data
|
||||
pickerData.ids.forEach(function(id) {
|
||||
var $loadingChip = $chips.find('.entity-chip-loading[data-id="' + id + '"]');
|
||||
if (entityMap[id]) {
|
||||
var entity = entityMap[id];
|
||||
validIds.push(entity.id);
|
||||
|
||||
// Create real chip
|
||||
var html = '<span class="entity-chip" data-id="' + self.escapeAttr(entity.id) + '">';
|
||||
if (entity.image) {
|
||||
html += '<span class="chip-icon"><img src="' + self.escapeAttr(entity.image) + '" alt=""></span>';
|
||||
}
|
||||
html += '<span class="chip-name">' + self.escapeHtml(entity.name) + '</span>';
|
||||
html += '<button type="button" class="chip-remove" title="Remove"><i class="icon-times"></i></button>';
|
||||
html += '</span>';
|
||||
|
||||
$loadingChip.replaceWith(html);
|
||||
} else {
|
||||
// Entity not found, remove loading chip
|
||||
$loadingChip.remove();
|
||||
}
|
||||
});
|
||||
|
||||
// Update chips visibility
|
||||
self.updateChipsVisibility($chips);
|
||||
|
||||
// If some entities were not found, update the hidden input
|
||||
if (validIds.length !== pickerData.ids.length) {
|
||||
$dataInput.val(JSON.stringify(validIds));
|
||||
self.serializeAllBlocks();
|
||||
}
|
||||
|
||||
self.updateBlockStatus($picker.closest('.target-block'));
|
||||
});
|
||||
}
|
||||
// Single bulk AJAX call for all entity types
|
||||
$.ajax({
|
||||
url: self.config.ajaxUrl,
|
||||
type: 'POST',
|
||||
dataType: 'json',
|
||||
data: {
|
||||
ajax: 1,
|
||||
action: 'getTargetEntitiesByIdsBulk',
|
||||
trait: 'EntitySelector',
|
||||
entities: JSON.stringify(bulkRequest)
|
||||
},
|
||||
success: function(response) {
|
||||
if (!response.success || !response.entities) {
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
// Process each entity type's results
|
||||
Object.keys(entitiesToLoad).forEach(function(entityType) {
|
||||
var data = entitiesToLoad[entityType];
|
||||
var entities = response.entities[entityType] || [];
|
||||
|
||||
// Build a map of id -> entity for quick lookup
|
||||
var entityMap = {};
|
||||
entities.forEach(function(entity) {
|
||||
entityMap[entity.id] = entity;
|
||||
});
|
||||
|
||||
// Update each picker that requested this entity type
|
||||
data.pickers.forEach(function(pickerData) {
|
||||
var $picker = pickerData.$picker;
|
||||
var $chips = $picker.find('.entity-chips');
|
||||
var $dataInput = $picker.find('.include-values-data, .exclude-values-data');
|
||||
var validIds = [];
|
||||
|
||||
// Replace loading chips with real data
|
||||
pickerData.ids.forEach(function(id) {
|
||||
var $loadingChip = $chips.find('.entity-chip-loading[data-id="' + id + '"]');
|
||||
if (entityMap[id]) {
|
||||
var entity = entityMap[id];
|
||||
validIds.push(entity.id);
|
||||
|
||||
// Create real chip
|
||||
var html = '<span class="entity-chip" data-id="' + self.escapeAttr(entity.id) + '">';
|
||||
if (entity.image) {
|
||||
html += '<span class="chip-icon"><img src="' + self.escapeAttr(entity.image) + '" alt=""></span>';
|
||||
}
|
||||
html += '<span class="chip-name">' + self.escapeHtml(entity.name) + '</span>';
|
||||
html += '<button type="button" class="chip-remove" title="Remove"><i class="icon-times"></i></button>';
|
||||
html += '</span>';
|
||||
|
||||
$loadingChip.replaceWith(html);
|
||||
} else {
|
||||
// Entity not found, remove loading chip
|
||||
$loadingChip.remove();
|
||||
}
|
||||
});
|
||||
|
||||
// Update chips visibility
|
||||
self.updateChipsVisibility($chips);
|
||||
|
||||
// If some entities were not found, update the hidden input
|
||||
if (validIds.length !== pickerData.ids.length) {
|
||||
$dataInput.val(JSON.stringify(validIds));
|
||||
self.serializeAllBlocks();
|
||||
}
|
||||
|
||||
self.updateBlockStatus($picker.closest('.target-block'));
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
@@ -1021,6 +1182,53 @@
|
||||
this.$wrapper.find('.target-block.active .selection-group').each(function() {
|
||||
self.updateGroupCounts($(this));
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch category names by IDs and add chips to the picker
|
||||
* Used when adding selections from the tree modal
|
||||
* @param {jQuery} $picker - Picker element
|
||||
* @param {Array} ids - Category IDs to add
|
||||
* @param {string} entityType - 'categories' or 'cms_categories'
|
||||
* @param {Function} callback - Called when done
|
||||
*/
|
||||
fetchCategoryNamesAndAddChips: function($picker, ids, entityType, callback) {
|
||||
var self = this;
|
||||
|
||||
if (!ids || ids.length === 0) {
|
||||
if (typeof callback === 'function') {
|
||||
callback();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
$.ajax({
|
||||
url: this.config.ajaxUrl,
|
||||
type: 'POST',
|
||||
dataType: 'json',
|
||||
data: {
|
||||
ajax: 1,
|
||||
action: 'getTargetEntitiesByIds',
|
||||
trait: 'EntitySelector',
|
||||
entity_type: entityType,
|
||||
ids: JSON.stringify(ids)
|
||||
},
|
||||
success: function(response) {
|
||||
if (response.success && response.entities) {
|
||||
response.entities.forEach(function(entity) {
|
||||
self.addSelectionNoUpdate($picker, entity.id, entity.name, entity);
|
||||
});
|
||||
}
|
||||
if (typeof callback === 'function') {
|
||||
callback();
|
||||
}
|
||||
},
|
||||
error: function() {
|
||||
if (typeof callback === 'function') {
|
||||
callback();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -204,6 +204,11 @@
|
||||
$.extend(instance, mixins.preview);
|
||||
}
|
||||
|
||||
// Merge tree mixin
|
||||
if (mixins.tree) {
|
||||
$.extend(instance, mixins.tree);
|
||||
}
|
||||
|
||||
return instance;
|
||||
}
|
||||
|
||||
|
||||
@@ -45,10 +45,10 @@
|
||||
html += '<i class="icon-sort-alpha-asc"></i>';
|
||||
html += '</button>';
|
||||
|
||||
// View mode selector
|
||||
// View mode selector - Tree option always present, shown for categories
|
||||
html += '<select class="view-mode-select" title="View mode">';
|
||||
html += '<option value="list">' + (trans.view_list || 'List') + '</option>';
|
||||
html += '<option value="tree" class="tree-view-option" disabled hidden>' + (trans.view_tree || 'Tree') + '</option>';
|
||||
html += '<option value="tree" class="tree-view-option">' + (trans.view_tree || 'Tree') + '</option>';
|
||||
html += '<option value="cols-2">2 ' + (trans.cols || 'cols') + '</option>';
|
||||
html += '<option value="cols-3">3 ' + (trans.cols || 'cols') + '</option>';
|
||||
html += '<option value="cols-4">4 ' + (trans.cols || 'cols') + '</option>';
|
||||
@@ -347,10 +347,8 @@
|
||||
html += '<span class="load-more-of">' + (trans.of || 'of') + ' <span class="remaining-count">0</span> ' + (trans.remaining || 'remaining') + '</span>';
|
||||
html += '<button type="button" class="btn-load-more"><i class="icon-plus"></i></button>';
|
||||
html += '</div>';
|
||||
html += '<div class="dropdown-footer-actions">';
|
||||
html += '<button type="button" class="btn-cancel-dropdown">' + (trans.cancel || 'Cancel') + ' <kbd>Esc</kbd></button>';
|
||||
html += '<button type="button" class="btn-confirm-dropdown"><i class="icon-check"></i> ' + (trans.done || 'Done') + ' <kbd>⏎</kbd></button>';
|
||||
html += '</div>';
|
||||
html += '<button type="button" class="btn-cancel-dropdown"><i class="icon-times"></i> ' + (trans.cancel || 'Cancel') + ' <kbd>Esc</kbd></button>';
|
||||
html += '<button type="button" class="btn-confirm-dropdown"><i class="icon-check"></i> ' + (trans.save || 'Save') + ' <kbd>⏎</kbd></button>';
|
||||
html += '</div>';
|
||||
|
||||
html += '</div>';
|
||||
@@ -403,6 +401,9 @@
|
||||
maxHeight: maxHeight,
|
||||
zIndex: 10000
|
||||
});
|
||||
|
||||
// Show the dropdown
|
||||
this.$dropdown.addClass('show');
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -45,11 +45,6 @@
|
||||
this.$wrapper.on('click', '.target-block-tab .tab-badge', function(e) {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
console.log('[EntitySelector] Tab badge clicked', {
|
||||
hasLoading: $(this).hasClass('loading'),
|
||||
hasPopoverOpen: $(this).hasClass('popover-open'),
|
||||
previewData: $(this).closest('.target-block-tab').data('previewData')
|
||||
});
|
||||
|
||||
var $tab = $(this).closest('.target-block-tab');
|
||||
var $badge = $(this);
|
||||
@@ -65,7 +60,6 @@
|
||||
this.$wrapper.on('click', '.condition-match-count.clickable', function(e) {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
console.log('[EntitySelector] Condition match count clicked', this);
|
||||
|
||||
var $badge = $(this);
|
||||
|
||||
@@ -80,7 +74,6 @@
|
||||
this.$wrapper.on('click', '.group-count-badge.clickable', function(e) {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
console.log('[EntitySelector] Group count badge clicked', this);
|
||||
|
||||
var $badge = $(this);
|
||||
|
||||
@@ -91,6 +84,20 @@
|
||||
}
|
||||
});
|
||||
|
||||
// Total count badge click for summary popover
|
||||
this.$wrapper.on('click', '.trait-total-count', function(e) {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
|
||||
var $badge = $(this);
|
||||
|
||||
if ($badge.hasClass('popover-open')) {
|
||||
self.hidePreviewPopover();
|
||||
} else {
|
||||
self.showTotalPreviewPopover($badge);
|
||||
}
|
||||
});
|
||||
|
||||
// Close popover when clicking outside
|
||||
$(document).on('click', function(e) {
|
||||
if (!$(e.target).closest('.target-preview-popover').length &&
|
||||
@@ -99,7 +106,8 @@
|
||||
!$(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('.toggle-count.clickable').length &&
|
||||
!$(e.target).closest('.trait-total-count').length) {
|
||||
self.hidePreviewPopover();
|
||||
}
|
||||
});
|
||||
@@ -108,7 +116,8 @@
|
||||
this.$wrapper.on('click', '.condition-trait-header', function(e) {
|
||||
if ($(e.target).closest('.target-block-tabs').length ||
|
||||
$(e.target).closest('.trait-header-actions').length ||
|
||||
$(e.target).closest('.prestashop-switch').length) {
|
||||
$(e.target).closest('.prestashop-switch').length ||
|
||||
$(e.target).closest('.trait-total-count').length) {
|
||||
return;
|
||||
}
|
||||
var $body = self.$wrapper.find('.condition-trait-body');
|
||||
@@ -881,6 +890,19 @@
|
||||
searchEntity: searchEntity
|
||||
};
|
||||
|
||||
// Initialize pending selections from current chips
|
||||
var $chips = $picker.find('.entity-chips');
|
||||
self.pendingSelections = [];
|
||||
$chips.find('.entity-chip').each(function() {
|
||||
self.pendingSelections.push({
|
||||
id: $(this).data('id'),
|
||||
name: $(this).find('.chip-name').text(),
|
||||
data: $(this).data()
|
||||
});
|
||||
});
|
||||
self.pendingPicker = $picker;
|
||||
self.pendingRow = section === 'include' ? $group.find('.group-include') : $group.find('.exclude-row[data-exclude-index="' + excludeIndex + '"]');
|
||||
|
||||
self.searchOffset = 0;
|
||||
self.searchQuery = $(this).val().trim();
|
||||
|
||||
@@ -892,7 +914,9 @@
|
||||
|
||||
self.positionDropdown($(this));
|
||||
|
||||
if (self.viewMode === 'tree') {
|
||||
// For tree view mode on categories, load category tree instead of search
|
||||
if (self.viewMode === 'tree' && (searchEntity === 'categories' || searchEntity === 'cms_categories')) {
|
||||
self.loadCategoryTree();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -955,6 +979,10 @@
|
||||
// Dropdown item click
|
||||
this.$dropdown.on('click', '.dropdown-item', function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
// Blur any focused input so Ctrl+A works for select all
|
||||
$(document.activeElement).filter('input, textarea').blur();
|
||||
|
||||
var $item = $(this);
|
||||
var id = $item.data('id');
|
||||
var name = $item.data('name');
|
||||
@@ -1032,6 +1060,38 @@
|
||||
e.preventDefault();
|
||||
if (!self.activeGroup) return;
|
||||
|
||||
// Handle tree view - use pending selections
|
||||
if (self.viewMode === 'tree') {
|
||||
if (!self.pendingSelections) self.pendingSelections = [];
|
||||
|
||||
// Select all visible (not filtered-out) tree items
|
||||
var $visibleTreeItems = self.$dropdown.find('.tree-item:not(.filtered-out)');
|
||||
$visibleTreeItems.each(function() {
|
||||
var $item = $(this);
|
||||
var id = parseInt($item.data('id'), 10);
|
||||
var name = $item.data('name');
|
||||
|
||||
if (!$item.hasClass('selected')) {
|
||||
$item.addClass('selected');
|
||||
var exists = self.pendingSelections.some(function(s) {
|
||||
return parseInt(s.id, 10) === id;
|
||||
});
|
||||
if (!exists) {
|
||||
self.pendingSelections.push({ id: id, name: name, data: $item.data() });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Update count display
|
||||
var selectedCount = self.$dropdown.find('.tree-item.selected').length;
|
||||
var totalCount = self.$dropdown.find('.tree-item').length;
|
||||
var entityType = self.$dropdown.find('.category-tree').data('entity-type') || 'categories';
|
||||
var categoryLabel = entityType === 'cms_categories' ? 'CMS categories' : 'categories';
|
||||
self.$dropdown.find('.results-count').text(totalCount + ' ' + categoryLabel + ' (' + selectedCount + ' selected)');
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle list view
|
||||
var $block = self.$wrapper.find('.target-block[data-block-type="' + self.activeGroup.blockType + '"]');
|
||||
var $group = $block.find('.selection-group[data-group-index="' + self.activeGroup.groupIndex + '"]');
|
||||
var $picker;
|
||||
@@ -1067,6 +1127,20 @@
|
||||
e.preventDefault();
|
||||
if (!self.activeGroup) return;
|
||||
|
||||
// Handle tree view - clear pending selections
|
||||
if (self.viewMode === 'tree') {
|
||||
self.pendingSelections = [];
|
||||
self.$dropdown.find('.tree-item').removeClass('selected');
|
||||
|
||||
// Update count display
|
||||
var totalCount = self.$dropdown.find('.tree-item').length;
|
||||
var entityType = self.$dropdown.find('.category-tree').data('entity-type') || 'categories';
|
||||
var categoryLabel = entityType === 'cms_categories' ? 'CMS categories' : 'categories';
|
||||
self.$dropdown.find('.results-count').text(totalCount + ' ' + categoryLabel);
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle list view
|
||||
var $block = self.$wrapper.find('.target-block[data-block-type="' + self.activeGroup.blockType + '"]');
|
||||
var $group = $block.find('.selection-group[data-group-index="' + self.activeGroup.groupIndex + '"]');
|
||||
var $picker;
|
||||
@@ -1087,15 +1161,43 @@
|
||||
self.serializeAllBlocks($row);
|
||||
});
|
||||
|
||||
// Done/confirm
|
||||
// Save - commit pending selections to chips
|
||||
this.$dropdown.on('click', '.btn-confirm-dropdown', function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
if (self.pendingPicker && self.pendingSelections) {
|
||||
var $chips = self.pendingPicker.find('.entity-chips');
|
||||
|
||||
// Clear existing chips
|
||||
$chips.empty();
|
||||
|
||||
// Add chips for all pending selections
|
||||
self.pendingSelections.forEach(function(sel) {
|
||||
self.addSelectionNoUpdate(self.pendingPicker, sel.id, sel.name, sel.data);
|
||||
});
|
||||
|
||||
self.updateChipsVisibility($chips);
|
||||
|
||||
// Serialize to hidden input
|
||||
if (self.pendingRow) {
|
||||
self.serializeAllBlocks(self.pendingRow);
|
||||
}
|
||||
}
|
||||
|
||||
self.pendingSelections = null;
|
||||
self.pendingPicker = null;
|
||||
self.pendingRow = null;
|
||||
self.hideDropdown();
|
||||
});
|
||||
|
||||
// Cancel
|
||||
// Cancel - discard pending selections (no changes to chips)
|
||||
this.$dropdown.on('click', '.btn-cancel-dropdown', function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
// Just discard pending - chips remain unchanged
|
||||
self.pendingSelections = null;
|
||||
self.pendingPicker = null;
|
||||
self.pendingRow = null;
|
||||
self.hideDropdown();
|
||||
});
|
||||
|
||||
@@ -1128,21 +1230,6 @@
|
||||
self.refreshSearch();
|
||||
});
|
||||
|
||||
// View mode change
|
||||
this.$dropdown.on('change', '.view-mode-select', function() {
|
||||
var value = $(this).val();
|
||||
self.viewMode = value;
|
||||
self.$dropdown.removeClass('view-list view-tree view-cols-2 view-cols-3 view-cols-4 view-cols-5 view-cols-6 view-cols-7 view-cols-8');
|
||||
self.$dropdown.addClass('view-' + value);
|
||||
|
||||
var searchEntity = self.activeGroup ? self.activeGroup.searchEntity : '';
|
||||
if (value === 'tree' && (searchEntity === 'categories' || searchEntity === 'cms_categories')) {
|
||||
self.loadCategoryTree();
|
||||
} else if (value !== 'tree') {
|
||||
self.performSearch();
|
||||
}
|
||||
});
|
||||
|
||||
// Tree view: Toggle expand/collapse
|
||||
this.$dropdown.on('click', '.category-tree .tree-toggle', function(e) {
|
||||
e.stopPropagation();
|
||||
@@ -1161,32 +1248,22 @@
|
||||
}
|
||||
});
|
||||
|
||||
// Tree view: Item click (select/deselect)
|
||||
// Tree view: Item click (select/deselect) - PENDING mode
|
||||
this.$dropdown.on('click', '.category-tree .tree-item', function(e) {
|
||||
if ($(e.target).closest('.tree-toggle, .btn-select-children').length) {
|
||||
if ($(e.target).closest('.tree-toggle, .btn-select-children, .tree-count').length) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Blur any focused input so Ctrl+A works for select all
|
||||
$(document.activeElement).filter('input, textarea').blur();
|
||||
|
||||
var $item = $(this);
|
||||
var id = $item.data('id');
|
||||
var id = parseInt($item.data('id'), 10);
|
||||
var name = $item.data('name');
|
||||
var isSelected = $item.hasClass('selected');
|
||||
|
||||
if (!self.activeGroup) return;
|
||||
|
||||
var $block = self.$wrapper.find('.target-block[data-block-type="' + self.activeGroup.blockType + '"]');
|
||||
var $group = $block.find('.selection-group[data-group-index="' + self.activeGroup.groupIndex + '"]');
|
||||
var $picker;
|
||||
var $row;
|
||||
|
||||
if (self.activeGroup.section === 'include') {
|
||||
$picker = $group.find('.include-picker');
|
||||
$row = $group.find('.group-include');
|
||||
} else {
|
||||
var $excludeRow = $group.find('.exclude-row[data-exclude-index="' + self.activeGroup.excludeIndex + '"]');
|
||||
$picker = $excludeRow.find('.exclude-picker');
|
||||
$row = $excludeRow;
|
||||
}
|
||||
if (!self.pendingSelections) self.pendingSelections = [];
|
||||
|
||||
var $allItems = self.$dropdown.find('.tree-item');
|
||||
|
||||
@@ -1200,27 +1277,43 @@
|
||||
};
|
||||
|
||||
if (isSelected) {
|
||||
self.removeSelection($picker, id);
|
||||
$item.toggleClass('selected');
|
||||
self.serializeAllBlocks($row);
|
||||
updateCount();
|
||||
// Remove from pending
|
||||
self.pendingSelections = self.pendingSelections.filter(function(s) {
|
||||
return parseInt(s.id, 10) !== id;
|
||||
});
|
||||
$item.removeClass('selected');
|
||||
} else {
|
||||
var currentSelection = self.getCurrentSingleSelection();
|
||||
if (currentSelection) {
|
||||
var newEntityType = self.activeGroup.blockType;
|
||||
self.showReplaceConfirmation(currentSelection, { name: name, entityType: newEntityType }, function() {
|
||||
self.$dropdown.find('.tree-item.selected').removeClass('selected');
|
||||
self.addSelection($picker, id, name, $item.data());
|
||||
$item.addClass('selected');
|
||||
self.serializeAllBlocks($row);
|
||||
updateCount();
|
||||
// Add to pending
|
||||
var exists = self.pendingSelections.some(function(s) {
|
||||
return parseInt(s.id, 10) === id;
|
||||
});
|
||||
if (!exists) {
|
||||
self.pendingSelections.push({
|
||||
id: id,
|
||||
name: name,
|
||||
data: $item.data()
|
||||
});
|
||||
} else {
|
||||
self.addSelection($picker, id, name, $item.data());
|
||||
$item.toggleClass('selected');
|
||||
self.serializeAllBlocks($row);
|
||||
updateCount();
|
||||
}
|
||||
$item.addClass('selected');
|
||||
}
|
||||
|
||||
updateCount();
|
||||
});
|
||||
|
||||
// Tree view: Product/page count click - show preview
|
||||
this.$dropdown.on('click', '.category-tree .tree-count.clickable', function(e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
var $count = $(this);
|
||||
var categoryId = $count.data('category-id');
|
||||
var $item = $count.closest('.tree-item');
|
||||
var categoryName = $item.data('name');
|
||||
var entityType = self.$dropdown.find('.category-tree').data('entity-type') || 'categories';
|
||||
|
||||
if ($count.hasClass('popover-open')) {
|
||||
self.hidePreviewPopover();
|
||||
} else {
|
||||
self.showCategoryItemsPreview($count, categoryId, categoryName, entityType);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1342,6 +1435,11 @@
|
||||
|
||||
clearTimeout(self.refineTimeout);
|
||||
self.refineTimeout = setTimeout(function() {
|
||||
// For tree view, filter client-side instead of server refresh
|
||||
if (self.viewMode === 'tree') {
|
||||
self.filterCategoryTree(query);
|
||||
return;
|
||||
}
|
||||
self.refreshSearch();
|
||||
}, 300);
|
||||
});
|
||||
@@ -1352,6 +1450,11 @@
|
||||
self.refineQuery = '';
|
||||
self.$dropdown.find('.refine-input').val('');
|
||||
$(this).hide();
|
||||
// For tree view, filter client-side instead of server refresh
|
||||
if (self.viewMode === 'tree') {
|
||||
self.filterCategoryTree('');
|
||||
return;
|
||||
}
|
||||
self.refreshSearch();
|
||||
});
|
||||
|
||||
@@ -1589,6 +1692,7 @@
|
||||
// View mode select change
|
||||
this.$dropdown.on('change', '.view-mode-select', function() {
|
||||
var mode = $(this).val();
|
||||
var prevMode = self.viewMode;
|
||||
self.viewMode = mode;
|
||||
|
||||
// Remove all view mode classes and add the new one
|
||||
@@ -1596,12 +1700,18 @@
|
||||
.removeClass('view-list view-tree view-cols-2 view-cols-3 view-cols-4 view-cols-5 view-cols-6 view-cols-7 view-cols-8')
|
||||
.addClass('view-' + mode.replace('cols-', 'cols-'));
|
||||
|
||||
// For tree view, load the category tree
|
||||
if (mode === 'tree') {
|
||||
// For tree view, load the category tree (only for categories/cms_categories)
|
||||
var searchEntity = self.activeGroup ? self.activeGroup.searchEntity : '';
|
||||
if (mode === 'tree' && (searchEntity === 'categories' || searchEntity === 'cms_categories')) {
|
||||
self.loadCategoryTree();
|
||||
} else {
|
||||
// Re-render current results with new view mode
|
||||
self.renderSearchResults(false);
|
||||
} else if (mode !== 'tree') {
|
||||
// If switching FROM tree mode, need to refresh search to load data
|
||||
if (prevMode === 'tree') {
|
||||
self.refreshSearch();
|
||||
} else {
|
||||
// Re-render current results with new view mode
|
||||
self.renderSearchResults(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1654,16 +1764,21 @@
|
||||
$(document).on('keydown', function(e) {
|
||||
if (!self.$dropdown || !self.$dropdown.hasClass('show')) return;
|
||||
|
||||
// Ctrl+A / Cmd+A - Select All
|
||||
// Allow default behavior in input/textarea fields
|
||||
var isInputFocused = $(document.activeElement).is('input, textarea');
|
||||
|
||||
// Ctrl+A / Cmd+A - Select All (only when not in input)
|
||||
if ((e.ctrlKey || e.metaKey) && e.keyCode === 65) {
|
||||
if (isInputFocused) return; // Let browser select text
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
self.$dropdown.find('.btn-select-all').trigger('click');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Ctrl+D / Cmd+D - Clear/Deselect all
|
||||
// Ctrl+D / Cmd+D - Clear/Deselect all (only when not in input)
|
||||
if ((e.ctrlKey || e.metaKey) && e.keyCode === 68) {
|
||||
if (isInputFocused) return; // Let browser handle
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
self.$dropdown.find('.btn-clear-selection').trigger('click');
|
||||
|
||||
@@ -104,7 +104,9 @@
|
||||
},
|
||||
|
||||
updateFilterPanelForEntity: function(entityType) {
|
||||
if (!this.$dropdown) return;
|
||||
if (!this.$dropdown) {
|
||||
return;
|
||||
}
|
||||
|
||||
var $panel = this.$dropdown.find('.filter-panel');
|
||||
|
||||
@@ -115,12 +117,20 @@
|
||||
$panel.find('.filter-row[data-entity="' + entityType + '"]').show();
|
||||
$panel.find('.filter-row-entity-' + entityType.replace('_', '-')).show();
|
||||
|
||||
// Enable/disable tree view option
|
||||
var $treeOption = this.$dropdown.find('.tree-view-option');
|
||||
if (entityType === 'categories' || entityType === 'cms_categories') {
|
||||
$treeOption.prop('disabled', false).show();
|
||||
} else {
|
||||
$treeOption.prop('disabled', true).hide();
|
||||
// Show/hide tree view option based on entity type
|
||||
var isCategory = (entityType === 'categories' || entityType === 'cms_categories');
|
||||
this.$dropdown.find('.tree-view-option').toggle(isCategory);
|
||||
|
||||
// Default to tree view for categories (only if currently on list mode)
|
||||
if (isCategory && this.viewMode === 'list') {
|
||||
this.viewMode = 'tree';
|
||||
this.$dropdown.find('.view-mode-select').val('tree');
|
||||
this.$dropdown.removeClass('view-list view-cols-2 view-cols-3 view-cols-4 view-cols-5 view-cols-6 view-cols-7 view-cols-8').addClass('view-tree');
|
||||
} else if (!isCategory && this.viewMode === 'tree') {
|
||||
// If switching away from categories while in tree mode, switch to list
|
||||
this.viewMode = 'list';
|
||||
this.$dropdown.find('.view-mode-select').val('list');
|
||||
this.$dropdown.removeClass('view-tree view-cols-2 view-cols-3 view-cols-4 view-cols-5 view-cols-6 view-cols-7 view-cols-8').addClass('view-list');
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -200,6 +200,8 @@
|
||||
updateTabBadges: function() {
|
||||
var self = this;
|
||||
|
||||
// Collect all block types with data and set loading state
|
||||
var blockTypesWithData = [];
|
||||
this.$wrapper.find('.target-block-tab').each(function() {
|
||||
var $tab = $(this);
|
||||
var blockType = $tab.data('blockType');
|
||||
@@ -216,9 +218,7 @@
|
||||
$tab.append('<span class="tab-badge loading"><i class="icon-spinner icon-spin"></i></span>');
|
||||
}
|
||||
$tab.addClass('has-data');
|
||||
|
||||
// Fetch real product count
|
||||
self.fetchProductCount(blockType, $tab);
|
||||
blockTypesWithData.push(blockType);
|
||||
} else {
|
||||
$badge.remove();
|
||||
$tab.removeClass('has-data');
|
||||
@@ -227,6 +227,11 @@
|
||||
|
||||
// Update target switch state based on whether any data exists
|
||||
this.updateTargetSwitchState();
|
||||
|
||||
// Fetch all counts in a single bulk request
|
||||
if (blockTypesWithData.length > 0) {
|
||||
this.fetchAllCounts(blockTypesWithData);
|
||||
}
|
||||
},
|
||||
|
||||
updateTargetSwitchState: function() {
|
||||
@@ -252,6 +257,100 @@
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch counts for all block types in a single bulk AJAX request
|
||||
* @param {Array} blockTypes - Array of block type strings to fetch counts for
|
||||
*/
|
||||
fetchAllCounts: function(blockTypes) {
|
||||
var self = this;
|
||||
|
||||
// Read saved data from hidden input
|
||||
var $hiddenInput = this.$wrapper.find('input[name="' + this.config.name + '"]');
|
||||
var savedData = {};
|
||||
try {
|
||||
savedData = JSON.parse($hiddenInput.val() || '{}');
|
||||
} catch (e) {
|
||||
savedData = {};
|
||||
}
|
||||
|
||||
// Build conditions object for all requested block types
|
||||
var conditions = {};
|
||||
blockTypes.forEach(function(blockType) {
|
||||
var groups = (savedData[blockType] && savedData[blockType].groups) ? savedData[blockType].groups : [];
|
||||
if (groups.length > 0) {
|
||||
conditions[blockType] = { groups: groups };
|
||||
}
|
||||
});
|
||||
|
||||
// If no valid conditions, remove loading spinners
|
||||
if (Object.keys(conditions).length === 0) {
|
||||
blockTypes.forEach(function(blockType) {
|
||||
var $tab = self.$wrapper.find('.target-block-tab[data-block-type="' + blockType + '"]');
|
||||
$tab.find('.tab-badge').remove();
|
||||
$tab.removeClass('has-data');
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Single bulk AJAX request for all counts
|
||||
$.ajax({
|
||||
url: this.config.ajaxUrl,
|
||||
type: 'POST',
|
||||
dataType: 'json',
|
||||
data: {
|
||||
ajax: 1,
|
||||
action: 'previewEntitySelectorBulk',
|
||||
trait: 'EntitySelector',
|
||||
conditions: JSON.stringify(conditions)
|
||||
},
|
||||
success: function(response) {
|
||||
if (response.success && response.counts) {
|
||||
// Update each tab with its count
|
||||
Object.keys(response.counts).forEach(function(blockType) {
|
||||
var count = response.counts[blockType];
|
||||
var $tab = self.$wrapper.find('.target-block-tab[data-block-type="' + blockType + '"]');
|
||||
var $badge = $tab.find('.tab-badge');
|
||||
|
||||
if ($badge.length) {
|
||||
$badge.removeClass('loading').html('<i class="icon-eye"></i> ' + count);
|
||||
// Store preview data for later popover use
|
||||
$tab.data('previewData', { count: count, success: true });
|
||||
}
|
||||
});
|
||||
|
||||
// Handle any block types not in response (set count to 0 or remove badge)
|
||||
blockTypes.forEach(function(blockType) {
|
||||
if (!(blockType in response.counts)) {
|
||||
var $tab = self.$wrapper.find('.target-block-tab[data-block-type="' + blockType + '"]');
|
||||
$tab.find('.tab-badge').remove();
|
||||
$tab.removeClass('has-data');
|
||||
}
|
||||
});
|
||||
|
||||
self.updateHeaderTotalCount();
|
||||
} else {
|
||||
console.error('[EntitySelector] Bulk preview failed:', response.error || 'Unknown error');
|
||||
// Remove loading spinners on error
|
||||
blockTypes.forEach(function(blockType) {
|
||||
var $tab = self.$wrapper.find('.target-block-tab[data-block-type="' + blockType + '"]');
|
||||
$tab.find('.tab-badge').remove();
|
||||
});
|
||||
}
|
||||
},
|
||||
error: function(xhr, status, error) {
|
||||
console.error('[EntitySelector] Bulk AJAX error:', status, error);
|
||||
// Remove loading spinners on error
|
||||
blockTypes.forEach(function(blockType) {
|
||||
var $tab = self.$wrapper.find('.target-block-tab[data-block-type="' + blockType + '"]');
|
||||
$tab.find('.tab-badge').remove();
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch count for a single block type (legacy, used for single updates)
|
||||
*/
|
||||
fetchProductCount: function(blockType, $tab) {
|
||||
var self = this;
|
||||
var data = {};
|
||||
@@ -310,10 +409,12 @@
|
||||
// Update header total count
|
||||
self.updateHeaderTotalCount();
|
||||
} else {
|
||||
console.error('[EntitySelector] Preview failed for', blockType, ':', response.error || 'Unknown error');
|
||||
$tab.find('.tab-badge').remove();
|
||||
}
|
||||
},
|
||||
error: function() {
|
||||
error: function(xhr, status, error) {
|
||||
console.error('[EntitySelector] AJAX error for', blockType, ':', status, error);
|
||||
$tab.find('.tab-badge').remove();
|
||||
self.updateHeaderTotalCount();
|
||||
}
|
||||
@@ -337,7 +438,8 @@
|
||||
|
||||
var $totalBadge = this.$wrapper.find('.trait-total-count');
|
||||
if (total > 0) {
|
||||
$totalBadge.text(total).show();
|
||||
$totalBadge.find('.count-value').text(total);
|
||||
$totalBadge.show();
|
||||
} else {
|
||||
$totalBadge.hide();
|
||||
}
|
||||
@@ -710,40 +812,98 @@
|
||||
return false;
|
||||
},
|
||||
|
||||
/**
|
||||
* Update all condition counts using a single bulk AJAX request
|
||||
*/
|
||||
updateAllConditionCounts: function() {
|
||||
var self = this;
|
||||
var conditions = {};
|
||||
var conditionElements = {};
|
||||
var conditionIndex = 0;
|
||||
|
||||
// Collect all conditions from all active groups
|
||||
this.$wrapper.find('.target-block.active .selection-group').each(function() {
|
||||
self.updateGroupCounts($(this));
|
||||
var $group = $(this);
|
||||
var $block = $group.closest('.target-block');
|
||||
var blockType = $block.data('blockType') || 'products';
|
||||
|
||||
// Process include row
|
||||
var $include = $group.find('.group-include');
|
||||
if ($include.length) {
|
||||
var includeData = self.getConditionData($include, blockType);
|
||||
if (includeData) {
|
||||
var id = 'c' + conditionIndex++;
|
||||
conditions[id] = includeData.condition;
|
||||
conditionElements[id] = includeData.$countEl;
|
||||
}
|
||||
}
|
||||
|
||||
// Process exclude rows
|
||||
$group.find('.exclude-row').each(function() {
|
||||
var excludeData = self.getConditionData($(this), blockType);
|
||||
if (excludeData) {
|
||||
var id = 'c' + conditionIndex++;
|
||||
conditions[id] = excludeData.condition;
|
||||
conditionElements[id] = excludeData.$countEl;
|
||||
}
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
updateGroupCounts: function($group) {
|
||||
var self = this;
|
||||
|
||||
// Update include count
|
||||
var $include = $group.find('.group-include');
|
||||
if ($include.length) {
|
||||
this.updateConditionCount($include);
|
||||
// If no conditions, nothing to do
|
||||
if (Object.keys(conditions).length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Update each exclude row count
|
||||
$group.find('.exclude-row').each(function() {
|
||||
self.updateConditionCount($(this));
|
||||
// Make single bulk AJAX request
|
||||
$.ajax({
|
||||
url: this.config.ajaxUrl,
|
||||
type: 'POST',
|
||||
dataType: 'json',
|
||||
data: {
|
||||
ajax: 1,
|
||||
action: 'countConditionMatchesBulk',
|
||||
trait: 'EntitySelector',
|
||||
conditions: JSON.stringify(conditions)
|
||||
},
|
||||
success: function(response) {
|
||||
if (response && response.success && response.counts) {
|
||||
// Update each count element with its result
|
||||
Object.keys(response.counts).forEach(function(id) {
|
||||
var count = response.counts[id] || 0;
|
||||
var $countEl = conditionElements[id];
|
||||
if ($countEl && $countEl.length) {
|
||||
$countEl.removeClass('no-matches clickable');
|
||||
if (count === 0) {
|
||||
$countEl.find('.preview-count').text(count);
|
||||
$countEl.addClass('no-matches').show();
|
||||
} else {
|
||||
$countEl.find('.preview-count').text(count);
|
||||
$countEl.addClass('clickable').show();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
// Note: Group totals are updated on-demand when user interacts, not on initial load
|
||||
},
|
||||
error: function() {
|
||||
// Hide all count elements on error
|
||||
Object.keys(conditionElements).forEach(function(id) {
|
||||
var $countEl = conditionElements[id];
|
||||
if ($countEl && $countEl.length) {
|
||||
$countEl.hide().removeClass('clickable');
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Update group total count (include - excludes)
|
||||
this.updateGroupTotalCount($group);
|
||||
},
|
||||
|
||||
updateConditionCount: function($row) {
|
||||
var self = this;
|
||||
var trans = this.config.trans || {};
|
||||
|
||||
// Find the count element - in method-selector-wrapper for include, in exclude-header-row for exclude
|
||||
/**
|
||||
* Extract condition data from a row for bulk counting
|
||||
*/
|
||||
getConditionData: function($row, blockType) {
|
||||
var $countEl = $row.find('.method-selector-wrapper > .condition-match-count, > .exclude-header-row .condition-match-count').first();
|
||||
if (!$countEl.length) return;
|
||||
if (!$countEl.length) return null;
|
||||
|
||||
// Determine if this is an include or exclude row
|
||||
var isExclude = $row.hasClass('exclude-row');
|
||||
var $methodSelect = isExclude
|
||||
? $row.find('.exclude-method-select')
|
||||
@@ -752,10 +912,9 @@
|
||||
var method = $methodSelect.val();
|
||||
if (!method) {
|
||||
$countEl.hide();
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get the picker and extract values
|
||||
var $picker = isExclude
|
||||
? $row.find('.exclude-picker')
|
||||
: $row.find('.include-picker');
|
||||
@@ -767,9 +926,87 @@
|
||||
var hasNoValues = !values ||
|
||||
(Array.isArray(values) && values.length === 0) ||
|
||||
(typeof values === 'object' && !Array.isArray(values) && (
|
||||
// For combination_attributes, check if attributes object is empty
|
||||
(valueType === 'combination_attributes' && values.attributes !== undefined && Object.keys(values.attributes).length === 0) ||
|
||||
// For other objects, check if completely empty
|
||||
(valueType !== 'combination_attributes' && Object.keys(values).length === 0)
|
||||
));
|
||||
if (valueType !== 'none' && valueType !== 'boolean' && hasNoValues) {
|
||||
$countEl.hide();
|
||||
return null;
|
||||
}
|
||||
|
||||
// Show loading spinner
|
||||
$countEl.find('.preview-count').html('<i class="icon-spinner icon-spin"></i>');
|
||||
$countEl.removeClass('clickable no-matches').show();
|
||||
|
||||
// Store condition data on badge for popover
|
||||
$countEl.data('conditionData', {
|
||||
method: method,
|
||||
values: values,
|
||||
blockType: blockType,
|
||||
isExclude: isExclude
|
||||
});
|
||||
|
||||
return {
|
||||
condition: {
|
||||
method: method,
|
||||
values: values,
|
||||
block_type: blockType
|
||||
},
|
||||
$countEl: $countEl
|
||||
};
|
||||
},
|
||||
|
||||
updateGroupCounts: function($group) {
|
||||
var self = this;
|
||||
var $block = $group.closest('.target-block');
|
||||
var blockType = $block.data('blockType') || 'products';
|
||||
|
||||
// Update include count
|
||||
var $include = $group.find('.group-include');
|
||||
if ($include.length) {
|
||||
this.updateConditionCount($include, blockType);
|
||||
}
|
||||
|
||||
// Update each exclude row count
|
||||
$group.find('.exclude-row').each(function() {
|
||||
self.updateConditionCount($(this), blockType);
|
||||
});
|
||||
|
||||
// Update group total count (include - excludes)
|
||||
this.updateGroupTotalCount($group);
|
||||
},
|
||||
|
||||
/**
|
||||
* Update a single condition count (used for individual updates after user changes)
|
||||
*/
|
||||
updateConditionCount: function($row, blockType) {
|
||||
var self = this;
|
||||
|
||||
var $countEl = $row.find('.method-selector-wrapper > .condition-match-count, > .exclude-header-row .condition-match-count').first();
|
||||
if (!$countEl.length) return;
|
||||
|
||||
var isExclude = $row.hasClass('exclude-row');
|
||||
var $methodSelect = isExclude
|
||||
? $row.find('.exclude-method-select')
|
||||
: $row.find('.include-method-select');
|
||||
|
||||
var method = $methodSelect.val();
|
||||
if (!method) {
|
||||
$countEl.hide();
|
||||
return;
|
||||
}
|
||||
|
||||
var $picker = isExclude
|
||||
? $row.find('.exclude-picker')
|
||||
: $row.find('.include-picker');
|
||||
|
||||
var valueType = $picker.data('valueType') || 'none';
|
||||
var values = this.getPickerValues($picker, valueType);
|
||||
|
||||
var hasNoValues = !values ||
|
||||
(Array.isArray(values) && values.length === 0) ||
|
||||
(typeof values === 'object' && !Array.isArray(values) && (
|
||||
(valueType === 'combination_attributes' && values.attributes !== undefined && Object.keys(values.attributes).length === 0) ||
|
||||
(valueType !== 'combination_attributes' && Object.keys(values).length === 0)
|
||||
));
|
||||
if (valueType !== 'none' && valueType !== 'boolean' && hasNoValues) {
|
||||
@@ -777,15 +1014,14 @@
|
||||
return;
|
||||
}
|
||||
|
||||
// Get block type
|
||||
var $block = $row.closest('.target-block');
|
||||
var blockType = $block.data('blockType') || 'products';
|
||||
if (!blockType) {
|
||||
var $block = $row.closest('.target-block');
|
||||
blockType = $block.data('blockType') || 'products';
|
||||
}
|
||||
|
||||
// Show loading
|
||||
$countEl.find('.preview-count').html('<i class="icon-spinner icon-spin"></i>');
|
||||
$countEl.removeClass('clickable no-matches').show();
|
||||
|
||||
// Store condition data on badge for popover
|
||||
$countEl.data('conditionData', {
|
||||
method: method,
|
||||
values: values,
|
||||
@@ -813,7 +1049,6 @@
|
||||
$countEl.find('.preview-count').text(count);
|
||||
$countEl.addClass('no-matches').show();
|
||||
} else {
|
||||
// Show count, make clickable for preview popover
|
||||
$countEl.find('.preview-count').text(count);
|
||||
$countEl.addClass('clickable').show();
|
||||
}
|
||||
@@ -917,6 +1152,17 @@
|
||||
html += '</button>';
|
||||
|
||||
$excludesDiv.addClass('has-excludes').html(html);
|
||||
|
||||
// Enhance the first exclude method select with styled dropdown
|
||||
var $firstRow = $excludesDiv.find('.exclude-row[data-exclude-index="0"]');
|
||||
var $firstSelect = $firstRow.find('.exclude-method-select');
|
||||
this.enhanceMethodSelect($firstSelect);
|
||||
|
||||
// Update method info placeholder for initial selection
|
||||
var blockType = $block.data('blockType');
|
||||
var initialMethod = $firstSelect.val();
|
||||
this.updateMethodInfoPlaceholder($firstRow.find('.method-selector-wrapper'), initialMethod, blockType);
|
||||
|
||||
this.updateMethodSelectorLock($group, true);
|
||||
this.serializeAllBlocks();
|
||||
},
|
||||
@@ -937,7 +1183,13 @@
|
||||
|
||||
// Enhance the exclude method select with styled dropdown
|
||||
var $newRow = $container.find('.exclude-row[data-exclude-index="' + excludeIndex + '"]');
|
||||
this.enhanceMethodSelect($newRow.find('.exclude-method-select'));
|
||||
var $newSelect = $newRow.find('.exclude-method-select');
|
||||
this.enhanceMethodSelect($newSelect);
|
||||
|
||||
// Update method info placeholder for initial selection
|
||||
var blockType = $block.data('blockType');
|
||||
var initialMethod = $newSelect.val();
|
||||
this.updateMethodInfoPlaceholder($newRow.find('.method-selector-wrapper'), initialMethod, blockType);
|
||||
|
||||
this.serializeAllBlocks();
|
||||
},
|
||||
|
||||
@@ -844,7 +844,7 @@
|
||||
}
|
||||
|
||||
$wrapper.addClass('selector-locked');
|
||||
if (!$wrapper.find('.mpr-info-wrapper').length) {
|
||||
if (!$wrapper.find('.lock-indicator').length) {
|
||||
var lockHtml = '<span class="mpr-info-wrapper lock-indicator">' +
|
||||
'<i class="icon-lock"></i>' +
|
||||
'<span class="mpr-tooltip">' +
|
||||
|
||||
@@ -1091,6 +1091,145 @@
|
||||
});
|
||||
},
|
||||
|
||||
// =========================================================================
|
||||
// CATEGORY ITEMS PREVIEW (products/pages in a category)
|
||||
// =========================================================================
|
||||
|
||||
showCategoryItemsPreview: function($badge, categoryId, categoryName, entityType) {
|
||||
var self = this;
|
||||
|
||||
this.hidePreviewPopover();
|
||||
|
||||
$badge.addClass('popover-open loading');
|
||||
this.$activeBadge = $badge;
|
||||
|
||||
var isProducts = (entityType === 'categories');
|
||||
var entityLabelPlural = isProducts ? 'products' : 'pages';
|
||||
var action = isProducts ? 'previewCategoryProducts' : 'previewCategoryPages';
|
||||
|
||||
$.ajax({
|
||||
url: this.config.ajaxUrl,
|
||||
type: 'POST',
|
||||
dataType: 'json',
|
||||
data: {
|
||||
ajax: 1,
|
||||
action: action,
|
||||
trait: 'EntitySelector',
|
||||
category_id: categoryId,
|
||||
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: entityLabelPlural,
|
||||
previewType: 'category-items',
|
||||
context: { categoryId: categoryId, categoryName: categoryName, entityType: entityType },
|
||||
onLoadMore: function($btn) {
|
||||
self.loadMoreCategoryItems($btn);
|
||||
},
|
||||
onFilter: function(query) {
|
||||
self.filterCategoryItems(query);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
$badge.removeClass('popover-open');
|
||||
self.$activeBadge = null;
|
||||
}
|
||||
},
|
||||
error: function() {
|
||||
$badge.removeClass('loading popover-open');
|
||||
self.$activeBadge = null;
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
loadMoreCategoryItems: function($btn) {
|
||||
var self = this;
|
||||
var ctx = this.previewContext;
|
||||
|
||||
if (!ctx || !ctx.categoryId) return;
|
||||
|
||||
var isProducts = (ctx.entityType === 'categories');
|
||||
var action = isProducts ? 'previewCategoryProducts' : 'previewCategoryPages';
|
||||
|
||||
$btn.prop('disabled', true).find('i').addClass('icon-spin');
|
||||
|
||||
$.ajax({
|
||||
url: this.config.ajaxUrl,
|
||||
type: 'POST',
|
||||
dataType: 'json',
|
||||
data: {
|
||||
ajax: 1,
|
||||
action: action,
|
||||
trait: 'EntitySelector',
|
||||
category_id: ctx.categoryId,
|
||||
offset: this.previewOffset,
|
||||
limit: 10,
|
||||
query: this.previewFilterQuery || ''
|
||||
},
|
||||
success: function(response) {
|
||||
$btn.prop('disabled', false).find('i').removeClass('icon-spin');
|
||||
|
||||
if (response.success && response.items) {
|
||||
self.appendPreviewItems(response.items);
|
||||
self.previewOffset += response.items.length;
|
||||
|
||||
if (!response.hasMore) {
|
||||
$btn.hide();
|
||||
}
|
||||
}
|
||||
},
|
||||
error: function() {
|
||||
$btn.prop('disabled', false).find('i').removeClass('icon-spin');
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
filterCategoryItems: function(query) {
|
||||
var self = this;
|
||||
var ctx = this.previewContext;
|
||||
|
||||
if (!ctx || !ctx.categoryId) {
|
||||
self.showFilterLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
var isProducts = (ctx.entityType === 'categories');
|
||||
var action = isProducts ? 'previewCategoryProducts' : 'previewCategoryPages';
|
||||
|
||||
$.ajax({
|
||||
url: this.config.ajaxUrl,
|
||||
type: 'POST',
|
||||
dataType: 'json',
|
||||
data: {
|
||||
ajax: 1,
|
||||
action: action,
|
||||
trait: 'EntitySelector',
|
||||
category_id: ctx.categoryId,
|
||||
query: query,
|
||||
limit: 10
|
||||
},
|
||||
success: function(response) {
|
||||
self.showFilterLoading(false);
|
||||
|
||||
if (response.success) {
|
||||
self.replacePreviewItems(response.items || [], response.count || 0, response.hasMore || false);
|
||||
self.previewOffset = response.items ? response.items.length : 0;
|
||||
self.previewFilterQuery = query;
|
||||
}
|
||||
},
|
||||
error: function() {
|
||||
self.showFilterLoading(false);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
// =========================================================================
|
||||
// PATTERN PREVIEW MODAL (for regex/pattern matching)
|
||||
// =========================================================================
|
||||
@@ -1214,6 +1353,110 @@
|
||||
.replace(/&/g, '&')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
},
|
||||
|
||||
// =========================================================================
|
||||
// TOTAL COUNT PREVIEW (Header total badge click)
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Show preview popover for total count badge
|
||||
* Displays a summary of all entity types with their counts
|
||||
*/
|
||||
showTotalPreviewPopover: function($badge) {
|
||||
console.log('[EntitySelector] showTotalPreviewPopover called', { badge: $badge[0] });
|
||||
var self = this;
|
||||
var trans = this.config.trans || {};
|
||||
|
||||
this.hidePreviewPopover();
|
||||
|
||||
$badge.addClass('popover-open');
|
||||
this.$activeBadge = $badge;
|
||||
|
||||
// Collect all entity types with data
|
||||
var summaryItems = [];
|
||||
console.log('[EntitySelector] Looking for tabs with data...');
|
||||
this.$wrapper.find('.target-block-tab.has-data').each(function() {
|
||||
var $tab = $(this);
|
||||
var blockType = $tab.data('blockType');
|
||||
var $tabBadge = $tab.find('.tab-badge');
|
||||
var countText = $tabBadge.text().replace(/[^0-9]/g, '');
|
||||
var count = parseInt(countText, 10) || 0;
|
||||
|
||||
if (count > 0) {
|
||||
var blockConfig = self.config.blocks && self.config.blocks[blockType] ? self.config.blocks[blockType] : {};
|
||||
var icon = $tab.find('.tab-label').prev('i').attr('class') || 'icon-cube';
|
||||
var label = $tab.find('.tab-label').text() || blockType;
|
||||
|
||||
summaryItems.push({
|
||||
blockType: blockType,
|
||||
label: label,
|
||||
icon: icon,
|
||||
count: count
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
console.log('[EntitySelector] Summary items collected:', summaryItems);
|
||||
|
||||
// Build popover HTML
|
||||
var totalCount = parseInt($badge.find('.count-value').text(), 10) || 0;
|
||||
console.log('[EntitySelector] Building popover, totalCount:', totalCount);
|
||||
var popoverHtml = '<div class="target-preview-popover total-preview-popover">';
|
||||
popoverHtml += '<div class="preview-popover-header">';
|
||||
popoverHtml += '<span class="preview-popover-title">' + (trans.total_summary || 'Selection Summary') + '</span>';
|
||||
popoverHtml += '<span class="preview-popover-count">' + totalCount + ' ' + (trans.total_items || 'total items') + '</span>';
|
||||
popoverHtml += '</div>';
|
||||
popoverHtml += '<div class="preview-popover-body">';
|
||||
popoverHtml += '<ul class="total-summary-list">';
|
||||
|
||||
for (var i = 0; i < summaryItems.length; i++) {
|
||||
var item = summaryItems[i];
|
||||
popoverHtml += '<li class="total-summary-item" data-block-type="' + item.blockType + '">';
|
||||
popoverHtml += '<i class="' + self.escapeAttr(item.icon) + '"></i>';
|
||||
popoverHtml += '<span class="summary-item-label">' + self.escapeHtml(item.label) + '</span>';
|
||||
popoverHtml += '<span class="summary-item-count">' + item.count + '</span>';
|
||||
popoverHtml += '</li>';
|
||||
}
|
||||
|
||||
popoverHtml += '</ul>';
|
||||
popoverHtml += '</div>';
|
||||
popoverHtml += '</div>';
|
||||
|
||||
var $popover = $(popoverHtml);
|
||||
this.$previewPopover = $popover;
|
||||
|
||||
// Click on item to switch to that tab
|
||||
$popover.on('click', '.total-summary-item', function() {
|
||||
var blockType = $(this).data('blockType');
|
||||
self.hidePreviewPopover();
|
||||
self.switchToBlock(blockType);
|
||||
});
|
||||
|
||||
// Position popover
|
||||
$('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);
|
||||
}
|
||||
|
||||
$popover.hide().fadeIn(150);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -475,331 +475,9 @@
|
||||
this.$dropdown.find('.btn-show-history').prop('disabled', !hasHistory);
|
||||
},
|
||||
|
||||
/**
|
||||
* Load and display category tree view
|
||||
*/
|
||||
loadCategoryTree: function() {
|
||||
var self = this;
|
||||
var $container = this.$dropdown.find('.dropdown-results');
|
||||
var entityType = this.activeGroup ? this.activeGroup.searchEntity : 'categories';
|
||||
|
||||
// Show the dropdown
|
||||
this.$dropdown.addClass('show');
|
||||
|
||||
// Show loading state
|
||||
$container.html('<div class="tree-loading"><i class="icon-spinner icon-spin"></i> Loading category tree...</div>');
|
||||
|
||||
// Use separate cache for each entity type
|
||||
var cacheKey = entityType + 'TreeCache';
|
||||
if (this[cacheKey]) {
|
||||
this.renderCategoryTree(this[cacheKey], entityType);
|
||||
return;
|
||||
}
|
||||
|
||||
$.ajax({
|
||||
url: this.config.ajaxUrl,
|
||||
type: 'POST',
|
||||
dataType: 'json',
|
||||
data: {
|
||||
ajax: 1,
|
||||
action: 'getCategoryTree',
|
||||
trait: 'EntitySelector',
|
||||
entity_type: entityType
|
||||
},
|
||||
success: function(response) {
|
||||
if (response.success && response.categories) {
|
||||
self[cacheKey] = response.categories;
|
||||
self.renderCategoryTree(response.categories, entityType);
|
||||
} else {
|
||||
$container.html('<div class="no-results"><i class="icon-warning"></i> Failed to load category tree</div>');
|
||||
}
|
||||
},
|
||||
error: function(xhr, status, error) {
|
||||
$container.html('<div class="no-results"><i class="icon-warning"></i> Error loading category tree</div>');
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Render category tree structure
|
||||
*/
|
||||
renderCategoryTree: function(categories, entityType) {
|
||||
var self = this;
|
||||
var trans = this.config.trans || {};
|
||||
var $container = this.$dropdown.find('.dropdown-results');
|
||||
var isCmsCategory = entityType === 'cms_categories';
|
||||
var categoryLabel = isCmsCategory ? 'CMS categories' : 'categories';
|
||||
|
||||
// Get selected IDs from current picker
|
||||
var selectedIds = [];
|
||||
if (this.activeGroup) {
|
||||
var $block = this.$wrapper.find('.target-block[data-block-type="' + this.activeGroup.blockType + '"]');
|
||||
var $group = $block.find('.selection-group[data-group-index="' + this.activeGroup.groupIndex + '"]');
|
||||
|
||||
if (this.activeGroup.section === 'include') {
|
||||
var $picker = $group.find('.include-picker');
|
||||
$picker.find('.entity-chip').each(function() {
|
||||
selectedIds.push(parseInt($(this).data('id'), 10));
|
||||
});
|
||||
} else {
|
||||
var $currentExcludeRow = $group.find('.exclude-row[data-exclude-index="' + this.activeGroup.excludeIndex + '"]');
|
||||
var $currentPicker = $currentExcludeRow.find('.exclude-picker');
|
||||
$currentPicker.find('.entity-chip').each(function() {
|
||||
selectedIds.push(parseInt($(this).data('id'), 10));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Build tree HTML
|
||||
var html = '<div class="category-tree" data-entity-type="' + this.escapeAttr(entityType) + '">';
|
||||
|
||||
html += '<div class="tree-container">';
|
||||
|
||||
// Find minimum level (usually 1 or 2)
|
||||
var minLevel = categories.length > 0 ? categories[0].level : 1;
|
||||
|
||||
categories.forEach(function(cat) {
|
||||
var isSelected = selectedIds.indexOf(cat.id) !== -1;
|
||||
var indent = (cat.level - minLevel) * 20;
|
||||
var hasChildren = cat.has_children;
|
||||
|
||||
html += '<div class="tree-item' + (isSelected ? ' selected' : '') + (hasChildren ? ' has-children' : '') + '" ';
|
||||
html += 'data-id="' + cat.id + '" ';
|
||||
html += 'data-parent-id="' + cat.parent_id + '" ';
|
||||
html += 'data-level="' + cat.level + '" ';
|
||||
html += 'data-nleft="' + cat.nleft + '" ';
|
||||
html += 'data-nright="' + cat.nright + '" ';
|
||||
html += 'data-name="' + self.escapeAttr(cat.name) + '" ';
|
||||
html += 'data-subtitle="' + self.escapeAttr(cat.subtitle) + '" ';
|
||||
html += 'style="padding-left: ' + (indent + 8) + 'px;">';
|
||||
|
||||
// Expand/collapse toggle for parents
|
||||
if (hasChildren) {
|
||||
html += '<span class="tree-toggle"><i class="icon-caret-down"></i></span>';
|
||||
} else {
|
||||
html += '<span class="tree-toggle-placeholder"></span>';
|
||||
}
|
||||
|
||||
// Select children button for parents (on the left, near toggle)
|
||||
// Hide in single mode - selecting multiple items doesn't make sense there
|
||||
var isSingleMode = self.config.mode === 'single';
|
||||
if (hasChildren && !isSingleMode) {
|
||||
html += '<button type="button" class="btn-select-children" title="' + (trans.select_with_children || 'Select with all children') + '">';
|
||||
html += '<i class="icon-plus-square"></i>';
|
||||
html += '</button>';
|
||||
} else if (!isSingleMode) {
|
||||
html += '<span class="btn-select-children-placeholder"></span>';
|
||||
}
|
||||
|
||||
// Checkbox
|
||||
html += '<span class="tree-checkbox"><i class="icon-check"></i></span>';
|
||||
|
||||
// Category icon (file icon for CMS categories)
|
||||
var iconClass = isCmsCategory ? 'icon-file-text-o' : ('icon-folder' + (hasChildren ? '' : '-o'));
|
||||
html += '<span class="tree-icon"><i class="' + iconClass + '"></i></span>';
|
||||
|
||||
// Name and subtitle
|
||||
html += '<div class="tree-info">';
|
||||
html += '<span class="tree-name">' + self.escapeHtml(cat.name) + '</span>';
|
||||
html += '<span class="tree-subtitle">' + self.escapeHtml(cat.subtitle) + '</span>';
|
||||
html += '</div>';
|
||||
|
||||
html += '</div>';
|
||||
});
|
||||
|
||||
html += '</div>'; // tree-container
|
||||
html += '</div>'; // category-tree
|
||||
|
||||
$container.html(html);
|
||||
|
||||
// Update results count with appropriate label
|
||||
var selectedCount = $container.find('.tree-item.selected').length;
|
||||
this.$dropdown.find('.results-count').text(categories.length + ' ' + categoryLabel + (selectedCount > 0 ? ' (' + selectedCount + ' selected)' : ''));
|
||||
|
||||
// Update select-children button states based on initial selection
|
||||
var $allItems = $container.find('.tree-item');
|
||||
this.updateSelectChildrenButtons($allItems);
|
||||
|
||||
// Hide load more controls in tree view
|
||||
this.$dropdown.find('.load-more-controls').hide();
|
||||
},
|
||||
|
||||
/**
|
||||
* Filter category tree by search query (client-side filtering)
|
||||
*/
|
||||
filterCategoryTree: function(query) {
|
||||
var self = this;
|
||||
var $container = this.$dropdown.find('.category-tree');
|
||||
if (!$container.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
var $items = $container.find('.tree-item');
|
||||
query = query.toLowerCase().trim();
|
||||
|
||||
if (!query) {
|
||||
// Show all items when query is empty
|
||||
$items.show().removeClass('collapsed');
|
||||
$container.find('.tree-toggle i').removeClass('icon-caret-right').addClass('icon-caret-down');
|
||||
return;
|
||||
}
|
||||
|
||||
// First pass: find matching items and their ancestors
|
||||
var matchingIds = [];
|
||||
var ancestorIds = [];
|
||||
|
||||
$items.each(function() {
|
||||
var $item = $(this);
|
||||
var name = ($item.data('name') || '').toLowerCase();
|
||||
if (name.indexOf(query) !== -1) {
|
||||
matchingIds.push($item.data('id'));
|
||||
// Also mark all ancestors using helper (works for both nleft/nright and parent_id)
|
||||
var ancestors = self.findTreeAncestors($item, $items);
|
||||
for (var i = 0; i < ancestors.length; i++) {
|
||||
ancestorIds.push($(ancestors[i]).data('id'));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Second pass: show/hide items
|
||||
$items.each(function() {
|
||||
var $item = $(this);
|
||||
var id = $item.data('id');
|
||||
if (matchingIds.indexOf(id) !== -1 || ancestorIds.indexOf(id) !== -1) {
|
||||
$item.show().removeClass('collapsed');
|
||||
$item.find('.tree-toggle i').removeClass('icon-caret-right').addClass('icon-caret-down');
|
||||
} else {
|
||||
$item.hide();
|
||||
}
|
||||
});
|
||||
|
||||
// Update count with appropriate label
|
||||
var visibleCount = $items.filter(':visible').length;
|
||||
var selectedCount = $items.filter('.selected').length;
|
||||
var entityType = $container.data('entity-type') || 'categories';
|
||||
var categoryLabel = entityType === 'cms_categories' ? 'CMS categories' : 'categories';
|
||||
this.$dropdown.find('.results-count').text(visibleCount + ' ' + categoryLabel + (selectedCount > 0 ? ' (' + selectedCount + ' selected)' : ''));
|
||||
},
|
||||
|
||||
/**
|
||||
* Find all descendant tree items of a parent.
|
||||
* Works with nleft/nright (product categories) or parent_id (CMS categories).
|
||||
*/
|
||||
findTreeDescendants: function($parent, $allItems) {
|
||||
var nleft = parseInt($parent.data('nleft'), 10);
|
||||
var nright = parseInt($parent.data('nright'), 10);
|
||||
var parentId = parseInt($parent.data('id'), 10);
|
||||
var descendants = [];
|
||||
|
||||
// If nleft/nright are valid (product categories), use nested set
|
||||
if (nleft > 0 && nright > 0 && nright > nleft) {
|
||||
$allItems.each(function() {
|
||||
var $item = $(this);
|
||||
var childNleft = parseInt($item.data('nleft'), 10);
|
||||
var childNright = parseInt($item.data('nright'), 10);
|
||||
if (childNleft > nleft && childNright < nright) {
|
||||
descendants.push($item);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// CMS categories: use parent_id recursively
|
||||
var idsToCheck = [parentId];
|
||||
var processed = {};
|
||||
|
||||
while (idsToCheck.length > 0) {
|
||||
var checkId = idsToCheck.shift();
|
||||
if (processed[checkId]) continue;
|
||||
processed[checkId] = true;
|
||||
|
||||
$allItems.each(function() {
|
||||
var $item = $(this);
|
||||
var itemParentId = parseInt($item.data('parent-id'), 10);
|
||||
var itemId = parseInt($item.data('id'), 10);
|
||||
if (itemParentId === checkId && !processed[itemId]) {
|
||||
descendants.push($item);
|
||||
idsToCheck.push(itemId);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return descendants;
|
||||
},
|
||||
|
||||
/**
|
||||
* Find all ancestor tree items of an item.
|
||||
* Works with nleft/nright (product categories) or parent_id (CMS categories).
|
||||
*/
|
||||
findTreeAncestors: function($item, $allItems) {
|
||||
var nleft = parseInt($item.data('nleft'), 10);
|
||||
var nright = parseInt($item.data('nright'), 10);
|
||||
var ancestors = [];
|
||||
|
||||
// If nleft/nright are valid (product categories), use nested set
|
||||
if (nleft > 0 && nright > 0) {
|
||||
$allItems.each(function() {
|
||||
var $ancestor = $(this);
|
||||
var ancNleft = parseInt($ancestor.data('nleft'), 10);
|
||||
var ancNright = parseInt($ancestor.data('nright'), 10);
|
||||
if (ancNleft < nleft && ancNright > nright) {
|
||||
ancestors.push($ancestor);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// CMS categories: use parent_id chain
|
||||
var parentId = parseInt($item.data('parent-id'), 10);
|
||||
var processed = {};
|
||||
|
||||
while (parentId > 0 && !processed[parentId]) {
|
||||
processed[parentId] = true;
|
||||
$allItems.each(function() {
|
||||
var $ancestor = $(this);
|
||||
var ancestorId = parseInt($ancestor.data('id'), 10);
|
||||
if (ancestorId === parentId) {
|
||||
ancestors.push($ancestor);
|
||||
parentId = parseInt($ancestor.data('parent-id'), 10);
|
||||
return false; // break inner loop
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return ancestors;
|
||||
},
|
||||
|
||||
/**
|
||||
* Update all select-children buttons to reflect current selection state.
|
||||
* Shows minus icon if item and all children are selected, plus icon otherwise.
|
||||
*/
|
||||
updateSelectChildrenButtons: function($allItems) {
|
||||
var self = this;
|
||||
var trans = this.config.trans || {};
|
||||
|
||||
$allItems.filter('.has-children').each(function() {
|
||||
var $item = $(this);
|
||||
var $btn = $item.find('.btn-select-children');
|
||||
if (!$btn.length) return;
|
||||
|
||||
var descendants = self.findTreeDescendants($item, $allItems);
|
||||
|
||||
// Check if parent and ALL descendants are selected
|
||||
var allSelected = $item.hasClass('selected');
|
||||
for (var i = 0; i < descendants.length && allSelected; i++) {
|
||||
if (!$(descendants[i]).hasClass('selected')) {
|
||||
allSelected = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Update button icon and title
|
||||
if (allSelected && descendants.length > 0) {
|
||||
$btn.find('i').removeClass('icon-plus-square').addClass('icon-minus-square');
|
||||
$btn.attr('title', trans.deselect_with_children || 'Deselect with all children');
|
||||
} else {
|
||||
$btn.find('i').removeClass('icon-minus-square').addClass('icon-plus-square');
|
||||
$btn.attr('title', trans.select_with_children || 'Select with all children');
|
||||
}
|
||||
});
|
||||
},
|
||||
// NOTE: Tree methods (loadCategoryTree, renderCategoryTree, filterCategoryTree,
|
||||
// findTreeDescendants, findTreeAncestors, updateSelectChildrenButtons) are
|
||||
// defined in _tree.js which is merged later and takes precedence.
|
||||
|
||||
// =========================================================================
|
||||
// Search History
|
||||
|
||||
359
sources/js/admin/entity-selector/_tree.js
Normal file
359
sources/js/admin/entity-selector/_tree.js
Normal file
@@ -0,0 +1,359 @@
|
||||
/**
|
||||
* Entity Selector - Category Tree Module
|
||||
* Hierarchical tree view for category selection inside the dropdown
|
||||
* @partial _tree.js
|
||||
*
|
||||
* Features:
|
||||
* - Expand/collapse individual nodes
|
||||
* - Expand all / Collapse all
|
||||
* - Select parent with all children button
|
||||
* - Visual tree with indentation
|
||||
* - Product count display
|
||||
* - Search/filter within tree
|
||||
*/
|
||||
|
||||
(function($) {
|
||||
'use strict';
|
||||
|
||||
// Create mixin namespace
|
||||
window._EntitySelectorMixins = window._EntitySelectorMixins || {};
|
||||
|
||||
// Tree mixin
|
||||
window._EntitySelectorMixins.tree = {
|
||||
|
||||
// Tree state
|
||||
treeData: null,
|
||||
treeFlatData: null,
|
||||
|
||||
/**
|
||||
* Load and display category tree in the dropdown
|
||||
* Called when view mode is changed to "tree"
|
||||
*/
|
||||
loadCategoryTree: function() {
|
||||
var self = this;
|
||||
var $results = this.$dropdown.find('.dropdown-results');
|
||||
var trans = this.config.trans || {};
|
||||
var searchEntity = this.activeGroup ? this.activeGroup.searchEntity : 'categories';
|
||||
|
||||
// Show loading
|
||||
$results.html('<div class="tree-loading"><i class="icon-spinner icon-spin"></i> ' +
|
||||
this.escapeHtml(trans.loading || 'Loading...') + '</div>');
|
||||
|
||||
// Fetch tree data
|
||||
$.ajax({
|
||||
url: this.config.ajaxUrl,
|
||||
type: 'POST',
|
||||
dataType: 'json',
|
||||
data: {
|
||||
ajax: 1,
|
||||
action: 'getCategoryTree',
|
||||
trait: 'EntitySelector',
|
||||
entity_type: searchEntity
|
||||
},
|
||||
success: function(response) {
|
||||
if (response.success && response.categories && response.categories.length > 0) {
|
||||
self.treeFlatData = response.categories;
|
||||
self.treeData = self.buildTreeStructure(response.categories);
|
||||
self.renderCategoryTree($results, searchEntity);
|
||||
} else {
|
||||
$results.html('<div class="dropdown-empty">' +
|
||||
self.escapeHtml(trans.no_categories || 'No categories found') + '</div>');
|
||||
}
|
||||
},
|
||||
error: function() {
|
||||
$results.html('<div class="dropdown-error">' +
|
||||
self.escapeHtml(trans.error_loading || 'Failed to load categories') + '</div>');
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Build nested tree structure from flat array
|
||||
* @param {Array} flatData - Flat array with parent_id references
|
||||
* @returns {Array} Nested tree structure
|
||||
*/
|
||||
buildTreeStructure: function(flatData) {
|
||||
var lookup = {};
|
||||
var tree = [];
|
||||
|
||||
// Create lookup and initialize children arrays
|
||||
flatData.forEach(function(item) {
|
||||
lookup[item.id] = $.extend({}, item, { children: [] });
|
||||
});
|
||||
|
||||
// Build tree by assigning children to parents
|
||||
flatData.forEach(function(item) {
|
||||
var node = lookup[item.id];
|
||||
var parentId = parseInt(item.parent_id, 10);
|
||||
|
||||
if (parentId && lookup[parentId]) {
|
||||
lookup[parentId].children.push(node);
|
||||
} else {
|
||||
tree.push(node);
|
||||
}
|
||||
});
|
||||
|
||||
return tree;
|
||||
},
|
||||
|
||||
/**
|
||||
* Render the category tree inside dropdown results
|
||||
* @param {jQuery} $container - The dropdown-results container
|
||||
* @param {string} entityType - 'categories' or 'cms_categories'
|
||||
*/
|
||||
renderCategoryTree: function($container, entityType) {
|
||||
var self = this;
|
||||
var trans = this.config.trans || {};
|
||||
|
||||
// Get currently selected IDs from chips
|
||||
var selectedIds = this.getSelectedIdsFromChips();
|
||||
|
||||
// Build tree HTML
|
||||
var html = '<div class="category-tree" data-entity-type="' + this.escapeAttr(entityType) + '">';
|
||||
|
||||
// Tree toolbar
|
||||
html += '<div class="tree-toolbar">';
|
||||
html += '<button type="button" class="btn-expand-all" title="' +
|
||||
this.escapeAttr(trans.expand_all || 'Expand all') + '">';
|
||||
html += '<i class="icon-plus-square-o"></i> ' + this.escapeHtml(trans.expand_all || 'Expand all');
|
||||
html += '</button>';
|
||||
html += '<button type="button" class="btn-collapse-all" title="' +
|
||||
this.escapeAttr(trans.collapse_all || 'Collapse all') + '">';
|
||||
html += '<i class="icon-minus-square-o"></i> ' + this.escapeHtml(trans.collapse_all || 'Collapse all');
|
||||
html += '</button>';
|
||||
html += '</div>';
|
||||
|
||||
// Tree items
|
||||
html += '<div class="tree-items">';
|
||||
html += this.renderTreeItems(this.treeData, 0, selectedIds);
|
||||
html += '</div>';
|
||||
|
||||
html += '</div>';
|
||||
|
||||
$container.html(html);
|
||||
|
||||
// Update count
|
||||
var totalCount = this.treeFlatData ? this.treeFlatData.length : 0;
|
||||
var selectedCount = selectedIds.length;
|
||||
var categoryLabel = entityType === 'cms_categories' ? 'CMS categories' : 'categories';
|
||||
var countText = totalCount + ' ' + categoryLabel;
|
||||
if (selectedCount > 0) {
|
||||
countText += ' (' + selectedCount + ' selected)';
|
||||
}
|
||||
this.$dropdown.find('.results-count').text(countText);
|
||||
|
||||
// Update select children button states
|
||||
this.updateSelectChildrenButtons(this.$dropdown.find('.tree-item'));
|
||||
},
|
||||
|
||||
/**
|
||||
* Render tree items recursively
|
||||
* @param {Array} nodes - Tree nodes
|
||||
* @param {number} level - Current depth level
|
||||
* @param {Array} selectedIds - Currently selected IDs
|
||||
* @returns {string} HTML string
|
||||
*/
|
||||
renderTreeItems: function(nodes, level, selectedIds) {
|
||||
var self = this;
|
||||
var html = '';
|
||||
var trans = this.config.trans || {};
|
||||
|
||||
nodes.forEach(function(node) {
|
||||
var hasChildren = node.children && node.children.length > 0;
|
||||
var isSelected = selectedIds.indexOf(parseInt(node.id, 10)) !== -1;
|
||||
var indent = level * 20;
|
||||
|
||||
var itemClass = 'tree-item';
|
||||
if (hasChildren) itemClass += ' has-children';
|
||||
if (isSelected) itemClass += ' selected';
|
||||
if (!node.active) itemClass += ' inactive';
|
||||
|
||||
html += '<div class="' + itemClass + '" data-id="' + node.id + '" ';
|
||||
html += 'data-name="' + self.escapeAttr(node.name) + '" ';
|
||||
html += 'data-level="' + level + '" ';
|
||||
html += 'data-parent-id="' + (node.parent_id || 0) + '">';
|
||||
|
||||
// Indentation
|
||||
html += '<span class="tree-indent" style="width: ' + indent + 'px;"></span>';
|
||||
|
||||
// Toggle button (expand/collapse)
|
||||
if (hasChildren) {
|
||||
html += '<span class="tree-toggle"><i class="icon-caret-down"></i></span>';
|
||||
// Select with children button (next to toggle on the left)
|
||||
html += '<button type="button" class="btn-select-children" title="' +
|
||||
self.escapeAttr(trans.select_with_children || 'Select with all children') + '">';
|
||||
html += '<i class="icon-check-square-o"></i>';
|
||||
html += '</button>';
|
||||
} else {
|
||||
html += '<span class="tree-toggle tree-leaf"></span>';
|
||||
}
|
||||
|
||||
// Checkbox indicator
|
||||
html += '<span class="tree-checkbox"><i class="icon-check"></i></span>';
|
||||
|
||||
// Category icon
|
||||
html += '<span class="tree-icon"><i class="icon-folder"></i></span>';
|
||||
|
||||
// Name
|
||||
html += '<span class="tree-name">' + self.escapeHtml(node.name) + '</span>';
|
||||
|
||||
// Product/page count with clickable preview
|
||||
var itemCount = node.product_count || node.page_count || 0;
|
||||
if (itemCount > 0) {
|
||||
var countLabel = node.page_count ? (trans.pages || 'pages') : (trans.products || 'products');
|
||||
html += '<span class="tree-count clickable" data-category-id="' + node.id + '" ';
|
||||
html += 'title="' + self.escapeAttr(itemCount + ' ' + countLabel) + '">';
|
||||
html += '<i class="icon-eye"></i> ' + itemCount;
|
||||
html += '</span>';
|
||||
}
|
||||
|
||||
// Inactive badge
|
||||
if (!node.active) {
|
||||
html += '<span class="tree-badge inactive">' +
|
||||
self.escapeHtml(trans.inactive || 'Inactive') + '</span>';
|
||||
}
|
||||
|
||||
html += '</div>';
|
||||
|
||||
// Render children
|
||||
if (hasChildren) {
|
||||
html += '<div class="tree-children">';
|
||||
html += self.renderTreeItems(node.children, level + 1, selectedIds);
|
||||
html += '</div>';
|
||||
}
|
||||
});
|
||||
|
||||
return html;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get selected IDs from the current picker's chips
|
||||
* @returns {Array} Array of selected IDs
|
||||
*/
|
||||
getSelectedIdsFromChips: function() {
|
||||
var selectedIds = [];
|
||||
|
||||
if (!this.activeGroup) return selectedIds;
|
||||
|
||||
var $block = this.$wrapper.find('.target-block[data-block-type="' + this.activeGroup.blockType + '"]');
|
||||
var $group = $block.find('.selection-group[data-group-index="' + this.activeGroup.groupIndex + '"]');
|
||||
var $picker;
|
||||
|
||||
if (this.activeGroup.section === 'include') {
|
||||
$picker = $group.find('.include-picker');
|
||||
} else {
|
||||
var $excludeRow = $group.find('.exclude-row[data-exclude-index="' + this.activeGroup.excludeIndex + '"]');
|
||||
$picker = $excludeRow.find('.exclude-picker');
|
||||
}
|
||||
|
||||
$picker.find('.entity-chip').each(function() {
|
||||
selectedIds.push(parseInt($(this).data('id'), 10));
|
||||
});
|
||||
|
||||
return selectedIds;
|
||||
},
|
||||
|
||||
/**
|
||||
* Filter category tree by search query
|
||||
* @param {string} query - Search query
|
||||
*/
|
||||
filterCategoryTree: function(query) {
|
||||
var $tree = this.$dropdown.find('.category-tree');
|
||||
if (!$tree.length) return;
|
||||
|
||||
var $items = $tree.find('.tree-item');
|
||||
var $children = $tree.find('.tree-children');
|
||||
query = (query || '').toLowerCase().trim();
|
||||
|
||||
// Remove any inline display styles set by jQuery .toggle()
|
||||
$items.css('display', '');
|
||||
|
||||
if (!query) {
|
||||
$items.removeClass('filtered-out filter-match');
|
||||
$children.removeClass('filter-expanded');
|
||||
return;
|
||||
}
|
||||
|
||||
// Mark all as filtered out first
|
||||
$items.addClass('filtered-out').removeClass('filter-match');
|
||||
|
||||
// Find matching items and show them with their parents
|
||||
$items.each(function() {
|
||||
var $item = $(this);
|
||||
var name = ($item.data('name') || '').toLowerCase();
|
||||
|
||||
if (name.indexOf(query) !== -1) {
|
||||
$item.removeClass('filtered-out');
|
||||
|
||||
// Show parent containers
|
||||
$item.parents('.tree-children').addClass('filter-expanded');
|
||||
$item.parents('.tree-item').removeClass('filtered-out');
|
||||
|
||||
// Show children of matching item
|
||||
$item.next('.tree-children').find('.tree-item').removeClass('filtered-out');
|
||||
$item.next('.tree-children').addClass('filter-expanded');
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Find all descendant tree items of a given item
|
||||
* @param {jQuery} $item - Parent tree item
|
||||
* @param {jQuery} $allItems - All tree items (for performance)
|
||||
* @returns {Array} Array of descendant jQuery elements
|
||||
*/
|
||||
findTreeDescendants: function($item, $allItems) {
|
||||
var descendants = [];
|
||||
var parentId = parseInt($item.data('id'), 10);
|
||||
var level = parseInt($item.data('level'), 10);
|
||||
|
||||
// Find immediate children first
|
||||
var $next = $item.next('.tree-children');
|
||||
if ($next.length) {
|
||||
$next.find('.tree-item').each(function() {
|
||||
descendants.push(this);
|
||||
});
|
||||
}
|
||||
|
||||
return descendants;
|
||||
},
|
||||
|
||||
/**
|
||||
* Update the state of select-children buttons based on selection
|
||||
* @param {jQuery} $allItems - All tree items
|
||||
*/
|
||||
updateSelectChildrenButtons: function($allItems) {
|
||||
var self = this;
|
||||
var trans = this.config.trans || {};
|
||||
|
||||
$allItems.filter('.has-children').each(function() {
|
||||
var $item = $(this);
|
||||
var $btn = $item.find('.btn-select-children');
|
||||
if (!$btn.length) return;
|
||||
|
||||
var $children = $item.next('.tree-children');
|
||||
if (!$children.length) return;
|
||||
|
||||
var $childItems = $children.find('.tree-item');
|
||||
var isParentSelected = $item.hasClass('selected');
|
||||
var allChildrenSelected = true;
|
||||
|
||||
$childItems.each(function() {
|
||||
if (!$(this).hasClass('selected')) {
|
||||
allChildrenSelected = false;
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
if (isParentSelected && allChildrenSelected) {
|
||||
$btn.find('i').removeClass('icon-plus-square').addClass('icon-minus-square');
|
||||
$btn.attr('title', trans.deselect_with_children || 'Deselect with all children');
|
||||
} else {
|
||||
$btn.find('i').removeClass('icon-minus-square').addClass('icon-plus-square');
|
||||
$btn.attr('title', trans.select_with_children || 'Select with all children');
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
})(jQuery);
|
||||
@@ -151,6 +151,13 @@
|
||||
};
|
||||
}
|
||||
return null;
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if entity type supports tree browsing
|
||||
*/
|
||||
supportsTreeBrowsing: function(entityType) {
|
||||
return entityType === 'categories' || entityType === 'cms_categories';
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -196,6 +196,12 @@
|
||||
box-shadow: 0 2px 8px rgba($bg, 0.4);
|
||||
}
|
||||
|
||||
// Focus state - maintain styled appearance
|
||||
&:focus {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px rgba($bg, 0.3), 0 2px 8px rgba($bg, 0.4);
|
||||
}
|
||||
|
||||
// Loading state - spinner icon replaces eye
|
||||
&.loading {
|
||||
cursor: wait;
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
/**
|
||||
* Entity Selector Variables
|
||||
* Bootstrap 4 compatible values for PrestaShop admin theme
|
||||
*
|
||||
* Imports shared variables from prestashop-admin package
|
||||
* and maps them to $es-* prefixed aliases for this package
|
||||
*/
|
||||
|
||||
// Import shared variables from prestashop-admin
|
||||
@use '../../../prestashop-admin/assets/scss/variables' as admin;
|
||||
|
||||
// =============================================================================
|
||||
// Base Colors
|
||||
// =============================================================================
|
||||
@@ -10,38 +16,38 @@
|
||||
$es-white: #ffffff !default;
|
||||
$es-black: #000000 !default;
|
||||
|
||||
// Primary (PrestaShop admin accent)
|
||||
$es-primary: #25b9d7 !default;
|
||||
// Primary (from prestashop-admin)
|
||||
$es-primary: admin.$primary !default;
|
||||
$es-primary-hover: #1a9ab7 !default;
|
||||
$es-primary-light: rgba(37, 185, 215, 0.1) !default;
|
||||
|
||||
// Semantic colors (Bootstrap 4 aligned)
|
||||
$es-success: #28a745 !default;
|
||||
// Semantic colors (from prestashop-admin)
|
||||
$es-success: admin.$success !default;
|
||||
$es-success-light: #d4edda !default;
|
||||
$es-success-dark: #1e7e34 !default;
|
||||
|
||||
$es-danger: #dc3545 !default;
|
||||
$es-danger: admin.$danger !default;
|
||||
$es-danger-light: #f8d7da !default;
|
||||
$es-danger-dark: #bd2130 !default;
|
||||
|
||||
$es-warning: #ffc107 !default;
|
||||
$es-warning: admin.$warning !default;
|
||||
$es-warning-light: #fff3cd !default;
|
||||
|
||||
$es-info: #17a2b8 !default;
|
||||
$es-info: admin.$info !default;
|
||||
$es-info-light: #d1ecf1 !default;
|
||||
|
||||
// =============================================================================
|
||||
// Gray Scale (Bootstrap 4)
|
||||
// =============================================================================
|
||||
|
||||
$es-gray-100: #f8f9fa !default;
|
||||
$es-gray-100: admin.$light !default;
|
||||
$es-gray-200: #e9ecef !default;
|
||||
$es-gray-300: #dee2e6 !default;
|
||||
$es-gray-300: admin.$border-color !default;
|
||||
$es-gray-400: #ced4da !default;
|
||||
$es-gray-500: #adb5bd !default;
|
||||
$es-gray-600: #6c757d !default;
|
||||
$es-gray-600: admin.$secondary !default;
|
||||
$es-gray-700: #495057 !default;
|
||||
$es-gray-800: #343a40 !default;
|
||||
$es-gray-800: admin.$dark !default;
|
||||
$es-gray-900: #212529 !default;
|
||||
|
||||
// Slate (subtle variations)
|
||||
@@ -73,7 +79,7 @@ $es-bg-hover: $es-gray-200 !default;
|
||||
$es-bg-active: $es-gray-200 !default;
|
||||
$es-bg-body: $es-white !default;
|
||||
|
||||
$es-border-color: $es-gray-300 !default;
|
||||
$es-border-color: admin.$border-color !default;
|
||||
$es-border-light: $es-gray-200 !default;
|
||||
$es-border-dark: $es-gray-400 !default;
|
||||
|
||||
@@ -83,22 +89,22 @@ $es-text-muted: $es-gray-600 !default;
|
||||
$es-text-light: $es-gray-500 !default;
|
||||
|
||||
// =============================================================================
|
||||
// Spacing (Bootstrap 4 compatible)
|
||||
// Spacing (Bootstrap 4 compatible, derived from admin.$spacer)
|
||||
// =============================================================================
|
||||
|
||||
$es-spacing-xs: 0.25rem !default; // 4px
|
||||
$es-spacing-sm: 0.5rem !default; // 8px
|
||||
$es-spacing-md: 1rem !default; // 16px
|
||||
$es-spacing-lg: 1.5rem !default; // 24px
|
||||
$es-spacing-xl: 2rem !default; // 32px
|
||||
$es-spacing-xs: admin.$spacer * 0.25 !default; // 4px
|
||||
$es-spacing-sm: admin.$spacer * 0.5 !default; // 8px
|
||||
$es-spacing-md: admin.$spacer !default; // 16px
|
||||
$es-spacing-lg: admin.$spacer * 1.5 !default; // 24px
|
||||
$es-spacing-xl: admin.$spacer * 2 !default; // 32px
|
||||
|
||||
// =============================================================================
|
||||
// Border Radius (Bootstrap 4 compatible)
|
||||
// Border Radius (from prestashop-admin)
|
||||
// =============================================================================
|
||||
|
||||
$es-radius-sm: 0.2rem !default;
|
||||
$es-radius-md: 0.25rem !default;
|
||||
$es-radius-lg: 0.3rem !default;
|
||||
$es-radius-sm: admin.$border-radius-sm !default;
|
||||
$es-radius-md: admin.$border-radius !default;
|
||||
$es-radius-lg: admin.$border-radius-lg !default;
|
||||
$es-radius-xl: 0.5rem !default;
|
||||
$es-radius-full: 50rem !default;
|
||||
|
||||
|
||||
@@ -9,19 +9,169 @@
|
||||
.target-conditions-trait,
|
||||
.entity-selector-trait {
|
||||
|
||||
// Chips container wrapper with toolbar
|
||||
.chips-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-top: $es-spacing-sm;
|
||||
background: $es-slate-50;
|
||||
border: 1px solid $es-border-color;
|
||||
border-radius: $es-radius-md;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
// Chips toolbar - search and actions
|
||||
.chips-toolbar {
|
||||
display: none; // Hidden by default, shown via JS when chips exist
|
||||
align-items: center;
|
||||
gap: $es-spacing-sm;
|
||||
padding: $es-spacing-sm $es-spacing-md;
|
||||
background: $es-white;
|
||||
border-bottom: 1px solid $es-border-color;
|
||||
|
||||
&.has-chips {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
// Search icon
|
||||
> i {
|
||||
color: $es-text-muted;
|
||||
font-size: 14px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.chips-search-input,
|
||||
input.chips-search-input,
|
||||
input.chips-search-input[type="text"] {
|
||||
flex: 1 !important;
|
||||
min-width: 80px !important;
|
||||
max-width: none !important;
|
||||
width: auto !important;
|
||||
height: auto !important;
|
||||
padding: 0 !important;
|
||||
border: none !important;
|
||||
border-bottom: 1px solid transparent !important;
|
||||
border-radius: 0 !important;
|
||||
background: transparent !important;
|
||||
font-size: $es-font-size-sm !important;
|
||||
color: $es-text-primary;
|
||||
box-shadow: none !important;
|
||||
transition: border-color $es-transition-fast;
|
||||
|
||||
&:focus {
|
||||
outline: none !important;
|
||||
border: none !important;
|
||||
border-bottom: 1px solid $es-primary !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: $es-text-muted;
|
||||
}
|
||||
}
|
||||
|
||||
.chips-count {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.25rem 0.625rem;
|
||||
background: $es-primary;
|
||||
color: $es-white;
|
||||
font-size: $es-font-size-xs;
|
||||
font-weight: $es-font-weight-semibold;
|
||||
border-radius: $es-radius-full;
|
||||
white-space: nowrap;
|
||||
|
||||
&.has-filter {
|
||||
background: $es-cyan-500;
|
||||
}
|
||||
|
||||
.count-filtered {
|
||||
font-weight: $es-font-weight-bold;
|
||||
}
|
||||
|
||||
.count-separator {
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
.chips-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $es-spacing-xs;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.btn-chips-clear {
|
||||
@include button-reset;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.375rem 0.75rem;
|
||||
color: $es-white;
|
||||
font-size: $es-font-size-xs;
|
||||
font-weight: $es-font-weight-semibold;
|
||||
background: $es-danger;
|
||||
border-radius: $es-radius-sm;
|
||||
transition: all $es-transition-fast;
|
||||
|
||||
&:hover {
|
||||
background: darken($es-danger, 8%);
|
||||
}
|
||||
|
||||
i {
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
|
||||
// Chips container
|
||||
.entity-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: $es-spacing-xs;
|
||||
padding: $es-spacing-sm 0;
|
||||
min-height: 32px;
|
||||
padding: $es-spacing-md;
|
||||
min-height: 40px;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
|
||||
&:empty {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
// Load more button
|
||||
.chips-load-more {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: $es-spacing-sm $es-spacing-md;
|
||||
background: $es-white;
|
||||
border-top: 1px solid $es-border-color;
|
||||
|
||||
.btn-load-more {
|
||||
@include button-reset;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.5rem 1rem;
|
||||
color: $es-white;
|
||||
font-size: $es-font-size-sm;
|
||||
font-weight: $es-font-weight-semibold;
|
||||
background: $es-primary;
|
||||
border-radius: $es-radius-sm;
|
||||
transition: all $es-transition-fast;
|
||||
|
||||
&:hover {
|
||||
background: $es-primary-hover;
|
||||
}
|
||||
|
||||
i {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Individual chip
|
||||
.entity-chip {
|
||||
display: inline-flex;
|
||||
@@ -33,7 +183,6 @@
|
||||
font-size: $es-font-size-xs;
|
||||
font-weight: $es-font-weight-medium;
|
||||
border-radius: $es-radius-full;
|
||||
max-width: 200px;
|
||||
transition: all $es-transition-fast;
|
||||
|
||||
&:hover {
|
||||
@@ -44,6 +193,12 @@
|
||||
&.has-image {
|
||||
padding-left: 0.25rem;
|
||||
}
|
||||
|
||||
// Hidden by search filter or pagination
|
||||
&.chip-filtered-out,
|
||||
&.chip-paginated-out {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.chip-image {
|
||||
@@ -55,14 +210,26 @@
|
||||
}
|
||||
|
||||
.chip-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
color: $es-text-muted;
|
||||
flex-shrink: 0;
|
||||
|
||||
// Product/entity images inside chip
|
||||
img {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
object-fit: cover;
|
||||
border-radius: $es-radius-sm;
|
||||
}
|
||||
}
|
||||
|
||||
.chip-text,
|
||||
.chip-name {
|
||||
@include text-truncate;
|
||||
// Show full name, no truncation
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.chip-remove {
|
||||
|
||||
@@ -117,7 +117,7 @@
|
||||
|
||||
// Results container
|
||||
.dropdown-results {
|
||||
padding: $es-spacing-sm;
|
||||
padding: 0 $es-spacing-sm;
|
||||
}
|
||||
|
||||
// Results count text
|
||||
@@ -609,80 +609,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
.tree-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: $es-text-muted;
|
||||
cursor: pointer;
|
||||
transition: transform $es-transition-fast;
|
||||
|
||||
i {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.tree-toggle-placeholder {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.btn-select-children {
|
||||
@include button-reset;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
color: $es-primary;
|
||||
border-radius: $es-radius-sm;
|
||||
transition: all $es-transition-fast;
|
||||
|
||||
&:hover {
|
||||
background: $es-primary-light;
|
||||
}
|
||||
|
||||
i {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-select-children-placeholder {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.tree-checkbox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid $es-border-dark;
|
||||
border-radius: 3px;
|
||||
transition: all $es-transition-fast;
|
||||
|
||||
i {
|
||||
display: none;
|
||||
font-size: 10px;
|
||||
color: $es-white;
|
||||
}
|
||||
}
|
||||
|
||||
.tree-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
color: $es-text-muted;
|
||||
|
||||
i {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
// tree-toggle, btn-select-children, tree-checkbox, tree-icon styles in _tree.scss
|
||||
|
||||
.tree-info {
|
||||
display: flex;
|
||||
@@ -1521,14 +1448,11 @@ body > .target-search-dropdown,
|
||||
color: $es-text-muted;
|
||||
}
|
||||
|
||||
.dropdown-footer-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $es-spacing-sm;
|
||||
}
|
||||
|
||||
.btn-cancel-dropdown {
|
||||
@include button-reset;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.375rem 0.75rem;
|
||||
font-size: $es-font-size-sm;
|
||||
color: $es-text-secondary;
|
||||
@@ -1539,6 +1463,12 @@ body > .target-search-dropdown,
|
||||
|
||||
&:hover {
|
||||
background: $es-bg-hover;
|
||||
color: $es-danger;
|
||||
border-color: $es-danger;
|
||||
}
|
||||
|
||||
i {
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
kbd {
|
||||
@@ -1566,6 +1496,11 @@ body > .target-search-dropdown,
|
||||
|
||||
&:hover {
|
||||
background: $es-primary-hover;
|
||||
border-color: $es-primary-hover;
|
||||
}
|
||||
|
||||
i {
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
kbd {
|
||||
@@ -1702,7 +1637,7 @@ body > .target-search-dropdown,
|
||||
|
||||
// Results container
|
||||
.dropdown-results {
|
||||
padding: $es-spacing-sm;
|
||||
padding: 0 $es-spacing-sm;
|
||||
background: $es-white;
|
||||
min-height: 200px;
|
||||
}
|
||||
@@ -1768,16 +1703,16 @@ body > .target-search-dropdown,
|
||||
}
|
||||
}
|
||||
|
||||
// View mode classes (applied to dropdown container)
|
||||
&.view-cols-2 .dropdown-results { @include grid-columns(2); }
|
||||
&.view-cols-3 .dropdown-results { @include grid-columns(3); }
|
||||
&.view-cols-4 .dropdown-results { @include grid-columns(4); }
|
||||
&.view-cols-5 .dropdown-results { @include grid-columns(5); }
|
||||
&.view-cols-6 .dropdown-results { @include grid-columns(6); }
|
||||
&.view-cols-7 .dropdown-results { @include grid-columns(7); }
|
||||
&.view-cols-8 .dropdown-results { @include grid-columns(8); }
|
||||
// View mode classes (applied to dropdown container) - no gap/padding for shared borders
|
||||
&.view-cols-2 .dropdown-results { display: grid; grid-template-columns: repeat(2, 1fr); gap: 0; padding: 0; border-top: 1px solid $es-border-color; border-left: 1px solid $es-border-color; }
|
||||
&.view-cols-3 .dropdown-results { display: grid; grid-template-columns: repeat(3, 1fr); gap: 0; padding: 0; border-top: 1px solid $es-border-color; border-left: 1px solid $es-border-color; }
|
||||
&.view-cols-4 .dropdown-results { display: grid; grid-template-columns: repeat(4, 1fr); gap: 0; padding: 0; border-top: 1px solid $es-border-color; border-left: 1px solid $es-border-color; }
|
||||
&.view-cols-5 .dropdown-results { display: grid; grid-template-columns: repeat(5, 1fr); gap: 0; padding: 0; border-top: 1px solid $es-border-color; border-left: 1px solid $es-border-color; }
|
||||
&.view-cols-6 .dropdown-results { display: grid; grid-template-columns: repeat(6, 1fr); gap: 0; padding: 0; border-top: 1px solid $es-border-color; border-left: 1px solid $es-border-color; }
|
||||
&.view-cols-7 .dropdown-results { display: grid; grid-template-columns: repeat(7, 1fr); gap: 0; padding: 0; border-top: 1px solid $es-border-color; border-left: 1px solid $es-border-color; }
|
||||
&.view-cols-8 .dropdown-results { display: grid; grid-template-columns: repeat(8, 1fr); gap: 0; padding: 0; border-top: 1px solid $es-border-color; border-left: 1px solid $es-border-color; }
|
||||
|
||||
// Grid view item styling (compact cards)
|
||||
// Grid view item styling (compact cards with shared borders)
|
||||
&.view-cols-2,
|
||||
&.view-cols-3,
|
||||
&.view-cols-4,
|
||||
@@ -1789,9 +1724,11 @@ body > .target-search-dropdown,
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
padding: 0;
|
||||
border: 1px solid $es-border-color;
|
||||
border-radius: $es-radius-sm;
|
||||
padding: $es-spacing-sm;
|
||||
border: none;
|
||||
border-right: 1px solid $es-border-color;
|
||||
border-bottom: 1px solid $es-border-color;
|
||||
border-radius: 0;
|
||||
|
||||
.result-checkbox {
|
||||
position: absolute;
|
||||
@@ -1861,6 +1798,15 @@ body > .target-search-dropdown,
|
||||
}
|
||||
}
|
||||
|
||||
// Remove right border from last item in each row (per column count)
|
||||
&.view-cols-2 .dropdown-results .dropdown-item:nth-child(2n) { border-right: none; }
|
||||
&.view-cols-3 .dropdown-results .dropdown-item:nth-child(3n) { border-right: none; }
|
||||
&.view-cols-4 .dropdown-results .dropdown-item:nth-child(4n) { border-right: none; }
|
||||
&.view-cols-5 .dropdown-results .dropdown-item:nth-child(5n) { border-right: none; }
|
||||
&.view-cols-6 .dropdown-results .dropdown-item:nth-child(6n) { border-right: none; }
|
||||
&.view-cols-7 .dropdown-results .dropdown-item:nth-child(7n) { border-right: none; }
|
||||
&.view-cols-8 .dropdown-results .dropdown-item:nth-child(8n) { border-right: none; }
|
||||
|
||||
// Smaller items for higher column counts
|
||||
&.view-cols-5,
|
||||
&.view-cols-6,
|
||||
@@ -2175,80 +2121,7 @@ body > .target-search-dropdown,
|
||||
}
|
||||
}
|
||||
|
||||
.tree-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: $es-text-muted;
|
||||
cursor: pointer;
|
||||
transition: transform $es-transition-fast;
|
||||
|
||||
i {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.tree-toggle-placeholder {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.btn-select-children {
|
||||
@include button-reset;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
color: $es-primary;
|
||||
border-radius: $es-radius-sm;
|
||||
transition: all $es-transition-fast;
|
||||
|
||||
&:hover {
|
||||
background: $es-primary-light;
|
||||
}
|
||||
|
||||
i {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-select-children-placeholder {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.tree-checkbox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid $es-border-dark;
|
||||
border-radius: 3px;
|
||||
transition: all $es-transition-fast;
|
||||
|
||||
i {
|
||||
display: none;
|
||||
font-size: 10px;
|
||||
color: $es-white;
|
||||
}
|
||||
}
|
||||
|
||||
.tree-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
color: $es-text-muted;
|
||||
|
||||
i {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
// tree-toggle, btn-select-children, tree-checkbox, tree-icon styles in _tree.scss
|
||||
|
||||
.tree-info {
|
||||
display: flex;
|
||||
@@ -2280,7 +2153,7 @@ body > .target-search-dropdown,
|
||||
.dropdown-results {
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
padding: $es-spacing-sm;
|
||||
padding: 0 $es-spacing-sm;
|
||||
@include custom-scrollbar;
|
||||
}
|
||||
|
||||
@@ -2498,6 +2371,7 @@ body > .target-search-dropdown,
|
||||
.entity-search-icon {
|
||||
color: $es-text-muted;
|
||||
flex-shrink: 0;
|
||||
margin-left: $es-spacing-xs;
|
||||
}
|
||||
|
||||
// Override Bootstrap/parent form input styles
|
||||
|
||||
@@ -319,22 +319,13 @@
|
||||
border-top: 1px dashed $es-border-color;
|
||||
}
|
||||
|
||||
// Legacy exclude-rows (if used elsewhere)
|
||||
.exclude-rows {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $es-spacing-sm;
|
||||
}
|
||||
|
||||
.exclude-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: $es-spacing-sm;
|
||||
padding: $es-spacing-sm;
|
||||
background: rgba($es-danger, 0.05);
|
||||
border: 1px solid rgba($es-danger, 0.2);
|
||||
border-radius: $es-radius-md;
|
||||
}
|
||||
|
||||
.exclude-row-content {
|
||||
flex: 1;
|
||||
}
|
||||
@@ -534,9 +525,13 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Group include section
|
||||
// Group include section - green accent to distinguish from exclude
|
||||
.group-include {
|
||||
margin-bottom: $es-spacing-md;
|
||||
padding: $es-spacing-sm;
|
||||
background: rgba($es-success, 0.03);
|
||||
border: 1px solid rgba($es-success, 0.2);
|
||||
border-radius: $es-radius-md;
|
||||
}
|
||||
|
||||
.section-row {
|
||||
@@ -565,32 +560,81 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
// Lock indicator for method selector (when excludes are present)
|
||||
.selector-locked {
|
||||
.include-method-select {
|
||||
opacity: 0.7;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.lock-indicator {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
color: $es-warning;
|
||||
cursor: help;
|
||||
|
||||
i {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.mpr-tooltip {
|
||||
display: none;
|
||||
position: absolute;
|
||||
bottom: calc(100% + 8px);
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
padding: $es-spacing-xs $es-spacing-sm;
|
||||
background: $es-slate-800;
|
||||
color: $es-white;
|
||||
font-size: $es-font-size-xs;
|
||||
font-weight: $es-font-weight-normal;
|
||||
white-space: nowrap;
|
||||
border-radius: $es-radius-sm;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
&:hover .mpr-tooltip {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
// Group excludes section
|
||||
.group-excludes {
|
||||
margin-top: $es-spacing-md;
|
||||
|
||||
&.has-excludes {
|
||||
padding-top: $es-spacing-md;
|
||||
border-top: 1px dashed $es-border-color;
|
||||
}
|
||||
}
|
||||
|
||||
.except-separator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: $es-spacing-sm;
|
||||
gap: $es-spacing-sm;
|
||||
margin: 0 0 $es-spacing-sm 0;
|
||||
|
||||
// Lines on both sides
|
||||
&::before,
|
||||
&::after {
|
||||
content: '';
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: rgba($es-danger, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
.except-label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
padding: 0.25rem 0.75rem;
|
||||
background: $es-danger-light;
|
||||
color: $es-danger;
|
||||
font-size: $es-font-size-xs;
|
||||
font-weight: $es-font-weight-semibold;
|
||||
border-radius: $es-radius-sm;
|
||||
border-radius: $es-radius-full;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
|
||||
i {
|
||||
font-size: 10px;
|
||||
@@ -604,17 +648,36 @@
|
||||
}
|
||||
|
||||
.exclude-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: $es-spacing-sm;
|
||||
background: rgba($es-danger, 0.03);
|
||||
border: 1px solid rgba($es-danger, 0.15);
|
||||
border-radius: $es-radius-md;
|
||||
|
||||
// Value picker inside exclude row - full width
|
||||
.value-picker {
|
||||
width: 100%;
|
||||
margin-top: $es-spacing-sm;
|
||||
}
|
||||
}
|
||||
|
||||
.exclude-header-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: $es-spacing-sm;
|
||||
margin-bottom: $es-spacing-sm;
|
||||
width: 100%;
|
||||
|
||||
.method-selector-wrapper {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
// Delete button at the far right
|
||||
.btn-remove-exclude-row {
|
||||
flex-shrink: 0;
|
||||
margin-left: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-remove-exclude-row {
|
||||
@@ -680,9 +743,14 @@
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
// Common height for all modifier controls
|
||||
$modifier-height: 26px;
|
||||
|
||||
.group-modifier-limit {
|
||||
width: 60px;
|
||||
padding: 0.25rem 0.5rem;
|
||||
width: 50px;
|
||||
max-width: 50px;
|
||||
height: $modifier-height;
|
||||
padding: 0 0.375rem;
|
||||
font-size: $es-font-size-xs;
|
||||
text-align: center;
|
||||
border: 1px solid $es-border-color;
|
||||
@@ -694,8 +762,59 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Sort modifier - input group style (select + button glued together)
|
||||
.modifier-sort {
|
||||
gap: 0; // Remove gap to glue select + button together
|
||||
|
||||
.modifier-label {
|
||||
margin-right: 0.375rem; // Keep space between label and input group
|
||||
}
|
||||
|
||||
.group-modifier-sort {
|
||||
height: $modifier-height;
|
||||
padding: 0 0.5rem;
|
||||
font-size: $es-font-size-xs;
|
||||
border: 1px solid $es-border-color;
|
||||
border-radius: $es-radius-sm 0 0 $es-radius-sm;
|
||||
border-right: none;
|
||||
cursor: pointer;
|
||||
|
||||
&:focus {
|
||||
border-color: $es-primary;
|
||||
outline: none;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-sort-dir {
|
||||
@include button-reset;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: $modifier-height;
|
||||
height: $modifier-height;
|
||||
color: $es-text-muted;
|
||||
background: $es-slate-100;
|
||||
border: 1px solid $es-border-color;
|
||||
border-radius: 0 $es-radius-sm $es-radius-sm 0;
|
||||
transition: all $es-transition-fast;
|
||||
|
||||
&:hover {
|
||||
background: $es-slate-200;
|
||||
color: $es-text-secondary;
|
||||
}
|
||||
|
||||
i {
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback for elements outside .modifier-sort context
|
||||
.group-modifier-sort {
|
||||
padding: 0.25rem 0.5rem;
|
||||
height: $modifier-height;
|
||||
padding: 0 0.5rem;
|
||||
font-size: $es-font-size-xs;
|
||||
border: 1px solid $es-border-color;
|
||||
border-radius: $es-radius-sm;
|
||||
@@ -712,8 +831,8 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
width: $modifier-height;
|
||||
height: $modifier-height;
|
||||
color: $es-text-muted;
|
||||
border: 1px solid $es-border-color;
|
||||
border-radius: $es-radius-sm;
|
||||
|
||||
@@ -494,3 +494,92 @@
|
||||
max-width: 100px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// =============================================================================
|
||||
// Total Summary Popover (header total badge click)
|
||||
// =============================================================================
|
||||
|
||||
.total-preview-popover {
|
||||
min-width: 240px;
|
||||
max-width: 320px;
|
||||
|
||||
.preview-popover-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: $es-spacing-sm $es-spacing-md;
|
||||
background: $es-bg-header;
|
||||
border-bottom: 1px solid $es-border-color;
|
||||
|
||||
.preview-popover-title {
|
||||
font-weight: $es-font-weight-semibold;
|
||||
color: $es-text-primary;
|
||||
font-size: $es-font-size-sm;
|
||||
}
|
||||
|
||||
.preview-popover-count {
|
||||
font-size: $es-font-size-xs;
|
||||
color: $es-text-muted;
|
||||
}
|
||||
}
|
||||
|
||||
.preview-popover-body {
|
||||
padding: $es-spacing-xs 0;
|
||||
}
|
||||
|
||||
.total-summary-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.total-summary-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $es-spacing-sm;
|
||||
padding: $es-spacing-sm $es-spacing-md;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background: $es-slate-50;
|
||||
}
|
||||
|
||||
i {
|
||||
width: 18px;
|
||||
text-align: center;
|
||||
color: $es-text-muted;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.summary-item-label {
|
||||
flex: 1;
|
||||
font-size: $es-font-size-sm;
|
||||
color: $es-text-primary;
|
||||
}
|
||||
|
||||
.summary-item-count {
|
||||
font-size: $es-font-size-sm;
|
||||
font-weight: $es-font-weight-semibold;
|
||||
color: $es-primary;
|
||||
background: rgba($es-primary, 0.1);
|
||||
padding: 2px 8px;
|
||||
border-radius: $es-radius-sm;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Make trait-total-count clickable
|
||||
.trait-total-count {
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
&.popover-open {
|
||||
opacity: 0.9;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,14 +82,25 @@
|
||||
|
||||
// Hidden select (for form submission)
|
||||
.method-select-hidden {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
width: 0;
|
||||
height: 0;
|
||||
position: absolute !important;
|
||||
opacity: 0 !important;
|
||||
pointer-events: none !important;
|
||||
width: 0 !important;
|
||||
height: 0 !important;
|
||||
overflow: hidden !important;
|
||||
}
|
||||
}
|
||||
|
||||
// Global fallback for hidden method selects
|
||||
.method-select-hidden {
|
||||
position: absolute !important;
|
||||
opacity: 0 !important;
|
||||
pointer-events: none !important;
|
||||
width: 0 !important;
|
||||
height: 0 !important;
|
||||
overflow: hidden !important;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Method Dropdown Menu (appended to body, outside trait wrappers)
|
||||
// =============================================================================
|
||||
|
||||
@@ -109,44 +109,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// MPR Icon (SVG mask icons)
|
||||
// =============================================================================
|
||||
|
||||
.mpr-icon {
|
||||
display: inline-block;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background-color: $es-slate-600;
|
||||
vertical-align: middle;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
mask-size: contain;
|
||||
mask-repeat: no-repeat;
|
||||
mask-position: center;
|
||||
-webkit-mask-size: contain;
|
||||
-webkit-mask-repeat: no-repeat;
|
||||
-webkit-mask-position: center;
|
||||
|
||||
&:hover {
|
||||
background-color: #5bc0de;
|
||||
}
|
||||
|
||||
&.link {
|
||||
background-color: #5bc0de;
|
||||
|
||||
&:hover {
|
||||
background-color: #337ab7;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Info icon
|
||||
.mpr-icon.icon-info {
|
||||
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 16 16'%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M11 2.5H5A2.5 2.5 0 0 0 2.5 5v6A2.5 2.5 0 0 0 5 13.5h6a2.5 2.5 0 0 0 2.5-2.5V5A2.5 2.5 0 0 0 11 2.5ZM5 1a4 4 0 0 0-4 4v6a4 4 0 0 0 4 4h6a4 4 0 0 0 4-4V5a4 4 0 0 0-4-4H5Z' fill='%23414552'/%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M6.25 8A.75.75 0 0 1 7 7.25h1.25A.75.75 0 0 1 9 8v3.5a.75.75 0 0 1-1.5 0V8.75H7A.75.75 0 0 1 6.25 8Z' fill='%23414552'/%3E%3Cpath d='M6.75 5a1.25 1.25 0 1 1 2.5 0 1.25 1.25 0 0 1-2.5 0Z' fill='%23414552'/%3E%3C/svg%3E");
|
||||
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 16 16'%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M11 2.5H5A2.5 2.5 0 0 0 2.5 5v6A2.5 2.5 0 0 0 5 13.5h6a2.5 2.5 0 0 0 2.5-2.5V5A2.5 2.5 0 0 0 11 2.5ZM5 1a4 4 0 0 0-4 4v6a4 4 0 0 0 4 4h6a4 4 0 0 0 4-4V5a4 4 0 0 0-4-4H5Z' fill='%23414552'/%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M6.25 8A.75.75 0 0 1 7 7.25h1.25A.75.75 0 0 1 9 8v3.5a.75.75 0 0 1-1.5 0V8.75H7A.75.75 0 0 1 6.25 8Z' fill='%23414552'/%3E%3Cpath d='M6.75 5a1.25 1.25 0 1 1 2.5 0 1.25 1.25 0 0 1-2.5 0Z' fill='%23414552'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Tooltip Content Styling
|
||||
// =============================================================================
|
||||
|
||||
342
sources/scss/components/_tree.scss
Normal file
342
sources/scss/components/_tree.scss
Normal file
@@ -0,0 +1,342 @@
|
||||
/**
|
||||
* Category Tree Component
|
||||
* Hierarchical tree view for category selection inside dropdown
|
||||
*/
|
||||
|
||||
@use '../variables' as *;
|
||||
@use '../mixins' as *;
|
||||
|
||||
// Category tree container (inside dropdown)
|
||||
.category-tree {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
// Tree toolbar inside dropdown
|
||||
.category-tree .tree-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $es-spacing-sm;
|
||||
padding: $es-spacing-xs $es-spacing-sm;
|
||||
background: $es-slate-50;
|
||||
border-bottom: 1px solid $es-border-light;
|
||||
flex-shrink: 0;
|
||||
|
||||
.btn-expand-all,
|
||||
.btn-collapse-all {
|
||||
@include button-reset;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: $es-spacing-xs $es-spacing-sm;
|
||||
font-size: $es-font-size-xs;
|
||||
font-weight: $es-font-weight-medium;
|
||||
color: $es-text-secondary;
|
||||
background: $es-white;
|
||||
border: 1px solid $es-border-color;
|
||||
border-radius: $es-radius-sm;
|
||||
transition: all $es-transition-fast;
|
||||
|
||||
&:hover {
|
||||
background: $es-slate-100;
|
||||
border-color: $es-slate-300;
|
||||
}
|
||||
|
||||
i {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Tree items container
|
||||
.category-tree .tree-items {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
// Tree item
|
||||
.tree-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $es-spacing-xs;
|
||||
padding: $es-spacing-xs $es-spacing-sm;
|
||||
cursor: pointer;
|
||||
transition: background $es-transition-fast;
|
||||
border-radius: 0;
|
||||
|
||||
&:hover {
|
||||
background: $es-slate-100;
|
||||
}
|
||||
|
||||
&.selected {
|
||||
background: $es-primary-light;
|
||||
|
||||
.tree-name {
|
||||
font-weight: $es-font-weight-semibold;
|
||||
color: $es-primary;
|
||||
}
|
||||
|
||||
.tree-checkbox {
|
||||
color: $es-primary;
|
||||
|
||||
i {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.inactive {
|
||||
opacity: 0.6;
|
||||
|
||||
.tree-name {
|
||||
font-style: italic;
|
||||
}
|
||||
}
|
||||
|
||||
&.filtered-out {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&.filter-match {
|
||||
background: $es-warning-light;
|
||||
|
||||
&.selected {
|
||||
background: $es-primary-light;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// All tree element styles nested under .category-tree for specificity
|
||||
.category-tree {
|
||||
// Tree indentation
|
||||
.tree-indent {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
// Tree toggle (expand/collapse)
|
||||
.tree-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
box-sizing: border-box;
|
||||
color: $es-text-secondary;
|
||||
flex-shrink: 0;
|
||||
border-radius: $es-radius-sm;
|
||||
transition: all $es-transition-fast;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: $es-slate-200;
|
||||
color: $es-text-primary;
|
||||
}
|
||||
|
||||
&.tree-leaf {
|
||||
cursor: default;
|
||||
visibility: hidden;
|
||||
|
||||
&:hover {
|
||||
background: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
i {
|
||||
font-size: 10px;
|
||||
transition: transform $es-transition-fast;
|
||||
}
|
||||
}
|
||||
|
||||
.tree-item.collapsed > .tree-toggle i {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
// Tree checkbox indicator - 12x12 to match PrestaShop admin standards
|
||||
.tree-checkbox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
box-sizing: border-box;
|
||||
flex-shrink: 0;
|
||||
border: 1px solid $es-border-color;
|
||||
border-radius: 2px;
|
||||
background: $es-white;
|
||||
|
||||
i {
|
||||
font-size: 8px;
|
||||
opacity: 0;
|
||||
color: $es-white;
|
||||
transition: opacity $es-transition-fast;
|
||||
}
|
||||
}
|
||||
|
||||
.tree-item.selected .tree-checkbox {
|
||||
background: $es-primary;
|
||||
border-color: $es-primary;
|
||||
|
||||
i {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Tree icon
|
||||
.tree-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
box-sizing: border-box;
|
||||
color: $es-text-muted;
|
||||
flex-shrink: 0;
|
||||
|
||||
i {
|
||||
font-size: 12px; // match visual weight of other icons
|
||||
}
|
||||
}
|
||||
|
||||
.tree-item.selected .tree-icon {
|
||||
color: $es-primary;
|
||||
}
|
||||
|
||||
// Tree name
|
||||
.tree-name {
|
||||
flex: 1;
|
||||
font-size: $es-font-size-sm;
|
||||
color: $es-text-primary;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
// Tree product/page count with preview
|
||||
.tree-count {
|
||||
@include count-badge($es-primary);
|
||||
height: 18px;
|
||||
min-width: 18px;
|
||||
padding: 0 $es-spacing-sm;
|
||||
|
||||
i {
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
&.clickable {
|
||||
&.loading {
|
||||
pointer-events: none;
|
||||
|
||||
i {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
}
|
||||
|
||||
&.popover-open {
|
||||
background: darken($es-primary, 10%);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Select children button - positioned on the left next to toggle
|
||||
.btn-select-children {
|
||||
@include button-reset;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
box-sizing: border-box;
|
||||
color: $es-text-muted;
|
||||
border-radius: $es-radius-sm;
|
||||
opacity: 0.3;
|
||||
transition: all $es-transition-fast;
|
||||
flex-shrink: 0;
|
||||
|
||||
i {
|
||||
font-size: 14px; // larger to visually match other icons
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: $es-primary;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.tree-item:hover .btn-select-children {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
// Tree badge (inactive, etc.)
|
||||
.tree-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.125rem $es-spacing-xs;
|
||||
font-size: 9px;
|
||||
font-weight: $es-font-weight-semibold;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.025em;
|
||||
border-radius: $es-radius-sm;
|
||||
flex-shrink: 0;
|
||||
|
||||
&.inactive {
|
||||
color: $es-warning;
|
||||
background: $es-warning-light;
|
||||
}
|
||||
}
|
||||
|
||||
// Tree children container
|
||||
.tree-children {
|
||||
display: block;
|
||||
|
||||
&.filter-expanded {
|
||||
display: block !important;
|
||||
}
|
||||
}
|
||||
|
||||
.tree-item.collapsed + .tree-children {
|
||||
display: none;
|
||||
}
|
||||
|
||||
// Filtering - must be inside .category-tree for specificity
|
||||
.tree-item.filtered-out {
|
||||
display: none !important;
|
||||
}
|
||||
} // end .category-tree
|
||||
|
||||
// Loading/empty/error states
|
||||
.category-tree .tree-loading,
|
||||
.category-tree .dropdown-empty,
|
||||
.category-tree .dropdown-error {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: $es-spacing-xl;
|
||||
color: $es-text-muted;
|
||||
font-size: $es-font-size-sm;
|
||||
|
||||
i {
|
||||
margin-right: $es-spacing-sm;
|
||||
}
|
||||
}
|
||||
|
||||
.category-tree .dropdown-error {
|
||||
color: $es-danger;
|
||||
}
|
||||
|
||||
// Tree view mode in dropdown
|
||||
.target-search-dropdown.view-tree {
|
||||
.dropdown-results {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.category-tree {
|
||||
max-height: 100%;
|
||||
overflow-y: auto;
|
||||
@include custom-scrollbar;
|
||||
}
|
||||
|
||||
.tree-items {
|
||||
max-height: calc(100% - 40px);
|
||||
overflow-y: auto;
|
||||
@include custom-scrollbar;
|
||||
}
|
||||
}
|
||||
@@ -42,10 +42,16 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Separation between chips and search box
|
||||
.chips-wrapper + .entity-search-box {
|
||||
margin-top: $es-spacing-md;
|
||||
}
|
||||
|
||||
.entity-search-icon {
|
||||
color: $es-text-muted;
|
||||
font-size: 14px;
|
||||
flex-shrink: 0;
|
||||
margin-left: $es-spacing-xs;
|
||||
}
|
||||
|
||||
// Override parent form's max-width on search input
|
||||
@@ -85,6 +91,31 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Browse tree button (for categories)
|
||||
.btn-browse-tree {
|
||||
@include button-reset;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
margin-left: auto;
|
||||
color: $es-primary;
|
||||
background: $es-primary-light;
|
||||
border-radius: $es-radius-sm;
|
||||
flex-shrink: 0;
|
||||
transition: all $es-transition-fast;
|
||||
|
||||
&:hover {
|
||||
background: $es-primary;
|
||||
color: $es-white;
|
||||
}
|
||||
|
||||
i {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
// Numeric range box
|
||||
.numeric-range-box,
|
||||
.multi-range-input-row {
|
||||
|
||||
@@ -28,3 +28,4 @@
|
||||
@use 'components/combinations';
|
||||
@use 'components/method-dropdown';
|
||||
@use 'components/tooltip';
|
||||
@use 'components/tree';
|
||||
|
||||
@@ -227,9 +227,15 @@ trait EntitySelector
|
||||
case 'getTargetEntitiesByIds':
|
||||
$this->ajaxGetTargetEntitiesByIds();
|
||||
return true;
|
||||
case 'getTargetEntitiesByIdsBulk':
|
||||
$this->ajaxGetTargetEntitiesByIdsBulk();
|
||||
return true;
|
||||
case 'previewEntitySelector':
|
||||
$this->ajaxPreviewEntitySelector();
|
||||
return true;
|
||||
case 'previewEntitySelectorBulk':
|
||||
$this->ajaxPreviewEntitySelectorBulk();
|
||||
return true;
|
||||
case 'getTargetFilterableAttributes':
|
||||
$this->ajaxGetTargetFilterableAttributes();
|
||||
return true;
|
||||
@@ -251,6 +257,9 @@ trait EntitySelector
|
||||
case 'countConditionMatches':
|
||||
$this->ajaxCountConditionMatches();
|
||||
return true;
|
||||
case 'countConditionMatchesBulk':
|
||||
$this->ajaxCountConditionMatchesBulk();
|
||||
return true;
|
||||
case 'previewConditionItems':
|
||||
$this->ajaxPreviewConditionItems();
|
||||
return true;
|
||||
@@ -266,6 +275,12 @@ trait EntitySelector
|
||||
case 'previewFilterGroupProducts':
|
||||
$this->ajaxPreviewFilterGroupProducts();
|
||||
return true;
|
||||
case 'previewCategoryProducts':
|
||||
$this->ajaxPreviewCategoryProducts();
|
||||
return true;
|
||||
case 'previewCategoryPages':
|
||||
$this->ajaxPreviewCategoryPages();
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
@@ -321,6 +336,69 @@ trait EntitySelector
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* AJAX: Count products matching multiple conditions in a single request
|
||||
* Receives an array of conditions and returns counts for each
|
||||
*/
|
||||
protected function ajaxCountConditionMatchesBulk()
|
||||
{
|
||||
$startTime = microtime(true);
|
||||
$conditionsJson = Tools::getValue('conditions', '[]');
|
||||
$conditions = json_decode($conditionsJson, true);
|
||||
|
||||
if (!is_array($conditions)) {
|
||||
$this->ajaxDie(json_encode([
|
||||
'success' => false,
|
||||
'error' => 'Invalid conditions format',
|
||||
'counts' => [],
|
||||
]));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$counts = [];
|
||||
$timing = [];
|
||||
|
||||
foreach ($conditions as $conditionId => $condition) {
|
||||
$conditionStart = microtime(true);
|
||||
$method = $condition['method'] ?? '';
|
||||
$values = $condition['values'] ?? [];
|
||||
$blockType = $condition['block_type'] ?? 'products';
|
||||
|
||||
if (empty($method)) {
|
||||
$counts[$conditionId] = 0;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($blockType === 'products') {
|
||||
$matchingIds = $this->getProductConditionResolver()->getIdsByMethod($method, $values);
|
||||
$counts[$conditionId] = count($matchingIds);
|
||||
} else {
|
||||
// For non-product entity types, use the query handler
|
||||
$matchingIds = $this->getEntityQueryHandler()->getIdsByMethod($blockType, $method, $values);
|
||||
$counts[$conditionId] = count($matchingIds);
|
||||
}
|
||||
$timing[$conditionId] = round((microtime(true) - $conditionStart) * 1000, 2);
|
||||
}
|
||||
|
||||
$totalTime = round((microtime(true) - $startTime) * 1000, 2);
|
||||
|
||||
$this->ajaxDie(json_encode([
|
||||
'success' => true,
|
||||
'counts' => $counts,
|
||||
'timing' => $timing,
|
||||
'total_time_ms' => $totalTime,
|
||||
'condition_count' => count($conditions),
|
||||
]));
|
||||
} catch (\Exception $e) {
|
||||
$this->ajaxDie(json_encode([
|
||||
'success' => false,
|
||||
'error' => $e->getMessage(),
|
||||
'counts' => [],
|
||||
]));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* AJAX: Preview items matching a single condition (method + values)
|
||||
* Delegates to ProductConditionResolver and EntityPreviewHandler
|
||||
@@ -716,6 +794,160 @@ trait EntitySelector
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* AJAX: Preview products in a category
|
||||
* Used by tree view product count click
|
||||
*/
|
||||
protected function ajaxPreviewCategoryProducts()
|
||||
{
|
||||
$categoryId = (int) Tools::getValue('category_id');
|
||||
$limit = (int) Tools::getValue('limit', 10);
|
||||
$offset = (int) Tools::getValue('offset', 0);
|
||||
$query = Tools::getValue('query', '');
|
||||
|
||||
if (!$categoryId) {
|
||||
die(json_encode(['success' => false, 'error' => 'Invalid category ID']));
|
||||
}
|
||||
|
||||
$idLang = (int) Context::getContext()->language->id;
|
||||
$idShop = (int) Context::getContext()->shop->id;
|
||||
|
||||
try {
|
||||
$db = Db::getInstance();
|
||||
|
||||
// Get products in category
|
||||
$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('category_product', 'cp', 'cp.id_product = p.id_product AND cp.id_category = ' . $categoryId);
|
||||
$sql->where('ps.active = 1');
|
||||
|
||||
$results = $db->executeS($sql);
|
||||
$productIds = array_column($results, 'id_product');
|
||||
|
||||
// Apply filter if provided
|
||||
if (!empty($query) && !empty($productIds)) {
|
||||
$productIds = $this->getEntityPreviewHandler()->filterProductIdsByQuery($productIds, $query, $idLang, $idShop);
|
||||
}
|
||||
|
||||
$totalCount = count($productIds);
|
||||
|
||||
// Get limited results for preview
|
||||
$previewIds = array_slice($productIds, $offset, $limit);
|
||||
|
||||
// Get product details
|
||||
$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' => ($offset + count($items)) < $totalCount
|
||||
]));
|
||||
|
||||
} catch (\Exception $e) {
|
||||
die(json_encode(['success' => false, 'error' => $e->getMessage()]));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* AJAX: Preview CMS pages in a CMS category
|
||||
* Used by tree view page count click
|
||||
*/
|
||||
protected function ajaxPreviewCategoryPages()
|
||||
{
|
||||
$categoryId = (int) Tools::getValue('category_id');
|
||||
$limit = (int) Tools::getValue('limit', 10);
|
||||
$offset = (int) Tools::getValue('offset', 0);
|
||||
$query = Tools::getValue('query', '');
|
||||
|
||||
if (!$categoryId) {
|
||||
die(json_encode(['success' => false, 'error' => 'Invalid category ID']));
|
||||
}
|
||||
|
||||
$idLang = (int) Context::getContext()->language->id;
|
||||
$idShop = (int) Context::getContext()->shop->id;
|
||||
|
||||
try {
|
||||
$db = Db::getInstance();
|
||||
|
||||
// Get CMS pages in category
|
||||
$sql = new DbQuery();
|
||||
$sql->select('c.id_cms, cl.meta_title');
|
||||
$sql->from('cms', 'c');
|
||||
$sql->innerJoin('cms_shop', 'cs', 'cs.id_cms = c.id_cms AND cs.id_shop = ' . $idShop);
|
||||
$sql->innerJoin('cms_lang', 'cl', 'cl.id_cms = c.id_cms AND cl.id_lang = ' . $idLang . ' AND cl.id_shop = ' . $idShop);
|
||||
$sql->where('c.id_cms_category = ' . $categoryId);
|
||||
$sql->where('c.active = 1');
|
||||
|
||||
if (!empty($query)) {
|
||||
$sql->where('cl.meta_title LIKE \'%' . pSQL($query) . '%\'');
|
||||
}
|
||||
|
||||
$allPages = $db->executeS($sql);
|
||||
$totalCount = count($allPages);
|
||||
|
||||
// Get limited results
|
||||
$pages = array_slice($allPages, $offset, $limit);
|
||||
|
||||
$items = [];
|
||||
foreach ($pages as $page) {
|
||||
$items[] = [
|
||||
'id' => (int) $page['id_cms'],
|
||||
'name' => $page['meta_title']
|
||||
];
|
||||
}
|
||||
|
||||
die(json_encode([
|
||||
'success' => true,
|
||||
'items' => $items,
|
||||
'count' => $totalCount,
|
||||
'hasMore' => ($offset + count($items)) < $totalCount
|
||||
]));
|
||||
|
||||
} catch (\Exception $e) {
|
||||
die(json_encode(['success' => false, 'error' => $e->getMessage()]));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply modifiers (sort, limit) to product IDs
|
||||
* Delegates to ProductConditionResolver
|
||||
@@ -857,6 +1089,54 @@ trait EntitySelector
|
||||
|
||||
$this->ajaxDie(json_encode($result));
|
||||
}
|
||||
|
||||
/**
|
||||
* AJAX: Get preview counts for all entity types in a single request
|
||||
*/
|
||||
protected function ajaxPreviewEntitySelectorBulk()
|
||||
{
|
||||
$startTime = microtime(true);
|
||||
$conditionsJson = Tools::getValue('conditions', '{}');
|
||||
|
||||
$conditions = json_decode($conditionsJson, true);
|
||||
if (!is_array($conditions)) {
|
||||
$conditions = [];
|
||||
}
|
||||
|
||||
try {
|
||||
$timing = [];
|
||||
$counts = [];
|
||||
|
||||
foreach ($conditions as $entityType => $conditionData) {
|
||||
$typeStart = microtime(true);
|
||||
$groups = $conditionData['groups'] ?? [];
|
||||
|
||||
try {
|
||||
$counts[$entityType] = $this->getEntityPreviewHandler()->getPreviewCount($entityType, $groups);
|
||||
} catch (\Exception $e) {
|
||||
$counts[$entityType] = 0;
|
||||
}
|
||||
$timing[$entityType] = round((microtime(true) - $typeStart) * 1000, 2);
|
||||
}
|
||||
|
||||
$totalTime = round((microtime(true) - $startTime) * 1000, 2);
|
||||
|
||||
$this->ajaxDie(json_encode([
|
||||
'success' => true,
|
||||
'counts' => $counts,
|
||||
'timing' => $timing,
|
||||
'total_time_ms' => $totalTime,
|
||||
'entity_count' => count($conditions),
|
||||
]));
|
||||
} catch (\Exception $e) {
|
||||
$this->ajaxDie(json_encode([
|
||||
'success' => false,
|
||||
'error' => $e->getMessage(),
|
||||
'counts' => [],
|
||||
]));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* AJAX: Search entities for target conditions
|
||||
*/
|
||||
@@ -977,6 +1257,39 @@ trait EntitySelector
|
||||
]));
|
||||
}
|
||||
|
||||
/**
|
||||
* AJAX: Get entities by IDs in bulk (multiple entity types in one request)
|
||||
* Accepts: entities = { "products": [1,2,3], "categories": [4,5], ... }
|
||||
* Returns: { "products": [...], "categories": [...], ... }
|
||||
*/
|
||||
protected function ajaxGetTargetEntitiesByIdsBulk()
|
||||
{
|
||||
$entitiesParam = Tools::getValue('entities', '');
|
||||
if (is_string($entitiesParam)) {
|
||||
$entitiesParam = json_decode($entitiesParam, true);
|
||||
}
|
||||
if (!is_array($entitiesParam)) {
|
||||
$entitiesParam = [];
|
||||
}
|
||||
|
||||
$result = [];
|
||||
$searchEngine = $this->getEntitySearchEngine();
|
||||
|
||||
foreach ($entitiesParam as $entityType => $ids) {
|
||||
if (!is_array($ids) || empty($ids)) {
|
||||
continue;
|
||||
}
|
||||
// Deduplicate and sanitize IDs
|
||||
$ids = array_unique(array_map('intval', $ids));
|
||||
$result[$entityType] = $searchEngine->getByIds($entityType, $ids);
|
||||
}
|
||||
|
||||
$this->ajaxDie(json_encode([
|
||||
'success' => true,
|
||||
'entities' => $result,
|
||||
]));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get target blocks configuration
|
||||
*/
|
||||
|
||||
@@ -145,6 +145,29 @@ class EntityPreviewHandler
|
||||
return count($ids);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get preview counts for multiple entity types in a single call
|
||||
*
|
||||
* @param array $allConditions Array of conditions keyed by entity type
|
||||
* @return array Counts keyed by entity type
|
||||
*/
|
||||
public function getPreviewCountsBulk(array $allConditions)
|
||||
{
|
||||
$counts = [];
|
||||
|
||||
foreach ($allConditions as $entityType => $conditionData) {
|
||||
$groups = $conditionData['groups'] ?? [];
|
||||
|
||||
try {
|
||||
$counts[$entityType] = $this->getPreviewCount($entityType, $groups);
|
||||
} catch (\Exception $e) {
|
||||
$counts[$entityType] = 0;
|
||||
}
|
||||
}
|
||||
|
||||
return $counts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert snake_case to CamelCase
|
||||
*
|
||||
@@ -1103,8 +1126,9 @@ class EntityPreviewHandler
|
||||
}
|
||||
|
||||
$sql = new DbQuery();
|
||||
$sql->select('c.id_currency, c.name, c.iso_code, c.active, c.conversion_rate, c.sign');
|
||||
$sql->select('c.id_currency, cl.name, c.iso_code, c.active, c.conversion_rate, cl.symbol');
|
||||
$sql->from('currency', 'c');
|
||||
$sql->leftJoin('currency_lang', 'cl', 'cl.id_currency = c.id_currency AND cl.id_lang = ' . (int) $idLang);
|
||||
$sql->where('c.id_currency IN (' . implode(',', array_map('intval', $ids)) . ')');
|
||||
|
||||
$results = Db::getInstance()->executeS($sql);
|
||||
@@ -1119,7 +1143,7 @@ class EntityPreviewHandler
|
||||
'id' => (int) $row['id_currency'],
|
||||
'name' => $row['name'],
|
||||
'iso_code' => $row['iso_code'],
|
||||
'sign' => $row['sign'],
|
||||
'sign' => $row['symbol'],
|
||||
'active' => (bool) $row['active'],
|
||||
'conversion_rate' => (float) $row['conversion_rate'],
|
||||
];
|
||||
|
||||
@@ -691,6 +691,132 @@ class EntitySearchEngine
|
||||
return implode(' > ', array_column($results, 'name'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get full category tree with hierarchy
|
||||
*
|
||||
* @param int $idLang Language ID
|
||||
* @param int $idShop Shop ID
|
||||
* @param array $selectedIds Currently selected category IDs
|
||||
* @param bool $activeOnly Only include active categories
|
||||
* @return array Tree structure
|
||||
*/
|
||||
public function getCategoryTree($idLang = null, $idShop = null, array $selectedIds = [], $activeOnly = false)
|
||||
{
|
||||
$idLang = $idLang ?: $this->idLang;
|
||||
$idShop = $idShop ?: $this->idShop;
|
||||
|
||||
$sql = new DbQuery();
|
||||
$sql->select('c.id_category, c.id_parent, cl.name, c.active, c.level_depth, c.position');
|
||||
$sql->select('(SELECT COUNT(cp.id_product) FROM ' . _DB_PREFIX_ . 'category_product cp WHERE cp.id_category = c.id_category) AS product_count');
|
||||
$sql->from('category', 'c');
|
||||
$sql->innerJoin('category_shop', 'cs', 'cs.id_category = c.id_category AND cs.id_shop = ' . (int) $idShop);
|
||||
$sql->leftJoin('category_lang', 'cl', 'cl.id_category = c.id_category AND cl.id_lang = ' . (int) $idLang . ' AND cl.id_shop = ' . (int) $idShop);
|
||||
$sql->where('c.id_parent > 0'); // Exclude root
|
||||
|
||||
if ($activeOnly) {
|
||||
$sql->where('c.active = 1');
|
||||
}
|
||||
|
||||
$sql->orderBy('c.level_depth ASC, c.position ASC');
|
||||
|
||||
$results = Db::getInstance()->executeS($sql);
|
||||
|
||||
if (!$results) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Convert selected IDs to lookup set
|
||||
$selectedSet = array_flip($selectedIds);
|
||||
|
||||
// Build flat array with parent references
|
||||
$categories = [];
|
||||
foreach ($results as $row) {
|
||||
$id = (int) $row['id_category'];
|
||||
$categories[$id] = [
|
||||
'id' => $id,
|
||||
'id_parent' => (int) $row['id_parent'],
|
||||
'name' => $row['name'],
|
||||
'active' => (bool) $row['active'],
|
||||
'level_depth' => (int) $row['level_depth'],
|
||||
'position' => (int) $row['position'],
|
||||
'product_count' => (int) $row['product_count'],
|
||||
'selected' => isset($selectedSet[$id]),
|
||||
'children' => [],
|
||||
];
|
||||
}
|
||||
|
||||
// Build tree structure
|
||||
$tree = [];
|
||||
foreach ($categories as $id => &$category) {
|
||||
$parentId = $category['id_parent'];
|
||||
if (isset($categories[$parentId])) {
|
||||
$categories[$parentId]['children'][] = &$category;
|
||||
} else {
|
||||
// Top-level category (parent is root)
|
||||
$tree[] = &$category;
|
||||
}
|
||||
}
|
||||
unset($category);
|
||||
|
||||
// Sort children by position at each level
|
||||
$this->sortTreeByPosition($tree);
|
||||
|
||||
return $tree;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively sort tree children by position
|
||||
*
|
||||
* @param array &$nodes Tree nodes
|
||||
*/
|
||||
protected function sortTreeByPosition(array &$nodes)
|
||||
{
|
||||
usort($nodes, function ($a, $b) {
|
||||
return $a['position'] - $b['position'];
|
||||
});
|
||||
|
||||
foreach ($nodes as &$node) {
|
||||
if (!empty($node['children'])) {
|
||||
$this->sortTreeByPosition($node['children']);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all descendant category IDs for given parent IDs
|
||||
*
|
||||
* @param array $parentIds Parent category IDs
|
||||
* @param int $idShop Shop ID
|
||||
* @return array All descendant IDs (including parents)
|
||||
*/
|
||||
public function getCategoryDescendants(array $parentIds, $idShop = null)
|
||||
{
|
||||
if (empty($parentIds)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$idShop = $idShop ?: $this->idShop;
|
||||
$allIds = $parentIds;
|
||||
|
||||
// Use nleft/nright for efficient descendant lookup
|
||||
$sql = new DbQuery();
|
||||
$sql->select('DISTINCT c2.id_category');
|
||||
$sql->from('category', 'c1');
|
||||
$sql->innerJoin('category', 'c2', 'c2.nleft > c1.nleft AND c2.nright < c1.nright');
|
||||
$sql->innerJoin('category_shop', 'cs', 'cs.id_category = c2.id_category AND cs.id_shop = ' . (int) $idShop);
|
||||
$sql->where('c1.id_category IN (' . implode(',', array_map('intval', $parentIds)) . ')');
|
||||
|
||||
$results = Db::getInstance()->executeS($sql);
|
||||
|
||||
if ($results) {
|
||||
foreach ($results as $row) {
|
||||
$allIds[] = (int) $row['id_category'];
|
||||
}
|
||||
}
|
||||
|
||||
return array_unique($allIds);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// MANUFACTURERS
|
||||
// =========================================================================
|
||||
@@ -2005,21 +2131,22 @@ class EntitySearchEngine
|
||||
$limit = isset($filters['limit']) ? (int) $filters['limit'] : 20;
|
||||
|
||||
$sql = new DbQuery();
|
||||
$sql->select('DISTINCT c.id_currency, c.name, c.iso_code, c.active, c.conversion_rate, c.sign');
|
||||
$sql->select('DISTINCT c.id_currency, c.iso_code, c.active, c.conversion_rate, cl.name, cl.symbol');
|
||||
$sql->from('currency', 'c');
|
||||
$sql->innerJoin('currency_shop', 'cs', 'cs.id_currency = c.id_currency AND cs.id_shop = ' . (int) $idShop);
|
||||
$sql->leftJoin('currency_lang', 'cl', 'cl.id_currency = c.id_currency AND cl.id_lang = ' . (int) $idLang);
|
||||
|
||||
if (!empty($query)) {
|
||||
$escapedQuery = $this->escapePattern($query);
|
||||
$sql->where('(c.name LIKE \'%' . $escapedQuery . '%\' OR c.iso_code LIKE \'%' . $escapedQuery . '%\' OR c.id_currency = ' . (int) $query . ')');
|
||||
$sql->where('(cl.name LIKE \'%' . $escapedQuery . '%\' OR c.iso_code LIKE \'%' . $escapedQuery . '%\' OR c.id_currency = ' . (int) $query . ')');
|
||||
}
|
||||
|
||||
if (!empty($filters['refine'])) {
|
||||
$refine = $this->escapePattern($filters['refine']);
|
||||
if (!empty($filters['refine_negate'])) {
|
||||
$sql->where('c.name NOT LIKE \'%' . $refine . '%\'');
|
||||
$sql->where('cl.name NOT LIKE \'%' . $refine . '%\'');
|
||||
} else {
|
||||
$sql->where('c.name LIKE \'%' . $refine . '%\'');
|
||||
$sql->where('cl.name LIKE \'%' . $refine . '%\'');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2027,7 +2154,7 @@ class EntitySearchEngine
|
||||
$sql->where('c.active = 1');
|
||||
}
|
||||
|
||||
$sql->orderBy('c.name ASC');
|
||||
$sql->orderBy('cl.name ASC');
|
||||
$sql->limit($limit);
|
||||
|
||||
$results = Db::getInstance()->executeS($sql);
|
||||
@@ -2040,9 +2167,9 @@ class EntitySearchEngine
|
||||
return [
|
||||
'id' => (int) $row['id_currency'],
|
||||
'type' => 'currency',
|
||||
'name' => $row['name'],
|
||||
'name' => $row['name'] ?: $row['iso_code'],
|
||||
'iso_code' => $row['iso_code'],
|
||||
'sign' => $row['sign'],
|
||||
'symbol' => $row['symbol'] ?: $row['iso_code'],
|
||||
'active' => (bool) $row['active'],
|
||||
'conversion_rate' => (float) $row['conversion_rate'],
|
||||
];
|
||||
@@ -2058,18 +2185,19 @@ class EntitySearchEngine
|
||||
$sql->select('COUNT(DISTINCT c.id_currency)');
|
||||
$sql->from('currency', 'c');
|
||||
$sql->innerJoin('currency_shop', 'cs', 'cs.id_currency = c.id_currency AND cs.id_shop = ' . (int) $idShop);
|
||||
$sql->leftJoin('currency_lang', 'cl', 'cl.id_currency = c.id_currency AND cl.id_lang = ' . (int) $idLang);
|
||||
|
||||
if (!empty($query)) {
|
||||
$escapedQuery = $this->escapePattern($query);
|
||||
$sql->where('(c.name LIKE \'%' . $escapedQuery . '%\' OR c.iso_code LIKE \'%' . $escapedQuery . '%\' OR c.id_currency = ' . (int) $query . ')');
|
||||
$sql->where('(cl.name LIKE \'%' . $escapedQuery . '%\' OR c.iso_code LIKE \'%' . $escapedQuery . '%\' OR c.id_currency = ' . (int) $query . ')');
|
||||
}
|
||||
|
||||
if (!empty($filters['refine'])) {
|
||||
$refine = $this->escapePattern($filters['refine']);
|
||||
if (!empty($filters['refine_negate'])) {
|
||||
$sql->where('c.name NOT LIKE \'%' . $refine . '%\'');
|
||||
$sql->where('cl.name NOT LIKE \'%' . $refine . '%\'');
|
||||
} else {
|
||||
$sql->where('c.name LIKE \'%' . $refine . '%\'');
|
||||
$sql->where('cl.name LIKE \'%' . $refine . '%\'');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2090,8 +2218,9 @@ class EntitySearchEngine
|
||||
}
|
||||
|
||||
$sql = new DbQuery();
|
||||
$sql->select('c.id_currency, c.name, c.iso_code, c.sign, c.active');
|
||||
$sql->select('c.id_currency, c.iso_code, c.active, cl.name, cl.symbol');
|
||||
$sql->from('currency', 'c');
|
||||
$sql->leftJoin('currency_lang', 'cl', 'cl.id_currency = c.id_currency AND cl.id_lang = ' . (int) $idLang);
|
||||
$sql->where('c.id_currency IN (' . implode(',', array_map('intval', $ids)) . ')');
|
||||
|
||||
$results = Db::getInstance()->executeS($sql);
|
||||
@@ -2105,9 +2234,9 @@ class EntitySearchEngine
|
||||
$currencies[(int) $row['id_currency']] = [
|
||||
'id' => (int) $row['id_currency'],
|
||||
'type' => 'currency',
|
||||
'name' => $row['name'],
|
||||
'name' => $row['name'] ?: $row['iso_code'],
|
||||
'iso_code' => $row['iso_code'],
|
||||
'sign' => $row['sign'],
|
||||
'symbol' => $row['symbol'] ?: $row['iso_code'],
|
||||
'active' => (bool) $row['active'],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -249,7 +249,7 @@ class EntitySelectorRenderer
|
||||
$html .= '<span class="trait-title">' . $this->escapeAttr($config['title']) . '</span>';
|
||||
$html .= '<span class="trait-subtitle">' . $this->escapeAttr($config['subtitle']) . '</span>';
|
||||
$html .= '</div>';
|
||||
$html .= '<span class="trait-total-count" style="display: none;" title="' . $this->trans('Total items targeted') . '"></span>';
|
||||
$html .= '<span class="trait-total-count" style="display: none;" title="' . $this->trans('Total items targeted') . '"><i class="icon-eye"></i> <span class="count-value"></span></span>';
|
||||
$html .= '</div>';
|
||||
$html .= '<div class="trait-header-right">';
|
||||
|
||||
@@ -351,6 +351,7 @@ class EntitySelectorRenderer
|
||||
$html .= '<i class="' . $this->escapeAttr($blockDef['icon']) . '"></i>';
|
||||
$html .= '<span class="tab-label">' . $this->escapeAttr($blockDef['label']) . '</span>';
|
||||
if ($hasData) {
|
||||
// Show loading spinner that will be replaced with actual count
|
||||
$html .= '<span class="tab-badge loading"><i class="icon-spinner icon-spin"></i></span>';
|
||||
}
|
||||
$html .= '</button>';
|
||||
@@ -491,10 +492,12 @@ class EntitySelectorRenderer
|
||||
|
||||
if ($hasGroups) {
|
||||
$renderedGroups = 0;
|
||||
$showEmptyGroups = $this->renderConfig['show_empty_groups'] ?? false;
|
||||
foreach ($groups as $groupIndex => $group) {
|
||||
$includeMethod = $group['include']['method'] ?? 'all';
|
||||
$includeValues = $group['include']['values'] ?? [];
|
||||
if (!$this->isConditionValid($includeMethod, $includeValues, $methods)) {
|
||||
// Skip validation if show_empty_groups is enabled (for pre-populated empty groups)
|
||||
if (!$showEmptyGroups && !$this->isConditionValid($includeMethod, $includeValues, $methods)) {
|
||||
continue;
|
||||
}
|
||||
if ($mode === 'single' && $renderedGroups > 0) {
|
||||
@@ -542,7 +545,7 @@ class EntitySelectorRenderer
|
||||
$html .= '<i class="icon-plus"></i> ' . $this->trans('Add selection group');
|
||||
$html .= '</button>';
|
||||
$html .= '<span class="mpr-info-wrapper" data-details="' . $this->escapeAttr($groupsTooltip) . '">';
|
||||
$html .= '<span class="mpr-icon icon-info link"></span>';
|
||||
$html .= '<i class="material-icons" style="font-size:16px;color:#5bc0de;cursor:pointer;vertical-align:middle">info</i>';
|
||||
$html .= '</span>';
|
||||
$html .= '</div>';
|
||||
}
|
||||
@@ -604,7 +607,7 @@ class EntitySelectorRenderer
|
||||
$html .= '<span class="result-modifiers-title">' . $this->trans('Result modifiers') . '</span>';
|
||||
$html .= '<span class="result-modifiers-hint">' . $this->trans('(optional)') . '</span>';
|
||||
$html .= '<span class="mpr-info-wrapper" data-details="' . $this->escapeAttr($modifiersTooltip) . '">';
|
||||
$html .= '<span class="mpr-icon icon-info link"></span>';
|
||||
$html .= '<i class="material-icons" style="font-size:16px;color:#5bc0de;cursor:pointer;vertical-align:middle">info</i>';
|
||||
$html .= '</span>';
|
||||
$html .= '</div>';
|
||||
|
||||
@@ -771,7 +774,7 @@ class EntitySelectorRenderer
|
||||
$html .= '<span class="method-info-placeholder">';
|
||||
if (!empty($methodHelp)) {
|
||||
$html .= '<span class="mpr-info-wrapper" data-details="' . $this->escapeAttr($methodHelp) . '">';
|
||||
$html .= '<span class="mpr-icon icon-info link"></span>';
|
||||
$html .= '<i class="material-icons" style="font-size:16px;color:#5bc0de;cursor:pointer;vertical-align:middle">info</i>';
|
||||
$html .= '</span>';
|
||||
}
|
||||
$html .= '</span>';
|
||||
@@ -988,7 +991,7 @@ class EntitySelectorRenderer
|
||||
$html .= '<span class="method-info-placeholder">';
|
||||
if (!empty($methodHelp)) {
|
||||
$html .= '<span class="mpr-info-wrapper" data-details="' . $this->escapeAttr($methodHelp) . '">';
|
||||
$html .= '<span class="mpr-icon icon-info link"></span>';
|
||||
$html .= '<i class="material-icons" style="font-size:16px;color:#5bc0de;cursor:pointer;vertical-align:middle">info</i>';
|
||||
$html .= '</span>';
|
||||
}
|
||||
$html .= '</span>';
|
||||
@@ -1044,7 +1047,9 @@ class EntitySelectorRenderer
|
||||
switch ($valueType) {
|
||||
case 'entity_search':
|
||||
$noItemsPlaceholder = $this->trans('No items selected - use search below');
|
||||
$html .= '<div class="chips-wrapper">';
|
||||
$html .= '<div class="entity-chips ' . $chipsClass . '" data-placeholder="' . $this->escapeAttr($noItemsPlaceholder) . '"></div>';
|
||||
$html .= '</div>';
|
||||
$html .= '<div class="entity-search-box">';
|
||||
$html .= '<i class="icon-search entity-search-icon"></i>';
|
||||
$html .= '<input type="text" class="entity-search-input" placeholder="' . $this->trans('Search by name, reference, ID...') . '" autocomplete="off">';
|
||||
@@ -1324,6 +1329,7 @@ class EntitySelectorRenderer
|
||||
'mode' => $config['mode'] ?? 'multi',
|
||||
'blocks' => $enabledBlocks,
|
||||
'ajaxUrl' => $ajaxUrl,
|
||||
'skipInitialCounts' => !empty($config['skip_initial_counts']) || !empty($config['show_empty_groups']),
|
||||
'trans' => [
|
||||
'all' => $this->trans('All'),
|
||||
'include' => $this->trans('Include'),
|
||||
|
||||
@@ -1214,6 +1214,17 @@ class ProductConditionResolver
|
||||
return [];
|
||||
}
|
||||
|
||||
// Check if additional_delivery_times column exists (added in PS 1.7.7.0)
|
||||
static $columnExists = null;
|
||||
if ($columnExists === null) {
|
||||
$columns = Db::getInstance()->executeS('SHOW COLUMNS FROM ' . _DB_PREFIX_ . 'product_shop LIKE \'additional_delivery_times\'');
|
||||
$columnExists = !empty($columns);
|
||||
}
|
||||
|
||||
if (!$columnExists) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!is_array($settings)) {
|
||||
$settings = [$settings];
|
||||
}
|
||||
|
||||
@@ -844,7 +844,7 @@ trait ScheduleConditions
|
||||
$html .= '<i class="icon-plus"></i> ' . $this->transScheduleConditions('Add selection group');
|
||||
$html .= '</button>';
|
||||
$html .= '<span class="mpr-info-wrapper" data-details="' . htmlspecialchars($groupsTooltip) . '">';
|
||||
$html .= '<span class="mpr-icon icon-info link"></span>';
|
||||
$html .= '<i class="material-icons" style="font-size:16px;color:#5bc0de;cursor:pointer;vertical-align:middle">info</i>';
|
||||
$html .= '</span>';
|
||||
$html .= '</div>';
|
||||
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
prefix: 'tw-',
|
||||
content: [
|
||||
'./src/**/*.php',
|
||||
'./assets/js/admin/**/*.js',
|
||||
// Also scan consuming modules' templates
|
||||
'../../../views/templates/admin/**/*.tpl',
|
||||
'../../../controllers/admin/**/*.php',
|
||||
],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
corePlugins: {
|
||||
preflight: false,
|
||||
},
|
||||
}
|
||||
Reference in New Issue
Block a user