Store $value in ISO and server timezone consistently, fix min/max timezone handling

This commit is contained in:
Ingo Schommer 2017-04-27 14:59:11 +12:00
parent 628fd216ad
commit 60706c8efd
2 changed files with 151 additions and 98 deletions

View File

@ -12,6 +12,7 @@ use SilverStripe\ORM\FieldType\DBDatetime;
* In the default HTML5 mode, the field expects form submissions
* in normalised ISO 8601 format, for example 2017-04-26T23:59:59 (with a "T" separator).
* Data is passed on via {@link dataValue()} with whitespace separators.
* The {@link $value} property is always in ISO 8601 format, in the server timezone.
*/
class DatetimeField extends TextField
{
@ -31,14 +32,14 @@ class DatetimeField extends TextField
/**
* Min date time
*
* @var string ISO 8601 date time for min date time
* @var string ISO 8601 date time in server timezone
*/
protected $minDatetime = null;
/**
* Max date time
*
* @var string ISO 860 date time for max date time
* @var string ISO 860 date time in server timezone
*/
protected $maxDatetime = null;
@ -100,8 +101,8 @@ class DatetimeField extends TextField
if ($this->getHTML5()) {
$attributes['type'] = 'datetime-local';
$attributes['min'] = $this->getMinDatetime();
$attributes['max'] = $this->getMaxDatetime();
$attributes['min'] = $this->internalToFrontend($this->getMinDatetime());
$attributes['max'] = $this->internalToFrontend($this->getMaxDatetime());
}
return $attributes;
@ -117,8 +118,8 @@ class DatetimeField extends TextField
'lang' => i18n::convert_rfc1766($this->getLocale()),
'data' => array_merge($defaults['data'], [
'html5' => $this->getHTML5(),
'min' => $this->getMinDate(),
'max' => $this->getMaxDate()
'min' => $this->internalToFrontend($this->getMinDatetime()),
'max' => $this->internalToFrontend($this->getMaxDatetime())
])
]);
}
@ -177,6 +178,8 @@ class DatetimeField extends TextField
/**
* Convert frontend date to the internal representation (ISO 8601).
* The frontend date is also in ISO 8601 when $html5=true.
* Assumes the value is in the defined {@link $timezone} (if one is set),
* and adjusts for server timezone.
*
* @param string $datetime
* @return string The formatted date, or null if not a valid date
@ -284,59 +287,35 @@ class DatetimeField extends TextField
return $this;
}
/**
* Get formatter for converting to the target timezone, if timezone is set
* Can return null if no timezone set
*
* @return IntlDateFormatter|null
*/
protected function getTimezoneFormatter()
{
$timezone = $this->getTimezone();
if (!$timezone) {
return null;
}
// Build new formatter with the altered timezone
$formatter = clone $this->getInternalFormatter();
$formatter->setTimeZone($timezone);
// ISO8601 date with a standard "T" separator (W3C standard).
// Note we omit timezone from this format, and we assume server TZ always.
$formatter->setPattern(DBDatetime::ISO_DATETIME_NORMALISED);
return $formatter;
}
/**
* Get a date formatter for the ISO 8601 format
*
* @param String $timezone Optional timezone identifier (defaults to server timezone)
* @return IntlDateFormatter
*/
protected function getInternalFormatter()
protected function getInternalFormatter($timezone = null)
{
if (!$timezone) {
$timezone = date_default_timezone_get(); // Default to server timezone
}
$formatter = IntlDateFormatter::create(
i18n::config()->uninherited('default_locale'),
IntlDateFormatter::MEDIUM,
IntlDateFormatter::MEDIUM,
date_default_timezone_get() // Default to server timezone
$timezone
);
$formatter->setLenient(false);
// Note we omit timezone from this format, and we always assume server TZ
if ($this->getHTML5()) {
// ISO8601 date with a standard "T" separator (W3C standard).
$formatter->setPattern(DBDatetime::ISO_DATETIME_NORMALISED);
} else {
// ISO8601 date with a whitespace separator
$formatter->setPattern(DBDatetime::ISO_DATETIME);
}
$formatter->setPattern(DBDatetime::ISO_DATETIME);
return $formatter;
}
/**
* Assign value based on {@link $datetimeFormat}, which might be localised.
* The value needs to be in the server timezone.
*
* When $html5=true, assign value from ISO 8601 normalised string (with a "T" separator).
* Falls back to an ISO 8601 string (with a whitespace separator).
@ -393,41 +372,6 @@ class DatetimeField extends TextField
return $this->internalToFrontend($this->value);
}
/**
* Returns the field value in the internal representation (ISO 8601),
* suitable for insertion into the data object.
*
* @return string
*/
public function dataValue()
{
return $this->internalToDataValue($this->value);
}
/**
* Convert an ISO 8601 localised datetime into a saveable representation.
*
* @param string $datetime
* @return string The formatted date and time, or null if not a valid date and time
*/
public function internalToDataValue($datetime)
{
$fromFormatter = $this->getInternalFormatter();
$toFormatter = $this->getFrontendFormatter();
// Set default timezone (avoid shifting data values into user timezone)
$toFormatter->setTimezone(date_default_timezone_get());
// Send to data object without "T" separator
$toFormatter->setPattern(DBDatetime::ISO_DATETIME);
$timestamp = $fromFormatter->parse($datetime);
if ($timestamp === false) {
return null;
}
return $toFormatter->format($timestamp) ?: null;
}
/**
* Convert the internal date representation (ISO 8601) to a format used by the frontend,
* as defined by {@link $dateFormat}. With $html5=true, the frontend date will also be
@ -580,7 +524,7 @@ class DatetimeField extends TextField
}
/**
* @return string
* @return string Date in ISO 8601 format, in server timezone.
*/
public function getMinDatetime()
{
@ -588,7 +532,7 @@ class DatetimeField extends TextField
}
/**
* @param string $minDatetime A string in ISO 8601 format
* @param string $minDatetime A string in ISO 8601 format, in server timezone.
* @return $this
*/
public function setMinDatetime($minDatetime)
@ -598,7 +542,7 @@ class DatetimeField extends TextField
}
/**
* @return string
* @return string Date in ISO 8601 format, in server timezone.
*/
public function getMaxDatetime()
{
@ -606,7 +550,7 @@ class DatetimeField extends TextField
}
/**
* @param string $maxDatetime
* @param string $maxDatetime A string in ISO 8601 format, in server timezone.
* @return $this
*/
public function setMaxDatetime($maxDatetime)
@ -639,7 +583,7 @@ class DatetimeField extends TextField
return false;
}
// Check min date
// Check min date (in server timezone)
$min = $this->getMinDatetime();
if ($min) {
$oops = strtotime($this->value) < strtotime($min);
@ -656,7 +600,7 @@ class DatetimeField extends TextField
}
}
// Check max date
// Check max date (in server timezone)
$max = $this->getMaxDatetime();
if ($max) {
$oops = strtotime($this->value) > strtotime($max);
@ -700,9 +644,9 @@ class DatetimeField extends TextField
if ($this->value && $timezone !== $this->timezone) {
throw new \BadMethodCallException("Can't change timezone after setting a value");
}
// Note: DateField has no timezone option, and TimeField::setTimezone
// should be ignored
$this->timezone = $timezone;
return $this;
}
}

