Administrace stránek - editace stránky

Minule jsme vytvořili stránku a zařadili ji do stromu stránek. Dnes se naučíme jak existující stránku zeditovat. Princip bude podobný jako u vytváření stránky: komponenta, úprava presenteru, šablona presenteru, registrace služeb a rout.

Tvorba komponenty

Tak, jak jsme ve složce Add vyráběli konmponentu pro přidání, tak ve složce Detail vyrobíme komponentu pro editaci. IPageDetailFactory.php:

<?php

namespace PageModule\AdminModule\Components\Page\Detail;

interface IPageDetailFactory
{

    /**
     * @param int $id
     * @return PageDetail
     */
    public function create($id);
}

Následuje třída PageDetail.php:

<?php

namespace PageModule\AdminModule\Components\Page\Detail;

use BaseModule\Components\FormFactory;
use Nette\Utils\Strings;
use PageModule\Model\Entity\Page;
use PageModule\Model\Facade\PageFacade;
use Nette\Application\UI\Control;
use Nette\Application\UI\Form;

class PageDetail extends Control
{

    /**
     * @var IPageDetailFactory @inject
     */
    public $detailFactory;

    /**
     * @var FormFactory
     */
    private $formFactory;

    /**
     * @var Page
     */
    private $page;

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

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

    /**
     * @var PageFacade
     */
    private $facade;

    /**
     * @var string
     */
    private $templateFile = __DIR__ . '/Page.detail.latte';

    /**
     * @param int           $id
     * @param PageFacade    $facade
     * @param FormFactory   $formFactory
     */
    public function __construct(
        $id,
        PageFacade $facade,
        FormFactory $formFactory
    ) {
        parent::__construct();
        $this->facade = $facade;
        $this->formFactory = $formFactory;
        $this->page = $this->facade->find($id);

        if ($this->page->getParent() === 0) {
            $this->posibleParentsTree = [0 => ''];
        } else {
            $this->posibleParentsTree = $this->facade->getPosibleParentsTree($this->page);
        }
    }

    public function render()
    {
        $this->template->setFile($this->templateFile);

        $form = $this['editForm'];
        if (!$form->isSubmitted()) {
            $pageArray = $this->page->extract();
            $pageArray['in_menu'] = json_decode($this->page->getInMenu(), true);

            $form->setDefaults($pageArray);
        }
        $this->template->render();
    }

    /**
     * @return \Nette\Application\UI\Form
     * @throws \Exception
     */
    public function createComponentEditForm()
    {
        $form = $this->formFactory->create();
        $form->addHidden('id', 'ID');
        $form->addSelect('parent', 'Rodič', $this->posibleParentsTree)
            ->setAttribute('class', 'form-control input-sm select2 col-sm-10 form-control-sm')
            ->setAttribute('placeholder', 'Rodič');
        $form->addText('name', 'Název')
            ->setAttribute('placeholder', 'Název stránky')
            ->setAttribute('class', 'form-control input-sm')
            ->setRequired('Zadejte prosím Název');
        $form->addText('url', 'URL')
            ->setAttribute('class', 'form-control input-sm')
            ->setAttribute('placeholder', 'URL stránky');
        $form->addCheckbox('status', 'Aktivní')
            ->setAttribute('class', 'bootstrap');
        $form->addCheckbox('on_homepage', 'Na úvodní stranu')
            ->setAttribute('class', 'bootstrap');
        $form->addCheckboxList(
            'in_menu',
            'Položka menu:',
            [
                'topMenu' => 'Horní menu',
                'footerMenu' => 'Menu v patičce',
                'sideMenu' => 'Postranní menu',
                'otherMenu' => 'Jiné menu',
            ]
        )
            ->setAttribute('class', 'bootstrap');
        $form->addText('menu_title', 'Název do menu')
            ->setAttribute('class', 'form-control input-sm')
            ->setAttribute('placeholder', 'Název v menu');
        $form->addText('title', 'Titulek')
            ->setAttribute('class', 'form-control input-sm')
            ->setAttribute('placeholder', 'Titulek v prohlížeči');
        $form->addText('description', 'Description')
            ->setAttribute('class', 'form-control input-sm')
            ->setAttribute('placeholder', 'Popisek');
        $form->addTextArea('perex', 'Perex')
            ->setAttribute('class', 'form-control input-sm w-100')
            ->setAttribute('placeholder', 'Perex');
        $form->addTextArea('text', 'Text')
            ->setAttribute('class', 'form-control input-sm tinymcetext')
            ->setAttribute('placeholder', 'Text stránky');
        $form->addSubmit('save', 'Uložit')
            ->setAttribute('class', 'btn btn-primary');
        $form->addSubmit('saveandstay', 'Uložit a zůstat')
            ->setAttribute('class', 'btn btn-default');

        $form->onSuccess[] = function (Form $form) {
            /** @var array $values */
            $values = $form->getValues();

            /** @var Page $page */
            $page = $this->facade->find($values->id);
            $parent = $this->facade->find($values->parent);
            if ($this->facade->countUrl($page->getUrl()) > 1) {
                $form->addError('Nepodařilo se uložit stránku. Toto URL se již používá');
            } else {
                if ($page->getParent() != $values->parent) {
                    $this->facade->changeParent($page, $parent);
                }
                $page->hydrate($values);
                $page->setUrl($this->setUrl($values));
                $page->setInMenu(json_encode($values->in_menu));
                $this->facade->save($page);
            }
        };

        return $form;
    }

