Completed
Push — authenticator-refactor ( fcc98b...4aec3f )
by Sam
05:51
created

DatetimeField::dataValue()   B

Complexity

Conditions 4
Paths 5

Size

Total Lines 22
Code Lines 12

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 4
eloc 12
c 0
b 0
f 0
nc 5
nop 0
dl 0
loc 22
rs 8.9197
1
<?php
2
3
namespace SilverStripe\Forms;
4
5
use IntlDateFormatter;
6
use InvalidArgumentException;
7
use SilverStripe\i18n\i18n;
8
use SilverStripe\ORM\FieldType\DBDatetime;
9
use SilverStripe\ORM\ValidationResult;
10
11
/**
12
 * Form field used for editing date time strings.
13
 * In the default HTML5 mode, the field expects form submissions
14
 * in normalised ISO 8601 format, for example 2017-04-26T23:59:59 (with a "T" separator).
15
 * Data is passed on via {@link dataValue()} with whitespace separators.
16
 * The {@link $value} property is always in ISO 8601 format, in the server timezone.
17
 */
18
class DatetimeField extends TextField
19
{
20
21
    /**
22
     * @var bool
23
     */
24
    protected $html5 = true;
25
26
    /**
27
     * Override locale. If empty will default to current locale
28
     *
29
     * @var string
30
     */
31
    protected $locale = null;
32
33
    /**
34
     * Min date time
35
     *
36
     * @var string ISO 8601 date time in server timezone
37
     */
38
    protected $minDatetime = null;
39
40
    /**
41
     * Max date time
42
     *
43
     * @var string ISO 860 date time in server timezone
44
     */
45
    protected $maxDatetime = null;
46
47
    /**
48
     * Override date format. If empty will default to that used by the current locale.
49
     *
50
     * @var null
51
     */
52
    protected $datetimeFormat = null;
53
54
    /**
55
     * Length of this date (full, short, etc).
56
     *
57
     * @see http://php.net/manual/en/class.intldateformatter.php#intl.intldateformatter-constants
58
     * @var int
59
     */
60
    protected $dateLength = null;
61
62
    /**
63
     * Length of this time (full, short, etc).
64
     *
65
     * @see http://php.net/manual/en/class.intldateformatter.php#intl.intldateformatter-constants
66
     * @var int
67
     */
68
    protected $timeLength = null;
69
70
    /**
71
     * Unparsed value, used exclusively for comparing with internal value
72
     * to detect invalid values.
73
     *
74
     * @var mixed
75
     */
76
    protected $rawValue = null;
77
78
    /**
79
     * @inheritDoc
80
     */
81
    protected $schemaDataType = FormField::SCHEMA_DATA_TYPE_DATETIME;
82
83
    /**
84
     * Custom timezone
85
     *
86
     * @var string
87
     */
88
    protected $timezone = null;
89
90 View Code Duplication
    public function getAttributes()
0 ignored issues
show
Duplication introduced by
This method seems to be duplicated in your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
91
    {
92
        $attributes = parent::getAttributes();
93
94
        $attributes['lang'] = i18n::convert_rfc1766($this->getLocale());
95
96
        if ($this->getHTML5()) {
97
            $attributes['type'] = 'datetime-local';
98
            $attributes['min'] = $this->internalToFrontend($this->getMinDatetime());
99
            $attributes['max'] = $this->internalToFrontend($this->getMaxDatetime());
100
        }
101
102
        return $attributes;
103
    }
104
105
    /**
106
     * @inheritDoc
107
     */
108 View Code Duplication
    public function getSchemaDataDefaults()
0 ignored issues
show
Duplication introduced by
This method seems to be duplicated in your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
109
    {
110
        $defaults = parent::getSchemaDataDefaults();
111
        return array_merge($defaults, [
112
            'lang' => i18n::convert_rfc1766($this->getLocale()),
113
            'data' => array_merge($defaults['data'], [
114
                'html5' => $this->getHTML5(),
115
                'min' => $this->internalToFrontend($this->getMinDatetime()),
116
                'max' => $this->internalToFrontend($this->getMaxDatetime())
117
            ])
118
        ]);
119
    }
120
121
    /**
122
     * @inheritDoc
123
     */
124
    public function Type()
125
    {
126
        return 'text datetime';
127
    }
128
129
    /**
130
     * @return bool
131
     */
132
    public function getHTML5()
133
    {
134
        return $this->html5;
135
    }
136
137
    /**
138
     * @param $bool
139
     * @return $this
140
     */
141
    public function setHTML5($bool)
142
    {
143
        $this->html5 = $bool;
144
        return $this;
145
    }
146
147
    /**
148
     * Assign value posted from form submission, based on {@link $datetimeFormat}.
149
     * When $html5=true, this needs to be normalised ISO format (with "T" separator).
150
     *
151
     * @param mixed $value
152
     * @param mixed $data
153
     * @return $this
154
     */
155 View Code Duplication
    public function setSubmittedValue($value, $data = null)
0 ignored issues
show
Duplication introduced by
This method seems to be duplicated in your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
156
    {
157
        // Save raw value for later validation
158
        $this->rawValue = $value;
159
160
        // Null case
161
        if (!$value) {
162
            $this->value = null;
163
            return $this;
164
        }
165
166
        // Parse from submitted value
167
        $this->value = $this->frontendToInternal($value);
168
169
        return $this;
170
    }
171
172
    /**
173
     * Convert frontend date to the internal representation (ISO 8601).
174
     * The frontend date is also in ISO 8601 when $html5=true.
175
     * Assumes the value is in the defined {@link $timezone} (if one is set),
176
     * and adjusts for server timezone.
177
     *
178
     * @param string $datetime
179
     * @return string The formatted date, or null if not a valid date
180
     */
181 View Code Duplication
    public function frontendToInternal($datetime)
0 ignored issues
show
Duplication introduced by
This method seems to be duplicated in your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
182
    {
183
        if (!$datetime) {
184
            return null;
185
        }
186
        $fromFormatter = $this->getFrontendFormatter();
187
        $toFormatter = $this->getInternalFormatter();
188
189
        // Try to parse time with seconds
190
        $timestamp = $fromFormatter->parse($datetime);
191
192
        // Try to parse time without seconds, since that's a valid HTML5 submission format
193
        // See https://html.spec.whatwg.org/multipage/infrastructure.html#times
194
        if ($timestamp === false && $this->getHTML5()) {
195
            $fromFormatter->setPattern(str_replace(':ss', '', $fromFormatter->getPattern()));
196
            $timestamp = $fromFormatter->parse($datetime);
197
        }
198
199
        if ($timestamp === false) {
200
            return null;
201
        }
202
        return $toFormatter->format($timestamp) ?: null;
203
    }
204
205
    /**
206
     * Get date formatter with the standard locale / date format
207
     *
208
     * @throws \LogicException
209
     * @return IntlDateFormatter
210
     */
211
    protected function getFrontendFormatter()
212
    {
213
        if ($this->getHTML5() && $this->datetimeFormat && $this->datetimeFormat !== DBDatetime::ISO_DATETIME_NORMALISED) {
214
            throw new \LogicException(
215
                'Please opt-out of HTML5 processing of ISO 8601 dates via setHTML5(false) if using setDatetimeFormat()'
216
            );
217
        }
218
219
        if ($this->getHTML5() && $this->dateLength) {
220
            throw new \LogicException(
221
                'Please opt-out of HTML5 processing of ISO 8601 dates via setHTML5(false) if using setDateLength()'
222
            );
223
        }
224
225
        if ($this->getHTML5() && $this->locale) {
226
            throw new \LogicException(
227
                'Please opt-out of HTML5 processing of ISO 8601 dates via setHTML5(false) if using setLocale()'
228
            );
229
        }
230
231
        $formatter = IntlDateFormatter::create(
232
            $this->getLocale(),
233
            $this->getDateLength(),
234
            $this->getTimeLength(),
235
            $this->getTimezone()
236
        );
237
238
        if ($this->getHTML5()) {
239
            // Browsers expect ISO 8601 dates, localisation is handled on the client.
240
            // Add 'T' date and time separator to create W3C compliant format
241
            $formatter->setPattern(DBDatetime::ISO_DATETIME_NORMALISED);
242
        } elseif ($this->datetimeFormat) {
243
            // Don't invoke getDatetimeFormat() directly to avoid infinite loop
244
            $ok = $formatter->setPattern($this->datetimeFormat);
245
            if (!$ok) {
246
                throw new InvalidArgumentException("Invalid date format {$this->datetimeFormat}");
247
            }
248
        }
249
        return $formatter;
250
    }
251
252
    /**
253
     * Get date format in CLDR standard format
254
     *
255
     * This can be set explicitly. If not, this will be generated from the current locale
256
     * with the current date length.
257
     *
258
     * @see http://userguide.icu-project.org/formatparse/datetime#TOC-Date-Field-Symbol-Table
259
     */
260
    public function getDatetimeFormat()
261
    {
262
        if ($this->datetimeFormat) {
263
            return $this->datetimeFormat;
264
        }
265
266
        // Get from locale
267
        return $this->getFrontendFormatter()->getPattern();
268
    }
269
270
    /**
271
     * Set date format in CLDR standard format.
272
     * Only applicable with {@link setHTML5(false)}.
273
     *
274
     * @see http://userguide.icu-project.org/formatparse/datetime#TOC-Date-Field-Symbol-Table
275
     * @param string $format
276
     * @return $this
277
     */
278
    public function setDatetimeFormat($format)
279
    {
280
        $this->datetimeFormat = $format;
0 ignored issues
show
Documentation Bug introduced by
It seems like $format of type string is incompatible with the declared type null of property $datetimeFormat.

Our type inference engine has found an assignment to a property that is incompatible with the declared type of that property.

Either this assignment is in error or the assigned type should be added to the documentation/type hint for that property..

Loading history...
281
        return $this;
282
    }
283
284
    /**
285
     * Get a date formatter for the ISO 8601 format
286
     *
287
     * @param String $timezone Optional timezone identifier (defaults to server timezone)
288
     * @return IntlDateFormatter
289
     */
290
    protected function getInternalFormatter($timezone = null)
291
    {
292
        if (!$timezone) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $timezone of type string|null is loosely compared to false; this is ambiguous if the string can be empty. You might want to explicitly use === null instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For string values, the empty string '' is a special case, in particular the following results might be unexpected:

''   == false // true
''   == null  // true
'ab' == false // false
'ab' == null  // false

// It is often better to use strict comparison
'' === false // false
'' === null  // false
Loading history...
293
            $timezone = date_default_timezone_get(); // Default to server timezone
294
        }
295
296
        $formatter = IntlDateFormatter::create(
297
            i18n::config()->uninherited('default_locale'),
298
            IntlDateFormatter::MEDIUM,
299
            IntlDateFormatter::MEDIUM,
300
            $timezone
301
        );
302
        $formatter->setLenient(false);
303
304
        // Note we omit timezone from this format, and we always assume server TZ
305
        $formatter->setPattern(DBDatetime::ISO_DATETIME);
306
307
        return $formatter;
308
    }
