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:
@@ -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) {
|
||||
|
||||
@@ -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(/,/, '.');
|
||||
|
||||
2
public/build/app.4f0fd3e5.js
Normal file
2
public/build/app.4f0fd3e5.js
Normal file
File diff suppressed because one or more lines are too long
173
public/build/app.4f0fd3e5.js.LICENSE.txt
Normal file
173
public/build/app.4f0fd3e5.js.LICENSE.txt
Normal 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
|
||||
*/
|
||||
@@ -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/",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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']);
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
Reference in New Issue
Block a user