    /**
     * @param array $values
     * @param int   $iterator
     *
     * @return string
     */
    private function setUrl($values, $iterator = 1)
    {
        if ($values->url === '/') {
            return $values->url;
        }

        if ($values->url === '') {
            $url = Strings::webalize($values->name) . ($iterator > 1 ? '-' . $iterator : '');
        } else {
            $url = $values->url . ($iterator > 1 ? '-' . $iterator : '');
        }

        return $url;
    }
}

Poslední člen trojice komponenty je její šablona. Soubor Page.detail.latte:

<div class="row" id="snippet--grid">
    <div class="col-sm-12" id="snippet-grid-grid">
        <div class="panel panel-default datagrid datagrid-grid">
            <div class="panel-body">
                <div class="box box-success">
                    <div class="box-header with-border">
                        <h3 class="box-title">Editace stránky</h3>
                    </div>
                    {control editForm}
                </div>
            </div>
        </div>
    </div>
</div>

Teď tedy zaregistrujeme novou službu do DI/services.neon. Stačí opět přidat řádek:

- PageModule\AdminModule\Components\Page\Detail\IPageDetailFactory

Další věcí bude úprava PagePresenteru. Injectneme službu IPageDetailFactory a doplníme metody, které ošetří danou akci a vyrobí komponentu:

    /** @var IPageDetailFactory @inject */
    public $pageDetailFactory;

    /**
     * @param int $id
     */
    public function actionDetail($id)
    {
        $this->id = $id;
    }

    /**
     * @return \PageModule\AdminModule\Components\Page\Detail\PageDetail
     */
    protected function createComponentPageDetail()
    {
        $control = $this->pageDetailFactory->create($this->id);
        $control['editForm']->onSuccess[] = function () use ($control) {
            $this->flashMessage('Změny byly uloženy', 'success');
            $data = $control['editForm']->getHttpData();
            if (isset($data['saveandstay'])) {
                $this->redirect('this');
            }
            $this->redirect(':Page:Admin:Page:');
        };

        return $control;
    }

No a když ještě dodáme šablonu default.latte, bude vše přichystáno k editaci stránky:

{block content}
    <section class="content-header">
        <h1>
            Stránky
            <small>Editace stránky</small>
        </h1>
        <ol class="breadcrumb">
            <li><a n:href="Page:"><i class="fa fa-address-card-o"></i> Stránky</a></li>
            <li class="active">Editace stránky</li>
        </ol>
    </section>
    {control pageDetail}
{/block}

{block title}Editace stránky |&nbsp;{/block}