309
310
    /**
311
     * Assign value based on {@link $datetimeFormat}, which might be localised.
312
     * The value needs to be in the server timezone.
313
     *
314
     * When $html5=true, assign value from ISO 8601 normalised string (with a "T" separator).
315
     * Falls back to an ISO 8601 string (with a whitespace separator).
316
     *
317
     * @param mixed $value
318
     * @param mixed $data
319
     * @return $this
320
     */
321
    public function setValue($value, $data = null)
322
    {
323
        // Save raw value for later validation
324
        $this->rawValue = $value;
325
326
        // Empty value
327
        if (empty($value)) {
328
            $this->value = null;
329
            return $this;
330
        }
331
332
        // Validate iso 8601 date
333
        // If invalid, assign for later validation failure
334
        $internalFormatter = $this->getInternalFormatter();
335
        $timestamp = $internalFormatter->parse($value);
336
337
        // Retry without "T" separator
338
        if (!$timestamp) {
339
            $fallbackFormatter = $this->getInternalFormatter();
340
            $fallbackFormatter->setPattern(DBDatetime::ISO_DATETIME);
341
            $timestamp = $fallbackFormatter->parse($value);
342
        }
343
344
        if ($timestamp === false) {
345
            return $this;
346
        }
347
348
        // Cleanup date
349
        $value = $internalFormatter->format($timestamp);
350
351
        // Save value
352
        $this->value = $value;
353
354
        return $this;
355
    }
