Skip to content

Commit 78d3b8a

Browse files
committed
Add DateIntervalField for editable, localized DateInterval rendering
Fix #7374
1 parent d5c60ce commit 78d3b8a

46 files changed

Lines changed: 622 additions & 4 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

config/services.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
use EasyCorp\Bundle\EasyAdminBundle\Field\Configurator\CommonPreConfigurator;
4343
use EasyCorp\Bundle\EasyAdminBundle\Field\Configurator\CountryConfigurator;
4444
use EasyCorp\Bundle\EasyAdminBundle\Field\Configurator\CurrencyConfigurator;
45+
use EasyCorp\Bundle\EasyAdminBundle\Field\Configurator\DateIntervalConfigurator;
4546
use EasyCorp\Bundle\EasyAdminBundle\Field\Configurator\DateTimeConfigurator;
4647
use EasyCorp\Bundle\EasyAdminBundle\Field\Configurator\EmailConfigurator;
4748
use EasyCorp\Bundle\EasyAdminBundle\Field\Configurator\FileConfigurator;
@@ -398,6 +399,8 @@
398399

399400
->set(CurrencyConfigurator::class)
400401

402+
->set(DateIntervalConfigurator::class)
403+
401404
->set(DateTimeConfigurator::class)
402405
->arg(0, service(IntlFormatter::class))
403406

doc/fields.rst

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -701,6 +701,7 @@ These are all the built-in fields provided by EasyAdmin:
701701
* :doc:`CountryField </fields/CountryField>`
702702
* :doc:`CurrencyField </fields/CurrencyField>`
703703
* :doc:`DateField </fields/DateField>`
704+
* :doc:`DateIntervalField </fields/DateIntervalField>`
704705
* :doc:`DateTimeField </fields/DateTimeField>`
705706
* :doc:`EmailField </fields/EmailField>`
706707
* :doc:`FileField </fields/FileField>`
@@ -743,7 +744,7 @@ Doctrine Type Recommended EasyAdmin Fields
743744
``datetime`` ``DateTimeField``
744745
``datetimetz_immutable`` ``DateTimeField``
745746
``datetimetz`` ``DateTimeField``
746-
``dateinterval`` ``TextField``
747+
``dateinterval`` ``DateIntervalField``
747748
``decimal`` ``NumberField``
748749
``float`` ``NumberField``
749750
``guid`` ``TextField``

