mirror of
https://gitlab.com/bramw/baserow.git
synced 2025-04-09 23:27:51 +00:00
Merge branch '133-filter-rows' into 'develop'
Resolve "Filter rows" Closes #133 See merge request bramw/baserow!93
This commit is contained in:
commit
f12f3f857a
74 changed files with 5530 additions and 250 deletions
backend
src/baserow
tests
baserow/contrib/database
fixtures
docs
web-frontend/modules
core
database
components
field
row
table
view
middleware
mixins
module.jspages
plugin.jsservices
store
utils
viewFilters.jsviewTypes.js
|
@ -14,3 +14,8 @@ ERROR_LINK_ROW_TABLE_NOT_PROVIDED = (
|
|||
'The `link_row_table` must be provided.'
|
||||
)
|
||||
ERROR_LINK_ROW_TABLE_NOT_IN_SAME_DATABASE = 'ERROR_LINK_ROW_TABLE_NOT_IN_SAME_DATABASE'
|
||||
ERROR_FIELD_NOT_IN_TABLE = (
|
||||
'ERROR_FIELD_NOT_IN_TABLE',
|
||||
HTTP_400_BAD_REQUEST,
|
||||
'The provided field does not belong in the related table.'
|
||||
)
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
from rest_framework.status import HTTP_404_NOT_FOUND
|
||||
from rest_framework.status import HTTP_400_BAD_REQUEST, HTTP_404_NOT_FOUND
|
||||
|
||||
|
||||
ERROR_VIEW_DOES_NOT_EXIST = (
|
||||
|
@ -6,3 +6,18 @@ ERROR_VIEW_DOES_NOT_EXIST = (
|
|||
HTTP_404_NOT_FOUND,
|
||||
'The requested view does not exist.'
|
||||
)
|
||||
ERROR_VIEW_FILTER_DOES_NOT_EXIST = (
|
||||
'ERROR_VIEW_FILTER_DOES_NOT_EXIST',
|
||||
HTTP_404_NOT_FOUND,
|
||||
'The view filter does not exist.'
|
||||
)
|
||||
ERROR_VIEW_FILTER_NOT_SUPPORTED = (
|
||||
'ERROR_VIEW_FILTER_NOT_SUPPORTED',
|
||||
HTTP_400_BAD_REQUEST,
|
||||
'Filtering is not supported for the view type.'
|
||||
)
|
||||
ERROR_VIEW_FILTER_TYPE_NOT_ALLOWED_FOR_FIELD = (
|
||||
'ERROR_VIEW_FILTER_TYPE_NOT_ALLOWED_FOR_FIELD',
|
||||
HTTP_400_BAD_REQUEST,
|
||||
'The chosen filter type is not allowed for the provided field.'
|
||||
)
|
||||
|
|
|
@ -103,11 +103,15 @@ class GridViewView(APIView):
|
|||
`field_options` are provided in the includes GET parameter.
|
||||
"""
|
||||
|
||||
view = ViewHandler().get_view(request.user, view_id, GridView)
|
||||
view_handler = ViewHandler()
|
||||
view = view_handler.get_view(request.user, view_id, GridView)
|
||||
|
||||
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.
|
||||
queryset = view_handler.apply_filters(view, queryset)
|
||||
|
||||
if LimitOffsetPagination.limit_query_param in request.GET:
|
||||
paginator = LimitOffsetPagination()
|
||||
else:
|
||||
|
|
|
@ -6,23 +6,74 @@ from drf_spectacular.openapi import OpenApiTypes
|
|||
from rest_framework import serializers
|
||||
|
||||
from baserow.contrib.database.api.serializers import TableSerializer
|
||||
from baserow.contrib.database.views.registries import view_type_registry
|
||||
from baserow.contrib.database.views.models import View
|
||||
from baserow.contrib.database.views.registries import (
|
||||
view_type_registry, view_filter_type_registry
|
||||
)
|
||||
from baserow.contrib.database.views.models import View, ViewFilter
|
||||
|
||||
|
||||
class ViewSerializer(serializers.ModelSerializer):
|
||||
type = serializers.SerializerMethodField()
|
||||
table = TableSerializer()
|
||||
|
||||
class ViewFilterSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = View
|
||||
fields = ('id', 'name', 'order', 'type', 'table')
|
||||
model = ViewFilter
|
||||
fields = ('id', 'view', 'field', 'type', 'value')
|
||||
extra_kwargs = {
|
||||
'id': {
|
||||
'read_only': True
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class CreateViewFilterSerializer(serializers.ModelSerializer):
|
||||
type = serializers.ChoiceField(
|
||||
choices=lazy(view_filter_type_registry.get_types, list)(),
|
||||
help_text=ViewFilter._meta.get_field('type').help_text
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = ViewFilter
|
||||
fields = ('field', 'type', 'value')
|
||||
extra_kwargs = {
|
||||
'value': {'default': ''}
|
||||
}
|
||||
|
||||
|
||||
class UpdateViewFilterSerializer(serializers.ModelSerializer):
|
||||
type = serializers.ChoiceField(
|
||||
choices=lazy(view_filter_type_registry.get_types, list)(),
|
||||
required=False,
|
||||
help_text=ViewFilter._meta.get_field('type').help_text
|
||||
)
|
||||
|
||||
class Meta(CreateViewFilterSerializer.Meta):
|
||||
model = ViewFilter
|
||||
fields = ('field', 'type', 'value')
|
||||
extra_kwargs = {
|
||||
'field': {'required': False},
|
||||
'value': {'required': False}
|
||||
}
|
||||
|
||||
|
||||
class ViewSerializer(serializers.ModelSerializer):
|
||||
type = serializers.SerializerMethodField()
|
||||
table = TableSerializer()
|
||||
filters = ViewFilterSerializer(many=True, source='viewfilter_set')
|
||||
|
||||
class Meta:
|
||||
model = View
|
||||
fields = ('id', 'name', 'order', 'type', 'table', 'filter_type', 'filters')
|
||||
extra_kwargs = {
|
||||
'id': {
|
||||
'read_only': True
|
||||
}
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
include_filters = kwargs.pop('filters') if 'filters' in kwargs else False
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
if not include_filters:
|
||||
self.fields.pop('filters')
|
||||
|
||||
@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
|
||||
|
@ -41,13 +92,14 @@ class CreateViewSerializer(serializers.ModelSerializer):
|
|||
|
||||
class Meta:
|
||||
model = View
|
||||
fields = ('name', 'type')
|
||||
fields = ('name', 'type', 'filter_type')
|
||||
|
||||
|
||||
class UpdateViewSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = View
|
||||
fields = ('name',)
|
||||
fields = ('name', 'filter_type')
|
||||
extra_kwargs = {
|
||||
'name': {'required': False}
|
||||
'name': {'required': False},
|
||||
'filter_type': {'required': False}
|
||||
}
|
||||
|
|
|
@ -2,12 +2,22 @@ from django.conf.urls import url
|
|||
|
||||
from baserow.contrib.database.views.registries import view_type_registry
|
||||
|
||||
from .views import ViewsView, ViewView
|
||||
from .views import ViewsView, ViewView, ViewFiltersView, ViewFilterView
|
||||
|
||||
|
||||
app_name = 'baserow.contrib.database.api.views'
|
||||
|
||||
urlpatterns = view_type_registry.api_urls + [
|
||||
url(r'table/(?P<table_id>[0-9]+)/$', ViewsView.as_view(), name='list'),
|
||||
url(
|
||||
r'filter/(?P<view_filter_id>[0-9]+)/$',
|
||||
ViewFilterView.as_view(),
|
||||
name='filter_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'
|
||||
),
|
||||
]
|
||||
|
|
|
@ -1,4 +1,3 @@
|
|||
from django.shortcuts import get_object_or_404
|
||||
from django.db import transaction
|
||||
|
||||
from rest_framework.views import APIView
|
||||
|
@ -8,41 +7,41 @@ from rest_framework.permissions import IsAuthenticated
|
|||
from drf_spectacular.utils import extend_schema
|
||||
from drf_spectacular.openapi import OpenApiParameter, OpenApiTypes
|
||||
|
||||
from baserow.api.decorators import validate_body_custom_fields, map_exceptions
|
||||
from baserow.api.decorators import (
|
||||
validate_body, validate_body_custom_fields, map_exceptions, allowed_includes
|
||||
)
|
||||
from baserow.api.utils import validate_data_custom_fields
|
||||
from baserow.api.errors import ERROR_USER_NOT_IN_GROUP
|
||||
from baserow.api.utils import PolymorphicCustomFieldRegistrySerializer
|
||||
from baserow.api.schemas import get_error_schema
|
||||
from baserow.core.exceptions import UserNotInGroupError
|
||||
from baserow.contrib.database.api.fields.errors import ERROR_FIELD_NOT_IN_TABLE
|
||||
from baserow.contrib.database.api.tables.errors import ERROR_TABLE_DOES_NOT_EXIST
|
||||
from baserow.contrib.database.table.models import Table
|
||||
from baserow.contrib.database.fields.models import Field
|
||||
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
|
||||
from baserow.contrib.database.views.models import View, ViewFilter
|
||||
from baserow.contrib.database.views.handler import ViewHandler
|
||||
from baserow.contrib.database.views.exceptions import ViewDoesNotExist
|
||||
from baserow.contrib.database.views.exceptions import (
|
||||
ViewDoesNotExist, ViewFilterDoesNotExist, ViewFilterNotSupported,
|
||||
ViewFilterTypeNotAllowedForField
|
||||
)
|
||||
|
||||
from .serializers import ViewSerializer, CreateViewSerializer, UpdateViewSerializer
|
||||
from .errors import ERROR_VIEW_DOES_NOT_EXIST
|
||||
from .serializers import (
|
||||
ViewSerializer, CreateViewSerializer, UpdateViewSerializer, ViewFilterSerializer,
|
||||
CreateViewFilterSerializer, UpdateViewFilterSerializer
|
||||
)
|
||||
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
|
||||
)
|
||||
|
||||
|
||||
class ViewsView(APIView):
|
||||
permission_classes = (IsAuthenticated,)
|
||||
|
||||
@staticmethod
|
||||
def get_table(user, table_id):
|
||||
table = get_object_or_404(
|
||||
Table.objects.select_related('database__group'),
|
||||
id=table_id
|
||||
)
|
||||
|
||||
group = table.database.group
|
||||
if not group.has_user(user):
|
||||
raise UserNotInGroupError(user, group)
|
||||
|
||||
return table
|
||||
|
||||
@extend_schema(
|
||||
parameters=[
|
||||
OpenApiParameter(
|
||||
|
@ -78,7 +77,8 @@ class ViewsView(APIView):
|
|||
TableDoesNotExist: ERROR_TABLE_DOES_NOT_EXIST,
|
||||
UserNotInGroupError: ERROR_USER_NOT_IN_GROUP
|
||||
})
|
||||
def get(self, request, table_id):
|
||||
@allowed_includes('filters')
|
||||
def get(self, request, table_id, filters):
|
||||
"""
|
||||
Responds with a list of serialized views that belong to the table if the user
|
||||
has access to that group.
|
||||
|
@ -86,8 +86,16 @@ class ViewsView(APIView):
|
|||
|
||||
table = TableHandler().get_table(request.user, table_id)
|
||||
views = View.objects.filter(table=table).select_related('content_type')
|
||||
|
||||
if filters:
|
||||
views = views.prefetch_related('viewfilter_set')
|
||||
|
||||
data = [
|
||||
view_type_registry.get_serializer(view, ViewSerializer).data
|
||||
view_type_registry.get_serializer(
|
||||
view,
|
||||
ViewSerializer,
|
||||
filters=filters
|
||||
).data
|
||||
for view in views
|
||||
]
|
||||
return Response(data)
|
||||
|
@ -132,14 +140,19 @@ class ViewsView(APIView):
|
|||
TableDoesNotExist: ERROR_TABLE_DOES_NOT_EXIST,
|
||||
UserNotInGroupError: ERROR_USER_NOT_IN_GROUP
|
||||
})
|
||||
def post(self, request, data, table_id):
|
||||
@allowed_includes('filters')
|
||||
def post(self, request, data, table_id, filters):
|
||||
"""Creates a new view for a user."""
|
||||
|
||||
table = TableHandler().get_table(request.user, table_id)
|
||||
view = ViewHandler().create_view(
|
||||
request.user, table, data.pop('type'), **data)
|
||||
|
||||
serializer = view_type_registry.get_serializer(view, ViewSerializer)
|
||||
serializer = view_type_registry.get_serializer(
|
||||
view,
|
||||
ViewSerializer,
|
||||
filters=filters
|
||||
)
|
||||
return Response(serializer.data)
|
||||
|
||||
|
||||
|
@ -175,11 +188,16 @@ class ViewView(APIView):
|
|||
ViewDoesNotExist: ERROR_VIEW_DOES_NOT_EXIST,
|
||||
UserNotInGroupError: ERROR_USER_NOT_IN_GROUP
|
||||
})
|
||||
def get(self, request, view_id):
|
||||
@allowed_includes('filters')
|
||||
def get(self, request, view_id, filters):
|
||||
"""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)
|
||||
serializer = view_type_registry.get_serializer(
|
||||
view,
|
||||
ViewSerializer,
|
||||
filters=filters
|
||||
)
|
||||
return Response(serializer.data)
|
||||
|
||||
@extend_schema(
|
||||
|
@ -218,7 +236,8 @@ class ViewView(APIView):
|
|||
ViewDoesNotExist: ERROR_VIEW_DOES_NOT_EXIST,
|
||||
UserNotInGroupError: ERROR_USER_NOT_IN_GROUP
|
||||
})
|
||||
def patch(self, request, view_id):
|
||||
@allowed_includes('filters')
|
||||
def patch(self, request, view_id, filters):
|
||||
"""Updates the view if the user belongs to the group."""
|
||||
|
||||
view = ViewHandler().get_view(request.user, view_id).specific
|
||||
|
@ -230,7 +249,11 @@ class ViewView(APIView):
|
|||
|
||||
view = ViewHandler().update_view(request.user, view, **data)
|
||||
|
||||
serializer = view_type_registry.get_serializer(view, ViewSerializer)
|
||||
serializer = view_type_registry.get_serializer(
|
||||
view,
|
||||
ViewSerializer,
|
||||
filters=filters
|
||||
)
|
||||
return Response(serializer.data)
|
||||
|
||||
@extend_schema(
|
||||
|
@ -268,3 +291,224 @@ class ViewView(APIView):
|
|||
ViewHandler().delete_view(request.user, view)
|
||||
|
||||
return Response(status=204)
|
||||
|
||||
|
||||
class ViewFiltersView(APIView):
|
||||
permission_classes = (IsAuthenticated,)
|
||||
|
||||
@extend_schema(
|
||||
parameters=[
|
||||
OpenApiParameter(
|
||||
name='view_id',
|
||||
location=OpenApiParameter.PATH,
|
||||
type=OpenApiTypes.INT,
|
||||
description='Returns only filters of the view related to the provided '
|
||||
'value.'
|
||||
)
|
||||
],
|
||||
tags=['Database table views'],
|
||||
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'
|
||||
'that apply to the filters are returned.'
|
||||
),
|
||||
responses={
|
||||
200: ViewFilterSerializer(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 filters that belong to the view if the user
|
||||
has access to that group.
|
||||
"""
|
||||
|
||||
view = ViewHandler().get_view(request.user, view_id)
|
||||
filters = ViewFilter.objects.filter(view=view)
|
||||
serializer = ViewFilterSerializer(filters, many=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
@extend_schema(
|
||||
parameters=[
|
||||
OpenApiParameter(
|
||||
name='view_id',
|
||||
location=OpenApiParameter.PATH,
|
||||
type=OpenApiTypes.INT,
|
||||
description='Creates a filter for the view related to the provided '
|
||||
'value.'
|
||||
)
|
||||
],
|
||||
tags=['Database table views'],
|
||||
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'
|
||||
'`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 '
|
||||
'values are going to be compared.'
|
||||
),
|
||||
request=CreateViewFilterSerializer(),
|
||||
responses={
|
||||
200: ViewFilterSerializer(),
|
||||
400: get_error_schema([
|
||||
'ERROR_USER_NOT_IN_GROUP', 'ERROR_REQUEST_BODY_VALIDATION',
|
||||
'ERROR_FIELD_NOT_IN_TABLE', 'ERROR_VIEW_FILTER_NOT_SUPPORTED',
|
||||
'ERROR_VIEW_FILTER_TYPE_NOT_ALLOWED_FOR_FIELD'
|
||||
]),
|
||||
404: get_error_schema(['ERROR_VIEW_DOES_NOT_EXIST'])
|
||||
}
|
||||
)
|
||||
@transaction.atomic
|
||||
@validate_body(CreateViewFilterSerializer)
|
||||
@map_exceptions({
|
||||
ViewDoesNotExist: ERROR_VIEW_DOES_NOT_EXIST,
|
||||
UserNotInGroupError: ERROR_USER_NOT_IN_GROUP,
|
||||
FieldNotInTable: ERROR_FIELD_NOT_IN_TABLE,
|
||||
ViewFilterNotSupported: ERROR_VIEW_FILTER_NOT_SUPPORTED,
|
||||
ViewFilterTypeNotAllowedForField: ERROR_VIEW_FILTER_TYPE_NOT_ALLOWED_FOR_FIELD
|
||||
})
|
||||
def post(self, request, data, view_id):
|
||||
"""Creates a new filter 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 CreateViewFilterSerializer
|
||||
# has already checked that.
|
||||
field = Field.objects.get(pk=data['field'])
|
||||
view_filter = view_handler.create_filter(request.user, view, field,
|
||||
data['type'], data['value'])
|
||||
|
||||
serializer = ViewFilterSerializer(view_filter)
|
||||
return Response(serializer.data)
|
||||
|
||||
|
||||
class ViewFilterView(APIView):
|
||||
permission_classes = (IsAuthenticated,)
|
||||
|
||||
@extend_schema(
|
||||
parameters=[
|
||||
OpenApiParameter(
|
||||
name='view_filter_id',
|
||||
location=OpenApiParameter.PATH,
|
||||
type=OpenApiTypes.INT,
|
||||
description='Returns the view filter related to the provided value.'
|
||||
)
|
||||
],
|
||||
tags=['Database table views'],
|
||||
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.'
|
||||
),
|
||||
responses={
|
||||
200: ViewFilterSerializer(),
|
||||
400: get_error_schema(['ERROR_USER_NOT_IN_GROUP']),
|
||||
404: get_error_schema(['ERROR_VIEW_FILTER_DOES_NOT_EXIST'])
|
||||
}
|
||||
)
|
||||
@map_exceptions({
|
||||
ViewFilterDoesNotExist: ERROR_VIEW_FILTER_DOES_NOT_EXIST,
|
||||
UserNotInGroupError: ERROR_USER_NOT_IN_GROUP
|
||||
})
|
||||
def get(self, request, view_filter_id):
|
||||
"""Selects a single filter and responds with a serialized version."""
|
||||
|
||||
view_filter = ViewHandler().get_filter(request.user, view_filter_id)
|
||||
serializer = ViewFilterSerializer(view_filter)
|
||||
return Response(serializer.data)
|
||||
|
||||
@extend_schema(
|
||||
parameters=[
|
||||
OpenApiParameter(
|
||||
name='view_filter_id',
|
||||
location=OpenApiParameter.PATH,
|
||||
type=OpenApiTypes.INT,
|
||||
description='Updates the view filter related to the provided value.'
|
||||
)
|
||||
],
|
||||
tags=['Database table views'],
|
||||
operation_id='update_database_table_view_filter',
|
||||
description=(
|
||||
'Updates the existing filter if the authorized user has access to the '
|
||||
'related database\'s group.'
|
||||
),
|
||||
request=UpdateViewFilterSerializer(),
|
||||
responses={
|
||||
200: ViewFilterSerializer(),
|
||||
400: get_error_schema([
|
||||
'ERROR_USER_NOT_IN_GROUP', 'ERROR_FIELD_NOT_IN_TABLE',
|
||||
'ERROR_VIEW_FILTER_NOT_SUPPORTED',
|
||||
'ERROR_VIEW_FILTER_TYPE_NOT_ALLOWED_FOR_FIELD'
|
||||
]),
|
||||
404: get_error_schema(['ERROR_VIEW_FILTER_DOES_NOT_EXIST'])
|
||||
}
|
||||
)
|
||||
@transaction.atomic
|
||||
@validate_body(UpdateViewFilterSerializer)
|
||||
@map_exceptions({
|
||||
ViewFilterDoesNotExist: ERROR_VIEW_FILTER_DOES_NOT_EXIST,
|
||||
UserNotInGroupError: ERROR_USER_NOT_IN_GROUP,
|
||||
FieldNotInTable: ERROR_FIELD_NOT_IN_TABLE,
|
||||
ViewFilterTypeNotAllowedForField: ERROR_VIEW_FILTER_TYPE_NOT_ALLOWED_FOR_FIELD
|
||||
})
|
||||
def patch(self, request, data, view_filter_id):
|
||||
"""Updates the view filter if the user belongs to the group."""
|
||||
|
||||
handler = ViewHandler()
|
||||
view_filter = handler.get_filter(request.user, view_filter_id)
|
||||
|
||||
if 'field' in data:
|
||||
# We can safely assume the field exists because the
|
||||
# UpdateViewFilterSerializer has already checked that.
|
||||
data['field'] = Field.objects.get(pk=data['field'])
|
||||
|
||||
if 'type' in data:
|
||||
data['type_name'] = data.pop('type')
|
||||
|
||||
view_filter = handler.update_filter(request.user, view_filter, **data)
|
||||
|
||||
serializer = ViewFilterSerializer(view_filter)
|
||||
return Response(serializer.data)
|
||||
|
||||
@extend_schema(
|
||||
parameters=[
|
||||
OpenApiParameter(
|
||||
name='view_filter_id',
|
||||
location=OpenApiParameter.PATH,
|
||||
type=OpenApiTypes.INT,
|
||||
description='Deletes the filter related to the provided value.'
|
||||
)
|
||||
],
|
||||
tags=['Database table views'],
|
||||
operation_id='delete_database_table_view_filter',
|
||||
description=(
|
||||
'Deletes the existing filter 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_FILTER_DOES_NOT_EXIST'])
|
||||
}
|
||||
)
|
||||
@transaction.atomic
|
||||
@map_exceptions({
|
||||
ViewFilterDoesNotExist: ERROR_VIEW_FILTER_DOES_NOT_EXIST,
|
||||
UserNotInGroupError: ERROR_USER_NOT_IN_GROUP
|
||||
})
|
||||
def delete(self, request, view_filter_id):
|
||||
"""Deletes an existing filter if the user belongs to the group."""
|
||||
|
||||
view = ViewHandler().get_filter(request.user, view_filter_id)
|
||||
ViewHandler().delete_filter(request.user, view)
|
||||
|
||||
return Response(status=204)
|
||||
|
|
|
@ -2,7 +2,7 @@ from django.apps import AppConfig
|
|||
|
||||
from baserow.core.registries import plugin_registry, application_type_registry
|
||||
|
||||
from .views.registries import view_type_registry
|
||||
from .views.registries import view_type_registry, view_filter_type_registry
|
||||
from .fields.registries import field_type_registry, field_converter_registry
|
||||
|
||||
|
||||
|
@ -60,5 +60,23 @@ class DatabaseConfig(AppConfig):
|
|||
from .views.view_types import GridViewType
|
||||
view_type_registry.register(GridViewType())
|
||||
|
||||
from .views.view_filters import (
|
||||
EqualViewFilterType, NotEqualViewFilterType, EmptyViewFilterType,
|
||||
NotEmptyViewFilterType, DateEqualViewFilterType, DateNotEqualViewFilterType,
|
||||
HigherThanViewFilterType, LowerThanViewFilterType, ContainsViewFilterType,
|
||||
ContainsNotViewFilterType, BooleanViewFilterType
|
||||
)
|
||||
view_filter_type_registry.register(EqualViewFilterType())
|
||||
view_filter_type_registry.register(NotEqualViewFilterType())
|
||||
view_filter_type_registry.register(ContainsViewFilterType())
|
||||
view_filter_type_registry.register(ContainsNotViewFilterType())
|
||||
view_filter_type_registry.register(HigherThanViewFilterType())
|
||||
view_filter_type_registry.register(LowerThanViewFilterType())
|
||||
view_filter_type_registry.register(DateEqualViewFilterType())
|
||||
view_filter_type_registry.register(DateNotEqualViewFilterType())
|
||||
view_filter_type_registry.register(BooleanViewFilterType())
|
||||
view_filter_type_registry.register(EmptyViewFilterType())
|
||||
view_filter_type_registry.register(NotEmptyViewFilterType())
|
||||
|
||||
from .application_types import DatabaseApplicationType
|
||||
application_type_registry.register(DatabaseApplicationType())
|
||||
|
|
|
@ -11,6 +11,10 @@ class FieldTypeDoesNotExist(InstanceTypeDoesNotExist):
|
|||
pass
|
||||
|
||||
|
||||
class FieldNotInTable(Exception):
|
||||
"""Raised when the field does not belong to a table."""
|
||||
|
||||
|
||||
class FieldDoesNotExist(Exception):
|
||||
"""Raised when the requested field does not exist."""
|
||||
|
||||
|
|
|
@ -33,7 +33,7 @@ class FieldHandler:
|
|||
field. This can for example be useful when you want to select a TextField or
|
||||
other child of the Field model.
|
||||
:type field_model: Field
|
||||
:param base_queryset: The base queryset from where to select the group
|
||||
:param base_queryset: The base queryset from where to select the field.
|
||||
object. This can for example be used to do a `select_related`. Note that
|
||||
if this is used the `field_model` parameter doesn't work anymore.
|
||||
:type base_queryset: Queryset
|
||||
|
|
|
@ -3,6 +3,7 @@ from baserow.core.registry import (
|
|||
CustomFieldsInstanceMixin, CustomFieldsRegistryMixin, MapAPIExceptionsInstanceMixin,
|
||||
APIUrlsRegistryMixin, APIUrlsInstanceMixin
|
||||
)
|
||||
|
||||
from .exceptions import FieldTypeAlreadyRegistered, FieldTypeDoesNotExist
|
||||
|
||||
|
||||
|
|
|
@ -0,0 +1,81 @@
|
|||
# Generated by Django 2.2.11 on 2020-09-04 14:10
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('database', '0011_link_row_column_name_fix'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='view',
|
||||
name='filter_type',
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
('AND', 'And'),
|
||||
('OR', 'Or')
|
||||
],
|
||||
default='AND',
|
||||
max_length=3,
|
||||
help_text='Indicates whether all the rows should apply to all filters '
|
||||
'(AND) or to any filter (OR).'
|
||||
),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ViewFilter',
|
||||
fields=[
|
||||
(
|
||||
'id',
|
||||
models.AutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name='ID'
|
||||
)
|
||||
),
|
||||
(
|
||||
'type', models.CharField(
|
||||
max_length=48,
|
||||
help_text="Indicates how the field's value must be compared "
|
||||
"to the filter's value. The filter is always in this "
|
||||
"order `field` `type` `value` (example: `field_1` "
|
||||
"`contains` `Test`)."
|
||||
)
|
||||
),
|
||||
(
|
||||
'value',
|
||||
models.CharField(
|
||||
blank=True,
|
||||
max_length=255,
|
||||
help_text="The filter value that must be compared to the "
|
||||
"field's value."
|
||||
)
|
||||
),
|
||||
(
|
||||
'field',
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to='database.Field',
|
||||
help_text="The field of which the value must be compared to "
|
||||
"the filter value."
|
||||
)
|
||||
),
|
||||
(
|
||||
'view',
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to='database.View',
|
||||
help_text='The view to which the filter applies. Each view '
|
||||
'can have his own filters.'
|
||||
)
|
||||
),
|
||||
],
|
||||
options={
|
||||
'ordering': ('id',),
|
||||
},
|
||||
),
|
||||
]
|
|
@ -1,16 +1,17 @@
|
|||
from baserow.core.models import Application
|
||||
|
||||
from .table.models import Table
|
||||
from .views.models import View, GridView
|
||||
from .views.models import View, GridView, GridViewFieldOptions, ViewFilter
|
||||
from .fields.models import (
|
||||
Field, TextField, NumberField, LongTextField, BooleanField, DateField
|
||||
Field, TextField, NumberField, LongTextField, BooleanField, DateField, LinkRowField
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
'Database',
|
||||
'Table',
|
||||
'View', 'GridView',
|
||||
'View', 'GridView', 'GridViewFieldOptions', 'ViewFilter',
|
||||
'Field', 'TextField', 'NumberField', 'LongTextField', 'BooleanField', 'DateField',
|
||||
'LinkRowField'
|
||||
]
|
||||
|
||||
|
||||
|
|
|
@ -20,3 +20,23 @@ class ViewTypeAlreadyRegistered(InstanceTypeAlreadyRegistered):
|
|||
|
||||
class ViewTypeDoesNotExist(InstanceTypeDoesNotExist):
|
||||
pass
|
||||
|
||||
|
||||
class ViewFilterDoesNotExist(Exception):
|
||||
"""Raised when trying to get a view filter that does not exist."""
|
||||
|
||||
|
||||
class ViewFilterNotSupported(Exception):
|
||||
"""Raised when the view type does not support filters."""
|
||||
|
||||
|
||||
class ViewFilterTypeNotAllowedForField(Exception):
|
||||
"""Raised when the view filter type is compatible with the field type."""
|
||||
|
||||
|
||||
class ViewFilterTypeDoesNotExist(InstanceTypeDoesNotExist):
|
||||
"""Raised when the view filter type was not found in the registry."""
|
||||
|
||||
|
||||
class ViewFilterTypeAlreadyRegistered(InstanceTypeAlreadyRegistered):
|
||||
"""Raised when the view filter type is already registered in the registry."""
|
||||
|
|
|
@ -1,14 +1,23 @@
|
|||
from django.db.models import Q
|
||||
|
||||
from baserow.core.exceptions import UserNotInGroupError
|
||||
from baserow.core.utils import extract_allowed, set_allowed_attrs
|
||||
from baserow.contrib.database.fields.registries import field_type_registry
|
||||
from baserow.contrib.database.fields.models import Field
|
||||
from baserow.contrib.database.fields.exceptions import FieldNotInTable
|
||||
|
||||
from .exceptions import ViewDoesNotExist, UnrelatedFieldError
|
||||
from .registries import view_type_registry
|
||||
from .models import View, GridViewFieldOptions
|
||||
from .exceptions import (
|
||||
ViewDoesNotExist, UnrelatedFieldError, ViewFilterDoesNotExist,
|
||||
ViewFilterNotSupported, ViewFilterTypeNotAllowedForField
|
||||
)
|
||||
from .registries import view_type_registry, view_filter_type_registry
|
||||
from .models import (
|
||||
View, GridViewFieldOptions, ViewFilter, FILTER_TYPE_AND, FILTER_TYPE_OR
|
||||
)
|
||||
|
||||
|
||||
class ViewHandler:
|
||||
def get_view(self, user, view_id, view_model=None):
|
||||
def get_view(self, user, view_id, view_model=None, base_queryset=None):
|
||||
"""
|
||||
Selects a view and checks if the user has access to that view. If everything
|
||||
is fine the view is returned.
|
||||
|
@ -20,6 +29,11 @@ class ViewHandler:
|
|||
:param view_model: If provided that models objects are used to select the
|
||||
view. This can for example be useful when you want to select a GridView or
|
||||
other child of the View model.
|
||||
:type view_model: View
|
||||
:param base_queryset: The base queryset from where to select the view
|
||||
object. This can for example be used to do a `select_related`. Note that
|
||||
if this is used the `view_model` parameter doesn't work anymore.
|
||||
:type base_queryset: Queryset
|
||||
:raises ViewDoesNotExist: When the view with the provided id does not exist.
|
||||
:raises UserNotInGroupError: When the user does not belong to the related group.
|
||||
:type view_model: View
|
||||
|
@ -29,8 +43,11 @@ class ViewHandler:
|
|||
if not view_model:
|
||||
view_model = View
|
||||
|
||||
if not base_queryset:
|
||||
base_queryset = view_model.objects
|
||||
|
||||
try:
|
||||
view = view_model.objects.select_related('table__database__group').get(
|
||||
view = base_queryset.select_related('table__database__group').get(
|
||||
pk=view_id
|
||||
)
|
||||
except View.DoesNotExist:
|
||||
|
@ -66,7 +83,7 @@ class ViewHandler:
|
|||
# Figure out which model to use for the given view type.
|
||||
view_type = view_type_registry.get(type_name)
|
||||
model_class = view_type.model_class
|
||||
allowed_fields = ['name'] + view_type.allowed_fields
|
||||
allowed_fields = ['name', 'filter_type'] + view_type.allowed_fields
|
||||
view_values = extract_allowed(kwargs, allowed_fields)
|
||||
last_order = model_class.get_last_order(table)
|
||||
|
||||
|
@ -99,7 +116,7 @@ class ViewHandler:
|
|||
raise UserNotInGroupError(user, group)
|
||||
|
||||
view_type = view_type_registry.get_by_model(view)
|
||||
allowed_fields = ['name'] + view_type.allowed_fields
|
||||
allowed_fields = ['name', 'filter_type'] + view_type.allowed_fields
|
||||
view = set_allowed_attrs(kwargs, allowed_fields, view)
|
||||
view.save()
|
||||
|
||||
|
@ -154,3 +171,211 @@ class ViewHandler:
|
|||
GridViewFieldOptions.objects.update_or_create(
|
||||
grid_view=grid_view, field_id=field_id, defaults=options
|
||||
)
|
||||
|
||||
def apply_filters(self, view, queryset):
|
||||
"""
|
||||
Applies the view's filter to the given queryset.
|
||||
|
||||
:param view: The view where to fetch the fields from.
|
||||
:type view: View
|
||||
:param queryset: The queryset where the filters 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 filters have 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 a table model is required.')
|
||||
|
||||
q_filters = Q()
|
||||
|
||||
for view_filter in view.viewfilter_set.all():
|
||||
# If the to be filtered 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']
|
||||
model_field = model._meta.get_field(field_name)
|
||||
view_filter_type = view_filter_type_registry.get(view_filter.type)
|
||||
q_filter = view_filter_type.get_filter(
|
||||
field_name,
|
||||
view_filter.value,
|
||||
model_field
|
||||
)
|
||||
|
||||
# Depending on filter type we are going to combine the Q either as AND or
|
||||
# as OR.
|
||||
if view.filter_type == FILTER_TYPE_AND:
|
||||
q_filters &= q_filter
|
||||
elif view.filter_type == FILTER_TYPE_OR:
|
||||
q_filters |= q_filter
|
||||
|
||||
queryset = queryset.filter(q_filters)
|
||||
|
||||
return queryset
|
||||
|
||||
def get_filter(self, user, view_filter_id):
|
||||
"""
|
||||
Returns an existing view filter by the given id.
|
||||
|
||||
:param user: The user on whose behalf the view filter is requested.
|
||||
:type user: User
|
||||
:param view_filter_id: The id of the view filter.
|
||||
:type view_filter_id: int
|
||||
:raises ViewFilterDoesNotExist: The the requested view does not exists.
|
||||
:raises UserNotInGroupError: When the user does not belong to the related group.
|
||||
:return: The requested view filter instance.
|
||||
:type: ViewFilter
|
||||
"""
|
||||
|
||||
try:
|
||||
view_filter = ViewFilter.objects.select_related(
|
||||
'view__table__database__group'
|
||||
).get(
|
||||
pk=view_filter_id
|
||||
)
|
||||
except ViewFilter.DoesNotExist:
|
||||
raise ViewFilterDoesNotExist(
|
||||
f'The view filter with id {view_filter_id} does not exist.'
|
||||
)
|
||||
|
||||
group = view_filter.view.table.database.group
|
||||
if not group.has_user(user):
|
||||
raise UserNotInGroupError(user, group)
|
||||
|
||||
return view_filter
|
||||
|
||||
def create_filter(self, user, view, field, type_name, value):
|
||||
"""
|
||||
Creates a new view filter. The rows that are visible in a view should always
|
||||
be filtered by the related view filters.
|
||||
|
||||
:param user: The user on whose behalf the view filter is created.
|
||||
:type user: User
|
||||
:param view: The view for which the filter needs to be created.
|
||||
:type: View
|
||||
:param field: The field that the filter should compare the value with.
|
||||
:type field: Field
|
||||
:param type_name: The filter type, allowed values are the types in the
|
||||
view_filter_type_registry `equal`, `not_equal` etc.
|
||||
:type type_name: str
|
||||
: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 ViewFilterTypeNotAllowedForField: When the field does not support the
|
||||
filter type.
|
||||
:raises FieldNotInTable: When the provided field does not belong to the
|
||||
provided view's table.
|
||||
:return: The created view filter instance.
|
||||
:rtype: ViewFilter
|
||||
"""
|
||||
|
||||
group = view.table.database.group
|
||||
if not group.has_user(user):
|
||||
raise UserNotInGroupError(user, group)
|
||||
|
||||
# Check if view supports filtering
|
||||
view_type = view_type_registry.get_by_model(view.specific_class)
|
||||
if not view_type.can_filter:
|
||||
raise ViewFilterNotSupported(
|
||||
f'Filtering is not supported for {view_type.type} views.'
|
||||
)
|
||||
|
||||
view_filter_type = view_filter_type_registry.get(type_name)
|
||||
field_type = field_type_registry.get_by_model(field.specific_class)
|
||||
|
||||
# Check if the field is allowed for this filter type.
|
||||
if field_type.type not in view_filter_type.compatible_field_types:
|
||||
raise ViewFilterTypeNotAllowedForField(
|
||||
f'The view filter type {type_name} is not supported for field type '
|
||||
f'{field_type.type}.'
|
||||
)
|
||||
|
||||
# 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}.')
|
||||
|
||||
return ViewFilter.objects.create(
|
||||
view=view,
|
||||
field=field,
|
||||
type=view_filter_type.type,
|
||||
value=value
|
||||
)
|
||||
|
||||
def update_filter(self, user, view_filter, **kwargs):
|
||||
"""
|
||||
Updates the values of an existing view filter.
|
||||
|
||||
:param user: The user on whose behalf the view filter is updated.
|
||||
:type user: User
|
||||
:param view_filter: The view filter that needs to be updated.
|
||||
:type view_filter: ViewFilter
|
||||
:param kwargs: The values that need to be updated, allowed values are
|
||||
`field`, `value` and `type_name`.
|
||||
:type kwargs: dict
|
||||
: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.
|
||||
:return: The updated view filter instance.
|
||||
:rtype: ViewFilter
|
||||
"""
|
||||
|
||||
group = view_filter.view.table.database.group
|
||||
if not group.has_user(user):
|
||||
raise UserNotInGroupError(user, group)
|
||||
|
||||
type_name = kwargs.get('type_name', view_filter.type)
|
||||
field = kwargs.get('field', view_filter.field)
|
||||
value = kwargs.get('value', view_filter.value)
|
||||
view_filter_type = view_filter_type_registry.get(type_name)
|
||||
field_type = field_type_registry.get_by_model(field.specific_class)
|
||||
|
||||
# Check if the field is allowed for this filter type.
|
||||
if field_type.type not in view_filter_type.compatible_field_types:
|
||||
raise ViewFilterTypeNotAllowedForField(
|
||||
f'The view filter type {type_name} is not supported for field type '
|
||||
f'{field_type.type}.'
|
||||
)
|
||||
|
||||
# If the field has changed we need to check if the field belongs to the table.
|
||||
if (
|
||||
field.id != view_filter.field_id and
|
||||
not view_filter.view.table.field_set.filter(id=field.pk).exists()
|
||||
):
|
||||
raise FieldNotInTable(f'The field {field.pk} does not belong to table '
|
||||
f'{view_filter.view.table.id}.')
|
||||
|
||||
view_filter.field = field
|
||||
view_filter.value = value
|
||||
view_filter.type = type_name
|
||||
view_filter.save()
|
||||
|
||||
return view_filter
|
||||
|
||||
def delete_filter(self, user, view_filter):
|
||||
"""
|
||||
Deletes an existing view filter.
|
||||
|
||||
:param user: The user on whose behalf the view filter is deleted.
|
||||
:type user: User
|
||||
:param view_filter: The view filter instance that needs to be deleted.
|
||||
:type view_filter: ViewFilter
|
||||
:raises UserNotInGroupError: When the user does not belong to the related group.
|
||||
"""
|
||||
|
||||
group = view_filter.view.table.database.group
|
||||
if not group.has_user(user):
|
||||
raise UserNotInGroupError(user, group)
|
||||
|
||||
view_filter.delete()
|
||||
|
|
|
@ -5,6 +5,14 @@ from baserow.core.mixins import OrderableMixin, PolymorphicContentTypeMixin
|
|||
from baserow.contrib.database.fields.models import Field
|
||||
|
||||
|
||||
FILTER_TYPE_AND = 'AND'
|
||||
FILTER_TYPE_OR = 'OR'
|
||||
FILTER_TYPES = (
|
||||
(FILTER_TYPE_AND, 'And'),
|
||||
(FILTER_TYPE_OR, 'Or')
|
||||
)
|
||||
|
||||
|
||||
def get_default_view_content_type():
|
||||
return ContentType.objects.get_for_model(View)
|
||||
|
||||
|
@ -19,6 +27,13 @@ class View(OrderableMixin, PolymorphicContentTypeMixin, models.Model):
|
|||
related_name='database_views',
|
||||
on_delete=models.SET(get_default_view_content_type)
|
||||
)
|
||||
filter_type = models.CharField(
|
||||
max_length=3,
|
||||
choices=FILTER_TYPES,
|
||||
default=FILTER_TYPE_AND,
|
||||
help_text='Indicates whether all the rows should apply to all filters (AND) '
|
||||
'or to any filter (OR).'
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ('order',)
|
||||
|
@ -29,6 +44,34 @@ class View(OrderableMixin, PolymorphicContentTypeMixin, models.Model):
|
|||
return cls.get_highest_order_of_queryset(queryset) + 1
|
||||
|
||||
|
||||
class ViewFilter(models.Model):
|
||||
view = models.ForeignKey(
|
||||
View,
|
||||
on_delete=models.CASCADE,
|
||||
help_text='The view to which the filter applies. Each view can have his own '
|
||||
'filters.'
|
||||
)
|
||||
field = models.ForeignKey(
|
||||
'database.Field',
|
||||
on_delete=models.CASCADE,
|
||||
help_text='The field of which the value must be compared to the filter value.'
|
||||
)
|
||||
type = models.CharField(
|
||||
max_length=48,
|
||||
help_text='Indicates how the field\'s value must be compared to the filter\'s '
|
||||
'value. The filter is always in this order `field` `type` `value` '
|
||||
'(example: `field_1` `contains` `Test`).'
|
||||
)
|
||||
value = models.CharField(
|
||||
max_length=255,
|
||||
blank=True,
|
||||
help_text='The filter value that must be compared to the field\'s value.'
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ('id',)
|
||||
|
||||
|
||||
class GridView(View):
|
||||
field_options = models.ManyToManyField(Field, through='GridViewFieldOptions')
|
||||
|
||||
|
|
|
@ -3,7 +3,10 @@ from baserow.core.registry import (
|
|||
CustomFieldsInstanceMixin, CustomFieldsRegistryMixin, APIUrlsRegistryMixin,
|
||||
APIUrlsInstanceMixin
|
||||
)
|
||||
from .exceptions import ViewTypeAlreadyRegistered, ViewTypeDoesNotExist
|
||||
from .exceptions import (
|
||||
ViewTypeAlreadyRegistered, ViewTypeDoesNotExist, ViewFilterTypeAlreadyRegistered,
|
||||
ViewFilterTypeDoesNotExist
|
||||
)
|
||||
|
||||
|
||||
class ViewType(APIUrlsInstanceMixin, CustomFieldsInstanceMixin, ModelInstanceMixin,
|
||||
|
@ -42,6 +45,12 @@ class ViewType(APIUrlsInstanceMixin, CustomFieldsInstanceMixin, ModelInstanceMix
|
|||
view_type_registry.register(ExampleViewType())
|
||||
"""
|
||||
|
||||
can_filter = True
|
||||
"""
|
||||
Defines if the view supports filters. If not, it will not be possible to add filter
|
||||
to the view.
|
||||
"""
|
||||
|
||||
|
||||
class ViewTypeRegistry(APIUrlsRegistryMixin, CustomFieldsRegistryMixin,
|
||||
ModelRegistryMixin, Registry):
|
||||
|
@ -56,6 +65,70 @@ class ViewTypeRegistry(APIUrlsRegistryMixin, CustomFieldsRegistryMixin,
|
|||
already_registered_exception_class = ViewTypeAlreadyRegistered
|
||||
|
||||
|
||||
class ViewFilterType(Instance):
|
||||
"""
|
||||
This abstract class represents a view filter type that can be added to the view
|
||||
filter type registry. It must be extended so customisation can be done. Each view
|
||||
filter type will have its own type names and rules. The get_filter method should
|
||||
be overwritten and should return a Q object that can be applied to the queryset
|
||||
later.
|
||||
|
||||
Example:
|
||||
from baserow.contrib.database.views.registry import (
|
||||
ViewFilterType, view_filter_type_registry
|
||||
)
|
||||
|
||||
class ExampleViewFilterType(ViewFilterType):
|
||||
type = 'equal'
|
||||
compatible_field_types = ['text', 'long_text']
|
||||
|
||||
def get_filter(self, field_name, value):
|
||||
return Q(**{
|
||||
field_name: value
|
||||
})
|
||||
|
||||
view_filter_type_registry.register(ExampleViewFilterType())
|
||||
"""
|
||||
|
||||
compatible_field_types = []
|
||||
"""
|
||||
Defines which field types are compatible with the filter. Only the supported ones
|
||||
can be used in combination with the field.
|
||||
"""
|
||||
|
||||
def get_filter(self, field_name, value, model_field):
|
||||
"""
|
||||
Should return a Q object containing the requested filtering based on the
|
||||
provided arguments.
|
||||
|
||||
:param field_name: The name of the field that needs to be filtered.
|
||||
:type field_name: str
|
||||
:param value: The value that the field must be compared to.
|
||||
:type value: str
|
||||
:param model_field: The field extracted form the model.
|
||||
:type model_field: models.Field
|
||||
:return: The Q object that does the filtering. This will later be added to the
|
||||
queryset in the correct way.
|
||||
:rtype: Q
|
||||
"""
|
||||
|
||||
raise NotImplementedError('Each must have his own get_filter method.')
|
||||
|
||||
|
||||
class ViewFilterTypeRegistry(Registry):
|
||||
"""
|
||||
With the view filter type registry is is possible to register new view filter
|
||||
types. A view filter type is an abstractions that allows different types of
|
||||
filtering for rows in a view. It is possible to add multiple view filters to a view
|
||||
and all the rows must match those filters.
|
||||
"""
|
||||
|
||||
name = 'view_filter'
|
||||
does_not_exist_exception_class = ViewFilterTypeDoesNotExist
|
||||
already_registered_exception_class = ViewFilterTypeAlreadyRegistered
|
||||
|
||||
|
||||
# A default view type registry is created here, this is the one that is used
|
||||
# throughout the whole Baserow application to add a new view type.
|
||||
view_type_registry = ViewTypeRegistry()
|
||||
view_filter_type_registry = ViewFilterTypeRegistry()
|
||||
|
|
274
backend/src/baserow/contrib/database/views/view_filters.py
Normal file
274
backend/src/baserow/contrib/database/views/view_filters.py
Normal file
|
@ -0,0 +1,274 @@
|
|||
from math import floor, ceil
|
||||
from pytz import timezone
|
||||
from decimal import Decimal
|
||||
|
||||
from dateutil import parser
|
||||
from dateutil.parser import ParserError
|
||||
|
||||
from django.db.models import Q, IntegerField, BooleanField
|
||||
from django.db.models.fields.related import ManyToManyField
|
||||
|
||||
from baserow.contrib.database.fields.field_types import (
|
||||
TextFieldType, LongTextFieldType, NumberFieldType, DateFieldType, LinkRowFieldType,
|
||||
BooleanFieldType
|
||||
)
|
||||
|
||||
from .registries import ViewFilterType
|
||||
|
||||
|
||||
class NotViewFilterTypeMixin:
|
||||
def get_filter(self, *args, **kwargs):
|
||||
return ~super().get_filter(*args, **kwargs)
|
||||
|
||||
|
||||
class EqualViewFilterType(ViewFilterType):
|
||||
"""
|
||||
The equal filter compared the field value to the filter value. It must be the same.
|
||||
It is compatible with models.CharField, models.TextField, models.BooleanField ('1'
|
||||
is True), models.IntegerField and models.DecimalField. It will probably also be
|
||||
compatible with other fields, but these have been tested.
|
||||
"""
|
||||
|
||||
type = 'equal'
|
||||
compatible_field_types = [
|
||||
TextFieldType.type,
|
||||
LongTextFieldType.type,
|
||||
NumberFieldType.type,
|
||||
BooleanFieldType.type
|
||||
]
|
||||
|
||||
def get_filter(self, field_name, value, model_field):
|
||||
value = value.strip()
|
||||
|
||||
# If an empty value has been provided we do not want to filter at all.
|
||||
if value == '':
|
||||
return Q()
|
||||
|
||||
# Check if the model_field accepts the value.
|
||||
try:
|
||||
model_field.get_prep_value(value)
|
||||
return Q(**{field_name: value})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return Q()
|
||||
|
||||
|
||||
class NotEqualViewFilterType(NotViewFilterTypeMixin, EqualViewFilterType):
|
||||
type = 'not_equal'
|
||||
|
||||
|
||||
class ContainsViewFilterType(ViewFilterType):
|
||||
"""
|
||||
The contains filter checks if the field value contains the provided filter value.
|
||||
It is compatible with models.CharField and models.TextField.
|
||||
"""
|
||||
|
||||
type = 'contains'
|
||||
compatible_field_types = [
|
||||
TextFieldType.type,
|
||||
LongTextFieldType.type
|
||||
]
|
||||
|
||||
def get_filter(self, field_name, value, model_field):
|
||||
value = value.strip()
|
||||
|
||||
# If an empty value has been provided we do not want to filter at all.
|
||||
if value == '':
|
||||
return Q()
|
||||
|
||||
# Check if the model_field accepts the value.
|
||||
try:
|
||||
model_field.get_prep_value(value)
|
||||
return Q(**{f'{field_name}__icontains': value})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return Q()
|
||||
|
||||
|
||||
class ContainsNotViewFilterType(NotViewFilterTypeMixin, ContainsViewFilterType):
|
||||
type = 'contains_not'
|
||||
|
||||
|
||||
class HigherThanViewFilterType(ViewFilterType):
|
||||
"""
|
||||
The higher than filter checks if the field value is higher than the filter value.
|
||||
It only works if a numeric number is provided. It is at compatible with
|
||||
models.IntegerField and models.DecimalField.
|
||||
"""
|
||||
|
||||
type = 'higher_than'
|
||||
compatible_field_types = [NumberFieldType.type]
|
||||
|
||||
def get_filter(self, field_name, value, model_field):
|
||||
value = value.strip()
|
||||
|
||||
# If an empty value has been provided we do not want to filter at all.
|
||||
if value == '':
|
||||
return Q()
|
||||
|
||||
if isinstance(model_field, IntegerField) and value.find('.') != -1:
|
||||
decimal = Decimal(value)
|
||||
value = floor(decimal)
|
||||
|
||||
# Check if the model_field accepts the value.
|
||||
try:
|
||||
model_field.get_prep_value(value)
|
||||
return Q(**{f'{field_name}__gt': value})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return Q()
|
||||
|
||||
|
||||
class LowerThanViewFilterType(ViewFilterType):
|
||||
"""
|
||||
The lower than filter checks if the field value is lower than the filter value.
|
||||
It only works if a numeric number is provided. It is at compatible with
|
||||
models.IntegerField and models.DecimalField.
|
||||
"""
|
||||
|
||||
type = 'lower_than'
|
||||
compatible_field_types = [NumberFieldType.type]
|
||||
|
||||
def get_filter(self, field_name, value, model_field):
|
||||
value = value.strip()
|
||||
|
||||
# If an empty value has been provided we do not want to filter at all.
|
||||
if value == '':
|
||||
return Q()
|
||||
|
||||
if isinstance(model_field, IntegerField) and value.find('.') != -1:
|
||||
decimal = Decimal(value)
|
||||
value = ceil(decimal)
|
||||
|
||||
# Check if the model_field accepts the value.
|
||||
try:
|
||||
model_field.get_prep_value(value)
|
||||
return Q(**{f'{field_name}__lt': value})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return Q()
|
||||
|
||||
|
||||
class DateEqualViewFilterType(ViewFilterType):
|
||||
"""
|
||||
The date filter parses the provided value as date and checks if the field value is
|
||||
the same date. It only works if a valid ISO date is provided as value and it is
|
||||
only compatible with models.DateField and models.DateTimeField.
|
||||
"""
|
||||
|
||||
type = 'date_equal'
|
||||
compatible_field_types = [DateFieldType.type]
|
||||
|
||||
def get_filter(self, field_name, value, model_field):
|
||||
"""
|
||||
Parses the provided value string and converts it to an aware datetime object.
|
||||
That object will used to make a comparison with the provided field name.
|
||||
"""
|
||||
|
||||
value = value.strip()
|
||||
|
||||
if value == '':
|
||||
return Q()
|
||||
|
||||
utc = timezone('UTC')
|
||||
|
||||
try:
|
||||
datetime = parser.isoparse(value).astimezone(utc)
|
||||
except (ParserError, ValueError):
|
||||
return Q()
|
||||
|
||||
# If the length if string value is lower than 10 characters we know it is only
|
||||
# a date so we can match only on year, month and day level. This way if a date
|
||||
# is provided, but if it tries to compare with a models.DateTimeField it will
|
||||
# still give back accurate results.
|
||||
if len(value) <= 10:
|
||||
return Q(**{
|
||||
f'{field_name}__year': datetime.year,
|
||||
f'{field_name}__month': datetime.month,
|
||||
f'{field_name}__day': datetime.day
|
||||
})
|
||||
else:
|
||||
return Q(**{field_name: datetime})
|
||||
|
||||
|
||||
class DateNotEqualViewFilterType(NotViewFilterTypeMixin, DateEqualViewFilterType):
|
||||
type = 'date_not_equal'
|
||||
|
||||
|
||||
class BooleanViewFilterType(ViewFilterType):
|
||||
"""
|
||||
The boolean filter tries to convert the provided filter value to a boolean and
|
||||
compares that to the field value. If for example '1' is provided then only field
|
||||
value with True are going to be matched. This filter is compatible with
|
||||
models.BooleanField.
|
||||
"""
|
||||
|
||||
type = 'boolean'
|
||||
compatible_field_types = [BooleanFieldType.type]
|
||||
|
||||
def get_filter(self, field_name, value, model_field):
|
||||
value = value.strip().lower()
|
||||
value = value in [
|
||||
'y',
|
||||
't',
|
||||
'o',
|
||||
'yes',
|
||||
'true',
|
||||
'on',
|
||||
'1',
|
||||
]
|
||||
|
||||
# Check if the model_field accepts the value.
|
||||
try:
|
||||
model_field.get_prep_value(value)
|
||||
return Q(**{field_name: value})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return Q()
|
||||
|
||||
|
||||
class EmptyViewFilterType(ViewFilterType):
|
||||
"""
|
||||
The empty filter checks if the field value is empty, this can be '', null,
|
||||
[] or anything. It is compatible with all fields
|
||||
"""
|
||||
|
||||
type = 'empty'
|
||||
compatible_field_types = [
|
||||
TextFieldType.type,
|
||||
LongTextFieldType.type,
|
||||
NumberFieldType.type,
|
||||
BooleanFieldType.type,
|
||||
DateFieldType.type,
|
||||
LinkRowFieldType.type
|
||||
]
|
||||
|
||||
def get_filter(self, field_name, value, model_field):
|
||||
# If the model_field is a ManyToMany field we only have to check if it is None.
|
||||
if isinstance(model_field, ManyToManyField):
|
||||
return Q(**{f'{field_name}': None})
|
||||
|
||||
if isinstance(model_field, BooleanField):
|
||||
return Q(**{f'{field_name}': False})
|
||||
|
||||
q = Q(**{f'{field_name}__isnull': True})
|
||||
q.add(Q(**{f'{field_name}': None}), Q.OR)
|
||||
|
||||
# If the model field accepts an empty string as value we are going to add
|
||||
# that to the or statement.
|
||||
try:
|
||||
model_field.get_prep_value('')
|
||||
q.add(Q(**{f'{field_name}': ''}), Q.OR)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return q
|
||||
|
||||
|
||||
class NotEmptyViewFilterType(NotViewFilterTypeMixin, EmptyViewFilterType):
|
||||
type = 'not_empty'
|
|
@ -68,7 +68,7 @@ class CustomFieldsInstanceMixin:
|
|||
*args, **kwargs
|
||||
)
|
||||
|
||||
def get_serializer(self, model_instance, base_class=None):
|
||||
def get_serializer(self, model_instance, base_class=None, **kwargs):
|
||||
"""
|
||||
Returns an instantiated model serializer based on this type field names and
|
||||
overrides. The provided model instance will be used instantiate the serializer.
|
||||
|
@ -78,13 +78,16 @@ class CustomFieldsInstanceMixin:
|
|||
:param base_class: The base serializer class that must be extended. For example
|
||||
common fields could be stored here.
|
||||
:type base_class: ModelSerializer
|
||||
:param kwargs: The kwargs are used to initialize the serializer class.
|
||||
:type kwargs: dict
|
||||
:return: The instantiated generated model serializer.
|
||||
:rtype: ModelSerializer
|
||||
"""
|
||||
|
||||
model_instance = model_instance.specific
|
||||
serializer_class = self.get_serializer_class(base_class=base_class)
|
||||
return serializer_class(model_instance, context={'instance_type': self})
|
||||
return serializer_class(model_instance, context={'instance_type': self},
|
||||
**kwargs)
|
||||
|
||||
|
||||
class APIUrlsInstanceMixin:
|
||||
|
@ -267,7 +270,7 @@ class ModelRegistryMixin:
|
|||
|
||||
|
||||
class CustomFieldsRegistryMixin:
|
||||
def get_serializer(self, model_instance, base_class=None):
|
||||
def get_serializer(self, model_instance, base_class=None, **kwargs):
|
||||
"""
|
||||
Based on the provided model_instance and base_class a unique serializer
|
||||
containing the correct field type is generated.
|
||||
|
@ -277,6 +280,8 @@ class CustomFieldsRegistryMixin:
|
|||
:param base_class: The base serializer class that must be extended. For example
|
||||
common fields could be stored here.
|
||||
:type base_class: ModelSerializer
|
||||
:param kwargs: The kwargs are used to initialize the serializer class.
|
||||
:type kwargs: dict
|
||||
:raises ValueError: When the `get_by_model` method was not found, which could
|
||||
indicate the `ModelRegistryMixin` has not been mixed in.
|
||||
:return: The instantiated generated model serializer.
|
||||
|
@ -290,7 +295,8 @@ class CustomFieldsRegistryMixin:
|
|||
'extend the ModelRegistryMixin?')
|
||||
|
||||
instance_type = self.get_by_model(model_instance.specific_class)
|
||||
return instance_type.get_serializer(model_instance, base_class=base_class)
|
||||
return instance_type.get_serializer(model_instance, base_class=base_class,
|
||||
**kwargs)
|
||||
|
||||
|
||||
class APIUrlsRegistryMixin:
|
||||
|
|
|
@ -135,6 +135,19 @@ 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')
|
||||
|
||||
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'] == 1
|
||||
assert len(response_json['results']) == 1
|
||||
assert response_json['results'][0]['id'] == row_1.id
|
||||
|
||||
row_1.delete()
|
||||
row_2.delete()
|
||||
row_3.delete()
|
||||
|
|
|
@ -4,7 +4,11 @@ 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 GridView
|
||||
from baserow.contrib.database.views.models import ViewFilter, 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
|
||||
|
@ -15,7 +19,7 @@ def test_list_views(api_client, data_fixture):
|
|||
table_2 = data_fixture.create_database_table()
|
||||
view_1 = data_fixture.create_grid_view(table=table_1, order=1)
|
||||
view_2 = data_fixture.create_grid_view(table=table_1, order=3)
|
||||
view_3 = data_fixture.create_grid_view(table=table_1, order=2)
|
||||
view_3 = data_fixture.create_grid_view(table=table_1, order=2, filter_type='OR')
|
||||
data_fixture.create_grid_view(table=table_2, order=1)
|
||||
|
||||
response = api_client.get(
|
||||
|
@ -30,12 +34,15 @@ def test_list_views(api_client, data_fixture):
|
|||
|
||||
assert response_json[0]['id'] == view_1.id
|
||||
assert response_json[0]['type'] == 'grid'
|
||||
assert response_json[0]['filter_type'] == 'AND'
|
||||
|
||||
assert response_json[1]['id'] == view_3.id
|
||||
assert response_json[1]['type'] == 'grid'
|
||||
assert response_json[1]['filter_type'] == 'OR'
|
||||
|
||||
assert response_json[2]['id'] == view_2.id
|
||||
assert response_json[2]['type'] == 'grid'
|
||||
assert response_json[2]['filter_type'] == 'AND'
|
||||
|
||||
response = api_client.get(
|
||||
reverse('api:database:views:list', kwargs={'table_id': table_2.id}), **{
|
||||
|
@ -54,6 +61,56 @@ def test_list_views(api_client, data_fixture):
|
|||
assert response.json()['error'] == 'ERROR_TABLE_DOES_NOT_EXIST'
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_list_views_including_filters(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)
|
||||
filter_1 = data_fixture.create_view_filter(view=view_1, field=field_1)
|
||||
filter_2 = data_fixture.create_view_filter(view=view_1, field=field_2)
|
||||
filter_3 = data_fixture.create_view_filter(view=view_2, field=field_1)
|
||||
filter_4 = data_fixture.create_view_filter(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 'filters' not in response_json[0]
|
||||
assert 'filters' not in response_json[1]
|
||||
|
||||
response = api_client.get(
|
||||
'{}?includes=filters'.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]['filters']) == 2
|
||||
assert response_json[0]['filters'][0]['id'] == filter_1.id
|
||||
assert response_json[0]['filters'][0]['view'] == view_1.id
|
||||
assert response_json[0]['filters'][0]['field'] == field_1.id
|
||||
assert response_json[0]['filters'][0]['type'] == filter_1.type
|
||||
assert response_json[0]['filters'][0]['value'] == filter_1.value
|
||||
assert response_json[0]['filters'][1]['id'] == filter_2.id
|
||||
assert len(response_json[1]['filters']) == 1
|
||||
assert response_json[1]['filters'][0]['id'] == filter_3.id
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_create_view(api_client, data_fixture):
|
||||
user, token = data_fixture.create_user_and_token()
|
||||
|
@ -96,7 +153,8 @@ def test_create_view(api_client, data_fixture):
|
|||
reverse('api:database:views:list', kwargs={'table_id': table.id}),
|
||||
{
|
||||
'name': 'Test 1',
|
||||
'type': 'grid'
|
||||
'type': 'grid',
|
||||
'filter_type': 'OR'
|
||||
},
|
||||
format='json',
|
||||
HTTP_AUTHORIZATION=f'JWT {token}'
|
||||
|
@ -104,11 +162,33 @@ def test_create_view(api_client, data_fixture):
|
|||
response_json = response.json()
|
||||
assert response.status_code == HTTP_200_OK
|
||||
assert response_json['type'] == 'grid'
|
||||
assert response_json['filter_type'] == 'OR'
|
||||
|
||||
grid = GridView.objects.filter()[0]
|
||||
assert response_json['id'] == grid.id
|
||||
assert response_json['name'] == grid.name
|
||||
assert response_json['order'] == grid.order
|
||||
assert response_json['filter_type'] == grid.filter_type
|
||||
assert 'filters' not in response_json
|
||||
|
||||
response = api_client.post(
|
||||
'{}?includes=filters'.format(
|
||||
reverse('api:database:views:list', kwargs={'table_id': table.id})
|
||||
),
|
||||
{
|
||||
'name': 'Test 2',
|
||||
'type': 'grid',
|
||||
'filter_type': 'AND'
|
||||
},
|
||||
format='json',
|
||||
HTTP_AUTHORIZATION=f'JWT {token}'
|
||||
)
|
||||
response_json = response.json()
|
||||
assert response.status_code == HTTP_200_OK
|
||||
assert response_json['name'] == 'Test 2'
|
||||
assert response_json['type'] == 'grid'
|
||||
assert response_json['filter_type'] == 'AND'
|
||||
assert response_json['filters'] == []
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
|
@ -119,6 +199,7 @@ def test_get_view(api_client, data_fixture):
|
|||
table_2 = data_fixture.create_database_table(user=user_2)
|
||||
view = data_fixture.create_grid_view(table=table)
|
||||
view_2 = data_fixture.create_grid_view(table=table_2)
|
||||
filter = data_fixture.create_view_filter(view=view)
|
||||
|
||||
url = reverse('api:database:views:item', kwargs={'view_id': view_2.id})
|
||||
response = api_client.get(
|
||||
|
@ -148,6 +229,24 @@ def test_get_view(api_client, data_fixture):
|
|||
assert response_json['id'] == view.id
|
||||
assert response_json['type'] == 'grid'
|
||||
assert response_json['table']['id'] == table.id
|
||||
assert response_json['filter_type'] == 'AND'
|
||||
assert 'filters' not in response_json
|
||||
|
||||
url = reverse('api:database:views:item', kwargs={'view_id': view.id})
|
||||
response = api_client.get(
|
||||
'{}?includes=filters'.format(url),
|
||||
format='json',
|
||||
HTTP_AUTHORIZATION=f'JWT {token}'
|
||||
)
|
||||
response_json = response.json()
|
||||
assert response.status_code == HTTP_200_OK
|
||||
assert response_json['id'] == view.id
|
||||
assert len(response_json['filters']) == 1
|
||||
assert response_json['filters'][0]['id'] == filter.id
|
||||
assert response_json['filters'][0]['view'] == filter.view_id
|
||||
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
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
|
@ -200,9 +299,41 @@ def test_update_view(api_client, data_fixture):
|
|||
assert response.status_code == HTTP_200_OK
|
||||
assert response_json['id'] == view.id
|
||||
assert response_json['name'] == 'Test 1'
|
||||
assert response_json['filter_type'] == 'AND'
|
||||
|
||||
view.refresh_from_db()
|
||||
assert view.name == 'Test 1'
|
||||
assert view.filter_type == 'AND'
|
||||
|
||||
url = reverse('api:database:views:item', kwargs={'view_id': view.id})
|
||||
response = api_client.patch(
|
||||
url,
|
||||
{'filter_type': 'OR'},
|
||||
format='json',
|
||||
HTTP_AUTHORIZATION=f'JWT {token}'
|
||||
)
|
||||
response_json = response.json()
|
||||
assert response.status_code == HTTP_200_OK
|
||||
assert response_json['id'] == view.id
|
||||
assert response_json['filter_type'] == 'OR'
|
||||
assert 'filters' not in response_json
|
||||
|
||||
view.refresh_from_db()
|
||||
assert view.filter_type == 'OR'
|
||||
|
||||
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),
|
||||
{'filter_type': 'AND'},
|
||||
format='json',
|
||||
HTTP_AUTHORIZATION=f'JWT {token}'
|
||||
)
|
||||
response_json = response.json()
|
||||
assert response.status_code == HTTP_200_OK
|
||||
assert response_json['id'] == view.id
|
||||
assert response_json['filter_type'] == 'AND'
|
||||
assert response_json['filters'][0]['id'] == filter_1.id
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
|
@ -230,3 +361,451 @@ def test_delete_view(api_client, data_fixture):
|
|||
assert response.status_code == 204
|
||||
|
||||
assert GridView.objects.all().count() == 1
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_list_view_filters(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)
|
||||
filter_1 = data_fixture.create_view_filter(view=view_1, field=field_1)
|
||||
filter_2 = data_fixture.create_view_filter(view=view_1, field=field_2)
|
||||
filter_3 = data_fixture.create_view_filter(view=view_2, field=field_1)
|
||||
filter_4 = data_fixture.create_view_filter(view=view_3, field=field_3)
|
||||
|
||||
response = api_client.get(
|
||||
reverse(
|
||||
'api:database:views:list_filters',
|
||||
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_filters',
|
||||
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_filters',
|
||||
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'] == filter_1.id
|
||||
assert response_json[0]['view'] == view_1.id
|
||||
assert response_json[0]['field'] == field_1.id
|
||||
assert response_json[0]['type'] == filter_1.type
|
||||
assert response_json[0]['value'] == filter_1.value
|
||||
assert response_json[1]['id'] == filter_2.id
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_create_view_filter(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)
|
||||
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_filters', kwargs={'view_id': view_2.id}),
|
||||
{
|
||||
'field': field_2.id,
|
||||
'type': 'equal',
|
||||
'value': 'test'
|
||||
},
|
||||
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_filters', kwargs={'view_id': 99999}),
|
||||
{
|
||||
'field': field_1.id,
|
||||
'type': 'equal',
|
||||
'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_filters', kwargs={'view_id': view_1.id}),
|
||||
{
|
||||
'field': 9999999,
|
||||
'type': 'NOT_EXISTING',
|
||||
'not_value': 'test'
|
||||
},
|
||||
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']['type'][0]['code'] == 'invalid_choice'
|
||||
|
||||
response = api_client.post(
|
||||
reverse('api:database:views:list_filters', kwargs={'view_id': view_1.id}),
|
||||
{
|
||||
'field': field_2.id,
|
||||
'type': 'equal',
|
||||
'value': 'test'
|
||||
},
|
||||
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_filter = False
|
||||
response = api_client.post(
|
||||
reverse('api:database:views:list_filters', kwargs={'view_id': view_1.id}),
|
||||
{
|
||||
'field': field_1.id,
|
||||
'type': 'equal',
|
||||
'value': 'test'
|
||||
},
|
||||
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_FILTER_NOT_SUPPORTED'
|
||||
grid_view_type.can_filter = True
|
||||
|
||||
equal_filter_type = view_filter_type_registry.get('equal')
|
||||
allowed = equal_filter_type.compatible_field_types
|
||||
equal_filter_type.compatible_field_types = []
|
||||
response = api_client.post(
|
||||
reverse('api:database:views:list_filters', kwargs={'view_id': view_1.id}),
|
||||
{
|
||||
'field': field_1.id,
|
||||
'type': 'equal',
|
||||
'value': 'test'
|
||||
},
|
||||
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_FILTER_TYPE_NOT_ALLOWED_FOR_FIELD'
|
||||
equal_filter_type.compatible_field_types = allowed
|
||||
|
||||
response = api_client.post(
|
||||
reverse('api:database:views:list_filters', kwargs={'view_id': view_1.id}),
|
||||
{
|
||||
'field': field_1.id,
|
||||
'type': 'equal',
|
||||
'value': 'test'
|
||||
},
|
||||
format='json',
|
||||
HTTP_AUTHORIZATION=f'JWT {token}'
|
||||
)
|
||||
response_json = response.json()
|
||||
assert response.status_code == HTTP_200_OK
|
||||
assert ViewFilter.objects.all().count() == 1
|
||||
first = ViewFilter.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['type'] == 'equal'
|
||||
assert response_json['value'] == 'test'
|
||||
|
||||
response = api_client.post(
|
||||
reverse('api:database:views:list_filters', kwargs={'view_id': view_1.id}),
|
||||
{
|
||||
'field': field_1.id,
|
||||
'type': 'equal',
|
||||
'value': ''
|
||||
},
|
||||
format='json',
|
||||
HTTP_AUTHORIZATION=f'JWT {token}'
|
||||
)
|
||||
response_json = response.json()
|
||||
assert response.status_code == HTTP_200_OK
|
||||
assert response_json['value'] == ''
|
||||
|
||||
response = api_client.post(
|
||||
reverse('api:database:views:list_filters', kwargs={'view_id': view_1.id}),
|
||||
{
|
||||
'field': field_1.id,
|
||||
'type': 'equal'
|
||||
},
|
||||
format='json',
|
||||
HTTP_AUTHORIZATION=f'JWT {token}'
|
||||
)
|
||||
response_json = response.json()
|
||||
assert response.status_code == HTTP_200_OK
|
||||
assert response_json['value'] == ''
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_get_view_filter(api_client, data_fixture):
|
||||
user, token = data_fixture.create_user_and_token()
|
||||
filter_1 = data_fixture.create_view_filter(user=user, value='test')
|
||||
filter_2 = data_fixture.create_view_filter()
|
||||
|
||||
response = api_client.get(
|
||||
reverse(
|
||||
'api:database:views:filter_item',
|
||||
kwargs={'view_filter_id': filter_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:filter_item',
|
||||
kwargs={'view_filter_id': 99999}
|
||||
),
|
||||
HTTP_AUTHORIZATION=f'JWT {token}'
|
||||
)
|
||||
assert response.status_code == HTTP_404_NOT_FOUND
|
||||
assert response.json()['error'] == 'ERROR_VIEW_FILTER_DOES_NOT_EXIST'
|
||||
|
||||
response = api_client.get(
|
||||
reverse(
|
||||
'api:database:views:filter_item',
|
||||
kwargs={'view_filter_id': filter_1.id}
|
||||
),
|
||||
HTTP_AUTHORIZATION=f'JWT {token}'
|
||||
)
|
||||
response_json = response.json()
|
||||
assert response.status_code == HTTP_200_OK
|
||||
assert ViewFilter.objects.all().count() == 2
|
||||
first = ViewFilter.objects.get(pk=filter_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['type'] == 'equal'
|
||||
assert response_json['value'] == 'test'
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_update_view_filter(api_client, data_fixture):
|
||||
user, token = data_fixture.create_user_and_token()
|
||||
filter_1 = data_fixture.create_view_filter(user=user, value='test')
|
||||
filter_2 = data_fixture.create_view_filter()
|
||||
field_1 = data_fixture.create_text_field(table=filter_1.view.table)
|
||||
field_2 = data_fixture.create_text_field()
|
||||
|
||||
response = api_client.patch(
|
||||
reverse(
|
||||
'api:database:views:filter_item',
|
||||
kwargs={'view_filter_id': filter_2.id}
|
||||
),
|
||||
{'value': 'test'},
|
||||
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:filter_item',
|
||||
kwargs={'view_filter_id': 9999}
|
||||
),
|
||||
{'value': 'test'},
|
||||
format='json',
|
||||
HTTP_AUTHORIZATION=f'JWT {token}'
|
||||
)
|
||||
assert response.status_code == HTTP_404_NOT_FOUND
|
||||
assert response.json()['error'] == 'ERROR_VIEW_FILTER_DOES_NOT_EXIST'
|
||||
|
||||
response = api_client.patch(
|
||||
reverse(
|
||||
'api:database:views:filter_item',
|
||||
kwargs={'view_filter_id': filter_1.id}
|
||||
),
|
||||
{
|
||||
'field': 9999999,
|
||||
'type': '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']['type'][0]['code'] == 'invalid_choice'
|
||||
|
||||
response = api_client.patch(
|
||||
reverse(
|
||||
'api:database:views:filter_item',
|
||||
kwargs={'view_filter_id': filter_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'
|
||||
|
||||
equal_filter_type = view_filter_type_registry.get('not_equal')
|
||||
allowed = equal_filter_type.compatible_field_types
|
||||
equal_filter_type.compatible_field_types = []
|
||||
grid_view_type = view_type_registry.get('grid')
|
||||
grid_view_type.can_filter = False
|
||||
response = api_client.patch(
|
||||
reverse(
|
||||
'api:database:views:filter_item',
|
||||
kwargs={'view_filter_id': filter_1.id}
|
||||
),
|
||||
{'type': 'not_equal'},
|
||||
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_FILTER_TYPE_NOT_ALLOWED_FOR_FIELD'
|
||||
equal_filter_type.compatible_field_types = allowed
|
||||
|
||||
response = api_client.patch(
|
||||
reverse(
|
||||
'api:database:views:filter_item',
|
||||
kwargs={'view_filter_id': filter_1.id}
|
||||
),
|
||||
{
|
||||
'field': field_1.id,
|
||||
'type': 'not_equal',
|
||||
'value': 'test 2'
|
||||
},
|
||||
format='json',
|
||||
HTTP_AUTHORIZATION=f'JWT {token}'
|
||||
)
|
||||
response_json = response.json()
|
||||
assert response.status_code == HTTP_200_OK
|
||||
assert ViewFilter.objects.all().count() == 2
|
||||
first = ViewFilter.objects.get(pk=filter_1.id)
|
||||
assert first.field_id == field_1.id
|
||||
assert first.type == 'not_equal'
|
||||
assert first.value == 'test 2'
|
||||
assert response_json['id'] == first.id
|
||||
assert response_json['view'] == first.view_id
|
||||
assert response_json['field'] == field_1.id
|
||||
assert response_json['type'] == 'not_equal'
|
||||
assert response_json['value'] == 'test 2'
|
||||
|
||||
response = api_client.patch(
|
||||
reverse(
|
||||
'api:database:views:filter_item',
|
||||
kwargs={'view_filter_id': filter_1.id}
|
||||
),
|
||||
{'type': 'equal',},
|
||||
format='json',
|
||||
HTTP_AUTHORIZATION=f'JWT {token}'
|
||||
)
|
||||
response_json = response.json()
|
||||
assert response.status_code == HTTP_200_OK
|
||||
first = ViewFilter.objects.get(pk=filter_1.id)
|
||||
assert first.field_id == field_1.id
|
||||
assert first.type == 'equal'
|
||||
assert first.value == 'test 2'
|
||||
assert response_json['id'] == first.id
|
||||
assert response_json['field'] == field_1.id
|
||||
assert response_json['type'] == 'equal'
|
||||
assert response_json['value'] == 'test 2'
|
||||
|
||||
response = api_client.patch(
|
||||
reverse(
|
||||
'api:database:views:filter_item',
|
||||
kwargs={'view_filter_id': filter_1.id}
|
||||
),
|
||||
{'value': 'test 3',},
|
||||
format='json',
|
||||
HTTP_AUTHORIZATION=f'JWT {token}'
|
||||
)
|
||||
response_json = response.json()
|
||||
assert response.status_code == HTTP_200_OK
|
||||
first = ViewFilter.objects.get(pk=filter_1.id)
|
||||
assert first.field_id == field_1.id
|
||||
assert first.type == 'equal'
|
||||
assert first.value == 'test 3'
|
||||
assert response_json['id'] == first.id
|
||||
assert response_json['view'] == first.view_id
|
||||
assert response_json['field'] == field_1.id
|
||||
assert response_json['type'] == 'equal'
|
||||
assert response_json['value'] == 'test 3'
|
||||
|
||||
response = api_client.patch(
|
||||
reverse(
|
||||
'api:database:views:filter_item',
|
||||
kwargs={'view_filter_id': filter_1.id}
|
||||
),
|
||||
{'value': '',},
|
||||
format='json',
|
||||
HTTP_AUTHORIZATION=f'JWT {token}'
|
||||
)
|
||||
response_json = response.json()
|
||||
assert response.status_code == HTTP_200_OK
|
||||
first = ViewFilter.objects.get(pk=filter_1.id)
|
||||
assert first.value == ''
|
||||
assert response_json['value'] == ''
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_delete_view_filter(api_client, data_fixture):
|
||||
user, token = data_fixture.create_user_and_token()
|
||||
filter_1 = data_fixture.create_view_filter(user=user, value='test')
|
||||
filter_2 = data_fixture.create_view_filter()
|
||||
|
||||
response = api_client.delete(
|
||||
reverse(
|
||||
'api:database:views:filter_item',
|
||||
kwargs={'view_filter_id': filter_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:filter_item',
|
||||
kwargs={'view_filter_id': 9999}
|
||||
),
|
||||
HTTP_AUTHORIZATION=f'JWT {token}'
|
||||
)
|
||||
assert response.status_code == HTTP_404_NOT_FOUND
|
||||
assert response.json()['error'] == 'ERROR_VIEW_FILTER_DOES_NOT_EXIST'
|
||||
|
||||
response = api_client.delete(
|
||||
reverse(
|
||||
'api:database:views:filter_item',
|
||||
kwargs={'view_filter_id': filter_1.id}
|
||||
),
|
||||
HTTP_AUTHORIZATION=f'JWT {token}'
|
||||
)
|
||||
assert response.status_code == 204
|
||||
assert ViewFilter.objects.all().count() == 1
|
||||
|
|
1187
backend/tests/baserow/contrib/database/view/test_view_filters.py
Normal file
1187
backend/tests/baserow/contrib/database/view/test_view_filters.py
Normal file
File diff suppressed because it is too large
Load diff
|
@ -2,10 +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
|
||||
from baserow.contrib.database.views.exceptions import (
|
||||
ViewTypeDoesNotExist, ViewDoesNotExist, UnrelatedFieldError
|
||||
from baserow.contrib.database.views.models import View, GridView, ViewFilter
|
||||
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
|
||||
)
|
||||
from baserow.contrib.database.fields.models import Field
|
||||
from baserow.contrib.database.fields.registries import field_type_registry
|
||||
from baserow.contrib.database.fields.exceptions import FieldNotInTable
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
|
@ -26,14 +34,23 @@ def test_get_view(data_fixture):
|
|||
|
||||
assert view.id == grid.id
|
||||
assert view.name == grid.name
|
||||
assert view.filter_type == 'AND'
|
||||
assert isinstance(view, View)
|
||||
|
||||
view = handler.get_view(user=user, view_id=grid.id, view_model=GridView)
|
||||
|
||||
assert view.id == grid.id
|
||||
assert view.name == grid.name
|
||||
assert view.filter_type == 'AND'
|
||||
assert isinstance(view, GridView)
|
||||
|
||||
# If the error is raised we know for sure that the query has resolved.
|
||||
with pytest.raises(AttributeError):
|
||||
handler.get_view(
|
||||
user=user, view_id=grid.id,
|
||||
base_queryset=View.objects.prefetch_related('UNKNOWN')
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_create_view(data_fixture):
|
||||
|
@ -51,6 +68,7 @@ def test_create_view(data_fixture):
|
|||
assert grid.name == 'Test grid'
|
||||
assert grid.order == 1
|
||||
assert grid.table == table
|
||||
assert grid.filter_type == 'AND'
|
||||
|
||||
with pytest.raises(UserNotInGroupError):
|
||||
handler.create_view(user=user_2, table=table, type_name='grid', name='')
|
||||
|
@ -79,6 +97,11 @@ def test_update_view(data_fixture):
|
|||
grid.refresh_from_db()
|
||||
assert grid.name == 'Test 1'
|
||||
|
||||
handler.update_view(user=user, view=grid, filter_type='OR')
|
||||
|
||||
grid.refresh_from_db()
|
||||
assert grid.filter_type == 'OR'
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_delete_view(data_fixture):
|
||||
|
@ -149,3 +172,286 @@ def test_update_grid_view_field_options(data_fixture):
|
|||
assert options_4[1].field_id == field_2.id
|
||||
assert options_4[2].width == 50
|
||||
assert options_4[2].field_id == field_4.id
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_apply_filters(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}': 'Value 1',
|
||||
f'field_{number_field.id}': 10,
|
||||
f'field_{boolean_field.id}': True
|
||||
})
|
||||
row_2 = model.objects.create(**{
|
||||
f'field_{text_field.id}': 'Entry 2',
|
||||
f'field_{number_field.id}': 20,
|
||||
f'field_{boolean_field.id}': False
|
||||
})
|
||||
row_3 = model.objects.create(**{
|
||||
f'field_{text_field.id}': 'Item 3',
|
||||
f'field_{number_field.id}': 30,
|
||||
f'field_{boolean_field.id}': True
|
||||
})
|
||||
row_4 = model.objects.create(**{
|
||||
f'field_{text_field.id}': '',
|
||||
f'field_{number_field.id}': None,
|
||||
f'field_{boolean_field.id}': False
|
||||
})
|
||||
|
||||
filter_1 = data_fixture.create_view_filter(view=grid_view, field=text_field,
|
||||
type='equal', value='Value 1')
|
||||
|
||||
# Should raise a value error if the modal doesn't have the _field_objects property.
|
||||
with pytest.raises(ValueError):
|
||||
view_handler.apply_filters(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_filters(
|
||||
grid_view,
|
||||
table.get_model(field_ids=[]).objects.all()
|
||||
)
|
||||
|
||||
rows = view_handler.apply_filters(grid_view, model.objects.all())
|
||||
assert len(rows) == 1
|
||||
assert rows[0].id == row_1.id
|
||||
|
||||
filter_2 = data_fixture.create_view_filter(view=grid_view, field=number_field,
|
||||
type='equal', value='20')
|
||||
filter_1.value = 'Entry 2'
|
||||
filter_1.save()
|
||||
rows = view_handler.apply_filters(grid_view, model.objects.all())
|
||||
assert len(rows) == 1
|
||||
assert rows[0].id == row_2.id
|
||||
|
||||
filter_1.value = 'Item 3'
|
||||
filter_1.type = 'equal'
|
||||
filter_1.save()
|
||||
filter_2.value = '20'
|
||||
filter_2.type = 'not_equal'
|
||||
filter_2.save()
|
||||
rows = view_handler.apply_filters(grid_view, model.objects.all())
|
||||
assert len(rows) == 1
|
||||
assert rows[0].id == row_3.id
|
||||
|
||||
grid_view.filter_type = 'OR'
|
||||
filter_1.value = 'Value 1'
|
||||
filter_1.type = 'equal'
|
||||
filter_1.save()
|
||||
filter_2.field = text_field
|
||||
filter_2.value = 'Entry 2'
|
||||
filter_2.type = 'equal'
|
||||
filter_2.save()
|
||||
rows = view_handler.apply_filters(grid_view, model.objects.all())
|
||||
assert len(rows) == 2
|
||||
assert rows[0].id == row_1.id
|
||||
assert rows[1].id == row_2.id
|
||||
|
||||
filter_2.delete()
|
||||
|
||||
grid_view.filter_type = 'AND'
|
||||
filter_1.value = ''
|
||||
filter_1.type = 'empty'
|
||||
filter_1.save()
|
||||
rows = view_handler.apply_filters(grid_view, model.objects.all())
|
||||
assert len(rows) == 1
|
||||
assert rows[0].id == row_4.id
|
||||
|
||||
grid_view.filter_type = 'AND'
|
||||
filter_1.value = ''
|
||||
filter_1.type = 'not_empty'
|
||||
filter_1.save()
|
||||
rows = view_handler.apply_filters(grid_view, model.objects.all())
|
||||
assert len(rows) == 3
|
||||
assert rows[0].id == row_1.id
|
||||
assert rows[1].id == row_2.id
|
||||
assert rows[2].id == row_3.id
|
||||
|
||||
grid_view.filter_type = 'AND'
|
||||
filter_1.value = '1'
|
||||
filter_1.type = 'equal'
|
||||
filter_1.field = boolean_field
|
||||
filter_1.save()
|
||||
rows = view_handler.apply_filters(grid_view, model.objects.all())
|
||||
assert len(rows) == 2
|
||||
assert rows[0].id == row_1.id
|
||||
assert rows[1].id == row_3.id
|
||||
|
||||
grid_view.filter_type = 'AND'
|
||||
filter_1.value = '1'
|
||||
filter_1.type = 'not_equal'
|
||||
filter_1.field = boolean_field
|
||||
filter_1.save()
|
||||
rows = view_handler.apply_filters(grid_view, model.objects.all())
|
||||
assert len(rows) == 2
|
||||
assert rows[0].id == row_2.id
|
||||
assert rows[1].id == row_4.id
|
||||
|
||||
grid_view.filter_type = 'AND'
|
||||
filter_1.value = 'False'
|
||||
filter_1.type = 'equal'
|
||||
filter_1.field = boolean_field
|
||||
filter_1.save()
|
||||
rows = view_handler.apply_filters(grid_view, model.objects.all())
|
||||
assert len(rows) == 2
|
||||
assert rows[0].id == row_2.id
|
||||
assert rows[1].id == row_4.id
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_get_filter(data_fixture):
|
||||
user = data_fixture.create_user()
|
||||
user_2 = data_fixture.create_user()
|
||||
equal_filter = data_fixture.create_view_filter(user=user)
|
||||
|
||||
handler = ViewHandler()
|
||||
|
||||
with pytest.raises(ViewFilterDoesNotExist):
|
||||
handler.get_filter(user=user, view_filter_id=99999)
|
||||
|
||||
with pytest.raises(UserNotInGroupError):
|
||||
handler.get_filter(user=user_2, view_filter_id=equal_filter.id)
|
||||
|
||||
filter = handler.get_filter(user=user, view_filter_id=equal_filter.id)
|
||||
|
||||
assert filter.id == equal_filter.id
|
||||
assert filter.view_id == equal_filter.view_id
|
||||
assert filter.field_id == equal_filter.field_id
|
||||
assert filter.type == equal_filter.type
|
||||
assert filter.value == equal_filter.value
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_create_filter(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)
|
||||
other_field = data_fixture.create_text_field()
|
||||
|
||||
handler = ViewHandler()
|
||||
|
||||
with pytest.raises(UserNotInGroupError):
|
||||
handler.create_filter(user=user_2, view=grid_view, field=text_field,
|
||||
type_name='equal', value='test')
|
||||
|
||||
grid_view_type = view_type_registry.get('grid')
|
||||
grid_view_type.can_filter = False
|
||||
with pytest.raises(ViewFilterNotSupported):
|
||||
handler.create_filter(user=user, view=grid_view, field=text_field,
|
||||
type_name='equal', value='test')
|
||||
grid_view_type.can_filter = True
|
||||
|
||||
with pytest.raises(ViewFilterTypeDoesNotExist):
|
||||
handler.create_filter(user=user, view=grid_view, field=text_field,
|
||||
type_name='NOT_EXISTS', value='test')
|
||||
|
||||
equal_filter_type = view_filter_type_registry.get('equal')
|
||||
allowed = equal_filter_type.compatible_field_types
|
||||
equal_filter_type.compatible_field_types = []
|
||||
with pytest.raises(ViewFilterTypeNotAllowedForField):
|
||||
handler.create_filter(user=user, view=grid_view, field=text_field,
|
||||
type_name='equal', value='test')
|
||||
equal_filter_type.compatible_field_types = allowed
|
||||
|
||||
with pytest.raises(FieldNotInTable):
|
||||
handler.create_filter(user=user, view=grid_view, field=other_field,
|
||||
type_name='equal', value='test')
|
||||
|
||||
view_filter = handler.create_filter(user=user, view=grid_view, field=text_field,
|
||||
type_name='equal', value='test')
|
||||
|
||||
assert ViewFilter.objects.all().count() == 1
|
||||
first = ViewFilter.objects.all().first()
|
||||
|
||||
assert view_filter.id == first.id
|
||||
assert view_filter.view_id == grid_view.id
|
||||
assert view_filter.field_id == text_field.id
|
||||
assert view_filter.type == 'equal'
|
||||
assert view_filter.value == 'test'
|
||||
|
||||
tmp_field = Field.objects.get(pk=text_field.id)
|
||||
view_filter_2 = handler.create_filter(user=user, view=grid_view, field=tmp_field,
|
||||
type_name='equal', value='test')
|
||||
assert view_filter_2.view_id == grid_view.id
|
||||
assert view_filter_2.field_id == text_field.id
|
||||
assert view_filter_2.type == 'equal'
|
||||
assert view_filter_2.value == 'test'
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_update_filter(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)
|
||||
other_field = data_fixture.create_text_field()
|
||||
equal_filter = data_fixture.create_view_filter(
|
||||
view=grid_view,
|
||||
field=long_text_field,
|
||||
type='equal',
|
||||
value='test1'
|
||||
)
|
||||
|
||||
handler = ViewHandler()
|
||||
|
||||
with pytest.raises(UserNotInGroupError):
|
||||
handler.update_filter(user=user_2, view_filter=equal_filter)
|
||||
|
||||
with pytest.raises(ViewFilterTypeDoesNotExist):
|
||||
handler.update_filter(user=user, view_filter=equal_filter,
|
||||
type_name='NOT_EXISTS')
|
||||
|
||||
equal_filter_type = view_filter_type_registry.get('equal')
|
||||
allowed = equal_filter_type.compatible_field_types
|
||||
equal_filter_type.compatible_field_types = []
|
||||
with pytest.raises(ViewFilterTypeNotAllowedForField):
|
||||
handler.update_filter(user=user, view_filter=equal_filter, field=text_field)
|
||||
equal_filter_type.compatible_field_types = allowed
|
||||
|
||||
with pytest.raises(FieldNotInTable):
|
||||
handler.update_filter(user=user, view_filter=equal_filter, field=other_field)
|
||||
|
||||
updated_filter = handler.update_filter(user=user, view_filter=equal_filter,
|
||||
value='test2')
|
||||
assert updated_filter.value == 'test2'
|
||||
assert updated_filter.field_id == long_text_field.id
|
||||
assert updated_filter.type == 'equal'
|
||||
assert updated_filter.view_id == grid_view.id
|
||||
|
||||
updated_filter = handler.update_filter(user=user, view_filter=equal_filter,
|
||||
value='test3', field=text_field,
|
||||
type_name='not_equal')
|
||||
assert updated_filter.value == 'test3'
|
||||
assert updated_filter.field_id == text_field.id
|
||||
assert updated_filter.type == 'not_equal'
|
||||
assert updated_filter.view_id == grid_view.id
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_delete_filter(data_fixture):
|
||||
user = data_fixture.create_user()
|
||||
filter_1 = data_fixture.create_view_filter(user=user)
|
||||
filter_2 = data_fixture.create_view_filter()
|
||||
|
||||
assert ViewFilter.objects.all().count() == 2
|
||||
|
||||
handler = ViewHandler()
|
||||
|
||||
with pytest.raises(UserNotInGroupError):
|
||||
handler.delete_filter(user=user, view_filter=filter_2)
|
||||
|
||||
handler.delete_filter(user=user, view_filter=filter_1)
|
||||
|
||||
assert ViewFilter.objects.all().count() == 1
|
||||
assert ViewFilter.objects.filter(pk=filter_1.pk).count() == 0
|
||||
|
|
19
backend/tests/fixtures/view.py
vendored
19
backend/tests/fixtures/view.py
vendored
|
@ -1,5 +1,7 @@
|
|||
from baserow.contrib.database.fields.models import Field
|
||||
from baserow.contrib.database.views.models import GridView, GridViewFieldOptions
|
||||
from baserow.contrib.database.views.models import (
|
||||
GridView, GridViewFieldOptions, ViewFilter
|
||||
)
|
||||
|
||||
|
||||
class ViewFixtures:
|
||||
|
@ -27,3 +29,18 @@ class ViewFixtures:
|
|||
return GridViewFieldOptions.objects.create(
|
||||
grid_view=grid_view, field=field, **kwargs
|
||||
)
|
||||
|
||||
def create_view_filter(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 'type' not in kwargs:
|
||||
kwargs['type'] = 'equal'
|
||||
|
||||
if 'value' not in kwargs:
|
||||
kwargs['value'] = self.fake.name()
|
||||
|
||||
return ViewFilter.objects.create(**kwargs)
|
||||
|
|
|
@ -9,6 +9,7 @@
|
|||
changed.
|
||||
* Fixed bug where the link row field is not removed from the store when the related
|
||||
table is deleted.
|
||||
* Added filtering of rows per view.
|
||||
|
||||
## Released (2020-09-02)
|
||||
|
||||
|
|
|
@ -50,6 +50,8 @@ Everything related to custom plugin development.
|
|||
type? Here you find how to do that.
|
||||
* [Create database table view](./plugins/view-type.md): Display table data like a
|
||||
calendar, Kanban board or however you like by creating a view type.
|
||||
* [Create database table view filter](./plugins/view-filter-type.md): Filter the rows
|
||||
of a view with custom conditions.
|
||||
* [Create database table field](./plugins/field-type.md): You can store data in a
|
||||
custom format by creating a field type.
|
||||
* [Creata a field converter](./plugins/field-converter.md): Converters can alter a
|
||||
|
|
171
docs/plugins/view-filter-type.md
Normal file
171
docs/plugins/view-filter-type.md
Normal file
|
@ -0,0 +1,171 @@
|
|||
# View filter type
|
||||
|
||||
A view filter can be created by a user to filter the rows of a view. Only the rows
|
||||
that apply to the filters are going to be displayed. There can be many types of filters
|
||||
like equals, contains, lower than, is empty, etc. These filter types can easily be
|
||||
added when creating a plugin.
|
||||
|
||||
## Backend
|
||||
|
||||
We are going to create a really simple `equals` filter. This filter already exists, but
|
||||
because of its simplicity we are going to use it because of example purposes. In your
|
||||
filter class you can define compatible field types. It will only be possible to create
|
||||
a filter in combination with those field types. The `get_filter` method should return
|
||||
a Django `models.Q` object which will automatically be added to the correct queryset.
|
||||
Because the field name is provided we can easily do a `Q(**{field_name: value})`
|
||||
comparison with the provided value.
|
||||
|
||||
plugins/my_baserow_plugin/backend/src/my_baserow_plugin/view_filters.py
|
||||
```python
|
||||
from django.db.models import Q
|
||||
|
||||
from baserow.contrib.database.views.registries import ViewFilterType
|
||||
|
||||
|
||||
class EqualToViewFilterType(ViewFilterType):
|
||||
type = 'equal_to'
|
||||
compatible_field_types = ['text']
|
||||
|
||||
def get_filter(self, field_name, value, model_field):
|
||||
value = value.strip()
|
||||
|
||||
# If an empty value has been provided we do not want to filter at all.
|
||||
if value == '':
|
||||
return Q()
|
||||
|
||||
# Check if the model_field accepts the value.
|
||||
try:
|
||||
model_field.get_prep_value(value)
|
||||
return Q(**{field_name: value})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return Q()
|
||||
```
|
||||
|
||||
Finally we need to register the view filter in the registry.
|
||||
|
||||
plugins/my_baserow_plugin/backend/src/my_baserow_plugin/config.py
|
||||
```python
|
||||
from django.apps import AppConfig
|
||||
|
||||
from baserow.core.registries import plugin_registry
|
||||
from baserow.contrib.database.views.registries import view_filter_type_registry
|
||||
|
||||
|
||||
class PluginNameConfig(AppConfig):
|
||||
name = 'my_baserow_plugin'
|
||||
|
||||
def ready(self):
|
||||
from .plugins import PluginNamePlugin
|
||||
from .view_filters import EqualToViewFilterType
|
||||
|
||||
plugin_registry.register(PluginNamePlugin())
|
||||
view_filter_type_registry.register(EqualToViewFilterType())
|
||||
```
|
||||
|
||||
### API request
|
||||
|
||||
After creating the filter type you can create a filter by making the following API
|
||||
request. Note that you must already have a grid view that contains some fields.
|
||||
|
||||
```
|
||||
POST /api/database/views/{view_id}/filters/
|
||||
Host: api.baserow.io
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"field": {field_id},
|
||||
"type": "equal_to",
|
||||
"value": "Example"
|
||||
}
|
||||
```
|
||||
or
|
||||
```
|
||||
curl -X POST -H 'Content-Type: application/json' -i https://api.baserow.io/api/database/views/{view_id}/filters/ --data '{
|
||||
"field": {field_id},
|
||||
"type": "equal_to",
|
||||
"value": "Example"
|
||||
}'
|
||||
```
|
||||
|
||||
Now that the filter has been created you can refresh your grid view by calling the
|
||||
`list_database_table_grid_view_rows endpoint`. It will now only contain the rows that
|
||||
apply to the filter.
|
||||
|
||||
```
|
||||
GET /api/database/views/grid/{view_id}/
|
||||
Host: api.baserow.io
|
||||
Content-Type: application/json
|
||||
```
|
||||
or
|
||||
```
|
||||
curl -X GET -H 'Content-Type: application/json' -i https://api.baserow.io/api/database/views/grid/{view_id}/'
|
||||
```
|
||||
|
||||
## Web frontend
|
||||
|
||||
This filter also needs to be added to the web frontend, otherwise it does not know the
|
||||
filter exists. You can add the filter by creating a new `ViewFilterType` class and
|
||||
register it with the `viewFilter` registry. the `getName` method should return a string
|
||||
that is visible to the user when choosing the filter. The `getInputComponent` method
|
||||
handles the user input of the value. By default no input is shown. The
|
||||
`getCompatibleFieldTypes` should return a list of field type names that are compatible
|
||||
with the filter. The last method is named `matches` and we use this to check if a value
|
||||
is compatible applies to the filter in real time.
|
||||
|
||||
It is really unfortunate that we need to have the same code in two places, but because
|
||||
the filtering needs to happen at the backend and the real time comparison needs to
|
||||
happen at the web frontend we do need this.
|
||||
|
||||
plugins/my_baserow_plugin/web-frontend/viewTypes.js
|
||||
```javascript
|
||||
import { ViewFilterType } from '@baserow/modules/database/viewFilters'
|
||||
import ViewFilterTypeText from '@baserow/modules/database/components/view/ViewFilterTypeText'
|
||||
|
||||
export class EqualViewFilterType extends ViewFilterType {
|
||||
static getType() {
|
||||
return 'equal_to'
|
||||
}
|
||||
|
||||
getName() {
|
||||
return 'is 2'
|
||||
}
|
||||
|
||||
getInputComponent() {
|
||||
// The component that handles the value input, in this case we use the existing
|
||||
// text input, but it is also possible to create a custom component. It should
|
||||
// follow v-model principle.
|
||||
return ViewFilterTypeText
|
||||
}
|
||||
|
||||
getCompatibleFieldTypes() {
|
||||
return ['text']
|
||||
}
|
||||
|
||||
matches(rowValue, filterValue) {
|
||||
if (rowValue === null) {
|
||||
rowValue = ''
|
||||
}
|
||||
|
||||
rowValue = rowValue.toString().toLowerCase().trim()
|
||||
filterValue = filterValue.toString().toLowerCase().trim()
|
||||
return filterValue === '' || rowValue === filterValue
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
plugins/my_baserow_plugin/web-frontend/plugin.js
|
||||
```javascript
|
||||
import { PluginNamePlugin } from '@my-baserow-plugin/plugins'
|
||||
import { EqualViewFilterType } from '@my-baserow-plugin/viewFilters'
|
||||
|
||||
export default ({ store, app }) => {
|
||||
app.$registry.register('plugin', new PluginNamePlugin())
|
||||
app.$registry.register('viewFilter', new EqualViewFilterType())
|
||||
}
|
||||
```
|
||||
|
||||
Once you have added this code and you add a new filter to a view and you also selected
|
||||
a text field you should be able to select the `is 2` filter. It should also be possible
|
||||
to provide a text value to compare the field value with.
|
|
@ -34,3 +34,4 @@
|
|||
@import 'time_select';
|
||||
@import 'settings';
|
||||
@import 'select_row_modal';
|
||||
@import 'filters';
|
||||
|
|
|
@ -11,7 +11,7 @@
|
|||
width: 100%;
|
||||
border: 1px solid $color-neutral-400;
|
||||
border-radius: 3px;
|
||||
padding: 0 40px 0 12px;
|
||||
padding: 0 32px 0 12px;
|
||||
color: $color-primary-900;
|
||||
|
||||
@include fixed-height(32px, 13px);
|
||||
|
@ -48,4 +48,8 @@
|
|||
.select__item.active:hover::after {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.dropdown--floating & {
|
||||
right: auto;
|
||||
}
|
||||
}
|
||||
|
|
106
web-frontend/modules/core/assets/scss/components/filters.scss
Normal file
106
web-frontend/modules/core/assets/scss/components/filters.scss
Normal file
|
@ -0,0 +1,106 @@
|
|||
.filters {
|
||||
padding: 12px;
|
||||
|
||||
.dropdown__selected {
|
||||
@extend %ellipsis;
|
||||
}
|
||||
}
|
||||
|
||||
.filters__none {
|
||||
padding: 4px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.filters__none-title {
|
||||
font-weight: 700;
|
||||
font-size: 14px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.filters__none-description {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.filters__item {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 6px 10px 6px 0;
|
||||
border-radius: 3px;
|
||||
background-color: $color-neutral-100;
|
||||
|
||||
&:not(:last-child) {
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
&.filters__item--loading {
|
||||
padding-left: 32px;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
margin-top: -7px;
|
||||
|
||||
@include loading(14px);
|
||||
@include absolute(50%, auto, 0, 10px);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.filters__remove {
|
||||
color: $color-primary-900;
|
||||
line-height: 30px;
|
||||
text-align: center;
|
||||
width: 32px;
|
||||
|
||||
&:hover {
|
||||
text-decoration: none;
|
||||
color: $color-neutral-500;
|
||||
}
|
||||
|
||||
.filters__item--loading & {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.filters__operator {
|
||||
flex: 0 0 72px;
|
||||
width: 72px;
|
||||
margin-right: 10px;
|
||||
|
||||
span {
|
||||
padding-left: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.filters__field {
|
||||
margin-right: 10px;
|
||||
|
||||
@include filter-dropdown-width(100px);
|
||||
}
|
||||
|
||||
.filters__type {
|
||||
margin-right: 10px;
|
||||
|
||||
@include filter-dropdown-width(100px);
|
||||
}
|
||||
|
||||
.filters__value {
|
||||
flex: 0 0;
|
||||
}
|
||||
|
||||
.filters__value-input {
|
||||
width: 130px;
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
line-height: 30px;
|
||||
}
|
||||
|
||||
.filters__add {
|
||||
display: inline-block;
|
||||
margin: 12px 0 6px 4px;
|
||||
|
||||
&:hover {
|
||||
text-decoration: none;
|
||||
color: $color-primary-900;
|
||||
}
|
||||
}
|
|
@ -1,10 +1,45 @@
|
|||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
justify-content: left;
|
||||
background-color: $white;
|
||||
border-bottom: 2px solid $color-neutral-200;
|
||||
}
|
||||
|
||||
@keyframes header-loading-loop {
|
||||
0% {
|
||||
left: -32px;
|
||||
}
|
||||
|
||||
100% {
|
||||
left: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.header__loading {
|
||||
position: absolute;
|
||||
left: 20px;
|
||||
top: 50%;
|
||||
width: 140px;
|
||||
height: 12px;
|
||||
margin-top: -6px;
|
||||
border-radius: 6px;
|
||||
background-color: $color-neutral-100;
|
||||
overflow: hidden;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 32px;
|
||||
background-color: $color-neutral-50;
|
||||
transform: skewX(-10deg);
|
||||
animation: header-loading-loop infinite 1000ms;
|
||||
animation-timing-function: cubic-bezier(0.785, 0.135, 0.15, 0.86);
|
||||
}
|
||||
}
|
||||
|
||||
.header__undo-redo a {
|
||||
@extend %first-last-no-margin;
|
||||
|
||||
|
@ -52,14 +87,22 @@
|
|||
text-decoration: none;
|
||||
background-color: $color-neutral-100;
|
||||
}
|
||||
|
||||
&.active {
|
||||
background-color: $color-success-200;
|
||||
}
|
||||
}
|
||||
|
||||
.header__filter-icon {
|
||||
color: $color-primary-500;
|
||||
color: $color-primary-900;
|
||||
margin-right: 4px;
|
||||
font-size: 14px;
|
||||
|
||||
&.header-filter-icon-no-choice {
|
||||
&.header-filter-icon--view {
|
||||
color: $color-primary-500;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
&.header-filter-icon--no-choice {
|
||||
color: $color-neutral-200;
|
||||
}
|
||||
}
|
||||
|
@ -69,7 +112,7 @@
|
|||
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: auto 0;
|
||||
margin: auto 0 auto auto;
|
||||
|
||||
li {
|
||||
float: left;
|
||||
|
|
|
@ -1,3 +1,21 @@
|
|||
.loading {
|
||||
@include loading();
|
||||
}
|
||||
|
||||
.loading-overlay {
|
||||
content: "";
|
||||
background-color: rgba(0, 0, 0, 0.1);
|
||||
z-index: 1;
|
||||
|
||||
@include absolute(0, 0, 0, 0);
|
||||
|
||||
&::after {
|
||||
content: "";
|
||||
margin-left: -7px;
|
||||
margin-top: -7px;
|
||||
z-index: 1;
|
||||
|
||||
@include loading(14px);
|
||||
@include absolute(50%, 0, auto, 50%);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -89,6 +89,10 @@
|
|||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
&.disabled:hover {
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
.select__item-link {
|
||||
|
@ -101,6 +105,14 @@
|
|||
&:hover {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.select__item.disabled & {
|
||||
color: $color-neutral-400;
|
||||
|
||||
&:hover {
|
||||
cursor: inherit;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.select__item-icon {
|
||||
|
|
|
@ -148,6 +148,25 @@
|
|||
}
|
||||
}
|
||||
|
||||
.grid-view__filtered-no-results {
|
||||
@include absolute(50%, auto, auto, 50%);
|
||||
|
||||
margin: -33px 0 0 -150px;
|
||||
width: 300px;
|
||||
height: 66px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.grid-view__filtered-no-results-icon {
|
||||
font-size: 26px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.grid-view__filtered-no-results-content {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.grid-view__rows {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
|
@ -159,6 +178,25 @@
|
|||
|
||||
position: relative;
|
||||
height: 32px + 1px;
|
||||
|
||||
&.grid-view__row--filter-warning::before {
|
||||
@include absolute(-2px, -2px, -2px, -2px);
|
||||
|
||||
content: '';
|
||||
z-index: 2;
|
||||
border: 2px solid $color-warning-500;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
.grid-view__row-filter-warning {
|
||||
@include absolute(auto, auto, -20px, 0);
|
||||
@include fixed-height(20px, 12px);
|
||||
|
||||
z-index: 1;
|
||||
background-color: $color-warning-500;
|
||||
color: $white;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
// Because the width of a column can be adjusted it is specified in the html file.
|
||||
|
@ -183,6 +221,13 @@
|
|||
.grid-view__left & {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
&.grid-view__column--filtered::after {
|
||||
content: '';
|
||||
background-color: rgba($color-success-100, 0.5);
|
||||
|
||||
@include absolute(0, 0, 0, 0);
|
||||
}
|
||||
}
|
||||
|
||||
.grid-view__row-info {
|
||||
|
@ -293,6 +338,8 @@
|
|||
}
|
||||
|
||||
.grid-view__description {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
padding: 0 30px;
|
||||
line-height: 32px;
|
||||
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
@import 'helpers';
|
||||
@import 'alert';
|
||||
@import 'button';
|
||||
@import 'filters';
|
||||
|
|
|
@ -0,0 +1,8 @@
|
|||
@mixin filter-dropdown-width($width) {
|
||||
flex: 0 0 $width;
|
||||
|
||||
.dropdown,
|
||||
.dropdown__selected {
|
||||
width: $width;
|
||||
}
|
||||
}
|
|
@ -105,10 +105,13 @@ export default {
|
|||
/**
|
||||
* Hide the context menu and make sure the body event is removed.
|
||||
*/
|
||||
hide() {
|
||||
hide(emit = true) {
|
||||
this.opener = null
|
||||
this.open = false
|
||||
this.$emit('hidden')
|
||||
|
||||
if (emit) {
|
||||
this.$emit('hidden')
|
||||
}
|
||||
|
||||
document.body.removeEventListener('click', this.$el.clickOutsideEvent)
|
||||
},
|
||||
|
|
|
@ -15,7 +15,7 @@
|
|||
<i class="dropdown__toggle-icon fas fa-caret-down"></i>
|
||||
</a>
|
||||
<div class="dropdown__items" :class="{ hidden: !open }">
|
||||
<div class="select__search">
|
||||
<div v-if="showSearch" class="select__search">
|
||||
<i class="select__search-icon fas fa-search"></i>
|
||||
<input
|
||||
ref="search"
|
||||
|
@ -26,7 +26,7 @@
|
|||
@keyup="search(query)"
|
||||
/>
|
||||
</div>
|
||||
<ul class="select__items">
|
||||
<ul ref="items" class="select__items">
|
||||
<slot></slot>
|
||||
</ul>
|
||||
</div>
|
||||
|
@ -50,6 +50,20 @@ export default {
|
|||
required: false,
|
||||
default: 'Search',
|
||||
},
|
||||
showSearch: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: true,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
loaded: false,
|
||||
open: false,
|
||||
name: null,
|
||||
icon: null,
|
||||
query: '',
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
selectedName() {
|
||||
|
@ -59,13 +73,19 @@ export default {
|
|||
return this.getSelectedProperty(this.value, 'icon')
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
open: false,
|
||||
name: null,
|
||||
icon: null,
|
||||
query: '',
|
||||
}
|
||||
watch: {
|
||||
value() {
|
||||
this.$nextTick(() => {
|
||||
// When the value changes we want to forcefully reload the selectName and
|
||||
// selectedIcon a little bit later because the children might have changed.
|
||||
this.forceRefreshSelectedValue()
|
||||
})
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
// When the component is mounted we want to forcefully reload the selectedName and
|
||||
// selectedIcon.
|
||||
this.forceRefreshSelectedValue()
|
||||
},
|
||||
methods: {
|
||||
/**
|
||||
|
@ -82,9 +102,19 @@ export default {
|
|||
this.open = true
|
||||
this.$emit('show')
|
||||
|
||||
// We have to wait for the input to be visible before we can focus.
|
||||
this.$nextTick(() => {
|
||||
this.$refs.search.focus()
|
||||
// We have to wait for the input to be visible before we can focus.
|
||||
this.showSearch && this.$refs.search.focus()
|
||||
|
||||
// Scroll to the selected child.
|
||||
this.$children.forEach((child) => {
|
||||
if (child.value === this.value) {
|
||||
this.$refs.items.scrollTop =
|
||||
child.$el.offsetTop -
|
||||
child.$el.clientHeight -
|
||||
Math.round(this.$refs.items.clientHeight / 2)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// If the user clicks outside the dropdown while the list of choices of open we
|
||||
|
@ -144,6 +174,18 @@ export default {
|
|||
}
|
||||
return ''
|
||||
},
|
||||
/**
|
||||
* A nasty hack, but in some cases the $children have not yet been loaded when the
|
||||
* `selectName` and `selectIcon` are computed. This would result in an empty
|
||||
* initial value of the Dropdown because the correct value can't be extracted from
|
||||
* the DropdownItem. With this hack we force the computed properties to recompute
|
||||
* when the component is mounted. At this moment the $children have been added.
|
||||
*/
|
||||
forceRefreshSelectedValue() {
|
||||
this._computedWatchers.selectedName.run()
|
||||
this._computedWatchers.selectedIcon.run()
|
||||
this.$forceUpdate()
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
|
|
@ -4,9 +4,10 @@
|
|||
:class="{
|
||||
hidden: !isVisible(query),
|
||||
active: isActive(value),
|
||||
disabled: disabled,
|
||||
}"
|
||||
>
|
||||
<a class="select__item-link" @click="select(value)">
|
||||
<a class="select__item-link" @click="select(value, disabled)">
|
||||
<i
|
||||
v-if="icon"
|
||||
class="select__item-icon fas fa-fw"
|
||||
|
@ -34,6 +35,11 @@ export default {
|
|||
required: false,
|
||||
default: null,
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
|
@ -41,8 +47,10 @@ export default {
|
|||
}
|
||||
},
|
||||
methods: {
|
||||
select(value) {
|
||||
this.$parent.select(value)
|
||||
select(value, disabled) {
|
||||
if (!disabled) {
|
||||
this.$parent.select(value)
|
||||
}
|
||||
},
|
||||
search(query) {
|
||||
this.query = query
|
||||
|
|
|
@ -27,6 +27,11 @@ export default {
|
|||
newValue: '',
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
value(value) {
|
||||
this.set(value)
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.set(this.value)
|
||||
},
|
||||
|
|
|
@ -70,7 +70,7 @@ export default {
|
|||
/**
|
||||
* Hide the modal.
|
||||
*/
|
||||
hide() {
|
||||
hide(emit = true) {
|
||||
// This is a temporary fix. What happens is the model is opened by a context menu
|
||||
// item and the user closes the modal, the element is first deleted and then the
|
||||
// click outside event of the context is fired. It then checks if the click was
|
||||
|
@ -80,7 +80,11 @@ export default {
|
|||
setTimeout(() => {
|
||||
this.open = false
|
||||
})
|
||||
this.$emit('hidden')
|
||||
|
||||
if (emit) {
|
||||
this.$emit('hidden')
|
||||
}
|
||||
|
||||
window.removeEventListener('keyup', this.keyup)
|
||||
},
|
||||
/**
|
||||
|
@ -93,7 +97,7 @@ export default {
|
|||
}
|
||||
},
|
||||
/**
|
||||
*
|
||||
* When the escape key is pressed the modal needs to be hidden.
|
||||
*/
|
||||
keyup(event) {
|
||||
if (event.keyCode === 27) {
|
||||
|
|
|
@ -66,7 +66,7 @@ export default {
|
|||
* removed and that the element is removed from the body.
|
||||
*/
|
||||
destroyed() {
|
||||
this.hide()
|
||||
this.hide(false)
|
||||
|
||||
if (this.$el.parentNode) {
|
||||
this.$el.parentNode.removeChild(this.$el)
|
||||
|
|
|
@ -123,6 +123,73 @@
|
|||
value="choice-3"
|
||||
icon="database"
|
||||
></DropdownItem>
|
||||
<DropdownItem
|
||||
name="Choice 4"
|
||||
value="choice-4"
|
||||
icon="times"
|
||||
:disabled="true"
|
||||
></DropdownItem>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="control">
|
||||
<label class="control__label">Dropdown</label>
|
||||
<div class="control__elements">
|
||||
value: {{ dropdown }}
|
||||
<br />
|
||||
<br />
|
||||
<div style="width: 200px;">
|
||||
<Dropdown v-model="dropdown" :show-search="false">
|
||||
<DropdownItem name="Choice 1" value="choice-1"></DropdownItem>
|
||||
<DropdownItem
|
||||
name="Choice 2"
|
||||
value="choice-2"
|
||||
icon="pencil"
|
||||
></DropdownItem>
|
||||
<DropdownItem
|
||||
name="Choice 3"
|
||||
value="choice-3"
|
||||
icon="database"
|
||||
></DropdownItem>
|
||||
<DropdownItem
|
||||
name="Choice 4"
|
||||
value="choice-4"
|
||||
icon="times"
|
||||
:disabled="true"
|
||||
></DropdownItem>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="control">
|
||||
<label class="control__label">Very long dropdown</label>
|
||||
<div class="control__elements">
|
||||
<div style="width: 200px;">
|
||||
<Dropdown v-model="longDropdown">
|
||||
<DropdownItem
|
||||
v-for="i in [
|
||||
0,
|
||||
1,
|
||||
2,
|
||||
3,
|
||||
4,
|
||||
5,
|
||||
6,
|
||||
7,
|
||||
8,
|
||||
9,
|
||||
10,
|
||||
11,
|
||||
12,
|
||||
13,
|
||||
14,
|
||||
15,
|
||||
]"
|
||||
:key="i"
|
||||
:name="'Choice ' + i"
|
||||
:value="i"
|
||||
></DropdownItem>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -757,6 +824,7 @@ export default {
|
|||
return {
|
||||
checkbox: false,
|
||||
dropdown: '',
|
||||
longDropdown: '0',
|
||||
date: '',
|
||||
}
|
||||
},
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
import Vue from 'vue'
|
||||
|
||||
import { Registry } from '@baserow/modules/core/registry'
|
||||
|
||||
import applicationStore from '@baserow/modules/core/store/application'
|
||||
|
@ -7,6 +9,8 @@ import notificationStore from '@baserow/modules/core/store/notification'
|
|||
import sidebarStore from '@baserow/modules/core/store/sidebar'
|
||||
|
||||
export default ({ store, app }, inject) => {
|
||||
inject('bus', new Vue())
|
||||
|
||||
const registry = new Registry()
|
||||
registry.registerNamespace('plugin')
|
||||
registry.registerNamespace('application')
|
||||
|
|
|
@ -24,6 +24,7 @@
|
|||
@update="$refs.context.hide()"
|
||||
></UpdateFieldContext>
|
||||
</li>
|
||||
<slot></slot>
|
||||
<li v-if="!field.primary">
|
||||
<a @click="deleteField(field)">
|
||||
<i class="context__menu-icon fas fa-fw fa-trash"></i>
|
||||
|
|
|
@ -25,7 +25,7 @@ import FieldForm from '@baserow/modules/database/components/field/FieldForm'
|
|||
import { notifyIf } from '@baserow/modules/core/utils/error'
|
||||
|
||||
export default {
|
||||
name: 'CreateFieldContext',
|
||||
name: 'UpdateFieldContext',
|
||||
components: { FieldForm },
|
||||
mixins: [context],
|
||||
props: {
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
<template>
|
||||
<Modal>
|
||||
<Modal @hidden="$emit('hidden', { row })">
|
||||
<h2 v-if="primary !== undefined" class="box__title">
|
||||
{{ getHeading(primary, row) }}
|
||||
</h2>
|
||||
|
|
|
@ -1,21 +1,12 @@
|
|||
<template>
|
||||
<li class="tree__sub" :class="{ active: table._.selected }">
|
||||
<nuxt-link
|
||||
:to="{
|
||||
name: 'database-table',
|
||||
params: {
|
||||
databaseId: database.id,
|
||||
tableId: table.id,
|
||||
},
|
||||
}"
|
||||
class="tree__sub-link"
|
||||
>
|
||||
<a class="tree__sub-link" @click.prevent="selectTable(database, table)">
|
||||
<Editable
|
||||
ref="rename"
|
||||
:value="table.name"
|
||||
@change="renameTable(database, table, $event)"
|
||||
></Editable>
|
||||
</nuxt-link>
|
||||
</a>
|
||||
<a
|
||||
v-show="!database._.loading"
|
||||
class="tree__options"
|
||||
|
@ -65,6 +56,25 @@ export default {
|
|||
value,
|
||||
})
|
||||
},
|
||||
selectTable(database, table) {
|
||||
this.setLoading(database, true)
|
||||
|
||||
this.$nuxt.$router.push(
|
||||
{
|
||||
name: 'database-table',
|
||||
params: {
|
||||
databaseId: database.id,
|
||||
tableId: table.id,
|
||||
},
|
||||
},
|
||||
() => {
|
||||
this.setLoading(database, false)
|
||||
},
|
||||
() => {
|
||||
this.setLoading(database, false)
|
||||
}
|
||||
)
|
||||
},
|
||||
async deleteTable(database, table) {
|
||||
this.$refs.context.hide()
|
||||
this.setLoading(database, true)
|
||||
|
|
59
web-frontend/modules/database/components/view/ViewFilter.vue
Normal file
59
web-frontend/modules/database/components/view/ViewFilter.vue
Normal file
|
@ -0,0 +1,59 @@
|
|||
<template>
|
||||
<div>
|
||||
<a
|
||||
ref="contextLink"
|
||||
class="header__filter-link"
|
||||
:class="{
|
||||
active: view.filters.length > 0,
|
||||
}"
|
||||
@click="$refs.context.toggle($refs.contextLink, 'bottom', 'left', 4)"
|
||||
>
|
||||
<i class="header__filter-icon fas fa-filter"></i>
|
||||
Filter
|
||||
</a>
|
||||
<ViewFilterContext
|
||||
ref="context"
|
||||
:view="view"
|
||||
:fields="fields"
|
||||
:primary="primary"
|
||||
@changed="$emit('changed')"
|
||||
></ViewFilterContext>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import ViewFilterContext from '@baserow/modules/database/components/view/ViewFilterContext'
|
||||
|
||||
export default {
|
||||
name: 'ViewFilter',
|
||||
components: { ViewFilterContext },
|
||||
props: {
|
||||
primary: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
fields: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
view: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
beforeMount() {
|
||||
this.$bus.$on('view-filter-created', this.filterCreated)
|
||||
},
|
||||
beforeDestroy() {
|
||||
this.$bus.$off('view-filter-created', this.filterCreated)
|
||||
},
|
||||
methods: {
|
||||
/**
|
||||
* When a filter is created via an outside source we want to show the context menu.
|
||||
*/
|
||||
filterCreated() {
|
||||
this.$refs.context.show(this.$refs.contextLink, 'bottom', 'left', 4)
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
|
@ -0,0 +1,274 @@
|
|||
<template>
|
||||
<Context ref="context" class="filters">
|
||||
<div v-show="view.filters.length === 0">
|
||||
<div class="filters__none">
|
||||
<div class="filters__none-title">You have not yet created a filter</div>
|
||||
<div class="filters__none-description">
|
||||
Filters allow you to show rows that apply to your conditions.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-for="(filter, index) in view.filters"
|
||||
:key="filter.id"
|
||||
class="filters__item"
|
||||
:class="{
|
||||
'filters__item--loading':
|
||||
filter._.loading || (index === 1 && view._.loading),
|
||||
}"
|
||||
>
|
||||
<a class="filters__remove" @click.prevent="deleteFilter(filter)">
|
||||
<i class="fas fa-times"></i>
|
||||
</a>
|
||||
<div class="filters__operator">
|
||||
<span v-if="index === 0">Where</span>
|
||||
<Dropdown
|
||||
v-if="index === 1"
|
||||
:value="view.filter_type"
|
||||
:show-search="false"
|
||||
class="dropdown--floating"
|
||||
@input="updateType(view, filter, $event)"
|
||||
>
|
||||
<DropdownItem name="And" value="AND"></DropdownItem>
|
||||
<DropdownItem name="Or" value="OR"></DropdownItem>
|
||||
</Dropdown>
|
||||
<span v-if="index > 1 && view.filter_type === 'AND'">And</span>
|
||||
<span v-if="index > 1 && view.filter_type === 'OR'">Or</span>
|
||||
</div>
|
||||
<div class="filters__field">
|
||||
<Dropdown
|
||||
:value="filter.field"
|
||||
class="dropdown--floating"
|
||||
@input="updateFilter(filter, { field: $event })"
|
||||
>
|
||||
<DropdownItem
|
||||
:key="'filter-field-' + primary.id + '-' + primary.id"
|
||||
:name="primary.name"
|
||||
:value="primary.id"
|
||||
:disabled="hasCompatibleFilterTypes(primary, filterTypes)"
|
||||
></DropdownItem>
|
||||
<DropdownItem
|
||||
v-for="field in fields"
|
||||
:key="'filter-field-' + filter.id + '-' + field.id"
|
||||
:name="field.name"
|
||||
:value="field.id"
|
||||
:disabled="hasCompatibleFilterTypes(field, filterTypes)"
|
||||
></DropdownItem>
|
||||
</Dropdown>
|
||||
</div>
|
||||
<div class="filters__type">
|
||||
<Dropdown
|
||||
:value="filter.type"
|
||||
class="dropdown--floating"
|
||||
@input="updateFilter(filter, { type: $event })"
|
||||
>
|
||||
<DropdownItem
|
||||
v-for="filterType in allowedFilters(
|
||||
filterTypes,
|
||||
primary,
|
||||
fields,
|
||||
filter.field
|
||||
)"
|
||||
:key="filterType.type"
|
||||
:name="filterType.name"
|
||||
:value="filterType.type"
|
||||
></DropdownItem>
|
||||
</Dropdown>
|
||||
</div>
|
||||
<div class="filters__value">
|
||||
<component
|
||||
:is="getInputComponent(filter.type)"
|
||||
:ref="'filter-' + filter.id + '-value'"
|
||||
:value="filter.value"
|
||||
:field-id="filter.field"
|
||||
:fields="fields"
|
||||
:primary="primary"
|
||||
@input="updateFilter(filter, { value: $event })"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<a class="filters__add" @click.prevent="addFilter()">
|
||||
<i class="fas fa-plus"></i>
|
||||
add filter
|
||||
</a>
|
||||
</Context>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { notifyIf } from '@baserow/modules/core/utils/error'
|
||||
import context from '@baserow/modules/core/mixins/context'
|
||||
|
||||
export default {
|
||||
name: 'ViewFilterContext',
|
||||
mixins: [context],
|
||||
props: {
|
||||
primary: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
fields: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
view: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
filterTypes() {
|
||||
return this.$registry.getAll('viewFilter')
|
||||
},
|
||||
},
|
||||
beforeMount() {
|
||||
this.$bus.$on('view-filter-created', this.filterCreated)
|
||||
},
|
||||
beforeDestroy() {
|
||||
this.$bus.$off('view-filter-created', this.filterCreated)
|
||||
},
|
||||
methods: {
|
||||
/**
|
||||
* When the filter has been created we want to focus on the value.
|
||||
*/
|
||||
filterCreated({ filter }) {
|
||||
this.$nextTick(() => {
|
||||
this.focusValue(filter)
|
||||
})
|
||||
},
|
||||
focusValue(filter) {
|
||||
const ref = 'filter-' + filter.id + '-value'
|
||||
if (
|
||||
Object.prototype.hasOwnProperty.call(this.$refs, ref) &&
|
||||
Object.prototype.hasOwnProperty.call(this.$refs[ref][0], 'focus')
|
||||
) {
|
||||
this.$refs[ref][0].focus()
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Indicates if the field has any compatible filter types.
|
||||
*/
|
||||
hasCompatibleFilterTypes(field, filterTypes) {
|
||||
for (const type in filterTypes) {
|
||||
if (filterTypes[type].compatibleFieldTypes.includes(field.type)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
},
|
||||
/**
|
||||
* Returns a list of filter types that are allowed for the given fieldId.
|
||||
*/
|
||||
allowedFilters(filterTypes, primary, fields, fieldId) {
|
||||
const field =
|
||||
primary.id === fieldId ? primary : fields.find((f) => f.id === fieldId)
|
||||
return Object.values(filterTypes).filter((filterType) => {
|
||||
return (
|
||||
field !== undefined &&
|
||||
filterType.compatibleFieldTypes.includes(field.type)
|
||||
)
|
||||
})
|
||||
},
|
||||
async addFilter() {
|
||||
try {
|
||||
const { filter } = await this.$store.dispatch('view/createFilter', {
|
||||
view: this.view,
|
||||
field: this.primary,
|
||||
values: {
|
||||
field: this.primary.id,
|
||||
value: '',
|
||||
emitEvent: false,
|
||||
},
|
||||
})
|
||||
this.$emit('changed')
|
||||
|
||||
// Wait for the filter to be rendered and then focus on the value input.
|
||||
this.$nextTick(() => {
|
||||
this.focusValue(filter)
|
||||
})
|
||||
} catch (error) {
|
||||
notifyIf(error, 'view')
|
||||
}
|
||||
},
|
||||
async deleteFilter(filter) {
|
||||
try {
|
||||
await this.$store.dispatch('view/deleteFilter', {
|
||||
view: this.view,
|
||||
filter,
|
||||
})
|
||||
this.$emit('changed')
|
||||
} catch (error) {
|
||||
notifyIf(error, 'view')
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Updates a filter with the given values. Some data manipulation will also be done
|
||||
* 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
|
||||
|
||||
// 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.
|
||||
if (Object.prototype.hasOwnProperty.call(values, 'field')) {
|
||||
const allowedFilterTypes = this.allowedFilters(
|
||||
this.filterTypes,
|
||||
this.primary,
|
||||
this.fields,
|
||||
field
|
||||
).map((filter) => filter.type)
|
||||
if (!allowedFilterTypes.includes(type)) {
|
||||
values.type = allowedFilterTypes[0]
|
||||
}
|
||||
}
|
||||
|
||||
// If the type or value has changed it could be that the value needs to be
|
||||
// formatted or prepared.
|
||||
if (
|
||||
Object.prototype.hasOwnProperty.call(values, 'type') ||
|
||||
Object.prototype.hasOwnProperty.call(values, 'value')
|
||||
) {
|
||||
const filterType = this.$registry.get('viewFilter', type)
|
||||
values.value = filterType.prepareValue(value)
|
||||
}
|
||||
|
||||
try {
|
||||
await this.$store.dispatch('view/updateFilter', {
|
||||
filter,
|
||||
values,
|
||||
})
|
||||
this.$emit('changed')
|
||||
} catch (error) {
|
||||
notifyIf(error, 'view')
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Updates the view filter type. It will mark the view as loading because that
|
||||
* will also trigger the loading state of the second filter.
|
||||
*/
|
||||
async updateType(view, filter, value) {
|
||||
this.$store.dispatch('view/setItemLoading', { view, value: true })
|
||||
|
||||
try {
|
||||
await this.$store.dispatch('view/update', {
|
||||
view,
|
||||
values: { filter_type: value },
|
||||
})
|
||||
this.$emit('changed')
|
||||
} catch (error) {
|
||||
notifyIf(error, 'view')
|
||||
}
|
||||
|
||||
this.$store.dispatch('view/setItemLoading', { view, value: false })
|
||||
},
|
||||
/**
|
||||
* Returns the input component related to the filter type. This component is
|
||||
* responsible for updating the filter value.
|
||||
*/
|
||||
getInputComponent(type) {
|
||||
return this.$registry.get('viewFilter', type).getInputComponent()
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
|
@ -0,0 +1,30 @@
|
|||
<template>
|
||||
<Checkbox :value="copy" @input="input($event)">
|
||||
Selected
|
||||
</Checkbox>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { trueString } from '@baserow/modules/database/utils/constants'
|
||||
|
||||
export default {
|
||||
name: 'ViewFilterTypeBoolean',
|
||||
props: {
|
||||
value: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
copy() {
|
||||
const value = this.value.toLowerCase().trim()
|
||||
return trueString.includes(value)
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
input(value) {
|
||||
this.$emit('input', value ? '1' : '0')
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
|
@ -0,0 +1,132 @@
|
|||
<template>
|
||||
<div class="filters__value-date">
|
||||
<input
|
||||
ref="date"
|
||||
v-model="dateString"
|
||||
type="text"
|
||||
class="input filters__value-input"
|
||||
:class="{ 'input--error': $v.copy.$error }"
|
||||
:placeholder="getDatePlaceholder(field)"
|
||||
@focus="$refs.dateContext.toggle($refs.date, 'bottom', 'left', 0)"
|
||||
@blur="$refs.dateContext.hide()"
|
||||
@input="
|
||||
;[setCopyFromDateString(dateString, 'dateString'), delayedUpdate(copy)]
|
||||
"
|
||||
@keydown.enter="delayedUpdate(copy, true)"
|
||||
/>
|
||||
<Context
|
||||
ref="dateContext"
|
||||
:hide-on-click-outside="false"
|
||||
class="datepicker-context"
|
||||
>
|
||||
<client-only>
|
||||
<date-picker
|
||||
:inline="true"
|
||||
:monday-first="true"
|
||||
:value="dateObject"
|
||||
class="datepicker"
|
||||
@input=";[setCopy($event, 'dateObject'), delayedUpdate(copy, true)]"
|
||||
></date-picker>
|
||||
</client-only>
|
||||
</Context>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import moment from 'moment'
|
||||
|
||||
import {
|
||||
getDateMomentFormat,
|
||||
getDateHumanReadableFormat,
|
||||
} from '@baserow/modules/database/utils/date'
|
||||
import filterTypeInput from '@baserow/modules/database/mixins/filterTypeInput'
|
||||
|
||||
export default {
|
||||
name: 'ViewFilterTypeDate',
|
||||
mixins: [filterTypeInput],
|
||||
props: {
|
||||
value: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
fieldId: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
primary: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
fields: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
copy: '',
|
||||
dateString: '',
|
||||
dateObject: '',
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
field() {
|
||||
return this.primary.id === this.fieldId
|
||||
? this.primary
|
||||
: this.fields.find((f) => f.id === this.fieldId)
|
||||
},
|
||||
},
|
||||
created() {
|
||||
this.setCopy(this.value)
|
||||
},
|
||||
mounted() {
|
||||
this.$v.$touch()
|
||||
},
|
||||
methods: {
|
||||
setCopy(value, sender) {
|
||||
const newDate = moment.utc(value)
|
||||
|
||||
if (newDate.isValid()) {
|
||||
this.copy = newDate.format('YYYY-MM-DD')
|
||||
|
||||
if (sender !== 'dateObject') {
|
||||
this.dateObject = newDate.toDate()
|
||||
}
|
||||
|
||||
if (sender !== 'dateString') {
|
||||
const dateFormat = getDateMomentFormat(this.field.date_format)
|
||||
this.dateString = newDate.format(dateFormat)
|
||||
}
|
||||
}
|
||||
},
|
||||
setCopyFromDateString(value, sender) {
|
||||
if (value === '') {
|
||||
this.copy = ''
|
||||
return
|
||||
}
|
||||
|
||||
const dateFormat = getDateMomentFormat(this.field.date_format)
|
||||
const newDate = moment.utc(value, dateFormat)
|
||||
|
||||
if (newDate.isValid()) {
|
||||
this.setCopy(newDate, sender)
|
||||
} else {
|
||||
this.copy = value
|
||||
}
|
||||
},
|
||||
getDatePlaceholder(field) {
|
||||
return getDateHumanReadableFormat(field.date_format)
|
||||
},
|
||||
focus() {
|
||||
this.$refs.date.focus()
|
||||
},
|
||||
},
|
||||
validations: {
|
||||
copy: {
|
||||
date(value) {
|
||||
return value === '' || moment(value).isValid()
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
|
@ -0,0 +1,30 @@
|
|||
<template>
|
||||
<input
|
||||
ref="input"
|
||||
v-model="copy"
|
||||
type="text"
|
||||
class="input filters__value-input"
|
||||
:class="{ 'input--error': $v.copy.$error }"
|
||||
@input="delayedUpdate($event.target.value)"
|
||||
@keydown.enter="delayedUpdate($event.target.value, true)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { decimal } from 'vuelidate/lib/validators'
|
||||
|
||||
import filterTypeInput from '@baserow/modules/database/mixins/filterTypeInput'
|
||||
|
||||
export default {
|
||||
name: 'ViewFilterTypeText',
|
||||
mixins: [filterTypeInput],
|
||||
methods: {
|
||||
focus() {
|
||||
this.$refs.input.focus()
|
||||
},
|
||||
},
|
||||
validations: {
|
||||
copy: { decimal },
|
||||
},
|
||||
}
|
||||
</script>
|
|
@ -0,0 +1,25 @@
|
|||
<template>
|
||||
<input
|
||||
ref="input"
|
||||
v-model="copy"
|
||||
type="text"
|
||||
class="input filters__value-input"
|
||||
:class="{ 'input--error': $v.copy.$error }"
|
||||
@input="delayedUpdate($event.target.value)"
|
||||
@keydown.enter="delayedUpdate($event.target.value, true)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import filterTypeInput from '@baserow/modules/database/mixins/filterTypeInput'
|
||||
|
||||
export default {
|
||||
name: 'ViewFilterTypeText',
|
||||
mixins: [filterTypeInput],
|
||||
methods: {
|
||||
focus() {
|
||||
this.$refs.input.focus()
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
|
@ -75,7 +75,6 @@ export default {
|
|||
},
|
||||
})
|
||||
} catch (error) {
|
||||
this.$refs.rename.set(event.oldValue)
|
||||
notifyIf(error, 'view')
|
||||
}
|
||||
|
||||
|
@ -94,23 +93,14 @@ export default {
|
|||
this.setLoading(view, false)
|
||||
},
|
||||
selectView(view) {
|
||||
this.setLoading(view, true)
|
||||
this.$nuxt.$router.push({
|
||||
name: 'database-table',
|
||||
params: {
|
||||
viewId: view.id,
|
||||
},
|
||||
})
|
||||
|
||||
this.$nuxt.$router.push(
|
||||
{
|
||||
name: 'database-table',
|
||||
params: {
|
||||
viewId: view.id,
|
||||
},
|
||||
},
|
||||
() => {
|
||||
this.setLoading(view, false)
|
||||
this.$emit('selected')
|
||||
},
|
||||
() => {
|
||||
this.setLoading(view, false)
|
||||
}
|
||||
)
|
||||
this.$emit('selected')
|
||||
},
|
||||
},
|
||||
}
|
||||
|
|
|
@ -18,8 +18,11 @@
|
|||
<GridViewFieldType
|
||||
v-if="primary !== null"
|
||||
:table="table"
|
||||
:view="view"
|
||||
:field="primary"
|
||||
:filters="view.filters"
|
||||
:style="{ width: widths.fields[primary.id] + 'px' }"
|
||||
@refresh="$emit('refresh')"
|
||||
></GridViewFieldType>
|
||||
</div>
|
||||
<div ref="leftBody" class="grid-view__body">
|
||||
|
@ -49,17 +52,26 @@
|
|||
:class="{
|
||||
'grid-view__row--loading': row._.loading,
|
||||
'grid-view__row--hover': row._.hover,
|
||||
'grid-view__row--filter-warning': !row._.matchFilters,
|
||||
}"
|
||||
@mouseover="setRowHover(row, true)"
|
||||
@mouseleave="setRowHover(row, false)"
|
||||
@contextmenu.prevent="showRowContext($event, row)"
|
||||
>
|
||||
<div
|
||||
v-if="!row._.matchFilters"
|
||||
class="grid-view__row-filter-warning"
|
||||
>
|
||||
Row does not match filters
|
||||
</div>
|
||||
<div
|
||||
class="grid-view__column"
|
||||
:style="{ width: widths.leftReserved + 'px' }"
|
||||
>
|
||||
<div class="grid-view__row-info">
|
||||
<div class="grid-view__row-count">{{ row.id }}</div>
|
||||
<div class="grid-view__row-count">
|
||||
{{ row.id }}
|
||||
</div>
|
||||
<a
|
||||
class="grid-view__row-more"
|
||||
@click="$refs.rowEditModal.show(row)"
|
||||
|
@ -74,7 +86,8 @@
|
|||
:field="primary"
|
||||
:row="row"
|
||||
:style="{ width: widths.fields[primary.id] + 'px' }"
|
||||
@selected="selectedField(primary, $event.component)"
|
||||
@selected="selectedField(primary, $event)"
|
||||
@unselected="unselectedField(primary, $event)"
|
||||
@selectNext="selectNextField(row, primary, fields, primary)"
|
||||
@selectAbove="
|
||||
selectNextField(row, primary, fields, primary, 'above')
|
||||
|
@ -83,6 +96,7 @@
|
|||
selectNextField(row, primary, fields, primary, 'below')
|
||||
"
|
||||
@update="updateValue"
|
||||
@edit="editValue"
|
||||
></GridViewField>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -137,8 +151,11 @@
|
|||
v-for="field in fields"
|
||||
:key="'right-head-field-' + view.id + '-' + field.id"
|
||||
:table="table"
|
||||
:view="view"
|
||||
:field="field"
|
||||
:filters="view.filters"
|
||||
:style="{ width: widths.fields[field.id] + 'px' }"
|
||||
@refresh="$emit('refresh')"
|
||||
>
|
||||
<GridViewFieldWidthHandle
|
||||
class="grid-view__description-width"
|
||||
|
@ -194,6 +211,7 @@
|
|||
:class="{
|
||||
'grid-view__row--loading': row._.loading,
|
||||
'grid-view__row--hover': row._.hover,
|
||||
'grid-view__row--filter-warning': !row._.matchFilters,
|
||||
}"
|
||||
@mouseover="setRowHover(row, true)"
|
||||
@mouseleave="setRowHover(row, false)"
|
||||
|
@ -208,7 +226,8 @@
|
|||
:field="field"
|
||||
:row="row"
|
||||
:style="{ width: widths.fields[field.id] + 'px' }"
|
||||
@selected="selectedField(field, $event.component)"
|
||||
@selected="selectedField(field, $event)"
|
||||
@unselected="unselectedField(field, $event)"
|
||||
@selectPrevious="
|
||||
selectNextField(row, field, fields, primary, 'previous')
|
||||
"
|
||||
|
@ -220,6 +239,7 @@
|
|||
selectNextField(row, field, fields, primary, 'below')
|
||||
"
|
||||
@update="updateValue"
|
||||
@edit="editValue"
|
||||
></GridViewField>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -245,6 +265,17 @@
|
|||
<div class="grid-view__foot"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="view.filters.length > 0 && count === 0"
|
||||
class="grid-view__filtered-no-results"
|
||||
>
|
||||
<div class="grid-view__filtered-no-results-icon">
|
||||
<i class="fas fa-filter"></i>
|
||||
</div>
|
||||
<div class="grid-view__filtered-no-results-content">
|
||||
Rows are filtered
|
||||
</div>
|
||||
</div>
|
||||
<Context ref="rowContext">
|
||||
<ul class="context__menu">
|
||||
<li>
|
||||
|
@ -271,6 +302,7 @@
|
|||
:primary="primary"
|
||||
:fields="fields"
|
||||
@update="updateValue"
|
||||
@hidden="rowEditModalHidden"
|
||||
></RowEditModal>
|
||||
</div>
|
||||
</template>
|
||||
|
@ -320,7 +352,6 @@ export default {
|
|||
data() {
|
||||
return {
|
||||
addHover: false,
|
||||
loading: true,
|
||||
selectedRow: null,
|
||||
lastHoveredRow: null,
|
||||
widths: {
|
||||
|
@ -357,11 +388,40 @@ export default {
|
|||
// render the page properly on the server side.
|
||||
this.calculateWidths(this.primary, this.fields, this.fieldOptions)
|
||||
},
|
||||
beforeMount() {
|
||||
this.$bus.$on('field-deleted', this.fieldDeleted)
|
||||
},
|
||||
beforeDestroy() {
|
||||
this.$bus.$off('field-deleted', this.fieldDeleted)
|
||||
},
|
||||
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.
|
||||
*/
|
||||
fieldDeleted({ field }) {
|
||||
const filterIndex = this.view.filters.findIndex((filter) => {
|
||||
return filter.field === field.id
|
||||
})
|
||||
if (filterIndex > -1) {
|
||||
this.$emit('refresh')
|
||||
}
|
||||
},
|
||||
/**
|
||||
* 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 updateValue({ field, row, value, oldValue }) {
|
||||
try {
|
||||
await this.$store.dispatch('view/grid/updateValue', {
|
||||
table: this.table,
|
||||
view: this.view,
|
||||
row,
|
||||
field,
|
||||
value,
|
||||
|
@ -371,6 +431,19 @@ export default {
|
|||
notifyIf(error, 'field')
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Called when a value is edited, but not yet saved. Here we can do a preliminary
|
||||
* check to see if the values matches the filters.
|
||||
*/
|
||||
editValue({ field, row, value, oldValue }) {
|
||||
const overrides = {}
|
||||
overrides[`field_${field.id}`] = value
|
||||
this.$store.dispatch('view/grid/updateMatchFilters', {
|
||||
view: this.view,
|
||||
row,
|
||||
overrides,
|
||||
})
|
||||
},
|
||||
scroll(pixelY, pixelX) {
|
||||
const $rightBody = this.$refs.rightBody
|
||||
const $right = this.$refs.right
|
||||
|
@ -474,6 +547,7 @@ export default {
|
|||
async addRow() {
|
||||
try {
|
||||
await this.$store.dispatch('view/grid/create', {
|
||||
view: this.view,
|
||||
table: this.table,
|
||||
// We need a list of all fields including the primary one here.
|
||||
fields: [this.primary].concat(...this.fields),
|
||||
|
@ -515,7 +589,7 @@ export default {
|
|||
* When a field is selected we want to make sure it is visible in the viewport, so
|
||||
* we might need to scroll a little bit.
|
||||
*/
|
||||
selectedField(field, component) {
|
||||
selectedField(field, { component, row }) {
|
||||
const element = component.$el
|
||||
const verticalContainer = this.$refs.rightBody
|
||||
const horizontalContainer = this.$refs.right
|
||||
|
@ -565,6 +639,16 @@ export default {
|
|||
)
|
||||
this.$refs.scrollbars.updateHorizontal()
|
||||
}
|
||||
|
||||
this.$store.dispatch('view/grid/addRowSelectedBy', { row, field })
|
||||
},
|
||||
unselectedField(field, { row }) {
|
||||
this.$store.dispatch('view/grid/removeRowSelectedBy', {
|
||||
grid: this.view,
|
||||
row,
|
||||
field,
|
||||
getScrollTop: () => this.$refs.leftBody.scrollTop,
|
||||
})
|
||||
},
|
||||
/**
|
||||
* This method is called when the next field must be selected. This can for example
|
||||
|
@ -646,6 +730,19 @@ export default {
|
|||
this.$store.dispatch('view/grid/setRowHover', { row, value })
|
||||
this.lastHoveredRow = row
|
||||
},
|
||||
/**
|
||||
* When the modal hides and the related row does not match the filters anymore it
|
||||
* 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,
|
||||
})
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
|
|
@ -7,6 +7,7 @@
|
|||
:value="row['field_' + field.id]"
|
||||
:selected="selected"
|
||||
@update="update"
|
||||
@edit="edit"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
@ -65,6 +66,19 @@ export default {
|
|||
oldValue,
|
||||
})
|
||||
},
|
||||
/**
|
||||
* If the grid field components emits an edit event then the user has changed the
|
||||
* value without saving it yet. This is for example used to check in real time if
|
||||
* the value still matches the filters.
|
||||
*/
|
||||
edit(value, oldValue) {
|
||||
this.$emit('edit', {
|
||||
row: this.row,
|
||||
field: this.field,
|
||||
value,
|
||||
oldValue,
|
||||
})
|
||||
},
|
||||
/**
|
||||
* Method that is called when a user clicks on the grid field. It wil
|
||||
* @TODO improve speed somehow, maybe with the fastclick library.
|
||||
|
@ -194,6 +208,8 @@ export default {
|
|||
// making sure that the element fits in the viewport.
|
||||
this.$emit('selected', {
|
||||
component: this,
|
||||
row: this.row,
|
||||
field: this.field,
|
||||
})
|
||||
}
|
||||
|
||||
|
@ -203,6 +219,10 @@ export default {
|
|||
this.$refs.field.beforeUnSelect()
|
||||
this.$nextTick(() => {
|
||||
this.selected = false
|
||||
this.$emit('unselected', {
|
||||
row: this.row,
|
||||
field: this.field,
|
||||
})
|
||||
})
|
||||
document.body.removeEventListener('click', this.$el.clickOutsideEvent)
|
||||
document.body.removeEventListener('keydown', this.$el.keyDownEvent)
|
||||
|
@ -211,4 +231,3 @@ export default {
|
|||
},
|
||||
}
|
||||
</script>
|
||||
gl
|
||||
|
|
|
@ -1,5 +1,11 @@
|
|||
<template>
|
||||
<div class="grid-view__column">
|
||||
<div
|
||||
class="grid-view__column"
|
||||
:class="{
|
||||
'grid-view__column--filtered':
|
||||
view.filters.findIndex((filter) => filter.field === field.id) !== -1,
|
||||
}"
|
||||
>
|
||||
<div
|
||||
class="grid-view__description"
|
||||
:class="{ 'grid-view__description--loading': field._.loading }"
|
||||
|
@ -15,13 +21,24 @@
|
|||
>
|
||||
<i class="fas fa-caret-down"></i>
|
||||
</a>
|
||||
<FieldContext ref="context" :table="table" :field="field"></FieldContext>
|
||||
<FieldContext ref="context" :table="table" :field="field">
|
||||
<li v-if="canFilter">
|
||||
<a
|
||||
class="grid-view__description-options"
|
||||
@click="createFilter($event, view, field)"
|
||||
>
|
||||
<i class="context__menu-icon fas fa-fw fa-filter"></i>
|
||||
Create filter
|
||||
</a>
|
||||
</li>
|
||||
</FieldContext>
|
||||
<slot></slot>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { notifyIf } from '@baserow/modules/core/utils/error'
|
||||
import FieldContext from '@baserow/modules/database/components/field/FieldContext'
|
||||
|
||||
export default {
|
||||
|
@ -32,10 +49,49 @@ export default {
|
|||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
view: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
field: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
canFilter() {
|
||||
const filters = Object.values(this.$registry.getAll('viewFilter'))
|
||||
for (const type in filters) {
|
||||
if (filters[type].compatibleFieldTypes.includes(this.field.type)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
async createFilter(event, view, field) {
|
||||
// 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()
|
||||
|
||||
try {
|
||||
await this.$store.dispatch('view/createFilter', {
|
||||
view,
|
||||
field,
|
||||
values: {
|
||||
field: field.id,
|
||||
value: '',
|
||||
},
|
||||
})
|
||||
this.$emit('refresh')
|
||||
} catch (error) {
|
||||
notifyIf(error, 'view')
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
|
|
@ -21,6 +21,8 @@ import RowEditFieldNumber from '@baserow/modules/database/components/row/RowEdit
|
|||
import RowEditFieldBoolean from '@baserow/modules/database/components/row/RowEditFieldBoolean'
|
||||
import RowEditFieldDate from '@baserow/modules/database/components/row/RowEditFieldDate'
|
||||
|
||||
import { trueString } from '@baserow/modules/database/utils/constants'
|
||||
|
||||
export class FieldType extends Registerable {
|
||||
/**
|
||||
* The font awesome 5 icon name that is used as convenience for the user to
|
||||
|
@ -363,9 +365,8 @@ export class BooleanFieldType extends FieldType {
|
|||
* value is true.
|
||||
*/
|
||||
prepareValueForPaste(field, clipboardData) {
|
||||
const value = clipboardData.getData('text').toLowerCase()
|
||||
const allowed = ['1', 'y', 't', 'y', 'yes', 'true', 'on']
|
||||
return allowed.includes(value)
|
||||
const value = clipboardData.getData('text').toLowerCase().trim()
|
||||
return trueString.includes(value)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
6
web-frontend/modules/database/middleware.js
Normal file
6
web-frontend/modules/database/middleware.js
Normal file
|
@ -0,0 +1,6 @@
|
|||
import tableLoading from '@baserow/modules/database/middleware/tableLoading'
|
||||
|
||||
/* eslint-disable-next-line */
|
||||
import Middleware from './middleware'
|
||||
|
||||
Middleware.tableLoading = tableLoading
|
8
web-frontend/modules/database/middleware/tableLoading.js
Normal file
8
web-frontend/modules/database/middleware/tableLoading.js
Normal file
|
@ -0,0 +1,8 @@
|
|||
/**
|
||||
* Middleware that changes the table loading state to true before the the route
|
||||
* changes. That way we can show a loading animation to the user when switching
|
||||
* between views.
|
||||
*/
|
||||
export default async function ({ store }) {
|
||||
await store.dispatch('table/setLoading', true)
|
||||
}
|
45
web-frontend/modules/database/mixins/filterTypeInput.js
Normal file
45
web-frontend/modules/database/mixins/filterTypeInput.js
Normal file
|
@ -0,0 +1,45 @@
|
|||
let delayTimeout = null
|
||||
|
||||
/**
|
||||
* Mixin that introduces a delayedUpdate helper method. This method is specifically
|
||||
* helpful in combination with an input field that accepts any form of text. When the
|
||||
* user stops typing for 400ms it will do the actual update, but only if the validation
|
||||
* passes.
|
||||
*/
|
||||
export default {
|
||||
props: {
|
||||
value: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
copy: null,
|
||||
}
|
||||
},
|
||||
created() {
|
||||
this.copy = this.value
|
||||
},
|
||||
methods: {
|
||||
delayedUpdate(value, immediately = false) {
|
||||
clearTimeout(delayTimeout)
|
||||
this.$v.$touch()
|
||||
|
||||
if (this.$v.copy.$error) {
|
||||
return
|
||||
}
|
||||
|
||||
if (immediately) {
|
||||
this.$emit('input', value)
|
||||
} else {
|
||||
delayTimeout = setTimeout(() => {
|
||||
this.$emit('input', value)
|
||||
}, 400)
|
||||
}
|
||||
},
|
||||
},
|
||||
validations: {
|
||||
copy: {},
|
||||
},
|
||||
}
|
|
@ -17,6 +17,13 @@ export default {
|
|||
copy: null,
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
copy(value) {
|
||||
if (this.editing) {
|
||||
this.$emit('edit', value, this.value)
|
||||
}
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
/**
|
||||
* Event that is called when the column is selected. Here we will add an event
|
||||
|
@ -113,6 +120,7 @@ export default {
|
|||
cancel() {
|
||||
this.editing = false
|
||||
this.copy = this.value
|
||||
this.$emit('edit', this.value, this.value)
|
||||
},
|
||||
/**
|
||||
* Method that is called after initiating the edit state. This can be overridden
|
||||
|
|
|
@ -3,6 +3,8 @@ import path from 'path'
|
|||
import { routes } from './routes'
|
||||
|
||||
export default function DatabaseModule(options) {
|
||||
this.addPlugin({ src: path.resolve(__dirname, 'middleware.js') })
|
||||
|
||||
// Add the plugin to register the database application.
|
||||
this.appendPlugin({
|
||||
src: path.resolve(__dirname, 'plugin.js'),
|
||||
|
|
|
@ -1,21 +1,29 @@
|
|||
<template>
|
||||
<div>
|
||||
<header class="layout__col-3-1 header">
|
||||
<ul class="header__filter">
|
||||
<li class="header__filter-item">
|
||||
<div v-show="tableLoading" class="header__loading"></div>
|
||||
<ul v-show="!tableLoading" class="header__filter">
|
||||
<li class="header__filter-item header__filter-item--grids">
|
||||
<a
|
||||
ref="viewsSelectToggle"
|
||||
class="header__filter-link"
|
||||
@click="$refs.viewsContext.toggle($refs.viewsSelectToggle)"
|
||||
@click="
|
||||
$refs.viewsContext.toggle(
|
||||
$refs.viewsSelectToggle,
|
||||
'bottom',
|
||||
'left',
|
||||
4
|
||||
)
|
||||
"
|
||||
>
|
||||
<span v-if="hasSelectedView">
|
||||
<span v-if="hasSelectedView()">
|
||||
<i
|
||||
class="header__filter-icon fas"
|
||||
:class="'fa-' + selectedView._.type.iconClass"
|
||||
class="header__filter-icon header-filter-icon--view fas"
|
||||
:class="'fa-' + view._.type.iconClass"
|
||||
></i>
|
||||
{{ selectedView.name }}
|
||||
{{ view.name }}
|
||||
</span>
|
||||
<span v-if="!hasSelectedView">
|
||||
<span v-else>
|
||||
<i
|
||||
class="header__filter-icon header-filter-icon-no-choice fas fa-caret-square-down"
|
||||
></i>
|
||||
|
@ -24,41 +32,52 @@
|
|||
</a>
|
||||
<ViewsContext ref="viewsContext" :table="table"></ViewsContext>
|
||||
</li>
|
||||
<li v-if="view._.type.canFilter" class="header__filter-item">
|
||||
<ViewFilter
|
||||
:view="view"
|
||||
:fields="fields"
|
||||
:primary="primary"
|
||||
@changed="refresh()"
|
||||
></ViewFilter>
|
||||
</li>
|
||||
</ul>
|
||||
<template v-if="hasSelectedView">
|
||||
<component
|
||||
:is="getViewHeaderComponent(selectedView)"
|
||||
:database="database"
|
||||
:table="table"
|
||||
:view="selectedView"
|
||||
:fields="fields"
|
||||
:primary="primary"
|
||||
/>
|
||||
</template>
|
||||
<ul class="header__info">
|
||||
<component
|
||||
:is="getViewHeaderComponent(view)"
|
||||
v-if="hasSelectedView()"
|
||||
:database="database"
|
||||
:table="table"
|
||||
:view="view"
|
||||
:fields="fields"
|
||||
:primary="primary"
|
||||
/>
|
||||
<ul v-show="!tableLoading" class="header__info">
|
||||
<li>{{ database.name }}</li>
|
||||
<li>{{ table.name }}</li>
|
||||
</ul>
|
||||
</header>
|
||||
<div class="layout__col-3-2 content">
|
||||
<template v-if="hasSelectedView">
|
||||
<component
|
||||
:is="getViewComponent(selectedView)"
|
||||
:database="database"
|
||||
:table="table"
|
||||
:view="selectedView"
|
||||
:fields="fields"
|
||||
:primary="primary"
|
||||
/>
|
||||
</template>
|
||||
<component
|
||||
:is="getViewComponent(view)"
|
||||
v-if="hasSelectedView()"
|
||||
v-show="!tableLoading"
|
||||
ref="view"
|
||||
:database="database"
|
||||
:table="table"
|
||||
:view="view"
|
||||
:fields="fields"
|
||||
:primary="primary"
|
||||
@refresh="refresh()"
|
||||
/>
|
||||
<div v-if="viewLoading" class="loading-overlay"></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapState, mapGetters } from 'vuex'
|
||||
import { mapState } from 'vuex'
|
||||
|
||||
import ViewsContext from '@baserow/modules/database/components/view/ViewsContext'
|
||||
import ViewFilter from '@baserow/modules/database/components/view/ViewFilter'
|
||||
|
||||
/**
|
||||
* This page component is the skeleton for a table. Depending on the selected view it
|
||||
|
@ -68,16 +87,24 @@ export default {
|
|||
layout: 'app',
|
||||
components: {
|
||||
ViewsContext,
|
||||
ViewFilter,
|
||||
},
|
||||
/**
|
||||
* Because there is no hook that is called before the route changes, we need the
|
||||
* tableLoading middleware to change the table loading state. This change will get
|
||||
* rendered right away. This allows us to have a custom loading animation when
|
||||
* switching views.
|
||||
*/
|
||||
middleware: ['tableLoading'],
|
||||
/**
|
||||
* Prepares all the table, field and view data for the provided database, table and
|
||||
* view id.
|
||||
*/
|
||||
async asyncData({ store, params, error, redirect, app }) {
|
||||
async asyncData({ store, params, error, app }) {
|
||||
// @TODO figure out why the id's aren't converted to an int in the route.
|
||||
const databaseId = parseInt(params.databaseId)
|
||||
const tableId = parseInt(params.tableId)
|
||||
const viewId = params.viewId ? parseInt(params.viewId) : null
|
||||
let viewId = params.viewId ? parseInt(params.viewId) : null
|
||||
const data = {}
|
||||
|
||||
// Try to find the table in the already fetched applications by the
|
||||
|
@ -95,20 +122,16 @@ export default {
|
|||
return error({ statusCode: 404, message: 'Table not found.' })
|
||||
}
|
||||
|
||||
// After selecting the table the fields become available which need to be added to
|
||||
// the data.
|
||||
data.fields = store.getters['field/getAll']
|
||||
data.primary = store.getters['field/getPrimary']
|
||||
|
||||
// Because we do not have a dashboard for the table yet we're going to redirect to
|
||||
// the first available view.
|
||||
if (viewId === null) {
|
||||
const firstView = store.getters['view/first']
|
||||
if (firstView !== null) {
|
||||
return redirect({
|
||||
name: 'database-table',
|
||||
params: {
|
||||
databaseId: data.database.id,
|
||||
tableId: data.table.id,
|
||||
viewId: firstView.id,
|
||||
},
|
||||
})
|
||||
}
|
||||
const firstView = store.getters['view/first']
|
||||
if (viewId === null && firstView !== null) {
|
||||
viewId = firstView.id
|
||||
}
|
||||
|
||||
// If a view id is provided and the table is selected we can select the view. The
|
||||
|
@ -130,16 +153,35 @@ export default {
|
|||
|
||||
return data
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
// Shows a small spinning loading animation when the view is being refreshed.
|
||||
viewLoading: false,
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
...mapState({
|
||||
selectedView: (state) => state.view.selected,
|
||||
fields: (state) => state.field.items,
|
||||
primary: (state) => state.field.primary,
|
||||
}),
|
||||
...mapGetters({
|
||||
hasSelectedView: 'view/hasSelected',
|
||||
// We need the tableLoading state to show a small loading animation when
|
||||
// switching between views. Because some of the data will be populated by
|
||||
// the asyncData function and some by mapping the state of a store it could look
|
||||
// a bit strange for the user when switching between views because not all data
|
||||
// renders at the same time. That is why we show this loading animation. Store
|
||||
// changes are always rendered right away.
|
||||
tableLoading: (state) => state.table.loading,
|
||||
}),
|
||||
},
|
||||
/**
|
||||
* The beforeCreate hook is called right after the asyncData finishes and when the
|
||||
* page has been rendered for the first time. The perfect moment to stop the table
|
||||
* loading animation.
|
||||
*/
|
||||
beforeCreate() {
|
||||
this.$store.dispatch('table/setLoading', false)
|
||||
},
|
||||
/**
|
||||
* When the user leaves to another page we want to unselect the selected table. This
|
||||
* way it will not be highlighted the left sidebar.
|
||||
*/
|
||||
beforeRouteLeave(to, from, next) {
|
||||
this.$store.dispatch('table/unselect')
|
||||
next()
|
||||
|
@ -153,6 +195,31 @@ export default {
|
|||
const type = this.$registry.get('view', view.type)
|
||||
return type.getHeaderComponent()
|
||||
},
|
||||
/**
|
||||
* Refreshes the whole view. All data will be reloaded and it will visually look
|
||||
* the same as seeing the view for the first time.
|
||||
*/
|
||||
async refresh() {
|
||||
this.viewLoading = true
|
||||
const type = this.$registry.get('view', this.view.type)
|
||||
await type.fetch({ 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()
|
||||
}
|
||||
this.$nextTick(() => {
|
||||
this.viewLoading = false
|
||||
})
|
||||
},
|
||||
/**
|
||||
* Indicates if there is a selected view by checking if the view object has been
|
||||
* populated.
|
||||
*/
|
||||
hasSelectedView() {
|
||||
return Object.prototype.hasOwnProperty.call(this.view, '_')
|
||||
},
|
||||
},
|
||||
head() {
|
||||
return {
|
||||
|
|
|
@ -8,6 +8,19 @@ import {
|
|||
BooleanFieldType,
|
||||
DateFieldType,
|
||||
} from '@baserow/modules/database/fieldTypes'
|
||||
import {
|
||||
EqualViewFilterType,
|
||||
NotEqualViewFilterType,
|
||||
DateEqualViewFilterType,
|
||||
DateNotEqualViewFilterType,
|
||||
ContainsViewFilterType,
|
||||
ContainsNotViewFilterType,
|
||||
HigherThanViewFilterType,
|
||||
LowerThanViewFilterType,
|
||||
BooleanViewFilterType,
|
||||
EmptyViewFilterType,
|
||||
NotEmptyViewFilterType,
|
||||
} from '@baserow/modules/database/viewFilters'
|
||||
|
||||
import tableStore from '@baserow/modules/database/store/table'
|
||||
import viewStore from '@baserow/modules/database/store/view'
|
||||
|
@ -22,6 +35,17 @@ export default ({ store, app }) => {
|
|||
|
||||
app.$registry.register('application', new DatabaseApplicationType())
|
||||
app.$registry.register('view', new GridViewType())
|
||||
app.$registry.register('viewFilter', new EqualViewFilterType())
|
||||
app.$registry.register('viewFilter', new NotEqualViewFilterType())
|
||||
app.$registry.register('viewFilter', new DateEqualViewFilterType())
|
||||
app.$registry.register('viewFilter', new DateNotEqualViewFilterType())
|
||||
app.$registry.register('viewFilter', new ContainsViewFilterType())
|
||||
app.$registry.register('viewFilter', new ContainsNotViewFilterType())
|
||||
app.$registry.register('viewFilter', new HigherThanViewFilterType())
|
||||
app.$registry.register('viewFilter', new LowerThanViewFilterType())
|
||||
app.$registry.register('viewFilter', new BooleanViewFilterType())
|
||||
app.$registry.register('viewFilter', new EmptyViewFilterType())
|
||||
app.$registry.register('viewFilter', new NotEmptyViewFilterType())
|
||||
app.$registry.register('field', new TextFieldType())
|
||||
app.$registry.register('field', new LongTextFieldType())
|
||||
app.$registry.register('field', new LinkRowFieldType())
|
||||
|
|
19
web-frontend/modules/database/services/filter.js
Normal file
19
web-frontend/modules/database/services/filter.js
Normal file
|
@ -0,0 +1,19 @@
|
|||
export default (client) => {
|
||||
return {
|
||||
fetchAll(viewId) {
|
||||
return client.get(`/database/views/view/${viewId}/filter/`)
|
||||
},
|
||||
create(viewId, values) {
|
||||
return client.post(`/database/views/view/${viewId}/filters/`, values)
|
||||
},
|
||||
get(viewFilterId) {
|
||||
return client.get(`/database/views/filter/${viewFilterId}/`)
|
||||
},
|
||||
update(viewFilterId, values) {
|
||||
return client.patch(`/database/views/filter/${viewFilterId}/`, values)
|
||||
},
|
||||
delete(viewFilterId) {
|
||||
return client.delete(`/database/views/filter/${viewFilterId}/`)
|
||||
},
|
||||
}
|
||||
}
|
|
@ -1,7 +1,20 @@
|
|||
export default (client) => {
|
||||
return {
|
||||
fetchAll(tableId) {
|
||||
return client.get(`/database/views/table/${tableId}/`)
|
||||
fetchAll(tableId, includeFilters = false) {
|
||||
const config = {
|
||||
params: {},
|
||||
}
|
||||
const includes = []
|
||||
|
||||
if (includeFilters) {
|
||||
includes.push('filters')
|
||||
}
|
||||
|
||||
if (includes.length > 0) {
|
||||
config.params.includes = includes.join(',')
|
||||
}
|
||||
|
||||
return client.get(`/database/views/table/${tableId}/`, config)
|
||||
},
|
||||
create(tableId, values) {
|
||||
return client.post(`/database/views/table/${tableId}/`, values)
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
import _ from 'lodash'
|
||||
|
||||
import FieldService from '@baserow/modules/database/services/field'
|
||||
import { clone } from '@baserow/modules/core/utils/object'
|
||||
|
||||
|
@ -36,7 +38,7 @@ export const mutations = {
|
|||
state.loaded = value
|
||||
},
|
||||
SET_PRIMARY(state, item) {
|
||||
state.primary = item
|
||||
state.primary = _.assign(state.primary || {}, item)
|
||||
},
|
||||
ADD_ITEM(state, item) {
|
||||
state.items.push(item)
|
||||
|
@ -177,6 +179,7 @@ export const actions = {
|
|||
async delete({ commit, dispatch }, field) {
|
||||
try {
|
||||
await FieldService(this.$client).delete(field.id)
|
||||
this.$bus.$emit('field-deleted', { field })
|
||||
dispatch('forceDelete', field)
|
||||
} catch (error) {
|
||||
// If the field to delete wasn't found we can just delete it from the
|
||||
|
@ -189,9 +192,11 @@ export const actions = {
|
|||
}
|
||||
},
|
||||
/**
|
||||
* Forcibly remove the field from the items without calling the server.
|
||||
* 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 })
|
||||
commit('DELETE_ITEM', field.id)
|
||||
},
|
||||
}
|
||||
|
@ -203,6 +208,9 @@ export const getters = {
|
|||
get: (state) => (id) => {
|
||||
return state.items.find((item) => item.id === id)
|
||||
},
|
||||
getPrimary: (state) => {
|
||||
return state.primary
|
||||
},
|
||||
getAll(state) {
|
||||
return state.items
|
||||
},
|
||||
|
|
|
@ -12,6 +12,9 @@ export function populateTable(table) {
|
|||
}
|
||||
|
||||
export const state = () => ({
|
||||
// Indicates whether the table is loading. This is used to show a loading
|
||||
// animation when switching between views.
|
||||
loading: false,
|
||||
selected: {},
|
||||
})
|
||||
|
||||
|
@ -41,9 +44,15 @@ export const mutations = {
|
|||
const index = database.tables.findIndex((item) => item.id === id)
|
||||
database.tables.splice(index, 1)
|
||||
},
|
||||
SET_LOADING(state, value) {
|
||||
state.loading = value
|
||||
},
|
||||
}
|
||||
|
||||
export const actions = {
|
||||
setLoading({ commit }, value) {
|
||||
commit('SET_LOADING', value)
|
||||
},
|
||||
/**
|
||||
* Create a new table based on the provided values and add it to the tables
|
||||
* of the provided database.
|
||||
|
@ -125,32 +134,13 @@ export const actions = {
|
|||
return { database, table }
|
||||
}
|
||||
|
||||
// A small helper to change the loading state of the database application.
|
||||
const setDatabaseLoading = (database, value) => {
|
||||
return dispatch(
|
||||
'application/setItemLoading',
|
||||
{ application: database, value },
|
||||
{ root: true }
|
||||
)
|
||||
}
|
||||
|
||||
await setDatabaseLoading(database, true)
|
||||
|
||||
try {
|
||||
await axios.all([
|
||||
dispatch('view/fetchAll', table, { root: true }),
|
||||
dispatch('field/fetchAll', table, { root: true }),
|
||||
])
|
||||
|
||||
await dispatch('application/clearChildrenSelected', null, { root: true })
|
||||
commit('SET_SELECTED', { database, table })
|
||||
|
||||
setDatabaseLoading(database, false)
|
||||
return { database, table }
|
||||
} catch (error) {
|
||||
setDatabaseLoading(database, false)
|
||||
throw error
|
||||
}
|
||||
await axios.all([
|
||||
dispatch('view/fetchAll', table, { root: true }),
|
||||
dispatch('field/fetchAll', table, { root: true }),
|
||||
])
|
||||
await dispatch('application/clearChildrenSelected', null, { root: true })
|
||||
commit('SET_SELECTED', { database, table })
|
||||
return { database, table }
|
||||
},
|
||||
/**
|
||||
* Selects a table based on the provided database (application) and table id. The
|
||||
|
|
|
@ -1,7 +1,19 @@
|
|||
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 { clone } from '@baserow/modules/core/utils/object'
|
||||
import { DatabaseApplicationType } from '@baserow/modules/database/applicationTypes'
|
||||
|
||||
export function populateFilter(filter) {
|
||||
filter._ = {
|
||||
hover: false,
|
||||
loading: false,
|
||||
}
|
||||
return filter
|
||||
}
|
||||
|
||||
export function populateView(view, registry) {
|
||||
const type = registry.get('view', view.type)
|
||||
|
||||
|
@ -10,6 +22,15 @@ export function populateView(view, registry) {
|
|||
selected: false,
|
||||
loading: false,
|
||||
}
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(view, 'filters')) {
|
||||
view.filters.forEach((filter) => {
|
||||
populateFilter(filter)
|
||||
})
|
||||
} else {
|
||||
view.filters = []
|
||||
}
|
||||
|
||||
return type.populate(view)
|
||||
}
|
||||
|
||||
|
@ -61,6 +82,35 @@ export const mutations = {
|
|||
})
|
||||
state.selected = {}
|
||||
},
|
||||
ADD_FILTER(state, { view, filter }) {
|
||||
view.filters.push(filter)
|
||||
},
|
||||
FINALIZE_FILTER(state, { view, oldId, id }) {
|
||||
const index = view.filters.findIndex((item) => item.id === oldId)
|
||||
if (index !== -1) {
|
||||
view.filters[index].id = id
|
||||
view.filters[index]._.loading = false
|
||||
}
|
||||
},
|
||||
DELETE_FILTER(state, { view, id }) {
|
||||
const index = view.filters.findIndex((item) => item.id === id)
|
||||
if (index !== -1) {
|
||||
view.filters.splice(index, 1)
|
||||
}
|
||||
},
|
||||
DELETE_FIELD_FILTERS(state, { view, fieldId }) {
|
||||
for (let i = view.filters.length - 1; i >= 0; i--) {
|
||||
if (view.filters[i].field === fieldId) {
|
||||
view.filters.splice(i, 1)
|
||||
}
|
||||
}
|
||||
},
|
||||
UPDATE_FILTER(state, { filter, values }) {
|
||||
Object.assign(filter, filter, values)
|
||||
},
|
||||
SET_FILTER_LOADING(state, { filter, value }) {
|
||||
filter._.loading = value
|
||||
},
|
||||
}
|
||||
|
||||
export const actions = {
|
||||
|
@ -80,7 +130,7 @@ export const actions = {
|
|||
commit('UNSELECT', {})
|
||||
|
||||
try {
|
||||
const { data } = await ViewService(this.$client).fetchAll(table.id)
|
||||
const { data } = await ViewService(this.$client).fetchAll(table.id, true)
|
||||
data.forEach((part, index, d) => {
|
||||
populateView(data[index], this.$registry)
|
||||
})
|
||||
|
@ -123,13 +173,24 @@ export const actions = {
|
|||
* Updates the values of the view with the provided id.
|
||||
*/
|
||||
async update({ commit, dispatch }, { view, values }) {
|
||||
const { data } = await ViewService(this.$client).update(view.id, values)
|
||||
// Create a dict with only the values we want to update.
|
||||
const update = Object.keys(values).reduce((result, key) => {
|
||||
result[key] = data[key]
|
||||
return result
|
||||
}, {})
|
||||
commit('UPDATE_ITEM', { id: view.id, values: update })
|
||||
const oldValues = {}
|
||||
const newValues = {}
|
||||
Object.keys(values).forEach((name) => {
|
||||
if (Object.prototype.hasOwnProperty.call(view, name)) {
|
||||
oldValues[name] = view[name]
|
||||
newValues[name] = values[name]
|
||||
}
|
||||
})
|
||||
|
||||
commit('UPDATE_ITEM', { id: view.id, values: newValues })
|
||||
|
||||
try {
|
||||
await ViewService(this.$client).update(view.id, values)
|
||||
commit('SET_ITEM_LOADING', { view, value: false })
|
||||
} catch (error) {
|
||||
commit('UPDATE_ITEM', { id: view.id, values: oldValues })
|
||||
throw error
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Deletes an existing view with the provided id. A request to the server is first
|
||||
|
@ -202,6 +263,105 @@ export const actions = {
|
|||
}
|
||||
return dispatch('select', view)
|
||||
},
|
||||
/**
|
||||
* Changes the loading state of a specific filter.
|
||||
*/
|
||||
setFilterLoading({ commit }, { filter, value }) {
|
||||
commit('SET_FILTER_LOADING', { filter, value })
|
||||
},
|
||||
/**
|
||||
* Creates a new filter 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 createFilter({ commit }, { view, field, values, emitEvent = true }) {
|
||||
// If the type is not provided we are going to choose the first available type.
|
||||
if (!Object.prototype.hasOwnProperty.call(values, 'type')) {
|
||||
const viewFilterTypes = this.$registry.getAll('viewFilter')
|
||||
const compatibleType = Object.values(viewFilterTypes).find(
|
||||
(viewFilterType) => {
|
||||
return viewFilterType.compatibleFieldTypes.includes(field.type)
|
||||
}
|
||||
)
|
||||
if (compatibleType === undefined) {
|
||||
throw new Error(
|
||||
`No compatible filter type could be found for field' ${field.type}`
|
||||
)
|
||||
}
|
||||
values.type = compatibleType.type
|
||||
}
|
||||
|
||||
const filter = _.assign({}, values)
|
||||
populateFilter(filter)
|
||||
filter.id = uuid()
|
||||
filter._.loading = true
|
||||
|
||||
commit('ADD_FILTER', { view, filter })
|
||||
|
||||
try {
|
||||
const { data } = await FilterService(this.$client).create(view.id, values)
|
||||
commit('FINALIZE_FILTER', { view, oldId: filter.id, id: data.id })
|
||||
|
||||
if (emitEvent) {
|
||||
this.$bus.$emit('view-filter-created', { view, filter })
|
||||
}
|
||||
} catch (error) {
|
||||
commit('DELETE_FILTER', { view, id: filter.id })
|
||||
throw error
|
||||
}
|
||||
|
||||
return { filter }
|
||||
},
|
||||
/**
|
||||
* Updates the filter values in the store right away. If the API call fails the
|
||||
* changes will be undone.
|
||||
*/
|
||||
async updateFilter({ commit }, { filter, values }) {
|
||||
commit('SET_FILTER_LOADING', { filter, value: true })
|
||||
|
||||
const oldValues = {}
|
||||
const newValues = {}
|
||||
Object.keys(values).forEach((name) => {
|
||||
if (Object.prototype.hasOwnProperty.call(filter, name)) {
|
||||
oldValues[name] = filter[name]
|
||||
newValues[name] = values[name]
|
||||
}
|
||||
})
|
||||
|
||||
commit('UPDATE_FILTER', { filter, values: newValues })
|
||||
|
||||
try {
|
||||
await FilterService(this.$client).update(filter.id, values)
|
||||
commit('SET_FILTER_LOADING', { filter, value: false })
|
||||
} catch (error) {
|
||||
commit('UPDATE_FILTER', { filter, values: oldValues })
|
||||
commit('SET_FILTER_LOADING', { filter, value: false })
|
||||
throw error
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Deletes an existing filter. A request to the server will be made first and
|
||||
* after that it will be deleted.
|
||||
*/
|
||||
async deleteFilter({ commit }, { view, filter }) {
|
||||
commit('SET_FILTER_LOADING', { filter, value: true })
|
||||
|
||||
try {
|
||||
await FilterService(this.$client).delete(filter.id)
|
||||
commit('DELETE_FILTER', { view, id: filter.id })
|
||||
} catch (error) {
|
||||
commit('SET_FILTER_LOADING', { filter, value: false })
|
||||
throw error
|
||||
}
|
||||
},
|
||||
/**
|
||||
* When a field is deleted the related filters are also automatically deleted in the
|
||||
* backend so they need to be removed here.
|
||||
*/
|
||||
deleteFieldFilters({ commit, getters }, { field }) {
|
||||
getters.getAll.forEach((view) => {
|
||||
commit('DELETE_FIELD_FILTERS', { view, fieldId: field.id })
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
export const getters = {
|
||||
|
@ -217,6 +377,9 @@ export const getters = {
|
|||
first(state) {
|
||||
return state.items.length > 0 ? state.items[0] : null
|
||||
},
|
||||
getAll(state) {
|
||||
return state.items
|
||||
},
|
||||
}
|
||||
|
||||
export default {
|
||||
|
|
|
@ -2,6 +2,7 @@ import Vue from 'vue'
|
|||
import axios from 'axios'
|
||||
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'
|
||||
|
||||
|
@ -9,6 +10,8 @@ export function populateRow(row) {
|
|||
row._ = {
|
||||
loading: false,
|
||||
hover: false,
|
||||
selectedBy: [],
|
||||
matchFilters: true,
|
||||
}
|
||||
return row
|
||||
}
|
||||
|
@ -159,6 +162,20 @@ export const mutations = {
|
|||
SET_ROW_HOVER(state, { row, value }) {
|
||||
row._.hover = value
|
||||
},
|
||||
SET_ROW_MATCH_FILTERS(state, { row, value }) {
|
||||
row._.matchFilters = value
|
||||
},
|
||||
ADD_ROW_SELECTED_BY(state, { row, fieldId }) {
|
||||
if (!row._.selectedBy.includes(fieldId)) {
|
||||
row._.selectedBy.push(fieldId)
|
||||
}
|
||||
},
|
||||
REMOVE_ROW_SELECTED_BY(state, { row, fieldId }) {
|
||||
const index = row._.selectedBy.indexOf(fieldId)
|
||||
if (index > -1) {
|
||||
row._.selectedBy.splice(index, 1)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
// Contains the timeout needed for the delayed delayed scroll top action.
|
||||
|
@ -415,7 +432,6 @@ export const actions = {
|
|||
*/
|
||||
async fetchInitial({ dispatch, commit, getters }, { gridId }) {
|
||||
commit('SET_LAST_GRID_ID', gridId)
|
||||
commit('CLEAR_ROWS')
|
||||
|
||||
const limit = getters.getBufferRequestSize * 2
|
||||
const { data } = await GridService(this.$client).fetchRows({
|
||||
|
@ -427,6 +443,7 @@ export const actions = {
|
|||
data.results.forEach((part, index) => {
|
||||
populateRow(data.results[index])
|
||||
})
|
||||
commit('CLEAR_ROWS')
|
||||
commit('ADD_ROWS', {
|
||||
rows: data.results,
|
||||
prependToRows: 0,
|
||||
|
@ -444,34 +461,69 @@ export const actions = {
|
|||
})
|
||||
commit('REPLACE_ALL_FIELD_OPTIONS', data.field_options)
|
||||
},
|
||||
/**
|
||||
* 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
|
||||
* override values that not actually belong to the row to do some preliminary checks.
|
||||
*/
|
||||
updateMatchFilters({ commit }, { view, row, overrides = {} }) {
|
||||
const isValid = (filters, values) => {
|
||||
for (const i in filters) {
|
||||
const filterType = this.$registry.get('viewFilter', filters[i].type)
|
||||
const filterValue = filters[i].value
|
||||
const rowValue = values[`field_${filters[i].field}`]
|
||||
const matches = filterType.matches(rowValue, filterValue)
|
||||
if (view.filter_type === 'AND' && !matches) {
|
||||
return false
|
||||
} else if (view.filter_type === 'OR' && matches) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
if (view.filter_type === 'AND') {
|
||||
return true
|
||||
} else if (view.filter_type === 'OR') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
const values = JSON.parse(JSON.stringify(row))
|
||||
Object.keys(overrides).forEach((key) => {
|
||||
values[key] = overrides[key]
|
||||
})
|
||||
const matches = isValid(view.filters, values)
|
||||
commit('SET_ROW_MATCH_FILTERS', { row, value: matches })
|
||||
},
|
||||
/**
|
||||
* 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
|
||||
* experience for the user.
|
||||
*/
|
||||
updateValue({ commit, dispatch }, { table, row, field, value, oldValue }) {
|
||||
async updateValue(
|
||||
{ commit, dispatch },
|
||||
{ table, view, row, field, value, oldValue }
|
||||
) {
|
||||
commit('SET_VALUE', { row, field, value })
|
||||
dispatch('updateMatchFilters', { view, row })
|
||||
|
||||
const fieldType = this.$registry.get('field', field._.type.type)
|
||||
const newValue = fieldType.prepareValueForUpdate(field, value)
|
||||
|
||||
const values = {}
|
||||
values[`field_${field.id}`] = newValue
|
||||
return RowService(this.$client)
|
||||
.update(table.id, row.id, values)
|
||||
.catch((error) => {
|
||||
commit('SET_VALUE', { row, field, value: oldValue })
|
||||
throw error
|
||||
})
|
||||
|
||||
try {
|
||||
await RowService(this.$client).update(table.id, row.id, values)
|
||||
} catch (error) {
|
||||
commit('SET_VALUE', { row, field, value: oldValue })
|
||||
dispatch('updateMatchFilters', { view, row })
|
||||
throw error
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Creates a new row. Based on the default values of the fields a row is created
|
||||
* which will be added to the store. Only if the request fails the row is @TODO
|
||||
* removed.
|
||||
* which will be added to the store. Only if the request fails the row is removed.
|
||||
*/
|
||||
async create(
|
||||
{ commit, getters, rootGetters, dispatch },
|
||||
{ table, fields, values = {} }
|
||||
{ view, table, fields, values = {} }
|
||||
) {
|
||||
// Fill the not provided values with the empty value of the field type so we can
|
||||
// immediately commit the created row to the state.
|
||||
|
@ -488,7 +540,7 @@ export const actions = {
|
|||
// yet been added.
|
||||
const row = _.assign({}, values)
|
||||
populateRow(row)
|
||||
row.id = 0
|
||||
row.id = uuid()
|
||||
row._.loading = true
|
||||
|
||||
commit('ADD_ROWS', {
|
||||
|
@ -505,9 +557,16 @@ export const actions = {
|
|||
})
|
||||
const index = getters.getRowsLength - 1
|
||||
|
||||
// @TODO remove the correct row is the request fails.
|
||||
const { data } = await RowService(this.$client).create(table.id, values)
|
||||
commit('FINALIZE_ROW', { index, id: data.id })
|
||||
// Check if the newly created row matches the filters.
|
||||
dispatch('updateMatchFilters', { view, row })
|
||||
|
||||
try {
|
||||
const { data } = await RowService(this.$client).create(table.id, values)
|
||||
commit('FINALIZE_ROW', { index, id: data.id })
|
||||
} catch (error) {
|
||||
commit('DELETE_ROW', row.id)
|
||||
throw error
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Deletes an existing row of the provided table. After deleting, the visible rows
|
||||
|
@ -522,24 +581,32 @@ export const actions = {
|
|||
|
||||
try {
|
||||
await RowService(this.$client).delete(table.id, row.id)
|
||||
commit('DELETE_ROW', row.id)
|
||||
|
||||
// We use the provided function to recalculate the scrollTop offset in order
|
||||
// to get fresh data.
|
||||
const scrollTop = getScrollTop()
|
||||
const windowHeight = getters.getWindowHeight
|
||||
|
||||
dispatch('fetchByScrollTop', {
|
||||
gridId: grid.id,
|
||||
scrollTop,
|
||||
windowHeight,
|
||||
})
|
||||
dispatch('visibleByScrollTop', { scrollTop, windowHeight })
|
||||
dispatch('forceDelete', { grid, row, getScrollTop })
|
||||
} catch (error) {
|
||||
commit('SET_ROW_LOADING', { row, value: false })
|
||||
throw error
|
||||
}
|
||||
},
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
forceDelete({ commit, dispatch, getters }, { grid, row, getScrollTop }) {
|
||||
commit('DELETE_ROW', row.id)
|
||||
|
||||
// We use the provided function to recalculate the scrollTop offset in order
|
||||
// to get fresh data.
|
||||
const scrollTop = getScrollTop()
|
||||
const windowHeight = getters.getWindowHeight
|
||||
|
||||
dispatch('fetchByScrollTop', {
|
||||
gridId: grid.id,
|
||||
scrollTop,
|
||||
windowHeight,
|
||||
})
|
||||
dispatch('visibleByScrollTop', { scrollTop, windowHeight })
|
||||
},
|
||||
/**
|
||||
* Adds a field with a provided value to the rows in memory.
|
||||
*/
|
||||
|
@ -584,6 +651,28 @@ export const actions = {
|
|||
setRowHover({ commit }, { row, value }) {
|
||||
commit('SET_ROW_HOVER', { row, value })
|
||||
},
|
||||
/**
|
||||
* Adds a field to the list of selected fields of a row. We use this to indicate
|
||||
* if a row is selected or not.
|
||||
*/
|
||||
addRowSelectedBy({ commit }, { row, field }) {
|
||||
commit('ADD_ROW_SELECTED_BY', { row, fieldId: field.id })
|
||||
},
|
||||
/**
|
||||
* Removes a field from the list of selected fields of a row. We use this to
|
||||
* indicate if a row is selected or not. If the field is not selected anymore
|
||||
* and it does not match the filters it can be removed from the store.
|
||||
*/
|
||||
removeRowSelectedBy(
|
||||
{ dispatch, commit },
|
||||
{ grid, row, field, getScrollTop }
|
||||
) {
|
||||
commit('REMOVE_ROW_SELECTED_BY', { row, fieldId: field.id })
|
||||
|
||||
if (row._.selectedBy.length === 0 && !row._.matchFilters) {
|
||||
dispatch('forceDelete', { grid, row, getScrollTop })
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Refreshes all the buffered data values of a given field. The new values are
|
||||
* requested from the backend and replaced.
|
||||
|
|
1
web-frontend/modules/database/utils/constants.js
Normal file
1
web-frontend/modules/database/utils/constants.js
Normal file
|
@ -0,0 +1 @@
|
|||
export const trueString = ['y', 't', 'o', 'yes', 'true', 'on', '1']
|
377
web-frontend/modules/database/viewFilters.js
Normal file
377
web-frontend/modules/database/viewFilters.js
Normal file
|
@ -0,0 +1,377 @@
|
|||
import { Registerable } from '@baserow/modules/core/registry'
|
||||
import ViewFilterTypeText from '@baserow/modules/database/components/view/ViewFilterTypeText'
|
||||
import ViewFilterTypeNumber from '@baserow/modules/database/components/view/ViewFilterTypeNumber'
|
||||
import ViewFilterTypeBoolean from '@baserow/modules/database/components/view/ViewFilterTypeBoolean'
|
||||
import ViewFilterTypeDate from '@baserow/modules/database/components/view/ViewFilterTypeDate'
|
||||
import { trueString } from '@baserow/modules/database/utils/constants'
|
||||
|
||||
export class ViewFilterType extends Registerable {
|
||||
/**
|
||||
* A human readable name of the view filter type.
|
||||
*/
|
||||
getName() {
|
||||
return null
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
this.type = this.getType()
|
||||
this.name = this.getName()
|
||||
this.compatibleFieldTypes = this.getCompatibleFieldTypes()
|
||||
|
||||
if (this.type === null) {
|
||||
throw new Error('The type name of a view type must be set.')
|
||||
}
|
||||
if (this.name === null) {
|
||||
throw new Error('The name of a view type must be set.')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return object
|
||||
*/
|
||||
serialize() {
|
||||
return {
|
||||
type: this.type,
|
||||
name: this.name,
|
||||
compatibleFieldTypes: this.compatibleFieldTypes,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Should return a component that is responsible for the filter's value. For example
|
||||
* for the equal filter a text field will be added where the user can enter whatever
|
||||
* he wants to filter on.
|
||||
*/
|
||||
getInputComponent() {
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Optionally, right before updating the string value can be prepared. This could for
|
||||
* example be used to convert the value to a number.
|
||||
*/
|
||||
prepareValue(value) {
|
||||
return value
|
||||
}
|
||||
|
||||
/**
|
||||
* Should return the field type names that the filter is compatible with. So for
|
||||
* example ['text', 'long_text']. When that field is selected as filter it is only
|
||||
* possible to select compatible filter types. If no filters are compatible with a
|
||||
* field then that field will be disabled.
|
||||
*/
|
||||
getCompatibleFieldTypes() {
|
||||
return []
|
||||
}
|
||||
|
||||
/**
|
||||
* In order to real time check if the row applies to the filters we also need to
|
||||
* check on the web-frontend side if the value matches. Should return true if the
|
||||
* rowValue applies to the filterValue. This is really unfortunate in my opinion
|
||||
* because basically have the same code twice, but I could not think of an
|
||||
* alternative solution where we keep the real time check and we don't have
|
||||
* to wait for the server in order to tell us if the value matches.
|
||||
*/
|
||||
matches(rowValue, filterValue) {
|
||||
throw new Error('The matches method must be implemented for every filter.')
|
||||
}
|
||||
}
|
||||
|
||||
export class EqualViewFilterType extends ViewFilterType {
|
||||
static getType() {
|
||||
return 'equal'
|
||||
}
|
||||
|
||||
getName() {
|
||||
return 'is'
|
||||
}
|
||||
|
||||
getInputComponent() {
|
||||
return ViewFilterTypeText
|
||||
}
|
||||
|
||||
getCompatibleFieldTypes() {
|
||||
return ['text', 'long_text', 'number']
|
||||
}
|
||||
|
||||
matches(rowValue, filterValue) {
|
||||
if (rowValue === null) {
|
||||
rowValue = ''
|
||||
}
|
||||
|
||||
rowValue = rowValue.toString().toLowerCase().trim()
|
||||
filterValue = filterValue.toString().toLowerCase().trim()
|
||||
return filterValue === '' || rowValue === filterValue
|
||||
}
|
||||
}
|
||||
|
||||
export class NotEqualViewFilterType extends ViewFilterType {
|
||||
static getType() {
|
||||
return 'not_equal'
|
||||
}
|
||||
|
||||
getName() {
|
||||
return 'is not'
|
||||
}
|
||||
|
||||
getInputComponent() {
|
||||
return ViewFilterTypeText
|
||||
}
|
||||
|
||||
getCompatibleFieldTypes() {
|
||||
return ['text', 'long_text', 'number']
|
||||
}
|
||||
|
||||
matches(rowValue, filterValue) {
|
||||
if (rowValue === null) {
|
||||
rowValue = ''
|
||||
}
|
||||
|
||||
rowValue = rowValue.toString().toLowerCase().trim()
|
||||
filterValue = filterValue.toString().toLowerCase().trim()
|
||||
return filterValue === '' || rowValue !== filterValue
|
||||
}
|
||||
}
|
||||
|
||||
export class ContainsViewFilterType extends ViewFilterType {
|
||||
static getType() {
|
||||
return 'contains'
|
||||
}
|
||||
|
||||
getName() {
|
||||
return 'contains'
|
||||
}
|
||||
|
||||
getInputComponent() {
|
||||
return ViewFilterTypeText
|
||||
}
|
||||
|
||||
getCompatibleFieldTypes() {
|
||||
return ['text', 'long_text']
|
||||
}
|
||||
|
||||
matches(rowValue, filterValue) {
|
||||
rowValue = rowValue.toString().toLowerCase().trim()
|
||||
filterValue = filterValue.toString().toLowerCase().trim()
|
||||
return filterValue === '' || rowValue.includes(filterValue)
|
||||
}
|
||||
}
|
||||
|
||||
export class ContainsNotViewFilterType extends ViewFilterType {
|
||||
static getType() {
|
||||
return 'contains_not'
|
||||
}
|
||||
|
||||
getName() {
|
||||
return 'contains not'
|
||||
}
|
||||
|
||||
getInputComponent() {
|
||||
return ViewFilterTypeText
|
||||
}
|
||||
|
||||
getCompatibleFieldTypes() {
|
||||
return ['text', 'long_text']
|
||||
}
|
||||
|
||||
matches(rowValue, filterValue) {
|
||||
rowValue = rowValue.toString().toLowerCase().trim()
|
||||
filterValue = filterValue.toString().toLowerCase().trim()
|
||||
return filterValue === '' || !rowValue.includes(filterValue)
|
||||
}
|
||||
}
|
||||
|
||||
export class DateEqualViewFilterType extends ViewFilterType {
|
||||
static getType() {
|
||||
return 'date_equal'
|
||||
}
|
||||
|
||||
getName() {
|
||||
return 'is date'
|
||||
}
|
||||
|
||||
getInputComponent() {
|
||||
return ViewFilterTypeDate
|
||||
}
|
||||
|
||||
getCompatibleFieldTypes() {
|
||||
return ['date']
|
||||
}
|
||||
|
||||
matches(rowValue, filterValue) {
|
||||
if (rowValue === null) {
|
||||
rowValue = ''
|
||||
}
|
||||
|
||||
rowValue = rowValue.toString().toLowerCase().trim()
|
||||
rowValue = rowValue.slice(0, 10)
|
||||
|
||||
return filterValue === '' || rowValue === filterValue
|
||||
}
|
||||
}
|
||||
|
||||
export class DateNotEqualViewFilterType extends ViewFilterType {
|
||||
static getType() {
|
||||
return 'date_not_equal'
|
||||
}
|
||||
|
||||
getName() {
|
||||
return 'is not date'
|
||||
}
|
||||
|
||||
getInputComponent() {
|
||||
return ViewFilterTypeDate
|
||||
}
|
||||
|
||||
getCompatibleFieldTypes() {
|
||||
return ['date']
|
||||
}
|
||||
|
||||
matches(rowValue, filterValue) {
|
||||
if (rowValue === null) {
|
||||
rowValue = ''
|
||||
}
|
||||
|
||||
rowValue = rowValue.toString().toLowerCase().trim()
|
||||
rowValue = rowValue.slice(0, 10)
|
||||
|
||||
return filterValue === '' || rowValue !== filterValue
|
||||
}
|
||||
}
|
||||
|
||||
export class HigherThanViewFilterType extends ViewFilterType {
|
||||
static getType() {
|
||||
return 'higher_than'
|
||||
}
|
||||
|
||||
getName() {
|
||||
return 'higher than'
|
||||
}
|
||||
|
||||
getInputComponent() {
|
||||
return ViewFilterTypeNumber
|
||||
}
|
||||
|
||||
getCompatibleFieldTypes() {
|
||||
return ['number']
|
||||
}
|
||||
|
||||
matches(rowValue, filterValue) {
|
||||
if (filterValue === '') {
|
||||
return true
|
||||
}
|
||||
|
||||
rowValue = parseFloat(rowValue)
|
||||
filterValue = parseFloat(filterValue)
|
||||
return !isNaN(rowValue) && !isNaN(filterValue) && rowValue > filterValue
|
||||
}
|
||||
}
|
||||
|
||||
export class LowerThanViewFilterType extends ViewFilterType {
|
||||
static getType() {
|
||||
return 'lower_than'
|
||||
}
|
||||
|
||||
getName() {
|
||||
return 'lower than'
|
||||
}
|
||||
|
||||
getInputComponent() {
|
||||
return ViewFilterTypeNumber
|
||||
}
|
||||
|
||||
getCompatibleFieldTypes() {
|
||||
return ['number']
|
||||
}
|
||||
|
||||
matches(rowValue, filterValue) {
|
||||
if (filterValue === '') {
|
||||
return true
|
||||
}
|
||||
|
||||
rowValue = parseFloat(rowValue)
|
||||
filterValue = parseFloat(filterValue)
|
||||
return !isNaN(rowValue) && !isNaN(filterValue) && rowValue < filterValue
|
||||
}
|
||||
}
|
||||
|
||||
export class BooleanViewFilterType extends ViewFilterType {
|
||||
static getType() {
|
||||
return 'boolean'
|
||||
}
|
||||
|
||||
getName() {
|
||||
return 'equals'
|
||||
}
|
||||
|
||||
getInputComponent() {
|
||||
return ViewFilterTypeBoolean
|
||||
}
|
||||
|
||||
getCompatibleFieldTypes() {
|
||||
return ['boolean']
|
||||
}
|
||||
|
||||
matches(rowValue, filterValue) {
|
||||
filterValue = trueString.includes(
|
||||
filterValue.toString().toLowerCase().trim()
|
||||
)
|
||||
rowValue = trueString.includes(rowValue.toString().toLowerCase().trim())
|
||||
return filterValue ? rowValue : !rowValue
|
||||
}
|
||||
}
|
||||
|
||||
export class EmptyViewFilterType extends ViewFilterType {
|
||||
static getType() {
|
||||
return 'empty'
|
||||
}
|
||||
|
||||
getName() {
|
||||
return 'is empty'
|
||||
}
|
||||
|
||||
prepareValue(value) {
|
||||
return ''
|
||||
}
|
||||
|
||||
getCompatibleFieldTypes() {
|
||||
return ['text', 'long_text', 'number', 'date', 'boolean', 'link_row']
|
||||
}
|
||||
|
||||
matches(rowValue, filterValue) {
|
||||
return (
|
||||
rowValue === null ||
|
||||
rowValue === [] ||
|
||||
rowValue === false ||
|
||||
rowValue.toString().trim() === ''
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export class NotEmptyViewFilterType extends ViewFilterType {
|
||||
static getType() {
|
||||
return 'not_empty'
|
||||
}
|
||||
|
||||
getName() {
|
||||
return 'is not empty'
|
||||
}
|
||||
|
||||
prepareValue(value) {
|
||||
return ''
|
||||
}
|
||||
|
||||
getCompatibleFieldTypes() {
|
||||
return ['text', 'long_text', 'number', 'date', 'boolean', 'link_row']
|
||||
}
|
||||
|
||||
matches(rowValue, filterValue) {
|
||||
return !(
|
||||
rowValue === null ||
|
||||
rowValue === [] ||
|
||||
rowValue === false ||
|
||||
rowValue.toString().trim() === ''
|
||||
)
|
||||
}
|
||||
}
|
|
@ -20,11 +20,20 @@ export class ViewType extends Registerable {
|
|||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicates whether it is possible to filter the rows. If true the filter context
|
||||
* menu is added to the header.
|
||||
*/
|
||||
canFilter() {
|
||||
return true
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
this.type = this.getType()
|
||||
this.iconClass = this.getIconClass()
|
||||
this.name = this.getName()
|
||||
this.canFilter = this.canFilter()
|
||||
|
||||
if (this.type === null) {
|
||||
throw new Error('The type name of a view type must be set.')
|
||||
|
@ -99,6 +108,7 @@ export class ViewType extends Registerable {
|
|||
type: this.type,
|
||||
iconClass: this.iconClass,
|
||||
name: this.name,
|
||||
canFilter: this.canFilter,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Add table
Reference in a new issue