Pokud jsem na nic nezapomněl, pak po zadání adresy http://rsrs.loc/admin/page/detail/3 uvidíš stránku, kterou můžeš editovat. Nicméně uděláme si to ještě pohodlnější a upravíme komponentu PageGrid pro výpis stránek tak, aby obsahovala ikonky pro editaci. A editací budeme rozumět i nastavování statusu, mazání a přesouvání.

Tak nejprve doplníme metodu, která ošetří změnu stavu z aktivního na neaktivní a naopak:

    /**
     * @param integer $id
     * @param integer $newStatus
     *
     * @throws \Nette\Application\AbortException
     * @throws \Kdyby\Doctrine\InvalidStateException
     * @throws \Exception
     */
    public function statusChange($id, $newStatus)
    {
        $item = $this->facade->find($id);
        $item->setStatus($newStatus);
        $this->facade->save($item);

        if (!$this->getPresenter()->isAjax()) {
            $this->redirect('this');
        }
        $this->redrawControl();
    }

Metodu chceme volat po změně statusu ve výpisu, takže upravíme prvek gridu, který zobrazuje status. Vlastně jen pdáme událost onChange:

        $grid->addColumnStatus('status', 'Status')
            ->addAttributes(['style' => 'width: 10%;'])
            ->addOption(1, 'Aktivní')
            ->setClass('btn-success ajax')
            ->endOption()
            ->addOption(0, 'Neaktivní')
            ->setClass('btn-danger ajax')
            ->endOption()
            ->onChange[] = [$this, 'statusChange'];

Dále přidáme novou akci, a to editaci stránky:

        $grid->addAction('Page:detail', '')
            ->setIcon('pencil')
            ->setTitle('Upravit')
            ->setClass('btn btn-xs btn-info');

Další 2 akce budou nabízet přesuny stránky nahoru nebo dolů. Přesuny budou možné jen v rámci svých "sourozenců", čili jen mezi stránkami, které mají stejného rodiče. Pokud je stránka v pořadí první, nebude možné ji posunout nahoru a naopak pokud bude poslední, nepůjde posunout dolů. Budeme potřebovat nějaký handler, který stránky přesune:

    private function move($id, $direction = 'up')
    {
        $item = $this->facade->find($id);
        $this->facade->move($item, $direction);

        if (!$this->getPresenter()->isAjax()) {
            $this->redirect('this');
        }
        $this->redrawControl();
    }

    public function handleMoveUp($id)
    {
        $this->move($id, 'up');
    }

    public function handleMoveDn($id)
    {
        $this->move($id, 'dn');
    }

Handlery jsou dva. Každý určený pro svůj směr přesunu. Volají metodu move(), která přijímá jako argument id přesouvané stránky a směr přesouvání. Pak jen volá metodu move() z PageFacade. Pokud je požadavek ajaxový, překreslí grid. Jinak přenačte aktuální stránku.

Obě akce tedy do gridu přidáme:

        $facade = $this->facade;
        $grid->addAction('moveup', '', 'moveup!')
            ->setIcon('arrow-up')
            ->setTitle('Přesunout výš')
            ->setClass(
                function (Page $item) use ($facade, $grid) {
                    $parentId = $item->getParent();
                    if ($parentId === 0) {
                        $grid->redrawControl();
                        return 'btn btn-xs btn-primary disabled';
                    }
                    $parent = $facade->find($parentId);
                    if ($parent === null) {
                        $parent[] = $item;
                    }

                    if (($item->getLft()-1) === $parent->getLft()) {
                        $grid->redrawControl();
                        return 'btn btn-xs btn-primary disabled';
                    }

                    return 'btn btn-xs btn-primary';
                }
            );

        $grid->addAction('movedn', '', 'movedn!')
            ->setTitle('Přesunout níž')
            ->setIcon('arrow-down')
            ->setClass(
                function (Page $item) use ($facade, $grid) {
                    $parentId = $item->getParent();
                    if ($parentId === 0) {
                        $grid->redrawControl();
                        return 'btn btn-xs btn-primary disabled';
                    }
                    $parent = $facade->find($parentId);
                    if ($parent === null) {
                        $parent[] = $item;
                    }
                    if (($item->getRgt() + 1) === $parent->getRgt()) {
                        $grid->redrawControl();
                        return 'btn btn-xs btn-primary disabled';
                    }

                    $grid->redrawControl();
                    return 'btn btn-xs btn-primary';
                }
            );

