diff --git a/docs/en/04_Changelogs/4.0.0.md b/docs/en/04_Changelogs/4.0.0.md index c0266346a..ea1a357bc 100644 --- a/docs/en/04_Changelogs/4.0.0.md +++ b/docs/en/04_Changelogs/4.0.0.md @@ -1397,6 +1397,7 @@ The below methods have been added or had their functionality updated to `DBDate` * `getFormatter` method added, which returns a locale-specific date/time formatter. `DBTime` specific changes: + * Added `DBTime::FormatFromSettings` #### ORM Removed API @@ -1670,6 +1671,17 @@ New `DatetimeField` methods replace `getConfig()` / `setConfig()`: * `getLocale()` / `setLocale()` * `datavaluefield` config is removed as internal data value is now fixed to ISO 8601 format +The `DatetimeField` has changed behaviour: + +* It uses a combined input instead of a composite from `DateField` and `TimeField` + Consequently, `getDateField()` and `getTimeField()` have been removed. +* It returns [ISO 8601 normalised dates](https://html.spec.whatwg.org/multipage/infrastructure.html#local-dates-and-times) + by default in `Value()`, which include a "T" separator between date and time. + This is required to allow HTML5 input. Either use `setHTML5(false)` to set your custom format, + or use `dataValue()` to retrieve a whitespace separated representation. +* It no longer accepts `setValue()` as an array with 'date' and 'time' keys +* Added `getHTML5()` / `setHTML5()` + New `DateField` methods replace `getConfig()` / `setConfig()`: * `getDateFormat()` / `setDateFormat()` diff --git a/lang/en.yml b/lang/en.yml index 58f12af3e..384af5f54 100644 --- a/lang/en.yml +++ b/lang/en.yml @@ -57,6 +57,10 @@ en: VALIDDATEFORMAT2: 'Please enter a valid date format ({format})' VALIDDATEMAXDATE: 'Your date has to be older or matching the maximum allowed date ({date})' VALIDDATEMINDATE: 'Your date has to be newer or matching the minimum allowed date ({date})' + DatetimeField: + VALIDDATETIMEFORMAT: 'Please enter a valid date and time format ({format})' + VALIDDATEMAXDATETIME: 'Your date has to be older or matching the maximum allowed date and time ({datetime})' + VALIDDATEMINDATETIME: 'Your date has to be newer or matching the minimum allowed date and time ({datetime})' Director: INVALID_REQUEST: 'Invalid request' DropdownField: diff --git a/src/Forms/DateField.php b/src/Forms/DateField.php index b3da3be06..8ed22d84c 100644 --- a/src/Forms/DateField.php +++ b/src/Forms/DateField.php @@ -7,6 +7,7 @@ use SilverStripe\i18n\i18n; use InvalidArgumentException; use SilverStripe\ORM\FieldType\DBDate; use SilverStripe\ORM\FieldType\DBDatetime; +use SilverStripe\ORM\ValidationResult; /** * Form used for editing a date stirng @@ -194,7 +195,7 @@ class DateField extends TextField } // Get from locale - return $this->getFormatter()->getPattern(); + return $this->getFrontendFormatter()->getPattern(); } /** @@ -217,7 +218,7 @@ class DateField extends TextField * @throws \LogicException * @return IntlDateFormatter */ - protected function getFormatter() + protected function getFrontendFormatter() { if ($this->getHTML5() && $this->dateFormat && $this->dateFormat !== DBDate::ISO_DATE) { throw new \LogicException( @@ -261,7 +262,7 @@ class DateField extends TextField * * @return IntlDateFormatter */ - protected function getISO8601Formatter() + protected function getInternalFormatter() { $locale = i18n::config()->uninherited('default_locale'); $formatter = IntlDateFormatter::create( @@ -290,6 +291,19 @@ class DateField extends TextField return $attributes; } + public function getSchemaDataDefaults() + { + $defaults = parent::getSchemaDataDefaults(); + return array_merge($defaults, [ + 'lang' => i18n::convert_rfc1766($this->getLocale()), + 'data' => array_merge($defaults['data'], [ + 'html5' => $this->getHTML5(), + 'min' => $this->getMinDate(), + 'max' => $this->getMaxDate() + ]) + ]); + } + public function Type() { return 'date text'; @@ -314,10 +328,19 @@ class DateField extends TextField } // Parse from submitted value - $this->value = $this->localisedToISO8601($value); + $this->value = $this->frontendToInternal($value); return $this; } + /** + * Assign value based on {@link $datetimeFormat}, which might be localised. + * + * When $html5=true, assign value from ISO 8601 string. + * + * @param mixed $value + * @param mixed $data + * @return $this + */ public function setValue($value, $data = null) { // Save raw value for later validation @@ -329,18 +352,14 @@ class DateField extends TextField return $this; } - if (is_array($value)) { - throw new InvalidArgumentException("Use setSubmittedValue to assign by array"); - } - // Re-run through formatter to tidy up (e.g. remove time component) - $this->value = $this->tidyISO8601($value); + $this->value = $this->tidyInternal($value); return $this; } public function Value() { - return $this->iso8601ToLocalised($this->value); + return $this->internalToFrontend($this->value); } public function performReadonlyTransformation() @@ -385,8 +404,16 @@ class DateField extends TextField _t( 'DateField.VALIDDATEMINDATE', "Your date has to be newer or matching the minimum allowed date ({date})", - ['date' => $this->iso8601ToLocalised($min)] - ) + [ + 'date' => sprintf( + '', + $min, + $this->internalToFrontend($min) + ) + ] + ), + ValidationResult::TYPE_ERROR, + ValidationResult::CAST_HTML ); return false; } @@ -402,8 +429,16 @@ class DateField extends TextField _t( 'DateField.VALIDDATEMAXDATE', "Your date has to be older or matching the maximum allowed date ({date})", - ['date' => $this->iso8601ToLocalised($max)] - ) + [ + 'date' => sprintf( + '', + $max, + $this->internalToFrontend($max) + ) + ] + ), + ValidationResult::TYPE_ERROR, + ValidationResult::CAST_HTML ); return false; } @@ -457,7 +492,7 @@ class DateField extends TextField */ public function setMinDate($minDate) { - $this->minDate = $this->tidyISO8601($minDate); + $this->minDate = $this->tidyInternal($minDate); return $this; } @@ -475,23 +510,24 @@ class DateField extends TextField */ public function setMaxDate($maxDate) { - $this->maxDate = $this->tidyISO8601($maxDate); + $this->maxDate = $this->tidyInternal($maxDate); return $this; } /** - * Convert date localised in the current locale to ISO 8601 date + * Convert frontend date to the internal representation (ISO 8601). + * The frontend date is also in ISO 8601 when $html5=true. * * @param string $date * @return string The formatted date, or null if not a valid date */ - public function localisedToISO8601($date) + protected function frontendToInternal($date) { if (!$date) { return null; } - $fromFormatter = $this->getFormatter(); - $toFormatter = $this->getISO8601Formatter(); + $fromFormatter = $this->getFrontendFormatter(); + $toFormatter = $this->getInternalFormatter(); $timestamp = $fromFormatter->parse($date); if ($timestamp === false) { return null; @@ -500,20 +536,21 @@ class DateField extends TextField } /** - * Convert an ISO 8601 localised date into the format specified by the - * current date format. + * 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 + * in ISO 8601. * * @param string $date * @return string The formatted date, or null if not a valid date */ - public function iso8601ToLocalised($date) + protected function internalToFrontend($date) { - $date = $this->tidyISO8601($date); + $date = $this->tidyInternal($date); if (!$date) { return null; } - $fromFormatter = $this->getISO8601Formatter(); - $toFormatter = $this->getFormatter(); + $fromFormatter = $this->getInternalFormatter(); + $toFormatter = $this->getFrontendFormatter(); $timestamp = $fromFormatter->parse($date); if ($timestamp === false) { return null; @@ -522,18 +559,19 @@ class DateField extends TextField } /** - * Tidy up iso8601-ish date, or approximation + * Tidy up the internal date representation (ISO 8601), + * and fall back to strtotime() if there's parsing errors. * - * @param string $date Date in iso8601 or approximate form - * @return string iso8601 date, or null if not valid + * @param string $date Date in ISO 8601 or approximate form + * @return string ISO 8601 date, or null if not valid */ - public function tidyISO8601($date) + protected function tidyInternal($date) { if (!$date) { return null; } // Re-run through formatter to tidy up (e.g. remove time component) - $formatter = $this->getISO8601Formatter(); + $formatter = $this->getInternalFormatter(); $timestamp = $formatter->parse($date); if ($timestamp === false) { // Fallback to strtotime diff --git a/src/Forms/DatetimeField.php b/src/Forms/DatetimeField.php index 0ae8b65c6..02295b319 100644 --- a/src/Forms/DatetimeField.php +++ b/src/Forms/DatetimeField.php @@ -5,156 +5,314 @@ namespace SilverStripe\Forms; use IntlDateFormatter; use InvalidArgumentException; use SilverStripe\i18n\i18n; +use SilverStripe\ORM\FieldType\DBDatetime; +use SilverStripe\ORM\ValidationResult; /** - * A composite field for date and time entry, - * based on {@link DateField} and {@link TimeField}. - * Usually saves into a single {@link DBDateTime} database column. - * If you want to save into {@link Date} or {@link Time} columns, - * please instanciate the fields separately. - * - * This field does not implement the HTML5 field, - * but can use date and time HTML5 inputs separately (through {@link DateField->setHTML5()} - * and {@link TimeField->setHTML5()}. - * - * # Configuration - * - * Individual options are configured either on the DatetimeField, or on individual - * sub-fields accessed via getDateField() or getTimeField() - * - * Example: - * - * $field = new DatetimeField('Name', 'Label'); - * $field->getDateField()->setTitle('Select Date'); - * - * - * - setLocale(): Sets a custom locale for date / time formatting. - * - setTimezone(): Set a different timezone for viewing. {@link dataValue()} will still save - * the time in PHP's default timezone (date_default_timezone_get()), its only a view setting. - * Note that the sub-fields ({@link getDateField()} and {@link getTimeField()}) - * are not timezone aware, and will have their values set in local time, rather than server time. - * - setDateTimeOrder(): An sprintf() template to determine in which order the date and time values will - * be combined. This is necessary as those separate formats are set in their invididual fields. + * Form field used for editing date time strings. + * 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 FormField +class DatetimeField extends TextField { /** - * @var DateField + * @var bool */ - protected $dateField = null; + protected $html5 = true; /** - * @var TimeField - */ - protected $timeField = null; - - protected $schemaDataType = FormField::SCHEMA_DATA_TYPE_DATETIME; - - /** - * Date time order + * Override locale. If empty will default to current locale * * @var string */ - protected $dateTimeOrder = '{date} {time}'; + protected $locale = null; - public function __construct($name, $title = null, $value = "") + /** + * Min date time + * + * @var string ISO 8601 date time in server timezone + */ + protected $minDatetime = null; + + /** + * Max date time + * + * @var string ISO 860 date time in server timezone + */ + protected $maxDatetime = null; + + /** + * Override date format. If empty will default to that used by the current locale. + * + * @var null + */ + protected $datetimeFormat = null; + + /** + * Length of this date (full, short, etc). + * + * @see http://php.net/manual/en/class.intldateformatter.php#intl.intldateformatter-constants + * @var int + */ + protected $dateLength = null; + + /** + * Length of this time (full, short, etc). + * + * @see http://php.net/manual/en/class.intldateformatter.php#intl.intldateformatter-constants + * @var int + */ + protected $timeLength = null; + + /** + * Unparsed value, used exclusively for comparing with internal value + * to detect invalid values. + * + * @var mixed + */ + protected $rawValue = null; + + /** + * @inheritDoc + */ + protected $schemaDataType = FormField::SCHEMA_DATA_TYPE_DATETIME; + + /** + * Custom timezone + * + * @var string + */ + protected $timezone = null; + + public function getAttributes() { - $this->timeField = TimeField::create($name . '[time]', false); - $this->dateField = DateField::create($name . '[date]', false); - parent::__construct($name, $title, $value); + $attributes = parent::getAttributes(); + + $attributes['lang'] = i18n::convert_rfc1766($this->getLocale()); + + if ($this->getHTML5()) { + $attributes['type'] = 'datetime-local'; + $attributes['min'] = $this->internalToFrontend($this->getMinDatetime()); + $attributes['max'] = $this->internalToFrontend($this->getMaxDatetime()); + } + + return $attributes; } - public function setForm($form) + /** + * @inheritDoc + */ + public function getSchemaDataDefaults() { - parent::setForm($form); - $this->dateField->setForm($form); - $this->timeField->setForm($form); - return $this; + $defaults = parent::getSchemaDataDefaults(); + return array_merge($defaults, [ + 'lang' => i18n::convert_rfc1766($this->getLocale()), + 'data' => array_merge($defaults['data'], [ + 'html5' => $this->getHTML5(), + 'min' => $this->internalToFrontend($this->getMinDatetime()), + 'max' => $this->internalToFrontend($this->getMaxDatetime()) + ]) + ]); } - public function setName($name) + /** + * @inheritDoc + */ + public function Type() { - parent::setName($name); - $this->dateField->setName($name . '[date]'); - $this->timeField->setName($name . '[time]'); + return 'text datetime'; + } + + /** + * @return bool + */ + public function getHTML5() + { + return $this->html5; + } + + /** + * @param $bool + * @return $this + */ + public function setHTML5($bool) + { + $this->html5 = $bool; return $this; } /** - * Sets value from a submitted form array + * Assign value posted from form submission, based on {@link $datetimeFormat}. + * When $html5=true, this needs to be normalised ISO format (with "T" separator). * - * @param array $value Expected submission value is either an empty value, - * or an array with the necessary components keyed against 'date' and 'time', each value - * localised according to each's localisation setting. + * @param mixed $value * @param mixed $data * @return $this */ public function setSubmittedValue($value, $data = null) { - // Empty value - if (empty($value)) { + // Save raw value for later validation + $this->rawValue = $value; + + // Null case + if (!$value) { $this->value = null; - $this->dateField->setValue(null); - $this->timeField->setValue(null); return $this; } - // Validate value is submitted in array format - if (!is_array($value)) { - throw new InvalidArgumentException("Value is not submitted array"); - } + // Parse from submitted value + $this->value = $this->frontendToInternal($value); - // Save each field, and convert from array to iso8601 string - $this->dateField->setSubmittedValue($value['date'], $value); - $this->timeField->setSubmittedValue($value['time'], $value); - - // Combine date components back into iso8601 string for the root value - $this->value = $this->dataValue(); return $this; } /** - * Get formatter for converting to the target timezone, if timezone is set - * Can return null if no timezone set + * 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. * - * @return IntlDateFormatter|null + * @param string $datetime + * @return string The formatted date, or null if not a valid date */ - protected function getTimezoneFormatter() + public function frontendToInternal($datetime) { - $timezone = $this->getTimezone(); - if (!$timezone) { + if (!$datetime) { return null; } + $fromFormatter = $this->getFrontendFormatter(); + $toFormatter = $this->getInternalFormatter(); - // Build new formatter with the altered timezone - $formatter = clone $this->getISO8601Formatter(); - $formatter->setTimeZone($timezone); + // Try to parse time with seconds + $timestamp = $fromFormatter->parse($datetime); + + // Try to parse time without seconds, since that's a valid HTML5 submission format + // See https://html.spec.whatwg.org/multipage/infrastructure.html#times + if ($timestamp === false && $this->getHTML5()) { + $fromFormatter->setPattern(str_replace(':ss', '', $fromFormatter->getPattern())); + $timestamp = $fromFormatter->parse($datetime); + } + + if ($timestamp === false) { + return null; + } + return $toFormatter->format($timestamp) ?: null; + } + + /** + * Get date formatter with the standard locale / date format + * + * @throws \LogicException + * @return IntlDateFormatter + */ + protected function getFrontendFormatter() + { + if ($this->getHTML5() && $this->datetimeFormat && $this->datetimeFormat !== DBDatetime::ISO_DATETIME_NORMALISED) { + throw new \LogicException( + 'Please opt-out of HTML5 processing of ISO 8601 dates via setHTML5(false) if using setDatetimeFormat()' + ); + } + + if ($this->getHTML5() && $this->dateLength) { + throw new \LogicException( + 'Please opt-out of HTML5 processing of ISO 8601 dates via setHTML5(false) if using setDateLength()' + ); + } + + if ($this->getHTML5() && $this->locale) { + throw new \LogicException( + 'Please opt-out of HTML5 processing of ISO 8601 dates via setHTML5(false) if using setLocale()' + ); + } + + $formatter = IntlDateFormatter::create( + $this->getLocale(), + $this->getDateLength(), + $this->getTimeLength(), + $this->getTimezone() + ); + + if ($this->getHTML5()) { + // Browsers expect ISO 8601 dates, localisation is handled on the client. + // Add 'T' date and time separator to create W3C compliant format + $formatter->setPattern(DBDatetime::ISO_DATETIME_NORMALISED); + } elseif ($this->datetimeFormat) { + // Don't invoke getDatetimeFormat() directly to avoid infinite loop + $ok = $formatter->setPattern($this->datetimeFormat); + if (!$ok) { + throw new InvalidArgumentException("Invalid date format {$this->datetimeFormat}"); + } + } return $formatter; } + /** + * Get date format in CLDR standard format + * + * This can be set explicitly. If not, this will be generated from the current locale + * with the current date length. + * + * @see http://userguide.icu-project.org/formatparse/datetime#TOC-Date-Field-Symbol-Table + */ + public function getDatetimeFormat() + { + if ($this->datetimeFormat) { + return $this->datetimeFormat; + } + + // Get from locale + return $this->getFrontendFormatter()->getPattern(); + } + + /** + * Set date format in CLDR standard format. + * Only applicable with {@link setHTML5(false)}. + * + * @see http://userguide.icu-project.org/formatparse/datetime#TOC-Date-Field-Symbol-Table + * @param string $format + * @return $this + */ + public function setDatetimeFormat($format) + { + $this->datetimeFormat = $format; + return $this; + } + /** * Get a date formatter for the ISO 8601 format * + * @param String $timezone Optional timezone identifier (defaults to server timezone) * @return IntlDateFormatter */ - protected function getISO8601Formatter() + 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); - // CLDR iso8601 date. - // Note we omit timezone from this format, and we assume server TZ always. - $formatter->setPattern('y-MM-dd HH:mm:ss'); + + // Note we omit timezone from this format, and we always assume server TZ + $formatter->setPattern(DBDatetime::ISO_DATETIME); + return $formatter; } /** - * Assign value from iso8601 string + * 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). * * @param mixed $value * @param mixed $data @@ -162,170 +320,181 @@ class DatetimeField extends FormField */ public function setValue($value, $data = null) { + // Save raw value for later validation + $this->rawValue = $value; + // Empty value if (empty($value)) { $this->value = null; - $this->dateField->setValue(null); - $this->timeField->setValue(null); return $this; } - if (is_array($value)) { - throw new InvalidArgumentException("Use setSubmittedValue to assign by array"); - }; // Validate iso 8601 date // If invalid, assign for later validation failure - $isoFormatter = $this->getISO8601Formatter(); - $timestamp = $isoFormatter->parse($value); + $internalFormatter = $this->getInternalFormatter(); + $timestamp = $internalFormatter->parse($value); + + // Retry without "T" separator + if (!$timestamp) { + $fallbackFormatter = $this->getInternalFormatter(); + $fallbackFormatter->setPattern(DBDatetime::ISO_DATETIME); + $timestamp = $fallbackFormatter->parse($value); + } + if ($timestamp === false) { - $this->dateField->setSubmittedValue($value); - $this->timeField->setValue(null); return $this; } // Cleanup date - $value = $isoFormatter->format($timestamp); + $value = $internalFormatter->format($timestamp); // Save value $this->value = $value; - // Shift iso date into timezone before assignment to subfields - $timezoneFormatter = $this->getTimezoneFormatter(); - if ($timezoneFormatter) { - $value = $timezoneFormatter->format($timestamp); - } - - // Set date / time components, which are unaware of their timezone - list($date, $time) = explode(' ', $value); - $this->dateField->setValue($date, $data); - $this->timeField->setValue($time, $data); return $this; } /** - * localised time value + * Returns the frontend representation of the field value, + * according to the defined {@link dateFormat}. + * With $html5=true, this will be in ISO 8601 format. * * @return string */ public function Value() { - $date = $this->dateField->Value(); - $time = $this->timeField->Value(); - return $this->joinDateTime($date, $time); + return $this->internalToFrontend($this->value); } /** - * @param string $date - * @param string $time - * @return string - */ - protected function joinDateTime($date, $time) - { - $format = $this->getDateTimeOrder(); - return strtr($format, [ - '{date}' => $date, - '{time}' => $time - ]); - } - - /** - * Get ISO8601 formatted string in the local server timezone + * 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 + * in ISO 8601. * - * @return string|null + * @param string $datetime + * @return string The formatted date and time, or null if not a valid date and time */ - public function dataValue() + public function internalToFrontend($datetime) { - // No date means no value (even if time is specified) - $dateDataValue = $this->getDateField()->dataValue(); - if (empty($dateDataValue)) { + $datetime = $this->tidyInternal($datetime); + if (!$datetime) { + return null; + } + $fromFormatter = $this->getInternalFormatter(); + $toFormatter = $this->getFrontendFormatter(); + $timestamp = $fromFormatter->parse($datetime); + if ($timestamp === false) { return null; } - // Build iso8601 timestamp from combined date and time - $timeDataValue = $this->getTimeField()->dataValue() ?: '00:00:00'; - $value = $dateDataValue . ' ' . $timeDataValue; + return $toFormatter->format($timestamp) ?: null; + } - // If necessary, convert timezone - $timezoneFormatter = $this->getTimezoneFormatter(); - if ($timezoneFormatter) { - $timestamp = $timezoneFormatter->parse($value); - $isoFormatter = $this->getISO8601Formatter(); - $value = $isoFormatter->format($timestamp); + /** + * Tidy up the internal date representation (ISO 8601), + * and fall back to strtotime() if there's parsing errors. + * + * @param string $date Date in ISO 8601 or approximate form + * @return string ISO 8601 date, or null if not valid + */ + public function tidyInternal($datetime) + { + if (!$datetime) { + return null; } + // Re-run through formatter to tidy up (e.g. remove time component) + $formatter = $this->getInternalFormatter(); + $timestamp = $formatter->parse($datetime); + if ($timestamp === false) { + // Fallback to strtotime + $timestamp = strtotime($datetime, DBDatetime::now()->getTimestamp()); + if ($timestamp === false) { + return null; + } + } + return $formatter->format($timestamp); + } - return $value; + /** + * Get length of the date format to use. One of: + * + * - IntlDateFormatter::SHORT + * - IntlDateFormatter::MEDIUM + * - IntlDateFormatter::LONG + * - IntlDateFormatter::FULL + * + * @see http://php.net/manual/en/class.intldateformatter.php#intl.intldateformatter-constants + * @return int + */ + public function getDateLength() + { + if ($this->dateLength) { + return $this->dateLength; + } + return IntlDateFormatter::MEDIUM; + } + + /** + * Get length of the date format to use. + * Only applicable with {@link setHTML5(false)}. + * + * @see http://php.net/manual/en/class.intldateformatter.php#intl.intldateformatter-constants + * + * @param int $length + * @return $this + */ + public function setDateLength($length) + { + $this->dateLength = $length; + return $this; + } + + /** + * Get length of the date format to use. One of: + * + * - IntlDateFormatter::SHORT + * - IntlDateFormatter::MEDIUM + * - IntlDateFormatter::LONG + * - IntlDateFormatter::FULL + * + * @see http://php.net/manual/en/class.intldateformatter.php#intl.intldateformatter-constants + * @return int + */ + public function getTimeLength() + { + if ($this->timeLength) { + return $this->timeLength; + } + return IntlDateFormatter::MEDIUM; + } + + /** + * Get length of the date format to use. + * Only applicable with {@link setHTML5(false)}. + * + * @see http://php.net/manual/en/class.intldateformatter.php#intl.intldateformatter-constants + * + * @param int $length + * @return $this + */ + public function setTimeLength($length) + { + $this->timeLength = $length; + return $this; } public function setDisabled($bool) { parent::setDisabled($bool); - $this->dateField->setDisabled($bool); - $this->timeField->setDisabled($bool); return $this; } public function setReadonly($bool) { parent::setReadonly($bool); - $this->dateField->setReadonly($bool); - $this->timeField->setReadonly($bool); return $this; } - /** - * @return DateField - */ - public function getDateField() - { - return $this->dateField; - } - - /** - * @param FormField $field - */ - public function setDateField($field) - { - $expected = $this->getName() . '[date]'; - if ($field->getName() != $expected) { - throw new InvalidArgumentException(sprintf( - 'Wrong name format for date field: "%s" (expected "%s")', - $field->getName(), - $expected - )); - } - - $field->setForm($this->getForm()); - $field->setValue($this->dateField->dataValue()); - $this->dateField = $field; - } - - /** - * @return TimeField - */ - public function getTimeField() - { - return $this->timeField; - } - - /** - * @param FormField $field - */ - public function setTimeField($field) - { - $expected = $this->getName() . '[time]'; - if ($field->getName() != $expected) { - throw new InvalidArgumentException(sprintf( - 'Wrong name format for time field: "%s" (expected "%s")', - $field->getName(), - $expected - )); - } - - $field->setForm($this->getForm()); - $field->setValue($this->timeField->dataValue()); - $this->timeField = $field; - } - /** * Set default locale for this field. If omitted will default to the current locale. * @@ -334,8 +503,7 @@ class DatetimeField extends FormField */ public function setLocale($locale) { - $this->dateField->setLocale($locale); - $this->timeField->setLocale($locale); + $this->locale = $locale; return $this; } @@ -346,16 +514,120 @@ class DatetimeField extends FormField */ public function getLocale() { - return $this->dateField->getLocale(); + return $this->locale ?: i18n::get_locale(); } + /** + * @return string Date in ISO 8601 format, in server timezone. + */ + public function getMinDatetime() + { + return $this->minDatetime; + } + + /** + * @param string $minDatetime A string in ISO 8601 format, in server timezone. + * @return $this + */ + public function setMinDatetime($minDatetime) + { + $this->minDatetime = $this->tidyInternal($minDatetime); + return $this; + } + + /** + * @return string Date in ISO 8601 format, in server timezone. + */ + public function getMaxDatetime() + { + return $this->maxDatetime; + } + + /** + * @param string $maxDatetime A string in ISO 8601 format, in server timezone. + * @return $this + */ + public function setMaxDatetime($maxDatetime) + { + $this->maxDatetime = $this->tidyInternal($maxDatetime); + return $this; + } + + /** + * @param Validator $validator + * @return bool + */ public function validate($validator) { - $dateValid = $this->dateField->validate($validator); - $timeValid = $this->timeField->validate($validator); + // Don't validate empty fields + if (empty($this->rawValue)) { + return true; + } - // Validate if both subfields are valid - return $dateValid && $timeValid; + // We submitted a value, but it couldn't be parsed + if (empty($this->value)) { + $validator->validationError( + $this->name, + _t( + 'DatetimeField.VALIDDATETIMEFORMAT', + "Please enter a valid date and time format ({format})", + ['format' => $this->getDatetimeFormat()] + ) + ); + return false; + } + + // Check min date (in server timezone) + $min = $this->getMinDatetime(); + if ($min) { + $oops = strtotime($this->value) < strtotime($min); + if ($oops) { + $validator->validationError( + $this->name, + _t( + 'DatetimeField.VALIDDATETIMEMINDATE', + "Your date has to be newer or matching the minimum allowed date and time ({datetime})", + [ + 'datetime' => sprintf( + '', + $min, + $this->internalToFrontend($min) + ) + ] + ), + ValidationResult::TYPE_ERROR, + ValidationResult::CAST_HTML + ); + return false; + } + } + + // Check max date (in server timezone) + $max = $this->getMaxDatetime(); + if ($max) { + $oops = strtotime($this->value) > strtotime($max); + if ($oops) { + $validator->validationError( + $this->name, + _t( + 'DatetimeField.VALIDDATEMAXDATETIME', + "Your date has to be older or matching the maximum allowed date and time ({datetime})", + [ + 'datetime' => sprintf( + '', + $max, + $this->internalToFrontend($max) + ) + ] + ), + ValidationResult::TYPE_ERROR, + ValidationResult::CAST_HTML + ); + return false; + } + } + + return true; } public function performReadonlyTransformation() @@ -365,12 +637,6 @@ class DatetimeField extends FormField return $field; } - public function __clone() - { - $this->dateField = clone $this->dateField; - $this->timeField = clone $this->timeField; - } - /** * @return string */ @@ -379,13 +645,6 @@ class DatetimeField extends FormField return $this->timezone; } - /** - * Custom timezone - * - * @var string - */ - protected $timezone = null; - /** * @param string $timezone * @return $this @@ -395,29 +654,9 @@ class DatetimeField extends FormField 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; - } - /** - * @return string - */ - public function getDateTimeOrder() - { - return $this->dateTimeOrder; - } - - /** - * Set date time order format string. Use {date} and {time} as placeholders. - * - * @param string $dateTimeOrder - * @return $this - */ - public function setDateTimeOrder($dateTimeOrder) - { - $this->dateTimeOrder = $dateTimeOrder; return $this; } } diff --git a/src/Forms/FormField.php b/src/Forms/FormField.php index ac60eb425..076fb0f52 100644 --- a/src/Forms/FormField.php +++ b/src/Forms/FormField.php @@ -70,7 +70,7 @@ class FormField extends RequestHandler const SCHEMA_DATA_TYPE_DATE = 'Date'; /** @see $schemaDataType */ - const SCHEMA_DATA_TYPE_DATETIME = 'DateTime'; + const SCHEMA_DATA_TYPE_DATETIME = 'Datetime'; /** @see $schemaDataType */ const SCHEMA_DATA_TYPE_TIME = 'Time'; diff --git a/src/Forms/TimeField.php b/src/Forms/TimeField.php index 94d480236..4cfa187ac 100644 --- a/src/Forms/TimeField.php +++ b/src/Forms/TimeField.php @@ -103,7 +103,7 @@ class TimeField extends TextField } // Get from locale - return $this->getFormatter()->getPattern(); + return $this->getFrontendFormatter()->getPattern(); } /** @@ -159,7 +159,7 @@ class TimeField extends TextField * * @return IntlDateFormatter */ - protected function getFormatter() + protected function getFrontendFormatter() { if ($this->getHTML5() && $this->timeFormat && $this->timeFormat !== DBTime::ISO_TIME) { throw new \LogicException( @@ -204,7 +204,7 @@ class TimeField extends TextField * * @return IntlDateFormatter */ - protected function getISO8601Formatter() + protected function getInternalFormatter() { $formatter = IntlDateFormatter::create( i18n::config()->uninherited('default_locale'), @@ -231,6 +231,17 @@ class TimeField extends TextField return $attributes; } + public function getSchemaDataDefaults() + { + $defaults = parent::getSchemaDataDefaults(); + return array_merge($defaults, [ + 'lang' => i18n::convert_rfc1766($this->getLocale()), + 'data' => array_merge($defaults['data'], [ + 'html5' => $this->getHTML5(), + ]) + ]); + } + public function Type() { return 'time text'; @@ -249,7 +260,7 @@ class TimeField extends TextField $this->rawValue = $value; // Parse from submitted value - $this->value = $this->localisedToISO8601($value); + $this->value = $this->frontendToInternal($value); return $this; } @@ -272,13 +283,13 @@ class TimeField extends TextField } // Re-run through formatter to tidy up (e.g. remove date component) - $this->value = $this->tidyISO8601($value); + $this->value = $this->tidyInternal($value); return $this; } public function Value() { - $localised = $this->iso8601ToLocalised($this->value); + $localised = $this->internalToFrontend($this->value); if ($localised) { return $localised; } @@ -294,7 +305,7 @@ class TimeField extends TextField */ public function getMidnight() { - $formatter = $this->getFormatter(); + $formatter = $this->getFrontendFormatter(); $timestamp = $this->withTimezone($this->getTimezone(), function () { return strtotime('midnight'); }); @@ -364,24 +375,25 @@ class TimeField extends TextField } /** - * Convert time localised in the current locale to ISO 8601 time + * Convert frontend time to the internal representation (ISO 8601). + * The frontend time is also in ISO 8601 when $html5=true. * * @param string $time * @return string The formatted time, or null if not a valid time */ - public function localisedToISO8601($time) + protected function frontendToInternal($time) { if (!$time) { return null; } - $fromFormatter = $this->getFormatter(); - $toFormatter = $this->getISO8601Formatter(); + $fromFormatter = $this->getFrontendFormatter(); + $toFormatter = $this->getInternalFormatter(); $timestamp = $fromFormatter->parse($time); // Try to parse time without seconds, since that's a valid HTML5 submission format // See https://html.spec.whatwg.org/multipage/infrastructure.html#times if ($timestamp === false && $this->getHTML5()) { - $fromFormatter->setPattern('HH:mm'); + $fromFormatter->setPattern(str_replace(':ss', '', DBTime::ISO_TIME)); $timestamp = $fromFormatter->parse($time); } @@ -394,19 +406,21 @@ class TimeField extends TextField } /** - * Format iso time to localised form + * Convert the internal time representation (ISO 8601) to a format used by the frontend, + * as defined by {@link $timeFormat}. With $html5=true, the frontend time will also be + * in ISO 8601. * * @param string $time * @return string */ - public function iso8601ToLocalised($time) + protected function internalToFrontend($time) { - $time = $this->tidyISO8601($time); + $time = $this->tidyInternal($time); if (!$time) { return null; } - $fromFormatter = $this->getISO8601Formatter(); - $toFormatter = $this->getFormatter(); + $fromFormatter = $this->getInternalFormatter(); + $toFormatter = $this->getFrontendFormatter(); $timestamp = $fromFormatter->parse($time); if ($timestamp === false) { return null; @@ -417,18 +431,19 @@ class TimeField extends TextField /** - * Tidy up iso8601-ish time, or approximation + * Tidy up the internal time representation (ISO 8601), + * and fall back to strtotime() if there's parsing errors. * - * @param string $time Time in iso8601 or approximate form - * @return string iso8601 time, or null if not valid + * @param string $time Time in ISO 8601 or approximate form + * @return string ISO 8601 time, or null if not valid */ - public function tidyISO8601($time) + protected function tidyInternal($time) { if (!$time) { return null; } // Re-run through formatter to tidy up (e.g. remove date component) - $formatter = $this->getISO8601Formatter(); + $formatter = $this->getInternalFormatter(); $timestamp = $formatter->parse($time); if ($timestamp === false) { // Fallback to strtotime diff --git a/src/ORM/FieldType/DBDatetime.php b/src/ORM/FieldType/DBDatetime.php index 291b31d46..c52dbc8d8 100644 --- a/src/ORM/FieldType/DBDatetime.php +++ b/src/ORM/FieldType/DBDatetime.php @@ -35,10 +35,17 @@ use InvalidArgumentException; class DBDatetime extends DBDate implements TemplateGlobalProvider { /** - * Standard ISO format string for date and time in CLDR standard format + * Standard ISO format string for date and time in CLDR standard format, + * with a whitespace separating date and time (common database representation, e.g. in MySQL). */ const ISO_DATETIME = 'y-MM-dd HH:mm:ss'; + /** + * Standard ISO format string for date and time in CLDR standard format, + * with a "T" separator between date and time (W3C standard, e.g. for HTML5 datetime-local fields). + */ + const ISO_DATETIME_NORMALISED = 'y-MM-dd\'T\'HH:mm:ss'; + /** * Returns the standard localised date * @@ -132,32 +139,18 @@ class DBDatetime extends DBDate implements TemplateGlobalProvider public function scaffoldFormField($title = null, $params = null) { $field = DatetimeField::create($this->name, $title); - $dateFormat = $field->getDateField()->getDateFormat(); - $timeFormat = $field->getTimeField()->getTimeFormat(); + $dateTimeFormat = $field->getDatetimeFormat(); // Set date formatting hints and example - $date = static::now()->Format($dateFormat); + $date = static::now()->Format($dateTimeFormat); $field - ->getDateField() ->setDescription(_t( 'FormField.EXAMPLE', 'e.g. {format}', 'Example format', [ 'format' => $date ] )) - ->setAttribute('placeholder', $dateFormat); - - // Set time formatting hints and example - $time = static::now()->Format($timeFormat); - $field - ->getTimeField() - ->setDescription(_t( - 'FormField.EXAMPLE', - 'e.g. {format}', - 'Example format', - [ 'format' => $time ] - )) - ->setAttribute('placeholder', $timeFormat); + ->setAttribute('placeholder', $dateTimeFormat); return $field; } diff --git a/templates/SilverStripe/Forms/DatetimeField.ss b/templates/SilverStripe/Forms/DatetimeField.ss deleted file mode 100644 index 90a7836ea..000000000 --- a/templates/SilverStripe/Forms/DatetimeField.ss +++ /dev/null @@ -1,7 +0,0 @@ -
- $DateField.SmallFieldHolder - $TimeField.SmallFieldHolder - <% if $TimeZone %> - $TimezoneField.Field - <% end_if %> -
diff --git a/tests/php/Forms/DateFieldTest.php b/tests/php/Forms/DateFieldTest.php index 676ae58eb..7843a02c0 100644 --- a/tests/php/Forms/DateFieldTest.php +++ b/tests/php/Forms/DateFieldTest.php @@ -94,15 +94,22 @@ class DateFieldTest extends SapphireTest $this->assertEquals('2003-03-29', $f->dataValue()); } - public function testTidyISO8601() + public function testSetValue() { - $f = new DateField('Date', 'Date'); - $this->assertEquals(null, $f->tidyISO8601('notadate')); - $this->assertEquals('2011-01-31', $f->tidyISO8601('-1 day')); - $this->assertEquals(null, $f->tidyISO8601('29/03/2003')); + $f = (new DateField('Date', 'Date'))->setValue('notadate'); + $this->assertNull($f->Value(), 'Invalid input ignored'); + + $f = (new DateField('Date', 'Date'))->setValue('-1 day'); + $this->assertEquals($f->Value(), '2011-01-31', 'Relative dates accepted'); + + $f = (new DateField('Date', 'Date'))->setValue('2011-01-31'); + $this->assertEquals($f->Value(), '2011-01-31', 'ISO format accepted'); + + $f = (new DateField('Date', 'Date'))->setValue('2011-01-31 23:59:59'); + $this->assertEquals($f->Value(), '2011-01-31', 'ISO format with time accepted'); } - public function testSetValueWithDateString() + public function testSetValueWithLocalisedDateString() { $f = new DateField('Date', 'Date'); $f->setHTML5(false); diff --git a/tests/php/Forms/DatetimeFieldTest.php b/tests/php/Forms/DatetimeFieldTest.php index f0736f44a..6cb8057d9 100644 --- a/tests/php/Forms/DatetimeFieldTest.php +++ b/tests/php/Forms/DatetimeFieldTest.php @@ -13,6 +13,7 @@ use SilverStripe\Forms\FieldList; use SilverStripe\Forms\FormAction; use SilverStripe\Forms\Form; use SilverStripe\i18n\i18n; +use SilverStripe\ORM\FieldType\DBDatetime; class DatetimeFieldTest extends SapphireTest { @@ -37,10 +38,7 @@ class DatetimeFieldTest extends SapphireTest $form = $this->getMockForm(); $form->Fields()->push($dateTimeField); - $dateTimeField->setSubmittedValue([ - 'date' => '2003-03-29', - 'time' => '23:59:38' - ]); + $dateTimeField->setSubmittedValue('2003-03-29T23:59:38'); $validator = new RequiredFields(); $this->assertTrue($dateTimeField->validate($validator)); $m = new Model(); @@ -51,12 +49,7 @@ class DatetimeFieldTest extends SapphireTest public function testFormSaveIntoLocalised() { $dateTimeField = new DatetimeField('MyDatetime'); - - $dateTimeField->getDateField() - ->setHTML5(false) - ->setLocale('en_NZ'); - - $dateTimeField->getTimeField() + $dateTimeField ->setHTML5(false) ->setLocale('en_NZ'); @@ -64,10 +57,7 @@ class DatetimeFieldTest extends SapphireTest $form->Fields()->push($dateTimeField); // en_NZ standard format - $dateTimeField->setSubmittedValue([ - 'date' => '29/03/2003', - 'time' => '11:59:38 pm' - ]); + $dateTimeField->setSubmittedValue('29/03/2003 11:59:38 pm'); $validator = new RequiredFields(); $this->assertTrue($dateTimeField->validate($validator)); $m = new Model(); @@ -84,18 +74,28 @@ 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'); $this->assertEquals($f->dataValue(), null); } - // /** - // * @expectedException InvalidArgumentException - // */ - // public function testConstructorWithLocalizedDateString() { - // $f = new DatetimeField('Datetime', 'Datetime', '29/03/2003 23:59:38'); - // } + public function testConstructorWithLocalizedDateSetsNullValue() + { + $f = new DatetimeField('Datetime', 'Datetime', '29/03/2003 23:59:38'); + $this->assertNull($f->Value()); + } public function testConstructorWithIsoDate() { @@ -104,49 +104,45 @@ class DatetimeFieldTest extends SapphireTest $this->assertEquals($f->dataValue(), '2003-03-29 23:59:38'); } - // /** - // * @expectedException InvalidArgumentException - // */ - // public function testSetValueWithDateString() { - // $f = new DatetimeField('Datetime', 'Datetime'); - // $f->setValue('29/03/2003'); - // } - public function testSetValueWithDateTimeString() { $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->assertNull($f->dataValue(), 'Rejects normalised ISO'); } - public function testSetValueWithArray() + public function testSubmittedValue() { $datetimeField = new DatetimeField('Datetime', 'Datetime'); - $datetimeField->setSubmittedValue([ - 'date' => '2003-03-29', - 'time' => '23:00:00' - ]); + $datetimeField->setSubmittedValue('2003-03-29 23:00:00'); $this->assertEquals($datetimeField->dataValue(), '2003-03-29 23:00:00'); + + $datetimeField = new DatetimeField('Datetime', 'Datetime'); + $datetimeField->setSubmittedValue('2003-03-29T23:00:00'); + $this->assertEquals($datetimeField->dataValue(), '2003-03-29 23:00:00', 'Normalised ISO'); } - public function testSetValueWithArrayLocalised() + public function testSetValueWithLocalised() { $datetimeField = new DatetimeField('Datetime', 'Datetime'); - $datetimeField->getDateField() + $datetimeField ->setHTML5(false) ->setLocale('en_NZ'); - $datetimeField->getTimeField() - ->setHTML5(false) - ->setLocale('en_NZ'); - - // Values can only be localized (= non-ISO) in array notation - $datetimeField->setSubmittedValue([ - 'date' => '29/03/2003', - 'time' => '11:00:00 pm' - ]); + $datetimeField->setSubmittedValue('29/03/2003 11:00:00 pm'); $this->assertEquals($datetimeField->dataValue(), '2003-03-29 23:00:00'); + + // Some localisation packages exclude the ',' in default medium format + $this->assertRegExp( + '#29/03/2003(,)? 11:00:00 (PM|pm)#', + $datetimeField->Value(), + 'User value is formatted, and in user timezone' + ); } public function testValidate() @@ -154,124 +150,244 @@ class DatetimeFieldTest extends SapphireTest $f = new DatetimeField('Datetime', 'Datetime', '2003-03-29 23:59:38'); $this->assertTrue($f->validate(new RequiredFields())); - $f = new DatetimeField('Datetime', 'Datetime', '2003-03-29 00:00:00'); - $this->assertTrue($f->validate(new RequiredFields())); + $f = new DatetimeField('Datetime', 'Datetime', '2003-03-29T23:59:38'); + $this->assertFalse($f->validate(new RequiredFields()), 'Normalised ISO'); + + $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-29T00:00'); + $this->assertTrue($f->validate(new RequiredFields()), 'Leaving out seconds (like many browsers)'); $f = new DatetimeField('Datetime', 'Datetime', 'wrong'); $this->assertFalse($f->validate(new RequiredFields())); } - public function testTimezoneSetLocalised() + public function testSetMinDate() + { + $f = (new DatetimeField('Datetime'))->setMinDatetime('2009-03-31T23:00:00'); + $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-31 23:00:00', 'Converts normalised ISO to ISO'); + + $f = (new DatetimeField('Datetime'))->setMinDatetime('invalid'); + $this->assertNull($f->getMinDatetime(), 'Ignores invalid values'); + } + + public function testSetMaxDate() + { + $f = (new DatetimeField('Datetime'))->setMaxDatetime('2009-03-31T23:00:00'); + $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-31 23:00:00', 'Converts normalised ISO to ISO'); + + $f = (new DatetimeField('Datetime'))->setMaxDatetime('invalid'); + $this->assertNull($f->getMaxDatetime(), 'Ignores invalid values'); + } + + public function testValidateMinDate() + { + $dateField = new DatetimeField('Datetime'); + $dateField->setMinDatetime('2009-03-31 23:00:00'); + $dateField->setValue('2009-03-31 23:00:01'); + $this->assertTrue($dateField->validate(new RequiredFields()), 'Time above min datetime'); + + $dateField = new DatetimeField('Datetime'); + $dateField->setMinDatetime('2009-03-31 23:00:00'); + $dateField->setValue('2009-03-31 22:00:00'); + $this->assertFalse($dateField->validate(new RequiredFields()), 'Time below min datetime'); + + $dateField = new DatetimeField('Datetime'); + $dateField->setMinDatetime('2009-03-31 23:00:00'); + $dateField->setValue('2009-03-31 23:00:00'); + $this->assertTrue($dateField->validate(new RequiredFields()), 'Date and time matching min datetime'); + + $dateField = new DatetimeField('Datetime'); + $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->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() + { + $f = new DatetimeField('Datetime'); + $f->setMinDatetime('-7 days'); + $f->setValue(strftime('%Y-%m-%d %T', strtotime('-8 days', DBDatetime::now()->getTimestamp()))); + $this->assertFalse($f->validate(new RequiredFields()), 'Date below min datetime, with strtotime'); + + $f = new DatetimeField('Datetime'); + $f->setMinDatetime('-7 days'); + $f->setValue(strftime('%Y-%m-%d %T', strtotime('-7 days', DBDatetime::now()->getTimestamp()))); + $this->assertTrue($f->validate(new RequiredFields()), 'Date matching min datetime, with strtotime'); + } + + public function testValidateMaxDateStrtotime() + { + $f = new DatetimeField('Datetime'); + $f->setMaxDatetime('7 days'); + $f->setValue(strftime('%Y-%m-%d %T', strtotime('8 days', DBDatetime::now()->getTimestamp()))); + $this->assertFalse($f->validate(new RequiredFields()), 'Date above max date, with strtotime'); + + $f = new DatetimeField('Datetime'); + $f->setMaxDatetime('7 days'); + $f->setValue(strftime('%Y-%m-%d %T', strtotime('7 days', DBDatetime::now()->getTimestamp()))); + $this->assertTrue($f->validate(new RequiredFields()), 'Date matching max date, with strtotime'); + } + + public function testValidateMaxDate() + { + $f = new DatetimeField('Datetime'); + $f->setMaxDatetime('2009-03-31 23:00:00'); + $f->setValue('2009-03-31 22:00:00'); + $this->assertTrue($f->validate(new RequiredFields()), 'Time below max datetime'); + + $f = new DatetimeField('Datetime'); + $f->setMaxDatetime('2009-03-31 23:00:00'); + $f->setValue('2010-03-31 23:00:01'); + $this->assertFalse($f->validate(new RequiredFields()), 'Time above max datetime'); + + $f = new DatetimeField('Datetime'); + $f->setMaxDatetime('2009-03-31 23:00:00'); + $f->setValue('2009-03-31 23:00:00'); + $this->assertTrue($f->validate(new RequiredFields()), 'Date and time matching max datetime'); + + $f = new DatetimeField('Datetime'); + $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->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() { date_default_timezone_set('Europe/Berlin'); // Berlin and Auckland have 12h time difference in northern hemisphere winter $datetimeField = new DatetimeField('Datetime', 'Datetime'); - $datetimeField->getDateField() + $datetimeField ->setHTML5(false) - ->setLocale('en_NZ'); - - $datetimeField->getTimeField() - ->setHTML5(false) - ->setLocale('en_NZ'); + ->setDatetimeFormat('dd/MM/y HH:mm:ss'); $datetimeField->setTimezone('Pacific/Auckland'); $datetimeField->setValue('2003-12-24 23:59:59'); $this->assertEquals( - '25/12/2003 11:59:59 AM', + '25/12/2003 11:59:59', $datetimeField->Value(), 'User value is formatted, and in user timezone' ); - $this->assertEquals('25/12/2003', $datetimeField->getDateField()->Value()); - $this->assertEquals('11:59:59 AM', $datetimeField->getTimeField()->Value()); + $this->assertEquals( '2003-12-24 23:59:59', $datetimeField->dataValue(), - 'Data value is unformatted, and in server timezone' + 'Data value is in ISO format, and in server timezone' ); } - public function testTimezoneFromConfigLocalised() + public function testTimezoneSetValueWithHtml5() + { + date_default_timezone_set('Europe/Berlin'); + // Berlin and Auckland have 12h time difference in northern hemisphere winter + $datetimeField = new DatetimeField('Datetime', 'Datetime'); + + $datetimeField->setTimezone('Pacific/Auckland'); + $datetimeField->setValue('2003-12-24 23:59:59'); + $this->assertEquals( + '2003-12-25T11:59:59', + $datetimeField->Value(), + 'User value is in normalised ISO format and in user timezone' + ); + + $this->assertEquals( + '2003-12-24 23:59:59', + $datetimeField->dataValue(), + 'Data value is in ISO format, and in server timezone' + ); + } + + public function testTimezoneSetSubmittedValueLocalised() { date_default_timezone_set('Europe/Berlin'); // Berlin and Auckland have 12h time difference in northern hemisphere summer, but Berlin and Moscow only 2h. $datetimeField = new DatetimeField('Datetime', 'Datetime'); - $datetimeField->getDateField() - ->setHTML5(false) - ->setLocale('en_NZ'); - - $datetimeField->getTimeField() + $datetimeField ->setHTML5(false) ->setLocale('en_NZ'); $datetimeField->setTimezone('Europe/Moscow'); - $datetimeField->setSubmittedValue([ - // pass in default format, at user time (Moscow) - 'date' => '24/06/2003', - 'time' => '11:59:59 pm', - ]); + // pass in default format, at user time (Moscow) + $datetimeField->setSubmittedValue('24/06/2003 11:59:59 pm'); $this->assertTrue($datetimeField->validate(new RequiredFields())); $this->assertEquals('2003-06-24 21:59:59', $datetimeField->dataValue(), 'Data value matches server timezone'); } - public function testSetDateField() - { - $form = $this->getMockForm(); - $field = new DatetimeField('Datetime', 'Datetime'); - $field->setForm($form); - $field->setSubmittedValue([ - 'date' => '2003-06-24', - 'time' => '23:59:59', - ]); - $dateField = new DateField('Datetime[date]'); - $field->setDateField($dateField); - - $this->assertEquals( - $dateField->getForm(), - $form, - 'Sets form on new field' - ); - - $this->assertEquals( - '2003-06-24', - $dateField->dataValue(), - 'Sets existing value on new field' - ); - } - - public function testSetTimeField() - { - $form = $this->getMockForm(); - $field = new DatetimeField('Datetime', 'Datetime'); - $field->setForm($form); - $field->setSubmittedValue([ - 'date' => '2003-06-24', - 'time' => '23:59:59', - ]); - $timeField = new TimeField('Datetime[time]'); - $field->setTimeField($timeField); - - $this->assertEquals( - $timeField->getForm(), - $form, - 'Sets form on new field' - ); - - $this->assertEquals( - '23:59:59', - $timeField->dataValue(), - 'Sets existing value on new field' - ); - } - public function testGetName() { $field = new DatetimeField('Datetime'); $this->assertEquals('Datetime', $field->getName()); - $this->assertEquals('Datetime[date]', $field->getDateField()->getName()); - $this->assertEquals('Datetime[time]', $field->getTimeField()->getName()); } public function testSetName() @@ -279,8 +395,54 @@ class DatetimeFieldTest extends SapphireTest $field = new DatetimeField('Datetime', 'Datetime'); $field->setName('CustomDatetime'); $this->assertEquals('CustomDatetime', $field->getName()); - $this->assertEquals('CustomDatetime[date]', $field->getDateField()->getName()); - $this->assertEquals('CustomDatetime[time]', $field->getTimeField()->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() diff --git a/tests/php/Forms/FormSchemaTest.php b/tests/php/Forms/FormSchemaTest.php index 3073482f2..7bd29161c 100644 --- a/tests/php/Forms/FormSchemaTest.php +++ b/tests/php/Forms/FormSchemaTest.php @@ -462,6 +462,7 @@ class FormSchemaTest extends SapphireTest 'extraClass' => 'date text', 'description' => null, 'rightTitle' => null, + 'lang' => 'en-US', 'leftTitle' => null, 'readOnly' => false, 'disabled' => false, @@ -472,8 +473,11 @@ class FormSchemaTest extends SapphireTest ], 'attributes' => [], - 'data' => - [], + 'data' => [ + 'html5' => true, + 'min' => null, + 'max' => null + ], ], [ 'name' => 'Number',