В этой маленькой заметке я расскажу о том, как подключить удобный datepicker в админку Symfony. По умолчанию datepicker в SonataAdminBundle выглядит так:
А мы его превратим в удобные и красивые контролы:
Те, кто еще мучаются с неудобным datepicker-ом, добро пожаловать под кат.
Если вам не нужен выбор времени, то вы может воспользоваться готовым решением, спасибо dmkuznetsov
Я не буду рассказывать о том, как установить SonataAdminBundle, об этом можно прочитать в этой статье. Я предполагаю, что у вас уже установлено приложение и админка. Ну чтож, приступим.
Первое с чего стоит начать это создание нового поля формы как описано в документации. Его нужно обязательно создавать в namespace <vendor_name>\<bundle_name>\Form\Type\ иначе будет ругаться SensioLabsInsight при тестировании.
Я не делал перенос стандартных опций datetime в новый класс за ненадобностью, но вы можете это сделать если вам это необходимо. В разделе Тип поля Time я опишу как это сделать на примере опции with_seconds.
Следующим пунктом нашей программы будет создание общего шаблон форм (темы для форм). В нем мы наследуемся от темы Sonata и переопределим шаблон даты. Шаблон сохраняем в файд Resources/views/Form/fields.html.twig . Можно выбрать и другой путь, но мне так привычней.
Класс form-field-datetime нам потом будет нужен для навешивания JavaScript. Теперь укажем Sonata что ей необходимо использовать другую тему для форм прописав в конфиге app/config/config.yml следующие строчки:
Не забываем создать сервис для нового поля формы:
Мы не создаем метку для сервиса как описано тут потому что мы не создаем новое поля и будем перегружать старое. По той же причине он нам не нужен в публичном доступе. Теперь приступим к перезаписи стандартных полей формы. Создадим компилятор для DI контейнера:
Здесь мы указываем что form.type.datetime является псевдонимом для нашего, вновь созданного сериса acme.demo.form.type.datetime. Таким образом когда в формах мы будем создавать поле типа datetime будет использоваться наш сервис. Так мы меняем контрол не меняя код проекта. Теперь подключим компилятор в бандл:
Сейчас datepicker уже имеет красивую и удобную форму, осталось только навесить JavaScript для открытия выпадающего окна с выбором даты.
Устанавливать мы будем Bootstrap 3 Datepicker который есть на packagist.org, за что им большое спасибо. Пропишем зависимость в composer.json:
При такой установки пакета его удобней подключать через assetic что мы и сделаем. Прописываем в app/config/config.yml следующие строчки:
Мы определили файл js/admin.js в который будет билдиться наш datepicker и JavaScript код который его инициализирует и навешивает на соответствующие поля формы. Этот файл будет лежать по адресу web/js/admin.js. Так же мы переопределили лайаут Sonata для того что бы подключить наш JavaScript. Давайте этим и займемся:
Теперь мы создадим файл Resources/public/js/admin.js в котором обвяжем наши поля формы JavaScript-ом.
Вот собственно и все. Выполняем сбору assetic и радуемся жизни:
По аналогии создаем поле date с небольшими отличиями. Класс для поля формы:
Шаблон:
Сервис:
Добавляем псевдоним:
Ну и JavaScript:
По аналогии с предыдущими, но с небольшими отличиями. В нашем проекте публикуются видео ролики и необходимо в админке указывать их продолжительность. Для этого мы используем поле time и выставляем ему опцию with_seconds в true. В новом поле формы нужно было сохранить эту функциональность.
Шаблон:
Сервис:
Добавляем псевдоним:
JavaScript будет немного отличатся:
В место заключения скажу, что есть очень интересная библиотека ClockPicker для выбора времени. Если она вас заинтересует, то вы с легкостью сможете подключить ее по моим примерам.
А мы его превратим в удобные и красивые контролы:
Те, кто еще мучаются с неудобным 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 для выбора времени. Если она вас заинтересует, то вы с легкостью сможете подключить ее по моим примерам.
dmkuznetsov
У меня только один вопрос — чем не угодил стандартный datetimepicker?
ghost404
Спасибо за ссылку. Я его не нашел в документации
dmkuznetsov
Можете в начале статьи указать на существующее решение, чтобы ищущий знал, о том, что нет необходимости писать что-то свое
ghost404
добавил ссылку
ghost404
Однако для выбора времени поле в существующем виде не подходит
dmkuznetsov
Опять поторопились. Смотрите документацию (Custom Formats), там поразному виджет можно сконфигурировать
ghost404
В том то и дело что нет. В приведенном мною примере указывается формат HH:mm:ss, что говорит о том что дата выводится не должна и если создавать поля как в статье или как документации Bootstrap Datepicker, то оно так и работает. Однако, при использовании sonata_type_datetime_picker это не срабатывает. Очень похоже на то что sonata под капотом выполняет какие-то свои манипуляции с датой.
Сейчас попробовал использовать поле time из статьи совместно с sonata_type_datetime_picker и получил такой же результат как в моем комментарии.
ghost404
Проблема в том что в sonata используется устаревшая версия 3.1.3 от 16 августа 2014. В актуальной версии 4.17.37 от 10 сентября 2015 эту проблему исправили, но если просто перейти на версию 4.17.37 мы получим ошибку:
ruFog
Изящнее было бы в настройках типа формы вынести локаль в конфигурируемую опцию. Было бы интересно почитать общую статью про то, чем хорош этот бандл, что нового и т.д. Я его пробовал 2-3 года назад и как-то он не прижился.
ghost404
тогда уж лучше локаль определять в конфиге проекта и передавать аргументом сервису поля, хотя большой необходимости в этом нет ибо bootstrap datepicker сам определяет локаль.
Из любопытства, чем вы сейчас пользуетесь в качестве админки?
ruFog
Самописные кастомные типы форм.
korotovsky
Статья в целом хорошая, но есть грубая ошибка с DataTransformer'ами. Они просто напросто у вас не правильно используются. Тот кусок кода, что у вас в buildView() должен быть в методе ->transform().
Метод ->transform() должен выглядеть так:
А метод ->reverseTransform() должен выглядеть так:
И еще, т.к. у нас DataTransformer начинает быть зависимым от $options его надо вынести в отдеьный класс, чтобы передавать опции через конструктор.
Ссылки:
korotovsky
Сейчас заметил, что вы используете ModelTransformer. Тогда это все объясняет. Но если переделать на ViewTransformer кода станет меньше и в общем случае процесс будет более наглядный. Т.к. сейчас получается у вас buildView() по факту забрал логику работы DataTransformer'а и полезность ModelTranformer'а стремится к нулю.
ghost404
у меня не работало форматирование даты в transform(), а после вашего комментария я понял почему. спасибо
Meliborn
Матерь божья, это столько кода надо, что бы подключить datepicker?
baldr
А что вас пугает? Это много?
Уберите все комментарии и отступы — и размер в 3 раза меньше станет. Автор постарался оформить это нормально, с тем, чтобы другим людям было проще встроить это в свои проекты, если нужно.
Я не знаком с Symfony и перешел с PHP в Python уже достаточно много лет назад, но вполне могу угадать стандартные для многих фреймворков вещи. Классы, заголовки функций — все это игнорируется при прочтении кода, а функционального кода как такового — не очень много.
ghost404
а если сделать как советует korotovsky и вынести DataTransformer в отдельный класс то кода станет еще меньше
Meliborn
Конечно много, это же просто datepicker, сколько времени и кода заняло его добавление, хотя это просто свистелка. А что если rich editor какой добавить надо?
baldr
А на чем таком вы обычно пишете что добавление контрола занимает две строчки?
Meliborn
Если вам так интересно, то на рельсах.