Jak zaregistrovat Nette komponentu jako posluchače událostí

This post is Tested

9 min | by Tomáš Pilař


Dnes si povíme o tom, jak přimět Nette komponenty poslouchat na události, které nám vyvolává aplikace a umožnit jim se podle toho zachovat.

Úvod do problému

U složitějších aplikací může dojit k tomu, že v jednom presenteru máte vloženo více komponent, které mají mezi sebou pomyslnou vazbu. Pokud se stane něco v jedné komponentě, tak je potřeba překreslit (ajaxově snippetem) komponentu druhou apod. Typicky u eshopů se jedná o ten use-case, že při přidání položky do košíku potřebuji překreslit ten malý košík s cenou umístěný většinou v pravo nahoře a má mi vyskočit nějaký popup s podobnými produkty.

Jak elegantně překreslovat komponenty v závislosti na ajaxových požadavcích? Řešením je použití událostí a jejich posluchačů.

Náš CategoryPresenter

Mějmě například takovýto presenter:

// Presenter/CategoryPresenter.php

use Nette\Application\UI\Multiplier;
use Nette\Application\UI\Presenter;
use Component\AddToBasketControl\AddToBasketControl;
use Component\AddToBasketControl\AddToBasketControlFactoryInterface;
use Component\BasketContentControl\BasketContentControl;
use Component\BasketContentControl\BasketContentControlFactoryInterface;

final class CategoryPresenter extends Presenter
{

    const PRODUCTS = [
        [
            'id' => 1,
            'name' => 'T-Shirt',
            'price' => 100
        ],
        [
            'id' => 2,
            'name' => 'Red socks',
            'price' => 29
        ],
        [
            'id' => 3,
            'name' => 'Green hat',
            'price' => 99
        ]
    ];

    /**
     * @var AddToBasketControlFactoryInterface
     */
    private $addToBasketControlFactory;

    /**
     * @var BasketContentControlFactoryInterface
     */
    private $basketContentControlFactory;

    public function __construct(
        AddToBasketControlFactoryInterface $addToBasketControlFactory,
        BasketContentControlFactoryInterface $basketContentControlFactory
    ) {
        $this->addToBasketControlFactory = $addToBasketControlFactory;
        $this->basketContentControlFactory = $basketContentControlFactory;
    }

    public function renderDefault()
    {
        $this->template->setParameters([
            'products' => self::PRODUCTS
        ]);
    }

    protected function createComponentAddToBasket(): Multiplier
    {
        // Musíme použít Multiplier, protože potřebujeme samostatnou instanci pro každý produkt.
        // Co je to Multiplier? Více informací najdeš ve článku https://pla.nette.org/cs/multiplier.

        return new Multiplier(function($productId) {
            $product = [];
            foreach (self::PRODUCTS as $productData) {
                if ($productData['id'] === (int) $productId) {
                    $product = $productData;
                    break;
                }
            }

            return $this->addToBasketControlFactory->create($product);
        });
    }

    protected function createComponentBasketContent(): BasketContentControl
    {
        return $this->basketContentControlFactory->create();
    }

}

V presenteru CategoryPresenter máme zaregistrované komponenty AddToBasketControl a BasketContentControl. Komponenta AddToBasketControl bude sloužit pro přidání produktu do košíku a komponenta BasketContentControl nám bude vypisovat produkty v košíku. Naším cílem bude po přidání produktu do košíku v komponentě AddToBasketControl překreslit komponentu BasketContentControl pomocí událostí.

Nástroje

Budeme potřebovat Symfony\EventDispatcher (základní infromace o něm můžete načerpat z článku Event Dispatcher from the Scratch). To je vše! :)

Napojíme Symfony\EventDispatcher do Nette DI

Vždy jsem si myslel, že propojit Symfony a Nette nejde nebo je to velmi složité. No - složité to není, takže s chutí do toho!

Spustíme příkaz composer require symfony/event-dispatcher a následně zaregistrujeme EventDispatcher do Nette/DI.

// config.neon

services:
    - Symfony\Component\EventDispatcher\EventDispatcher

Propojení Nette a Symfony máme hotové. Tak co jsem říkal, je to složité? :)

V tuto chvíli máme vše co potřebujeme. Máme myšlenku toho co chceme udělat a všechny potřebné nástroje, takže jdeme na to!

Registrace komponenty jako posluchače do EventDispatcheru

