From be27e89fb0813421ea65334436188162cbc16c4c Mon Sep 17 00:00:00 2001 From: Davide Silvestri <davide@baserow.io> Date: Tue, 12 Nov 2024 19:22:38 +0000 Subject: [PATCH] Add filters support for lookups of single select fields --- .../database/api/formula/serializers.py | 20 +- backend/src/baserow/contrib/database/apps.py | 4 + .../contrib/database/fields/field_types.py | 25 +- .../fields/filter_support/single_select.py | 96 +++ .../contrib/database/fields/registries.py | 5 + .../django_expressions.py | 42 ++ .../database/formula/types/formula_type.py | 4 + .../database/formula/types/formula_types.py | 33 +- .../database/views/array_view_filters.py | 48 +- .../database/view/test_view_array_filters.py | 607 +++++++++++++++++- ...t_for_lookups_of_single_select_fields.json | 7 + web-frontend/locales/en.json | 2 + web-frontend/modules/core/mixins.js | 8 +- .../modules/database/arrayFilterMixins.js | 102 +++ .../modules/database/arrayViewFilters.js | 66 +- .../modules/database/formula/formulaTypes.js | 59 +- web-frontend/modules/database/plugin.js | 10 + .../modules/database/utils/fieldFilters.js | 2 +- .../database/arrayViewFiltersMatch.spec.js | 313 +++++++++ 19 files changed, 1358 insertions(+), 95 deletions(-) create mode 100644 backend/src/baserow/contrib/database/fields/filter_support/single_select.py create mode 100644 changelog/entries/unreleased/feature/add_filters_support_for_lookups_of_single_select_fields.json diff --git a/backend/src/baserow/contrib/database/api/formula/serializers.py b/backend/src/baserow/contrib/database/api/formula/serializers.py index e815b2f4c..cdd72475c 100644 --- a/backend/src/baserow/contrib/database/api/formula/serializers.py +++ b/backend/src/baserow/contrib/database/api/formula/serializers.py @@ -5,9 +5,7 @@ from baserow.contrib.database.fields.dependencies.circular_reference_checker imp ) from baserow.contrib.database.fields.field_types import FormulaFieldType from baserow.contrib.database.fields.models import FormulaField -from baserow.contrib.database.formula.types.formula_types import ( - BaserowFormulaSingleSelectType, -) +from baserow.contrib.database.fields.registries import field_type_registry class TypeFormulaRequestSerializer(serializers.ModelSerializer): @@ -27,10 +25,14 @@ class BaserowFormulaSelectOptionsSerializer(serializers.ListField): from baserow.contrib.database.fields.models import SelectOption field = data.instance - if field.formula_type != BaserowFormulaSingleSelectType.type: - return [] + field_type = field_type_registry.get_by_model(field) - select_options = SelectOption.objects.filter( - field_id__in=get_all_field_dependencies(field) - ) - return [self.child.to_representation(item) for item in select_options] + # Select options are needed for view filters in the frontend, + # but let's avoid the potentially slow query if not required. + if field_type.can_represent_select_options(field): + select_options = SelectOption.objects.filter( + field_id__in=get_all_field_dependencies(field) + ) + return [self.child.to_representation(item) for item in select_options] + else: + return [] diff --git a/backend/src/baserow/contrib/database/apps.py b/backend/src/baserow/contrib/database/apps.py index 4c51f8a09..bb7cdc2c9 100755 --- a/backend/src/baserow/contrib/database/apps.py +++ b/backend/src/baserow/contrib/database/apps.py @@ -443,7 +443,9 @@ class DatabaseConfig(AppConfig): view_filter_type_registry.register(UserIsNotViewFilterType()) from .views.array_view_filters import ( + HasAnySelectOptionEqualViewFilterType, HasEmptyValueViewFilterType, + HasNoneSelectOptionEqualViewFilterType, HasNotEmptyValueViewFilterType, HasNotValueContainsViewFilterType, HasNotValueContainsWordViewFilterType, @@ -463,6 +465,8 @@ class DatabaseConfig(AppConfig): view_filter_type_registry.register(HasValueLengthIsLowerThanViewFilterType()) view_filter_type_registry.register(HasEmptyValueViewFilterType()) view_filter_type_registry.register(HasNotEmptyValueViewFilterType()) + view_filter_type_registry.register(HasAnySelectOptionEqualViewFilterType()) + view_filter_type_registry.register(HasNoneSelectOptionEqualViewFilterType()) from .views.view_aggregations import ( AverageViewAggregationType, diff --git a/backend/src/baserow/contrib/database/fields/field_types.py b/backend/src/baserow/contrib/database/fields/field_types.py index 980399e79..8c1ab84e5 100755 --- a/backend/src/baserow/contrib/database/fields/field_types.py +++ b/backend/src/baserow/contrib/database/fields/field_types.py @@ -4890,6 +4890,9 @@ class FormulaFieldType(FormulaArrayFilterSupport, ReadOnlyFieldType): def can_represent_files(self, field): return self.to_baserow_formula_type(field.specific).can_represent_files + def can_represent_select_options(self, field): + return self.to_baserow_formula_type(field.specific).can_represent_select_options + def get_permission_error_when_user_changes_field_to_depend_on_forbidden_field( self, user: AbstractUser, changed_field: Field, forbidden_field: Field ) -> Exception: @@ -5268,14 +5271,24 @@ class LookupFieldType(FormulaFieldType): "target_field_id", "target_field_name", ] - serializer_field_names = BASEROW_FORMULA_TYPE_ALLOWED_FIELDS + [ + request_serializer_field_names = ( + BASEROW_FORMULA_TYPE_REQUEST_SERIALIZER_FIELD_NAMES + + [ + "through_field_id", + "through_field_name", + "target_field_id", + "target_field_name", + "formula_type", + ] + ) + serializer_field_names = BASEROW_FORMULA_TYPE_SERIALIZER_FIELD_NAMES + [ "through_field_id", "through_field_name", "target_field_id", "target_field_name", "formula_type", ] - serializer_field_overrides = { + request_serializer_field_overrides = { "through_field_name": serializers.CharField( required=False, allow_blank=True, @@ -5311,14 +5324,6 @@ class LookupFieldType(FormulaFieldType): "error": serializers.CharField(required=False, read_only=True), } - @property - def request_serializer_field_names(self): - return self.serializer_field_names - - @property - def request_serializer_field_overrides(self): - return self.serializer_field_overrides - def before_create( self, table, primary, allowed_field_values, order, user, field_kwargs ): diff --git a/backend/src/baserow/contrib/database/fields/filter_support/single_select.py b/backend/src/baserow/contrib/database/fields/filter_support/single_select.py new file mode 100644 index 000000000..0a158fd80 --- /dev/null +++ b/backend/src/baserow/contrib/database/fields/filter_support/single_select.py @@ -0,0 +1,96 @@ +from functools import reduce +from typing import TYPE_CHECKING, List + +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 ( + JSONArrayContainsSelectOptionValueExpr, + JSONArrayContainsSelectOptionValueSimilarToExpr, + JSONArrayEqualSelectOptionIdExpr, +) + +from .base import ( + HasValueContainsFilterSupport, + HasValueContainsWordFilterSupport, + HasValueEmptyFilterSupport, + HasValueFilterSupport, +) + +if TYPE_CHECKING: + from baserow.contrib.database.fields.models import Field + + +class SingleSelectFormulaTypeFilterSupport( + HasValueEmptyFilterSupport, + HasValueFilterSupport, + HasValueContainsFilterSupport, + HasValueContainsWordFilterSupport, +): + def get_in_array_empty_query(self, field_name, model_field, field: "Field"): + return Q(**{f"{field_name}__contains": Value([{"value": None}], JSONField())}) + + def get_in_array_is_query( + self, + field_name: str, + value: str | List[str], + model_field: models.Field, + field: "Field", + ) -> OptionallyAnnotatedQ: + if not value: + return Q() + elif isinstance(value, str): + try: + # If the value is a single value it must be a valid ID. + int(value) + except ValueError: + return Q() + value = [value] + + annotations, q = {}, [] + for v in value: + hashed_value = hash(v) + annotation_key = f"{field_name}_has_value_{hashed_value}" + annotation_query = JSONArrayEqualSelectOptionIdExpr( + F(field_name), Value(f"{v}"), output_field=BooleanField() + ) + annotations[annotation_key] = annotation_query + q.append(Q(**{annotation_key: True})) + + return AnnotatedQ( + annotation=annotations, + q=reduce(lambda a, b: a | b, q), + ) + + def get_in_array_contains_query( + self, field_name: str, value: str, model_field: models.Field, field: "Field" + ) -> OptionallyAnnotatedQ: + annotation_query = JSONArrayContainsSelectOptionValueExpr( + 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}, + ) + + def get_in_array_contains_word_query( + self, field_name: str, value: str, model_field: models.Field, field: "Field" + ) -> OptionallyAnnotatedQ: + annotation_query = JSONArrayContainsSelectOptionValueSimilarToExpr( + F(field_name), Value(f"{value}"), 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}, + ) diff --git a/backend/src/baserow/contrib/database/fields/registries.py b/backend/src/baserow/contrib/database/fields/registries.py index f3c106cce..0799f737a 100644 --- a/backend/src/baserow/contrib/database/fields/registries.py +++ b/backend/src/baserow/contrib/database/fields/registries.py @@ -1725,6 +1725,11 @@ class FieldType( return False + def can_represent_select_options(self, field): + """Indicates whether the field can be used to represent select options.""" + + return False + def get_permission_error_when_user_changes_field_to_depend_on_forbidden_field( self, user: AbstractUser, changed_field: Field, forbidden_field: Field ) -> Exception: 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 7f8db40c6..7f168a51b 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 @@ -209,3 +209,45 @@ class JSONArrayContainsValueLengthLowerThanExpr(BaserowFilterExpression): """ # nosec B608 %(value)s ) # fmt: on + + +class JSONArrayEqualSelectOptionIdExpr(BaserowFilterExpression): + # fmt: off + template = ( + f""" + EXISTS( + SELECT filtered_field -> 'value' ->> 'id' + FROM JSONB_ARRAY_ELEMENTS(%(field_name)s) as filtered_field + WHERE (filtered_field -> 'value' ->> 'id') LIKE (%(value)s) + ) + """ # nosec B608 + ) + # fmt: on + + +class JSONArrayContainsSelectOptionValueExpr(BaserowFilterExpression): + # fmt: off + template = ( + f""" + EXISTS( + SELECT filtered_field -> 'value' ->> 'value' + FROM JSONB_ARRAY_ELEMENTS(%(field_name)s) as filtered_field + WHERE UPPER(filtered_field -> 'value' ->> 'value') LIKE UPPER(%(value)s) + ) + """ # nosec B608 + ) + # fmt: on + + +class JSONArrayContainsSelectOptionValueSimilarToExpr(BaserowFilterExpression): + # fmt: off + template = ( + r""" + EXISTS( + SELECT filtered_field -> 'value' ->> 'value' + FROM JSONB_ARRAY_ELEMENTS(%(field_name)s) as filtered_field + WHERE filtered_field -> 'value' ->> 'value' ~* ('\y' || %(value)s || '\y') + ) + """ # nosec B608 %(value)s + ) + # fmt: on diff --git a/backend/src/baserow/contrib/database/formula/types/formula_type.py b/backend/src/baserow/contrib/database/formula/types/formula_type.py index 2b7a40f18..b727e4cb7 100644 --- a/backend/src/baserow/contrib/database/formula/types/formula_type.py +++ b/backend/src/baserow/contrib/database/formula/types/formula_type.py @@ -258,6 +258,10 @@ class BaserowFormulaType(abc.ABC): def can_represent_files(self) -> bool: return False + @property + def can_represent_select_options(self) -> bool: + return False + @property def item_is_in_nested_value_object_when_in_array(self) -> bool: return True 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 d3b028000..5751a679f 100644 --- a/backend/src/baserow/contrib/database/formula/types/formula_types.py +++ b/backend/src/baserow/contrib/database/formula/types/formula_types.py @@ -29,6 +29,9 @@ from baserow.contrib.database.fields.filter_support.base import ( from baserow.contrib.database.fields.filter_support.exceptions import ( FilterNotSupportedException, ) +from baserow.contrib.database.fields.filter_support.single_select import ( + SingleSelectFormulaTypeFilterSupport, +) from baserow.contrib.database.fields.mixins import get_date_time_format from baserow.contrib.database.fields.utils.duration import ( D_H_M_S, @@ -1295,8 +1298,32 @@ class BaserowFormulaArrayType( def can_represent_files(self, field): return self.sub_type.can_represent_files(field) + def can_represent_select_options(self, field) -> bool: + return self.sub_type.can_represent_select_options(field) -class BaserowFormulaSingleSelectType(BaserowJSONBObjectBaseType): + @classmethod + def get_serializer_field_overrides(cls): + from baserow.contrib.database.api.fields.serializers import ( + SelectOptionSerializer, + ) + from baserow.contrib.database.api.formula.serializers import ( + BaserowFormulaSelectOptionsSerializer, + ) + + return { + "select_options": BaserowFormulaSelectOptionsSerializer( + child=SelectOptionSerializer(), + required=False, + allow_null=True, + read_only=True, + ) + } + + +class BaserowFormulaSingleSelectType( + SingleSelectFormulaTypeFilterSupport, + BaserowJSONBObjectBaseType, +): type = "single_select" baserow_field_type = "single_select" can_order_by = True @@ -1310,6 +1337,10 @@ class BaserowFormulaSingleSelectType(BaserowJSONBObjectBaseType): BaserowFormulaTextType, ] + @property + def can_represent_select_options(self) -> bool: + return True + @property def limit_comparable_types(self) -> List[Type["BaserowFormulaValidType"]]: return [] diff --git a/backend/src/baserow/contrib/database/views/array_view_filters.py b/backend/src/baserow/contrib/database/views/array_view_filters.py index 0bfb0dafb..9db172243 100644 --- a/backend/src/baserow/contrib/database/views/array_view_filters.py +++ b/backend/src/baserow/contrib/database/views/array_view_filters.py @@ -14,6 +14,7 @@ from baserow.contrib.database.fields.registries import field_type_registry from baserow.contrib.database.formula import BaserowFormulaTextType from baserow.contrib.database.formula.types.formula_types import ( BaserowFormulaCharType, + BaserowFormulaSingleSelectType, BaserowFormulaURLType, ) @@ -33,13 +34,13 @@ class HasEmptyValueViewFilterType(ViewFilterType): FormulaFieldType.array_of(BaserowFormulaTextType.type), FormulaFieldType.array_of(BaserowFormulaCharType.type), FormulaFieldType.array_of(BaserowFormulaURLType.type), + FormulaFieldType.array_of(BaserowFormulaSingleSelectType.type), ), ] def get_filter(self, field_name, value, model_field, field) -> OptionallyAnnotatedQ: + field_type = field_type_registry.get_by_model(field) try: - field_type = field_type_registry.get_by_model(field) - if not isinstance(field_type, HasValueEmptyFilterSupport): raise FilterNotSupportedException() @@ -66,13 +67,13 @@ class HasValueEqualViewFilterType(ViewFilterType): FormulaFieldType.array_of(BaserowFormulaTextType.type), FormulaFieldType.array_of(BaserowFormulaCharType.type), FormulaFieldType.array_of(BaserowFormulaURLType.type), + FormulaFieldType.array_of(BaserowFormulaSingleSelectType.type), ), ] def get_filter(self, field_name, value, model_field, field) -> OptionallyAnnotatedQ: + field_type = field_type_registry.get_by_model(field) try: - field_type = field_type_registry.get_by_model(field) - if not isinstance(field_type, HasValueFilterSupport): raise FilterNotSupportedException() @@ -101,13 +102,13 @@ class HasValueContainsViewFilterType(ViewFilterType): FormulaFieldType.array_of(BaserowFormulaTextType.type), FormulaFieldType.array_of(BaserowFormulaCharType.type), FormulaFieldType.array_of(BaserowFormulaURLType.type), + FormulaFieldType.array_of(BaserowFormulaSingleSelectType.type), ), ] def get_filter(self, field_name, value, model_field, field) -> OptionallyAnnotatedQ: + field_type = field_type_registry.get_by_model(field) try: - field_type = field_type_registry.get_by_model(field) - if not isinstance(field_type, HasValueContainsFilterSupport): raise FilterNotSupportedException() @@ -136,13 +137,13 @@ class HasValueContainsWordViewFilterType(ViewFilterType): FormulaFieldType.array_of(BaserowFormulaTextType.type), FormulaFieldType.array_of(BaserowFormulaCharType.type), FormulaFieldType.array_of(BaserowFormulaURLType.type), + FormulaFieldType.array_of(BaserowFormulaSingleSelectType.type), ), ] def get_filter(self, field_name, value, model_field, field) -> OptionallyAnnotatedQ: + field_type = field_type_registry.get_by_model(field) try: - field_type = field_type_registry.get_by_model(field) - if not isinstance(field_type, HasValueContainsWordFilterSupport): raise FilterNotSupportedException() @@ -175,9 +176,8 @@ class HasValueLengthIsLowerThanViewFilterType(ViewFilterType): ] def get_filter(self, field_name, value, model_field, field) -> OptionallyAnnotatedQ: + field_type = field_type_registry.get_by_model(field) try: - field_type = field_type_registry.get_by_model(field) - if not isinstance(field_type, HasValueLengthIsLowerThanFilterSupport): raise FilterNotSupportedException() @@ -186,3 +186,31 @@ class HasValueLengthIsLowerThanViewFilterType(ViewFilterType): ) except Exception: return self.default_filter_on_exception() + + +class HasAnySelectOptionEqualViewFilterType(HasValueEqualViewFilterType): + """ + This filter can be used to verify if any of the select options in an array + are equal to the option IDs provided. + """ + + type = "has_any_select_option_equal" + compatible_field_types = [ + FormulaFieldType.compatible_with_formula_types( + FormulaFieldType.array_of(BaserowFormulaSingleSelectType.type), + ), + ] + + def get_filter(self, field_name, value, model_field, field) -> OptionallyAnnotatedQ: + return super().get_filter(field_name, value.split(","), model_field, field) + + +class HasNoneSelectOptionEqualViewFilterType( + NotViewFilterTypeMixin, HasAnySelectOptionEqualViewFilterType +): + """ + This filter can be used to verify if none of the select options in an array are + equal to the option IDs provided + """ + + type = "has_none_select_option_equal" 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 index 2c1a6c0e4..7a94e78f9 100644 --- a/backend/tests/baserow/contrib/database/view/test_view_array_filters.py +++ b/backend/tests/baserow/contrib/database/view/test_view_array_filters.py @@ -49,6 +49,12 @@ 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 setup(data_fixture, target_field_factory): user = data_fixture.create_user() database = data_fixture.create_database_application(user=user) @@ -85,28 +91,47 @@ def setup(data_fixture, target_field_factory): ) +def text_field_value_factory(data_fixture, target_field, value=None): + return value or "" + + +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 + ) + + @pytest.mark.parametrize( - "target_field_factory", + "target_field_factory,target_field_value_factory", [ - text_field_factory, - long_text_field_factory, - email_field_factory, - phone_number_field_factory, - url_field_factory, + (text_field_factory, text_field_value_factory), + (long_text_field_factory, text_field_value_factory), + (email_field_factory, text_field_value_factory), + (phone_number_field_factory, text_field_value_factory), + (url_field_factory, text_field_value_factory), + (single_select_field_factory, single_select_field_value_factory), ], ) @pytest.mark.django_db -def test_has_empty_value_filter_text_field_types(data_fixture, target_field_factory): +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) + 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") + row_empty_value = target_field_value_factory(data_fixture, test_setup.target_field) + other_row_A = test_setup.other_table_model.objects.create( - **{f"field_{test_setup.target_field.id}": "A"} + **{f"field_{test_setup.target_field.id}": row_A_value} ) other_row_B = test_setup.other_table_model.objects.create( - **{f"field_{test_setup.target_field.id}": "B"} + **{f"field_{test_setup.target_field.id}": row_B_value} ) other_row_empty = test_setup.other_table_model.objects.create( - **{f"field_{test_setup.target_field.id}": ""} + **{f"field_{test_setup.target_field.id}": row_empty_value} ) row_1 = test_setup.row_handler.create_row( user=test_setup.user, @@ -189,29 +214,34 @@ def test_has_empty_value_filter_uuid_field_types(data_fixture): @pytest.mark.parametrize( - "target_field_factory", + "target_field_factory,target_field_value_factory", [ - text_field_factory, - long_text_field_factory, - email_field_factory, - phone_number_field_factory, - url_field_factory, + (text_field_factory, text_field_value_factory), + (long_text_field_factory, text_field_value_factory), + (email_field_factory, text_field_value_factory), + (phone_number_field_factory, text_field_value_factory), + (url_field_factory, text_field_value_factory), + (single_select_field_factory, single_select_field_value_factory), ], ) @pytest.mark.django_db def test_has_not_empty_value_filter_text_field_types( - data_fixture, target_field_factory + data_fixture, target_field_factory, target_field_value_factory ): test_setup = setup(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") + row_empty_value = target_field_value_factory(data_fixture, test_setup.target_field) + other_row_A = test_setup.other_table_model.objects.create( - **{f"field_{test_setup.target_field.id}": "A"} + **{f"field_{test_setup.target_field.id}": row_A_value} ) other_row_B = test_setup.other_table_model.objects.create( - **{f"field_{test_setup.target_field.id}": "B"} + **{f"field_{test_setup.target_field.id}": row_B_value} ) other_row_empty = test_setup.other_table_model.objects.create( - **{f"field_{test_setup.target_field.id}": ""} + **{f"field_{test_setup.target_field.id}": row_empty_value} ) row_1 = test_setup.row_handler.create_row( user=test_setup.user, @@ -1560,3 +1590,540 @@ def test_has_value_length_is_lower_than_uuid_field_types(data_fixture): ).all() ] assert len(ids) == 4 + + +@pytest.mark.django_db +def test_has_value_equal_filter_single_select_field(data_fixture): + test_setup = setup(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") + opt_c = data_fixture.create_select_option(field=test_setup.target_field, value="c") + + other_row_a = test_setup.other_table_model.objects.create( + **{f"field_{test_setup.target_field.id}": opt_a} + ) + other_row_b = test_setup.other_table_model.objects.create( + **{f"field_{test_setup.target_field.id}": opt_b} + ) + other_row_c = test_setup.other_table_model.objects.create( + **{f"field_{test_setup.target_field.id}": opt_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}": [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_c.id] + }, + ) + + view_filter = data_fixture.create_view_filter( + view=test_setup.grid_view, + field=test_setup.lookup_field, + type="has_value_equal", + value=str(opt_a.id), + ) + 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_2.id in ids + + view_filter.value = str(opt_b.id) + 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 + + +@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) + + 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") + opt_c = data_fixture.create_select_option(field=test_setup.target_field, value="c") + + other_row_a = test_setup.other_table_model.objects.create( + **{f"field_{test_setup.target_field.id}": opt_a} + ) + other_row_b = test_setup.other_table_model.objects.create( + **{f"field_{test_setup.target_field.id}": opt_b} + ) + other_row_c = test_setup.other_table_model.objects.create( + **{f"field_{test_setup.target_field.id}": opt_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}": [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_c.id] + }, + ) + + view_filter = data_fixture.create_view_filter( + view=test_setup.grid_view, + field=test_setup.lookup_field, + type="has_not_value_equal", + value=str(opt_c.id), + ) + 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_2.id in ids + + view_filter.value = str(opt_b.id) + 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 + + +@pytest.mark.django_db +def test_has_value_contains_filter_single_select_field(data_fixture): + test_setup = setup(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") + opt_c = data_fixture.create_select_option(field=test_setup.target_field, value="c") + + other_row_a = test_setup.other_table_model.objects.create( + **{f"field_{test_setup.target_field.id}": opt_a} + ) + other_row_b = test_setup.other_table_model.objects.create( + **{f"field_{test_setup.target_field.id}": opt_b} + ) + other_row_c = test_setup.other_table_model.objects.create( + **{f"field_{test_setup.target_field.id}": opt_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}": [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_c.id] + }, + ) + + view_filter = data_fixture.create_view_filter( + view=test_setup.grid_view, + field=test_setup.lookup_field, + type="has_value_contains", + 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) == 3 + assert row_1.id in ids + 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) == 1 + assert row_3.id in ids + + +@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) + + 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") + opt_c = data_fixture.create_select_option(field=test_setup.target_field, value="c") + + other_row_a = test_setup.other_table_model.objects.create( + **{f"field_{test_setup.target_field.id}": opt_a} + ) + other_row_b = test_setup.other_table_model.objects.create( + **{f"field_{test_setup.target_field.id}": opt_b} + ) + other_row_c = test_setup.other_table_model.objects.create( + **{f"field_{test_setup.target_field.id}": opt_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}": [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_c.id] + }, + ) + + view_filter = data_fixture.create_view_filter( + view=test_setup.grid_view, + field=test_setup.lookup_field, + type="has_not_value_contains", + 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) == 0 + + 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) == 2 + assert row_1.id in ids + assert row_2.id in ids + + +@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) + + 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") + opt_c = data_fixture.create_select_option(field=test_setup.target_field, value="ca") + + other_row_a = test_setup.other_table_model.objects.create( + **{f"field_{test_setup.target_field.id}": opt_a} + ) + other_row_b = test_setup.other_table_model.objects.create( + **{f"field_{test_setup.target_field.id}": opt_b} + ) + other_row_c = test_setup.other_table_model.objects.create( + **{f"field_{test_setup.target_field.id}": opt_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}": [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_c.id] + }, + ) + + view_filter = data_fixture.create_view_filter( + view=test_setup.grid_view, + field=test_setup.lookup_field, + type="has_value_contains_word", + 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_1.id in ids + assert row_2.id in ids + + view_filter.value = "ca" + 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_3.id in ids + + +@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) + + 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") + opt_c = data_fixture.create_select_option(field=test_setup.target_field, value="ca") + + other_row_a = test_setup.other_table_model.objects.create( + **{f"field_{test_setup.target_field.id}": opt_a} + ) + other_row_b = test_setup.other_table_model.objects.create( + **{f"field_{test_setup.target_field.id}": opt_b} + ) + other_row_c = test_setup.other_table_model.objects.create( + **{f"field_{test_setup.target_field.id}": opt_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}": [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_c.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="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_3.id in ids + + view_filter.value = "ca" + 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_2.id in ids + + +@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) + + 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") + opt_c = data_fixture.create_select_option(field=test_setup.target_field, value="c") + + other_row_a = test_setup.other_table_model.objects.create( + **{f"field_{test_setup.target_field.id}": opt_a} + ) + other_row_b = test_setup.other_table_model.objects.create( + **{f"field_{test_setup.target_field.id}": opt_b} + ) + other_row_c = test_setup.other_table_model.objects.create( + **{f"field_{test_setup.target_field.id}": opt_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}": [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_c.id] + }, + ) + + view_filter = data_fixture.create_view_filter( + view=test_setup.grid_view, + field=test_setup.lookup_field, + type="has_any_select_option_equal", + value=f"{opt_a.id},{opt_c.id}", + ) + 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 = f"{opt_b.id},{opt_c.id}" + 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 + + +@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) + + 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") + opt_c = data_fixture.create_select_option(field=test_setup.target_field, value="ca") + + other_row_a = test_setup.other_table_model.objects.create( + **{f"field_{test_setup.target_field.id}": opt_a} + ) + other_row_b = test_setup.other_table_model.objects.create( + **{f"field_{test_setup.target_field.id}": opt_b} + ) + other_row_c = test_setup.other_table_model.objects.create( + **{f"field_{test_setup.target_field.id}": opt_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}": [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_c.id] + }, + ) + + view_filter = data_fixture.create_view_filter( + view=test_setup.grid_view, + field=test_setup.lookup_field, + type="has_none_select_option_equal", + value=f"{opt_a.id},{opt_c.id}", + ) + 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 = f"{opt_b.id},{opt_c.id}" + 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 diff --git a/changelog/entries/unreleased/feature/add_filters_support_for_lookups_of_single_select_fields.json b/changelog/entries/unreleased/feature/add_filters_support_for_lookups_of_single_select_fields.json new file mode 100644 index 000000000..bb1be3701 --- /dev/null +++ b/changelog/entries/unreleased/feature/add_filters_support_for_lookups_of_single_select_fields.json @@ -0,0 +1,7 @@ +{ + "type": "feature", + "message": "Add filters support for lookups of single select fields.", + "issue_number": 3182, + "bullet_points": [], + "created_at": "2024-11-06" +} \ No newline at end of file diff --git a/web-frontend/locales/en.json b/web-frontend/locales/en.json index bea9a2b3b..2b521e82f 100644 --- a/web-frontend/locales/en.json +++ b/web-frontend/locales/en.json @@ -198,6 +198,8 @@ "hasValueContainsWord": "has value contains word", "hasNotValueContainsWord": "doesn't have value contains word", "hasValueLengthIsLowerThan": "has value length is lower than", + "hasAnySelectOptionEqual": "has any select option equal", + "hasNoneSelectOptionEqual": "doesn't have select option equal", "contains": "contains", "containsNot": "doesn't contain", "containsWord": "contains word", diff --git a/web-frontend/modules/core/mixins.js b/web-frontend/modules/core/mixins.js index 531f22fd5..333f1404f 100644 --- a/web-frontend/modules/core/mixins.js +++ b/web-frontend/modules/core/mixins.js @@ -36,14 +36,16 @@ export function mix(...chain) { const [baseClass, ...mixins] = chain.reverse() + class Mixed extends baseClass {} + for (const mixin of mixins) { for (const [key, value] of Object.entries(mixin)) { /* eslint no-prototype-builtins: "off" */ - if (!baseClass.prototype.hasOwnProperty(key)) { - baseClass.prototype[key] = value + if (!Mixed.prototype.hasOwnProperty(key)) { + Mixed.prototype[key] = value } } } - return baseClass + return Mixed } diff --git a/web-frontend/modules/database/arrayFilterMixins.js b/web-frontend/modules/database/arrayFilterMixins.js index 1be27893f..fd10cc9e6 100644 --- a/web-frontend/modules/database/arrayFilterMixins.js +++ b/web-frontend/modules/database/arrayFilterMixins.js @@ -71,3 +71,105 @@ export const hasValueLengthIsLowerThanFilterMixin = { return genericHasValueLengthLowerThanFilter }, } + +export const formulaArrayFilterMixin = { + getSubType(field) { + return this.app.$registry.get('formula_type', field.array_formula_type) + }, + + getHasEmptyValueFilterFunction(field) { + const subType = this.getSubType(field) + return subType.getHasEmptyValueFilterFunction(field) + }, + + getHasValueLengthIsLowerThanFilterFunction(field) { + const subType = this.getSubType(field) + return subType.getHasValueLengthIsLowerThanFilterFunction(field) + }, + + getHasValueContainsFilterFunction(field) { + const subType = this.getSubType(field) + return subType.getHasValueContainsFilterFunction(field) + }, + + getHasValueContainsWordFilterFunction(field) { + const subType = this.getSubType(field) + return subType.getHasValueContainsWordFilterFunction(field) + }, + + hasValueContainsWordFilter(cellValue, filterValue, field) { + const subType = this.getSubType(field) + return subType.hasValueContainsWordFilter(cellValue, filterValue, field) + }, + + hasNotValueContainsWordFilter(cellValue, filterValue, field) { + const subType = this.getSubType(field) + return subType.hasNotValueContainsWordFilter(cellValue, filterValue, field) + }, + + getHasValueEqualFilterFunction(field) { + const subType = this.getSubType(field) + return subType.getHasValueEqualFilterFunction(field) + }, + + hasValueEqualFilter(cellValue, filterValue, field) { + const subType = this.getSubType(field) + return subType.hasValueEqualFilter(cellValue, filterValue, field) + }, + + hasNotValueEqualFilter(cellValue, filterValue, field) { + const subType = this.getSubType(field) + return subType.hasNotValueEqualFilter(cellValue, filterValue, field) + }, +} + +export const hasSelectOptionIdEqualMixin = Object.assign( + {}, + hasValueEqualFilterMixin, + { + getHasValueEqualFilterFunction(field) { + const mapOptionIdsToValues = (cellVal) => + cellVal.map((v) => ({ + id: v.id, + value: String(v.value?.id || ''), + })) + const hasValueEqualFilter = (cellVal, fltValue) => + genericHasValueEqualFilter(mapOptionIdsToValues(cellVal), fltValue) + + return (cellValue, filterValue) => { + const filterValues = filterValue.trim().split(',') + return filterValues.reduce((acc, fltValue) => { + return acc || hasValueEqualFilter(cellValue, String(fltValue)) + }, false) + } + }, + } +) + +export const hasSelectOptionValueContainsFilterMixin = Object.assign( + {}, + hasValueContainsFilterMixin, + { + getHasValueContainsFilterFunction(field) { + return (cellValue, filterValue) => + genericHasValueContainsFilter( + cellValue.map((v) => ({ id: v.id, value: v.value?.value || '' })), + filterValue + ) + }, + } +) + +export const hasSelectOptionValueContainsWordFilterMixin = Object.assign( + {}, + hasValueContainsWordFilterMixin, + { + getHasValueContainsWordFilterFunction(field) { + return (cellValue, filterValue) => + genericHasValueContainsWordFilter( + cellValue.map((v) => ({ id: v.id, value: v.value?.value || '' })), + filterValue + ) + }, + } +) diff --git a/web-frontend/modules/database/arrayViewFilters.js b/web-frontend/modules/database/arrayViewFilters.js index f20e21c83..8b224e833 100644 --- a/web-frontend/modules/database/arrayViewFilters.js +++ b/web-frontend/modules/database/arrayViewFilters.js @@ -2,6 +2,8 @@ import ViewFilterTypeText from '@baserow/modules/database/components/view/ViewFi import ViewFilterTypeNumber from '@baserow/modules/database/components/view/ViewFilterTypeNumber' import { FormulaFieldType } from '@baserow/modules/database/fieldTypes' import { ViewFilterType } from '@baserow/modules/database/viewFilters' +import ViewFilterTypeSelectOptions from '@baserow/modules/database/components/view/ViewFilterTypeSelectOptions' +import ViewFilterTypeMultipleSelectOptions from '@baserow/modules/database/components/view/ViewFilterTypeMultipleSelectOptions' export class HasEmptyValueViewFilterType extends ViewFilterType { static getType() { @@ -18,6 +20,7 @@ export class HasEmptyValueViewFilterType extends ViewFilterType { FormulaFieldType.compatibleWithFormulaTypes('array(text)'), FormulaFieldType.compatibleWithFormulaTypes('array(char)'), FormulaFieldType.compatibleWithFormulaTypes('array(url)'), + FormulaFieldType.compatibleWithFormulaTypes('array(single_select)'), ] } @@ -41,6 +44,7 @@ export class HasNotEmptyValueViewFilterType extends ViewFilterType { FormulaFieldType.compatibleWithFormulaTypes('array(text)'), FormulaFieldType.compatibleWithFormulaTypes('array(char)'), FormulaFieldType.compatibleWithFormulaTypes('array(url)'), + FormulaFieldType.compatibleWithFormulaTypes('array(single_select)'), ] } @@ -60,7 +64,10 @@ export class HasValueEqualViewFilterType extends ViewFilterType { } getInputComponent(field) { - return ViewFilterTypeText + const mapping = { + single_select: ViewFilterTypeSelectOptions, + } + return mapping[field.array_formula_type] || ViewFilterTypeText } getCompatibleFieldTypes() { @@ -68,6 +75,7 @@ export class HasValueEqualViewFilterType extends ViewFilterType { FormulaFieldType.compatibleWithFormulaTypes('array(text)'), FormulaFieldType.compatibleWithFormulaTypes('array(char)'), FormulaFieldType.compatibleWithFormulaTypes('array(url)'), + FormulaFieldType.compatibleWithFormulaTypes('array(single_select)'), ] } @@ -87,7 +95,10 @@ export class HasNotValueEqualViewFilterType extends ViewFilterType { } getInputComponent(field) { - return ViewFilterTypeText + const mapping = { + single_select: ViewFilterTypeSelectOptions, + } + return mapping[field.array_formula_type] || ViewFilterTypeText } getCompatibleFieldTypes() { @@ -95,6 +106,7 @@ export class HasNotValueEqualViewFilterType extends ViewFilterType { FormulaFieldType.compatibleWithFormulaTypes('array(text)'), FormulaFieldType.compatibleWithFormulaTypes('array(char)'), FormulaFieldType.compatibleWithFormulaTypes('array(url)'), + FormulaFieldType.compatibleWithFormulaTypes('array(single_select)'), ] } @@ -122,6 +134,7 @@ export class HasValueContainsViewFilterType extends ViewFilterType { FormulaFieldType.compatibleWithFormulaTypes('array(text)'), FormulaFieldType.compatibleWithFormulaTypes('array(char)'), FormulaFieldType.compatibleWithFormulaTypes('array(url)'), + FormulaFieldType.compatibleWithFormulaTypes('array(single_select)'), ] } @@ -149,6 +162,7 @@ export class HasNotValueContainsViewFilterType extends ViewFilterType { FormulaFieldType.compatibleWithFormulaTypes('array(text)'), FormulaFieldType.compatibleWithFormulaTypes('array(char)'), FormulaFieldType.compatibleWithFormulaTypes('array(url)'), + FormulaFieldType.compatibleWithFormulaTypes('array(single_select)'), ] } @@ -176,6 +190,7 @@ export class HasValueContainsWordViewFilterType extends ViewFilterType { FormulaFieldType.compatibleWithFormulaTypes('array(text)'), FormulaFieldType.compatibleWithFormulaTypes('array(char)'), FormulaFieldType.compatibleWithFormulaTypes('array(url)'), + FormulaFieldType.compatibleWithFormulaTypes('array(single_select)'), ] } @@ -203,6 +218,7 @@ export class HasNotValueContainsWordViewFilterType extends ViewFilterType { FormulaFieldType.compatibleWithFormulaTypes('array(text)'), FormulaFieldType.compatibleWithFormulaTypes('array(char)'), FormulaFieldType.compatibleWithFormulaTypes('array(url)'), + FormulaFieldType.compatibleWithFormulaTypes('array(single_select)'), ] } @@ -244,3 +260,49 @@ export class HasValueLengthIsLowerThanViewFilterType extends ViewFilterType { ) } } + +export class HasAnySelectOptionEqualViewFilterType extends ViewFilterType { + static getType() { + return 'has_any_select_option_equal' + } + + getName() { + const { i18n } = this.app + return i18n.t('viewFilter.hasAnySelectOptionEqual') + } + + getInputComponent(field) { + return ViewFilterTypeMultipleSelectOptions + } + + getCompatibleFieldTypes() { + return [FormulaFieldType.compatibleWithFormulaTypes('array(single_select)')] + } + + matches(cellValue, filterValue, field, fieldType) { + return fieldType.hasValueEqualFilter(cellValue, filterValue, field) + } +} + +export class HasNoneSelectOptionEqualViewFilterType extends ViewFilterType { + static getType() { + return 'has_none_select_option_equal' + } + + getName() { + const { i18n } = this.app + return i18n.t('viewFilter.hasNoneSelectOptionEqual') + } + + getInputComponent(field) { + return ViewFilterTypeMultipleSelectOptions + } + + getCompatibleFieldTypes() { + return [FormulaFieldType.compatibleWithFormulaTypes('array(single_select)')] + } + + matches(cellValue, filterValue, field, fieldType) { + return fieldType.hasNotValueEqualFilter(cellValue, filterValue, field) + } +} diff --git a/web-frontend/modules/database/formula/formulaTypes.js b/web-frontend/modules/database/formula/formulaTypes.js index 5a883e620..0a1a8ee3f 100644 --- a/web-frontend/modules/database/formula/formulaTypes.js +++ b/web-frontend/modules/database/formula/formulaTypes.js @@ -53,6 +53,10 @@ import { hasValueContainsFilterMixin, hasValueContainsWordFilterMixin, hasValueLengthIsLowerThanFilterMixin, + hasSelectOptionIdEqualMixin, + hasSelectOptionValueContainsFilterMixin, + hasSelectOptionValueContainsWordFilterMixin, + formulaArrayFilterMixin, } from '@baserow/modules/database/arrayFilterMixins' import _ from 'lodash' @@ -517,11 +521,7 @@ export class BaserowFormulaInvalidType extends BaserowFormulaTypeDefinition { } export class BaserowFormulaArrayType extends mix( - hasEmptyValueFilterMixin, - hasValueEqualFilterMixin, - hasValueContainsFilterMixin, - hasValueContainsWordFilterMixin, - hasValueLengthIsLowerThanFilterMixin, + formulaArrayFilterMixin, BaserowFormulaTypeDefinition ) { static getType() { @@ -705,38 +705,6 @@ export class BaserowFormulaArrayType extends mix( ) 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 { @@ -842,7 +810,13 @@ export class BaserowFormulaFileType extends BaserowFormulaTypeDefinition { } } -export class BaserowFormulaSingleSelectType extends BaserowFormulaTypeDefinition { +export class BaserowFormulaSingleSelectType extends mix( + hasEmptyValueFilterMixin, + hasSelectOptionIdEqualMixin, + hasSelectOptionValueContainsFilterMixin, + hasSelectOptionValueContainsWordFilterMixin, + BaserowFormulaTypeDefinition +) { static getType() { return 'single_select' } @@ -984,7 +958,14 @@ export class BaserowFormulaLinkType extends BaserowFormulaTypeDefinition { } } -export class BaserowFormulaURLType extends BaserowFormulaTypeDefinition { +export class BaserowFormulaURLType extends mix( + hasEmptyValueFilterMixin, + hasValueEqualFilterMixin, + hasValueContainsFilterMixin, + hasValueContainsWordFilterMixin, + hasValueLengthIsLowerThanFilterMixin, + BaserowFormulaTypeDefinition +) { static getType() { return 'url' } diff --git a/web-frontend/modules/database/plugin.js b/web-frontend/modules/database/plugin.js index 14b5ef8e2..46d12e1bc 100644 --- a/web-frontend/modules/database/plugin.js +++ b/web-frontend/modules/database/plugin.js @@ -105,6 +105,8 @@ import { HasValueContainsWordViewFilterType, HasNotValueContainsWordViewFilterType, HasValueLengthIsLowerThanViewFilterType, + HasAnySelectOptionEqualViewFilterType, + HasNoneSelectOptionEqualViewFilterType, } from '@baserow/modules/database/arrayViewFilters' import { CSVImporterType, @@ -487,6 +489,14 @@ export default (context) => { 'viewFilter', new HasValueLengthIsLowerThanViewFilterType(context) ) + app.$registry.register( + 'viewFilter', + new HasAnySelectOptionEqualViewFilterType(context) + ) + app.$registry.register( + 'viewFilter', + new HasNoneSelectOptionEqualViewFilterType(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 d41c70337..77f450234 100644 --- a/web-frontend/modules/database/utils/fieldFilters.js +++ b/web-frontend/modules/database/utils/fieldFilters.js @@ -59,7 +59,7 @@ export function genericHasEmptyValueFilter(cellValue, filterValue) { for (let i = 0; i < cellValue.length; i++) { const value = cellValue[i].value - if (value === '') { + if (value === '' || value === null) { return true } } diff --git a/web-frontend/test/unit/database/arrayViewFiltersMatch.spec.js b/web-frontend/test/unit/database/arrayViewFiltersMatch.spec.js index 6656f4a30..7abdf5196 100644 --- a/web-frontend/test/unit/database/arrayViewFiltersMatch.spec.js +++ b/web-frontend/test/unit/database/arrayViewFiltersMatch.spec.js @@ -435,4 +435,317 @@ describe('Text-based array view filters', () => { ) } ) + + const hasSelectOptionsEqualCases = [ + { + cellValue: [], + filterValue: '1', + expected: false, + }, + { + cellValue: [ + { value: { id: 2, value: 'B' } }, + { value: { id: 1, value: 'A' } }, + ], + filterValue: '1', + expected: true, + }, + { + cellValue: [{ value: { id: 1, value: 'A' } }], + filterValue: '2', + expected: false, + }, + { + cellValue: [{ value: { id: 3, value: 'Aa' } }], + filterValue: '1', + expected: false, + }, + ] + + const hasSelectOptionEqualSupportedFields = [ + { + TestFieldType: FormulaFieldType, + formula_type: 'array', + array_formula_type: 'single_select', + }, + ] + + describe.each(hasSelectOptionEqualSupportedFields)( + 'HasValueEqualViewFilterType %j', + (field) => { + test.each(hasSelectOptionsEqualCases)( + '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(hasSelectOptionEqualSupportedFields)( + 'HasNotValueEqualViewFilterType %j', + (field) => { + test.each(hasSelectOptionsEqualCases)( + '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 hasSelectOptionContainsCases = [ + { + cellValue: [], + filterValue: 'A', + expected: false, + }, + { + cellValue: [ + { value: { id: 2, value: 'B' } }, + { value: { id: 1, value: 'A' } }, + ], + filterValue: 'A', + expected: true, + }, + { + cellValue: [{ value: { id: 1, value: 'A' } }], + filterValue: 'B', + expected: false, + }, + { + cellValue: [{ value: { id: 3, value: 'Aa' } }], + filterValue: 'a', + expected: true, + }, + ] + + const hasSelectOptionContainsSupportedFields = [ + { + TestFieldType: FormulaFieldType, + formula_type: 'array', + array_formula_type: 'single_select', + }, + ] + + describe.each(hasSelectOptionContainsSupportedFields)( + 'HasValueContainsViewFilterType %j', + (field) => { + test.each(hasSelectOptionContainsCases)( + '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(hasSelectOptionContainsSupportedFields)( + 'HasNotValueContainsViewFilterType %j', + (field) => { + test.each(hasSelectOptionContainsCases)( + '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 hasSelectOptionContainsWordCases = [ + { + cellValue: [], + filterValue: 'A', + expected: false, + }, + { + cellValue: [ + { value: { id: 2, value: 'B' } }, + { value: { id: 1, value: 'Aa' } }, + ], + filterValue: 'Aa', + expected: true, + }, + { + cellValue: [{ value: { id: 1, value: 'A' } }], + filterValue: 'B', + expected: false, + }, + { + cellValue: [{ value: { id: 3, value: 'Aa' } }], + filterValue: 'a', + expected: false, + }, + ] + + const hasSelectOptionsContainsWordSupportedFields = [ + { + TestFieldType: FormulaFieldType, + formula_type: 'array', + array_formula_type: 'single_select', + }, + ] + + describe.each(hasSelectOptionsContainsWordSupportedFields)( + 'HasValueContainsWordViewFilterType %j', + (field) => { + test.each(hasSelectOptionContainsWordCases)( + '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(hasSelectOptionsContainsWordSupportedFields)( + 'HasNotValueContainsWordViewFilterType %j', + (field) => { + test.each(hasSelectOptionContainsWordCases)( + '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 hasEmptySelectOptionsCases = [ + { + cellValue: [], + expected: false, + }, + { + cellValue: [{ value: { id: 1, value: 'a' } }, { value: null }], + expected: true, + }, + { + cellValue: [{ value: null }], + expected: true, + }, + { + cellValue: [{ value: { id: 2, value: 'b' } }], + expected: false, + }, + ] + + const hasEmptySelectOptionSupportedFields = [ + { + TestFieldType: FormulaFieldType, + formula_type: 'array', + array_formula_type: 'single_select', + }, + ] + + describe.each(hasEmptySelectOptionSupportedFields)( + 'HasEmptyValueViewFilterType %j', + (field) => { + test.each(hasEmptySelectOptionsCases)( + 'filter not 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(hasEmptySelectOptionSupportedFields)( + 'HasNotEmptyValueViewFilterType %j', + (field) => { + test.each(hasEmptySelectOptionsCases)( + '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) + } + ) + } + ) })