356
357
    /**
358
     * Returns the frontend representation of the field value,
359
     * according to the defined {@link dateFormat}.
360
     * With $html5=true, this will be in ISO 8601 format.
361
     *
362
     * @return string
363
     */
364
    public function Value()
365
    {
366
        return $this->internalToFrontend($this->value);
367
    }
368
369
    /**
370
     * Convert the internal date representation (ISO 8601) to a format used by the frontend,
371
     * as defined by {@link $dateFormat}. With $html5=true, the frontend date will also be
372
     * in ISO 8601.
373
     *
374
     * @param string $datetime
375
     * @return string The formatted date and time, or null if not a valid date and time
376
     */
377 View Code Duplication
    public function internalToFrontend($datetime)
0 ignored issues
show
Duplication introduced by
This method seems to be duplicated in your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
378
    {
379
        $datetime = $this->tidyInternal($datetime);
380
        if (!$datetime) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $datetime of type null|string is loosely compared to false; this is ambiguous if the string can be empty. You might want to explicitly use === null instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For string values, the empty string '' is a special case, in particular the following results might be unexpected:

''   == false // true
''   == null  // true
'ab' == false // false
'ab' == null  // false

// It is often better to use strict comparison
'' === false // false
'' === null  // false
Loading history...
381
            return null;
382
        }