Zde se nám hodí znát jaký má Presenter v Nette životní cyklus. Pro náš počin se výborně hodí metoda startup(). Při jejím volání je již presenter nakonfigurován a tak máme přístup ke komponentám. V metodě startup() tedy řekneme EventDispatcheru, které komponenty si má zaregistrovat jako posluchače.

Do našeho presenteru CategoryPresenter tedy přidáme metodu startup() a zároveň si přidáme závislost na EventDispatcher.

// Presenter/CategoryPresenter.php

...

/**
 * @var EventDispatcherInterface
 */
private $eventDispatcher;

public function __construct(
    AddToBasketControlFactoryInterface $addToBasketControlFactory,
    BasketContentControlFactoryInterface $basketContentControlFactory,
    EventDispatcherInterface $eventDispatcher
) {
    $this->addToBasketControlFactory = $addToBasketControlFactory;
    $this->basketContentControlFactory = $basketContentControlFactory;
    $this->eventDispatcher = $eventDispatcher;
}

public function startup()
{
    parent::startup();

    // Magic goes here!
}

...

EventDispatcher pro přidání nových posluchačů na události používá metodu addListener, která má dva parametry. První parametrem je název události, na kterou posluchač poslouchá a druhým parametrem je callback, který se zavolá při vyvolání události. Docela jednoduché API, že? :)

Přes nově nabyté znalosti tedy vytvoříme událost, kterou bude vyvolávat komponenta AddTobasketControl a zaregistrujeme komponentu BasketContentControl do EventDispatcheru jako posluchače této události.

// Event/ProductAddedToBasketEvent.php

use Symfony\Component\EventDispatcher\Event;

final class ProductAddedToBasketEvent extends Event
{

    /**
     * @var int
     */
    private $id;

    /**
     * @var string
     */
    private $name;

    /**
     * @var int
     */
    private $price;

    public function __construct(int $id, string $name, int $price)
    {
        $this->id = $id;
        $this->name = $name;
        $this->price = $price;
    }

    public function getId(): int
    {
        return $this->id;
    }

    public function getName(): string
    {
        return $this->name;
    }

    public function getPrice(): int
    {
        return $this->price;
    }

}
// Presenter/CategoryPresenter.php

use Event\ProductAddedToBasketEvent;

...

public function startup()
{
    parent::startup();

    $basketContentControl = $this->getComponent('basketContent');

    $this->eventDispatcher->addListener(
        ProductAddedToBasketEvent::class,
        [$basketContentControl, 'onProductAddedToBasketEvent']
    );
}
...

Vyvolání události

Tak už máme EventDispatcher napojený do Nette. Také máme komponentu BasketContentControl zaregistrovanou jako posluchače události ProductAddedToBasketEvent. Takže je na řadě samotné vyvolání události.

To se udělá opět velmi snadno - konkrétně přes metodu dispatch, která je nečekaně součástí EventDispatcheru. Metoda má opět dva parametry. První parametr je název události, která se bude vyvolávat (na tento název jsou zaregistrováni posluchači). Druhý parametr je samotná instance události, přes kterou můžete předávat data do posluchačů.

Dost teorie - chci vyvolat svoji událost!

// Control/AddToBasketControl/AddToBasketControl.php

use Nette\Application\UI\Control;
use Event\ProductAddedToBasketEvent;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;

final class AddToBasketControl extends Control
{

    /**
     * @var array
     */
    private $product;

    /**
     * @var EventDispatcherInterface
     */
    private $eventDispatcher;

    public function __construct(array $product, EventDispatcherInterface $eventDispatcher)
    {
        $this->product = $product;
        $this->eventDispatcher = $eventDispatcher;
    }

    public function handleAdd()
    {
        // Zde může být nějaká složitější logika
        // např.: $this->basketFacade->addProduct($this->product);

        // vytvoříme instanci události
        $productAddedToBasketEvent = new ProductAddedToBasketEvent(
            $this->product['id'],
            $this->product['name'],
            $this->product['price']
        );
        $this->eventDispatcher->dispatch(ProductAddedToBasketEvent::class, $productAddedToBasketEvent); // vyvoláme událost!
    }

    public function render()
    {
        $this->template->render(__DIR__ . '/templates/default.latte');
    }

}

Pokud někdo klikne na link vedoucí do handleAdd metody, tak bude vyvolána událost ProductAddedToBasketEvent, na kterou čeká a poslouchá naše druhá komponenta BasketContentControl. Komponenta BasketContentControl může vypadat následovně.

