1
0
Fork 0
mirror of https://gitlab.com/bramw/baserow.git synced 2025-04-27 14:06:13 +00:00

3110 formula duration field filtering

This commit is contained in:
Cezary Statkiewicz 2024-12-12 18:18:59 +00:00
parent b96a21151f
commit 1fa2af432d
11 changed files with 1950 additions and 253 deletions
backend
src/baserow/contrib/database/views
tests/baserow/contrib/database
changelog/entries/unreleased/feature
web-frontend

View file

@ -108,6 +108,7 @@ class EqualViewFilterType(ViewFilterType):
BaserowFormulaTextType.type,
BaserowFormulaCharType.type,
BaserowFormulaNumberType.type,
BaserowFormulaDurationType.type,
BaserowFormulaURLType.type,
),
]
@ -369,7 +370,7 @@ class NumericComparisonViewFilterType(ViewFilterType):
AutonumberFieldType.type,
DurationFieldType.type,
FormulaFieldType.compatible_with_formula_types(
BaserowFormulaNumberType.type,
BaserowFormulaNumberType.type, BaserowFormulaDurationType.type
),
]

View file

@ -0,0 +1,783 @@
import typing
from datetime import timedelta
from functools import partial
import pytest
from baserow.contrib.database.fields.field_types import FormulaFieldType
from baserow.contrib.database.fields.utils.duration import D_H_M_S
from tests.baserow.contrib.database.utils import (
duration_field_factory,
setup_formula_field,
text_field_factory,
)
if typing.TYPE_CHECKING:
from baserow.test_utils.fixtures import Fixtures
def duration_formula_filter_proc(
data_fixture: "Fixtures",
duration_format: str,
filter_type_name: str,
test_value: str,
expected_rows: list[int],
expected_test_value: None = None,
):
"""
Common duration formula field test procedure. Each test operates on a fixed set of
data, where each table row contains a formula field with a predefined value.
Formula duration field will store calculated duration value 'as is', with
raw value's precision regardless selected duration_format.
The value will be truncated/rounded according to duration_format for display only.
However, when filtering, a filter value will be truncated/rounded to the format,
which may introduce inconsistencies in filtering.
"""
formula_text = """field('target')"""
t = setup_formula_field(
data_fixture,
formula_text=formula_text,
formula_type="duration",
# Data field is a source of values for formula field. In this case we want it
# to be at seconds precision, so we can measure filter value rounding effects.
data_field_factory=partial(duration_field_factory, duration_format=D_H_M_S),
extra_fields=[partial(text_field_factory, name="text_field")],
# Duration format for formula field causes filter value to be rounded
# Note that field value will remain the same regardless format
formula_extra_kwargs={"duration_format": duration_format},
)
assert t.formula_field.formula_type == "duration"
t.view_handler.create_filter(
t.user,
t.grid_view,
field=t.formula_field,
type_name=filter_type_name,
value=test_value,
)
src_field_name = t.data_source_field.db_column
formula_field_name = t.formula_field.db_column
refname = t.extra_fields["text_field"].db_column
rows = [
{src_field_name: 3600, refname: "1h"},
{src_field_name: 2 * 3600, refname: "2h"},
{src_field_name: 3 * 3600, refname: "3h"},
{src_field_name: 4 * 3600, refname: "4h"},
{src_field_name: 5 * 3600, refname: "5h"},
{src_field_name: None, refname: "none"},
{src_field_name: 3601, refname: "1h 1s"},
{src_field_name: 3599, refname: "1h -1s"},
{src_field_name: (3 * 3600) + 1, refname: "3h 1s"},
{src_field_name: (3 * 3600) - 1, refname: "3h -1s"},
{src_field_name: 59, refname: "59s"},
{src_field_name: 61, refname: "1m 1s"},
]
created = t.row_handler.create_rows(
user=t.user,
table=t.table,
rows_values=rows,
send_webhook_events=False,
send_realtime_update=False,
)
q = t.view_handler.get_queryset(t.grid_view)
actual_names = [getattr(r, refname) for r in q]
actual_duration_values = [getattr(r, t.data_source_field.db_column) for r in q]
actual_formula_values = [getattr(r, t.formula_field.db_column) for r in q]
if expected_test_value is not None:
mfield = FormulaFieldType().get_model_field(t.formula_field)
assert t.formula_field.duration_format == duration_format
assert (
getattr(mfield.expression_field, "duration_format", None) == duration_format
)
actual_test_value = mfield.get_prep_value(test_value)
assert actual_test_value == expected_test_value
assert len(q) == len(expected_rows)
assert set(actual_names) == set(expected_rows)
@pytest.mark.parametrize(
"filter_type_name,test_value,expected_rows,duration_format,expected_test_value",
[
(
"equal",
str(3 * 3600),
[
"3h",
],
"h:mm",
timedelta(hours=3),
),
("equal", "3h", ["3h"], "h:mm", timedelta(hours=3)),
("equal", str(3 * 3600), ["3h"], "d h mm ss", timedelta(hours=3)),
(
"equal",
str((3 * 3600) + 2),
["3h"],
"h:mm",
timedelta(hours=3),
), # rounded to 3h
("equal", "3600s", ["1h"], "h:mm", timedelta(hours=1)),
("equal", "1:00", ["1h"], "h:mm", timedelta(hours=1)), # 1h
("equal", "1:00", [], "h:mm:ss", timedelta(minutes=1)), # 1m
("equal", "0:59", ["1h"], "d h", timedelta(hours=1)), # 1h
("equal", "0:59", ["59s"], "d h mm ss", timedelta(seconds=59)), # 59s
("equal", "3601s", ["1h"], "h:mm", timedelta(hours=1)), # rounded to 1h
(
"equal",
"3601s",
["1h 1s"],
"h:mm:ss",
timedelta(hours=1, seconds=1),
), # exact 1h 1s
("equal", "1d 20h", [], "d h:mm", timedelta(days=1, hours=20)),
("equal", str(3 * 1800), [], "d h mm ss", timedelta(hours=1, minutes=30)),
# 1.5h rounded to 2h
("equal", str(3 * 1800), ["2h"], "d h", timedelta(hours=2)),
("equal", "invalid", [], "d h mm ss", None),
(
"equal",
"",
[
"1h",
"2h",
"3h",
"4h",
"5h",
"none",
"1h 1s",
"1h -1s",
"3h 1s",
"3h -1s",
"59s",
"1m 1s",
],
"d h mm ss",
None,
),
(
"not_equal",
str(3 * 3600),
[
"1h",
"2h",
"4h",
"5h",
"none",
"1h 1s",
"1h -1s",
"3h 1s",
"3h -1s",
"59s",
"1m 1s",
],
"h:mm",
timedelta(hours=3),
),
(
"not_equal",
"3h 2s",
# equals 3h due to rounding
[
"1h",
"2h",
"4h",
"5h",
"none",
"1h 1s",
"1h -1s",
"3h 1s",
"3h -1s",
"59s",
"1m 1s",
],
"d h",
timedelta(hours=3),
),
(
"not_equal",
str(3 * 3600),
[
"1h",
"2h",
"4h",
"5h",
"none",
"1h 1s",
"1h -1s",
"3h 1s",
"3h -1s",
"59s",
"1m 1s",
],
"d h",
timedelta(hours=3),
),
(
"not_equal",
str(3 * 1800),
[
"1h",
"2h",
"3h",
"4h",
"5h",
"none",
"1h 1s",
"1h -1s",
"3h 1s",
"3h -1s",
"59s",
"1m 1s",
],
"d h mm ss",
timedelta(hours=1, minutes=30),
),
(
"not_equal",
str(3 * 1800), # 2h due to rounding
[
"1h",
"3h",
"4h",
"5h",
"none",
"1h 1s",
"1h -1s",
"3h 1s",
"3h -1s",
"59s",
"1m 1s",
],
"d h",
timedelta(hours=2),
),
(
"not_equal",
"1:00", # parsed as 1m
[
"1h",
"2h",
"3h",
"4h",
"5h",
"none",
"1h 1s",
"1h -1s",
"3h 1s",
"3h -1s",
"59s",
"1m 1s",
],
"d h mm ss",
timedelta(minutes=1),
),
(
"not_equal",
"1:00", # parsed as 1h
[
"2h",
"3h",
"4h",
"5h",
"none",
"1h 1s",
"1h -1s",
"3h 1s",
"3h -1s",
"59s",
"1m 1s",
],
"h:mm",
timedelta(hours=1),
),
(
"not_equal",
"invalid",
[
"1h",
"2h",
"3h",
"4h",
"5h",
"none",
"1h 1s",
"1h -1s",
"3h 1s",
"3h -1s",
"59s",
"1m 1s",
],
"d h mm ss",
None,
),
(
"not_equal",
"",
[
"1h",
"2h",
"3h",
"4h",
"5h",
"none",
"1h 1s",
"1h -1s",
"3h 1s",
"3h -1s",
"59s",
"1m 1s",
],
"d h mm ss",
None,
),
],
)
@pytest.mark.django_db
def test_duration_formula_equal_value_filter(
data_fixture,
filter_type_name,
test_value,
expected_rows,
duration_format,
expected_test_value,
):
"""
Test equal/not equal filters. Note that due to implementation,
filter value will be rounded accordingly to duration format set for the field.
:param data_fixture:
:param filter_type_name:
:param test_value:
:param expected_rows:
:param duration_format:
:return:
"""
duration_formula_filter_proc(
data_fixture,
duration_format,
filter_type_name,
test_value,
expected_rows,
expected_test_value,
)
@pytest.mark.parametrize(
"filter_type_name,test_value,expected_rows,duration_format,expected_test_value",
[
(
"higher_than",
str(3 * 1800),
["2h", "3h", "4h", "5h", "3h 1s", "3h -1s"],
"d h mm ss",
timedelta(hours=1, minutes=30),
),
(
"higher_than",
str(3 * 1800),
["3h", "4h", "5h", "3h 1s", "3h -1s"],
"d h",
timedelta(hours=2),
),
(
"higher_than",
str(3600),
["2h", "3h", "4h", "5h", "1h 1s", "3h 1s", "3h -1s"],
"d h mm",
timedelta(hours=1),
),
(
"higher_than",
str((2 * 3600) - 2),
[
"3h",
"4h",
"5h",
"3h 1s",
"3h -1s",
],
"d h mm",
timedelta(hours=2),
),
(
"higher_than",
"1:59:59",
["3h", "4h", "5h", "3h 1s", "3h -1s"],
"d h",
timedelta(hours=2),
),
(
"higher_than",
"1:59:59",
["2h", "3h", "4h", "5h", "3h 1s", "3h -1s"],
"h:mm:ss",
timedelta(hours=1, minutes=59, seconds=59),
),
(
"higher_than",
str(3600),
["2h", "3h", "4h", "5h", "3h 1s", "3h -1s", "1h 1s"],
"d h mm ss",
timedelta(hours=1),
),
(
"higher_than",
str(3600),
["2h", "3h", "4h", "5h", "1h 1s", "3h 1s", "3h -1s"],
"d h",
timedelta(hours=1),
),
(
"higher_than",
"1:01",
["2h", "3h", "4h", "5h", "1h 1s", "3h 1s", "3h -1s"],
"d h",
timedelta(hours=1),
),
(
"higher_than",
"1:01",
["2h", "3h", "4h", "5h", "3h 1s", "3h -1s"],
"h:mm",
timedelta(hours=1, minutes=1),
),
# value parsed to 1m
(
"higher_than",
"1:01",
["1h", "2h", "3h", "4h", "5h", "1h 1s", "1h -1s", "3h 1s", "3h -1s"],
"h:mm:ss",
timedelta(minutes=1, seconds=1),
),
(
"higher_than",
"1:00",
[
"1h",
"2h",
"3h",
"4h",
"5h",
"1h 1s",
"1h -1s",
"3h 1s",
"3h -1s",
"1m 1s",
],
"h:mm:ss",
timedelta(minutes=1),
),
(
"higher_than",
str(3 * 3600),
["4h", "5h", "3h 1s"],
"d h",
timedelta(hours=3),
),
("higher_than", str((3 * 3600) + 1801), ["5h"], "d h", timedelta(hours=4)),
(
"higher_than",
str((3 * 3600) + 1801),
["4h", "5h"],
"h:mm:ss",
timedelta(hours=3, minutes=30, seconds=1),
),
("higher_than", "invalid", [], "d h", None),
(
"higher_than",
"",
[
"1h",
"2h",
"3h",
"4h",
"5h",
"none",
"1h 1s",
"1h -1s",
"3h 1s",
"3h -1s",
"59s",
"1m 1s",
],
"d h",
None,
),
(
"higher_than_or_equal",
str(3 * 1800),
["2h", "3h", "4h", "5h", "3h 1s", "3h -1s"],
"d h",
timedelta(hours=2),
),
(
"higher_than_or_equal",
str(3 * 1800),
["2h", "3h", "4h", "5h", "3h 1s", "3h -1s"],
"h:mm:ss",
timedelta(hours=1, minutes=30),
),
(
"higher_than_or_equal",
str((3 * 3600) + 1),
["3h", "4h", "5h", "3h 1s"],
"d h",
timedelta(hours=3),
),
(
"higher_than_or_equal",
str((3 * 3600) + 1),
["4h", "5h", "3h 1s"],
"h:mm:ss",
timedelta(hours=3, seconds=1),
),
(
"higher_than_or_equal",
"1:59:59", # 1h59m59s
["2h", "3h", "4h", "5h", "3h 1s", "3h -1s"],
"h:mm:ss",
timedelta(hours=1, minutes=59, seconds=59),
),
(
"higher_than_or_equal",
"1:59:59", # 1h59m59s
["2h", "3h", "4h", "5h", "3h 1s", "3h -1s"],
"d h",
timedelta(hours=2),
),
(
"higher_than_or_equal",
"1:59", # parsed as 1m59s
["1h", "2h", "3h", "4h", "5h", "1h 1s", "1h -1s", "3h 1s", "3h -1s"],
"h:mm:ss",
timedelta(minutes=1, seconds=59),
),
(
"higher_than_or_equal",
"1:59", # parsed as 1h59m, rounded to 2h
["2h", "3h", "4h", "5h", "3h 1s", "3h -1s"],
"d h",
timedelta(hours=2),
),
("higher_than_or_equal", "invalid", [], "d h mm ss", None),
(
"higher_than_or_equal",
"",
[
"1h",
"2h",
"3h",
"4h",
"5h",
"none",
"1h 1s",
"1h -1s",
"3h 1s",
"3h -1s",
"59s",
"1m 1s",
],
"d h mm ss",
None,
),
],
)
@pytest.mark.django_db
def test_duration_formula_higher_than_equal_value_filter(
data_fixture,
filter_type_name,
test_value,
expected_rows,
duration_format,
expected_test_value,
):
duration_formula_filter_proc(
data_fixture,
duration_format,
filter_type_name,
test_value,
expected_rows,
expected_test_value,
)
@pytest.mark.parametrize(
"filter_type_name,test_value,expected_rows,duration_format,expected_test_value",
[
# duration rounding will bump filter value from 1.5h to 2h for `d h` format
(
"lower_than",
str(3 * 1800),
["1h", "1h -1s", "1h 1s", "59s", "1m 1s"],
"d h",
timedelta(hours=2),
),
# filter value is rounded to 1h
(
"lower_than",
str(3599),
["1h -1s", "59s", "1m 1s"],
"d h",
timedelta(hours=1),
),
(
"lower_than",
str(3 * 3600),
["1h", "2h", "1h 1s", "1h -1s", "3h -1s", "59s", "1m 1s"],
"d h mm ss",
timedelta(hours=3),
),
("lower_than", "invalid", [], "d h mm ss", None),
(
"lower_than",
"",
[
"1h",
"2h",
"3h",
"4h",
"5h",
"none",
"1h 1s",
"1h -1s",
"3h 1s",
"3h -1s",
"59s",
"1m 1s",
],
"d h mm ss",
None,
),
(
"lower_than_or_equal",
"1:01", # parsed as 1m1!
["59s", "1m 1s"],
"h:mm:ss",
timedelta(seconds=61),
),
(
"lower_than_or_equal",
"1:01", # parsed as 1h1m, but truncated to 1h
["1h", "1h -1s", "59s", "1m 1s"],
"d h",
timedelta(hours=1),
),
(
"lower_than_or_equal",
str(3 * 3600),
["1h", "2h", "3h", "1h 1s", "1h -1s", "3h -1s", "59s", "1m 1s"],
"d h",
timedelta(hours=3),
),
(
"lower_than_or_equal",
str((3 * 3600) - 1801),
["1h", "2h", "1h -1s", "1h 1s", "59s", "1m 1s"],
"d h",
timedelta(hours=2),
),
(
"lower_than_or_equal",
"1:01", # parsed as 1m1s
["1m 1s", "59s"],
"h:mm:ss",
timedelta(seconds=61),
),
(
"lower_than_or_equal",
"0:01", # parsed as 1m!
["59s"],
"h:mm",
timedelta(minutes=1),
),
(
"lower_than_or_equal",
"0:59", # parsed 59m, rounded to 1h
["1h", "1h -1s", "59s", "1m 1s"],
"d h",
timedelta(hours=1),
),
("lower_than_or_equal", "invalid", [], "d h mm ss", None),
(
"lower_than_or_equal",
"",
[
"1h",
"2h",
"3h",
"4h",
"5h",
"none",
"1h 1s",
"1h -1s",
"3h 1s",
"3h -1s",
"59s",
"1m 1s",
],
"d h mm ss",
None,
),
],
)
@pytest.mark.django_db
def test_duration_formula_lower_than_equal_value_filter(
data_fixture,
filter_type_name,
test_value,
expected_rows,
duration_format,
expected_test_value,
):
duration_formula_filter_proc(
data_fixture,
duration_format,
filter_type_name,
test_value,
expected_rows,
expected_test_value,
)
@pytest.mark.parametrize(
"filter_type_name,test_value,expected_rows,duration_format",
[
("empty", "", ["none"], "d h"),
(
"not_empty",
"",
[
"1h",
"2h",
"3h",
"4h",
"5h",
"1h 1s",
"1h -1s",
"3h 1s",
"3h -1s",
"59s",
"1m 1s",
],
"d h",
),
],
)
@pytest.mark.django_db
def test_duration_formula_empty_value_filter(
data_fixture, filter_type_name, test_value, expected_rows, duration_format
):
duration_formula_filter_proc(
data_fixture, duration_format, filter_type_name, test_value, expected_rows
)

