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);
});