Внедряем Bootstrap 3 Datepicker в SonataAdminBundle

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



А мы его превратим в удобные и красивые контролы:



Те, кто еще мучаются с неудобным datepicker-ом, добро пожаловать под кат.

Если вам не нужен выбор времени, то вы может воспользоваться готовым решением, спасибо dmkuznetsov

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

Тип поля datetime


Первое с чего стоит начать это создание нового поля формы как описано в документации. Его нужно обязательно создавать в namespace <vendor_name>\<bundle_name>\Form\Type\ иначе будет ругаться SensioLabsInsight при тестировании.

namespace Acme\Bundle\DemoBundle\Form\Type\Field;

use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormView;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Form\DataTransformerInterface; 
// Symfony >=2.8
//use Symfony\Component\Form\Extension\Core\Type\TextType;

class DateTime extends AbstractType implements DataTransformerInterface
{
    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        // результатом заполнения форму является строка и ее необходимо конвертировать в \DateTime
        $builder->addModelTransformer($this);
    }

    public function transform($value)
    {
        return $value; // нужно для интерфейса DataTransformerInterface
    }

    public function reverseTransform($value)
    {
        // собственно конвертирование значения в \DateTime
        return $value instanceof \DateTime ? $value : new \DateTime($value);
    }

    public function buildView(FormView $view, FormInterface $form, array $options)
    {
        // объект даты нужно преобразовать в строку
        if ($form->getData() instanceof \DateTime) {
            $view->vars['value'] = $form->getData()->format('Y-m-d H:i');
        }
        // css класс для bootstrap форм
        $view->vars['attr']['class'] = 'form-control';
    }

    public function configureOptions(OptionsResolver $resolver)
    {
        // без указания data_class не работает DataTransformer
        $resolver->setDefaults([
             'data_class' => \DateTime::class
        ]);
    }

    public function getParent()
    {
        // Symfony >=2.8
        //return TextType::class;
        // Symfony <2.8
        return 'text';
    }

    public function getName()
    {
        return 'datetime'; // мы потом перегрузим стандартный datetime
    }
}

Я не делал перенос стандартных опций datetime в новый класс за ненадобностью, но вы можете это сделать если вам это необходимо. В разделе Тип поля Time я опишу как это сделать на примере опции with_seconds.

Следующим пунктом нашей программы будет создание общего шаблон форм (темы для форм). В нем мы наследуемся от темы Sonata и переопределим шаблон даты. Шаблон сохраняем в файд Resources/views/Form/fields.html.twig . Можно выбрать и другой путь, но мне так привычней.

{% extends 'SonataAdminBundle:Form:form_admin_fields.html.twig' %}

{% block datetime_widget %}
{% spaceless %}
    <div class="input-group date form-field-datetime">
        <input type="text" {{ block('widget_attributes') }} {% if value is not empty %}value="{{ value }}" {% endif %}/>
        <span class="input-group-addon">
            <span class="glyphicon glyphicon-calendar"></span>
        </span>
    </div>
{% endspaceless %}
{% endblock datetime_widget %}

Класс form-field-datetime нам потом будет нужен для навешивания JavaScript. Теперь укажем Sonata что ей необходимо использовать другую тему для форм прописав в конфиге app/config/config.yml следующие строчки:

sonata_doctrine_orm_admin:
    templates:
        form: [ AcmeDemoBundle:Form:fields.html.twig ]

Не забываем создать сервис для нового поля формы:

    acme.demo.form.type.datetime:
        class: Acme\Bundle\DemoBundle\Form\Type\Field\DateTime
        public: false

Мы не создаем метку для сервиса как описано тут потому что мы не создаем новое поля и будем перегружать старое. По той же причине он нам не нужен в публичном доступе. Теперь приступим к перезаписи стандартных полей формы. Создадим компилятор для DI контейнера:

namespace Acme\Bundle\DemoBundle\DependencyInjection\Compiler;

use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder;

class FormTypePass implements CompilerPassInterface
{
    public function process(ContainerBuilder $container)
    {
        $container->setAlias('form.type.datetime', 'acme.demo.form.type.datetime');
    }
}

Здесь мы указываем что form.type.datetime является псевдонимом для нашего, вновь созданного сериса acme.demo.form.type.datetime. Таким образом когда в формах мы будем создавать поле типа datetime будет использоваться наш сервис. Так мы меняем контрол не меняя код проекта. Теперь подключим компилятор в бандл:

namespace Acme\Bundle\DemoBundle

