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

See merge request 
This commit is contained in:
Bram Wiepjes 2020-09-27 14:41:03 +00:00
commit f12f3f857a
74 changed files with 5530 additions and 250 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -3,6 +3,7 @@ from baserow.core.registry import (
CustomFieldsInstanceMixin, CustomFieldsRegistryMixin, MapAPIExceptionsInstanceMixin,
APIUrlsRegistryMixin, APIUrlsInstanceMixin
)
from .exceptions import FieldTypeAlreadyRegistered, FieldTypeDoesNotExist

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

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

View file

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

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

View file

@ -34,3 +34,4 @@
@import 'time_select';
@import 'settings';
@import 'select_row_modal';
@import 'filters';

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,3 +1,4 @@
@import 'helpers';
@import 'alert';
@import 'button';
@import 'filters';

View file

@ -0,0 +1,8 @@
@mixin filter-dropdown-width($width) {
flex: 0 0 $width;
.dropdown,
.dropdown__selected {
width: $width;
}
}

View file

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

View file

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

View file

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

View file

@ -27,6 +27,11 @@ export default {
newValue: '',
}
},
watch: {
value(value) {
this.set(value)
},
},
mounted() {
this.set(this.value)
},

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,5 +1,5 @@
<template>
<Modal>
<Modal @hidden="$emit('hidden', { row })">
<h2 v-if="primary !== undefined" class="box__title">
{{ getHeading(primary, row) }}
</h2>

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,6 @@
import tableLoading from '@baserow/modules/database/middleware/tableLoading'
/* eslint-disable-next-line */
import Middleware from './middleware'
Middleware.tableLoading = tableLoading

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

View 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: {},
},
}

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1 @@
export const trueString = ['y', 't', 'o', 'yes', 'true', 'on', '1']

View 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() === ''
)
}
}

View file

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