Completed
Pull Request — master (#6766)
by Ingo
08:55
created

TimeField::getHTML5()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 4
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
eloc 2
nc 1
nop 0
dl 0
loc 4
rs 10
c 0
b 0
f 0
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\FieldType\DBTime;
10
11
/**
12
 * Form field to display editable time values in an <input type="text"> field.
13
 *
14
 * # Localization
15
 *
16
 * See {@link DateField}
17
 *
18
 * @todo Timezone support
19
 */
20
class TimeField extends TextField
21
{
22
    protected $schemaDataType = FormField::SCHEMA_DATA_TYPE_TIME;
23
24
    /**
25
     * Override locale. If empty will default to current locale
26
     *
27
     * @var string
28
     */
29
    protected $locale = null;
30
31
    /**
32
     * Override time format. If empty will default to that used by the current locale.
33
     *
34
     * @var string
35
     */
36
    protected $timeFormat = null;
37
38
    /**
39
     * Length of this date (full, short, etc).
40
     *
41
     * @see http://php.net/manual/en/class.intldateformatter.php#intl.intldateformatter-constants
42
     * @var int
43
     */
44
    protected $timeLength = null;
45
46
    /**
47
     * Unparsed value, used exclusively for comparing with internal value
48
     * to detect invalid values.
49
     *
50
     * @var mixed
51
     */
52
    protected $rawValue = null;
53
54
    /**
55
     * Set custom timezone
56
     *
57
     * @var string
58
     */
59
    protected $timezone = null;
60
61
    /**
62
     * Use HTML5-based input fields (and force ISO 8601 time formats).
63
     *
64
     * @var bool
65
     */
66
    protected $html5 = true;
67
68
    /**
69
     * @return bool
70
     */
71
    public function getHTML5()
72
    {
73
        return $this->html5;
74
    }
75
76
    /**
77
     * @param boolean $bool
78
     * @return $this
79
     */
80
    public function setHTML5($bool)
81
    {
82
        $this->html5 = $bool;
83
        return $this;
84
    }
85
86
    /**
87
     * Get time format in CLDR standard format
88
     *
89
     * This can be set explicitly. If not, this will be generated from the current locale
90
     * with the current time length.
91
     *
92
     * @see http://userguide.icu-project.org/formatparse/datetime#TOC-Date-Field-Symbol-Table
93
     */
94
    public function getTimeFormat()
95
    {
96
        if ($this->getHTML5()) {
97
            // Browsers expect ISO 8601 times, localisation is handled on the client
98
            $this->setTimeFormat(DBTime::ISO_TIME);
99
        }
100
101
        if ($this->timeFormat) {
102
            return $this->timeFormat;
103
        }
104
105
        // Get from locale
106
        return $this->getFormatter()->getPattern();
107
    }
108
109
    /**
110
     * Set time format in CLDR standard format.
111
     * Only applicable with {@link setHTML5(false)}.
112
     *
113
     * @see http://userguide.icu-project.org/formatparse/datetime#TOC-Date-Field-Symbol-Table
114
     * @param string $format
115
     * @return $this
116
     */
117
    public function setTimeFormat($format)
118
    {
119
        $this->timeFormat = $format;
120
        return $this;
121
    }
122
123
    /**
124
     * Get length of the time format to use. One of:
125
     *
126
     *  - IntlDateFormatter::SHORT E.g. '6:31 PM'
127
     *  - IntlDateFormatter::MEDIUM E.g. '6:30:48 PM'
128
     *  - IntlDateFormatter::LONG E.g. '6:32:09 PM NZDT'
129
     *  - IntlDateFormatter::FULL E.g. '6:32:24 PM New Zealand Daylight Time'
130
     *
131
     * @see http://php.net/manual/en/class.intldateformatter.php#intl.intldateformatter-constants
132
     * @return int
133
     */
134
    public function getTimeLength()
135
    {
136
        if ($this->timeLength) {
137
            return $this->timeLength;
138
        }
139
        return IntlDateFormatter::MEDIUM;
140
    }
141
142
    /**
143
     * Get length of the time format to use.
144
     * Only applicable with {@link setHTML5(false)}.
145
     *
146
     * @see http://php.net/manual/en/class.intldateformatter.php#intl.intldateformatter-constants
147
     *
148
     * @param int $length
149
     * @return $this
150
     */
151
    public function setTimeLength($length)
152
    {
153
        $this->timeLength = $length;
154
        return $this;
155
    }
156
157
    /**
158
     * Get time formatter with the standard locale / date format
159
     *
160
     * @return IntlDateFormatter
161
     */
162
    protected function getFormatter()
163
    {
164
        if ($this->getHTML5() && $this->timeFormat && $this->timeFormat !== DBTime::ISO_TIME) {
165
            throw new \LogicException(
166
                'Please opt-out of HTML5 processing of ISO 8601 times via setHTML5(false) if using setTimeFormat()'
167
            );
168
        }
169
170
        if ($this->getHTML5() && $this->timeLength) {
171
            throw new \LogicException(
172
                'Please opt-out of HTML5 processing of ISO 8601 times via setHTML5(false) if using setTimeLength()'
173
            );
174
        }
175
176
        if ($this->getHTML5() && $this->locale) {
177
            throw new \LogicException(
178
                'Please opt-out of HTML5 processing of ISO 8601 times via setHTML5(false) if using setLocale()'
179
            );
180
        }
181
182
        $formatter =  IntlDateFormatter::create(
183
            $this->getLocale(),
184
            IntlDateFormatter::NONE,
185
            $this->getTimeLength(),
186
            $this->getTimezone()
187
        );
188
189
        if ($this->getHTML5()) {
190
            // Browsers expect ISO 8601 times, localisation is handled on the client
191
            $formatter->setPattern(DBTime::ISO_TIME);
192
            // Don't invoke getTimeFormat() directly to avoid infinite loop
193
        } elseif ($this->timeFormat) {
194
            $ok = $formatter->setPattern($this->timeFormat);
195
            if (!$ok) {
196
                throw new InvalidArgumentException("Invalid time format {$this->timeFormat}");
197
            }
198
        }
199
        return $formatter;
200
    }
201
202
    /**
203
     * Get a time formatter for the ISO 8601 format
204
     *
205
     * @return IntlDateFormatter
206
     */
207
    protected function getISO8601Formatter()
208
    {
209
        $formatter = IntlDateFormatter::create(
210
            i18n::config()->uninherited('default_locale'),
211
            IntlDateFormatter::NONE,
212
            IntlDateFormatter::MEDIUM,
213
            date_default_timezone_get() // Default to server timezone
214
        );
215
        $formatter->setLenient(false);
216
217
        // Note we omit timezone from this format, and we assume server TZ always.
218
        $formatter->setPattern(DBTime::ISO_TIME);
219
220
        return $formatter;
221
    }
222
223
    public function getAttributes()
224
    {
225
        $attributes = parent::getAttributes();
226
227
        if ($this->getHTML5()) {
228
            $attributes['type'] = 'time';
229
        }
230
231
        return $attributes;
232
    }
233
234
    public function Type()
235
    {
236
        return 'time text';
237
    }
238
239
    /**
240
     * Assign value posted from form submission
241
     *
242
     * @param mixed $value
243
     * @param mixed $data
244
     * @return $this
245
     */
246
    public function setSubmittedValue($value, $data = null)
247
    {
248
        // Save raw value for later validation
249
        $this->rawValue = $value;
250
251
        // Parse from submitted value
252
        $this->value = $this->localisedToISO8601($value);
253
        return $this;
254
    }
255
256
    /**
257
     * Set time assigned from database value
258
     *
259
     * @param mixed $value
260
     * @param mixed $data
261
     * @return $this
262
     */
263
    public function setValue($value, $data = null)
264
    {
265
        // Save raw value for later validation
266
        $this->rawValue = $value;
267
268
        // Null case
269
        if (!$value) {
270
            $this->value = null;
271
            return $this;
272
        }
273
274
        // Re-run through formatter to tidy up (e.g. remove date component)
275
        $this->value = $this->tidyISO8601($value);
276
        return $this;
277
    }
278
279
    public function Value()
280
    {
281
        $localised = $this->iso8601ToLocalised($this->value);
282
        if ($localised) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $localised of type null|string is loosely compared to true; 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...
283
            return $localised;
284
        }
285
286
        // Show midnight in localised format
287
        return $this->getMidnight();
288
    }
289
290
    /**
291
     * Show midnight in current format (adjusts for timezone)
292
     *
293
     * @return string
294
     */
295
    public function getMidnight()
296
    {
297
        $formatter = $this->getFormatter();
298
        $timestamp = $this->withTimezone($this->getTimezone(), function () {
299
            return strtotime('midnight');
300
        });
301
        return $formatter->format($timestamp);
302
    }
303
304
    /**
305
     * Validate this field
306
     *
307
     * @param Validator $validator
308
     * @return bool
309
     */
310
    public function validate($validator)
311
    {
312
        // Don't validate empty fields
313
        if (empty($this->rawValue)) {
314
            return true;
315
        }
316
317
        // We submitted a value, but it couldn't be parsed
318
        if (empty($this->value)) {
319
            $validator->validationError(
320
                $this->name,
321
                _t(
322
                    'TimeField.VALIDATEFORMAT',
323
                    "Please enter a valid time format ({format})",
324
                    ['format' => $this->getTimeFormat()]
325
                )
326
            );
327
            return false;
328
        }
329
        return true;
330
    }
331
332
    /**
333
     * @return string
334
     */
335
    public function getLocale()
336
    {
337
        return $this->locale ?: i18n::get_locale();
338
    }
339
340
    /**
341
     * Determines the presented/processed format based on locale defaults,
342
     * instead of explicitly setting {@link setTimeFormat()}.
343
     * Only applicable with {@link setHTML5(false)}.
344
     *
345
     * @param string $locale
346
     * @return $this
347
     */
348
    public function setLocale($locale)
349
    {
350
        $this->locale = $locale;
351
        return $this;
352
    }
353
354
    /**
355
     * Creates a new readonly field specified below
356
     *
357
     * @return TimeField_Readonly
358
     */
359
    public function performReadonlyTransformation()
360
    {
361
        /** @var TimeField_Readonly $result */
362
        $result = $this->castedCopy(TimeField_Readonly::class);
363
        return $result;
364
    }
365
366
    /**
367
     * Convert time localised in the current locale to ISO 8601 time
368
     *
369
     * @param string $time
370
     * @return string The formatted time, or null if not a valid time
371
     */
372
    public function localisedToISO8601($time)
373
    {
374
        if (!$time) {
375
            return null;
376
        }
377
        $fromFormatter = $this->getFormatter();
378
        $toFormatter = $this->getISO8601Formatter();
379
        $timestamp = $fromFormatter->parse($time);
380
381
        // Try to parse time without seconds, since that's a valid HTML5 submission format
382
        // See https://html.spec.whatwg.org/multipage/infrastructure.html#times
383
        if ($timestamp === false && $this->getHTML5()) {
384
            $fromFormatter->setPattern('HH:mm');
385
            $timestamp = $fromFormatter->parse($time);
386
        }
387
388
        // If timestamp still can't be detected, we've got an invalid time
389
        if ($timestamp === false) {
390
            return null;
391
        }
392
393
        return $toFormatter->format($timestamp);
394
    }
395
396
    /**
397
     * Format iso time to localised form
398
     *
399
     * @param string $time
400
     * @return string
401
     */
402
    public function iso8601ToLocalised($time)
403
    {
404
        $time = $this->tidyISO8601($time);
405
        if (!$time) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $time 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...
406
            return null;
407
        }
408
        $fromFormatter = $this->getISO8601Formatter();
409
        $toFormatter = $this->getFormatter();
410
        $timestamp = $fromFormatter->parse($time);
411
        if ($timestamp === false) {
412
            return null;
413
        }
414
        return $toFormatter->format($timestamp);
415
    }