use Symfony\Component\HttpKernel\Bundle\Bundle;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Acme\BundleDemoBundle\DependencyInjection\Compiler\FormTypePass;

class AcmeDemoBundle extends Bundle
{
    public function build(ContainerBuilder $container)
    {
        parent::build($container);
        $container->addCompilerPass(new FormTypePass());
    }
}

Сейчас datepicker уже имеет красивую и удобную форму, осталось только навесить JavaScript для открытия выпадающего окна с выбором даты.



Устанавливать мы будем Bootstrap 3 Datepicker который есть на packagist.org, за что им большое спасибо. Пропишем зависимость в composer.json:

{
    "require": {
        …
        "eonasdan/bootstrap-datetimepicker": "~4.17.37",
        …
    }
}

При такой установки пакета его удобней подключать через assetic что мы и сделаем. Прописываем в app/config/config.yml следующие строчки:

assetic:
    assets:
        admin-js:
            inputs:
                - '%kernel.root_dir%/../vendor/eonasdan/bootstrap-datetimepicker/build/js/bootstrap-datetimepicker.min.js'
                - '@AcmeDemoBundle/Resources/public/js/admin.js'
            output: js/admin.js

sonata_admin:
    templates:
        layout: AcmeDemoBundle:Admin:standard_layout.html.twig

Мы определили файл js/admin.js в который будет билдиться наш datepicker и JavaScript код который его инициализирует и навешивает на соответствующие поля формы. Этот файл будет лежать по адресу web/js/admin.js. Так же мы переопределили лайаут Sonata для того что бы подключить наш JavaScript. Давайте этим и займемся:

{% extends 'SonataAdminBundle::standard_layout.html.twig' %}

{% block javascripts %}
    {{ parent() }}
    <script src="{{ asset('js/admin.js') }}" type="text/javascript"></script>
{% endblock %}

Теперь мы создадим файл Resources/public/js/admin.js в котором обвяжем наши поля формы JavaScript-ом.
$(function(){
    $('.form-field-datetime').datetimepicker({
        format: 'YYYY-MM-DD HH:mm',
        locale: 'ru'
    });
});

Вот собственно и все. Выполняем сбору assetic и радуемся жизни:

app/console assetic:dump web --no-debug

Тип поля date


По аналогии создаем поле date с небольшими отличиями. Класс для поля формы:

namespace Acme\Bundle\DemoBundle\Form\Type\Field;

use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormView;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Form\DataTransformerInterface; 
// Symfony >=2.8
//use Symfony\Component\Form\Extension\Core\Type\TextType;

class Date extends AbstractType implements DataTransformerInterface
{
    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        $builder->addModelTransformer($this);
    }

    public function transform($value)
    {
        return $value;
    }

    public function reverseTransform($value)
    {
        return $value instanceof \DateTime ? $value : new \DateTime($value);
    }

    public function buildView(FormView $view, FormInterface $form, array $options)
    {
        if ($form->getData() instanceof \DateTime) {
            $view->vars['value'] = $form->getData()->format('Y-m-d');
        }

        $view->vars['attr']['class'] = 'form-control';
    }

    public function configureOptions(OptionsResolver $resolver)
    {
        $resolver->setDefaults([
             'data_class' => \DateTime::class
        ]);
    }

    public function getParent()
    {
        // Symfony >=2.8
        //return TextType::class;
        // Symfony <2.8
        return 'text';
    }

    public function getName()
    {
        return 'date';
    }
}

Шаблон:

{% block date_widget %}
{% spaceless %}
    <div class="input-group date form-field-date">
        <input type="text" {{ block('widget_attributes') }} {% if value is not empty %}value="{{ value }}" {% endif %}/>
        <span class="input-group-addon">
            <span class="glyphicon glyphicon-calendar"></span>
        </span>
    </div>
{% endspaceless %}
{% endblock date_widget %}

Сервис:

    acme.demo.form.type.date:
        class: Acme\Bundle\DemoBundle\Form\Type\Field\Date
        public: false

Добавляем псевдоним:

// ..
class FormTypePass implements CompilerPassInterface
{
    public function process(ContainerBuilder $container)
    {
        // ..
        $container->setAlias('form.type.date', 'acme.demo.form.type.date');
    }
}

Ну и JavaScript:

$(function(){
    // .. 
    $('.form-field-date').datetimepicker({
        format: 'YYYY-MM-DD',
        locale: 'ru'
    });
});

Тип поля time


