1
0
Fork 0
mirror of https://gitlab.com/bramw/baserow.git synced 2025-04-17 18:32:35 +00:00

Resolve "Navigation through the full row view: going to previous/next row using ← and → keys."

This commit is contained in:
Alexander Haller 2022-09-22 14:21:22 +00:00
parent d4db3332dd
commit 93bfd27103
34 changed files with 1538 additions and 80 deletions

View file

@ -383,3 +383,10 @@ def get_example_batch_rows_serializer_class(example_type="get", user_field_names
}
class_object = type(class_name, (serializers.Serializer,), fields)
return class_object
class GetRowAdjacentSerializer(serializers.Serializer):
user_field_names = serializers.BooleanField(required=False, default=False)
previous = serializers.BooleanField(required=False, default=False)
view_id = serializers.IntegerField(required=False)
search = serializers.CharField(required=False, allow_null=True, default=None)

View file

@ -3,6 +3,7 @@ from django.urls import re_path
from .views import (
BatchDeleteRowsView,
BatchRowsView,
RowAdjacentView,
RowMoveView,
RowNamesView,
RowsView,
@ -18,6 +19,11 @@ urlpatterns = [
RowView.as_view(),
name="item",
),
re_path(
r"table/(?P<table_id>[0-9]+)/(?P<row_id>[0-9]+)/adjacent/$",
RowAdjacentView.as_view(),
name="adjacent",
),
re_path(
r"table/(?P<table_id>[0-9]+)/batch/$",
BatchRowsView.as_view(),

View file

@ -9,6 +9,7 @@ from drf_spectacular.utils import extend_schema
from rest_framework.permissions import IsAuthenticated
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.status import HTTP_204_NO_CONTENT
from rest_framework.views import APIView
from baserow.api.decorators import (
@ -40,6 +41,7 @@ from baserow.contrib.database.api.rows.errors import (
ERROR_ROW_IDS_NOT_UNIQUE,
)
from baserow.contrib.database.api.rows.serializers import (
GetRowAdjacentSerializer,
example_pagination_row_serializer_class,
)
from baserow.contrib.database.api.tables.errors import ERROR_TABLE_DOES_NOT_EXIST
@ -267,7 +269,11 @@ class RowsView(APIView):
),
401: get_error_schema(["ERROR_NO_PERMISSION_TO_TABLE"]),
404: get_error_schema(
["ERROR_TABLE_DOES_NOT_EXIST", "ERROR_FIELD_DOES_NOT_EXIST"]
[
"ERROR_TABLE_DOES_NOT_EXIST",
"ERROR_FIELD_DOES_NOT_EXIST",
"ERROR_VIEW_DOES_NOT_EXIST",
]
),
},
)
@ -1236,3 +1242,140 @@ class BatchDeleteRowsView(APIView):
)
return Response(status=204)
class RowAdjacentView(APIView):
permission_classes = (IsAuthenticated,)
@extend_schema(
parameters=[
OpenApiParameter(
name="table_id",
location=OpenApiParameter.PATH,
type=OpenApiTypes.INT,
description="Returns the row of the table related to the provided "
"value.",
),
OpenApiParameter(
name="row_id",
location=OpenApiParameter.PATH,
type=OpenApiTypes.INT,
description="Returns the row adjacent the provided value.",
),
OpenApiParameter(
name="view_id",
location=OpenApiParameter.QUERY,
type=OpenApiTypes.INT,
description="Applies the filters and sorts of the provided view.",
),
OpenApiParameter(
name="user_field_names",
location=OpenApiParameter.QUERY,
type=OpenApiTypes.BOOL,
description=(
"A flag query parameter which if provided the returned json "
"will use the user specified field names instead of internal "
"Baserow field names (field_123 etc). "
),
),
OpenApiParameter(
name="previous",
location=OpenApiParameter.QUERY,
type=OpenApiTypes.BOOL,
description=(
"A flag query parameter which if provided returns the"
"previous row to the specified row_id. If it's not set"
"it will return the next row."
),
),
OpenApiParameter(
name="search",
location=OpenApiParameter.QUERY,
type=OpenApiTypes.STR,
description="If provided, the adjacent row will be one that matches"
"the search query.",
),
],
tags=["Database table rows"],
operation_id="get_database_table_row",
description=(
"Fetches the adjacent row to a given row_id in the table with the "
"given table_id. If the previous flag is set it will return the "
"previous row, otherwise it will return the next row. You can specify"
"a view_id and it will apply the filters and sorts of the provided "
"view."
),
responses={
200: get_example_row_serializer_class(
example_type="get", user_field_names=True
),
204: None,
400: get_error_schema(
["ERROR_USER_NOT_IN_GROUP", "ERROR_REQUEST_BODY_VALIDATION"]
),
404: get_error_schema(
[
"ERROR_TABLE_DOES_NOT_EXIST",
"ERROR_ROW_DOES_NOT_EXIST",
"ERROR_VIEW_DOES_NOT_EXIST",
]
),
},
)
@map_exceptions(
{
UserNotInGroup: ERROR_USER_NOT_IN_GROUP,
TableDoesNotExist: ERROR_TABLE_DOES_NOT_EXIST,
ViewDoesNotExist: ERROR_VIEW_DOES_NOT_EXIST,
RowDoesNotExist: ERROR_ROW_DOES_NOT_EXIST,
}
)
@validate_query_parameters(GetRowAdjacentSerializer)
def get(
self, request: Request, table_id: int, row_id: int, query_params: Dict[str, Any]
) -> Response:
previous = query_params.get("previous")
view_id = query_params.get("view_id")
user_field_names = query_params.get("user_field_names")
search = query_params.get("search")
table = TableHandler().get_table(table_id)
table.database.group.has_user(request.user, raise_error=True)
model = table.get_model()
queryset = model.objects.all().enhance_by_fields()
if search is not None:
queryset = queryset.search_all_fields(search)
view = None
if view_id:
view_handler = ViewHandler()
view = view_handler.get_view(view_id)
if view.table_id != table.id:
raise ViewDoesNotExist()
queryset = view_handler.apply_filters(view, queryset)
queryset = view_handler.apply_sorting(view, queryset)
try:
row = queryset.get(pk=row_id)
except model.DoesNotExist:
raise RowDoesNotExist(row_id)
adjacent_row = RowHandler().get_adjacent_row(
row, queryset, previous=previous, view=view
)
# Don't fail, just let the user know there isn't an adjacent row
if adjacent_row is None:
return Response(status=HTTP_204_NO_CONTENT)
serializer_class = get_row_serializer_class(
model, RowSerializer, is_response=True, user_field_names=user_field_names
)
serializer = serializer_class(adjacent_row)
return Response(serializer.data)

View file

@ -1,5 +1,5 @@
from dataclasses import dataclass
from typing import Any, Dict
from typing import Any, Dict, Optional
@dataclass
@ -12,5 +12,5 @@ class AnnotatedOrder:
an order expression, as well as an annotation on which the order expression depends.
"""
annotation: Dict[str, Any]
order: Any
annotation: Optional[Dict[str, Any]] = None

View file

@ -15,7 +15,7 @@ from django.contrib.postgres.fields import JSONField
from django.core.exceptions import ValidationError
from django.core.files.storage import Storage, default_storage
from django.db import OperationalError, models
from django.db.models import Case, CharField, DateTimeField, F, Func, Q, Value, When
from django.db.models import CharField, DateTimeField, F, Func, Q, Value
from django.db.models.functions import Coalesce
from django.utils.timezone import make_aware
@ -2167,6 +2167,9 @@ class SingleSelectFieldType(SelectOptionBaseFieldType):
models.Prefetch(name, queryset=SelectOption.objects.using("default").all())
)
def get_value_for_filter(self, row: "GeneratedTableModel", field_name: str) -> int:
return getattr(row, field_name).value
def get_internal_value_from_db(
self, row: "GeneratedTableModel", field_name: str
) -> int:
@ -2329,7 +2332,7 @@ class SingleSelectFieldType(SelectOptionBaseFieldType):
connection, from_field, to_field
)
def get_order(self, field, field_name, order_direction):
def get_order(self, field, field_name, order_direction) -> AnnotatedOrder:
"""
If the user wants to sort the results he expects them to be ordered
alphabetically based on the select option value and not in the id which is
@ -2337,20 +2340,12 @@ class SingleSelectFieldType(SelectOptionBaseFieldType):
to the correct position.
"""
select_options = field.select_options.all().order_by("value")
options = [select_option.pk for select_option in select_options]
options.insert(0, None)
if order_direction == "DESC":
options.reverse()
order = Case(
*[
When(**{field_name: option, "then": index})
for index, option in enumerate(options)
]
)
return order
order = F(f"{field_name}__value")
if order_direction == "ASC":
order = order.asc(nulls_first=True)
else:
order = order.desc(nulls_last=True)
return AnnotatedOrder(order=order)
def random_value(self, instance, fake, cache):
"""
@ -2416,6 +2411,11 @@ class MultipleSelectFieldType(SelectOptionBaseFieldType):
)
return serializers.ListSerializer(child=field_serializer, required=required)
def get_value_for_filter(self, row: "GeneratedTableModel", field_name: str) -> str:
related_objects = getattr(row, field_name)
values = [related_object.value for related_object in related_objects.all()]
return list_to_comma_separated_string(values)
def get_internal_value_from_db(
self, row: "GeneratedTableModel", field_name: str
) -> List[int]:
@ -2640,7 +2640,7 @@ class MultipleSelectFieldType(SelectOptionBaseFieldType):
"""
sort_column_name = f"{field_name}_agg_sort"
query = Coalesce(StringAgg(f"{field_name}__value", ""), Value(""))
query = Coalesce(StringAgg(f"{field_name}__value", ","), Value(""))
annotation = {sort_column_name: query}
order = F(sort_column_name)
@ -3611,3 +3611,8 @@ class MultipleCollaboratorsFieldType(FieldType):
order = order.asc(nulls_first=True)
return AnnotatedOrder(annotation=annotation, order=order)
def get_value_for_filter(self, row: "GeneratedTableModel", field_name: str) -> any:
related_objects = getattr(row, field_name)
values = [related_object.first_name for related_object in related_objects.all()]
return list_to_comma_separated_string(values)

View file

@ -622,6 +622,8 @@ class FieldType(
based on the select option value.
Additionally an annotation can be returned which will get applied to the
queryset.
If you are implementing this method you should also implement the
get_value_for_filter method.
:param field: The related field object instance.
:type field: Field
@ -1348,6 +1350,22 @@ class FieldType(
return []
def get_value_for_filter(self, row: "GeneratedTableModel", field_name: str) -> any:
"""
Returns the value of a field in a row that can be used for SQL filtering.
Usually this is just a string or int value stored in the row but for
some field types this is not the case. For example a multiple select field will
return a list of values which need to be converted to a string for filtering.
If you are implementing this method you should also implement the get_order
method.
:param row: The row which contains the field value.
:param field_name: The name of the field to get the value for.
:return: The value of the field in the row in a filterable format.
"""
return getattr(row, field_name)
class ReadOnlyFieldHasNoInternalDbValueError(Exception):
"""

View file

@ -1,5 +1,6 @@
import re
from collections import defaultdict
from copy import copy
from decimal import Decimal
from math import ceil, floor
from typing import Any, Dict, List, NewType, Optional, Set, Tuple, Type, cast
@ -7,7 +8,7 @@ from typing import Any, Dict, List, NewType, Optional, Set, Tuple, Type, cast
from django.contrib.auth.models import AbstractUser
from django.core.exceptions import ValidationError
from django.db import transaction
from django.db.models import F, Max, QuerySet
from django.db.models import F, Max, Q, QuerySet
from django.db.models.fields.related import ForeignKey, ManyToManyField
from django.utils.encoding import force_str
@ -16,8 +17,13 @@ from baserow.contrib.database.fields.dependencies.update_collector import (
FieldUpdateCollector,
)
from baserow.contrib.database.fields.field_cache import FieldCache
from baserow.contrib.database.fields.field_filters import (
FILTER_TYPE_OR,
AnnotatedQ,
FilterBuilder,
)
from baserow.contrib.database.fields.models import LinkRowField
from baserow.contrib.database.fields.registries import FieldType
from baserow.contrib.database.fields.registries import FieldType, field_type_registry
from baserow.contrib.database.table.models import GeneratedTableModel, Table
from baserow.contrib.database.trash.models import TrashedRows
from baserow.core.trash.handler import TrashHandler
@ -297,6 +303,8 @@ class RowHandler:
:param row_id: The id of the row that must be fetched.
:param model: If the correct model has already been generated it can be
provided so that it does not have to be generated for a second time.
:param base_queryset: A queryset that can be used to already pre-filter
the results.
:raises RowDoesNotExist: When the row with the provided id does not exist.
:return: The requested row instance.
"""
@ -313,10 +321,120 @@ class RowHandler:
try:
row = base_queryset.get(id=row_id)
except model.DoesNotExist:
raise RowDoesNotExist(f"The row with id {row_id} does not exist.")
raise RowDoesNotExist(row_id)
return row
def get_adjacent_row(self, row, original_queryset, previous=False, view=None):
"""
Fetches the adjacent row of the provided row. By default, the next row will
be fetched. This will be done by applying the order as greater than or lower
than filter using the values of the provided row.
:param row: An instance of the row where the adjacent row must be
fetched from.
:param original_queryset: The original queryset that was used to fetch the
row. This should contain all the orders, annotations, filters that were
used when fetching the row.
:param previous: If the previous row should be fetched.
:param view: The view which contains the sorts that need to be applied to the
queryset, to find the right adjacent field.
:return: The adjacent rows.
"""
from baserow.contrib.database.views.handler import ViewHandler
default_sorting = ["order", "id"]
if previous:
original_queryset = original_queryset.reverse()
# Sort query set
if view:
queryset_sorted = ViewHandler().apply_sorting(view, original_queryset)
else:
direction_prefix = "-" if previous else ""
sorting = [f"{direction_prefix}{sort}" for sort in default_sorting]
queryset_sorted = original_queryset.order_by(*sorting)
# Apply filters to find the adjacent row
filter_builder = FilterBuilder(FILTER_TYPE_OR)
previous_fields = {}
# Append view sorting
if view:
for view_sort in view.viewsort_set.all():
field = view_sort.field
field_name = field.db_column
field_type = field_type_registry.get_by_model(
view_sort.field.specific_class
)
if previous:
if view_sort.order == "DESC":
order_direction = "ASC"
else:
order_direction = "DESC"
else:
order_direction = view_sort.order
order_direction_suffix = "__gt" if order_direction == "ASC" else "__lt"
order = field_type.get_order(field, field_name, order_direction)
annotation = None
expression_name = None
if order is None:
# In this case there isn't a custom implementation for the order
# and we can assume that we can just filter by th field name.
filter_key = f"{field_name}{order_direction_suffix}"
else:
# In this case the field type is more complex and probably requires
# joins in order to filter on the field. We will add the order
# expression to the queryset and filter on that expression.
annotation = order.annotation
order = order.order
expression_name = order.expression.name
filter_key = f"{expression_name}{order_direction_suffix}"
value = field_type.get_value_for_filter(row, field_name)
q_kwargs = copy(previous_fields)
q_kwargs[filter_key] = value
q = Q(**q_kwargs)
# As the key we want to use the field name without any direction suffix.
# In the case of a "normal" field type, that will just be the field_name
# But in the case of a more complex field type, it might be the
# expression name.
# An expression name could look like `field_1__value` while a field name
# will always be like `field_1`.
previous_fields[expression_name or field_name] = value
if annotation:
q = AnnotatedQ(annotation=annotation, q=q)
filter_builder.filter(q)
# Append default sorting
for field_name in default_sorting:
direction_suffix = "__lt" if previous else "__gt"
filter_key = f"{field_name}{direction_suffix}"
value = getattr(row, field_name)
q_kwargs = copy(previous_fields)
q_kwargs[filter_key] = value
previous_fields[field_name] = value
filter_builder.filter(Q(**q_kwargs))
queryset_filtered = filter_builder.apply_to_queryset(queryset_sorted)
return queryset_filtered.first()
def get_row_for_update(
self,
user: AbstractUser,

View file

@ -205,7 +205,8 @@ class TableModelQuerySet(models.QuerySet):
field_order = field_type.get_order(field, field_name, order_direction)
if isinstance(field_order, AnnotatedOrder):
annotations = {**annotations, **field_order.annotation}
if field_order.annotation is not None:
annotations = {**annotations, **field_order.annotation}
field_order = field_order.order
if field_order:

View file

@ -5,6 +5,7 @@ from django.shortcuts import reverse
import pytest
from rest_framework.status import (
HTTP_200_OK,
HTTP_204_NO_CONTENT,
HTTP_400_BAD_REQUEST,
HTTP_401_UNAUTHORIZED,
HTTP_404_NOT_FOUND,
@ -1818,3 +1819,232 @@ def test_list_row_names(api_client, data_fixture):
)
assert response.status_code == HTTP_404_NOT_FOUND
assert response.json()["error"] == "ERROR_TABLE_DOES_NOT_EXIST"
@pytest.mark.django_db
def test_get_row_adjacent(api_client, data_fixture):
user, jwt_token = data_fixture.create_user_and_token(
email="test@test.nl", password="password", first_name="Test1"
)
table = data_fixture.create_database_table(name="table", user=user)
field = data_fixture.create_text_field(name="some name", table=table)
[row_1, row_2, row_3] = RowHandler().create_rows(
user,
table,
rows_values=[
{f"field_{field.id}": "some value"},
{f"field_{field.id}": "some value"},
{f"field_{field.id}": "some value"},
],
)
# Get the next row
response = api_client.get(
reverse(
"api:database:rows:adjacent",
kwargs={"table_id": table.id, "row_id": row_2.id},
),
format="json",
HTTP_AUTHORIZATION=f"JWT {jwt_token}",
data={"user_field_names": True},
)
response_json = response.json()
assert response.status_code == HTTP_200_OK
assert response_json["id"] == row_3.id
assert field.name in response_json
# Get the previous row
response = api_client.get(
reverse(
"api:database:rows:adjacent",
kwargs={"table_id": table.id, "row_id": row_2.id},
),
format="json",
HTTP_AUTHORIZATION=f"JWT {jwt_token}",
data={"previous": True},
)
response_json = response.json()
assert response.status_code == HTTP_200_OK
assert response_json["id"] == row_1.id
@pytest.mark.django_db
def test_get_row_adjacent_view_id_provided(api_client, data_fixture):
user, jwt_token = data_fixture.create_user_and_token(
email="test@test.nl", password="password", first_name="Test1"
)
table = data_fixture.create_database_table(name="table", user=user)
view = data_fixture.create_grid_view(name="view", user=user, table=table)
field = data_fixture.create_text_field(name="field", table=table)
data_fixture.create_view_sort(user, field=field, view=view)
data_fixture.create_view_filter(
user, field=field, view=view, type="contains", value="a"
)
[row_1, row_2, row_3] = RowHandler().create_rows(
user,
table,
rows_values=[
{f"field_{field.id}": "ab"},
{f"field_{field.id}": "b"},
{f"field_{field.id}": "a"},
],
)
response = api_client.get(
reverse(
"api:database:rows:adjacent",
kwargs={"table_id": table.id, "row_id": row_3.id},
),
format="json",
HTTP_AUTHORIZATION=f"JWT {jwt_token}",
data={"view_id": view.id},
)
response_json = response.json()
assert response.status_code == HTTP_200_OK
assert response_json["id"] == row_1.id
@pytest.mark.django_db
def test_get_row_adjacent_view_id_no_adjacent_row(api_client, data_fixture):
user, jwt_token = data_fixture.create_user_and_token(
email="test@test.nl", password="password", first_name="Test1"
)
table = data_fixture.create_database_table(name="table", user=user)
field = data_fixture.create_text_field(name="field", table=table)
[row_1, row_2, row_3] = RowHandler().create_rows(
user,
table,
rows_values=[
{f"field_{field.id}": "a"},
{f"field_{field.id}": "b"},
{f"field_{field.id}": "c"},
],
)
response = api_client.get(
reverse(
"api:database:rows:adjacent",
kwargs={"table_id": table.id, "row_id": row_3.id},
),
format="json",
HTTP_AUTHORIZATION=f"JWT {jwt_token}",
)
assert response.status_code == HTTP_204_NO_CONTENT
@pytest.mark.django_db
def test_get_row_adjacent_view_invalid_requests(api_client, data_fixture):
user, jwt_token = data_fixture.create_user_and_token(
email="test@test.nl", password="password", first_name="Test1"
)
user_2, jwt_token_2 = data_fixture.create_user_and_token(
email="test2@test.nl", password="password", first_name="Test2"
)
table = data_fixture.create_database_table(name="table", user=user)
table_unrelated = data_fixture.create_database_table(
name="table unrelated", user=user
)
view_unrelated = data_fixture.create_grid_view(table=table_unrelated)
row = RowHandler().create_row(user, table, {})
response = api_client.get(
reverse(
"api:database:rows:adjacent",
kwargs={"table_id": table.id, "row_id": row.id},
),
format="json",
HTTP_AUTHORIZATION=f"JWT {jwt_token_2}",
)
assert response.status_code == HTTP_400_BAD_REQUEST
assert response.data["error"] == "ERROR_USER_NOT_IN_GROUP"
response = api_client.get(
reverse(
"api:database:rows:adjacent",
kwargs={"table_id": 999999, "row_id": row.id},
),
format="json",
HTTP_AUTHORIZATION=f"JWT {jwt_token}",
)
assert response.status_code == HTTP_404_NOT_FOUND
assert response.data["error"] == "ERROR_TABLE_DOES_NOT_EXIST"
response = api_client.get(
reverse(
"api:database:rows:adjacent",
kwargs={"table_id": table.id, "row_id": row.id},
),
format="json",
HTTP_AUTHORIZATION=f"JWT {jwt_token}",
data={"view_id": 999999},
)
assert response.status_code == HTTP_404_NOT_FOUND
assert response.data["error"] == "ERROR_VIEW_DOES_NOT_EXIST"
response = api_client.get(
reverse(
"api:database:rows:adjacent",
kwargs={"table_id": table.id, "row_id": 99999},
),
format="json",
HTTP_AUTHORIZATION=f"JWT {jwt_token}",
)
assert response.status_code == HTTP_404_NOT_FOUND
assert response.data["error"] == "ERROR_ROW_DOES_NOT_EXIST"
response = api_client.get(
reverse(
"api:database:rows:adjacent",
kwargs={"table_id": table.id, "row_id": row.id},
),
format="json",
data={"view_id": view_unrelated.id},
HTTP_AUTHORIZATION=f"JWT {jwt_token}",
)
assert response.status_code == HTTP_404_NOT_FOUND
assert response.data["error"] == "ERROR_VIEW_DOES_NOT_EXIST"
@pytest.mark.django_db
def test_get_row_adjacent_search(api_client, data_fixture):
user, jwt_token = data_fixture.create_user_and_token(
email="test@test.nl", password="password", first_name="Test1"
)
table = data_fixture.create_database_table(name="table", user=user)
field = data_fixture.create_text_field(name="field", table=table)
[row_1, row_2, row_3] = RowHandler().create_rows(
user,
table,
rows_values=[
{f"field_{field.id}": "a"},
{f"field_{field.id}": "ab"},
{f"field_{field.id}": "c"},
],
)
response = api_client.get(
reverse(
"api:database:rows:adjacent",
kwargs={"table_id": table.id, "row_id": row_2.id},
),
data={"search": "a"},
format="json",
HTTP_AUTHORIZATION=f"JWT {jwt_token}",
)
assert response.status_code == HTTP_204_NO_CONTENT

View file

@ -2,6 +2,7 @@ import pytest
from baserow.contrib.database.fields.handler import FieldHandler
from baserow.contrib.database.fields.registries import field_type_registry
from baserow.contrib.database.rows.handler import RowHandler
@pytest.mark.django_db
@ -109,3 +110,41 @@ def test_get_set_export_serialized_value_boolean_field(data_fixture):
assert old_row_1_value == getattr(row_1, boolean_field_name)
assert old_row_2_value == getattr(row_2, boolean_field_name)
assert old_row_3_value == getattr(row_3, boolean_field_name)
@pytest.mark.django_db
def test_boolean_field_adjacent_row(data_fixture):
user = data_fixture.create_user()
table = data_fixture.create_database_table(name="Car", user=user)
grid_view = data_fixture.create_grid_view(user=user, table=table, name="Test")
boolean_field = data_fixture.create_boolean_field(table=table)
data_fixture.create_view_sort(view=grid_view, field=boolean_field, order="DESC")
handler = RowHandler()
[row_a, row_b, row_c] = handler.create_rows(
user=user,
table=table,
rows_values=[
{
f"field_{boolean_field.id}": False,
},
{
f"field_{boolean_field.id}": True,
},
{
f"field_{boolean_field.id}": True,
},
],
)
base_queryset = table.get_model().objects.all()
row_c = base_queryset.get(pk=row_c.id)
previous_row = handler.get_adjacent_row(
row_c, base_queryset, previous=True, view=grid_view
)
next_row = handler.get_adjacent_row(row_c, base_queryset, view=grid_view)
assert previous_row.id == row_b.id
assert next_row.id == row_a.id

View file

@ -240,3 +240,35 @@ def test_import_export_last_modified_field(data_fixture):
assert getattr(imported_row, f"field_{import_created_on_field_2.id}") == datetime(
2021, 1, 1, 12, 00, tzinfo=timezone("UTC")
)
@pytest.mark.django_db
def test_created_on_field_adjacent_row(data_fixture):
user = data_fixture.create_user()
table = data_fixture.create_database_table(name="Car", user=user)
grid_view = data_fixture.create_grid_view(user=user, table=table, name="Test")
created_on_field = data_fixture.create_created_on_field(table=table)
data_fixture.create_view_sort(view=grid_view, field=created_on_field, order="DESC")
handler = RowHandler()
[row_a, row_b, row_c] = handler.create_rows(
user=user,
table=table,
rows_values=[
{},
{},
{},
],
)
base_queryset = table.get_model().objects.all()
row_b = base_queryset.get(pk=row_b.id)
previous_row = handler.get_adjacent_row(
row_b, base_queryset, previous=True, view=grid_view
)
next_row = handler.get_adjacent_row(row_b, base_queryset, view=grid_view)
assert previous_row.id == row_c.id
assert next_row.id == row_a.id

View file

@ -627,3 +627,41 @@ def test_get_set_export_serialized_value_date_field(data_fixture):
assert old_row_1_datetime == getattr(row_1, datetime_field_name)
assert old_row_2_date == getattr(row_2, date_field_name)
assert old_row_2_datetime == getattr(row_2, datetime_field_name)
@pytest.mark.django_db
def test_date_field_adjacent_row(data_fixture):
user = data_fixture.create_user()
table = data_fixture.create_database_table(name="Car", user=user)
grid_view = data_fixture.create_grid_view(user=user, table=table, name="Test")
date_field = data_fixture.create_date_field(table=table)
data_fixture.create_view_sort(view=grid_view, field=date_field, order="DESC")
handler = RowHandler()
[row_a, row_b, row_c] = handler.create_rows(
user=user,
table=table,
rows_values=[
{
f"field_{date_field.id}": "2010-02-03",
},
{
f"field_{date_field.id}": "2010-02-04",
},
{
f"field_{date_field.id}": "2010-02-05",
},
],
)
base_queryset = table.get_model().objects.all()
row_b = base_queryset.get(pk=row_b.id)
previous_row = handler.get_adjacent_row(
row_b, base_queryset, previous=True, view=grid_view
)
next_row = handler.get_adjacent_row(row_b, base_queryset, view=grid_view)
assert previous_row.id == row_c.id
assert next_row.id == row_a.id

View file

@ -13,7 +13,7 @@ from baserow.contrib.database.fields.models import (
PhoneNumberField,
URLField,
)
from baserow.contrib.database.fields.registries import field_type_registry
from baserow.contrib.database.fields.registries import FieldType, field_type_registry
from baserow.contrib.database.rows.handler import RowHandler
from baserow.test_utils.helpers import setup_interesting_test_table
@ -662,3 +662,24 @@ def test_import_export_lookup_field(data_fixture, api_client):
assert lookup_field_imported.target_field_name == lookup.target_field_name
assert id_mapping["database_fields"][lookup.id] == lookup_field_imported.id
def test_field_types_with_get_order_have_get_value_for_filter():
for field_type in field_type_registry.get_all():
if field_type.__class__.get_order.__code__ is not FieldType.get_order.__code__:
assert (
field_type.__class__.get_value_for_filter.__code__
is not FieldType.get_value_for_filter.__code__
)
def test_field_types_with_get_value_for_filter_have_get_order():
for field_type in field_type_registry.get_all():
if (
field_type.__class__.get_value_for_filter.__code__
is not FieldType.get_value_for_filter.__code__
):
assert (
field_type.__class__.get_order.__code__
is not FieldType.get_order.__code__
)

View file

@ -1372,3 +1372,46 @@ def test_can_have_nested_date_formulas(
name="failured",
formula="date_diff('day', field('jaar_van'), field('datum')) + 1",
)
@pytest.mark.django_db
def test_formula_field_adjacent_row(data_fixture):
user = data_fixture.create_user()
table = data_fixture.create_database_table(name="Car", user=user)
grid_view = data_fixture.create_grid_view(user=user, table=table, name="Test")
text_field = data_fixture.create_text_field(table=table)
formula_field = data_fixture.create_formula_field(
table=table,
formula=f"field('{text_field.name}')",
formula_type="text",
)
data_fixture.create_view_sort(view=grid_view, field=formula_field, order="DESC")
handler = RowHandler()
[row_a, row_b, row_c] = handler.create_rows(
user=user,
table=table,
rows_values=[
{
f"field_{text_field.id}": "A",
},
{
f"field_{text_field.id}": "B",
},
{
f"field_{text_field.id}": "C",
},
],
)
base_queryset = table.get_model().objects.all()
row_b = base_queryset.get(pk=row_b.id)
previous_row = handler.get_adjacent_row(
row_b, base_queryset, previous=True, view=grid_view
)
next_row = handler.get_adjacent_row(row_b, base_queryset, view=grid_view)
assert previous_row.id == row_c.id
assert next_row.id == row_a.id

View file

@ -244,3 +244,37 @@ def test_import_export_last_modified_field(data_fixture):
assert getattr(
imported_row, f"field_{imported_last_modified_field_2.id}"
) == datetime(2021, 1, 1, 12, 00, tzinfo=timezone("UTC"))
@pytest.mark.django_db
def test_last_modified_field_adjacent_row(data_fixture):
user = data_fixture.create_user()
table = data_fixture.create_database_table(name="Car", user=user)
grid_view = data_fixture.create_grid_view(user=user, table=table, name="Test")
last_modified_field = data_fixture.create_last_modified_field(table=table)
data_fixture.create_view_sort(
view=grid_view, field=last_modified_field, order="DESC"
)
handler = RowHandler()
[row_a, row_b, row_c] = handler.create_rows(
user=user,
table=table,
rows_values=[
{},
{},
{},
],
)
base_queryset = table.get_model().objects.all()
row_b = base_queryset.get(pk=row_b.id)
previous_row = handler.get_adjacent_row(
row_b, base_queryset, previous=True, view=grid_view
)
next_row = handler.get_adjacent_row(row_b, base_queryset, view=grid_view)
assert previous_row.id == row_c.id
assert next_row.id == row_a.id

View file

@ -425,3 +425,56 @@ def test_multiple_collaborators_field_type_random_value(data_fixture):
set([x.id for x in possible_collaborators]).issuperset(set(random_choice))
is True
)
@pytest.mark.django_db
def test_multiple_collaborators_field_adjacent_row(data_fixture):
group = data_fixture.create_group()
user = data_fixture.create_user(group=group, first_name="User 1")
user_2 = data_fixture.create_user(group=group, first_name="User 2")
user_3 = data_fixture.create_user(group=group, first_name="User 3")
database = data_fixture.create_database_application(
user=user, group=group, name="database"
)
table = data_fixture.create_database_table(name="table", database=database)
grid_view = data_fixture.create_grid_view(table=table)
field_handler = FieldHandler()
field = field_handler.create_field(
user=user,
table=table,
type_name="multiple_collaborators",
name="Multiple collaborators",
)
data_fixture.create_view_sort(view=grid_view, field=field, order="DESC")
row_a = data_fixture.create_row_for_many_to_many_field(
table=table,
field=field,
values=[{"id": user.id}],
user=user,
)
row_b = data_fixture.create_row_for_many_to_many_field(
table=table,
field=field,
values=[{"id": user_2.id}],
user=user,
)
row_c = data_fixture.create_row_for_many_to_many_field(
table=table,
field=field,
values=[{"id": user_3.id}],
user=user,
)
base_queryset = table.get_model().objects.all()
row_b = base_queryset.get(pk=row_b.id)
previous_row = RowHandler().get_adjacent_row(
row_b, base_queryset, previous=True, view=grid_view
)
next_row = RowHandler().get_adjacent_row(row_b, base_queryset, view=grid_view)
assert previous_row.id == row_c.id
assert next_row.id == row_a.id

View file

@ -156,7 +156,7 @@ def test_multiple_select_field_type_rows(data_fixture, django_assert_num_queries
user = data_fixture.create_user()
database = data_fixture.create_database_application(user=user, name="Placeholder")
table = data_fixture.create_database_table(name="Example", database=database)
other_select_option_single_select_field = data_fixture.create_select_option()
other_select_option_multiple_select_field = data_fixture.create_select_option()
field_handler = FieldHandler()
row_handler = RowHandler()
@ -192,7 +192,9 @@ def test_multiple_select_field_type_rows(data_fixture, django_assert_num_queries
row_handler.create_row(
user=user,
table=table,
values={f"field_{field.id}": [other_select_option_single_select_field.id]},
values={
f"field_{field.id}": [other_select_option_multiple_select_field.id]
},
)
with pytest.raises(AllProvidedMultipleSelectValuesMustBeSelectOption):
@ -794,7 +796,7 @@ def test_conversion_single_select_to_multiple_select_field(
@pytest.mark.django_db
def test_conversion_multiple_select_to_single_select_field(data_fixture):
def test_conversion_multiple_select_to_multiple_select_field(data_fixture):
user = data_fixture.create_user()
group = data_fixture.create_group(user=user)
database = data_fixture.create_database_application(group=group)
@ -876,34 +878,34 @@ def test_conversion_multiple_select_to_single_select_field(data_fixture):
# Check first row
rows = list(model.objects.all().enhance_by_fields())
row_0, row_1, row_2, row_3, row_4, row_5 = rows
row_single_select_field_list_0 = getattr(row_0, f"field_{field.id}")
assert row_single_select_field_list_0.id == select_options[0].id
assert row_single_select_field_list_0.value == select_options[0].value
row_multiple_select_field_list_0 = getattr(row_0, f"field_{field.id}")
assert row_multiple_select_field_list_0.id == select_options[0].id
assert row_multiple_select_field_list_0.value == select_options[0].value
# Check second row
row_single_select_field_list_1 = getattr(row_1, f"field_{field.id}")
assert row_single_select_field_list_1.id == select_options[1].id
assert row_single_select_field_list_1.value == select_options[1].value
row_multiple_select_field_list_1 = getattr(row_1, f"field_{field.id}")
assert row_multiple_select_field_list_1.id == select_options[1].id
assert row_multiple_select_field_list_1.value == select_options[1].value
# Check third row
row_single_select_field_list_2 = getattr(row_2, f"field_{field.id}")
assert row_single_select_field_list_2.id == select_options[2].id
assert row_single_select_field_list_2.value == select_options[2].value
row_multiple_select_field_list_2 = getattr(row_2, f"field_{field.id}")
assert row_multiple_select_field_list_2.id == select_options[2].id
assert row_multiple_select_field_list_2.value == select_options[2].value
# Check fourth row
row_single_select_field_list_3 = getattr(row_3, f"field_{field.id}")
assert row_single_select_field_list_3.id == select_options[1].id
assert row_single_select_field_list_3.value == select_options[1].value
row_multiple_select_field_list_3 = getattr(row_3, f"field_{field.id}")
assert row_multiple_select_field_list_3.id == select_options[1].id
assert row_multiple_select_field_list_3.value == select_options[1].value
# Check fifth row
row_single_select_field_list_4 = getattr(row_4, f"field_{field.id}")
assert row_single_select_field_list_4.id == select_options[2].id
assert row_single_select_field_list_4.value == select_options[2].value
row_multiple_select_field_list_4 = getattr(row_4, f"field_{field.id}")
assert row_multiple_select_field_list_4.id == select_options[2].id
assert row_multiple_select_field_list_4.value == select_options[2].value
# Check sixth row
row_single_select_field_list_5 = getattr(row_5, f"field_{field.id}")
assert row_single_select_field_list_5.id == select_options[0].id
assert row_single_select_field_list_5.value == select_options[0].value
row_multiple_select_field_list_5 = getattr(row_5, f"field_{field.id}")
assert row_multiple_select_field_list_5.id == select_options[0].id
assert row_multiple_select_field_list_5.value == select_options[0].value
@pytest.mark.django_db
@ -1477,7 +1479,7 @@ def test_multiple_select_with_single_select_present(data_fixture):
field_handler = FieldHandler()
row_handler = RowHandler()
single_select_field = field_handler.create_field(
multiple_select_field = field_handler.create_field(
user=user,
table=table,
type_name="single_select",
@ -1492,7 +1494,7 @@ def test_multiple_select_with_single_select_present(data_fixture):
user=user, table=table, type_name="multiple_select", name="Multi"
)
single_options = single_select_field.select_options.all()
single_options = multiple_select_field.select_options.all()
first_select_option = single_options[0]
assert type(first_select_option) == SelectOption
@ -1500,10 +1502,10 @@ def test_multiple_select_with_single_select_present(data_fixture):
row = row_handler.create_row(
user=user,
table=table,
values={f"field_{single_select_field.id}": single_options[0]},
values={f"field_{multiple_select_field.id}": single_options[0]},
)
field_cell = getattr(row, f"field_{single_select_field.id}")
field_cell = getattr(row, f"field_{multiple_select_field.id}")
assert field_cell.id == first_select_option.id
assert field_cell.value == first_select_option.value
assert field_cell.color == first_select_option.color
@ -2033,3 +2035,59 @@ def test_conversion_to_multiple_select_with_same_option_value_on_same_row(
assert len(cell_2) == 1
assert cell_2[0].id == id_of_only_select_option
@pytest.mark.django_db
def test_multiple_select_adjacent_row(data_fixture):
user = data_fixture.create_user()
table = data_fixture.create_database_table(name="Car", user=user)
grid_view = data_fixture.create_grid_view(user=user, table=table, name="Test")
multiple_select_field = data_fixture.create_multiple_select_field(
table=table, name="option_field", order=1, primary=True
)
option_a = data_fixture.create_select_option(
field=multiple_select_field, value="A", color="blue", order=0
)
option_b = data_fixture.create_select_option(
field=multiple_select_field, value="B", color="red", order=1
)
option_c = data_fixture.create_select_option(
field=multiple_select_field, value="C", color="green", order=2
)
data_fixture.create_view_sort(
view=grid_view, field=multiple_select_field, order="ASC"
)
handler = RowHandler()
[row_b, row_c, row_a] = handler.create_rows(
user=user,
table=table,
rows_values=[
{
f"field_{multiple_select_field.id}": [option_b.id],
},
{
f"field_{multiple_select_field.id}": [option_c.id],
},
{
f"field_{multiple_select_field.id}": [option_a.id],
},
],
)
base_queryset = ViewHandler().apply_sorting(
grid_view, table.get_model().objects.all()
)
assert base_queryset[0].id == row_a.id
assert base_queryset[1].id == row_b.id
assert base_queryset[2].id == row_c.id
row_b = base_queryset.get(pk=row_b.id)
previous_row = handler.get_adjacent_row(
row_b, base_queryset, previous=True, view=grid_view
)
next_row = handler.get_adjacent_row(row_b, base_queryset, view=grid_view)
assert previous_row.id == row_a.id
assert next_row.id == row_c.id

View file

@ -7,6 +7,7 @@ import pytest
from baserow.contrib.database.fields.handler import FieldHandler
from baserow.contrib.database.fields.models import NumberField
from baserow.contrib.database.fields.registries import field_type_registry
from baserow.contrib.database.rows.handler import RowHandler
@pytest.mark.django_db
@ -216,3 +217,41 @@ def test_content_type_still_set_when_save_overridden(data_fixture):
expected_content_type = ContentType.objects.get_for_model(NumberField)
assert field.content_type == expected_content_type
assert field.content_type_id == expected_content_type.id
@pytest.mark.django_db
def test_number_field_adjacent_row(data_fixture):
user = data_fixture.create_user()
table = data_fixture.create_database_table(name="Car", user=user)
grid_view = data_fixture.create_grid_view(user=user, table=table, name="Test")
number_field = data_fixture.create_number_field(table=table)
data_fixture.create_view_sort(view=grid_view, field=number_field, order="DESC")
handler = RowHandler()
[row_a, row_b, row_c] = handler.create_rows(
user=user,
table=table,
rows_values=[
{
f"field_{number_field.id}": 1,
},
{
f"field_{number_field.id}": 2,
},
{
f"field_{number_field.id}": 3,
},
],
)
base_queryset = table.get_model().objects.all()
row_b = base_queryset.get(pk=row_b.id)
previous_row = handler.get_adjacent_row(
row_b, base_queryset, previous=True, view=grid_view
)
next_row = handler.get_adjacent_row(row_b, base_queryset, view=grid_view)
assert previous_row.id == row_c.id
assert next_row.id == row_a.id

View file

@ -59,7 +59,7 @@ def test_field_creation(data_fixture):
table=table,
type_name="rating",
name="rating invalid",
**invalid_value
**invalid_value,
)
@ -292,3 +292,41 @@ def test_rating_field_modification(data_fixture):
(6, "0", Decimal("0"), Decimal("0.00"), False),
(7, "0", Decimal("0"), Decimal("0.00"), False),
]
@pytest.mark.django_db
def test_rating_field_adjacent_row(data_fixture):
user = data_fixture.create_user()
table = data_fixture.create_database_table(name="Car", user=user)
grid_view = data_fixture.create_grid_view(user=user, table=table, name="Test")
rating_field = data_fixture.create_rating_field(table=table)
data_fixture.create_view_sort(view=grid_view, field=rating_field, order="DESC")
handler = RowHandler()
[row_a, row_b, row_c] = handler.create_rows(
user=user,
table=table,
rows_values=[
{
f"field_{rating_field.id}": 1,
},
{
f"field_{rating_field.id}": 2,
},
{
f"field_{rating_field.id}": 3,
},
],
)
base_queryset = table.get_model().objects.all()
row_b = base_queryset.get(pk=row_b.id)
previous_row = handler.get_adjacent_row(
row_b, base_queryset, previous=True, view=grid_view
)
next_row = handler.get_adjacent_row(row_b, base_queryset, view=grid_view)
assert previous_row.id == row_c.id
assert next_row.id == row_a.id

View file

@ -794,3 +794,52 @@ def test_get_set_export_serialized_value_single_select_field(data_fixture):
assert getattr(imported_row_3, f"field_{imported_field.id}_id") != option_b.id
assert getattr(imported_row_3, f"field_{imported_field.id}").value == "B"
assert getattr(imported_row_3, f"field_{imported_field.id}").color == "red"
@pytest.mark.django_db
def test_single_select_adjacent_row(data_fixture):
user = data_fixture.create_user()
table = data_fixture.create_database_table(name="Car", user=user)
grid_view = data_fixture.create_grid_view(user=user, table=table, name="Test")
single_select_field = data_fixture.create_single_select_field(
table=table, name="option_field", order=1, primary=True
)
option_a = data_fixture.create_select_option(
field=single_select_field, value="A", color="blue", order=0
)
option_b = data_fixture.create_select_option(
field=single_select_field, value="B", color="red", order=1
)
option_c = data_fixture.create_select_option(
field=single_select_field, value="C", color="green", order=2
)
data_fixture.create_view_sort(
view=grid_view, field=single_select_field, order="ASC"
)
handler = RowHandler()
[row_b, row_c, row_a] = handler.create_rows(
user=user,
table=table,
rows_values=[
{
f"field_{single_select_field.id}": option_b.id,
},
{
f"field_{single_select_field.id}": option_c.id,
},
{
f"field_{single_select_field.id}": option_a.id,
},
],
)
base_queryset = table.get_model().objects.all()
previous_row = handler.get_adjacent_row(
row_b, base_queryset, previous=True, view=grid_view
)
next_row = handler.get_adjacent_row(row_b, base_queryset, view=grid_view)
assert previous_row.id == row_a.id
assert next_row.id == row_c.id

View file

@ -7,6 +7,7 @@ from django.db import models
import pytest
from freezegun import freeze_time
from pyinstrument import Profiler
from pytz import UTC
from baserow.contrib.database.api.utils import (
@ -287,6 +288,178 @@ def test_get_row(data_fixture):
assert getattr(row_tmp, f"field_{price_field.id}") == Decimal("59999.99")
@pytest.mark.django_db
def test_get_adjacent_row(data_fixture):
user = data_fixture.create_user()
table = data_fixture.create_database_table(name="Car", user=user)
name_field = data_fixture.create_text_field(
table=table, name="Name", text_default="Test"
)
handler = RowHandler()
rows = handler.create_rows(
user=user,
table=table,
rows_values=[
{
f"field_{name_field.id}": "Tesla",
},
{
f"field_{name_field.id}": "Audi",
},
{
f"field_{name_field.id}": "BMW",
},
],
)
queryset = table.get_model().objects.all()
next_row = handler.get_adjacent_row(rows[1], queryset)
previous_row = handler.get_adjacent_row(rows[1], queryset, previous=True)
assert next_row.id == rows[2].id
assert previous_row.id == rows[0].id
@pytest.mark.django_db
def test_get_adjacent_row_with_custom_filters(data_fixture):
user = data_fixture.create_user()
table = data_fixture.create_database_table(name="Car", user=user)
name_field = data_fixture.create_text_field(
table=table, name="Name", text_default="Test"
)
handler = RowHandler()
[row_1, row_2, row_3] = handler.create_rows(
user=user,
table=table,
rows_values=[
{
f"field_{name_field.id}": "Tesla",
},
{
f"field_{name_field.id}": "Audi",
},
{
f"field_{name_field.id}": "BMW",
},
],
)
base_queryset = table.get_model().objects.filter(id__in=[row_2.id, row_3.id])
next_row = handler.get_adjacent_row(row_2, base_queryset)
previous_row = handler.get_adjacent_row(row_2, base_queryset, previous=True)
assert next_row.id == row_3.id
assert previous_row is None
@pytest.mark.django_db
def test_get_adjacent_row_with_view_sort(data_fixture):
user = data_fixture.create_user()
table = data_fixture.create_database_table(name="Car", user=user)
view = data_fixture.create_grid_view(table=table)
name_field = data_fixture.create_text_field(
table=table, name="Name", text_default="Test"
)
data_fixture.create_view_sort(view=view, field=name_field, order="DESC")
handler = RowHandler()
[row_1, row_2, row_3] = handler.create_rows(
user=user,
table=table,
rows_values=[
{
f"field_{name_field.id}": "A",
},
{
f"field_{name_field.id}": "B",
},
{
f"field_{name_field.id}": "C",
},
],
)
base_queryset = table.get_model().objects.all()
next_row = handler.get_adjacent_row(row_2, base_queryset, view=view)
previous_row = handler.get_adjacent_row(
row_2, base_queryset, previous=True, view=view
)
assert next_row.id == row_1.id
assert previous_row.id == row_3.id
@pytest.mark.django_db
@pytest.mark.disabled_in_ci
# You must add --run-disabled-in-ci -s to pytest to run this test, you can do this in
# intellij by editing the run config for this test and adding --run-disabled-in-ci -s
# to additional args.
def test_get_adjacent_row_performance_many_rows(data_fixture):
user = data_fixture.create_user()
table = data_fixture.create_database_table(name="Car", user=user)
name_field = data_fixture.create_text_field(
table=table, name="Name", text_default="Test"
)
handler = RowHandler()
row_amount = 100000
row_values = [{f"field_{name_field.id}": "Tesla"} for _ in range(row_amount)]
rows = handler.create_rows(user=user, table=table, rows_values=row_values)
profiler = Profiler()
profiler.start()
next_row = handler.get_adjacent_row(rows[5])
profiler.stop()
print(profiler.output_text(unicode=True, color=True))
assert next_row.id == rows[6].id
assert table.get_model().objects.count() == row_amount
@pytest.mark.django_db
@pytest.mark.disabled_in_ci
# You must add --run-disabled-in-ci -s to pytest to run this test, you can do this in
# intellij by editing the run config for this test and adding --run-disabled-in-ci -s
# to additional args.
def test_get_adjacent_row_performance_many_fields(data_fixture):
user = data_fixture.create_user()
table = data_fixture.create_database_table(name="Car", user=user)
handler = RowHandler()
field_amount = 1000
fields = [
data_fixture.create_text_field(table=table, name=f"Field_{i}")
for i in range(field_amount)
]
row_amount = 4000
row_values = []
for i in range(row_amount):
row_value = {f"field_{field.id}": "Tesla" for field in fields}
row_values.append(row_value)
rows = handler.create_rows(user=user, table=table, rows_values=row_values)
profiler = Profiler()
profiler.start()
next_row = handler.get_adjacent_row(rows[5])
profiler.stop()
print(profiler.output_text(unicode=True, color=True))
assert next_row.id == rows[6].id
assert table.get_model().objects.count() == row_amount
@pytest.mark.django_db
@patch("baserow.contrib.database.rows.signals.rows_updated.send")
def test_update_row(send_mock, data_fixture):

View file

@ -66,6 +66,8 @@ For example:
* Added Multiple Collaborators field type. [#1119](https://gitlab.com/bramw/baserow/-/issues/1119)
* Added missing success printouts to `count_rows` and `calculate_storage_usage` commands.
* Add `isort` settings to sort python imports.
* Add row url parameter to `gallery` and `kanban` view.
* Add navigation buttons to the `RowEditModal`.
* Introduced a premium form survey style theme. [#524](https://gitlab.com/bramw/baserow/-/issues/524).
* Allow creating new rows when selecting a related row [#1064](https://gitlab.com/bramw/baserow/-/issues/1064).
* Add row url parameter to `gallery` and `kanban` view.

View file

@ -81,6 +81,7 @@
></RowCreateModal>
<RowEditModal
ref="rowEditModal"
enable-navigation
:database="database"
:table="table"
:primary-is-sortable="true"
@ -102,6 +103,8 @@
fieldCreated($event)
showHiddenFieldsInRowModal = true
"
@navigate-previous="$emit('navigate-previous', $event)"
@navigate-next="$emit('navigate-next', $event)"
></RowEditModal>
</div>
</template>
@ -166,6 +169,9 @@ export default {
}
},
computed: {
...mapGetters({
row: 'rowModalNavigation/getRow',
}),
/**
* Returns the visible field objects in the right order.
*/
@ -202,6 +208,16 @@ export default {
})
},
},
watch: {
row: {
deep: true,
handler(row) {
if (row !== null && this.$refs.rowEditModal) {
this.populateAndEditRow(row)
}
},
},
},
beforeCreate() {
this.$options.computed = {
...(this.$options.computed || {}),
@ -217,8 +233,7 @@ export default {
},
mounted() {
if (this.row !== null) {
const rowClone = populateRow(clone(this.row))
this.$refs.rowEditModal.show(this.row.id, rowClone)
this.populateAndEditRow(this.row)
}
},
methods: {
@ -273,6 +288,14 @@ export default {
notifyIf(error, 'field')
}
},
/**
* Populates a new row and opens the row edit modal
* to edit the row.
*/
populateAndEditRow(row) {
const rowClone = populateRow(clone(row))
this.$refs.rowEditModal.show(row.id, rowClone)
},
},
}
</script>

View file

@ -103,3 +103,4 @@
@import 'deactivated_label';
@import 'snapshots_modal';
@import 'import_modal';
@import 'row_edit_modal';

View file

@ -0,0 +1,9 @@
.row-edit-modal__navigation {
margin-bottom: 15px;
height: 20px;
}
.row-edit-modal__navigation__item {
padding: 5px;
color: $color-neutral-400;
}

View file

@ -8,6 +8,23 @@
@hidden="$emit('hidden', { row })"
>
<template #content>
<div v-if="enableNavigation" class="row-edit-modal__navigation">
<div v-if="navigationLoading" class="loading"></div>
<template v-else>
<a
class="row-edit-modal__navigation__item"
@click="$emit('navigate-previous', previousRow)"
>
<i class="fa fa-lg fa-chevron-up"></i>
</a>
<a
class="row-edit-modal__navigation__item"
@click="$emit('navigate-next', nextRow)"
>
<i class="fa fa-lg fa-chevron-down"></i>
</a>
</template>
</div>
<h2 class="box__title">
{{ heading }}
</h2>
@ -75,8 +92,8 @@
</template>
<script>
import { mapGetters } from 'vuex'
import modal from '@baserow/modules/core/mixins/modal'
import CreateFieldContext from '@baserow/modules/database/components/field/CreateFieldContext'
import RowEditModalFieldsList from './RowEditModalFieldsList.vue'
import RowEditModalHiddenFieldsSection from './RowEditModalHiddenFieldsSection.vue'
@ -127,6 +144,11 @@ export default {
type: Boolean,
required: true,
},
enableNavigation: {
type: Boolean,
required: false,
default: false,
},
},
data() {
return {
@ -136,6 +158,9 @@ export default {
}
},
computed: {
...mapGetters({
navigationLoading: 'rowModalNavigation/getLoading',
}),
modalRow() {
return this.$store.getters['rowModal/get'](this._uid)
},
@ -148,6 +173,17 @@ export default {
row() {
return this.modalRow.row
},
rowIndex() {
return this.rows.findIndex((r) => r !== null && r.id === this.rowId)
},
nextRow() {
return this.rowIndex !== -1 && this.rows.length > this.rowIndex + 1
? this.rows[this.rowIndex + 1]
: null
},
previousRow() {
return this.rowIndex > 0 ? this.rows[this.rowIndex - 1] : null
},
heading() {
const field = getPrimaryOrFirstField(this.visibleFields)

View file

@ -139,11 +139,18 @@
:table="table"
:view="view"
:fields="fields"
:row="row"
:read-only="readOnly"
:store-prefix="storePrefix"
@refresh="refresh"
@selected-row="$emit('selected-row', $event)"
@navigate-previous="
(row, activeSearchTerm) =>
$emit('navigate-previous', row, activeSearchTerm)
"
@navigate-next="
(row, activeSearchTerm) =>
$emit('navigate-next', row, activeSearchTerm)
"
/>
<div v-if="viewLoading" class="loading-overlay"></div>
</div>
@ -208,11 +215,6 @@ export default {
required: true,
validator: (prop) => typeof prop === 'object' || prop === undefined,
},
row: {
type: Object,
required: false,
default: null,
},
tableLoading: {
type: Boolean,
required: true,

View file

@ -71,6 +71,7 @@
></RowCreateModal>
<RowEditModal
ref="rowEditModal"
enable-navigation
:database="database"
:table="table"
:primary-is-sortable="true"
@ -89,6 +90,8 @@
@field-updated="$emit('refresh', $event)"
@field-deleted="$emit('refresh')"
@field-created="showFieldCreated"
@navigate-previous="$emit('navigate-previous', $event, activeSearchTerm)"
@navigate-next="$emit('navigate-next', $event, activeSearchTerm)"
>
</RowEditModal>
</div>
@ -148,10 +151,6 @@ export default {
type: String,
required: true,
},
row: {
validator: (prop) => typeof prop === 'object' || prop === null,
required: true,
},
},
data() {
return {
@ -165,6 +164,9 @@ export default {
}
},
computed: {
...mapGetters({
row: 'rowModalNavigation/getRow',
}),
firstRows() {
return this.allRows.slice(0, 200)
},
@ -198,6 +200,11 @@ export default {
const fieldId = this.view.card_cover_image_field
return this.fields.find((field) => field.id === fieldId) || null
},
activeSearchTerm() {
return this.$store.getters[
`${this.storePrefix}view/gallery/getActiveSearchTerm`
]
},
},
watch: {
cardHeight() {
@ -210,6 +217,14 @@ export default {
this.updateBuffer(true, false)
})
},
row: {
deep: true,
handler(row) {
if (row !== null && this.$refs.rowEditModal) {
this.populateAndEditRow(row)
}
},
},
},
mounted() {
this.updateBuffer()
@ -273,8 +288,7 @@ export default {
this.$refs.scroll.addEventListener('scroll', this.$el.scrollEvent)
if (this.row !== null) {
const rowClone = populateRow(clone(this.row))
this.$refs.rowEditModal.show(this.row.id, rowClone)
this.populateAndEditRow(this.row)
}
},
beforeDestroy() {
@ -415,6 +429,14 @@ export default {
this.fieldCreated({ fetchNeeded, ...context })
this.showHiddenFieldsInRowModal = true
},
/**
* Populates a new row and opens the row edit modal
* to edit the row.
*/
populateAndEditRow(row) {
const rowClone = populateRow(clone(row))
this.$refs.rowEditModal.show(row.id, rowClone)
},
},
}
</script>

View file

@ -192,6 +192,7 @@
:hidden-fields="hiddenFields"
:rows="allRows"
:read-only="readOnly"
:enable-navigation="!readOnly"
:show-hidden-fields="showHiddenFieldsInRowModal"
@toggle-hidden-fields-visibility="
showHiddenFieldsInRowModal = !showHiddenFieldsInRowModal
@ -203,6 +204,8 @@
@field-updated="$emit('refresh', $event)"
@field-deleted="$emit('refresh')"
@field-created="fieldCreated"
@navigate-previous="$emit('navigate-previous', $event, activeSearchTerm)"
@navigate-next="$emit('navigate-next', $event, activeSearchTerm)"
></RowEditModal>
</div>
</template>
@ -254,10 +257,6 @@ export default {
type: Object,
required: true,
},
row: {
validator: (prop) => typeof prop === 'object' || prop === null,
required: true,
},
readOnly: {
type: Boolean,
required: true,
@ -272,6 +271,9 @@ export default {
}
},
computed: {
...mapGetters({
row: 'rowModalNavigation/getRow',
}),
allVisibleFields() {
return this.leftFields.concat(this.visibleFields)
},
@ -308,6 +310,11 @@ export default {
leftWidth() {
return this.leftFieldsWidth + this.gridViewRowDetailsWidth
},
activeSearchTerm() {
return this.$store.getters[
`${this.storePrefix}view/grid/getActiveSearchTerm`
]
},
},
watch: {
fieldOptions: {
@ -322,6 +329,14 @@ export default {
// When a field is added or removed, we want to update the scrollbars.
this.fieldsUpdated()
},
row: {
deep: true,
handler(row) {
if (row !== null && this.$refs.rowEditModal) {
this.populateAndEditRow(row)
}
},
},
},
beforeCreate() {
this.$options.computed = {
@ -366,8 +381,7 @@ export default {
)
if (this.row !== null) {
const rowClone = populateRow(clone(this.row))
this.$refs.rowEditModal.show(this.row.id, rowClone)
this.populateAndEditRow(this.row)
}
},
beforeDestroy() {
@ -652,6 +666,14 @@ export default {
this.$refs.rowEditModal.show(rowId)
this.$emit('selected-row', rowId)
},
/**
* Populates a new row and opens the row edit modal
* to edit the row.
*/
populateAndEditRow(row) {
const rowClone = populateRow(clone(row))
this.$refs.rowEditModal.show(row.id, rowClone)
},
/**
* When a cell is selected we want to make sure it is visible in the viewport, so
* we might need to scroll a little bit.

View file

@ -714,5 +714,25 @@
"titlePlaceholder": "Title",
"descriptionPlaceholder": "Description",
"noFields": "This form doesn't have any fields. Click on a field in the left sidebar to add one."
},
"table": {
"adjacentRow": {
"notification": {
"notFound": {
"next": {
"title": "No more rows",
"message": "There is no next row"
},
"previous": {
"title": "No more rows",
"message": "There is no previous row"
}
},
"error": {
"title": "Error occurred",
"message": "An error occurred while retrieving the adjacent row"
}
}
}
}
}

View file

@ -6,11 +6,16 @@
:fields="fields"
:views="views"
:view="view"
:row="row"
:table-loading="tableLoading"
store-prefix="page/"
@selected-view="selectedView"
@selected-row="selectRow"
@navigate-previous="
(row, activeSearchTerm) => setAdjacentRow(true, row, activeSearchTerm)
"
@navigate-next="
(row, activeSearchTerm) => setAdjacentRow(false, row, activeSearchTerm)
"
></Table>
</div>
</template>
@ -19,7 +24,6 @@
import { mapState } from 'vuex'
import Table from '@baserow/modules/database/components/table/Table'
import RowService from '@baserow/modules/database/services/row'
import { StoreItemLookupError } from '@baserow/modules/core/errors'
/**
@ -54,7 +58,7 @@ export default {
const databaseId = parseInt(params.databaseId)
const tableId = parseInt(params.tableId)
let viewId = params.viewId ? parseInt(params.viewId) : null
const data = { row: null }
const data = {}
// Try to find the table in the already fetched applications by the
// groupsAndApplications middleware and select that one. By selecting the table, the
@ -124,11 +128,10 @@ export default {
if (params.rowId) {
try {
const { data: rowData } = await RowService(app.$client).get(
await store.dispatch('rowModalNavigation/fetchRow', {
tableId,
params.rowId
)
data.row = rowData
rowId: params.rowId,
})
} catch (e) {
return error({ statusCode: 404, message: 'Row not found.' })
}
@ -180,7 +183,32 @@ export default {
},
})
},
selectRow(rowId) {
async setAdjacentRow(previous, row = null, activeSearchTerm = null) {
if (row) {
await this.$store.dispatch('rowModalNavigation/setRow', row)
this.updatePath(row.id)
} else {
// If the row isn't provided then the row is
// probably not visible to the user at the moment
// and needs to be fetched
await this.fetchAdjacentRow(previous, activeSearchTerm)
}
},
async selectRow(rowId) {
if (rowId) {
const row = await this.$store.dispatch('rowModalNavigation/fetchRow', {
tableId: this.table.id,
rowId,
})
if (row) {
this.updatePath(rowId)
}
} else {
await this.$store.dispatch('rowModalNavigation/clearRow')
this.updatePath(rowId)
}
},
updatePath(rowId) {
if (
this.$route.params.rowId !== undefined &&
this.$route.params.rowId === rowId
@ -199,6 +227,36 @@ export default {
}).href
history.replaceState({}, null, newPath)
},
async fetchAdjacentRow(previous, activeSearchTerm = null) {
const { row, status } = await this.$store.dispatch(
'rowModalNavigation/fetchAdjacentRow',
{
tableId: this.table.id,
viewId: this.view?.id,
activeSearchTerm,
previous,
}
)
if (status === 204 || status === 404) {
const translationPath = `table.adjacentRow.notification.notFound.${
previous ? 'previous' : 'next'
}`
await this.$store.dispatch('notification/info', {
title: this.$t(`${translationPath}.title`),
message: this.$t(`${translationPath}.message`),
})
} else if (status !== 200) {
await this.$store.dispatch('notification/error', {
title: this.$t(`table.adjacentRow.notification.error.title`),
message: this.$t(`table.adjacentRow.notification.error.message`),
})
}
if (row) {
this.updatePath(row.id)
}
},
},
}
</script>

View file

@ -89,6 +89,7 @@ import galleryStore from '@baserow/modules/database/store/view/gallery'
import formStore from '@baserow/modules/database/store/view/form'
import rowModal from '@baserow/modules/database/store/rowModal'
import publicStore from '@baserow/modules/database/store/view/public'
import rowModalNavigation from '@baserow/modules/database/store/rowModalNavigation'
import { registerRealtimeEvents } from '@baserow/modules/database/realtime'
import { CSVTableExporterType } from '@baserow/modules/database/exporterTypes'
@ -220,6 +221,7 @@ export default (context) => {
store.registerModule('view', viewStore)
store.registerModule('field', fieldStore)
store.registerModule('rowModal', rowModal)
store.registerModule('rowModalNavigation', rowModalNavigation)
store.registerModule('page/view/grid', gridStore)
store.registerModule('page/view/gallery', galleryStore)
store.registerModule('page/view/form', formStore)

View file

@ -95,5 +95,21 @@ export default (client) => {
items,
})
},
getAdjacent({
tableId,
rowId,
viewId = null,
previous = false,
search = null,
}) {
const searchSanitized = search === '' ? null : search
return client.get(`/database/rows/table/${tableId}/${rowId}/adjacent/`, {
params: {
previous,
search: searchSanitized,
view_id: viewId,
},
})
},
}
}

View file

@ -0,0 +1,100 @@
import RowService from '@baserow/modules/database/services/row'
import { notifyIf } from '@baserow/modules/core/utils/error'
/**
* This store exists to deal with the row edit modal navigation.
* It handles the state of the navigation which can be in a loading
* state or in a ready state. It also handles the state of the row
* which can either be taken from the buffer or fetched if outside
* of the buffer.
*/
export const state = () => ({
loading: false,
/**
* The row refers to the row that the view is
* trying to display. It has a slightly different
* purpose to the rows stored in the `rowModal` store
* since those rows are accessed by the row edit modal
* directly while this row is accessed by the view and
* given to the row edit modal via `show()`.
*
* The row can be outside of the buffer and could therefore
* also not be part of the `rows` in the `rowModal` store.
*/
row: null,
})
export const mutations = {
CLEAR_ROW(state) {
state.row = null
},
SET_LOADING(state, value) {
state.loading = value
},
SET_ROW(state, row) {
state.row = row
},
}
export const actions = {
clearRow({ commit }) {
commit('CLEAR_ROW')
},
setRow({ commit }, row) {
commit('SET_ROW', row)
},
async fetchRow({ commit }, { tableId, rowId }) {
try {
const { data: row } = await RowService(this.$client).get(tableId, rowId)
commit('SET_ROW', row)
return row
} catch (error) {
notifyIf(error, 'row')
}
},
async fetchAdjacentRow(
{ commit, dispatch, state },
{ tableId, viewId, previous, activeSearchTerm }
) {
commit('SET_LOADING', true)
try {
const { data: row, status } = await RowService(this.$client).getAdjacent({
previous,
tableId,
viewId,
rowId: state.row.id,
search: activeSearchTerm,
})
if (row) {
commit('SET_ROW', row)
}
commit('SET_LOADING', false)
return { row, status }
} catch (error) {
commit('SET_LOADING', false)
const status = error.response?.status ?? null
// This is the backend not responding at all
if (status === null) {
notifyIf(error, 'row')
}
return { row: null, status }
}
},
}
export const getters = {
getLoading(state) {
return state.loading
},
getRow(state) {
return state.row
},
}
export default {
namespaced: true,
state,
getters,
actions,
mutations,
}