diff --git a/src/Forms/DatetimeField.php b/src/Forms/DatetimeField.php index a5520bdc6..03da92f68 100644 --- a/src/Forms/DatetimeField.php +++ b/src/Forms/DatetimeField.php @@ -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; } } diff --git a/tests/php/Forms/DatetimeFieldTest.php b/tests/php/Forms/DatetimeFieldTest.php index 9d33b39f3..e8bcdcc69 100644 --- a/tests/php/Forms/DatetimeFieldTest.php +++ b/tests/php/Forms/DatetimeFieldTest.php @@ -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 */