Completed
Pull Request — master (#6783)
by Ingo
08:19
created

DateField::getFrontendFormatter()   C

Complexity

Conditions 11
Paths 7

Size

Total Lines 38
Code Lines 21

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 11
eloc 21
nc 7
nop 0
dl 0
loc 38
rs 5.2653
c 0
b 0
f 0

How to fix   Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

1
<?php
2
3
namespace SilverStripe\Forms;
4
5
use IntlDateFormatter;
6
use SilverStripe\i18n\i18n;
7
use InvalidArgumentException;
8
use SilverStripe\ORM\FieldType\DBDate;
9
use SilverStripe\ORM\FieldType\DBDatetime;
10
use SilverStripe\ORM\ValidationResult;
11
12
/**
13
 * Form used for editing a date stirng
14
 *
15
 * Caution: The form field does not include any JavaScript or CSS when used outside of the CMS context,
16
 * since the required frontend dependencies are included through CMS bundling.
17
 *
18
 * # Localization
19
 *
20
 * Date formatting can be controlled in the below order of priority:
21
 *  - Format set via setDateFormat()
22
 *  - Format generated from current locale set by setLocale() and setDateLength()
23
 *  - Format generated from current locale in i18n
24
 *
25
 * You can also specify a setClientLocale() to set the javascript to a specific locale
26
 * on the frontend. However, this will not override the date format string.
27
 *
28
 * See http://doc.silverstripe.org/framework/en/topics/i18n for more information about localizing form fields.
29
 *
30
 * # Usage
31
 *
32
 * ## Example: Field localised with german date format
33
 *
34
 *   $f = new DateField('MyDate');
35
 *   $f->setLocale('de_DE');
36
 *
37
 * # Validation
38
 *
39
 * Caution: JavaScript validation is only supported for the 'en_NZ' locale at the moment,
40
 * it will be disabled automatically for all other locales.
41
 *
42
 * # Formats
43
 *
44
 * All format strings should follow the CLDR standard as per
45
 * http://userguide.icu-project.org/formatparse/datetime. These will be converted
46
 * automatically to jquery UI format.
47
 *
48
 * The value of this field in PHP will be ISO 8601 standard (e.g. 2004-02-12), and
49
 * stores this as a timestamp internally.
50
 *
51
 * Note: Do NOT use php date format strings. Date format strings follow the date
52
 * field symbol table as below.
53
 *
54
 * @see http://userguide.icu-project.org/formatparse/datetime
55
 * @see http://api.jqueryui.com/datepicker/#utility-formatDate
56
 */
57
class DateField extends TextField
58
{
59
    protected $schemaDataType = FormField::SCHEMA_DATA_TYPE_DATE;
60
61
    /**
62
     * Override locale. If empty will default to current locale
63
     *
64
     * @var string
65
     */
66
    protected $locale = null;
67
68
    /**
69
     * Override date format. If empty will default to that used by the current locale.
70
     *
71
     * @var null
72
     */
73
    protected $dateFormat = null;
74
75
    /**
76
     * Set if js calendar should popup
77
     *
78
     * @var bool
79
     */
80
    protected $showCalendar = false;
81
82
    /**
83
     * Length of this date (full, short, etc).
84
     *
85
     * @see http://php.net/manual/en/class.intldateformatter.php#intl.intldateformatter-constants
86
     * @var int
87
     */
88
    protected $dateLength = null;
89
90
    /**
91
     * Override locale for client side.
92
     *
93
     * @var string
94
     */
95
    protected $clientLocale = null;
96
97
    /**
98
     * Min date
99
     *
100
     * @var string ISO 8601 date for min date
101
     */
102
    protected $minDate = null;
103
104
    /**
105
     * Max date
106
     *
107
     * @var string ISO 860 date for max date
108
     */
109
    protected $maxDate = null;
110
111
    /**
112
     * Unparsed value, used exclusively for comparing with internal value
113
     * to detect invalid values.
114
     *
115
     * @var mixed
116
     */
117
    protected $rawValue = null;
118
119
    /**
120
     * Use HTML5-based input fields (and force ISO 8601 date formats).
121
     *
122
     * @var bool
123
     */
124
    protected $html5 = true;
125
126
    /**
127
     * @return bool
128
     */
129
    public function getHTML5()
130
    {
131
        return $this->html5;
132
    }
133
134
    /**
135
     * @param boolean $bool
136
     * @return $this
137
     */
138
    public function setHTML5($bool)
139
    {
140
        $this->html5 = $bool;
141
        return $this;
142
    }
143
144
    /**
145
     * Get length of the date format to use. One of:
146
     *
147
     *  - IntlDateFormatter::SHORT
148
     *  - IntlDateFormatter::MEDIUM
149
     *  - IntlDateFormatter::LONG
150
     *  - IntlDateFormatter::FULL
151
     *
152
     * @see http://php.net/manual/en/class.intldateformatter.php#intl.intldateformatter-constants
153
     * @return int
154
     */
155
    public function getDateLength()
156
    {
157
        if ($this->dateLength) {
158
            return $this->dateLength;
159
        }
160
        return IntlDateFormatter::MEDIUM;
161
    }
162
163
    /**
164
     * Get length of the date format to use.
165
     * Only applicable with {@link setHTML5(false)}.
166
     *
167
     * @see http://php.net/manual/en/class.intldateformatter.php#intl.intldateformatter-constants
168
     *
169
     * @param int $length
170
     * @return $this
171
     */
172
    public function setDateLength($length)
173
    {
174
        $this->dateLength = $length;
175
        return $this;
176
    }
177
178
    /**
179
     * Get date format in CLDR standard format
180
     *
181
     * This can be set explicitly. If not, this will be generated from the current locale
182
     * with the current date length.
183
     *
184
     * @see http://userguide.icu-project.org/formatparse/datetime#TOC-Date-Field-Symbol-Table
185
     */
186
    public function getDateFormat()
187
    {
188
        if ($this->getHTML5()) {
189
            // Browsers expect ISO 8601 dates, localisation is handled on the client
190
            $this->setDateFormat(DBDate::ISO_DATE);
191
        }
192
193
        if ($this->dateFormat) {
194
            return $this->dateFormat;
195
        }
196
197
        // Get from locale
198
        return $this->getFrontendFormatter()->getPattern();
199
    }
200
201
    /**
202
     * Set date format in CLDR standard format.
203
     * Only applicable with {@link setHTML5(false)}.
204
     *
205
     * @see http://userguide.icu-project.org/formatparse/datetime#TOC-Date-Field-Symbol-Table
206
     * @param string $format
207
     * @return $this
208
     */
209
    public function setDateFormat($format)
210
    {
211
        $this->dateFormat = $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 $dateFormat.

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...
212
        return $this;
213
    }
214
215
    /**
216
     * Get date formatter with the standard locale / date format
217
     *
218
     * @throws \LogicException
219
     * @return IntlDateFormatter
220
     */
221
    protected function getFrontendFormatter()
222
    {
223
        if ($this->getHTML5() && $this->dateFormat && $this->dateFormat !== DBDate::ISO_DATE) {
224
            throw new \LogicException(
225
                'Please opt-out of HTML5 processing of ISO 8601 dates via setHTML5(false) if using setDateFormat()'
226
            );
227
        }
228
229
        if ($this->getHTML5() && $this->dateLength) {
230
            throw new \LogicException(
231
                'Please opt-out of HTML5 processing of ISO 8601 dates via setHTML5(false) if using setDateLength()'
232
            );
233
        }
234
235
        if ($this->getHTML5() && $this->locale) {
236
            throw new \LogicException(
237
                'Please opt-out of HTML5 processing of ISO 8601 dates via setHTML5(false) if using setLocale()'
238
            );
239
        }
240
241
        $formatter = IntlDateFormatter::create(
242
            $this->getLocale(),
243
            $this->getDateLength(),
244
            IntlDateFormatter::NONE
245
        );
246
247
        if ($this->getHTML5()) {
248
            // Browsers expect ISO 8601 dates, localisation is handled on the client
249
            $formatter->setPattern(DBDate::ISO_DATE);
250
        } elseif ($this->dateFormat) {
251
            // Don't invoke getDateFormat() directly to avoid infinite loop
252
            $ok = $formatter->setPattern($this->dateFormat);
253
            if (!$ok) {
254
                throw new InvalidArgumentException("Invalid date format {$this->dateFormat}");
255
            }
256
        }
257
        return $formatter;
258
    }
259
260
    /**
261
     * Get a date formatter for the ISO 8601 format
262
     *
263
     * @return IntlDateFormatter
264
     */
265
    protected function getInternalFormatter()
266
    {
267
        $locale = i18n::config()->uninherited('default_locale');
268
        $formatter = IntlDateFormatter::create(
269
            i18n::config()->uninherited('default_locale'),
270
            IntlDateFormatter::MEDIUM,
271
            IntlDateFormatter::NONE
272
        );
273
        $formatter->setLenient(false);
274
        // CLDR ISO 8601 date.
275
        $formatter->setPattern(DBDate::ISO_DATE);
276
        return $formatter;
277
    }
278
279
    public function getAttributes()
280
    {
281
        $attributes = parent::getAttributes();
282
283
        $attributes['lang'] = i18n::convert_rfc1766($this->getLocale());
284
285
        if ($this->getHTML5()) {
286
            $attributes['type'] = 'date';
287
            $attributes['min'] = $this->getMinDate();
288
            $attributes['max'] = $this->getMaxDate();
289
        }
290
291
        return $attributes;
292
    }
293
294
    public function getSchemaDataDefaults()
295
    {
296
        $defaults = parent::getSchemaDataDefaults();
297
        return array_merge($defaults, [
298
            'lang' => i18n::convert_rfc1766($this->getLocale()),
299
            'data' => array_merge($defaults['data'], [
300
                'html5' => $this->getHTML5(),
301
                'min' => $this->getMinDate(),
302
                'max' => $this->getMaxDate()
303
            ])
304
        ]);
305
    }
306
307
    public function Type()
308
    {
309
        return 'date text';
310
    }
311
312
    /**
313
     * Assign value posted from form submission
314
     *
315
     * @param mixed $value
316
     * @param mixed $data
317
     * @return $this
318
     */
319
    public function setSubmittedValue($value, $data = null)
320
    {
321
        // Save raw value for later validation
322
        $this->rawValue = $value;
323
324
        // Null case
325
        if (!$value) {
326
            $this->value = null;
327
            return $this;
328
        }
329
330
        // Parse from submitted value
331
        $this->value = $this->frontendToInternal($value);
332
        return $this;
333
    }
334
335
    /**
336
     * Assign value based on {@link $datetimeFormat}, which might be localised.
337
     *
338
     * When $html5=true, assign value from ISO 8601 string.
339
     *
340
     * @param mixed $value
341
     * @param mixed $data
342
     * @return $this
343
     */
344
    public function setValue($value, $data = null)
345
    {
346
        // Save raw value for later validation
347
        $this->rawValue = $value;
348
349
        // Null case
350
        if (!$value) {
351
            $this->value = null;
352
            return $this;
353
        }
354
355
        // Re-run through formatter to tidy up (e.g. remove time component)
356
        $this->value = $this->tidyInternal($value);
357
        return $this;
358
    }
359
360
    public function Value()
361
    {
362
        return $this->internalToFrontend($this->value);
363
    }
364
365
    public function performReadonlyTransformation()
366
    {
367
        $field = $this->castedCopy(DateField_Disabled::class);
368
        $field->setValue($this->dataValue());
369
        $field->setReadonly(true);
370
        return $field;
371
    }
372
373
    /**
374
     * @param Validator $validator
375
     * @return bool
376
     */
377
    public function validate($validator)
378
    {
379
        // Don't validate empty fields
380
        if (empty($this->rawValue)) {
381
            return true;
382
        }
383
384
        // We submitted a value, but it couldn't be parsed
385
        if (empty($this->value)) {
386
            $validator->validationError(
387
                $this->name,
388
                _t(
389
                    'DateField.VALIDDATEFORMAT2',
390
                    "Please enter a valid date format ({format})",
391
                    ['format' => $this->getDateFormat()]
392
                )
393
            );
394
            return false;
395
        }
396
397
        // Check min date
398
        $min = $this->getMinDate();
399
        if ($min) {
400
            $oops = strtotime($this->value) < strtotime($min);
401
            if ($oops) {
402
                $validator->validationError(
403
                    $this->name,
404
                    _t(
405
                        'DateField.VALIDDATEMINDATE',
406
                        "Your date has to be newer or matching the minimum allowed date ({date})",
407
                        [
408
                            'date' => sprintf(
409
                                '<time datetime="%s">%s</time>',
410
                                $min,
411
                                $this->internalToFrontend($min)
412
                            )
413
                        ]
414
                    ),
415
                    ValidationResult::TYPE_ERROR,
416
                    ValidationResult::CAST_HTML
417
                );
418
                return false;
419
            }
420
        }
421
422
        // Check max date
423
        $max = $this->getMaxDate();
424
        if ($max) {
425
            $oops = strtotime($this->value) > strtotime($max);
426
            if ($oops) {
427
                $validator->validationError(
428
                    $this->name,
429
                    _t(
430
                        'DateField.VALIDDATEMAXDATE',
431
                        "Your date has to be older or matching the maximum allowed date ({date})",
432
                        [
433
                            'date' => sprintf(
434
                                '<time datetime="%s">%s</time>',
435
                                $max,
436
                                $this->internalToFrontend($max)
437
                            )
438
                        ]
439
                    ),
440
                    ValidationResult::TYPE_ERROR,
441
                    ValidationResult::CAST_HTML
442
                );
443
                return false;
444
            }
445
        }
446
447
        return true;
448
    }
449
450
    /**
451
     * Get locale to use for this field
452
     *
453
     * @return string
454
     */
455
    public function getLocale()
456
    {
457
        return $this->locale ?: i18n::get_locale();
458
    }
459
460
    /**
461
     * Determines the presented/processed format based on locale defaults,
462
     * instead of explicitly setting {@link setDateFormat()}.
463
     * Only applicable with {@link setHTML5(false)}.
464
     *
465
     * @param string $locale
466
     * @return $this
467
     */
468
    public function setLocale($locale)
469
    {
470
        $this->locale = $locale;
471
        return $this;
472
    }
473
474
    public function getSchemaValidation()
475
    {
476
        $rules = parent::getSchemaValidation();
477
        $rules['date'] = true;
478
        return $rules;
479
    }
480
481
    /**
482
     * @return string
483
     */
484
    public function getMinDate()
485
    {
486
        return $this->minDate;
487
    }
488
489
    /**
490
     * @param string $minDate
491
     * @return $this
492
     */
493
    public function setMinDate($minDate)
494
    {
495
        $this->minDate = $this->tidyInternal($minDate);
496
        return $this;
497
    }
498
499
    /**
500
     * @return string
501
     */
502
    public function getMaxDate()
503
    {
504
        return $this->maxDate;
505
    }
506
507
    /**
508
     * @param string $maxDate
509
     * @return $this
510
     */
511
    public function setMaxDate($maxDate)
512
    {
513
        $this->maxDate = $this->tidyInternal($maxDate);
514
        return $this;
515
    }
516
517
    /**
518
     * Convert frontend date to the internal representation (ISO 8601).
519
     * The frontend date is also in ISO 8601 when $html5=true.
520
     *
521
     * @param string $date
522
     * @return string The formatted date, or null if not a valid date
523
     */
524
    protected function frontendToInternal($date)
525
    {
526
        if (!$date) {
527
            return null;
528
        }
529
        $fromFormatter = $this->getFrontendFormatter();
530
        $toFormatter = $this->getInternalFormatter();
531
        $timestamp = $fromFormatter->parse($date);
532
        if ($timestamp === false) {
533
            return null;
534
        }
535
        return $toFormatter->format($timestamp) ?: null;
536
    }
537
538
    /**
539
     * Convert the internal date representation (ISO 8601) to a format used by the frontend,
540
     * as defined by {@link $dateFormat}. With $html5=true, the frontend date will also be
541
     * in ISO 8601.
542
     *
543
     * @param string $date
544
     * @return string The formatted date, or null if not a valid date
545
     */
546
    protected function internalToFrontend($date)
547
    {
548
        $date = $this->tidyInternal($date);
549
        if (!$date) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $date 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...
550
            return null;
551
        }
552
        $fromFormatter = $this->getInternalFormatter();
553
        $toFormatter = $this->getFrontendFormatter();
554
        $timestamp = $fromFormatter->parse($date);
555
        if ($timestamp === false) {
556
            return null;
557
        }
558
        return $toFormatter->format($timestamp) ?: null;
559
    }
560
561
    /**
562
     * Tidy up the internal date representation (ISO 8601),
563
     * and fall back to strtotime() if there's parsing errors.
564
     *
565
     * @param string $date Date in ISO 8601 or approximate form
566
     * @return string ISO 8601 date, or null if not valid
567
     */
568
    protected function tidyInternal($date)
569
    {
570
        if (!$date) {
571
            return null;
572
        }
573
        // Re-run through formatter to tidy up (e.g. remove time component)
574
        $formatter = $this->getInternalFormatter();
575
        $timestamp = $formatter->parse($date);
576
        if ($timestamp === false) {
577
            // Fallback to strtotime
578
            $timestamp = strtotime($date, DBDatetime::now()->getTimestamp());
579
            if ($timestamp === false) {
580
                return null;
581
            }
582
        }
583
        return $formatter->format($timestamp);
584
    }
585
}
586