1
0
Fork 0
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 

See merge request 
This commit is contained in:
Bram Wiepjes 2020-10-06 14:43:02 +00:00
commit c9e0ddd856
44 changed files with 2681 additions and 124 deletions

View file

@ -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'}
],

View file

@ -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.'
)

View file

@ -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()

View file

@ -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

View file

@ -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'
),
]

View file

@ -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)

View file

@ -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):
"""

View file

@ -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)

View file

@ -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

View file

@ -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',),
},
),
]

View file

@ -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."""

View file

@ -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()

View file

@ -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')

View file

@ -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.
"""

View file

@ -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()

View file

@ -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

View file

@ -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

View file

@ -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)

View file

@ -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)

View file

@ -35,3 +35,4 @@
@import 'settings';
@import 'select_row_modal';
@import 'filters';
@import 'sortings';

View file

@ -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;

View file

@ -91,6 +91,10 @@
&.active {
background-color: $color-success-200;
}
&.active--warning {
background-color: $color-warning-200;
}
}
.header__filter-icon {

View 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;
}
}

View file

@ -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 {

View file

@ -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)

View file

@ -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

View file

@ -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"

View file

@ -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.

View 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>

View file

@ -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>

View file

@ -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,
})
},
},
}

View file

@ -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>

View file

@ -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)
}
}
}

View file

@ -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

View 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}/`)
},
}
}

View file

@ -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(',')
}

View file

@ -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 }

View file

@ -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)
},
}

View file

@ -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 = {

View file

@ -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 {

View 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
}

View file

@ -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 })
}
}

View file

@ -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"
},

View file

@ -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"