Compare commits

..
3 Commits
Author SHA1 Message Date
isabelle fb641fc236 feat(main): Add custom CSS classes to HTML blocks
This feature allows administrators to define custom CSS classes for individual HTML blocks.
It enhances styling flexibility and integrates with common grid systems (e.g., Bootstrap `col-md-6`)
by applying user-defined classes to the block's wrapper element.

Includes:
- A new `custom_class` field in the block model, form, and data handlers.
- Database schema updates with an upgrade path to handle potential renames from `column_class`.
- Frontend rendering to apply the classes.
- Registration of `actionFrontControllerSetMedia` hook to load front-end CSS.
- Basic responsive image styling for blocks.
2026-08-13 11:33:24 +02:00
isabelle d0da05845a feat(main): Draggable blocks 2026-08-12 15:39:07 +02:00
isabelle 1493081602 feat(main): Add FAQ accordion 2026-08-11 13:25:06 +02:00
30 changed files with 632 additions and 12 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

+8
View File
@@ -33,6 +33,14 @@ services:
arguments: arguments:
- '@websmid.customhtml.form.customhtmlgroup_data_handler' - '@websmid.customhtml.form.customhtmlgroup_data_handler'
websmid.customhtml.form.type.customhtml_faq:
public: true
class: 'Websmid\CustomHtml\Form\Type\CustomHtmlFaqType'
arguments:
- '@translator'
tags:
- { name: form.type }
websmid.customhtml.form.type.customhtml_block: websmid.customhtml.form.type.customhtml_block:
public: true public: true
class: 'Websmid\CustomHtml\Form\Type\CustomHtmlBlockType' class: 'Websmid\CustomHtml\Form\Type\CustomHtmlBlockType'
+10
View File
@@ -68,3 +68,13 @@ services:
- '@websmid.customhtml.grid.data.factory.customhtmlblock_data_factory' - '@websmid.customhtml.grid.data.factory.customhtmlblock_data_factory'
- '@prestashop.core.grid.filter.form_factory' - '@prestashop.core.grid.filter.form_factory'
- '@prestashop.core.hook.dispatcher' - '@prestashop.core.hook.dispatcher'
websmid.customhtml.grid.position.customhtmlblock_position_definition:
public: true
class: 'PrestaShop\PrestaShop\Core\Grid\Position\PositionDefinition'
arguments:
- 'ws_customhtml'
- 'id_ws_customhtml'
- 'ordering'
- 'id_ws_customhtml_group'
- 0
+6
View File
@@ -71,3 +71,9 @@ customhtmlblock_delete:
methods: [DELETE, GET, POST] methods: [DELETE, GET, POST]
defaults: defaults:
_controller: 'Websmid\CustomHtml\Controller\CustomHtmlBlockController::deleteAction' _controller: 'Websmid\CustomHtml\Controller\CustomHtmlBlockController::deleteAction'
customhtmlblock_update_position:
path: ws_customhtml/block/{idGroup}/update-position
methods: [POST]
defaults:
_controller: 'Websmid\CustomHtml\Controller\CustomHtmlBlockController::updatePositionAction'
+7
View File
@@ -6,6 +6,13 @@ class Config
{ {
const HOOKS = [ const HOOKS = [
'actionAdminControllerSetMedia', 'actionAdminControllerSetMedia',
'actionCustomhtmlblockGridDataModifier',
'actionFrontControllerSetMedia',
];
const BLOCK_TYPES = [
'html' => 'HTML',
'faq' => 'FAQ Accordion',
]; ];
const ADMIN_CONTROLLERS = [ const ADMIN_CONTROLLERS = [
@@ -3,6 +3,9 @@
namespace Websmid\CustomHtml\Controller; namespace Websmid\CustomHtml\Controller;
use Exception; use Exception;
use PrestaShop\PrestaShop\Core\Grid\Position\Exception\PositionUpdateException;
use PrestaShop\PrestaShop\Core\Grid\Position\GridPositionUpdaterInterface;
use PrestaShop\PrestaShop\Core\Grid\Position\PositionUpdateFactoryInterface;
use PrestaShopBundle\Controller\Admin\FrameworkBundleAdminController; use PrestaShopBundle\Controller\Admin\FrameworkBundleAdminController;
use PrestaShopBundle\Security\Voter\PageVoter; use PrestaShopBundle\Security\Voter\PageVoter;
use Symfony\Component\HttpFoundation\RedirectResponse; use Symfony\Component\HttpFoundation\RedirectResponse;
@@ -11,6 +14,7 @@ use Symfony\Component\HttpFoundation\Response;
use Websmid\CustomHtml\Grid\Definition\Factory\CustomHtmlBlockGridDefinitionFactory; use Websmid\CustomHtml\Grid\Definition\Factory\CustomHtmlBlockGridDefinitionFactory;
use Websmid\CustomHtml\Grid\Filters\CustomHtmlBlockFilters; use Websmid\CustomHtml\Grid\Filters\CustomHtmlBlockFilters;
use Websmid\CustomHtml\Model\CustomHtmlBlock; use Websmid\CustomHtml\Model\CustomHtmlBlock;
use Websmid\CustomHtml\Model\CustomHtmlFaq;
use Validate; use Validate;
class CustomHtmlBlockController extends FrameworkBundleAdminController class CustomHtmlBlockController extends FrameworkBundleAdminController
@@ -107,12 +111,35 @@ class CustomHtmlBlockController extends FrameworkBundleAdminController
return $this->redirectToRoute('customhtmlblock_index'); return $this->redirectToRoute('customhtmlblock_index');
} }
CustomHtmlFaq::deleteByBlockId($idCustomHtmlBlock);
$block->delete(); $block->delete();
$this->addFlash('success', $this->trans('Successful deletion.', 'Admin.Notifications.Success')); $this->addFlash('success', $this->trans('Successful deletion.', 'Admin.Notifications.Success'));
return $this->redirectToRoute('customhtmlblock_index'); return $this->redirectToRoute('customhtmlblock_index');
} }
public function updatePositionAction(int $idGroup, Request $request): RedirectResponse
{
$positionDefinition = $this->get('websmid.customhtml.grid.position.customhtmlblock_position_definition');
try {
$positionUpdateFactory = $this->get(PositionUpdateFactoryInterface::class);
$positionUpdate = $positionUpdateFactory->buildPositionUpdate([
'positions' => $request->request->all('positions'),
'parentId' => $idGroup,
], $positionDefinition);
$updater = $this->get(GridPositionUpdaterInterface::class);
$updater->update($positionUpdate);
$this->addFlash('success', $this->trans('Successful update.', 'Admin.Notifications.Success'));
} catch (PositionUpdateException $e) {
$this->addFlash('error', $this->getErrorMessageForException($e, []));
}
return $this->redirectToRoute('customhtmlblock_index', ['idGroup' => $idGroup]);
}
private function getToolbarButtons(?string $securitySubject): array private function getToolbarButtons(?string $securitySubject): array
{ {
$toolbarButtons = []; $toolbarButtons = [];
@@ -4,24 +4,32 @@ namespace Websmid\CustomHtml\Form\DataHandler;
use PrestaShop\PrestaShop\Core\Form\IdentifiableObject\DataHandler\FormDataHandlerInterface; use PrestaShop\PrestaShop\Core\Form\IdentifiableObject\DataHandler\FormDataHandlerInterface;
use Websmid\CustomHtml\Model\CustomHtmlBlock; use Websmid\CustomHtml\Model\CustomHtmlBlock;
use Websmid\CustomHtml\Model\CustomHtmlFaq;
use Db;
use Validate; use Validate;
final class CustomHtmlBlockDataHandler implements FormDataHandlerInterface final class CustomHtmlBlockDataHandler implements FormDataHandlerInterface
{ {
public function create(array $formData) public function create(array $formData)
{ {
$idGroup = (int) $formData['id_ws_customhtml_group'];
$block = new CustomHtmlBlock(); $block = new CustomHtmlBlock();
$block->id_ws_customhtml_group = (int) $formData['id_ws_customhtml_group']; $block->id_ws_customhtml_group = $idGroup;
$block->title = (string) $formData['title']; $block->title = (string) $formData['title'];
$block->type = (string) $formData['type'];
$block->show_title = (int) $formData['show_title']; $block->show_title = (int) $formData['show_title'];
$block->custom_class = (string) ($formData['custom_class'] ?? '');
$block->content = (string) $formData['content']; $block->content = (string) $formData['content'];
$block->active = (int) $formData['active']; $block->active = (int) $formData['active'];
$block->ordering = 0; $block->ordering = $this->getNextOrdering($idGroup);
if (!$block->add()) { if (!$block->add()) {
return false; return false;
} }
$this->saveFaqs((int) $block->id, $formData['faqs'] ?? []);
return $block->id; return $block->id;
} }
@@ -35,10 +43,46 @@ final class CustomHtmlBlockDataHandler implements FormDataHandlerInterface
$block->id_ws_customhtml_group = (int) $formData['id_ws_customhtml_group']; $block->id_ws_customhtml_group = (int) $formData['id_ws_customhtml_group'];
$block->title = (string) $formData['title']; $block->title = (string) $formData['title'];
$block->type = (string) $formData['type'];
$block->show_title = (int) $formData['show_title']; $block->show_title = (int) $formData['show_title'];
$block->custom_class = (string) ($formData['custom_class'] ?? '');
$block->content = (string) $formData['content']; $block->content = (string) $formData['content'];
$block->active = (int) $formData['active']; $block->active = (int) $formData['active'];
return (bool) $block->update(); if (!$block->update()) {
return false;
}
$this->saveFaqs((int) $id, $formData['faqs'] ?? []);
return true;
}
private function getNextOrdering(int $idGroup): int
{
$maxOrdering = Db::getInstance()->getValue(
'SELECT MAX(`ordering`) FROM `' . _DB_PREFIX_ . 'ws_customhtml`
WHERE `id_ws_customhtml_group` = ' . $idGroup
);
return $maxOrdering === false || $maxOrdering === null ? 0 : ((int) $maxOrdering + 1);
}
private function saveFaqs(int $idBlock, array $faqs): void
{
CustomHtmlFaq::deleteByBlockId($idBlock);
foreach (array_values($faqs) as $position => $faqData) {
if (empty($faqData['question'])) {
continue;
}
$faq = new CustomHtmlFaq();
$faq->id_ws_customhtml = $idBlock;
$faq->question = (string) $faqData['question'];
$faq->answer = (string) ($faqData['answer'] ?? '');
$faq->ordering = $position;
$faq->add();
}
} }
} }
@@ -4,6 +4,7 @@ namespace Websmid\CustomHtml\Form\DataProvider;
use PrestaShop\PrestaShop\Core\Form\IdentifiableObject\DataProvider\FormDataProviderInterface; use PrestaShop\PrestaShop\Core\Form\IdentifiableObject\DataProvider\FormDataProviderInterface;
use Websmid\CustomHtml\Model\CustomHtmlBlock; use Websmid\CustomHtml\Model\CustomHtmlBlock;
use Websmid\CustomHtml\Model\CustomHtmlFaq;
use Validate; use Validate;
final class CustomHtmlBlockDataProvider implements FormDataProviderInterface final class CustomHtmlBlockDataProvider implements FormDataProviderInterface
@@ -19,8 +20,11 @@ final class CustomHtmlBlockDataProvider implements FormDataProviderInterface
return [ return [
'id_ws_customhtml_group' => (int) $block->id_ws_customhtml_group, 'id_ws_customhtml_group' => (int) $block->id_ws_customhtml_group,
'title' => $block->title, 'title' => $block->title,
'type' => $block->type,
'show_title' => (bool) $block->show_title, 'show_title' => (bool) $block->show_title,
'custom_class' => $block->custom_class,
'content' => $block->content, 'content' => $block->content,
'faqs' => $this->loadFaqs((int) $id),
'active' => (bool) $block->active, 'active' => (bool) $block->active,
]; ];
} }
@@ -30,9 +34,22 @@ final class CustomHtmlBlockDataProvider implements FormDataProviderInterface
return [ return [
'id_ws_customhtml_group' => 0, 'id_ws_customhtml_group' => 0,
'title' => '', 'title' => '',
'type' => 'html',
'show_title' => false, 'show_title' => false,
'custom_class' => '',
'content' => '', 'content' => '',
'faqs' => [],
'active' => true, 'active' => true,
]; ];
} }
private function loadFaqs(int $idBlock): array
{
return array_map(static function (array $faq) {
return [
'question' => $faq['question'],
'answer' => $faq['answer'],
];
}, CustomHtmlFaq::getByBlockId($idBlock));
}
} }
+21
View File
@@ -5,11 +5,13 @@ namespace Websmid\CustomHtml\Form\Type;
use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType; use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\CollectionType;
use Symfony\Component\Form\Extension\Core\Type\TextType; use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType; use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use PrestaShop\PrestaShop\Core\Form\ConfigurableFormChoiceProviderInterface; use PrestaShop\PrestaShop\Core\Form\ConfigurableFormChoiceProviderInterface;
use PrestaShopBundle\Form\Admin\Type\SwitchType; use PrestaShopBundle\Form\Admin\Type\SwitchType;
use Symfony\Contracts\Translation\TranslatorInterface; use Symfony\Contracts\Translation\TranslatorInterface;
use Websmid\CustomHtml\Config\Config;
class CustomHtmlBlockType extends AbstractType class CustomHtmlBlockType extends AbstractType
{ {
@@ -37,14 +39,33 @@ class CustomHtmlBlockType extends AbstractType
'label' => $this->translator->trans('Title', [], 'Modules.Wscustomhtml.Admin'), 'label' => $this->translator->trans('Title', [], 'Modules.Wscustomhtml.Admin'),
'required' => true, 'required' => true,
]) ])
->add('type', ChoiceType::class, [
'label' => $this->translator->trans('Block type', [], 'Modules.Wscustomhtml.Admin'),
'choices' => array_flip(Config::BLOCK_TYPES),
'required' => true,
])
->add('show_title', SwitchType::class, [ ->add('show_title', SwitchType::class, [
'label' => $this->translator->trans('Show title', [], 'Modules.Wscustomhtml.Admin'), 'label' => $this->translator->trans('Show title', [], 'Modules.Wscustomhtml.Admin'),
'required' => false, 'required' => false,
]) ])
->add('custom_class', TextType::class, [
'label' => $this->translator->trans('Custom classes', [], 'Modules.Wscustomhtml.Admin'),
'required' => false,
'attr' => ['placeholder' => 'col-md-6'],
])
->add('content', TextareaType::class, [ ->add('content', TextareaType::class, [
'label' => $this->translator->trans('Content', [], 'Modules.Wscustomhtml.Admin'), 'label' => $this->translator->trans('Content', [], 'Modules.Wscustomhtml.Admin'),
'required' => false, 'required' => false,
'attr' => ['class' => 'autoload_rte'], 'attr' => ['class' => 'autoload_rte'],
'row_attr' => ['class' => 'customhtmlblock-type-field customhtmlblock-type-html'],
])
->add('faqs', CollectionType::class, [
'entry_type' => CustomHtmlFaqType::class,
'allow_add' => true,
'allow_delete' => true,
'by_reference' => false,
'prototype' => true,
'required' => false,
]) ])
->add('active', SwitchType::class, [ ->add('active', SwitchType::class, [
'label' => $this->translator->trans('Active', [], 'Modules.Wscustomhtml.Admin'), 'label' => $this->translator->trans('Active', [], 'Modules.Wscustomhtml.Admin'),
+32
View File
@@ -0,0 +1,32 @@
<?php
namespace Websmid\CustomHtml\Form\Type;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Contracts\Translation\TranslatorInterface;
class CustomHtmlFaqType extends AbstractType
{
private $translator;
public function __construct(TranslatorInterface $translator)
{
$this->translator = $translator;
}
public function buildForm(FormBuilderInterface $formBuilder, array $options): void
{
$formBuilder
->add('question', TextType::class, [
'label' => $this->translator->trans('Question', [], 'Modules.Wscustomhtml.Admin'),
'required' => false,
])
->add('answer', TextareaType::class, [
'label' => $this->translator->trans('Answer', [], 'Modules.Wscustomhtml.Admin'),
'required' => false,
]);
}
}
@@ -8,6 +8,7 @@ use PrestaShop\PrestaShop\Core\Grid\Action\Row\Type\LinkRowAction;
use PrestaShop\PrestaShop\Core\Grid\Action\Type\SimpleGridAction; use PrestaShop\PrestaShop\Core\Grid\Action\Type\SimpleGridAction;
use PrestaShop\PrestaShop\Core\Grid\Column\ColumnCollection; use PrestaShop\PrestaShop\Core\Grid\Column\ColumnCollection;
use PrestaShop\PrestaShop\Core\Grid\Column\Type\Common\ActionColumn; use PrestaShop\PrestaShop\Core\Grid\Column\Type\Common\ActionColumn;
use PrestaShop\PrestaShop\Core\Grid\Column\Type\Common\PositionColumn;
use PrestaShop\PrestaShop\Core\Grid\Column\Type\DataColumn; use PrestaShop\PrestaShop\Core\Grid\Column\Type\DataColumn;
use PrestaShop\PrestaShop\Core\Grid\Definition\Factory\AbstractGridDefinitionFactory; use PrestaShop\PrestaShop\Core\Grid\Definition\Factory\AbstractGridDefinitionFactory;
use PrestaShop\PrestaShop\Core\Grid\Definition\Factory\DeleteActionTrait; use PrestaShop\PrestaShop\Core\Grid\Definition\Factory\DeleteActionTrait;
@@ -15,7 +16,9 @@ use PrestaShop\PrestaShop\Core\Grid\Filter\Filter;
use PrestaShop\PrestaShop\Core\Grid\Filter\FilterCollection; use PrestaShop\PrestaShop\Core\Grid\Filter\FilterCollection;
use PrestaShop\PrestaShop\Core\Hook\HookDispatcherInterface; use PrestaShop\PrestaShop\Core\Hook\HookDispatcherInterface;
use PrestaShopBundle\Form\Admin\Type\SearchAndResetType; use PrestaShopBundle\Form\Admin\Type\SearchAndResetType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\TextType; use Symfony\Component\Form\Extension\Core\Type\TextType;
use Websmid\CustomHtml\Config\Config;
final class CustomHtmlBlockGridDefinitionFactory extends AbstractGridDefinitionFactory final class CustomHtmlBlockGridDefinitionFactory extends AbstractGridDefinitionFactory
{ {
@@ -71,15 +74,29 @@ final class CustomHtmlBlockGridDefinitionFactory extends AbstractGridDefinitionF
->setName($this->trans('Group', [], 'Modules.Wscustomhtml.Admin')) ->setName($this->trans('Group', [], 'Modules.Wscustomhtml.Admin'))
->setOptions(['field' => 'group_title']) ->setOptions(['field' => 'group_title'])
) )
->add(
(new DataColumn('type'))
->setName($this->trans('Type', [], 'Modules.Wscustomhtml.Admin'))
->setOptions(['field' => 'type'])
)
->add( ->add(
(new DataColumn('active')) (new DataColumn('active'))
->setName($this->trans('Active', [], 'Admin.Global')) ->setName($this->trans('Active', [], 'Admin.Global'))
->setOptions(['field' => 'active']) ->setOptions(['field' => 'active'])
) )
->add( ->add(
(new DataColumn('ordering')) (new PositionColumn('position'))
->setName($this->trans('Position', [], 'Modules.Wscustomhtml.Admin')) ->setName($this->trans('Position', [], 'Modules.Wscustomhtml.Admin'))
->setOptions(['field' => 'ordering']) ->setOptions([
'id_field' => 'id_ws_customhtml',
'position_field' => 'ordering',
'update_method' => 'POST',
'update_route' => 'customhtmlblock_update_position',
'record_route_params' => [
'id_ws_customhtml_group' => 'idGroup',
],
'required_filter' => 'id_ws_customhtml_group',
])
) )
->add( ->add(
(new DataColumn('date_add')) (new DataColumn('date_add'))
@@ -120,6 +137,15 @@ final class CustomHtmlBlockGridDefinitionFactory extends AbstractGridDefinitionF
'attr' => ['placeholder' => $this->trans('Group', [], 'Modules.Wscustomhtml.Admin')], 'attr' => ['placeholder' => $this->trans('Group', [], 'Modules.Wscustomhtml.Admin')],
]) ])
) )
->add(
(new Filter('type', ChoiceType::class))
->setAssociatedColumn('type')
->setTypeOptions([
'required' => false,
'choices' => array_flip(Config::BLOCK_TYPES),
'placeholder' => $this->trans('All', [], 'Admin.Global'),
])
)
->add( ->add(
(new Filter('actions', SearchAndResetType::class)) (new Filter('actions', SearchAndResetType::class))
->setAssociatedColumn('actions') ->setAssociatedColumn('actions')
+1 -1
View File
@@ -14,7 +14,7 @@ final class CustomHtmlBlockFilters extends Filters
return [ return [
'limit' => 10, 'limit' => 10,
'offset' => 0, 'offset' => 0,
'orderBy' => 'wchb.id_ws_customhtml', 'orderBy' => 'position',
'sortOrder' => 'asc', 'sortOrder' => 'asc',
'filters' => [], 'filters' => [],
]; ];
+11 -1
View File
@@ -43,8 +43,10 @@ final class CustomHtmlBlockQueryBuilder extends AbstractDoctrineQueryBuilder
$qb->select( $qb->select(
'wchb.`id_ws_customhtml`', 'wchb.`id_ws_customhtml`',
'wchb.`id_ws_customhtml_group`',
'wchb.`title`', 'wchb.`title`',
'wchg.`title` AS group_title', 'wchg.`title` AS group_title',
'wchb.`type`',
'wchb.`active`', 'wchb.`active`',
'wchb.`ordering`', 'wchb.`ordering`',
'wchb.`date_add`' 'wchb.`date_add`'
@@ -52,8 +54,10 @@ final class CustomHtmlBlockQueryBuilder extends AbstractDoctrineQueryBuilder
$this->applyFilters($qb, $searchCriteria); $this->applyFilters($qb, $searchCriteria);
$orderBy = $searchCriteria->getOrderBy() === 'position' ? 'wchb.ordering' : $searchCriteria->getOrderBy();
$qb $qb
->orderBy($searchCriteria->getOrderBy(), $searchCriteria->getOrderWay()) ->orderBy($orderBy, $searchCriteria->getOrderWay())
->setFirstResult($searchCriteria->getOffset() ?? 0) ->setFirstResult($searchCriteria->getOffset() ?? 0)
->setMaxResults($searchCriteria->getLimit()); ->setMaxResults($searchCriteria->getLimit());
@@ -108,6 +112,12 @@ final class CustomHtmlBlockQueryBuilder extends AbstractDoctrineQueryBuilder
continue; continue;
} }
if ($filterName === 'type') {
$qb->andWhere('wchb.`type` = :type')
->setParameter('type', pSQL($filterValue));
continue;
}
$qb->andWhere('wchb.`' . $filterName . '` LIKE :' . $filterName) $qb->andWhere('wchb.`' . $filterName . '` LIKE :' . $filterName)
->setParameter($filterName, '%' . pSQL($filterValue) . '%'); ->setParameter($filterName, '%' . pSQL($filterValue) . '%');
} }
+9 -1
View File
@@ -16,9 +16,15 @@ class CustomHtmlBlock extends ObjectModel
/** @var string */ /** @var string */
public $title = ''; public $title = '';
/** @var string */
public $type = 'html';
/** @var string */ /** @var string */
public $content = ''; public $content = '';
/** @var string */
public $custom_class = '';
/** @var int */ /** @var int */
public $active = 1; public $active = 1;
@@ -40,7 +46,9 @@ class CustomHtmlBlock extends ObjectModel
'fields' => [ 'fields' => [
'id_ws_customhtml_group' => ['type' => self::TYPE_INT, 'validate' => 'isUnsignedId', 'required' => true], 'id_ws_customhtml_group' => ['type' => self::TYPE_INT, 'validate' => 'isUnsignedId', 'required' => true],
'title' => ['type' => self::TYPE_STRING, 'validate' => 'isGenericName', 'required' => true, 'size' => 256], 'title' => ['type' => self::TYPE_STRING, 'validate' => 'isGenericName', 'required' => true, 'size' => 256],
'type' => ['type' => self::TYPE_STRING, 'validate' => 'isGenericName', 'required' => true, 'size' => 32],
'content' => ['type' => self::TYPE_HTML, 'validate' => 'isCleanHtml'], 'content' => ['type' => self::TYPE_HTML, 'validate' => 'isCleanHtml'],
'custom_class' => ['type' => self::TYPE_STRING, 'validate' => 'isGenericName', 'required' => false, 'size' => 128],
'active' => ['type' => self::TYPE_BOOL, 'validate' => 'isBool'], 'active' => ['type' => self::TYPE_BOOL, 'validate' => 'isBool'],
'show_title' => ['type' => self::TYPE_BOOL, 'validate' => 'isBool'], 'show_title' => ['type' => self::TYPE_BOOL, 'validate' => 'isBool'],
'ordering' => ['type' => self::TYPE_INT, 'validate' => 'isInt'], 'ordering' => ['type' => self::TYPE_INT, 'validate' => 'isInt'],
@@ -58,7 +66,7 @@ class CustomHtmlBlock extends ObjectModel
} }
$result = Db::getInstance()->executeS( $result = Db::getInstance()->executeS(
'SELECT `id_ws_customhtml`, `id_ws_customhtml_group`, `title`, `content`, `active`, `show_title`, `ordering` 'SELECT `id_ws_customhtml`, `id_ws_customhtml_group`, `title`, `type`, `content`, `custom_class`, `active`, `show_title`, `ordering`
FROM `' . _DB_PREFIX_ . 'ws_customhtml` FROM `' . _DB_PREFIX_ . 'ws_customhtml`
WHERE `id_ws_customhtml_group` IN (' . implode(',', $groupIds) . ') WHERE `id_ws_customhtml_group` IN (' . implode(',', $groupIds) . ')
AND `active` = 1 AND `active` = 1
+60
View File
@@ -0,0 +1,60 @@
<?php
namespace Websmid\CustomHtml\Model;
use ObjectModel;
use Db;
class CustomHtmlFaq extends ObjectModel
{
/** @var int */
public $id;
/** @var int */
public $id_ws_customhtml;
/** @var string */
public $question = '';
/** @var string */
public $answer = '';
/** @var int */
public $ordering = 0;
/** @var string */
public $date_add;
/** @var string */
public $date_upd;
public static $definition = [
'table' => 'ws_customhtml_faq',
'primary' => 'id_ws_customhtml_faq',
'fields' => [
'id_ws_customhtml' => ['type' => self::TYPE_INT, 'validate' => 'isUnsignedId', 'required' => true],
'question' => ['type' => self::TYPE_STRING, 'validate' => 'isGenericName', 'required' => true, 'size' => 512],
'answer' => ['type' => self::TYPE_HTML, 'validate' => 'isCleanHtml'],
'ordering' => ['type' => self::TYPE_INT, 'validate' => 'isInt'],
'date_add' => ['type' => self::TYPE_DATE, 'validate' => 'isDate'],
'date_upd' => ['type' => self::TYPE_DATE, 'validate' => 'isDate'],
],
];
public static function getByBlockId(int $idBlock): array
{
$result = Db::getInstance()->executeS(
'SELECT `id_ws_customhtml_faq`, `question`, `answer`, `ordering`
FROM `' . _DB_PREFIX_ . 'ws_customhtml_faq`
WHERE `id_ws_customhtml` = ' . (int) $idBlock . '
ORDER BY `ordering` ASC'
);
return $result ?: [];
}
public static function deleteByBlockId(int $idBlock): void
{
Db::getInstance()->delete('ws_customhtml_faq', '`id_ws_customhtml` = ' . (int) $idBlock);
}
}
+15
View File
@@ -3,6 +3,8 @@
namespace Websmid\CustomHtml\Module; namespace Websmid\CustomHtml\Module;
use Websmid\CustomHtml\Config\Config; use Websmid\CustomHtml\Config\Config;
use PrestaShop\PrestaShop\Core\Grid\Data\GridData;
use PrestaShop\PrestaShop\Core\Grid\Record\RecordCollection;
use Tools; use Tools;
trait AdminHooks trait AdminHooks
@@ -27,4 +29,17 @@ trait AdminHooks
$this->context->controller->addCSS($this->getPathUri() . 'views/css/admin/grid.css'); $this->context->controller->addCSS($this->getPathUri() . 'views/css/admin/grid.css');
} }
public function hookActionCustomhtmlblockGridDataModifier(array $params): void
{
$gridData = $params['data'];
$records = array_map(static function (array $row) {
$row['type'] = Config::BLOCK_TYPES[$row['type']] ?? $row['type'];
return $row;
}, $gridData->getRecords()->all());
$params['data'] = new GridData(new RecordCollection($records), $gridData->getRecordsTotal(), $gridData->getQuery());
}
} }
+23
View File
@@ -3,11 +3,20 @@
namespace Websmid\CustomHtml\Module; namespace Websmid\CustomHtml\Module;
use Websmid\CustomHtml\Model\CustomHtmlBlock; use Websmid\CustomHtml\Model\CustomHtmlBlock;
use Websmid\CustomHtml\Model\CustomHtmlFaq;
use Websmid\CustomHtml\Model\CustomHtmlGroup; use Websmid\CustomHtml\Model\CustomHtmlGroup;
use Context; use Context;
trait DisplayHooks trait DisplayHooks
{ {
public function hookActionFrontControllerSetMedia(): void
{
$this->context->controller->registerStylesheet(
'ws_customhtml_front',
'/modules/' . $this->name . '/views/css/front/customhtml.css'
);
}
public function renderWidget($hookName = null, array $configuration = []) public function renderWidget($hookName = null, array $configuration = [])
{ {
if (empty($hookName)) { if (empty($hookName)) {
@@ -54,6 +63,7 @@ trait DisplayHooks
} }
$blocksByGroup = CustomHtmlBlock::getActiveByGroupIds(array_column($groups, 'id_ws_customhtml_group')); $blocksByGroup = CustomHtmlBlock::getActiveByGroupIds(array_column($groups, 'id_ws_customhtml_group'));
$blocksByGroup = $this->attachFaqsToBlocks($blocksByGroup);
$groups = array_map(static function (array $group) use ($blocksByGroup) { $groups = array_map(static function (array $group) use ($blocksByGroup) {
$group['blocks'] = $blocksByGroup[(int) $group['id_ws_customhtml_group']] ?? []; $group['blocks'] = $blocksByGroup[(int) $group['id_ws_customhtml_group']] ?? [];
@@ -65,4 +75,17 @@ trait DisplayHooks
return !empty($group['blocks']); return !empty($group['blocks']);
})); }));
} }
private function attachFaqsToBlocks(array $blocksByGroup): array
{
return array_map(static function (array $blocks) {
return array_map(static function (array $block) {
if ($block['type'] === 'faq') {
$block['faqs'] = CustomHtmlFaq::getByBlockId((int) $block['id_ws_customhtml']);
}
return $block;
}, $blocks);
}, $blocksByGroup);
}
} }
+45
View File
@@ -3,7 +3,9 @@
namespace Websmid\CustomHtml\Module; namespace Websmid\CustomHtml\Module;
use Websmid\CustomHtml\Config\Config; use Websmid\CustomHtml\Config\Config;
use Context;
use Db; use Db;
use Hook;
use Language; use Language;
use Tab; use Tab;
@@ -31,6 +33,11 @@ trait Install
return false; return false;
} }
if (!$this->registerGroupHooks()) {
error_log('[ws_customhtml] registerGroupHooks() failed');
return false;
}
return true; return true;
} }
@@ -55,7 +62,9 @@ trait Install
`id_ws_customhtml` INT(11) NOT NULL AUTO_INCREMENT PRIMARY KEY, `id_ws_customhtml` INT(11) NOT NULL AUTO_INCREMENT PRIMARY KEY,
`id_ws_customhtml_group` INT(11) NOT NULL, `id_ws_customhtml_group` INT(11) NOT NULL,
`title` VARCHAR(256) NOT NULL DEFAULT \'\', `title` VARCHAR(256) NOT NULL DEFAULT \'\',
`type` VARCHAR(32) NOT NULL DEFAULT \'html\',
`content` LONGTEXT, `content` LONGTEXT,
`custom_class` VARCHAR(128) NOT NULL DEFAULT \'\',
`active` TINYINT(1) NOT NULL DEFAULT 1, `active` TINYINT(1) NOT NULL DEFAULT 1,
`show_title` TINYINT(1) NOT NULL DEFAULT 0, `show_title` TINYINT(1) NOT NULL DEFAULT 0,
`ordering` INT(11) NOT NULL DEFAULT 0, `ordering` INT(11) NOT NULL DEFAULT 0,
@@ -65,6 +74,19 @@ trait Install
); );
'; ';
$sqlToExec[] = '
CREATE TABLE IF NOT EXISTS ' . _DB_PREFIX_ . 'ws_customhtml_faq (
`id_ws_customhtml_faq` INT(11) NOT NULL AUTO_INCREMENT PRIMARY KEY,
`id_ws_customhtml` INT(11) NOT NULL,
`question` VARCHAR(512) NOT NULL DEFAULT \'\',
`answer` TEXT,
`ordering` INT(11) NOT NULL DEFAULT 0,
`date_add` DATETIME NOT NULL,
`date_upd` DATETIME NOT NULL,
KEY `id_ws_customhtml` (`id_ws_customhtml`)
);
';
foreach ($sqlToExec as $sqlBlock) { foreach ($sqlToExec as $sqlBlock) {
Db::getInstance()->execute(trim($sqlBlock)); Db::getInstance()->execute(trim($sqlBlock));
} }
@@ -83,6 +105,29 @@ trait Install
return true; return true;
} }
private function registerGroupHooks(): bool
{
$hookNames = Db::getInstance()->executeS(
'SELECT DISTINCT `hook_name` FROM `' . _DB_PREFIX_ . 'ws_customhtml_group`'
);
$idShop = (int) Context::getContext()->shop->id;
foreach ($hookNames as $row) {
$hookName = $row['hook_name'];
if (Hook::isModuleRegisteredOnHook($this, $hookName, $idShop)) {
continue;
}
if (!$this->registerHook($hookName)) {
return false;
}
}
return true;
}
private function registerTabs(array $moduleTabs): bool private function registerTabs(array $moduleTabs): bool
{ {
$languages = Language::getLanguages(false); $languages = Language::getLanguages(false);
+38
View File
@@ -0,0 +1,38 @@
<?php
if (!defined('_PS_VERSION_')) {
exit;
}
function upgrade_module_1_2_0($module)
{
$columns = Db::getInstance()->executeS(
'SHOW COLUMNS FROM `' . _DB_PREFIX_ . 'ws_customhtml` LIKE \'type\''
);
if (empty($columns)) {
if (!Db::getInstance()->execute(
'ALTER TABLE `' . _DB_PREFIX_ . 'ws_customhtml`
ADD COLUMN `type` VARCHAR(32) NOT NULL DEFAULT \'html\' AFTER `title`'
)) {
return false;
}
}
if (!Db::getInstance()->execute(
'CREATE TABLE IF NOT EXISTS `' . _DB_PREFIX_ . 'ws_customhtml_faq` (
`id_ws_customhtml_faq` INT(11) NOT NULL AUTO_INCREMENT PRIMARY KEY,
`id_ws_customhtml` INT(11) NOT NULL,
`question` VARCHAR(512) NOT NULL DEFAULT \'\',
`answer` TEXT,
`ordering` INT(11) NOT NULL DEFAULT 0,
`date_add` DATETIME NOT NULL,
`date_upd` DATETIME NOT NULL,
KEY `id_ws_customhtml` (`id_ws_customhtml`)
)'
)) {
return false;
}
return $module->registerHook('actionCustomhtmlblockGridDataModifier');
}
+23
View File
@@ -0,0 +1,23 @@
<?php
if (!defined('_PS_VERSION_')) {
exit;
}
function upgrade_module_1_3_0($module)
{
$columns = Db::getInstance()->executeS(
'SHOW COLUMNS FROM `' . _DB_PREFIX_ . 'ws_customhtml` LIKE \'column_class\''
);
if (empty($columns)) {
if (!Db::getInstance()->execute(
'ALTER TABLE `' . _DB_PREFIX_ . 'ws_customhtml`
ADD COLUMN `column_class` VARCHAR(128) NOT NULL DEFAULT \'col-12\' AFTER `content`'
)) {
return false;
}
}
return true;
}
+14
View File
@@ -0,0 +1,14 @@
<?php
if (!defined('_PS_VERSION_')) {
exit;
}
function upgrade_module_1_4_0($module)
{
if (!Hook::isModuleRegisteredOnHook($module, 'actionFrontControllerSetMedia', (int) Context::getContext()->shop->id)) {
return $module->registerHook('actionFrontControllerSetMedia');
}
return true;
}
+32
View File
@@ -0,0 +1,32 @@
<?php
if (!defined('_PS_VERSION_')) {
exit;
}
function upgrade_module_1_5_0($module)
{
$newColumn = Db::getInstance()->executeS(
'SHOW COLUMNS FROM `' . _DB_PREFIX_ . 'ws_customhtml` LIKE \'custom_class\''
);
if (!empty($newColumn)) {
return true;
}
$oldColumn = Db::getInstance()->executeS(
'SHOW COLUMNS FROM `' . _DB_PREFIX_ . 'ws_customhtml` LIKE \'column_class\''
);
if (!empty($oldColumn)) {
return Db::getInstance()->execute(
'ALTER TABLE `' . _DB_PREFIX_ . 'ws_customhtml`
CHANGE `column_class` `custom_class` VARCHAR(128) NOT NULL DEFAULT \'\''
);
}
return Db::getInstance()->execute(
'ALTER TABLE `' . _DB_PREFIX_ . 'ws_customhtml`
ADD COLUMN `custom_class` VARCHAR(128) NOT NULL DEFAULT \'\' AFTER `content`'
);
}
+4
View File
@@ -0,0 +1,4 @@
.customhtml-block img {
max-width: 100%;
height: auto;
}
@@ -1,6 +1,12 @@
{% import '@PrestaShop/Admin/macros.html.twig' as ps %} {% import '@PrestaShop/Admin/macros.html.twig' as ps %}
{% form_theme customHtmlBlockForm '@PrestaShop/Admin/TwigTemplateForm/prestashop_ui_kit.html.twig' %} {% form_theme customHtmlBlockForm '@PrestaShop/Admin/TwigTemplateForm/prestashop_ui_kit.html.twig' %}
{% macro faqFields(faqForm) %}
{{ form_row(faqForm.question) }}
{{ form_row(faqForm.answer) }}
{% endmacro %}
{% import _self as faqMacros %}
{% block customhtmlblock_form_block %} {% block customhtmlblock_form_block %}
<div class="card"> <div class="card">
{{ form_start(customHtmlBlockForm) }} {{ form_start(customHtmlBlockForm) }}
@@ -10,6 +16,39 @@
</h3> </h3>
</div> </div>
<div class="card-body"> <div class="card-body">
{{ form_row(customHtmlBlockForm.id_ws_customhtml_group) }}
{{ form_row(customHtmlBlockForm.title) }}
{{ form_row(customHtmlBlockForm.type) }}
{{ form_row(customHtmlBlockForm.show_title) }}
{{ form_row(customHtmlBlockForm.custom_class) }}
{{ form_row(customHtmlBlockForm.content) }}
<div class="customhtmlblock-type-field customhtmlblock-type-faq">
<label class="form-control-label">
{{ 'FAQ items'|trans({}, 'Modules.Wscustomhtml.Admin') }}
</label>
<div
id="customhtmlblock-faq-items"
data-prototype="{{ faqMacros.faqFields(customHtmlBlockForm.faqs.vars.prototype)|e('html_attr') }}"
data-index="{{ customHtmlBlockForm.faqs|length }}"
>
{% for faqRow in customHtmlBlockForm.faqs %}
<div class="customhtmlblock-faq-item card mb-2">
<div class="card-body">
{{ faqMacros.faqFields(faqRow) }}
<button type="button" class="btn btn-outline-danger btn-sm customhtmlblock-remove-faq">
<i class="material-icons">delete</i> {{ 'Remove'|trans({}, 'Admin.Actions') }}
</button>
</div>
</div>
{% endfor %}
</div>
<button type="button" id="customhtmlblock-add-faq" class="btn btn-outline-primary btn-sm">
<i class="material-icons">add</i> {{ 'Add FAQ item'|trans({}, 'Modules.Wscustomhtml.Admin') }}
</button>
</div>
{{ form_row(customHtmlBlockForm.active) }}
{{ form_rest(customHtmlBlockForm) }} {{ form_rest(customHtmlBlockForm) }}
</div> </div>
<div class="card-footer"> <div class="card-footer">
@@ -13,6 +13,44 @@
window.prestashop.component.initComponents([ window.prestashop.component.initComponents([
'TinyMCEEditor', 'TinyMCEEditor',
]); ]);
function toggleBlockTypeFields() {
var type = $('#custom_html_block_type').val();
if (type === 'html') {
$('.customhtmlblock-type-html').show();
$('.customhtmlblock-type-faq').hide();
} else {
$('.customhtmlblock-type-html').hide();
$('.customhtmlblock-type-faq').show();
}
}
$('#custom_html_block_type').on('change', toggleBlockTypeFields);
toggleBlockTypeFields();
var removeFaqLabel = '{{ 'Remove'|trans({}, 'Admin.Actions')|e('js') }}';
var faqContainer = $('#customhtmlblock-faq-items');
$('#customhtmlblock-add-faq').on('click', function () {
var index = faqContainer.data('index');
var prototype = faqContainer.data('prototype').replace(/__name__/g, index);
var item = $('<div class="customhtmlblock-faq-item card mb-2"><div class="card-body"></div></div>');
item.find('.card-body')
.append(prototype)
.append(
'<button type="button" class="btn btn-outline-danger btn-sm customhtmlblock-remove-faq">' +
'<i class="material-icons">delete</i> ' + removeFaqLabel + '</button>'
);
faqContainer.append(item);
faqContainer.data('index', index + 1);
});
faqContainer.on('click', '.customhtmlblock-remove-faq', function () {
$(this).closest('.customhtmlblock-faq-item').remove();
});
}); });
</script> </script>
{% endblock %} {% endblock %}
@@ -13,6 +13,44 @@
window.prestashop.component.initComponents([ window.prestashop.component.initComponents([
'TinyMCEEditor', 'TinyMCEEditor',
]); ]);
function toggleBlockTypeFields() {
var type = $('#custom_html_block_type').val();
if (type === 'html') {
$('.customhtmlblock-type-html').show();
$('.customhtmlblock-type-faq').hide();
} else {
$('.customhtmlblock-type-html').hide();
$('.customhtmlblock-type-faq').show();
}
}
$('#custom_html_block_type').on('change', toggleBlockTypeFields);
toggleBlockTypeFields();
var removeFaqLabel = '{{ 'Remove'|trans({}, 'Admin.Actions')|e('js') }}';
var faqContainer = $('#customhtmlblock-faq-items');
$('#customhtmlblock-add-faq').on('click', function () {
var index = faqContainer.data('index');
var prototype = faqContainer.data('prototype').replace(/__name__/g, index);
var item = $('<div class="customhtmlblock-faq-item card mb-2"><div class="card-body"></div></div>');
item.find('.card-body')
.append(prototype)
.append(
'<button type="button" class="btn btn-outline-danger btn-sm customhtmlblock-remove-faq">' +
'<i class="material-icons">delete</i> ' + removeFaqLabel + '</button>'
);
faqContainer.append(item);
faqContainer.data('index', index + 1);
});
faqContainer.on('click', '.customhtmlblock-remove-faq', function () {
$(this).closest('.customhtmlblock-faq-item').remove();
});
}); });
</script> </script>
{% endblock %} {% endblock %}
@@ -20,6 +20,7 @@
customhtmlblockGrid.addExtension(new window.prestashop.component.GridExtensions.FiltersResetExtension()); customhtmlblockGrid.addExtension(new window.prestashop.component.GridExtensions.FiltersResetExtension());
customhtmlblockGrid.addExtension(new window.prestashop.component.GridExtensions.ColumnTogglingExtension()); customhtmlblockGrid.addExtension(new window.prestashop.component.GridExtensions.ColumnTogglingExtension());
customhtmlblockGrid.addExtension(new window.prestashop.component.GridExtensions.SubmitRowActionExtension()); customhtmlblockGrid.addExtension(new window.prestashop.component.GridExtensions.SubmitRowActionExtension());
customhtmlblockGrid.addExtension(new window.prestashop.component.GridExtensions.PositionExtension());
}); });
</script> </script>
{% endblock %} {% endblock %}
+30
View File
@@ -0,0 +1,30 @@
{if !empty($block.faqs)}
<div class="accordion customhtml-faq-accordion" id="customhtml-faq-{$block.id_ws_customhtml|intval}">
{foreach from=$block.faqs item="faq" name="customhtmlFaqLoop"}
<div class="card">
<div class="card-header" id="customhtml-faq-heading-{$block.id_ws_customhtml|intval}-{$faq.id_ws_customhtml_faq|intval}">
<button
class="btn btn-link{if !$smarty.foreach.customhtmlFaqLoop.first} collapsed{/if}"
type="button"
data-toggle="collapse"
data-target="#customhtml-faq-collapse-{$block.id_ws_customhtml|intval}-{$faq.id_ws_customhtml_faq|intval}"
aria-expanded="{if $smarty.foreach.customhtmlFaqLoop.first}true{else}false{/if}"
aria-controls="customhtml-faq-collapse-{$block.id_ws_customhtml|intval}-{$faq.id_ws_customhtml_faq|intval}"
>
{$faq.question|escape:'html':'UTF-8'}
</button>
</div>
<div
id="customhtml-faq-collapse-{$block.id_ws_customhtml|intval}-{$faq.id_ws_customhtml_faq|intval}"
class="collapse{if $smarty.foreach.customhtmlFaqLoop.first} show{/if}"
aria-labelledby="customhtml-faq-heading-{$block.id_ws_customhtml|intval}-{$faq.id_ws_customhtml_faq|intval}"
data-parent="#customhtml-faq-{$block.id_ws_customhtml|intval}"
>
<div class="card-body">
{$faq.answer nofilter}
</div>
</div>
</div>
{/foreach}
</div>
{/if}
+6 -2
View File
@@ -1,13 +1,17 @@
{if !empty($groupList)} {if !empty($groupList)}
<div class="customhtml-container {$hookName|escape:'html':'UTF-8'}--container"> <div class="customhtml-container {$hookName|escape:'html':'UTF-8'}--container">
{foreach from=$groupList item="group"} {foreach from=$groupList item="group"}
<div class="customhtml-group"> <div class="customhtml-group customhtml-group-{$group.id_ws_customhtml_group|intval} row">
{foreach from=$group.blocks item="block"} {foreach from=$group.blocks item="block"}
<div class="customhtml-block"> <div class="customhtml-block col{if !empty($block.custom_class)} {$block.custom_class|escape:'html':'UTF-8'}{/if}">
{if $block.show_title && !empty($block.title)} {if $block.show_title && !empty($block.title)}
<div class="customhtml-title">{$block.title|escape:'html':'UTF-8'}</div> <div class="customhtml-title">{$block.title|escape:'html':'UTF-8'}</div>
{/if} {/if}
{if $block.type == 'faq'}
{include file="module:ws_customhtml/views/templates/hook/_faq_accordion.tpl" block=$block}
{else}
<div class="customhtml-content">{$block.content nofilter}</div> <div class="customhtml-content">{$block.content nofilter}</div>
{/if}
</div> </div>
{/foreach} {/foreach}
</div> </div>
+1 -1
View File
@@ -29,7 +29,7 @@ class Ws_CustomHtml extends Module implements WidgetInterface
public function __construct() public function __construct()
{ {
$this->name = 'ws_customhtml'; $this->name = 'ws_customhtml';
$this->version = '1.1.0'; $this->version = '1.5.0';
$this->author = 'Isabelle Oving-Anno | De Websmid BV'; $this->author = 'Isabelle Oving-Anno | De Websmid BV';
$this->need_instance = 0; $this->need_instance = 0;
$this->bootstrap = true; $this->bootstrap = true;