// Control/BasketContentControl/BasketContentControl.php

use Nette\Application\UI\Control;
use Event\ProductAddedToBasketEvent;

final class BasketContentControl extends Control
{

    /**
     * @var array
     */
    private $products = [];

    // Tuto metodu zavolá EventSubscriber, protože je nastavena jako listener callback v CategoryPresenter::startup()
    public function onProductAddedToBasketEvent(ProductAddedToBasketEvent $productAddedToBasketEvent)
    {
        $product = [
            'id' => $productAddedToBasketEvent->getId(),
            'name' => $productAddedToBasketEvent->getName(),
            'price' => $productAddedToBasketEvent->getPrice(),
        ];

        $this->products[] = $product;

        $this->redrawControl('content');
    }

    public function render()
    {
        $this->template->setParameters([
            'products' => $this->products
        ]);

        $this->template->render(__DIR__ . '/templates/default.latte');
    }

}

Oddechneme si u šablon

Tato sekce přímo nesouvisí s tím, jak registrovat komponentu jako posluchače, ale pro náš příklad je stejně tak důležitá jako kterákoliv předchozí sekce.

V šabloně presenteru si vykreslíme komponentu BasketContentControl a vypíšeme seznam produktů.

<!-- /templates/Category/default.latte -->

{control basketContent}
<table>
    <tr n:foreach="$products as $product">
        <td>{$product['id']}</td>
        <td>{$product['name']}</td>
        <td>{$product['price']}</td>
        <td>{control 'addToBasket-' . $product['id']}</td>
    </tr>
</table>

Následuje šablona pro vykreslení odkazu pro přidání produktu do košíku.

<!--Component/AddToBasketControl/templates/default.latte -->

<a n:href="add!" class="ajax">Přidat do košíku</a>
  • Pro ajaxovou funkčnost použijeme javascriptovou knihovnu nette.ajax.js.

A do třetice je tu šablona pro vykreslení obsahu košíku.

<!-- Component/BasketContentControl/templates/default.latte -->

{snippet content}
    <table>
        <tr n:foreach="$products as $product">
            <td>{$product['id']}</td>
            <td>{$product['name']}</td>
            <td>{$product['price']}</td>
        </tr>
    </table>
{/snippet}

Nyní máme vše hotové a můžeme spustit aplikaci!

Existuje i jiné řešení bez událostí?

Samozřejmě! Stačí upravit metodu handleAdd v AddToBasketControl například takto:

public function handleAdd()
{
    $this->presenter->getComponent('basketContent')->onProductAddedToBasket($this->product);
}

U tohoto řešení je problém v tom, že komponenta addToBasketControl zná implementaci presenteru, ve kterém je připojena a spoléhá na to, že je v něm zaregistrovaná componenta s názvem basketContent. Pokud bych tedy chtěl komponentu addToBasketControl použít v jiném presenteru, musel bych v něm zaregistrovat i komponentu BasketContentControl, což je nehezké provázání závislostí.

Co pak, když by bylo potřeba, aby na událost ProductAddedToBasket poslouchala i jiná komponenta? OK - upravíme metodu na:

public function handleAdd()
{
    $this->presenter->getComponent('basketContent')->onProductAddedToBasket($this->product);
    $this->presenter->getComponent('anotherComponent')->onProductAddedToBasket($this->product);
}

a už tu vzniká programming hell a programátorský dluh do budoucnosti.

Druhý mnohem složitější problém by nastal v momentě, kdy se událost nevyhazuje v komponentě, ale v nějaké službě. Typicky můžeme mít BasketFacade, která před přidáním produktu do košíku musí zvalidovat např. to, zda může být produkt přidán do košíku a pokud ano, tak produkt přidá a vyvolá událost. Pak nám nezbývá nic jiného než použití return pokud bychom chtěli událost přeci jen vyvolávat v komponentě. Problém může být, ale pokud BasketFacade deleguje požadavek na přidání produktu jiné službě apod. Pak musíme returnovat returny z celého řetezce volaných metod a to je pěkný oser. :)

Shrnutí

Ukázali jsme si jak jednoduše se dá propojit Symfony s Nette a jak přimět komponenty poslouchat na události. Zároveň jsme si osvěžili práci se ajaxem, snippety a vysvětlili jsme si, jak fungují události a posluchači.

Jaký to dobrý pocit z nově nabytých znalostí! :)

Chceš znát více?

Zde jsou linky pro zvídavé programátory/ky: