1
0
Fork 0
mirror of https://gitlab.com/bramw/baserow.git synced 2025-04-15 01:28:30 +00:00

Resolve "Support ad hoc filtering in grid view for editor roles and lower"

This commit is contained in:
Petr Stribny 2024-02-29 07:42:55 +00:00
parent f453c84f11
commit bbaca8e381
22 changed files with 1090 additions and 51 deletions
backend
src/baserow/contrib/database
api/views/grid
views
tests/baserow/contrib/database/api/views/grid
changelog/entries/unreleased/feature
premium/web-frontend/modules/baserow_premium
web-frontend/modules/database

View file

@ -71,6 +71,7 @@ from baserow.contrib.database.views.exceptions import (
ViewFilterTypeDoesNotExist,
ViewFilterTypeNotAllowedForField,
)
from baserow.contrib.database.views.filters import AdHocFilters
from baserow.contrib.database.views.handler import ViewHandler
from baserow.contrib.database.views.models import GridView
from baserow.contrib.database.views.registries import (
@ -167,6 +168,58 @@ class GridViewView(APIView):
description="If provided only rows with data that matches the search "
"query are going to be returned.",
),
OpenApiParameter(
name="filters",
location=OpenApiParameter.QUERY,
type=OpenApiTypes.STR,
description=(
"A JSON serialized string containing the filter tree to apply "
"to this view. The filter tree is a nested structure containing "
"the filters that need to be applied. \n\n"
"An example of a valid filter tree is the following:"
'`{"filter_type": "AND", "filters": [{"field": 1, "type": "equal", '
'"value": "test"}]}`.\n\n'
f"The following filters are available: "
f'{", ".join(view_filter_type_registry.get_types())}.'
"Please note that by passing the filters parameter the "
"view filters saved for the view itself will be ignored."
),
),
OpenApiParameter(
name="filter__{field}__{filter}",
location=OpenApiParameter.QUERY,
type=OpenApiTypes.STR,
description=(
f"The rows can optionally be filtered by the same view filters "
f"available for the views. Multiple filters can be provided if "
f"they follow the same format. The field and filter variable "
f"indicate how to filter and the value indicates where to filter "
f"on.\n\n"
"Please note that if the `filters` parameter is provided, "
"this parameter will be ignored. \n\n"
f"For example if you provide the following GET parameter "
f"`filter__field_1__equal=test` then only rows where the value of "
f"field_1 is equal to test are going to be returned.\n\n"
f"The following filters are available: "
f'{", ".join(view_filter_type_registry.get_types())}.'
"Please note that by passing the filter parameters the "
"view filters saved for the view itself will be ignored."
),
),
OpenApiParameter(
name="filter_type",
location=OpenApiParameter.QUERY,
type=OpenApiTypes.STR,
description=(
"`AND`: Indicates that the rows must match all the provided "
"filters.\n"
"`OR`: Indicates that the rows only have to match one of the "
"filters.\n\n"
"This works only if two or more filters are provided."
"Please note that if the `filters` parameter is provided, "
"this parameter will be ignored. \n\n"
),
),
OpenApiParameter(
name="include_fields",
location=OpenApiParameter.QUERY,
@ -227,7 +280,15 @@ class GridViewView(APIView):
},
serializer_name="PaginationSerializerWithGridViewFieldOptions",
),
400: get_error_schema(["ERROR_USER_NOT_IN_GROUP"]),
400: get_error_schema(
[
"ERROR_USER_NOT_IN_GROUP",
"ERROR_FILTER_FIELD_NOT_FOUND",
"ERROR_VIEW_FILTER_TYPE_DOES_NOT_EXIST",
"ERROR_VIEW_FILTER_TYPE_UNSUPPORTED_FIELD",
"ERROR_FILTERS_PARAM_VALIDATION_ERROR",
]
),
404: get_error_schema(
["ERROR_GRID_DOES_NOT_EXIST", "ERROR_FIELD_DOES_NOT_EXIST"]
),
@ -237,6 +298,9 @@ class GridViewView(APIView):
{
UserNotInWorkspace: ERROR_USER_NOT_IN_GROUP,
ViewDoesNotExist: ERROR_GRID_DOES_NOT_EXIST,
FilterFieldNotFound: ERROR_FILTER_FIELD_NOT_FOUND,
ViewFilterTypeDoesNotExist: ERROR_VIEW_FILTER_TYPE_DOES_NOT_EXIST,
ViewFilterTypeNotAllowedForField: ERROR_VIEW_FILTER_TYPE_UNSUPPORTED_FIELD,
FieldDoesNotExist: ERROR_FIELD_DOES_NOT_EXIST,
}
)
@ -254,6 +318,7 @@ class GridViewView(APIView):
include_fields = request.GET.get("include_fields")
exclude_fields = request.GET.get("exclude_fields")
adhoc_filters = AdHocFilters.from_request(request)
view_handler = ViewHandler()
view = view_handler.get_view_as_user(
@ -281,11 +346,15 @@ class GridViewView(APIView):
model = view.table.get_model()
queryset = view_handler.get_queryset(
view,
apply_filters=not adhoc_filters.has_any_filters,
search=query_params.get("search"),
search_mode=query_params.get("search_mode"),
model=model,
)
if adhoc_filters.has_any_filters:
queryset = adhoc_filters.apply_to_queryset(model, queryset)
if "count" in request.GET:
return Response({"count": queryset.count()})
@ -442,6 +511,58 @@ class GridViewFieldAggregationsView(APIView):
"returned with the result."
),
),
OpenApiParameter(
name="filters",
location=OpenApiParameter.QUERY,
type=OpenApiTypes.STR,
description=(
"A JSON serialized string containing the filter tree to apply "
"for the aggregation. The filter tree is a nested structure containing "
"the filters that need to be applied. \n\n"
"An example of a valid filter tree is the following:"
'`{"filter_type": "AND", "filters": [{"field": 1, "type": "equal", '
'"value": "test"}]}`.\n\n'
f"The following filters are available: "
f'{", ".join(view_filter_type_registry.get_types())}.'
"Please note that by passing the filters parameter the "
"view filters saved for the view itself will be ignored."
),
),
OpenApiParameter(
name="filter__{field}__{filter}",
location=OpenApiParameter.QUERY,
type=OpenApiTypes.STR,
description=(
f"The aggregation can optionally be filtered by the same view filters "
f"available for the views. Multiple filters can be provided if "
f"they follow the same format. The field and filter variable "
f"indicate how to filter and the value indicates where to filter "
f"on.\n\n"
"Please note that if the `filters` parameter is provided, "
"this parameter will be ignored. \n\n"
f"For example if you provide the following GET parameter "
f"`filter__field_1__equal=test` then only rows where the value of "
f"field_1 is equal to test are going to be returned.\n\n"
f"The following filters are available: "
f'{", ".join(view_filter_type_registry.get_types())}.'
"Please note that by passing the filter parameters the "
"view filters saved for the view itself will be ignored."
),
),
OpenApiParameter(
name="filter_type",
location=OpenApiParameter.QUERY,
type=OpenApiTypes.STR,
description=(
"`AND`: Indicates that the aggregated rows must match all the provided "
"filters.\n"
"`OR`: Indicates that the aggregated rows only have to match one of the "
"filters.\n\n"
"This works only if two or more filters are provided."
"Please note that if the `filters` parameter is provided, "
"this parameter will be ignored. \n\n"
),
),
SEARCH_MODE_API_PARAM,
],
tags=["Database table grid view"],
@ -457,6 +578,10 @@ class GridViewFieldAggregationsView(APIView):
400: get_error_schema(
[
"ERROR_USER_NOT_IN_GROUP",
"ERROR_FILTER_FIELD_NOT_FOUND",
"ERROR_VIEW_FILTER_TYPE_DOES_NOT_EXIST",
"ERROR_VIEW_FILTER_TYPE_UNSUPPORTED_FIELD",
"ERROR_FILTERS_PARAM_VALIDATION_ERROR",
]
),
404: get_error_schema(
@ -470,6 +595,9 @@ class GridViewFieldAggregationsView(APIView):
{
UserNotInWorkspace: ERROR_USER_NOT_IN_GROUP,
ViewDoesNotExist: ERROR_GRID_DOES_NOT_EXIST,
FilterFieldNotFound: ERROR_FILTER_FIELD_NOT_FOUND,
ViewFilterTypeDoesNotExist: ERROR_VIEW_FILTER_TYPE_DOES_NOT_EXIST,
ViewFilterTypeNotAllowedForField: ERROR_VIEW_FILTER_TYPE_UNSUPPORTED_FIELD,
}
)
@allowed_includes("total")
@ -482,6 +610,7 @@ class GridViewFieldAggregationsView(APIView):
asked.
"""
adhoc_filters = AdHocFilters.from_request(request)
search = query_params.get("search")
search_mode = query_params.get("search_mode")
view_handler = ViewHandler()
@ -491,7 +620,12 @@ class GridViewFieldAggregationsView(APIView):
# Note: we can't optimize model by giving a model with just
# the aggregated field because we may need other fields for filtering
result = view_handler.get_view_field_aggregations(
request.user, view, with_total=total, search=search, search_mode=search_mode
request.user,
view,
with_total=total,
search=search,
search_mode=search_mode,
adhoc_filters=adhoc_filters,
)
# Decimal("NaN") can't be serialized, therefore we have to replace it
@ -806,6 +940,8 @@ class PublicGridViewRowsView(APIView):
400: get_error_schema(
[
"ERROR_USER_NOT_IN_GROUP",
"ERROR_ORDER_BY_FIELD_NOT_FOUND",
"ERROR_ORDER_BY_FIELD_NOT_POSSIBLE",
"ERROR_FILTER_FIELD_NOT_FOUND",
"ERROR_VIEW_FILTER_TYPE_DOES_NOT_EXIST",
"ERROR_VIEW_FILTER_TYPE_UNSUPPORTED_FIELD",

View file

@ -0,0 +1,68 @@
from dataclasses import dataclass
from typing import Literal, Optional
from baserow.contrib.database.api.views.serializers import validate_api_grouped_filters
from baserow.contrib.database.fields.field_filters import (
FILTER_TYPE_AND,
FILTER_TYPE_OR,
)
from baserow.contrib.database.views.view_filter_groups import (
construct_filter_builder_from_grouped_api_filters,
)
@dataclass
class AdHocFilters:
"""Dataclass that can hold data for basic and grouped filters at the same time."""
# grouped filters
api_filters: Optional[dict[str, any]] = None
# simple filters
filter_type: Literal["OR", "AND"] = "OR"
filter_object: Optional[dict] = None
@classmethod
def from_request(cls, request):
filter_type = (
FILTER_TYPE_OR
if request.GET.get("filter_type", "AND").upper() == "OR"
else FILTER_TYPE_AND
)
filter_object = {key: request.GET.getlist(key) for key in request.GET.keys()}
api_filters = None
if (filters := filter_object.get("filters", None)) and len(filters) > 0:
api_filters = validate_api_grouped_filters(filters[0])
return AdHocFilters(
api_filters=api_filters,
filter_type=filter_type,
filter_object=filter_object,
)
@property
def has_simple_filters(self):
return (
any(param for param in self.filter_object if param.startswith("filter__"))
if self.filter_object
else False
)
@property
def has_any_filters(self):
return self.api_filters or self.has_simple_filters
def apply_to_queryset(self, model, queryset):
if self.api_filters and len(self.api_filters):
filter_builder = construct_filter_builder_from_grouped_api_filters(
self.api_filters,
model,
)
return filter_builder.apply_to_queryset(queryset)
if self.filter_object:
return queryset.filter_by_fields_object(
self.filter_object, self.filter_type, None
)
return queryset

View file

@ -39,6 +39,7 @@ from baserow.contrib.database.rows.handler import RowHandler
from baserow.contrib.database.search.handler import SearchModes
from baserow.contrib.database.table.models import GeneratedTableModel, Table
from baserow.contrib.database.views.exceptions import ViewOwnershipTypeDoesNotExist
from baserow.contrib.database.views.filters import AdHocFilters
from baserow.contrib.database.views.operations import (
CreatePublicViewOperationType,
CreateViewDecorationOperationType,
@ -2703,6 +2704,7 @@ class ViewHandler(metaclass=baserow_trace_methods(tracer)):
view: View,
model: Union[GeneratedTableModel, None] = None,
with_total: bool = False,
adhoc_filters: Optional[AdHocFilters] = None,
search: Optional[str] = None,
search_mode: Optional[SearchModes] = None,
) -> Dict[str, Any]:
@ -2721,6 +2723,8 @@ class ViewHandler(metaclass=baserow_trace_methods(tracer)):
automatically.
:param with_total: Whether the total row count should be returned in the
result.
:param adhoc_filters: The filters that can be optionally applied
instead of the view's own filters.
:param search: the search string to considerate. If the search parameter is
defined, we don't use the cache so we recompute aggregation on the fly.
:param search_mode: the search mode that the search is using.
@ -2737,6 +2741,9 @@ class ViewHandler(metaclass=baserow_trace_methods(tracer)):
allow_if_template=True,
)
if not adhoc_filters:
adhoc_filters = AdHocFilters()
view_type = view_type_registry.get_by_model(view.specific_class)
# Check if view supports field aggregation
@ -2750,7 +2757,9 @@ class ViewHandler(metaclass=baserow_trace_methods(tracer)):
(
values,
need_computation,
) = self._get_aggregations_to_compute(view, aggregations, no_cache=search)
) = self._get_aggregations_to_compute(
view, aggregations, no_cache=search or adhoc_filters.has_any_filters
)
use_lock = hasattr(cache, "lock")
used_lock = False
@ -2781,11 +2790,12 @@ class ViewHandler(metaclass=baserow_trace_methods(tracer)):
],
model,
with_total=with_total,
adhoc_filters=adhoc_filters,
search=search,
search_mode=search_mode,
)
if not search:
if not search and not adhoc_filters.has_any_filters:
to_cache = {}
for key, value in db_result.items():
# We don't cache total value
@ -2818,6 +2828,7 @@ class ViewHandler(metaclass=baserow_trace_methods(tracer)):
aggregations: Iterable[Tuple[django_models.Field, str]],
model: Union[GeneratedTableModel, None] = None,
with_total: bool = False,
adhoc_filters: Optional[AdHocFilters] = None,
search: Optional[str] = None,
search_mode: Optional[SearchModes] = None,
) -> Dict[str, Any]:
@ -2834,6 +2845,8 @@ class ViewHandler(metaclass=baserow_trace_methods(tracer)):
automatically.
:param with_total: Whether the total row count should be returned in the
result.
:param adhoc_filters: The filters that can be optionally applied
instead of the view's own filters.
:param search: the search string to consider.
:param search: the mode that the search is in.
:raises FieldAggregationNotSupported: When the view type doesn't support
@ -2854,6 +2867,9 @@ class ViewHandler(metaclass=baserow_trace_methods(tracer)):
if model is None:
model = view.table.get_model()
if adhoc_filters is None:
adhoc_filters = AdHocFilters()
queryset = model.objects.all().enhance_by_fields()
view_type = view_type_registry.get_by_model(view.specific_class)
@ -2866,7 +2882,12 @@ class ViewHandler(metaclass=baserow_trace_methods(tracer)):
# Apply filters and search to have accurate aggregations
if view_type.can_filter:
queryset = self.apply_filters(view, queryset)
queryset = (
adhoc_filters.apply_to_queryset(model, queryset)
if adhoc_filters.has_any_filters
else self.apply_filters(view, queryset)
)
if search is not None:
queryset = queryset.search_all_fields(search, search_mode=search_mode)

View file

@ -1191,6 +1191,249 @@ def test_view_aggregations(api_client, data_fixture):
}
@pytest.mark.django_db
def test_view_aggregations_no_adhoc_filtering_uses_view_filters(
api_client, data_fixture
):
user, token = data_fixture.create_user_and_token()
table = data_fixture.create_database_table(user=user)
text_field = data_fixture.create_text_field(table=table, name="text_field")
grid_view = data_fixture.create_grid_view(
table=table, user=user, public=True, create_options=False
)
# this filter would filters out all rows
equal_filter = data_fixture.create_view_filter(
view=grid_view, field=text_field, type="equal", value="y"
)
RowHandler().create_row(
user, table, values={"text_field": "a"}, user_field_names=True
)
RowHandler().create_row(
user, table, values={"text_field": "b"}, user_field_names=True
)
view_handler = ViewHandler()
view_handler.update_field_options(
view=grid_view,
field_options={
text_field.id: {
"aggregation_type": "unique_count",
"aggregation_raw_type": "unique_count",
}
},
)
url = reverse(
"api:database:views:grid:field-aggregations",
kwargs={"view_id": grid_view.id},
)
# without ad hoc filters the view filter is applied
response = api_client.get(
url,
**{"HTTP_AUTHORIZATION": f"JWT {token}"},
)
assert response.status_code == HTTP_200_OK
response_json = response.json()
assert response_json == {text_field.db_column: 0}
@pytest.mark.django_db
def test_view_aggregations_adhoc_filtering_overrides_existing_filters(
api_client, data_fixture
):
user, token = data_fixture.create_user_and_token()
table = data_fixture.create_database_table(user=user)
text_field = data_fixture.create_text_field(table=table, name="text_field")
grid_view = data_fixture.create_grid_view(
table=table, user=user, public=True, create_options=False
)
# in usual scenario this filter would filtered out all rows
equal_filter = data_fixture.create_view_filter(
view=grid_view, field=text_field, type="equal", value="y"
)
RowHandler().create_row(
user, table, values={"text_field": "a"}, user_field_names=True
)
RowHandler().create_row(
user, table, values={"text_field": "b"}, user_field_names=True
)
view_handler = ViewHandler()
view_handler.update_field_options(
view=grid_view,
field_options={
text_field.id: {
"aggregation_type": "unique_count",
"aggregation_raw_type": "unique_count",
}
},
)
url = reverse(
"api:database:views:grid:field-aggregations",
kwargs={"view_id": grid_view.id},
)
advanced_filters = {
"filter_type": "AND",
"filters": [
{
"field": text_field.id,
"type": "equal",
"value": "a",
},
],
}
get_params = [f"filters={json.dumps(advanced_filters)}"]
response = api_client.get(
f'{url}?{"&".join(get_params)}', HTTP_AUTHORIZATION=f"JWT {token}"
)
assert response.status_code == HTTP_200_OK
response_json = response.json()
assert response_json == {text_field.db_column: 1}
@pytest.mark.django_db
def test_view_aggregations_adhoc_filtering_advanced_filters_are_preferred_to_other_filter_query_params(
api_client, data_fixture
):
user, token = data_fixture.create_user_and_token()
table = data_fixture.create_database_table(user=user)
text_field = data_fixture.create_text_field(table=table, name="text_field")
grid_view = data_fixture.create_grid_view(
table=table, user=user, public=True, create_options=False
)
RowHandler().create_row(
user, table, values={"text_field": "a"}, user_field_names=True
)
RowHandler().create_row(
user, table, values={"text_field": "b"}, user_field_names=True
)
view_handler = ViewHandler()
view_handler.update_field_options(
view=grid_view,
field_options={
text_field.id: {
"aggregation_type": "unique_count",
"aggregation_raw_type": "unique_count",
}
},
)
url = reverse(
"api:database:views:grid:field-aggregations",
kwargs={"view_id": grid_view.id},
)
advanced_filters = {
"filter_type": "OR",
"filters": [
{
"field": text_field.id,
"type": "equal",
"value": "a",
},
{
"field": text_field.id,
"type": "equal",
"value": "b",
},
],
}
get_params = [
"filters=" + json.dumps(advanced_filters),
f"filter__field_{text_field.id}__equal=z",
f"filter_type=AND",
]
response = api_client.get(
f'{url}?{"&".join(get_params)}', HTTP_AUTHORIZATION=f"JWT {token}"
)
assert response.status_code == HTTP_200_OK
response_json = response.json()
assert response_json == {text_field.db_column: 2}
@pytest.mark.django_db
def test_view_aggregations_adhoc_filtering_invalid_advanced_filters(
api_client, data_fixture
):
user, token = data_fixture.create_user_and_token()
table = data_fixture.create_database_table(user=user)
text_field = data_fixture.create_text_field(table=table, name="text_field")
grid_view = data_fixture.create_grid_view(
table=table, user=user, public=True, create_options=False
)
view_handler = ViewHandler()
view_handler.update_field_options(
view=grid_view,
field_options={
text_field.id: {
"aggregation_type": "unique_count",
"aggregation_raw_type": "unique_count",
}
},
)
RowHandler().create_row(
user, table, values={"text_field": "a"}, user_field_names=True
)
url = reverse(
"api:database:views:grid:field-aggregations",
kwargs={"view_id": grid_view.id},
)
expected_errors = [
(
"invalid_json",
{
"error": "The provided filters are not valid JSON.",
"code": "invalid_json",
},
),
(
json.dumps({"filter_type": "invalid"}),
{
"filter_type": [
{
"error": '"invalid" is not a valid choice.',
"code": "invalid_choice",
}
]
},
),
(
json.dumps(
{"filter_type": "OR", "filters": "invalid", "groups": "invalid"}
),
{
"filters": [
{
"error": 'Expected a list of items but got type "str".',
"code": "not_a_list",
}
],
"groups": {
"non_field_errors": [
{
"error": 'Expected a list of items but got type "str".',
"code": "not_a_list",
}
],
},
},
),
]
for filters, error_detail in expected_errors:
get_params = [f"filters={filters}"]
response = api_client.get(
f'{url}?{"&".join(get_params)}', HTTP_AUTHORIZATION=f"JWT {token}"
)
response_json = response.json()
assert response.status_code == HTTP_400_BAD_REQUEST
assert response_json["error"] == "ERROR_FILTERS_PARAM_VALIDATION_ERROR"
assert response_json["detail"] == error_detail
@pytest.mark.django_db
def test_view_aggregations_cache_invalidation_with_dependant_fields(
api_client, data_fixture
@ -3193,3 +3436,421 @@ def test_list_rows_public_advanced_filters_are_preferred_to_other_filter_query_p
response_json = response.json()
assert response.status_code == HTTP_200_OK
assert len(response_json["results"]) == 2
@pytest.mark.django_db
def test_list_grid_rows_adhoc_filtering_query_param_filter(api_client, data_fixture):
user, token = data_fixture.create_user_and_token()
table = data_fixture.create_database_table(user=user)
text_field = data_fixture.create_text_field(table=table, name="normal")
# hidden field should behave the same as normal one
text_field_hidden = data_fixture.create_text_field(table=table, name="hidden")
grid_view = data_fixture.create_grid_view(
table=table, user=user, create_options=False
)
data_fixture.create_grid_view_field_option(grid_view, text_field, hidden=False)
data_fixture.create_grid_view_field_option(
grid_view, text_field_hidden, hidden=True
)
first_row = RowHandler().create_row(
user, table, values={"normal": "a", "hidden": "y"}, user_field_names=True
)
RowHandler().create_row(
user, table, values={"normal": "b", "hidden": "z"}, user_field_names=True
)
url = reverse("api:database:views:grid:list", kwargs={"view_id": grid_view.id})
get_params = [f"filter__field_{text_field.id}__contains=a"]
response = api_client.get(
f'{url}?{"&".join(get_params)}', HTTP_AUTHORIZATION=f"JWT {token}"
)
response_json = response.json()
assert response.status_code == HTTP_200_OK
assert len(response_json["results"]) == 1
assert response_json["results"][0]["id"] == first_row.id
url = reverse("api:database:views:grid:list", kwargs={"view_id": grid_view.id})
get_params = [
f"filter__field_{text_field.id}__contains=a",
f"filter__field_{text_field.id}__contains=b",
f"filter_type=OR",
]
response = api_client.get(
f'{url}?{"&".join(get_params)}', HTTP_AUTHORIZATION=f"JWT {token}"
)
response_json = response.json()
assert response.status_code == HTTP_200_OK
assert len(response_json["results"]) == 2
url = reverse("api:database:views:grid:list", kwargs={"view_id": grid_view.id})
get_params = [f"filter__field_{text_field_hidden.id}__contains=y"]
response = api_client.get(
f'{url}?{"&".join(get_params)}', HTTP_AUTHORIZATION=f"JWT {token}"
)
response_json = response.json()
assert response.status_code == HTTP_200_OK
assert len(response_json["results"]) == 1
url = reverse("api:database:views:grid:list", kwargs={"view_id": grid_view.id})
get_params = [f"filter__field_{text_field.id}__random=y"]
response = api_client.get(
f'{url}?{"&".join(get_params)}', HTTP_AUTHORIZATION=f"JWT {token}"
)
response_json = response.json()
assert response.status_code == HTTP_400_BAD_REQUEST
assert response_json["error"] == "ERROR_VIEW_FILTER_TYPE_DOES_NOT_EXIST"
url = reverse("api:database:views:grid:list", kwargs={"view_id": grid_view.id})
get_params = [f"filter__field_{text_field.id}__higher_than=1"]
response = api_client.get(
f'{url}?{"&".join(get_params)}', HTTP_AUTHORIZATION=f"JWT {token}"
)
response_json = response.json()
assert response.status_code == HTTP_400_BAD_REQUEST
assert response_json["error"] == "ERROR_VIEW_FILTER_TYPE_UNSUPPORTED_FIELD"
@pytest.mark.django_db
def test_list_grid_rows_adhoc_filtering_invalid_advanced_filters(
api_client, data_fixture
):
user, token = data_fixture.create_user_and_token()
table = data_fixture.create_database_table(user=user)
text_field = data_fixture.create_text_field(table=table, name="text_field")
grid_view = data_fixture.create_grid_view(
table=table, user=user, public=True, create_options=False
)
data_fixture.create_grid_view_field_option(grid_view, text_field, hidden=False)
RowHandler().create_row(
user, table, values={"text_field": "a"}, user_field_names=True
)
url = reverse("api:database:views:grid:list", kwargs={"view_id": grid_view.id})
expected_errors = [
(
"invalid_json",
{
"error": "The provided filters are not valid JSON.",
"code": "invalid_json",
},
),
(
json.dumps({"filter_type": "invalid"}),
{
"filter_type": [
{
"error": '"invalid" is not a valid choice.',
"code": "invalid_choice",
}
]
},
),
(
json.dumps(
{"filter_type": "OR", "filters": "invalid", "groups": "invalid"}
),
{
"filters": [
{
"error": 'Expected a list of items but got type "str".',
"code": "not_a_list",
}
],
"groups": {
"non_field_errors": [
{
"error": 'Expected a list of items but got type "str".',
"code": "not_a_list",
}
],
},
},
),
]
for filters, error_detail in expected_errors:
get_params = [f"filters={filters}"]
response = api_client.get(
f'{url}?{"&".join(get_params)}', HTTP_AUTHORIZATION=f"JWT {token}"
)
response_json = response.json()
assert response.status_code == HTTP_400_BAD_REQUEST
assert response_json["error"] == "ERROR_FILTERS_PARAM_VALIDATION_ERROR"
assert response_json["detail"] == error_detail
@pytest.mark.django_db
def test_list_grid_rows_adhoc_filtering_advanced_filters_are_preferred_to_other_filter_query_params(
api_client, data_fixture
):
user, token = data_fixture.create_user_and_token()
table = data_fixture.create_database_table(user=user)
text_field = data_fixture.create_text_field(table=table, name="text_field")
grid_view = data_fixture.create_grid_view(
table=table, user=user, public=True, create_options=False
)
data_fixture.create_grid_view_field_option(grid_view, text_field)
RowHandler().create_row(
user, table, values={"text_field": "a"}, user_field_names=True
)
RowHandler().create_row(
user, table, values={"text_field": "b"}, user_field_names=True
)
url = reverse("api:database:views:grid:list", kwargs={"view_id": grid_view.id})
advanced_filters = {
"filter_type": "OR",
"filters": [
{
"field": text_field.id,
"type": "equal",
"value": "a",
},
{
"field": text_field.id,
"type": "equal",
"value": "b",
},
],
}
get_params = [
"filters=" + json.dumps(advanced_filters),
f"filter__field_{text_field.id}__equal=z",
f"filter_type=AND",
]
response = api_client.get(
f'{url}?{"&".join(get_params)}', HTTP_AUTHORIZATION=f"JWT {token}"
)
response_json = response.json()
assert response.status_code == HTTP_200_OK
assert len(response_json["results"]) == 2
@pytest.mark.django_db
def test_list_grid_rows_adhoc_filtering_overrides_existing_filters(
api_client, data_fixture
):
user, token = data_fixture.create_user_and_token()
table = data_fixture.create_database_table(user=user)
text_field = data_fixture.create_text_field(table=table, name="text_field")
grid_view = data_fixture.create_grid_view(
table=table, user=user, public=True, create_options=False
)
# in usual scenario this filter would filtered out all rows
equal_filter = data_fixture.create_view_filter(
view=grid_view, field=text_field, type="equal", value="y"
)
RowHandler().create_row(
user, table, values={"text_field": "a"}, user_field_names=True
)
RowHandler().create_row(
user, table, values={"text_field": "b"}, user_field_names=True
)
url = reverse("api:database:views:grid:list", kwargs={"view_id": grid_view.id})
advanced_filters = {
"filter_type": "OR",
"filters": [
{
"field": text_field.id,
"type": "equal",
"value": "a",
},
{
"field": text_field.id,
"type": "equal",
"value": "b",
},
],
}
get_params = [
"filters=" + json.dumps(advanced_filters),
]
response = api_client.get(
f'{url}?{"&".join(get_params)}', HTTP_AUTHORIZATION=f"JWT {token}"
)
response_json = response.json()
assert response.status_code == HTTP_200_OK
assert len(response_json["results"]) == 2
@pytest.mark.django_db
def test_list_grid_rows_adhoc_filtering_advanced_filters(api_client, data_fixture):
user, token = data_fixture.create_user_and_token()
table = data_fixture.create_database_table(user=user)
public_field = data_fixture.create_text_field(table=table, name="public")
# hidden fields should behave like normal ones
hidden_field = data_fixture.create_text_field(table=table, name="hidden")
grid_view = data_fixture.create_grid_view(
table=table, user=user, public=True, create_options=False
)
data_fixture.create_grid_view_field_option(grid_view, public_field, hidden=False)
data_fixture.create_grid_view_field_option(grid_view, hidden_field, hidden=True)
first_row = RowHandler().create_row(
user, table, values={"public": "a", "hidden": "y"}, user_field_names=True
)
RowHandler().create_row(
user, table, values={"public": "b", "hidden": "z"}, user_field_names=True
)
url = reverse("api:database:views:grid:list", kwargs={"view_id": grid_view.id})
advanced_filters = {
"filter_type": "AND",
"filters": [
{
"field": public_field.id,
"type": "contains",
"value": "a",
}
],
}
get_params = ["filters=" + json.dumps(advanced_filters)]
response = api_client.get(
f'{url}?{"&".join(get_params)}', HTTP_AUTHORIZATION=f"JWT {token}"
)
response_json = response.json()
assert response.status_code == HTTP_200_OK
assert len(response_json["results"]) == 1
assert response_json["results"][0]["id"] == first_row.id
advanced_filters = {
"filter_type": "AND",
"groups": [
{
"filter_type": "OR",
"filters": [
{
"field": public_field.id,
"type": "contains",
"value": "a",
},
{
"field": public_field.id,
"type": "contains",
"value": "b",
},
],
}
],
}
get_params = ["filters=" + json.dumps(advanced_filters)]
response = api_client.get(
f'{url}?{"&".join(get_params)}', HTTP_AUTHORIZATION=f"JWT {token}"
)
response_json = response.json()
assert response.status_code == HTTP_200_OK
assert len(response_json["results"]) == 2
# groups can be arbitrarily nested
advanced_filters = {
"filter_type": "AND",
"groups": [
{
"filter_type": "AND",
"filters": [
{
"field": public_field.id,
"type": "contains",
"value": "",
},
],
"groups": [
{
"filter_type": "OR",
"filters": [
{
"field": public_field.id,
"type": "contains",
"value": "a",
},
{
"field": public_field.id,
"type": "contains",
"value": "b",
},
],
},
],
},
],
}
get_params = ["filters=" + json.dumps(advanced_filters)]
response = api_client.get(
f'{url}?{"&".join(get_params)}', HTTP_AUTHORIZATION=f"JWT {token}"
)
response_json = response.json()
assert response.status_code == HTTP_200_OK
assert len(response_json["results"]) == 2
advanced_filters = {
"filter_type": "AND",
"filters": [
{
"field": hidden_field.id,
"type": "contains",
"value": "y",
}
],
}
get_params = ["filters=" + json.dumps(advanced_filters)]
response = api_client.get(
f'{url}?{"&".join(get_params)}', HTTP_AUTHORIZATION=f"JWT {token}"
)
response_json = response.json()
assert response.status_code == HTTP_200_OK
assert len(response_json["results"]) == 1
advanced_filters = {
"filter_type": "AND",
"filters": [
{
"field": public_field.id,
"type": "random",
"value": "y",
}
],
}
get_params = ["filters=" + json.dumps(advanced_filters)]
response = api_client.get(
f'{url}?{"&".join(get_params)}', HTTP_AUTHORIZATION=f"JWT {token}"
)
response_json = response.json()
assert response.status_code == HTTP_400_BAD_REQUEST
assert response_json["error"] == "ERROR_VIEW_FILTER_TYPE_DOES_NOT_EXIST"
advanced_filters = {
"filter_type": "AND",
"filters": [
{
"field": public_field.id,
"type": "higher_than",
"value": "y",
}
],
}
get_params = ["filters=" + json.dumps(advanced_filters)]
response = api_client.get(
f'{url}?{"&".join(get_params)}', HTTP_AUTHORIZATION=f"JWT {token}"
)
response_json = response.json()
assert response.status_code == HTTP_400_BAD_REQUEST
assert response_json["error"] == "ERROR_VIEW_FILTER_TYPE_UNSUPPORTED_FIELD"
for filters in [
"invalid_json",
json.dumps({"filter_type": "invalid"}),
json.dumps({"filter_type": "OR", "filters": "invalid"}),
]:
get_params = [f"filters={filters}"]
response = api_client.get(
f'{url}?{"&".join(get_params)}', HTTP_AUTHORIZATION=f"JWT {token}"
)
response_json = response.json()
assert response.status_code == HTTP_400_BAD_REQUEST
assert response_json["error"] == "ERROR_FILTERS_PARAM_VALIDATION_ERROR"

View file

@ -0,0 +1,7 @@
{
"type": "feature",
"message": "Support ad hoc filtering in grid view for editor roles and lower",
"issue_number": 2329,
"bullet_points": [],
"created_at": "2024-02-22"
}

View file

@ -49,6 +49,8 @@ export const state = () => ({
draggingRow: null,
draggingOriginalStackId: null,
draggingOriginalBefore: null,
// If true, ad hoc filtering is used instead of persistent one
adhocFiltering: false,
})
export const mutations = {
@ -178,6 +180,9 @@ export const mutations = {
const currentValue = row._.metadata[rowMetadataType]
Vue.set(row._.metadata, rowMetadataType, updateFunction(currentValue))
},
SET_ADHOC_FILTERING(state, adhocFiltering) {
state.adhocFiltering = adhocFiltering
},
}
export const actions = {
@ -194,8 +199,15 @@ export const actions = {
*/
async fetchInitial(
{ dispatch, commit, getters, rootGetters },
{ kanbanId, singleSelectFieldId, includeFieldOptions = true }
{
kanbanId,
singleSelectFieldId,
adhocFiltering,
includeFieldOptions = true,
}
) {
commit('SET_ADHOC_FILTERING', adhocFiltering)
const view = rootGetters['view/get'](kanbanId)
const { data } = await KanbanService(this.$client).fetchRows({
kanbanId,
limit: getters.getBufferRequestSize,
@ -204,7 +216,7 @@ export const actions = {
selectOptions: [],
publicUrl: rootGetters['page/view/public/getIsPublic'],
publicAuthToken: rootGetters['page/view/public/getAuthToken'],
filters: getFilters(rootGetters, kanbanId),
filters: getFilters(view, adhocFiltering),
})
Object.keys(data.rows).forEach((key) => {
populateStack(data.rows[key], data)
@ -226,6 +238,7 @@ export const actions = {
{ selectOptionId }
) {
const stack = getters.getStack(selectOptionId)
const view = rootGetters['view/get'](getters.getLastKanbanId)
const { data } = await KanbanService(this.$client).fetchRows({
kanbanId: getters.getLastKanbanId,
limit: getters.getBufferRequestSize,
@ -240,7 +253,7 @@ export const actions = {
],
publicUrl: rootGetters['page/view/public/getIsPublic'],
publicAuthToken: rootGetters['page/view/public/getAuthToken'],
filters: getFilters(rootGetters, getters.getLastKanbanId),
filters: getFilters(view, getters.getAdhocFiltering),
})
const count = data.rows[selectOptionId].count
const rows = data.rows[selectOptionId].results
@ -1038,6 +1051,9 @@ export const getters = {
}
}
},
getAdhocFiltering(state) {
return state.adhocFiltering
},
}
export default {

View file

@ -70,7 +70,9 @@ export class KanbanViewType extends PremiumViewType {
return KanbanView
}
async fetch({ store }, view, fields, storePrefix = '') {
async fetch({ store }, database, view, fields, storePrefix = '') {
const isPublic = store.getters[storePrefix + 'view/public/getIsPublic']
const adhocFiltering = isPublic
// If the single select field is `null` we can't fetch the initial data anyway,
// we don't have to do anything. The KanbanView component will handle it by
// showing a form to choose or create a single select field.
@ -80,23 +82,28 @@ export class KanbanViewType extends PremiumViewType {
await store.dispatch(storePrefix + 'view/kanban/fetchInitial', {
kanbanId: view.id,
singleSelectFieldId: view.single_select_field,
adhocFiltering,
})
}
}
async refresh(
{ store },
database,
view,
fields,
storePrefix = '',
includeFieldOptions = false,
sourceEvent = null
) {
const isPublic = store.getters[storePrefix + 'view/public/getIsPublic']
const adhocFiltering = isPublic
try {
await store.dispatch(storePrefix + 'view/kanban/fetchInitial', {
kanbanId: view.id,
singleSelectFieldId: view.single_select_field,
includeFieldOptions,
adhocFiltering,
})
} catch (error) {
if (

View file

@ -80,7 +80,7 @@
v-if="
hasSelectedView &&
view._.type.canFilter &&
(readOnly ||
(adhocFiltering ||
$hasPermission(
'database.table.view.create_filter',
view,
@ -91,8 +91,9 @@
>
<ViewFilter
:view="view"
:is-public-view="isPublic"
:fields="fields"
:read-only="readOnly"
:read-only="adhocFiltering"
:disable-filter="disableFilter"
@changed="refresh()"
></ViewFilter>
@ -375,6 +376,25 @@ export default {
)
)
},
adhocFiltering() {
if (this.readOnly) {
return true
}
return (
this.view.type === 'grid' &&
this.$hasPermission(
'database.table.view.list_filter',
this.view,
this.database.workspace.id
) &&
!this.$hasPermission(
'database.table.view.create_filter',
this.view,
this.database.workspace.id
)
)
},
...mapGetters({
isPublic: 'page/view/public/getIsPublic',
}),
@ -457,6 +477,7 @@ export default {
try {
await type.refresh(
{ store: this.$store },
this.database,
this.view,
fieldsToRefresh,
this.storePrefix,

View file

@ -146,6 +146,7 @@ export default {
const type = this.$registry.get('view', view.type)
await type.fetch(
{ store: this.$store },
this.database,
view,
this.fields,
'template/'

View file

@ -56,6 +56,7 @@
ref="filter-value"
:filter="filter"
:view="view"
:is-public-view="isPublicView"
:fields="fields"
:disabled="disableFilter"
:read-only="readOnly"
@ -112,6 +113,11 @@ export default {
required: false,
default: () => {},
},
isPublicView: {
type: Boolean,
required: false,
default: false,
},
disableFilter: {
type: Boolean,
default: false,

View file

@ -22,6 +22,7 @@
:ref="`condition-${filter.id}`"
:filter="filter"
:view="view"
:is-public-view="isPublicView"
:fields="fields"
:disable-filter="disableFilter"
:read-only="readOnly"
@ -71,6 +72,7 @@
:ref="`condition-${filter.id}`"
:filter="filter"
:view="view"
:is-public-view="isPublicView"
:fields="fields"
:disable-filter="disableFilter"
:read-only="readOnly"
@ -198,6 +200,11 @@ export default {
required: false,
default: () => {},
},
isPublicView: {
type: Boolean,
required: false,
default: false,
},
readOnly: {
type: Boolean,
required: true,

View file

@ -24,6 +24,7 @@
<ViewFilterForm
:fields="fields"
:view="view"
:is-public-view="isPublicView"
:read-only="readOnly"
:disable-filter="disableFilter"
@changed="$emit('changed')"
@ -47,6 +48,11 @@ export default {
type: Object,
required: true,
},
isPublicView: {
type: Boolean,
required: false,
default: false,
},
readOnly: {
type: Boolean,
required: true,

View file

@ -19,6 +19,7 @@
:filter-type="view.filter_type"
:fields="fields"
:view="view"
:is-public-view="isPublicView"
:read-only="readOnly"
:add-condition-string="$t('viewFilterContext.addFilter')"
class="filters__items--with-padding filters__items--scrollable"
@ -74,6 +75,11 @@ export default {
type: Object,
required: true,
},
isPublicView: {
type: Boolean,
required: false,
default: false,
},
readOnly: {
type: Boolean,
required: true,

View file

@ -62,7 +62,7 @@ export default {
return isNumeric(this.filter.value)
},
isDropdown() {
return this.readOnly && this.view
return this.readOnly && this.view && this.isPublicView
},
},
watch: {

View file

@ -10,6 +10,11 @@ export default {
required: false,
default: undefined,
},
isPublicView: {
type: Boolean,
required: false,
default: false,
},
filter: {
type: Object,
required: true,

View file

@ -77,7 +77,7 @@ export default {
// It might be possible that the view also has some stores that need to be
// filled with initial data, so we're going to call the fetch function here.
const type = app.$registry.get('view', view.type)
await type.fetch({ store }, view, fields, 'page/')
await type.fetch({ store }, database, view, fields, 'page/')
return {
database,
table,

View file

@ -182,7 +182,7 @@ export default {
return error({ statusCode: 400, message: type.getDeactivatedText() })
}
await type.fetch({ store }, view, data.fields, 'page/')
await type.fetch({ store }, data.database, view, data.fields, 'page/')
} catch (e) {
// In case of a network error we want to fail hard.
if (e.response === undefined && !(e instanceof StoreItemLookupError)) {

View file

@ -130,12 +130,19 @@ export default (client) => {
},
fetchFieldAggregations({
gridId,
filters = {},
search = '',
searchMode = '',
signal = null,
}) {
const params = new URLSearchParams()
Object.keys(filters).forEach((key) => {
filters[key].forEach((value) => {
params.append(key, value)
})
})
if (search) {
params.append('search', search)
if (searchMode) {

View file

@ -148,6 +148,8 @@ export default ({ service, customPopulateRow }) => {
requestSize: 100,
// The current view id.
viewId: -1,
// If true, ad hoc filtering is used instead of persistent one
adhocFiltering: false,
// Indicates whether the store is currently fetching another batch of rows.
fetching: false,
// A list of all the rows in the table. The ones that haven't been fetched yet
@ -266,6 +268,9 @@ export default ({ service, customPopulateRow }) => {
const currentValue = row._.metadata[rowMetadataType]
Vue.set(row._.metadata, rowMetadataType, updateFunction(currentValue))
},
SET_ADHOC_FILTERING(state, adhocFiltering) {
state.adhocFiltering = adhocFiltering
},
}
const actions = {
@ -276,13 +281,15 @@ export default ({ service, customPopulateRow }) => {
*/
async fetchInitialRows(
context,
{ viewId, fields, initialRowArguments = {} }
{ viewId, fields, adhocFiltering, initialRowArguments = {} }
) {
const { commit, getters, rootGetters } = context
commit('SET_VIEW_ID', viewId)
commit('SET_SEARCH', {
activeSearchTerm: '',
})
commit('SET_ADHOC_FILTERING', adhocFiltering)
const view = rootGetters['view/get'](viewId)
const { data } = await service(this.$client).fetchRows({
viewId,
offset: 0,
@ -292,7 +299,7 @@ export default ({ service, customPopulateRow }) => {
publicUrl: rootGetters['page/view/public/getIsPublic'],
publicAuthToken: rootGetters['page/view/public/getAuthToken'],
orderBy: getOrderBy(rootGetters, getters.getViewId),
filters: getFilters(rootGetters, getters.getViewId),
filters: getFilters(view, adhocFiltering),
...initialRowArguments,
})
const rows = Array(data.count).fill(null)
@ -351,6 +358,8 @@ export default ({ service, customPopulateRow }) => {
return
}
const view = rootGetters['view/get'](getters.getViewId)
// We can only make one request at the same time, so we're going to set the
// fetching state to `true` to prevent multiple requests being fired
// simultaneously.
@ -367,7 +376,7 @@ export default ({ service, customPopulateRow }) => {
publicUrl: rootGetters['page/view/public/getIsPublic'],
publicAuthToken: rootGetters['page/view/public/getAuthToken'],
orderBy: getOrderBy(rootGetters, getters.getViewId),
filters: getFilters(rootGetters, getters.getViewId),
filters: getFilters(view, getters.getAdhocFiltering),
})
commit('UPDATE_ROWS', {
offset: rangeToFetch.offset,
@ -399,8 +408,9 @@ export default ({ service, customPopulateRow }) => {
*/
async refresh(
{ dispatch, commit, getters, rootGetters },
{ fields, includeFieldOptions = false }
{ fields, adhocFiltering, includeFieldOptions = false }
) {
commit('SET_ADHOC_FILTERING', adhocFiltering)
// If another refresh or fetch request is currently running, we need to cancel
// it because the response is most likely going to be outdated and we don't
// need it anymore.
@ -409,7 +419,7 @@ export default ({ service, customPopulateRow }) => {
}
lastRequestController = new AbortController()
const view = rootGetters['view/get'](getters.getViewId)
try {
// We first need to fetch the count of all rows because we need to know how
// many rows there are in total to estimate what are new visible range it
@ -424,7 +434,7 @@ export default ({ service, customPopulateRow }) => {
searchMode: getDefaultSearchModeFromEnv(this.$config),
publicUrl: rootGetters['page/view/public/getIsPublic'],
publicAuthToken: rootGetters['page/view/public/getAuthToken'],
filters: getFilters(rootGetters, getters.getViewId),
filters: getFilters(view, adhocFiltering),
})
// Create a new empty array containing un-fetched rows.
@ -469,7 +479,7 @@ export default ({ service, customPopulateRow }) => {
publicUrl: rootGetters['page/view/public/getIsPublic'],
publicAuthToken: rootGetters['page/view/public/getAuthToken'],
orderBy: getOrderBy(rootGetters, getters.getViewId),
filters: getFilters(rootGetters, getters.getViewId),
filters: getFilters(view, adhocFiltering),
})
results.forEach((row, index) => {
@ -1078,6 +1088,9 @@ export default ({ service, customPopulateRow }) => {
isHidingRowsNotMatchingSearch(state) {
return true
},
getAdhocFiltering(state) {
return state.adhocFiltering
},
}
return {

View file

@ -89,6 +89,8 @@ export const state = () => ({
multiSelectStartFieldIndex: -1,
// The last used grid id.
lastGridId: -1,
// If true, ad hoc filtering is used instead of persistent one
adhocFiltering: false,
// Contains the custom field options per view. Things like the field width are
// stored here.
fieldOptions: {},
@ -156,6 +158,9 @@ export const mutations = {
SET_LAST_GRID_ID(state, gridId) {
state.lastGridId = gridId
},
SET_ADHOC_FILTERING(state, adhocFiltering) {
state.adhocFiltering = adhocFiltering
},
SET_SCROLL_TOP(state, scrollTop) {
state.scrollTop = scrollTop
},
@ -612,6 +617,7 @@ export const actions = {
) {
const windowHeight = getters.getWindowHeight
const gridId = getters.getLastGridId
const view = rootGetters['view/get'](getters.getLastGridId)
// Calculate what the middle row index of the visible window based on the scroll
// top.
@ -719,7 +725,7 @@ export const actions = {
publicAuthToken: rootGetters['page/view/public/getAuthToken'],
groupBy: getGroupBy(rootGetters, getters.getLastGridId),
orderBy: getOrderBy(rootGetters, getters.getLastGridId),
filters: getFilters(rootGetters, getters.getLastGridId),
filters: getFilters(view, getters.getAdhocFiltering),
})
.then(({ data }) => {
data.results.forEach((row) => {
@ -858,7 +864,7 @@ export const actions = {
*/
async fetchInitial(
{ dispatch, commit, getters, rootGetters },
{ gridId, fields }
{ gridId, fields, adhocFiltering }
) {
// Reset scrollTop when switching table
fireScrollTop.distance = 0
@ -870,7 +876,9 @@ export const actions = {
hideRowsNotMatchingSearch: true,
})
commit('SET_LAST_GRID_ID', gridId)
commit('SET_ADHOC_FILTERING', adhocFiltering)
const view = rootGetters['view/get'](getters.getLastGridId)
const limit = getters.getBufferRequestSize * 2
const { data } = await GridService(this.$client).fetchRows({
gridId,
@ -883,7 +891,7 @@ export const actions = {
publicAuthToken: rootGetters['page/view/public/getAuthToken'],
groupBy: getGroupBy(rootGetters, getters.getLastGridId),
orderBy: getOrderBy(rootGetters, getters.getLastGridId),
filters: getFilters(rootGetters, getters.getLastGridId),
filters: getFilters(view, adhocFiltering),
})
data.results.forEach((row) => {
const metadata = extractRowMetadata(data, row.id)
@ -917,8 +925,9 @@ export const actions = {
*/
refresh(
{ dispatch, commit, getters, rootGetters },
{ view, fields, includeFieldOptions = false }
{ view, fields, adhocFiltering, includeFieldOptions = false }
) {
commit('SET_ADHOC_FILTERING', adhocFiltering)
const gridId = getters.getLastGridId
if (lastRefreshRequest !== null) {
@ -933,7 +942,7 @@ export const actions = {
signal: lastRefreshRequestController.signal,
publicUrl: rootGetters['page/view/public/getIsPublic'],
publicAuthToken: rootGetters['page/view/public/getAuthToken'],
filters: getFilters(rootGetters, getters.getLastGridId),
filters: getFilters(view, adhocFiltering),
})
.then((response) => {
const count = response.data.count
@ -960,7 +969,7 @@ export const actions = {
publicAuthToken: rootGetters['page/view/public/getAuthToken'],
groupBy: getGroupBy(rootGetters, getters.getLastGridId),
orderBy: getOrderBy(rootGetters, getters.getLastGridId),
filters: getFilters(rootGetters, getters.getLastGridId),
filters: getFilters(view, adhocFiltering),
})
.then(({ data }) => ({
data,
@ -1164,6 +1173,7 @@ export const actions = {
this.$client
).fetchFieldAggregations({
gridId: view.id,
filters: getFilters(view, getters.getAdhocFiltering),
search,
searchMode: getDefaultSearchModeFromEnv(this.$config),
signal: lastAggregationRequest.controller.signal,
@ -1632,6 +1642,7 @@ export const actions = {
}
const gridId = getters.getLastGridId
const view = rootGetters['view/get'](getters.getLastGridId)
const { data } = await GridService(this.$client).fetchRows({
gridId,
offset: startIndex,
@ -1642,7 +1653,7 @@ export const actions = {
publicAuthToken: rootGetters['page/view/public/getAuthToken'],
groupBy: getGroupBy(rootGetters, getters.getLastGridId),
orderBy: getOrderBy(rootGetters, getters.getLastGridId),
filters: getFilters(rootGetters, getters.getLastGridId),
filters: getFilters(view, getters.getAdhocFiltering),
includeFields: fields,
excludeFields,
})
@ -3102,6 +3113,9 @@ export const getters = {
getGroupByMetadata(state) {
return state.groupByMetadata
},
getAdhocFiltering(state) {
return state.adhocFiltering
},
}
export default {

View file

@ -453,26 +453,35 @@ export function getOrderBy(rootGetters, viewId) {
}
}
export function getFilters(rootGetters, viewId) {
export function isadhocFiltering(app, workspace, view, publicView) {
return (
publicView ||
(app.$hasPermission(
'database.table.view.list_filter',
view,
workspace.id
) &&
!app.$hasPermission(
'database.table.view.create_filter',
view,
workspace.id
))
)
}
export function getFilters(view, adhocFiltering) {
const payload = {}
if (rootGetters['page/view/public/getIsPublic']) {
const view = rootGetters['view/get'](viewId)
if (!view.filters_disabled) {
const {
filter_type: filterType,
filter_groups: filterGroups,
filters,
} = view
const filterTree = createFiltersTree(filterType, filters, filterGroups)
if (filterTree.hasFilters()) {
const serializedTree = filterTree.getFiltersTreeSerialized()
payload.filters = [JSON.stringify(serializedTree)]
}
}
return payload
if (adhocFiltering && !view.filters_disabled) {
const {
filter_type: filterType,
filter_groups: filterGroups,
filters,
} = view
const filterTree = createFiltersTree(filterType, filters, filterGroups)
const serializedTree = filterTree.getFiltersTreeSerialized()
payload.filters = [JSON.stringify(serializedTree)]
}
return payload
}
/**

View file

@ -7,9 +7,11 @@ import GalleryViewHeader from '@baserow/modules/database/components/view/gallery
import FormView from '@baserow/modules/database/components/view/form/FormView'
import FormViewHeader from '@baserow/modules/database/components/view/form/FormViewHeader'
import { FileFieldType } from '@baserow/modules/database/fieldTypes'
import { newFieldMatchesActiveSearchTerm } from '@baserow/modules/database/utils/view'
import {
newFieldMatchesActiveSearchTerm,
isadhocFiltering,
} from '@baserow/modules/database/utils/view'
import { clone } from '@baserow/modules/core/utils/object'
export const maxPossibleOrderValue = 32767
export class ViewType extends Registerable {
@ -185,6 +187,7 @@ export class ViewType extends Registerable {
*/
refresh(
context,
database,
view,
fields,
storePrefix = '',
@ -385,10 +388,18 @@ export class GridViewType extends ViewType {
return 'database-public-grid-view'
}
async fetch({ store }, view, fields, storePrefix = '') {
async fetch({ store }, database, view, fields, storePrefix = '') {
const isPublic = store.getters[storePrefix + 'view/public/getIsPublic']
const adhocFiltering = isadhocFiltering(
this.app,
database.workspace,
view,
isPublic
)
await store.dispatch(storePrefix + 'view/grid/fetchInitial', {
gridId: view.id,
fields,
adhocFiltering,
})
// The grid view store keeps a copy of the group bys that must only be updated
// after the refresh of the page. This is because the group by depends on the rows
@ -401,16 +412,25 @@ export class GridViewType extends ViewType {
async refresh(
{ store },
database,
view,
fields,
storePrefix = '',
includeFieldOptions = false,
sourceEvent = null
) {
const isPublic = store.getters[storePrefix + 'view/public/getIsPublic']
const adhocFiltering = isadhocFiltering(
this.app,
database.workspace,
view,
isPublic
)
await store.dispatch(storePrefix + 'view/grid/refresh', {
view,
fields,
includeFieldOptions,
adhocFiltering,
})
}
@ -590,24 +610,31 @@ class BaseBufferedRowView extends ViewType {
return {}
}
async fetch({ store }, view, fields, storePrefix = '') {
async fetch({ store }, database, view, fields, storePrefix = '') {
const isPublic = store.getters[storePrefix + 'view/public/getIsPublic']
const adhocFiltering = isPublic
await store.dispatch(`${storePrefix}view/${this.getType()}/fetchInitial`, {
viewId: view.id,
fields,
adhocFiltering,
})
}
async refresh(
{ store },
database,
view,
fields,
storePrefix = '',
includeFieldOptions = false,
sourceEvent = null
) {
const isPublic = store.getters[storePrefix + 'view/public/getIsPublic']
const adhocFiltering = isPublic
await store.dispatch(storePrefix + 'view/' + this.getType() + '/refresh', {
fields,
includeFieldOptions,
adhocFiltering,
})
}
@ -879,6 +906,7 @@ export class FormViewType extends ViewType {
async refresh(
{ store },
database,
view,
fields,
storePrefix = '',
@ -927,7 +955,7 @@ export class FormViewType extends ViewType {
)
}
async fetch({ store }, view, fields, storePrefix = '') {
async fetch({ store }, database, view, fields, storePrefix = '') {
await store.dispatch(storePrefix + 'view/form/fetchInitial', {
formId: view.id,
})