Relax time input format requirements (#5504)

* remove locale support from time-input, only allow 12 and 24 hour format
* added blur listeners to parse all kinds of time inputs
* fix duration inputs like ":9" for 9 minutes
This commit is contained in:
Kevin Papst
2025-05-28 09:36:29 +02:00
committed by GitHub
parent e9c172daea
commit 81107377f4
9 changed files with 361 additions and 17 deletions

View File

@@ -41,11 +41,15 @@ export default class KimaiTimesheetForm extends KimaiFormPlugin {
if (this._beginTime !== undefined) {
this._beginTime.removeEventListener('change', this._beginListener);
delete this._beginTime;
this._beginTime.removeEventListener('blur', this._beginBlurListener);
delete this._beginBlurListener;
}
if (this._endTime !== undefined) {
this._endTime.removeEventListener('change', this._endListener);
delete this._endTime;
this._endTime.removeEventListener('blur', this._endBlurListener);
delete this._endBlurListener;
}
if (this._duration !== undefined) {
@@ -53,6 +57,8 @@ export default class KimaiTimesheetForm extends KimaiFormPlugin {
delete this._durationListener;
this._duration.removeEventListener('keydown', this._durationKeyListener);
delete this._durationKeyListener;
this._duration.removeEventListener('blur', this._durationBlurListener);
delete this._durationBlurListener;
delete this._duration;
}
@@ -110,15 +116,21 @@ export default class KimaiTimesheetForm extends KimaiFormPlugin {
}
this._beginListener = () => this._changedBegin();
this._beginBlurListener = () => this._parseBeginTime();
this._endListener = () => this._changedEnd();
this._endBlurListener = () => this._parseEndTime();
this._durationListener = () => this._changedDuration();
this._durationKeyListener = (event) => this._changeDurationOnKeypress(event);
this._durationBlurListener = () => this._parseDuration();
this._beginDate.addEventListener('change', this._beginListener);
this._beginTime.addEventListener('change', this._beginListener);
this._beginTime.addEventListener('blur', this._beginBlurListener);
this._endTime.addEventListener('change', this._endListener);
this._endTime.addEventListener('blur', this._endBlurListener);
this._duration.addEventListener('change', this._durationListener);
this._duration.addEventListener('keydown', this._durationKeyListener);
this._duration.addEventListener('blur', this._durationBlurListener);
if (this._duration !== null && this._durationToggle !== null) {
this._durationToggleListener = () => {
@@ -128,6 +140,147 @@ export default class KimaiTimesheetForm extends KimaiFormPlugin {
}
}
_parseBeginTime()
{
let newBeginTime = this._formatTimeForParsing(this._beginTime.value, this._beginTime.dataset['format']);
if (newBeginTime !== this._beginTime.value) {
this._beginTime.value = newBeginTime;
this._changedBegin();
}
}
_parseEndTime()
{
let newEndTime = this._formatTimeForParsing(this._endTime.value, this._endTime.dataset['format']);
if (newEndTime !== this._endTime.value) {
this._endTime.value = newEndTime;
this._changedEnd();
}
}
_parseDuration()
{
this._setDurationAsString(this._getParsedDuration());
}
/**
* Receives a time, written by a human, probably in an invalid format.
* This method supports 12-hour or 24-hour format, the format string contains an uppercase "A" in case of the 12-hour format.
* If it is 12-hour format, then always en-US locallized with AM/PM.
*
* Ruleset:
* - Some locales use a dot instead of a colon, always replace the dot in HH.mm with a colon as in HH:mm
* - If there is an "am" or "pm", always uppercase them
* - Split the string into time and prefix: if AM/PM is included remove it and remember for later
* - If the time is a 1 or 2 character long number: use as hours
* - If the time now is 3 character long: use the 1 char as hour and the 2 and 3 char as minute
* - If the time now is 4 character long: use the 1 and 2 char as hour and the 3 and 4 char as minutes
* - If the format is 12-hour: try to identify the correct time and suffix
* - If the format is 12-hour and misses the AM/PM: try to detect whether it
* - If the time contains AM or PM, make sure that it is always prefixed by a space character
*
* @param {string} time
* @param {string} format
* @returns {string}
* @private
*/
_formatTimeForParsing(time, format)
{
let formatted = time.trim();
// replace dot with colon
formatted = formatted.replace(/\./g, ':');
// uppercase 12-hour format
formatted = formatted.replace(/am/i, 'AM');
formatted = formatted.replace(/pm/i, 'PM');
// Split time and AM/PM suffix if present
let suffix = '';
let hour = 0;
let minute = 0;
let timePart = formatted;
const ampmMatch = formatted.match(/\s*(AM|PM)$/i);
if (ampmMatch) {
suffix = ampmMatch[1].toUpperCase();
timePart = formatted.replace(/\s*(AM|PM)$/i, '').trim();
}
if (timePart.indexOf(':') !== -1) {
const match = timePart.match(/(?:(\d+):)?(\d+)/);
hour = parseInt(match?.[1] || 0, 10);
minute = parseInt(match?.[2] || 0, 10);
} else {
timePart = timePart.replace(/:/, '');
if (/^\d{1,2}$/.test(timePart)) {
hour = timePart;
}
if (/^\d{3}$/.test(timePart)) {
hour = timePart.slice(0, 1);
minute = timePart.slice(1);
}
if (/^\d{4}$/.test(timePart)) {
hour = timePart.slice(0, 2);
minute = timePart.slice(2);
}
}
hour = parseInt(hour);
minute = parseInt(minute);
// just in case a person entered a wrong time like 35 hours
hour = hour % 24;
minute = minute % 60;
// format is 12-hour
if (format.toUpperCase().indexOf('A') !== -1) {
// time entered in 24-hour: convert to 12-hour format
if (hour > 12 && hour < 24) {
hour = hour - 12;
suffix = 'PM';
}
// if the person forgot to add a suffix, calculate it and convert time
if (suffix === '') {
if (hour === 0) {
hour = 12;
suffix = 'AM';
} else if (hour === 12) {
suffix = 'PM';
} else {
suffix = 'AM';
}
}
if (suffix === 'PM' && hour === 0) {
hour = 12;
}
} else {
// this is the 34-hour format branch
// check if the person entered time in 12-hour format and convert it
if (suffix === 'AM' && hour === 12) {
hour = 0;
} else if (suffix === 'PM' && hour !== 12) {
hour = (hour + 12) % 24;
}
// make sure we have no suffix
suffix = '';
}
formatted = hour + ':' + minute.toString().padStart(2, '0');
if (suffix !== '') {
formatted = formatted + ' ' + suffix.trim();
}
return formatted;
}
_isDurationConnected()
{
if (this._duration === null && this._durationToggle === null) {

View File

@@ -273,7 +273,10 @@ export default class KimaiDateUtils extends KimaiPlugin {
let luxonDuration = null;
if (duration.indexOf(':') !== -1) {
const [, hours, minutes, seconds] = duration.match(/(\d+):(\d+)(?::(\d+))*/);
const match = duration.match(/(?:(\d+):)?(\d+)(?::(\d+))?/);
const hours = parseInt(match?.[1] || 0, 10);
const minutes = parseInt(match?.[2] || 0, 10);
const seconds = parseInt(match?.[3] || 0, 10);
luxonDuration = Duration.fromObject({hours: hours, minutes: minutes, seconds: seconds});
} else if (duration.indexOf('.') !== -1 || duration.indexOf(',') !== -1) {
duration = duration.replace(/,/, '.');

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,173 @@
/*!
* Bootstrap v5.3.3 (https://getbootstrap.com/)
* Copyright 2011-2024 The Bootstrap Authors (https://github.com/twbs/bootstrap/graphs/contributors)
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
*/
/*!
*
* litepicker.umd.js
* Litepicker v2.0.12 (https://github.com/wakirin/Litepicker)
* Package: litepicker (https://www.npmjs.com/package/litepicker)
* License: MIT (https://github.com/wakirin/Litepicker/blob/master/LICENCE.md)
* Copyright 2019-2021 Rinat G.
*
* Hash: 504eef9c08cb42543660
*
*/
/*!
*
* plugins/mobilefriendly.js
* Litepicker v2.0.12 (https://github.com/wakirin/Litepicker)
* Package: litepicker (https://www.npmjs.com/package/litepicker)
* License: MIT (https://github.com/wakirin/Litepicker/blob/master/LICENCE.md)
* Copyright 2019-2021 Rinat G.
*
* Hash: b9a648207aabe31b2912
*
*/
/*!
* [KIMAI] KimaiAPI: easy access to API methods
*/
/*!
* [KIMAI] KimaiActiveRecords: responsible to display the users active records
*/
/*!
* [KIMAI] KimaiAjaxModalForm
*
* allows to assign the given selector to any element, which then is used as click-handler:
* opening a modal with the content from the URL given in the elements 'data-href' or 'href' attribute
*/
/*!
* [KIMAI] KimaiAlert: notifications for Kimai
*/
/*!
* [KIMAI] KimaiAlternativeLinks
*
* allows to assign the given selector to any element, which then is used as click-handler
* redirecting to the URL given in the elements 'data-href' or 'href' attribute
*/
/*!
* [KIMAI] KimaiColor: handle colors
*/
/*!
* [KIMAI] KimaiConfiguration: handling all configuration and runtime settings
*/
/*!
* [KIMAI] KimaiContainer
*
* ServiceContainer for Kimai
*/
/*!
* [KIMAI] KimaiContextMenu: help to create, position and display context menus
*/
/*!
* [KIMAI] KimaiDatatable: handles functionality for the datatable
*/
/*!
* [KIMAI] KimaiDatatableColumnView: manages the visibility of data-table columns in cookies
*/
/*!
* [KIMAI] KimaiDatePicker: single date selects (currently unused)
*/
/*!
* [KIMAI] KimaiDateRangePicker: activate the (daterange picker) compound field in toolbar
*/
/*!
* [KIMAI] KimaiDateUtils: responsible for handling date specific tasks
*/
/*!
* [KIMAI] KimaiEditTimesheetForm: responsible for the most important form in the application
*/
/*!
* [KIMAI] KimaiEscape: sanitize strings
*/
/*!
* [KIMAI] KimaiEvent: helper to trigger events
*/
/*!
* [KIMAI] KimaiForm: basic functions for all forms
*/
/*!
* [KIMAI] KimaiFormPlugin: base class for all none ID plugin that handle forms
*/
/*!
* [KIMAI] KimaiFormSelect: enhanced functionality for HTMLSelectElement
*/
/*!
* [KIMAI] KimaiLoader: bootstrap the application and all plugins
*/
/*!
* [KIMAI] KimaiMultiUpdateForm: handle the multi update checkbox list and form
*/
/*!
* [KIMAI] KimaiPaginatedBoxWidget: handles box widgets that have a pagination
*/
/*!
* [KIMAI] KimaiPlugin: base class for all plugins
*/
/*!
* [KIMAI] KimaiRecentActivities: responsible to reload the users recent activities
*/
/*!
* [KIMAI] KimaiReducedClickHandler: abstract class
*/
/*!
* [KIMAI] KimaiReloadPageWidget: a simple helper to reload the page on events
*/
/*!
* [KIMAI] KimaiStorage: simple wrapper to handle localStorage access
*/
/*!
* [KIMAI] KimaiThemeInitializer: initialize theme functionality
*/
/*!
* [KIMAI] KimaiToolbar: some event listener to handle the toolbar/data-table filter, toolbar and navigation
*/
/*!
* [KIMAI] KimaiTranslation: handling translation strings
*/
/*!
* [KIMAI] KimaiUser: information about the current user
*/
/*!
* [KIMAI] Notification: notifications for Kimai
*/
/*!
* [KIMAI] Wrapper class for loading Kimai app in browser script scope
*/

View File

@@ -3,7 +3,7 @@
"app": {
"js": [
"/build/runtime.6c399d29.js",
"/build/app.26740f92.js"
"/build/app.4f0fd3e5.js"
],
"css": [
"/build/app.0416ea92.css"
@@ -72,7 +72,7 @@
},
"integrity": {
"/build/runtime.6c399d29.js": "sha384-/rm616f12czi8l/27GvWXtb3g608vJZf2XTUKxqCRI4tsa2vUHP+BW90edTok5zC",
"/build/app.26740f92.js": "sha384-HapsoomsQcRK4QxBGYW0bZhGuDiYxFvCQVcpK5bO2MwFd8StKhncAzK9fbCVlauM",
"/build/app.4f0fd3e5.js": "sha384-p1bPGT8nZ43+h+77R5zqIOwLx0RIESeYGq9x/3FxDd5V43nw2aF6263pbj9oa57M",
"/build/app.0416ea92.css": "sha384-JAIO6+B/vmV8IlpsmQ+zkfO4JsvdKTMsZZaDg6EMlZxkMZ5K3cpEK0mo24D/Wgkh",
"/build/app-rtl.7a875ca7.js": "sha384-T7gLI61h9dGeMgzo63vKu4GiDOeLPct9zSUHrceNbhSwIdUmSSNoZ1+d7fKhJJ4/",
"/build/app-rtl.0848906b.css": "sha384-O26Xw3P/NSea5iT6lt5v2RaZ6+jP06hf9vYD2pUJbkFSCLGU1iGXnobP25dWjUs/",

View File

@@ -1,6 +1,6 @@
{
"build/app.css": "/build/app.0416ea92.css",
"build/app.js": "/build/app.26740f92.js",
"build/app.js": "/build/app.4f0fd3e5.js",
"build/app-rtl.css": "/build/app-rtl.0848906b.css",
"build/app-rtl.js": "/build/app-rtl.7a875ca7.js",
"build/export-pdf.css": "/build/export-pdf.d8a6c23b.css",

View File

@@ -162,6 +162,7 @@ final class RegenerateLocalesCommand extends Command
$settings['time'] = str_replace("\u{202f}", ' ', $settings['time']);
// keep it simple, we don't need to convert it during runtime
// ISO-8601 defines that 24-hour format should always use a leading zero: use HH instead of H
$settings['time'] = str_replace('HH', 'H', $settings['time']);
$settings['time'] = str_replace('H', 'HH', $settings['time']);

View File

@@ -11,6 +11,7 @@ namespace App\Form\Type;
use App\Configuration\LocaleService;
use App\Utils\FormFormatConverter;
use App\Utils\JavascriptFormatConverter;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\CallbackTransformer;
use Symfony\Component\Form\Exception\TransformationFailedException;
@@ -18,33 +19,47 @@ use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\Form\FormView;
use Symfony\Component\OptionsResolver\Options;
use Symfony\Component\OptionsResolver\OptionsResolver;
final class TimePickerType extends AbstractType
{
public function __construct(private LocaleService $localeService)
public function __construct(private readonly LocaleService $localeService)
{
}
public function configureOptions(OptionsResolver $resolver): void
{
$format = $this->localeService->getTimeFormat(\Locale::getDefault());
$converter = new FormFormatConverter();
$formFormat = $converter->convert($format);
$resolver->setDefaults([
'input' => 'string',
'format' => $formFormat,
'placeholder' => $formFormat, // $format
'locale' => \Locale::getDefault(),
'model_timezone' => date_default_timezone_get(),
'view_timezone' => date_default_timezone_get(),
'block_prefix' => 'time'
]);
$resolver->setDefault('time_format', function (Options $options): string {
// We used the configured time format via "getTimeFormat()" for entering times before, but it caused issues.
// So now we only allow two different input types: 12-hour with AM/PM suffix and 24-hour
return $this->localeService->is24Hour($options['locale']) ? 'HH:mm' : 'h:mm a';
});
$resolver->setDefault('format', function (Options $options): string {
$converter = new FormFormatConverter();
return $converter->convert($options['time_format']);
});
$resolver->setDefault('placeholder', function (Options $options): string {
return $options['time_format'];
});
}
public function buildView(FormView $view, FormInterface $form, array $options): void
{
$view->vars['format'] = $options['format'];
$view->vars['time_format'] = $options['time_format'];
$view->vars['js_format'] = (new JavascriptFormatConverter())->convert($options['time_format']); // @phpstan-ignore argument.type
}
public function buildForm(FormBuilderInterface $builder, array $options): void

View File

@@ -151,13 +151,10 @@
{%- endblock date_widget %}
{% block time_widget -%}
{%- set user_format = format -%}
{%- set format = locale_format('time') -%}
{%- set jsFormat = format|js_format -%}
{%- set attr = attr|merge({'pattern': format|pattern, 'autocomplete': 'off', 'data-timepicker': 'on', 'data-format': jsFormat, 'placeholder': jsFormat}) -%}
{%- set attr = attr|merge({'pattern': time_format|pattern, 'autocomplete': 'off', 'data-timepicker': 'on', 'data-format': js_format, 'placeholder': time_format}) -%}
<div class="input-group">
<div class="input-group-text">
<a href="#" data-form-widget="date-now" data-format="{{ jsFormat }}" data-target="{{ id }}">{{ icon('calendar') }}</a>
<a href="#" data-form-widget="date-now" data-format="{{ js_format }}" data-target="{{ id }}">{{ icon('calendar') }}</a>
</div>
{{ block('form_widget_simple') }}
{% set time_presets = form_time_presets(app.user.timezone) %}
@@ -168,7 +165,7 @@
{% for index in [0, 1, 2, 3] %}
<div class="dropdown-menu-column" style="min-width: 4rem">
{% for value in time_presets %}
{% set value = value|date_format(user_format) %}
{% set value = value|date_format(format) %}
{% if loop.index0 % 4 == index %}
<a class="dropdown-item justify-content-center" href="#" data-form-widget="copy-data" data-target="#{{ form.vars.id }}" data-value="{{ value }}" data-event="change">{{ value }}</a>
{% endif %}