Уверен, что у многих разработчиков, предпочитающих фреймворки готовым CMS, есть в запасе решение на Bootstrap или его аналогах, которое используется в для создания интерфейсов админок и прочих бэк-офисов. И у меня такое есть. Успешно работает много лет, но безнадежно устарело. Пришло время переписывать.


Работая над новой версией, я постарался обобщить весь свой опыт по этой теме, и в результате получился YIMP — велосипед, которым не стыдно поделиться: GitHub, LiveDemo, API Documentation.


YIMP очень прост. Но за этой простотой стоят долгие размышления, которыми я тоже хочу поделиться. Так что эта статья — не инструкция. Здесь мы поговорим об архитектуре, управлении зависимостями, парадигме MVC, ну и о пользовательском интерфейсе, конечно.


Итак, YIMP — это панель управления (dashboard). Это не готовая админка, не CMS и даже не CMF. Код представлений нужно писать самостоятельно или использовать Gii (шаблоны прилагаются). YIMP предоставляет лейаут, в котором определено, где какие элементы управления должны находиться, а также интерфейс, через который приложение передает данные в лейаут. Вот так это выглядит на десктопах:



Лейаут адаптивный. По мере уменьшения экрана, элементы начинают пропадать или перебираться под кнопки в навбаре. В результате эта же страница на телефоне выглядит так:


Лучше пусть будет под спойлером

Что мы видим в лейауте? Заголовок приложения, хлебные крошки, три меню (левое, правое и верхнее), виджеты в боковых панелях, заголовок страницы. В моей практике этого набора элементов было достаточно для разработки любых интерфейсов — от админок лендинговых страниц до корпоративных информационных систем. Я постарался скомпоновать их так, чтобы пространство использовалось максимально эффективно. Что скажете?


Разметка написана на чистом Bootstrap, без расширений и кастомизации. Везде, где это было возможно, использовались классы из Bootstrap, поэтому если вы решите использовать кастомизацию, то проблем возникнуть не должно.


Как я уже говорил, YIMP включает в себя интерфейс, через который приложение передает данные в лейаут. Давайте разберемся, как это происходит. Открываем капот!


Лейаут


Я считаю, что разработчик должен иметь полный контроль над лейаутом, поэтому при установке YIMP рекомендую скопировать его код к себе в приложение. На мой взгляд это гораздо лучше, чем оставлять лейаут в пакете и городить к нему кучу настроек. Давайте посмотрим код лейаута:


77 строк кода. Вникать не обязательно!
<?php

use dmitrybtn\yimp\widgets\Alert;
use dmitrybtn\yimp\Yimp;
use yii\bootstrap4\Html;

$yimp = new Yimp();
$yimp->register($this);

/** @var string $content Content came from view */

?>

<?php $this->beginPage() ?>

