Створення модуля: покроково

Що ми створимо#

У цьому посібнику ми з нуля напишемо модуль «Привітальний блок» (hello_block) — блок із заголовком і текстом, який можна розмістити в будь-якій позиції макета (ліва чи права колонка, верх або низ сторінки). На відміну від прикладу з розділу Доповнення, де модуль має лише глобальний статус і працює через події, тут модуль:

  • має кілька екземплярів (наприклад, один блок для головної, інший — для категорій);
  • зберігає налаштування кожного екземпляра в таблиці oc_module;
  • підтримує кілька мов вітрини;
  • виводиться на вітрині через систему макетів.

Саме так улаштовані стандартні модулі ocStore на кшталт «HTML контент», «Банер» чи «Рекомендовані».

Перш ніж почати

Ознайомтеся з розділами Архітектура MVC-L (маршрути, завантажувач, шаблони) та Доповнення (типи доповнень, структура пакета). Тут ми не повторюємо теорію, а зосереджуємося на коді.

Структура файлів#

hello_block/
└── upload/
    ├── admin/
    │   ├── controller/extension/module/hello_block.php
    │   ├── language/uk-ua/extension/module/hello_block.php
    │   ├── language/en-gb/extension/module/hello_block.php
    │   └── view/template/extension/module/hello_block.twig
    └── catalog/
        ├── controller/extension/module/hello_block.php
        ├── language/uk-ua/extension/module/hello_block.php
        ├── language/en-gb/extension/module/hello_block.php
        └── view/theme/default/template/extension/module/hello_block.twig

Зверніть увагу на кілька правил:

  • Код модуля (hello_block) — це назва файлів. Пишіть його латиницею в нижньому регістрі, слова розділяйте _.
  • Маршрут модуля в адмінці та на вітрині — extension/module/hello_block.
  • Клас контролера: ControllerExtensionModuleHelloBlock (у адмінці й на вітрині — однаково, бо це різні застосунки).
  • Шаблон вітрини кладіть у тему default: якщо в активній темі немає власного файлу, ocStore автоматично візьме його з default.

Крок 1. Контролер адмінки#

Файл: upload/admin/controller/extension/module/hello_block.php

Контролер показує форму налаштувань екземпляра, перевіряє дані та зберігає їх через модель setting/module.

<?php
class ControllerExtensionModuleHelloBlock extends Controller {
    private $error = array();