View File

@ -74,6 +74,17 @@ class DatetimeFieldTest extends SapphireTest
$this->assertEquals('2003-03-29 23:59:38', $f->dataValue(), 'From date/time string');
}
public function testDataValueWithTimezone()
{
// Berlin and Auckland have 12h time difference in northern hemisphere winter
date_default_timezone_set('Europe/Berlin');
$f = new DatetimeField('Datetime');
$f->setTimezone('Pacific/Auckland');
$f->setSubmittedValue('2003-01-30T23:59:38'); // frontend timezone (Auckland)
$this->assertEquals('2003-01-30 11:59:38', $f->dataValue()); // server timezone (Berlin)
}
public function testConstructorWithoutArgs()
{
$f = new DatetimeField('Datetime');
@ -97,11 +108,11 @@ class DatetimeFieldTest extends SapphireTest
{
$f = new DatetimeField('Datetime', 'Datetime');
$f->setValue('2003-03-29 23:59:38');
$this->assertEquals($f->dataValue(), '2003-03-29 23:59:38');
$this->assertEquals($f->dataValue(), '2003-03-29 23:59:38', 'Accepts ISO');
$f = new DatetimeField('Datetime', 'Datetime');
$f->setValue('2003-03-29T23:59:38');
$this->assertEquals($f->dataValue(), '2003-03-29 23:59:38', 'Normalised ISO');
$this->assertNull($f->dataValue(), 'Rejects normalised ISO');
}
public function testSubmittedValue()
@ -140,13 +151,13 @@ class DatetimeFieldTest extends SapphireTest
$this->assertTrue($f->validate(new RequiredFields()));
$f = new DatetimeField('Datetime', 'Datetime', '2003-03-29T23:59:38');
$this->assertTrue($f->validate(new RequiredFields()), 'Normalised ISO');
$this->assertFalse($f->validate(new RequiredFields()), 'Normalised ISO');
$f = new DatetimeField('Datetime', 'Datetime', '2003-03-29 00:00:00');
$this->assertTrue($f->validate(new RequiredFields()));
$f = new DatetimeField('Datetime', 'Datetime', '2003-03-29');
$this->assertFalse($f->validate(new RequiredFields()), 'Leaving out time');
$f = (new DatetimeField('Datetime', 'Datetime'))
->setSubmittedValue('2003-03-29 00:00');
->setSubmittedValue('2003-03-29T00:00');
$this->assertTrue($f->validate(new RequiredFields()), 'Leaving out seconds (like many browsers)');
$f = new DatetimeField('Datetime', 'Datetime', 'wrong');
@ -156,10 +167,10 @@ class DatetimeFieldTest extends SapphireTest
public function testSetMinDate()
{
$f = (new DatetimeField('Datetime'))->setMinDatetime('2009-03-31T23:00:00');
$this->assertEquals($f->getMinDatetime(), '2009-03-31T23:00:00', 'Retains normalised ISO');
$this->assertEquals($f->getMinDatetime(), '2009-03-31 23:00:00', 'Retains ISO');
$f = (new DatetimeField('Datetime'))->setMinDatetime('2009-03-31 23:00:00');
$this->assertEquals($f->getMinDatetime(), '2009-03-31T23:00:00', 'Converts ISO to normalised ISO');
$this->assertEquals($f->getMinDatetime(), '2009-03-31 23:00:00', 'Converts normalised ISO to ISO');
$f = (new DatetimeField('Datetime'))->setMinDatetime('invalid');
$this->assertNull($f->getMinDatetime(), 'Ignores invalid values');
@ -168,10 +179,10 @@ class DatetimeFieldTest extends SapphireTest
public function testSetMaxDate()
{
$f = (new DatetimeField('Datetime'))->setMaxDatetime('2009-03-31T23:00:00');
$this->assertEquals($f->getMaxDatetime(), '2009-03-31T23:00:00', 'Retains normalised ISO');
$this->assertEquals($f->getMaxDatetime(), '2009-03-31 23:00:00', 'Retains ISO');
$f = (new DatetimeField('Datetime'))->setMaxDatetime('2009-03-31 23:00:00');
$this->assertEquals($f->getMaxDatetime(), '2009-03-31T23:00:00', 'Converts ISO to normalised ISO');
$this->assertEquals($f->getMaxDatetime(), '2009-03-31 23:00:00', 'Converts normalised ISO to ISO');
$f = (new DatetimeField('Datetime'))->setMaxDatetime('invalid');
$this->assertNull($f->getMaxDatetime(), 'Ignores invalid values');
@ -198,11 +209,36 @@ class DatetimeFieldTest extends SapphireTest
$dateField->setMinDatetime('2009-03-31 23:00:00');
$dateField->setValue('2008-03-31 23:00:00');
$this->assertFalse($dateField->validate(new RequiredFields()), 'Date below min datetime');
}
public function testValidateMinDateWithSubmittedValueAndTimezone()
{
// Berlin and Auckland have 12h time difference in northern hemisphere winter
date_default_timezone_set('Europe/Berlin');
$dateField = new DatetimeField('Datetime');
$dateField->setMinDatetime('2009-03-31T23:00:00');
$dateField->setValue('2009-03-31T23:00:01');
$this->assertTrue($dateField->validate(new RequiredFields()), 'Time above min datetime with normalised ISO');
$dateField->setTimezone('Pacific/Auckland');
$dateField->setMinDatetime('2009-01-30 23:00:00'); // server timezone (Berlin)
$dateField->setSubmittedValue('2009-01-31T11:00:01'); // frontend timezone (Auckland)
$this->assertTrue($dateField->validate(new RequiredFields()), 'Time above min datetime');
$dateField = new DatetimeField('Datetime');
$dateField->setTimezone('Pacific/Auckland');
$dateField->setMinDatetime('2009-01-30 23:00:00');
$dateField->setSubmittedValue('2009-01-31T10:00:00');
$this->assertFalse($dateField->validate(new RequiredFields()), 'Time below min datetime');
$dateField = new DatetimeField('Datetime');
$dateField->setTimezone('Pacific/Auckland');
$dateField->setMinDatetime('2009-01-30 23:00:00');
$dateField->setSubmittedValue('2009-01-31T11:00:00');
$this->assertTrue($dateField->validate(new RequiredFields()), 'Date and time matching min datetime');
$dateField = new DatetimeField('Datetime');
$dateField->setTimezone('Pacific/Auckland');
$dateField->setMinDatetime('2009-01-30 23:00:00');
$dateField->setSubmittedValue('2008-01-31T11:00:00');
$this->assertFalse($dateField->validate(new RequiredFields()), 'Date below min datetime');
}
public function testValidateMinDateStrtotime()
@ -252,11 +288,36 @@ class DatetimeFieldTest extends SapphireTest
$f->setMaxDatetime('2009-03-31 23:00:00');
$f->setValue('2010-03-31 23:00:00');
$this->assertFalse($f->validate(new RequiredFields()), 'Date above max datetime');
}
public function testValidateMaxDateWithSubmittedValueAndTimezone()
{
// Berlin and Auckland have 12h time difference in northern hemisphere winter
date_default_timezone_set('Europe/Berlin');
$f = new DatetimeField('Datetime');
$f->setMaxDatetime('2009-03-31T23:00:00');
$f->setValue('2009-03-31T22:00:00');
$this->assertTrue($f->validate(new RequiredFields()), 'Time below max datetime with normalised ISO');
$f->setTimezone('Pacific/Auckland');
$f->setMaxDatetime('2009-01-31 23:00:00'); // server timezone (Berlin)
$f->setSubmittedValue('2009-01-31T10:00:00'); // frontend timezone (Auckland)
$this->assertTrue($f->validate(new RequiredFields()), 'Time below max datetime');
$f = new DatetimeField('Datetime');
$f->setTimezone('Pacific/Auckland');
$f->setMaxDatetime('2009-01-31 23:00:00');
$f->setSubmittedValue('2010-01-31T11:00:01');
$this->assertFalse($f->validate(new RequiredFields()), 'Time above max datetime');
$f = new DatetimeField('Datetime');
$f->setTimezone('Pacific/Auckland');
$f->setMaxDatetime('2009-01-31 23:00:00');
$f->setSubmittedValue('2009-01-31T11:00:00');
$this->assertTrue($f->validate(new RequiredFields()), 'Date and time matching max datetime');
$f = new DatetimeField('Datetime');
$f->setTimezone('Pacific/Auckland');
$f->setMaxDatetime('2009-01-31 23:00:00');
$f->setSubmittedValue('2010-01-31T11:00:00');
$this->assertFalse($f->validate(new RequiredFields()), 'Date above max datetime');
}
public function testTimezoneSetValueLocalised()
@ -336,6 +397,54 @@ class DatetimeFieldTest extends SapphireTest
$this->assertEquals('CustomDatetime', $field->getName());
}
public function testSchemaDataDefaultsIncludesMinMax()
{
$field = new DatetimeField('Datetime');
$field->setMinDatetime('2009-03-31 23:00:00');
$field->setMaxDatetime('2010-03-31 23:00:00');
$defaults = $field->getSchemaDataDefaults();
$this->assertEquals($defaults['data']['min'], '2009-03-31T23:00:00');
$this->assertEquals($defaults['data']['max'], '2010-03-31T23:00:00');
}
public function testSchemaDataDefaultsAdjustsMinMaxToTimezone()
{
date_default_timezone_set('Europe/Berlin');
// Berlin and Auckland have 12h time difference in northern hemisphere summer, but Berlin and Moscow only 2h.
$field = new DatetimeField('Datetime');
$field->setTimezone('Pacific/Auckland');
$field->setMinDatetime('2009-01-31 11:00:00'); // server timezone
$field->setMaxDatetime('2010-01-31 11:00:00'); // server timezone
$defaults = $field->getSchemaDataDefaults();
$this->assertEquals($defaults['data']['min'], '2009-01-31T23:00:00'); // frontend timezone
$this->assertEquals($defaults['data']['max'], '2010-01-31T23:00:00'); // frontend timezone
}
public function testAttributesIncludesMinMax()
{
$field = new DatetimeField('Datetime');
$field->setMinDatetime('2009-03-31 23:00:00');
$field->setMaxDatetime('2010-03-31 23:00:00');
$attrs = $field->getAttributes();
$this->assertEquals($attrs['min'], '2009-03-31T23:00:00');
$this->assertEquals($attrs['max'], '2010-03-31T23:00:00');
}
public function testAttributesAdjustsMinMaxToTimezone()
{
date_default_timezone_set('Europe/Berlin');
// Berlin and Auckland have 12h time difference in northern hemisphere summer, but Berlin and Moscow only 2h.
$field = new DatetimeField('Datetime');
$field->setTimezone('Pacific/Auckland');
$field->setMinDatetime('2009-01-31 11:00:00'); // server timezone
$field->setMaxDatetime('2010-01-31 11:00:00'); // server timezone
$attrs = $field->getAttributes();
$this->assertEquals($attrs['min'], '2009-01-31T23:00:00'); // frontend timezone
$this->assertEquals($attrs['max'], '2010-01-31T23:00:00'); // frontend timezone
}
protected function getMockForm()
{
/** @skipUpgrade */