prestashop-product-extra-tabs · git:20260604.8ae1905 · 2026-06-04 · sha256 3aea4c8cdffafe2d

prestashop-product-extra-tabs git:20260604.8ae1905C

Immutable. This exact content never changes and is served at /api/v1/blob/3aea4c8cdffafe2d.

# PrestaShop Module Development: The "Direct Injection" Pattern (Foolproof Product Tabs)

## Description
This is the **DEFINITIVE, BATTLE-TESTED** method for adding extra tabs and content to PrestaShop 8+ product forms.
It bypasses Symfony's complex data mapping and retrieval mechanisms (Provider/Handler) in favor of **Direct Data Injection** and **Raw $_POST Retrieval**.
This method solves:
- Data saving failure (empty fields).
- Data display failure (empty editors).
- Multi-language indexing confusion (ID vs ISO).
- TinyMCE initialization issues.

## Core Principles (The "Anti-Magic" Rules)
1.  **Direct Read (Display)**: Fetch data via SQL inside the Form Builder and inject it directly into the form's `data` option. Do NOT use `hookActionProductFormDataProviderData`.
2.  **Direct Write (Save)**: Read `$_POST` raw data inside the Save Hook. Do NOT trust Symfony's `$params['form_data']`.
3.  **Double Indexing**: When preparing data arrays, index by **BOTH** ISO Code (`es`, `en`) AND Language ID (`1`, `2`). This covers all variations of `TranslateType` expectations.
4.  **SQL Persistence**: Use `REPLACE INTO` for atomic saving.

## The Implementation

### 1. Database Structure
Standard table with composite key.
```sql
CREATE TABLE IF NOT EXISTS `_DB_PREFIX_my_module_table` (
    `id_product` int(11) UNSIGNED NOT NULL,
    `id_shop` int(11) UNSIGNED NOT NULL,
    `id_lang` int(11) UNSIGNED NOT NULL,
    `extra_content` TEXT,
    PRIMARY KEY (`id_product`, `id_shop`, `id_lang`)
) ENGINE=_MYSQL_ENGINE_ DEFAULT CHARSET=utf8mb4;
```

### 2. Form Builder (Display Logic) - The "Direct Injection"
**Hook**: `hookActionProductFormBuilderModifier`
**Crucial**: Fetch SQL data HERE and pass it to `create()` via `'data' => ...`.

```php
public function hookActionProductFormBuilderModifier(array $params)
{
    $idProduct = (int)$params['id'];
    $formBuilder = $params['form_builder'];
    $idShop = (int)Context::getContext()->shop->id;
    $languages = Language::getLanguages(true);

    // 1. FETCH DATA DIRECTLY
    $sql = 'SELECT * FROM `' . _DB_PREFIX_ . 'my_module_table` 
            WHERE `id_product` = ' . $idProduct . ' 
            AND `id_shop` = ' . $idShop;
    $results = Db::getInstance()->executeS($sql);

    // 2. PREPARE ARRAY (Double Indexing Strategy)
    $content = [];
    foreach ($languages as $lang) {
        $content[$lang['iso_code']] = '';
        $content[$lang['id_lang']] = ''; // Initial empty for ID too
    }

    if ($results) {
        foreach ($results as $row) {
            foreach ($languages as $lang) {
                if ((int)$lang['id_lang'] === (int)$row['id_lang']) {
                    // Populate BOTH keys to be 100% sure
                    $content[$lang['iso_code']] = $row['extra_content'];
                    $content[$lang['id_lang']] = $row['extra_content'];
                    break; 
                }
            }
        }
    }

    // 3. INJECT DATA DIRECTLY INTO FORM
    $initialData = [
        'extra_content' => $content
    ];

    $extraTab = $formBuilder->create('my_module_tab', FormType::class, [
        'label' => $this->trans('My Tab', [], 'Modules.Mymodule.Admin'),
        'inherit_data' => false,
        'mapped' => false, 
        'required' => false,
        'data' => $initialData, // <--- CRITICAL: Direct Injection
    ]);

    $extraTab->add('extra_content', TranslateType::class, [
        'type' => FormattedTextareaType::class, // Standard TinyMCE
        'label' => $this->trans('Content (HTML)', [], 'Modules.Mymodule.Admin'),
        'locales' => $languages,
        'required' => false,
        // Do NOT add 'hideLabels' => false (it crashes)
        // Do NOT add 'attr' => ['class' => 'autoload_rte'] manually (it duplicates)
    ]);

    $formBuilder->add($extraTab);
}
```

### 3. Data Provider (Disable it)
DO NOT USE IT. Leave it empty to prevent conflicts if registered.
```php
public function hookActionProductFormDataProviderData(array $params)
{
    // RETIRED: Logic moved to FormBuilderModifier for reliability.
}
```

### 4. Save Logic (Write Logic) - The "Raw POST"
**Hook**: `hookActionAfterUpdateProductFormHandler`
**Crucial**: Read `$_POST` directly to bypass Symfony filtering/mapping issues.

```php
public function hookActionAfterUpdateProductFormHandler(array $params)
{
    $this->saveData($params);
}

private function saveData(array $params)
{
    // 1. RAW POST RETRIEVAL
    // Structure typically: product[my_module_tab][extra_content][1]
    $moduleData = null;
    if (isset($_POST['product']['my_module_tab'])) {
        $moduleData = $_POST['product']['my_module_tab'];
    }

    // debug log is recommended here
    
    if (!$moduleData) {
        return; 
    }

    $idProduct = (int)$params['id'];
    $idShop = (int)Context::getContext()->shop->id;
    $languages = Language::getLanguages(true);

    foreach ($languages as $lang) {
        $idLang = (int)$lang['id_lang'];
        $iso = $lang['iso_code'];
        
        // 2. RETRIEVE CONTENT (Check ID first, then ISO)
        $content = '';
        if (isset($moduleData['extra_content'][$idLang])) {
            $content = $moduleData['extra_content'][$idLang];
        } elseif (isset($moduleData['extra_content'][$iso])) {
            $content = $moduleData['extra_content'][$iso];
        }

        // 3. DIRECT SQL SAVE
        Db::getInstance()->execute('
            REPLACE INTO `' . _DB_PREFIX_ . 'my_module_table` 
            (`id_product`, `id_shop`, `id_lang`, `extra_content`)
            VALUES (' . $idProduct . ', ' . $idShop . ', ' . $idLang . ', "' . pSQL($content, true) . '")
        ');
    }
}
```

## Summary Checklist
- [ ] Table uses `id_product`, `id_shop`, `id_lang`.
- [ ] `FormBuilder`: Reads SQL and passes `'data' => $array` in `create()`.
- [ ] `FormBuilder`: `initialData` array has double keys (ISO + ID) for each language.
- [ ] `DataProvider`: Empty.
- [ ] `SaveHandler`: Reads `$_POST['product']['tab_name']`.
- [ ] `SaveHandler`: Uses `REPLACE INTO`.

## 🎨 Front-Office Persistence (AJAX)
**DANGER**: Even if your Admin Tab saves perfectly, your Front-Office JS will **DIE** when the user changes a product combination (color, size) because PrestaShop replaces the DOM via AJAX.

**OBLIGATORIO**: Always include this re-initializer in your Front JS:
```javascript
if (typeof prestashop !== 'undefined') {
    prestashop.on('updateProduct', function (event) {
        initMyModuleFrontLogic(); // Re-run your event binders
    });
}
```

**SI NO CUMPLES ESTE PROTOCOLO, CUALQUIER "FIX" SE CONSIDERA FALLIDO.**