diff --git a/backend/src/baserow/contrib/database/apps.py b/backend/src/baserow/contrib/database/apps.py index 612a0afb0..b003ca161 100755 --- a/backend/src/baserow/contrib/database/apps.py +++ b/backend/src/baserow/contrib/database/apps.py @@ -385,6 +385,28 @@ class DatabaseConfig(AppConfig): view_filter_type_registry.register(UserIsViewFilterType()) view_filter_type_registry.register(UserIsNotViewFilterType()) + from .views.array_view_filters import ( + HasEmptyValueViewFilterType, + HasNotEmptyValueViewFilterType, + HasNotValueContainsViewFilterType, + HasNotValueContainsWordViewFilterType, + HasNotValueEqualViewFilterType, + HasValueContainsViewFilterType, + HasValueContainsWordViewFilterType, + HasValueEqualViewFilterType, + HasValueLengthIsLowerThanViewFilterType, + ) + + view_filter_type_registry.register(HasValueEqualViewFilterType()) + view_filter_type_registry.register(HasNotValueEqualViewFilterType()) + view_filter_type_registry.register(HasValueContainsViewFilterType()) + view_filter_type_registry.register(HasNotValueContainsViewFilterType()) + view_filter_type_registry.register(HasValueContainsWordViewFilterType()) + view_filter_type_registry.register(HasNotValueContainsWordViewFilterType()) + view_filter_type_registry.register(HasValueLengthIsLowerThanViewFilterType()) + view_filter_type_registry.register(HasEmptyValueViewFilterType()) + view_filter_type_registry.register(HasNotEmptyValueViewFilterType()) + from .views.view_aggregations import ( AverageViewAggregationType, DecileViewAggregationType, diff --git a/backend/src/baserow/contrib/database/fields/field_types.py b/backend/src/baserow/contrib/database/fields/field_types.py index bf68b1bf4..35e2ccdf9 100755 --- a/backend/src/baserow/contrib/database/fields/field_types.py +++ b/backend/src/baserow/contrib/database/fields/field_types.py @@ -77,6 +77,14 @@ from baserow.contrib.database.api.views.errors import ( ) from baserow.contrib.database.db.functions import RandomUUID from baserow.contrib.database.export_serialized import DatabaseExportSerializedStructure +from baserow.contrib.database.fields.filter_support import ( + FilterNotSupportedException, + HasValueContainsFilterSupport, + HasValueContainsWordFilterSupport, + HasValueEmptyFilterSupport, + HasValueFilterSupport, + HasValueLengthIsLowerThanFilterSupport, +) from baserow.contrib.database.formula import ( BASEROW_FORMULA_TYPE_ALLOWED_FIELDS, BaserowExpression, @@ -149,6 +157,7 @@ from .expressions import extract_jsonb_array_values_to_single_string from .field_cache import FieldCache from .field_filters import ( AnnotatedQ, + OptionallyAnnotatedQ, contains_filter, contains_word_filter, filename_contains_filter, @@ -4308,7 +4317,14 @@ class PhoneNumberFieldType(CollationSortMixin, CharFieldMatchingRegexFieldType): return collate_expression(Value(value)) -class FormulaFieldType(ReadOnlyFieldType): +class FormulaFieldType( + HasValueEmptyFilterSupport, + HasValueFilterSupport, + HasValueContainsFilterSupport, + HasValueContainsWordFilterSupport, + HasValueLengthIsLowerThanFilterSupport, + ReadOnlyFieldType, +): type = "formula" model_class = FormulaField @@ -4467,6 +4483,83 @@ class FormulaFieldType(ReadOnlyFieldType): rich_value=rich_value, ) + def get_in_array_empty_query(self, field_name, model_field, field: FormulaField): + ( + field_instance, + field_type, + ) = self._get_field_instance_and_type_from_formula_field(field) + + if not isinstance(field_type, HasValueEmptyFilterSupport): + raise FilterNotSupportedException() + + return field_type.get_in_array_empty_query( + field_name, model_field, field_instance + ) + + def get_in_array_is_query( + self, + field_name: str, + value: str, + model_field: models.Field, + field: FormulaField, + ) -> Q | OptionallyAnnotatedQ: + ( + field_instance, + field_type, + ) = self._get_field_instance_and_type_from_formula_field(field) + + if not isinstance(field_type, HasValueFilterSupport): + raise FilterNotSupportedException() + + return field_type.get_in_array_is_query( + field_name, value, model_field, field_instance + ) + + def get_in_array_contains_query( + self, field_name, value, model_field, field: FormulaField + ): + ( + field_instance, + field_type, + ) = self._get_field_instance_and_type_from_formula_field(field) + + if not isinstance(field_type, HasValueContainsFilterSupport): + raise FilterNotSupportedException() + + return field_type.get_in_array_contains_query( + field_name, value, model_field, field_instance + ) + + def get_in_array_contains_word_query( + self, field_name, value, model_field, field: FormulaField + ): + ( + field_instance, + field_type, + ) = self._get_field_instance_and_type_from_formula_field(field) + + if not isinstance(field_type, HasValueContainsWordFilterSupport): + raise FilterNotSupportedException() + + return field_type.get_in_array_contains_word_query( + field_name, value, model_field, field_instance + ) + + def get_in_array_length_is_lower_than_query( + self, field_name, value, model_field, field: FormulaField + ): + ( + field_instance, + field_type, + ) = self._get_field_instance_and_type_from_formula_field(field) + + if not isinstance(field_type, HasValueLengthIsLowerThanFilterSupport): + raise FilterNotSupportedException() + + return field_type.get_in_array_length_is_lower_than_query( + field_name, value, model_field, field_instance + ) + def contains_query(self, field_name, value, model_field, field: FormulaField): ( field_instance, diff --git a/backend/src/baserow/contrib/database/fields/filter_support.py b/backend/src/baserow/contrib/database/fields/filter_support.py new file mode 100644 index 000000000..e8f3b0f99 --- /dev/null +++ b/backend/src/baserow/contrib/database/fields/filter_support.py @@ -0,0 +1,150 @@ +import re +import typing + +from django.contrib.postgres.fields import JSONField +from django.db import models +from django.db.models import BooleanField, F, Q, Value + +from baserow.contrib.database.fields.field_filters import ( + AnnotatedQ, + OptionallyAnnotatedQ, +) +from baserow.contrib.database.formula.expression_generator.django_expressions import ( + JSONArrayContainsValueExpr, + JSONArrayContainsValueLengthLowerThanExpr, + JSONArrayContainsValueSimilarToExpr, +) + +if typing.TYPE_CHECKING: + from baserow.contrib.database.fields.models import Field + + +class FilterNotSupportedException(Exception): + pass + + +class HasValueEmptyFilterSupport: + def get_in_array_empty_query( + self, field_name: str, model_field: models.Field, field: "Field" + ) -> OptionallyAnnotatedQ: + """ + Specifies a Q expression to filter empty values contained in an array. + + :param field_name: The name of the field. + :param model_field: The field's actual django field model instance. + :param field: The related field's instance. + :return: A Q or AnnotatedQ filter given value. + """ + + return Q(**{f"{field_name}__contains": Value([{"value": ""}], JSONField())}) + + +class HasValueFilterSupport: + def get_in_array_is_query( + self, field_name: str, value: str, model_field: models.Field, field: "Field" + ) -> OptionallyAnnotatedQ: + """ + Specifies a Q expression to filter exact values contained in an array. + + :param field_name: The name of the field. + :param value: The value to check if it is contained in array. + :param model_field: The field's actual django field model instance. + :param field: The related field's instance. + :return: A Q or AnnotatedQ filter given value. + """ + + if not value: + return Q() + + return Q(**{f"{field_name}__contains": Value([{"value": value}], JSONField())}) + + +class HasValueContainsFilterSupport: + def get_in_array_contains_query( + self, field_name: str, value: str, model_field: models.Field, field: "Field" + ) -> OptionallyAnnotatedQ: + """ + Specifies a Q expression to filter values in an array that contain a + specific value. + + :param field_name: The name of the field. + :param value: The value to check if it is contained in array. + :param model_field: The field's actual django field model instance. + :param field: The related field's instance. + :return: A Q or AnnotatedQ filter given value. + """ + + if not value: + return Q() + annotation_query = JSONArrayContainsValueExpr( + F(field_name), Value(f"%{value}%"), output_field=BooleanField() + ) + hashed_value = hash(value) + return AnnotatedQ( + annotation={ + f"{field_name}_has_value_contains_{hashed_value}": annotation_query + }, + q={f"{field_name}_has_value_contains_{hashed_value}": True}, + ) + + +class HasValueContainsWordFilterSupport: + def get_in_array_contains_word_query( + self, field_name: str, value: str, model_field: models.Field, field: "Field" + ) -> OptionallyAnnotatedQ: + """ + Specifies a Q expression to filter values in an array that contain a + specific word. + + :param field_name: The name of the field. + :param value: The value to check if it is contained in array. + :param model_field: The field's actual django field model instance. + :param field: The related field's instance. + :return: A Q or AnnotatedQ filter given value. + """ + + value = value.strip() + if not value: + return Q() + value = re.escape(value.upper()) + annotation_query = JSONArrayContainsValueSimilarToExpr( + F(field_name), Value(f"%\\m{value}\\M%"), output_field=BooleanField() + ) + hashed_value = hash(value) + return AnnotatedQ( + annotation={ + f"{field_name}_has_value_contains_word_{hashed_value}": annotation_query + }, + q={f"{field_name}_has_value_contains_word_{hashed_value}": True}, + ) + + +class HasValueLengthIsLowerThanFilterSupport: + def get_in_array_length_is_lower_than_query( + self, field_name: str, value: str, model_field: models.Field, field: "Field" + ) -> OptionallyAnnotatedQ: + """ + Specifies a Q expression to filter values in an array that has lower + than length. + + :param field_name: The name of the field. + :param value: The value representing the length to use for the check. + :param model_field: The field's actual django field model instance. + :param field: The related field's instance. + :return: A Q or AnnotatedQ filter given value. + """ + + value = value.strip() + if not value: + return Q() + converted_value = int(value) + annotation_query = JSONArrayContainsValueLengthLowerThanExpr( + F(field_name), Value(converted_value), output_field=BooleanField() + ) + hashed_value = hash(value) + return AnnotatedQ( + annotation={ + f"{field_name}_has_value_length_is_lower_than_{hashed_value}": annotation_query + }, + q={f"{field_name}_has_value_length_is_lower_than_{hashed_value}": True}, + ) diff --git a/backend/src/baserow/contrib/database/formula/expression_generator/django_expressions.py b/backend/src/baserow/contrib/database/formula/expression_generator/django_expressions.py index c3de6cc91..7f8db40c6 100644 --- a/backend/src/baserow/contrib/database/formula/expression_generator/django_expressions.py +++ b/backend/src/baserow/contrib/database/formula/expression_generator/django_expressions.py @@ -116,18 +116,12 @@ class JSONArray(Func): ) -class FileNameContainsExpr(Expression): - # fmt: off - template = ( - f""" - EXISTS( - SELECT attached_files ->> 'visible_name' - FROM JSONB_ARRAY_ELEMENTS(%(field_name)s) as attached_files - WHERE UPPER(attached_files ->> 'visible_name') LIKE UPPER(%(value)s) - ) - """ # nosec B608 - ) - # fmt: on +class BaserowFilterExpression(Expression): + """ + Baserow expression that works with field_name and value + to provide expressions for filters. To use, subclass and + define the template. + """ def __init__(self, field_name: F, value: Value, output_field: Field): super().__init__(output_field=output_field) @@ -159,3 +153,59 @@ class FileNameContainsExpr(Expression): "value": sql_value, } return template % data, params_value + + +class FileNameContainsExpr(BaserowFilterExpression): + # fmt: off + template = ( + f""" + EXISTS( + SELECT attached_files ->> 'visible_name' + FROM JSONB_ARRAY_ELEMENTS(%(field_name)s) as attached_files + WHERE UPPER(attached_files ->> 'visible_name') LIKE UPPER(%(value)s) + ) + """ # nosec B608 + ) + # fmt: on + + +class JSONArrayContainsValueExpr(BaserowFilterExpression): + # fmt: off + template = ( + f""" + EXISTS( + SELECT filtered_field ->> 'value' + FROM JSONB_ARRAY_ELEMENTS(%(field_name)s) as filtered_field + WHERE UPPER(filtered_field ->> 'value') LIKE UPPER(%(value)s) + ) + """ # nosec B608 + ) + # fmt: on + + +class JSONArrayContainsValueSimilarToExpr(BaserowFilterExpression): + # fmt: off + template = ( + f""" + EXISTS( + SELECT filtered_field ->> 'value' + FROM JSONB_ARRAY_ELEMENTS(%(field_name)s) as filtered_field + WHERE UPPER(filtered_field ->> 'value') SIMILAR TO %(value)s + ) + """ # nosec B608 %(value)s + ) + # fmt: on + + +class JSONArrayContainsValueLengthLowerThanExpr(BaserowFilterExpression): + # fmt: off + template = ( + f""" + EXISTS( + SELECT filtered_field ->> 'value' + FROM JSONB_ARRAY_ELEMENTS(%(field_name)s) as filtered_field + WHERE LENGTH(filtered_field ->> 'value') < %(value)s + ) + """ # nosec B608 %(value)s + ) + # fmt: on diff --git a/backend/src/baserow/contrib/database/formula/types/formula_types.py b/backend/src/baserow/contrib/database/formula/types/formula_types.py index 620e52f27..74f42da92 100644 --- a/backend/src/baserow/contrib/database/formula/types/formula_types.py +++ b/backend/src/baserow/contrib/database/formula/types/formula_types.py @@ -20,6 +20,14 @@ from baserow.contrib.database.fields.expressions import ( json_extract_path, ) from baserow.contrib.database.fields.field_sortings import OptionallyAnnotatedOrderBy +from baserow.contrib.database.fields.filter_support import ( + FilterNotSupportedException, + HasValueContainsFilterSupport, + HasValueContainsWordFilterSupport, + HasValueEmptyFilterSupport, + HasValueFilterSupport, + HasValueLengthIsLowerThanFilterSupport, +) from baserow.contrib.database.fields.mixins import get_date_time_format from baserow.contrib.database.fields.utils.duration import ( D_H_M_S, @@ -95,6 +103,11 @@ class BaserowFormulaBaseTextType(BaserowFormulaTypeHasEmptyBaserowExpression): class BaserowFormulaTextType( + HasValueEmptyFilterSupport, + HasValueFilterSupport, + HasValueContainsFilterSupport, + HasValueContainsWordFilterSupport, + HasValueLengthIsLowerThanFilterSupport, BaserowFormulaBaseTextType, BaserowFormulaTypeHasEmptyBaserowExpression, BaserowFormulaValidType, @@ -961,7 +974,14 @@ class BaserowFormulaSingleFileType(BaserowJSONBObjectBaseType): ) -class BaserowFormulaArrayType(BaserowFormulaValidType): +class BaserowFormulaArrayType( + HasValueEmptyFilterSupport, + HasValueFilterSupport, + HasValueContainsFilterSupport, + HasValueContainsWordFilterSupport, + HasValueLengthIsLowerThanFilterSupport, + BaserowFormulaValidType, +): type = "array" user_overridable_formatting_option_fields = [ "array_formula_type", @@ -1123,6 +1143,46 @@ class BaserowFormulaArrayType(BaserowFormulaValidType): def contains_query(self, field_name, value, model_field, field): return Q() + def get_in_array_is_query(self, field_name, value, model_field, field): + if not isinstance(self.sub_type, HasValueFilterSupport): + raise FilterNotSupportedException() + + return self.sub_type.get_in_array_is_query( + field_name, value, model_field, field + ) + + def get_in_array_empty_query(self, field_name, model_field, field): + if not isinstance(self.sub_type, HasValueEmptyFilterSupport): + raise FilterNotSupportedException() + + return self.sub_type.get_in_array_empty_query(field_name, model_field, field) + + def get_in_array_contains_query(self, field_name, value, model_field, field): + if not isinstance(self.sub_type, HasValueContainsFilterSupport): + raise FilterNotSupportedException() + + return self.sub_type.get_in_array_contains_query( + field_name, value, model_field, field + ) + + def get_in_array_contains_word_query(self, field_name, value, model_field, field): + if not isinstance(self.sub_type, HasValueContainsWordFilterSupport): + raise FilterNotSupportedException() + + return self.sub_type.get_in_array_contains_word_query( + field_name, value, model_field, field + ) + + def get_in_array_length_is_lower_than_query( + self, field_name, value, model_field, field + ): + if not isinstance(self.sub_type, HasValueLengthIsLowerThanFilterSupport): + raise FilterNotSupportedException() + + return self.sub_type.get_in_array_length_is_lower_than_query( + field_name, value, model_field, field + ) + def get_alter_column_prepare_old_value(self, connection, from_field, to_field): return "p_in = '';" diff --git a/backend/src/baserow/contrib/database/views/array_view_filters.py b/backend/src/baserow/contrib/database/views/array_view_filters.py new file mode 100644 index 000000000..6f6dbcb1b --- /dev/null +++ b/backend/src/baserow/contrib/database/views/array_view_filters.py @@ -0,0 +1,172 @@ +from baserow.contrib.database.fields.field_filters import OptionallyAnnotatedQ +from baserow.contrib.database.fields.field_types import FormulaFieldType +from baserow.contrib.database.fields.filter_support import ( + FilterNotSupportedException, + HasValueContainsFilterSupport, + HasValueContainsWordFilterSupport, + HasValueEmptyFilterSupport, + HasValueFilterSupport, + HasValueLengthIsLowerThanFilterSupport, +) +from baserow.contrib.database.fields.registries import field_type_registry +from baserow.contrib.database.formula import BaserowFormulaTextType + +from .registries import ViewFilterType +from .view_filters import NotViewFilterTypeMixin + + +class HasEmptyValueViewFilterType(ViewFilterType): + """ + The filter can be used to check for empty condition for + items in an array. + """ + + type = "has_empty_value" + compatible_field_types = [ + FormulaFieldType.compatible_with_formula_types( + FormulaFieldType.array_of(BaserowFormulaTextType.type), + ), + ] + + def get_filter(self, field_name, value, model_field, field) -> OptionallyAnnotatedQ: + try: + field_type = field_type_registry.get_by_model(field) + + if not isinstance(field_type, HasValueEmptyFilterSupport): + raise FilterNotSupportedException() + + return field_type.get_in_array_empty_query(field_name, model_field, field) + except Exception: + return self.default_filter_on_exception() + + +class HasNotEmptyValueViewFilterType( + NotViewFilterTypeMixin, HasEmptyValueViewFilterType +): + type = "has_not_empty_value" + + +class HasValueEqualViewFilterType(ViewFilterType): + """ + The filter can be used to check for "is" condition for + items in an array. + """ + + type = "has_value_equal" + compatible_field_types = [ + FormulaFieldType.compatible_with_formula_types( + FormulaFieldType.array_of(BaserowFormulaTextType.type), + ), + ] + + def get_filter(self, field_name, value, model_field, field) -> OptionallyAnnotatedQ: + try: + field_type = field_type_registry.get_by_model(field) + + if not isinstance(field_type, HasValueFilterSupport): + raise FilterNotSupportedException() + + return field_type.get_in_array_is_query( + field_name, value, model_field, field + ) + except Exception: + return self.default_filter_on_exception() + + +class HasNotValueEqualViewFilterType( + NotViewFilterTypeMixin, HasValueEqualViewFilterType +): + type = "has_not_value_equal" + + +class HasValueContainsViewFilterType(ViewFilterType): + """ + The filter can be used to check for "contains" condition for + items in an array. + """ + + type = "has_value_contains" + compatible_field_types = [ + FormulaFieldType.compatible_with_formula_types( + FormulaFieldType.array_of(BaserowFormulaTextType.type), + ), + ] + + def get_filter(self, field_name, value, model_field, field) -> OptionallyAnnotatedQ: + try: + field_type = field_type_registry.get_by_model(field) + + if not isinstance(field_type, HasValueContainsFilterSupport): + raise FilterNotSupportedException() + + return field_type.get_in_array_contains_query( + field_name, value, model_field, field + ) + except Exception: + return self.default_filter_on_exception() + + +class HasNotValueContainsViewFilterType( + NotViewFilterTypeMixin, HasValueContainsViewFilterType +): + type = "has_not_value_contains" + + +class HasValueContainsWordViewFilterType(ViewFilterType): + """ + The filter can be used to check for "contains word" condition + for items in an array. + """ + + type = "has_value_contains_word" + compatible_field_types = [ + FormulaFieldType.compatible_with_formula_types( + FormulaFieldType.array_of(BaserowFormulaTextType.type), + ), + ] + + def get_filter(self, field_name, value, model_field, field) -> OptionallyAnnotatedQ: + try: + field_type = field_type_registry.get_by_model(field) + + if not isinstance(field_type, HasValueContainsWordFilterSupport): + raise FilterNotSupportedException() + + return field_type.get_in_array_contains_word_query( + field_name, value, model_field, field + ) + except Exception: + return self.default_filter_on_exception() + + +class HasNotValueContainsWordViewFilterType( + NotViewFilterTypeMixin, HasValueContainsWordViewFilterType +): + type = "has_not_value_contains_word" + + +class HasValueLengthIsLowerThanViewFilterType(ViewFilterType): + """ + The filter can be used to check for "length is lower than" condition + for items in an array. + """ + + type = "has_value_length_is_lower_than" + compatible_field_types = [ + FormulaFieldType.compatible_with_formula_types( + FormulaFieldType.array_of(BaserowFormulaTextType.type), + ), + ] + + def get_filter(self, field_name, value, model_field, field) -> OptionallyAnnotatedQ: + try: + field_type = field_type_registry.get_by_model(field) + + if not isinstance(field_type, HasValueLengthIsLowerThanFilterSupport): + raise FilterNotSupportedException() + + return field_type.get_in_array_length_is_lower_than_query( + field_name, value, model_field, field + ) + except Exception: + return self.default_filter_on_exception() diff --git a/backend/tests/baserow/contrib/database/view/test_view_array_filters.py b/backend/tests/baserow/contrib/database/view/test_view_array_filters.py new file mode 100644 index 000000000..d809023ff --- /dev/null +++ b/backend/tests/baserow/contrib/database/view/test_view_array_filters.py @@ -0,0 +1,754 @@ +from dataclasses import dataclass + +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 + + +@dataclass +class ArrayFiltersSetup: + user: AbstractUser + 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 + + +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 setup(data_fixture, target_field_factory): + 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_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, + ) + + +@pytest.mark.parametrize( + "target_field_factory", [text_field_factory, long_text_field_factory] +) +@pytest.mark.django_db +def test_has_empty_value_filter_text_field_types(data_fixture, target_field_factory): + test_setup = setup(data_fixture, target_field_factory) + + other_row_A = test_setup.other_table_model.objects.create( + **{f"field_{test_setup.target_field.id}": "A"} + ) + other_row_B = test_setup.other_table_model.objects.create( + **{f"field_{test_setup.target_field.id}": "B"} + ) + other_row_empty = test_setup.other_table_model.objects.create( + **{f"field_{test_setup.target_field.id}": ""} + ) + row_1 = test_setup.row_handler.create_row( + user=test_setup.user, + table=test_setup.table, + values={ + f"field_{test_setup.link_row_field.id}": [ + other_row_A.id, + other_row_empty.id, + ] + }, + ) + row_2 = test_setup.row_handler.create_row( + user=test_setup.user, + table=test_setup.table, + values={f"field_{test_setup.link_row_field.id}": []}, + ) + row_3 = test_setup.row_handler.create_row( + user=test_setup.user, + table=test_setup.table, + values={f"field_{test_setup.link_row_field.id}": [other_row_B.id]}, + ) + + view_filter = data_fixture.create_view_filter( + view=test_setup.grid_view, + field=test_setup.lookup_field, + type="has_empty_value", + value="", + ) + ids = [ + r.id + for r in test_setup.view_handler.apply_filters( + test_setup.grid_view, test_setup.model.objects.all() + ).all() + ] + assert len(ids) == 1 + assert row_1.id in ids + + +@pytest.mark.parametrize( + "target_field_factory", [text_field_factory, long_text_field_factory] +) +@pytest.mark.django_db +def test_has_not_empty_value_filter_text_field_types( + data_fixture, target_field_factory +): + test_setup = setup(data_fixture, target_field_factory) + + other_row_A = test_setup.other_table_model.objects.create( + **{f"field_{test_setup.target_field.id}": "A"} + ) + other_row_B = test_setup.other_table_model.objects.create( + **{f"field_{test_setup.target_field.id}": "B"} + ) + other_row_empty = test_setup.other_table_model.objects.create( + **{f"field_{test_setup.target_field.id}": ""} + ) + row_1 = test_setup.row_handler.create_row( + user=test_setup.user, + table=test_setup.table, + values={ + f"field_{test_setup.link_row_field.id}": [ + other_row_A.id, + other_row_empty.id, + ] + }, + ) + row_2 = test_setup.row_handler.create_row( + user=test_setup.user, + table=test_setup.table, + values={f"field_{test_setup.link_row_field.id}": []}, + ) + row_3 = test_setup.row_handler.create_row( + user=test_setup.user, + table=test_setup.table, + values={f"field_{test_setup.link_row_field.id}": [other_row_B.id]}, + ) + + view_filter = data_fixture.create_view_filter( + view=test_setup.grid_view, + field=test_setup.lookup_field, + type="has_not_empty_value", + value="", + ) + ids = [ + r.id + for r in test_setup.view_handler.apply_filters( + test_setup.grid_view, test_setup.model.objects.all() + ).all() + ] + assert len(ids) == 2 + assert row_2.id in ids + assert row_3.id in ids + + +@pytest.mark.parametrize( + "target_field_factory", [text_field_factory, long_text_field_factory] +) +@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) + + other_row_A = test_setup.other_table_model.objects.create( + **{f"field_{test_setup.target_field.id}": "A"} + ) + other_row_B = test_setup.other_table_model.objects.create( + **{f"field_{test_setup.target_field.id}": "B"} + ) + other_row_C = test_setup.other_table_model.objects.create( + **{f"field_{test_setup.target_field.id}": "C"} + ) + other_row_a = test_setup.other_table_model.objects.create( + **{f"field_{test_setup.target_field.id}": "a"} + ) + row_1 = test_setup.row_handler.create_row( + user=test_setup.user, + table=test_setup.table, + values={ + f"field_{test_setup.link_row_field.id}": [other_row_A.id, other_row_B.id] + }, + ) + row_2 = test_setup.row_handler.create_row( + user=test_setup.user, + table=test_setup.table, + values={f"field_{test_setup.link_row_field.id}": [other_row_a.id]}, + ) + row_3 = test_setup.row_handler.create_row( + user=test_setup.user, + table=test_setup.table, + values={ + f"field_{test_setup.link_row_field.id}": [other_row_B.id, other_row_a.id] + }, + ) + + view_filter = data_fixture.create_view_filter( + view=test_setup.grid_view, + field=test_setup.lookup_field, + type="has_value_equal", + value="A", + ) + ids = [ + r.id + for r in test_setup.view_handler.apply_filters( + test_setup.grid_view, test_setup.model.objects.all() + ).all() + ] + assert len(ids) == 1 + assert row_1.id in ids + + view_filter.value = "a" + view_filter.save() + ids = [ + r.id + for r in test_setup.view_handler.apply_filters( + test_setup.grid_view, test_setup.model.objects.all() + ).all() + ] + assert len(ids) == 2 + assert row_2.id in ids + assert row_3.id in ids + + view_filter.value = "C" + view_filter.save() + ids = [ + r.id + for r in test_setup.view_handler.apply_filters( + test_setup.grid_view, test_setup.model.objects.all() + ).all() + ] + assert len(ids) == 0 + + view_filter.value = "" + view_filter.save() + ids = [ + r.id + for r in test_setup.view_handler.apply_filters( + test_setup.grid_view, test_setup.model.objects.all() + ).all() + ] + assert len(ids) == 3 + + +@pytest.mark.parametrize( + "target_field_factory", [text_field_factory, long_text_field_factory] +) +@pytest.mark.django_db +def test_has_not_value_equal_filter_text_field_types( + data_fixture, target_field_factory +): + test_setup = setup(data_fixture, target_field_factory) + + other_row_A = test_setup.other_table_model.objects.create( + **{f"field_{test_setup.target_field.id}": "A"} + ) + other_row_B = test_setup.other_table_model.objects.create( + **{f"field_{test_setup.target_field.id}": "B"} + ) + other_row_C = test_setup.other_table_model.objects.create( + **{f"field_{test_setup.target_field.id}": "C"} + ) + row_1 = test_setup.row_handler.create_row( + user=test_setup.user, + table=test_setup.table, + values={ + f"field_{test_setup.link_row_field.id}": [other_row_A.id, other_row_B.id] + }, + ) + row_2 = test_setup.row_handler.create_row( + user=test_setup.user, + table=test_setup.table, + values={f"field_{test_setup.link_row_field.id}": []}, + ) + row_3 = test_setup.row_handler.create_row( + user=test_setup.user, + table=test_setup.table, + values={f"field_{test_setup.link_row_field.id}": [other_row_B.id]}, + ) + + view_filter = data_fixture.create_view_filter( + view=test_setup.grid_view, + field=test_setup.lookup_field, + type="has_not_value_equal", + value="A", + ) + ids = [ + r.id + for r in test_setup.view_handler.apply_filters( + test_setup.grid_view, test_setup.model.objects.all() + ).all() + ] + assert len(ids) == 2 + assert row_2.id in ids + assert row_3.id in ids + + view_filter.value = "a" + view_filter.save() + ids = [ + r.id + for r in test_setup.view_handler.apply_filters( + test_setup.grid_view, test_setup.model.objects.all() + ).all() + ] + assert len(ids) == 3 + + view_filter.value = "C" + view_filter.save() + ids = [ + r.id + for r in test_setup.view_handler.apply_filters( + test_setup.grid_view, test_setup.model.objects.all() + ).all() + ] + assert len(ids) == 3 + + view_filter.value = "" + view_filter.save() + ids = [ + r.id + for r in test_setup.view_handler.apply_filters( + test_setup.grid_view, test_setup.model.objects.all() + ).all() + ] + assert len(ids) == 3 + + +@pytest.mark.parametrize( + "target_field_factory", [text_field_factory, long_text_field_factory] +) +@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) + + other_row_John_Smith = test_setup.other_table_model.objects.create( + **{f"field_{test_setup.target_field.id}": "John Smith"} + ) + other_row_Anna_Smith = test_setup.other_table_model.objects.create( + **{f"field_{test_setup.target_field.id}": "Anna Smith"} + ) + other_row_John_Wick = test_setup.other_table_model.objects.create( + **{f"field_{test_setup.target_field.id}": "John Wick"} + ) + row_1 = test_setup.row_handler.create_row( + user=test_setup.user, + table=test_setup.table, + values={f"field_{test_setup.link_row_field.id}": [other_row_John_Smith.id]}, + ) + row_2 = test_setup.row_handler.create_row( + user=test_setup.user, + table=test_setup.table, + values={f"field_{test_setup.link_row_field.id}": []}, + ) + row_3 = test_setup.row_handler.create_row( + user=test_setup.user, + table=test_setup.table, + values={f"field_{test_setup.link_row_field.id}": [other_row_Anna_Smith.id]}, + ) + row_4 = test_setup.row_handler.create_row( + user=test_setup.user, + table=test_setup.table, + values={f"field_{test_setup.link_row_field.id}": [other_row_John_Wick.id]}, + ) + + view_filter = data_fixture.create_view_filter( + view=test_setup.grid_view, + field=test_setup.lookup_field, + type="has_value_contains", + value="smith", + ) + ids = [ + r.id + for r in test_setup.view_handler.apply_filters( + test_setup.grid_view, test_setup.model.objects.all() + ).all() + ] + assert len(ids) == 2 + assert row_1.id in ids + assert row_3.id in ids + + view_filter.value = "john" + view_filter.save() + ids = [ + r.id + for r in test_setup.view_handler.apply_filters( + test_setup.grid_view, test_setup.model.objects.all() + ).all() + ] + assert len(ids) == 2 + assert row_1.id in ids + assert row_4.id in ids + + view_filter.value = "" + view_filter.save() + ids = [ + r.id + for r in test_setup.view_handler.apply_filters( + test_setup.grid_view, test_setup.model.objects.all() + ).all() + ] + assert len(ids) == 4 + + +@pytest.mark.parametrize( + "target_field_factory", [text_field_factory, long_text_field_factory] +) +@pytest.mark.django_db +def test_has_not_value_contains_filter_text_field_types( + data_fixture, target_field_factory +): + test_setup = setup(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"} + ) + other_row_Anna_Smith = test_setup.other_table_model.objects.create( + **{f"field_{test_setup.target_field.id}": "Anna Smith"} + ) + other_row_John_Wick = test_setup.other_table_model.objects.create( + **{f"field_{test_setup.target_field.id}": "John Wick"} + ) + row_1 = test_setup.row_handler.create_row( + user=test_setup.user, + table=test_setup.table, + values={f"field_{test_setup.link_row_field.id}": [other_row_John_Smith.id]}, + ) + row_2 = test_setup.row_handler.create_row( + user=test_setup.user, + table=test_setup.table, + values={f"field_{test_setup.link_row_field.id}": []}, + ) + row_3 = test_setup.row_handler.create_row( + user=test_setup.user, + table=test_setup.table, + values={f"field_{test_setup.link_row_field.id}": [other_row_Anna_Smith.id]}, + ) + row_4 = test_setup.row_handler.create_row( + user=test_setup.user, + table=test_setup.table, + values={f"field_{test_setup.link_row_field.id}": [other_row_John_Wick.id]}, + ) + + view_filter = data_fixture.create_view_filter( + view=test_setup.grid_view, + field=test_setup.lookup_field, + type="has_not_value_contains", + value="smith", + ) + ids = [ + r.id + for r in test_setup.view_handler.apply_filters( + test_setup.grid_view, test_setup.model.objects.all() + ).all() + ] + assert len(ids) == 2 + assert row_2.id in ids + assert row_4.id in ids + + view_filter.value = "john" + view_filter.save() + ids = [ + r.id + for r in test_setup.view_handler.apply_filters( + test_setup.grid_view, test_setup.model.objects.all() + ).all() + ] + assert len(ids) == 2 + assert row_2.id in ids + assert row_3.id in ids + + view_filter.value = "" + view_filter.save() + ids = [ + r.id + for r in test_setup.view_handler.apply_filters( + test_setup.grid_view, test_setup.model.objects.all() + ).all() + ] + assert len(ids) == 4 + + +@pytest.mark.parametrize( + "target_field_factory", [text_field_factory, long_text_field_factory] +) +@pytest.mark.django_db +def test_has_value_contains_word_filter_text_field_types( + data_fixture, target_field_factory +): + test_setup = setup(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."} + ) + other_row_2 = test_setup.other_table_model.objects.create( + **{f"field_{test_setup.target_field.id}": "Another Sentence."} + ) + other_row_3 = test_setup.other_table_model.objects.create( + **{f"field_{test_setup.target_field.id}": ""} + ) + row_1 = test_setup.row_handler.create_row( + user=test_setup.user, + table=test_setup.table, + values={ + f"field_{test_setup.link_row_field.id}": [other_row_1.id, other_row_3.id] + }, + ) + row_2 = test_setup.row_handler.create_row( + user=test_setup.user, + table=test_setup.table, + values={f"field_{test_setup.link_row_field.id}": [other_row_3.id]}, + ) + row_3 = test_setup.row_handler.create_row( + user=test_setup.user, + table=test_setup.table, + values={f"field_{test_setup.link_row_field.id}": [other_row_2.id]}, + ) + row_4 = test_setup.row_handler.create_row( + user=test_setup.user, + table=test_setup.table, + values={f"field_{test_setup.link_row_field.id}": []}, + ) + + view_filter = data_fixture.create_view_filter( + view=test_setup.grid_view, + field=test_setup.lookup_field, + type="has_value_contains_word", + value="sentence", + ) + ids = [ + r.id + for r in test_setup.view_handler.apply_filters( + test_setup.grid_view, test_setup.model.objects.all() + ).all() + ] + assert len(ids) == 2 + assert row_1.id in ids + assert row_3.id in ids + + view_filter.value = "Sentence" + view_filter.save() + ids = [ + r.id + for r in test_setup.view_handler.apply_filters( + test_setup.grid_view, test_setup.model.objects.all() + ).all() + ] + assert len(ids) == 2 + assert row_1.id in ids + assert row_3.id in ids + + view_filter.value = "" + view_filter.save() + ids = [ + r.id + for r in test_setup.view_handler.apply_filters( + test_setup.grid_view, test_setup.model.objects.all() + ).all() + ] + assert len(ids) == 4 + + +@pytest.mark.parametrize( + "target_field_factory", [text_field_factory, long_text_field_factory] +) +@pytest.mark.django_db +def test_has_not_value_contains_word_filter_text_field_types( + data_fixture, target_field_factory +): + test_setup = setup(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."} + ) + other_row_2 = test_setup.other_table_model.objects.create( + **{f"field_{test_setup.target_field.id}": "Another Sentence."} + ) + other_row_3 = test_setup.other_table_model.objects.create( + **{f"field_{test_setup.target_field.id}": ""} + ) + row_1 = test_setup.row_handler.create_row( + user=test_setup.user, + table=test_setup.table, + values={ + f"field_{test_setup.link_row_field.id}": [other_row_1.id, other_row_3.id] + }, + ) + row_2 = test_setup.row_handler.create_row( + user=test_setup.user, + table=test_setup.table, + values={f"field_{test_setup.link_row_field.id}": [other_row_3.id]}, + ) + row_3 = test_setup.row_handler.create_row( + user=test_setup.user, + table=test_setup.table, + values={f"field_{test_setup.link_row_field.id}": [other_row_2.id]}, + ) + row_4 = test_setup.row_handler.create_row( + user=test_setup.user, + table=test_setup.table, + values={f"field_{test_setup.link_row_field.id}": []}, + ) + + view_filter = data_fixture.create_view_filter( + view=test_setup.grid_view, + field=test_setup.lookup_field, + type="has_not_value_contains_word", + value="sentence", + ) + ids = [ + r.id + for r in test_setup.view_handler.apply_filters( + test_setup.grid_view, test_setup.model.objects.all() + ).all() + ] + assert len(ids) == 2 + assert row_2.id in ids + assert row_4.id in ids + + view_filter.value = "Sentence" + view_filter.save() + ids = [ + r.id + for r in test_setup.view_handler.apply_filters( + test_setup.grid_view, test_setup.model.objects.all() + ).all() + ] + assert len(ids) == 2 + assert row_2.id in ids + assert row_4.id in ids + + view_filter.value = "" + view_filter.save() + ids = [ + r.id + for r in test_setup.view_handler.apply_filters( + test_setup.grid_view, test_setup.model.objects.all() + ).all() + ] + assert len(ids) == 4 + + +@pytest.mark.parametrize( + "target_field_factory", [text_field_factory, long_text_field_factory] +) +@pytest.mark.django_db +def test_has_value_length_is_lower_than_text_field_types( + data_fixture, target_field_factory +): + test_setup = setup(data_fixture, target_field_factory) + + other_row_10a = test_setup.other_table_model.objects.create( + **{f"field_{test_setup.target_field.id}": "aaaaaaaaaa"} + ) + other_row_5a = test_setup.other_table_model.objects.create( + **{f"field_{test_setup.target_field.id}": "aaaaa"} + ) + other_row_0a = test_setup.other_table_model.objects.create( + **{f"field_{test_setup.target_field.id}": ""} + ) + row_1 = test_setup.row_handler.create_row( + user=test_setup.user, + table=test_setup.table, + values={f"field_{test_setup.link_row_field.id}": [other_row_10a.id]}, + ) + row_2 = test_setup.row_handler.create_row( + user=test_setup.user, + table=test_setup.table, + values={ + f"field_{test_setup.link_row_field.id}": [other_row_0a.id, other_row_10a.id] + }, + ) + row_3 = test_setup.row_handler.create_row( + user=test_setup.user, + table=test_setup.table, + values={f"field_{test_setup.link_row_field.id}": [other_row_5a.id]}, + ) + row_4 = test_setup.row_handler.create_row( + user=test_setup.user, + table=test_setup.table, + values={f"field_{test_setup.link_row_field.id}": []}, + ) + + view_filter = data_fixture.create_view_filter( + view=test_setup.grid_view, + field=test_setup.lookup_field, + type="has_value_length_is_lower_than", + value="10", + ) + ids = [ + r.id + for r in test_setup.view_handler.apply_filters( + test_setup.grid_view, test_setup.model.objects.all() + ).all() + ] + assert len(ids) == 2 + assert row_2.id in ids + assert row_3.id in ids + + view_filter.value = "5" + view_filter.save() + ids = [ + r.id + for r in test_setup.view_handler.apply_filters( + test_setup.grid_view, test_setup.model.objects.all() + ).all() + ] + assert len(ids) == 1 + assert row_2.id in ids + + view_filter.value = "11" + view_filter.save() + ids = [ + r.id + for r in test_setup.view_handler.apply_filters( + test_setup.grid_view, test_setup.model.objects.all() + ).all() + ] + assert len(ids) == 3 + assert row_1.id in ids + assert row_2.id in ids + assert row_3.id in ids + + view_filter.value = "" + view_filter.save() + ids = [ + r.id + for r in test_setup.view_handler.apply_filters( + test_setup.grid_view, test_setup.model.objects.all() + ).all() + ] + assert len(ids) == 4 diff --git a/changelog/entries/unreleased/feature/2727_add_array_filters_for_formula_arrays_based_on_text.json b/changelog/entries/unreleased/feature/2727_add_array_filters_for_formula_arrays_based_on_text.json new file mode 100644 index 000000000..6291857e3 --- /dev/null +++ b/changelog/entries/unreleased/feature/2727_add_array_filters_for_formula_arrays_based_on_text.json @@ -0,0 +1,7 @@ +{ + "type": "feature", + "message": "Add array filters for formula arrays based on text", + "issue_number": 2727, + "bullet_points": [], + "created_at": "2024-07-10" +} \ No newline at end of file diff --git a/web-frontend/locales/en.json b/web-frontend/locales/en.json index 40601fc3b..03b78eaa0 100644 --- a/web-frontend/locales/en.json +++ b/web-frontend/locales/en.json @@ -174,6 +174,15 @@ "password": "A write-only field that holds a hashed password. The value will be `null` if not set, or `true` if it has been set. It accepts a string to set it." }, "viewFilter": { + "hasEmptyValue": "has empty value", + "hasNotEmptyValue": "doesn't have empty value", + "hasValueEqual": "has value equal", + "hasNotValueEqual": "doesn't have value equal", + "hasValueContains": "has value contains", + "hasNotValueContains": "doesn't have value contains", + "hasValueContainsWord": "has value contains word", + "hasNotValueContainsWord": "doesn't have value contains word", + "hasValueLengthIsLowerThan": "has value length is lower than", "contains": "contains", "containsNot": "doesn't contain", "containsWord": "contains word", diff --git a/web-frontend/modules/database/arrayViewFilters.js b/web-frontend/modules/database/arrayViewFilters.js new file mode 100644 index 000000000..1e8ed18cc --- /dev/null +++ b/web-frontend/modules/database/arrayViewFilters.js @@ -0,0 +1,210 @@ +import ViewFilterTypeText from '@baserow/modules/database/components/view/ViewFilterTypeText' +import ViewFilterTypeNumber from '@baserow/modules/database/components/view/ViewFilterTypeNumber' +import { FormulaFieldType } from '@baserow/modules/database/fieldTypes' +import { ViewFilterType } from '@baserow/modules/database/viewFilters' + +export class HasEmptyValueViewFilterType extends ViewFilterType { + static getType() { + return 'has_empty_value' + } + + getName() { + const { i18n } = this.app + return i18n.t('viewFilter.hasEmptyValue') + } + + getCompatibleFieldTypes() { + return [FormulaFieldType.compatibleWithFormulaTypes('array(text)')] + } + + matches(cellValue, filterValue, field, fieldType) { + return fieldType.getHasEmptyValueFilterFunction(field)(cellValue) + } +} + +export class HasNotEmptyValueViewFilterType extends ViewFilterType { + static getType() { + return 'has_not_empty_value' + } + + getName() { + const { i18n } = this.app + return i18n.t('viewFilter.hasNotEmptyValue') + } + + getCompatibleFieldTypes() { + return [FormulaFieldType.compatibleWithFormulaTypes('array(text)')] + } + + matches(cellValue, filterValue, field, fieldType) { + return !fieldType.getHasEmptyValueFilterFunction(field)(cellValue) + } +} + +export class HasValueEqualViewFilterType extends ViewFilterType { + static getType() { + return 'has_value_equal' + } + + getName() { + const { i18n } = this.app + return i18n.t('viewFilter.hasValueEqual') + } + + getInputComponent(field) { + return ViewFilterTypeText + } + + getCompatibleFieldTypes() { + return [FormulaFieldType.compatibleWithFormulaTypes('array(text)')] + } + + matches(cellValue, filterValue, field, fieldType) { + return fieldType.hasValueEqualFilter(cellValue, filterValue, field) + } +} + +export class HasNotValueEqualViewFilterType extends ViewFilterType { + static getType() { + return 'has_not_value_equal' + } + + getName() { + const { i18n } = this.app + return i18n.t('viewFilter.hasNotValueEqual') + } + + getInputComponent(field) { + return ViewFilterTypeText + } + + getCompatibleFieldTypes() { + return [FormulaFieldType.compatibleWithFormulaTypes('array(text)')] + } + + matches(cellValue, filterValue, field, fieldType) { + return fieldType.hasNotValueEqualFilter(cellValue, filterValue, field) + } +} + +export class HasValueContainsViewFilterType extends ViewFilterType { + static getType() { + return 'has_value_contains' + } + + getName() { + const { i18n } = this.app + return i18n.t('viewFilter.hasValueContains') + } + + getInputComponent(field) { + return ViewFilterTypeText + } + + getCompatibleFieldTypes() { + return [FormulaFieldType.compatibleWithFormulaTypes('array(text)')] + } + + matches(cellValue, filterValue, field, fieldType) { + return fieldType.hasValueContainsFilter(cellValue, filterValue, field) + } +} + +export class HasNotValueContainsViewFilterType extends ViewFilterType { + static getType() { + return 'has_not_value_contains' + } + + getName() { + const { i18n } = this.app + return i18n.t('viewFilter.hasNotValueContains') + } + + getInputComponent(field) { + return ViewFilterTypeText + } + + getCompatibleFieldTypes() { + return [FormulaFieldType.compatibleWithFormulaTypes('array(text)')] + } + + matches(cellValue, filterValue, field, fieldType) { + return fieldType.hasNotValueContainsFilter(cellValue, filterValue, field) + } +} + +export class HasValueContainsWordViewFilterType extends ViewFilterType { + static getType() { + return 'has_value_contains_word' + } + + getName() { + const { i18n } = this.app + return i18n.t('viewFilter.hasValueContainsWord') + } + + getInputComponent(field) { + return ViewFilterTypeText + } + + getCompatibleFieldTypes() { + return [FormulaFieldType.compatibleWithFormulaTypes('array(text)')] + } + + matches(cellValue, filterValue, field, fieldType) { + return fieldType.hasValueContainsWordFilter(cellValue, filterValue, field) + } +} + +export class HasNotValueContainsWordViewFilterType extends ViewFilterType { + static getType() { + return 'has_not_value_contains_word' + } + + getName() { + const { i18n } = this.app + return i18n.t('viewFilter.hasNotValueContainsWord') + } + + getInputComponent(field) { + return ViewFilterTypeText + } + + getCompatibleFieldTypes() { + return [FormulaFieldType.compatibleWithFormulaTypes('array(text)')] + } + + matches(cellValue, filterValue, field, fieldType) { + return fieldType.hasNotValueContainsWordFilter( + cellValue, + filterValue, + field + ) + } +} + +export class HasValueLengthIsLowerThanViewFilterType extends ViewFilterType { + static getType() { + return 'has_value_length_is_lower_than' + } + + getName() { + const { i18n } = this.app + return i18n.t('viewFilter.hasValueLengthIsLowerThan') + } + + getInputComponent(field) { + return ViewFilterTypeNumber + } + + getCompatibleFieldTypes() { + return [FormulaFieldType.compatibleWithFormulaTypes('array(text)')] + } + + matches(cellValue, filterValue, field, fieldType) { + return fieldType.getHasValueLengthIsLowerThanFilterFunction(field)( + cellValue, + filterValue + ) + } +} diff --git a/web-frontend/modules/database/fieldFilterCompatibility.js b/web-frontend/modules/database/fieldFilterCompatibility.js new file mode 100644 index 000000000..d73abb984 --- /dev/null +++ b/web-frontend/modules/database/fieldFilterCompatibility.js @@ -0,0 +1,82 @@ +import { + genericHasValueEqualFilter, + genericHasValueContainsFilter, + genericHasValueContainsWordFilter, + genericHasEmptyValueFilter, + genericHasValueLengthLowerThanFilter, +} from '@baserow/modules/database/utils/fieldFilters' + +export function fieldSupportsFilter(fieldType, filterMixin) { + for (const [key, value] of Object.entries(filterMixin)) { + /* eslint no-prototype-builtins: "off" */ + if (!fieldType.prototype.hasOwnProperty(key)) { + fieldType.prototype[key] = value + } + } +} + +export const hasEmptyValueFilterMixin = { + getHasEmptyValueFilterFunction(field) { + return genericHasEmptyValueFilter + }, +} + +export const hasValueEqualFilterMixin = { + getHasValueEqualFilterFunction(field) { + return genericHasValueEqualFilter + }, + hasValueEqualFilter(cellValue, filterValue, field) { + return ( + filterValue === '' || + this.getHasValueEqualFilterFunction(field)(cellValue, filterValue) + ) + }, + hasNotValueEqualFilter(cellValue, filterValue, field) { + return ( + filterValue === '' || + !this.getHasValueEqualFilterFunction(field)(cellValue, filterValue) + ) + }, +} + +export const hasValueContainsFilterMixin = { + getHasValueContainsFilterFunction(field) { + return genericHasValueContainsFilter + }, + hasValueContainsFilter(cellValue, filterValue, field) { + return ( + filterValue === '' || + this.getHasValueContainsFilterFunction(field)(cellValue, filterValue) + ) + }, + hasNotValueContainsFilter(cellValue, filterValue, field) { + return ( + filterValue === '' || + !this.getHasValueContainsFilterFunction(field)(cellValue, filterValue) + ) + }, +} + +export const hasValueContainsWordFilterMixin = { + getHasValueContainsWordFilterFunction(field) { + return genericHasValueContainsWordFilter + }, + hasValueContainsWordFilter(cellValue, filterValue, field) { + return ( + filterValue === '' || + this.getHasValueContainsWordFilterFunction(field)(cellValue, filterValue) + ) + }, + hasNotValueContainsWordFilter(cellValue, filterValue, field) { + return ( + filterValue === '' || + !this.getHasValueContainsWordFilterFunction(field)(cellValue, filterValue) + ) + }, +} + +export const hasValueLengthIsLowerThanFilterMixin = { + getHasValueLengthIsLowerThanFilterFunction(field) { + return genericHasValueLengthLowerThanFilter + }, +} diff --git a/web-frontend/modules/database/fieldTypes.js b/web-frontend/modules/database/fieldTypes.js index a72e7500d..f547f51e3 100644 --- a/web-frontend/modules/database/fieldTypes.js +++ b/web-frontend/modules/database/fieldTypes.js @@ -15,7 +15,14 @@ import { isValidEmail, isValidURL, } from '@baserow/modules/core/utils/string' - +import { + fieldSupportsFilter, + hasEmptyValueFilterMixin, + hasValueContainsFilterMixin, + hasValueEqualFilterMixin, + hasValueContainsWordFilterMixin, + hasValueLengthIsLowerThanFilterMixin, +} from '@baserow/modules/database/fieldFilterCompatibility' import moment from '@baserow/modules/core/moment' import guessFormat from 'moment-guess' import { Registerable } from '@baserow/modules/core/registry' @@ -135,6 +142,12 @@ import FormViewFieldMultipleLinkRow from '@baserow/modules/database/components/v import FormViewFieldMultipleSelectCheckboxes from '@baserow/modules/database/components/view/form/FormViewFieldMultipleSelectCheckboxes' import FormViewFieldSingleSelectRadios from '@baserow/modules/database/components/view/form/FormViewFieldSingleSelectRadios' +import { + BaserowFormulaArrayType, + BaserowFormulaCharType, + BaserowFormulaTextType, +} from '@baserow/modules/database/formula/formulaTypes' + import { trueValues } from '@baserow/modules/core/utils/constants' import { getDateMomentFormat, @@ -3658,6 +3671,31 @@ export class FormulaFieldType extends FieldType { const subType = this.app.$registry.get('formula_type', field.formula_type) return subType.canRepresentFiles(field) } + + getHasEmptyValueFilterFunction(field) { + const subType = this.app.$registry.get('formula_type', field.formula_type) + return subType.getHasEmptyValueFilterFunction(field) + } + + getHasValueEqualFilterFunction(field) { + const subType = this.app.$registry.get('formula_type', field.formula_type) + return subType.getHasValueEqualFilterFunction(field) + } + + getHasValueContainsFilterFunction(field) { + const subType = this.app.$registry.get('formula_type', field.formula_type) + return subType.getHasValueContainsFilterFunction(field) + } + + getHasValueContainsWordFilterFunction(field) { + const subType = this.app.$registry.get('formula_type', field.formula_type) + return subType.getHasValueContainsWordFilterFunction(field) + } + + getHasValueLengthIsLowerThanFilterFunction(field) { + const subType = this.app.$registry.get('formula_type', field.formula_type) + return subType.getHasValueLengthIsLowerThanFilterFunction(field) + } } export class CountFieldType extends FormulaFieldType { @@ -4202,3 +4240,37 @@ export class PasswordFieldType extends FieldType { return RowHistoryFieldPassword } } + +fieldSupportsFilter(FormulaFieldType, hasEmptyValueFilterMixin) +fieldSupportsFilter(BaserowFormulaArrayType, hasEmptyValueFilterMixin) +fieldSupportsFilter(BaserowFormulaTextType, hasEmptyValueFilterMixin) +fieldSupportsFilter(BaserowFormulaCharType, hasEmptyValueFilterMixin) + +fieldSupportsFilter(FormulaFieldType, hasValueEqualFilterMixin) +fieldSupportsFilter(BaserowFormulaArrayType, hasValueEqualFilterMixin) +fieldSupportsFilter(BaserowFormulaTextType, hasValueEqualFilterMixin) +fieldSupportsFilter(BaserowFormulaCharType, hasValueEqualFilterMixin) + +fieldSupportsFilter(FormulaFieldType, hasValueContainsFilterMixin) +fieldSupportsFilter(BaserowFormulaArrayType, hasValueContainsFilterMixin) +fieldSupportsFilter(BaserowFormulaTextType, hasValueContainsFilterMixin) +fieldSupportsFilter(BaserowFormulaCharType, hasValueContainsFilterMixin) + +fieldSupportsFilter(FormulaFieldType, hasValueContainsWordFilterMixin) +fieldSupportsFilter(BaserowFormulaArrayType, hasValueContainsWordFilterMixin) +fieldSupportsFilter(BaserowFormulaTextType, hasValueContainsWordFilterMixin) +fieldSupportsFilter(BaserowFormulaCharType, hasValueContainsWordFilterMixin) + +fieldSupportsFilter(FormulaFieldType, hasValueLengthIsLowerThanFilterMixin) +fieldSupportsFilter( + BaserowFormulaArrayType, + hasValueLengthIsLowerThanFilterMixin +) +fieldSupportsFilter( + BaserowFormulaTextType, + hasValueLengthIsLowerThanFilterMixin +) +fieldSupportsFilter( + BaserowFormulaCharType, + hasValueLengthIsLowerThanFilterMixin +) diff --git a/web-frontend/modules/database/formula/formulaTypes.js b/web-frontend/modules/database/formula/formulaTypes.js index b6099dc7a..235fe2ba8 100644 --- a/web-frontend/modules/database/formula/formulaTypes.js +++ b/web-frontend/modules/database/formula/formulaTypes.js @@ -666,6 +666,46 @@ export class BaserowFormulaArrayType extends BaserowFormulaTypeDefinition { canGroupByInView() { return false } + + getHasEmptyValueFilterFunction(field) { + const subType = this.app.$registry.get( + 'formula_type', + field.array_formula_type + ) + return subType.getHasEmptyValueFilterFunction(field) + } + + getHasValueEqualFilterFunction(field) { + const subType = this.app.$registry.get( + 'formula_type', + field.array_formula_type + ) + return subType.getHasValueEqualFilterFunction(field) + } + + getHasValueContainsFilterFunction(field) { + const subType = this.app.$registry.get( + 'formula_type', + field.array_formula_type + ) + return subType.getHasValueContainsFilterFunction(field) + } + + getHasValueContainsWordFilterFunction(field) { + const subType = this.app.$registry.get( + 'formula_type', + field.array_formula_type + ) + return subType.getHasValueContainsWordFilterFunction(field) + } + + getHasValueLengthIsLowerThanFilterFunction(field) { + const subType = this.app.$registry.get( + 'formula_type', + field.array_formula_type + ) + return subType.getHasValueLengthIsLowerThanFilterFunction(field) + } } export class BaserowFormulaFileType extends BaserowFormulaTypeDefinition { diff --git a/web-frontend/modules/database/plugin.js b/web-frontend/modules/database/plugin.js index cca396549..c46cee5f6 100644 --- a/web-frontend/modules/database/plugin.js +++ b/web-frontend/modules/database/plugin.js @@ -95,6 +95,17 @@ import { DateAfterOrEqualViewFilterType, DateEqualsDayOfMonthViewFilterType, } from '@baserow/modules/database/viewFilters' +import { + HasValueEqualViewFilterType, + HasEmptyValueViewFilterType, + HasNotEmptyValueViewFilterType, + HasNotValueEqualViewFilterType, + HasValueContainsViewFilterType, + HasNotValueContainsViewFilterType, + HasValueContainsWordViewFilterType, + HasNotValueContainsWordViewFilterType, + HasValueLengthIsLowerThanViewFilterType, +} from '@baserow/modules/database/arrayViewFilters' import { CSVImporterType, PasteImporterType, @@ -427,6 +438,36 @@ export default (context) => { new DateAfterDaysAgoViewFilterType(context) ) // END + app.$registry.register('viewFilter', new HasEmptyValueViewFilterType(context)) + app.$registry.register( + 'viewFilter', + new HasNotEmptyValueViewFilterType(context) + ) + app.$registry.register('viewFilter', new HasValueEqualViewFilterType(context)) + app.$registry.register( + 'viewFilter', + new HasNotValueEqualViewFilterType(context) + ) + app.$registry.register( + 'viewFilter', + new HasValueContainsViewFilterType(context) + ) + app.$registry.register( + 'viewFilter', + new HasNotValueContainsViewFilterType(context) + ) + app.$registry.register( + 'viewFilter', + new HasValueContainsWordViewFilterType(context) + ) + app.$registry.register( + 'viewFilter', + new HasNotValueContainsWordViewFilterType(context) + ) + app.$registry.register( + 'viewFilter', + new HasValueLengthIsLowerThanViewFilterType(context) + ) app.$registry.register('viewFilter', new ContainsViewFilterType(context)) app.$registry.register('viewFilter', new ContainsNotViewFilterType(context)) app.$registry.register('viewFilter', new ContainsWordViewFilterType(context)) diff --git a/web-frontend/modules/database/utils/fieldFilters.js b/web-frontend/modules/database/utils/fieldFilters.js index 29aac60a1..6d7c36e1c 100644 --- a/web-frontend/modules/database/utils/fieldFilters.js +++ b/web-frontend/modules/database/utils/fieldFilters.js @@ -49,3 +49,91 @@ export function genericContainsWordFilter( filterValue = filterValue.replace(/[-[\]{}()*+?.,\\^$|#]/g, '\\$&') return humanReadableRowValue.match(new RegExp(`\\b${filterValue}\\b`)) } + +export function genericHasEmptyValueFilter(cellValue, filterValue) { + if (!Array.isArray(cellValue)) { + return false + } + + for (let i = 0; i < cellValue.length; i++) { + const value = cellValue[i].value + + if (value === '') { + return true + } + } + + return false +} + +export function genericHasValueEqualFilter(cellValue, filterValue) { + if (!Array.isArray(cellValue)) { + return false + } + + for (let i = 0; i < cellValue.length; i++) { + const value = cellValue[i].value + if (value === filterValue) { + return true + } + } + + return false +} + +export function genericHasValueContainsFilter(cellValue, filterValue) { + if (!Array.isArray(cellValue)) { + return false + } + + filterValue = filterValue.toString().toLowerCase().trim() + + for (let i = 0; i < cellValue.length; i++) { + const value = cellValue[i].value.toString().toLowerCase().trim() + + if (value.includes(filterValue)) { + return true + } + } + + return false +} + +export function genericHasValueContainsWordFilter(cellValue, filterValue) { + if (!Array.isArray(cellValue)) { + return false + } + + filterValue = filterValue.toString().toLowerCase().trim() + filterValue = filterValue.replace(/[-[\]{}()*+?.,\\^$|#]/g, '\\$&') + + for (let i = 0; i < cellValue.length; i++) { + if (cellValue[i].value == null) { + continue + } + const value = cellValue[i].value.toString().toLowerCase().trim() + if (value.match(new RegExp(`\\b${filterValue}\\b`))) { + return true + } + } + + return false +} + +export function genericHasValueLengthLowerThanFilter(cellValue, filterValue) { + if (!Array.isArray(cellValue)) { + return false + } + + for (let i = 0; i < cellValue.length; i++) { + if (cellValue[i].value == null) { + continue + } + const valueLength = cellValue[i].value.toString().length + if (valueLength < filterValue) { + return true + } + } + + return false +} diff --git a/web-frontend/modules/database/viewFilters.js b/web-frontend/modules/database/viewFilters.js index d638c32a3..ed9a8481e 100644 --- a/web-frontend/modules/database/viewFilters.js +++ b/web-frontend/modules/database/viewFilters.js @@ -197,7 +197,6 @@ export class EqualViewFilterType extends ViewFilterType { 'uuid', 'autonumber', 'duration', - FormulaFieldType.compatibleWithFormulaTypes('text', 'char', 'number'), ] } diff --git a/web-frontend/test/unit/database/arrayViewFiltersMatch.spec.js b/web-frontend/test/unit/database/arrayViewFiltersMatch.spec.js new file mode 100644 index 000000000..129c67799 --- /dev/null +++ b/web-frontend/test/unit/database/arrayViewFiltersMatch.spec.js @@ -0,0 +1,388 @@ +import { TestApp } from '@baserow/test/helpers/testApp' +import { + HasValueEqualViewFilterType, + HasNotValueEqualViewFilterType, + HasValueContainsViewFilterType, + HasNotValueContainsViewFilterType, + HasValueContainsWordViewFilterType, + HasNotValueContainsWordViewFilterType, + HasEmptyValueViewFilterType, + HasNotEmptyValueViewFilterType, + HasValueLengthIsLowerThanViewFilterType, +} from '@baserow/modules/database/arrayViewFilters' +import { FormulaFieldType } from '@baserow/modules/database/fieldTypes' + +describe('Text-based array view filters', () => { + let testApp = null + + beforeAll(() => { + testApp = new TestApp() + }) + + afterEach(() => { + testApp.afterEach() + }) + + const hasTextValueEqualCases = [ + { + cellValue: [], + filterValue: 'A', + expected: false, + }, + { + cellValue: [{ value: 'B' }, { value: 'A' }], + filterValue: 'A', + expected: true, + }, + { + cellValue: [{ value: 'a' }], + filterValue: 'A', + expected: false, + }, + { + cellValue: [{ value: 'Aa' }], + filterValue: 'A', + expected: false, + }, + ] + + const hasValueEqualSupportedFields = [ + { + TestFieldType: FormulaFieldType, + formula_type: 'array', + array_formula_type: 'text', + }, + ] + + describe.each(hasValueEqualSupportedFields)( + 'HasValueEqualViewFilterType %j', + (field) => { + test.each(hasTextValueEqualCases)( + 'filter matches values %j', + (testValues) => { + const fieldType = new field.TestFieldType({ + app: testApp._app, + }) + const result = new HasValueEqualViewFilterType({ + app: testApp._app, + }).matches( + testValues.cellValue, + testValues.filterValue, + field, + fieldType + ) + expect(result).toBe(testValues.expected) + } + ) + } + ) + + describe.each(hasValueEqualSupportedFields)( + 'HasNotValueEqualViewFilterType %j', + (field) => { + test.each(hasTextValueEqualCases)( + 'filter not matches values %j', + (testValues) => { + const fieldType = new field.TestFieldType({ + app: testApp._app, + }) + const result = new HasNotValueEqualViewFilterType({ + app: testApp._app, + }).matches( + testValues.cellValue, + testValues.filterValue, + field, + fieldType + ) + expect(result).toBe(!testValues.expected) + } + ) + } + ) + + const hasValueContainsCases = [ + { + cellValue: [], + filterValue: 'A', + expected: false, + }, + { + cellValue: [{ value: 'B' }, { value: 'Aa' }], + filterValue: 'A', + expected: true, + }, + { + cellValue: [{ value: 't a t' }], + filterValue: 'A', + expected: true, + }, + { + cellValue: [{ value: 'C' }], + filterValue: 'A', + expected: false, + }, + ] + + const hasValueContainsSupportedFields = [ + { + TestFieldType: FormulaFieldType, + formula_type: 'array', + array_formula_type: 'text', + }, + ] + + describe.each(hasValueContainsSupportedFields)( + 'HasValueContainsViewFilterType %j', + (field) => { + test.each(hasValueContainsCases)( + 'filter matches values %j', + (testValues) => { + const fieldType = new field.TestFieldType({ + app: testApp._app, + }) + const result = new HasValueContainsViewFilterType({ + app: testApp._app, + }).matches( + testValues.cellValue, + testValues.filterValue, + field, + fieldType + ) + expect(result).toBe(testValues.expected) + } + ) + } + ) + + describe.each(hasValueContainsSupportedFields)( + 'HasNotValueContainsViewFilterType %j', + (field) => { + test.each(hasValueContainsCases)( + 'filter not matches values %j', + (testValues) => { + const fieldType = new field.TestFieldType({ + app: testApp._app, + }) + const result = new HasNotValueContainsViewFilterType({ + app: testApp._app, + }).matches( + testValues.cellValue, + testValues.filterValue, + field, + fieldType + ) + expect(result).toBe(!testValues.expected) + } + ) + } + ) + + const hasValueContainsWordCases = [ + { + cellValue: [], + filterValue: 'Word', + expected: false, + }, + { + cellValue: [{ value: '...Word...' }, { value: 'Some sentence' }], + filterValue: 'Word', + expected: true, + }, + { + cellValue: [{ value: 'Word' }], + filterValue: 'ord', + expected: false, + }, + { + cellValue: [{ value: 'Some word in a sentence.' }], + filterValue: 'Word', + expected: true, + }, + { + cellValue: [{ value: 'Some Word in a sentence.' }], + filterValue: 'word', + expected: true, + }, + ] + + const hasValueContainsWordSupportedFields = [ + { + TestFieldType: FormulaFieldType, + formula_type: 'array', + array_formula_type: 'text', + }, + ] + + describe.each(hasValueContainsWordSupportedFields)( + 'HasValueContainsWordViewFilterType %j', + (field) => { + test.each(hasValueContainsWordCases)( + 'filter matches values %j', + (testValues) => { + const fieldType = new field.TestFieldType({ + app: testApp._app, + }) + const result = new HasValueContainsWordViewFilterType({ + app: testApp._app, + }).matches( + testValues.cellValue, + testValues.filterValue, + field, + fieldType + ) + expect(result).toBe(testValues.expected) + } + ) + } + ) + + describe.each(hasValueContainsWordSupportedFields)( + 'HasNotValueContainsWordViewFilterType %j', + (field) => { + test.each(hasValueContainsWordCases)( + 'filter not matches values %j', + (testValues) => { + const fieldType = new field.TestFieldType({ + app: testApp._app, + }) + const result = new HasNotValueContainsWordViewFilterType({ + app: testApp._app, + }).matches( + testValues.cellValue, + testValues.filterValue, + field, + fieldType + ) + expect(result).toBe(!testValues.expected) + } + ) + } + ) + + const hasEmptyValueCases = [ + { + cellValue: [], + expected: false, + }, + { + cellValue: [{ value: 'B' }, { value: '' }], + expected: true, + }, + { + cellValue: [{ value: '' }], + expected: true, + }, + { + cellValue: [{ value: 'C' }], + expected: false, + }, + ] + + const hasEmptyValueSupportedFields = [ + { + TestFieldType: FormulaFieldType, + formula_type: 'array', + array_formula_type: 'text', + }, + ] + + describe.each(hasEmptyValueSupportedFields)( + 'HasEmptyValueViewFilterType %j', + (field) => { + test.each(hasEmptyValueCases)( + 'filter matches values %j', + (testValues) => { + const fieldType = new field.TestFieldType({ + app: testApp._app, + }) + const result = new HasEmptyValueViewFilterType({ + app: testApp._app, + }).matches( + testValues.cellValue, + testValues.filterValue, + field, + fieldType + ) + expect(result).toBe(testValues.expected) + } + ) + } + ) + + describe.each(hasEmptyValueSupportedFields)( + 'HasNotEmptyValueViewFilterType %j', + (field) => { + test.each(hasEmptyValueCases)( + 'filter not matches values %j', + (testValues) => { + const fieldType = new field.TestFieldType({ + app: testApp._app, + }) + const result = new HasNotEmptyValueViewFilterType({ + app: testApp._app, + }).matches( + testValues.cellValue, + testValues.filterValue, + field, + fieldType + ) + expect(result).toBe(!testValues.expected) + } + ) + } + ) + + const hasLengthLowerThanValueCases = [ + { + cellValue: [], + filterValue: '1', + expected: false, + }, + { + cellValue: [{ value: 'aaaaa' }, { value: 'aaaaaaaaaa' }], + filterValue: '6', + expected: true, + }, + { + cellValue: [{ value: 'aaaaa' }], + filterValue: '5', + expected: false, + }, + { + cellValue: [{ value: '' }], + filterValue: '1', + expected: true, + }, + ] + + const hasLengthLowerThanSupportedFields = [ + { + TestFieldType: FormulaFieldType, + formula_type: 'array', + array_formula_type: 'text', + }, + ] + + describe.each(hasLengthLowerThanSupportedFields)( + 'HasValueLengthIsLowerThanViewFilterType %j', + (field) => { + test.each(hasLengthLowerThanValueCases)( + 'filter matches values %j', + (testValues) => { + const fieldType = new field.TestFieldType({ + app: testApp._app, + }) + const result = new HasValueLengthIsLowerThanViewFilterType({ + app: testApp._app, + }).matches( + testValues.cellValue, + testValues.filterValue, + field, + fieldType + ) + expect(result).toBe(testValues.expected) + } + ) + } + ) +})