    public function index() {
        $this->load->language('extension/module/hello_block');

        $this->document->setTitle($this->language->get('heading_title'));

        $this->load->model('setting/module');

        // Збереження форми
        if (($this->request->server['REQUEST_METHOD'] == 'POST') && $this->validate()) {
            if (!isset($this->request->get['module_id'])) {
                // Новий екземпляр модуля
                $this->model_setting_module->addModule('hello_block', $this->request->post);
            } else {
                // Редагування наявного екземпляра
                $this->model_setting_module->editModule($this->request->get['module_id'], $this->request->post);
            }

            $this->session->data['success'] = $this->language->get('text_success');

            $this->response->redirect($this->url->link('marketplace/extension', 'user_token=' . $this->session->data['user_token'] . '&type=module', true));
        }

        // Помилки
        if (isset($this->error['warning'])) {
            $data['error_warning'] = $this->error['warning'];
        } else {
            $data['error_warning'] = '';
        }

        if (isset($this->error['name'])) {
            $data['error_name'] = $this->error['name'];
        } else {
            $data['error_name'] = '';
        }

        // Посилання
        $url = '';

        if (isset($this->request->get['module_id'])) {
            $url .= '&module_id=' . (int)$this->request->get['module_id'];
        }

        $data['breadcrumbs'] = array();

        $data['breadcrumbs'][] = array(
            'text' => $this->language->get('text_home'),
            'href' => $this->url->link('common/dashboard', 'user_token=' . $this->session->data['user_token'], true)
        );

        $data['breadcrumbs'][] = array(
            'text' => $this->language->get('text_extension'),
            'href' => $this->url->link('marketplace/extension', 'user_token=' . $this->session->data['user_token'] . '&type=module', true)
        );

        $data['breadcrumbs'][] = array(
            'text' => $this->language->get('heading_title'),
            'href' => $this->url->link('extension/module/hello_block', 'user_token=' . $this->session->data['user_token'] . $url, true)
        );

        $data['action'] = $this->url->link('extension/module/hello_block', 'user_token=' . $this->session->data['user_token'] . $url, true);
        $data['cancel'] = $this->url->link('marketplace/extension', 'user_token=' . $this->session->data['user_token'] . '&type=module', true);

        // Дані наявного екземпляра
        if (isset($this->request->get['module_id']) && ($this->request->server['REQUEST_METHOD'] != 'POST')) {
            $module_info = $this->model_setting_module->getModule($this->request->get['module_id']);
        }

        if (isset($this->request->post['name'])) {
            $data['name'] = $this->request->post['name'];
        } elseif (!empty($module_info)) {
            $data['name'] = $module_info['name'];
        } else {
            $data['name'] = '';
        }

        if (isset($this->request->post['module_description'])) {
            $data['module_description'] = $this->request->post['module_description'];
        } elseif (!empty($module_info['module_description'])) {
            $data['module_description'] = $module_info['module_description'];
        } else {
            $data['module_description'] = array();
        }

        if (isset($this->request->post['status'])) {
            $data['status'] = $this->request->post['status'];
        } elseif (!empty($module_info)) {
            $data['status'] = $module_info['status'];
        } else {
            $data['status'] = '';
        }

        // Мови для вкладок форми
        $this->load->model('localisation/language');

        $data['languages'] = $this->model_localisation_language->getLanguages();

        $data['header'] = $this->load->controller('common/header');
        $data['column_left'] = $this->load->controller('common/column_left');
        $data['footer'] = $this->load->controller('common/footer');

        $this->response->setOutput($this->load->view('extension/module/hello_block', $data));
    }

    protected function validate() {
        if (!$this->user->hasPermission('modify', 'extension/module/hello_block')) {
            $this->error['warning'] = $this->language->get('error_permission');
        }

        if ((utf8_strlen($this->request->post['name']) < 3) || (utf8_strlen($this->request->post['name']) > 64)) {
            $this->error['name'] = $this->language->get('error_name');
        }

        return !$this->error;
    }
}

Що тут важливо:

  • addModule($code, $data) і editModule($module_id, $data) зберігають увесь масив $this->request->post у JSON-полі setting таблиці oc_module. Поле name записується ще й в окрему колонку — це назва екземпляра, яку видно в списку модулів і в макетах.
  • Поле status обов'язкове для модулів з екземплярами: вітрина показує екземпляр, лише якщо в його налаштуваннях status дорівнює 1.
  • Методи install() та uninstall() тут не потрібні: під час активації/деактивації ocStore сам додає модуль у таблицю oc_extension, видає й прибирає права, а під час деактивації видаляє всі екземпляри модуля та їх розміщення в макетах. Додавайте ці методи, лише якщо модулю потрібні власні таблиці чи події.

Крок 2. Мовні файли адмінки#

Файл: upload/admin/language/uk-ua/extension/module/hello_block.php

<?php
// Heading
$_['heading_title']     = 'Привітальний блок';

// Text
$_['text_extension']    = 'Доповнення';
$_['text_success']      = 'Налаштування модуля збережено!';
$_['text_edit']         = 'Редагування модуля';

// Entry
$_['entry_name']        = 'Назва модуля';
$_['entry_title']       = 'Заголовок';
$_['entry_text']        = 'Текст';
$_['entry_status']      = 'Статус';

// Error
$_['error_permission']  = 'У вас недостатньо прав для зміни модуля «Привітальний блок»!';
$_['error_name']        = 'Назва модуля має містити від 3 до 64 символів!';

Файл: upload/admin/language/en-gb/extension/module/hello_block.php

<?php
// Heading
$_['heading_title']     = 'Hello block';

