mirror of
https://gitlab.com/bramw/baserow.git
synced 2025-04-17 18:32:35 +00:00
Merge branch '135-sorting-rows-by-field' into 'develop'
Resolve "Sorting rows by field" Closes #135 See merge request bramw/baserow!97
This commit is contained in:
commit
c9e0ddd856
44 changed files with 2681 additions and 124 deletions
backend
src/baserow
config/settings
contrib/database
tests
baserow/contrib/database
fixtures
web-frontend
modules
core/assets/scss/components
database
|
@ -162,6 +162,8 @@ SPECTACULAR_SETTINGS = {
|
|||
{'name': 'Database tables'},
|
||||
{'name': 'Database table fields'},
|
||||
{'name': 'Database table views'},
|
||||
{'name': 'Database table view filters'},
|
||||
{'name': 'Database table view sortings'},
|
||||
{'name': 'Database table grid view'},
|
||||
{'name': 'Database table rows'}
|
||||
],
|
||||
|
|
|
@ -21,3 +21,23 @@ ERROR_VIEW_FILTER_TYPE_NOT_ALLOWED_FOR_FIELD = (
|
|||
HTTP_400_BAD_REQUEST,
|
||||
'The chosen filter type is not allowed for the provided field.'
|
||||
)
|
||||
ERROR_VIEW_SORT_DOES_NOT_EXIST = (
|
||||
'ERROR_VIEW_SORT_DOES_NOT_EXIST',
|
||||
HTTP_404_NOT_FOUND,
|
||||
'The view sort does not exist.'
|
||||
)
|
||||
ERROR_VIEW_SORT_NOT_SUPPORTED = (
|
||||
'ERROR_VIEW_SORT_NOT_SUPPORTED',
|
||||
HTTP_400_BAD_REQUEST,
|
||||
'Sorting is not supported for the view type.'
|
||||
)
|
||||
ERROR_VIEW_SORT_FIELD_ALREADY_EXISTS = (
|
||||
'ERROR_VIEW_SORT_FIELD_ALREADY_EXISTS',
|
||||
HTTP_400_BAD_REQUEST,
|
||||
'A sort with the field already exists in the view.'
|
||||
)
|
||||
ERROR_VIEW_SORT_FIELD_NOT_SUPPORTED = (
|
||||
'ERROR_VIEW_SORT_FIELD_NOT_SUPPORTED',
|
||||
HTTP_400_BAD_REQUEST,
|
||||
'The field does not support view sorting.'
|
||||
)
|
||||
|
|
|
@ -38,6 +38,10 @@ class GridViewView(APIView):
|
|||
description='Returns only rows that belong to the related view\'s '
|
||||
'table.'
|
||||
),
|
||||
OpenApiParameter(
|
||||
name='count', location=OpenApiParameter.PATH, type=OpenApiTypes.NONE,
|
||||
description='If provided only the count will be returned.'
|
||||
),
|
||||
OpenApiParameter(
|
||||
name='include', location=OpenApiParameter.QUERY, type=OpenApiTypes.STR,
|
||||
description=(
|
||||
|
@ -80,7 +84,12 @@ class GridViewView(APIView):
|
|||
'list them all. In the example all field types are listed, but normally '
|
||||
'the number in field_{id} key is going to be the id of the field. '
|
||||
'The value is what the user has provided and the format of it depends on '
|
||||
'the fields type.'
|
||||
'the fields type.\n'
|
||||
'\n'
|
||||
'The filters and sortings are automatically applied. To get a full '
|
||||
'overview of the applied filters and sortings you can use the '
|
||||
'`list_database_table_view_filters` and '
|
||||
'`list_database_table_view_sortings` endpoints.'
|
||||
),
|
||||
responses={
|
||||
200: example_pagination_row_serializer_class,
|
||||
|
@ -109,8 +118,12 @@ class GridViewView(APIView):
|
|||
model = view.table.get_model()
|
||||
queryset = model.objects.all().enhance_by_fields().order_by('id')
|
||||
|
||||
# Applies the view filters to the queryset if there are any.
|
||||
# Applies the view filters and sortings to the queryset if there are any.
|
||||
queryset = view_handler.apply_filters(view, queryset)
|
||||
queryset = view_handler.apply_sorting(view, queryset)
|
||||
|
||||
if 'count' in request.GET:
|
||||
return Response({'count': queryset.count()})
|
||||
|
||||
if LimitOffsetPagination.limit_query_param in request.GET:
|
||||
paginator = LimitOffsetPagination()
|
||||
|
|
|
@ -9,7 +9,7 @@ from baserow.contrib.database.api.serializers import TableSerializer
|
|||
from baserow.contrib.database.views.registries import (
|
||||
view_type_registry, view_filter_type_registry
|
||||
)
|
||||
from baserow.contrib.database.views.models import View, ViewFilter
|
||||
from baserow.contrib.database.views.models import View, ViewFilter, ViewSort
|
||||
|
||||
|
||||
class ViewFilterSerializer(serializers.ModelSerializer):
|
||||
|
@ -53,14 +53,46 @@ class UpdateViewFilterSerializer(serializers.ModelSerializer):
|
|||
}
|
||||
|
||||
|
||||
class ViewSortSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = ViewSort
|
||||
fields = ('id', 'view', 'field', 'order')
|
||||
extra_kwargs = {
|
||||
'id': {
|
||||
'read_only': True
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class CreateViewSortSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = ViewSort
|
||||
fields = ('field', 'order')
|
||||
extra_kwargs = {
|
||||
'order': {'default': ViewSort._meta.get_field('order').default},
|
||||
}
|
||||
|
||||
|
||||
class UpdateViewSortSerializer(serializers.ModelSerializer):
|
||||
class Meta(CreateViewFilterSerializer.Meta):
|
||||
model = ViewSort
|
||||
fields = ('field', 'order')
|
||||
extra_kwargs = {
|
||||
'field': {'required': False},
|
||||
'order': {'required': False}
|
||||
}
|
||||
|
||||
|
||||
class ViewSerializer(serializers.ModelSerializer):
|
||||
type = serializers.SerializerMethodField()
|
||||
table = TableSerializer()
|
||||
filters = ViewFilterSerializer(many=True, source='viewfilter_set')
|
||||
sortings = ViewSortSerializer(many=True, source='viewsort_set')
|
||||
|
||||
class Meta:
|
||||
model = View
|
||||
fields = ('id', 'name', 'order', 'type', 'table', 'filter_type', 'filters')
|
||||
fields = ('id', 'name', 'order', 'type', 'table', 'filter_type', 'filters',
|
||||
'sortings')
|
||||
extra_kwargs = {
|
||||
'id': {
|
||||
'read_only': True
|
||||
|
@ -69,11 +101,15 @@ class ViewSerializer(serializers.ModelSerializer):
|
|||
|
||||
def __init__(self, *args, **kwargs):
|
||||
include_filters = kwargs.pop('filters') if 'filters' in kwargs else False
|
||||
include_sortings = kwargs.pop('sortings') if 'sortings' in kwargs else False
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
if not include_filters:
|
||||
self.fields.pop('filters')
|
||||
|
||||
if not include_sortings:
|
||||
self.fields.pop('sortings')
|
||||
|
||||
@extend_schema_field(OpenApiTypes.STR)
|
||||
def get_type(self, instance):
|
||||
# It could be that the view related to the instance is already in the context
|
||||
|
|
|
@ -2,7 +2,10 @@ from django.conf.urls import url
|
|||
|
||||
from baserow.contrib.database.views.registries import view_type_registry
|
||||
|
||||
from .views import ViewsView, ViewView, ViewFiltersView, ViewFilterView
|
||||
from .views import (
|
||||
ViewsView, ViewView, ViewFiltersView, ViewFilterView, ViewSortingsView,
|
||||
ViewSortView
|
||||
)
|
||||
|
||||
|
||||
app_name = 'baserow.contrib.database.api.views'
|
||||
|
@ -14,10 +17,20 @@ urlpatterns = view_type_registry.api_urls + [
|
|||
ViewFilterView.as_view(),
|
||||
name='filter_item'
|
||||
),
|
||||
url(
|
||||
r'sort/(?P<view_sort_id>[0-9]+)/$',
|
||||
ViewSortView.as_view(),
|
||||
name='sort_item'
|
||||
),
|
||||
url(r'(?P<view_id>[0-9]+)/$', ViewView.as_view(), name='item'),
|
||||
url(
|
||||
r'(?P<view_id>[0-9]+)/filters/$',
|
||||
ViewFiltersView.as_view(),
|
||||
name='list_filters'
|
||||
),
|
||||
url(
|
||||
r'(?P<view_id>[0-9]+)/sortings/$',
|
||||
ViewSortingsView.as_view(),
|
||||
name='list_sortings'
|
||||
),
|
||||
]
|
||||
|
|
|
@ -22,20 +22,24 @@ from baserow.contrib.database.fields.exceptions import FieldNotInTable
|
|||
from baserow.contrib.database.table.handler import TableHandler
|
||||
from baserow.contrib.database.table.exceptions import TableDoesNotExist
|
||||
from baserow.contrib.database.views.registries import view_type_registry
|
||||
from baserow.contrib.database.views.models import View, ViewFilter
|
||||
from baserow.contrib.database.views.models import View, ViewFilter, ViewSort
|
||||
from baserow.contrib.database.views.handler import ViewHandler
|
||||
from baserow.contrib.database.views.exceptions import (
|
||||
ViewDoesNotExist, ViewFilterDoesNotExist, ViewFilterNotSupported,
|
||||
ViewFilterTypeNotAllowedForField
|
||||
ViewFilterTypeNotAllowedForField, ViewSortDoesNotExist, ViewSortNotSupported,
|
||||
ViewSortFieldAlreadyExist, ViewSortFieldNotSupported
|
||||
)
|
||||
|
||||
from .serializers import (
|
||||
ViewSerializer, CreateViewSerializer, UpdateViewSerializer, ViewFilterSerializer,
|
||||
CreateViewFilterSerializer, UpdateViewFilterSerializer
|
||||
CreateViewFilterSerializer, UpdateViewFilterSerializer, ViewSortSerializer,
|
||||
CreateViewSortSerializer, UpdateViewSortSerializer
|
||||
)
|
||||
from .errors import (
|
||||
ERROR_VIEW_DOES_NOT_EXIST, ERROR_VIEW_FILTER_DOES_NOT_EXIST,
|
||||
ERROR_VIEW_FILTER_NOT_SUPPORTED, ERROR_VIEW_FILTER_TYPE_NOT_ALLOWED_FOR_FIELD
|
||||
ERROR_VIEW_FILTER_NOT_SUPPORTED, ERROR_VIEW_FILTER_TYPE_NOT_ALLOWED_FOR_FIELD,
|
||||
ERROR_VIEW_SORT_DOES_NOT_EXIST, ERROR_VIEW_SORT_NOT_SUPPORTED,
|
||||
ERROR_VIEW_SORT_FIELD_ALREADY_EXISTS, ERROR_VIEW_SORT_FIELD_NOT_SUPPORTED
|
||||
)
|
||||
|
||||
|
||||
|
@ -77,8 +81,8 @@ class ViewsView(APIView):
|
|||
TableDoesNotExist: ERROR_TABLE_DOES_NOT_EXIST,
|
||||
UserNotInGroupError: ERROR_USER_NOT_IN_GROUP
|
||||
})
|
||||
@allowed_includes('filters')
|
||||
def get(self, request, table_id, filters):
|
||||
@allowed_includes('filters', 'sortings')
|
||||
def get(self, request, table_id, filters, sortings):
|
||||
"""
|
||||
Responds with a list of serialized views that belong to the table if the user
|
||||
has access to that group.
|
||||
|
@ -90,11 +94,15 @@ class ViewsView(APIView):
|
|||
if filters:
|
||||
views = views.prefetch_related('viewfilter_set')
|
||||
|
||||
if sortings:
|
||||
views = views.prefetch_related('viewsort_set')
|
||||
|
||||
data = [
|
||||
view_type_registry.get_serializer(
|
||||
view,
|
||||
ViewSerializer,
|
||||
filters=filters
|
||||
filters=filters,
|
||||
sortings=sortings
|
||||
).data
|
||||
for view in views
|
||||
]
|
||||
|
@ -140,8 +148,8 @@ class ViewsView(APIView):
|
|||
TableDoesNotExist: ERROR_TABLE_DOES_NOT_EXIST,
|
||||
UserNotInGroupError: ERROR_USER_NOT_IN_GROUP
|
||||
})
|
||||
@allowed_includes('filters')
|
||||
def post(self, request, data, table_id, filters):
|
||||
@allowed_includes('filters', 'sortings')
|
||||
def post(self, request, data, table_id, filters, sortings):
|
||||
"""Creates a new view for a user."""
|
||||
|
||||
table = TableHandler().get_table(request.user, table_id)
|
||||
|
@ -151,7 +159,8 @@ class ViewsView(APIView):
|
|||
serializer = view_type_registry.get_serializer(
|
||||
view,
|
||||
ViewSerializer,
|
||||
filters=filters
|
||||
filters=filters,
|
||||
sortings=sortings
|
||||
)
|
||||
return Response(serializer.data)
|
||||
|
||||
|
@ -188,15 +197,16 @@ class ViewView(APIView):
|
|||
ViewDoesNotExist: ERROR_VIEW_DOES_NOT_EXIST,
|
||||
UserNotInGroupError: ERROR_USER_NOT_IN_GROUP
|
||||
})
|
||||
@allowed_includes('filters')
|
||||
def get(self, request, view_id, filters):
|
||||
@allowed_includes('filters', 'sortings')
|
||||
def get(self, request, view_id, filters, sortings):
|
||||
"""Selects a single view and responds with a serialized version."""
|
||||
|
||||
view = ViewHandler().get_view(request.user, view_id)
|
||||
serializer = view_type_registry.get_serializer(
|
||||
view,
|
||||
ViewSerializer,
|
||||
filters=filters
|
||||
filters=filters,
|
||||
sortings=sortings
|
||||
)
|
||||
return Response(serializer.data)
|
||||
|
||||
|
@ -236,8 +246,8 @@ class ViewView(APIView):
|
|||
ViewDoesNotExist: ERROR_VIEW_DOES_NOT_EXIST,
|
||||
UserNotInGroupError: ERROR_USER_NOT_IN_GROUP
|
||||
})
|
||||
@allowed_includes('filters')
|
||||
def patch(self, request, view_id, filters):
|
||||
@allowed_includes('filters', 'sortings')
|
||||
def patch(self, request, view_id, filters, sortings):
|
||||
"""Updates the view if the user belongs to the group."""
|
||||
|
||||
view = ViewHandler().get_view(request.user, view_id).specific
|
||||
|
@ -252,7 +262,8 @@ class ViewView(APIView):
|
|||
serializer = view_type_registry.get_serializer(
|
||||
view,
|
||||
ViewSerializer,
|
||||
filters=filters
|
||||
filters=filters,
|
||||
sortings=sortings
|
||||
)
|
||||
return Response(serializer.data)
|
||||
|
||||
|
@ -306,12 +317,12 @@ class ViewFiltersView(APIView):
|
|||
'value.'
|
||||
)
|
||||
],
|
||||
tags=['Database table views'],
|
||||
tags=['Database table view filters'],
|
||||
operation_id='list_database_table_view_filters',
|
||||
description=(
|
||||
'Lists all filters of the view related to the provided `view_id` if the '
|
||||
'user has access to the related database\'s group. A view can have '
|
||||
'multiple filters. When all the rows are requested for the view only those'
|
||||
'multiple filters. When all the rows are requested for the view only those '
|
||||
'that apply to the filters are returned.'
|
||||
),
|
||||
responses={
|
||||
|
@ -345,12 +356,12 @@ class ViewFiltersView(APIView):
|
|||
'value.'
|
||||
)
|
||||
],
|
||||
tags=['Database table views'],
|
||||
tags=['Database table view filters'],
|
||||
operation_id='create_database_table_view_filter',
|
||||
description=(
|
||||
'Creates a new filter for the view related to the provided `view_id` '
|
||||
'parameter if the authorized user has access to the related database\'s '
|
||||
'group. When the rows of a view are requested, for example via the'
|
||||
'group. When the rows of a view are requested, for example via the '
|
||||
'`list_database_table_grid_view_rows` endpoint, then only the rows that '
|
||||
'apply to all the filters are going to be returned. A filters compares the '
|
||||
'value of a field to the value of a filter. It depends on the type how '
|
||||
|
@ -403,11 +414,11 @@ class ViewFilterView(APIView):
|
|||
description='Returns the view filter related to the provided value.'
|
||||
)
|
||||
],
|
||||
tags=['Database table views'],
|
||||
tags=['Database table view filters'],
|
||||
operation_id='get_database_table_view_filter',
|
||||
description=(
|
||||
'Returns the existing view filter if the authorized user has access to the'
|
||||
'related database\'s group.'
|
||||
' related database\'s group.'
|
||||
),
|
||||
responses={
|
||||
200: ViewFilterSerializer(),
|
||||
|
@ -435,7 +446,7 @@ class ViewFilterView(APIView):
|
|||
description='Updates the view filter related to the provided value.'
|
||||
)
|
||||
],
|
||||
tags=['Database table views'],
|
||||
tags=['Database table view filters'],
|
||||
operation_id='update_database_table_view_filter',
|
||||
description=(
|
||||
'Updates the existing filter if the authorized user has access to the '
|
||||
|
@ -488,7 +499,7 @@ class ViewFilterView(APIView):
|
|||
description='Deletes the filter related to the provided value.'
|
||||
)
|
||||
],
|
||||
tags=['Database table views'],
|
||||
tags=['Database table view filters'],
|
||||
operation_id='delete_database_table_view_filter',
|
||||
description=(
|
||||
'Deletes the existing filter if the authorized user has access to the '
|
||||
|
@ -512,3 +523,220 @@ class ViewFilterView(APIView):
|
|||
ViewHandler().delete_filter(request.user, view)
|
||||
|
||||
return Response(status=204)
|
||||
|
||||
|
||||
class ViewSortingsView(APIView):
|
||||
permission_classes = (IsAuthenticated,)
|
||||
|
||||
@extend_schema(
|
||||
parameters=[
|
||||
OpenApiParameter(
|
||||
name='view_id',
|
||||
location=OpenApiParameter.PATH,
|
||||
type=OpenApiTypes.INT,
|
||||
description='Returns only sortings of the view related to the provided '
|
||||
'value.'
|
||||
)
|
||||
],
|
||||
tags=['Database table view sortings'],
|
||||
operation_id='list_database_table_view_sortings',
|
||||
description=(
|
||||
'Lists all sortings of the view related to the provided `view_id` if the '
|
||||
'user has access to the related database\'s group. A view can have '
|
||||
'multiple sortings. When all the rows are requested they will be in the '
|
||||
'desired order.'
|
||||
),
|
||||
responses={
|
||||
200: ViewSortSerializer(many=True),
|
||||
400: get_error_schema(['ERROR_USER_NOT_IN_GROUP']),
|
||||
404: get_error_schema(['ERROR_VIEW_DOES_NOT_EXIST'])
|
||||
}
|
||||
)
|
||||
@map_exceptions({
|
||||
ViewDoesNotExist: ERROR_VIEW_DOES_NOT_EXIST,
|
||||
UserNotInGroupError: ERROR_USER_NOT_IN_GROUP
|
||||
})
|
||||
def get(self, request, view_id):
|
||||
"""
|
||||
Responds with a list of serialized sortings that belong to the view if the user
|
||||
has access to that group.
|
||||
"""
|
||||
|
||||
view = ViewHandler().get_view(request.user, view_id)
|
||||
sortings = ViewSort.objects.filter(view=view)
|
||||
serializer = ViewSortSerializer(sortings, many=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
@extend_schema(
|
||||
parameters=[
|
||||
OpenApiParameter(
|
||||
name='view_id',
|
||||
location=OpenApiParameter.PATH,
|
||||
type=OpenApiTypes.INT,
|
||||
description='Creates a sort for the view related to the provided '
|
||||
'value.'
|
||||
)
|
||||
],
|
||||
tags=['Database table view sortings'],
|
||||
operation_id='create_database_table_view_sort',
|
||||
description=(
|
||||
'Creates a new sort for the view related to the provided `view_id` '
|
||||
'parameter if the authorized user has access to the related database\'s '
|
||||
'group. When the rows of a view are requested, for example via the '
|
||||
'`list_database_table_grid_view_rows` endpoint, they will be returned in '
|
||||
'the respected order defined by all the sortings.'
|
||||
),
|
||||
request=CreateViewSortSerializer(),
|
||||
responses={
|
||||
200: ViewSortSerializer(),
|
||||
400: get_error_schema([
|
||||
'ERROR_USER_NOT_IN_GROUP', 'ERROR_REQUEST_BODY_VALIDATION',
|
||||
'ERROR_VIEW_SORT_NOT_SUPPORTED', 'ERROR_FIELD_NOT_IN_TABLE',
|
||||
'ERROR_VIEW_SORT_FIELD_ALREADY_EXISTS',
|
||||
'ERROR_VIEW_SORT_FIELD_NOT_SUPPORTED'
|
||||
]),
|
||||
404: get_error_schema(['ERROR_VIEW_DOES_NOT_EXIST'])
|
||||
}
|
||||
)
|
||||
@transaction.atomic
|
||||
@validate_body(CreateViewSortSerializer)
|
||||
@map_exceptions({
|
||||
ViewDoesNotExist: ERROR_VIEW_DOES_NOT_EXIST,
|
||||
UserNotInGroupError: ERROR_USER_NOT_IN_GROUP,
|
||||
FieldNotInTable: ERROR_FIELD_NOT_IN_TABLE,
|
||||
ViewSortNotSupported: ERROR_VIEW_SORT_NOT_SUPPORTED,
|
||||
ViewSortFieldAlreadyExist: ERROR_VIEW_SORT_FIELD_ALREADY_EXISTS,
|
||||
ViewSortFieldNotSupported: ERROR_VIEW_SORT_FIELD_NOT_SUPPORTED,
|
||||
})
|
||||
def post(self, request, data, view_id):
|
||||
"""Creates a new sort for the provided view."""
|
||||
|
||||
view_handler = ViewHandler()
|
||||
view = view_handler.get_view(request.user, view_id)
|
||||
# We can safely assume the field exists because the CreateViewSortSerializer
|
||||
# has already checked that.
|
||||
field = Field.objects.get(pk=data['field'])
|
||||
view_sort = view_handler.create_sort(request.user, view, field, data['order'])
|
||||
|
||||
serializer = ViewSortSerializer(view_sort)
|
||||
return Response(serializer.data)
|
||||
|
||||
|
||||
class ViewSortView(APIView):
|
||||
permission_classes = (IsAuthenticated,)
|
||||
|
||||
@extend_schema(
|
||||
parameters=[
|
||||
OpenApiParameter(
|
||||
name='view_sort_id',
|
||||
location=OpenApiParameter.PATH,
|
||||
type=OpenApiTypes.INT,
|
||||
description='Returns the view sort related to the provided value.'
|
||||
)
|
||||
],
|
||||
tags=['Database table view sortings'],
|
||||
operation_id='get_database_table_view_sort',
|
||||
description=(
|
||||
'Returns the existing view sort if the authorized user has access to the'
|
||||
' related database\'s group.'
|
||||
),
|
||||
responses={
|
||||
200: ViewSortSerializer(),
|
||||
400: get_error_schema(['ERROR_USER_NOT_IN_GROUP']),
|
||||
404: get_error_schema(['ERROR_VIEW_SORT_DOES_NOT_EXIST'])
|
||||
}
|
||||
)
|
||||
@map_exceptions({
|
||||
ViewSortDoesNotExist: ERROR_VIEW_SORT_DOES_NOT_EXIST,
|
||||
UserNotInGroupError: ERROR_USER_NOT_IN_GROUP
|
||||
})
|
||||
def get(self, request, view_sort_id):
|
||||
"""Selects a single sort and responds with a serialized version."""
|
||||
|
||||
view_sort = ViewHandler().get_sort(request.user, view_sort_id)
|
||||
serializer = ViewSortSerializer(view_sort)
|
||||
return Response(serializer.data)
|
||||
|
||||
@extend_schema(
|
||||
parameters=[
|
||||
OpenApiParameter(
|
||||
name='view_sort_id',
|
||||
location=OpenApiParameter.PATH,
|
||||
type=OpenApiTypes.INT,
|
||||
description='Updates the view sort related to the provided value.'
|
||||
)
|
||||
],
|
||||
tags=['Database table view sortings'],
|
||||
operation_id='update_database_table_view_sort',
|
||||
description=(
|
||||
'Updates the existing sort if the authorized user has access to the '
|
||||
'related database\'s group.'
|
||||
),
|
||||
request=UpdateViewSortSerializer(),
|
||||
responses={
|
||||
200: ViewSortSerializer(),
|
||||
400: get_error_schema([
|
||||
'ERROR_USER_NOT_IN_GROUP', 'ERROR_FIELD_NOT_IN_TABLE',
|
||||
'ERROR_VIEW_SORT_FIELD_ALREADY_EXISTS'
|
||||
]),
|
||||
404: get_error_schema(['ERROR_VIEW_SORT_DOES_NOT_EXIST'])
|
||||
}
|
||||
)
|
||||
@transaction.atomic
|
||||
@validate_body(UpdateViewSortSerializer)
|
||||
@map_exceptions({
|
||||
ViewSortDoesNotExist: ERROR_VIEW_SORT_DOES_NOT_EXIST,
|
||||
UserNotInGroupError: ERROR_USER_NOT_IN_GROUP,
|
||||
FieldNotInTable: ERROR_FIELD_NOT_IN_TABLE,
|
||||
ViewSortFieldAlreadyExist: ERROR_VIEW_SORT_FIELD_ALREADY_EXISTS,
|
||||
ViewSortFieldNotSupported: ERROR_VIEW_SORT_FIELD_NOT_SUPPORTED,
|
||||
})
|
||||
def patch(self, request, data, view_sort_id):
|
||||
"""Updates the view sort if the user belongs to the group."""
|
||||
|
||||
handler = ViewHandler()
|
||||
view_sort = handler.get_sort(request.user, view_sort_id)
|
||||
|
||||
if 'field' in data:
|
||||
# We can safely assume the field exists because the
|
||||
# UpdateViewSortSerializer has already checked that.
|
||||
data['field'] = Field.objects.get(pk=data['field'])
|
||||
|
||||
view_sort = handler.update_sort(request.user, view_sort, **data)
|
||||
|
||||
serializer = ViewSortSerializer(view_sort)
|
||||
return Response(serializer.data)
|
||||
|
||||
@extend_schema(
|
||||
parameters=[
|
||||
OpenApiParameter(
|
||||
name='view_sort_id',
|
||||
location=OpenApiParameter.PATH,
|
||||
type=OpenApiTypes.INT,
|
||||
description='Deletes the sort related to the provided value.'
|
||||
)
|
||||
],
|
||||
tags=['Database table view sortings'],
|
||||
operation_id='delete_database_table_view_sort',
|
||||
description=(
|
||||
'Deletes the existing sort if the authorized user has access to the '
|
||||
'related database\'s group.'
|
||||
),
|
||||
responses={
|
||||
204: None,
|
||||
400: get_error_schema(['ERROR_USER_NOT_IN_GROUP']),
|
||||
404: get_error_schema(['ERROR_VIEW_SORT_DOES_NOT_EXIST'])
|
||||
}
|
||||
)
|
||||
@transaction.atomic
|
||||
@map_exceptions({
|
||||
ViewSortDoesNotExist: ERROR_VIEW_SORT_DOES_NOT_EXIST,
|
||||
UserNotInGroupError: ERROR_USER_NOT_IN_GROUP
|
||||
})
|
||||
def delete(self, request, view_sort_id):
|
||||
"""Deletes an existing sort if the user belongs to the group."""
|
||||
|
||||
view = ViewHandler().get_sort(request.user, view_sort_id)
|
||||
ViewHandler().delete_sort(request.user, view)
|
||||
|
||||
return Response(status=204)
|
||||
|
|
|
@ -289,6 +289,7 @@ class LinkRowFieldType(FieldType):
|
|||
LinkRowTableNotProvided: ERROR_LINK_ROW_TABLE_NOT_PROVIDED,
|
||||
LinkRowTableNotInSameDatabase: ERROR_LINK_ROW_TABLE_NOT_IN_SAME_DATABASE
|
||||
}
|
||||
can_sort_in_view = False
|
||||
|
||||
def enhance_queryset(self, queryset, field, name):
|
||||
"""
|
||||
|
|
|
@ -8,6 +8,7 @@ from django.conf import settings
|
|||
from baserow.core.exceptions import UserNotInGroupError
|
||||
from baserow.core.utils import extract_allowed, set_allowed_attrs
|
||||
from baserow.contrib.database.db.schema import lenient_schema_editor
|
||||
from baserow.contrib.database.views.handler import ViewHandler
|
||||
|
||||
from .exceptions import (
|
||||
PrimaryFieldAlreadyExists, CannotDeletePrimaryField, CannotChangeFieldType,
|
||||
|
@ -163,12 +164,17 @@ class FieldHandler:
|
|||
from_field_type = field_type.type
|
||||
|
||||
# If the provided field type does not match with the current one we need to
|
||||
# migrate the field to the new type.
|
||||
# migrate the field to the new type. Because the type has changed we also need
|
||||
# to remove all view filters.
|
||||
if new_type_name and field_type.type != new_type_name:
|
||||
field_type = field_type_registry.get(new_type_name)
|
||||
new_model_class = field_type.model_class
|
||||
field.change_polymorphic_type_to(new_model_class)
|
||||
|
||||
# If the field type changes it could be that some dependencies,
|
||||
# like filters or sortings need to be changed.
|
||||
ViewHandler().field_type_changed(field)
|
||||
|
||||
allowed_fields = ['name'] + field_type.allowed_fields
|
||||
field_values = extract_allowed(kwargs, allowed_fields)
|
||||
|
||||
|
|
|
@ -36,6 +36,9 @@ class FieldType(MapAPIExceptionsInstanceMixin, APIUrlsInstanceMixin,
|
|||
field_type_registry.register(ExampleFieldType())
|
||||
"""
|
||||
|
||||
can_sort_in_view = True
|
||||
"""Indicates whether is is possible to sort on a field in a view."""
|
||||
|
||||
def prepare_value_for_db(self, instance, value):
|
||||
"""
|
||||
When a row is created or updated all the values are going to be prepared for the
|
||||
|
|
|
@ -0,0 +1,46 @@
|
|||
# Generated by Django 2.2.11 on 2020-09-28 09:27
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('database', '0013_urlfield'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='ViewSort',
|
||||
fields=[
|
||||
('id', models.AutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name='ID'
|
||||
)),
|
||||
('order', models.CharField(
|
||||
choices=[('ASC', 'Ascending'), ('DESC', 'Descending')],
|
||||
default='ASC',
|
||||
help_text='Indicates the sort order direction. ASC (Ascending) is '
|
||||
'from A to Z and DESC (Descending) is from Z to A.',
|
||||
max_length=4
|
||||
)),
|
||||
('field', models.ForeignKey(
|
||||
help_text='The field that must be sorted on.',
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to='database.Field'
|
||||
)),
|
||||
('view', models.ForeignKey(
|
||||
help_text='The view to which the sort applies. Each view can have '
|
||||
'his own sortings.',
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to='database.View'
|
||||
)),
|
||||
],
|
||||
options={
|
||||
'ordering': ('id',),
|
||||
},
|
||||
),
|
||||
]
|
|
@ -40,3 +40,19 @@ class ViewFilterTypeDoesNotExist(InstanceTypeDoesNotExist):
|
|||
|
||||
class ViewFilterTypeAlreadyRegistered(InstanceTypeAlreadyRegistered):
|
||||
"""Raised when the view filter type is already registered in the registry."""
|
||||
|
||||
|
||||
class ViewSortDoesNotExist(Exception):
|
||||
"""Raised when trying to get a view sort that does not exist."""
|
||||
|
||||
|
||||
class ViewSortNotSupported(Exception):
|
||||
"""Raised when the view type does not support sorting."""
|
||||
|
||||
|
||||
class ViewSortFieldAlreadyExist(Exception):
|
||||
"""Raised when a view sort with the field type already exists."""
|
||||
|
||||
|
||||
class ViewSortFieldNotSupported(Exception):
|
||||
"""Raised when a field does not supports sorting in a view."""
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
from django.db.models import Q
|
||||
from django.db.models import Q, F
|
||||
|
||||
from baserow.core.exceptions import UserNotInGroupError
|
||||
from baserow.core.utils import extract_allowed, set_allowed_attrs
|
||||
|
@ -8,11 +8,12 @@ from baserow.contrib.database.fields.exceptions import FieldNotInTable
|
|||
|
||||
from .exceptions import (
|
||||
ViewDoesNotExist, UnrelatedFieldError, ViewFilterDoesNotExist,
|
||||
ViewFilterNotSupported, ViewFilterTypeNotAllowedForField
|
||||
ViewFilterNotSupported, ViewFilterTypeNotAllowedForField, ViewSortDoesNotExist,
|
||||
ViewSortNotSupported, ViewSortFieldAlreadyExist, ViewSortFieldNotSupported
|
||||
)
|
||||
from .registries import view_type_registry, view_filter_type_registry
|
||||
from .models import (
|
||||
View, GridViewFieldOptions, ViewFilter, FILTER_TYPE_AND, FILTER_TYPE_OR
|
||||
View, GridViewFieldOptions, ViewFilter, ViewSort, FILTER_TYPE_AND, FILTER_TYPE_OR
|
||||
)
|
||||
|
||||
|
||||
|
@ -172,6 +173,30 @@ class ViewHandler:
|
|||
grid_view=grid_view, field_id=field_id, defaults=options
|
||||
)
|
||||
|
||||
def field_type_changed(self, field):
|
||||
"""
|
||||
This method is called by the FieldHandler when the field type of a field has
|
||||
changed. It could be that the field has filters or sortings that are not
|
||||
compatible anymore. If that is the case then those need to be removed.
|
||||
|
||||
:param field: The new field object.
|
||||
:type field: Field
|
||||
"""
|
||||
|
||||
field_type = field_type_registry.get_by_model(field.specific_class)
|
||||
|
||||
# If the new field type does not support sorting then all sortings will be
|
||||
# removed.
|
||||
if not field_type.can_sort_in_view:
|
||||
field.viewsort_set.all().delete()
|
||||
|
||||
# Check which filters are not compatible anymore and remove those.
|
||||
for filter in field.viewfilter_set.all():
|
||||
filter_type = view_filter_type_registry.get(filter.type)
|
||||
|
||||
if field_type.type not in filter_type.compatible_field_types:
|
||||
filter.delete()
|
||||
|
||||
def apply_filters(self, view, queryset):
|
||||
"""
|
||||
Applies the view's filter to the given queryset.
|
||||
|
@ -191,7 +216,7 @@ class ViewHandler:
|
|||
# If the model does not have the `_field_objects` property then it is not a
|
||||
# generated table model which is not supported.
|
||||
if not hasattr(model, '_field_objects'):
|
||||
raise ValueError('A queryset of the a table model is required.')
|
||||
raise ValueError('A queryset of the table model is required.')
|
||||
|
||||
q_filters = Q()
|
||||
|
||||
|
@ -270,7 +295,8 @@ class ViewHandler:
|
|||
:param value: The value that the filter must apply to.
|
||||
:type value: str
|
||||
:raises UserNotInGroupError: When the user does not belong to the related group.
|
||||
:raises ViewFilterNotSupported: When the provided view does not support filters.
|
||||
:raises ViewFilterNotSupported: When the provided view does not support
|
||||
filtering.
|
||||
:raises ViewFilterTypeNotAllowedForField: When the field does not support the
|
||||
filter type.
|
||||
:raises FieldNotInTable: When the provided field does not belong to the
|
||||
|
@ -326,7 +352,8 @@ class ViewHandler:
|
|||
:raises UserNotInGroupError: When the user does not belong to the related group.
|
||||
:raises ViewFilterTypeNotAllowedForField: When the field does not supports the
|
||||
filter type.
|
||||
:raises FieldNotInTable: When the does not support the filter type.
|
||||
:raises FieldNotInTable: When the provided field does not belong to the
|
||||
view's table.
|
||||
:return: The updated view filter instance.
|
||||
:rtype: ViewFilter
|
||||
"""
|
||||
|
@ -379,3 +406,219 @@ class ViewHandler:
|
|||
raise UserNotInGroupError(user, group)
|
||||
|
||||
view_filter.delete()
|
||||
|
||||
def apply_sorting(self, view, queryset):
|
||||
"""
|
||||
Applies the view's sorting to the given queryset. The first sort, which for now
|
||||
is the first created, will always be applied first. Secondary sortings are
|
||||
going to be applied if the values of the first sort rows are the same.
|
||||
|
||||
Example:
|
||||
|
||||
id | field_1 | field_2
|
||||
1 | Bram | 20
|
||||
2 | Bram | 10
|
||||
3 | Elon | 30
|
||||
|
||||
If we are going to sort ascending on field_1 and field_2 the resulting ids are
|
||||
going to be 2, 1 and 3 in that order.
|
||||
|
||||
:param view: The view where to fetch the sorting from.
|
||||
:type view: View
|
||||
:param queryset: The queryset where the sorting need to be applied to.
|
||||
:type queryset: QuerySet
|
||||
:raises ValueError: When the queryset's model is not a table model or if the
|
||||
table model does not contain the one of the fields.
|
||||
:return: The queryset where the sorting has been applied to.
|
||||
:type: QuerySet
|
||||
"""
|
||||
|
||||
model = queryset.model
|
||||
|
||||
# If the model does not have the `_field_objects` property then it is not a
|
||||
# generated table model which is not supported.
|
||||
if not hasattr(model, '_field_objects'):
|
||||
raise ValueError('A queryset of the table model is required.')
|
||||
|
||||
order_by = []
|
||||
|
||||
for view_filter in view.viewsort_set.all():
|
||||
# If the to be sort field is not present in the `_field_objects` we
|
||||
# cannot filter so we raise a ValueError.
|
||||
if view_filter.field_id not in model._field_objects:
|
||||
raise ValueError(f'The table model does not contain field '
|
||||
f'{view_filter.field_id}.')
|
||||
|
||||
field_name = model._field_objects[view_filter.field_id]['name']
|
||||
order = F(field_name)
|
||||
|
||||
if view_filter.order == 'ASC':
|
||||
order = order.asc(nulls_first=True)
|
||||
else:
|
||||
order = order.desc(nulls_last=True)
|
||||
|
||||
order_by.append(order)
|
||||
|
||||
order_by.append('id')
|
||||
queryset = queryset.order_by(*order_by)
|
||||
|
||||
return queryset
|
||||
|
||||
def get_sort(self, user, view_sort_id):
|
||||
"""
|
||||
Returns an existing view sort with the given id.
|
||||
|
||||
:param user: The user on whose behalf the view sort is requested.
|
||||
:type user: User
|
||||
:param view_sort_id: The id of the view sort.
|
||||
:type view_sort_id: int
|
||||
:raises ViewSortDoesNotExist: The the requested view does not exists.
|
||||
:raises UserNotInGroupError: When the user does not belong to the related group.
|
||||
:return: The requested view sort instance.
|
||||
:type: ViewSort
|
||||
"""
|
||||
|
||||
try:
|
||||
view_sort = ViewSort.objects.select_related(
|
||||
'view__table__database__group'
|
||||
).get(
|
||||
pk=view_sort_id
|
||||
)
|
||||
except ViewSort.DoesNotExist:
|
||||
raise ViewSortDoesNotExist(
|
||||
f'The view sort with id {view_sort_id} does not exist.'
|
||||
)
|
||||
|
||||
group = view_sort.view.table.database.group
|
||||
if not group.has_user(user):
|
||||
raise UserNotInGroupError(user, group)
|
||||
|
||||
return view_sort
|
||||
|
||||
def create_sort(self, user, view, field, order):
|
||||
"""
|
||||
Creates a new view sort.
|
||||
|
||||
:param user: The user on whose behalf the view sort is created.
|
||||
:type user: User
|
||||
:param view: The view for which the sort needs to be created.
|
||||
:type: View
|
||||
:param field: The field that needs to be sorted.
|
||||
:type field: Field
|
||||
:param order: The desired order, can either be ascending (A to Z) or
|
||||
descending (Z to A).
|
||||
:type order: str
|
||||
:raises UserNotInGroupError: When the user does not belong to the related group.
|
||||
:raises ViewSortNotSupported: When the provided view does not support sorting.
|
||||
:raises FieldNotInTable: When the provided field does not belong to the
|
||||
provided view's table.
|
||||
:return: The created view sort instance.
|
||||
:rtype: ViewSort
|
||||
"""
|
||||
|
||||
group = view.table.database.group
|
||||
if not group.has_user(user):
|
||||
raise UserNotInGroupError(user, group)
|
||||
|
||||
# Check if view supports sorting.
|
||||
view_type = view_type_registry.get_by_model(view.specific_class)
|
||||
if not view_type.can_sort:
|
||||
raise ViewSortNotSupported(
|
||||
f'Sorting is not supported for {view_type.type} views.'
|
||||
)
|
||||
|
||||
# Check if the field supports sorting.
|
||||
field_type = field_type_registry.get_by_model(field.specific_class)
|
||||
if not field_type.can_sort_in_view:
|
||||
raise ViewSortFieldNotSupported(f'The field {field.pk} does not support '
|
||||
f'sorting.')
|
||||
|
||||
# Check if field belongs to the grid views table
|
||||
if not view.table.field_set.filter(id=field.pk).exists():
|
||||
raise FieldNotInTable(f'The field {field.pk} does not belong to table '
|
||||
f'{view.table.id}.')
|
||||
|
||||
# Check if the field already exists as sort
|
||||
if view.viewsort_set.filter(field_id=field.pk).exists():
|
||||
raise ViewSortFieldAlreadyExist(f'A sort with the field {field.pk} '
|
||||
f'already exists.')
|
||||
|
||||
return ViewSort.objects.create(
|
||||
view=view,
|
||||
field=field,
|
||||
order=order
|
||||
)
|
||||
|
||||
def update_sort(self, user, view_sort, **kwargs):
|
||||
"""
|
||||
Updates the values of an existing view sort.
|
||||
|
||||
:param user: The user on whose behalf the view sort is updated.
|
||||
:type user: User
|
||||
:param view_sort: The view sort that needs to be updated.
|
||||
:type view_sort: ViewSort
|
||||
:param kwargs: The values that need to be updated, allowed values are
|
||||
`field` and `order`.
|
||||
:type kwargs: dict
|
||||
:raises UserNotInGroupError: When the user does not belong to the related group.
|
||||
:raises FieldNotInTable: When the field does not support sorting.
|
||||
:return: The updated view sort instance.
|
||||
:rtype: ViewSort
|
||||
"""
|
||||
|
||||
group = view_sort.view.table.database.group
|
||||
if not group.has_user(user):
|
||||
raise UserNotInGroupError(user, group)
|
||||
|
||||
field = kwargs.get('field', view_sort.field)
|
||||
order = kwargs.get('order', view_sort.order)
|
||||
|
||||
# If the field has changed we need to check if the field belongs to the table.
|
||||
if (
|
||||
field.id != view_sort.field_id and
|
||||
not view_sort.view.table.field_set.filter(id=field.pk).exists()
|
||||
):
|
||||
raise FieldNotInTable(f'The field {field.pk} does not belong to table '
|
||||
f'{view_sort.view.table.id}.')
|
||||
|
||||
# If the field has changed we need to check if the new field type supports
|
||||
# sorting.
|
||||
field_type = field_type_registry.get_by_model(field.specific_class)
|
||||
if (
|
||||
field.id != view_sort.field_id and
|
||||
not field_type.can_sort_in_view
|
||||
):
|
||||
raise ViewSortFieldNotSupported(f'The field {field.pk} does not support '
|
||||
f'sorting.')
|
||||
|
||||
# If the field has changed we need to check if the new field doesn't already
|
||||
# exist as sort.
|
||||
if (
|
||||
field.id != view_sort.field_id and
|
||||
view_sort.view.viewsort_set.filter(field_id=field.pk).exists()
|
||||
):
|
||||
raise ViewSortFieldAlreadyExist(f'A sort with the field {field.pk} '
|
||||
f'already exists.')
|
||||
|
||||
view_sort.field = field
|
||||
view_sort.order = order
|
||||
view_sort.save()
|
||||
|
||||
return view_sort
|
||||
|
||||
def delete_sort(self, user, view_sort):
|
||||
"""
|
||||
Deletes an existing view sort.
|
||||
|
||||
:param user: The user on whose behalf the view sort is deleted.
|
||||
:type user: User
|
||||
:param view_sort: The view sort instance that needs to be deleted.
|
||||
:type view_sort: ViewSort
|
||||
:raises UserNotInGroupError: When the user does not belong to the related group.
|
||||
"""
|
||||
|
||||
group = view_sort.view.table.database.group
|
||||
if not group.has_user(user):
|
||||
raise UserNotInGroupError(user, group)
|
||||
|
||||
view_sort.delete()
|
||||
|
|
|
@ -12,6 +12,13 @@ FILTER_TYPES = (
|
|||
(FILTER_TYPE_OR, 'Or')
|
||||
)
|
||||
|
||||
SORT_ORDER_ASC = 'ASC'
|
||||
SORT_ORDER_DESC = 'DESC'
|
||||
SORT_ORDER_CHOICES = (
|
||||
(SORT_ORDER_ASC, 'Ascending'),
|
||||
(SORT_ORDER_DESC, 'Descending')
|
||||
)
|
||||
|
||||
|
||||
def get_default_view_content_type():
|
||||
return ContentType.objects.get_for_model(View)
|
||||
|
@ -72,6 +79,30 @@ class ViewFilter(models.Model):
|
|||
ordering = ('id',)
|
||||
|
||||
|
||||
class ViewSort(models.Model):
|
||||
view = models.ForeignKey(
|
||||
View,
|
||||
on_delete=models.CASCADE,
|
||||
help_text='The view to which the sort applies. Each view can have his own '
|
||||
'sortings.'
|
||||
)
|
||||
field = models.ForeignKey(
|
||||
'database.Field',
|
||||
on_delete=models.CASCADE,
|
||||
help_text='The field that must be sorted on.'
|
||||
)
|
||||
order = models.CharField(
|
||||
max_length=4,
|
||||
choices=SORT_ORDER_CHOICES,
|
||||
help_text='Indicates the sort order direction. ASC (Ascending) is from A to Z '
|
||||
'and DESC (Descending) is from Z to A.',
|
||||
default=SORT_ORDER_ASC,
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ('id',)
|
||||
|
||||
|
||||
class GridView(View):
|
||||
field_options = models.ManyToManyField(Field, through='GridViewFieldOptions')
|
||||
|
||||
|
|
|
@ -47,8 +47,14 @@ class ViewType(APIUrlsInstanceMixin, CustomFieldsInstanceMixin, ModelInstanceMix
|
|||
|
||||
can_filter = True
|
||||
"""
|
||||
Defines if the view supports filters. If not, it will not be possible to add filter
|
||||
to the view.
|
||||
Indicates if the view supports filters. If not, it will not be possible to add
|
||||
filter to the view.
|
||||
"""
|
||||
|
||||
can_sort = True
|
||||
"""
|
||||
Indicates if the view support sortings. If not, it will not be possible to add a
|
||||
sort to the view.
|
||||
"""
|
||||
|
||||
|
||||
|
|
|
@ -135,8 +135,22 @@ def test_list_rows(api_client, data_fixture):
|
|||
assert response_json['count'] == 4
|
||||
assert response_json['results'][0]['id'] == row_3.id
|
||||
|
||||
data_fixture.create_view_filter(view=grid, field=text_field, value='Green')
|
||||
sort = data_fixture.create_view_sort(view=grid, field=text_field, order='ASC')
|
||||
url = reverse('api:database:views:grid:list', kwargs={'view_id': grid.id})
|
||||
response = api_client.get(
|
||||
url,
|
||||
**{'HTTP_AUTHORIZATION': f'JWT {token}'}
|
||||
)
|
||||
response_json = response.json()
|
||||
assert response.status_code == HTTP_200_OK
|
||||
assert response_json['count'] == 4
|
||||
assert response_json['results'][0]['id'] == row_1.id
|
||||
assert response_json['results'][1]['id'] == row_3.id
|
||||
assert response_json['results'][2]['id'] == row_4.id
|
||||
assert response_json['results'][3]['id'] == row_2.id
|
||||
sort.delete()
|
||||
|
||||
filter = data_fixture.create_view_filter(view=grid, field=text_field, value='Green')
|
||||
url = reverse('api:database:views:grid:list', kwargs={'view_id': grid.id})
|
||||
response = api_client.get(
|
||||
url,
|
||||
|
@ -147,6 +161,17 @@ def test_list_rows(api_client, data_fixture):
|
|||
assert response_json['count'] == 1
|
||||
assert len(response_json['results']) == 1
|
||||
assert response_json['results'][0]['id'] == row_1.id
|
||||
filter.delete()
|
||||
|
||||
url = reverse('api:database:views:grid:list', kwargs={'view_id': grid.id})
|
||||
response = api_client.get(
|
||||
url,
|
||||
data={'count': ''},
|
||||
**{'HTTP_AUTHORIZATION': f'JWT {token}'}
|
||||
)
|
||||
response_json = response.json()
|
||||
assert response_json['count'] == 4
|
||||
assert len(response_json.keys()) == 1
|
||||
|
||||
row_1.delete()
|
||||
row_2.delete()
|
||||
|
|
|
@ -4,11 +4,10 @@ from rest_framework.status import HTTP_200_OK, HTTP_400_BAD_REQUEST, HTTP_404_NO
|
|||
|
||||
from django.shortcuts import reverse
|
||||
|
||||
from baserow.contrib.database.views.models import ViewFilter, GridView
|
||||
from baserow.contrib.database.views.models import ViewFilter, ViewSort, GridView
|
||||
from baserow.contrib.database.views.registries import (
|
||||
view_type_registry, view_filter_type_registry
|
||||
)
|
||||
from baserow.contrib.database.fields.registries import field_type_registry
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
|
@ -111,6 +110,55 @@ def test_list_views_including_filters(api_client, data_fixture):
|
|||
assert response_json[1]['filters'][0]['id'] == filter_3.id
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_list_views_including_sortings(api_client, data_fixture):
|
||||
user, token = data_fixture.create_user_and_token()
|
||||
table_1 = data_fixture.create_database_table(user=user)
|
||||
table_2 = data_fixture.create_database_table()
|
||||
field_1 = data_fixture.create_text_field(table=table_1)
|
||||
field_2 = data_fixture.create_text_field(table=table_1)
|
||||
field_3 = data_fixture.create_text_field(table=table_2)
|
||||
view_1 = data_fixture.create_grid_view(table=table_1, order=1)
|
||||
view_2 = data_fixture.create_grid_view(table=table_1, order=2)
|
||||
view_3 = data_fixture.create_grid_view(table=table_2, order=1)
|
||||
sort_1 = data_fixture.create_view_sort(view=view_1, field=field_1)
|
||||
sort_2 = data_fixture.create_view_sort(view=view_1, field=field_2)
|
||||
sort_3 = data_fixture.create_view_sort(view=view_2, field=field_1)
|
||||
sort_4 = data_fixture.create_view_sort(view=view_3, field=field_3)
|
||||
|
||||
response = api_client.get(
|
||||
'{}'.format(reverse(
|
||||
'api:database:views:list',
|
||||
kwargs={'table_id': table_1.id}
|
||||
)),
|
||||
HTTP_AUTHORIZATION=f'JWT {token}'
|
||||
)
|
||||
assert response.status_code == HTTP_200_OK
|
||||
response_json = response.json()
|
||||
assert len(response_json) == 2
|
||||
assert 'sortings' not in response_json[0]
|
||||
assert 'sortings' not in response_json[1]
|
||||
|
||||
response = api_client.get(
|
||||
'{}?includes=sortings'.format(reverse(
|
||||
'api:database:views:list',
|
||||
kwargs={'table_id': table_1.id}
|
||||
)),
|
||||
HTTP_AUTHORIZATION=f'JWT {token}'
|
||||
)
|
||||
assert response.status_code == HTTP_200_OK
|
||||
response_json = response.json()
|
||||
|
||||
assert len(response_json[0]['sortings']) == 2
|
||||
assert response_json[0]['sortings'][0]['id'] == sort_1.id
|
||||
assert response_json[0]['sortings'][0]['view'] == view_1.id
|
||||
assert response_json[0]['sortings'][0]['field'] == field_1.id
|
||||
assert response_json[0]['sortings'][0]['order'] == sort_1.order
|
||||
assert response_json[0]['sortings'][1]['id'] == sort_2.id
|
||||
assert len(response_json[1]['sortings']) == 1
|
||||
assert response_json[1]['sortings'][0]['id'] == sort_3.id
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_create_view(api_client, data_fixture):
|
||||
user, token = data_fixture.create_user_and_token()
|
||||
|
@ -170,9 +218,10 @@ def test_create_view(api_client, data_fixture):
|
|||
assert response_json['order'] == grid.order
|
||||
assert response_json['filter_type'] == grid.filter_type
|
||||
assert 'filters' not in response_json
|
||||
assert 'sortings' not in response_json
|
||||
|
||||
response = api_client.post(
|
||||
'{}?includes=filters'.format(
|
||||
'{}?includes=filters,sortings'.format(
|
||||
reverse('api:database:views:list', kwargs={'table_id': table.id})
|
||||
),
|
||||
{
|
||||
|
@ -189,6 +238,7 @@ def test_create_view(api_client, data_fixture):
|
|||
assert response_json['type'] == 'grid'
|
||||
assert response_json['filter_type'] == 'AND'
|
||||
assert response_json['filters'] == []
|
||||
assert response_json['sortings'] == []
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
|
@ -231,10 +281,11 @@ def test_get_view(api_client, data_fixture):
|
|||
assert response_json['table']['id'] == table.id
|
||||
assert response_json['filter_type'] == 'AND'
|
||||
assert 'filters' not in response_json
|
||||
assert 'sortings' not in response_json
|
||||
|
||||
url = reverse('api:database:views:item', kwargs={'view_id': view.id})
|
||||
response = api_client.get(
|
||||
'{}?includes=filters'.format(url),
|
||||
'{}?includes=filters,sortings'.format(url),
|
||||
format='json',
|
||||
HTTP_AUTHORIZATION=f'JWT {token}'
|
||||
)
|
||||
|
@ -247,6 +298,7 @@ def test_get_view(api_client, data_fixture):
|
|||
assert response_json['filters'][0]['field'] == filter.field_id
|
||||
assert response_json['filters'][0]['type'] == filter.type
|
||||
assert response_json['filters'][0]['value'] == filter.value
|
||||
assert response_json['sortings'] == []
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
|
@ -317,6 +369,7 @@ def test_update_view(api_client, data_fixture):
|
|||
assert response_json['id'] == view.id
|
||||
assert response_json['filter_type'] == 'OR'
|
||||
assert 'filters' not in response_json
|
||||
assert 'sortings' not in response_json
|
||||
|
||||
view.refresh_from_db()
|
||||
assert view.filter_type == 'OR'
|
||||
|
@ -324,7 +377,7 @@ def test_update_view(api_client, data_fixture):
|
|||
filter_1 = data_fixture.create_view_filter(view=view)
|
||||
url = reverse('api:database:views:item', kwargs={'view_id': view.id})
|
||||
response = api_client.patch(
|
||||
'{}?includes=filters'.format(url),
|
||||
'{}?includes=filters,sortings'.format(url),
|
||||
{'filter_type': 'AND'},
|
||||
format='json',
|
||||
HTTP_AUTHORIZATION=f'JWT {token}'
|
||||
|
@ -334,6 +387,7 @@ def test_update_view(api_client, data_fixture):
|
|||
assert response_json['id'] == view.id
|
||||
assert response_json['filter_type'] == 'AND'
|
||||
assert response_json['filters'][0]['id'] == filter_1.id
|
||||
assert response_json['sortings'] == []
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
|
@ -809,3 +863,442 @@ def test_delete_view_filter(api_client, data_fixture):
|
|||
)
|
||||
assert response.status_code == 204
|
||||
assert ViewFilter.objects.all().count() == 1
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_list_view_sortings(api_client, data_fixture):
|
||||
user, token = data_fixture.create_user_and_token()
|
||||
table_1 = data_fixture.create_database_table(user=user)
|
||||
table_2 = data_fixture.create_database_table()
|
||||
field_1 = data_fixture.create_text_field(table=table_1)
|
||||
field_2 = data_fixture.create_text_field(table=table_1)
|
||||
field_3 = data_fixture.create_text_field(table=table_2)
|
||||
view_1 = data_fixture.create_grid_view(table=table_1, order=1)
|
||||
view_2 = data_fixture.create_grid_view(table=table_1, order=2)
|
||||
view_3 = data_fixture.create_grid_view(table=table_2, order=1)
|
||||
sort_1 = data_fixture.create_view_sort(view=view_1, field=field_1)
|
||||
sort_2 = data_fixture.create_view_sort(view=view_1, field=field_2)
|
||||
sort_4 = data_fixture.create_view_sort(view=view_3, field=field_3)
|
||||
|
||||
response = api_client.get(
|
||||
reverse(
|
||||
'api:database:views:list_sortings',
|
||||
kwargs={'view_id': view_3.id}
|
||||
),
|
||||
HTTP_AUTHORIZATION=f'JWT {token}'
|
||||
)
|
||||
assert response.status_code == HTTP_400_BAD_REQUEST
|
||||
assert response.json()['error'] == 'ERROR_USER_NOT_IN_GROUP'
|
||||
|
||||
response = api_client.get(
|
||||
reverse(
|
||||
'api:database:views:list_sortings',
|
||||
kwargs={'view_id': 999999}
|
||||
),
|
||||
HTTP_AUTHORIZATION=f'JWT {token}'
|
||||
)
|
||||
assert response.status_code == HTTP_404_NOT_FOUND
|
||||
assert response.json()['error'] == 'ERROR_VIEW_DOES_NOT_EXIST'
|
||||
|
||||
response = api_client.get(
|
||||
reverse(
|
||||
'api:database:views:list_sortings',
|
||||
kwargs={'view_id': view_1.id}
|
||||
),
|
||||
HTTP_AUTHORIZATION=f'JWT {token}'
|
||||
)
|
||||
response_json = response.json()
|
||||
assert len(response_json) == 2
|
||||
assert response_json[0]['id'] == sort_1.id
|
||||
assert response_json[0]['view'] == view_1.id
|
||||
assert response_json[0]['field'] == field_1.id
|
||||
assert response_json[0]['order'] == sort_1.order
|
||||
assert response_json[1]['id'] == sort_2.id
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_create_view_sort(api_client, data_fixture):
|
||||
user, token = data_fixture.create_user_and_token()
|
||||
table_1 = data_fixture.create_database_table(user=user)
|
||||
table_2 = data_fixture.create_database_table()
|
||||
field_1 = data_fixture.create_text_field(table=table_1)
|
||||
field_2 = data_fixture.create_text_field(table=table_2)
|
||||
field_3 = data_fixture.create_text_field(table=table_1)
|
||||
field_4 = data_fixture.create_text_field(table=table_1)
|
||||
link_row_field = data_fixture.create_link_row_field(table=table_1)
|
||||
view_1 = data_fixture.create_grid_view(table=table_1)
|
||||
view_2 = data_fixture.create_grid_view(table=table_2)
|
||||
|
||||
response = api_client.post(
|
||||
reverse('api:database:views:list_sortings', kwargs={'view_id': view_2.id}),
|
||||
{
|
||||
'field': field_2.id,
|
||||
'order': 'ASC',
|
||||
},
|
||||
format='json',
|
||||
HTTP_AUTHORIZATION=f'JWT {token}'
|
||||
)
|
||||
assert response.status_code == HTTP_400_BAD_REQUEST
|
||||
assert response.json()['error'] == 'ERROR_USER_NOT_IN_GROUP'
|
||||
|
||||
response = api_client.post(
|
||||
reverse('api:database:views:list_sortings', kwargs={'view_id': 99999}),
|
||||
{
|
||||
'field': field_1.id,
|
||||
'order': 'ASC',
|
||||
'value': 'test'
|
||||
},
|
||||
format='json',
|
||||
HTTP_AUTHORIZATION=f'JWT {token}'
|
||||
)
|
||||
assert response.status_code == HTTP_404_NOT_FOUND
|
||||
assert response.json()['error'] == 'ERROR_VIEW_DOES_NOT_EXIST'
|
||||
|
||||
response = api_client.post(
|
||||
reverse('api:database:views:list_sortings', kwargs={'view_id': view_1.id}),
|
||||
{
|
||||
'field': 9999999,
|
||||
'order': 'NOT_EXISTING'
|
||||
},
|
||||
format='json',
|
||||
HTTP_AUTHORIZATION=f'JWT {token}'
|
||||
)
|
||||
response_json = response.json()
|
||||
assert response.status_code == HTTP_400_BAD_REQUEST
|
||||
assert response_json['error'] == 'ERROR_REQUEST_BODY_VALIDATION'
|
||||
assert response_json['detail']['field'][0]['code'] == 'does_not_exist'
|
||||
assert response_json['detail']['order'][0]['code'] == 'invalid_choice'
|
||||
|
||||
response = api_client.post(
|
||||
reverse('api:database:views:list_sortings', kwargs={'view_id': view_1.id}),
|
||||
{
|
||||
'field': field_2.id,
|
||||
'order': 'ASC',
|
||||
},
|
||||
format='json',
|
||||
HTTP_AUTHORIZATION=f'JWT {token}'
|
||||
)
|
||||
response_json = response.json()
|
||||
assert response.status_code == HTTP_400_BAD_REQUEST
|
||||
assert response_json['error'] == 'ERROR_FIELD_NOT_IN_TABLE'
|
||||
|
||||
grid_view_type = view_type_registry.get('grid')
|
||||
grid_view_type.can_sort = False
|
||||
response = api_client.post(
|
||||
reverse('api:database:views:list_sortings', kwargs={'view_id': view_1.id}),
|
||||
{
|
||||
'field': field_1.id,
|
||||
'order': 'ASC'
|
||||
},
|
||||
format='json',
|
||||
HTTP_AUTHORIZATION=f'JWT {token}'
|
||||
)
|
||||
response_json = response.json()
|
||||
assert response.status_code == HTTP_400_BAD_REQUEST
|
||||
assert response_json['error'] == 'ERROR_VIEW_SORT_NOT_SUPPORTED'
|
||||
grid_view_type.can_sort = True
|
||||
|
||||
response = api_client.post(
|
||||
reverse('api:database:views:list_sortings', kwargs={'view_id': view_1.id}),
|
||||
{
|
||||
'field': link_row_field.id,
|
||||
'order': 'ASC'
|
||||
},
|
||||
format='json',
|
||||
HTTP_AUTHORIZATION=f'JWT {token}'
|
||||
)
|
||||
response_json = response.json()
|
||||
assert response.status_code == HTTP_400_BAD_REQUEST
|
||||
assert response_json['error'] == 'ERROR_VIEW_SORT_FIELD_NOT_SUPPORTED'
|
||||
|
||||
response = api_client.post(
|
||||
reverse('api:database:views:list_sortings', kwargs={'view_id': view_1.id}),
|
||||
{
|
||||
'field': field_1.id,
|
||||
'order': 'ASC'
|
||||
},
|
||||
format='json',
|
||||
HTTP_AUTHORIZATION=f'JWT {token}'
|
||||
)
|
||||
response_json = response.json()
|
||||
assert response.status_code == HTTP_200_OK
|
||||
assert ViewSort.objects.all().count() == 1
|
||||
first = ViewSort.objects.all().first()
|
||||
assert response_json['id'] == first.id
|
||||
assert response_json['view'] == view_1.id
|
||||
assert response_json['field'] == field_1.id
|
||||
assert response_json['order'] == 'ASC'
|
||||
|
||||
response = api_client.post(
|
||||
reverse('api:database:views:list_sortings', kwargs={'view_id': view_1.id}),
|
||||
{
|
||||
'field': field_1.id,
|
||||
'order': 'ASC'
|
||||
},
|
||||
format='json',
|
||||
HTTP_AUTHORIZATION=f'JWT {token}'
|
||||
)
|
||||
response_json = response.json()
|
||||
assert response.status_code == HTTP_400_BAD_REQUEST
|
||||
assert response_json['error'] == 'ERROR_VIEW_SORT_FIELD_ALREADY_EXISTS'
|
||||
|
||||
response = api_client.post(
|
||||
reverse('api:database:views:list_sortings', kwargs={'view_id': view_1.id}),
|
||||
{
|
||||
'field': field_3.id,
|
||||
'order': 'DESC'
|
||||
},
|
||||
format='json',
|
||||
HTTP_AUTHORIZATION=f'JWT {token}'
|
||||
)
|
||||
response_json = response.json()
|
||||
assert response.status_code == HTTP_200_OK
|
||||
assert response_json['order'] == 'DESC'
|
||||
|
||||
response = api_client.post(
|
||||
reverse('api:database:views:list_sortings', kwargs={'view_id': view_1.id}),
|
||||
{
|
||||
'field': field_4.id,
|
||||
},
|
||||
format='json',
|
||||
HTTP_AUTHORIZATION=f'JWT {token}'
|
||||
)
|
||||
response_json = response.json()
|
||||
assert response.status_code == HTTP_200_OK
|
||||
assert response_json['order'] == 'ASC'
|
||||
|
||||
assert ViewSort.objects.all().count() == 3
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_get_view_sort(api_client, data_fixture):
|
||||
user, token = data_fixture.create_user_and_token()
|
||||
sort_1 = data_fixture.create_view_sort(user=user, order='DESC')
|
||||
sort_2 = data_fixture.create_view_sort()
|
||||
|
||||
response = api_client.get(
|
||||
reverse(
|
||||
'api:database:views:sort_item',
|
||||
kwargs={'view_sort_id': sort_2.id}
|
||||
),
|
||||
HTTP_AUTHORIZATION=f'JWT {token}'
|
||||
)
|
||||
assert response.status_code == HTTP_400_BAD_REQUEST
|
||||
assert response.json()['error'] == 'ERROR_USER_NOT_IN_GROUP'
|
||||
|
||||
response = api_client.get(
|
||||
reverse(
|
||||
'api:database:views:sort_item',
|
||||
kwargs={'view_sort_id': 99999}
|
||||
),
|
||||
HTTP_AUTHORIZATION=f'JWT {token}'
|
||||
)
|
||||
assert response.status_code == HTTP_404_NOT_FOUND
|
||||
assert response.json()['error'] == 'ERROR_VIEW_SORT_DOES_NOT_EXIST'
|
||||
|
||||
response = api_client.get(
|
||||
reverse(
|
||||
'api:database:views:sort_item',
|
||||
kwargs={'view_sort_id': sort_1.id}
|
||||
),
|
||||
HTTP_AUTHORIZATION=f'JWT {token}'
|
||||
)
|
||||
response_json = response.json()
|
||||
assert response.status_code == HTTP_200_OK
|
||||
assert ViewSort.objects.all().count() == 2
|
||||
first = ViewSort.objects.get(pk=sort_1.id)
|
||||
assert response_json['id'] == first.id
|
||||
assert response_json['view'] == first.view_id
|
||||
assert response_json['field'] == first.field_id
|
||||
assert response_json['order'] == 'DESC'
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_update_view_sort(api_client, data_fixture):
|
||||
user, token = data_fixture.create_user_and_token()
|
||||
sort_1 = data_fixture.create_view_sort(user=user, order='DESC')
|
||||
sort_2 = data_fixture.create_view_sort()
|
||||
sort_3 = data_fixture.create_view_sort(view=sort_1.view, order='ASC')
|
||||
field_1 = data_fixture.create_text_field(table=sort_1.view.table)
|
||||
link_row_field = data_fixture.create_link_row_field(table=sort_1.view.table)
|
||||
field_2 = data_fixture.create_text_field()
|
||||
|
||||
response = api_client.patch(
|
||||
reverse(
|
||||
'api:database:views:sort_item',
|
||||
kwargs={'view_sort_id': sort_2.id}
|
||||
),
|
||||
{'order': 'ASC'},
|
||||
format='json',
|
||||
HTTP_AUTHORIZATION=f'JWT {token}'
|
||||
)
|
||||
assert response.status_code == HTTP_400_BAD_REQUEST
|
||||
assert response.json()['error'] == 'ERROR_USER_NOT_IN_GROUP'
|
||||
|
||||
response = api_client.patch(
|
||||
reverse(
|
||||
'api:database:views:sort_item',
|
||||
kwargs={'view_sort_id': 9999}
|
||||
),
|
||||
{'order': 'ASC'},
|
||||
format='json',
|
||||
HTTP_AUTHORIZATION=f'JWT {token}'
|
||||
)
|
||||
assert response.status_code == HTTP_404_NOT_FOUND
|
||||
assert response.json()['error'] == 'ERROR_VIEW_SORT_DOES_NOT_EXIST'
|
||||
|
||||
response = api_client.patch(
|
||||
reverse(
|
||||
'api:database:views:sort_item',
|
||||
kwargs={'view_sort_id': sort_1.id}
|
||||
),
|
||||
{
|
||||
'field': 9999999,
|
||||
'order': 'EXISTING',
|
||||
},
|
||||
format='json',
|
||||
HTTP_AUTHORIZATION=f'JWT {token}'
|
||||
)
|
||||
response_json = response.json()
|
||||
assert response.status_code == HTTP_400_BAD_REQUEST
|
||||
assert response_json['error'] == 'ERROR_REQUEST_BODY_VALIDATION'
|
||||
assert response_json['detail']['field'][0]['code'] == 'does_not_exist'
|
||||
assert response_json['detail']['order'][0]['code'] == 'invalid_choice'
|
||||
|
||||
response = api_client.patch(
|
||||
reverse(
|
||||
'api:database:views:sort_item',
|
||||
kwargs={'view_sort_id': sort_1.id}
|
||||
),
|
||||
{'field': field_2.id},
|
||||
format='json',
|
||||
HTTP_AUTHORIZATION=f'JWT {token}'
|
||||
)
|
||||
response_json = response.json()
|
||||
assert response.status_code == HTTP_400_BAD_REQUEST
|
||||
assert response_json['error'] == 'ERROR_FIELD_NOT_IN_TABLE'
|
||||
|
||||
response = api_client.patch(
|
||||
reverse(
|
||||
'api:database:views:sort_item',
|
||||
kwargs={'view_sort_id': sort_1.id}
|
||||
),
|
||||
{'field': link_row_field.id},
|
||||
format='json',
|
||||
HTTP_AUTHORIZATION=f'JWT {token}'
|
||||
)
|
||||
response_json = response.json()
|
||||
assert response.status_code == HTTP_400_BAD_REQUEST
|
||||
assert response_json['error'] == 'ERROR_VIEW_SORT_FIELD_NOT_SUPPORTED'
|
||||
|
||||
response = api_client.patch(
|
||||
reverse(
|
||||
'api:database:views:sort_item',
|
||||
kwargs={'view_sort_id': sort_3.id}
|
||||
),
|
||||
{'field': sort_1.field_id},
|
||||
format='json',
|
||||
HTTP_AUTHORIZATION=f'JWT {token}'
|
||||
)
|
||||
response_json = response.json()
|
||||
assert response.status_code == HTTP_400_BAD_REQUEST
|
||||
assert response_json['error'] == 'ERROR_VIEW_SORT_FIELD_ALREADY_EXISTS'
|
||||
|
||||
response = api_client.patch(
|
||||
reverse(
|
||||
'api:database:views:sort_item',
|
||||
kwargs={'view_sort_id': sort_1.id}
|
||||
),
|
||||
{
|
||||
'field': field_1.id,
|
||||
'order': 'ASC',
|
||||
|
||||
},
|
||||
format='json',
|
||||
HTTP_AUTHORIZATION=f'JWT {token}'
|
||||
)
|
||||
response_json = response.json()
|
||||
assert response.status_code == HTTP_200_OK
|
||||
assert ViewSort.objects.all().count() == 3
|
||||
first = ViewSort.objects.get(pk=sort_1.id)
|
||||
assert first.field_id == field_1.id
|
||||
assert first.order == 'ASC'
|
||||
assert response_json['id'] == first.id
|
||||
assert response_json['view'] == first.view_id
|
||||
assert response_json['field'] == field_1.id
|
||||
assert response_json['order'] == 'ASC'
|
||||
|
||||
response = api_client.patch(
|
||||
reverse(
|
||||
'api:database:views:sort_item',
|
||||
kwargs={'view_sort_id': sort_1.id}
|
||||
),
|
||||
{'order': 'DESC',},
|
||||
format='json',
|
||||
HTTP_AUTHORIZATION=f'JWT {token}'
|
||||
)
|
||||
response_json = response.json()
|
||||
assert response.status_code == HTTP_200_OK
|
||||
first = ViewSort.objects.get(pk=sort_1.id)
|
||||
assert first.field_id == field_1.id
|
||||
assert first.order == 'DESC'
|
||||
assert response_json['id'] == first.id
|
||||
assert response_json['view'] == first.view_id
|
||||
assert response_json['field'] == field_1.id
|
||||
assert response_json['order'] == 'DESC'
|
||||
|
||||
response = api_client.patch(
|
||||
reverse(
|
||||
'api:database:views:sort_item',
|
||||
kwargs={'view_sort_id': sort_1.id}
|
||||
),
|
||||
{},
|
||||
format='json',
|
||||
HTTP_AUTHORIZATION=f'JWT {token}'
|
||||
)
|
||||
response_json = response.json()
|
||||
assert response.status_code == HTTP_200_OK
|
||||
first = ViewSort.objects.get(pk=sort_1.id)
|
||||
assert first.field_id == field_1.id
|
||||
assert first.order == 'DESC'
|
||||
assert response_json['id'] == first.id
|
||||
assert response_json['view'] == first.view_id
|
||||
assert response_json['field'] == field_1.id
|
||||
assert response_json['order'] == 'DESC'
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_delete_view_sort(api_client, data_fixture):
|
||||
user, token = data_fixture.create_user_and_token()
|
||||
sort_1 = data_fixture.create_view_sort(user=user, order='DESC')
|
||||
sort_2 = data_fixture.create_view_sort()
|
||||
|
||||
response = api_client.delete(
|
||||
reverse(
|
||||
'api:database:views:sort_item',
|
||||
kwargs={'view_sort_id': sort_2.id}
|
||||
),
|
||||
HTTP_AUTHORIZATION=f'JWT {token}'
|
||||
)
|
||||
assert response.status_code == HTTP_400_BAD_REQUEST
|
||||
assert response.json()['error'] == 'ERROR_USER_NOT_IN_GROUP'
|
||||
|
||||
response = api_client.delete(
|
||||
reverse(
|
||||
'api:database:views:sort_item',
|
||||
kwargs={'view_sort_id': 9999}
|
||||
),
|
||||
HTTP_AUTHORIZATION=f'JWT {token}'
|
||||
)
|
||||
assert response.status_code == HTTP_404_NOT_FOUND
|
||||
assert response.json()['error'] == 'ERROR_VIEW_SORT_DOES_NOT_EXIST'
|
||||
|
||||
response = api_client.delete(
|
||||
reverse(
|
||||
'api:database:views:sort_item',
|
||||
kwargs={'view_sort_id': sort_1.id}
|
||||
),
|
||||
HTTP_AUTHORIZATION=f'JWT {token}'
|
||||
)
|
||||
assert response.status_code == 204
|
||||
assert ViewSort.objects.all().count() == 1
|
||||
|
|
|
@ -2,17 +2,18 @@ import pytest
|
|||
|
||||
from baserow.core.exceptions import UserNotInGroupError
|
||||
from baserow.contrib.database.views.handler import ViewHandler
|
||||
from baserow.contrib.database.views.models import View, GridView, ViewFilter
|
||||
from baserow.contrib.database.views.models import View, GridView, ViewFilter, ViewSort
|
||||
from baserow.contrib.database.views.registries import (
|
||||
view_type_registry, view_filter_type_registry
|
||||
)
|
||||
from baserow.contrib.database.views.exceptions import (
|
||||
ViewTypeDoesNotExist, ViewDoesNotExist, UnrelatedFieldError,
|
||||
ViewFilterDoesNotExist, ViewFilterNotSupported, ViewFilterTypeNotAllowedForField,
|
||||
ViewFilterTypeDoesNotExist
|
||||
ViewFilterTypeDoesNotExist, ViewSortDoesNotExist, ViewSortNotSupported,
|
||||
ViewSortFieldAlreadyExist, ViewSortFieldNotSupported
|
||||
)
|
||||
from baserow.contrib.database.fields.models import Field
|
||||
from baserow.contrib.database.fields.registries import field_type_registry
|
||||
from baserow.contrib.database.fields.handler import FieldHandler
|
||||
from baserow.contrib.database.fields.exceptions import FieldNotInTable
|
||||
|
||||
|
||||
|
@ -174,6 +175,33 @@ def test_update_grid_view_field_options(data_fixture):
|
|||
assert options_4[2].field_id == field_4.id
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_field_type_changed(data_fixture):
|
||||
user = data_fixture.create_user()
|
||||
table = data_fixture.create_database_table(user=user)
|
||||
table_2 = data_fixture.create_database_table(user=user, database=table.database)
|
||||
text_field = data_fixture.create_text_field(table=table)
|
||||
grid_view = data_fixture.create_grid_view(table=table)
|
||||
contains_filter = data_fixture.create_view_filter(view=grid_view, field=text_field,
|
||||
type='contains', value='test')
|
||||
sort = data_fixture.create_view_sort(view=grid_view, field=text_field, order='ASC')
|
||||
|
||||
field_handler = FieldHandler()
|
||||
long_text_field = field_handler.update_field(user=user, field=text_field,
|
||||
new_type_name='long_text')
|
||||
assert ViewFilter.objects.all().count() == 1
|
||||
assert ViewSort.objects.all().count() == 1
|
||||
|
||||
field_handler.update_field(user=user, field=long_text_field, new_type_name='number')
|
||||
assert ViewFilter.objects.all().count() == 0
|
||||
assert ViewSort.objects.all().count() == 1
|
||||
|
||||
field_handler.update_field(user=user, field=long_text_field,
|
||||
new_type_name='link_row', link_row_table=table_2)
|
||||
assert ViewFilter.objects.all().count() == 0
|
||||
assert ViewSort.objects.all().count() == 0
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_apply_filters(data_fixture):
|
||||
user = data_fixture.create_user()
|
||||
|
@ -455,3 +483,261 @@ def test_delete_filter(data_fixture):
|
|||
|
||||
assert ViewFilter.objects.all().count() == 1
|
||||
assert ViewFilter.objects.filter(pk=filter_1.pk).count() == 0
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_apply_sortings(data_fixture):
|
||||
user = data_fixture.create_user()
|
||||
table = data_fixture.create_database_table(user=user)
|
||||
text_field = data_fixture.create_text_field(table=table)
|
||||
number_field = data_fixture.create_number_field(table=table)
|
||||
boolean_field = data_fixture.create_boolean_field(table=table)
|
||||
grid_view = data_fixture.create_grid_view(table=table)
|
||||
|
||||
view_handler = ViewHandler()
|
||||
|
||||
model = table.get_model()
|
||||
row_1 = model.objects.create(**{
|
||||
f'field_{text_field.id}': 'Aaa',
|
||||
f'field_{number_field.id}': 30,
|
||||
f'field_{boolean_field.id}': True
|
||||
})
|
||||
row_2 = model.objects.create(**{
|
||||
f'field_{text_field.id}': 'Aaa',
|
||||
f'field_{number_field.id}': 20,
|
||||
f'field_{boolean_field.id}': True
|
||||
})
|
||||
row_3 = model.objects.create(**{
|
||||
f'field_{text_field.id}': 'Aaa',
|
||||
f'field_{number_field.id}': 10,
|
||||
f'field_{boolean_field.id}': False
|
||||
})
|
||||
row_4 = model.objects.create(**{
|
||||
f'field_{text_field.id}': 'Bbbb',
|
||||
f'field_{number_field.id}': 60,
|
||||
f'field_{boolean_field.id}': False
|
||||
})
|
||||
row_5 = model.objects.create(**{
|
||||
f'field_{text_field.id}': 'Cccc',
|
||||
f'field_{number_field.id}': 50,
|
||||
f'field_{boolean_field.id}': False
|
||||
})
|
||||
row_6 = model.objects.create(**{
|
||||
f'field_{text_field.id}': 'Dddd',
|
||||
f'field_{number_field.id}': 40,
|
||||
f'field_{boolean_field.id}': True
|
||||
})
|
||||
|
||||
# Without any sortings.
|
||||
rows = view_handler.apply_sorting(grid_view, model.objects.all())
|
||||
row_ids = [row.id for row in rows]
|
||||
assert row_ids == [row_1.id, row_2.id, row_3.id, row_4.id, row_5.id, row_6.id]
|
||||
|
||||
sort = data_fixture.create_view_sort(view=grid_view, field=text_field,
|
||||
order='ASC')
|
||||
|
||||
# Should raise a value error if the modal doesn't have the _field_objects property.
|
||||
with pytest.raises(ValueError):
|
||||
view_handler.apply_sorting(grid_view, GridView.objects.all())
|
||||
|
||||
# Should raise a value error if the field is not included in the model.
|
||||
with pytest.raises(ValueError):
|
||||
view_handler.apply_sorting(
|
||||
grid_view,
|
||||
table.get_model(field_ids=[]).objects.all()
|
||||
)
|
||||
|
||||
rows = view_handler.apply_sorting(grid_view, model.objects.all())
|
||||
row_ids = [row.id for row in rows]
|
||||
assert row_ids == [row_1.id, row_2.id, row_3.id, row_4.id, row_5.id, row_6.id]
|
||||
|
||||
sort.order = 'DESC'
|
||||
sort.save()
|
||||
rows = view_handler.apply_sorting(grid_view, model.objects.all())
|
||||
row_ids = [row.id for row in rows]
|
||||
assert row_ids == [row_6.id, row_5.id, row_4.id, row_1.id, row_2.id, row_3.id]
|
||||
|
||||
sort.order = 'ASC'
|
||||
sort.field_id = number_field.id
|
||||
sort.save()
|
||||
rows = view_handler.apply_sorting(grid_view, model.objects.all())
|
||||
row_ids = [row.id for row in rows]
|
||||
assert row_ids == [row_3.id, row_2.id, row_1.id, row_6.id, row_5.id, row_4.id]
|
||||
|
||||
sort.field_id = boolean_field.id
|
||||
sort.save()
|
||||
rows = view_handler.apply_sorting(grid_view, model.objects.all())
|
||||
row_ids = [row.id for row in rows]
|
||||
assert row_ids == [row_3.id, row_4.id, row_5.id, row_1.id, row_2.id, row_6.id]
|
||||
|
||||
sort.field_id = text_field.id
|
||||
sort.save()
|
||||
sort_2 = data_fixture.create_view_sort(view=grid_view, field=number_field,
|
||||
order='ASC')
|
||||
rows = view_handler.apply_sorting(grid_view, model.objects.all())
|
||||
row_ids = [row.id for row in rows]
|
||||
assert row_ids == [row_3.id, row_2.id, row_1.id, row_4.id, row_5.id, row_6.id]
|
||||
|
||||
sort.field_id = text_field.id
|
||||
sort.save()
|
||||
sort_2.field_id = boolean_field
|
||||
sort_2.order = 'DESC'
|
||||
sort_2.save()
|
||||
rows = view_handler.apply_sorting(grid_view, model.objects.all())
|
||||
row_ids = [row.id for row in rows]
|
||||
assert row_ids == [row_1.id, row_2.id, row_3.id, row_4.id, row_5.id, row_6.id]
|
||||
|
||||
sort.field_id = text_field.id
|
||||
sort.order = 'DESC'
|
||||
sort.save()
|
||||
sort_2.field_id = boolean_field
|
||||
sort_2.order = 'ASC'
|
||||
sort_2.save()
|
||||
rows = view_handler.apply_sorting(grid_view, model.objects.all())
|
||||
row_ids = [row.id for row in rows]
|
||||
assert row_ids == [row_6.id, row_5.id, row_4.id, row_3.id, row_1.id, row_2.id]
|
||||
|
||||
sort.field_id = number_field.id
|
||||
sort.save()
|
||||
rows = view_handler.apply_sorting(grid_view, model.objects.all())
|
||||
row_ids = [row.id for row in rows]
|
||||
assert row_ids == [row_4.id, row_5.id, row_6.id, row_1.id, row_2.id, row_3.id]
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_get_sort(data_fixture):
|
||||
user = data_fixture.create_user()
|
||||
user_2 = data_fixture.create_user()
|
||||
equal_sort = data_fixture.create_view_sort(user=user)
|
||||
|
||||
handler = ViewHandler()
|
||||
|
||||
with pytest.raises(ViewSortDoesNotExist):
|
||||
handler.get_sort(user=user, view_sort_id=99999)
|
||||
|
||||
with pytest.raises(UserNotInGroupError):
|
||||
handler.get_sort(user=user_2, view_sort_id=equal_sort.id)
|
||||
|
||||
sort = handler.get_sort(user=user, view_sort_id=equal_sort.id)
|
||||
|
||||
assert sort.id == equal_sort.id
|
||||
assert sort.view_id == equal_sort.view_id
|
||||
assert sort.field_id == equal_sort.field_id
|
||||
assert sort.order == equal_sort.order
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_create_sort(data_fixture):
|
||||
user = data_fixture.create_user()
|
||||
user_2 = data_fixture.create_user()
|
||||
grid_view = data_fixture.create_grid_view(user=user)
|
||||
text_field = data_fixture.create_text_field(table=grid_view.table)
|
||||
text_field_2 = data_fixture.create_text_field(table=grid_view.table)
|
||||
link_row_field = data_fixture.create_link_row_field(table=grid_view.table)
|
||||
other_field = data_fixture.create_text_field()
|
||||
|
||||
handler = ViewHandler()
|
||||
|
||||
with pytest.raises(UserNotInGroupError):
|
||||
handler.create_sort(user=user_2, view=grid_view, field=text_field,
|
||||
order='ASC')
|
||||
|
||||
grid_view_type = view_type_registry.get('grid')
|
||||
grid_view_type.can_sort = False
|
||||
with pytest.raises(ViewSortNotSupported):
|
||||
handler.create_sort(user=user, view=grid_view, field=text_field,
|
||||
order='ASC')
|
||||
grid_view_type.can_sort = True
|
||||
|
||||
with pytest.raises(ViewSortFieldNotSupported):
|
||||
handler.create_sort(user=user, view=grid_view, field=link_row_field,
|
||||
order='ASC')
|
||||
|
||||
with pytest.raises(FieldNotInTable):
|
||||
handler.create_sort(user=user, view=grid_view, field=other_field,
|
||||
order='ASC')
|
||||
|
||||
view_sort = handler.create_sort(user=user, view=grid_view, field=text_field,
|
||||
order='ASC')
|
||||
|
||||
assert ViewSort.objects.all().count() == 1
|
||||
first = ViewSort.objects.all().first()
|
||||
|
||||
assert view_sort.id == first.id
|
||||
assert view_sort.view_id == grid_view.id
|
||||
assert view_sort.field_id == text_field.id
|
||||
assert view_sort.order == 'ASC'
|
||||
|
||||
with pytest.raises(ViewSortFieldAlreadyExist):
|
||||
handler.create_sort(user=user, view=grid_view, field=text_field, order='ASC')
|
||||
|
||||
view_sort_2 = handler.create_sort(user=user, view=grid_view, field=text_field_2,
|
||||
order='DESC')
|
||||
assert view_sort_2.view_id == grid_view.id
|
||||
assert view_sort_2.field_id == text_field_2.id
|
||||
assert view_sort_2.order == 'DESC'
|
||||
assert ViewSort.objects.all().count() == 2
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_update_sort(data_fixture):
|
||||
user = data_fixture.create_user()
|
||||
user_2 = data_fixture.create_user()
|
||||
grid_view = data_fixture.create_grid_view(user=user)
|
||||
text_field = data_fixture.create_text_field(table=grid_view.table)
|
||||
long_text_field = data_fixture.create_long_text_field(table=grid_view.table)
|
||||
link_row_field = data_fixture.create_link_row_field(table=grid_view.table)
|
||||
other_field = data_fixture.create_text_field()
|
||||
view_sort = data_fixture.create_view_sort(
|
||||
view=grid_view,
|
||||
field=long_text_field,
|
||||
order='ASC',
|
||||
)
|
||||
|
||||
handler = ViewHandler()
|
||||
|
||||
with pytest.raises(UserNotInGroupError):
|
||||
handler.update_sort(user=user_2, view_sort=view_sort)
|
||||
|
||||
with pytest.raises(ViewSortFieldNotSupported):
|
||||
handler.update_sort(user=user, view_sort=view_sort, field=link_row_field)
|
||||
|
||||
with pytest.raises(FieldNotInTable):
|
||||
handler.update_sort(user=user, view_sort=view_sort, field=other_field)
|
||||
|
||||
updated_sort = handler.update_sort(user=user, view_sort=view_sort,
|
||||
order='DESC')
|
||||
assert updated_sort.order == 'DESC'
|
||||
assert updated_sort.field_id == long_text_field.id
|
||||
assert updated_sort.view_id == grid_view.id
|
||||
|
||||
updated_sort = handler.update_sort(user=user, view_sort=updated_sort,
|
||||
order='ASC', field=text_field)
|
||||
assert updated_sort.order == 'ASC'
|
||||
assert updated_sort.field_id == text_field.id
|
||||
assert updated_sort.view_id == grid_view.id
|
||||
|
||||
view_sort_2 = data_fixture.create_view_sort(view=grid_view, field=long_text_field)
|
||||
|
||||
with pytest.raises(ViewSortFieldAlreadyExist):
|
||||
handler.update_sort(user=user, view_sort=view_sort, order='ASC',
|
||||
field=long_text_field)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_delete_sort(data_fixture):
|
||||
user = data_fixture.create_user()
|
||||
sort_1 = data_fixture.create_view_sort(user=user)
|
||||
sort_2 = data_fixture.create_view_sort()
|
||||
|
||||
assert ViewSort.objects.all().count() == 2
|
||||
|
||||
handler = ViewHandler()
|
||||
|
||||
with pytest.raises(UserNotInGroupError):
|
||||
handler.delete_sort(user=user, view_sort=sort_2)
|
||||
|
||||
handler.delete_sort(user=user, view_sort=sort_1)
|
||||
|
||||
assert ViewSort.objects.all().count() == 1
|
||||
assert ViewSort.objects.filter(pk=sort_1.pk).count() == 0
|
||||
|
|
14
backend/tests/fixtures/view.py
vendored
14
backend/tests/fixtures/view.py
vendored
|
@ -1,6 +1,6 @@
|
|||
from baserow.contrib.database.fields.models import Field
|
||||
from baserow.contrib.database.views.models import (
|
||||
GridView, GridViewFieldOptions, ViewFilter
|
||||
GridView, GridViewFieldOptions, ViewFilter, ViewSort
|
||||
)
|
||||
|
||||
|
||||
|
@ -44,3 +44,15 @@ class ViewFixtures:
|
|||
kwargs['value'] = self.fake.name()
|
||||
|
||||
return ViewFilter.objects.create(**kwargs)
|
||||
|
||||
def create_view_sort(self, user=None, **kwargs):
|
||||
if 'view' not in kwargs:
|
||||
kwargs['view'] = self.create_grid_view(user)
|
||||
|
||||
if 'field' not in kwargs:
|
||||
kwargs['field'] = self.create_text_field(table=kwargs['view'].table)
|
||||
|
||||
if 'order' not in kwargs:
|
||||
kwargs['order'] = 'ASC'
|
||||
|
||||
return ViewSort.objects.create(**kwargs)
|
||||
|
|
|
@ -13,6 +13,7 @@
|
|||
* Fixed bug where the error message of the 'Select a table to link to' was not always
|
||||
displayed.
|
||||
* Added URL field.
|
||||
* Added sorting of rows per view.
|
||||
|
||||
## Released (2020-09-02)
|
||||
|
||||
|
|
|
@ -35,3 +35,4 @@
|
|||
@import 'settings';
|
||||
@import 'select_row_modal';
|
||||
@import 'filters';
|
||||
@import 'sortings';
|
||||
|
|
|
@ -25,9 +25,8 @@
|
|||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 6px 10px 6px 0;
|
||||
padding: 6px 0;
|
||||
border-radius: 3px;
|
||||
background-color: $color-neutral-100;
|
||||
|
||||
&:not(:last-child) {
|
||||
margin-bottom: 6px;
|
||||
|
@ -47,10 +46,11 @@
|
|||
}
|
||||
|
||||
.filters__remove {
|
||||
flex: 0 0 32px;
|
||||
width: 32px;
|
||||
color: $color-primary-900;
|
||||
line-height: 30px;
|
||||
text-align: center;
|
||||
width: 32px;
|
||||
|
||||
&:hover {
|
||||
text-decoration: none;
|
||||
|
|
|
@ -91,6 +91,10 @@
|
|||
&.active {
|
||||
background-color: $color-success-200;
|
||||
}
|
||||
|
||||
&.active--warning {
|
||||
background-color: $color-warning-200;
|
||||
}
|
||||
}
|
||||
|
||||
.header__filter-icon {
|
||||
|
|
128
web-frontend/modules/core/assets/scss/components/sortings.scss
Normal file
128
web-frontend/modules/core/assets/scss/components/sortings.scss
Normal file
|
@ -0,0 +1,128 @@
|
|||
.sortings {
|
||||
padding: 12px;
|
||||
|
||||
.dropdown__selected {
|
||||
@extend %ellipsis;
|
||||
}
|
||||
}
|
||||
|
||||
.sortings__none {
|
||||
padding: 4px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.sortings__none-title {
|
||||
font-weight: 700;
|
||||
font-size: 14px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.sortings__none-description {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.sortings__item {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 6px 0;
|
||||
border-radius: 3px;
|
||||
|
||||
&:not(:last-child) {
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
&.sortings__item--loading {
|
||||
padding-left: 32px;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
margin-top: -7px;
|
||||
|
||||
@include loading(14px);
|
||||
@include absolute(50%, auto, 0, 10px);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.sortings__remove {
|
||||
flex: 0 0 32px;
|
||||
width: 32px;
|
||||
color: $color-primary-900;
|
||||
line-height: 30px;
|
||||
text-align: center;
|
||||
|
||||
&:hover {
|
||||
text-decoration: none;
|
||||
color: $color-neutral-500;
|
||||
}
|
||||
|
||||
.sortings__item--loading & {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.sortings__description {
|
||||
flex: 0 0 50px;
|
||||
width: 50px;
|
||||
margin-right: 10px;
|
||||
|
||||
span {
|
||||
padding-left: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.sortings__field {
|
||||
margin-right: 10px;
|
||||
flex: 0 0 120px;
|
||||
|
||||
.dropdown,
|
||||
.dropdown__selected {
|
||||
width: 120px;
|
||||
}
|
||||
}
|
||||
|
||||
.sortings__order {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.sortings__order-item {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
flex-wrap: nowrap;
|
||||
line-height: 32px;
|
||||
border-radius: 3px;
|
||||
color: $color-neutral-900;
|
||||
width: 80px;
|
||||
|
||||
&:hover {
|
||||
background-color: $color-neutral-100;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
&.active {
|
||||
background-color: $color-primary-100;
|
||||
}
|
||||
|
||||
&:not(:last-child) {
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
> div {
|
||||
font-weight: 700;
|
||||
|
||||
&:not(:last-child) {
|
||||
margin-right: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.sortings__add {
|
||||
display: inline-block;
|
||||
margin: 12px 0 6px 4px;
|
||||
|
||||
&:hover {
|
||||
text-decoration: none;
|
||||
color: $color-primary-900;
|
||||
}
|
||||
}
|
|
@ -179,7 +179,7 @@
|
|||
position: relative;
|
||||
height: 32px + 1px;
|
||||
|
||||
&.grid-view__row--filter-warning::before {
|
||||
&.grid-view__row--warning::before {
|
||||
@include absolute(-2px, -2px, -2px, -2px);
|
||||
|
||||
content: '';
|
||||
|
@ -189,7 +189,7 @@
|
|||
}
|
||||
}
|
||||
|
||||
.grid-view__row-filter-warning {
|
||||
.grid-view__row-warning {
|
||||
@include absolute(auto, auto, -20px, 0);
|
||||
@include fixed-height(20px, 12px);
|
||||
|
||||
|
@ -222,12 +222,20 @@
|
|||
border-right: none;
|
||||
}
|
||||
|
||||
&.grid-view__column--sorted::after,
|
||||
&.grid-view__column--filtered::after {
|
||||
content: '';
|
||||
background-color: rgba($color-success-100, 0.5);
|
||||
|
||||
@include absolute(0, 0, 0, 0);
|
||||
}
|
||||
|
||||
&.grid-view__column--sorted::after {
|
||||
background-color: rgba($color-warning-100, 0.8);
|
||||
}
|
||||
|
||||
&.grid-view__column--filtered::after {
|
||||
background-color: rgba($color-success-100, 0.5);
|
||||
}
|
||||
}
|
||||
|
||||
.grid-view__row-info {
|
||||
|
|
|
@ -21,7 +21,7 @@
|
|||
ref="updateFieldContext"
|
||||
:table="table"
|
||||
:field="field"
|
||||
@update="$refs.context.hide()"
|
||||
@update=";[$emit('update'), $refs.context.hide()]"
|
||||
></UpdateFieldContext>
|
||||
</li>
|
||||
<slot></slot>
|
||||
|
@ -63,9 +63,16 @@ export default {
|
|||
this.setLoading(field, true)
|
||||
|
||||
try {
|
||||
await this.$store.dispatch('field/delete', field)
|
||||
await this.$store.dispatch('field/deleteCall', field)
|
||||
this.$emit('delete')
|
||||
this.$store.dispatch('field/forceDelete', field)
|
||||
} catch (error) {
|
||||
notifyIf(error, 'field')
|
||||
if (error.response && error.response.status === 404) {
|
||||
this.$emit('delete')
|
||||
this.$store.dispatch('field/forceDelete', field)
|
||||
} else {
|
||||
notifyIf(error, 'field')
|
||||
}
|
||||
}
|
||||
|
||||
this.setLoading(field, false)
|
||||
|
|
|
@ -12,6 +12,8 @@
|
|||
:field="field"
|
||||
:row="row"
|
||||
@update="update"
|
||||
@field-updated="$emit('field-updated')"
|
||||
@field-deleted="$emit('field-deleted')"
|
||||
></RowEditModalField>
|
||||
<div class="actions">
|
||||
<a
|
||||
|
|
|
@ -14,7 +14,13 @@
|
|||
<i class="fas fa-caret-down"></i>
|
||||
</a>
|
||||
</label>
|
||||
<FieldContext ref="context" :table="table" :field="field"></FieldContext>
|
||||
<FieldContext
|
||||
ref="context"
|
||||
:table="table"
|
||||
:field="field"
|
||||
@update="$emit('field-updated')"
|
||||
@delete="$emit('field-deleted')"
|
||||
></FieldContext>
|
||||
<component
|
||||
:is="getFieldComponent(field.type)"
|
||||
ref="field"
|
||||
|
|
|
@ -42,7 +42,7 @@
|
|||
@input="updateFilter(filter, { field: $event })"
|
||||
>
|
||||
<DropdownItem
|
||||
:key="'filter-field-' + primary.id + '-' + primary.id"
|
||||
:key="'filter-field-' + filter.id + '-' + primary.id"
|
||||
:name="primary.name"
|
||||
:value="primary.id"
|
||||
:disabled="hasCompatibleFilterTypes(primary, filterTypes)"
|
||||
|
@ -205,9 +205,15 @@ export default {
|
|||
* because some filter types are not compatible with certain field types.
|
||||
*/
|
||||
async updateFilter(filter, values) {
|
||||
const field = values.field || filter.field
|
||||
const type = values.type || filter.type
|
||||
const value = values.value || filter.value
|
||||
const field = Object.prototype.hasOwnProperty.call(values, 'field')
|
||||
? values.field
|
||||
: filter.field
|
||||
const type = Object.prototype.hasOwnProperty.call(values, 'type')
|
||||
? values.type
|
||||
: filter.type
|
||||
const value = Object.prototype.hasOwnProperty.call(values, 'value')
|
||||
? values.value
|
||||
: filter.value
|
||||
|
||||
// If the field has changed we need to check if the filter type is compatible
|
||||
// and if not we are going to choose the first compatible type.
|
||||
|
|
45
web-frontend/modules/database/components/view/ViewSort.vue
Normal file
45
web-frontend/modules/database/components/view/ViewSort.vue
Normal file
|
@ -0,0 +1,45 @@
|
|||
<template>
|
||||
<div>
|
||||
<a
|
||||
ref="contextLink"
|
||||
class="header__filter-link"
|
||||
:class="{
|
||||
'active--warning': view.sortings.length > 0,
|
||||
}"
|
||||
@click="$refs.context.toggle($refs.contextLink, 'bottom', 'left', 4)"
|
||||
>
|
||||
<i class="header__filter-icon fas fa-sort"></i>
|
||||
Sort
|
||||
</a>
|
||||
<ViewSortContext
|
||||
ref="context"
|
||||
:view="view"
|
||||
:fields="fields"
|
||||
:primary="primary"
|
||||
@changed="$emit('changed')"
|
||||
></ViewSortContext>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import ViewSortContext from './ViewSortContext'
|
||||
|
||||
export default {
|
||||
name: 'ViewSort',
|
||||
components: { ViewSortContext },
|
||||
props: {
|
||||
primary: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
fields: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
view: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
|
@ -0,0 +1,250 @@
|
|||
<template>
|
||||
<Context ref="context" class="sortings">
|
||||
<div>
|
||||
<div v-if="view.sortings.length === 0" class="sortings__none">
|
||||
<div class="sortings__none-title">You have not yet created a sort</div>
|
||||
<div class="sortings__none-description">
|
||||
Sorts allow you to sort rows by a field.
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-for="(sort, index) in view.sortings"
|
||||
:key="sort.id"
|
||||
class="sortings__item"
|
||||
:class="{
|
||||
'sortings__item--loading': sort._.loading,
|
||||
}"
|
||||
:set="(field = getField(sort.field))"
|
||||
>
|
||||
<a class="sortings__remove" @click="deleteSort(sort)">
|
||||
<i class="fas fa-times"></i>
|
||||
</a>
|
||||
<div class="sortings__description">
|
||||
<template v-if="index === 0">Sort by</template>
|
||||
<template v-if="index > 0">Then by</template>
|
||||
</div>
|
||||
<div class="sortings__field">
|
||||
<Dropdown
|
||||
:value="sort.field"
|
||||
class="dropdown--floating"
|
||||
@input="updateSort(sort, { field: $event })"
|
||||
>
|
||||
<DropdownItem
|
||||
:key="'sort-field-' + sort.id + '-' + primary.id"
|
||||
:name="primary.name"
|
||||
:value="primary.id"
|
||||
:disabled="
|
||||
sort.field !== primary.id && !isFieldAvailable(primary)
|
||||
"
|
||||
></DropdownItem>
|
||||
<DropdownItem
|
||||
v-for="field in fields"
|
||||
:key="'sort-field-' + sort.id + '-' + field.id"
|
||||
:name="field.name"
|
||||
:value="field.id"
|
||||
:disabled="sort.field !== field.id && !isFieldAvailable(field)"
|
||||
></DropdownItem>
|
||||
</Dropdown>
|
||||
</div>
|
||||
<div class="sortings__order">
|
||||
<a
|
||||
class="sortings__order-item"
|
||||
:class="{ active: sort.order === 'ASC' }"
|
||||
@click="updateSort(sort, { order: 'ASC' })"
|
||||
>
|
||||
<div>
|
||||
<template v-if="field._.type.sortIndicator[0] === 'text'">{{
|
||||
field._.type.sortIndicator[1]
|
||||
}}</template>
|
||||
<i
|
||||
v-if="field._.type.sortIndicator[0] === 'icon'"
|
||||
class="fa"
|
||||
:class="'fa-' + field._.type.sortIndicator[1]"
|
||||
></i>
|
||||
</div>
|
||||
<div>
|
||||
<i class="fas fa-long-arrow-alt-right"></i>
|
||||
</div>
|
||||
<div>
|
||||
<template v-if="field._.type.sortIndicator[0] === 'text'">{{
|
||||
field._.type.sortIndicator[2]
|
||||
}}</template>
|
||||
<i
|
||||
v-if="field._.type.sortIndicator[0] === 'icon'"
|
||||
class="fa"
|
||||
:class="'fa-' + field._.type.sortIndicator[2]"
|
||||
></i>
|
||||
</div>
|
||||
</a>
|
||||
<a
|
||||
class="sortings__order-item"
|
||||
:class="{ active: sort.order === 'DESC' }"
|
||||
@click="updateSort(sort, { order: 'DESC' })"
|
||||
>
|
||||
<div>
|
||||
<template v-if="field._.type.sortIndicator[0] === 'text'">{{
|
||||
field._.type.sortIndicator[2]
|
||||
}}</template>
|
||||
<i
|
||||
v-if="field._.type.sortIndicator[0] === 'icon'"
|
||||
class="fa"
|
||||
:class="'fa-' + field._.type.sortIndicator[2]"
|
||||
></i>
|
||||
</div>
|
||||
<div>
|
||||
<i class="fas fa-long-arrow-alt-right"></i>
|
||||
</div>
|
||||
<div>
|
||||
<template v-if="field._.type.sortIndicator[0] === 'text'">{{
|
||||
field._.type.sortIndicator[1]
|
||||
}}</template>
|
||||
<i
|
||||
v-if="field._.type.sortIndicator[0] === 'icon'"
|
||||
class="fa"
|
||||
:class="'fa-' + field._.type.sortIndicator[1]"
|
||||
></i>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<template v-if="view.sortings.length < availableFieldsLength">
|
||||
<a
|
||||
ref="addContextToggle"
|
||||
class="sortings__add"
|
||||
@click="
|
||||
$refs.addContext.toggle($refs.addContextToggle, 'bottom', 'left', 4)
|
||||
"
|
||||
>
|
||||
<i class="fas fa-plus"></i>
|
||||
choose a field to sort by
|
||||
</a>
|
||||
<Context ref="addContext">
|
||||
<div class="dropdown dropdown--floating">
|
||||
<div class="dropdown__items">
|
||||
<ul ref="items" class="select__items">
|
||||
<li v-show="isFieldAvailable(primary)" class="select__item">
|
||||
<a class="select__item-link" @click="addSort(primary)">
|
||||
<i
|
||||
class="select__item-icon fas fa-fw"
|
||||
:class="'fa-' + primary._.type.iconClass"
|
||||
></i>
|
||||
{{ primary.name }}
|
||||
</a>
|
||||
</li>
|
||||
<li
|
||||
v-for="field in fields"
|
||||
v-show="isFieldAvailable(field)"
|
||||
:key="field.id"
|
||||
class="select__item"
|
||||
>
|
||||
<a class="select__item-link" @click="addSort(field)">
|
||||
<i
|
||||
class="select__item-icon fas fa-fw"
|
||||
:class="'fa-' + field._.type.iconClass"
|
||||
></i>
|
||||
{{ field.name }}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</Context>
|
||||
</template>
|
||||
</div>
|
||||
</Context>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { notifyIf } from '@baserow/modules/core/utils/error'
|
||||
import context from '@baserow/modules/core/mixins/context'
|
||||
|
||||
export default {
|
||||
name: 'ViewSortContext',
|
||||
mixins: [context],
|
||||
props: {
|
||||
primary: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
fields: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
view: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
addOpen: false,
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
/**
|
||||
* Calculates the total amount of available fields.
|
||||
*/
|
||||
availableFieldsLength() {
|
||||
const fields = this.fields.filter((field) => field._.type.canSortInView)
|
||||
.length
|
||||
const primary = this.primary._.type.canSortInView ? 1 : 0
|
||||
return fields + primary
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
getField(fieldId) {
|
||||
if (this.primary.id === fieldId) {
|
||||
return this.primary
|
||||
}
|
||||
for (const i in this.fields) {
|
||||
if (this.fields[i].id === fieldId) {
|
||||
return this.fields[i]
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
},
|
||||
isFieldAvailable(field) {
|
||||
const allFieldIds = this.view.sortings.map((sort) => sort.field)
|
||||
return field._.type.canSortInView && !allFieldIds.includes(field.id)
|
||||
},
|
||||
async addSort(field) {
|
||||
this.$refs.addContext.hide()
|
||||
|
||||
try {
|
||||
await this.$store.dispatch('view/createSort', {
|
||||
view: this.view,
|
||||
values: {
|
||||
field: field.id,
|
||||
value: 'ASC',
|
||||
},
|
||||
})
|
||||
this.$emit('changed')
|
||||
} catch (error) {
|
||||
notifyIf(error, 'view')
|
||||
}
|
||||
},
|
||||
async deleteSort(sort) {
|
||||
try {
|
||||
await this.$store.dispatch('view/deleteSort', {
|
||||
view: this.view,
|
||||
sort,
|
||||
})
|
||||
this.$emit('changed')
|
||||
} catch (error) {
|
||||
notifyIf(error, 'view')
|
||||
}
|
||||
},
|
||||
async updateSort(sort, values) {
|
||||
try {
|
||||
await this.$store.dispatch('view/updateSort', {
|
||||
sort,
|
||||
values,
|
||||
})
|
||||
this.$emit('changed')
|
||||
} catch (error) {
|
||||
notifyIf(error, 'view')
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
|
@ -52,24 +52,30 @@
|
|||
:class="{
|
||||
'grid-view__row--loading': row._.loading,
|
||||
'grid-view__row--hover': row._.hover,
|
||||
'grid-view__row--filter-warning': !row._.matchFilters,
|
||||
'grid-view__row--warning':
|
||||
!row._.matchFilters || !row._.matchSortings,
|
||||
}"
|
||||
@mouseover="setRowHover(row, true)"
|
||||
@mouseleave="setRowHover(row, false)"
|
||||
@contextmenu.prevent="showRowContext($event, row)"
|
||||
>
|
||||
<div
|
||||
v-if="!row._.matchFilters"
|
||||
class="grid-view__row-filter-warning"
|
||||
v-if="!row._.matchFilters || !row._.matchSortings"
|
||||
class="grid-view__row-warning"
|
||||
>
|
||||
Row does not match filters
|
||||
<template v-if="!row._.matchFilters">
|
||||
Row does not match filters
|
||||
</template>
|
||||
<template v-else-if="!row._.matchSortings">
|
||||
Row has moved
|
||||
</template>
|
||||
</div>
|
||||
<div
|
||||
class="grid-view__column"
|
||||
:style="{ width: widths.leftReserved + 'px' }"
|
||||
>
|
||||
<div class="grid-view__row-info">
|
||||
<div class="grid-view__row-count">
|
||||
<div class="grid-view__row-count" :title="row.id">
|
||||
{{ row.id }}
|
||||
</div>
|
||||
<a
|
||||
|
@ -211,7 +217,8 @@
|
|||
:class="{
|
||||
'grid-view__row--loading': row._.loading,
|
||||
'grid-view__row--hover': row._.hover,
|
||||
'grid-view__row--filter-warning': !row._.matchFilters,
|
||||
'grid-view__row--warning':
|
||||
!row._.matchFilters || !row._.matchSortings,
|
||||
}"
|
||||
@mouseover="setRowHover(row, true)"
|
||||
@mouseleave="setRowHover(row, false)"
|
||||
|
@ -303,6 +310,8 @@
|
|||
:fields="fields"
|
||||
@update="updateValue"
|
||||
@hidden="rowEditModalHidden"
|
||||
@field-updated="$emit('refresh')"
|
||||
@field-deleted="$emit('refresh')"
|
||||
></RowEditModal>
|
||||
</div>
|
||||
</template>
|
||||
|
@ -397,14 +406,17 @@ export default {
|
|||
methods: {
|
||||
/**
|
||||
* When a field is deleted we need to check if that field was related to any
|
||||
* filters. If that is the case then the view needs to be refreshed so we can see
|
||||
* fresh results.
|
||||
* filters or sortings. If that is the case then the view needs to be refreshed so
|
||||
* we can see fresh results.
|
||||
*/
|
||||
fieldDeleted({ field }) {
|
||||
const filterIndex = this.view.filters.findIndex((filter) => {
|
||||
return filter.field === field.id
|
||||
})
|
||||
if (filterIndex > -1) {
|
||||
const sortIndex = this.view.sortings.findIndex((sort) => {
|
||||
return sort.field === field.id
|
||||
})
|
||||
if (filterIndex > -1 || sortIndex > -1) {
|
||||
this.$emit('refresh')
|
||||
}
|
||||
},
|
||||
|
@ -412,16 +424,22 @@ export default {
|
|||
* This method is called from the parent component when the data in the view has
|
||||
* been reset. This can for example happen when a user filters.
|
||||
*/
|
||||
refresh() {
|
||||
this.$refs.leftBody.scrollTop = 0
|
||||
this.$refs.rightBody.scrollTop = 0
|
||||
this.$refs.scrollbars.update()
|
||||
async refresh() {
|
||||
await this.$store.dispatch('view/grid/visibleByScrollTop', {
|
||||
scrollTop: this.$refs.rightBody.scrollTop,
|
||||
windowHeight: this.$refs.rightBody.clientHeight,
|
||||
})
|
||||
this.$nextTick(() => {
|
||||
this.$refs.scrollbars.update()
|
||||
})
|
||||
},
|
||||
async updateValue({ field, row, value, oldValue }) {
|
||||
try {
|
||||
await this.$store.dispatch('view/grid/updateValue', {
|
||||
table: this.table,
|
||||
view: this.view,
|
||||
fields: this.fields,
|
||||
primary: this.primary,
|
||||
row,
|
||||
field,
|
||||
value,
|
||||
|
@ -443,6 +461,13 @@ export default {
|
|||
row,
|
||||
overrides,
|
||||
})
|
||||
this.$store.dispatch('view/grid/updateMatchSortings', {
|
||||
view: this.view,
|
||||
fields: this.fields,
|
||||
primary: this.primary,
|
||||
row,
|
||||
overrides,
|
||||
})
|
||||
},
|
||||
scroll(pixelY, pixelX) {
|
||||
const $rightBody = this.$refs.rightBody
|
||||
|
@ -645,6 +670,8 @@ export default {
|
|||
unselectedField(field, { row }) {
|
||||
this.$store.dispatch('view/grid/removeRowSelectedBy', {
|
||||
grid: this.view,
|
||||
fields: this.fields,
|
||||
primary: this.primary,
|
||||
row,
|
||||
field,
|
||||
getScrollTop: () => this.$refs.leftBody.scrollTop,
|
||||
|
@ -735,13 +762,13 @@ export default {
|
|||
* must be deleted.
|
||||
*/
|
||||
rowEditModalHidden({ row }) {
|
||||
if (row._.selectedBy.length === 0 && !row._.matchFilters) {
|
||||
this.$store.dispatch('view/grid/forceDelete', {
|
||||
grid: this.view,
|
||||
row,
|
||||
getScrollTop: () => this.$refs.leftBody.scrollTop,
|
||||
})
|
||||
}
|
||||
this.$store.dispatch('view/grid/refreshRow', {
|
||||
grid: this.view,
|
||||
fields: this.fields,
|
||||
primary: this.primary,
|
||||
row,
|
||||
getScrollTop: () => this.$refs.leftBody.scrollTop,
|
||||
})
|
||||
},
|
||||
},
|
||||
}
|
||||
|
|
|
@ -4,6 +4,8 @@
|
|||
:class="{
|
||||
'grid-view__column--filtered':
|
||||
view.filters.findIndex((filter) => filter.field === field.id) !== -1,
|
||||
'grid-view__column--sorted':
|
||||
view.sortings.findIndex((sort) => sort.field === field.id) !== -1,
|
||||
}"
|
||||
>
|
||||
<div
|
||||
|
@ -21,16 +23,65 @@
|
|||
>
|
||||
<i class="fas fa-caret-down"></i>
|
||||
</a>
|
||||
<FieldContext ref="context" :table="table" :field="field">
|
||||
<FieldContext
|
||||
ref="context"
|
||||
:table="table"
|
||||
:field="field"
|
||||
@update="$emit('refresh')"
|
||||
@delete="$emit('refresh')"
|
||||
>
|
||||
<li v-if="canFilter">
|
||||
<a
|
||||
class="grid-view__description-options"
|
||||
@click="createFilter($event, view, field)"
|
||||
>
|
||||
<a @click="createFilter($event, view, field)">
|
||||
<i class="context__menu-icon fas fa-fw fa-filter"></i>
|
||||
Create filter
|
||||
</a>
|
||||
</li>
|
||||
<li v-if="field._.type.canSortInView">
|
||||
<a @click="createSort($event, view, field, 'ASC')">
|
||||
<i class="context__menu-icon fas fa-fw fa-sort-amount-down-alt"></i>
|
||||
Sort
|
||||
<template v-if="field._.type.sortIndicator[0] === 'text'">{{
|
||||
field._.type.sortIndicator[1]
|
||||
}}</template>
|
||||
<i
|
||||
v-if="field._.type.sortIndicator[0] === 'icon'"
|
||||
class="fa"
|
||||
:class="'fa-' + field._.type.sortIndicator[1]"
|
||||
></i>
|
||||
<i class="fas fa-long-arrow-alt-right"></i>
|
||||
<template v-if="field._.type.sortIndicator[0] === 'text'">{{
|
||||
field._.type.sortIndicator[2]
|
||||
}}</template>
|
||||
<i
|
||||
v-if="field._.type.sortIndicator[0] === 'icon'"
|
||||
class="fa"
|
||||
:class="'fa-' + field._.type.sortIndicator[2]"
|
||||
></i>
|
||||
</a>
|
||||
</li>
|
||||
<li v-if="field._.type.canSortInView">
|
||||
<a @click="createSort($event, view, field, 'DESC')">
|
||||
<i class="context__menu-icon fas fa-fw fa-sort-amount-down"></i>
|
||||
Sort
|
||||
<template v-if="field._.type.sortIndicator[0] === 'text'">{{
|
||||
field._.type.sortIndicator[2]
|
||||
}}</template>
|
||||
<i
|
||||
v-if="field._.type.sortIndicator[0] === 'icon'"
|
||||
class="fa"
|
||||
:class="'fa-' + field._.type.sortIndicator[2]"
|
||||
></i>
|
||||
<i class="fas fa-long-arrow-alt-right"></i>
|
||||
<template v-if="field._.type.sortIndicator[0] === 'text'">{{
|
||||
field._.type.sortIndicator[1]
|
||||
}}</template>
|
||||
<i
|
||||
v-if="field._.type.sortIndicator[0] === 'icon'"
|
||||
class="fa"
|
||||
:class="'fa-' + field._.type.sortIndicator[1]"
|
||||
></i>
|
||||
</a>
|
||||
</li>
|
||||
</FieldContext>
|
||||
<slot></slot>
|
||||
</div>
|
||||
|
@ -92,6 +143,35 @@ export default {
|
|||
notifyIf(error, 'view')
|
||||
}
|
||||
},
|
||||
async createSort(event, view, field, order) {
|
||||
// Prevent the event from propagating to the body so that it does not close the
|
||||
// view filter context menu right after it has been opened. This is due to the
|
||||
// click outside event that is fired there.
|
||||
event.stopPropagation()
|
||||
event.preventDefault()
|
||||
this.$refs.context.hide()
|
||||
|
||||
const sort = view.sortings.find((sort) => sort.field === this.field.id)
|
||||
const values = {
|
||||
field: field.id,
|
||||
order,
|
||||
}
|
||||
|
||||
try {
|
||||
if (sort === undefined) {
|
||||
await this.$store.dispatch('view/createSort', { view, field, values })
|
||||
} else {
|
||||
await this.$store.dispatch('view/updateSort', { sort, values })
|
||||
}
|
||||
|
||||
this.$emit('refresh')
|
||||
} catch (error) {
|
||||
notifyIf(error, 'view')
|
||||
}
|
||||
},
|
||||
tmp() {
|
||||
console.log('received delete')
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
|
|
@ -85,11 +85,20 @@ export class FieldType extends Registerable {
|
|||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicates whether or not it is possible to sort in a view.
|
||||
*/
|
||||
getCanSortInView() {
|
||||
return true
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
this.type = this.getType()
|
||||
this.iconClass = this.getIconClass()
|
||||
this.name = this.getName()
|
||||
this.sortIndicator = this.getSortIndicator()
|
||||
this.canSortInView = this.getCanSortInView()
|
||||
|
||||
if (this.type === null) {
|
||||
throw new Error('The type name of a view type must be set.')
|
||||
|
@ -120,6 +129,8 @@ export class FieldType extends Registerable {
|
|||
type: this.type,
|
||||
iconClass: this.iconClass,
|
||||
name: this.name,
|
||||
sortIndicator: this.sortIndicator,
|
||||
canSortInView: this.canSortInView,
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -130,6 +141,26 @@ export class FieldType extends Registerable {
|
|||
return value
|
||||
}
|
||||
|
||||
/**
|
||||
* Should return a sort function that is unique for the field type.
|
||||
*/
|
||||
getSort() {
|
||||
throw new Error(
|
||||
'Not implement error. This method should by a sort function.'
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Should return a visualisation of how the sort function is going to work. For
|
||||
* example ['text', 'A', 'Z'] will result in 'A -> Z' as ascending and 'Z -> A'
|
||||
* descending visualisation for the user. It is also possible to use a Font Awesome
|
||||
* icon here by changing the first value to 'icon'. For example
|
||||
* ['icon', 'square', 'check-square'].
|
||||
*/
|
||||
getSortIndicator() {
|
||||
return ['text', 'A', 'Z']
|
||||
}
|
||||
|
||||
/**
|
||||
* This hook is called before the field's value is copied to the clipboard.
|
||||
* Optionally formatting can be done here. By default the value is always
|
||||
|
@ -193,6 +224,17 @@ export class TextFieldType extends FieldType {
|
|||
getEmptyValue(field) {
|
||||
return field.text_default
|
||||
}
|
||||
|
||||
getSort(name, order) {
|
||||
return (a, b) => {
|
||||
const stringA = a[name] === null ? '' : '' + a[name]
|
||||
const stringB = b[name] === null ? '' : '' + b[name]
|
||||
|
||||
return order === 'ASC'
|
||||
? stringA.localeCompare(stringB)
|
||||
: stringB.localeCompare(stringA)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class LongTextFieldType extends FieldType {
|
||||
|
@ -215,6 +257,17 @@ export class LongTextFieldType extends FieldType {
|
|||
getRowEditFieldComponent() {
|
||||
return RowEditFieldLongText
|
||||
}
|
||||
|
||||
getSort(name, order) {
|
||||
return (a, b) => {
|
||||
const stringA = a[name] === null ? '' : '' + a[name]
|
||||
const stringB = b[name] === null ? '' : '' + b[name]
|
||||
|
||||
return order === 'ASC'
|
||||
? stringA.localeCompare(stringB)
|
||||
: stringB.localeCompare(stringA)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class LinkRowFieldType extends FieldType {
|
||||
|
@ -246,6 +299,10 @@ export class LinkRowFieldType extends FieldType {
|
|||
return []
|
||||
}
|
||||
|
||||
getCanSortInView() {
|
||||
return false
|
||||
}
|
||||
|
||||
prepareValueForCopy(field, value) {
|
||||
return JSON.stringify({
|
||||
tableId: field.link_row_table,
|
||||
|
@ -307,6 +364,23 @@ export class NumberFieldType extends FieldType {
|
|||
return RowEditFieldNumber
|
||||
}
|
||||
|
||||
getSortIndicator() {
|
||||
return ['text', '1', '9']
|
||||
}
|
||||
|
||||
getSort(name, order) {
|
||||
return (a, b) => {
|
||||
const numberA = parseFloat(a[name])
|
||||
const numberB = parseFloat(b[name])
|
||||
|
||||
if (isNaN(numberA) || isNaN(numberB)) {
|
||||
return -1
|
||||
}
|
||||
|
||||
return order === 'ASC' ? numberA - numberB : numberB - numberA
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* First checks if the value is numeric, if that is the case, the number is going
|
||||
* to be formatted.
|
||||
|
@ -363,6 +437,18 @@ export class BooleanFieldType extends FieldType {
|
|||
return false
|
||||
}
|
||||
|
||||
getSortIndicator() {
|
||||
return ['icon', 'square', 'check-square']
|
||||
}
|
||||
|
||||
getSort(name, order) {
|
||||
return (a, b) => {
|
||||
const intA = +a[name]
|
||||
const intB = +b[name]
|
||||
return order === 'ASC' ? intA - intB : intB - intA
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the clipboard data text contains a string that might indicate if the
|
||||
* value is true.
|
||||
|
@ -398,6 +484,22 @@ export class DateFieldType extends FieldType {
|
|||
return RowEditFieldDate
|
||||
}
|
||||
|
||||
getSortIndicator() {
|
||||
return ['text', '1', '9']
|
||||
}
|
||||
|
||||
getSort(name, order) {
|
||||
return (a, b) => {
|
||||
if (a[name] === null || b[name] === null) {
|
||||
return -1
|
||||
}
|
||||
|
||||
const timeA = new Date(a[name]).getTime()
|
||||
const timeB = new Date(b[name]).getTime()
|
||||
return order === 'ASC' ? timeA - timeB : timeB - timeA
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tries to parse the clipboard text value with moment and returns the date in the
|
||||
* correct format for the field. If it can't be parsed null is returned.
|
||||
|
@ -439,4 +541,15 @@ export class URLFieldType extends FieldType {
|
|||
const value = clipboardData.getData('text')
|
||||
return isValidURL(value) ? value : ''
|
||||
}
|
||||
|
||||
getSort(name, order) {
|
||||
return (a, b) => {
|
||||
const stringA = a[name] === null ? '' : '' + a[name]
|
||||
const stringB = b[name] === null ? '' : '' + b[name]
|
||||
|
||||
return order === 'ASC'
|
||||
? stringA.localeCompare(stringB)
|
||||
: stringB.localeCompare(stringA)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
<div>
|
||||
<header class="layout__col-3-1 header">
|
||||
<div v-show="tableLoading" class="header__loading"></div>
|
||||
<ul v-show="!tableLoading" class="header__filter">
|
||||
<ul v-if="!tableLoading" class="header__filter">
|
||||
<li class="header__filter-item header__filter-item--grids">
|
||||
<a
|
||||
ref="viewsSelectToggle"
|
||||
|
@ -40,6 +40,14 @@
|
|||
@changed="refresh()"
|
||||
></ViewFilter>
|
||||
</li>
|
||||
<li v-if="view._.type.canSort" class="header__filter-item">
|
||||
<ViewSort
|
||||
:view="view"
|
||||
:fields="fields"
|
||||
:primary="primary"
|
||||
@changed="refresh()"
|
||||
></ViewSort>
|
||||
</li>
|
||||
</ul>
|
||||
<component
|
||||
:is="getViewHeaderComponent(view)"
|
||||
|
@ -50,7 +58,7 @@
|
|||
:fields="fields"
|
||||
:primary="primary"
|
||||
/>
|
||||
<ul v-show="!tableLoading" class="header__info">
|
||||
<ul v-if="!tableLoading" class="header__info">
|
||||
<li>{{ database.name }}</li>
|
||||
<li>{{ table.name }}</li>
|
||||
</ul>
|
||||
|
@ -58,8 +66,7 @@
|
|||
<div class="layout__col-3-2 content">
|
||||
<component
|
||||
:is="getViewComponent(view)"
|
||||
v-if="hasSelectedView()"
|
||||
v-show="!tableLoading"
|
||||
v-if="hasSelectedView() && !tableLoading"
|
||||
ref="view"
|
||||
:database="database"
|
||||
:table="table"
|
||||
|
@ -78,6 +85,7 @@ import { mapState } from 'vuex'
|
|||
|
||||
import ViewsContext from '@baserow/modules/database/components/view/ViewsContext'
|
||||
import ViewFilter from '@baserow/modules/database/components/view/ViewFilter'
|
||||
import ViewSort from '@baserow/modules/database/components/view/ViewSort'
|
||||
|
||||
/**
|
||||
* This page component is the skeleton for a table. Depending on the selected view it
|
||||
|
@ -88,6 +96,7 @@ export default {
|
|||
components: {
|
||||
ViewsContext,
|
||||
ViewFilter,
|
||||
ViewSort,
|
||||
},
|
||||
/**
|
||||
* Because there is no hook that is called before the route changes, we need the
|
||||
|
@ -202,12 +211,12 @@ export default {
|
|||
async refresh() {
|
||||
this.viewLoading = true
|
||||
const type = this.$registry.get('view', this.view.type)
|
||||
await type.fetch({ store: this.$store }, this.view)
|
||||
await type.refresh({ store: this.$store }, this.view)
|
||||
if (
|
||||
Object.prototype.hasOwnProperty.call(this.$refs, 'view') &&
|
||||
Object.prototype.hasOwnProperty.call(this.$refs.view, 'refresh')
|
||||
) {
|
||||
this.$refs.view.refresh()
|
||||
await this.$refs.view.refresh()
|
||||
}
|
||||
this.$nextTick(() => {
|
||||
this.viewLoading = false
|
||||
|
|
19
web-frontend/modules/database/services/sort.js
Normal file
19
web-frontend/modules/database/services/sort.js
Normal file
|
@ -0,0 +1,19 @@
|
|||
export default (client) => {
|
||||
return {
|
||||
fetchAll(sortId) {
|
||||
return client.get(`/database/views/view/${sortId}/sortings/`)
|
||||
},
|
||||
create(sortId, values) {
|
||||
return client.post(`/database/views/view/${sortId}/sortings/`, values)
|
||||
},
|
||||
get(viewSortId) {
|
||||
return client.get(`/database/views/sort/${viewSortId}/`)
|
||||
},
|
||||
update(viewSortId, values) {
|
||||
return client.patch(`/database/views/sort/${viewSortId}/`, values)
|
||||
},
|
||||
delete(viewSortId) {
|
||||
return client.delete(`/database/views/sort/${viewSortId}/`)
|
||||
},
|
||||
}
|
||||
}
|
|
@ -1,6 +1,6 @@
|
|||
export default (client) => {
|
||||
return {
|
||||
fetchAll(tableId, includeFilters = false) {
|
||||
fetchAll(tableId, includeFilters = false, includeSortings = false) {
|
||||
const config = {
|
||||
params: {},
|
||||
}
|
||||
|
@ -10,6 +10,10 @@ export default (client) => {
|
|||
includes.push('filters')
|
||||
}
|
||||
|
||||
if (includeSortings) {
|
||||
includes.push('sortings')
|
||||
}
|
||||
|
||||
if (includes.length > 0) {
|
||||
config.params.includes = includes.join(',')
|
||||
}
|
||||
|
|
|
@ -32,6 +32,15 @@ export default (client) => {
|
|||
|
||||
return client.get(`/database/views/grid/${gridId}/`, config)
|
||||
},
|
||||
fetchCount(gridId) {
|
||||
const config = {
|
||||
params: {
|
||||
count: true,
|
||||
},
|
||||
}
|
||||
|
||||
return client.get(`/database/views/grid/${gridId}/`, config)
|
||||
},
|
||||
filterRows({ gridId, rowIds, fieldIds = null }) {
|
||||
const data = { row_ids: rowIds }
|
||||
|
||||
|
|
|
@ -140,7 +140,7 @@ export const actions = {
|
|||
* Updates the values of the provided field.
|
||||
*/
|
||||
async update(context, { field, type, values }) {
|
||||
const { commit } = context
|
||||
const { dispatch, commit } = context
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(values, 'type')) {
|
||||
throw new Error(
|
||||
|
@ -161,12 +161,21 @@ export const actions = {
|
|||
|
||||
let { data } = await FieldService(this.$client).update(field.id, postData)
|
||||
data = populateField(data, this.$registry)
|
||||
|
||||
if (field.primary) {
|
||||
commit('SET_PRIMARY', data)
|
||||
} else {
|
||||
commit('UPDATE_ITEM', { id: field.id, values: data })
|
||||
}
|
||||
|
||||
// The view might need to do some cleanup regarding the filters and sortings if the
|
||||
// type has changed.
|
||||
await dispatch(
|
||||
'view/fieldUpdated',
|
||||
{ field: data, fieldType },
|
||||
{ root: true }
|
||||
)
|
||||
|
||||
// Call the field updated event on all the registered views because they might
|
||||
// need to change things in loaded data. For example the changed rows.
|
||||
for (const viewType of Object.values(this.$registry.getAll('view'))) {
|
||||
|
@ -178,8 +187,7 @@ export const actions = {
|
|||
*/
|
||||
async delete({ commit, dispatch }, field) {
|
||||
try {
|
||||
await FieldService(this.$client).delete(field.id)
|
||||
this.$bus.$emit('field-deleted', { field })
|
||||
await dispatch('deleteCall', field)
|
||||
dispatch('forceDelete', field)
|
||||
} catch (error) {
|
||||
// If the field to delete wasn't found we can just delete it from the
|
||||
|
@ -191,12 +199,18 @@ export const actions = {
|
|||
}
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Only makes the the delete call to the server.
|
||||
*/
|
||||
async deleteCall({ commit, dispatch }, field) {
|
||||
await FieldService(this.$client).delete(field.id)
|
||||
},
|
||||
/**
|
||||
* Remove the field from the items without calling the server.
|
||||
*/
|
||||
forceDelete({ commit, dispatch }, field) {
|
||||
// Also delete the related filters if there are any.
|
||||
dispatch('view/deleteFieldFilters', { field }, { root: true })
|
||||
dispatch('view/fieldDeleted', { field }, { root: true })
|
||||
commit('DELETE_ITEM', field.id)
|
||||
},
|
||||
}
|
||||
|
|
|
@ -3,6 +3,7 @@ import _ from 'lodash'
|
|||
import { uuid } from '@baserow/modules/core/utils/string'
|
||||
import ViewService from '@baserow/modules/database/services/view'
|
||||
import FilterService from '@baserow/modules/database/services/filter'
|
||||
import SortService from '@baserow/modules/database/services/sort'
|
||||
import { clone } from '@baserow/modules/core/utils/object'
|
||||
import { DatabaseApplicationType } from '@baserow/modules/database/applicationTypes'
|
||||
|
||||
|
@ -14,6 +15,14 @@ export function populateFilter(filter) {
|
|||
return filter
|
||||
}
|
||||
|
||||
export function populateSort(sort) {
|
||||
sort._ = {
|
||||
hover: false,
|
||||
loading: false,
|
||||
}
|
||||
return sort
|
||||
}
|
||||
|
||||
export function populateView(view, registry) {
|
||||
const type = registry.get('view', view.type)
|
||||
|
||||
|
@ -31,6 +40,14 @@ export function populateView(view, registry) {
|
|||
view.filters = []
|
||||
}
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(view, 'sortings')) {
|
||||
view.sortings.forEach((sort) => {
|
||||
populateFilter(sort)
|
||||
})
|
||||
} else {
|
||||
view.sortings = []
|
||||
}
|
||||
|
||||
return type.populate(view)
|
||||
}
|
||||
|
||||
|
@ -111,6 +128,35 @@ export const mutations = {
|
|||
SET_FILTER_LOADING(state, { filter, value }) {
|
||||
filter._.loading = value
|
||||
},
|
||||
ADD_SORT(state, { view, sort }) {
|
||||
view.sortings.push(sort)
|
||||
},
|
||||
FINALIZE_SORT(state, { view, oldId, id }) {
|
||||
const index = view.sortings.findIndex((item) => item.id === oldId)
|
||||
if (index !== -1) {
|
||||
view.sortings[index].id = id
|
||||
view.sortings[index]._.loading = false
|
||||
}
|
||||
},
|
||||
DELETE_SORT(state, { view, id }) {
|
||||
const index = view.sortings.findIndex((item) => item.id === id)
|
||||
if (index !== -1) {
|
||||
view.sortings.splice(index, 1)
|
||||
}
|
||||
},
|
||||
DELETE_FIELD_SORTINGS(state, { view, fieldId }) {
|
||||
for (let i = view.sortings.length - 1; i >= 0; i--) {
|
||||
if (view.sortings[i].field === fieldId) {
|
||||
view.sortings.splice(i, 1)
|
||||
}
|
||||
}
|
||||
},
|
||||
UPDATE_SORT(state, { sort, values }) {
|
||||
Object.assign(sort, sort, values)
|
||||
},
|
||||
SET_SORT_LOADING(state, { sort, value }) {
|
||||
sort._.loading = value
|
||||
},
|
||||
}
|
||||
|
||||
export const actions = {
|
||||
|
@ -130,7 +176,11 @@ export const actions = {
|
|||
commit('UNSELECT', {})
|
||||
|
||||
try {
|
||||
const { data } = await ViewService(this.$client).fetchAll(table.id, true)
|
||||
const { data } = await ViewService(this.$client).fetchAll(
|
||||
table.id,
|
||||
true,
|
||||
true
|
||||
)
|
||||
data.forEach((part, index, d) => {
|
||||
populateView(data[index], this.$registry)
|
||||
})
|
||||
|
@ -362,6 +412,124 @@ export const actions = {
|
|||
commit('DELETE_FIELD_FILTERS', { view, fieldId: field.id })
|
||||
})
|
||||
},
|
||||
/**
|
||||
* Changes the loading state of a specific sort.
|
||||
*/
|
||||
setSortLoading({ commit }, { sort, value }) {
|
||||
commit('SET_SORT_LOADING', { sort, value })
|
||||
},
|
||||
/**
|
||||
* Creates a new sort and adds it to the store right away. If the API call succeeds
|
||||
* the row ID will be added, but if it fails it will be removed from the store.
|
||||
*/
|
||||
async createSort({ commit }, { view, values }) {
|
||||
// If the order is not provided we are going to choose the ascending order.
|
||||
if (!Object.prototype.hasOwnProperty.call(values, 'order')) {
|
||||
values.order = 'ASC'
|
||||
}
|
||||
|
||||
const sort = _.assign({}, values)
|
||||
populateSort(sort)
|
||||
sort.id = uuid()
|
||||
sort._.loading = true
|
||||
|
||||
commit('ADD_SORT', { view, sort })
|
||||
|
||||
try {
|
||||
const { data } = await SortService(this.$client).create(view.id, values)
|
||||
commit('FINALIZE_SORT', { view, oldId: sort.id, id: data.id })
|
||||
} catch (error) {
|
||||
commit('DELETE_SORT', { view, id: sort.id })
|
||||
throw error
|
||||
}
|
||||
|
||||
return { sort }
|
||||
},
|
||||
/**
|
||||
* Updates the sort values in the store right away. If the API call fails the
|
||||
* changes will be undone.
|
||||
*/
|
||||
async updateSort({ commit }, { sort, values }) {
|
||||
commit('SET_SORT_LOADING', { sort, value: true })
|
||||
|
||||
const oldValues = {}
|
||||
const newValues = {}
|
||||
Object.keys(values).forEach((name) => {
|
||||
if (Object.prototype.hasOwnProperty.call(sort, name)) {
|
||||
oldValues[name] = sort[name]
|
||||
newValues[name] = values[name]
|
||||
}
|
||||
})
|
||||
|
||||
commit('UPDATE_SORT', { sort, values: newValues })
|
||||
|
||||
try {
|
||||
await SortService(this.$client).update(sort.id, values)
|
||||
commit('SET_SORT_LOADING', { sort, value: false })
|
||||
} catch (error) {
|
||||
commit('UPDATE_SORT', { sort, values: oldValues })
|
||||
commit('SET_SORT_LOADING', { sort, value: false })
|
||||
throw error
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Deletes an existing sort. A request to the server will be made first and
|
||||
* after that it will be deleted.
|
||||
*/
|
||||
async deleteSort({ commit }, { view, sort }) {
|
||||
commit('SET_SORT_LOADING', { sort, value: true })
|
||||
|
||||
try {
|
||||
await SortService(this.$client).delete(sort.id)
|
||||
commit('DELETE_SORT', { view, id: sort.id })
|
||||
} catch (error) {
|
||||
commit('SET_SORT_LOADING', { sort, value: false })
|
||||
throw error
|
||||
}
|
||||
},
|
||||
/**
|
||||
* When a field is deleted the related sortings are also automatically deleted in the
|
||||
* backend so they need to be removed here.
|
||||
*/
|
||||
deleteFieldSortings({ commit, getters }, { field }) {
|
||||
getters.getAll.forEach((view) => {
|
||||
commit('DELETE_FIELD_SORTINGS', { view, fieldId: field.id })
|
||||
})
|
||||
},
|
||||
/**
|
||||
* Is called when a field is updated. It will check if there are filters related
|
||||
* to the delete field.
|
||||
*/
|
||||
fieldUpdated({ dispatch, commit, getters }, { field, fieldType }) {
|
||||
// Remove all filters are not compatible anymore.
|
||||
getters.getAll.forEach((view) => {
|
||||
view.filters
|
||||
.filter((filter) => filter.field === field.id)
|
||||
.forEach((filter) => {
|
||||
const filterType = this.$registry.get('viewFilter', filter.type)
|
||||
const compatible = filterType.compatibleFieldTypes.includes(
|
||||
fieldType.type
|
||||
)
|
||||
if (!compatible) {
|
||||
commit('DELETE_FILTER', { view, id: filter.id })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// Remove all the field sortings because the new field does not support sortings
|
||||
// at all.
|
||||
if (!fieldType.canSortInView) {
|
||||
dispatch('deleteFieldSortings', { field })
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Is called when a field is deleted. It will remove all filters and sortings
|
||||
* related to the field.
|
||||
*/
|
||||
fieldDeleted({ dispatch }, { field }) {
|
||||
dispatch('deleteFieldFilters', { field })
|
||||
dispatch('deleteFieldSortings', { field })
|
||||
},
|
||||
}
|
||||
|
||||
export const getters = {
|
||||
|
|
|
@ -5,6 +5,7 @@ import _ from 'lodash'
|
|||
import { uuid } from '@baserow/modules/core/utils/string'
|
||||
import GridService from '@baserow/modules/database/services/view/grid'
|
||||
import RowService from '@baserow/modules/database/services/row'
|
||||
import { getRowSortFunction } from '@baserow/modules/database/utils/view'
|
||||
|
||||
export function populateRow(row) {
|
||||
row._ = {
|
||||
|
@ -12,6 +13,7 @@ export function populateRow(row) {
|
|||
hover: false,
|
||||
selectedBy: [],
|
||||
matchFilters: true,
|
||||
matchSortings: true,
|
||||
}
|
||||
return row
|
||||
}
|
||||
|
@ -120,6 +122,21 @@ export const mutations = {
|
|||
state.rows.splice(index, 1)
|
||||
}
|
||||
},
|
||||
DELETE_ROW_MOVED_UP(state, id) {
|
||||
const index = state.rows.findIndex((item) => item.id === id)
|
||||
if (index !== -1) {
|
||||
state.bufferStartIndex++
|
||||
state.bufferLimit--
|
||||
state.rows.splice(index, 1)
|
||||
}
|
||||
},
|
||||
DELETE_ROW_MOVED_DOWN(state, id) {
|
||||
const index = state.rows.findIndex((item) => item.id === id)
|
||||
if (index !== -1) {
|
||||
state.bufferLimit--
|
||||
state.rows.splice(index, 1)
|
||||
}
|
||||
},
|
||||
FINALIZE_ROW(state, { index, id }) {
|
||||
state.rows[index].id = id
|
||||
state.rows[index]._.loading = false
|
||||
|
@ -135,6 +152,17 @@ export const mutations = {
|
|||
}
|
||||
})
|
||||
},
|
||||
SORT_ROWS(state, sortFunction) {
|
||||
state.rows.sort(sortFunction)
|
||||
|
||||
// Because all the rows have been sorted again we can safely asume they are all in
|
||||
// the right order again.
|
||||
state.rows.forEach((row) => {
|
||||
if (!row._.matchSortings) {
|
||||
row._.matchSortings = true
|
||||
}
|
||||
})
|
||||
},
|
||||
ADD_FIELD(state, { field, value }) {
|
||||
const name = `field_${field.id}`
|
||||
state.rows.forEach((row) => {
|
||||
|
@ -165,6 +193,9 @@ export const mutations = {
|
|||
SET_ROW_MATCH_FILTERS(state, { row, value }) {
|
||||
row._.matchFilters = value
|
||||
},
|
||||
SET_ROW_MATCH_SORTINGS(state, { row, value }) {
|
||||
row._.matchSortings = value
|
||||
},
|
||||
ADD_ROW_SELECTED_BY(state, { row, fieldId }) {
|
||||
if (!row._.selectedBy.includes(fieldId)) {
|
||||
row._.selectedBy.push(fieldId)
|
||||
|
@ -461,6 +492,41 @@ export const actions = {
|
|||
})
|
||||
commit('REPLACE_ALL_FIELD_OPTIONS', data.field_options)
|
||||
},
|
||||
/**
|
||||
* Refreshes the current state with fresh data. It keeps the scroll offset the same
|
||||
* if possible. This can be used when a new filter or sort is created.
|
||||
*/
|
||||
async refresh({ dispatch, commit, getters }, { gridId }) {
|
||||
const response = await GridService(this.$client).fetchCount(gridId)
|
||||
const count = response.data.count
|
||||
|
||||
const limit = getters.getBufferRequestSize * 3
|
||||
const bufferEndIndex = getters.getBufferEndIndex
|
||||
const offset =
|
||||
count >= bufferEndIndex
|
||||
? getters.getBufferStartIndex
|
||||
: Math.max(0, count - limit)
|
||||
|
||||
const { data } = await GridService(this.$client).fetchRows({
|
||||
gridId,
|
||||
offset,
|
||||
limit,
|
||||
})
|
||||
|
||||
// If there are results we can replace the existing rows so that the user stays
|
||||
// at the same scroll offset.
|
||||
data.results.forEach((part, index) => {
|
||||
populateRow(data.results[index])
|
||||
})
|
||||
await commit('ADD_ROWS', {
|
||||
rows: data.results,
|
||||
prependToRows: -getters.getBufferLimit,
|
||||
appendToRows: data.results.length,
|
||||
count: data.count,
|
||||
bufferStartIndex: offset,
|
||||
bufferLimit: data.results.length,
|
||||
})
|
||||
},
|
||||
/**
|
||||
* Checks if the given row still matches the given view filters. The row's
|
||||
* matchFilters value is updated accordingly. It is also possible to provide some
|
||||
|
@ -492,6 +558,31 @@ export const actions = {
|
|||
const matches = isValid(view.filters, values)
|
||||
commit('SET_ROW_MATCH_FILTERS', { row, value: matches })
|
||||
},
|
||||
/**
|
||||
* Checks if the given row index is still the same. The row's matchSortings value is
|
||||
* updated accordingly. It is also possible to provide some override values that not
|
||||
* actually belong to the row to do some preliminary checks.
|
||||
*/
|
||||
updateMatchSortings(
|
||||
{ commit, getters, rootGetters },
|
||||
{ view, row, fields, primary, overrides = {} }
|
||||
) {
|
||||
const values = JSON.parse(JSON.stringify(row))
|
||||
Object.keys(overrides).forEach((key) => {
|
||||
values[key] = overrides[key]
|
||||
})
|
||||
|
||||
const allRows = getters.getAllRows
|
||||
const currentIndex = getters.getAllRows.findIndex((r) => r.id === row.id)
|
||||
const sortedRows = JSON.parse(JSON.stringify(allRows))
|
||||
sortedRows[currentIndex] = values
|
||||
sortedRows.sort(
|
||||
getRowSortFunction(this.$registry, view.sortings, fields, primary)
|
||||
)
|
||||
const newIndex = sortedRows.findIndex((r) => r.id === row.id)
|
||||
|
||||
commit('SET_ROW_MATCH_SORTINGS', { row, value: currentIndex === newIndex })
|
||||
},
|
||||
/**
|
||||
* Updates a grid view field value. It will immediately be updated in the store
|
||||
* and only if the change request fails it will reverted to give a faster
|
||||
|
@ -499,10 +590,11 @@ export const actions = {
|
|||
*/
|
||||
async updateValue(
|
||||
{ commit, dispatch },
|
||||
{ table, view, row, field, value, oldValue }
|
||||
{ table, view, row, field, fields, primary, value, oldValue }
|
||||
) {
|
||||
commit('SET_VALUE', { row, field, value })
|
||||
dispatch('updateMatchFilters', { view, row })
|
||||
dispatch('updateMatchSortings', { view, fields, primary, row })
|
||||
|
||||
const fieldType = this.$registry.get('field', field._.type.type)
|
||||
const newValue = fieldType.prepareValueForUpdate(field, value)
|
||||
|
@ -590,10 +682,20 @@ export const actions = {
|
|||
/**
|
||||
* Deletes a row from the store without making a request to the backend. Note that
|
||||
* this should only be used if the row really isn't visible in the view anymore.
|
||||
* Otherwise wrong data could be fetched later.
|
||||
* Otherwise wrong data could be fetched later. This action can also be used when a
|
||||
* row has been moved outside the current buffer.
|
||||
*/
|
||||
forceDelete({ commit, dispatch, getters }, { grid, row, getScrollTop }) {
|
||||
commit('DELETE_ROW', row.id)
|
||||
forceDelete(
|
||||
{ commit, dispatch, getters },
|
||||
{ grid, row, getScrollTop, moved = false }
|
||||
) {
|
||||
if (moved === 'up') {
|
||||
commit('DELETE_ROW_MOVED_UP', row.id)
|
||||
} else if (moved === 'down') {
|
||||
commit('DELETE_ROW_MOVED_DOWN', row.id)
|
||||
} else {
|
||||
commit('DELETE_ROW', row.id)
|
||||
}
|
||||
|
||||
// We use the provided function to recalculate the scrollTop offset in order
|
||||
// to get fresh data.
|
||||
|
@ -665,29 +767,46 @@ export const actions = {
|
|||
*/
|
||||
removeRowSelectedBy(
|
||||
{ dispatch, commit },
|
||||
{ grid, row, field, getScrollTop }
|
||||
{ grid, row, field, fields, primary, getScrollTop }
|
||||
) {
|
||||
commit('REMOVE_ROW_SELECTED_BY', { row, fieldId: field.id })
|
||||
|
||||
if (row._.selectedBy.length === 0 && !row._.matchFilters) {
|
||||
dispatch('forceDelete', { grid, row, getScrollTop })
|
||||
}
|
||||
dispatch('refreshRow', { grid, row, fields, primary, getScrollTop })
|
||||
},
|
||||
/**
|
||||
* Refreshes all the buffered data values of a given field. The new values are
|
||||
* requested from the backend and replaced.
|
||||
* The row is going to be removed or repositioned if the matchFilters and
|
||||
* matchSortings state is false. It will make the state correct.
|
||||
*/
|
||||
async refreshFieldValues({ commit, getters }, { field }) {
|
||||
const rowIds = getters.getAllRows.map((row) => row.id)
|
||||
const fieldIds = [field.id]
|
||||
const gridId = getters.getLastGridId
|
||||
refreshRow(
|
||||
{ dispatch, commit, getters },
|
||||
{ grid, row, fields, primary, getScrollTop }
|
||||
) {
|
||||
if (row._.selectedBy.length === 0 && !row._.matchFilters) {
|
||||
dispatch('forceDelete', { grid, row, getScrollTop })
|
||||
return
|
||||
}
|
||||
|
||||
const { data } = await GridService(this.$client).filterRows({
|
||||
gridId,
|
||||
rowIds,
|
||||
fieldIds,
|
||||
})
|
||||
commit('UPDATE_ROWS', { rows: data })
|
||||
if (row._.selectedBy.length === 0 && !row._.matchSortings) {
|
||||
const sortFunction = getRowSortFunction(
|
||||
this.$registry,
|
||||
grid.sortings,
|
||||
fields,
|
||||
primary
|
||||
)
|
||||
commit('SORT_ROWS', sortFunction)
|
||||
|
||||
// We cannot know for sure if the row has been moved outside the scope of the
|
||||
// current buffer. Therefore if the row is at the beginning or the end of the
|
||||
// buffer we are going to remove it. This doesn't matter because the
|
||||
// fetchByScrollTop action, which is called in the forceDelete action, will fix
|
||||
// the buffer automatically.
|
||||
const up = getters.isFirst(row.id) && getters.getBufferStartIndex > 0
|
||||
const down =
|
||||
getters.isLast(row.id) && getters.getBufferEndIndex < getters.getCount
|
||||
if (up || down) {
|
||||
const moved = up ? 'up' : 'down'
|
||||
dispatch('forceDelete', { grid, row, getScrollTop, moved })
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
|
@ -752,6 +871,14 @@ export const getters = {
|
|||
getAllFieldOptions(state) {
|
||||
return state.fieldOptions
|
||||
},
|
||||
isFirst: (state) => (id) => {
|
||||
const index = state.rows.findIndex((row) => row.id === id)
|
||||
return index === 0
|
||||
},
|
||||
isLast: (state) => (id) => {
|
||||
const index = state.rows.findIndex((row) => row.id === id)
|
||||
return index === state.rows.length - 1
|
||||
},
|
||||
}
|
||||
|
||||
export default {
|
||||
|
|
25
web-frontend/modules/database/utils/view.js
Normal file
25
web-frontend/modules/database/utils/view.js
Normal file
|
@ -0,0 +1,25 @@
|
|||
import { firstBy } from 'thenby'
|
||||
|
||||
/**
|
||||
* Generates a sort function based on the provided sortings.
|
||||
*/
|
||||
export function getRowSortFunction($registry, sortings, fields, primary) {
|
||||
let sortFunction = firstBy()
|
||||
|
||||
sortings.forEach((sort) => {
|
||||
let field = fields.find((f) => f.id === sort.field)
|
||||
if (field === undefined && primary.id === sort.field) {
|
||||
field = primary
|
||||
}
|
||||
|
||||
if (field !== undefined) {
|
||||
const fieldName = `field_${field.id}`
|
||||
const fieldType = $registry.get('field', field.type)
|
||||
const fieldSortFunction = fieldType.getSort(fieldName, sort.order)
|
||||
sortFunction = sortFunction.thenBy(fieldSortFunction)
|
||||
}
|
||||
})
|
||||
|
||||
sortFunction = sortFunction.thenBy((a, b) => a.id - b.id)
|
||||
return sortFunction
|
||||
}
|
|
@ -28,12 +28,21 @@ export class ViewType extends Registerable {
|
|||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicates whether it is possible to sort the rows. If true the sort context menu
|
||||
* is added to the header.
|
||||
*/
|
||||
canSort() {
|
||||
return true
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
this.type = this.getType()
|
||||
this.iconClass = this.getIconClass()
|
||||
this.name = this.getName()
|
||||
this.canFilter = this.canFilter()
|
||||
this.canSort = this.canSort()
|
||||
|
||||
if (this.type === null) {
|
||||
throw new Error('The type name of a view type must be set.')
|
||||
|
@ -88,6 +97,14 @@ export class ViewType extends Registerable {
|
|||
*/
|
||||
fetch() {}
|
||||
|
||||
/**
|
||||
* Should refresh the data inside a few. This is method could be called when a filter
|
||||
* or sort has been changed or when a field is updated or deleted. It should keep the
|
||||
* state as much the same as it was before. So for example the scroll offset should
|
||||
* stay the same if possible.
|
||||
*/
|
||||
refresh() {}
|
||||
|
||||
/**
|
||||
* Method that is called when a field has been created. This can be useful to
|
||||
* maintain data integrity for example to add the field to the grid view store.
|
||||
|
@ -109,6 +126,7 @@ export class ViewType extends Registerable {
|
|||
iconClass: this.iconClass,
|
||||
name: this.name,
|
||||
canFilter: this.canFilter,
|
||||
canSort: this.canSort,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -134,6 +152,10 @@ export class GridViewType extends ViewType {
|
|||
await store.dispatch('view/grid/fetchInitial', { gridId: view.id })
|
||||
}
|
||||
|
||||
async refresh({ store }, view) {
|
||||
await store.dispatch('view/grid/refresh', { gridId: view.id })
|
||||
}
|
||||
|
||||
async fieldCreated({ dispatch }, table, field, fieldType) {
|
||||
const value = fieldType.getEmptyValue(field)
|
||||
await dispatch('view/grid/addField', { field, value }, { root: true })
|
||||
|
@ -148,8 +170,4 @@ export class GridViewType extends ViewType {
|
|||
{ root: true }
|
||||
)
|
||||
}
|
||||
|
||||
async fieldUpdated({ dispatch }, field, oldField, fieldType) {
|
||||
await dispatch('view/grid/refreshFieldValues', { field }, { root: true })
|
||||
}
|
||||
}
|
||||
|
|
|
@ -28,6 +28,7 @@
|
|||
"nuxt": "2.12.1",
|
||||
"nuxt-env": "^0.1.0",
|
||||
"sass-loader": "8.0.2",
|
||||
"thenby": "^1.3.4",
|
||||
"vuejs-datepicker": "^1.6.2",
|
||||
"vuelidate": "0.7.5"
|
||||
},
|
||||
|
|
|
@ -10786,6 +10786,11 @@ text-table@^0.2.0:
|
|||
resolved "https://registry.yarnpkg.com/text-table/-/text-table-0.2.0.tgz#7f5ee823ae805207c00af2df4a84ec3fcfa570b4"
|
||||
integrity sha1-f17oI66AUgfACvLfSoTsP8+lcLQ=
|
||||
|
||||
thenby@^1.3.4:
|
||||
version "1.3.4"
|
||||
resolved "https://registry.yarnpkg.com/thenby/-/thenby-1.3.4.tgz#81581f6e1bb324c6dedeae9bfc28e59b1a2201cc"
|
||||
integrity sha512-89Gi5raiWA3QZ4b2ePcEwswC3me9JIg+ToSgtE0JWeCynLnLxNr/f9G+xfo9K+Oj4AFdom8YNJjibIARTJmapQ==
|
||||
|
||||
thread-loader@^2.1.3:
|
||||
version "2.1.3"
|
||||
resolved "https://registry.yarnpkg.com/thread-loader/-/thread-loader-2.1.3.tgz#cbd2c139fc2b2de6e9d28f62286ab770c1acbdda"
|
||||
|
|
Loading…
Add table
Reference in a new issue