<!DOCTYPE html>
<html lang="en">
    <head>
        <meta charset="<?= Yii::$app->charset ?>">
        <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">

        <?php echo Html::csrfMetaTags() ?>

        <title><?php echo Html::encode($yimp->nav->getTitle()) ?></title>

        <?php $this->head() ?>
    </head>
    <body>
        <?php $this->beginBody() ?>

            <?php echo $yimp->navbar() ?>

            <?php echo $yimp->beginSidebars() ?>
                <?php echo $yimp->beginLeftSidebar() ?>

                        <?php echo $yimp->beginLeftSidebarMenu() ?>
                            <?php echo $yimp->menuLeft([
                                'options' => ['class' => 'nav-pills flex-column border rounded py-2']
                            ]) ?>
                        <?php echo $yimp->endLeftSidebarMenu() ?>

                        <?php if (isset($this->blocks[$yimp::SIDEBAR_LEFT])): ?>
                            <?php echo $this->blocks[$yimp::SIDEBAR_LEFT] ?>
                        <?php endif ?>

                <?php echo $yimp->endLeftSidebar() ?>
                <?php echo $yimp->beginRightSidebar() ?>

                        <?php echo $yimp->beginRightSidebarMenu() ?>
                            <?php echo $yimp->menuRight([
                                'options' => ['class' => 'nav-pills flex-column border rounded py-2']
                            ]) ?>
                        <?php echo $yimp->endRightSidebarMenu() ?>

                        <?php if (isset($this->blocks[$yimp::SIDEBAR_RIGHT])): ?>
                            <?php echo $this->blocks[$yimp::SIDEBAR_RIGHT] ?>
                        <?php endif ?>

                <?php echo $yimp->endRightSidebar() ?>
            <?php echo $yimp->endSidebars() ?>

            <?php echo $yimp->beginContent() ?>
                <?php echo $yimp->headerDesktop() ?>

                <?php echo Alert::widget() ?>

                <?php echo $content ?>
            <?php echo $yimp->endContent() ?>

            <?php if (isset($this->blocks[$yimp::FOOTER])): ?>
                    <?php echo $this->blocks[$yimp::FOOTER] ?>
            <?php endif ?>

        <?php $this->endBody() ?>
    </body>
</html>
<?php $this->endPage() ?>

Как видите, вся разметка YIMP обернута в методы. Большинство этих методов просто выводит строки, которые берет вот из этого массива. Да, принцип KISS — наше всё.


Обратите внимание, что в лейауте используются блоки. Они нужны для отображения виджетов, определенных в представлениях (именно так рендерятся элементы управления формой). Ну а если виджет должен висеть на всех страницах, его лучше определить прямо в лейауте.


Итак, основная область и виджеты определяются в представлениях. А где определяются заголовки, меню и хлебные крошки? На мой взгляд, их лучше всего определять в контроллерах. Это важный момент, поскольку такое решение противоречит парадигме MVC. Давайте рассмотрим этот вопрос подробнее.


Контроллеры


Итак, пусть имеется ProfileController, который умеет отображать информацию о профиле текущего пользователя и менять пароль. Логично, что действие profile/view будет называться «Мой профиль». Также логично, что в главном меню должен быть пункт «Мой профиль». Наконец, «Мой профиль» должен быть и в хлебных крошках: «Главная / Мой профиль / Сменить пароль». Думаю, желание определить константу со словами «Мой профиль» вполне оправданно. Сделать это в представлении не получится. Выделять для заголовков отдельный слой — громоздко. Рассуждая так, я и пришел к контроллерам.


Следующим шагом было решение определять в контроллерах не только заголовки действий, но и хлебные крошки, и меню. Причем делать это так, чтобы YIMP смог их прочитать. И тут уже нужен пример. Давайте рассмотрим возможную реализацию класса ProfileController.


52 строки простого кода. Лучше посмотреть внимательно!
class ProfileController extends \yii\web\Controller
{
    public $nav;

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

        $this->nav = new \dmitrybtn\yimp\Navigator;
    }

    public static function titleView()
    {
        return 'Мой профиль';
    }

    public static function titlePassword()
    {
        return 'Сменить пароль';
    }

    public static function crumbsToView()
    {
        return [
            ['label' => static::titleView(), 'url' => [‘/profile/view’]]
        ];
    }

    public function actionView()
    {
        $this->nav->title = static::titleView();
        $this->nav->menuRight = [
            ['label' => 'Опции'],
            ['label' => static::titlePassword(), ['password']],
        ];

        ...

        return $this->render(‘view’);
    }

    public function actionPassword()
    {
        $this->nav->title = static::titlePassword();
        $this->nav->crumbs = static::crumbsToView();

        ...

        return $this->render(‘password’);
    }
}

Заголовки и хлебные крошки определены через статические методы, значит их можно использовать где угодно. Например, в главном меню приложения вы сможете написать:


['label' => ProfileController::titleView(), 'url' => ['/profile/view']],