// Text
$_['text_extension']    = 'Extensions';
$_['text_success']      = 'Success: You have modified Hello block module!';
$_['text_edit']         = 'Edit Hello block module';

// Entry
$_['entry_name']        = 'Module name';
$_['entry_title']       = 'Title';
$_['entry_text']        = 'Text';
$_['entry_status']      = 'Status';

// Error
$_['error_permission']  = 'Warning: You do not have permission to modify Hello block module!';
$_['error_name']        = 'Module name must be between 3 and 64 characters!';

Загальні рядки (text_home, text_enabled, text_disabled, button_save, button_cancel) уже є в головному мовному файлі адмінки, тож оголошувати їх повторно не потрібно. Усі завантажені мовні рядки автоматично доступні в шаблоні.

Крок 3. Шаблон адмінки#

Файл: upload/admin/view/template/extension/module/hello_block.twig

Адмінка ocStore 3 використовує Bootstrap 3 і Font Awesome 4.

{{ header }}{{ column_left }}
<div id="content">
  <div class="page-header">
    <div class="container-fluid">
      <div class="pull-right">
        <button type="submit" form="form-module" data-toggle="tooltip" title="{{ button_save }}" class="btn btn-primary"><i class="fa fa-save"></i></button>
        <a href="{{ cancel }}" data-toggle="tooltip" title="{{ button_cancel }}" class="btn btn-default"><i class="fa fa-reply"></i></a></div>
      <h1>{{ heading_title }}</h1>
      <ul class="breadcrumb">
        {% for breadcrumb in breadcrumbs %}
        <li><a href="{{ breadcrumb.href }}">{{ breadcrumb.text }}</a></li>
        {% endfor %}
      </ul>
    </div>
  </div>
  <div class="container-fluid">
    {% if error_warning %}
    <div class="alert alert-danger alert-dismissible"><i class="fa fa-exclamation-circle"></i> {{ error_warning }}
      <button type="button" class="close" data-dismiss="alert">&times;</button>
    </div>
    {% endif %}
    <div class="panel panel-default">
      <div class="panel-heading">
        <h3 class="panel-title"><i class="fa fa-pencil"></i> {{ text_edit }}</h3>
      </div>
      <div class="panel-body">
        <form action="{{ action }}" method="post" enctype="multipart/form-data" id="form-module" class="form-horizontal">
          <div class="form-group required">
            <label class="col-sm-2 control-label" for="input-name">{{ entry_name }}</label>
            <div class="col-sm-10">
              <input type="text" name="name" value="{{ name }}" placeholder="{{ entry_name }}" id="input-name" class="form-control" />
              {% if error_name %}
              <div class="text-danger">{{ error_name }}</div>
              {% endif %}
            </div>
          </div>
          <ul class="nav nav-tabs" id="language">
            {% for language in languages %}
            <li><a href="#language{{ language.language_id }}" data-toggle="tab"><img src="language/{{ language.code }}/{{ language.code }}.png" title="{{ language.name }}" /> {{ language.name }}</a></li>
            {% endfor %}
          </ul>
          <div class="tab-content">
            {% for language in languages %}
            <div class="tab-pane" id="language{{ language.language_id }}">
              <div class="form-group">
                <label class="col-sm-2 control-label" for="input-title{{ language.language_id }}">{{ entry_title }}</label>
                <div class="col-sm-10">
                  <input type="text" name="module_description[{{ language.language_id }}][title]" value="{{ module_description[language.language_id] ? module_description[language.language_id].title }}" placeholder="{{ entry_title }}" id="input-title{{ language.language_id }}" class="form-control" />
                </div>
              </div>
              <div class="form-group">
                <label class="col-sm-2 control-label" for="input-text{{ language.language_id }}">{{ entry_text }}</label>
                <div class="col-sm-10">
                  <textarea name="module_description[{{ language.language_id }}][text]" rows="5" placeholder="{{ entry_text }}" id="input-text{{ language.language_id }}" class="form-control">{{ module_description[language.language_id] ? module_description[language.language_id].text }}</textarea>
                </div>
              </div>
            </div>
            {% endfor %}
          </div>
          <div class="form-group">
            <label class="col-sm-2 control-label" for="input-status">{{ entry_status }}</label>
            <div class="col-sm-10">
              <select name="status" id="input-status" class="form-control">
                {% if status %}
                <option value="1" selected="selected">{{ text_enabled }}</option>
                <option value="0">{{ text_disabled }}</option>
                {% else %}
                <option value="1">{{ text_enabled }}</option>
                <option value="0" selected="selected">{{ text_disabled }}</option>
                {% endif %}
              </select>
            </div>
          </div>
        </form>
      </div>
    </div>
  </div>
  <script type="text/javascript"><!--