View file

@ -1,7 +1,24 @@
import asyncio
import dataclasses
from dataclasses import dataclass
from typing import Callable, Iterable
from django.contrib.auth.models import AbstractUser
from channels.testing import WebsocketCommunicator
from baserow.contrib.database.fields.models import (
Field,
FormulaField,
LinkRowField,
LookupField,
)
from baserow.contrib.database.rows.handler import RowHandler
from baserow.contrib.database.table.models import GeneratedTableModel, Table
from baserow.contrib.database.views.handler import ViewHandler
from baserow.contrib.database.views.models import GridView
from baserow.test_utils.fixtures import Fixtures
async def received_message(communicator: WebsocketCommunicator, message_type: str):
"""
@ -32,3 +49,191 @@ async def get_message(communicator: WebsocketCommunicator, message_type: str):
return message
except asyncio.exceptions.TimeoutError: # No more messages
return None
@dataclass
class LookupFieldSetup:
user: AbstractUser
table: Table
other_table: Table
model: GeneratedTableModel
other_table_model: GeneratedTableModel
grid_view: GridView
link_row_field: LinkRowField
lookup_field: LookupField
target_field: Field
row_handler: RowHandler
view_handler: ViewHandler
@dataclasses.dataclass
class FormulaFieldSetup:
user: AbstractUser
table: Table
formula_field: FormulaField
model: GeneratedTableModel
grid_view: GridView
data_source_field: Field
row_handler: RowHandler
view_handler: ViewHandler
formula: str
formula_type: str
extra_fields: dict[str, Field]
def boolean_field_factory(data_fixture, table, user):
return data_fixture.create_boolean_field(name="target", user=user, table=table)
def text_field_factory(data_fixture, table, user, name: str | None = None):
return data_fixture.create_text_field(name=name or "target", user=user, table=table)
def long_text_field_factory(data_fixture, table, user):
return data_fixture.create_long_text_field(name="target", user=user, table=table)
def url_field_factory(data_fixture, table, user):
return data_fixture.create_url_field(name="target", user=user, table=table)
def email_field_factory(data_fixture, table, user):
return data_fixture.create_email_field(name="target", user=user, table=table)
def phone_number_field_factory(data_fixture, table, user):
return data_fixture.create_phone_number_field(name="target", user=user, table=table)
def uuid_field_factory(data_fixture, table, user):
return data_fixture.create_uuid_field(name="target", user=user, table=table)
def single_select_field_factory(data_fixture, table, user):
return data_fixture.create_single_select_field(
name="target", user=user, table=table
)
def single_select_field_value_factory(data_fixture, target_field, value=None):
return (
data_fixture.create_select_option(field=target_field, value=value)
if value
else None
)
def duration_field_factory(
data_fixture, table, user, duration_format: str = "d h mm", name: str | None = None
):
return data_fixture.create_duration_field(
name=name or "target", user=user, table=table, duration_format=duration_format
)
def number_field_factory(data_fixture: Fixtures, table, user, **kwargs):
return data_fixture.create_number_field(
name="target", table=table, user=user, **kwargs
)
def text_field_value_factory(data_fixture, target_field, value=None):
return value or ""
def setup_linked_table_and_lookup(
data_fixture, target_field_factory
) -> LookupFieldSetup:
user = data_fixture.create_user()
database = data_fixture.create_database_application(user=user)
table = data_fixture.create_database_table(user=user, database=database)
other_table = data_fixture.create_database_table(user=user, database=database)
target_field = target_field_factory(data_fixture, other_table, user)
link_row_field = data_fixture.create_link_row_field(
name="link", table=table, link_row_table=other_table
)
lookup_field = data_fixture.create_lookup_field(
table=table,
through_field=link_row_field,
target_field=target_field,
through_field_name=link_row_field.name,
target_field_name=target_field.name,
setup_dependencies=False,
)
grid_view = data_fixture.create_grid_view(table=table)
view_handler = ViewHandler()
row_handler = RowHandler()
model = table.get_model()
other_table_model = other_table.get_model()
return LookupFieldSetup(
user=user,
table=table,
other_table=other_table,
other_table_model=other_table_model,
target_field=target_field,
row_handler=row_handler,
grid_view=grid_view,
link_row_field=link_row_field,
lookup_field=lookup_field,
view_handler=view_handler,
model=model,
)
def setup_formula_field(
data_fixture,
formula_text: str,
formula_type: str,
data_field_factory,
extra_fields: Iterable[Callable],
formula_extra_kwargs: dict | None = None,
) -> FormulaFieldSetup:
"""
Create a table with duration formula field.
:param data_fixture:
:param formula_text:
:param formula_type:
:param data_field_factory:
:param extra_fields: iterable with field factory functions.
:param formula_extra_kwargs: optional dict with additional keyword args for
formula field creation
:return:
"""
user = data_fixture.create_user()
database = data_fixture.create_database_application(user=user)
table = data_fixture.create_database_table(user=user, database=database)
data_source_field = data_field_factory(data_fixture, table, user)
formula_field = data_fixture.create_formula_field(
table=table,
user=user,
formula=formula_text,
formula_type=formula_type,
**{k: v for k, v in (formula_extra_kwargs or {}).items()},
)
extra_fields_map = {}
for field_factory in extra_fields:
extra_field = field_factory(data_fixture, table=table, user=user)
extra_fields_map[extra_field.name] = extra_field
grid_view = data_fixture.create_grid_view(table=table)
view_handler = ViewHandler()
row_handler = RowHandler()
model = table.get_model()
return FormulaFieldSetup(
user=user,
table=table,
data_source_field=data_source_field,
formula_field=formula_field,
row_handler=row_handler,
grid_view=grid_view,
view_handler=view_handler,
model=model,
formula=formula_text,
formula_type=formula_type,
extra_fields=extra_fields_map,
)

View file

@ -1,36 +1,26 @@
import typing
from dataclasses import dataclass
from enum import Enum
from django.contrib.auth.models import AbstractUser
import pytest
from baserow.contrib.database.fields.models import Field, LinkRowField, LookupField
from baserow.contrib.database.rows.handler import RowHandler
from baserow.contrib.database.table.models import GeneratedTableModel, Table
from baserow.contrib.database.views.handler import ViewHandler
from baserow.contrib.database.views.models import GridView
from tests.baserow.contrib.database.utils import (
boolean_field_factory,
email_field_factory,
long_text_field_factory,
phone_number_field_factory,
setup_linked_table_and_lookup,
single_select_field_factory,
single_select_field_value_factory,
text_field_factory,
text_field_value_factory,
url_field_factory,
uuid_field_factory,
)
if typing.TYPE_CHECKING:
from baserow.test_utils.fixtures import Fixtures
@dataclass
class ArrayFiltersSetup:
user: AbstractUser
table: Table
other_table: Table
model: GeneratedTableModel
other_table_model: GeneratedTableModel
grid_view: GridView
link_row_field: LinkRowField
lookup_field: LookupField
target_field: Field
row_handler: RowHandler
view_handler: ViewHandler
class BooleanLookupRow(int, Enum):
"""
Helper enum for boolean lookup field filters tests.
@ -46,85 +36,6 @@ class BooleanLookupRow(int, Enum):
NO_VALUES = 3
def boolean_field_factory(data_fixture, table, user):
return data_fixture.create_boolean_field(name="target", user=user, table=table)
def text_field_factory(data_fixture, table, user):
return data_fixture.create_text_field(name="target", user=user, table=table)
def long_text_field_factory(data_fixture, table, user):
return data_fixture.create_long_text_field(name="target", user=user, table=table)
def url_field_factory(data_fixture, table, user):
return data_fixture.create_url_field(name="target", user=user, table=table)
def email_field_factory(data_fixture, table, user):
return data_fixture.create_email_field(name="target", user=user, table=table)
def phone_number_field_factory(data_fixture, table, user):
return data_fixture.create_phone_number_field(name="target", user=user, table=table)
def uuid_field_factory(data_fixture, table, user):
return data_fixture.create_uuid_field(name="target", user=user, table=table)
def single_select_field_factory(data_fixture, table, user):
return data_fixture.create_single_select_field(
name="target", user=user, table=table
)
def single_select_field_value_factory(data_fixture, target_field, value=None):
return (
data_fixture.create_select_option(field=target_field, value=value)
if value
else None
)
def setup(data_fixture, target_field_factory) -> ArrayFiltersSetup:
user = data_fixture.create_user()
database = data_fixture.create_database_application(user=user)
table = data_fixture.create_database_table(user=user, database=database)
other_table = data_fixture.create_database_table(user=user, database=database)
target_field = target_field_factory(data_fixture, other_table, user)
link_row_field = data_fixture.create_link_row_field(
name="link", table=table, link_row_table=other_table
)
lookup_field = data_fixture.create_lookup_field(
table=table,
through_field=link_row_field,
target_field=target_field,
through_field_name=link_row_field.name,
target_field_name=target_field.name,
setup_dependencies=False,
)
grid_view = data_fixture.create_grid_view(table=table)
view_handler = ViewHandler()
row_handler = RowHandler()
model = table.get_model()
other_table_model = other_table.get_model()
return ArrayFiltersSetup(
user=user,
table=table,
other_table=other_table,
other_table_model=other_table_model,
target_field=target_field,
row_handler=row_handler,
grid_view=grid_view,
link_row_field=link_row_field,
lookup_field=lookup_field,
view_handler=view_handler,
model=model,
)
def boolean_lookup_filter_proc(
data_fixture: "Fixtures",
filter_type_name: str,
@ -137,7 +48,7 @@ def boolean_lookup_filter_proc(
rows.
"""
test_setup = setup(data_fixture, boolean_field_factory)
test_setup = setup_linked_table_and_lookup(data_fixture, boolean_field_factory)
dict_rows = [{test_setup.target_field.db_column: idx % 2} for idx in range(0, 10)]
@ -193,10 +104,6 @@ def boolean_lookup_filter_proc(
assert set([r.id for r in q]) == set([r.id for r in selected])
def text_field_value_factory(data_fixture, target_field, value=None):
return value or ""
@pytest.mark.parametrize(
"target_field_factory,target_field_value_factory",
[
@ -212,7 +119,7 @@ def text_field_value_factory(data_fixture, target_field, value=None):
def test_has_empty_value_filter_text_field_types(
data_fixture, target_field_factory, target_field_value_factory
):
test_setup = setup(data_fixture, target_field_factory)
test_setup = setup_linked_table_and_lookup(data_fixture, target_field_factory)
row_A_value = target_field_value_factory(data_fixture, test_setup.target_field, "A")
row_B_value = target_field_value_factory(data_fixture, test_setup.target_field, "B")
@ -266,7 +173,7 @@ def test_has_empty_value_filter_text_field_types(
@pytest.mark.django_db
def test_has_empty_value_filter_uuid_field_types(data_fixture):
test_setup = setup(data_fixture, uuid_field_factory)
test_setup = setup_linked_table_and_lookup(data_fixture, uuid_field_factory)
other_row_1 = test_setup.other_table_model.objects.create()
other_row_2 = test_setup.other_table_model.objects.create()
@ -322,7 +229,7 @@ def test_has_empty_value_filter_uuid_field_types(data_fixture):
def test_has_not_empty_value_filter_text_field_types(
data_fixture, target_field_factory, target_field_value_factory
):
test_setup = setup(data_fixture, target_field_factory)
test_setup = setup_linked_table_and_lookup(data_fixture, target_field_factory)
row_A_value = target_field_value_factory(data_fixture, test_setup.target_field, "A")
row_B_value = target_field_value_factory(data_fixture, test_setup.target_field, "B")
@ -377,7 +284,7 @@ def test_has_not_empty_value_filter_text_field_types(
@pytest.mark.django_db
def test_has_not_empty_value_filter_uuid_field_types(data_fixture):
test_setup = setup(data_fixture, uuid_field_factory)
test_setup = setup_linked_table_and_lookup(data_fixture, uuid_field_factory)
other_row_1 = test_setup.other_table_model.objects.create()
other_row_2 = test_setup.other_table_model.objects.create()
@ -430,7 +337,7 @@ def test_has_not_empty_value_filter_uuid_field_types(data_fixture):
)
@pytest.mark.django_db
def test_has_value_equal_filter_text_field_types(data_fixture, target_field_factory):
test_setup = setup(data_fixture, target_field_factory)
test_setup = setup_linked_table_and_lookup(data_fixture, target_field_factory)
other_row_A = test_setup.other_table_model.objects.create(
**{f"field_{test_setup.target_field.id}": "A"}
@ -514,7 +421,7 @@ def test_has_value_equal_filter_text_field_types(data_fixture, target_field_fact
@pytest.mark.django_db
def test_has_value_equal_filter_uuid_field_types(data_fixture):
test_setup = setup(data_fixture, uuid_field_factory)
test_setup = setup_linked_table_and_lookup(data_fixture, uuid_field_factory)
other_row_A = test_setup.other_table_model.objects.create(
**{
@ -615,7 +522,7 @@ def test_has_value_equal_filter_uuid_field_types(data_fixture):
def test_has_not_value_equal_filter_text_field_types(
data_fixture, target_field_factory
):
test_setup = setup(data_fixture, target_field_factory)
test_setup = setup_linked_table_and_lookup(data_fixture, target_field_factory)
other_row_A = test_setup.other_table_model.objects.create(
**{f"field_{test_setup.target_field.id}": "A"}
@ -693,7 +600,7 @@ def test_has_not_value_equal_filter_text_field_types(
@pytest.mark.django_db
def test_has_not_value_equal_filter_uuid_field_types(data_fixture):
test_setup = setup(data_fixture, uuid_field_factory)
test_setup = setup_linked_table_and_lookup(data_fixture, uuid_field_factory)
other_row_A = test_setup.other_table_model.objects.create(
**{
@ -792,7 +699,7 @@ def test_has_not_value_equal_filter_uuid_field_types(data_fixture):
)
@pytest.mark.django_db
def test_has_value_contains_filter_text_field_types(data_fixture, target_field_factory):
test_setup = setup(data_fixture, target_field_factory)
test_setup = setup_linked_table_and_lookup(data_fixture, target_field_factory)
other_row_John_Smith = test_setup.other_table_model.objects.create(
**{f"field_{test_setup.target_field.id}": "John Smith"}
@ -865,7 +772,7 @@ def test_has_value_contains_filter_text_field_types(data_fixture, target_field_f
@pytest.mark.django_db
def test_has_value_contains_filter_uuid_field_types(data_fixture):
test_setup = setup(data_fixture, uuid_field_factory)
test_setup = setup_linked_table_and_lookup(data_fixture, uuid_field_factory)
other_row_A = test_setup.other_table_model.objects.create(
**{
@ -978,7 +885,7 @@ def test_has_value_contains_filter_uuid_field_types(data_fixture):
def test_has_not_value_contains_filter_text_field_types(
data_fixture, target_field_factory
):
test_setup = setup(data_fixture, target_field_factory)
test_setup = setup_linked_table_and_lookup(data_fixture, target_field_factory)
other_row_John_Smith = test_setup.other_table_model.objects.create(
**{f"field_{test_setup.target_field.id}": "John Smith"}
@ -1051,7 +958,7 @@ def test_has_not_value_contains_filter_text_field_types(
@pytest.mark.django_db
def test_has_not_value_contains_filter_uuid_field_types(data_fixture):
test_setup = setup(data_fixture, uuid_field_factory)
test_setup = setup_linked_table_and_lookup(data_fixture, uuid_field_factory)
other_row_A = test_setup.other_table_model.objects.create(
**{
@ -1163,7 +1070,7 @@ def test_has_not_value_contains_filter_uuid_field_types(data_fixture):
def test_has_value_contains_word_filter_text_field_types(
data_fixture, target_field_factory
):
test_setup = setup(data_fixture, target_field_factory)
test_setup = setup_linked_table_and_lookup(data_fixture, target_field_factory)
other_row_1 = test_setup.other_table_model.objects.create(
**{f"field_{test_setup.target_field.id}": "This is a sentence."}
@ -1238,7 +1145,7 @@ def test_has_value_contains_word_filter_text_field_types(
@pytest.mark.django_db
def test_has_value_contains_word_filter_uuid_field_types(data_fixture):
test_setup = setup(data_fixture, uuid_field_factory)
test_setup = setup_linked_table_and_lookup(data_fixture, uuid_field_factory)
other_row_A = test_setup.other_table_model.objects.create(
**{
@ -1349,7 +1256,7 @@ def test_has_value_contains_word_filter_uuid_field_types(data_fixture):
def test_has_not_value_contains_word_filter_text_field_types(
data_fixture, target_field_factory
):
test_setup = setup(data_fixture, target_field_factory)
test_setup = setup_linked_table_and_lookup(data_fixture, target_field_factory)
other_row_1 = test_setup.other_table_model.objects.create(
**{f"field_{test_setup.target_field.id}": "This is a sentence."}
@ -1424,7 +1331,7 @@ def test_has_not_value_contains_word_filter_text_field_types(
@pytest.mark.django_db
def test_has_not_value_contains_word_filter_uuid_field_types(data_fixture):
test_setup = setup(data_fixture, uuid_field_factory)
test_setup = setup_linked_table_and_lookup(data_fixture, uuid_field_factory)
other_row_A = test_setup.other_table_model.objects.create(
**{
@ -1535,7 +1442,7 @@ def test_has_not_value_contains_word_filter_uuid_field_types(data_fixture):
def test_has_value_length_is_lower_than_text_field_types(
data_fixture, target_field_factory
):
test_setup = setup(data_fixture, target_field_factory)
test_setup = setup_linked_table_and_lookup(data_fixture, target_field_factory)
other_row_10a = test_setup.other_table_model.objects.create(
**{f"field_{test_setup.target_field.id}": "aaaaaaaaaa"}
@ -1622,7 +1529,7 @@ def test_has_value_length_is_lower_than_text_field_types(
@pytest.mark.django_db
def test_has_value_length_is_lower_than_uuid_field_types(data_fixture):
test_setup = setup(data_fixture, uuid_field_factory)
test_setup = setup_linked_table_and_lookup(data_fixture, uuid_field_factory)
other_row_1 = test_setup.other_table_model.objects.create()
other_row_2 = test_setup.other_table_model.objects.create()
row_1 = test_setup.row_handler.create_row(
@ -1878,7 +1785,9 @@ def test_empty_not_empty_filters_boolean_lookup_field_type(
@pytest.mark.django_db
def test_has_value_equal_filter_single_select_field(data_fixture):
test_setup = setup(data_fixture, single_select_field_factory)
test_setup = setup_linked_table_and_lookup(
data_fixture, single_select_field_factory
)
opt_a = data_fixture.create_select_option(field=test_setup.target_field, value="a")
opt_b = data_fixture.create_select_option(field=test_setup.target_field, value="b")
@ -1946,7 +1855,9 @@ def test_has_value_equal_filter_single_select_field(data_fixture):
@pytest.mark.django_db
def test_has_not_value_equal_filter_single_select_field(data_fixture):
test_setup = setup(data_fixture, single_select_field_factory)
test_setup = setup_linked_table_and_lookup(
data_fixture, single_select_field_factory
)
opt_a = data_fixture.create_select_option(field=test_setup.target_field, value="a")
opt_b = data_fixture.create_select_option(field=test_setup.target_field, value="b")
@ -2013,7 +1924,9 @@ def test_has_not_value_equal_filter_single_select_field(data_fixture):
@pytest.mark.django_db
def test_has_value_contains_filter_single_select_field(data_fixture):
test_setup = setup(data_fixture, single_select_field_factory)
test_setup = setup_linked_table_and_lookup(
data_fixture, single_select_field_factory
)
opt_a = data_fixture.create_select_option(field=test_setup.target_field, value="a")
opt_b = data_fixture.create_select_option(field=test_setup.target_field, value="ba")
@ -2081,7 +1994,9 @@ def test_has_value_contains_filter_single_select_field(data_fixture):
@pytest.mark.django_db
def test_has_not_value_contains_filter_single_select_field(data_fixture):
test_setup = setup(data_fixture, single_select_field_factory)
test_setup = setup_linked_table_and_lookup(
data_fixture, single_select_field_factory
)
opt_a = data_fixture.create_select_option(field=test_setup.target_field, value="a")
opt_b = data_fixture.create_select_option(field=test_setup.target_field, value="ba")
@ -2147,7 +2062,9 @@ def test_has_not_value_contains_filter_single_select_field(data_fixture):
@pytest.mark.django_db
def test_has_value_contains_word_filter_single_select_field(data_fixture):
test_setup = setup(data_fixture, single_select_field_factory)
test_setup = setup_linked_table_and_lookup(
data_fixture, single_select_field_factory
)
opt_a = data_fixture.create_select_option(field=test_setup.target_field, value="a")
opt_b = data_fixture.create_select_option(field=test_setup.target_field, value="b")
@ -2214,7 +2131,9 @@ def test_has_value_contains_word_filter_single_select_field(data_fixture):
@pytest.mark.django_db
def test_has_not_value_contains_word_filter_single_select_field(data_fixture):
test_setup = setup(data_fixture, single_select_field_factory)
test_setup = setup_linked_table_and_lookup(
data_fixture, single_select_field_factory
)
opt_a = data_fixture.create_select_option(field=test_setup.target_field, value="a")
opt_b = data_fixture.create_select_option(field=test_setup.target_field, value="b")
@ -2281,7 +2200,9 @@ def test_has_not_value_contains_word_filter_single_select_field(data_fixture):
@pytest.mark.django_db
def test_has_any_select_option_equal_filter_single_select_field(data_fixture):
test_setup = setup(data_fixture, single_select_field_factory)
test_setup = setup_linked_table_and_lookup(
data_fixture, single_select_field_factory
)
opt_a = data_fixture.create_select_option(field=test_setup.target_field, value="a")
opt_b = data_fixture.create_select_option(field=test_setup.target_field, value="b")
@ -2350,7 +2271,9 @@ def test_has_any_select_option_equal_filter_single_select_field(data_fixture):
@pytest.mark.django_db
def test_has_none_select_option_equal_filter_single_select_field(data_fixture):
test_setup = setup(data_fixture, single_select_field_factory)
test_setup = setup_linked_table_and_lookup(
data_fixture, single_select_field_factory
)
opt_a = data_fixture.create_select_option(field=test_setup.target_field, value="a")
opt_b = data_fixture.create_select_option(field=test_setup.target_field, value="b")

View file

@ -0,0 +1,7 @@
{
"type": "feature",
"message": "Duration formula field filters",
"issue_number": 3110,
"bullet_points": [],
"created_at": "2024-11-19"
}

View file

@ -20,6 +20,13 @@ import durationField from '@baserow/modules/database/mixins/durationField'
export default {
name: 'ViewFilterTypeDuration',
mixins: [filterTypeInput, durationField],
watch: {
'field.duration_format': {
handler() {
this.updateFormattedValue(this.field, this.filter.value)
},
},
},
created() {
this.updateCopy(this.field, this.filter.value)
this.updateFormattedValue(this.field, this.filter.value)

View file

@ -165,6 +165,8 @@ import FormService from '@baserow/modules/database/services/view/form'
import { UploadFileUserFileUploadType } from '@baserow/modules/core/userFileUploadTypes'
import _ from 'lodash'
import { trueValues } from '@baserow/modules/core/utils/constants'
import ViewFilterTypeNumber from '@baserow/modules/database/components/view/ViewFilterTypeNumber.vue'
import ViewFilterTypeDuration from '@baserow/modules/database/components/view/ViewFilterTypeDuration.vue'
export class FieldType extends Registerable {
/**
@ -1308,6 +1310,10 @@ export class NumberFieldType extends FieldType {
return RowHistoryFieldNumber
}
getFilterInputComponent(field, filterType) {
return ViewFilterTypeNumber
}
getSortIndicator() {
return ['text', '1', '9']
}
@ -2475,6 +2481,10 @@ export class DurationFieldType extends FieldType {
return FunctionalGridViewFieldDuration
}
getFilterInputComponent(field, filterType) {
return ViewFilterTypeDuration
}
getCanImport() {
return true
}

View file

@ -65,6 +65,7 @@ import {
genericHasValueContainsFilter,
} from '@baserow/modules/database/utils/fieldFilters'
import ViewFilterTypeSelectOptions from '@baserow/modules/database/components/view/ViewFilterTypeSelectOptions.vue'
import ViewFilterTypeDuration from '@baserow/modules/database/components/view/ViewFilterTypeDuration.vue'
export class BaserowFormulaTypeDefinition extends Registerable {
getIconClass() {
@ -457,6 +458,10 @@ export class BaserowFormulaDurationType extends BaserowFormulaTypeDefinition {
return RowEditFieldDurationReadOnly
}
getFilterInputComponent(field, filterType) {
return ViewFilterTypeDuration
}
getSortOrder() {
return 5
}

View file

@ -302,7 +302,7 @@ export const parseDurationValue = (
// If the value is a number, we assume it's already in seconds (i.e. from the backend).
if (Number.isFinite(inputValue)) {
return inputValue > 0 ? inputValue : null
return inputValue
}
let multiplier = 1

View file

@ -25,8 +25,6 @@ import ViewFilterTypeCollaborators from '@baserow/modules/database/components/vi
import {
FormulaFieldType,
NumberFieldType,
RatingFieldType,
DurationFieldType,
} from '@baserow/modules/database/fieldTypes'
export class ViewFilterType extends Registerable {
@ -131,16 +129,36 @@ export class ViewFilterType extends Registerable {
* list provided by getCompatibleFieldTypes to calculate this.
*/
fieldIsCompatible(field) {
for (const typeOrFunc of this.getCompatibleFieldTypes()) {
const valuesMap = this.getCompatibleFieldTypes().map((type) => [type, true])
return this.getCompatibleFieldValue(field, valuesMap, false)
}
/**
* Given a field and a map of field types to values, this method will return the
* value that is compatible with the field. If no value is found the notFoundValue
* will be returned.
* This can be used to verify if a field is compatible with a filter type or to
* return the correct component for the filter input.
*
* @param {object} field The field object that should be checked.
* @param {object} valuesMap A list of tuple where the key is the field type or a function
* that takes a field and returns a boolean and the value is the value that should be
* returned if the field is compatible.
* @param {any} notFoundValue The value that should be returned if no compatible value
* is found.
* @returns {any} The value that is compatible with the field or the notFoundValue.
*/
getCompatibleFieldValue(field, valuesMap, notFoundValue = null) {
for (const [typeOrFunc, value] of valuesMap) {
if (typeOrFunc instanceof Function) {
if (typeOrFunc(field)) {
return true
return value
}
} else if (field.type === typeOrFunc) {
return true
return value
}
}
return false
return notFoundValue
}
/**
@ -166,7 +184,179 @@ export class ViewFilterType extends Registerable {
}
}
export class EqualViewFilterType extends ViewFilterType {
/**
* Base class for field-type specific filtering details.
*
* In some cases we want to have per field-type handling of certain aspects of
* a filter: input component selection and value parsing logic.
*
* This is a base class defining common interface for such customizations
*/
class SpecificFieldViewFilterHandler {
getInputComponent() {
return null
}
parseRowValue(value, field, fieldType) {
return value
}
parseFilterValue(value, field, fieldType) {
return value
}
}
/**
* Handle duration-specific filtering aspects:
*
* * input component should understand duration formats
* * values should be parsed to duration value (a number of seconds).
*
*
* Parsing is especially important because duration parsing result depends on duration
* format picked. Filter value is passed as a string, and in case of duration, backend
* will send a number of seconds. This, however, may be parsed as a number of minutes
* or hours if a duration format picked uses minutes or hours as a lowest unit (i.e.
* `d h m` or `d h` format).
*
* In case of parsing, this class ensures that a number string is passed as a Number
* type to be consistent with backend's behavior.
*
*/
class DurationFieldViewFilterHandler extends SpecificFieldViewFilterHandler {
getInputComponent() {
return ViewFilterTypeDuration
}
_parseDuration(value, field, fieldType) {
if (String(value === null ? '' : value).trim() === '') {
return null
}
const parsedValue = Number(value)
if (_.isFinite(parsedValue)) {
value = parsedValue
}
return fieldType.parseInputValue(field, value)
}
parseRowValue(value, field, fieldType) {
// already processed, can be returned as-is.
if (_.isInteger(value)) {
return value
}
return fieldType.parseInputValue(field, value)
}
parseFilterValue(value, field, fieldType) {
return this._parseDuration(value, field, fieldType)
}
}
class TextLikeFieldViewFilterHandler extends SpecificFieldViewFilterHandler {
getInputComponent() {
return ViewFilterTypeText
}
parseRowValue(value, field, fieldType) {
return (value === null ? '' : value).toString().toLowerCase().trim()
}
parseFilterValue(value, field, fieldType) {
return (value === null ? '' : value).toString().toLowerCase().trim()
}
}
class RatingFieldViewFilterHandler extends SpecificFieldViewFilterHandler {
getInputComponent() {
return ViewFilterTypeRating
}
parseRowValue(value, field, fieldType) {
if (value === '' || value === null) {
return NaN
}
return Number(value.toString().toLowerCase().trim())
}
parseFilterValue(value, field, fieldType) {
if (value === '' || value === null) {
return NaN
}
return Number(value.toString().toLowerCase().trim())
}
}
class NumberFieldViewFilterHandler extends SpecificFieldViewFilterHandler {
getInputComponent() {
return ViewFilterTypeNumber
}
_parseNumberValue(value) {
if (value === '' || value === null) {
return NaN
}
return Number(value.toString().toLowerCase().trim())
}
parseRowValue(value, field, fieldType) {
return this._parseNumberValue(value)
}
parseFilterValue(value, field, fieldType) {
return this._parseNumberValue(value)
}
}
class SpecificFieldFilterType extends ViewFilterType {
getFieldsMapping() {
const map = [
['duration', new DurationFieldViewFilterHandler()],
[
FormulaFieldType.compatibleWithFormulaTypes('duration'),
new DurationFieldViewFilterHandler(),
],
['rating', new RatingFieldViewFilterHandler()],
['number', new NumberFieldViewFilterHandler()],
[
FormulaFieldType.compatibleWithFormulaTypes('number'),
new NumberFieldViewFilterHandler(),
],
['autonumber', new NumberFieldViewFilterHandler()],
]
return map
}
getSpecificFieldFilterType(field) {
const map = this.getFieldsMapping()
return this.getCompatibleFieldValue(
field,
map,
new TextLikeFieldViewFilterHandler()
)
}
getMatchesParsedValues(rowValue, filterValue, field, fieldType) {
const specificFieldType = this.getSpecificFieldFilterType(field)
const parsedRowValue = specificFieldType.parseRowValue(
rowValue,
field,
fieldType
)
const parsedFilterValue = specificFieldType.parseFilterValue(
filterValue,
field,
fieldType
)
return { rowVal: parsedRowValue, filterVal: parsedFilterValue }
}
getInputComponent(field) {
return this.getSpecificFieldFilterType(field).getInputComponent()
}
}
export class EqualViewFilterType extends SpecificFieldFilterType {
static getType() {
return 'equal'
}
@ -176,15 +366,6 @@ export class EqualViewFilterType extends ViewFilterType {
return i18n.t('viewFilter.is')
}
getInputComponent(field) {
const inputComponent = {
[RatingFieldType.getType()]: ViewFilterTypeRating,
[NumberFieldType.getType()]: ViewFilterTypeNumber,
[DurationFieldType.getType()]: ViewFilterTypeDuration,
}
return inputComponent[field?.type] || ViewFilterTypeText
}
getCompatibleFieldTypes() {
return [
'text',
@ -201,6 +382,7 @@ export class EqualViewFilterType extends ViewFilterType {
'text',
'char',
'number',
'duration',
'url'
),
]
@ -210,14 +392,18 @@ export class EqualViewFilterType extends ViewFilterType {
if (rowValue === null) {
rowValue = ''
}
const { rowVal, filterVal } = this.getMatchesParsedValues(
rowValue,
filterValue,
field,
fieldType
)
rowValue = rowValue.toString().toLowerCase().trim()
filterValue = filterValue.toString().toLowerCase().trim()
return filterValue === '' || rowValue === filterValue
return filterVal === '' || rowVal === filterVal
}
}
export class NotEqualViewFilterType extends ViewFilterType {
export class NotEqualViewFilterType extends SpecificFieldFilterType {
static getType() {
return 'not_equal'
}
@ -227,15 +413,6 @@ export class NotEqualViewFilterType extends ViewFilterType {
return i18n.t('viewFilter.isNot')
}
getInputComponent(field) {
const inputComponent = {
[RatingFieldType.getType()]: ViewFilterTypeRating,
[NumberFieldType.getType()]: ViewFilterTypeNumber,
[DurationFieldType.getType()]: ViewFilterTypeDuration,
}
return inputComponent[field?.type] || ViewFilterTypeText
}
getCompatibleFieldTypes() {
return [
'text',
@ -252,6 +429,7 @@ export class NotEqualViewFilterType extends ViewFilterType {
'text',
'char',
'number',
'duration',
'url'
),
]
@ -262,9 +440,13 @@ export class NotEqualViewFilterType extends ViewFilterType {
rowValue = ''
}
rowValue = rowValue.toString().toLowerCase().trim()
filterValue = filterValue.toString().toLowerCase().trim()
return filterValue === '' || rowValue !== filterValue
const { rowVal, filterVal } = this.getMatchesParsedValues(
rowValue,
filterValue,
field,
fieldType
)
return filterVal === '' || rowVal !== filterVal
}
}
@ -2032,26 +2214,18 @@ export class DateEqualsDayOfMonthViewFilterType extends LocalizedDateViewFilterT
// Base filter type for basic numeric comparisons. It defines common logic for
// 'lower than', 'lower than or equal', 'higher than' and 'higher than or equal'
// view filter types.
export class NumericComparisonViewFilterType extends ViewFilterType {
export class NumericComparisonViewFilterType extends SpecificFieldFilterType {
getExample() {
return '100'
}
getInputComponent(field) {
const inputComponent = {
[RatingFieldType.getType()]: ViewFilterTypeRating,
[DurationFieldType.getType()]: ViewFilterTypeDuration,
}
return inputComponent[field?.type] || ViewFilterTypeNumber
}
getCompatibleFieldTypes() {
return [
'number',
'rating',
'autonumber',
'duration',
FormulaFieldType.compatibleWithFormulaTypes('number'),
FormulaFieldType.compatibleWithFormulaTypes('number', 'duration'),
]
}
@ -2076,9 +2250,17 @@ export class HigherThanViewFilterType extends NumericComparisonViewFilterType {
return true
}
const rowVal = fieldType.parseInputValue(field, rowValue)
const fltVal = fieldType.parseInputValue(field, filterValue)
return Number.isFinite(rowVal) && Number.isFinite(fltVal) && rowVal > fltVal
const { rowVal, filterVal } = this.getMatchesParsedValues(
rowValue,
filterValue,
field,
fieldType
)
return (
Number.isFinite(rowVal) &&
Number.isFinite(filterVal) &&
rowVal > filterVal
)
}
}
@ -2097,10 +2279,16 @@ export class HigherThanOrEqualViewFilterType extends NumericComparisonViewFilter
return true
}
const rowVal = fieldType.parseInputValue(field, rowValue)
const fltVal = fieldType.parseInputValue(field, filterValue)
const { rowVal, filterVal } = this.getMatchesParsedValues(
rowValue,
filterValue,
field,
fieldType
)
return (
Number.isFinite(rowVal) && Number.isFinite(fltVal) && rowVal >= fltVal
Number.isFinite(rowVal) &&
Number.isFinite(filterVal) &&
rowVal >= filterVal
)
}
}
@ -2119,10 +2307,18 @@ export class LowerThanViewFilterType extends NumericComparisonViewFilterType {
if (filterValue === '') {
return true
}
const { rowVal, filterVal } = this.getMatchesParsedValues(
rowValue,
filterValue,
field,
fieldType
)
const rowVal = fieldType.parseInputValue(field, rowValue)
const fltVal = fieldType.parseInputValue(field, filterValue)
return Number.isFinite(rowVal) && Number.isFinite(fltVal) && rowVal < fltVal
return (
Number.isFinite(rowVal) &&
Number.isFinite(filterVal) &&
rowVal < filterVal
)
}
}
@ -2141,10 +2337,17 @@ export class LowerThanOrEqualViewFilterType extends NumericComparisonViewFilterT
return true
}
const rowVal = fieldType.parseInputValue(field, rowValue)
const fltVal = fieldType.parseInputValue(field, filterValue)
const { rowVal, filterVal } = this.getMatchesParsedValues(
rowValue,
filterValue,
field,
fieldType
)
return (
Number.isFinite(rowVal) && Number.isFinite(fltVal) && rowVal <= fltVal
Number.isFinite(rowVal) &&
Number.isFinite(filterVal) &&
rowVal <= filterVal
)
}
}
@ -2737,6 +2940,7 @@ export class EmptyViewFilterType extends ViewFilterType {
'boolean',
'date',
'number',
'duration',
'url',
'single_select',
FormulaFieldType.arrayOf('single_file'),
@ -2802,6 +3006,7 @@ export class NotEmptyViewFilterType extends ViewFilterType {
'boolean',
'date',
'number',
'duration',
'url',
'single_select',
FormulaFieldType.arrayOf('single_file'),

View file

@ -26,6 +26,8 @@ import {
DateWithinDaysViewFilterType,
DateWithinMonthsViewFilterType,
DateWithinWeeksViewFilterType,
EmptyViewFilterType,
EqualViewFilterType,
FilesLowerThanViewFilterType,
HasFileTypeViewFilterType,
HigherThanOrEqualViewFilterType,
@ -38,6 +40,7 @@ import {
LowerThanViewFilterType,
MultipleSelectHasFilterType,
MultipleSelectHasNotFilterType,
NotEmptyViewFilterType,
SingleSelectIsAnyOfViewFilterType,
SingleSelectIsNoneOfViewFilterType,
} from '@baserow/modules/database/viewFilters'
@ -1168,81 +1171,479 @@ const numberIsEvenAndWholeCases = [
},
]
const durationHigherThanCases = [
const durationHigherLowerThanCases = [
{
rowValue: null,
filterValue: '1:01',
context: { field: { duration_format: 'h:mm' } },
expected: false,
context: {
field: {
type: 'formula',
formula_type: 'duration',
duration_format: 'h:mm',
},
},
expectedGte: false,
expectedGt: false,
expectedLte: false,
expectedLt: false,
},
{
rowValue: 60,
filterValue: '0:01',
context: { field: { duration_format: 'h:mm' } },
filterValue: '0:01', // will parse to one minute
context: {
field: {
type: 'formula',
formula_type: 'duration',
duration_format: 'h:mm',
},
},
expectedGte: true,
expectedGt: false,
expectedLte: true,
expectedLt: false,
},
{
rowValue: 59,
filterValue: '0:01', // will parse to one minute
context: {
field: {
type: 'formula',
formula_type: 'duration',
duration_format: 'h:mm',
},
},
expectedGte: false,
expectedGt: false,
expectedLte: true,
expectedLt: true,
},
{
rowValue: 59,
filterValue: '1:00', // will parse to one minute
context: {
field: {
type: 'formula',
formula_type: 'duration',
duration_format: 'h:mm:ss',
},
},
expectedGte: false,
expectedGt: false,
expectedLte: true,
expectedLt: true,
},
{
rowValue: 60,
filterValue: '0:01', // will parse to one second
context: {
field: {
type: 'formula',
formula_type: 'duration',
duration_format: 'h:mm:ss',
},
},
expectedGte: true,
expectedGt: true,
expectedLte: false,
expectedLt: false,
},
{
rowValue: 120, // 2m
filterValue: '0:01', // one minute
context: {
field: {
type: 'formula',
formula_type: 'duration',
duration_format: 'h:mm',
},
},
expectedGte: true,
expectedGt: true,
expectedLte: false,
expectedLt: false,
},
{
rowValue: 61,
filterValue: '60', // one minute
context: {
field: {
type: 'formula',
formula_type: 'duration',
duration_format: 'h:mm',
},
},
expectedGte: true,
expectedGt: true,
expectedLte: false,
expectedLt: false,
},
{
rowValue: 61,
filterValue: '60', // one minute
context: {
field: {
type: 'formula',
formula_type: 'duration',
duration_format: 'h:mm:ss',
},
},
expectedGte: true,
expectedGt: true,
expectedLte: false,
expectedLt: false,
},
{
rowValue: 86401,
filterValue: '24:00:00',
context: {
field: {
type: 'formula',
formula_type: 'duration',
duration_format: 'h:mm:ss',
},
},
expectedGte: true,
expectedGt: true,
expectedLte: false,
expectedLt: false,
},
{
rowValue: 86401, // 1d 1s
filterValue: '86401', // 1d 1s
context: {
field: {
type: 'formula',
formula_type: 'duration',
duration_format: 'h:mm:ss',
},
},
expectedGte: true,
expectedGt: false,
expectedLte: true,
expectedLt: false,
},
{
rowValue: 86399,
filterValue: '24:00:00',
context: {
field: {
type: 'formula',
formula_type: 'duration',
duration_format: 'h:mm:ss',
},
},
expectedGte: false,
expectedGt: false,
expectedLte: true,
expectedLt: true,
},
{
rowValue: 86399, // exact
filterValue: '86399', // exact
context: {
field: {
type: 'formula',
formula_type: 'duration',
duration_format: 'h:mm:ss',
},
},
expectedGte: true,
expectedGt: false,
expectedLte: true,
expectedLt: false,
},
{
rowValue: 86399,
filterValue: '24:00:00',
context: {
field: {
type: 'formula',
formula_type: 'duration',
duration_format: 'd h',
},
},
expectedGte: false,
expectedGt: false,
expectedLte: true,
expectedLt: true,
},
{
rowValue: 86401,
filterValue: '24:00:00',
context: {
field: {
type: 'formula',
formula_type: 'duration',
duration_format: 'd h',
},
},
expectedGte: true,
expectedGt: true,
expectedLte: false,
expectedLt: false,
},
{
rowValue: 86401,
filterValue: '86401', // 24h
context: {
field: {
type: 'formula',
formula_type: 'duration',
duration_format: 'd h',
},
},
expectedGte: true,
expectedGt: true,
expectedLte: false,
expectedLt: false,
},
{
rowValue: 86400,
filterValue: '24:00:00',
context: {
field: {
type: 'formula',
formula_type: 'duration',
duration_format: 'd h',
},
},
expectedGte: true,
expectedGt: false,
expectedLte: true,
expectedLt: false,
},
{
rowValue: 86400,
filterValue: '86399', // 24h
context: {
field: {
type: 'formula',
formula_type: 'duration',
duration_format: 'd h',
},
},
expectedGte: true,
expectedGt: false,
expectedLte: true,
expectedLt: false,
},
]
const durationEmptyNotEmptyCases = [
{
rowValue: null,
filterValue: '',
context: {
field: {
type: 'formula',
formula_type: 'duration',
duration_format: 'h:mm',
},
},
emptyExpected: true,
notEmptyExpected: false,
},
{
rowValue: '',
filterValue: '',
context: {
field: {
type: 'formula',
formula_type: 'duration',
duration_format: 'h:mm',
},
},
emptyExpected: true,
notEmptyExpected: false,
},
{
rowValue: 1234,
filterValue: '',
context: {
field: {
type: 'formula',
formula_type: 'duration',
duration_format: 'h:mm',
},
},
emptyExpected: false,
notEmptyExpected: true,
},
]
const durationEqualToValueCases = [
{
rowValue: null,
filterValue: '1:01',
context: {
field: {
type: 'formula',
formula_type: 'duration',
duration_format: 'h:mm',
},
},
expected: false,
},
{
rowValue: 120,
filterValue: '0:01',
context: { field: { duration_format: 'h:mm' } },
expected: true,
rowValue: 20 * 60, // 20 min
filterValue: '0:01', // 1 min
context: {
field: {
type: 'formula',
formula_type: 'duration',
duration_format: 'h:mm',
},
},
{
rowValue: 61, // will be rounded to 0:01
filterValue: 60,
context: { field: { duration_format: 'h:mm' } },
expected: false,
},
{
rowValue: 61,
filterValue: 60,
context: { field: { duration_format: 'h:mm:ss' } },
filterValue: '0:01', // 1 min
context: {
field: {
type: 'formula',
formula_type: 'duration',
duration_format: 'h:mm',
},
},
expected: false,
},
{
rowValue: 20 * 60,
filterValue: '0:20',
context: {
field: {
type: 'formula',
formula_type: 'duration',
duration_format: 'h:mm',
},
},
expected: true,
},
{
rowValue: 864001,
filterValue: '24:00:00',
context: { field: { duration_format: 'h:mm:ss' } },
expected: true,
rowValue: 20 * 60 - 1,
filterValue: '0:20',
context: {
field: {
type: 'formula',
formula_type: 'duration',
duration_format: 'h:mm',
},
},
expected: false,
},
{
rowValue: 20 * 60 - 1,
filterValue: '0:20:00',
context: {
field: {
type: 'formula',
formula_type: 'duration',
duration_format: 'h:mm:ss',
},
},
expected: false,
},
]
const durationLowerThanCases = [
{
rowValue: null,
filterValue: '1:01',
context: { field: { duration_format: 'h:mm' } },
rowValue: 61,
filterValue: 60,
context: {
field: {
type: 'formula',
formula_type: 'duration',
duration_format: 'h:mm',
},
},
expected: false,
},
{
rowValue: 20,
filterValue: '0:01',
context: { field: { duration_format: 'h:mm' } },
rowValue: 1234,
filterValue: 1234,
context: {
field: {
type: 'formula',
formula_type: 'duration',
duration_format: 'h:mm:ss',
},
},
expected: true,
},
{
rowValue: 120,
filterValue: '0:01',
context: { field: { duration_format: 'h:mm' } },
rowValue: 86399, // 24h -1s
filterValue: '24:00:00',
context: {
field: {
type: 'formula',
formula_type: 'duration',
duration_format: 'h:mm:ss',
},
},
expected: false,
},
{
rowValue: 61, // will be rounded to 0:01
filterValue: 60,
context: { field: { duration_format: 'h:mm' } },
rowValue: 86399,
filterValue: '86400',
context: {
field: {
type: 'formula',
formula_type: 'duration',
duration_format: 'h:mm:ss',
},
},
expected: false,
},
{
rowValue: 59,
filterValue: 60,
context: { field: { duration_format: 'h:mm:ss' } },
rowValue: 86399,
filterValue: '86400',
context: {
field: {
type: 'formula',
formula_type: 'duration',
duration_format: 'd h',
},
},
expected: false,
},
{
rowValue: 86400,
filterValue: '86402',
context: {
field: {
type: 'formula',
formula_type: 'duration',
duration_format: 'd h',
},
},
expected: true,
},
{
rowValue: 86399,
filterValue: '86399',
context: {
field: {
type: 'formula',
formula_type: 'duration',
duration_format: 'h:mm:ss',
},
},
expected: true,
},
{
rowValue: 86399,
filterValue: '24:00:00',
context: { field: { duration_format: 'h:mm:ss' } },
expected: true,
context: {
field: {
type: 'formula',
formula_type: 'duration',
duration_format: 'h:mm',
},
},
expected: false,
},
]
@ -1892,8 +2293,40 @@ describe('All Tests', () => {
).toBe(true)
})
test.each(durationHigherThanCases)(
'DurationHigherThanFilterType',
test.each(durationEmptyNotEmptyCases)(
'durationEmptyNotEmptyCases empty test on duration formula field: %j',
(values) => {
const app = testApp.getApp()
const fieldType = new FormulaFieldType({ app })
const { field } = values.context
const result = new EmptyViewFilterType({ app }).matches(
values.rowValue,
values.filterValue,
field,
fieldType
)
expect(result).toBe(values.emptyExpected)
}
)
test.each(durationEmptyNotEmptyCases)(
'durationEmptyNotEmptyCases not empty test on duration formula field: %j',
(values) => {
const app = testApp.getApp()
const fieldType = new FormulaFieldType({ app })
const { field } = values.context
const result = new NotEmptyViewFilterType({ app }).matches(
values.rowValue,
values.filterValue,
field,
fieldType
)
expect(result).toBe(values.notEmptyExpected)
}
)
test.each(durationHigherLowerThanCases)(
'DurationHigherThanFilterType duration field %j',
(values) => {
const fieldType = new DurationFieldType({
app: testApp,
@ -1905,11 +2338,61 @@ describe('All Tests', () => {
field,
fieldType
)
expect(result).toBe(values.expected)
expect(result).toBe(values.expectedGt)
}
)
test.each(durationLowerThanCases)('DurationLowerThanFilterType', (values) => {
test.each(durationHigherLowerThanCases)(
'DurationHigherThanFilterType on duration formula field %j',
(values) => {
const app = testApp.getApp()
const fieldType = new FormulaFieldType({ app })
const { field } = values.context
field.formula_type = 'duration'
const result = new HigherThanViewFilterType({ app }).matches(
values.rowValue,
values.filterValue,
field,
fieldType
)
expect(result).toBe(values.expectedGt)
}
)
test.each(durationHigherLowerThanCases)(
'DurationHigherOrEqualThanFilterType %j',
(values) => {
const fieldType = new DurationFieldType({
app: testApp,
})
const { field } = values.context
const result = new HigherThanOrEqualViewFilterType({
app: testApp,
}).matches(values.rowValue, values.filterValue, field, fieldType)
expect(result).toBe(values.expectedGte)
}
)
test.each(durationHigherLowerThanCases)(
'DurationHigherThanOrEqualFilterType on duration formula field %j',
(values) => {
const app = testApp.getApp()
const fieldType = new FormulaFieldType({ app })
const { field } = values.context
field.formula_type = 'duration'
const result = new HigherThanOrEqualViewFilterType({ app }).matches(
values.rowValue,
values.filterValue,
field,
fieldType
)
expect(result).toBe(values.expectedGte)
}
)
test.each(durationHigherLowerThanCases)(
'DurationLowerThanFilterType %j',
(values) => {
const app = testApp.getApp()
const fieldType = new DurationFieldType({ app })
const { field } = values.context
@ -1919,11 +2402,79 @@ describe('All Tests', () => {
field,
fieldType
)
expect(result).toBe(values.expectedLt)
}
)
test.each(durationHigherLowerThanCases)(
'DurationLowerThanFilterType on duration formula field %j',
(values) => {
const app = testApp.getApp()
const fieldType = new FormulaFieldType({ app })
const { field } = values.context
field.formula_type = 'duration'
const result = new LowerThanViewFilterType({ app }).matches(
values.rowValue,
values.filterValue,
field,
fieldType
)
expect(result).toBe(values.expectedLt)
}
)
test.each(durationHigherLowerThanCases)(
'DurationLowerThanOrEqualFilterType %j',
(values) => {
const app = testApp.getApp()
const fieldType = new DurationFieldType({ app })
const { field } = values.context
const result = new LowerThanOrEqualViewFilterType({ app }).matches(
values.rowValue,
values.filterValue,
field,
fieldType
)
expect(result).toBe(values.expectedLte)
}
)
test.each(durationHigherLowerThanCases)(
'DurationLowerThanOrEqualFilterType on duration formula field %j',
(values) => {
const app = testApp.getApp()
const fieldType = new FormulaFieldType({ app })
const { field } = values.context
field.formula_type = 'duration'
const result = new LowerThanOrEqualViewFilterType({ app }).matches(
values.rowValue,
values.filterValue,
field,
fieldType
)
expect(result).toBe(values.expectedLte)
}
)
test.each(durationEqualToValueCases)(
'durationEqualToValueCases on duration formula field: %j',
(values) => {
const app = testApp.getApp()
const fieldType = new FormulaFieldType({ app })
const { field } = values.context
field.formula_type = 'duration'
const result = new EqualViewFilterType({ app }).matches(
values.rowValue,
values.filterValue,
field,
fieldType
)
expect(result).toBe(values.expected)
})
}
)
test.each(numberValueIsHigherThanCases)(
'NumberHigherThanFilterType',
'NumberHigherThanFilterType %j',
(values) => {
const app = testApp.getApp()
const result = new HigherThanViewFilterType({ app }).matches(
@ -1937,7 +2488,7 @@ describe('All Tests', () => {
)
test.each(numberValueIsHigherThanOrEqualCases)(
'NumberHigherThanOrEqualFilterType',
'NumberHigherThanOrEqualFilterType %j',
(values) => {
const app = testApp.getApp()
const result = new HigherThanOrEqualViewFilterType({ app }).matches(
@ -1951,7 +2502,7 @@ describe('All Tests', () => {
)
test.each(numberValueIsHigherThanCases)(
'FormulaNumberHigherThanFilterType',
'FormulaNumberHigherThanFilterType %j',
(values) => {
const app = testApp.getApp()
const result = new HigherThanViewFilterType({ app }).matches(
@ -1965,7 +2516,7 @@ describe('All Tests', () => {
)
test.each(numberValueIsLowerThanCases)(
'NumberLowerThanFilterType',
'NumberLowerThanFilterType %j',
(values) => {
const app = testApp.getApp()
const result = new LowerThanViewFilterType({ app }).matches(
@ -1979,7 +2530,7 @@ describe('All Tests', () => {
)
test.each(numberValueIsLowerThanOrEqualCases)(
'NumberLowerThanOrEqualFilterType',
'NumberLowerThanOrEqualFilterType %j',
(values) => {
const app = testApp.getApp()
const result = new LowerThanOrEqualViewFilterType({ app }).matches(
@ -1993,7 +2544,7 @@ describe('All Tests', () => {
)
test.each(numberValueIsLowerThanCases)(
'FormulaNumberLowerThanFilterType',
'FormulaNumberLowerThanFilterType %j',
(values) => {
const app = testApp.getApp()
const result = new LowerThanViewFilterType({ app }).matches(
@ -2007,7 +2558,7 @@ describe('All Tests', () => {
)
test.each(singleSelectValuesInFilterCases)(
'SingleSelectIsAnyOfViewFilterType',
'SingleSelectIsAnyOfViewFilterType %j',
(values) => {
const fieldType = new SingleSelectFieldType()
const field = {}
@ -2019,7 +2570,7 @@ describe('All Tests', () => {
)
test.each(singleSelectValuesInFilterCases)(
'SingleSelectIsAnyOfViewFilterType',
'SingleSelectIsAnyOfViewFilterType %j',
(values) => {
const fieldType = new FormulaFieldType()
const field = {
@ -2033,7 +2584,7 @@ describe('All Tests', () => {
)
test.each(singleSelectValuesInFilterCases)(
'SingleSelectIsNoneOfViewFilterType',
'SingleSelectIsNoneOfViewFilterType %j',
(values) => {
const fieldType = new SingleSelectFieldType()
const field = {}
@ -2045,7 +2596,7 @@ describe('All Tests', () => {
)
test.each(singleSelectValuesInFilterCases)(
'SingleSelectIsNoneOfViewFilterType',
'SingleSelectIsNoneOfViewFilterType %j',
(values) => {
const fieldType = new FormulaFieldType()
const field = {