silverstripe-framework/src/Forms/DatetimeField.php

667 lines
18 KiB
PHP

<?php
namespace SilverStripe\Forms;
use IntlDateFormatter;
use InvalidArgumentException;
use SilverStripe\i18n\i18n;
use SilverStripe\ORM\FieldType\DBDate;
use SilverStripe\ORM\FieldType\DBDatetime;
use SilverStripe\ORM\ValidationResult;
/**
* 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 TextField
{
/**
* @var bool
*/
protected $html5 = true;
/**
* Override locale. If empty will default to current locale
*
* @var string
*/
protected $locale = null;
protected $inputType = 'datetime-local';
/**
* 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()
{
$attributes = parent::getAttributes();
$attributes['lang'] = i18n::convert_rfc1766($this->getLocale());
if ($this->getHTML5()) {
$attributes['min'] = $this->internalToFrontend($this->getMinDatetime());
$attributes['max'] = $this->internalToFrontend($this->getMaxDatetime());
} else {
$attributes['type'] = 'text';
}
return $attributes;
}
/**
* @inheritDoc
*/
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->internalToFrontend($this->getMinDatetime()),
'max' => $this->internalToFrontend($this->getMaxDatetime())
])
]);
}
/**
* @inheritDoc
*/
public function Type()
{
return 'text datetime';
}
/**
* @return bool
*/
public function getHTML5()
{
return $this->html5;
}
/**
* @param $bool
* @return $this
*/
public function setHTML5($bool)
{
$this->html5 = $bool;
return $this;
}
/**
* Assign value posted from form submission, based on {@link $datetimeFormat}.
* When $html5=true, this needs to be normalised ISO format (with "T" separator).
*
* @param mixed $value
* @param mixed $data
* @return $this
*/
public function setSubmittedValue($value, $data = null)
{
// Save raw value for later validation
$this->rawValue = $value;
// Null case
if (!$value) {
$this->value = null;
return $this;
}
// Parse from submitted value
$this->value = $this->frontendToInternal($value);
return $this;
}
/**
* 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
*/
public function frontendToInternal($datetime)
{
if (!$datetime) {
return null;
}
$fromFormatter = $this->getFrontendFormatter();
$toFormatter = $this->getInternalFormatter();
// 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 https://unicode-org.github.io/icu/userguide/format_parse/datetime/#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 https://unicode-org.github.io/icu/userguide/format_parse/datetime/#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 getInternalFormatter($timezone = null)
{
if (!$timezone) {
$timezone = date_default_timezone_get(); // Default to server timezone
}
$formatter = IntlDateFormatter::create(
DBDate::ISO_LOCALE,
IntlDateFormatter::MEDIUM,
IntlDateFormatter::MEDIUM,
$timezone
);
$formatter->setLenient(false);
// Note we omit timezone from this format, and we always assume server TZ
$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).
*
* @param mixed $value
* @param mixed $data
* @return $this
*/
public function setValue($value, $data = null)
{
// Save raw value for later validation
$this->rawValue = $value;
// Empty value
if (empty($value)) {
$this->value = null;
return $this;
}
// Validate iso 8601 date
// If invalid, assign for later validation failure
$internalFormatter = $this->getInternalFormatter();
$timestamp = $internalFormatter->parse($value);
// Retry with "T" separator
if (!$timestamp) {
$fallbackFormatter = $this->getInternalFormatter();
$fallbackFormatter->setPattern(DBDatetime::ISO_DATETIME_NORMALISED);
$timestamp = $fallbackFormatter->parse($value);
}
if ($timestamp === false) {
return $this;
}
// Cleanup date
$value = $internalFormatter->format($timestamp);
// Save value
$this->value = $value;
return $this;
}
/**
* 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()
{
return $this->internalToFrontend($this->value);
}
/**
* 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 $datetime
* @return string The formatted date and time, or null if not a valid date and time
*/
public function internalToFrontend($datetime)
{
$datetime = $this->tidyInternal($datetime);
if (!$datetime) {
return null;
}
$fromFormatter = $this->getInternalFormatter();
$toFormatter = $this->getFrontendFormatter();
$timestamp = $fromFormatter->parse($datetime);
if ($timestamp === false) {
return null;
}
return $toFormatter->format($timestamp) ?: null;
}
/**
* Tidy up the internal date representation (ISO 8601),
* and fall back to strtotime() if there's parsing errors.
*
* @param string $datetime 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);
}
/**
* 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);
return $this;
}
public function setReadonly($bool)
{
parent::setReadonly($bool);
return $this;
}
/**
* Set default locale for this field. If omitted will default to the current locale.
*
* @param string $locale
* @return $this
*/
public function setLocale($locale)
{
$this->locale = $locale;
return $this;
}
/**
* Get locale for this field
*
* @return string
*/
public function 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)
{
// Don't validate empty fields
if (empty($this->rawValue)) {
return true;
}
// We submitted a value, but it couldn't be parsed
if (empty($this->value)) {
$validator->validationError(
$this->name,
_t(
__CLASS__ . '.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(
__CLASS__ . '.VALIDDATETIMEMINDATE',
"Your date has to be newer or matching the minimum allowed date and time ({datetime})",
[
'datetime' => sprintf(
'<time datetime="%s">%s</time>',
$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(
__CLASS__ . '.VALIDDATEMAXDATETIME',
"Your date has to be older or matching the maximum allowed date and time ({datetime})",
[
'datetime' => sprintf(
'<time datetime="%s">%s</time>',
$max,
$this->internalToFrontend($max)
)
]
),
ValidationResult::TYPE_ERROR,
ValidationResult::CAST_HTML
);
return false;
}
}
return true;
}
public function performReadonlyTransformation()
{
$field = clone $this;
$field->setReadonly(true);
return $field;
}
/**
* @return string
*/
public function getTimezone()
{
return $this->timezone;
}
/**
* @param string $timezone
* @return $this
*/
public function setTimezone($timezone)
{
if ($this->value && $timezone !== $this->timezone) {
throw new \BadMethodCallException("Can't change timezone after setting a value");
}
$this->timezone = $timezone;
return $this;
}
}