$('#language a:first').tab('show');
//--></script></div>
{{ footer }}

Тексти зберігаються в масиві module_description, де ключ — language_id мови. Так само зберігає дані стандартний модуль «HTML контент».

Крок 4. Контролер вітрини#

Файл: upload/catalog/controller/extension/module/hello_block.php

Коли макет сторінки містить екземпляр модуля, ocStore викликає метод index() і передає йому масив налаштувань цього екземпляра (той самий, що зберегла форма в адмінці).

<?php
class ControllerExtensionModuleHelloBlock extends Controller {
    public function index($setting) {
        $language_id = (int)$this->config->get('config_language_id');

        // Немає тексту для поточної мови — нічого не виводимо
        if (empty($setting['module_description'][$language_id]['text'])) {
            return '';
        }

        $this->load->language('extension/module/hello_block');

        $description = $setting['module_description'][$language_id];

        if (!empty($description['title'])) {
            $data['title'] = $description['title'];
        } else {
            $data['title'] = $this->language->get('heading_title');
        }

        $data['text'] = nl2br($description['text']);

        return $this->load->view('extension/module/hello_block', $data);
    }
}
  • Метод повертає HTML (return), а не виводить його через $this->response->setOutput(): результат вставляє в сторінку контролер позиції (common/column_left, common/content_top тощо).
  • Дані форми потрапили в базу через $this->request->post, тобто вже пропущені через htmlspecialchars(). Тому текст можна безпечно виводити в шаблоні як є.

Крок 5. Мовні файли вітрини#

Файл: upload/catalog/language/uk-ua/extension/module/hello_block.php

<?php
// Heading
$_['heading_title'] = 'Вітаємо!';

Файл: upload/catalog/language/en-gb/extension/module/hello_block.php

<?php
// Heading
$_['heading_title'] = 'Welcome!';

Цей рядок використовується як заголовок за замовчуванням, якщо для екземпляра не вказано власного.

Крок 6. Шаблон вітрини#

Файл: upload/catalog/view/theme/default/template/extension/module/hello_block.twig

<div class="hello-block">
  {% if title %}
  <h3>{{ title }}</h3>
  {% endif %}
  <p>{{ text }}</p>
</div>

Якщо ваша тема потребує іншої розмітки, створіть власну копію файлу за шляхом catalog/view/theme/<ваша_тема>/template/extension/module/hello_block.twig — вона матиме пріоритет над файлом з default.

Крок 7. Пакування#

  1. Переконайтеся, що структура файлів відповідає наведеній вище.
  2. Заархівуйте вміст теки hello_block/ (тобто саму теку upload/) у ZIP з назвою hello_block.ocmod.zip. Відкривши архів, ви маєте одразу побачити upload/.
  3. Назва архіву обов'язково має закінчуватися на .ocmod.zip, інакше інсталятор його не прийме.

Модулю не потрібен install.xml, бо він не змінює файлів ядра. Якщо колись знадобиться — див. Система модифікацій OCMOD.

Порада

Під час розробки зручніше не перепаковувати архів після кожної правки, а одразу скопіювати файли з upload/ у відповідні теки тестового магазину через FTP. ZIP знадобиться для фінальної перевірки й поширення.

