diff --git a/backend/src/baserow/contrib/database/fields/filter_support/base.py b/backend/src/baserow/contrib/database/fields/filter_support/base.py index 43fa9f536c..4c583bc1a2 100644 --- a/backend/src/baserow/contrib/database/fields/filter_support/base.py +++ b/backend/src/baserow/contrib/database/fields/filter_support/base.py @@ -30,7 +30,7 @@ class HasValueEmptyFilterSupport: def get_in_array_empty_value(self, field: "Field") -> Any: """ - Returns a sigle value or a list of values to use for filtering empty values in + Returns a single value or a list of values to use for filtering empty values in an arrays with the `get_jsonb_has_any_in_value_filter_expr`. See `get_in_array_empty_query` and `get_all_empty_query` for more details on how this value is used. 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 ff836ef55f..1b378a1fcb 100644 --- a/backend/src/baserow/contrib/database/formula/types/formula_types.py +++ b/backend/src/baserow/contrib/database/formula/types/formula_types.py @@ -6,7 +6,9 @@ from django.contrib.postgres.fields import ArrayField, JSONField from django.db import models -from django.db.models import Expression, F, Func, Q, QuerySet, TextField, Value +from django.db.models import Expression, F +from django.db.models import Field as DjangoField +from django.db.models import Func, Q, QuerySet, TextField, Value from django.db.models.functions import Cast, Concat from dateutil import parser @@ -1694,7 +1696,11 @@ def contains_word_query(self, field_name, value, model_field, field): class BaserowFormulaMultipleCollaboratorsType( - HasValueEmptyFilterSupport, BaserowJSONBObjectBaseType + HasValueContainsWordFilterSupport, + HasValueContainsFilterSupport, + HasValueEmptyFilterSupport, + HasValueEqualFilterSupport, + BaserowJSONBObjectBaseType, ): type = "multiple_collaborators" baserow_field_type = "multiple_collaborators" @@ -1702,6 +1708,40 @@ class BaserowFormulaMultipleCollaboratorsType( can_order_by_in_array = False can_group_by = False + def get_in_array_contains_word_query( + self, field_name: str, value: str, model_field: DjangoField, field: "Field" + ) -> OptionallyAnnotatedQ: + return get_jsonb_contains_word_filter_expr( + model_field, value, query_path="$[*].value.first_name" + ) + + def get_in_array_contains_query( + self, field_name: str, value: str, model_field: DjangoField, field: "Field" + ) -> OptionallyAnnotatedQ: + return get_jsonb_contains_filter_expr( + model_field, value, query_path="$[*].value.first_name" + ) + + def get_in_array_is_query( + self, field_name: str, value: str, model_field: DjangoField, field: "Field" + ) -> OptionallyAnnotatedQ: + try: + value = [int(value)] + + except (TypeError, ValueError): + return Q() + + return get_jsonb_has_any_in_value_filter_expr( + model_field, value, query_path="$[*].value.id" + ) + + def get_in_array_empty_query( + self, field_name: str, model_field: DjangoField, field: "Field" + ) -> OptionallyAnnotatedQ: + return get_jsonb_has_any_in_value_filter_expr( + model_field, [0], query_path="$[*].value.size()" + ) + def get_all_empty_query( self, field_name: str, model_field: Field, field, in_array: bool = True ) -> OptionallyAnnotatedQ: 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 554d964254..2796c5240d 100644 --- a/backend/src/baserow/contrib/database/views/array_view_filters.py +++ b/backend/src/baserow/contrib/database/views/array_view_filters.py @@ -18,6 +18,7 @@ ) from baserow.contrib.database.fields.registries import field_type_registry from baserow.contrib.database.formula import ( + BaserowFormulaMultipleCollaboratorsType, BaserowFormulaNumberType, BaserowFormulaTextType, ) @@ -53,6 +54,7 @@ class HasEmptyValueViewFilterType(ViewFilterType): FormulaFieldType.array_of(BaserowFormulaSingleSelectType.type), FormulaFieldType.array_of(BaserowFormulaNumberType.type), FormulaFieldType.array_of(BaserowFormulaMultipleSelectType.type), + FormulaFieldType.array_of(BaserowFormulaMultipleCollaboratorsType.type), ), ] @@ -121,6 +123,7 @@ class HasValueEqualViewFilterType(ComparisonHasValueFilter): FormulaFieldType.array_of(BaserowFormulaSingleSelectType.type), FormulaFieldType.array_of(BaserowFormulaNumberType.type), FormulaFieldType.array_of(BaserowFormulaMultipleSelectType.type), + FormulaFieldType.array_of(BaserowFormulaMultipleCollaboratorsType.type), ), ] @@ -153,6 +156,7 @@ class HasValueContainsViewFilterType(ViewFilterType): FormulaFieldType.array_of(BaserowFormulaSingleSelectType.type), FormulaFieldType.array_of(BaserowFormulaNumberType.type), FormulaFieldType.array_of(BaserowFormulaMultipleSelectType.type), + FormulaFieldType.array_of(BaserowFormulaMultipleCollaboratorsType.type), ), ] @@ -185,6 +189,7 @@ class HasValueContainsWordViewFilterType(ViewFilterType): FormulaFieldType.array_of(BaserowFormulaURLType.type), FormulaFieldType.array_of(BaserowFormulaSingleSelectType.type), FormulaFieldType.array_of(BaserowFormulaMultipleSelectType.type), + FormulaFieldType.array_of(BaserowFormulaMultipleCollaboratorsType.type), ), ] diff --git a/backend/tests/baserow/contrib/database/utils.py b/backend/tests/baserow/contrib/database/utils.py index 7d5146235c..fc94bb7b93 100644 --- a/backend/tests/baserow/contrib/database/utils.py +++ b/backend/tests/baserow/contrib/database/utils.py @@ -74,6 +74,7 @@ class LookupFieldSetup: target_field: Field row_handler: RowHandler view_handler: ViewHandler + extra: dict @dataclasses.dataclass @@ -253,6 +254,7 @@ def setup_linked_table_and_lookup( lookup_field=lookup_field, view_handler=view_handler, model=model, + extra={}, ) 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 2db8946838..629d3dc546 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 @@ -5,7 +5,9 @@ from freezegun import freeze_time from pytest_unordered import unordered +from baserow.contrib.database.fields.handler import FieldHandler from baserow.contrib.database.rows.handler import RowHandler +from baserow.contrib.database.table.handler import TableHandler from baserow.contrib.database.views.array_view_filters import ( HasDateAfterViewFilterType, HasDateBeforeViewFilterType, @@ -30,6 +32,7 @@ DateIsOnOrBeforeMultiStepFilterType, DateIsWithinMultiStepFilterType, ) +from baserow.core.handler import CoreHandler from tests.baserow.contrib.database.utils import ( boolean_field_factory, date_field_factory, @@ -2524,6 +2527,108 @@ def test_has_none_select_option_equal_filter_single_select_field(data_fixture): assert row_2.id in ids +def setup_multiple_collaborators_fields(data_fixture): + test_setup = setup_linked_table_and_lookup( + data_fixture, multiple_collaborators_field_factory + ) + + # tables layout: + # table A: + # row A: one collab, Collaborator A + # row B: two collabs, Collaborator A, Collaborator B + # row C: no collaborator, + # table B (test table for filters): + # one value row, [ row A ] + # two values row, [row A, row B] + # two values, one empty, [row A, row C - empty ] + # no values, [] + + user = test_setup.user + user.first_name = "derp" + user.save() + table = test_setup.table + other_table = test_setup.other_table + other_user_A = data_fixture.create_user(first_name="test user") + other_user_B = data_fixture.create_user(first_name="other user") + database = test_setup.table.database + workspace = database.workspace + chandler = CoreHandler() + thandler = TableHandler() + fhandler = FieldHandler() + rhandler = RowHandler() + + chandler.add_user_to_workspace(workspace, other_user_A) + chandler.add_user_to_workspace(workspace, other_user_B) + text_field_a = data_fixture.create_text_field( + table=table, user=user, name="pk", primary=True + ) + text_field_b = data_fixture.create_text_field( + table=other_table, user=user, name="pk", primary=True + ) + + related_table_rows = [ + { + text_field_b.db_column: "relA", + test_setup.target_field.db_column: [other_user_A.id], + }, + { + text_field_b.db_column: "relB", + test_setup.target_field.db_column: [other_user_A.id, other_user_B.id], + }, + {text_field_b.db_column: "relC", test_setup.target_field.db_column: []}, + ] + + related_rows = rhandler.force_create_rows( + user, + table=other_table, + rows_values=related_table_rows, + send_webhook_events=False, + send_realtime_update=False, + ) + + related_rows = { + getattr(r, text_field_b.db_column): r for r in related_rows.created_rows + } + + table_rows = [ + { + text_field_a.db_column: "A", + test_setup.link_row_field.db_column: [related_rows["relA"].id], + }, + { + text_field_a.db_column: "B", + test_setup.link_row_field.db_column: [ + related_rows["relA"].id, + related_rows["relB"].id, + ], + }, + { + text_field_a.db_column: "C", + test_setup.link_row_field.db_column: [ + related_rows["relA"].id, + related_rows["relC"].id, + ], + }, + {text_field_a.db_column: "D", test_setup.link_row_field.db_column: []}, + ] + created_rows = rhandler.force_create_rows( + user=user, + table=table, + rows_values=table_rows, + send_webhook_events=False, + send_realtime_update=False, + ) + + created_rows = { + getattr(r, text_field_a.db_column): r for r in created_rows.created_rows + } + + test_setup.extra["other_users"] = { + u.first_name: u for u in [other_user_A, other_user_B] + } + return test_setup, created_rows, related_rows + + def setup_multiple_select_rows(data_fixture): test_setup = setup_linked_table_and_lookup( data_fixture, multiple_select_field_factory @@ -2576,6 +2681,150 @@ def setup_multiple_select_rows(data_fixture): return test_setup, [row_1, row_2, row_3], [*row_A_value, *row_B_value] +@pytest.mark.django_db +@pytest.mark.field_multiple_collaborators +def test_has_or_has_not_empty_value_filter_multiple_collaborators_lookup_type( + data_fixture, +): + test_setup, created_rows, related_rows = setup_multiple_collaborators_fields( + data_fixture + ) + + view_filter = data_fixture.create_view_filter( + view=test_setup.grid_view, + field=test_setup.lookup_field, + type="has_empty_value", + value="", + ) + ids = [ + r.id + for r in test_setup.view_handler.apply_filters( + test_setup.grid_view, test_setup.model.objects.all() + ).all() + ] + assert len(ids) == 1 + assert created_rows["C"].id in ids + + view_filter.type = "has_not_empty_value" + view_filter.save() + + ids = [ + r.id + for r in test_setup.view_handler.apply_filters( + test_setup.grid_view, test_setup.model.objects.all() + ).all() + ] + assert ids == [created_rows["A"].id, created_rows["B"].id, created_rows["D"].id] + + +@pytest.mark.django_db +@pytest.mark.field_multiple_collaborators +def test_has_or_doesnt_have_value_equal_filter_multiple_collaborators_lookup_field_types( + data_fixture, +): + test_setup, created_rows, related_rows = setup_multiple_collaborators_fields( + data_fixture + ) + + view_filter = data_fixture.create_view_filter( + view=test_setup.grid_view, + field=test_setup.lookup_field, + type="has_value_equal", + value=str(test_setup.extra["other_users"]["other user"].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) == 1 + assert created_rows["B"].id in ids + + view_filter.type = "has_not_value_equal" + 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 ids == [created_rows[row_id].id for row_id in ["A", "C", "D"]] + + +@pytest.mark.django_db +@pytest.mark.field_multiple_collaborators +def test_has_or_doesnt_have_value_contains_filter_multiple_collaborators_lookup_field_types( + data_fixture, +): + test_setup, created_rows, related_rows = setup_multiple_collaborators_fields( + data_fixture + ) + + view_filter = data_fixture.create_view_filter( + view=test_setup.grid_view, + field=test_setup.lookup_field, + type="has_value_contains", + value="oth", + ) + 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 created_rows["B"].id in ids + + view_filter.type = "has_not_value_contains" + 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 ids == [created_rows[row_id].id for row_id in ["A", "C", "D"]] + + +@pytest.mark.django_db +@pytest.mark.field_multiple_collaborators +def test_has_or_doesnt_have_value_contains_word_filter_multiple_collaborators_lookup_field_types( + data_fixture, +): + test_setup, created_rows, related_rows = setup_multiple_collaborators_fields( + data_fixture + ) + + view_filter = data_fixture.create_view_filter( + view=test_setup.grid_view, + field=test_setup.lookup_field, + type="has_value_contains_word", + value="other", + ) + 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 created_rows["B"].id in ids + + view_filter.type = "has_not_value_contains_word" + 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 ids == [created_rows[row_id].id for row_id in ["A", "C", "D"]] + + @pytest.mark.django_db @pytest.mark.field_multiple_select def test_has_or_has_not_empty_value_filter_multiple_select_field_types( diff --git a/changelog/entries/unreleased/feature/3450_multiple_collabolators_lookup_fields_filters.json b/changelog/entries/unreleased/feature/3450_multiple_collabolators_lookup_fields_filters.json new file mode 100644 index 0000000000..51498082ea --- /dev/null +++ b/changelog/entries/unreleased/feature/3450_multiple_collabolators_lookup_fields_filters.json @@ -0,0 +1,8 @@ +{ + "type": "feature", + "message": "Multiple collaborators lookup fields filters", + "domain": "database", + "issue_number": 3450, + "bullet_points": [], + "created_at": "2025-10-17" +} diff --git a/web-frontend/modules/database/arrayFilterMixins.js b/web-frontend/modules/database/arrayFilterMixins.js index 4af673ba15..91e3593f1c 100644 --- a/web-frontend/modules/database/arrayFilterMixins.js +++ b/web-frontend/modules/database/arrayFilterMixins.js @@ -317,14 +317,17 @@ export const hasNestedSelectOptionValueContainsFilterMixin = Object.assign( {}, hasValueContainsFilterMixin, { + _getHasValueContainsFilterFunction(cellValue, filterValue) { + return cellValue.some((v) => + genericHasValueContainsFilter(v?.value || [], filterValue) + ) + }, getHasValueContainsFilterFunction(field) { return (cellValue, filterValue) => { if (!Array.isArray(cellValue) || cellValue.length === 0) { return false } - return cellValue.some((v) => - genericHasValueContainsFilter(v?.value || [], filterValue) - ) + return this._getHasValueContainsFilterFunction(cellValue, filterValue) } }, } @@ -334,13 +337,19 @@ export const hasNestedSelectOptionValueContainsWordFilterMixin = Object.assign( {}, hasValueContainsWordFilterMixin, { + _getHasValueContainsWordFilterFunction(cellValue, filterValue) { + return cellValue.some((v) => + genericHasValueContainsWordFilter(v?.value || [], filterValue) + ) + }, getHasValueContainsWordFilterFunction(field) { return (cellValue, filterValue) => { if (!Array.isArray(cellValue) || cellValue.length === 0) { return false } - return cellValue.some((v) => - genericHasValueContainsWordFilter(v?.value || [], filterValue) + return this._getHasValueContainsWordFilterFunction( + cellValue, + filterValue ) } }, @@ -373,6 +382,13 @@ export const hasMultipleSelectOptionIdEqualMixin = Object.assign( {}, hasValueEqualFilterMixin, { + _getHasValueEqualFilterFunctionForRowValues(rowValueIdSets, filterValues) { + // Compare if any of the linked row values match exactly the filter values + return rowValueIdSets.some((rowValueIdSet) => + _.isEqual(rowValueIdSet, new Set(filterValues)) + ) + }, + getHasValueEqualFilterFunction(field) { return (cellValue, filterValue) => { if (!Array.isArray(cellValue)) { @@ -388,9 +404,9 @@ export const hasMultipleSelectOptionIdEqualMixin = Object.assign( const rowValueIdSets = cellValue.map( (v) => new Set(v?.value.map((i) => i.id)) ) - // Compare if any of the linked row values match exactly the filter values - return rowValueIdSets.some((rowValueIdSet) => - _.isEqual(rowValueIdSet, new Set(filterValues)) + return this._getHasValueEqualFilterFunctionForRowValues( + rowValueIdSets, + filterValues ) } }, diff --git a/web-frontend/modules/database/arrayViewFilters.js b/web-frontend/modules/database/arrayViewFilters.js index 1cc22837f9..dd205e8e95 100644 --- a/web-frontend/modules/database/arrayViewFilters.js +++ b/web-frontend/modules/database/arrayViewFilters.js @@ -22,6 +22,9 @@ const HasEmptyValueViewFilterTypeMixin = { FormulaFieldType.compatibleWithFormulaTypes('array(date)'), FormulaFieldType.compatibleWithFormulaTypes('array(single_select)'), FormulaFieldType.compatibleWithFormulaTypes('array(multiple_select)'), + FormulaFieldType.compatibleWithFormulaTypes( + 'array(multiple_collaborators)' + ), ] }, } @@ -82,7 +85,8 @@ const HasValueEqualViewFilterTypeMixin = { FormulaFieldType.arrayOf('boolean'), FormulaFieldType.arrayOf('number'), FormulaFieldType.arrayOf('single_select'), - FormulaFieldType.arrayOf('multiple_select') + FormulaFieldType.arrayOf('multiple_select'), + FormulaFieldType.arrayOf('multiple_collaborators') ), ] }, @@ -146,7 +150,8 @@ const HasValueContainsViewFilterTypeMixin = { FormulaFieldType.arrayOf('number'), FormulaFieldType.arrayOf('date'), FormulaFieldType.arrayOf('single_select'), - FormulaFieldType.arrayOf('multiple_select') + FormulaFieldType.arrayOf('multiple_select'), + FormulaFieldType.arrayOf('multiple_collaborators') ), ] }, @@ -206,7 +211,8 @@ const HasValueContainsWordViewFilterTypeMixin = { FormulaFieldType.arrayOf('char'), FormulaFieldType.arrayOf('url'), FormulaFieldType.arrayOf('single_select'), - FormulaFieldType.arrayOf('multiple_select') + FormulaFieldType.arrayOf('multiple_select'), + FormulaFieldType.arrayOf('multiple_collaborators') ), ] }, diff --git a/web-frontend/modules/database/formula/formulaTypes.js b/web-frontend/modules/database/formula/formulaTypes.js index 22db414400..86be021713 100644 --- a/web-frontend/modules/database/formula/formulaTypes.js +++ b/web-frontend/modules/database/formula/formulaTypes.js @@ -18,7 +18,7 @@ import RowEditFieldMultipleCollaboratorsReadOnly from '@baserow/modules/database import RowEditFieldArray from '@baserow/modules/database/components/row/RowEditFieldArray' import RowEditFieldLinkURL from '@baserow/modules/database/components/row/RowEditFieldLinkURL' import RowEditFieldButton from '@baserow/modules/database/components/row/RowEditFieldButton' -import RowEditFieldDurationReadOnly from '@baserow/modules/database/components/row/RowEditFieldDurationReadOnly.vue' +import RowEditFieldDurationReadOnly from '@baserow/modules/database/components/row/RowEditFieldDurationReadOnly' import FunctionalFormulaArrayItem from '@baserow/modules/database/components/formula/array/FunctionalFormulaArrayItem' import FunctionalFormulaArrayDurationItem from '@baserow/modules/database/components/formula/array/FunctionalFormulaArrayDurationItem' import FunctionalFormulaArrayNumberItem from '@baserow/modules/database/components/formula/array/FunctionalFormulaArrayNumberItem' @@ -34,21 +34,21 @@ import RowEditFieldBlank from '@baserow/modules/database/components/row/RowEditF import RowCardFieldBlank from '@baserow/modules/database/components/card/RowCardFieldBlank' import RowCardFieldLinkURL from '@baserow/modules/database/components/card/RowCardFieldLinkURL' import RowCardFieldButton from '@baserow/modules/database/components/card/RowCardFieldButton' -import GridViewFieldButton from '@baserow/modules/database/components/view/grid/fields/GridViewFieldButton.vue' -import GridViewFieldLinkURL from '@baserow/modules/database/components/view/grid/fields/GridViewFieldLinkURL.vue' -import GridViewFieldText from '@baserow/modules/database/components/view/grid/fields/GridViewFieldText.vue' -import RowEditFieldFileReadOnly from '@baserow/modules/database/components/row/RowEditFieldFileReadOnly.vue' -import FunctionalGridViewSingleFile from '@baserow/modules/database/components/view/grid/fields/FunctionalGridViewSingleFile.vue' -import FunctionalFormulaFileArrayItem from '@baserow/modules/database/components/formula/array/FunctionalFormulaFileArrayItem.vue' -import SingleFileArrayModal from '@baserow/modules/database/components/view/grid/fields/SingleFileArrayModal.vue' -import GridViewSingleFile from '@baserow/modules/database/components/view/grid/fields/GridViewSingleFile.vue' -import RowEditSingleFileReadOnly from '@baserow/modules/database/components/row/RowEditSingleFileReadOnly.vue' -import RowCardFieldSingleFile from '@baserow/modules/database/components/card/RowCardFieldSingleFile.vue' -import RowEditFieldURL from '@baserow/modules/database/components/row/RowEditFieldURL.vue' -import FunctionalGridViewFieldURL from '@baserow/modules/database/components/view/grid/fields/FunctionalGridViewFieldURL.vue' -import GridViewFieldURL from '@baserow/modules/database/components/view/grid/fields/GridViewFieldURL.vue' -import RowCardFieldURL from '@baserow/modules/database/components/card/RowCardFieldURL.vue' -import FunctionalFormulaURLArrayItem from '@baserow/modules/database/components/formula/array/FunctionalFormulaURLArrayItem.vue' +import GridViewFieldButton from '@baserow/modules/database/components/view/grid/fields/GridViewFieldButton' +import GridViewFieldLinkURL from '@baserow/modules/database/components/view/grid/fields/GridViewFieldLinkURL' +import GridViewFieldText from '@baserow/modules/database/components/view/grid/fields/GridViewFieldText' +import RowEditFieldFileReadOnly from '@baserow/modules/database/components/row/RowEditFieldFileReadOnly' +import FunctionalGridViewSingleFile from '@baserow/modules/database/components/view/grid/fields/FunctionalGridViewSingleFile' +import FunctionalFormulaFileArrayItem from '@baserow/modules/database/components/formula/array/FunctionalFormulaFileArrayItem' +import SingleFileArrayModal from '@baserow/modules/database/components/view/grid/fields/SingleFileArrayModal' +import GridViewSingleFile from '@baserow/modules/database/components/view/grid/fields/GridViewSingleFile' +import RowEditSingleFileReadOnly from '@baserow/modules/database/components/row/RowEditSingleFileReadOnly' +import RowCardFieldSingleFile from '@baserow/modules/database/components/card/RowCardFieldSingleFile' +import RowEditFieldURL from '@baserow/modules/database/components/row/RowEditFieldURL' +import FunctionalGridViewFieldURL from '@baserow/modules/database/components/view/grid/fields/FunctionalGridViewFieldURL' +import GridViewFieldURL from '@baserow/modules/database/components/view/grid/fields/GridViewFieldURL' +import RowCardFieldURL from '@baserow/modules/database/components/card/RowCardFieldURL' +import FunctionalFormulaURLArrayItem from '@baserow/modules/database/components/formula/array/FunctionalFormulaURLArrayItem' import { mix } from '@baserow/modules/core/mixins' import { hasEmptyValueFilterMixin, @@ -66,14 +66,16 @@ import { hasMultipleSelectOptionIdEqualMixin, } from '@baserow/modules/database/arrayFilterMixins' import _ from 'lodash' -import ViewFilterTypeBoolean from '@baserow/modules/database/components/view/ViewFilterTypeBoolean.vue' +import ViewFilterTypeBoolean from '@baserow/modules/database/components/view/ViewFilterTypeBoolean' import { genericHasAllValuesEqualFilter, genericHasValueContainsFilter, + genericHasValueContainsWordFilter, } from '@baserow/modules/database/utils/fieldFilters' -import ViewFilterTypeDuration from '@baserow/modules/database/components/view/ViewFilterTypeDuration.vue' -import ViewFilterTypeMultipleSelectOptions from '@baserow/modules/database/components/view/ViewFilterTypeMultipleSelectOptions.vue' +import ViewFilterTypeDuration from '@baserow/modules/database/components/view/ViewFilterTypeDuration' +import ViewFilterTypeMultipleSelectOptions from '@baserow/modules/database/components/view/ViewFilterTypeMultipleSelectOptions' import { DEFAULT_SORT_TYPE_KEY } from '@baserow/modules/database/constants' +import ViewFilterTypeCollaborators from '@baserow/modules/database/components/view/ViewFilterTypeCollaborators' export class BaserowFormulaTypeDefinition extends Registerable { getIconClass() { @@ -1064,6 +1066,10 @@ export class BaserowFormulaMultipleSelectType extends mix( } export class BaserowFormulaMultipleCollaboratorsType extends mix( + hasEmptyValueFilterMixin, + hasNestedSelectOptionValueContainsFilterMixin, + hasNestedSelectOptionValueContainsWordFilterMixin, + hasMultipleSelectOptionIdEqualMixin, BaserowFormulaTypeDefinition ) { static getType() { @@ -1079,7 +1085,7 @@ export class BaserowFormulaMultipleCollaboratorsType extends mix( } getFilterInputComponent(field, filterType) { - return null + return ViewFilterTypeCollaborators } getRowEditFieldComponent(field) { @@ -1109,6 +1115,24 @@ export class BaserowFormulaMultipleCollaboratorsType extends mix( canGroupByInView() { return false } + + _getHasValueEqualFilterFunctionForRowValues(rowValueIdSets, filterValues) { + return rowValueIdSets.some((rowValueIdSet) => + rowValueIdSet.isSupersetOf(new Set(filterValues)) + ) + } + + _getHasValueContainsFilterFunction(cellValue, filterValue) { + return cellValue.some((v) => + genericHasValueContainsFilter(v?.value || [], filterValue, 'name') + ) + } + + _getHasValueContainsWordFilterFunction(cellValue, filterValue) { + return cellValue.some((v) => + genericHasValueContainsWordFilter(v?.value || [], filterValue, 'name') + ) + } } export class BaserowFormulaLinkType extends BaserowFormulaTypeDefinition { diff --git a/web-frontend/modules/database/utils/fieldFilters.js b/web-frontend/modules/database/utils/fieldFilters.js index fad6ba1cdf..99c1e1865a 100644 --- a/web-frontend/modules/database/utils/fieldFilters.js +++ b/web-frontend/modules/database/utils/fieldFilters.js @@ -100,7 +100,11 @@ export function genericHasValueEqualFilter(cellValue, filterValue) { return false } -export function genericHasValueContainsFilter(cellValue, filterValue) { +export function genericHasValueContainsFilter( + cellValue, + filterValue, + valueField = 'value' +) { if (!Array.isArray(cellValue)) { return false } @@ -108,8 +112,7 @@ export function genericHasValueContainsFilter(cellValue, filterValue) { filterValue = String(filterValue).toLowerCase().trim() for (let i = 0; i < cellValue.length; i++) { - const value = String(cellValue[i].value).toLowerCase().trim() - + const value = String(cellValue[i][valueField]).toLowerCase().trim() if (value.includes(filterValue)) { return true } @@ -118,7 +121,11 @@ export function genericHasValueContainsFilter(cellValue, filterValue) { return false } -export function genericHasValueContainsWordFilter(cellValue, filterValue) { +export function genericHasValueContainsWordFilter( + cellValue, + filterValue, + valueField = 'value' +) { if (!Array.isArray(cellValue)) { return false } @@ -127,10 +134,10 @@ export function genericHasValueContainsWordFilter(cellValue, filterValue) { filterValue = filterValue.replace(/[-[\]{}()*+?.,\\^$|#]/g, '\\$&') for (let i = 0; i < cellValue.length; i++) { - if (cellValue[i].value == null) { + if (cellValue[i][valueField] == null) { continue } - const value = String(cellValue[i].value).toLowerCase().trim() + const value = String(cellValue[i][valueField]).toLowerCase().trim() if (value.match(new RegExp(`\\b${filterValue}\\b`))) { return true } diff --git a/web-frontend/test/unit/database/arrayViewFiltersMatch.spec.js b/web-frontend/test/unit/database/arrayViewFiltersMatch.spec.js index 8764bcafc3..45006b791e 100644 --- a/web-frontend/test/unit/database/arrayViewFiltersMatch.spec.js +++ b/web-frontend/test/unit/database/arrayViewFiltersMatch.spec.js @@ -2196,11 +2196,114 @@ const MultipleCollaboratorsEmptyCases = [ }, ] +const MultipleCollaboratorsArrayFiltersCases = [ + { + rowValue: [], + filterValue: { + hasValueEqual: '1', + hasValueContains: 'fo', + hasValueContainsWord: 'foo', + }, + // expected is false, because there's no empty value in the list + expected: { + hasEmptyValue: false, + hasValueEqual: false, + hasValueContains: false, + hasValueContainsWord: false, + }, + }, + { + rowValue: [{ id: 1, value: [] }], + filterValue: { + hasValueEqual: '1', + hasValueContains: 'fo', + hasValueContainsWord: 'foo', + }, + expected: { + hasEmptyValue: true, + hasValueEqual: false, + hasValueContains: false, + hasValueContainsWord: false, + }, + }, + { + rowValue: [ + { + id: 2, + value: [ + { id: 1, name: 'foo' }, + { id: 2, name: 'bar' }, + ], + }, + { id: 3, value: [{ id: 2, name: 'bar' }] }, + ], + filterValue: { + hasValueEqual: '1', + hasValueContains: 'fo', + hasValueContainsWord: 'foo', + }, + expected: { + hasEmptyValue: false, + hasValueEqual: true, + hasValueContains: true, + hasValueContainsWord: true, + }, + }, + { + rowValue: [ + { id: 2, value: [{ id: 1, name: 'foo' }] }, + { id: 1, value: [] }, + ], + filterValue: { + hasValueEqual: '2', + hasValueContains: 'fo', + hasValueContainsWord: 'foo', + }, + expected: { + hasEmptyValue: true, + hasValueEqual: false, + hasValueContains: true, + hasValueContainsWord: true, + }, + }, + + { + rowValue: [ + { + id: 2, + value: [ + { id: 1, name: 'foo' }, + { id: 2, name: 'bar' }, + ], + }, + { id: 3, value: [{ id: 2, name: 'bar' }] }, + ], + filterValue: { + hasValueEqual: '100', + hasValueContains: 'za', + hasValueContainsWord: 'zap!', + }, + expected: { + hasEmptyValue: false, + hasValueEqual: false, + hasValueContains: false, + hasValueContainsWord: false, + }, + }, +] + describe('Multiple collaborators view filters', () => { let testApp = null + let fieldType = null + const field = { + type: 'lookup', + formula_type: 'array', + array_formula_type: 'multiple_collaborators', + } beforeAll(() => { testApp = new TestApp() + fieldType = new FormulaFieldType({ app: testApp.getApp() }) }) afterEach(() => { @@ -2210,14 +2313,8 @@ describe('Multiple collaborators view filters', () => { test.each(MultipleCollaboratorsEmptyCases)( 'Multiple collaborators is empty.', (values) => { - const fieldType = new FormulaFieldType({ app: testApp }) - const field = { - type: 'lookup', - formula_type: 'array', - array_formula_type: 'multiple_collaborators', - } const result = new EmptyViewFilterType({ - app: testApp, + app: testApp.getApp(), }).matches(values.rowValue, '', field, fieldType) expect(result).toBe(values.expected) } @@ -2226,18 +2323,122 @@ describe('Multiple collaborators view filters', () => { test.each(MultipleCollaboratorsEmptyCases)( 'Multiple collaborators is not empty.', (values) => { - const fieldType = new FormulaFieldType({ app: testApp }) - const field = { - type: 'lookup', - formula_type: 'array', - array_formula_type: 'multiple_collaborators', - } const result = new NotEmptyViewFilterType({ - app: testApp, + app: testApp.getApp(), }).matches(values.rowValue, '', field, fieldType) expect(result).toBe(!values.expected) } ) + + test.each(MultipleCollaboratorsArrayFiltersCases)( + 'Multiple collaborators has empty value empty %j.', + (values) => { + const result = new HasEmptyValueViewFilterType({ + app: testApp.getApp(), + }).matches(values.rowValue, '', field, fieldType) + expect(result).toBe(values.expected.hasEmptyValue) + } + ) + + test.each(MultipleCollaboratorsArrayFiltersCases)( + 'Multiple collaborators has not empty value empty %j.', + (values) => { + const result = new HasNotEmptyValueViewFilterType({ + app: testApp.getApp(), + }).matches(values.rowValue, '', field, fieldType) + expect(result).toBe(!values.expected.hasEmptyValue) + } + ) + + test.each(MultipleCollaboratorsArrayFiltersCases)( + 'Multiple collaborators has value equal %j.', + (values) => { + const result = new HasValueEqualViewFilterType({ + app: testApp.getApp(), + }).matches( + values.rowValue, + values.filterValue.hasValueEqual, + field, + fieldType + ) + expect(result).toBe(values.expected.hasValueEqual) + } + ) + + test.each(MultipleCollaboratorsArrayFiltersCases)( + 'Multiple collaborators has not value equal %j.', + (values) => { + const result = new HasNotValueEqualViewFilterType({ + app: testApp.getApp(), + }).matches( + values.rowValue, + values.filterValue.hasValueEqual, + field, + fieldType + ) + expect(result).toBe(!values.expected.hasValueEqual) + } + ) + + test.each(MultipleCollaboratorsArrayFiltersCases)( + 'Multiple collaborators has value contains %j.', + (values) => { + const result = new HasValueContainsViewFilterType({ + app: testApp.getApp(), + }).matches( + values.rowValue, + values.filterValue.hasValueContains, + field, + fieldType + ) + expect(result).toBe(values.expected.hasValueContains) + } + ) + + test.each(MultipleCollaboratorsArrayFiltersCases)( + 'Multiple collaborators has not value contains %j.', + (values) => { + const result = new HasNotValueContainsViewFilterType({ + app: testApp.getApp(), + }).matches( + values.rowValue, + values.filterValue.hasValueContains, + field, + fieldType + ) + expect(result).toBe(!values.expected.hasValueContains) + } + ) + + test.each(MultipleCollaboratorsArrayFiltersCases)( + 'Multiple collaborators has value contains word %j.', + (values) => { + const result = new HasValueContainsWordViewFilterType({ + app: testApp.getApp(), + }).matches( + values.rowValue, + values.filterValue.hasValueContainsWord, + field, + fieldType + ) + expect(result).toBe(values.expected.hasValueContainsWord) + } + ) + + test.each(MultipleCollaboratorsArrayFiltersCases)( + 'Multiple collaborators has not value contains word %j.', + (values) => { + const result = new HasNotValueContainsWordViewFilterType({ + app: testApp.getApp(), + }).matches( + values.rowValue, + values.filterValue.hasValueContainsWord, + field, + fieldType + ) + expect(result).toBe(!values.expected.hasValueContainsWord) + } + ) }) const durationEmptyCases = [