Další akce zajistí přidání stránky. Tady pro ilustraci využijeme 2 způsoby - první tlačítko bude vytvářet stránky na hlavní úrovni. Bude v záhlaví celého gridu. Potom také ke každé stránce, ať je na jakékoli úrovni vnoření, dodáme další tlačítko. To bude mít za úkol vyrobit podstránku, čili novou stránku, která bude potomkem té stránky, u které se kliklo na to přidávací tlačítko. Možná složitá věta, ale jak si to napíšeme a vyzkoušíš to, bude to jasné.

Takže grid doplníme o další příkaz, který vyrobí to tlačítko pro přidání stránky na hlavní úrovni:

        $grid->addToolbarButton('Page:add', 'Přidat stránku')->setIcon('plus')->setClass('btn btn-success btn-sm');

Pak doplníme akci pro přidávací tlačítko ke každé stránce:

        $grid->addAction('Page:add', '')
            ->setTitle('Přidat podstránku')
            ->setIcon('plus')
            ->setClass('btn btn-xs btn-success');

Poslední akcí bude smazání stránky. Opět potřebujeme handler, který zavoláme z akce gridu:

    public function handleDelete($id)
    {
        $item = $this->facade->find($id);
        $this->facade->delete($item);

        if (!$this->getPresenter()->isAjax()) {
            $this->redirect('this');
        }
        $this->redrawControl();
    }

No a do gridu přidáme tu akci:

        $grid->addAction('default', '')
            ->setClass('btn-xs disabled');

        $grid->addAction('delete', '', 'delete!')
            ->setConfirm('Opravdu chcete smazat stránku %s?', 'name')
            ->setIcon('trash')
            ->setTitle('Smazat')
            ->setClass(
                function (Page $item) {
                    if ($item->getParent() === 0 || $item->getRgt() - $item->getLft() > 1) {
                        return 'btn btn-xs btn-danger disabled';
                    }

                    return 'btn btn-xs btn-danger ajax';
                }
            );

Když si všimneš, jsou to vlastně 2 akce. Ale ta 'default' slouží jen pro optické uspořádání - odsune tlačítko pro smazání trochu doprava. Mazání je kritická záležitost, takže po stisknutí musí uživatel svůj úmysl ještě potvrdit. Smazat navíc nelze hlavní stránku, ani stránky, které mají nějaké podstránky. Šlo by vymyslet metodu pro smazání celého stromu, ale kvůli bezpečnosti to nenabízím.

Na závěr ještě jednu dekorativní věc. Stránky by se měly vypisovat jako strom, čili ty, co jsou podstránkami, bychom měli odsadit zleva. Takže doplníme každému řádku třídu podle vlastnosti "level":

        $grid->setRowCallback(
            function (Page $item, $tr) {
                $tr->addClass('level_' . $item->getLevel());
            }
        );

No a do nějakého css, které si načítáme, doplníme pár pravidel:

tr.level_2 .col-name {
    padding: 0 0 0 20px;
}
tr.level_3 .col-name {
    padding: 0 0 0 40px;
}
tr.level_4 .col-name {
    padding: 0 0 0 60px;
}

Doplň si libovolný počet levelů, podle toho, kolik úrovní budeš potřebovat. Nezapomeň si CSS nalinkovat do @layout.latte.

A jako vždy, můžeš si stáhnout dnešní změny. Příště si trochu uklidíme a zaměříme se na optimalizaci rychlosti webu.

Celý seriál o vývoji redakčního systému

O mně

Jmenuji se Rudolf Svátek - lektor výpočetní techniky, trochu PHP programátor a SEO konzultant na volné noze.

Adresa

Příčná 326/3
736 01 Havířov

Kontakty

Email: office@rudolfsvatek.cz
Telefon: +420 777 828 353
Skype: svatekr