416
417
418
419
    /**
420
     * Tidy up iso8601-ish time, or approximation
421
     *
422
     * @param string $time Time in iso8601 or approximate form
423
     * @return string iso8601 time, or null if not valid
424
     */
425
    public function tidyISO8601($time)
426
    {
427
        if (!$time) {
428
            return null;
429
        }
430
        // Re-run through formatter to tidy up (e.g. remove date component)
431
        $formatter = $this->getISO8601Formatter();
432
        $timestamp = $formatter->parse($time);
433
        if ($timestamp === false) {
434
            // Fallback to strtotime
435
            $timestamp = strtotime($time, DBDatetime::now()->getTimestamp());
436
            if ($timestamp === false) {
437
                return null;
438
            }
439
        }
440
        return $formatter->format($timestamp);
441
    }
442
443
    /**
444
     * @return string
445
     */
446
    public function getTimezone()
447
    {
448
        return $this->timezone;
449
    }
450
451
    /**
452
     * @param string $timezone
453
     * @return $this
454
     */
455
    public function setTimezone($timezone)
456
    {
457
        if ($this->value && $timezone !== $this->timezone) {
458
            throw new \BadMethodCallException("Can't change timezone after setting a value");
459
        }
460
        $this->timezone = $timezone;
461
        return $this;
462
    }
463
464
465
    /**
466
     * Run a callback within a specific timezone
467
     *
468
     * @param string $timezone
469
     * @param callable $callback
470
     */
471
    protected function withTimezone($timezone, $callback)
472
    {
473
        $currentTimezone = date_default_timezone_get();
474
        try {
475
            if ($timezone) {
476
                date_default_timezone_set($timezone);
477
            }
478
            return $callback();
479
        } finally {
480
            // Restore timezone
481
            if ($timezone) {
482
                date_default_timezone_set($currentTimezone);
483
            }
484
        }
485
    }
486
}
487