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:
2026-01-31 15:03:51 +01:00
parent b79a89bbb4
commit 7d79273743
37 changed files with 4620 additions and 1913 deletions

937
README.md
View File

@@ -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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -11,7 +11,8 @@
}
],
"require": {
"php": ">=7.1"
"php": ">=7.1",
"myprestarocks/prestashop-admin": "^1.0"
},
"autoload": {
"psr-4": {

View File

@@ -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: [

View File

@@ -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();
}
}
});
}
};

View File

@@ -204,6 +204,11 @@
$.extend(instance, mixins.preview);
}
// Merge tree mixin
if (mixins.tree) {
$.extend(instance, mixins.tree);
}
return instance;
}

View File

@@ -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');
}
};

View File

@@ -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');

View File

@@ -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');
}
},

View File

@@ -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();
},

View File

@@ -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">' +

View File

@@ -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, '&amp;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
},
// =========================================================================
// 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);
}
};

View File

@@ -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

View 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);

View File

@@ -151,6 +151,13 @@
};
}
return null;
},
/**
* Check if entity type supports tree browsing
*/
supportsTreeBrowsing: function(entityType) {
return entityType === 'categories' || entityType === 'cms_categories';
}
};

View File

@@ -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;

View File

@@ -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;

View File

@@ -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 {

View File

@@ -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

View File

@@ -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;

View File

@@ -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;
}
}

View File

@@ -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)
// =============================================================================

View File

@@ -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
// =============================================================================

View 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;
}
}

View File

@@ -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 {

View File

@@ -28,3 +28,4 @@
@use 'components/combinations';
@use 'components/method-dropdown';
@use 'components/tooltip';
@use 'components/tree';

View File

@@ -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
*/

View File

@@ -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'],
];

View File

@@ -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'],
];
}

View File

@@ -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'),

View File

@@ -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];
}

View File

@@ -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>';

View File

@@ -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,
},
}