diff --git a/composer.json b/composer.json index 1aa046e32..9acf463c5 100644 --- a/composer.json +++ b/composer.json @@ -31,6 +31,9 @@ "conflict": { "latte/latte": ">=3.1" }, + "suggest": { + "ext-intl": "to use date/time controls" + }, "autoload": { "classmap": ["src/"] }, diff --git a/src/Forms/Container.php b/src/Forms/Container.php index 2392a7b28..db6f5871d 100644 --- a/src/Forms/Container.php +++ b/src/Forms/Container.php @@ -405,6 +405,36 @@ public function addFloat(string $name, $label = null): Controls\TextInput } + /** + * Adds input for date selection. + * @param string|object|null $label + */ + public function addDate(string $name, $label = null): Controls\DateTimeControl + { + return $this[$name] = new Controls\DateTimeControl($label, Controls\DateTimeControl::TypeDate); + } + + + /** + * Adds input for time selection. + * @param string|object|null $label + */ + public function addTime(string $name, $label = null, bool $withSeconds = false): Controls\DateTimeControl + { + return $this[$name] = new Controls\DateTimeControl($label, Controls\DateTimeControl::TypeTime, $withSeconds); + } + + + /** + * Adds input for date and time selection. + * @param string|object|null $label + */ + public function addDateTime(string $name, $label = null, bool $withSeconds = false): Controls\DateTimeControl + { + return $this[$name] = new Controls\DateTimeControl($label, Controls\DateTimeControl::TypeDateTime, $withSeconds); + } + + /** * Adds control that allows the user to upload files. * @param string|object|null $label diff --git a/src/Forms/Controls/DateTimeControl.php b/src/Forms/Controls/DateTimeControl.php new file mode 100644 index 000000000..5227665f9 --- /dev/null +++ b/src/Forms/Controls/DateTimeControl.php @@ -0,0 +1,206 @@ +type = $type; + $this->withSeconds = $withSeconds; + parent::__construct($label); + $this->control->step = $withSeconds ? 1 : null; + $this->setOption('type', 'datetime'); + } + + + /** + * Format of returned value. Allowed values are string (ie 'Y-m-d'), DateTimeControl::FormatObject and DateTimeControl::FormatTimestamp. + * @return static + */ + public function setFormat(string $format) + { + $this->format = $format; + return $this; + } + + + /** + * @param \DateTimeInterface|string|int|null $value + * @return static + */ + public function setValue($value) + { + $this->value = $value === null || $value === '' + ? null + : $this->normalizeValue($value); + return $this; + } + + + /** + * @return \DateTimeImmutable|string|int|null + */ + public function getValue() + { + if ($this->format === self::FormatObject) { + return $this->value; + } elseif ($this->format === self::FormatTimestamp) { + return $this->value ? $this->value->getTimestamp() : null; + } else { + return $this->value ? $this->value->format($this->format) : null; + } + } + + + /** + * @param \DateTimeInterface|string|int $value + */ + private function normalizeValue($value): \DateTimeImmutable + { + if (is_numeric($value)) { + $dt = (new \DateTimeImmutable)->setTimestamp((int) $value); + } elseif (is_string($value) && $value !== '') { + $dt = new \DateTimeImmutable($value); // createFromFormat() must not be used because it allows invalid values + } elseif ($value instanceof \DateTime) { + $dt = \DateTimeImmutable::createFromMutable($value); + } elseif ($value instanceof \DateTimeImmutable) { + $dt = $value; + } elseif (!$value instanceof \DateTimeInterface) { + throw new Nette\InvalidArgumentException('Value must be DateTimeInterface or string or null, ' . gettype($value) . ' given.'); + } + + [$h, $m, $s] = [(int) $dt->format('H'), (int) $dt->format('i'), $this->withSeconds ? (int) $dt->format('s') : 0]; + if ($this->type === self::TypeDate) { + return $dt->setTime(0, 0); + } elseif ($this->type === self::TypeTime) { + return $dt->setDate(0, 1, 1)->setTime($h, $m, $s); + } elseif ($this->type === self::TypeDateTime) { + return $dt->setTime($h, $m, $s); + } + } + + + public function loadHttpData(): void + { + $value = $this->getHttpData(Nette\Forms\Form::DataText); + try { + $this->value = is_string($value) && preg_match('~^[\dT.:-]+$~', $value) + ? $this->normalizeValue($value) + : null; + } catch (\Throwable $e) { + $this->value = null; + } + } + + + public function getControl(): Nette\Utils\Html + { + return parent::getControl()->addAttributes([ + ...$this->getAttributesFromRules(), + 'value' => $this->value ? $this->formatHtmlValue($this->value) : null, + 'type' => [self::TypeDate => 'date', self::TypeTime => 'time', self::TypeDateTime => 'datetime-local'][$this->type], + ]); + } + + + /** + * Formats a date/time for HTML attributes. + * @param \DateTimeInterface|string|int $value + */ + public function formatHtmlValue($value): string + { + $value = $this->normalizeValue($value); + return $value->format([ + self::TypeDate => 'Y-m-d', + self::TypeTime => $this->withSeconds ? 'H:i:s' : 'H:i', + self::TypeDateTime => $this->withSeconds ? 'Y-m-d\\TH:i:s' : 'Y-m-d\\TH:i', + ][$this->type]); + } + + + /** + * Formats a date/time according to the locale and formatting options. + * @param \DateTimeInterface|string|int $value + */ + public function formatLocaleText($value): string + { + $value = $this->normalizeValue($value); + if ($this->type === self::TypeDate) { + return \IntlDateFormatter::formatObject($value, [\IntlDateFormatter::MEDIUM, \IntlDateFormatter::NONE]); + } elseif ($this->type === self::TypeTime) { + return \IntlDateFormatter::formatObject($value, [\IntlDateFormatter::NONE, $this->withSeconds ? \IntlDateFormatter::MEDIUM : \IntlDateFormatter::SHORT]); + } elseif ($this->type === self::TypeDateTime) { + return \IntlDateFormatter::formatObject($value, [\IntlDateFormatter::MEDIUM, $this->withSeconds ? \IntlDateFormatter::MEDIUM : \IntlDateFormatter::SHORT]); + } + } + + + private function getAttributesFromRules(): array + { + $attrs = []; + $format = function ($val) { + return is_scalar($val) || $val instanceof \DateTimeInterface + ? $this->formatHtmlValue($val) + : null; + }; + foreach ($this->getRules() as $rule) { + if ($rule->branch) { + } elseif (!$rule->canExport()) { + break; + } elseif ($rule->validator === Form::Min) { + $attrs['min'] = $format($rule->arg); + } elseif ($rule->validator === Form::Max) { + $attrs['max'] = $format($rule->arg); + } elseif ($rule->validator === Form::Range) { + $attrs['min'] = $format($rule->arg[0] ?? null); + $attrs['max'] = $format($rule->arg[1] ?? null); + } + } + return $attrs; + } + + + public function validateMinMax($min, $max): bool + { + $value = $this->formatHtmlValue($this->value); + $min = $min === null ? '00:00' : $this->formatHtmlValue($min); + $max = $max === null ? '23:59' : $this->formatHtmlValue($max); + return $this->type === self::TypeTime && $min > $max + ? $value >= $min || $value <= $max + : $value >= $min && $value <= $max; + } +} diff --git a/src/Forms/Helpers.php b/src/Forms/Helpers.php index c95da3eaa..ee39042af 100644 --- a/src/Forms/Helpers.php +++ b/src/Forms/Helpers.php @@ -141,14 +141,10 @@ public static function exportRules(Rules $rules): array if (is_array($rule->arg)) { $item['arg'] = []; foreach ($rule->arg as $key => $value) { - $item['arg'][$key] = $value instanceof Control - ? ['control' => $value->getHtmlName()] - : $value; + $item['arg'][$key] = self::exportArgument($value, $rule->control); } } elseif ($rule->arg !== null) { - $item['arg'] = $rule->arg instanceof Control - ? ['control' => $rule->arg->getHtmlName()] - : $rule->arg; + $item['arg'] = self::exportArgument($rule->arg, $rule->control); } $payload[] = $item; @@ -158,6 +154,18 @@ public static function exportRules(Rules $rules): array } + private static function exportArgument($value, Control $control) + { + if ($value instanceof Control) { + return ['control' => $value->getHtmlName()]; + } elseif ($control instanceof Controls\DateTimeControl) { + return $control->formatHtmlValue($value); + } else { + return $value; + } + } + + public static function createInputList( array $items, ?array $inputAttrs = null, diff --git a/src/Forms/Validator.php b/src/Forms/Validator.php index e17ebeaf5..c56d84482 100644 --- a/src/Forms/Validator.php +++ b/src/Forms/Validator.php @@ -92,9 +92,16 @@ public static function formatMessage(Rule $rule, bool $withValue = true) default: $args = is_array($rule->arg) ? $rule->arg : [$rule->arg]; $i = (int) $m[1] ? (int) $m[1] - 1 : $i + 1; - return isset($args[$i]) - ? ($args[$i] instanceof Control ? ($withValue ? $args[$i]->getValue() : "%$i") : $args[$i]) - : ''; + $arg = $args[$i] ?? null; + if ($arg === null) { + return ''; + } elseif ($arg instanceof Control) { + return $withValue ? $args[$i]->getValue() : "%$i"; + } elseif ($rule->control instanceof Controls\DateTimeControl) { + return $rule->control->formatLocaleText($arg); + } else { + return $arg; + } } }, $message); return $message; @@ -181,6 +188,9 @@ public static function validateValid(Controls\BaseControl $control): bool */ public static function validateRange(Control $control, array $range): bool { + if ($control instanceof Controls\DateTimeControl) { + return $control->validateMinMax($range[0] ?? null, $range[1] ?? null); + } $range = array_map(function ($v) { return $v === '' ? null : $v; }, $range); diff --git a/src/assets/netteForms.js b/src/assets/netteForms.js index 13e7c41e6..f9812abbc 100644 --- a/src/assets/netteForms.js +++ b/src/assets/netteForms.js @@ -508,6 +508,8 @@ range: function(elem, arg, val) { if (!Array.isArray(arg)) { return null; + } else if (elem.type === 'time' && arg[0] > arg[1]) { + return val >= arg[0] || val <= arg[1]; } return (arg[0] === null || Nette.validators.min(elem, arg[0], val)) && (arg[1] === null || Nette.validators.max(elem, arg[1], val)); diff --git a/tests/Forms/Controls.DateTimeControl.format.phpt b/tests/Forms/Controls.DateTimeControl.format.phpt new file mode 100644 index 000000000..c596814ee --- /dev/null +++ b/tests/Forms/Controls.DateTimeControl.format.phpt @@ -0,0 +1,44 @@ +addDate('date') + ->setValue('2023-10-22 10:30') + ->setFormat('j.n.Y'); + + Assert::same('22.10.2023', $input->getValue()); +}); + + +test('timestamp format', function () { + $form = new Form; + $input = $form->addDate('date') + ->setValue('2023-10-22 10:30') + ->setFormat(DateTimeControl::FormatTimestamp); + + Assert::same(1697925600, $input->getValue()); +}); + + +test('object format', function () { + $form = new Form; + $input = $form->addDate('date') + ->setValue('2023-10-22 10:30') + ->setFormat(DateTimeControl::FormatObject); + + Assert::equal(new DateTimeImmutable('2023-10-22 00:00'), $input->getValue()); +}); diff --git a/tests/Forms/Controls.DateTimeControl.loadData.phpt b/tests/Forms/Controls.DateTimeControl.loadData.phpt new file mode 100644 index 000000000..a5236bf07 --- /dev/null +++ b/tests/Forms/Controls.DateTimeControl.loadData.phpt @@ -0,0 +1,137 @@ +addDate('unknown'); + Assert::null($input->getValue()); + Assert::false($input->isFilled()); +}); + + +test('invalid data', function () { + $_POST = ['malformed' => ['']]; + $form = new Form; + $input = $form->addDate('malformed'); + Assert::null($input->getValue()); + Assert::false($input->isFilled()); +}); + + +test('invalid format', function () { + $_POST = ['text' => 'invalid']; + $form = new Form; + $input = $form->addDate('date'); + Assert::null($input->getValue()); + Assert::false($input->isFilled()); +}); + + +test('invalid date', function () { + $_POST = ['date' => '2023-13-22']; + $form = new Form; + $input = $form->addDate('date'); + Assert::null($input->getValue()); + Assert::false($input->isFilled()); +}); + + +test('invalid time', function () { + $_POST = ['time' => '10:60']; + $form = new Form; + $input = $form->addTime('time'); + Assert::null($input->getValue()); + Assert::false($input->isFilled()); +}); + + +test('empty date', function () { + $_POST = ['date' => '']; + $form = new Form; + $input = $form->addDate('date'); + Assert::null($input->getValue()); + Assert::false($input->isFilled()); +}); + + +test('empty time', function () { + $_POST = ['time' => '']; + $form = new Form; + $input = $form->addTime('time'); + Assert::null($input->getValue()); + Assert::false($input->isFilled()); +}); + + +test('empty date-time', function () { + $_POST = ['date' => '']; + $form = new Form; + $input = $form->addDateTime('date'); + Assert::null($input->getValue()); + Assert::false($input->isFilled()); +}); + + +test('valid date', function () { + $_POST = ['date' => '2023-10-22']; + $form = new Form; + $input = $form->addDate('date'); + Assert::equal(new DateTimeImmutable('2023-10-22 00:00'), $input->getValue()); + Assert::true($input->isFilled()); +}); + + +test('valid time', function () { + $_POST = ['time' => '10:22:33.44']; + $form = new Form; + $input = $form->addTime('time'); + Assert::equal(new DateTimeImmutable('0000-01-01 10:22'), $input->getValue()); + Assert::true($input->isFilled()); +}); + + +test('valid time with seconds', function () { + $_POST = ['time' => '10:22:33.44']; + $form = new Form; + $input = $form->addTime('time', null, true); + Assert::equal(new DateTimeImmutable('0000-01-01 10:22:33'), $input->getValue()); + Assert::true($input->isFilled()); +}); + + +test('valid date-time', function () { + $_POST = ['date' => '2023-10-22T10:23:11.123']; + $form = new Form; + $input = $form->addDateTime('date'); + Assert::equal(new DateTimeImmutable('2023-10-22 10:23:00'), $input->getValue()); + Assert::true($input->isFilled()); +}); + + +test('valid date-time with seconds', function () { + $_POST = ['date' => '2023-10-22T10:23:11.123']; + $form = new Form; + $input = $form->addDateTime('date', null, true); + Assert::equal(new DateTimeImmutable('2023-10-22 10:23:11'), $input->getValue()); + Assert::true($input->isFilled()); +}); diff --git a/tests/Forms/Controls.DateTimeControl.render.phpt b/tests/Forms/Controls.DateTimeControl.render.phpt new file mode 100644 index 000000000..8b7a03229 --- /dev/null +++ b/tests/Forms/Controls.DateTimeControl.render.phpt @@ -0,0 +1,116 @@ +addDate('date', 'label'); + + Assert::type(Html::class, $input->getControl()); + Assert::same('', (string) $input->getControl()); +}); + + +test('required date', function () { + $form = new Form; + $input = $form->addDate('date')->setRequired('required'); + + Assert::same('', (string) $input->getControl()); +}); + + +test('date: min & max validator', function () { + $form = new Form; + $input = $form->addDate('date') + ->addRule($form::Min, null, new DateTime('2020-01-01 11:22:33')) + ->addRule($form::Max, null, new DateTime('2040-01-01 11:22:33')); + + Assert::match('', (string) $input->getControl()); +}); + + +test('date: range validator', function () { + $form = new Form; + $input = $form->addDate('date') + ->addRule($form::Range, null, [new DateTime('2020-01-01 11:22:33'), new DateTime('2040-01-01 22:33:44')]); + + Assert::match('', (string) $input->getControl()); +}); + + +test('time: range validator', function () { + $form = new Form; + $input = $form->addTime('time') + ->addRule($form::Range, null, [new DateTime('2020-01-01 11:22:33'), new DateTime('2040-01-01 22:33:44')]); + + Assert::match('', (string) $input->getControl()); +}); + + +test('time with seconds: range validator', function () { + $form = new Form; + $input = $form->addTime('time', null, true) + ->addRule($form::Range, null, [new DateTime('2020-01-01 11:22:33'), new DateTime('2040-01-01 22:33:44')]); + + Assert::match('', (string) $input->getControl()); +}); + + +test('date with value', function () { + $form = new Form; + $input = $form->addDate('date') + ->setValue(new Nette\Utils\DateTime('2023-10-22')); + + Assert::same('', (string) $input->getControl()); +}); + + +test('time with value', function () { + $form = new Form; + $input = $form->addTime('time') + ->setValue(new Nette\Utils\DateTime('07:05')); + + Assert::same('', (string) $input->getControl()); +}); + + +test('date-time with value', function () { + $form = new Form; + $input = $form->addDateTime('date') + ->setValue(new Nette\Utils\DateTime('2023-10-22 07:05')); + + Assert::same('', (string) $input->getControl()); +}); + + +test('dynamic validation', function () { + $form = new Form; + $text = $form->addText('text'); + $input = $form->addDateTime('date') + ->addRule($form::Min, null, $text); + + Assert::same('', (string) $input->getControl()); +}); + + +test('filter in rules', function () { + $form = new Form; + $input = $form->addDateTime('date'); + $input->getRules() + ->addFilter(function () {}) + ->addRule($form::Min, null, new DateTime('2020-01-01 11:22:33')); + + Assert::same('', (string) $input->getControl()); +}); diff --git a/tests/Forms/Controls.DateTimeControl.value.phpt b/tests/Forms/Controls.DateTimeControl.value.phpt new file mode 100644 index 000000000..557237069 --- /dev/null +++ b/tests/Forms/Controls.DateTimeControl.value.phpt @@ -0,0 +1,124 @@ +addDate('date'); + + Assert::exception(function () use ($input) { + $input->setValue([]); + }, Nette\InvalidArgumentException::class, 'Value must be DateTimeInterface or string or null, array given.'); +}); + + +test('empty string', function () { + $form = new Form; + $input = $form->addDate('date') + ->setValue(''); + + Assert::null($input->getValue()); +}); + + +test('date as string', function () { + $form = new Form; + $input = $form->addDate('date') + ->setValue('2013-07-05 10:30'); + + Assert::equal(new DateTimeImmutable('2013-07-05 00:00'), $input->getValue()); +}); + + +test('date as string timestamp', function () { + $form = new Form; + $input = $form->addDate('date') + ->setValue('254400000'); + + Assert::equal(new DateTimeImmutable('1978-01-23 00:00'), $input->getValue()); +}); + + +test('date as int timestamp', function () { + $form = new Form; + $input = $form->addDate('date') + ->setValue(254400000); + + Assert::equal(new DateTimeImmutable('1978-01-23 00:00'), $input->getValue()); +}); + + +test('date as DateTime object', function () { + $form = new Form; + $input = $form->addDate('date') + ->setValue(new Nette\Utils\DateTime('2023-10-05 11:22:33.44')); + + Assert::equal(new DateTimeImmutable('2023-10-05 00:00'), $input->getValue()); +}); + + +test('time as DateTime object', function () { + $form = new Form; + $input = $form->addTime('time') + ->setValue(new Nette\Utils\DateTime('2023-10-05 11:22:33.44')); + + Assert::equal(new DateTimeImmutable('0000-01-01 11:22'), $input->getValue()); +}); + + +test('date-time as DateTime object', function () { + $form = new Form; + $input = $form->addDateTime('time') + ->setValue(new Nette\Utils\DateTime('2023-10-05 11:22:33.44')); + + Assert::equal(new DateTimeImmutable('2023-10-05 11:22'), $input->getValue()); +}); + + +test('date-time with seconds as DateTime object', function () { + $form = new Form; + $input = $form->addDateTime('time', null, true) + ->setValue(new Nette\Utils\DateTime('2023-10-05 11:22:33.44')); + + Assert::equal(new DateTimeImmutable('2023-10-05 11:22:33'), $input->getValue()); +}); + + +test('range datetime validation', function () { + $form = new Form; + $input = $form->addDateTime('time', null, true) + ->setValue(new DateTime('2023-10-05')); + + Assert::true(Validator::validateRange($input, [new DateTime('2023-09-05'), new DateTime('2023-11-05')])); + Assert::false(Validator::validateRange($input, [new DateTime('2023-11-05'), new DateTime('2023-09-05')])); + Assert::true(Validator::validateRange($input, ['2023-09-05', '2023-11-05'])); + Assert::false(Validator::validateRange($input, ['2023-11-05', '2023-09-05'])); +}); + + +test('range time validation', function () { + $form = new Form; + $input = $form->addTime('time', null, true) + ->setValue(new DateTime('12:30')); + + Assert::true(Validator::validateRange($input, [new DateTime('12:30'), new DateTime('14:00')])); + Assert::false(Validator::validateRange($input, [new DateTime('13:00'), new DateTime('14:00')])); + Assert::true(Validator::validateRange($input, ['12:30', '14:00'])); + Assert::false(Validator::validateRange($input, ['13:00', '14:00'])); + + // cross midnight + Assert::true(Validator::validateRange($input, ['21:00', '13:00'])); + Assert::false(Validator::validateRange($input, ['21:00', '12:00'])); +}); diff --git a/tests/netteForms/spec/Nette.validatorsSpec.js b/tests/netteForms/spec/Nette.validatorsSpec.js index 555b5befc..ce1a4d11f 100644 --- a/tests/netteForms/spec/Nette.validatorsSpec.js +++ b/tests/netteForms/spec/Nette.validatorsSpec.js @@ -64,6 +64,10 @@ describe('Nette.validators', function() { expect(Nette.validators.range(el, ['10:30', '14:00'], '12:30')).toBe(true); expect(Nette.validators.range(el, ['10:30', '14:00'], '09:30')).toBe(false); expect(Nette.validators.range(el, ['14:00', '10:30'], '12:30')).toBe(false); + + el.type = 'time'; + expect(Nette.validators.range(el, ['14:00', '10:30'], '12:30')).toBe(false); + expect(Nette.validators.range(el, ['14:00', '10:30'], '09:00')).toBe(true); });