Почему именно методы? Потому что завтра вас попросят вместо слов «Мой профиль» вывести логин текущего пользователя.


С хлебными крошками то же самое. Предположим, что у нашего пользователя появляется список картинок, за которые отвечает ImageController. Тогда в действии image/create вы сможете написать:


 $this->nav->crumbs = ProfileController::crumbsToView(),

и получить хлебные крошки вроде «Главная / Мой профиль / Добавить картинку». Кстати, раз действие image/create называется «Добавить картинку», то меню действия profile/view нужно будет поправить:


    $this->nav->menuRight = [
        ['label' => 'Опции'],
        ['label' => static::titlePassword(), ['password']],
        ['label' => ImageController::titleCreate(), 'url' => ['/image/create']]
    ];

Думаю, идея вполне понятна. На мой взгляд, это простое и эффективное решение, ради которого можно отойти от парадигмы MVC. Да, код контроллера становится больше, но в контроллере есть для него место — мы ведь не пишем там бизнес-логику, правда? И да, мне было бы очень интересно узнать ваше мнение по этому поводу.


Едем дальше. Как вы уже догадались, свойство nav, определенное как \dmitrybtn\yimp\Navigator, используется для передачи заголовков, меню и хлебных крошек из контроллера в лейаут. И это еще одна особенность YIMP.



Как же настройки попадают в лейаут? Очень просто. Во время своей инициализации, YIMP проверяет наличие свойства nav у текущего контроллера. Если это свойство доступно для чтения и является навигатором (instanceof \dmitrybtn\yimp\Navigator), оно используется для вывода соответствующей информации. Навигатор включает в себя свойства, соответствующие элементам лейаута, полный список которых проще посмотреть в API документации.


В приложении рекомендуется создать свой навигатор и определить в нем меню, которые не будут зависеть от текущего действия (верхнее и левое). После этого, во всех контроллерах нужно создать свойство nav и определить его как навигатор (можно руками, можно наследованием, а можно трейтом). Если нужно, можно определить и несколько навигаторов.


Такой подход исключает прямую зависимость между YIMP и контроллером. Все взаимодействие осуществляется через один объект, реализация которого контролируется разработчиком. То есть это тот самый Dependency Inversion Principle из SOLID или Low Coupling из GRASP.


Идеологически правильным было бы использовать интерфейсы, причем как для контроллера, так и для навигатора. Но и тут я решил пойти по самому простому пути и не загромождать систему. В конце-концов, в DIP сказано не об интерфейсах, а об абстракциях. И в данном случае, абстракция — это соглашение о наличии в определенном классе свойства определенного типа. Что думаете?


Отсутствие прямой зависимости между YIMP и контроллером становится важным, когда в системе появляются модули, ничего не знающие про YIMP. Или наоборот — модули, написанные под YIMP устанавливаются в систему, которая YIMP не использует.


В первом случае YIMP не увидит в контроллере свойства nav. Ошибки не будет, но с экрана пропадут ваши меню, а в качестве заголовка будет использовано ID действия. Как быть? Очень просто — если YIMP не сможет взять навигатор из контроллера, он создаст его через DI-контейнер по псевдониму yimp-nav. Используя этот псевдоним, вы можете зарегистрировать собственный дефолтный навигатор, например указав в настройках приложения:


    'container' => [
        'definitions' => [
            'yimp-nav' => [
                'class' => '\your\own\Navigator',
            ]
        ]
    ],

Во втором случае навигатор в контроллере будет, но некому будет его прочитать. На этот случай рекомендуется написать в модуле обертку для представлений, которая будет адаптировать навигатор из текущего контроллера к формату, принятому в Yii. То есть выводить <h1> в основной области, <title> и хлебные крошки передавать через параметры Yii::$app->view, а правое меню выводить в виде кнопок.


Заключение


