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