По аналогии с предыдущими, но с небольшими отличиями. В нашем проекте публикуются видео ролики и необходимо в админке указывать их продолжительность. Для этого мы используем поле time и выставляем ему опцию with_seconds в true. В новом поле формы нужно было сохранить эту функциональность.

namespace Acme\Bundle\DemoBundle\Form\Type\Field;

use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormView;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Form\DataTransformerInterface; 
// Symfony >=2.8
//use Symfony\Component\Form\Extension\Core\Type\TextType;

class Time extends AbstractType implements DataTransformerInterface
{
    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        $builder->addModelTransformer($this);
    }

    public function transform($value)
    {
        return $value;
    }

    public function reverseTransform($value)
    {
        return $value instanceof \DateTime ? $value : new \DateTime($value);
    }

    public function buildView(FormView $view, FormInterface $form, array $options)
    {
        if ($form->getData() instanceof \DateTime) {
            // формат даты соответственно различается
            $view->vars['value'] = $form->getData()->format($options['with_seconds'] ? 'H:i:s' : 'H:i');
        }
        $view->vars['attr']['class'] = 'form-control'; 
        // сохраняем переменную для шаблона
        $view->vars['with_seconds'] = $options['with_seconds'];
    }

    public function configureOptions(OptionsResolver $resolver)
    {
        $resolver->setDefaults([
            'data_class' => \DateTime::class,
            'with_seconds' => false // по умолчанию опция выключена
        ]);
    }

    public function getParent()
    {
        // Symfony >=2.8
        //return TextType::class;
        // Symfony <2.8
        return 'text';
    }


    public function getName()
    {
        return 'time';
    }
}

Шаблон:

{% block time_widget %}
{% spaceless %}
    <div class="input-group date form-field-time" data-with-seconds="{{ with_seconds == true ? 1 : 0 }}">
        <input type="text" {{ block('widget_attributes') }} {% if value is not empty %}value="{{ value }}" {% endif %}/>
        <span class="input-group-addon">
            <span class="glyphicon glyphicon-time"></span>
        </span>
    </div>
{% endspaceless %}
{% endblock time_widget %}

Сервис:

    acme.demo.form.type.time:
        class: Acme\Bundle\DemoBundle\Form\Type\Field\Time
        public: false

Добавляем псевдоним:

// ..
class FormTypePass implements CompilerPassInterface
{
    public function process(ContainerBuilder $container)
    {
        // ..
        $container->setAlias('form.type.time', 'acme.demo.form.type.time');
    }
}

JavaScript будет немного отличатся:

