1
0
Fork 0
mirror of https://gitlab.com/bramw/baserow.git synced 2025-03-31 11:25:00 +00:00

2️⃣ Support for duration in formula: add backend/frontend support

This commit is contained in:
Davide Silvestri 2024-01-18 12:31:59 +00:00
parent 21b66a7aaa
commit 83cbe6ced2
30 changed files with 509 additions and 51 deletions

View file

@ -301,4 +301,9 @@ class DurationFieldSerializer(serializers.Field):
)
def to_representation(self, value):
return value.total_seconds()
if isinstance(value, (int, float)):
# Durations are stored as the number of seconds for lookups/arrays in
# formula land, so just return the value in that case.
return value
else:
return value.total_seconds()

View file

@ -1,6 +1,7 @@
from typing import Any, Dict, List
from baserow.contrib.database.fields.registries import field_type_registry
from baserow.contrib.database.fields.utils.duration import D_H_M
def construct_all_possible_field_kwargs(
@ -194,7 +195,11 @@ def construct_all_possible_field_kwargs(
{"name": "formula_int", "formula": "1"},
{"name": "formula_bool", "formula": "true"},
{"name": "formula_decimal", "formula": "100/3"},
{"name": "formula_dateinterval", "formula": "date_interval('1 day')"},
{
"name": "formula_dateinterval",
"formula": "date_interval('1 day')",
"duration_format": D_H_M,
},
{"name": "formula_date", "formula": "todate('20200101', 'YYYYMMDD')"},
{"name": "formula_singleselect", "formula": "field('single_select')"},
{"name": "formula_email", "formula": "field('email')"},

View file

@ -104,6 +104,7 @@ from baserow.core.utils import list_to_comma_separated_string
from ..formula.types.formula_types import (
BaserowFormulaArrayType,
BaserowFormulaDurationType,
BaserowFormulaMultipleSelectType,
BaserowFormulaSingleFileType,
)
@ -1796,6 +1797,14 @@ class DurationFieldType(FieldType):
return {**base, "duration_format": field.duration_format}
def to_baserow_formula_type(self, field) -> BaserowFormulaType:
return BaserowFormulaDurationType(
duration_format=field.duration_format, nullable=True
)
def from_baserow_formula_type(self, formula_type: BaserowFormulaCharType):
return self.model_class(duration_format=formula_type.duration_format)
class LinkRowFieldType(ManyToManyFieldTypeSerializeToInputValueMixin, FieldType):
"""

View file

@ -489,6 +489,13 @@ class FormulaField(Field):
null=True,
help_text="Force a timezone for the field overriding user profile settings.",
)
duration_format = models.CharField(
choices=DURATION_FORMAT_CHOICES,
default=DURATION_FORMAT_CHOICES[0][0],
max_length=32,
null=True,
help_text=_("The format of the duration."),
)
needs_periodic_update = models.BooleanField(
default=False,
help_text="Indicates if the field needs to be periodically updated.",

View file

@ -105,8 +105,8 @@ from baserow.contrib.database.formula.types.formula_types import (
BaserowFormulaBooleanType,
BaserowFormulaButtonType,
BaserowFormulaCharType,
BaserowFormulaDateIntervalType,
BaserowFormulaDateType,
BaserowFormulaDurationType,
BaserowFormulaLinkType,
BaserowFormulaMultipleSelectType,
BaserowFormulaNumberType,
@ -1237,9 +1237,12 @@ class BaserowEqual(TwoArgumentBaserowFunction):
return func_call.with_valid_type(BaserowFormulaBooleanType())
def to_django_expression(self, arg1: Expression, arg2: Expression) -> Expression:
return EqualsExpr(
arg1,
arg2,
return Case(
When(
condition=IsNullExpr(arg1, output_field=fields.BooleanField()),
then=IsNullExpr(arg2, output_field=fields.BooleanField()),
),
default=EqualsExpr(arg1, arg2, output_field=fields.BooleanField()),
output_field=fields.BooleanField(),
)
@ -1728,7 +1731,7 @@ class BaserowDateInterval(OneArgumentBaserowFunction):
func_call: BaserowFunctionCall[UnTyped],
arg: BaserowExpression[BaserowFormulaValidType],
) -> BaserowExpression[BaserowFormulaType]:
return func_call.with_valid_type(BaserowFormulaDateIntervalType(nullable=True))
return func_call.with_valid_type(BaserowFormulaDurationType(nullable=True))
def to_django_expression(self, arg: Expression) -> Expression:
return Func(

View file

@ -18,7 +18,7 @@ from django.db.models import (
When,
fields,
)
from django.db.models.functions import Cast, Coalesce, JSONObject
from django.db.models.functions import Cast, Coalesce, Extract, JSONObject
from baserow.contrib.database.formula.ast.exceptions import UnknownFieldReference
from baserow.contrib.database.formula.ast.tree import (
@ -372,6 +372,7 @@ class BaserowExpressionToDjangoExpressionGenerator(
self, db_column: str, model_field: fields.Field, already_in_subquery: bool
) -> Expression:
from baserow.contrib.database.fields.fields import (
DurationField,
MultipleSelectManyToManyField,
SingleSelectForeignKey,
)
@ -417,6 +418,14 @@ class BaserowExpressionToDjangoExpressionGenerator(
),
Value([], output_field=JSONField()),
)
elif isinstance(model_field, DurationField) and already_in_subquery:
# already_in_subquery is set to True in a lookup, but the JSON produced by
# looking up a duration field cannot contains intervals/timedelta, so we
# need to convert the value to a number of seconds instead.
return ExpressionWrapper(
Extract(db_column, "epoch"),
output_field=model_field,
)
else:
return ExpressionWrapper(
F(db_column),

View file

@ -21,6 +21,7 @@ from baserow.contrib.database.fields.expressions import (
)
from baserow.contrib.database.fields.field_sortings import OptionallyAnnotatedOrderBy
from baserow.contrib.database.fields.mixins import get_date_time_format
from baserow.contrib.database.fields.utils.duration import D_H_M_S
from baserow.contrib.database.formula.ast.tree import (
BaserowBooleanLiteral,
BaserowDecimalLiteral,
@ -414,12 +415,12 @@ def _calculate_addition_interval_type(
) -> BaserowFormulaValidType:
arg1_type = arg1.expression_type
arg2_type = arg2.expression_type
if isinstance(arg1_type, BaserowFormulaDateIntervalType) and isinstance(
arg2_type, BaserowFormulaDateIntervalType
if isinstance(arg1_type, BaserowFormulaDateIntervalTypeMixin) and isinstance(
arg2_type, BaserowFormulaDateIntervalTypeMixin
):
# interval + interval = interval
resulting_type = arg1_type
elif isinstance(arg1_type, BaserowFormulaDateIntervalType):
elif isinstance(arg1_type, BaserowFormulaDateIntervalTypeMixin):
# interval + date = date
resulting_type = arg2_type
else:
@ -429,9 +430,20 @@ def _calculate_addition_interval_type(
return resulting_type
# noinspection PyMethodMayBeStatic
class BaserowFormulaDateIntervalTypeMixin:
"""
Empty mixin to allow us to check if a type is a date interval type or a duration
type. NOTE: This can be removed once the BaserowFormulaDateIntervalType is removed.
"""
pass
# Deprecated, use BaserowFormulaDurationType instead
class BaserowFormulaDateIntervalType(
BaserowFormulaTypeHasEmptyBaserowExpression, BaserowFormulaValidType
BaserowFormulaTypeHasEmptyBaserowExpression,
BaserowFormulaValidType,
BaserowFormulaDateIntervalTypeMixin,
):
type = "date_interval"
baserow_field_type = None
@ -535,6 +547,74 @@ class BaserowFormulaDateIntervalType(
return Cast(field.db_column, output_field=models.TextField())
class BaserowFormulaDurationType(
BaserowFormulaTypeHasEmptyBaserowExpression,
BaserowFormulaValidType,
BaserowFormulaDateIntervalTypeMixin,
):
type = "duration"
baserow_field_type = "duration"
user_overridable_formatting_option_fields = ["duration_format"]
can_group_by = True
can_order_by_in_array = True
def __init__(self, duration_format: str = D_H_M_S, **kwargs):
super().__init__(**kwargs)
self.duration_format = duration_format
@property
def comparable_types(self) -> List[Type["BaserowFormulaValidType"]]:
return [type(self)]
@property
def limit_comparable_types(self) -> List[Type["BaserowFormulaValidType"]]:
return [type(self)]
@property
def addable_types(self) -> List[Type["BaserowFormulaValidType"]]:
return [type(self), BaserowFormulaDateType]
@property
def subtractable_types(self) -> List[Type["BaserowFormulaValidType"]]:
return [type(self)]
def add(
self,
add_func_call: "BaserowFunctionCall[UnTyped]",
arg1: "BaserowExpression[BaserowFormulaValidType]",
arg2: "BaserowExpression[BaserowFormulaValidType]",
):
return add_func_call.with_valid_type(
_calculate_addition_interval_type(arg1, arg2)
)
def minus(
self,
minus_func_call: "BaserowFunctionCall[UnTyped]",
arg1: "BaserowExpression[BaserowFormulaValidType]",
arg2: "BaserowExpression[BaserowFormulaValidType]",
):
return minus_func_call.with_valid_type(
BaserowFormulaDurationType(
duration_format=self.duration_format,
nullable=arg1.expression_type.nullable or arg2.expression_type.nullable,
)
)
def placeholder_empty_value(self):
return Value(datetime.timedelta(hours=0), output_field=models.DurationField())
def placeholder_empty_baserow_expression(
self,
) -> "BaserowExpression[BaserowFormulaValidType]":
return literal(datetime.timedelta(hours=0))
def get_order_by_in_array_expr(self, field, field_name, order_direction):
return JSONBSingleKeyArrayExpression(
field_name, "value", "interval", output_field=models.DurationField()
)
class BaserowFormulaDateType(BaserowFormulaValidType):
type = "date"
baserow_field_type = "date"
@ -579,11 +659,11 @@ class BaserowFormulaDateType(BaserowFormulaValidType):
@property
def addable_types(self) -> List[Type["BaserowFormulaValidType"]]:
return [BaserowFormulaDateIntervalType]
return [BaserowFormulaDateIntervalType, BaserowFormulaDurationType]
@property
def subtractable_types(self) -> List[Type["BaserowFormulaValidType"]]:
return [type(self), BaserowFormulaDateIntervalType]
return [type(self), BaserowFormulaDateIntervalType, BaserowFormulaDurationType]
def add(
self,
@ -604,12 +684,12 @@ class BaserowFormulaDateType(BaserowFormulaValidType):
arg1_type = arg1.expression_type
arg2_type = arg2.expression_type
if isinstance(arg2_type, BaserowFormulaDateType):
# date - date = interval
resulting_type = BaserowFormulaDateIntervalType(
# date - date = duration
resulting_type = BaserowFormulaDurationType(
nullable=arg1_type.nullable or arg2_type.nullable
)
else:
# date - interval = date
# date - duration = date
resulting_type = arg1_type
return minus_func_call.with_valid_type(resulting_type)
@ -1003,6 +1083,11 @@ class BaserowFormulaArrayType(BaserowFormulaValidType):
# strings, we need to reparse them back first before giving it to
# the date field type.
list_item = parser.isoparse(list_item)
elif list_item is not None and self.sub_type.type == "duration":
# Arrays are stored as JSON which means the durations are converted to
# the number of seconds, we need to reparse them back first before
# giving the duration field type.
list_item = datetime.timedelta(seconds=list_item)
export_value = map_func(list_item)
if export_value is None:
export_value = ""
@ -1268,7 +1353,8 @@ BASEROW_FORMULA_TYPES = [
BaserowFormulaCharType,
BaserowFormulaButtonType,
BaserowFormulaLinkType,
BaserowFormulaDateIntervalType,
BaserowFormulaDateIntervalType, # Deprecated in favor of BaserowFormulaDurationType
BaserowFormulaDurationType,
BaserowFormulaDateType,
BaserowFormulaBooleanType,
BaserowFormulaNumberType,

View file

@ -0,0 +1,78 @@
# Generated by Django 4.0.10 on 2024-01-05 15:22
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("database", "0149_alter_durationfield_duration_format"),
]
operations = [
migrations.AddField(
model_name="formulafield",
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,
null=True,
),
),
migrations.AlterField(
model_name="formulafield",
name="array_formula_type",
field=models.TextField(
choices=[
("invalid", "invalid"),
("text", "text"),
("char", "char"),
("button", "button"),
("link", "link"),
("date_interval", "date_interval"),
("duration", "duration"),
("date", "date"),
("boolean", "boolean"),
("number", "number"),
("single_select", "single_select"),
("multiple_select", "multiple_select"),
("single_file", "single_file"),
],
default=None,
null=True,
),
),
migrations.AlterField(
model_name="formulafield",
name="formula_type",
field=models.TextField(
choices=[
("invalid", "invalid"),
("text", "text"),
("char", "char"),
("button", "button"),
("link", "link"),
("date_interval", "date_interval"),
("duration", "duration"),
("date", "date"),
("boolean", "boolean"),
("number", "number"),
("array", "array"),
("single_select", "single_select"),
("multiple_select", "multiple_select"),
("single_file", "single_file"),
],
default="invalid",
),
),
]

View file

@ -55,6 +55,7 @@ from baserow.contrib.database.formula import (
)
from baserow.contrib.database.formula.types.formula_types import (
BaserowFormulaDateIntervalType,
BaserowFormulaDurationType,
BaserowFormulaSingleFileType,
)
from baserow.core.datetime import get_timezones
@ -1405,6 +1406,7 @@ class EmptyViewFilterType(ViewFilterType):
BaserowFormulaDateType.type,
BaserowFormulaBooleanType.type,
BaserowFormulaDateIntervalType.type,
BaserowFormulaDurationType.type,
FormulaFieldType.array_of(BaserowFormulaSingleFileType.type),
),
]

View file

@ -948,7 +948,7 @@ def test_can_type_a_valid_formula_field(data_fixture, api_client):
"api:database:formula:type_formula",
kwargs={"table_id": table.id},
),
{f"formula": "1+1", "name": formula_field_name},
{"formula": "1+1", "name": formula_field_name},
format="json",
HTTP_AUTHORIZATION=f"JWT {token}",
)
@ -960,6 +960,7 @@ def test_can_type_a_valid_formula_field(data_fixture, api_client):
"date_time_format": None,
"date_show_tzinfo": None,
"date_force_timezone": None,
"duration_format": None,
"error": None,
"formula": "1+1",
"formula_type": "number",

View file

@ -245,14 +245,14 @@ def test_get_row_serializer_with_user_field_names(data_fixture):
{"id": 2, "value": "-123.456"},
{"id": 3, "value": ""},
],
"duration_hm": 3660,
"duration_hms": 3666,
"duration_hm": 3660.0,
"duration_hms": 3666.0,
"duration_hms_s": 3666.6,
"duration_hms_ss": 3666.66,
"duration_hms_sss": 3666.666,
"duration_dh": 90000,
"duration_dhm": 90060,
"duration_dhms": 90066,
"duration_dh": 90000.0,
"duration_dhm": 90060.0,
"duration_dhms": 90066.0,
"email": "test@example.com",
"file": [
{
@ -333,7 +333,7 @@ def test_get_row_serializer_with_user_field_names(data_fixture):
"url": "https://www.google.com",
"formula_bool": True,
"formula_date": "2020-01-01",
"formula_dateinterval": "1 day",
"formula_dateinterval": 86400.0,
"formula_decimal": "33.3333333333",
"formula_email": "test@example.com",
"formula_int": "1",

View file

@ -207,7 +207,7 @@ def test_serialize_group_by_metadata_on_all_fields_in_interesting_table(data_fix
"formula_int": [{"count": 2, "field_formula_int": "1"}],
"formula_bool": [{"count": 2, "field_formula_bool": True}],
"formula_decimal": [{"count": 2, "field_formula_decimal": "33.3333333333"}],
"formula_dateinterval": [{"count": 2, "field_formula_dateinterval": "1 day"}],
"formula_dateinterval": [{"count": 2, "field_formula_dateinterval": 24 * 3600}],
"formula_date": [{"count": 2, "field_formula_date": "2020-01-01"}],
"formula_email": [
{"count": 1, "field_formula_email": ""},

View file

@ -236,7 +236,7 @@ def test_can_export_every_interesting_different_field_to_csv(
"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,"
"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,,"
"1d 0:00,2020-01-01,,,label (https://google.com),https://google.com,,0,0.000,,"
"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,"
"02/01/2020 01:23,02/01/2020,01/02/2020 01:23,01/02/2020,01/02/2020 02:23,"
@ -250,7 +250,7 @@ def test_can_export_every_interesting_different_field_to_csv(
'"a.txt (http://localhost:8000/media/user_files/hashed_name.txt),'
'b.txt (http://localhost:8000/media/user_files/other_name.txt)",A,"D,C,E",'
'"user2@example.com,user3@example.com",\'+4412345678,test FORMULA,1,True,33.3333333333,'
"1 day,2020-01-01,A,test@example.com,label (https://google.com),https://google.com,"
"1d 0:00,2020-01-01,A,test@example.com,label (https://google.com),https://google.com,"
'"D,C,E",3,-122.222,"linked_row_1,linked_row_2,",00000000-0000-4000-8000-000000000002,2\r\n'
)

View file

@ -662,3 +662,126 @@ def test_get_group_by_metadata_in_rows_with_duration_field(data_fixture):
]
)
}
@pytest.mark.field_duration
@pytest.mark.django_db
def test_duration_field_can_be_used_in_formulas(data_fixture):
user = data_fixture.create_user()
table = data_fixture.create_database_table(user=user)
duration_field = data_fixture.create_duration_field(
table=table, name="duration", duration_format="h:mm:ss"
)
ref_field = data_fixture.create_formula_field(
table=table, name="ref", formula=f"field('{duration_field.name}')"
)
add_field = data_fixture.create_formula_field(
table=table,
formula=f"field('{duration_field.name}') + field('{ref_field.name}')",
)
sub_field = data_fixture.create_formula_field(
table=table,
formula=f"field('{duration_field.name}') - field('{ref_field.name}')",
)
compare_field = data_fixture.create_formula_field(
table=table,
formula=f"field('{duration_field.name}') = field('{ref_field.name}')",
)
RowHandler().create_rows(
user,
table,
rows_values=[
{duration_field.db_column: 3600},
{duration_field.db_column: 0},
{},
],
)
model = table.get_model()
assert list(
model.objects.all().values(
ref_field.db_column,
add_field.db_column,
sub_field.db_column,
compare_field.db_column,
)
) == [
{
ref_field.db_column: timedelta(seconds=3600),
add_field.db_column: timedelta(seconds=7200),
sub_field.db_column: timedelta(seconds=0),
compare_field.db_column: True,
},
{
ref_field.db_column: timedelta(seconds=0),
add_field.db_column: timedelta(seconds=0),
sub_field.db_column: timedelta(seconds=0),
compare_field.db_column: True,
},
{
ref_field.db_column: None,
add_field.db_column: timedelta(seconds=0),
sub_field.db_column: timedelta(seconds=0),
compare_field.db_column: True,
},
]
@pytest.mark.field_duration
@pytest.mark.django_db
def test_duration_field_can_be_looked_up(data_fixture):
user = data_fixture.create_user()
table_a, table_b, link_field = data_fixture.create_two_linked_tables(user=user)
duration_field = data_fixture.create_duration_field(
table=table_b, name="duration", duration_format="h:mm:ss"
)
lookup_field = data_fixture.create_formula_field(
table=table_a, formula=f"lookup('{link_field.name}', 'duration')"
)
# Also a formula field referencing a duration can be looked up
duration_formula = data_fixture.create_formula_field(
table=table_b,
name="formula",
formula=f"field('{duration_field.name}') + date_interval('60s')",
)
lookup_formula = data_fixture.create_formula_field(
table=table_a, formula=f"lookup('{link_field.name}', 'formula')"
)
model_b = table_b.get_model()
row_b_1, row_b_2 = RowHandler().create_rows(
user=user,
table=table_b,
rows_values=[
{duration_field.db_column: 3600},
{duration_field.db_column: 60},
],
model=model_b,
)
assert list(model_b.objects.values_list(duration_formula.db_column, flat=True)) == [
timedelta(seconds=3600 + 60),
timedelta(seconds=60 + 60),
]
model_a = table_a.get_model()
(row,) = RowHandler().create_rows(
user=user,
table=table_a,
rows_values=[
{f"field_{link_field.id}": [row_b_1.id, row_b_2.id]},
],
model=model_a,
)
assert getattr(row, f"field_{lookup_field.id}") == [
{"id": row_b_1.id, "value": 3600},
{"id": row_b_2.id, "value": 60},
]
assert getattr(row, f"field_{lookup_formula.id}") == [
{"id": row_b_1.id, "value": 3600 + 60},
{"id": row_b_2.id, "value": 60 + 60},
]

View file

@ -567,7 +567,7 @@ def test_human_readable_values(data_fixture):
"url": "",
"formula_bool": "True",
"formula_date": "2020-01-01",
"formula_dateinterval": "1 day",
"formula_dateinterval": "1d 0:00",
"formula_decimal": "33.3333333333",
"formula_email": "",
"formula_int": "1",
@ -622,7 +622,7 @@ def test_human_readable_values(data_fixture):
"url": "https://www.google.com",
"formula_bool": "True",
"formula_date": "2020-01-01",
"formula_dateinterval": "1 day",
"formula_dateinterval": "1d 0:00",
"formula_decimal": "33.3333333333",
"formula_email": "test@example.com",
"formula_int": "1",

View file

@ -147,7 +147,7 @@ VALID_FORMULA_TESTS = [
("or(false, true)", True),
("or(true, true)", True),
("'a' + 'b'", "ab"),
("date_interval('1 year')", "1 year"),
("date_interval('1 year')", 365 * 24 * 3600),
("date_interval('1 year') > date_interval('1 day')", True),
("date_interval('1 invalid')", None),
("todate('20200101', 'YYYYMMDD') + date_interval('1 year')", "2021-01-01"),
@ -163,8 +163,11 @@ VALID_FORMULA_TESTS = [
")",
"6",
),
("todate('20200101', 'YYYYMMDD') - todate('20210101', 'YYYYMMDD')", "-366 days"),
("date_interval('1 year') - date_interval('1 day')", "1 year -1 days"),
(
"todate('20200101', 'YYYYMMDD') - todate('20210101', 'YYYYMMDD')",
-366 * 24 * 3600,
),
("date_interval('1 year') - date_interval('1 day')", 364 * 24 * 3600),
("now() > todate('20200101', 'YYYYMMDD')", True),
("todate('01123456', 'DDMMYYYY') < now()", False),
("todate('01123456', 'DDMMYYYY') < today()", False),
@ -582,7 +585,7 @@ def test_can_lookup_date_intervals(data_fixture, api_client):
)
assert response.status_code == HTTP_200_OK
assert [o[lookup_formula.db_column] for o in response.json()["results"]] == [
[{"id": row_1.id, "value": "2 days"}]
[{"id": row_1.id, "value": 2 * 24 * 3600}]
]
@ -964,13 +967,13 @@ INVALID_FORMULA_TESTS = [
"todate('20200101', 'YYYYMMDD') + todate('20210101', 'YYYYMMDD')",
"ERROR_WITH_FORMULA",
"Error with formula: argument number 2 given to operator + was of type date "
"but the only usable type for this argument is date_interval.",
"but the only usable types for this argument are date_interval,duration.",
),
(
"date_interval('1 second') - todate('20210101', 'YYYYMMDD')",
"ERROR_WITH_FORMULA",
"Error with formula: argument number 2 given to operator - was of type date "
"but the only usable type for this argument is date_interval.",
"but the only usable type for this argument is duration.",
),
(
"when_empty(1, 'a')",

View file

@ -4181,7 +4181,9 @@ def test_get_group_by_on_all_fields_in_interesting_table(data_fixture):
"formula_decimal": [
{"field_formula_decimal": Decimal("33.3333333333"), "count": 2}
],
"formula_dateinterval": [{"field_formula_dateinterval": "1 day", "count": 2}],
"formula_dateinterval": [
{"field_formula_dateinterval": datetime.timedelta(days=1), "count": 2}
],
"formula_date": [{"field_formula_date": datetime.date(2020, 1, 1), "count": 2}],
"formula_email": [
{"field_formula_email": "", "count": 1},

View file

@ -0,0 +1,7 @@
{
"type": "breaking_change",
"message": "New formulas returning a date_interval/duration are sent as number of seconds instead of a formatted string.",
"issue_number": 2190,
"bullet_points": [],
"created_at": "2024-01-12"
}

View file

@ -0,0 +1,7 @@
{
"type": "feature",
"message": "Add support to reference duration fields in the formula language.",
"issue_number": 2190,
"bullet_points": [],
"created_at": "2024-01-08"
}

View file

@ -88,7 +88,7 @@ def test_can_export_every_interesting_different_field_to_json(
"formula_int": 1,
"formula_bool": true,
"formula_decimal": "33.3333333333",
"formula_dateinterval": "1 day",
"formula_dateinterval": "1d 0:00",
"formula_date": "2020-01-01",
"formula_singleselect": "",
"formula_email": "",
@ -195,7 +195,7 @@ def test_can_export_every_interesting_different_field_to_json(
"formula_int": 1,
"formula_bool": true,
"formula_decimal": "33.3333333333",
"formula_dateinterval": "1 day",
"formula_dateinterval": "1d 0:00",
"formula_date": "2020-01-01",
"formula_singleselect": "A",
"formula_email": "test@example.com",
@ -351,7 +351,7 @@ def test_can_export_every_interesting_different_field_to_xml(
<formula-int>1</formula-int>
<formula-bool>true</formula-bool>
<formula-decimal>33.3333333333</formula-decimal>
<formula-dateinterval>1 day</formula-dateinterval>
<formula-dateinterval>1d 0:00</formula-dateinterval>
<formula-date>2020-01-01</formula-date>
<formula-singleselect/>
<formula-email/>
@ -458,7 +458,7 @@ def test_can_export_every_interesting_different_field_to_xml(
<formula-int>1</formula-int>
<formula-bool>true</formula-bool>
<formula-decimal>33.3333333333</formula-decimal>
<formula-dateinterval>1 day</formula-dateinterval>
<formula-dateinterval>1d 0:00</formula-dateinterval>
<formula-date>2020-01-01</formula-date>
<formula-singleselect>A</formula-singleselect>
<formula-email>test@example.com</formula-email>

View file

@ -15,17 +15,29 @@
:view="view"
>
</FieldDateSubForm>
<FieldDurationSubForm
v-else-if="formulaType === 'duration'"
:default-values="defaultValues"
:table="table"
:view="view"
>
</FieldDurationSubForm>
</div>
</template>
<script>
import FieldNumberSubForm from '@baserow/modules/database/components/field/FieldNumberSubForm'
import FieldDateSubForm from '@baserow/modules/database/components/field/FieldDateSubForm'
import FieldDurationSubForm from '@baserow/modules/database/components/field/FieldDurationSubForm'
import form from '@baserow/modules/core/mixins/form'
import fieldSubForm from '@baserow/modules/database/mixins/fieldSubForm'
export default {
name: 'FormulaTypeSubForms',
components: { FieldNumberSubForm, FieldDateSubForm },
components: {
FieldNumberSubForm,
FieldDateSubForm,
FieldDurationSubForm,
},
mixins: [form, fieldSubForm],
props: {
table: {

View file

@ -0,0 +1,15 @@
<template functional>
<div v-if="props.value" class="array-field__item">
<span>
{{ $options.methods.formatValue(props.field, props.value) }}
</span>
</div>
</template>
<script>
import durationField from '@baserow/modules/database/mixins/durationField'
export default {
name: 'FunctionalFormulaArrayDurationItem',
mixins: [durationField],
}
</script>

View file

@ -0,0 +1,28 @@
<template>
<div class="control__elements">
<div>{{ formattedValue }}</div>
</div>
</template>
<script>
import rowEditField from '@baserow/modules/database/mixins/rowEditField'
import durationField from '@baserow/modules/database/mixins/durationField'
export default {
mixins: [rowEditField, durationField],
watch: {
'field.duration_format': {
handler() {
this.updateCopy(this.field, this.value)
this.updateFormattedValue(this.field, this.copy)
},
},
value: {
handler(newValue) {
this.updateCopy(this.field, newValue)
this.updateFormattedValue(this.field, this.copy)
},
},
},
}
</script>

View file

@ -18,7 +18,7 @@
class="grid-field-duration__input"
:placeholder="field.duration_format"
@keypress="onKeyPress(field, $event)"
@keyup="updateCopy(field, $event.target.value)"
@input="onInput(field, $event)"
/>
<div v-show="!isValid()" class="grid-view__cell--error align-right">
{{ getError() }}
@ -43,7 +43,11 @@ export default {
this.updateFormattedValue(this.field, value)
return this.$super(gridFieldInput).beforeSave(value)
},
afterEdit() {
afterEdit(event, value) {
if (value !== null) {
this.updateCopy(this.field, value)
this.updateFormattedValue(this.field, this.copy)
}
this.$nextTick(() => {
this.$refs.input.focus()
this.$refs.input.selectionStart = this.$refs.input.selectionEnd = 100000

View file

@ -2261,6 +2261,10 @@ export class DurationFieldType extends FieldType {
return DURATION_FORMATS.get(field.duration_format).example
}
canBeReferencedByFormulaField() {
return true
}
getDocsDescription(field) {
return this.app.i18n.t('fieldDocs.duration', {
format: field.duration_format,

View file

@ -17,7 +17,9 @@ import RowEditFieldMultipleSelectReadOnly from '@baserow/modules/database/compon
import RowEditFieldArray from '@baserow/modules/database/components/row/RowEditFieldArray'
import RowEditFieldLinkURL from '@baserow/modules/database/components/row/RowEditFieldLinkURL'
import RowEditFieldButton from '@baserow/modules/database/components/row/RowEditFieldButton'
import RowEditFieldDurationReadOnly from '@baserow/modules/database/components/row/RowEditFieldDurationReadOnly.vue'
import FunctionalFormulaArrayItem from '@baserow/modules/database/components/formula/array/FunctionalFormulaArrayItem'
import FunctionalFormulaArrayDurationItem from '@baserow/modules/database/components/formula/array/FunctionalFormulaArrayDurationItem'
import FunctionalFormulaBooleanArrayItem from '@baserow/modules/database/components/formula/array/FunctionalFormulaBooleanArrayItem'
import FunctionalFormulaDateArrayItem from '@baserow/modules/database/components/formula/array/FunctionalFormulaDateArrayItem'
import FunctionalFormulaSingleSelectArrayItem from '@baserow/modules/database/components/formula/array/FunctionalFormulaSingleSelectArrayItem'
@ -351,6 +353,41 @@ export class BaserowFormulaDateType extends BaserowFormulaTypeDefinition {
}
}
export class BaserowFormulaDurationType extends BaserowFormulaTypeDefinition {
static getType() {
return 'duration'
}
getFieldType() {
return 'duration'
}
getIconClass() {
return 'iconoir-clock-rotate-right'
}
getRowEditFieldComponent(field) {
return RowEditFieldDurationReadOnly
}
getSortOrder() {
return 5
}
canGroupByInView() {
return true
}
getFunctionalGridViewFieldArrayComponent() {
return FunctionalFormulaArrayDurationItem
}
canBeSortedWhenInArray(field) {
return true
}
}
// Deprecated, use BaserowFormulaDurationType instead.
export class BaserowFormulaDateIntervalType extends BaserowFormulaTypeDefinition {
static getType() {
return 'date_interval'

View file

@ -47,11 +47,17 @@ export default {
return newCopy
}
},
onKeyPress(field, event) {
isValidChar(char) {
const allowedChars = ['.', ':', ' ', 'd', 'h']
if (!/\d/.test(event.key) && !allowedChars.includes(event.key)) {
return /\d/.test(char) || allowedChars.includes(char)
},
onKeyPress(field, event) {
if (!this.isValidChar(event.key)) {
return event.preventDefault()
}
},
onInput(field, event) {
this.updateCopy(field, event.target.value)
},
},
}

View file

@ -144,7 +144,7 @@ export default {
this.editing = true
this.copy = value === null ? this.value : value
this.afterEdit(event)
this.afterEdit(event, value)
},
/**
* Method that can be called when in the editing state. It will bring the

View file

@ -217,7 +217,8 @@ import {
BaserowFormulaButtonType,
BaserowFormulaCharType,
BaserowFormulaLinkType,
BaserowFormulaDateIntervalType,
BaserowFormulaDateIntervalType, // Deprecated
BaserowFormulaDurationType,
BaserowFormulaDateType,
BaserowFormulaInvalidType,
BaserowFormulaNumberType,
@ -613,6 +614,10 @@ export default (context) => {
'formula_type',
new BaserowFormulaDateIntervalType(context)
)
app.$registry.register(
'formula_type',
new BaserowFormulaDurationType(context)
)
app.$registry.register('formula_type', new BaserowFormulaNumberType(context))
app.$registry.register('formula_type', new BaserowFormulaArrayType(context))
app.$registry.register('formula_type', new BaserowFormulaSpecialType(context))

View file

@ -117,7 +117,7 @@ export const DURATION_FORMATS = new Map([
example: '1:23:40',
toString(d, h, m, s) {
return `${d * 24 + h}:${m.toString().padStart(2, '0')}:${s
.toString()
.toFixed(0)
.padStart(2, '0')}`
},
round: (value) => Math.round(value),
@ -191,7 +191,7 @@ export const DURATION_FORMATS = new Map([
example: '1d 2:34:56',
toString(d, h, m, s) {
return `${d}d ${h}:${m.toString().padStart(2, '0')}:${s
.toString()
.toFixed(0)
.padStart(2, '0')}`
},
round: (value) => Math.round(value),