383
        $fromFormatter = $this->getInternalFormatter();
384
        $toFormatter = $this->getFrontendFormatter();
385
        $timestamp = $fromFormatter->parse($datetime);
386
        if ($timestamp === false) {
387
            return null;
388
        }
389
390
        return $toFormatter->format($timestamp) ?: null;
391
    }
392
393
    /**
394
     * Tidy up the internal date representation (ISO 8601),
395
     * and fall back to strtotime() if there's parsing errors.
396
     *
397
     * @param string $date Date in ISO 8601 or approximate form
0 ignored issues
show
Bug introduced by
There is no parameter named $date. Was it maybe removed?

This check looks for PHPDoc comments describing methods or function parameters that do not exist on the corresponding method or function.

Consider the following example. The parameter $italy is not defined by the method finale(...).

/**
 * @param array $germany
 * @param array $island
 * @param array $italy
 */
function finale($germany, $island) {
    return "2:1";
}

The most likely cause is that the parameter was removed, but the annotation was not.

Loading history...
398
     * @return string ISO 8601 date, or null if not valid
399
     */
400 View Code Duplication
    public function tidyInternal($datetime)
0 ignored issues
show
Duplication introduced by
This method seems to be duplicated in your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
401
    {
402
        if (!$datetime) {
403
            return null;
404
        }
405
        // Re-run through formatter to tidy up (e.g. remove time component)
406
        $formatter = $this->getInternalFormatter();
407
        $timestamp = $formatter->parse($datetime);
408
        if ($timestamp === false) {
409
            // Fallback to strtotime
410
            $timestamp = strtotime($datetime, DBDatetime::now()->getTimestamp());
411
            if ($timestamp === false) {
412
                return null;
413
            }
414
        }
415
        return $formatter->format($timestamp);
416
    }
417
418
    /**
419
     * Get length of the date format to use. One of:
420
     *
421
     *  - IntlDateFormatter::SHORT
422
     *  - IntlDateFormatter::MEDIUM
423
     *  - IntlDateFormatter::LONG
424
     *  - IntlDateFormatter::FULL
425
     *
426
     * @see http://php.net/manual/en/class.intldateformatter.php#intl.intldateformatter-constants
427
     * @return int
428
     */
429
    public function getDateLength()
430
    {
431
        if ($this->dateLength) {
432
            return $this->dateLength;
433
        }
434
        return IntlDateFormatter::MEDIUM;
435
    }
436
437
    /**
438
     * Get length of the date format to use.
439
     * Only applicable with {@link setHTML5(false)}.
440
     *
441
     * @see http://php.net/manual/en/class.intldateformatter.php#intl.intldateformatter-constants
442
     *
443
     * @param int $length
444
     * @return $this
445
     */
446
    public function setDateLength($length)
447
    {
448
        $this->dateLength = $length;
449
        return $this;
450
    }
451
452
    /**
453
     * Get length of the date format to use. One of:
454
     *
455
     *  - IntlDateFormatter::SHORT
456
     *  - IntlDateFormatter::MEDIUM
457
     *  - IntlDateFormatter::LONG
458
     *  - IntlDateFormatter::FULL
459
     *
460
     * @see http://php.net/manual/en/class.intldateformatter.php#intl.intldateformatter-constants
461
     * @return int
462
     */
463
    public function getTimeLength()
464
    {
465
        if ($this->timeLength) {
466
            return $this->timeLength;
467
        }
468
        return IntlDateFormatter::MEDIUM;
469
    }
470
471
    /**
472
     * Get length of the date format to use.
473
     * Only applicable with {@link setHTML5(false)}.
474
     *
475
     * @see http://php.net/manual/en/class.intldateformatter.php#intl.intldateformatter-constants
476
     *
477
     * @param int $length
478
     * @return $this
479
     */
480
    public function setTimeLength($length)
481
    {
482
        $this->timeLength = $length;
483
        return $this;
484
    }
485
486
    public function setDisabled($bool)
487
    {
488
        parent::setDisabled($bool);
489
        return $this;
490
    }
491
492
    public function setReadonly($bool)
493
    {
494
        parent::setReadonly($bool);
495
        return $this;
496
    }
497
498
    /**
499
     * Set default locale for this field. If omitted will default to the current locale.
500
     *
501
     * @param string $locale
502
     * @return $this
503
     */