Крок 8. Встановлення та перевірка#

  1. Завантажте пакет. Відкрийте Доповнення → Установка доповнень, натисніть Завантажити й виберіть hello_block.ocmod.zip. Дочекайтеся завершення.
  2. Активуйте модуль. Перейдіть у Доповнення → Доповнення, у списку «Оберіть тип доповнення» виберіть Модулі, знайдіть «Привітальний блок» і натисніть зелену кнопку Активація (+).
  3. Створіть екземпляр. Натисніть синю кнопку навпроти модуля (Редагувати з олівцем; коли екземпляри вже існують, на її місці кнопка Додати з +), заповніть назву (наприклад, «Привітання на головній»), заголовок і текст для кожної мови, встановіть статус «Включено» та збережіть. Екземпляр з'явиться в списку під назвою модуля. Повторіть, якщо потрібно кілька різних блоків.
  4. Розмістіть модуль у макеті. Відкрийте Дизайн → Макети, відредагуйте потрібний макет (наприклад, «Головна»), у бажаній позиції («Ліва колонка», «Верх сторінки» тощо) виберіть у списку екземпляр «Привітання на головній» (він згрупований під назвою модуля «Привітальний блок»), натисніть + і збережіть. Докладніше — у розділі Макети.
  5. Перевірте вітрину. Відкрийте сторінку, до якої застосовано макет. Якщо змін не видно, очистіть кеш шаблонів: Панель стану → кнопка з шестернею («Налаштування розробника») → Шаблон.

Права доступу#

Під час активації ocStore автоматично додає дозволи на перегляд (access) і редагування (modify) маршруту extension/module/hello_block лише групі поточного користувача. Іншим групам адміністраторів доступ треба надати вручну:

  1. Відкрийте Система → Користувачі → Групи користувачів і відредагуйте групу.
  2. Позначте extension/module/hello_block у списках Дозволено перегляд і Дозволено редагування.
  3. Збережіть.

У тій самій формі є список Приховати доповнення — позначені там доповнення не показуються цій групі в списку доповнень.

Без дозволу на перегляд адмінка покаже сторінку «Доступ заборонений», без дозволу на зміну — помилку error_permission під час збереження форми. Під час деактивації модуля ці дозволи прибираються автоматично.

Типові проблеми#

Модуля немає в списку «Модулі»

Перевірте шлях і назву файлу контролера адмінки: admin/controller/extension/module/hello_block.php. ocStore будує список модулів саме за файлами в цій теці. Також переконайтеся, що модуль не приховано в налаштуваннях прав групи користувачів.

Після збереження форми з'являється порожня сторінка або помилка «Could not call»

Найчастіше це помилка в назві класу. Для файлу extension/module/hello_block.php клас має називатися ControllerExtensionModuleHelloBlock. Подробиці помилки дивіться в Система → Обслуговування → Журнал помилок.

Модуль розміщено в макеті, але на вітрині його не видно

Перевірте, що: статус екземпляра — «Включено»; для поточної мови вітрини заповнено поле «Текст»; макет справді застосовується до відкритої сторінки (маршрут у налаштуваннях макета); шаблон лежить у catalog/view/theme/default/template/extension/module/. Після змін очистіть кеш шаблонів.

На вітрині замість тексту видно ключі на кшталт heading_title

Мовний файл вітрини не знайдено. Перевірте шлях catalog/language/<код мови>/extension/module/hello_block.php і що контролер викликає $this->load->language('extension/module/hello_block').

Що далі#

  • Додайте модель catalog/model/extension/module/hello_block.php (клас ModelExtensionModuleHelloBlock), якщо модулю потрібні дані з бази — наприклад, список товарів.
  • Підключіть власні стилі: покладіть CSS у catalog/view/theme/default/stylesheet/ і додайте його в контролері через $this->document->addStyle().
  • Вивчіть код стандартних модулів html, banner, featured у admin/controller/extension/module/ і catalog/controller/extension/module/ — вони побудовані за тією самою схемою.
  • Дізнайтеся, як реагувати на дії в магазині, у розділі Події.
Сторінку перекладено й адаптовано для ocStore з офіційної документації OpenCart. Ліцензія — GNU GPL v3.