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:
parent
d4db3332dd
commit
93bfd27103
34 changed files with 1538 additions and 80 deletions
backend
src/baserow/contrib/database
api/rows
fields
rows
table
tests/baserow/contrib/database
api/rows
field
test_boolean_field_type.pytest_created_on_field_type.pytest_date_field_type.pytest_field_types.pytest_formula_field_type.pytest_last_modified_field_type.pytest_multiple_collaborators_field_type.pytest_multiple_select_field_type.pytest_number_field_type.pytest_rating_field_type.pytest_single_select_field_type.py
rows
premium/web-frontend/modules/baserow_premium/components/views/kanban
web-frontend/modules
|
@ -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)
|
||||
|
|
|
@ -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(),
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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):
|
||||
"""
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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__
|
||||
)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -103,3 +103,4 @@
|
|||
@import 'deactivated_label';
|
||||
@import 'snapshots_modal';
|
||||
@import 'import_modal';
|
||||
@import 'row_edit_modal';
|
||||
|
|
|
@ -0,0 +1,9 @@
|
|||
.row-edit-modal__navigation {
|
||||
margin-bottom: 15px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.row-edit-modal__navigation__item {
|
||||
padding: 5px;
|
||||
color: $color-neutral-400;
|
||||
}
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
100
web-frontend/modules/database/store/rowModalNavigation.js
Normal file
100
web-frontend/modules/database/store/rowModalNavigation.js
Normal 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,
|
||||
}
|
Loading…
Add table
Reference in a new issue