$(function(){
    // .. 
    $('.form-field-time') .each(function () {
        var el = $(this),
            options = {locale: 'ru'};
        if (el.data('with-seconds') == 1) {
            options.format = 'HH:mm:ss';
        } else {
            options.format = 'HH:mm';
        }
        el.datetimepicker(options);
});

Заключение


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

Комментарии 20

    +2
    У меня только один вопрос — чем не угодил стандартный datetimepicker?
      0
      Спасибо за ссылку. Я его не нашел в документации
        +1
        Можете в начале статьи указать на существующее решение, чтобы ищущий знал, о том, что нет необходимости писать что-то свое
          0
          добавил ссылку
            0
            Однако для выбора времени поле в существующем виде не подходит
            выбор времени
            $formMapper->add('duration', 'sonata_type_datetime_picker', [
                'label' => 'Продолжительность',
                'required' => false,
                'format' => 'HH:mm:ss',
                'date_format' => 'HH:mm:ss'
            ])
            

            image
              0
              Опять поторопились. Смотрите документацию (Custom Formats), там поразному виджет можно сконфигурировать
                0
                В том то и дело что нет. В приведенном мною примере указывается формат HH:mm:ss, что говорит о том что дата выводится не должна и если создавать поля как в статье или как документации Bootstrap Datepicker, то оно так и работает. Однако, при использовании sonata_type_datetime_picker это не срабатывает. Очень похоже на то что sonata под капотом выполняет какие-то свои манипуляции с датой.
                Сейчас попробовал использовать поле time из статьи совместно с sonata_type_datetime_picker и получил такой же результат как в моем комментарии.
                И это не смотря на то что код JavaScript имеет вид
                 $('.form-field-time').each(function () {
                        var el = $(this),
                            options = {locale: 'ru'};
                        if (el.data('with-seconds') == 1) {
                            options.format = 'HH:mm:ss';
                        } else {
                            options.format = 'HH:mm';
                        }
                        el.datetimepicker(options);
                });
                


                Завтра попробую подробней разобраться в проблеме.
                  0
                  Проблема в том что в sonata используется устаревшая версия 3.1.3 от 16 августа 2014. В актуальной версии 4.17.37 от 10 сентября 2015 эту проблему исправили, но если просто перейти на версию 4.17.37 мы получим ошибку:
                  Uncaught TypeError: option pickTime is not recognized!

                  Генерируемый JavaScript код
                  $('#dtp_s5666a539ecb69_duration').datetimepicker({
                      "pickTime":true,
                      "useCurrent":true,
                      "minDate":"1\/1\/1900",
                      "maxDate":null,
                      "showToday":true,
                      "language":"ru",
                      "defaultDate":"",
                      "disabledDates":[],
                      "enabledDates":[],
                      "icons":{
                          "time":"fa fa-clock-o",
                          "date":"fa fa-calendar",
                          "up":"fa fa-chevron-up",
                          "down":"fa fa-chevron-down"
                      },
                      "useStrict":false,
                      "sideBySide":false,
                      "daysOfWeekDisabled":[],
                      "useMinutes":true,
                      "useSeconds":true,
                      "minuteStepping":1
                  });
                  


                  Соответственно необходимо переопределять поведение DateTimePickerType что бы поменять опции передаваемые datetimepicker-у или переопределять шаблон SonataCoreBundle:Form:datepicker.html.twig и писать в нем большой if-else.
        0
        Изящнее было бы в настройках типа формы вынести локаль в конфигурируемую опцию. Было бы интересно почитать общую статью про то, чем хорош этот бандл, что нового и т.д. Я его пробовал 2-3 года назад и как-то он не прижился.
          0
          тогда уж лучше локаль определять в конфиге проекта и передавать аргументом сервису поля, хотя большой необходимости в этом нет ибо bootstrap datepicker сам определяет локаль.
          Из любопытства, чем вы сейчас пользуетесь в качестве админки?
            0
            Самописные кастомные типы форм.
          0
          Статья в целом хорошая, но есть грубая ошибка с DataTransformer'ами. Они просто напросто у вас не правильно используются. Тот кусок кода, что у вас в buildView() должен быть в методе ->transform().

          Метод ->transform() должен выглядеть так:

          public function transform($value)
          {
              if ($value === null) {
                  return null;
              }
          
              return $value->format($this->options['with_seconds'] ? 'H:i:s' : 'H:i');
          }
          

          А метод ->reverseTransform() должен выглядеть так:

          public function reverseTransform($value)
          {
              if ($value === null) {
                  return null;
              }
          
              try {
                  $value = new \DateTime($value);
              } catch (\Exception $e) {
                  throw new TransformationFailedException($e->getMessage());
              }
          
              return $value;
          }
          

          И еще, т.к. у нас DataTransformer начинает быть зависимым от $options его надо вынести в отдеьный класс, чтобы передавать опции через конструктор.

          Ссылки:
            0
            Сейчас заметил, что вы используете ModelTransformer. Тогда это все объясняет. Но если переделать на ViewTransformer кода станет меньше и в общем случае процесс будет более наглядный. Т.к. сейчас получается у вас buildView() по факту забрал логику работы DataTransformer'а и полезность ModelTranformer'а стремится к нулю.
              0
              у меня не работало форматирование даты в transform(), а после вашего комментария я понял почему. спасибо
              +1
              Матерь божья, это столько кода надо, что бы подключить datepicker?
                0
                А что вас пугает? Это много?
                Уберите все комментарии и отступы — и размер в 3 раза меньше станет. Автор постарался оформить это нормально, с тем, чтобы другим людям было проще встроить это в свои проекты, если нужно.

                Я не знаком с Symfony и перешел с PHP в Python уже достаточно много лет назад, но вполне могу угадать стандартные для многих фреймворков вещи. Классы, заголовки функций — все это игнорируется при прочтении кода, а функционального кода как такового — не очень много.
                  0
                  а если сделать как советует korotovsky и вынести DataTransformer в отдельный класс то кода станет еще меньше
                    +1
                    Конечно много, это же просто datepicker, сколько времени и кода заняло его добавление, хотя это просто свистелка. А что если rich editor какой добавить надо?
                      0
                      А на чем таком вы обычно пишете что добавление контрола занимает две строчки?
                        +1
                        Если вам так интересно, то на рельсах.

                Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                Самое читаемое