Сейчас YIMP опубликован без версии. Я не вижу смысла публиковать предрелизную версию — там все слишком просто для этого. Думаю, лучше пару недель потестировать на реальном проекте и сразу перейти к версии 1.0.0. Так что критика, комментрии и помощь на GitHub очень приветствуются.


А еще я доделываю модуль, в котором реализовано разграничение доступа. Как закончу — напишу.


Как видите, ничего сложного тут нет. Уверен, многие из вас имеют в запасе что-то похожее. И мне было бы очень интересно узнать, как вы решаете задачу пользовательского интерфейса в своих админках.


Спасибо всем!

Комментарии (7)


  1. pro100hikka98
    10.09.2019 09:30

    модуль, в котором реализовано разграничение доступа

    А чем rbac не устроил?


  1. dmitrybtn Автор
    10.09.2019 09:50
    -1

    Всем устроил)) В этом модуле как раз будет RBAC и некоторые дополнительные компоненты. Например меню, пункты которых отображаются только если они разрешены текущему пользователю.


    1. pro100hikka98
      10.09.2019 10:41

      Это умеет делать и стандартный компонент меню.


      1. dmitrybtn Автор
        10.09.2019 11:50

        Интересно, что Вы обратили внимание именно на это место. Я ждал критики по любому вопросу, кроме этого, честно)

        Смотрите, YIMP вообще никак не связан с разграничением доступа и никак не ограничивает в этом разработчика. Вы можете использовать встроенный RBAC, ACF или любой модуль где все это сделано. В модуле, на который Вы обратили внимание, я просто собрал привычные мне инструменты. Может быть, кому-то они пригодятся. Но повторюсь, эта статья не про него.


  1. 3ton
    10.09.2019 10:43

    Спасибо за проделанную работу, но было бы не плохо запилить хоть какую-то из уже имеющихся бесплатных тем на базе 4 бутстрапа. Это бы позволило более продуктивнее использовать Вашу работу(а так же думаю дать толчок большему распространению Вашему проекту), нежели просто иметь возможность использовать 4 бутстрап на своем проекте и сидеть компоновать блоки самому вместе. У нас прогеры больше привыкли размышлять над логикой продукта, нежели над UI ))))


    1. dmitrybtn Автор
      10.09.2019 12:00

      Спасибо!) Вы попали в самую точку, конечно это надо сделать. Более того, изначально в YIMP была встроена тема с Bootswatch, но я все-таки решил, что в базовом пакете лучше использовать минимум сторонних инструментов. А темизацию вынести в отдельное расширение.

      Хотелось бы посоветоваться, как лучше сделать. Чтобы использовать стороннюю тему, нужно подменить CSS, который использует AssetBundle из YIMP. Это можно сделать прямо в бутстрапинге расширения. Вопрос в том, как собрать CSS с нужной темой.

      Самый элегантный путь — включить в расширение файл с переменными (в случае с Bootswatch), исходники Bootstrap брать из bower-asset, и компилировать все это при публикации ресурсов. Но для этого на сервере должен стоять препроцессор, это меня смущает.

      Второй вариант — затащить Bootstrap к себе в расширение и поставлять уже откомпилированным с нужной темой. Но это как-то костыльно выглядит.

      Буду благодарен за совет.


      1. 3ton
        12.09.2019 14:26

        Тут думаю одним CSS не отделаетесь.
        К примеру многие темы имеют уже и набор блоков, при чем каждый из них скомпонован своими наборами тегов. Тот же AdminLTE использую в проектах на Yii2, но иногда встречаются ситуации когда часть готовых блоков(снипетов) есть лишь на четвертом бутстрапе и уже прогеру приходится тратить время на сведение верстки чтоб все отображалось гладко, а тут была бы возможность юзать тему построенную изначально на четверке. И «из области фантастики» чтоб темы строились на базе наборов — классы+CSS, и настройкой/добавкой пакетов мог спокойно переключить тему, а писать все под одно и то же расширение интерфейса.