504
    public function setLocale($locale)
505
    {
506
        $this->locale = $locale;
507
        return $this;
508
    }
509
510
    /**
511
     * Get locale for this field
512
     *
513
     * @return string
514
     */
515
    public function getLocale()
516
    {
517
        return $this->locale ?: i18n::get_locale();
518
    }
519
520
    /**
521
     * @return string Date in ISO 8601 format, in server timezone.
522
     */
523
    public function getMinDatetime()
524
    {
525
        return $this->minDatetime;
526
    }
527
528
    /**
529
     * @param string $minDatetime A string in ISO 8601 format, in server timezone.
530
     * @return $this
531
     */
532
    public function setMinDatetime($minDatetime)
533
    {
534
        $this->minDatetime = $this->tidyInternal($minDatetime);
535
        return $this;
536
    }
537
538
    /**
539
     * @return string Date in ISO 8601 format, in server timezone.
540
     */
541
    public function getMaxDatetime()
542
    {
543
        return $this->maxDatetime;
544
    }
545
546
    /**
547
     * @param string $maxDatetime A string in ISO 8601 format, in server timezone.
548
     * @return $this
549
     */
550
    public function setMaxDatetime($maxDatetime)
551
    {
552
        $this->maxDatetime = $this->tidyInternal($maxDatetime);
553
        return $this;
554
    }
555
556
    /**
557
     * @param Validator $validator
558
     * @return bool
559
     */
560 View Code Duplication
    public function validate($validator)
0 ignored issues
show
Duplication introduced by
This method seems to be duplicated in your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
561
    {
562
        // Don't validate empty fields
563
        if (empty($this->rawValue)) {
564
            return true;
565
        }
566
567
        // We submitted a value, but it couldn't be parsed
568
        if (empty($this->value)) {
569
            $validator->validationError(
570
                $this->name,
571
                _t(
572
                    'DatetimeField.VALIDDATETIMEFORMAT',
573
                    "Please enter a valid date and time format ({format})",
574
                    ['format' => $this->getDatetimeFormat()]
575
                )
576
            );
577
            return false;
578
        }
579
580
        // Check min date (in server timezone)
581
        $min = $this->getMinDatetime();
582
        if ($min) {
583
            $oops = strtotime($this->value) < strtotime($min);
584
            if ($oops) {
585
                $validator->validationError(
586
                    $this->name,
587
                    _t(
588
                        'DatetimeField.VALIDDATETIMEMINDATE',
589
                        "Your date has to be newer or matching the minimum allowed date and time ({datetime})",
590
                        [
591
                            'datetime' => sprintf(
592
                                '<time datetime="%s">%s</time>',
593
                                $min,
594
                                $this->internalToFrontend($min)
595
                            )
596
                        ]
597
                    ),
598
                    ValidationResult::TYPE_ERROR,
599
                    ValidationResult::CAST_HTML
600
                );
601
                return false;
602
            }
603
        }
604
605
        // Check max date (in server timezone)
606
        $max = $this->getMaxDatetime();
607
        if ($max) {
608
            $oops = strtotime($this->value) > strtotime($max);
609
            if ($oops) {
610
                $validator->validationError(
611
                    $this->name,
612
                    _t(
613
                        'DatetimeField.VALIDDATEMAXDATETIME',
614
                        "Your date has to be older or matching the maximum allowed date and time ({datetime})",
615
                        [
616
                            'datetime' => sprintf(
617
                                '<time datetime="%s">%s</time>',
618
                                $max,
619
                                $this->internalToFrontend($max)
620
                            )
621
                        ]
622
                    ),
623
                    ValidationResult::TYPE_ERROR,
624
                    ValidationResult::CAST_HTML
625
                );
626
                return false;
627
            }
628
        }
629
630
        return true;
631
    }
632
633
    public function performReadonlyTransformation()
634
    {
635
        $field = clone $this;
636
        $field->setReadonly(true);
637
        return $field;
638
    }
639
640
    /**
641
     * @return string
642
     */
643
    public function getTimezone()
644
    {
645
        return $this->timezone;
646
    }
647
648
    /**
649
     * @param string $timezone
650
     * @return $this
651
     */
652 View Code Duplication
    public function setTimezone($timezone)
0 ignored issues
show
Duplication introduced by
This method seems to be duplicated in your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
653
    {
654
        if ($this->value && $timezone !== $this->timezone) {
655
            throw new \BadMethodCallException("Can't change timezone after setting a value");
656
        }
657
658
        $this->timezone = $timezone;
659
660
        return $this;
661
    }
662
}
663