1
0
Fork 0
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 

See merge request 
This commit is contained in:
Davide Silvestri 2024-01-18 08:36:56 +00:00
commit 21b66a7aaa
21 changed files with 1355 additions and 369 deletions
backend
changelog/entries/unreleased/feature
premium/backend/tests/baserow_premium_tests/export
web-frontend

View file

@ -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},

View file

@ -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,

View file

@ -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)

View file

@ -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

View file

@ -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,
),
),
]

View file

@ -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,

View file

@ -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."

View file

@ -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": [
{ {

View 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},
],
} }

View file

@ -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",'

View file

@ -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
], ],

View file

@ -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"},
} }

View file

@ -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"
}

View file

@ -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>

View file

@ -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) {

View file

@ -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) {

View file

@ -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()
} }
}, },

View file

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

View file

@ -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,
] ]

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

View file

@ -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