doc/fields/DateIntervalField.rst

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
EasyAdmin Date Interval Field
2+
=============================
3+
4+
This field is used to represent a value that stores a PHP ``DateInterval``
5+
object (e.g. a duration mapped to Doctrine's ``dateinterval`` column type).
6+
7+
In :ref:`form pages (edit and new) <crud-pages>` it is rendered as a single
8+
text input that expects an `ISO 8601 duration`_ pattern (for example ``P1Y2M3D``
9+
for "1 year, 2 months and 3 days" or ``PT1H30M`` for "1 hour and 30 minutes").
10+
11+
In :ref:`read-only pages (index and detail) <crud-pages>` the value is rendered
12+
as a localized, human-friendly string (for example ``2 years 4 days 6 hours
13+
8 minutes``). Each part is translated and pluralized using the
14+
``EasyAdminBundle`` translation domain.
15+
16+
Basic Information
17+
-----------------
18+
19+
* **PHP Class**: ``EasyCorp\Bundle\EasyAdminBundle\Field\DateIntervalField``
20+
* **Doctrine DBAL Type** used to store this value: ``dateinterval``
21+
* **Symfony Form Type** used to render the field: `DateIntervalType`_
22+
* **Rendered as**:
23+
24+
.. code-block:: html
25+
26+
<input type="text" placeholder="P1Y2M3DT4H5M6S">
27+
28+
Options
29+
-------
30+
31+
setFormat
32+
~~~~~~~~~
33+
34+
By default, in read-only pages (``index`` and ``detail``) date intervals are
35+
displayed using a localized, pluralized representation built from the
36+
``date_interval.*`` translation keys.
37+
38+
Use this option to override that default with a raw format string passed to
39+
``DateInterval::format()``::
40+
41+
yield DateIntervalField::new('duration')->setFormat('%y years, %m months, %d days');
42+
43+
The same override is available globally on the CRUD configuration via
44+
:ref:`Crud::setDateIntervalFormat() <crud-date-time-number-format-options>`.
45+
46+
.. _`ISO 8601 duration`: https://en.wikipedia.org/wiki/ISO_8601#Durations
47+
.. _`DateIntervalType`: https://symfony.com/doc/current/reference/forms/types/dateinterval.html

src/Dto/CrudDto.php

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ final class CrudDto
5757
private ?string $timePattern = 'medium';
5858
/** @var array{string, string} */
5959
private array $dateTimePattern = ['medium', 'medium'];
60-
private string $dateIntervalFormat = '%%y Year(s) %%m Month(s) %%d Day(s)';
60+
private ?string $dateIntervalFormat = null;
6161
private ?string $timezone = null;
6262
private ?string $numberFormat = null;
6363
private ?string $thousandsSeparator = null;
@@ -299,12 +299,12 @@ public function setDateTimePattern(string $dateFormatOrPattern, string $timeForm
299299
$this->dateTimePattern = [$dateFormatOrPattern, $timeFormat];
300300
}
301301

302-
public function getDateIntervalFormat(): string
302+
public function getDateIntervalFormat(): ?string
303303
{
304304
return $this->dateIntervalFormat;
305305
}
306306

307-
public function setDateIntervalFormat(string $format): void
307+
public function setDateIntervalFormat(?string $format): void
308308
{
309309
$this->dateIntervalFormat = $format;
310310
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
<?php
2+
3+
namespace EasyCorp\Bundle\EasyAdminBundle\Field\Configurator;
4+
5+
use EasyCorp\Bundle\EasyAdminBundle\Context\AdminContext;
6+
use EasyCorp\Bundle\EasyAdminBundle\Contracts\Field\FieldConfiguratorInterface;
7+
use EasyCorp\Bundle\EasyAdminBundle\Dto\EntityDto;
8+
use EasyCorp\Bundle\EasyAdminBundle\Dto\FieldDto;
9+
use EasyCorp\Bundle\EasyAdminBundle\Field\DateIntervalField;
10+
11+
/**
12+
* @author Pascal CESCON <pascal.cescon@gmail.com>
13+
*/
14+
final class DateIntervalConfigurator implements FieldConfiguratorInterface
15+
{
16+
public function supports(FieldDto $field, EntityDto $entityDto): bool
17+
{
18+
return DateIntervalField::class === $field->getFieldFqcn();
19+
}
20+
21+
public function configure(FieldDto $field, EntityDto $entityDto, AdminContext $context): void
22+
{
23+
$value = $field->getValue();
24+
25+
if (!$value instanceof \DateInterval) {
26+
return;
27+
}
28+
29+
$format = $field->getCustomOption(DateIntervalField::OPTION_FORMAT)
30+
?? $context->getCrud()?->getDateIntervalFormat();
31+
32+
if (null !== $format) {
33+
$field->setFormattedValue($value->format($format));
34+
35+
return;
36+
}
37+
38+
// sentinel: keep formattedValue non-null so the CommonPostConfigurator does not
39+
// swap our template for label/null; the template builds the localized string
40+
// from $field->getValue() (the original DateInterval).
41+
$field->setFormattedValue('');
42+
}
43+
}

src/Field/DateIntervalField.php

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
<?php
2+
3+
namespace EasyCorp\Bundle\EasyAdminBundle\Field;
4+
5+
use EasyCorp\Bundle\EasyAdminBundle\Contracts\Field\FieldInterface;
6+
use Symfony\Component\Form\Extension\Core\Type\DateIntervalType;
7+
use Symfony\Contracts\Translation\TranslatableInterface;
8+
9+
/**
10+
* @author Pascal CESCON <pascal.cescon@gmail.com>
11+
*/
12+
final class DateIntervalField implements FieldInterface
13+
{
14+
use FieldTrait;
15+
16+
public const OPTION_FORMAT = 'format';
17+
18+
public static function new(string $propertyName, TranslatableInterface|string|bool|null $label = null): self
19+
{
20+
return (new self())
21+
->setProperty($propertyName)
22+
->setLabel($label)
23+
->setTemplateName('crud/field/date_interval')
24+
->setFormType(DateIntervalType::class)
25+
->setFormTypeOptions([
26+
'widget' => 'single_text',
27+
'input' => 'dateinterval',
28+
'with_years' => true,
29+
'with_months' => true,
30+
'with_weeks' => false,
31+
'with_days' => true,
32+
'with_hours' => true,
33+
'with_minutes' => true,
34+
'with_seconds' => true,
35+
])
36+
->addCssClass('field-date-interval')
37+
->setDefaultColumns('col-md-6 col-xxl-5')
38+
->setCustomOption(self::OPTION_FORMAT, null);
39+
}
40+
41+
/**
42+
* Set a custom format string passed to DateInterval::format().
43+
*
44+
* When set, this overrides the localized rendering and the value is
45+
* displayed using the raw DateInterval::format() output.
46+
*/
47+
public function setFormat(?string $format): self
48+
{
49+
$this->setCustomOption(self::OPTION_FORMAT, $format);
50+
51+
return $this;
52+
}
53+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
{# @var ea \EasyCorp\Bundle\EasyAdminBundle\Context\AdminContext #}
2+
{# @var field \EasyCorp\Bundle\EasyAdminBundle\Dto\FieldDto #}
3+
{# @var entity \EasyCorp\Bundle\EasyAdminBundle\Dto\EntityDto #}
4+
{% if field.formattedValue is not empty %}
5+
<time datetime="{{ field.value.format('P%yY%mM%dDT%hH%iM%sS') }}">{{ field.formattedValue }}</time>
6+
{% else %}
7+
{% set parts = [] %}
8+
{% if field.value.y > 0 %}{% set parts = parts|merge([('date_interval.years'|trans({'%count%': field.value.y}, 'EasyAdminBundle'))]) %}{% endif %}
9+
{% if field.value.m > 0 %}{% set parts = parts|merge([('date_interval.months'|trans({'%count%': field.value.m}, 'EasyAdminBundle'))]) %}{% endif %}
10+
{% if field.value.d > 0 %}{% set parts = parts|merge([('date_interval.days'|trans({'%count%': field.value.d}, 'EasyAdminBundle'))]) %}{% endif %}
11+
{% if field.value.h > 0 %}{% set parts = parts|merge([('date_interval.hours'|trans({'%count%': field.value.h}, 'EasyAdminBundle'))]) %}{% endif %}
12+
{% if field.value.i > 0 %}{% set parts = parts|merge([('date_interval.minutes'|trans({'%count%': field.value.i}, 'EasyAdminBundle'))]) %}{% endif %}
13+
{% if field.value.s > 0 %}{% set parts = parts|merge([('date_interval.seconds'|trans({'%count%': field.value.s}, 'EasyAdminBundle'))]) %}{% endif %}
14+
{% if parts is empty %}{% set parts = [('date_interval.empty'|trans({}, 'EasyAdminBundle'))] %}{% endif %}
15+
<time datetime="{{ field.value.format('P%yY%mM%dDT%hH%iM%sS') }}">{{ parts|join(' ') }}</time>
16+
{% endif %}
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
<?php
2+
3+
namespace EasyCorp\Bundle\EasyAdminBundle\Tests\Unit\Field;
4+
5+
use EasyCorp\Bundle\EasyAdminBundle\Field\Configurator\DateIntervalConfigurator;
6+
use EasyCorp\Bundle\EasyAdminBundle\Field\DateIntervalField;
7+
8+
class DateIntervalFieldTest extends AbstractFieldTest
9+
{
10+
protected function setUp(): void
11+
{
12+
parent::setUp();
13+
14+
$this->configurator = new DateIntervalConfigurator();
15+
}
16+
17+
public function testFieldWithoutValue(): void
18+
{
19+
$field = DateIntervalField::new('foo');
20+
$field->setFieldFqcn(DateIntervalField::class);
21+
$fieldDto = $this->configure($field);
22+
23+
$this->assertNull($fieldDto->getFormattedValue());
24+
}
25+
26+
public function testFieldWithoutCustomFormatLeavesValueForTemplateRendering(): void
27+
{
28+
$field = DateIntervalField::new('foo')->setValue(new \DateInterval('P2Y4DT6H8M'));
29+
$field->setFieldFqcn(DateIntervalField::class);
30+
$fieldDto = $this->configure($field);
31+
32+
// sentinel empty string keeps CommonPostConfigurator from swapping the template
33+
$this->assertSame('', $fieldDto->getFormattedValue());
34+
$this->assertInstanceOf(\DateInterval::class, $fieldDto->getValue());
35+
}
36+
37+
public function testFieldWithPerFieldFormat(): void
38+
{
39+
$field = DateIntervalField::new('foo')
40+
->setValue(new \DateInterval('P1Y2M3D'))
41+
->setFormat('%y years, %m months, %d days');
42+
$field->setFieldFqcn(DateIntervalField::class);
43+
$fieldDto = $this->configure($field);
44+
45+
$this->assertSame('1 years, 2 months, 3 days', $fieldDto->getFormattedValue());
46+
}
47+
48+
public function testTemplateRendersLocalizedPluralizedString(): void
49+
{
50+
$field = DateIntervalField::new('foo')->setValue(new \DateInterval('P1Y2M3DT4H5M6S'));
51+
$field->setFieldFqcn(DateIntervalField::class);
52+
$fieldDto = $this->configure($field);
53+
54+
$html = $this->renderFieldTemplate($fieldDto, $this->entityDto, $this->adminContext);
55+
56+
$this->assertStringContainsString('1 year', $html);
57+
$this->assertStringContainsString('2 months', $html);
58+
$this->assertStringContainsString('3 days', $html);
59+
$this->assertStringContainsString('4 hours', $html);
60+
$this->assertStringContainsString('5 minutes', $html);
61+
$this->assertStringContainsString('6 seconds', $html);
62+
$this->assertStringContainsString('datetime="P1Y2M3DT4H5M6S"', $html);
63+
}
64+
65+
public function testTemplateRendersZeroDurationAsEmptyLabel(): void
66+
{
67+
$field = DateIntervalField::new('foo')->setValue(new \DateInterval('PT0S'));
68+
$field->setFieldFqcn(DateIntervalField::class);
69+
$fieldDto = $this->configure($field);
70+
71+
$html = $this->renderFieldTemplate($fieldDto, $this->entityDto, $this->adminContext);
72+
73+
$this->assertStringContainsString('0 seconds', $html);
74+
}
75+
}

translations/EasyAdminBundle.ar.php

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,16 @@
4040
'text_editor.view_content' => 'رؤية المحتوى',
4141
],
4242

43+
'date_interval' => [
44+
'years' => '{1} %count% year|]1,Inf] %count% years',
45+
'months' => '{1} %count% month|]1,Inf] %count% months',
46+
'days' => '{1} %count% day|]1,Inf] %count% days',
47+
'hours' => '{1} %count% hour|]1,Inf] %count% hours',
48+
'minutes' => '{1} %count% minute|]1,Inf] %count% minutes',
49+
'seconds' => '{1} %count% second|]1,Inf] %count% seconds',
50+
'empty' => '0 seconds',
51+
],
52+
4353
'action' => [
4454
'entity_actions' => 'إجراءات',
4555
'new' => '%entity_label_singular% جديد',

translations/EasyAdminBundle.bg.php

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,16 @@
4040
'text_editor.view_content' => 'Преглед на съдържание',
4141
],
4242

43+
'date_interval' => [
44+
'years' => '{1} %count% year|]1,Inf] %count% years',
45+
'months' => '{1} %count% month|]1,Inf] %count% months',
46+
'days' => '{1} %count% day|]1,Inf] %count% days',
47+
'hours' => '{1} %count% hour|]1,Inf] %count% hours',
48+
'minutes' => '{1} %count% minute|]1,Inf] %count% minutes',
49+
'seconds' => '{1} %count% second|]1,Inf] %count% seconds',
50+
'empty' => '0 seconds',
51+
],
52+
4353
'action' => [
4454
'entity_actions' => 'Действия',
4555
'new' => 'Добавяне на %entity_label_singular%',

0 commit comments

Comments
 (0)