В этой маленькой заметке я расскажу о том, как подключить удобный 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)


  1. dmkuznetsov
    07.12.2015 11:56
    +2

    У меня только один вопрос — чем не угодил стандартный datetimepicker?


    1. ghost404
      07.12.2015 14:05

      Спасибо за ссылку. Я его не нашел в документации


      1. dmkuznetsov
        07.12.2015 15:36
        +1

        Можете в начале статьи указать на существующее решение, чтобы ищущий знал, о том, что нет необходимости писать что-то свое


        1. ghost404
          07.12.2015 17:51

          добавил ссылку


        1. ghost404
          07.12.2015 18:31

          Однако для выбора времени поле в существующем виде не подходит

          выбор времени
          $formMapper->add('duration', 'sonata_type_datetime_picker', [
              'label' => 'Продолжительность',
              'required' => false,
              'format' => 'HH:mm:ss',
              'date_format' => 'HH:mm:ss'
          ])
          

          image


          1. dmkuznetsov
            07.12.2015 18:38

            Опять поторопились. Смотрите документацию (Custom Formats), там поразному виджет можно сконфигурировать


            1. ghost404
              07.12.2015 19:16

              В том то и дело что нет. В приведенном мною примере указывается формат 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);
              });
              


              1. ghost404
                08.12.2015 13:45

                Проблема в том что в 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
                });
                


  1. ruFog
    07.12.2015 12:16

    Изящнее было бы в настройках типа формы вынести локаль в конфигурируемую опцию. Было бы интересно почитать общую статью про то, чем хорош этот бандл, что нового и т.д. Я его пробовал 2-3 года назад и как-то он не прижился.


    1. ghost404
      07.12.2015 14:19

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


      1. ruFog
        07.12.2015 15:25

        Самописные кастомные типы форм.


  1. korotovsky
    07.12.2015 12:22

    Статья в целом хорошая, но есть грубая ошибка с 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 его надо вынести в отдеьный класс, чтобы передавать опции через конструктор.

    Ссылки:


  1. korotovsky
    07.12.2015 12:26

    Сейчас заметил, что вы используете ModelTransformer. Тогда это все объясняет. Но если переделать на ViewTransformer кода станет меньше и в общем случае процесс будет более наглядный. Т.к. сейчас получается у вас buildView() по факту забрал логику работы DataTransformer'а и полезность ModelTranformer'а стремится к нулю.


    1. ghost404
      07.12.2015 14:07

      у меня не работало форматирование даты в transform(), а после вашего комментария я понял почему. спасибо


  1. Meliborn
    08.12.2015 13:32
    +1

    Матерь божья, это столько кода надо, что бы подключить datepicker?


    1. baldr
      08.12.2015 14:05

      А что вас пугает? Это много?
      Уберите все комментарии и отступы — и размер в 3 раза меньше станет. Автор постарался оформить это нормально, с тем, чтобы другим людям было проще встроить это в свои проекты, если нужно.

      Я не знаком с Symfony и перешел с PHP в Python уже достаточно много лет назад, но вполне могу угадать стандартные для многих фреймворков вещи. Классы, заголовки функций — все это игнорируется при прочтении кода, а функционального кода как такового — не очень много.


      1. ghost404
        08.12.2015 14:44

        а если сделать как советует korotovsky и вынести DataTransformer в отдельный класс то кода станет еще меньше


      1. Meliborn
        08.12.2015 16:37
        +1

        Конечно много, это же просто datepicker, сколько времени и кода заняло его добавление, хотя это просто свистелка. А что если rich editor какой добавить надо?


        1. baldr
          08.12.2015 16:55

          А на чем таком вы обычно пишете что добавление контрола занимает две строчки?


          1. Meliborn
            08.12.2015 17:12
            +1

            Если вам так интересно, то на рельсах.