mirror of
https://gitlab.com/bramw/baserow.git
synced 2025-04-13 16:49:07 +00:00
Merge branch '2217-add-formats-with-days-to-the-duration-field' into 'develop'
1️⃣ Support for duration in formula: add formats with days Closes #2217 See merge request baserow/baserow!2002
This commit is contained in:
commit
21b66a7aaa
21 changed files with 1355 additions and 369 deletions
backend
src/baserow
contrib/database
fields
migrations
test_utils
tests/baserow/contrib
database
api
export
field
integrations/local_baserow
changelog/entries/unreleased/feature
premium/backend/tests/baserow_premium_tests/export
web-frontend
modules/database
test/unit/database
|
@ -142,6 +142,9 @@ def construct_all_possible_field_kwargs(
|
||||||
{"name": "duration_hms_s", "duration_format": "h:mm:ss.s"},
|
{"name": "duration_hms_s", "duration_format": "h:mm:ss.s"},
|
||||||
{"name": "duration_hms_ss", "duration_format": "h:mm:ss.ss"},
|
{"name": "duration_hms_ss", "duration_format": "h:mm:ss.ss"},
|
||||||
{"name": "duration_hms_sss", "duration_format": "h:mm:ss.sss"},
|
{"name": "duration_hms_sss", "duration_format": "h:mm:ss.sss"},
|
||||||
|
{"name": "duration_dh", "duration_format": "d h"},
|
||||||
|
{"name": "duration_dhm", "duration_format": "d h:mm"},
|
||||||
|
{"name": "duration_dhms", "duration_format": "d h:mm:ss"},
|
||||||
],
|
],
|
||||||
"link_row": [
|
"link_row": [
|
||||||
{"name": "link_row", "link_row_table": link_table},
|
{"name": "link_row", "link_row_table": link_table},
|
||||||
|
|
|
@ -190,9 +190,12 @@ from .registries import (
|
||||||
field_type_registry,
|
field_type_registry,
|
||||||
)
|
)
|
||||||
from .utils.duration import (
|
from .utils.duration import (
|
||||||
DURATION_FORMAT_TOKENS,
|
|
||||||
DURATION_FORMATS,
|
DURATION_FORMATS,
|
||||||
convert_duration_input_value_to_timedelta,
|
duration_value_sql_to_text,
|
||||||
|
duration_value_to_timedelta,
|
||||||
|
format_duration_value,
|
||||||
|
get_duration_search_expression,
|
||||||
|
is_duration_format_conversion_lossy,
|
||||||
prepare_duration_value_for_db,
|
prepare_duration_value_for_db,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -1716,32 +1719,19 @@ class DurationFieldType(FieldType):
|
||||||
return prepare_duration_value_for_db(value, instance.duration_format)
|
return prepare_duration_value_for_db(value, instance.duration_format)
|
||||||
|
|
||||||
def get_search_expression(self, field: Field, queryset: QuerySet) -> Expression:
|
def get_search_expression(self, field: Field, queryset: QuerySet) -> Expression:
|
||||||
search_exprs = []
|
return get_duration_search_expression(field)
|
||||||
for token in field.duration_format.split(":"):
|
|
||||||
search_expr = DURATION_FORMAT_TOKENS[token]["search_expr"](field.db_column)
|
|
||||||
search_exprs.append(search_expr)
|
|
||||||
separators = [Value(" ")] * len(search_exprs)
|
|
||||||
# interleave a separator between each extract_expr
|
|
||||||
exprs = [expr for pair in zip(search_exprs, separators) for expr in pair][:-1]
|
|
||||||
return Func(*exprs, function="CONCAT")
|
|
||||||
|
|
||||||
def random_value(self, instance, fake, cache):
|
def random_value(self, instance, fake, cache):
|
||||||
random_seconds = fake.random.random() * 60 * 60 * 2
|
random_seconds = fake.random.random() * 60 * 60 * 24
|
||||||
return convert_duration_input_value_to_timedelta(
|
# if we have days in the format, ensure the random value is picked accordingly
|
||||||
random_seconds, instance.duration_format
|
if "d" in instance.duration_format:
|
||||||
)
|
random_seconds *= 30
|
||||||
|
return duration_value_to_timedelta(random_seconds, instance.duration_format)
|
||||||
|
|
||||||
def get_alter_column_prepare_old_value(self, connection, from_field, to_field):
|
def get_alter_column_prepare_old_value(self, connection, from_field, to_field):
|
||||||
to_field_type = field_type_registry.get_by_model(to_field)
|
to_field_type = field_type_registry.get_by_model(to_field)
|
||||||
if to_field_type.type in (TextFieldType.type, LongTextFieldType.type):
|
if to_field_type.type in (TextFieldType.type, LongTextFieldType.type):
|
||||||
format_func = " || ':' || ".join(
|
return f"p_in = {duration_value_sql_to_text(from_field)};"
|
||||||
[
|
|
||||||
DURATION_FORMAT_TOKENS[format_token]["sql_to_text"]
|
|
||||||
for format_token in from_field.duration_format.split(":")
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
return f"p_in = {format_func};"
|
|
||||||
elif to_field_type.type == NumberFieldType.type:
|
elif to_field_type.type == NumberFieldType.type:
|
||||||
return "p_in = EXTRACT(EPOCH FROM CAST(p_in AS INTERVAL))::NUMERIC;"
|
return "p_in = EXTRACT(EPOCH FROM CAST(p_in AS INTERVAL))::NUMERIC;"
|
||||||
|
|
||||||
|
@ -1768,19 +1758,7 @@ class DurationFieldType(FieldType):
|
||||||
:return: The formatted string.
|
:return: The formatted string.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
if value is None:
|
return format_duration_value(value, duration_format)
|
||||||
return None
|
|
||||||
|
|
||||||
secs_in_a_min = 60
|
|
||||||
secs_in_an_hour = 60 * 60
|
|
||||||
|
|
||||||
total_seconds = value.total_seconds()
|
|
||||||
hours = int(total_seconds / secs_in_an_hour)
|
|
||||||
minutes = int(total_seconds % secs_in_an_hour / secs_in_a_min)
|
|
||||||
seconds = total_seconds % secs_in_a_min
|
|
||||||
|
|
||||||
format_func = DURATION_FORMATS[duration_format]["format_func"]
|
|
||||||
return format_func(hours, minutes, seconds)
|
|
||||||
|
|
||||||
def get_export_value(
|
def get_export_value(
|
||||||
self,
|
self,
|
||||||
|
@ -1788,22 +1766,9 @@ class DurationFieldType(FieldType):
|
||||||
field_object: "FieldObject",
|
field_object: "FieldObject",
|
||||||
rich_value: bool = False,
|
rich_value: bool = False,
|
||||||
) -> Optional[str]:
|
) -> Optional[str]:
|
||||||
if value is None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
secs_in_a_min = 60
|
|
||||||
secs_in_an_hour = 60 * 60
|
|
||||||
|
|
||||||
total_seconds = value.total_seconds()
|
|
||||||
|
|
||||||
hours = int(total_seconds / secs_in_an_hour)
|
|
||||||
mins = int(total_seconds % secs_in_an_hour / secs_in_a_min)
|
|
||||||
secs = total_seconds % secs_in_a_min
|
|
||||||
|
|
||||||
field = field_object["field"]
|
field = field_object["field"]
|
||||||
duration_format = field.duration_format
|
duration_format = field.duration_format
|
||||||
format_func = DURATION_FORMATS[duration_format]["format_func"]
|
return self.format_duration(value, duration_format)
|
||||||
return format_func(hours, mins, secs)
|
|
||||||
|
|
||||||
def should_backup_field_data_for_same_type_update(
|
def should_backup_field_data_for_same_type_update(
|
||||||
self, old_field: DurationField, new_field_attrs: Dict[str, Any]
|
self, old_field: DurationField, new_field_attrs: Dict[str, Any]
|
||||||
|
@ -1812,17 +1777,14 @@ class DurationFieldType(FieldType):
|
||||||
"duration_format", old_field.duration_format
|
"duration_format", old_field.duration_format
|
||||||
)
|
)
|
||||||
|
|
||||||
formats_needing_a_backup = DURATION_FORMATS[old_field.duration_format][
|
return is_duration_format_conversion_lossy(
|
||||||
"backup_field_if_changing_to"
|
new_duration_format, old_field.duration_format
|
||||||
]
|
)
|
||||||
return new_duration_format in formats_needing_a_backup
|
|
||||||
|
|
||||||
def force_same_type_alter_column(self, from_field, to_field):
|
def force_same_type_alter_column(self, from_field, to_field):
|
||||||
curr_format = from_field.duration_format
|
return is_duration_format_conversion_lossy(
|
||||||
formats_needing_alter_column = DURATION_FORMATS[curr_format][
|
to_field.duration_format, from_field.duration_format
|
||||||
"backup_field_if_changing_to"
|
)
|
||||||
]
|
|
||||||
return to_field.duration_format in formats_needing_alter_column
|
|
||||||
|
|
||||||
def serialize_metadata_for_row_history(
|
def serialize_metadata_for_row_history(
|
||||||
self,
|
self,
|
||||||
|
|
|
@ -9,9 +9,7 @@ from django.db.models.fields.related_descriptors import (
|
||||||
)
|
)
|
||||||
from django.utils.functional import cached_property
|
from django.utils.functional import cached_property
|
||||||
|
|
||||||
from baserow.contrib.database.fields.utils.duration import (
|
from baserow.contrib.database.fields.utils.duration import duration_value_to_timedelta
|
||||||
convert_duration_input_value_to_timedelta,
|
|
||||||
)
|
|
||||||
from baserow.contrib.database.formula import BaserowExpression, FormulaHandler
|
from baserow.contrib.database.formula import BaserowExpression, FormulaHandler
|
||||||
from baserow.core.fields import SyncedDateTimeField
|
from baserow.core.fields import SyncedDateTimeField
|
||||||
|
|
||||||
|
@ -318,7 +316,7 @@ class DurationField(models.DurationField):
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
def get_prep_value(self, value: Any) -> Any:
|
def get_prep_value(self, value: Any) -> Any:
|
||||||
value = convert_duration_input_value_to_timedelta(value, self.duration_format)
|
value = duration_value_to_timedelta(value, self.duration_format)
|
||||||
return super().get_prep_value(value)
|
return super().get_prep_value(value)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
|
import re
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
from typing import Optional, Union
|
from typing import List, Optional, Union
|
||||||
|
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from django.db.models import (
|
from django.db.models import (
|
||||||
|
@ -10,82 +11,228 @@ from django.db.models import (
|
||||||
TextField,
|
TextField,
|
||||||
Value,
|
Value,
|
||||||
)
|
)
|
||||||
from django.db.models.functions import Cast, Extract
|
from django.db.models.functions import Cast, Extract, Mod
|
||||||
|
|
||||||
DURATION_FORMATS = {
|
H_M = "h:mm"
|
||||||
"h:mm": {
|
H_M_S = "h:mm:ss"
|
||||||
"name": "hours:minutes",
|
H_M_S_S = "h:mm:ss.s"
|
||||||
"backup_field_if_changing_to": set(),
|
H_M_S_SS = "h:mm:ss.ss"
|
||||||
"round_func": lambda value: round(value / 60, 0) * 60,
|
H_M_S_SSS = "h:mm:ss.sss"
|
||||||
"sql_round_func": "(EXTRACT(EPOCH FROM p_in::INTERVAL) / 60)::int * 60",
|
D_H = "d h"
|
||||||
"format_func": lambda hours, mins, _: "%d:%02d" % (hours, mins),
|
D_H_M = "d h:mm"
|
||||||
|
D_H_M_S = "d h:mm:ss"
|
||||||
|
|
||||||
|
MOST_ACCURATE_DURATION_FORMAT = H_M_S_SSS
|
||||||
|
|
||||||
|
|
||||||
|
def total_secs(days=0, hours=0, mins=0, secs=0):
|
||||||
|
return int(days) * 86400 + int(hours) * 3600 + int(mins) * 60 + float(secs)
|
||||||
|
|
||||||
|
|
||||||
|
# These regexps are supposed to tokenize the provided duration value and to return a
|
||||||
|
# proper number of seconds based on format and the tokens. NOTE: Keep these in sync with
|
||||||
|
# web-frontend/modules/database/utils/duration.js:DURATION_REGEXPS
|
||||||
|
DURATION_REGEXPS = {
|
||||||
|
re.compile(r"^(\d+)(?:d\s*|\s+)(\d+):(\d+):(\d+|\d+.\d+)$"): {
|
||||||
|
"default": lambda d, h, m, s: total_secs(days=d, hours=h, mins=m, secs=s),
|
||||||
},
|
},
|
||||||
"h:mm:ss": {
|
re.compile(r"^(\d+):(\d+):(\d+|\d+.\d+)$"): {
|
||||||
"name": "hours:minutes:seconds",
|
"default": lambda h, m, s: total_secs(hours=h, mins=m, secs=s),
|
||||||
"backup_field_if_changing_to": {"h:mm"},
|
|
||||||
"round_func": lambda value: round(value, 0),
|
|
||||||
"sql_round_func": "EXTRACT(EPOCH FROM p_in::INTERVAL)::int",
|
|
||||||
"format_func": lambda hours, mins, secs: "%d:%02d:%02d" % (hours, mins, secs),
|
|
||||||
},
|
},
|
||||||
"h:mm:ss.s": {
|
re.compile(r"^(\d+)(?:d\s*|\s+)(\d+)h$"): {
|
||||||
"name": "hours:minutes:seconds:deciseconds",
|
"default": lambda d, h: total_secs(days=d, hours=h),
|
||||||
"round_func": lambda value: round(value, 1),
|
|
||||||
"sql_round_func": "ROUND(EXTRACT(EPOCH FROM p_in::INTERVAL)::NUMERIC, 1)",
|
|
||||||
"backup_field_if_changing_to": {"h:mm", "h:mm:ss"},
|
|
||||||
"format_func": lambda hours, mins, secs: "%d:%02d:%04.1f" % (hours, mins, secs),
|
|
||||||
},
|
},
|
||||||
"h:mm:ss.ss": {
|
re.compile(r"^(\d+)h$"): {
|
||||||
"name": "hours:minutes:seconds:centiseconds",
|
"default": lambda h: total_secs(hours=h),
|
||||||
"backup_field_if_changing_to": {"h:mm", "h:mm:ss", "h:mm:ss.s"},
|
|
||||||
"round_func": lambda value: round(value, 2),
|
|
||||||
"sql_round_func": "ROUND(EXTRACT(EPOCH FROM p_in::INTERVAL)::NUMERIC, 2)",
|
|
||||||
"format_func": lambda hours, mins, secs: "%d:%02d:%05.2f" % (hours, mins, secs),
|
|
||||||
},
|
},
|
||||||
"h:mm:ss.sss": {
|
re.compile(r"^(\d+)d$"): {
|
||||||
"name": "hours:minutes:seconds:milliseconds",
|
"default": lambda d: total_secs(days=d),
|
||||||
"backup_field_if_changing_to": {
|
|
||||||
"h:mm",
|
|
||||||
"h:mm:ss",
|
|
||||||
"h:mm:ss.s",
|
|
||||||
"h:mm:ss.ss",
|
|
||||||
},
|
},
|
||||||
"round_func": lambda value: round(value, 3),
|
re.compile(r"^(\d+)(?:d\s*|\s+)(\d+):(\d+)$"): {
|
||||||
"sql_round_func": "ROUND(EXTRACT(EPOCH FROM p_in::INTERVAL)::NUMERIC, 3)",
|
H_M: lambda d, h, m: total_secs(days=d, hours=h, mins=m),
|
||||||
"format_func": lambda hours, mins, secs: "%d:%02d:%06.3f" % (hours, mins, secs),
|
D_H: lambda d, h, m: total_secs(days=d, hours=h, mins=m),
|
||||||
|
D_H_M: lambda d, h, m: total_secs(days=d, hours=h, mins=m),
|
||||||
|
"default": lambda d, m, s: total_secs(days=d, mins=m, secs=s),
|
||||||
|
},
|
||||||
|
re.compile(r"^(\d+)(?:d\s*|\s+)(\d+):(\d+.\d+)$"): {
|
||||||
|
"default": lambda d, m, s: total_secs(days=d, mins=m, secs=s),
|
||||||
|
},
|
||||||
|
re.compile(r"^(\d+):(\d+)$"): {
|
||||||
|
H_M: lambda h, m: total_secs(hours=h, mins=m),
|
||||||
|
D_H: lambda h, m: total_secs(hours=h, mins=m),
|
||||||
|
D_H_M: lambda h, m: total_secs(hours=h, mins=m),
|
||||||
|
"default": lambda m, s: total_secs(mins=m, secs=s),
|
||||||
|
},
|
||||||
|
re.compile(r"^(\d+):(\d+.\d+)$"): {
|
||||||
|
"default": lambda m, s: total_secs(mins=m, secs=s),
|
||||||
|
},
|
||||||
|
re.compile(r"^(\d+)(?:d\s*|\s+)(\d+)$"): {
|
||||||
|
H_M: lambda d, m: total_secs(days=d, mins=m),
|
||||||
|
D_H: lambda d, h: total_secs(days=d, hours=h),
|
||||||
|
D_H_M: lambda d, m: total_secs(days=d, mins=m),
|
||||||
|
"default": lambda d, s: total_secs(days=d, secs=s),
|
||||||
|
},
|
||||||
|
re.compile(r"^(\d+)(?:d\s*|\s+)(\d+.\d+)$"): {
|
||||||
|
"default": lambda d, s: total_secs(days=d, secs=s),
|
||||||
|
},
|
||||||
|
re.compile(r"^(\d+)$"): {
|
||||||
|
H_M: lambda m: total_secs(mins=float(m)),
|
||||||
|
D_H: lambda h: total_secs(hours=float(h)),
|
||||||
|
D_H_M: lambda m: total_secs(mins=float(m)),
|
||||||
|
"default": lambda s: total_secs(secs=s),
|
||||||
|
},
|
||||||
|
re.compile(r"^(\d+.\d+)$"): {
|
||||||
|
"default": lambda s: total_secs(secs=s),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
# The tokens used in the format strings and their utility functions.
|
|
||||||
|
def rround(value: float, ndigits: int = 0) -> int:
|
||||||
|
"""
|
||||||
|
Rounds a float to the specified number of decimal places. Python round will round
|
||||||
|
0.5 to 0, but we want it to round to 1. See
|
||||||
|
https://docs.python.org/3/library/functions.html#round for more info.
|
||||||
|
|
||||||
|
:param value: the value to round
|
||||||
|
:param ndigits: the number of digits to round to
|
||||||
|
"""
|
||||||
|
|
||||||
|
digit_value = 10**ndigits
|
||||||
|
return int(value * digit_value + 0.5) / digit_value
|
||||||
|
|
||||||
|
|
||||||
|
DURATION_FORMATS = {
|
||||||
|
H_M: {
|
||||||
|
"name": "hours:minutes",
|
||||||
|
"round_func": lambda value: rround(value / 60) * 60,
|
||||||
|
"sql_round_func": "(EXTRACT(EPOCH FROM p_in::INTERVAL) / 60)::int * 60",
|
||||||
|
"format_func": lambda d, h, m, s: "%d:%02d" % (d * 24 + h, m),
|
||||||
|
},
|
||||||
|
H_M_S: {
|
||||||
|
"name": "hours:minutes:seconds",
|
||||||
|
"round_func": lambda value: rround(value, 0),
|
||||||
|
"sql_round_func": "EXTRACT(EPOCH FROM p_in::INTERVAL)::int",
|
||||||
|
"format_func": lambda d, h, m, s: "%d:%02d:%02d" % (d * 24 + h, m, s),
|
||||||
|
},
|
||||||
|
H_M_S_S: {
|
||||||
|
"name": "hours:minutes:seconds:deciseconds",
|
||||||
|
"round_func": lambda value: rround(value, 1),
|
||||||
|
"sql_round_func": "ROUND(EXTRACT(EPOCH FROM p_in::INTERVAL)::NUMERIC, 1)",
|
||||||
|
"format_func": lambda d, h, m, s: "%d:%02d:%04.1f" % (d * 24 + h, m, s),
|
||||||
|
},
|
||||||
|
H_M_S_SS: {
|
||||||
|
"name": "hours:minutes:seconds:centiseconds",
|
||||||
|
"round_func": lambda value: rround(value, 2),
|
||||||
|
"sql_round_func": "ROUND(EXTRACT(EPOCH FROM p_in::INTERVAL)::NUMERIC, 2)",
|
||||||
|
"format_func": lambda d, h, m, s: "%d:%02d:%05.2f" % (d * 24 + h, m, s),
|
||||||
|
},
|
||||||
|
H_M_S_SSS: {
|
||||||
|
"name": "hours:minutes:seconds:milliseconds",
|
||||||
|
"round_func": lambda value: rround(value, 3),
|
||||||
|
"sql_round_func": "ROUND(EXTRACT(EPOCH FROM p_in::INTERVAL)::NUMERIC, 3)",
|
||||||
|
"format_func": lambda d, h, m, s: "%d:%02d:%06.3f" % (d * 24 + h, m, s),
|
||||||
|
},
|
||||||
|
D_H: {
|
||||||
|
"name": "days:hours",
|
||||||
|
"round_func": lambda value: rround(value / 3600) * 3600,
|
||||||
|
"sql_round_func": "(EXTRACT(EPOCH FROM p_in::INTERVAL) / 3600)::int * 3600",
|
||||||
|
"format_func": lambda d, h, m, s: "%dd %dh" % (d, h),
|
||||||
|
},
|
||||||
|
D_H_M: {
|
||||||
|
"name": "days:hours:minutes",
|
||||||
|
"round_func": lambda value: rround(value / 60) * 60,
|
||||||
|
"sql_round_func": "(EXTRACT(EPOCH FROM p_in::INTERVAL) / 60)::int * 60",
|
||||||
|
"format_func": lambda d, h, m, s: "%dd %d:%02d" % (d, h, m),
|
||||||
|
},
|
||||||
|
D_H_M_S: {
|
||||||
|
"name": "days:hours:minutes:seconds",
|
||||||
|
"round_func": lambda value: rround(value, 0),
|
||||||
|
"sql_round_func": "EXTRACT(EPOCH FROM p_in::INTERVAL)::int",
|
||||||
|
"format_func": lambda d, h, m, s: "%dd %d:%02d:%02d" % (d, h, m, s),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
HOURS_WITH_DAYS_SQL_TO_TEXT = (
|
||||||
|
"(EXTRACT(HOUR FROM CAST(p_in AS INTERVAL))::INTEGER %% 24)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def hours_with_days_search_expr(field_name):
|
||||||
|
return Mod(
|
||||||
|
Cast(Extract(field_name, "hour"), output_field=IntegerField()), Value(24)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# The tokens used in the format strings and their utility functions. NOTE: the order of
|
||||||
|
# duration units is crucial, as it ranges from the largest to the smallest unit. This
|
||||||
|
# order is significant because it helps determine the precision of different duration
|
||||||
|
# formats (see is_duration_format_conversion_lossy down below), which are created by
|
||||||
|
# combining various tokens. If there's a need to convert between these formats, it might
|
||||||
|
# be necessary to backup data beforehand to prevent loss of precision.
|
||||||
DURATION_FORMAT_TOKENS = {
|
DURATION_FORMAT_TOKENS = {
|
||||||
"h": {
|
"d": {
|
||||||
"multiplier": 3600,
|
"sql_to_text": {
|
||||||
"parse_func": int,
|
"default": "CASE WHEN p_in IS null THEN null ELSE CONCAT(TRUNC(EXTRACT(EPOCH FROM CAST(p_in AS INTERVAL))::INTEGER / 86400), 'd') END",
|
||||||
"sql_to_text": "TRUNC(EXTRACT(EPOCH FROM CAST(p_in AS INTERVAL))::INTEGER / 3600)",
|
},
|
||||||
"search_expr": lambda field_name: Cast(
|
"search_expr": {
|
||||||
|
"default": lambda field_name: Func(
|
||||||
|
Cast(
|
||||||
Func(
|
Func(
|
||||||
Extract(field_name, "epoch", output_field=IntegerField()) / Value(3600),
|
Extract(field_name, "epoch", output_field=IntegerField())
|
||||||
|
/ Value(86400),
|
||||||
|
function="TRUNC",
|
||||||
|
output_field=IntegerField(),
|
||||||
|
),
|
||||||
|
output_field=TextField(),
|
||||||
|
),
|
||||||
|
Value("d"),
|
||||||
|
function="CONCAT",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"h": {
|
||||||
|
"sql_to_text": {
|
||||||
|
D_H: f"CASE WHEN p_in IS null THEN null ELSE CONCAT({HOURS_WITH_DAYS_SQL_TO_TEXT}, 'h') END",
|
||||||
|
D_H_M: HOURS_WITH_DAYS_SQL_TO_TEXT,
|
||||||
|
D_H_M_S: HOURS_WITH_DAYS_SQL_TO_TEXT,
|
||||||
|
"default": "TRUNC(EXTRACT(EPOCH FROM CAST(p_in AS INTERVAL))::INTEGER / 3600)",
|
||||||
|
},
|
||||||
|
"search_expr": {
|
||||||
|
D_H: lambda field_name: Func(
|
||||||
|
hours_with_days_search_expr(field_name), Value("h"), function="CONCAT"
|
||||||
|
),
|
||||||
|
D_H_M: hours_with_days_search_expr,
|
||||||
|
D_H_M_S: hours_with_days_search_expr,
|
||||||
|
"default": lambda field_name: Cast(
|
||||||
|
Func(
|
||||||
|
Extract(field_name, "epoch", output_field=IntegerField())
|
||||||
|
/ Value(3600),
|
||||||
function="TRUNC",
|
function="TRUNC",
|
||||||
output_field=IntegerField(),
|
output_field=IntegerField(),
|
||||||
),
|
),
|
||||||
output_field=TextField(),
|
output_field=TextField(),
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
},
|
||||||
"mm": {
|
"mm": {
|
||||||
"multiplier": 60,
|
"sql_to_text": {
|
||||||
"parse_func": int,
|
"default": "TO_CHAR(EXTRACT(MINUTE FROM CAST(p_in AS INTERVAL))::INTEGER, 'FM00')",
|
||||||
"sql_to_text": "TO_CHAR(EXTRACT(MINUTE FROM CAST(p_in AS INTERVAL))::INTEGER, 'FM00')",
|
},
|
||||||
"search_expr": lambda field_name: Func(
|
"search_expr": {
|
||||||
|
"default": lambda field_name: Func(
|
||||||
Extract(field_name, "minutes", output_field=IntegerField()),
|
Extract(field_name, "minutes", output_field=IntegerField()),
|
||||||
Value("FM00"),
|
Value("FM00"),
|
||||||
function="TO_CHAR",
|
function="TO_CHAR",
|
||||||
output_field=TextField(),
|
output_field=TextField(),
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
},
|
||||||
"ss": {
|
"ss": {
|
||||||
"multiplier": 1,
|
"sql_to_text": {
|
||||||
"parse_func": lambda value: round(float(value), 0),
|
"default": "TO_CHAR(CAST(EXTRACT(SECOND FROM CAST(p_in AS INTERVAL)) AS NUMERIC(15, 0)), 'FM00')",
|
||||||
"sql_to_text": "TO_CHAR(CAST(EXTRACT(SECOND FROM CAST(p_in AS INTERVAL)) AS NUMERIC(15, 0)), 'FM00')",
|
},
|
||||||
"search_expr": lambda field_name: Func(
|
"search_expr": {
|
||||||
|
"default": lambda field_name: Func(
|
||||||
Cast(
|
Cast(
|
||||||
Extract(field_name, "seconds", output_field=FloatField()),
|
Extract(field_name, "seconds", output_field=FloatField()),
|
||||||
output_field=DecimalField(max_digits=15, decimal_places=0),
|
output_field=DecimalField(max_digits=15, decimal_places=0),
|
||||||
|
@ -95,11 +242,13 @@ DURATION_FORMAT_TOKENS = {
|
||||||
output_field=TextField(),
|
output_field=TextField(),
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
},
|
||||||
"ss.s": {
|
"ss.s": {
|
||||||
"multiplier": 1,
|
"sql_to_text": {
|
||||||
"parse_func": lambda value: round(float(value), 1),
|
"default": "TO_CHAR(CAST(EXTRACT(SECOND FROM CAST(p_in AS INTERVAL)) AS NUMERIC(15, 1)), 'FM00.0')"
|
||||||
"sql_to_text": "TO_CHAR(CAST(EXTRACT(SECOND FROM CAST(p_in AS INTERVAL)) AS NUMERIC(15, 1)), 'FM00.0')",
|
},
|
||||||
"search_expr": lambda field_name: Func(
|
"search_expr": {
|
||||||
|
"default": lambda field_name: Func(
|
||||||
Cast(
|
Cast(
|
||||||
Extract(field_name, "seconds", output_field=DecimalField()),
|
Extract(field_name, "seconds", output_field=DecimalField()),
|
||||||
output_field=DecimalField(max_digits=15, decimal_places=1),
|
output_field=DecimalField(max_digits=15, decimal_places=1),
|
||||||
|
@ -107,13 +256,15 @@ DURATION_FORMAT_TOKENS = {
|
||||||
Value("FM00.0"),
|
Value("FM00.0"),
|
||||||
function="TO_CHAR",
|
function="TO_CHAR",
|
||||||
output_field=TextField(),
|
output_field=TextField(),
|
||||||
),
|
)
|
||||||
|
},
|
||||||
},
|
},
|
||||||
"ss.ss": {
|
"ss.ss": {
|
||||||
"multiplier": 1,
|
"sql_to_text": {
|
||||||
"parse_func": lambda value: round(float(value), 2),
|
"default": "TO_CHAR(CAST(EXTRACT(SECOND FROM CAST(p_in AS INTERVAL)) AS NUMERIC(15, 2)), 'FM00.00')"
|
||||||
"sql_to_text": "TO_CHAR(CAST(EXTRACT(SECOND FROM CAST(p_in AS INTERVAL)) AS NUMERIC(15, 2)), 'FM00.00')",
|
},
|
||||||
"search_expr": lambda field_name: Func(
|
"search_expr": {
|
||||||
|
"default": lambda field_name: Func(
|
||||||
Cast(
|
Cast(
|
||||||
Extract(field_name, "seconds", output_field=DecimalField()),
|
Extract(field_name, "seconds", output_field=DecimalField()),
|
||||||
output_field=DecimalField(max_digits=15, decimal_places=2),
|
output_field=DecimalField(max_digits=15, decimal_places=2),
|
||||||
|
@ -121,13 +272,15 @@ DURATION_FORMAT_TOKENS = {
|
||||||
Value("FM00.00"),
|
Value("FM00.00"),
|
||||||
function="TO_CHAR",
|
function="TO_CHAR",
|
||||||
output_field=TextField(),
|
output_field=TextField(),
|
||||||
),
|
)
|
||||||
|
},
|
||||||
},
|
},
|
||||||
"ss.sss": {
|
"ss.sss": {
|
||||||
"multiplier": 1,
|
"sql_to_text": {
|
||||||
"parse_func": lambda value: round(float(value), 3),
|
"default": "TO_CHAR(CAST(EXTRACT(SECOND FROM CAST(p_in AS INTERVAL)) AS NUMERIC(15, 3)), 'FM00.000')"
|
||||||
"sql_to_text": "TO_CHAR(CAST(EXTRACT(SECOND FROM CAST(p_in AS INTERVAL)) AS NUMERIC(15, 3)), 'FM00.000')",
|
},
|
||||||
"search_expr": lambda field_name: Func(
|
"search_expr": {
|
||||||
|
"default": lambda field_name: Func(
|
||||||
Cast(
|
Cast(
|
||||||
Extract(field_name, "seconds", output_field=DecimalField()),
|
Extract(field_name, "seconds", output_field=DecimalField()),
|
||||||
output_field=DecimalField(max_digits=15, decimal_places=3),
|
output_field=DecimalField(max_digits=15, decimal_places=3),
|
||||||
|
@ -135,19 +288,13 @@ DURATION_FORMAT_TOKENS = {
|
||||||
Value("FM00.000"),
|
Value("FM00.000"),
|
||||||
function="TO_CHAR",
|
function="TO_CHAR",
|
||||||
output_field=TextField(),
|
output_field=TextField(),
|
||||||
),
|
)
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
MOST_ACCURATE_DURATION_FORMAT = "h:mm:ss.sss"
|
|
||||||
MAX_NUMBER_OF_TOKENS_IN_DURATION_FORMAT = len(MOST_ACCURATE_DURATION_FORMAT.split(":"))
|
|
||||||
|
|
||||||
|
|
||||||
# Keep this function in sync with the one in the frontend:
|
def parse_duration_value(formatted_value: str, format: str) -> float:
|
||||||
# web-frontend/modules/database/utils/duration.js ->
|
|
||||||
# guessDurationValueFromString
|
|
||||||
def parse_formatted_duration(
|
|
||||||
formatted_value: str, format: str, strict: bool = False
|
|
||||||
) -> float:
|
|
||||||
"""
|
"""
|
||||||
Parses a formatted duration string into a number of seconds according to the
|
Parses a formatted duration string into a number of seconds according to the
|
||||||
provided format. If the format doesn't match exactly, it will still try to
|
provided format. If the format doesn't match exactly, it will still try to
|
||||||
|
@ -155,46 +302,24 @@ def parse_formatted_duration(
|
||||||
|
|
||||||
:param formatted_value: The formatted duration string.
|
:param formatted_value: The formatted duration string.
|
||||||
:param format: The format of the duration string.
|
:param format: The format of the duration string.
|
||||||
:param strict: If True, the formatted value must match the format exactly or
|
:return: The total number of seconds for the given formatted duration.
|
||||||
a ValueError will be raised. If False, the formatted value can be any
|
|
||||||
acceptable format and will be parsed as best as possible.
|
|
||||||
:return: The number of seconds.
|
|
||||||
:raises ValueError: If the format is invalid.
|
:raises ValueError: If the format is invalid.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
if format not in DURATION_FORMATS:
|
if format not in DURATION_FORMATS:
|
||||||
raise ValueError(f"{format} is not a valid duration format.")
|
raise ValueError(f"{format} is not a valid duration format.")
|
||||||
|
|
||||||
tokens = format.split(":")
|
for regex, format_funcs in DURATION_REGEXPS.items():
|
||||||
parts = formatted_value.split(":")
|
match = regex.match(formatted_value)
|
||||||
if len(parts) > len(tokens):
|
if match:
|
||||||
if strict or len(parts) > MAX_NUMBER_OF_TOKENS_IN_DURATION_FORMAT:
|
format_func = format_funcs.get(format, format_funcs["default"])
|
||||||
raise ValueError(
|
return format_func(*match.groups())
|
||||||
f"Too many parts in formatted value {formatted_value} for format {format}."
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
# In this case we still want to parse the value as best as possible,
|
|
||||||
# so use the most accurate format and round the value later.
|
|
||||||
tokens = MOST_ACCURATE_DURATION_FORMAT.split(":")
|
|
||||||
|
|
||||||
total_seconds = 0
|
# None of the regexps matches the formatted value
|
||||||
for i, token in enumerate(reversed(tokens)):
|
raise ValueError(f"{formatted_value} is not a valid duration string.")
|
||||||
if len(parts) <= i:
|
|
||||||
break
|
|
||||||
# pick the corresponding part from the end of the list
|
|
||||||
part = parts[-(i + 1)]
|
|
||||||
|
|
||||||
token_options = DURATION_FORMAT_TOKENS[token]
|
|
||||||
multiplier = token_options["multiplier"]
|
|
||||||
parse_func = token_options["parse_func"]
|
|
||||||
|
|
||||||
total_seconds += parse_func(part) * multiplier
|
|
||||||
|
|
||||||
total_seconds = DURATION_FORMATS[format]["round_func"](total_seconds)
|
|
||||||
return total_seconds
|
|
||||||
|
|
||||||
|
|
||||||
def convert_duration_input_value_to_timedelta(
|
def duration_value_to_timedelta(
|
||||||
value: Union[int, float, timedelta, str, None], format: str
|
value: Union[int, float, timedelta, str, None], format: str
|
||||||
) -> Optional[timedelta]:
|
) -> Optional[timedelta]:
|
||||||
"""
|
"""
|
||||||
|
@ -207,8 +332,7 @@ def convert_duration_input_value_to_timedelta(
|
||||||
:param format: The format to use when parsing the value if it is a string
|
:param format: The format to use when parsing the value if it is a string
|
||||||
and to round the value to.
|
and to round the value to.
|
||||||
:return: The timedelta object.
|
:return: The timedelta object.
|
||||||
:raises ValueError: If the value is not a valid integer or string according
|
:raises ValueError: If the value has an invalid type.
|
||||||
to the format.
|
|
||||||
:raise OverflowError: If the value is too big to be converted to a timedelta.
|
:raise OverflowError: If the value is too big to be converted to a timedelta.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
@ -220,10 +344,9 @@ def convert_duration_input_value_to_timedelta(
|
||||||
elif isinstance(value, timedelta):
|
elif isinstance(value, timedelta):
|
||||||
return value
|
return value
|
||||||
|
|
||||||
# Since our view_filters are storing the number of seconds as string, let's try to
|
# Since our view_filters are storing the number of seconds as string, let's try
|
||||||
# convert it to a float first. Please note that this is different in the frontend
|
# to convert it to a number first. Please note that this is different in the
|
||||||
# where the input value is a string and immediately use the field format to parse
|
# frontend where the input value is parsed accordingly to the field format.
|
||||||
# it.
|
|
||||||
try:
|
try:
|
||||||
value = float(value)
|
value = float(value)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
|
@ -232,8 +355,7 @@ def convert_duration_input_value_to_timedelta(
|
||||||
if isinstance(value, (int, float)) and value >= 0:
|
if isinstance(value, (int, float)) and value >= 0:
|
||||||
total_seconds = value
|
total_seconds = value
|
||||||
elif isinstance(value, str):
|
elif isinstance(value, str):
|
||||||
formatted_duration = value
|
total_seconds = parse_duration_value(value, format)
|
||||||
total_seconds = parse_formatted_duration(formatted_duration, format)
|
|
||||||
else:
|
else:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
"The provided value should be a valid integer or string according to the "
|
"The provided value should be a valid integer or string according to the "
|
||||||
|
@ -262,7 +384,7 @@ def prepare_duration_value_for_db(
|
||||||
"""
|
"""
|
||||||
|
|
||||||
try:
|
try:
|
||||||
return convert_duration_input_value_to_timedelta(value, duration_format)
|
return duration_value_to_timedelta(value, duration_format)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
raise default_exc(
|
raise default_exc(
|
||||||
f"The value {value} is not a valid duration.",
|
f"The value {value} is not a valid duration.",
|
||||||
|
@ -273,3 +395,107 @@ def prepare_duration_value_for_db(
|
||||||
f"Value {value} is too large. The maximum is {timedelta.max}.",
|
f"Value {value} is too large. The maximum is {timedelta.max}.",
|
||||||
code="invalid",
|
code="invalid",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def format_duration_value(
|
||||||
|
duration: Optional[timedelta], duration_format
|
||||||
|
) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
Format a duration value according to the provided format.
|
||||||
|
|
||||||
|
:param duration: The duration to format.
|
||||||
|
:param duration_format: The format to use.
|
||||||
|
:return: The formatted duration.
|
||||||
|
"""
|
||||||
|
|
||||||
|
if duration is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
days = duration.days
|
||||||
|
hours = duration.seconds // 3600
|
||||||
|
mins = duration.seconds % 3600 // 60
|
||||||
|
secs = duration.seconds % 60 + duration.microseconds / 10**6
|
||||||
|
|
||||||
|
format_func = DURATION_FORMATS[duration_format]["format_func"]
|
||||||
|
return format_func(days, hours, mins, secs)
|
||||||
|
|
||||||
|
|
||||||
|
def tokenize_formatted_duration(duration_format: str) -> List[str]:
|
||||||
|
"""
|
||||||
|
Tokenize a formatted duration format returning a list of tokens.
|
||||||
|
|
||||||
|
:param formatted_value: The formatted duration string.
|
||||||
|
:return: A list with tokens.
|
||||||
|
"""
|
||||||
|
|
||||||
|
return re.split("[: ]", duration_format)
|
||||||
|
|
||||||
|
|
||||||
|
def is_duration_format_conversion_lossy(new_format, old_format):
|
||||||
|
"""
|
||||||
|
Returns True if converting from starting_format to ending_format will result in a
|
||||||
|
loss of precision.
|
||||||
|
|
||||||
|
:param starting_format: The format to convert from.
|
||||||
|
:param ending_format: The format to convert to.
|
||||||
|
"""
|
||||||
|
|
||||||
|
ordered_tokens = list(DURATION_FORMAT_TOKENS.keys())
|
||||||
|
|
||||||
|
new_lst = tokenize_formatted_duration(new_format)[-1]
|
||||||
|
old_lst = tokenize_formatted_duration(old_format)[-1]
|
||||||
|
|
||||||
|
return ordered_tokens.index(old_lst) > ordered_tokens.index(new_lst)
|
||||||
|
|
||||||
|
|
||||||
|
def get_duration_search_expression(field) -> Func:
|
||||||
|
"""
|
||||||
|
Returns a search expression that can be used to search the field as a formatted
|
||||||
|
string.
|
||||||
|
|
||||||
|
:param field: The field to get the search expression for.
|
||||||
|
:return: A search expression that can be used to search the field as a formatted
|
||||||
|
string.
|
||||||
|
"""
|
||||||
|
|
||||||
|
search_exprs = []
|
||||||
|
for token in tokenize_formatted_duration(field.duration_format):
|
||||||
|
search_exp_functions = DURATION_FORMAT_TOKENS[token]["search_expr"]
|
||||||
|
search_expr_func = search_exp_functions.get(
|
||||||
|
field.duration_format, search_exp_functions["default"]
|
||||||
|
)
|
||||||
|
search_exprs.append(search_expr_func(field.db_column))
|
||||||
|
separators = [Value(" ")] * len(search_exprs)
|
||||||
|
# interleave a separator between each extract_expr
|
||||||
|
exprs = [expr for pair in zip(search_exprs, separators) for expr in pair][:-1]
|
||||||
|
return Func(*exprs, function="CONCAT")
|
||||||
|
|
||||||
|
|
||||||
|
def duration_value_sql_to_text(field) -> str:
|
||||||
|
"""
|
||||||
|
Returns a SQL expression that can be used to convert the duration value to a
|
||||||
|
formatted string.
|
||||||
|
|
||||||
|
:param field: The field to get the SQL expression for.
|
||||||
|
:return: A string containing the SQL expression that can be used to convert the
|
||||||
|
duration value to a formatted string.
|
||||||
|
"""
|
||||||
|
|
||||||
|
format_func = ""
|
||||||
|
tokens = tokenize_formatted_duration(field.duration_format)
|
||||||
|
for i, format_token in enumerate(tokens):
|
||||||
|
sql_to_text_funcs = DURATION_FORMAT_TOKENS[format_token]["sql_to_text"]
|
||||||
|
sql_to_text = sql_to_text_funcs.get(
|
||||||
|
field.duration_format, sql_to_text_funcs["default"]
|
||||||
|
)
|
||||||
|
format_func += sql_to_text
|
||||||
|
|
||||||
|
# Add the proper separator between each token, if it's not the last one
|
||||||
|
if i == len(tokens) - 1:
|
||||||
|
break
|
||||||
|
elif format_token == "d": # nosec b105
|
||||||
|
format_func += " || ' ' || "
|
||||||
|
else:
|
||||||
|
format_func += " || ':' || "
|
||||||
|
(format_func)
|
||||||
|
return format_func
|
||||||
|
|
|
@ -0,0 +1,31 @@
|
||||||
|
# Generated by Django 4.0.10 on 2024-01-03 15:03
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("database", "0148_add_formula_button_type"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="durationfield",
|
||||||
|
name="duration_format",
|
||||||
|
field=models.CharField(
|
||||||
|
choices=[
|
||||||
|
("h:mm", "hours:minutes"),
|
||||||
|
("h:mm:ss", "hours:minutes:seconds"),
|
||||||
|
("h:mm:ss.s", "hours:minutes:seconds:deciseconds"),
|
||||||
|
("h:mm:ss.ss", "hours:minutes:seconds:centiseconds"),
|
||||||
|
("h:mm:ss.sss", "hours:minutes:seconds:milliseconds"),
|
||||||
|
("d h", "days:hours"),
|
||||||
|
("d h:mm", "days:hours:minutes"),
|
||||||
|
("d h:mm:ss", "days:hours:minutes:seconds"),
|
||||||
|
],
|
||||||
|
default="h:mm",
|
||||||
|
help_text="The format of the duration.",
|
||||||
|
max_length=32,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
|
@ -195,11 +195,14 @@ def setup_interesting_test_table(
|
||||||
"created_on_datetime_eu_tzone": None,
|
"created_on_datetime_eu_tzone": None,
|
||||||
"last_modified_by": None,
|
"last_modified_by": None,
|
||||||
"created_by": None,
|
"created_by": None,
|
||||||
"duration_hm": timedelta(seconds=3660),
|
"duration_hm": timedelta(hours=1, minutes=1),
|
||||||
"duration_hms": timedelta(seconds=3666),
|
"duration_hms": timedelta(hours=1, minutes=1, seconds=6),
|
||||||
"duration_hms_s": timedelta(seconds=3666.6),
|
"duration_hms_s": timedelta(hours=1, minutes=1, seconds=6.6),
|
||||||
"duration_hms_ss": timedelta(seconds=3666.66),
|
"duration_hms_ss": timedelta(hours=1, minutes=1, seconds=6.66),
|
||||||
"duration_hms_sss": timedelta(seconds=3666.666),
|
"duration_hms_sss": timedelta(hours=1, minutes=1, seconds=6.666),
|
||||||
|
"duration_dh": timedelta(days=1, hours=1),
|
||||||
|
"duration_dhm": timedelta(days=1, hours=1, minutes=1),
|
||||||
|
"duration_dhms": timedelta(days=1, hours=1, minutes=1, seconds=6),
|
||||||
# We will setup link rows manually later
|
# We will setup link rows manually later
|
||||||
"link_row": None,
|
"link_row": None,
|
||||||
"self_link_row": None,
|
"self_link_row": None,
|
||||||
|
|
|
@ -7,13 +7,14 @@ from baserow.contrib.database.api.fields.serializers import DurationFieldSeriali
|
||||||
from baserow.contrib.database.fields.utils.duration import (
|
from baserow.contrib.database.fields.utils.duration import (
|
||||||
DURATION_FORMAT_TOKENS,
|
DURATION_FORMAT_TOKENS,
|
||||||
DURATION_FORMATS,
|
DURATION_FORMATS,
|
||||||
|
tokenize_formatted_duration,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"duration_format,user_input,saved_value",
|
"duration_format,user_input,saved_value",
|
||||||
[
|
[
|
||||||
# Normal input:
|
# number input:
|
||||||
("h:mm", 0, timedelta(seconds=0)),
|
("h:mm", 0, timedelta(seconds=0)),
|
||||||
("h:mm", 3660, timedelta(seconds=3660)),
|
("h:mm", 3660, timedelta(seconds=3660)),
|
||||||
("h:mm", 86400, timedelta(days=1)),
|
("h:mm", 86400, timedelta(days=1)),
|
||||||
|
@ -23,6 +24,14 @@ from baserow.contrib.database.fields.utils.duration import (
|
||||||
("h:mm:ss.ss", 3661.12, timedelta(seconds=3661.12)),
|
("h:mm:ss.ss", 3661.12, timedelta(seconds=3661.12)),
|
||||||
("h:mm:ss.sss", 0, timedelta(seconds=0)),
|
("h:mm:ss.sss", 0, timedelta(seconds=0)),
|
||||||
("h:mm:ss.sss", 3661.123, timedelta(seconds=3661.123)),
|
("h:mm:ss.sss", 3661.123, timedelta(seconds=3661.123)),
|
||||||
|
("d h", 0, timedelta(seconds=0)),
|
||||||
|
("d h", 3600, timedelta(hours=1)),
|
||||||
|
("d h", 90000, timedelta(days=1, hours=1)),
|
||||||
|
("d h:mm", 0, timedelta(seconds=0)),
|
||||||
|
("d h:mm", 3660, timedelta(hours=1, minutes=1)),
|
||||||
|
("d h:mm", 86400, timedelta(days=1)),
|
||||||
|
("d h:mm:ss", 0, timedelta(seconds=0)),
|
||||||
|
("d h:mm:ss", 3661, timedelta(seconds=3661)),
|
||||||
# Rounding:
|
# Rounding:
|
||||||
("h:mm", 3661.123, timedelta(seconds=3660)),
|
("h:mm", 3661.123, timedelta(seconds=3660)),
|
||||||
("h:mm", 3661.999, timedelta(seconds=3660)),
|
("h:mm", 3661.999, timedelta(seconds=3660)),
|
||||||
|
@ -34,12 +43,39 @@ from baserow.contrib.database.fields.utils.duration import (
|
||||||
("h:mm:ss.ss", 3661.789, timedelta(seconds=3661.79)),
|
("h:mm:ss.ss", 3661.789, timedelta(seconds=3661.79)),
|
||||||
("h:mm:ss.sss", 3661.1234, timedelta(seconds=3661.123)),
|
("h:mm:ss.sss", 3661.1234, timedelta(seconds=3661.123)),
|
||||||
("h:mm:ss.sss", 3661.6789, timedelta(seconds=3661.679)),
|
("h:mm:ss.sss", 3661.6789, timedelta(seconds=3661.679)),
|
||||||
|
("d h", 86400 + 3600.123, timedelta(days=1, hours=1)),
|
||||||
|
("d h", 29.9 * 60, timedelta(seconds=0)),
|
||||||
|
("d h", 30 * 60, timedelta(hours=1)),
|
||||||
|
("d h:mm", 29, timedelta(seconds=0)),
|
||||||
|
("d h:mm", 30, timedelta(minutes=1)),
|
||||||
|
("d h:mm:ss", 0.49, timedelta(seconds=0)),
|
||||||
|
("d h:mm:ss", 0.5, timedelta(seconds=1)),
|
||||||
# String input:
|
# String input:
|
||||||
("h:mm", "1:01", timedelta(seconds=3660)),
|
("h:mm", "1:01", timedelta(seconds=3660)),
|
||||||
("h:mm:ss", "1:01:01", timedelta(seconds=3661)),
|
("h:mm:ss", "1:01:01", timedelta(seconds=3661)),
|
||||||
("h:mm:ss.s", "1:01:01.1", timedelta(seconds=3661.1)),
|
("h:mm:ss.s", "1:01:01.1", timedelta(seconds=3661.1)),
|
||||||
("h:mm:ss.ss", "1:01:01.12", timedelta(seconds=3661.12)),
|
("h:mm:ss.ss", "1:01:01.12", timedelta(seconds=3661.12)),
|
||||||
("h:mm:ss.sss", "1:01:01.123", timedelta(seconds=3661.123)),
|
("h:mm:ss.sss", "1:01:01.123", timedelta(seconds=3661.123)),
|
||||||
|
("d h", "1 1", timedelta(days=1, hours=1)),
|
||||||
|
("d h", "1d1h", timedelta(days=1, hours=1)),
|
||||||
|
("d h", "1d 1h", timedelta(days=1, hours=1)),
|
||||||
|
("d h", "1 1h", timedelta(days=1, hours=1)),
|
||||||
|
("d h", "1d 1", timedelta(days=1, hours=1)),
|
||||||
|
("d h", "1d1", timedelta(days=1, hours=1)),
|
||||||
|
("d h", "12h", timedelta(hours=12)),
|
||||||
|
("d h", "3d", timedelta(days=3)),
|
||||||
|
("d h:mm", "1 1:01", timedelta(days=1, seconds=3660)),
|
||||||
|
("d h:mm", "1d1:01", timedelta(days=1, seconds=3660)),
|
||||||
|
("d h:mm", "1d 1:01", timedelta(days=1, seconds=3660)),
|
||||||
|
("d h:mm", "1 1:1", timedelta(days=1, seconds=3660)),
|
||||||
|
("d h:mm", "1d1:1", timedelta(days=1, seconds=3660)),
|
||||||
|
("d h:mm", "1d 1:1", timedelta(days=1, seconds=3660)),
|
||||||
|
("d h:mm:ss", "1 1:01:01", timedelta(days=1, seconds=3661)),
|
||||||
|
("d h:mm:ss", "1d1:01:01", timedelta(days=1, seconds=3661)),
|
||||||
|
("d h:mm:ss", "1d 1:01:01", timedelta(days=1, seconds=3661)),
|
||||||
|
("d h:mm:ss", "1 1:1:1", timedelta(days=1, seconds=3661)),
|
||||||
|
("d h:mm:ss", "1d1:1:1", timedelta(days=1, seconds=3661)),
|
||||||
|
("d h:mm:ss", "1d 1:1:1", timedelta(days=1, seconds=3661)),
|
||||||
# String input with rounding:
|
# String input with rounding:
|
||||||
("h:mm", "1:01:01.123", timedelta(seconds=3660)),
|
("h:mm", "1:01:01.123", timedelta(seconds=3660)),
|
||||||
("h:mm", "1:01:01.999", timedelta(seconds=3660)),
|
("h:mm", "1:01:01.999", timedelta(seconds=3660)),
|
||||||
|
@ -51,18 +87,35 @@ from baserow.contrib.database.fields.utils.duration import (
|
||||||
("h:mm:ss.ss", "1:01:01.789", timedelta(seconds=3661.79)),
|
("h:mm:ss.ss", "1:01:01.789", timedelta(seconds=3661.79)),
|
||||||
("h:mm:ss.sss", "1:01:01.1234", timedelta(seconds=3661.123)),
|
("h:mm:ss.sss", "1:01:01.1234", timedelta(seconds=3661.123)),
|
||||||
("h:mm:ss.sss", "1:01:01.6789", timedelta(seconds=3661.679)),
|
("h:mm:ss.sss", "1:01:01.6789", timedelta(seconds=3661.679)),
|
||||||
|
("d h", "1 1:01:01.123", timedelta(days=1, hours=1)),
|
||||||
|
("d h", "1.123", timedelta(seconds=0)),
|
||||||
|
("d h", "29:59.999", timedelta(seconds=0)),
|
||||||
|
("d h", "30:00", timedelta(hours=30)),
|
||||||
|
("d h", "30:00.001", timedelta(hours=1)),
|
||||||
|
("d h:mm", "1 1:01:01.123", timedelta(days=1, hours=1, minutes=1)),
|
||||||
|
("d h:mm", "1.123", timedelta(seconds=0)),
|
||||||
|
("d h:mm", "29.999", timedelta(seconds=0)),
|
||||||
|
("d h:mm", "30", timedelta(minutes=1)),
|
||||||
|
("d h:mm", "30.001", timedelta(minutes=1)),
|
||||||
|
("d h:mm:ss", "1 1:01:01.123", timedelta(days=1, seconds=3661)),
|
||||||
|
("d h:mm:ss", "1.123", timedelta(seconds=1)),
|
||||||
|
("d h:mm:ss", "29.999", timedelta(seconds=30)),
|
||||||
|
("d h:mm:ss", "70", timedelta(seconds=70)),
|
||||||
|
("d h:mm:ss", "0.5", timedelta(seconds=1)),
|
||||||
# None should be None in every format:
|
# None should be None in every format:
|
||||||
("h:mm", None, None),
|
("h:mm", None, None),
|
||||||
("h:mm:ss", None, None),
|
("h:mm:ss", None, None),
|
||||||
("h:mm:ss.s", None, None),
|
("h:mm:ss.s", None, None),
|
||||||
("h:mm:ss.ss", None, None),
|
("h:mm:ss.ss", None, None),
|
||||||
("h:mm:ss.sss", None, None),
|
("h:mm:ss.sss", None, None),
|
||||||
|
("d h", None, None),
|
||||||
|
("d h:mm", None, None),
|
||||||
|
("d h:mm:ss", None, None),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
@pytest.mark.django_db
|
|
||||||
@pytest.mark.field_duration
|
@pytest.mark.field_duration
|
||||||
def test_duration_serializer_to_internal_value(
|
def test_duration_serializer_to_internal_value(
|
||||||
data_fixture, duration_format, user_input, saved_value
|
duration_format, user_input, saved_value
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Tests that for the Duration Serializer, the value is always serialized as
|
Tests that for the Duration Serializer, the value is always serialized as
|
||||||
|
@ -88,12 +141,15 @@ def test_duration_serializer_to_internal_value(
|
||||||
("h:mm:ss.s", "aaaaaaa"),
|
("h:mm:ss.s", "aaaaaaa"),
|
||||||
("invalid format", 1),
|
("invalid format", 1),
|
||||||
("h:m", timedelta.max.total_seconds() + 1), # Overflow
|
("h:m", timedelta.max.total_seconds() + 1), # Overflow
|
||||||
|
("d h", "1dd"),
|
||||||
|
("d h", "1hh"),
|
||||||
|
("d h", "1d1d"),
|
||||||
|
("d h", "1h1h"),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
@pytest.mark.django_db
|
|
||||||
@pytest.mark.field_duration
|
@pytest.mark.field_duration
|
||||||
def test_duration_serializer_to_internal_value_with_invalid_values(
|
def test_duration_serializer_to_internal_value_with_invalid_values(
|
||||||
data_fixture, duration_format, user_input
|
duration_format, user_input
|
||||||
):
|
):
|
||||||
serializer = DurationFieldSerializer(duration_format=duration_format)
|
serializer = DurationFieldSerializer(duration_format=duration_format)
|
||||||
with pytest.raises(serializers.ValidationError):
|
with pytest.raises(serializers.ValidationError):
|
||||||
|
@ -103,38 +159,34 @@ def test_duration_serializer_to_internal_value_with_invalid_values(
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"duration_format,user_input,returned_value",
|
"duration_format,user_input,returned_value",
|
||||||
[
|
[
|
||||||
("h:mm", 3660, 3660),
|
("h:mm", timedelta(seconds=0), 0),
|
||||||
("h:mm:ss", 3661, 3661),
|
("h:mm", timedelta(hours=1, minutes=1), 3660),
|
||||||
("h:mm:ss.s", 3661.1, 3661.1),
|
("h:mm:ss", timedelta(hours=1, minutes=1, seconds=1), 3661),
|
||||||
("h:mm:ss.ss", 3661.12, 3661.12),
|
("h:mm:ss.s", timedelta(hours=1, minutes=1, seconds=1.1), 3661.1),
|
||||||
("h:mm:ss.sss", 3661.123, 3661.123),
|
("h:mm:ss.ss", timedelta(hours=1, minutes=1, seconds=1.12), 3661.12),
|
||||||
("h:mm:ss.sss", 3661.1234, 3661.1234),
|
("h:mm:ss.sss", timedelta(hours=1, minutes=1, seconds=1.123), 3661.123),
|
||||||
|
("h:mm:ss.sss", timedelta(hours=1, minutes=1, seconds=1.1234), 3661.1234),
|
||||||
|
("d h", timedelta(days=1, hours=1), 90000),
|
||||||
|
("d h:mm", timedelta(days=1, hours=1, minutes=1), 90060),
|
||||||
|
("d h:mm:ss", timedelta(days=1, hours=1, minutes=1, seconds=1), 90061),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
@pytest.mark.django_db
|
|
||||||
@pytest.mark.field_duration
|
@pytest.mark.field_duration
|
||||||
def test_duration_serializer_to_representation(
|
def test_duration_serializer_to_representation(
|
||||||
data_fixture, duration_format, user_input, returned_value
|
duration_format, user_input, returned_value
|
||||||
):
|
):
|
||||||
"""
|
|
||||||
Tests that for the Duration Serializer, the representation is returned in
|
|
||||||
seconds (value.total_seconds()) from the database for every duration format.
|
|
||||||
"""
|
|
||||||
|
|
||||||
serializer = DurationFieldSerializer(duration_format=duration_format)
|
serializer = DurationFieldSerializer(duration_format=duration_format)
|
||||||
|
assert serializer.to_representation(user_input) == returned_value
|
||||||
assert serializer.to_representation(timedelta(seconds=user_input)) == returned_value
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
|
||||||
@pytest.mark.field_duration
|
@pytest.mark.field_duration
|
||||||
def test_duration_token_options(data_fixture):
|
def test_duration_token_options():
|
||||||
"""
|
"""
|
||||||
Tests that the token options are correct for the duration field.
|
Tests that format are made of tokens that are in DURATION_FORMAT_TOKENS.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
for format in DURATION_FORMATS.keys():
|
for format in DURATION_FORMATS.keys():
|
||||||
for token in format.split(":"):
|
for token in tokenize_formatted_duration(format):
|
||||||
assert token in DURATION_FORMAT_TOKENS, (
|
assert token in DURATION_FORMAT_TOKENS, (
|
||||||
f"{token} not in DURATION_FORMAT_TOKENS. Please add it with the correct "
|
f"{token} not in DURATION_FORMAT_TOKENS. Please add it with the correct "
|
||||||
"options to be able to convert a formatted string to a timedelta."
|
"options to be able to convert a formatted string to a timedelta."
|
|
@ -250,6 +250,9 @@ def test_get_row_serializer_with_user_field_names(data_fixture):
|
||||||
"duration_hms_s": 3666.6,
|
"duration_hms_s": 3666.6,
|
||||||
"duration_hms_ss": 3666.66,
|
"duration_hms_ss": 3666.66,
|
||||||
"duration_hms_sss": 3666.666,
|
"duration_hms_sss": 3666.666,
|
||||||
|
"duration_dh": 90000,
|
||||||
|
"duration_dhm": 90060,
|
||||||
|
"duration_dhms": 90066,
|
||||||
"email": "test@example.com",
|
"email": "test@example.com",
|
||||||
"file": [
|
"file": [
|
||||||
{
|
{
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
import pytest
|
import pytest
|
||||||
|
from pytest_unordered import unordered
|
||||||
|
|
||||||
from baserow.contrib.database.api.views.serializers import serialize_group_by_metadata
|
from baserow.contrib.database.api.views.serializers import serialize_group_by_metadata
|
||||||
from baserow.contrib.database.fields.models import Field
|
from baserow.contrib.database.fields.models import Field
|
||||||
|
@ -90,7 +91,7 @@ def test_serialize_group_by_metadata_on_all_fields_in_interesting_table(data_fix
|
||||||
# comparison.
|
# comparison.
|
||||||
for result in serialized:
|
for result in serialized:
|
||||||
result[f"field_{field.name}"] = result.pop(f"field_{str(field.id)}")
|
result[f"field_{field.name}"] = result.pop(f"field_{str(field.id)}")
|
||||||
actual_result_per_field_name[field.name] = serialized
|
actual_result_per_field_name[field.name] = unordered(serialized)
|
||||||
|
|
||||||
assert actual_result_per_field_name == {
|
assert actual_result_per_field_name == {
|
||||||
"text": [
|
"text": [
|
||||||
|
@ -237,4 +238,16 @@ def test_serialize_group_by_metadata_on_all_fields_in_interesting_table(data_fix
|
||||||
{"count": 1, "field_duration_hms_sss": 3666.666},
|
{"count": 1, "field_duration_hms_sss": 3666.666},
|
||||||
{"count": 1, "field_duration_hms_sss": None},
|
{"count": 1, "field_duration_hms_sss": None},
|
||||||
],
|
],
|
||||||
|
"duration_dh": [
|
||||||
|
{"count": 1, "field_duration_dh": 90000.0},
|
||||||
|
{"count": 1, "field_duration_dh": None},
|
||||||
|
],
|
||||||
|
"duration_dhm": [
|
||||||
|
{"count": 1, "field_duration_dhm": 90060.0},
|
||||||
|
{"count": 1, "field_duration_dhm": None},
|
||||||
|
],
|
||||||
|
"duration_dhms": [
|
||||||
|
{"count": 1, "field_duration_dhms": 90066.0},
|
||||||
|
{"count": 1, "field_duration_dhms": None},
|
||||||
|
],
|
||||||
}
|
}
|
||||||
|
|
|
@ -227,14 +227,15 @@ def test_can_export_every_interesting_different_field_to_csv(
|
||||||
"last_modified_date_eu,last_modified_datetime_eu_tzone,created_on_datetime_us,"
|
"last_modified_date_eu,last_modified_datetime_eu_tzone,created_on_datetime_us,"
|
||||||
"created_on_date_us,created_on_datetime_eu,created_on_date_eu,created_on_datetime_eu_tzone,"
|
"created_on_date_us,created_on_datetime_eu,created_on_date_eu,created_on_datetime_eu_tzone,"
|
||||||
"last_modified_by,created_by,duration_hm,duration_hms,duration_hms_s,duration_hms_ss,"
|
"last_modified_by,created_by,duration_hm,duration_hms,duration_hms_s,duration_hms_ss,"
|
||||||
"duration_hms_sss,link_row,self_link_row,link_row_without_related,decimal_link_row,"
|
"duration_hms_sss,duration_dh,duration_dhm,duration_dhms,"
|
||||||
|
"link_row,self_link_row,link_row_without_related,decimal_link_row,"
|
||||||
"file_link_row,file,single_select,multiple_select,multiple_collaborators,"
|
"file_link_row,file,single_select,multiple_select,multiple_collaborators,"
|
||||||
"phone_number,formula_text,formula_int,formula_bool,formula_decimal,formula_dateinterval,"
|
"phone_number,formula_text,formula_int,formula_bool,formula_decimal,formula_dateinterval,"
|
||||||
"formula_date,formula_singleselect,formula_email,formula_link_with_label,"
|
"formula_date,formula_singleselect,formula_email,formula_link_with_label,"
|
||||||
"formula_link_url_only,formula_multipleselect,count,rollup,lookup,uuid,autonumber\r\n"
|
"formula_link_url_only,formula_multipleselect,count,rollup,lookup,uuid,autonumber\r\n"
|
||||||
"1,,,,,,,,,0,False,,,,,,,01/02/2021 12:00,01/02/2021,02/01/2021 12:00,02/01/2021,"
|
"1,,,,,,,,,0,False,,,,,,,01/02/2021 12:00,01/02/2021,02/01/2021 12:00,02/01/2021,"
|
||||||
"02/01/2021 13:00,01/02/2021 12:00,01/02/2021,02/01/2021 12:00,02/01/2021,02/01/2021 13:00,"
|
"02/01/2021 13:00,01/02/2021 12:00,01/02/2021,02/01/2021 12:00,02/01/2021,02/01/2021 13:00,"
|
||||||
"user@example.com,user@example.com,,,,,,,,,,,,,,,,test FORMULA,1,True,33.3333333333,"
|
"user@example.com,user@example.com,,,,,,,,,,,,,,,,,,,test FORMULA,1,True,33.3333333333,"
|
||||||
"1 day,2020-01-01,,,label (https://google.com),https://google.com,,0,0.000,,"
|
"1 day,2020-01-01,,,label (https://google.com),https://google.com,,0,0.000,,"
|
||||||
"00000000-0000-4000-8000-000000000001,1\r\n"
|
"00000000-0000-4000-8000-000000000001,1\r\n"
|
||||||
"2,text,long_text,https://www.google.com,test@example.com,-1,1,-1.2,1.2,3,True,"
|
"2,text,long_text,https://www.google.com,test@example.com,-1,1,-1.2,1.2,3,True,"
|
||||||
|
@ -242,7 +243,7 @@ def test_can_export_every_interesting_different_field_to_csv(
|
||||||
"01/02/2020 02:23,01/02/2021 12:00,01/02/2021,02/01/2021 12:00,02/01/2021,"
|
"01/02/2020 02:23,01/02/2021 12:00,01/02/2021,02/01/2021 12:00,02/01/2021,"
|
||||||
"02/01/2021 13:00,01/02/2021 12:00,01/02/2021,02/01/2021 12:00,02/01/2021,"
|
"02/01/2021 13:00,01/02/2021 12:00,01/02/2021,02/01/2021 12:00,02/01/2021,"
|
||||||
"02/01/2021 13:00,user@example.com,user@example.com,"
|
"02/01/2021 13:00,user@example.com,user@example.com,"
|
||||||
"1:01,1:01:06,1:01:06.6,1:01:06.66,1:01:06.666,"
|
"1:01,1:01:06,1:01:06.6,1:01:06.66,1:01:06.666,1d 1h,1d 1:01,1d 1:01:06,"
|
||||||
'"linked_row_1,linked_row_2,unnamed row 3",unnamed row 1,'
|
'"linked_row_1,linked_row_2,unnamed row 3",unnamed row 1,'
|
||||||
'"linked_row_1,linked_row_2","1.234,-123.456,unnamed row 3",'
|
'"linked_row_1,linked_row_2","1.234,-123.456,unnamed row 3",'
|
||||||
'"name.txt (http://localhost:8000/media/user_files/test_hash.txt),unnamed row 2",'
|
'"name.txt (http://localhost:8000/media/user_files/test_hash.txt),unnamed row 2",'
|
||||||
|
|
|
@ -196,6 +196,15 @@ def test_remove_duration_field(data_fixture):
|
||||||
(timedelta(seconds=3665.5), "h:mm:ss.s", "1:01:05.5"),
|
(timedelta(seconds=3665.5), "h:mm:ss.s", "1:01:05.5"),
|
||||||
(timedelta(seconds=3665.55), "h:mm:ss.ss", "1:01:05.55"),
|
(timedelta(seconds=3665.55), "h:mm:ss.ss", "1:01:05.55"),
|
||||||
(timedelta(seconds=3665.555), "h:mm:ss.sss", "1:01:05.555"),
|
(timedelta(seconds=3665.555), "h:mm:ss.sss", "1:01:05.555"),
|
||||||
|
(timedelta(days=1, hours=2), "d h", "1d 2h"),
|
||||||
|
(timedelta(days=1, hours=1, minutes=2), "d h:mm", "1d 1:02"),
|
||||||
|
(timedelta(days=1, hours=1, minutes=2, seconds=3), "d h:mm:ss", "1d 1:02:03"),
|
||||||
|
(None, "h:mm", None),
|
||||||
|
(None, "d h", None),
|
||||||
|
(None, "d h:mm", None),
|
||||||
|
(None, "d h:mm:ss", None),
|
||||||
|
(0, "h:mm", "0:00"),
|
||||||
|
(0, "d h", "0d 0h"),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
|
@ -261,6 +270,21 @@ def test_convert_duration_field_to_text_field(
|
||||||
"1:01:05.555",
|
"1:01:05.555",
|
||||||
timedelta(seconds=3665.555),
|
timedelta(seconds=3665.555),
|
||||||
),
|
),
|
||||||
|
(
|
||||||
|
"d h",
|
||||||
|
"1d 2h",
|
||||||
|
timedelta(days=1, hours=2),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"d h:mm",
|
||||||
|
"1d 1:02",
|
||||||
|
timedelta(days=1, hours=1, minutes=2),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"d h:mm:ss",
|
||||||
|
"1d 1:02:03",
|
||||||
|
timedelta(days=1, hours=1, minutes=2, seconds=3),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
|
@ -315,11 +339,38 @@ def test_convert_text_field_to_duration_field(
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"expected,field_kwargs",
|
"expected,field_kwargs",
|
||||||
[
|
[
|
||||||
([0, 0, 60, 120, 120], {"duration_format": "h:mm"}),
|
(
|
||||||
([1, 10, 51, 100, 122], {"duration_format": "h:mm:ss"}),
|
[0, 0, 60, 120, 120, 86460, 90000],
|
||||||
([1.2, 10.1, 50.7, 100.1, 122], {"duration_format": "h:mm:ss.s"}),
|
{"duration_format": "h:mm"},
|
||||||
([1.20, 10.11, 50.68, 100.1, 122], {"duration_format": "h:mm:ss.ss"}),
|
),
|
||||||
([1.199, 10.11, 50.679, 100.1, 122], {"duration_format": "h:mm:ss.sss"}),
|
(
|
||||||
|
[1, 10, 51, 100, 122, 86461, 90002],
|
||||||
|
{"duration_format": "h:mm:ss"},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
[1.2, 10.1, 50.7, 100.1, 122, 86461, 90001.8],
|
||||||
|
{"duration_format": "h:mm:ss.s"},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
[1.20, 10.11, 50.68, 100.1, 122, 86461, 90001.8],
|
||||||
|
{"duration_format": "h:mm:ss.ss"},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
[1.199, 10.11, 50.679, 100.1, 122, 86461, 90001.8],
|
||||||
|
{"duration_format": "h:mm:ss.sss"},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
[0, 0, 0, 0, 0, 86400, 90000],
|
||||||
|
{"duration_format": "d h"},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
[0, 0, 60, 120, 120, 86460, 90000],
|
||||||
|
{"duration_format": "d h:mm"},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
[1, 10, 51, 100, 122, 86461, 90002],
|
||||||
|
{"duration_format": "d h:mm:ss"},
|
||||||
|
),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
def test_alter_duration_format(expected, field_kwargs, data_fixture):
|
def test_alter_duration_format(expected, field_kwargs, data_fixture):
|
||||||
|
@ -329,7 +380,7 @@ def test_alter_duration_format(expected, field_kwargs, data_fixture):
|
||||||
user=user, table=table, duration_format="h:mm:ss.sss"
|
user=user, table=table, duration_format="h:mm:ss.sss"
|
||||||
)
|
)
|
||||||
|
|
||||||
original_values = [1.199, 10.11, 50.6789, 100.1, 122]
|
original_values = [1.199, 10.11, 50.6789, 100.1, 122, 86461, 90001.8]
|
||||||
|
|
||||||
RowHandler().create_rows(
|
RowHandler().create_rows(
|
||||||
user,
|
user,
|
||||||
|
@ -353,11 +404,38 @@ def test_alter_duration_format(expected, field_kwargs, data_fixture):
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"expected,field_kwargs",
|
"expected,field_kwargs",
|
||||||
[
|
[
|
||||||
([0, 0, 60, 120, 120], {"duration_format": "h:mm"}),
|
(
|
||||||
([1, 10, 51, 100, 122], {"duration_format": "h:mm:ss"}),
|
[0, 0, 60, 120, 120, 86460, 90000],
|
||||||
([1.2, 10.1, 50.7, 100.1, 122], {"duration_format": "h:mm:ss.s"}),
|
{"duration_format": "h:mm"},
|
||||||
([1.20, 10.11, 50.68, 100.1, 122], {"duration_format": "h:mm:ss.ss"}),
|
),
|
||||||
([1.199, 10.11, 50.679, 100.1, 122], {"duration_format": "h:mm:ss.sss"}),
|
(
|
||||||
|
[1, 10, 51, 100, 122, 86461, 90002],
|
||||||
|
{"duration_format": "h:mm:ss"},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
[1.2, 10.1, 50.7, 100.1, 122, 86461, 90001.8],
|
||||||
|
{"duration_format": "h:mm:ss.s"},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
[1.20, 10.11, 50.68, 100.1, 122, 86461, 90001.8],
|
||||||
|
{"duration_format": "h:mm:ss.ss"},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
[1.199, 10.11, 50.679, 100.1, 122, 86461, 90001.8],
|
||||||
|
{"duration_format": "h:mm:ss.sss"},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
[0, 0, 0, 0, 0, 86400, 90000],
|
||||||
|
{"duration_format": "d h"},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
[0, 0, 60, 120, 120, 86460, 90000],
|
||||||
|
{"duration_format": "d h:mm"},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
[1, 10, 51, 100, 122, 86461, 90002],
|
||||||
|
{"duration_format": "d h:mm:ss"},
|
||||||
|
),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
def test_alter_duration_format_can_be_undone(expected, field_kwargs, data_fixture):
|
def test_alter_duration_format_can_be_undone(expected, field_kwargs, data_fixture):
|
||||||
|
@ -368,7 +446,7 @@ def test_alter_duration_format_can_be_undone(expected, field_kwargs, data_fixtur
|
||||||
user=user, table=table, duration_format="h:mm:ss.sss"
|
user=user, table=table, duration_format="h:mm:ss.sss"
|
||||||
)
|
)
|
||||||
|
|
||||||
original_values = [1.199, 10.11, 50.679, 100.1, 122]
|
original_values = [1.199, 10.11, 50.679, 100.1, 122, 86461, 90001.8]
|
||||||
|
|
||||||
RowHandler().create_rows(
|
RowHandler().create_rows(
|
||||||
user,
|
user,
|
||||||
|
@ -416,7 +494,7 @@ def test_duration_field_view_filters(data_fixture):
|
||||||
{field.db_column: 1.123},
|
{field.db_column: 1.123},
|
||||||
{field.db_column: 60}, # 1min
|
{field.db_column: 60}, # 1min
|
||||||
{field.db_column: "24:0:0"}, # 1day
|
{field.db_column: "24:0:0"}, # 1day
|
||||||
{field.db_column: 86400}, # 1day
|
{field.db_column: "1 0"}, # 1day
|
||||||
{field.db_column: 3601}, # 1hour 1sec
|
{field.db_column: 3601}, # 1hour 1sec
|
||||||
{field.db_column: "1:0:0"}, # 1 hour
|
{field.db_column: "1:0:0"}, # 1 hour
|
||||||
],
|
],
|
||||||
|
|
|
@ -1563,6 +1563,27 @@ def test_local_baserow_table_service_generate_schema_with_interesting_test_table
|
||||||
"metadata": {},
|
"metadata": {},
|
||||||
"type": "string",
|
"type": "string",
|
||||||
},
|
},
|
||||||
|
field_db_column_by_name["duration_dh"]: {
|
||||||
|
"title": "duration_dh",
|
||||||
|
"default": None,
|
||||||
|
"original_type": "duration",
|
||||||
|
"metadata": {},
|
||||||
|
"type": "string",
|
||||||
|
},
|
||||||
|
field_db_column_by_name["duration_dhm"]: {
|
||||||
|
"title": "duration_dhm",
|
||||||
|
"default": None,
|
||||||
|
"original_type": "duration",
|
||||||
|
"metadata": {},
|
||||||
|
"type": "string",
|
||||||
|
},
|
||||||
|
field_db_column_by_name["duration_dhms"]: {
|
||||||
|
"title": "duration_dhms",
|
||||||
|
"default": None,
|
||||||
|
"original_type": "duration",
|
||||||
|
"metadata": {},
|
||||||
|
"type": "string",
|
||||||
|
},
|
||||||
"id": {"metadata": {}, "type": "number", "title": "Id"},
|
"id": {"metadata": {}, "type": "number", "title": "Id"},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,7 @@
|
||||||
|
{
|
||||||
|
"type": "feature",
|
||||||
|
"message": "Add formats with days to the duration field.",
|
||||||
|
"issue_number": 2217,
|
||||||
|
"bullet_points": [],
|
||||||
|
"created_at": "2024-01-04"
|
||||||
|
}
|
|
@ -71,6 +71,9 @@ def test_can_export_every_interesting_different_field_to_json(
|
||||||
"duration_hms_s": "",
|
"duration_hms_s": "",
|
||||||
"duration_hms_ss": "",
|
"duration_hms_ss": "",
|
||||||
"duration_hms_sss": "",
|
"duration_hms_sss": "",
|
||||||
|
"duration_dh": "",
|
||||||
|
"duration_dhm": "",
|
||||||
|
"duration_dhms": "",
|
||||||
"link_row": [],
|
"link_row": [],
|
||||||
"self_link_row": [],
|
"self_link_row": [],
|
||||||
"link_row_without_related": [],
|
"link_row_without_related": [],
|
||||||
|
@ -138,6 +141,9 @@ def test_can_export_every_interesting_different_field_to_json(
|
||||||
"duration_hms_s": "1:01:06.6",
|
"duration_hms_s": "1:01:06.6",
|
||||||
"duration_hms_ss": "1:01:06.66",
|
"duration_hms_ss": "1:01:06.66",
|
||||||
"duration_hms_sss": "1:01:06.666",
|
"duration_hms_sss": "1:01:06.666",
|
||||||
|
"duration_dh": "1d 1h",
|
||||||
|
"duration_dhm": "1d 1:01",
|
||||||
|
"duration_dhms": "1d 1:01:06",
|
||||||
"link_row": [
|
"link_row": [
|
||||||
"linked_row_1",
|
"linked_row_1",
|
||||||
"linked_row_2",
|
"linked_row_2",
|
||||||
|
@ -328,6 +334,9 @@ def test_can_export_every_interesting_different_field_to_xml(
|
||||||
<duration-hms-s/>
|
<duration-hms-s/>
|
||||||
<duration-hms-ss/>
|
<duration-hms-ss/>
|
||||||
<duration-hms-sss/>
|
<duration-hms-sss/>
|
||||||
|
<duration-dh/>
|
||||||
|
<duration-dhm/>
|
||||||
|
<duration-dhms/>
|
||||||
<link-row/>
|
<link-row/>
|
||||||
<self-link-row/>
|
<self-link-row/>
|
||||||
<link-row-without-related/>
|
<link-row-without-related/>
|
||||||
|
@ -395,6 +404,9 @@ def test_can_export_every_interesting_different_field_to_xml(
|
||||||
<duration-hms-s>1:01:06.6</duration-hms-s>
|
<duration-hms-s>1:01:06.6</duration-hms-s>
|
||||||
<duration-hms-ss>1:01:06.66</duration-hms-ss>
|
<duration-hms-ss>1:01:06.66</duration-hms-ss>
|
||||||
<duration-hms-sss>1:01:06.666</duration-hms-sss>
|
<duration-hms-sss>1:01:06.666</duration-hms-sss>
|
||||||
|
<duration-dh>1d 1h</duration-dh>
|
||||||
|
<duration-dhm>1d 1:01</duration-dhm>
|
||||||
|
<duration-dhms>1d 1:01:06</duration-dhms>
|
||||||
<link-row>
|
<link-row>
|
||||||
<item>linked_row_1</item>
|
<item>linked_row_1</item>
|
||||||
<item>linked_row_2</item>
|
<item>linked_row_2</item>
|
||||||
|
|
|
@ -38,7 +38,9 @@ export default {
|
||||||
setCopyAndDelayedUpdate(value, immediately = false) {
|
setCopyAndDelayedUpdate(value, immediately = false) {
|
||||||
const newValue = this.updateCopy(this.field, value)
|
const newValue = this.updateCopy(this.field, value)
|
||||||
if (newValue !== undefined) {
|
if (newValue !== undefined) {
|
||||||
this.delayedUpdate(this.copy, immediately)
|
// a filter Value cannot be null, so send an empty string in case.
|
||||||
|
const filterValue = newValue === null ? '' : newValue
|
||||||
|
this.delayedUpdate(filterValue, immediately)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
getValidationError(value) {
|
getValidationError(value) {
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import BigNumber from 'bignumber.js'
|
import BigNumber from 'bignumber.js'
|
||||||
import {
|
import {
|
||||||
formatDuration,
|
formatDurationValue,
|
||||||
parseDurationValue,
|
parseDurationValue,
|
||||||
roundDurationValueToFormat,
|
roundDurationValueToFormat,
|
||||||
DURATION_FORMATS,
|
DURATION_FORMATS,
|
||||||
|
@ -2285,31 +2285,17 @@ export class DurationFieldType extends FieldType {
|
||||||
|
|
||||||
getSort(name, order) {
|
getSort(name, order) {
|
||||||
return (a, b) => {
|
return (a, b) => {
|
||||||
const aValue = parseDurationValue(a[name])
|
const aValue = a[name]
|
||||||
const bValue = parseDurationValue(b[name])
|
const bValue = b[name]
|
||||||
|
|
||||||
if (aValue === bValue) {
|
if (aValue === bValue) {
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
|
||||||
(aValue === null && order === 'ASC') ||
|
|
||||||
(bValue === null && order === 'DESC')
|
|
||||||
) {
|
|
||||||
return -1
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
(bValue === null && order === 'ASC') ||
|
|
||||||
(aValue === null && order === 'DESC')
|
|
||||||
) {
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
|
|
||||||
if (order === 'ASC') {
|
if (order === 'ASC') {
|
||||||
return aValue < bValue ? -1 : 1
|
return aValue === null || (bValue !== null && aValue < bValue) ? -1 : 1
|
||||||
} else {
|
} else {
|
||||||
return bValue < aValue ? -1 : 1
|
return bValue === null || (aValue !== null && bValue < aValue) ? -1 : 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -2361,7 +2347,7 @@ export class DurationFieldType extends FieldType {
|
||||||
}
|
}
|
||||||
|
|
||||||
static formatValue(field, value) {
|
static formatValue(field, value) {
|
||||||
return formatDuration(value, field.duration_format)
|
return formatDurationValue(value, field.duration_format)
|
||||||
}
|
}
|
||||||
|
|
||||||
static parseInputValue(field, value) {
|
static parseInputValue(field, value) {
|
||||||
|
|
|
@ -48,7 +48,8 @@ export default {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onKeyPress(field, event) {
|
onKeyPress(field, event) {
|
||||||
if (!/\d/.test(event.key) && event.key !== '.' && event.key !== ':') {
|
const allowedChars = ['.', ':', ' ', 'd', 'h']
|
||||||
|
if (!/\d/.test(event.key) && !allowedChars.includes(event.key)) {
|
||||||
return event.preventDefault()
|
return event.preventDefault()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
@ -1,28 +1,122 @@
|
||||||
const MOST_ACCURATE_DURATION_FORMAT = 'h:mm:ss.sss'
|
const MOST_ACCURATE_DURATION_FORMAT = 'h:mm:ss.sss'
|
||||||
const MAX_NUMBER_OF_TOKENS_IN_DURATION_FORMAT =
|
// taken from backend timedelta.max.total_seconds() == 1_000_000_000 days
|
||||||
MOST_ACCURATE_DURATION_FORMAT.split(':').length
|
export const MAX_BACKEND_DURATION_VALUE_NUMBER_OF_SECS = 86400000000000
|
||||||
export const MAX_BACKEND_DURATION_VALUE_NUMBER_OF_SECS = 86400000000000 // taken from backend timedelta.max.total_seconds()
|
export const DEFAULT_DURATION_FORMAT = 'h:mm'
|
||||||
|
|
||||||
|
const D_H = 'd h'
|
||||||
|
const D_H_M = 'd h:mm'
|
||||||
|
const D_H_M_S = 'd h:mm:ss'
|
||||||
|
const H_M = 'h:mm'
|
||||||
|
const H_M_S = 'h:mm:ss'
|
||||||
|
const H_M_S_S = 'h:mm:ss.s'
|
||||||
|
const H_M_S_SS = 'h:mm:ss.ss'
|
||||||
|
const H_M_S_SSS = 'h:mm:ss.sss'
|
||||||
|
|
||||||
|
const SECS_IN_DAY = 86400
|
||||||
|
const SECS_IN_HOUR = 3600
|
||||||
|
const SECS_IN_MIN = 60
|
||||||
|
|
||||||
|
function totalSecs({ secs = 0, mins = 0, hours = 0, days = 0 }) {
|
||||||
|
return (
|
||||||
|
parseInt(days) * SECS_IN_DAY +
|
||||||
|
parseInt(hours) * SECS_IN_HOUR +
|
||||||
|
parseInt(mins) * SECS_IN_MIN +
|
||||||
|
parseFloat(secs)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const DURATION_REGEXPS = new Map([
|
||||||
|
[
|
||||||
|
/^(\d+)(?:d\s*|\s+)(\d+):(\d+):(\d+|\d+\.\d+)$/,
|
||||||
|
{
|
||||||
|
default: (days, hours, mins, secs) =>
|
||||||
|
totalSecs({ days, hours, mins, secs }),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
/^(\d+):(\d+):(\d+|\d+\.\d+)$/,
|
||||||
|
{
|
||||||
|
default: (hours, mins, secs) => totalSecs({ hours, mins, secs }),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
/^(\d+)(?:d\s*|\s+)(\d+):(\d+)$/,
|
||||||
|
{
|
||||||
|
[D_H]: (days, hours, mins) => totalSecs({ days, hours, mins }),
|
||||||
|
[D_H_M]: (days, hours, mins) => totalSecs({ days, hours, mins }),
|
||||||
|
default: (days, mins, secs) => totalSecs({ days, mins, secs }),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
/^(\d+)(?:d\s*|\s+)(\d+):(\d+\.\d+)$/,
|
||||||
|
{ default: (days, mins, secs) => totalSecs({ days, mins, secs }) },
|
||||||
|
],
|
||||||
|
[/^(\d+)h$/, { default: (hours) => totalSecs({ hours }) }],
|
||||||
|
[
|
||||||
|
/^(\d+)(?:d\s*|\s+)(\d+)h$/,
|
||||||
|
{
|
||||||
|
default: (days, hours) => totalSecs({ days, hours }),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[/^(\d+)d$/, { default: (days) => totalSecs({ days }) }],
|
||||||
|
[
|
||||||
|
/^(\d+)(?:d\s*|\s+)(\d+)$/,
|
||||||
|
{
|
||||||
|
[D_H]: (days, hours) => totalSecs({ days, hours }),
|
||||||
|
[D_H_M]: (days, mins) => totalSecs({ days, mins }),
|
||||||
|
[H_M]: (days, mins) => totalSecs({ days, mins }),
|
||||||
|
default: (days, secs) => totalSecs({ days, secs }),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
/^(\d+)(?:d\s*|\s+)(\d+\.\d+)$/,
|
||||||
|
{ default: (days, secs) => totalSecs({ days, secs }) },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
/^(\d+):(\d+)$/,
|
||||||
|
{
|
||||||
|
[D_H]: (hours, mins) => totalSecs({ hours, mins }),
|
||||||
|
[D_H_M]: (hours, mins) => totalSecs({ hours, mins }),
|
||||||
|
[H_M]: (hours, mins) => totalSecs({ hours, mins }),
|
||||||
|
default: (mins, secs) => totalSecs({ mins, secs }),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
/^(\d+):(\d+\.\d+)$/,
|
||||||
|
{ default: (mins, secs) => totalSecs({ mins, secs }) },
|
||||||
|
],
|
||||||
|
[/^(\d+\.\d+)$/, { default: (secs) => totalSecs({ secs }) }],
|
||||||
|
[
|
||||||
|
/^(\d+)$/,
|
||||||
|
{
|
||||||
|
[D_H]: (hours) => totalSecs({ hours }),
|
||||||
|
[D_H_M]: (mins) => totalSecs({ mins }),
|
||||||
|
[H_M]: (mins) => totalSecs({ mins }),
|
||||||
|
default: (secs) => totalSecs({ secs }),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
])
|
||||||
|
|
||||||
// Map guarantees the order of the entries
|
// Map guarantees the order of the entries
|
||||||
export const DURATION_FORMATS = new Map([
|
export const DURATION_FORMATS = new Map([
|
||||||
[
|
[
|
||||||
'h:mm',
|
H_M,
|
||||||
{
|
{
|
||||||
description: 'h:mm (1:23)',
|
description: 'h:mm (1:23)',
|
||||||
example: '1:23',
|
example: '1:23',
|
||||||
toString(hours, minutes) {
|
toString(d, h, m, s) {
|
||||||
return `${hours}:${minutes.toString().padStart(2, '0')}`
|
return `${d * 24 + h}:${m.toString().padStart(2, '0')}`
|
||||||
},
|
},
|
||||||
round: (value) => Math.round(value / 60) * 60,
|
round: (value) => Math.round(value / 60) * 60,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'h:mm:ss',
|
H_M_S,
|
||||||
{
|
{
|
||||||
description: 'h:mm:ss (1:23:40)',
|
description: 'h:mm:ss (1:23:40)',
|
||||||
example: '1:23:40',
|
example: '1:23:40',
|
||||||
toString(hours, minutes, seconds) {
|
toString(d, h, m, s) {
|
||||||
return `${hours}:${minutes.toString().padStart(2, '0')}:${seconds
|
return `${d * 24 + h}:${m.toString().padStart(2, '0')}:${s
|
||||||
.toString()
|
.toString()
|
||||||
.padStart(2, '0')}`
|
.padStart(2, '0')}`
|
||||||
},
|
},
|
||||||
|
@ -30,12 +124,12 @@ export const DURATION_FORMATS = new Map([
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'h:mm:ss.s',
|
H_M_S_S,
|
||||||
{
|
{
|
||||||
description: 'h:mm:ss.s (1:23:40.0)',
|
description: 'h:mm:ss.s (1:23:40.0)',
|
||||||
example: '1:23:40.0',
|
example: '1:23:40.0',
|
||||||
toString(hours, minutes, seconds) {
|
toString(d, h, m, s) {
|
||||||
return `${hours}:${minutes.toString().padStart(2, '0')}:${seconds
|
return `${d * 24 + h}:${m.toString().padStart(2, '0')}:${s
|
||||||
.toFixed(1)
|
.toFixed(1)
|
||||||
.padStart(4, '0')}`
|
.padStart(4, '0')}`
|
||||||
},
|
},
|
||||||
|
@ -43,12 +137,12 @@ export const DURATION_FORMATS = new Map([
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'h:mm:ss.ss',
|
H_M_S_SS,
|
||||||
{
|
{
|
||||||
description: 'h:mm:ss.ss (1:23:40.00)',
|
description: 'h:mm:ss.ss (1:23:40.00)',
|
||||||
example: '1:23:40.00',
|
example: '1:23:40.00',
|
||||||
toString(hours, minutes, seconds) {
|
toString(d, h, m, s) {
|
||||||
return `${hours}:${minutes.toString().padStart(2, '0')}:${seconds
|
return `${d * 24 + h}:${m.toString().padStart(2, '0')}:${s
|
||||||
.toFixed(2)
|
.toFixed(2)
|
||||||
.padStart(5, '0')}`
|
.padStart(5, '0')}`
|
||||||
},
|
},
|
||||||
|
@ -56,54 +150,65 @@ export const DURATION_FORMATS = new Map([
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'h:mm:ss.sss',
|
H_M_S_SSS,
|
||||||
{
|
{
|
||||||
description: 'h:mm:ss.sss (1:23:40.000)',
|
description: 'h:mm:ss.sss (1:23:40.000)',
|
||||||
example: '1:23:40.000',
|
example: '1:23:40.000',
|
||||||
toString(hours, minutes, seconds) {
|
toString(d, h, m, s) {
|
||||||
return `${hours}:${minutes.toString().padStart(2, '0')}:${seconds
|
return `${d * 24 + h}:${m.toString().padStart(2, '0')}:${s
|
||||||
.toFixed(3)
|
.toFixed(3)
|
||||||
.padStart(6, '0')}`
|
.padStart(6, '0')}`
|
||||||
},
|
},
|
||||||
round: (value) => Math.round(value * 1000) / 1000,
|
round: (value) => Math.round(value * 1000) / 1000,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
[
|
||||||
|
D_H,
|
||||||
|
{
|
||||||
|
description: 'd h (1d 2h)',
|
||||||
|
example: '1d 2h',
|
||||||
|
toString(d, h, m, s) {
|
||||||
|
return `${d}d ${h}h`
|
||||||
|
},
|
||||||
|
round: (value) => Math.round(value / 3600) * 3600,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
D_H_M,
|
||||||
|
{
|
||||||
|
description: 'd h:mm (1d 2:34)',
|
||||||
|
example: '1d 2:34',
|
||||||
|
toString(d, h, m, s) {
|
||||||
|
return `${d}d ${h}:${m.toString().padStart(2, '0')}`
|
||||||
|
},
|
||||||
|
round: (value) => Math.round(value / 60) * 60,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
D_H_M_S,
|
||||||
|
{
|
||||||
|
description: 'd h:mm:ss (1d 2:34:56)',
|
||||||
|
example: '1d 2:34:56',
|
||||||
|
toString(d, h, m, s) {
|
||||||
|
return `${d}d ${h}:${m.toString().padStart(2, '0')}:${s
|
||||||
|
.toString()
|
||||||
|
.padStart(2, '0')}`
|
||||||
|
},
|
||||||
|
round: (value) => Math.round(value),
|
||||||
|
},
|
||||||
|
],
|
||||||
])
|
])
|
||||||
|
|
||||||
export const DURATION_TOKENS = {
|
|
||||||
h: {
|
|
||||||
multiplier: 3600,
|
|
||||||
parse: (value) => parseInt(value),
|
|
||||||
},
|
|
||||||
mm: {
|
|
||||||
multiplier: 60,
|
|
||||||
parse: (value) => parseInt(value),
|
|
||||||
},
|
|
||||||
ss: {
|
|
||||||
multiplier: 1,
|
|
||||||
parse: (value) => parseInt(value),
|
|
||||||
},
|
|
||||||
'ss.s': {
|
|
||||||
multiplier: 1,
|
|
||||||
parse: (value) => Math.round(parseFloat(value) * 10) / 10,
|
|
||||||
},
|
|
||||||
'ss.ss': {
|
|
||||||
multiplier: 1,
|
|
||||||
parse: (value) => Math.round(parseFloat(value) * 100) / 100,
|
|
||||||
},
|
|
||||||
'ss.sss': {
|
|
||||||
multiplier: 1,
|
|
||||||
parse: (value) => Math.round(parseFloat(value) * 1000) / 1000,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
export const roundDurationValueToFormat = (value, format) => {
|
export const roundDurationValueToFormat = (value, format) => {
|
||||||
if (value === null) {
|
if (value === null) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
const roundFunc = DURATION_FORMATS.get(format).round
|
const durationFormatOptions = DURATION_FORMATS.get(format)
|
||||||
return roundFunc(value)
|
if (!durationFormatOptions) {
|
||||||
|
throw new Error(`Unknown duration format ${format}`)
|
||||||
|
}
|
||||||
|
return durationFormatOptions.round(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -113,65 +218,40 @@ export const roundDurationValueToFormat = (value, format) => {
|
||||||
*/
|
*/
|
||||||
export const parseDurationValue = (
|
export const parseDurationValue = (
|
||||||
inputValue,
|
inputValue,
|
||||||
format = MOST_ACCURATE_DURATION_FORMAT,
|
format = MOST_ACCURATE_DURATION_FORMAT
|
||||||
strict = false
|
|
||||||
) => {
|
) => {
|
||||||
if (inputValue === null || inputValue === undefined || inputValue === '') {
|
if (inputValue === null || inputValue === undefined || inputValue === '') {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
// If the input value is a number, we assume it is in seconds.
|
// If the value is a number, we assume it's already in seconds (i.e. from the backend).
|
||||||
if (Number.isFinite(inputValue)) {
|
if (Number.isFinite(inputValue)) {
|
||||||
return inputValue > 0 ? inputValue : null
|
return inputValue > 0 ? inputValue : null
|
||||||
}
|
}
|
||||||
|
|
||||||
const parts = inputValue.split(':').reverse()
|
for (const [fmtRegExp, formatFuncs] of DURATION_REGEXPS) {
|
||||||
let tokens = format.split(':').reverse()
|
const match = inputValue.match(fmtRegExp)
|
||||||
if (parts.length > tokens.length) {
|
if (match) {
|
||||||
if (strict || parts.length > MAX_NUMBER_OF_TOKENS_IN_DURATION_FORMAT) {
|
const formatFunc = formatFuncs[format] || formatFuncs.default
|
||||||
throw new Error(
|
return formatFunc(...match.slice(1))
|
||||||
`Invalid duration format: ${inputValue} does not match ${format}`
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
tokens = MOST_ACCURATE_DURATION_FORMAT.split(':').reverse()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
|
||||||
return tokens.reduce((acc, token, index) => {
|
|
||||||
if (index >= parts.length) {
|
|
||||||
return acc
|
|
||||||
}
|
|
||||||
const part = parts[index]
|
|
||||||
const parseFunc = DURATION_TOKENS[token].parse
|
|
||||||
const number = parseFunc(part)
|
|
||||||
|
|
||||||
if (isNaN(number) || number < 0) {
|
|
||||||
throw new Error(
|
|
||||||
`Invalid duration format: ${inputValue} does not match ${format}`
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const multiplier = DURATION_TOKENS[token].multiplier
|
|
||||||
return acc + number * multiplier
|
|
||||||
}, 0)
|
|
||||||
} catch (e) {
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* It formats the given duration value using the given format.
|
* It formats the given duration value using the given format.
|
||||||
*/
|
*/
|
||||||
export const formatDuration = (value, format) => {
|
export const formatDurationValue = (value, format) => {
|
||||||
if (value === null || value === undefined || value === '') {
|
if (value === null || value === undefined || value === '') {
|
||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
|
|
||||||
const hours = Math.floor(value / 3600)
|
const days = Math.floor(value / 86400)
|
||||||
const mins = Math.floor((value - hours * 3600) / 60)
|
const hours = Math.floor((value % 86400) / 3600)
|
||||||
const secs = value - hours * 3600 - mins * 60
|
const mins = Math.floor((value % 3600) / 60)
|
||||||
|
const secs = value % 60
|
||||||
|
|
||||||
const formatFunc = DURATION_FORMATS.get(format).toString
|
const formatFunc = DURATION_FORMATS.get(format).toString
|
||||||
return formatFunc(hours, mins, secs)
|
return formatFunc(days, hours, mins, secs)
|
||||||
}
|
}
|
||||||
|
|
|
@ -12,7 +12,7 @@ const tableRows = [
|
||||||
{
|
{
|
||||||
id: 2,
|
id: 2,
|
||||||
order: '2.00000000000000000000',
|
order: '2.00000000000000000000',
|
||||||
field: '0:1:0',
|
field: 60,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 3,
|
id: 3,
|
||||||
|
@ -32,12 +32,12 @@ const tableRows = [
|
||||||
{
|
{
|
||||||
id: 6,
|
id: 6,
|
||||||
order: '5.00000000000000000000',
|
order: '5.00000000000000000000',
|
||||||
field: '2:0:0.123',
|
field: 7200.123,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 7,
|
id: 7,
|
||||||
order: '6.00000000000000000000',
|
order: '6.00000000000000000000',
|
||||||
field: '1.12'
|
field: 1.12
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
|
@ -71,11 +71,11 @@ describe('LastModifiedByFieldType.getSort()', () => {
|
||||||
)
|
)
|
||||||
const expected = [
|
const expected = [
|
||||||
null,
|
null,
|
||||||
'1.12',
|
1.12,
|
||||||
'0:1:0',
|
60,
|
||||||
120,
|
120,
|
||||||
3600,
|
3600,
|
||||||
'2:0:0.123',
|
7200.123,
|
||||||
86400,
|
86400,
|
||||||
]
|
]
|
||||||
|
|
||||||
|
@ -89,11 +89,11 @@ describe('LastModifiedByFieldType.getSort()', () => {
|
||||||
|
|
||||||
const expectedReversed = [
|
const expectedReversed = [
|
||||||
86400,
|
86400,
|
||||||
'2:0:0.123',
|
7200.123,
|
||||||
3600,
|
3600,
|
||||||
120,
|
120,
|
||||||
'0:1:0',
|
60,
|
||||||
'1.12',
|
1.12,
|
||||||
null,
|
null,
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
507
web-frontend/test/unit/database/utils/duration.spec.js
Normal file
507
web-frontend/test/unit/database/utils/duration.spec.js
Normal file
|
@ -0,0 +1,507 @@
|
||||||
|
import {
|
||||||
|
parseDurationValue,
|
||||||
|
formatDurationValue,
|
||||||
|
roundDurationValueToFormat,
|
||||||
|
} from '@baserow/modules/database/utils/duration'
|
||||||
|
|
||||||
|
const SECS_IN_MIN = 60
|
||||||
|
const SECS_IN_HOUR = 60 * 60
|
||||||
|
|
||||||
|
const VALID_DURATION_VALUES = {
|
||||||
|
'd h': [
|
||||||
|
['1d 2h', 26 * SECS_IN_HOUR],
|
||||||
|
['2 1h', 49 * SECS_IN_HOUR],
|
||||||
|
['2 2', 50 * SECS_IN_HOUR],
|
||||||
|
['1 8', 32 * SECS_IN_HOUR],
|
||||||
|
['1d0h', 24 * SECS_IN_HOUR],
|
||||||
|
['1d25', 49 * SECS_IN_HOUR],
|
||||||
|
['5h', 5 * SECS_IN_HOUR],
|
||||||
|
['2d', 48 * SECS_IN_HOUR],
|
||||||
|
['10', 10 * SECS_IN_HOUR],
|
||||||
|
['3600.0', 1 * SECS_IN_HOUR],
|
||||||
|
['60:0.0', 1 * SECS_IN_HOUR],
|
||||||
|
['0', 0],
|
||||||
|
],
|
||||||
|
'd h:mm': [
|
||||||
|
['1d 2:30', 26 * SECS_IN_HOUR + 30 * 60],
|
||||||
|
['2 1:30', 49 * SECS_IN_HOUR + 30 * 60],
|
||||||
|
['2 1:61', 50 * SECS_IN_HOUR + 1 * 60],
|
||||||
|
['1 8:30', 32 * SECS_IN_HOUR + 30 * 60],
|
||||||
|
['1d0:61', 25 * SECS_IN_HOUR + 1 * 60],
|
||||||
|
['1d0:30', 24 * SECS_IN_HOUR + 30 * 60],
|
||||||
|
['1d1:30', 25 * SECS_IN_HOUR + 30 * 60],
|
||||||
|
['5:30', 5 * SECS_IN_HOUR + 30 * 60],
|
||||||
|
['10:30', 10 * SECS_IN_HOUR + 30 * 60],
|
||||||
|
['0:30', 30 * 60],
|
||||||
|
['0:0', 0],
|
||||||
|
],
|
||||||
|
'd h:mm:ss': [
|
||||||
|
['1d 2:30:45', 26 * SECS_IN_HOUR + 30 * 60 + 45],
|
||||||
|
['2 2:30:45', 50 * SECS_IN_HOUR + 30 * 60 + 45],
|
||||||
|
['1 8:30:45', 32 * SECS_IN_HOUR + 30 * 60 + 45],
|
||||||
|
['1d0:30:45', 24 * SECS_IN_HOUR + 30 * 60 + 45],
|
||||||
|
['1d1:30:45', 25 * SECS_IN_HOUR + 30 * 60 + 45],
|
||||||
|
['1d2:30:61', 26 * SECS_IN_HOUR + 31 * 60 + 1],
|
||||||
|
['5:30:45', 5 * SECS_IN_HOUR + 30 * 60 + 45],
|
||||||
|
['10:30:45', 10 * SECS_IN_HOUR + 30 * 60 + 45],
|
||||||
|
['0:30:45', 30 * 60 + 45],
|
||||||
|
['0:0:45', 45],
|
||||||
|
['0:0:0', 0],
|
||||||
|
],
|
||||||
|
'h:mm': [
|
||||||
|
['2:30', 2 * SECS_IN_HOUR + 30 * 60],
|
||||||
|
['1:30', 1 * SECS_IN_HOUR + 30 * 60],
|
||||||
|
['2:30', 2 * SECS_IN_HOUR + 30 * 60],
|
||||||
|
['8:30', 8 * SECS_IN_HOUR + 30 * 60],
|
||||||
|
['0:30', 30 * 60],
|
||||||
|
['0:61', 61 * 60],
|
||||||
|
['0:0', 0],
|
||||||
|
['0', 0],
|
||||||
|
],
|
||||||
|
'h:mm:ss': [
|
||||||
|
['2:30:45', 2 * SECS_IN_HOUR + 30 * 60 + 45],
|
||||||
|
['1:30:45', 1 * SECS_IN_HOUR + 30 * 60 + 45],
|
||||||
|
['2:30:45', 2 * SECS_IN_HOUR + 30 * 60 + 45],
|
||||||
|
['8:30:45', 8 * SECS_IN_HOUR + 30 * 60 + 45],
|
||||||
|
['0:30:45', 30 * 60 + 45],
|
||||||
|
['0:0:45', 45],
|
||||||
|
['0:0:61', 61],
|
||||||
|
['0:0:0', 0],
|
||||||
|
],
|
||||||
|
'h:mm:ss.s': [
|
||||||
|
['2:30:45.1', 2 * SECS_IN_HOUR + 30 * 60 + 45.1],
|
||||||
|
['1:30:45.2', 1 * SECS_IN_HOUR + 30 * 60 + 45.2],
|
||||||
|
['2:30:45.3', 2 * SECS_IN_HOUR + 30 * 60 + 45.3],
|
||||||
|
['8:30:45.9', 8 * SECS_IN_HOUR + 30 * 60 + 45.9],
|
||||||
|
['0:30:45.5', 30 * 60 + 45.5],
|
||||||
|
['0:0:45.0', 45],
|
||||||
|
['0:0:59.9', 59.9],
|
||||||
|
['0:0:0.0', 0],
|
||||||
|
],
|
||||||
|
'h:mm:ss.ss': [
|
||||||
|
['2:30:45.11', 2 * SECS_IN_HOUR + 30 * 60 + 45.11],
|
||||||
|
['1:30:45.22', 1 * SECS_IN_HOUR + 30 * 60 + 45.22],
|
||||||
|
['2:30:45.33', 2 * SECS_IN_HOUR + 30 * 60 + 45.33],
|
||||||
|
['8:30:45.46', 8 * SECS_IN_HOUR + 30 * 60 + 45.46],
|
||||||
|
['0:30:45.50', 30 * 60 + 45.5],
|
||||||
|
['0:0:45.00', 45],
|
||||||
|
['0:0:59.99', 59.99],
|
||||||
|
['0', 0],
|
||||||
|
],
|
||||||
|
'h:mm:ss.sss': [
|
||||||
|
['2:30:45.111', 2 * SECS_IN_HOUR + 30 * 60 + 45.111],
|
||||||
|
['1:30:45.222', 1 * SECS_IN_HOUR + 30 * 60 + 45.222],
|
||||||
|
['2:30:45.333', 2 * SECS_IN_HOUR + 30 * 60 + 45.333],
|
||||||
|
['8:30:45.456', 8 * SECS_IN_HOUR + 30 * 60 + 45.456],
|
||||||
|
['0:30:45.500', 30 * 60 + 45.5],
|
||||||
|
['0:0:45.000', 45],
|
||||||
|
['0:0:59.999', 59.999],
|
||||||
|
['0', 0],
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
const INVALID_DURATION_VALUES = [
|
||||||
|
'd',
|
||||||
|
'h',
|
||||||
|
'1d 2h 3m',
|
||||||
|
'1d 2h 3s',
|
||||||
|
'1day',
|
||||||
|
'2 days',
|
||||||
|
'1 hour',
|
||||||
|
'8 hours',
|
||||||
|
'1:2:3:4',
|
||||||
|
'aaaaa',
|
||||||
|
'1dd',
|
||||||
|
'2hh',
|
||||||
|
'1h2m',
|
||||||
|
]
|
||||||
|
|
||||||
|
const DURATION_FORMATTED_VALUES = {
|
||||||
|
'd h': [
|
||||||
|
[0, '0d 0h'],
|
||||||
|
[1 * SECS_IN_HOUR, '0d 1h'],
|
||||||
|
[24 * SECS_IN_HOUR, '1d 0h'],
|
||||||
|
[25 * SECS_IN_HOUR, '1d 1h'],
|
||||||
|
[49 * SECS_IN_HOUR, '2d 1h'],
|
||||||
|
[50 * SECS_IN_HOUR, '2d 2h'],
|
||||||
|
[32 * SECS_IN_HOUR, '1d 8h'],
|
||||||
|
[5 * SECS_IN_HOUR, '0d 5h'],
|
||||||
|
],
|
||||||
|
'd h:mm': [
|
||||||
|
[0, '0d 0:00'],
|
||||||
|
[1 * SECS_IN_HOUR, '0d 1:00'],
|
||||||
|
[24 * SECS_IN_HOUR, '1d 0:00'],
|
||||||
|
[2 * SECS_IN_HOUR, '0d 2:00'],
|
||||||
|
[49 * SECS_IN_HOUR, '2d 1:00'],
|
||||||
|
[50 * SECS_IN_HOUR, '2d 2:00'],
|
||||||
|
[32 * SECS_IN_HOUR, '1d 8:00'],
|
||||||
|
[5 * SECS_IN_HOUR, '0d 5:00'],
|
||||||
|
[5 * SECS_IN_HOUR + 30 * SECS_IN_MIN, '0d 5:30'],
|
||||||
|
],
|
||||||
|
'd h:mm:ss': [
|
||||||
|
[0, '0d 0:00:00'],
|
||||||
|
[1 * SECS_IN_HOUR, '0d 1:00:00'],
|
||||||
|
[24 * SECS_IN_HOUR, '1d 0:00:00'],
|
||||||
|
[25 * SECS_IN_HOUR, '1d 1:00:00'],
|
||||||
|
[49 * SECS_IN_HOUR, '2d 1:00:00'],
|
||||||
|
[50 * SECS_IN_HOUR, '2d 2:00:00'],
|
||||||
|
[32 * SECS_IN_HOUR, '1d 8:00:00'],
|
||||||
|
[5 * SECS_IN_HOUR, '0d 5:00:00'],
|
||||||
|
[5 * SECS_IN_HOUR + 30 * SECS_IN_MIN, '0d 5:30:00'],
|
||||||
|
[5 * SECS_IN_HOUR + 5 * SECS_IN_MIN + 5, '0d 5:05:05'],
|
||||||
|
],
|
||||||
|
'h:mm': [
|
||||||
|
[0, '0:00'],
|
||||||
|
[1 * SECS_IN_MIN, '0:01'],
|
||||||
|
[61 * SECS_IN_MIN, '1:01'],
|
||||||
|
[24 * SECS_IN_HOUR, '24:00'],
|
||||||
|
[25 * SECS_IN_HOUR, '25:00'],
|
||||||
|
[49 * SECS_IN_HOUR, '49:00'],
|
||||||
|
[50 * SECS_IN_HOUR, '50:00'],
|
||||||
|
[32 * SECS_IN_HOUR, '32:00'],
|
||||||
|
[5 * SECS_IN_HOUR, '5:00'],
|
||||||
|
[5 * SECS_IN_HOUR + 30 * SECS_IN_MIN, '5:30'],
|
||||||
|
],
|
||||||
|
'h:mm:ss': [
|
||||||
|
[0, '0:00:00'],
|
||||||
|
[1, '0:00:01'],
|
||||||
|
[61, '0:01:01'],
|
||||||
|
[1 * SECS_IN_MIN, '0:01:00'],
|
||||||
|
[61 * SECS_IN_MIN, '1:01:00'],
|
||||||
|
[24 * SECS_IN_HOUR, '24:00:00'],
|
||||||
|
[25 * SECS_IN_HOUR, '25:00:00'],
|
||||||
|
[49 * SECS_IN_HOUR, '49:00:00'],
|
||||||
|
[50 * SECS_IN_HOUR, '50:00:00'],
|
||||||
|
[32 * SECS_IN_HOUR, '32:00:00'],
|
||||||
|
[5 * SECS_IN_HOUR, '5:00:00'],
|
||||||
|
[5 * SECS_IN_HOUR + 30 * SECS_IN_MIN, '5:30:00'],
|
||||||
|
[5 * SECS_IN_HOUR + 5 * SECS_IN_MIN + 5, '5:05:05'],
|
||||||
|
],
|
||||||
|
'h:mm:ss.s': [
|
||||||
|
[0, '0:00:00.0'],
|
||||||
|
[1.1, '0:00:01.1'],
|
||||||
|
[61.9, '0:01:01.9'],
|
||||||
|
[1 * SECS_IN_MIN + 1.1, '0:01:01.1'],
|
||||||
|
[61 * SECS_IN_MIN + 2.2, '1:01:02.2'],
|
||||||
|
[24 * SECS_IN_HOUR + 3.3, '24:00:03.3'],
|
||||||
|
[25 * SECS_IN_HOUR + 9.9, '25:00:09.9'],
|
||||||
|
[49 * SECS_IN_HOUR, '49:00:00.0'],
|
||||||
|
[50 * SECS_IN_HOUR, '50:00:00.0'],
|
||||||
|
[32 * SECS_IN_HOUR, '32:00:00.0'],
|
||||||
|
[5 * SECS_IN_HOUR, '5:00:00.0'],
|
||||||
|
[5 * SECS_IN_HOUR + 30 * SECS_IN_MIN, '5:30:00.0'],
|
||||||
|
[5 * SECS_IN_HOUR + 5 * SECS_IN_MIN + 5, '5:05:05.0'],
|
||||||
|
[5 * SECS_IN_HOUR + 5 * SECS_IN_MIN + 5.1, '5:05:05.1'],
|
||||||
|
],
|
||||||
|
'h:mm:ss.ss': [
|
||||||
|
[0, '0:00:00.00'],
|
||||||
|
[1.11, '0:00:01.11'],
|
||||||
|
[61.22, '0:01:01.22'],
|
||||||
|
[1 * SECS_IN_MIN + 1.11, '0:01:01.11'],
|
||||||
|
[61 * SECS_IN_MIN + 2.22, '1:01:02.22'],
|
||||||
|
[24 * SECS_IN_HOUR + 3.33, '24:00:03.33'],
|
||||||
|
[25 * SECS_IN_HOUR + 9.99, '25:00:09.99'],
|
||||||
|
[49 * SECS_IN_HOUR, '49:00:00.00'],
|
||||||
|
[50 * SECS_IN_HOUR, '50:00:00.00'],
|
||||||
|
[32 * SECS_IN_HOUR, '32:00:00.00'],
|
||||||
|
[5 * SECS_IN_HOUR, '5:00:00.00'],
|
||||||
|
[5 * SECS_IN_HOUR + 30 * SECS_IN_MIN, '5:30:00.00'],
|
||||||
|
[5 * SECS_IN_HOUR + 5 * SECS_IN_MIN + 5, '5:05:05.00'],
|
||||||
|
[5 * SECS_IN_HOUR + 5 * SECS_IN_MIN + 5.1, '5:05:05.10'],
|
||||||
|
],
|
||||||
|
'h:mm:ss.sss': [
|
||||||
|
[0, '0:00:00.000'],
|
||||||
|
[1.111, '0:00:01.111'],
|
||||||
|
[61.222, '0:01:01.222'],
|
||||||
|
[1 * SECS_IN_MIN + 1.111, '0:01:01.111'],
|
||||||
|
[61 * SECS_IN_MIN + 2.222, '1:01:02.222'],
|
||||||
|
[24 * SECS_IN_HOUR + 3.333, '24:00:03.333'],
|
||||||
|
[25 * SECS_IN_HOUR + 9.999, '25:00:09.999'],
|
||||||
|
[49 * SECS_IN_HOUR, '49:00:00.000'],
|
||||||
|
[50 * SECS_IN_HOUR, '50:00:00.000'],
|
||||||
|
[32 * SECS_IN_HOUR, '32:00:00.000'],
|
||||||
|
[5 * SECS_IN_HOUR, '5:00:00.000'],
|
||||||
|
[5 * SECS_IN_HOUR + 30 * SECS_IN_MIN, '5:30:00.000'],
|
||||||
|
[5 * SECS_IN_HOUR + 5 * SECS_IN_MIN + 5, '5:05:05.000'],
|
||||||
|
[5 * SECS_IN_HOUR + 5 * SECS_IN_MIN + 5.1, '5:05:05.100'],
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
const DURATION_ROUNDING_VALUES = {
|
||||||
|
'd h': [
|
||||||
|
[null, null],
|
||||||
|
[0, 0],
|
||||||
|
[1.9, 0],
|
||||||
|
[30, 0],
|
||||||
|
[60, 0],
|
||||||
|
[29 * SECS_IN_MIN, 0],
|
||||||
|
[30 * SECS_IN_MIN, 1 * SECS_IN_HOUR],
|
||||||
|
[59 * SECS_IN_MIN, 1 * SECS_IN_HOUR],
|
||||||
|
[1 * SECS_IN_HOUR + 29 * SECS_IN_MIN, 1 * SECS_IN_HOUR],
|
||||||
|
[1 * SECS_IN_HOUR + 30 * SECS_IN_MIN, 2 * SECS_IN_HOUR],
|
||||||
|
],
|
||||||
|
'd h:mm': [
|
||||||
|
[null, null],
|
||||||
|
[0, 0],
|
||||||
|
[1, 0],
|
||||||
|
[29, 0],
|
||||||
|
[30, 1 * SECS_IN_MIN],
|
||||||
|
[59, 1 * SECS_IN_MIN],
|
||||||
|
[29 * SECS_IN_MIN + 29.9, 29 * SECS_IN_MIN],
|
||||||
|
[29 * SECS_IN_MIN + 30, 30 * SECS_IN_MIN],
|
||||||
|
],
|
||||||
|
'd h:mm:ss': [
|
||||||
|
[null, null],
|
||||||
|
[0, 0],
|
||||||
|
[0.499, 0],
|
||||||
|
[0.5, 1],
|
||||||
|
[0.9, 1],
|
||||||
|
[1, 1],
|
||||||
|
[1.5, 2],
|
||||||
|
],
|
||||||
|
'h:mm': [
|
||||||
|
[null, null],
|
||||||
|
[0, 0],
|
||||||
|
[1, 0],
|
||||||
|
[29, 0],
|
||||||
|
[30, 1 * SECS_IN_MIN],
|
||||||
|
[59, 1 * SECS_IN_MIN],
|
||||||
|
[29 * SECS_IN_MIN + 29.9, 29 * SECS_IN_MIN],
|
||||||
|
[29 * SECS_IN_MIN + 30, 30 * SECS_IN_MIN],
|
||||||
|
],
|
||||||
|
'h:mm:ss': [
|
||||||
|
[null, null],
|
||||||
|
[0, 0],
|
||||||
|
[0.499, 0],
|
||||||
|
[0.5, 1],
|
||||||
|
[0.9, 1],
|
||||||
|
[1, 1],
|
||||||
|
[1.5, 2],
|
||||||
|
],
|
||||||
|
'h:mm:ss.s': [
|
||||||
|
[null, null],
|
||||||
|
[0, 0],
|
||||||
|
[0.449, 0.4],
|
||||||
|
[0.45, 0.5],
|
||||||
|
[0.99, 1],
|
||||||
|
[1.12, 1.1],
|
||||||
|
[1.9, 1.9],
|
||||||
|
[1.99, 2],
|
||||||
|
],
|
||||||
|
'h:mm:ss.ss': [
|
||||||
|
[null, null],
|
||||||
|
[0, 0],
|
||||||
|
[0.49, 0.49],
|
||||||
|
[0.494, 0.49],
|
||||||
|
[0.499, 0.5],
|
||||||
|
[0.999, 1],
|
||||||
|
[1.123, 1.12],
|
||||||
|
[1.99, 1.99],
|
||||||
|
[1.999999, 2],
|
||||||
|
],
|
||||||
|
'h:mm:ss.sss': [
|
||||||
|
[null, null],
|
||||||
|
[0, 0],
|
||||||
|
[0.4994, 0.499],
|
||||||
|
[0.4995, 0.5],
|
||||||
|
[0.9991, 0.999],
|
||||||
|
[0.9995, 1],
|
||||||
|
[1.123, 1.123],
|
||||||
|
[1.999, 1.999],
|
||||||
|
[1.999999, 2],
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Duration field type utilities', () => {
|
||||||
|
describe('parse format: d h', () => {
|
||||||
|
for (const [input, expected] of VALID_DURATION_VALUES['d h']) {
|
||||||
|
test(`"${input}" should be parsed to ${expected}`, () => {
|
||||||
|
expect(parseDurationValue(input, 'd h')).toBe(expected)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
describe('parse format: d h:mm', () => {
|
||||||
|
for (const [input, expected] of VALID_DURATION_VALUES['d h:mm']) {
|
||||||
|
test(`"${input}" should be parsed to ${expected}`, () => {
|
||||||
|
expect(parseDurationValue(input, 'd h:mm')).toBe(expected)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
describe('parse format: d h:mm:ss', () => {
|
||||||
|
for (const [input, expected] of VALID_DURATION_VALUES['d h:mm:ss']) {
|
||||||
|
test(`"${input}" should be parsed to ${expected}`, () => {
|
||||||
|
expect(parseDurationValue(input, 'd h:mm:ss')).toBe(expected)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
describe('parse format: h:mm', () => {
|
||||||
|
for (const [input, expected] of VALID_DURATION_VALUES['h:mm']) {
|
||||||
|
test(`"${input}" should be parsed to ${expected}`, () => {
|
||||||
|
expect(parseDurationValue(input, 'h:mm')).toBe(expected)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
describe('parse format: h:mm:ss', () => {
|
||||||
|
for (const [input, expected] of VALID_DURATION_VALUES['h:mm:ss']) {
|
||||||
|
test(`"${input}" should be parsed to ${expected}`, () => {
|
||||||
|
expect(parseDurationValue(input, 'h:mm:ss')).toBe(expected)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
describe('parse format: h:mm:ss.s', () => {
|
||||||
|
for (const [input, expected] of VALID_DURATION_VALUES['h:mm:ss.s']) {
|
||||||
|
test(`"${input}" should be parsed to ${expected}`, () => {
|
||||||
|
expect(parseDurationValue(input, 'h:mm:ss.s')).toBe(expected)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
describe('parse format: h:mm:ss.ss', () => {
|
||||||
|
for (const [input, expected] of VALID_DURATION_VALUES['h:mm:ss.ss']) {
|
||||||
|
test(`"${input}" should be parsed to ${expected}`, () => {
|
||||||
|
expect(parseDurationValue(input, 'h:mm:ss.ss')).toBe(expected)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
describe('parse format: h:mm:ss.sss', () => {
|
||||||
|
for (const [input, expected] of VALID_DURATION_VALUES['h:mm:ss.sss']) {
|
||||||
|
test(`"${input}" should be parsed to ${expected}`, () => {
|
||||||
|
expect(parseDurationValue(input, 'h:mm:ss.sss')).toBe(expected)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
describe('parse invalid values', () => {
|
||||||
|
for (const input of INVALID_DURATION_VALUES) {
|
||||||
|
test(`"${input}" should be parsed to null`, () => {
|
||||||
|
expect(parseDurationValue(input, 'h:mm:ss.sss')).toBe(null)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('format format: d h', () => {
|
||||||
|
for (const [input, expected] of DURATION_FORMATTED_VALUES['d h']) {
|
||||||
|
test(`"${input}" should be formatted to ${expected}`, () => {
|
||||||
|
expect(formatDurationValue(input, 'd h')).toBe(expected)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('format format: d h:mm', () => {
|
||||||
|
for (const [input, expected] of DURATION_FORMATTED_VALUES['d h:mm']) {
|
||||||
|
test(`"${input}" should be formatted to ${expected}`, () => {
|
||||||
|
expect(formatDurationValue(input, 'd h:mm')).toBe(expected)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('format format: d h:mm:ss', () => {
|
||||||
|
for (const [input, expected] of DURATION_FORMATTED_VALUES['d h:mm:ss']) {
|
||||||
|
test(`"${input}" should be formatted to ${expected}`, () => {
|
||||||
|
expect(formatDurationValue(input, 'd h:mm:ss')).toBe(expected)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('format format: h:mm', () => {
|
||||||
|
for (const [input, expected] of DURATION_FORMATTED_VALUES['h:mm']) {
|
||||||
|
test(`"${input}" should be formatted to ${expected}`, () => {
|
||||||
|
expect(formatDurationValue(input, 'h:mm')).toBe(expected)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('format format: h:mm:ss', () => {
|
||||||
|
for (const [input, expected] of DURATION_FORMATTED_VALUES['h:mm:ss']) {
|
||||||
|
test(`"${input}" should be formatted to ${expected}`, () => {
|
||||||
|
expect(formatDurationValue(input, 'h:mm:ss')).toBe(expected)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('format format: h:mm:ss.s', () => {
|
||||||
|
for (const [input, expected] of DURATION_FORMATTED_VALUES['h:mm:ss.s']) {
|
||||||
|
test(`"${input}" should be formatted to ${expected}`, () => {
|
||||||
|
expect(formatDurationValue(input, 'h:mm:ss.s')).toBe(expected)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('format format: h:mm:ss.ss', () => {
|
||||||
|
for (const [input, expected] of DURATION_FORMATTED_VALUES['h:mm:ss.ss']) {
|
||||||
|
test(`"${input}" should be formatted to ${expected}`, () => {
|
||||||
|
expect(formatDurationValue(input, 'h:mm:ss.ss')).toBe(expected)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('format format: h:mm:ss.sss', () => {
|
||||||
|
for (const [input, expected] of DURATION_FORMATTED_VALUES['h:mm:ss.sss']) {
|
||||||
|
test(`"${input}" should be formatted to ${expected}`, () => {
|
||||||
|
expect(formatDurationValue(input, 'h:mm:ss.sss')).toBe(expected)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('round value: d h', () => {
|
||||||
|
for (const [input, expected] of DURATION_ROUNDING_VALUES['d h']) {
|
||||||
|
test(`"${input}" should be rounded to ${expected}`, () => {
|
||||||
|
expect(roundDurationValueToFormat(input, 'd h')).toBe(expected)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('round value: d h:mm', () => {
|
||||||
|
for (const [input, expected] of DURATION_ROUNDING_VALUES['d h:mm']) {
|
||||||
|
test(`"${input}" should be rounded to ${expected}`, () => {
|
||||||
|
expect(roundDurationValueToFormat(input, 'd h:mm')).toBe(expected)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('round value: d h:mm:ss', () => {
|
||||||
|
for (const [input, expected] of DURATION_ROUNDING_VALUES['d h:mm:ss']) {
|
||||||
|
test(`"${input}" should be rounded to ${expected}`, () => {
|
||||||
|
expect(roundDurationValueToFormat(input, 'd h:mm:ss')).toBe(expected)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('round value: h:mm', () => {
|
||||||
|
for (const [input, expected] of DURATION_ROUNDING_VALUES['h:mm']) {
|
||||||
|
test(`"${input}" should be rounded to ${expected}`, () => {
|
||||||
|
expect(roundDurationValueToFormat(input, 'h:mm')).toBe(expected)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('round value: h:mm:ss', () => {
|
||||||
|
for (const [input, expected] of DURATION_ROUNDING_VALUES['h:mm:ss']) {
|
||||||
|
test(`"${input}" should be rounded to ${expected}`, () => {
|
||||||
|
expect(roundDurationValueToFormat(input, 'h:mm:ss')).toBe(expected)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('round value: h:mm:ss.s', () => {
|
||||||
|
for (const [input, expected] of DURATION_ROUNDING_VALUES['h:mm:ss.s']) {
|
||||||
|
test(`"${input}" should be rounded to ${expected}`, () => {
|
||||||
|
expect(roundDurationValueToFormat(input, 'h:mm:ss.s')).toBe(expected)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('round value: h:mm:ss.ss', () => {
|
||||||
|
for (const [input, expected] of DURATION_ROUNDING_VALUES['h:mm:ss.ss']) {
|
||||||
|
test(`"${input}" should be rounded to ${expected}`, () => {
|
||||||
|
expect(roundDurationValueToFormat(input, 'h:mm:ss.ss')).toBe(expected)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('round value: h:mm:ss.sss', () => {
|
||||||
|
for (const [input, expected] of DURATION_ROUNDING_VALUES['h:mm:ss.sss']) {
|
||||||
|
test(`"${input}" should be rounded to ${expected}`, () => {
|
||||||
|
expect(roundDurationValueToFormat(input, 'h:mm:ss.sss')).toBe(expected)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
|
@ -5,7 +5,7 @@ import {
|
||||||
} from '@baserow/modules/database/utils/row'
|
} from '@baserow/modules/database/utils/row'
|
||||||
import { TestApp } from '@baserow/test/helpers/testApp'
|
import { TestApp } from '@baserow/test/helpers/testApp'
|
||||||
|
|
||||||
describe('Row untilities', () => {
|
describe('Row utilities', () => {
|
||||||
let testApp = null
|
let testApp = null
|
||||||
let store = null
|
let store = null
|
||||||
|
|
||||||
|
|
Loading…
Add table
Reference in a new issue