1
0
Fork 0
mirror of https://gitlab.com/bramw/baserow.git synced 2025-04-11 07:51:20 +00:00

Merge branch 'develop'

This commit is contained in:
Bram Wiepjes 2021-01-06 21:46:18 +01:00
commit 3f2f87a10f
113 changed files with 4735 additions and 652 deletions
LICENSEREADME.md
backend
changelog.md
web-frontend/modules

View file

@ -1,6 +1,6 @@
MIT License
Copyright 2020 Baserow
Copyright 2021 Baserow
Permission is hereby granted, free of charge, to any person obtaining a copy of this
software and associated documentation files (the "Software"), to deal in the Software

View file

@ -2,6 +2,9 @@
Open source online database tool and Airtable alternative.
**We're hiring** remote developers! More information at
https://baserow.io/jobs/experienced-full-stack-developer.
![Baserow screenshot](docs/assets/screenshot.png "Baserow screenshot")
## Introduction
@ -106,7 +109,7 @@ Created by Bram Wiepjes (Baserow) - bram@baserow.io.
Distributes under the MIT license. See `LICENSE` for more information.
Version: 0.6.0
Version: 0.7.0
The official repository can be found at https://gitlab.com/bramw/baserow.

View file

@ -6,7 +6,7 @@ from setuptools import find_packages, setup
PROJECT_DIR = os.path.dirname(__file__)
REQUIREMENTS_DIR = os.path.join(PROJECT_DIR, 'requirements')
VERSION = '0.6.0'
VERSION = '0.7.0'
def get_requirements(env):

View file

@ -2,6 +2,7 @@ from rest_framework import serializers
from rest_framework_jwt.serializers import JSONWebTokenSerializer
from django.contrib.auth import get_user_model
from django.contrib.auth.models import update_last_login
from baserow.core.user.utils import normalize_email_address
@ -64,3 +65,14 @@ class NormalizedEmailWebTokenSerializer(JSONWebTokenSerializer):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields[self.username_field] = NormalizedEmailField()
def validate(self, attrs):
"""
This serializer is only used by the ObtainJSONWebToken view which is only used
to generate a new token. When that happens we want to update the user's last
login timestamp.
"""
validated_data = super().validate(attrs)
update_last_login(None, validated_data['user'])
return validated_data

View file

@ -153,7 +153,7 @@ SPECTACULAR_SETTINGS = {
'name': 'MIT',
'url': 'https://gitlab.com/bramw/baserow/-/blob/master/LICENSE'
},
'VERSION': '0.6.0',
'VERSION': '0.7.0',
'SERVE_INCLUDE_SCHEMA': False,
'TAGS': [
{'name': 'User'},

View file

@ -30,6 +30,11 @@ ERROR_ORDER_BY_FIELD_NOT_POSSIBLE = (
'It is not possible to order by {e.field_name} because the field type '
'{e.field_type} does not support filtering.'
)
ERROR_FILTER_FIELD_NOT_FOUND = (
'ERROR_FILTER_FIELD_NOT_FOUND',
HTTP_400_BAD_REQUEST,
'The field {e.field_name} was not found in the table.'
)
ERROR_INCOMPATIBLE_PRIMARY_FIELD_TYPE = (
'ERROR_INCOMPATIBLE_PRIMARY_FIELD_TYPE',
HTTP_400_BAD_REQUEST,

View file

@ -35,6 +35,12 @@ class FieldSerializer(serializers.ModelSerializer):
return field.type
class SelectOptionSerializer(serializers.Serializer):
id = serializers.IntegerField(required=False)
value = serializers.CharField(max_length=255, required=True)
color = serializers.CharField(max_length=255, required=True)
class CreateFieldSerializer(serializers.ModelSerializer):
type = serializers.ChoiceField(
choices=lazy(field_type_registry.get_types, list)(),

View file

@ -13,11 +13,10 @@ logger = logging.getLogger(__name__)
class RowSerializer(serializers.ModelSerializer):
class Meta:
fields = ('id',)
fields = ('id', 'order',)
extra_kwargs = {
'id': {
'read_only': True
}
'id': {'read_only': True},
'order': {'read_only': True}
}
@ -84,6 +83,11 @@ def get_example_row_serializer_class(add_id=False):
read_only=True,
help_text='The unique identifier of the row in the table.'
)
fields['order'] = serializers.DecimalField(
max_digits=40, decimal_places=20, required=False,
help_text='Indicates the position of the row, lowest first and highest '
'last.'
)
field_types = field_type_registry.registry.values()

View file

@ -24,10 +24,15 @@ from baserow.contrib.database.api.rows.serializers import (
)
from baserow.contrib.database.api.tokens.errors import ERROR_NO_PERMISSION_TO_TABLE
from baserow.contrib.database.api.fields.errors import (
ERROR_ORDER_BY_FIELD_NOT_POSSIBLE, ERROR_ORDER_BY_FIELD_NOT_FOUND
ERROR_ORDER_BY_FIELD_NOT_POSSIBLE, ERROR_ORDER_BY_FIELD_NOT_FOUND,
ERROR_FILTER_FIELD_NOT_FOUND
)
from baserow.contrib.database.api.views.errors import (
ERROR_VIEW_FILTER_TYPE_DOES_NOT_EXIST,
ERROR_VIEW_FILTER_TYPE_NOT_ALLOWED_FOR_FIELD
)
from baserow.contrib.database.fields.exceptions import (
OrderByFieldNotFound, OrderByFieldNotPossible
OrderByFieldNotFound, OrderByFieldNotPossible, FilterFieldNotFound
)
from baserow.contrib.database.table.handler import TableHandler
from baserow.contrib.database.table.exceptions import TableDoesNotExist
@ -35,6 +40,11 @@ from baserow.contrib.database.rows.handler import RowHandler
from baserow.contrib.database.rows.exceptions import RowDoesNotExist
from baserow.contrib.database.tokens.handler import TokenHandler
from baserow.contrib.database.tokens.exceptions import NoPermissionToTable
from baserow.contrib.database.views.models import FILTER_TYPE_AND, FILTER_TYPE_OR
from baserow.contrib.database.views.exceptions import (
ViewFilterTypeNotAllowedForField, ViewFilterTypeDoesNotExist
)
from baserow.contrib.database.views.registries import view_filter_type_registry
from .serializers import (
RowSerializer, get_example_row_serializer_class, get_row_serializer_class
@ -81,7 +91,62 @@ class RowsView(APIView):
'separated by comma. By default a field is ordered in '
'ascending (A-Z) order, but by prepending the field with '
'a \'-\' it can be ordered descending (Z-A). '
)
),
OpenApiParameter(
name='filter__{field}__{filter}',
location=OpenApiParameter.QUERY,
type=OpenApiTypes.STR,
description=(
f'The rows can optionally be filtered by the same view filters '
f'available for the views. Multiple filters can be provided if '
f'they follow the same format. The field and filter variable '
f'indicate how to filter and the value indicates where to filter '
f'on.\n\n'
f'For example if you provide the following GET parameter '
f'`filter__field_1__equal=test` then only rows where the value of '
f'field_1 is equal to test are going to be returned.\n\n'
f'The following filters are available: '
f'{", ".join(view_filter_type_registry.get_types())}.'
)
),
OpenApiParameter(
name='filter_type',
location=OpenApiParameter.QUERY,
type=OpenApiTypes.STR,
description=(
'`AND`: Indicates that the rows must match all the provided '
'filters.\n'
'`OR`: Indicates that the rows only have to match one of the '
'filters.\n\n'
'This works only if two or more filters are provided.'
)
),
OpenApiParameter(
name='include',
location=OpenApiParameter.QUERY,
type=OpenApiTypes.STR,
description=(
'All the fields are included in the response by default. You can '
'select a subset of fields by providing the include query '
'parameter. If you for example provide the following GET '
'parameter `include=field_1,field_2` then only the fields with'
'id `1` and id `2` are going to be selected and included in the '
'response. '
)
),
OpenApiParameter(
name='exclude',
location=OpenApiParameter.QUERY,
type=OpenApiTypes.STR,
description=(
'All the fields are included in the response by default. You can '
'select a subset of fields by providing the exclude query '
'parameter. If you for example provide the following GET '
'parameter `exclude=field_1,field_2` then the fields with id `1` '
'and id `2` are going to be excluded from the selection and '
'response.'
)
),
],
tags=['Database table rows'],
operation_id='list_database_table_rows',
@ -105,7 +170,10 @@ class RowsView(APIView):
'ERROR_PAGE_SIZE_LIMIT',
'ERROR_INVALID_PAGE',
'ERROR_ORDER_BY_FIELD_NOT_FOUND',
'ERROR_ORDER_BY_FIELD_NOT_POSSIBLE'
'ERROR_ORDER_BY_FIELD_NOT_POSSIBLE',
'ERROR_FILTER_FIELD_NOT_FOUND',
'ERROR_VIEW_FILTER_TYPE_DOES_NOT_EXIST',
'ERROR_VIEW_FILTER_TYPE_NOT_ALLOWED_FOR_FIELD'
]),
401: get_error_schema(['ERROR_NO_PERMISSION_TO_TABLE']),
404: get_error_schema(['ERROR_TABLE_DOES_NOT_EXIST'])
@ -116,7 +184,10 @@ class RowsView(APIView):
TableDoesNotExist: ERROR_TABLE_DOES_NOT_EXIST,
NoPermissionToTable: ERROR_NO_PERMISSION_TO_TABLE,
OrderByFieldNotFound: ERROR_ORDER_BY_FIELD_NOT_FOUND,
OrderByFieldNotPossible: ERROR_ORDER_BY_FIELD_NOT_POSSIBLE
OrderByFieldNotPossible: ERROR_ORDER_BY_FIELD_NOT_POSSIBLE,
FilterFieldNotFound: ERROR_FILTER_FIELD_NOT_FOUND,
ViewFilterTypeDoesNotExist: ERROR_VIEW_FILTER_TYPE_DOES_NOT_EXIST,
ViewFilterTypeNotAllowedForField: ERROR_VIEW_FILTER_TYPE_NOT_ALLOWED_FOR_FIELD
})
def get(self, request, table_id):
"""
@ -126,12 +197,17 @@ class RowsView(APIView):
table = TableHandler().get_table(request.user, table_id)
TokenHandler().check_table_permissions(request, 'read', table, False)
model = table.get_model()
search = request.GET.get('search')
order_by = request.GET.get('order_by')
include = request.GET.get('include')
exclude = request.GET.get('exclude')
fields = RowHandler().get_include_exclude_fields(table, include, exclude)
queryset = model.objects.all().enhance_by_fields().order_by('id')
model = table.get_model(
fields=fields,
field_ids=[] if fields else None
)
queryset = model.objects.all().enhance_by_fields()
if search:
queryset = queryset.search_all_fields(search)
@ -139,6 +215,14 @@ class RowsView(APIView):
if order_by:
queryset = queryset.order_by_fields_string(order_by)
filter_type = (
FILTER_TYPE_OR
if str(request.GET.get('filter_type')).upper() == 'OR' else
FILTER_TYPE_AND
)
filter_object = {key: request.GET.getlist(key) for key in request.GET.keys()}
queryset = queryset.filter_by_fields_object(filter_object, filter_type)
paginator = PageNumberPagination(limit_page_size=settings.ROW_PAGE_SIZE_LIMIT)
page = paginator.paginate_queryset(queryset, request, self)
serializer_class = get_row_serializer_class(model, RowSerializer,
@ -153,6 +237,11 @@ class RowsView(APIView):
name='table_id', location=OpenApiParameter.PATH, type=OpenApiTypes.INT,
description='Creates a row in the table related to the provided '
'value.'
),
OpenApiParameter(
name='before', location=OpenApiParameter.QUERY, type=OpenApiTypes.INT,
description='If provided then the newly created row will be '
'positioned before the row with the provided id.'
)
],
tags=['Database table rows'],
@ -179,7 +268,8 @@ class RowsView(APIView):
]),
401: get_error_schema(['ERROR_NO_PERMISSION_TO_TABLE']),
404: get_error_schema([
'ERROR_TABLE_DOES_NOT_EXIST'
'ERROR_TABLE_DOES_NOT_EXIST',
'ERROR_ROW_DOES_NOT_EXIST'
])
}
)
@ -188,7 +278,8 @@ class RowsView(APIView):
UserNotInGroupError: ERROR_USER_NOT_IN_GROUP,
TableDoesNotExist: ERROR_TABLE_DOES_NOT_EXIST,
NoPermissionToTable: ERROR_NO_PERMISSION_TO_TABLE,
UserFileDoesNotExist: ERROR_USER_FILE_DOES_NOT_EXIST
UserFileDoesNotExist: ERROR_USER_FILE_DOES_NOT_EXIST,
RowDoesNotExist: ERROR_ROW_DOES_NOT_EXIST,
})
def post(self, request, table_id):
"""
@ -203,7 +294,14 @@ class RowsView(APIView):
validation_serializer = get_row_serializer_class(model)
data = validate_data(validation_serializer, request.data)
row = RowHandler().create_row(request.user, table, data, model)
before_id = request.GET.get('before')
before = (
RowHandler().get_row(request.user, table, before_id, model)
if before_id else
None
)
row = RowHandler().create_row(request.user, table, data, model, before=before)
serializer_class = get_row_serializer_class(model, RowSerializer,
is_response=True)
serializer = serializer_class(row)

View file

@ -16,10 +16,15 @@ ERROR_VIEW_FILTER_NOT_SUPPORTED = (
HTTP_400_BAD_REQUEST,
'Filtering is not supported for the view type.'
)
ERROR_VIEW_FILTER_TYPE_DOES_NOT_EXIST = (
'ERROR_VIEW_FILTER_TYPE_DOES_NOT_EXIST',
HTTP_400_BAD_REQUEST,
'The view filter type {e.type_name} doesn\'t exist.'
)
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.'
'The filter {e.filter_type} is not compatible with field type {e.field_type}.'
)
ERROR_VIEW_SORT_DOES_NOT_EXIST = (
'ERROR_VIEW_SORT_DOES_NOT_EXIST',

View file

@ -116,7 +116,7 @@ class GridViewView(APIView):
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')
queryset = model.objects.all().enhance_by_fields()
# Applies the view filters and sortings to the queryset if there are any.
queryset = view_handler.apply_filters(view, queryset)

View file

@ -46,7 +46,7 @@ class DatabaseConfig(AppConfig):
from .fields.field_types import (
TextFieldType, LongTextFieldType, URLFieldType, NumberFieldType,
BooleanFieldType, DateFieldType, LinkRowFieldType, EmailFieldType,
FileFieldType
FileFieldType, SingleSelectFieldType
)
field_type_registry.register(TextFieldType())
field_type_registry.register(LongTextFieldType())
@ -57,6 +57,7 @@ class DatabaseConfig(AppConfig):
field_type_registry.register(DateFieldType())
field_type_registry.register(LinkRowFieldType())
field_type_registry.register(FileFieldType())
field_type_registry.register(SingleSelectFieldType())
from .fields.field_converters import LinkRowFieldConverter, FileFieldConverter
field_converter_registry.register(LinkRowFieldConverter())
@ -69,7 +70,8 @@ class DatabaseConfig(AppConfig):
EqualViewFilterType, NotEqualViewFilterType, EmptyViewFilterType,
NotEmptyViewFilterType, DateEqualViewFilterType, DateNotEqualViewFilterType,
HigherThanViewFilterType, LowerThanViewFilterType, ContainsViewFilterType,
ContainsNotViewFilterType, BooleanViewFilterType
ContainsNotViewFilterType, BooleanViewFilterType,
SingleSelectEqualViewFilterType, SingleSelectNotEqualViewFilterType
)
view_filter_type_registry.register(EqualViewFilterType())
view_filter_type_registry.register(NotEqualViewFilterType())
@ -79,6 +81,8 @@ class DatabaseConfig(AppConfig):
view_filter_type_registry.register(LowerThanViewFilterType())
view_filter_type_registry.register(DateEqualViewFilterType())
view_filter_type_registry.register(DateNotEqualViewFilterType())
view_filter_type_registry.register(SingleSelectEqualViewFilterType())
view_filter_type_registry.register(SingleSelectNotEqualViewFilterType())
view_filter_type_registry.register(BooleanViewFilterType())
view_filter_type_registry.register(EmptyViewFilterType())
view_filter_type_registry.register(NotEmptyViewFilterType())

View file

@ -21,3 +21,14 @@ class TablesDatabaseRouter(object):
def db_for_write(self, model, **hints):
return self.user_table_database_if_generated_table_database(model)
def allow_relation(self, obj1, obj2, **hints):
"""
We explicitly want to allow relations between the two databases. This way a
database table can make references to for example a select option.
"""
allowed = ('default', settings.USER_TABLE_DATABASE)
if obj1._state.db in allowed and obj2._state.db in allowed:
return True
return None

View file

@ -23,6 +23,7 @@ class PostgresqlLenientDatabaseSchemaEditor:
$$
begin
begin
%(alter_column_prepare_value)s
return %(alert_column_type_function)s::%(type)s;
exception
when others then
@ -33,25 +34,44 @@ class PostgresqlLenientDatabaseSchemaEditor:
language plpgsql;
"""
def __init__(self, *args, alert_column_type_function='p_in'):
def __init__(self, *args, alter_column_prepare_value='',
alert_column_type_function='p_in'):
self.alter_column_prepare_value = alter_column_prepare_value
self.alert_column_type_function = alert_column_type_function
super().__init__(*args)
def _alter_field(self, model, old_field, new_field, old_type, new_type,
old_db_params, new_db_params, strict=False):
if old_type != new_type:
variables = {}
if isinstance(self.alter_column_prepare_value, tuple):
alter_column_prepare_value, v = self.alter_column_prepare_value
variables = {**variables, **v}
else:
alter_column_prepare_value = self.alter_column_prepare_value
if isinstance(self.alert_column_type_function, tuple):
alert_column_type_function, v = self.alert_column_type_function
variables = {**variables, **v}
else:
alert_column_type_function = self.alert_column_type_function
self.execute(self.sql_drop_try_cast)
self.execute(self.sql_create_try_cast % {
"column": self.quote_name(new_field.column),
"type": new_type,
"alert_column_type_function": self.alert_column_type_function
})
"alter_column_prepare_value": alter_column_prepare_value,
"alert_column_type_function": alert_column_type_function
}, variables)
return super()._alter_field(model, old_field, new_field, old_type, new_type,
old_db_params, new_db_params, strict)
@contextlib.contextmanager
def lenient_schema_editor(connection, alert_column_type_function=None):
def lenient_schema_editor(connection, alter_column_prepare_value=None,
alert_column_type_function=None):
"""
A contextual function that yields a modified version of the connection's schema
editor. This temporary version is more lenient then the regular editor. Normally
@ -63,6 +83,9 @@ def lenient_schema_editor(connection, alert_column_type_function=None):
:param connection: The current connection for which to generate the schema editor
for.
:type connection: DatabaseWrapper
:param alter_column_prepare_value: Optionally a query statement converting the
`p_in` value to a string format.
:type alter_column_prepare_value: None or str
:param alert_column_type_function: Optionally the string of a SQL function to
convert the data value to the the new type. The function will have the variable
`p_in` as old value.
@ -88,6 +111,10 @@ def lenient_schema_editor(connection, alert_column_type_function=None):
connection.SchemaEditorClass = schema_editor_class
kwargs = {}
if alter_column_prepare_value:
kwargs['alter_column_prepare_value'] = alter_column_prepare_value
if alert_column_type_function:
kwargs['alert_column_type_function'] = alert_column_type_function

View file

@ -61,6 +61,14 @@ class OrderByFieldNotPossible(Exception):
super().__init__(*args, **kwargs)
class FilterFieldNotFound(Exception):
"""Raised when the field was not found in the table."""
def __init__(self, field_name=None, *args, **kwargs):
self.field_name = field_name
super().__init__(*args, **kwargs)
class IncompatiblePrimaryFieldTypeError(Exception):
"""Raised when the primary field is changed to an incompatible field type."""

View file

@ -6,6 +6,7 @@ from dateutil.parser import ParserError
from datetime import datetime, date
from django.db import models
from django.db.models import Case, When
from django.contrib.postgres.fields import JSONField
from django.core.validators import URLValidator, EmailValidator
from django.core.exceptions import ValidationError
@ -16,7 +17,8 @@ from rest_framework import serializers
from baserow.core.models import UserFile
from baserow.core.user_files.exceptions import UserFileDoesNotExist
from baserow.contrib.database.api.fields.serializers import (
LinkRowValueSerializer, FileFieldRequestSerializer, FileFieldResponseSerializer
LinkRowValueSerializer, FileFieldRequestSerializer, FileFieldResponseSerializer,
SelectOptionSerializer
)
from baserow.contrib.database.api.fields.errors import (
ERROR_LINK_ROW_TABLE_NOT_IN_SAME_DATABASE, ERROR_LINK_ROW_TABLE_NOT_PROVIDED,
@ -24,15 +26,17 @@ from baserow.contrib.database.api.fields.errors import (
)
from .handler import FieldHandler
from .registries import FieldType
from .registries import FieldType, field_type_registry
from .models import (
NUMBER_TYPE_INTEGER, NUMBER_TYPE_DECIMAL, TextField, LongTextField, URLField,
NumberField, BooleanField, DateField, LinkRowField, EmailField, FileField
NumberField, BooleanField, DateField, LinkRowField, EmailField, FileField,
SingleSelectField, SelectOption
)
from .exceptions import (
LinkRowTableNotInSameDatabase, LinkRowTableNotProvided,
IncompatiblePrimaryFieldTypeError
)
from .fields import SingleSelectForeignKey
class TextFieldType(FieldType):
@ -90,7 +94,7 @@ class URLFieldType(FieldType):
def random_value(self, instance, fake, cache):
return fake.url()
def get_alter_column_type_function(self, connection, instance):
def get_alter_column_type_function(self, connection, from_field, to_field):
if connection.vendor == 'postgresql':
return r"""(
case
@ -100,7 +104,7 @@ class URLFieldType(FieldType):
end
)"""
return super().get_alter_column_type_function(connection, instance)
return super().get_alter_column_type_function(connection, from_field, to_field)
class NumberFieldType(FieldType):
@ -112,38 +116,44 @@ class NumberFieldType(FieldType):
serializer_field_names = ['number_type', 'number_decimal_places', 'number_negative']
def prepare_value_for_db(self, instance, value):
if value and instance.number_type == NUMBER_TYPE_DECIMAL:
if value is not None:
value = Decimal(value)
if value and not instance.number_negative and value < 0:
if value is not None and not instance.number_negative and value < 0:
raise ValidationError(f'The value for field {instance.id} cannot be '
f'negative.')
return value
def get_serializer_field(self, instance, **kwargs):
kwargs['required'] = False
kwargs['allow_null'] = True
kwargs['decimal_places'] = (
0
if instance.number_type == NUMBER_TYPE_INTEGER else
instance.number_decimal_places
)
if not instance.number_negative:
kwargs['min_value'] = 0
if instance.number_type == NUMBER_TYPE_INTEGER:
return serializers.IntegerField(**kwargs)
elif instance.number_type == NUMBER_TYPE_DECIMAL:
return serializers.DecimalField(
decimal_places=instance.number_decimal_places,
max_digits=self.MAX_DIGITS + instance.number_decimal_places,
**kwargs
)
return serializers.DecimalField(
max_digits=self.MAX_DIGITS + kwargs['decimal_places'],
required=False,
allow_null=True,
**kwargs
)
def get_model_field(self, instance, **kwargs):
kwargs['null'] = True
kwargs['blank'] = True
if instance.number_type == NUMBER_TYPE_INTEGER:
return models.IntegerField(**kwargs)
elif instance.number_type == NUMBER_TYPE_DECIMAL:
return models.DecimalField(
decimal_places=instance.number_decimal_places,
max_digits=self.MAX_DIGITS + instance.number_decimal_places,
**kwargs
)
kwargs['decimal_places'] = (
0
if instance.number_type == NUMBER_TYPE_INTEGER else
instance.number_decimal_places
)
return models.DecimalField(
max_digits=self.MAX_DIGITS + kwargs['decimal_places'],
null=True,
blank=True,
**kwargs
)
def random_value(self, instance, fake, cache):
if instance.number_type == NUMBER_TYPE_INTEGER:
@ -159,23 +169,23 @@ class NumberFieldType(FieldType):
positive=not instance.number_negative
)
def get_alter_column_type_function(self, connection, instance):
def get_alter_column_type_function(self, connection, from_field, to_field):
if connection.vendor == 'postgresql':
decimal_places = 0
if instance.number_type == NUMBER_TYPE_DECIMAL:
decimal_places = instance.number_decimal_places
if to_field.number_type == NUMBER_TYPE_DECIMAL:
decimal_places = to_field.number_decimal_places
function = f"round(p_in::numeric, {decimal_places})"
if not instance.number_negative:
if not to_field.number_negative:
function = f"greatest({function}, 0)"
return function
return super().get_alter_column_type_function(connection, instance)
return super().get_alter_column_type_function(connection, from_field, to_field)
def after_update(self, from_field, to_field, from_model, to_model, user, connection,
altered_column):
altered_column, before):
"""
The allowing of negative values isn't stored in the database field type. If
the type hasn't changed, but the allowing of negative values has it means that
@ -462,7 +472,7 @@ class LinkRowFieldType(FieldType):
f'as the table {table.id}.'
)
def after_create(self, field, model, user, connection):
def after_create(self, field, model, user, connection, before):
"""
When the field is created we have to add the related field to the related
table so a reversed lookup can be done by the user.
@ -505,7 +515,7 @@ class LinkRowFieldType(FieldType):
from_field.link_row_related_field.save()
def after_update(self, from_field, to_field, from_model, to_model, user, connection,
altered_column):
altered_column, before):
"""
If the old field is not already a link row field we have to create the related
field into the related table.
@ -587,7 +597,7 @@ class EmailFieldType(FieldType):
def random_value(self, instance, fake, cache):
return fake.email()
def get_alter_column_type_function(self, connection, instance):
def get_alter_column_type_function(self, connection, from_field, to_field):
if connection.vendor == 'postgresql':
return r"""(
case
@ -597,7 +607,7 @@ class EmailFieldType(FieldType):
end
)"""
return super().get_alter_column_type_function(connection, instance)
return super().get_alter_column_type_function(connection, from_field, to_field)
class FileFieldType(FieldType):
@ -696,3 +706,155 @@ class FileFieldType(FieldType):
values.append(serialized)
return values
class SingleSelectFieldType(FieldType):
type = 'single_select'
model_class = SingleSelectField
can_have_select_options = True
allowed_fields = ['select_options']
serializer_field_names = ['select_options']
serializer_field_overrides = {
'select_options': SelectOptionSerializer(many=True, required=False)
}
def enhance_queryset(self, queryset, field, name):
return queryset.prefetch_related(
models.Prefetch(name, queryset=SelectOption.objects.using('default').all())
)
def prepare_value_for_db(self, instance, value):
if value is None:
return value
if isinstance(value, int):
try:
return SelectOption.objects.get(field=instance, id=value)
except SelectOption.DoesNotExist:
pass
if isinstance(value, SelectOption) and value.field_id == instance.id:
return value
# If the select option is not found or if it does not belong to the right field
# then the provided value is invalid and a validation error can be raised.
raise ValidationError(f'The provided value is not a valid option.')
def get_serializer_field(self, instance, **kwargs):
return serializers.PrimaryKeyRelatedField(
queryset=SelectOption.objects.filter(field=instance), required=False,
allow_null=True, **kwargs
)
def get_response_serializer_field(self, instance, **kwargs):
return SelectOptionSerializer(required=False, allow_null=True, **kwargs)
def get_serializer_help_text(self, instance):
return (
'This field accepts an `integer` representing the chosen select option id '
'related to the field. Available ids can be found when getting or listing '
'the field. The response represents chosen field, but also the value and '
'color is exposed.'
)
def get_model_field(self, instance, **kwargs):
return SingleSelectForeignKey(
to=SelectOption,
on_delete=models.SET_NULL,
related_name='+',
related_query_name='+',
db_constraint=False,
null=True,
blank=True,
**kwargs
)
def before_create(self, table, primary, values, order, user):
if 'select_options' in values:
return values.pop('select_options')
def after_create(self, field, model, user, connection, before):
if before and len(before) > 0:
FieldHandler().update_field_select_options(user, field, before)
def before_update(self, from_field, to_field_values, user):
if 'select_options' in to_field_values:
FieldHandler().update_field_select_options(
user,
from_field,
to_field_values['select_options']
)
to_field_values.pop('select_options')
def get_alter_column_prepare_value(self, connection, from_field, to_field):
"""
If the new field type isn't a single select field we can convert the plain
text value of the option and maybe that can be used by the new field.
"""
to_field_type = field_type_registry.get_by_model(to_field)
if to_field_type.type != self.type and connection.vendor == 'postgresql':
variables = {}
values_mapping = []
for option in from_field.select_options.all():
variable_name = f'option_{option.id}_value'
variables[variable_name] = option.value
values_mapping.append(f"('{int(option.id)}', %({variable_name})s)")
sql = f"""
p_in = (SELECT value FROM (
VALUES {','.join(values_mapping)}
) AS values (key, value)
WHERE key = p_in);
"""
return sql, variables
return super().get_alter_column_prepare_value(connection, from_field, to_field)
def get_alter_column_type_function(self, connection, from_field, to_field):
"""
If the old field wasn't a single select field we can try to match the old text
values to the new options.
"""
from_field_type = field_type_registry.get_by_model(from_field)
if from_field_type.type != self.type and connection.vendor == 'postgresql':
variables = {}
values_mapping = []
for option in to_field.select_options.all():
variable_name = f'option_{option.id}_value'
variables[variable_name] = option.value
values_mapping.append(
f"(lower(%({variable_name})s), '{int(option.id)}')"
)
return f"""(
SELECT value FROM (
VALUES {','.join(values_mapping)}
) AS values (key, value)
WHERE key = lower(p_in)
)
""", variables
return super().get_alter_column_prepare_value(connection, from_field, to_field)
def get_order(self, field, field_name, view_sort):
"""
If the user wants to sort the results he expects them to be ordered
alphabetically based on the select option value and not in the id which is
stored in the table. This method generates a Case expression which maps the id
to the correct position.
"""
select_options = field.select_options.all().order_by('value')
options = [select_option.pk for select_option in select_options]
options.insert(0, None)
if view_sort.order == 'DESC':
options.reverse()
order = Case(*[
When(**{field_name: option, 'then': index})
for index, option in enumerate(options)
])
return order

View file

@ -0,0 +1,21 @@
from django.db import models
from django.db.models.fields.related_descriptors import ForwardManyToOneDescriptor
class SingleSelectForwardManyToOneDescriptor(ForwardManyToOneDescriptor):
def get_object(self, instance):
"""
Tries to fetch the reference object, but if it fails because it doesn't exist,
the value will be set to None instead of failing hard.
"""
try:
return super().get_object(instance)
except self.field.remote_field.model.DoesNotExist:
setattr(instance, self.field.name, None)
instance.save()
return None
class SingleSelectForeignKey(models.ForeignKey):
forward_related_accessor_class = SingleSelectForwardManyToOneDescriptor

View file

@ -15,7 +15,7 @@ from .exceptions import (
FieldDoesNotExist, IncompatiblePrimaryFieldTypeError
)
from .registries import field_type_registry, field_converter_registry
from .models import Field
from .models import Field, SelectOption
logger = logging.getLogger(__name__)
@ -109,7 +109,8 @@ class FieldHandler:
last_order = model_class.get_last_order(table)
field_values = field_type.prepare_values(field_values, user)
field_type.before_create(table, primary, field_values, last_order, user)
before = field_type.before_create(table, primary, field_values, last_order,
user)
instance = model_class.objects.create(table=table, order=last_order,
primary=primary, **field_values)
@ -123,7 +124,7 @@ class FieldHandler:
if do_schema_change:
schema_editor.add_field(to_model, model_field)
field_type.after_create(instance, to_model, user, connection)
field_type.after_create(instance, to_model, user, connection, before)
return instance
@ -183,7 +184,7 @@ class FieldHandler:
field_values = extract_allowed(kwargs, allowed_fields)
field_values = field_type.prepare_values(field_values, user)
field_type.before_update(old_field, field_values, user)
before = field_type.before_update(old_field, field_values, user)
field = set_allowed_attrs(field_values, allowed_fields, field)
field.save()
@ -228,7 +229,9 @@ class FieldHandler:
# the lenient schema editor.
with lenient_schema_editor(
connection,
field_type.get_alter_column_type_function(connection, field)
old_field_type.get_alter_column_prepare_value(
connection, old_field, field),
field_type.get_alter_column_type_function(connection, old_field, field)
) as schema_editor:
try:
schema_editor.alter_field(from_model, from_model_field,
@ -247,8 +250,16 @@ class FieldHandler:
to_model_field_type = to_model_field.db_parameters(connection)['type']
altered_column = from_model_field_type != to_model_field_type
# If the new field doesn't support select options we can delete those
# relations.
if (
old_field_type.can_have_select_options and
not field_type.can_have_select_options
):
old_field.select_options.all().delete()
field_type.after_update(old_field, field, from_model, to_model, user,
connection, altered_column)
connection, altered_column, before)
return field
@ -292,3 +303,74 @@ class FieldHandler:
# After the field is deleted we are going to to call the after_delete method of
# the field type because some instance cleanup might need to happen.
field_type.after_delete(field, from_model, user, connection)
def update_field_select_options(self, user, field, select_options):
"""
Brings the select options in the desired provided state in a query efficient
manner.
Example: select_options = [
{'id': 1, 'value': 'Option 1', 'color': 'blue'},
{'value': 'Option 2', 'color': 'red'}
]
:param user: The user on whose behalf the change is made.
:type user: User
:param field: The field of which the select options must be updated.
:type field: Field
:param select_options: A list containing dicts with the desired select options.
:type select_options: list
:raises UserNotInGroupError: When the user does not belong to the related group.
"""
group = field.table.database.group
if not group.has_user(user):
raise UserNotInGroupError(user, group)
existing_select_options = field.select_options.all()
# Checks which option ids must be selected by comparing the existing ids with
# the provided ids.
to_delete = [
existing.id
for existing in existing_select_options
if existing.id not in [
desired['id']
for desired in select_options
if 'id' in desired
]
]
if len(to_delete) > 0:
SelectOption.objects.filter(field=field, id__in=to_delete).delete()
# Checks which existing instances must be fetched using a single query.
to_select = [
select_option['id']
for select_option in select_options
if 'id' in select_option
]
if len(to_select) > 0:
for existing in field.select_options.filter(id__in=to_select):
for select_option in select_options:
if select_option.get('id') == existing.id:
select_option['instance'] = existing
to_create = []
for order, select_option in enumerate(select_options):
if 'instance' in select_option:
instance = select_option['instance']
instance.order = order
instance.value = select_option['value']
instance.color = select_option['color']
instance.save()
else:
to_create.append(SelectOption(
field=field, order=order, value=select_option['value'],
color=select_option['color'])
)
if len(to_create) > 0:
SelectOption.objects.bulk_create(to_create)

View file

@ -2,7 +2,9 @@ from django.db import models
from django.contrib.contenttypes.models import ContentType
from baserow.core.utils import to_snake_case, remove_special_characters
from baserow.core.mixins import OrderableMixin, PolymorphicContentTypeMixin
from baserow.core.mixins import (
OrderableMixin, PolymorphicContentTypeMixin, CreatedAndUpdatedOnMixin
)
NUMBER_TYPE_INTEGER = 'INTEGER'
@ -53,7 +55,8 @@ def get_default_field_content_type():
return ContentType.objects.get_for_model(Field)
class Field(OrderableMixin, PolymorphicContentTypeMixin, models.Model):
class Field(CreatedAndUpdatedOnMixin, OrderableMixin, PolymorphicContentTypeMixin,
models.Model):
"""
Because each field type can have custom settings, for example precision for a number
field, values for an option field or checkbox style for a boolean field we need a
@ -105,6 +108,17 @@ class Field(OrderableMixin, PolymorphicContentTypeMixin, models.Model):
return name
class SelectOption(models.Model):
value = models.CharField(max_length=255, blank=True)
color = models.CharField(max_length=255, blank=True)
order = models.PositiveIntegerField()
field = models.ForeignKey(Field, on_delete=models.CASCADE,
related_name='select_options')
class Meta:
ordering = ('order', 'id',)
class TextField(Field):
text_default = models.CharField(
max_length=255,
@ -243,3 +257,7 @@ class EmailField(Field):
class FileField(Field):
pass
class SingleSelectField(Field):
pass

View file

@ -42,6 +42,9 @@ class FieldType(MapAPIExceptionsInstanceMixin, APIUrlsInstanceMixin,
can_be_primary_field = True
"""Some field types cannot be the primary field."""
can_have_select_options = False
"""Indicates whether the field can have select options."""
def prepare_value_for_db(self, instance, value):
"""
When a row is created or updated all the values are going to be prepared for the
@ -186,7 +189,28 @@ class FieldType(MapAPIExceptionsInstanceMixin, APIUrlsInstanceMixin,
return None
def get_alter_column_type_function(self, connection, instance):
def get_alter_column_prepare_value(self, connection, from_field, to_field):
"""
Can return a small SQL statement to convert the `p_in` variable to a readable
text format for the new field.
Example: return "p_in = lower(p_in);"
:param connection: The used connection. This can for example be used to check
the database engine type.
:type connection: DatabaseWrapper
:param from_field: The old field instance.
:type to_field: Field
:param to_field: The new field instance.
:type to_field: Field
:return: The SQL statement converting the value to text for the next field. The
can for example be used to convert a select option to plain text.
:rtype: None or str
"""
return None
def get_alter_column_type_function(self, connection, from_field, to_field):
"""
Can optionally return a SQL function as string to convert the old field's value
when changing the field type. If None is returned no function will be
@ -200,8 +224,10 @@ class FieldType(MapAPIExceptionsInstanceMixin, APIUrlsInstanceMixin,
:param connection: The used connection. This can for example be used to check
the database engine type.
:type connection: DatabaseWrapper
:param instance: The new field instance.
:type instance: Field
:param from_field: The old field instance.
:type to_field: Field
:param to_field: The new field instance.
:type to_field: Field
:return: The SQL function to convert the value.
:rtype: None or str
"""
@ -243,7 +269,7 @@ class FieldType(MapAPIExceptionsInstanceMixin, APIUrlsInstanceMixin,
:type user: User
"""
def after_create(self, field, model, user, connection):
def after_create(self, field, model, user, connection, before):
"""
This hook is called right after the has been created. The schema change has
also been done so the provided model could optionally be used.
@ -256,6 +282,8 @@ class FieldType(MapAPIExceptionsInstanceMixin, APIUrlsInstanceMixin,
:type user: User
:param connection: The connection used to make the database schema change.
:type connection: DatabaseWrapper
:param before: The value returned by the before_created method.
:type before: any
"""
def before_update(self, from_field, to_field_values, user):
@ -298,7 +326,7 @@ class FieldType(MapAPIExceptionsInstanceMixin, APIUrlsInstanceMixin,
"""
def after_update(self, from_field, to_field, from_model, to_model, user, connection,
altered_column):
altered_column, before):
"""
This hook is called right after a field has been updated. In some cases data
mutation still has to be done in order to maintain data integrity. For example
@ -322,6 +350,8 @@ class FieldType(MapAPIExceptionsInstanceMixin, APIUrlsInstanceMixin,
:param altered_column: Indicates whether the column has been altered in the
table. Sometimes data has to be updated if the column hasn't been altered.
:type altered_column: bool
:param before: The value returned by the before_update method.
:type before: any
"""
def after_delete(self, field, model, user, connection):
@ -339,6 +369,26 @@ class FieldType(MapAPIExceptionsInstanceMixin, APIUrlsInstanceMixin,
:type connection: DatabaseWrapper
"""
def get_order(self, field, field_name, view_sort):
"""
This hook can be called to generate a different order by expression. By default
None is returned which means the normal field sorting will be applied.
Optionally a different expression can be generated. This is for example used
by the single select field generates a mapping achieve the correct sorting
based on the select option value.
:param field: The related field object instance.
:type field: Field
:param field_name: The name of the field.
:type field_name: str
:param view_sort: The view sort that must be applied.
:type view_sort: ViewSort
:return: The expression that is added directly to the model.objects.order().
:rtype: Expression or None
"""
return None
class FieldTypeRegistry(APIUrlsRegistryMixin, CustomFieldsRegistryMixin,
ModelRegistryMixin, Registry):

View file

@ -1,6 +1,9 @@
import sys
from math import ceil
from decimal import Decimal
from django.core.management.base import BaseCommand
from django.db.models import Max
from faker import Faker
@ -33,6 +36,11 @@ class Command(BaseCommand):
model = table.get_model()
# Find out what the highest order is because we want to append the new rows.
order = ceil(
model.objects.aggregate(max=Max('order')).get('max') or Decimal('0')
)
for i in range(0, limit):
# Based on the random_value function we have for each type we can
# build a dict with a random value for each field.
@ -48,6 +56,8 @@ class Command(BaseCommand):
values, manytomany_values = row_handler.extract_manytomany_values(
values, model
)
order += Decimal('1')
values['order'] = order
# Insert the row with the randomly created values.
instance = model.objects.create(**values)

View file

@ -0,0 +1,117 @@
# Note that if you have a lot of tables, it might table a while before this migrations
# completes.
import django.utils.timezone
from django.conf import settings
from django.db import migrations, models, connections
from baserow.contrib.database.table.models import Table as TableModel
def exists(cursor, table_id):
cursor.execute(
"""
SELECT exists(
SELECT
1
FROM
information_schema.columns
WHERE
columns.table_name = %s AND
columns.column_name = 'created_on'
)
""",
[f'database_table_{table_id}']
)
rows = cursor.fetchall()
return rows[0][0]
def add_to_tables(apps, schema_editor):
Table = apps.get_model('database', 'Table')
connection = connections[settings.USER_TABLE_DATABASE]
cursor = connection.cursor()
with connection.schema_editor() as tables_schema_editor:
# We need to stop the transaction because we might need to lock a lot of tables
# which could result in an out of memory exception.
tables_schema_editor.atomic.__exit__(None, None, None)
for table in Table.objects.all():
if not exists(cursor, table.id):
to_model = TableModel.get_model(table, field_ids=[])
created_on = to_model._meta.get_field('created_on')
updated_on = to_model._meta.get_field('updated_on')
tables_schema_editor.add_field(to_model, created_on)
tables_schema_editor.add_field(to_model, updated_on)
def remove_from_tables(apps, schema_editor):
Table = apps.get_model('database', 'Table')
connection = connections[settings.USER_TABLE_DATABASE]
cursor = connection.cursor()
with connection.schema_editor() as tables_schema_editor:
tables_schema_editor.atomic.__exit__(None, None, None)
for table in Table.objects.all():
if exists(cursor, table.id):
to_model = TableModel.get_model(table, field_ids=[])
created_on = to_model._meta.get_field('created_on')
updated_on = to_model._meta.get_field('updated_on')
tables_schema_editor.remove_field(to_model, created_on)
tables_schema_editor.remove_field(to_model, updated_on)
class Migration(migrations.Migration):
dependencies = [
('database', '0020_fix_primary_link_row'),
]
operations = [
migrations.AddField(
model_name='field',
name='created_on',
field=models.DateTimeField(
auto_now_add=True,
default=django.utils.timezone.now
),
preserve_default=False,
),
migrations.AddField(
model_name='field',
name='updated_on',
field=models.DateTimeField(auto_now=True),
),
migrations.AddField(
model_name='table',
name='created_on',
field=models.DateTimeField(
auto_now_add=True,
default=django.utils.timezone.now
),
preserve_default=False,
),
migrations.AddField(
model_name='table',
name='updated_on',
field=models.DateTimeField(auto_now=True),
),
migrations.AddField(
model_name='view',
name='created_on',
field=models.DateTimeField(
auto_now_add=True,
default=django.utils.timezone.now
),
preserve_default=False,
),
migrations.AddField(
model_name='view',
name='updated_on',
field=models.DateTimeField(auto_now=True),
),
migrations.RunPython(add_to_tables, remove_from_tables)
]

View file

@ -0,0 +1,72 @@
# Note that if you have a lot of tables, it might table a while before this migration
# completes.
from django.conf import settings
from django.db import migrations, connections
from django.db.models import F
from baserow.contrib.database.table.models import Table as TableModel
def exists(cursor, table_id):
cursor.execute(
"""
SELECT exists(
SELECT
1
FROM
information_schema.columns
WHERE
columns.table_name = %s AND
columns.column_name = 'order'
)
""",
[f'database_table_{table_id}']
)
rows = cursor.fetchall()
return rows[0][0]
def add_to_tables(apps, schema_editor):
Table = apps.get_model('database', 'Table')
connection = connections[settings.USER_TABLE_DATABASE]
cursor = connection.cursor()
with connection.schema_editor() as tables_schema_editor:
# We need to stop the transaction because we might need to lock a lot of tables
# which could result in an out of memory exception.
tables_schema_editor.atomic.__exit__(None, None, None)
for table in Table.objects.all():
if not exists(cursor, table.id):
to_model = TableModel.get_model(table, field_ids=[])
order = to_model._meta.get_field('order')
order.default = '1'
tables_schema_editor.add_field(to_model, order)
to_model.objects.all().update(order=F('id'))
def remove_from_tables(apps, schema_editor):
Table = apps.get_model('database', 'Table')
connection = connections[settings.USER_TABLE_DATABASE]
cursor = connection.cursor()
with connection.schema_editor() as tables_schema_editor:
tables_schema_editor.atomic.__exit__(None, None, None)
for table in Table.objects.all():
if exists(cursor, table.id):
to_model = TableModel.get_model(table, field_ids=[])
order = to_model._meta.get_field('order')
tables_schema_editor.remove_field(to_model, order)
class Migration(migrations.Migration):
dependencies = [
('database', '0021_auto_20201215_2047'),
]
operations = [
migrations.RunPython(add_to_tables, remove_from_tables)
]

View file

@ -0,0 +1,45 @@
# We need to change all NumberFields that are Integers to use DecimalField in Django
# and NUMERIC(50, 0) in Postgres. This migration converts all the existing Integer data
# types in fields to Decimal.
from baserow.contrib.database.fields.models import NUMBER_TYPE_INTEGER
from django.conf import settings
from django.db import migrations, connections
from baserow.contrib.database.fields.models import Field as FieldModel
def alter_sql(schema_editor, table_name, column_name):
changes_sql = schema_editor.sql_alter_column_type % {
"column": schema_editor.quote_name(column_name),
"type": 'NUMERIC(50,0)',
}
return schema_editor.sql_alter_column % {
"table": schema_editor.quote_name(table_name),
"changes": changes_sql,
}
def forward(apps, schema_editor):
NumberField = apps.get_model('database', 'NumberField')
connection = connections[settings.USER_TABLE_DATABASE]
with connection.schema_editor() as tables_schema_editor:
# We need to stop the transaction because we might need to lock a lot of tables
# which could result in an out of memory exception.
tables_schema_editor.atomic.__exit__(None, None, None)
for field in NumberField.objects.filter(number_type=NUMBER_TYPE_INTEGER):
table_name = f'database_table_{field.table.id}'
column_name = FieldModel.db_column.__get__(field, FieldModel)
sql = alter_sql(tables_schema_editor, table_name, column_name)
tables_schema_editor.execute(sql)
class Migration(migrations.Migration):
dependencies = [
('database', '0022_row_order'),
]
operations = [
migrations.RunPython(forward, migrations.RunPython.noop),
]

View file

@ -0,0 +1,59 @@
# Generated by Django 2.2.11 on 2020-12-27 20:58
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('database', '0023_convert_int_to_bigint'),
]
operations = [
migrations.CreateModel(
name='SingleSelectField',
fields=[
('field_ptr', models.OneToOneField(
auto_created=True,
on_delete=django.db.models.deletion.CASCADE,
parent_link=True,
primary_key=True,
serialize=False,
to='database.Field'
)),
],
options={
'abstract': False,
},
bases=('database.field',),
),
migrations.CreateModel(
name='SelectOption',
fields=[
('id', models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name='ID'
)),
('value', models.CharField(
blank=True,
max_length=255
)),
('color', models.CharField(
blank=True,
max_length=255
)),
('order', models.PositiveIntegerField()),
('field', models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name='select_options',
to='database.Field'
)),
],
options={
'ordering': ('order', 'id'),
},
),
]

View file

@ -54,7 +54,7 @@ class DatabasePlugin(Plugin):
fields=[notes_field, active_field]
)
model = table.get_model(attribute_names=True)
model.objects.create(name='Elon', last_name='Musk', active=True)
model.objects.create(name='Elon', last_name='Musk', active=True, order=1)
model.objects.create(
name='Bill',
last_name='Gates',
@ -63,10 +63,11 @@ class DatabasePlugin(Plugin):
'dignissim, urna eget rutrum sollicitudin, sapien diam interdum nisi, '
'quis malesuada nibh eros a est.'
),
active=False
active=False,
order=2
)
model.objects.create(name='Mark', last_name='Zuckerburg', active=True)
model.objects.create(name='Jeffrey', last_name='Bezos', active=True)
model.objects.create(name='Mark', last_name='Zuckerburg', active=True, order=3)
model.objects.create(name='Jeffrey', last_name='Bezos', active=True, order=4)
# Creating the example projects table.
table_2 = table_handler.create_table(user, database, name='Projects')
@ -80,9 +81,11 @@ class DatabasePlugin(Plugin):
user, table_2, BooleanFieldType.type, name='Active'
)
model = table_2.get_model(attribute_names=True)
model.objects.create(name='Tesla', active=True, started=date(2020, 6, 1))
model.objects.create(name='SpaceX', active=False)
model.objects.create(name='Amazon', active=False, started=date(2018, 1, 1))
model.objects.create(name='Tesla', active=True, started=date(2020, 6, 1),
order=1)
model.objects.create(name='SpaceX', active=False, order=2)
model.objects.create(name='Amazon', active=False, started=date(2018, 1, 1),
order=3)
view_handler.update_grid_view_field_options(
projects_view,
{active_field.id: {'width': 100}},

View file

@ -1,10 +1,14 @@
import re
from math import floor, ceil
from decimal import Decimal
from django.db import transaction
from django.db.models import Max, F, Q
from django.db.models.fields.related import ManyToManyField
from django.conf import settings
from baserow.core.exceptions import UserNotInGroupError
from baserow.contrib.database.fields.models import Field
from .exceptions import RowDoesNotExist
@ -53,6 +57,62 @@ class RowHandler:
if str(key).isnumeric() or field_pattern.match(str(key))
]
def extract_field_ids_from_string(self, value):
"""
Extracts the field ids from a string. Multiple ids can be separated by a comma.
For example if you provide 'field_1,field_2' then [1, 2] is returned.
:param value: A string containing multiple ids separated by comma.
:type value: str
:return: A list containing the field ids as integers.
:rtype: list
"""
if not value:
return []
return [
int(re.sub("[^0-9]", "", str(v)))
for v in value.split(',')
if any(c.isdigit() for c in v)
]
def get_include_exclude_fields(self, table, include=None, exclude=None):
"""
Returns a field queryset containing the requested fields based on the include
and exclude parameter.
:param table: The table where to select the fields from. Field id's that are
not in the table won't be included.
:type table: Table
:param include: The field ids that must be included. Only the provided ones
are going to be in the returned queryset. Multiple can be provided
separated by comma
:type include: str
:param exclude: The field ids that must be excluded. Only the ones that are not
provided are going to be in the returned queryset. Multiple can be provided
separated by comma.
:type exclude: str
:return: A Field's QuerySet containing the allowed fields based on the provided
input.
:rtype: QuerySet
"""
queryset = Field.objects.filter(table=table)
include_ids = self.extract_field_ids_from_string(include)
exclude_ids = self.extract_field_ids_from_string(exclude)
if len(include_ids) == 0 and len(exclude_ids) == 0:
return None
if len(include_ids) > 0:
queryset = queryset.filter(id__in=include_ids)
if len(exclude_ids) > 0:
queryset = queryset.filter(~Q(id__in=exclude_ids))
return queryset
def extract_manytomany_values(self, values, model):
"""
Extracts the ManyToMany values out of the values because they need to be
@ -114,7 +174,7 @@ class RowHandler:
return row
def create_row(self, user, table, values=None, model=None):
def create_row(self, user, table, values=None, model=None, before=None):
"""
Creates a new row for a given table with the provided values.
@ -128,6 +188,9 @@ class RowHandler:
:param model: If a model is already generated it can be provided here to avoid
having to generate the model again.
:type model: Model
:param before: If provided the new row will be placed right before that row
instance.
:type before: Table
:raises UserNotInGroupError: When the user does not belong to the related group.
:return: The created row instance.
:rtype: Model
@ -146,6 +209,26 @@ class RowHandler:
values = self.prepare_values(model._field_objects, values)
values, manytomany_values = self.extract_manytomany_values(values, model)
if before:
# Here we calculate the order value, which indicates the position of the
# row, by subtracting a fraction of the row that it must be placed
# before. The same fraction is also going to be subtracted from the other
# rows that have been placed before. By using these fractions we don't
# have to re-order every row in the table.
change = Decimal('0.00000000000000000001')
values['order'] = before.order - change
model.objects.filter(
order__gt=floor(values['order']),
order__lte=values['order']
).update(order=F('order') - change)
else:
# Because the row is by default added as last, we have to figure out what
# the highest order is and increase that by one. Because the order of new
# rows should always be a whole number we round it up.
values['order'] = ceil(
model.objects.aggregate(max=Max('order')).get('max') or Decimal('0')
) + 1
instance = model.objects.create(**values)
for name, value in manytomany_values.items():

View file

@ -179,11 +179,11 @@ class TableHandler:
ViewHandler().create_view(user, table, GridViewType.type, name='Grid')
bulk_data = [
model(**{
model(order=index + 1, **{
f'field_{fields[index].id}': str(value)
for index, value in enumerate(row)
})
for row in data
for index, row in enumerate(data)
]
model.objects.bulk_create(bulk_data)
@ -215,8 +215,8 @@ class TableHandler:
view_handler.update_grid_view_field_options(view, field_options, fields=fields)
model = table.get_model(attribute_names=True)
model.objects.create(name='Tesla', active=True)
model.objects.create(name='Amazon', active=False)
model.objects.create(name='Tesla', active=True, order=1)
model.objects.create(name='Amazon', active=False, order=2)
def update_table(self, user, table, **kwargs):
"""

View file

@ -1,12 +1,22 @@
import re
from decimal import Decimal, DecimalException
from django.db import models
from django.db.models import Q
from baserow.core.mixins import OrderableMixin
from baserow.core.mixins import OrderableMixin, CreatedAndUpdatedOnMixin
from baserow.contrib.database.fields.exceptions import (
OrderByFieldNotFound, OrderByFieldNotPossible
OrderByFieldNotFound, OrderByFieldNotPossible, FilterFieldNotFound
)
from baserow.contrib.database.views.registries import view_filter_type_registry
from baserow.contrib.database.fields.registries import field_type_registry
from baserow.contrib.database.views.models import FILTER_TYPE_AND, FILTER_TYPE_OR
from baserow.contrib.database.views.exceptions import ViewFilterTypeNotAllowedForField
deconstruct_filter_key_regex = re.compile(
r'filter__field_([0-9]+)__([a-zA-Z0-9_]*)$'
)
class TableModelQuerySet(models.QuerySet):
@ -42,8 +52,12 @@ class TableModelQuerySet(models.QuerySet):
"""
search_queries = models.Q()
excluded = ('order', 'created_on', 'updated_on')
for field in self.model._meta.get_fields():
if field.name in excluded:
continue
if (
isinstance(field, models.CharField) or
isinstance(field, models.TextField)
@ -61,6 +75,13 @@ class TableModelQuerySet(models.QuerySet):
})
except ValueError:
pass
elif isinstance(field, models.DecimalField):
try:
search_queries = search_queries | models.Q(**{
f'{field.name}': Decimal(search)
})
except (ValueError, DecimalException):
pass
return self.filter(search_queries) if len(search_queries) > 0 else self
@ -110,16 +131,92 @@ class TableModelQuerySet(models.QuerySet):
field_name
)
order_by.append('order')
order_by.append('id')
return self.order_by(*order_by)
def filter_by_fields_object(self, filter_object, filter_type=FILTER_TYPE_AND):
"""
Filters the query by the provided filters in the filter_object. The following
format `filter__field_{id}__{view_filter_type}` is expected as key and multiple
values can be provided as a list containing strings. Only the view filter types
are allowed.
Example: {
'filter__field_{id}__{view_filter_type}': {value}.
}
:param filter_object: The object containing the field and filter type as key
and the filter value as value.
:type filter_object: object
:param filter_type: Indicates if the provided filters are in an AND or OR
statement.
:type filter_type: str
:raises ValueError: Raised when the provided filer_type isn't AND or OR.
:raises FilterFieldNotFound: Raised when the provided field isn't found in
the model.
:raises ViewFilterTypeDoesNotExist: when the view filter type doesn't exist.
:raises ViewFilterTypeNotAllowedForField: when the view filter type isn't
compatible with field type.
:return: The filtered queryset.
:rtype: QuerySet
"""
if filter_type not in [FILTER_TYPE_AND, FILTER_TYPE_OR]:
raise ValueError(f'Unknown filter type {filter_type}.')
q_filters = Q()
for key, values in filter_object.items():
matches = deconstruct_filter_key_regex.match(key)
if not matches:
continue
field_id = int(matches[1])
if field_id not in self.model._field_objects:
raise FilterFieldNotFound(
field_id, f'Field {field_id} does not exist.'
)
field_name = self.model._field_objects[field_id]['name']
field_type = self.model._field_objects[field_id]['type'].type
model_field = self.model._meta.get_field(field_name)
view_filter_type = view_filter_type_registry.get(matches[2])
if field_type not in view_filter_type.compatible_field_types:
raise ViewFilterTypeNotAllowedForField(
matches[2],
field_type,
)
if not isinstance(values, list):
values = [values]
for value in values:
q_filter = view_filter_type.get_filter(
field_name,
value,
model_field
)
# Depending on filter type we are going to combine the Q either as
# AND or as OR.
if filter_type == FILTER_TYPE_AND:
q_filters &= q_filter
elif filter_type == FILTER_TYPE_OR:
q_filters |= q_filter
return self.filter(q_filters)
class TableModelManager(models.Manager):
def get_queryset(self):
return TableModelQuerySet(self.model, using=self._db)
class Table(OrderableMixin, models.Model):
class Table(CreatedAndUpdatedOnMixin, OrderableMixin, models.Model):
database = models.ForeignKey('database.Database', on_delete=models.CASCADE)
order = models.PositiveIntegerField()
name = models.CharField(max_length=255)
@ -168,7 +265,7 @@ class Table(OrderableMixin, models.Model):
'managed': False,
'db_table': f'database_table_{self.id}',
'app_label': app_label,
'ordering': ['id']
'ordering': ['order', 'id']
})
attrs = {
@ -182,7 +279,10 @@ class Table(OrderableMixin, models.Model):
'_field_objects': {},
# We are using our own table model manager to implement some queryset
# helpers.
'objects': TableModelManager()
'objects': TableModelManager(),
# Indicates which position the row has.
'order': models.DecimalField(max_digits=40, decimal_places=20,
editable=False, db_index=True, default=1)
}
# Construct a query to fetch all the fields of that table.
@ -198,7 +298,7 @@ class Table(OrderableMixin, models.Model):
# Create a combined list of fields that must be added and belong to the this
# table.
fields = fields + [field for field in fields_query]
fields = list(fields) + [field for field in fields_query]
# If there are duplicate field names we have to store them in a list so we know
# later which ones are duplicate.
@ -241,7 +341,7 @@ class Table(OrderableMixin, models.Model):
# Create the model class.
model = type(
str(f'Table{self.pk}Model'),
(models.Model,),
(CreatedAndUpdatedOnMixin, models.Model,),
attrs
)

View file

@ -33,6 +33,16 @@ class ViewFilterNotSupported(Exception):
class ViewFilterTypeNotAllowedForField(Exception):
"""Raised when the view filter type is compatible with the field type."""
def __init__(self, filter_type=None, field_type=None, *args, **kwargs):
self.filter_type = filter_type
self.field_type = field_type
super().__init__(
f'The view filter type {filter_type} is not compatible with field type '
f'{field_type}.',
*args,
**kwargs
)
class ViewFilterTypeDoesNotExist(InstanceTypeDoesNotExist):
"""Raised when the view filter type was not found in the registry."""

View file

@ -340,8 +340,7 @@ class ViewHandler:
# 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}.'
type_name, field_type.type
)
# Check if field belongs to the grid views table
@ -389,8 +388,8 @@ class ViewHandler:
# 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}.'
type_name,
field_type.type
)
# If the field has changed we need to check if the field belongs to the table.
@ -460,23 +459,32 @@ class ViewHandler:
order_by = []
for view_filter in view.viewsort_set.all():
for view_sort in view.viewsort_set.all():
# If the to be sort field is not present in the `_field_objects` we
# cannot filter so we raise a ValueError.
if view_filter.field_id not in model._field_objects:
if view_sort.field_id not in model._field_objects:
raise ValueError(f'The table model does not contain field '
f'{view_filter.field_id}.')
f'{view_sort.field_id}.')
field_name = model._field_objects[view_filter.field_id]['name']
order = F(field_name)
field = model._field_objects[view_sort.field_id]['field']
field_name = model._field_objects[view_sort.field_id]['name']
field_type = model._field_objects[view_sort.field_id]['type']
if view_filter.order == 'ASC':
order = order.asc(nulls_first=True)
else:
order = order.desc(nulls_last=True)
order = field_type.get_order(field, field_name, view_sort)
# If the field type does not have a specific ordering expression we can
# order the default way.
if not order:
order = F(field_name)
if view_sort.order == 'ASC':
order = order.asc(nulls_first=True)
else:
order = order.desc(nulls_last=True)
order_by.append(order)
order_by.append('order')
order_by.append('id')
queryset = queryset.order_by(*order_by)

View file

@ -1,7 +1,9 @@
from django.db import models
from django.contrib.contenttypes.models import ContentType
from baserow.core.mixins import OrderableMixin, PolymorphicContentTypeMixin
from baserow.core.mixins import (
OrderableMixin, PolymorphicContentTypeMixin, CreatedAndUpdatedOnMixin
)
from baserow.contrib.database.fields.models import Field
@ -24,7 +26,8 @@ def get_default_view_content_type():
return ContentType.objects.get_for_model(View)
class View(OrderableMixin, PolymorphicContentTypeMixin, models.Model):
class View(CreatedAndUpdatedOnMixin, OrderableMixin, PolymorphicContentTypeMixin,
models.Model):
table = models.ForeignKey('database.Table', on_delete=models.CASCADE)
order = models.PositiveIntegerField()
name = models.CharField(max_length=255)

View file

@ -6,12 +6,13 @@ 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 django.db.models.fields.related import ManyToManyField, ForeignKey
from django.contrib.postgres.fields import JSONField
from baserow.contrib.database.fields.field_types import (
TextFieldType, LongTextFieldType, URLFieldType, NumberFieldType, DateFieldType,
LinkRowFieldType, BooleanFieldType, EmailFieldType, FileFieldType
LinkRowFieldType, BooleanFieldType, EmailFieldType, FileFieldType,
SingleSelectFieldType
)
from .registries import ViewFilterType
@ -204,6 +205,33 @@ class DateNotEqualViewFilterType(NotViewFilterTypeMixin, DateEqualViewFilterType
type = 'date_not_equal'
class SingleSelectEqualViewFilterType(ViewFilterType):
"""
The single select equal filter accepts a select option id as filter value. This
filter is only compatible with the SingleSelectFieldType field type.
"""
type = 'single_select_equal'
compatible_field_types = [SingleSelectFieldType.type]
def get_filter(self, field_name, value, model_field):
value = value.strip()
if value == '':
return Q()
try:
int(value)
return Q(**{f'{field_name}_id': value})
except Exception:
return Q()
class SingleSelectNotEqualViewFilterType(NotViewFilterTypeMixin,
SingleSelectEqualViewFilterType):
type = 'single_select_not_equal'
class BooleanViewFilterType(ViewFilterType):
"""
The boolean filter tries to convert the provided filter value to a boolean and
@ -253,12 +281,16 @@ class EmptyViewFilterType(ViewFilterType):
DateFieldType.type,
LinkRowFieldType.type,
EmailFieldType.type,
FileFieldType.type
FileFieldType.type,
SingleSelectFieldType.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):
if (
isinstance(model_field, ManyToManyField) or
isinstance(model_field, ForeignKey)
):
return Q(**{f'{field_name}': None})
if isinstance(model_field, BooleanField):

View file

@ -28,6 +28,10 @@ class InstanceTypeDoesNotExist(Exception):
Raised when a requested instance model instance isn't registered in the registry.
"""
def __init__(self, type_name, *args, **kwargs):
self.type_name = type_name
super().__init__(*args, **kwargs)
class ApplicationTypeAlreadyRegistered(InstanceTypeAlreadyRegistered):
pass

View file

@ -0,0 +1,56 @@
# Generated by Django 2.2.11 on 2020-12-15 20:47
from django.db import migrations, models
import django.utils.timezone
class Migration(migrations.Migration):
dependencies = [
('core', '0002_userfile'),
]
operations = [
migrations.AddField(
model_name='application',
name='created_on',
field=models.DateTimeField(
auto_now_add=True,
default=django.utils.timezone.now
),
preserve_default=False,
),
migrations.AddField(
model_name='application',
name='updated_on',
field=models.DateTimeField(auto_now=True),
),
migrations.AddField(
model_name='group',
name='created_on',
field=models.DateTimeField(
auto_now_add=True,
default=django.utils.timezone.now
),
preserve_default=False,
),
migrations.AddField(
model_name='group',
name='updated_on',
field=models.DateTimeField(auto_now=True),
),
migrations.AddField(
model_name='groupuser',
name='created_on',
field=models.DateTimeField(
auto_now_add=True,
default=django.utils.timezone.now
),
preserve_default=False,
),
migrations.AddField(
model_name='groupuser',
name='updated_on',
field=models.DateTimeField(auto_now=True),
),
]

View file

@ -119,3 +119,16 @@ class PolymorphicContentTypeMixin:
# properties so that they wont return the values of the old type.
del self.specific
del self.specific_class
class CreatedAndUpdatedOnMixin(models.Model):
"""
This mixin introduces two new fields that store the created on and updated on
timestamps.
"""
created_on = models.DateTimeField(auto_now_add=True, blank=True, editable=False)
updated_on = models.DateTimeField(auto_now=True, blank=True, editable=False)
class Meta:
abstract = True

View file

@ -5,7 +5,9 @@ from django.contrib.contenttypes.models import ContentType
from baserow.core.user_files.models import UserFile
from .managers import GroupQuerySet
from .mixins import OrderableMixin, PolymorphicContentTypeMixin
from .mixins import (
OrderableMixin, PolymorphicContentTypeMixin, CreatedAndUpdatedOnMixin
)
__all__ = ['UserFile']
@ -17,7 +19,7 @@ def get_default_application_content_type():
return ContentType.objects.get_for_model(Application)
class Group(models.Model):
class Group(CreatedAndUpdatedOnMixin, models.Model):
name = models.CharField(max_length=100)
users = models.ManyToManyField(User, through='GroupUser')
@ -32,7 +34,7 @@ class Group(models.Model):
return f'<Group id={self.id}, name={self.name}>'
class GroupUser(OrderableMixin, models.Model):
class GroupUser(CreatedAndUpdatedOnMixin, OrderableMixin, models.Model):
user = models.ForeignKey(User, on_delete=models.CASCADE)
group = models.ForeignKey(Group, on_delete=models.CASCADE)
order = models.PositiveIntegerField()
@ -46,7 +48,8 @@ class GroupUser(OrderableMixin, models.Model):
return cls.get_highest_order_of_queryset(queryset) + 1
class Application(OrderableMixin, PolymorphicContentTypeMixin, models.Model):
class Application(CreatedAndUpdatedOnMixin, OrderableMixin,
PolymorphicContentTypeMixin, models.Model):
group = models.ForeignKey(Group, on_delete=models.CASCADE)
name = models.CharField(max_length=50)
order = models.PositiveIntegerField()

View file

@ -185,8 +185,9 @@ class Registry(object):
"""
if type_name not in self.registry:
raise self.does_not_exist_exception_class(f'The {self.name} type '
f'{type_name} does not exist.')
raise self.does_not_exist_exception_class(
type_name, f'The {self.name} type {type_name} does not exist.'
)
return self.registry[type_name]

View file

@ -1,6 +1,8 @@
import pytest
from pytz import timezone
from unittest.mock import patch
from datetime import datetime
from freezegun import freeze_time
from rest_framework.status import HTTP_200_OK, HTTP_400_BAD_REQUEST
@ -19,8 +21,10 @@ User = get_user_model()
@pytest.mark.django_db
def test_token_auth(api_client, data_fixture):
data_fixture.create_user(email='test@test.nl', password='password',
first_name='Test1')
user = data_fixture.create_user(email='test@test.nl', password='password',
first_name='Test1')
assert not user.last_login
response = api_client.post(reverse('api:user:token_auth'), {
'username': 'no_existing@test.nl',
@ -38,27 +42,35 @@ def test_token_auth(api_client, data_fixture):
assert response.status_code == HTTP_400_BAD_REQUEST
assert len(json['non_field_errors']) > 0
response = api_client.post(reverse('api:user:token_auth'), {
'username': 'test@test.nl',
'password': 'password'
}, format='json')
json = response.json()
assert response.status_code == HTTP_200_OK
assert 'token' in json
assert 'user' in json
assert json['user']['username'] == 'test@test.nl'
assert json['user']['first_name'] == 'Test1'
with freeze_time('2020-01-01 12:00'):
response = api_client.post(reverse('api:user:token_auth'), {
'username': 'test@test.nl',
'password': 'password'
}, format='json')
json = response.json()
assert response.status_code == HTTP_200_OK
assert 'token' in json
assert 'user' in json
assert json['user']['username'] == 'test@test.nl'
assert json['user']['first_name'] == 'Test1'
response = api_client.post(reverse('api:user:token_auth'), {
'username': ' teSt@teSt.nL ',
'password': 'password'
}, format='json')
json = response.json()
assert response.status_code == HTTP_200_OK
assert 'token' in json
assert 'user' in json
assert json['user']['username'] == 'test@test.nl'
assert json['user']['first_name'] == 'Test1'
user.refresh_from_db()
assert user.last_login == datetime(2020, 1, 1, 12, 00, tzinfo=timezone('UTC'))
with freeze_time('2020-01-02 12:00'):
response = api_client.post(reverse('api:user:token_auth'), {
'username': ' teSt@teSt.nL ',
'password': 'password'
}, format='json')
json = response.json()
assert response.status_code == HTTP_200_OK
assert 'token' in json
assert 'user' in json
assert json['user']['username'] == 'test@test.nl'
assert json['user']['first_name'] == 'Test1'
user.refresh_from_db()
assert user.last_login == datetime(2020, 1, 2, 12, 00, tzinfo=timezone('UTC'))
@pytest.mark.django_db

View file

@ -3,13 +3,14 @@ from faker import Faker
from pytz import timezone
from datetime import date, datetime
from freezegun import freeze_time
from decimal import Decimal
from rest_framework.status import HTTP_200_OK, HTTP_204_NO_CONTENT, HTTP_400_BAD_REQUEST
from django.shortcuts import reverse
from baserow.contrib.database.fields.models import (
LongTextField, URLField, DateField, EmailField, FileField
LongTextField, URLField, DateField, EmailField, FileField, NumberField
)
@ -643,3 +644,201 @@ def test_file_field_type(api_client, data_fixture):
assert (
response_json['results'][2][f'field_{field_id}'][1]['name'] == user_file_2.name
)
@pytest.mark.django_db
def test_number_field_type(api_client, data_fixture):
user, token = data_fixture.create_user_and_token(
email='test@test.nl', password='password', first_name='Test1')
table = data_fixture.create_database_table(user=user)
# Create a positive integer field
response = api_client.post(
reverse('api:database:fields:list', kwargs={'table_id': table.id}),
{
'name': 'PositiveInt',
'type': 'number',
'number_type': 'INTEGER',
'number_negative': False,
},
format='json',
HTTP_AUTHORIZATION=f'JWT {token}'
)
# Make sure the field was created properly
response_json = response.json()
assert response.status_code == HTTP_200_OK
assert response_json['type'] == 'number'
assert NumberField.objects.all().count() == 1
positive_int_field_id = response_json['id']
# Create a negative integer field
response = api_client.post(
reverse('api:database:fields:list', kwargs={'table_id': table.id}),
{
'name': 'NegativeInt',
'type': 'number',
'number_type': 'INTEGER',
'number_negative': True,
},
format='json',
HTTP_AUTHORIZATION=f'JWT {token}'
)
# Make sure the field was created properly
response_json = response.json()
assert response.status_code == HTTP_200_OK
assert response_json['type'] == 'number'
assert NumberField.objects.all().count() == 2
negative_int_field_id = response_json['id']
# Create a positive decimal field
response = api_client.post(
reverse('api:database:fields:list', kwargs={'table_id': table.id}),
{
'name': 'PositiveDecimal',
'type': 'number',
'number_type': 'DECIMAL',
'number_negative': False,
'number_decimal_places': 2,
},
format='json',
HTTP_AUTHORIZATION=f'JWT {token}'
)
# Make sure the field was created properly
response_json = response.json()
assert response.status_code == HTTP_200_OK
assert response_json['type'] == 'number'
assert NumberField.objects.all().count() == 3
positive_decimal_field_id = response_json['id']
# Create a negative decimal field
response = api_client.post(
reverse('api:database:fields:list', kwargs={'table_id': table.id}),
{
'name': 'NegativeDecimal',
'type': 'number',
'number_type': 'DECIMAL',
'number_negative': True,
'number_decimal_places': 2,
},
format='json',
HTTP_AUTHORIZATION=f'JWT {token}'
)
# Make sure the field was created properly
response_json = response.json()
assert response.status_code == HTTP_200_OK
assert response_json['type'] == 'number'
assert NumberField.objects.all().count() == 4
negative_decimal_field_id = response_json['id']
# Test re-writing the name of a field. 'PositiveInt' is now called 'PositiveIntEdit'
response = api_client.patch(
reverse('api:database:fields:item', kwargs={'field_id': positive_int_field_id}),
{'name': 'PositiveIntEdit'},
format='json',
HTTP_AUTHORIZATION=f'JWT {token}'
)
assert response.status_code == HTTP_200_OK
# Add a row with correct values
response = api_client.post(
reverse('api:database:rows:list', kwargs={'table_id': table.id}),
{
f'field_{positive_int_field_id}':
'99999999999999999999999999999999999999999999999999',
f'field_{negative_int_field_id}':
'-99999999999999999999999999999999999999999999999999',
f'field_{positive_decimal_field_id}': 1000.00,
f'field_{negative_decimal_field_id}': -1000.00,
},
format='json',
HTTP_AUTHORIZATION=f'JWT {token}'
)
response_json = response.json()
assert response.status_code == HTTP_200_OK
assert (
response_json[f'field_{positive_int_field_id}'] ==
'99999999999999999999999999999999999999999999999999'
)
assert (
response_json[f'field_{negative_int_field_id}'] ==
'-99999999999999999999999999999999999999999999999999'
)
assert response_json[f'field_{positive_decimal_field_id}'] == '1000.00'
assert response_json[f'field_{negative_decimal_field_id}'] == '-1000.00'
model = table.get_model(attribute_names=True)
row = model.objects.all().last()
assert (
row.positiveintedit ==
Decimal('99999999999999999999999999999999999999999999999999')
)
assert (
row.negativeint ==
Decimal('-99999999999999999999999999999999999999999999999999')
)
assert row.positivedecimal == Decimal(1000.00)
assert row.negativedecimal == Decimal(-1000.00)
# Add a row with Nones'
response = api_client.post(
reverse('api:database:rows:list', kwargs={'table_id': table.id}),
{
f'field_{positive_int_field_id}': None,
f'field_{negative_int_field_id}': None,
f'field_{positive_decimal_field_id}': None,
f'field_{negative_decimal_field_id}': None,
},
format='json',
HTTP_AUTHORIZATION=f'JWT {token}'
)
response_json = response.json()
assert response.status_code == HTTP_200_OK
assert response_json[f'field_{positive_int_field_id}'] is None
assert response_json[f'field_{negative_int_field_id}'] is None
assert response_json[f'field_{positive_decimal_field_id}'] is None
assert response_json[f'field_{negative_decimal_field_id}'] is None
row = model.objects.all().last()
assert row.positiveintedit is None
assert row.negativeint is None
assert row.positivedecimal is None
assert row.negativedecimal is None
# Add a row with an integer that's too big
response = api_client.post(
reverse('api:database:rows:list', kwargs={'table_id': table.id}),
{
f'field_{positive_int_field_id}':
'999999999999999999999999999999999999999999999999999',
},
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'][f'field_{positive_int_field_id}'][0]['code'] ==
'max_digits'
)
# Add a row with an integer that's too small
response = api_client.post(
reverse('api:database:rows:list', kwargs={'table_id': table.id}),
{
f'field_{negative_int_field_id}':
'-9999999999999999999999999999999999999999999999999999',
},
format='json',
HTTP_AUTHORIZATION=f'JWT {token}'
)
assert response.status_code == HTTP_400_BAD_REQUEST
assert response_json['error'] == 'ERROR_REQUEST_BODY_VALIDATION'
assert (
response_json['detail'][f'field_{positive_int_field_id}'][0]['code'] ==
'max_digits'
)

View file

@ -15,8 +15,8 @@ def test_get_table_serializer(data_fixture):
data_fixture.create_text_field(table=table, order=0, name='Color',
text_default='white')
data_fixture.create_number_field(table=table, order=1, name='Horsepower')
data_fixture.create_boolean_field(table=table, order=2, name='For sale')
data_fixture.create_number_field(table=table, order=2, name='Price',
data_fixture.create_boolean_field(table=table, order=3, name='For sale')
data_fixture.create_number_field(table=table, order=4, name='Price',
number_type='DECIMAL', number_negative=True,
number_decimal_places=2)
@ -49,7 +49,21 @@ def test_get_table_serializer(data_fixture):
# number field
serializer_instance = serializer_class(data={'horsepower': 120})
assert serializer_instance.is_valid()
assert serializer_instance.data['horsepower'] == 120
assert serializer_instance.data['horsepower'] == '120'
serializer_instance = serializer_class(data={
'horsepower': 99999999999999999999999999999999999999999999999999
})
assert serializer_instance.is_valid()
assert (
serializer_instance.data['horsepower'] ==
'99999999999999999999999999999999999999999999999999'
)
serializer_instance = serializer_class(data={
'horsepower': 999999999999999999999999999999999999999999999999999
})
assert not serializer_instance.is_valid()
serializer_instance = serializer_class(data={'horsepower': None})
assert serializer_instance.is_valid()
@ -117,7 +131,7 @@ def test_get_table_serializer(data_fixture):
assert serializer_instance.is_valid()
assert serializer_instance.data == {
'color': 'green',
'horsepower': 120,
'horsepower': '120',
'for_sale': True,
'price': '120.22'
}
@ -152,10 +166,10 @@ def test_get_example_row_serializer_class():
len(field_type_registry.registry.values())
)
assert len(response_serializer._declared_fields) == (
len(request_serializer._declared_fields) + 1
len(request_serializer._declared_fields) + 2 # fields + id + order
)
assert len(response_serializer._declared_fields) == (
len(field_type_registry.registry.values()) + 1
len(field_type_registry.registry.values()) + 2 # fields + id + order
)
assert isinstance(response_serializer._declared_fields['id'],

View file

@ -20,6 +20,7 @@ def test_list_rows(api_client, data_fixture):
table_2 = data_fixture.create_database_table()
field_1 = data_fixture.create_text_field(name='Name', table=table, primary=True)
field_2 = data_fixture.create_number_field(name='Price', table=table)
field_3 = data_fixture.create_text_field()
token = TokenHandler().create_token(user, table.database.group, 'Good')
wrong_token = TokenHandler().create_token(user, table.database.group, 'Wrong')
@ -27,10 +28,10 @@ def test_list_rows(api_client, data_fixture):
True)
model = table.get_model(attribute_names=True)
row_1 = model.objects.create(name='Product 1', price=50)
row_2 = model.objects.create(name='Product 2/3', price=100)
row_3 = model.objects.create(name='Product 3', price=150)
row_4 = model.objects.create(name='Last product', price=200)
row_1 = model.objects.create(name='Product 1', price=50, order=Decimal('1'))
row_2 = model.objects.create(name='Product 2/3', price=100, order=Decimal('2'))
row_3 = model.objects.create(name='Product 3', price=150, order=Decimal('3'))
row_4 = model.objects.create(name='Last product', price=200, order=Decimal('4'))
response = api_client.get(
reverse('api:database:rows:list', kwargs={'table_id': 999999}),
@ -82,7 +83,31 @@ def test_list_rows(api_client, data_fixture):
assert len(response_json['results']) == 4
assert response_json['results'][0]['id'] == row_1.id
assert response_json['results'][0][f'field_{field_1.id}'] == 'Product 1'
assert response_json['results'][0][f'field_{field_2.id}'] == 50
assert response_json['results'][0][f'field_{field_2.id}'] == '50'
assert response_json['results'][0]['order'] == '1.00000000000000000000'
url = reverse('api:database:rows:list', kwargs={'table_id': table.id})
response = api_client.get(
f'{url}?include=field_{field_1.id},field_{field_3.id}',
format='json',
HTTP_AUTHORIZATION=f'JWT {jwt_token}'
)
response_json = response.json()
assert response.status_code == HTTP_200_OK
assert f'field_{field_1.id}' in response_json['results'][0]
assert f'field_{field_2.id}' not in response_json['results'][0]
assert f'field_{field_3.id}' not in response_json['results'][0]
url = reverse('api:database:rows:list', kwargs={'table_id': table.id})
response = api_client.get(
f'{url}?exclude=field_{field_1.id}',
format='json',
HTTP_AUTHORIZATION=f'JWT {jwt_token}'
)
response_json = response.json()
assert response.status_code == HTTP_200_OK
assert f'field_{field_1.id}' not in response_json['results'][0]
assert f'field_{field_2.id}' in response_json['results'][0]
url = reverse('api:database:rows:list', kwargs={'table_id': table.id})
response = api_client.get(
@ -225,6 +250,108 @@ def test_list_rows(api_client, data_fixture):
assert response_json['results'][2]['id'] == row_2.id
assert response_json['results'][3]['id'] == row_1.id
url = reverse('api:database:rows:list', kwargs={'table_id': table.id})
get_params = [f'filter__field_9999999__contains=last']
response = api_client.get(
f'{url}?{"&".join(get_params)}',
format='json',
HTTP_AUTHORIZATION=f'JWT {jwt_token}'
)
response_json = response.json()
assert response.status_code == HTTP_400_BAD_REQUEST
assert response_json['error'] == 'ERROR_FILTER_FIELD_NOT_FOUND'
url = reverse('api:database:rows:list', kwargs={'table_id': table.id})
get_params = [f'filter__field_{field_2.id}__contains=100']
response = api_client.get(
f'{url}?{"&".join(get_params)}',
format='json',
HTTP_AUTHORIZATION=f'JWT {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'
url = reverse('api:database:rows:list', kwargs={'table_id': table.id})
get_params = [f'filter__field_{field_2.id}__INVALID=100']
response = api_client.get(
f'{url}?{"&".join(get_params)}',
format='json',
HTTP_AUTHORIZATION=f'JWT {jwt_token}'
)
response_json = response.json()
assert response.status_code == HTTP_400_BAD_REQUEST
assert response_json['error'] == 'ERROR_VIEW_FILTER_TYPE_DOES_NOT_EXIST'
assert response_json['detail'] == 'The view filter type INVALID doesn\'t exist.'
url = reverse('api:database:rows:list', kwargs={'table_id': table.id})
get_params = [
f'filter__field_{field_1.id}__contains=last',
f'filter__field_{field_2.id}__equal=200'
]
response = api_client.get(
f'{url}?{"&".join(get_params)}',
format='json',
HTTP_AUTHORIZATION=f'JWT {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_4.id
url = reverse('api:database:rows:list', kwargs={'table_id': table.id})
get_params = [
f'filter__field_{field_1.id}__contains=last',
f'filter__field_{field_2.id}__higher_than=110',
'filter_type=or'
]
response = api_client.get(
f'{url}?{"&".join(get_params)}',
format='json',
HTTP_AUTHORIZATION=f'JWT {jwt_token}'
)
response_json = response.json()
assert response.status_code == HTTP_200_OK
assert response_json['count'] == 2
assert len(response_json['results']) == 2
assert response_json['results'][0]['id'] == row_3.id
assert response_json['results'][1]['id'] == row_4.id
url = reverse('api:database:rows:list', kwargs={'table_id': table.id})
get_params = [
f'filter__field_{field_1.id}__equal=Product 1',
f'filter__field_{field_1.id}__equal=Product 3',
'filter_type=or'
]
response = api_client.get(
f'{url}?{"&".join(get_params)}',
format='json',
HTTP_AUTHORIZATION=f'JWT {jwt_token}'
)
response_json = response.json()
assert response.status_code == HTTP_200_OK
assert response_json['count'] == 2
assert len(response_json['results']) == 2
assert response_json['results'][0]['id'] == row_1.id
assert response_json['results'][1]['id'] == row_3.id
row_2.order = Decimal('999')
row_2.save()
response = api_client.get(
reverse('api:database:rows:list', kwargs={'table_id': table.id}),
format='json',
HTTP_AUTHORIZATION=f'JWT {jwt_token}'
)
response_json = response.json()
assert response.status_code == HTTP_200_OK
assert response_json['count'] == 4
assert len(response_json['results']) == 4
assert response_json['results'][0]['id'] == row_1.id
assert response_json['results'][1]['id'] == row_3.id
assert response_json['results'][2]['id'] == row_4.id
assert response_json['results'][3]['id'] == row_2.id
@pytest.mark.django_db
def test_create_row(api_client, data_fixture):
@ -281,6 +408,16 @@ def test_create_row(api_client, data_fixture):
assert response.status_code == HTTP_400_BAD_REQUEST
assert response.json()['error'] == 'ERROR_USER_NOT_IN_GROUP'
url = reverse('api:database:rows:list', kwargs={'table_id': table.id})
response = api_client.post(
f'{url}?before=99999',
{f'field_{text_field.id}': 'Green'},
format='json',
HTTP_AUTHORIZATION=f'JWT {jwt_token}'
)
assert response.status_code == HTTP_404_NOT_FOUND
assert response.json()['error'] == 'ERROR_ROW_DOES_NOT_EXIST'
response = api_client.post(
reverse('api:database:rows:list', kwargs={'table_id': table.id}),
{
@ -311,6 +448,7 @@ def test_create_row(api_client, data_fixture):
assert not response_json_row_1[f'field_{number_field.id}']
assert response_json_row_1[f'field_{boolean_field.id}'] is False
assert response_json_row_1[f'field_{text_field_2.id}'] is None
assert response_json_row_1['order'] == '1.00000000000000000000'
response = api_client.post(
reverse('api:database:rows:list', kwargs={'table_id': table.id}),
@ -328,6 +466,7 @@ def test_create_row(api_client, data_fixture):
assert not response_json_row_2[f'field_{number_field.id}']
assert response_json_row_2[f'field_{boolean_field.id}'] is False
assert response_json_row_2[f'field_{text_field_2.id}'] == ''
assert response_json_row_2['order'] == '2.00000000000000000000'
response = api_client.post(
reverse('api:database:rows:list', kwargs={'table_id': table.id}),
@ -343,9 +482,10 @@ def test_create_row(api_client, data_fixture):
response_json_row_3 = response.json()
assert response.status_code == HTTP_200_OK
assert response_json_row_3[f'field_{text_field.id}'] == 'Green'
assert response_json_row_3[f'field_{number_field.id}'] == 120
assert response_json_row_3[f'field_{number_field.id}'] == '120'
assert response_json_row_3[f'field_{boolean_field.id}']
assert response_json_row_3[f'field_{text_field_2.id}'] == 'Not important'
assert response_json_row_3['order'] == '3.00000000000000000000'
response = api_client.post(
reverse('api:database:rows:list', kwargs={'table_id': table.id}),
@ -361,13 +501,34 @@ def test_create_row(api_client, data_fixture):
response_json_row_4 = response.json()
assert response.status_code == HTTP_200_OK
assert response_json_row_4[f'field_{text_field.id}'] == 'Purple'
assert response_json_row_4[f'field_{number_field.id}'] == 240
assert response_json_row_4[f'field_{number_field.id}'] == '240'
assert response_json_row_4[f'field_{boolean_field.id}']
assert response_json_row_4[f'field_{text_field_2.id}'] == ''
assert response_json_row_4['order'] == '4.00000000000000000000'
url = reverse('api:database:rows:list', kwargs={'table_id': table.id})
response = api_client.post(
f"{url}?before={response_json_row_3['id']}",
{
f'field_{text_field.id}': 'Red',
f'field_{number_field.id}': 480,
f'field_{boolean_field.id}': False,
f'field_{text_field_2.id}': ''
},
format='json',
HTTP_AUTHORIZATION=f'Token {token.key}'
)
response_json_row_5 = response.json()
assert response.status_code == HTTP_200_OK
assert response_json_row_5[f'field_{text_field.id}'] == 'Red'
assert response_json_row_5[f'field_{number_field.id}'] == '480'
assert not response_json_row_5[f'field_{boolean_field.id}']
assert response_json_row_5[f'field_{text_field_2.id}'] == ''
assert response_json_row_5['order'] == '2.99999999999999999999'
model = table.get_model()
assert model.objects.all().count() == 4
rows = model.objects.all().order_by('id')
assert model.objects.all().count() == 5
rows = model.objects.all()
row_1 = rows[0]
assert row_1.id == response_json_row_1['id']
@ -381,16 +542,23 @@ def test_create_row(api_client, data_fixture):
assert getattr(row_2, f'field_{text_field.id}') == 'white'
assert getattr(row_2, f'field_{number_field.id}') is None
assert getattr(row_2, f'field_{boolean_field.id}') is False
assert getattr(row_1, f'field_{text_field_2.id}') is None
assert getattr(row_2, f'field_{text_field_2.id}') == ''
row_3 = rows[2]
row_5 = rows[2]
assert row_5.id == response_json_row_5['id']
assert getattr(row_5, f'field_{text_field.id}') == 'Red'
assert getattr(row_5, f'field_{number_field.id}') == 480
assert getattr(row_5, f'field_{boolean_field.id}') is False
assert getattr(row_5, f'field_{text_field_2.id}') == ''
row_3 = rows[3]
assert row_3.id == response_json_row_3['id']
assert getattr(row_3, f'field_{text_field.id}') == 'Green'
assert getattr(row_3, f'field_{number_field.id}') == 120
assert getattr(row_3, f'field_{boolean_field.id}') is True
assert getattr(row_3, f'field_{text_field_2.id}') == 'Not important'
row_4 = rows[3]
row_4 = rows[4]
assert row_4.id == response_json_row_4['id']
assert getattr(row_4, f'field_{text_field.id}') == 'Purple'
assert getattr(row_4, f'field_{number_field.id}') == 240
@ -501,7 +669,7 @@ def test_get_row(api_client, data_fixture):
assert response.status_code == HTTP_200_OK
assert response_json['id'] == row_1.id
assert response_json[f'field_{text_field.id}'] == 'Green'
assert response_json[f'field_{number_field.id}'] == 120
assert response_json[f'field_{number_field.id}'] == '120'
assert response_json[f'field_{boolean_field.id}'] is False
url = reverse('api:database:rows:item', kwargs={
@ -517,7 +685,7 @@ def test_get_row(api_client, data_fixture):
assert response.status_code == HTTP_200_OK
assert response_json['id'] == row_2.id
assert response_json[f'field_{text_field.id}'] == 'Purple'
assert response_json[f'field_{number_field.id}'] == 240
assert response_json[f'field_{number_field.id}'] == '240'
assert response_json[f'field_{boolean_field.id}'] is True
@ -646,12 +814,12 @@ def test_update_row(api_client, data_fixture):
assert response.status_code == HTTP_200_OK
assert response_json_row_1['id'] == row_1.id
assert response_json_row_1[f'field_{text_field.id}'] == 'Green'
assert response_json_row_1[f'field_{number_field.id}'] == 120
assert response_json_row_1[f'field_{number_field.id}'] == '120'
assert response_json_row_1[f'field_{boolean_field.id}'] is True
row_1.refresh_from_db()
assert getattr(row_1, f'field_{text_field.id}') == 'Green'
assert getattr(row_1, f'field_{number_field.id}') == 120
assert getattr(row_1, f'field_{number_field.id}') == Decimal('120')
assert getattr(row_1, f'field_{boolean_field.id}') is True
response = api_client.patch(
@ -703,12 +871,12 @@ def test_update_row(api_client, data_fixture):
assert response.status_code == HTTP_200_OK
assert response_json_row_2['id'] == row_2.id
assert response_json_row_2[f'field_{text_field.id}'] == 'Blue'
assert response_json_row_2[f'field_{number_field.id}'] == 50
assert response_json_row_2[f'field_{number_field.id}'] == '50'
assert response_json_row_2[f'field_{boolean_field.id}'] is False
row_2.refresh_from_db()
assert getattr(row_2, f'field_{text_field.id}') == 'Blue'
assert getattr(row_2, f'field_{number_field.id}') == 50
assert getattr(row_2, f'field_{number_field.id}') == Decimal('50')
assert getattr(row_2, f'field_{boolean_field.id}') is False
url = reverse('api:database:rows:item', kwargs={

View file

@ -66,7 +66,7 @@ def test_list_rows(api_client, data_fixture):
assert len(response_json['results']) == 4
assert response_json['results'][0]['id'] == row_1.id
assert response_json['results'][0][f'field_{text_field.id}'] == 'Green'
assert response_json['results'][0][f'field_{number_field.id}'] == 10
assert response_json['results'][0][f'field_{number_field.id}'] == '10'
assert not response_json['results'][0][f'field_{boolean_field.id}']
assert response_json['results'][1]['id'] == row_2.id
assert response_json['results'][2]['id'] == row_3.id

View file

@ -24,6 +24,7 @@ def test_lenient_schema_editor():
with lenient_schema_editor(connection) as schema_editor:
assert isinstance(schema_editor, PostgresqlLenientDatabaseSchemaEditor)
assert isinstance(schema_editor, BaseDatabaseSchemaEditor)
assert schema_editor.alter_column_prepare_value == ''
assert schema_editor.alert_column_type_function == 'p_in'
assert connection.SchemaEditorClass != PostgresqlDatabaseSchemaEditor
@ -31,8 +32,10 @@ def test_lenient_schema_editor():
with lenient_schema_editor(
connection,
'p_in = p_in;',
"REGEXP_REPLACE(p_in, 'test', '', 'g')"
) as schema_editor:
assert schema_editor.alter_column_prepare_value == "p_in = p_in;"
assert schema_editor.alert_column_type_function == (
"REGEXP_REPLACE(p_in, 'test', '', 'g')"
)

View file

@ -4,7 +4,7 @@ from decimal import Decimal
from baserow.core.exceptions import UserNotInGroupError
from baserow.contrib.database.fields.handler import FieldHandler
from baserow.contrib.database.fields.models import (
Field, TextField, NumberField, BooleanField
Field, TextField, NumberField, BooleanField, SelectOption
)
from baserow.contrib.database.fields.exceptions import (
FieldTypeDoesNotExist, PrimaryFieldAlreadyExists, CannotDeletePrimaryField,
@ -265,3 +265,97 @@ def test_delete_field(data_fixture):
primary = data_fixture.create_text_field(table=table, primary=True)
with pytest.raises(CannotDeletePrimaryField):
handler.delete_field(user=user, field=primary)
@pytest.mark.django_db
def test_update_select_options(data_fixture):
user = data_fixture.create_user()
user_2 = data_fixture.create_user()
table = data_fixture.create_database_table(user=user)
field = data_fixture.create_single_select_field(table=table)
field_2 = data_fixture.create_single_select_field(table=table)
data_fixture.create_text_field(table=table)
handler = FieldHandler()
with pytest.raises(UserNotInGroupError):
handler.update_field_select_options(field=field, user=user_2, select_options=[])
handler.update_field_select_options(field=field, user=user, select_options=[
{'value': 'Option 1', 'color': 'blue'},
{'value': 'Option 2', 'color': 'red'}
])
assert SelectOption.objects.all().count() == 2
select_options = field.select_options.all()
assert len(select_options) == 2
assert select_options[0].order == 0
assert select_options[0].value == 'Option 1'
assert select_options[0].color == 'blue'
assert select_options[0].field_id == field.id
assert select_options[1].order == 1
assert select_options[1].value == 'Option 2'
assert select_options[1].color == 'red'
assert select_options[1].field_id == field.id
handler.update_field_select_options(field=field, user=user, select_options=[
{'id': select_options[0].id, 'value': 'Option 1 A', 'color': 'blue 2'},
{'id': select_options[1].id, 'value': 'Option 2 A', 'color': 'red 2'}
])
assert SelectOption.objects.all().count() == 2
select_options_2 = field.select_options.all()
assert len(select_options_2) == 2
assert select_options_2[0].id == select_options[0].id
assert select_options_2[0].order == 0
assert select_options_2[0].value == 'Option 1 A'
assert select_options_2[0].color == 'blue 2'
assert select_options_2[0].field_id == field.id
assert select_options_2[1].id == select_options[1].id
assert select_options_2[1].order == 1
assert select_options_2[1].value == 'Option 2 A'
assert select_options_2[1].color == 'red 2'
assert select_options_2[1].field_id == field.id
handler.update_field_select_options(field=field, user=user, select_options=[
{'id': select_options[1].id, 'value': 'Option 1 B', 'color': 'red'},
{'value': 'Option 2 B', 'color': 'green'},
])
assert SelectOption.objects.all().count() == 2
select_options_3 = field.select_options.all()
assert len(select_options_3) == 2
assert select_options_3[0].id == select_options[1].id
assert select_options_3[0].order == 0
assert select_options_3[0].value == 'Option 1 B'
assert select_options_3[0].color == 'red'
assert select_options_3[0].field_id == field.id
assert select_options_3[1].order == 1
assert select_options_3[1].value == 'Option 2 B'
assert select_options_3[1].color == 'green'
assert select_options_3[1].field_id == field.id
handler.update_field_select_options(field=field_2, user=user, select_options=[
{'id': select_options[1].id, 'value': 'Option 1 B', 'color': 'red'},
])
assert SelectOption.objects.all().count() == 3
select_options_4 = field_2.select_options.all()
assert len(select_options_4) == 1
assert select_options_4[0].id != select_options[1].id
assert select_options_4[0].order == 0
assert select_options_4[0].value == 'Option 1 B'
assert select_options_4[0].color == 'red'
assert select_options_4[0].field_id == field_2.id
handler.update_field_select_options(field=field_2, user=user, select_options=[])
assert SelectOption.objects.all().count() == 2
assert field_2.select_options.all().count() == 0

View file

@ -24,17 +24,22 @@ from baserow.contrib.database.rows.handler import RowHandler
"expected,field_kwargs",
[
(
[100, 100, 101, 0, 0, 0, None, None, None, None, None],
[
9223372036854775807, 100, 100, 101, 0, 0, 0, 0, None, None, None, None,
None
],
{'number_type': 'INTEGER', 'number_negative': False}
),
(
[100, 100, 101, -100, -100, -101, None, None, None, None, None],
[9223372036854775807, 100, 100, 101, -9223372036854775808, -100, -100, -101,
None, None, None, None, None],
{'number_type': 'INTEGER', 'number_negative': True}
),
(
[
Decimal('100.0'), Decimal('100.2'), Decimal('100.6'), Decimal('0.0'),
Decimal('0.0'), Decimal('0.0'), None, None, None, None, None
Decimal('9223372036854775807.0'), Decimal('100.0'), Decimal('100.2'),
Decimal('100.6'), Decimal('0.0'), Decimal('0.0'), Decimal('0.0'),
Decimal('0.0'), None, None, None, None, None
],
{
'number_type': 'DECIMAL', 'number_negative': False,
@ -43,9 +48,10 @@ from baserow.contrib.database.rows.handler import RowHandler
),
(
[
Decimal('100.000'), Decimal('100.220'), Decimal('100.600'),
Decimal('-100.0'), Decimal('-100.220'), Decimal('-100.600'), None, None,
None, None, None
Decimal('9223372036854775807.000'), Decimal('100.000'),
Decimal('100.220'), Decimal('100.600'),
Decimal('-9223372036854775808.0'), Decimal('-100.0'),
Decimal('-100.220'), Decimal('-100.600'), None, None, None, None, None
],
{
'number_type': 'DECIMAL', 'number_negative': True,
@ -63,9 +69,11 @@ def test_alter_number_field_column_type(expected, field_kwargs, data_fixture):
field = handler.update_field(user=user, field=field, name='Text field')
model = table.get_model()
model.objects.create(**{f'field_{field.id}': '9223372036854775807'})
model.objects.create(**{f'field_{field.id}': '100'})
model.objects.create(**{f'field_{field.id}': '100.22'})
model.objects.create(**{f'field_{field.id}': '100.59999'})
model.objects.create(**{f'field_{field.id}': '-9223372036854775808'})
model.objects.create(**{f'field_{field.id}': '-100'})
model.objects.create(**{f'field_{field.id}': '-100.22'})
model.objects.create(**{f'field_{field.id}': '-100.5999'})

View file

@ -0,0 +1,475 @@
import pytest
from rest_framework.status import HTTP_200_OK, HTTP_204_NO_CONTENT, HTTP_400_BAD_REQUEST
from django.shortcuts import reverse
from django.core.exceptions import ValidationError
from baserow.contrib.database.fields.handler import FieldHandler
from baserow.contrib.database.fields.models import SelectOption, SingleSelectField
from baserow.contrib.database.rows.handler import RowHandler
from baserow.contrib.database.views.handler import ViewHandler
@pytest.mark.django_db
def test_single_select_field_type(data_fixture):
user = data_fixture.create_user()
database = data_fixture.create_database_application(user=user, name='Placeholder')
table = data_fixture.create_database_table(name='Example', database=database)
field_handler = FieldHandler()
field = field_handler.create_field(
user=user, table=table, type_name='single_select', name='Single select',
select_options=[{'value': 'Option 1', 'color': 'blue'}]
)
assert SingleSelectField.objects.all().first().id == field.id
assert SelectOption.objects.all().count() == 1
select_options = field.select_options.all()
assert len(select_options) == 1
assert select_options[0].order == 0
assert select_options[0].field_id == field.id
assert select_options[0].value == 'Option 1'
assert select_options[0].color == 'blue'
field = field_handler.update_field(
user=user, table=table, field=field,
select_options=[
{'value': 'Option 2 B', 'color': 'red 2'},
{'id': select_options[0].id, 'value': 'Option 1 B', 'color': 'blue 2'},
]
)
assert SelectOption.objects.all().count() == 2
select_options_2 = field.select_options.all()
assert len(select_options_2) == 2
assert select_options_2[0].order == 0
assert select_options_2[0].field_id == field.id
assert select_options_2[0].value == 'Option 2 B'
assert select_options_2[0].color == 'red 2'
assert select_options_2[1].id == select_options[0].id
assert select_options_2[1].order == 1
assert select_options_2[1].field_id == field.id
assert select_options_2[1].value == 'Option 1 B'
assert select_options_2[1].color == 'blue 2'
field_handler.delete_field(user=user, field=field)
assert SelectOption.objects.all().count() == 0
@pytest.mark.django_db
def test_single_select_field_type_rows(data_fixture, django_assert_num_queries):
user = data_fixture.create_user()
database = data_fixture.create_database_application(user=user, name='Placeholder')
table = data_fixture.create_database_table(name='Example', database=database)
other_select_option = data_fixture.create_select_option()
field_handler = FieldHandler()
row_handler = RowHandler()
field = field_handler.create_field(
user=user,
table=table,
type_name='single_select',
select_options=[
{'value': 'Option 1', 'color': 'red'},
{'value': 'Option 2', 'color': 'blue'}
]
)
with pytest.raises(ValidationError):
row_handler.create_row(user=user, table=table, values={
f'field_{field.id}': 999999
})
with pytest.raises(ValidationError):
row_handler.create_row(user=user, table=table, values={
f'field_{field.id}': other_select_option.id
})
select_options = field.select_options.all()
row = row_handler.create_row(user=user, table=table, values={
f'field_{field.id}': select_options[0].id
})
assert getattr(row, f'field_{field.id}').id == select_options[0].id
assert getattr(row, f'field_{field.id}').value == select_options[0].value
assert getattr(row, f'field_{field.id}').color == select_options[0].color
assert getattr(row, f'field_{field.id}_id') == select_options[0].id
field = field_handler.update_field(
user=user,
field=field,
select_options=[
{'value': 'Option 3', 'color': 'orange'},
{'value': 'Option 4', 'color': 'purple'},
]
)
select_options = field.select_options.all()
row_2 = row_handler.create_row(user=user, table=table, values={
f'field_{field.id}': select_options[0].id
})
assert getattr(row_2, f'field_{field.id}').id == select_options[0].id
assert getattr(row_2, f'field_{field.id}').value == select_options[0].value
assert getattr(row_2, f'field_{field.id}').color == select_options[0].color
assert getattr(row_2, f'field_{field.id}_id') == select_options[0].id
row_3 = row_handler.create_row(user=user, table=table, values={
f'field_{field.id}': select_options[1].id
})
assert getattr(row_3, f'field_{field.id}').id == select_options[1].id
assert getattr(row_3, f'field_{field.id}_id') == select_options[1].id
row_4 = row_handler.create_row(user=user, table=table, values={
f'field_{field.id}': select_options[0].id
})
assert getattr(row_4, f'field_{field.id}').id == select_options[0].id
assert getattr(row_4, f'field_{field.id}_id') == select_options[0].id
model = table.get_model()
with django_assert_num_queries(2):
rows = list(model.objects.all().enhance_by_fields())
assert getattr(rows[0], f'field_{field.id}') is None
assert getattr(rows[1], f'field_{field.id}').id == select_options[0].id
assert getattr(rows[2], f'field_{field.id}').id == select_options[1].id
assert getattr(rows[3], f'field_{field.id}').id == select_options[0].id
row.refresh_from_db()
assert getattr(row, f'field_{field.id}') is None
assert getattr(row, f'field_{field.id}_id') is None
field = field_handler.update_field(user=user, field=field, new_type_name='text')
assert field.select_options.all().count() == 0
model = table.get_model()
rows = model.objects.all().enhance_by_fields()
assert getattr(rows[0], f'field_{field.id}') is None
assert getattr(rows[1], f'field_{field.id}') == 'Option 3'
assert getattr(rows[2], f'field_{field.id}') == 'Option 4'
assert getattr(rows[3], f'field_{field.id}') == 'Option 3'
field = field_handler.update_field(
user=user, field=field, new_type_name='single_select',
select_options=[
{'value': 'Option 2', 'color': 'blue'},
{'value': 'option 3', 'color': 'purple'},
]
)
assert field.select_options.all().count() == 2
model = table.get_model()
rows = model.objects.all().enhance_by_fields()
select_options = field.select_options.all()
assert getattr(rows[0], f'field_{field.id}') is None
assert getattr(rows[1], f'field_{field.id}').id == select_options[1].id
assert getattr(rows[2], f'field_{field.id}') is None
assert getattr(rows[3], f'field_{field.id}').id == select_options[1].id
row_4 = row_handler.update_row(user=user, table=table, row_id=row_4.id, values={
f'field_{field.id}': None
})
assert getattr(row_4, f'field_{field.id}') is None
assert getattr(row_4, f'field_{field.id}_id') is None
@pytest.mark.django_db
def test_single_select_field_type_api_views(api_client, data_fixture):
user, token = data_fixture.create_user_and_token(
email='test@test.nl', password='password', first_name='Test1')
database = data_fixture.create_database_application(user=user, name='Placeholder')
table = data_fixture.create_database_table(name='Example', database=database)
response = api_client.post(
reverse('api:database:fields:list', kwargs={'table_id': table.id}),
{
'name': 'Select 1',
'type': 'single_select',
},
format='json',
HTTP_AUTHORIZATION=f'JWT {token}'
)
response_json = response.json()
assert response.status_code == HTTP_200_OK
assert response_json['name'] == 'Select 1'
assert response_json['type'] == 'single_select'
assert response_json['select_options'] == []
assert SingleSelectField.objects.all().count() == 1
assert SelectOption.objects.all().count() == 0
response = api_client.post(
reverse('api:database:fields:list', kwargs={'table_id': table.id}),
{
'name': 'Select 1',
'type': 'single_select',
'select_options': [
{'value': 'Option 1', 'color': 'red'}
]
},
format='json',
HTTP_AUTHORIZATION=f'JWT {token}'
)
response_json = response.json()
assert response.status_code == HTTP_200_OK
field_id = response_json['id']
select_options = SelectOption.objects.all()
assert len(select_options) == 1
assert select_options[0].field_id == field_id
assert select_options[0].value == 'Option 1'
assert select_options[0].color == 'red'
assert select_options[0].order == 0
assert response_json['name'] == 'Select 1'
assert response_json['type'] == 'single_select'
assert response_json['select_options'] == [
{'id': select_options[0].id, 'value': 'Option 1', 'color': 'red'}
]
assert SingleSelectField.objects.all().count() == 2
response = api_client.patch(
reverse('api:database:fields:item', kwargs={'field_id': field_id}),
{'name': 'New select 1'},
format='json',
HTTP_AUTHORIZATION=f'JWT {token}'
)
response_json = response.json()
assert response.status_code == HTTP_200_OK
assert response_json['name'] == 'New select 1'
assert response_json['type'] == 'single_select'
assert response_json['select_options'] == [
{'id': select_options[0].id, 'value': 'Option 1', 'color': 'red'}
]
response = api_client.patch(
reverse('api:database:fields:item', kwargs={'field_id': field_id}),
{
'name': 'New select 1',
'select_options': [
{'id': select_options[0].id, 'value': 'Option 1 B', 'color': 'red 2'},
{'value': 'Option 2 B', 'color': 'blue 2'}
]
},
format='json',
HTTP_AUTHORIZATION=f'JWT {token}'
)
response_json = response.json()
assert response.status_code == HTTP_200_OK
select_options = SelectOption.objects.all()
assert len(select_options) == 2
assert response_json['select_options'] == [
{'id': select_options[0].id, 'value': 'Option 1 B', 'color': 'red 2'},
{'id': select_options[1].id, 'value': 'Option 2 B', 'color': 'blue 2'}
]
response = api_client.patch(
reverse('api:database:fields:item', kwargs={'field_id': field_id}),
{
'name': 'New select 1',
'select_options': []
},
format='json',
HTTP_AUTHORIZATION=f'JWT {token}'
)
response_json = response.json()
assert response.status_code == HTTP_200_OK
assert SelectOption.objects.all().count() == 0
assert response_json['select_options'] == []
response = api_client.patch(
reverse('api:database:fields:item', kwargs={'field_id': field_id}),
{
'name': 'New select 1',
'select_options': [
{'value': 'Option 1 B', 'color': 'red 2'},
{'value': 'Option 2 B', 'color': 'blue 2'}
]
},
format='json',
HTTP_AUTHORIZATION=f'JWT {token}'
)
assert response.status_code == HTTP_200_OK
select_options = SelectOption.objects.all()
assert len(select_options) == 2
response = api_client.delete(
reverse('api:database:fields:item', kwargs={'field_id': field_id}),
format='json',
HTTP_AUTHORIZATION=f'JWT {token}'
)
assert response.status_code == HTTP_204_NO_CONTENT
assert SingleSelectField.objects.all().count() == 1
assert SelectOption.objects.all().count() == 0
@pytest.mark.django_db
def test_single_select_field_type_api_row_views(api_client, data_fixture):
user, token = data_fixture.create_user_and_token()
database = data_fixture.create_database_application(user=user, name='Placeholder')
table = data_fixture.create_database_table(name='Example', database=database)
other_select_option = data_fixture.create_select_option()
field_handler = FieldHandler()
field = field_handler.create_field(
user=user,
table=table,
type_name='single_select',
select_options=[
{'value': 'Option 1', 'color': 'red'},
{'value': 'Option 2', 'color': 'blue'}
]
)
select_options = field.select_options.all()
response = api_client.post(
reverse('api:database:rows:list', kwargs={'table_id': table.id}),
{f'field_{field.id}': 'Nothing'},
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'][f'field_{field.id}'][0]['code'] == 'incorrect_type'
response = api_client.post(
reverse('api:database:rows:list', kwargs={'table_id': table.id}),
{f'field_{field.id}': 999999},
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'][f'field_{field.id}'][0]['code'] == 'does_not_exist'
response = api_client.post(
reverse('api:database:rows:list', kwargs={'table_id': table.id}),
{f'field_{field.id}': other_select_option.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_REQUEST_BODY_VALIDATION'
assert response_json['detail'][f'field_{field.id}'][0]['code'] == 'does_not_exist'
response = api_client.post(
reverse('api:database:rows:list', kwargs={'table_id': table.id}),
{f'field_{field.id}': select_options[0].id},
format='json',
HTTP_AUTHORIZATION=f'JWT {token}'
)
response_json = response.json()
assert response.status_code == HTTP_200_OK
assert response_json[f'field_{field.id}']['id'] == select_options[0].id
assert response_json[f'field_{field.id}']['value'] == 'Option 1'
assert response_json[f'field_{field.id}']['color'] == 'red'
url = reverse('api:database:rows:item', kwargs={
'table_id': table.id,
'row_id': response_json['id']
})
response = api_client.patch(
url,
{},
format='json',
HTTP_AUTHORIZATION=f'JWT {token}'
)
assert response.status_code == HTTP_200_OK
url = reverse('api:database:rows:item', kwargs={
'table_id': table.id,
'row_id': response_json['id']
})
response = api_client.patch(
url,
{f'field_{field.id}': select_options[1].id},
format='json',
HTTP_AUTHORIZATION=f'JWT {token}'
)
response_json = response.json()
assert response.status_code == HTTP_200_OK
assert response_json[f'field_{field.id}']['id'] == select_options[1].id
assert response_json[f'field_{field.id}']['value'] == 'Option 2'
assert response_json[f'field_{field.id}']['color'] == 'blue'
url = reverse('api:database:rows:item', kwargs={
'table_id': table.id,
'row_id': response_json['id']
})
response = api_client.patch(
url,
{f'field_{field.id}': None},
format='json',
HTTP_AUTHORIZATION=f'JWT {token}'
)
response_json = response.json()
assert response.status_code == HTTP_200_OK
assert response_json[f'field_{field.id}'] is None
url = reverse('api:database:rows:item', kwargs={
'table_id': table.id,
'row_id': response_json['id']
})
response = api_client.delete(
url,
format='json',
HTTP_AUTHORIZATION=f'JWT {token}'
)
assert response.status_code == HTTP_204_NO_CONTENT
assert SelectOption.objects.all().count() == 3
@pytest.mark.django_db
def test_single_select_field_type_get_order(data_fixture):
user = data_fixture.create_user()
database = data_fixture.create_database_application(user=user, name='Placeholder')
table = data_fixture.create_database_table(name='Example', database=database)
field = data_fixture.create_single_select_field(table=table)
option_c = data_fixture.create_select_option(field=field, value='C', color='blue')
option_a = data_fixture.create_select_option(field=field, value='A', color='blue')
option_b = data_fixture.create_select_option(field=field, value='B', color='blue')
grid_view = data_fixture.create_grid_view(table=table)
view_handler = ViewHandler()
row_handler = RowHandler()
row_1 = row_handler.create_row(user=user, table=table, values={
f'field_{field.id}': option_b.id
})
row_2 = row_handler.create_row(user=user, table=table, values={
f'field_{field.id}': option_a.id
})
row_3 = row_handler.create_row(user=user, table=table, values={
f'field_{field.id}': option_c.id
})
row_4 = row_handler.create_row(user=user, table=table, values={
f'field_{field.id}': option_b.id
})
row_5 = row_handler.create_row(user=user, table=table, values={
f'field_{field.id}': None
})
sort = data_fixture.create_view_sort(view=grid_view, field=field, order='ASC')
model = table.get_model()
rows = view_handler.apply_sorting(grid_view, model.objects.all())
row_ids = [row.id for row in rows]
assert row_ids == [row_5.id, row_2.id, row_1.id, row_4.id, row_3.id]
sort.order = 'DESC'
sort.save()
rows = view_handler.apply_sorting(grid_view, model.objects.all())
row_ids = [row.id for row in rows]
assert row_ids == [row_3.id, row_1.id, row_4.id, row_2.id, row_5.id]
option_a.value = 'Z'
option_a.save()
sort.order = 'ASC'
sort.save()
model = table.get_model()
rows = view_handler.apply_sorting(grid_view, model.objects.all())
row_ids = [row.id for row in rows]
assert row_ids == [row_5.id, row_1.id, row_4.id, row_3.id, row_2.id]

View file

@ -21,6 +21,82 @@ def test_get_field_ids_from_dict():
}) == [1, 2, 3]
def test_extract_field_ids_from_string():
handler = RowHandler()
assert handler.extract_field_ids_from_string(None) == []
assert handler.extract_field_ids_from_string('not,something') == []
assert handler.extract_field_ids_from_string('field_1,field_2') == [1, 2]
assert handler.extract_field_ids_from_string('field_22,test_8,999') == [22, 8, 999]
assert handler.extract_field_ids_from_string('is,1,one') == [1]
@pytest.mark.django_db
def test_get_include_exclude_fields(data_fixture):
table = data_fixture.create_database_table()
table_2 = data_fixture.create_database_table()
field_1 = data_fixture.create_text_field(table=table, order=1)
field_2 = data_fixture.create_text_field(table=table, order=2)
field_3 = data_fixture.create_text_field(table=table_2, order=3)
row_handler = RowHandler()
assert row_handler.get_include_exclude_fields(
table,
include=None,
exclude=None
) is None
assert row_handler.get_include_exclude_fields(
table,
include='',
exclude=''
) is None
fields = row_handler.get_include_exclude_fields(
table,
f'field_{field_1.id}'
)
assert len(fields) == 1
assert fields[0].id == field_1.id
fields = row_handler.get_include_exclude_fields(
table,
f'field_{field_1.id},field_9999,field_{field_2.id}'
)
assert len(fields) == 2
assert fields[0].id == field_1.id
assert fields[1].id == field_2.id
fields = row_handler.get_include_exclude_fields(
table,
None,
f'field_{field_1.id},field_9999'
)
assert len(fields) == 1
assert fields[0].id == field_2.id
fields = row_handler.get_include_exclude_fields(
table,
f'field_{field_1.id},field_{field_2}',
f'field_{field_1.id}'
)
assert len(fields) == 1
assert fields[0].id == field_2.id
fields = row_handler.get_include_exclude_fields(
table,
f'field_{field_3.id}'
)
assert len(fields) == 0
fields = row_handler.get_include_exclude_fields(
table,
None,
f'field_{field_3.id}'
)
assert len(fields) == 2
@pytest.mark.django_db
def test_extract_manytomany_values(data_fixture):
row_handler = RowHandler()
@ -72,26 +148,86 @@ def test_create_row(data_fixture):
with pytest.raises(UserNotInGroupError):
handler.create_row(user=user_2, table=table)
row = handler.create_row(user=user, table=table, values={
row_1 = handler.create_row(user=user, table=table, values={
name_field.id: 'Tesla',
speed_field.id: 240,
f'field_{price_field.id}': 59999.99,
9999: 'Must not be added'
})
assert getattr(row, f'field_{name_field.id}') == 'Tesla'
assert getattr(row, f'field_{speed_field.id}') == 240
assert getattr(row, f'field_{price_field.id}') == 59999.99
assert not getattr(row, f'field_9999', None)
row.refresh_from_db()
assert getattr(row, f'field_{name_field.id}') == 'Tesla'
assert getattr(row, f'field_{speed_field.id}') == 240
assert getattr(row, f'field_{price_field.id}') == Decimal('59999.99')
assert not getattr(row, f'field_9999', None)
assert getattr(row_1, f'field_{name_field.id}') == 'Tesla'
assert getattr(row_1, f'field_{speed_field.id}') == 240
assert getattr(row_1, f'field_{price_field.id}') == 59999.99
assert not getattr(row_1, f'field_9999', None)
assert row_1.order == 1
row_1.refresh_from_db()
assert getattr(row_1, f'field_{name_field.id}') == 'Tesla'
assert getattr(row_1, f'field_{speed_field.id}') == 240
assert getattr(row_1, f'field_{price_field.id}') == Decimal('59999.99')
assert not getattr(row_1, f'field_9999', None)
assert row_1.order == Decimal('1.00000000000000000000')
row = handler.create_row(user=user, table=table)
assert getattr(row, f'field_{name_field.id}') == 'Test'
assert not getattr(row, f'field_{speed_field.id}')
assert not getattr(row, f'field_{price_field.id}')
row_2 = handler.create_row(user=user, table=table)
assert getattr(row_2, f'field_{name_field.id}') == 'Test'
assert not getattr(row_2, f'field_{speed_field.id}')
assert not getattr(row_2, f'field_{price_field.id}')
row_1.refresh_from_db()
assert row_1.order == Decimal('1.00000000000000000000')
assert row_2.order == Decimal('2.00000000000000000000')
row_3 = handler.create_row(user=user, table=table, before=row_2)
row_1.refresh_from_db()
row_2.refresh_from_db()
assert row_1.order == Decimal('1.00000000000000000000')
assert row_2.order == Decimal('2.00000000000000000000')
assert row_3.order == Decimal('1.99999999999999999999')
row_4 = handler.create_row(user=user, table=table, before=row_2)
row_1.refresh_from_db()
row_2.refresh_from_db()
row_3.refresh_from_db()
assert row_1.order == Decimal('1.00000000000000000000')
assert row_2.order == Decimal('2.00000000000000000000')
assert row_3.order == Decimal('1.99999999999999999998')
assert row_4.order == Decimal('1.99999999999999999999')
row_5 = handler.create_row(user=user, table=table, before=row_3)
row_1.refresh_from_db()
row_2.refresh_from_db()
row_3.refresh_from_db()
row_4.refresh_from_db()
assert row_1.order == Decimal('1.00000000000000000000')
assert row_2.order == Decimal('2.00000000000000000000')
assert row_3.order == Decimal('1.99999999999999999998')
assert row_4.order == Decimal('1.99999999999999999999')
assert row_5.order == Decimal('1.99999999999999999997')
row_6 = handler.create_row(user=user, table=table, before=row_2)
row_1.refresh_from_db()
row_2.refresh_from_db()
row_3.refresh_from_db()
row_4.refresh_from_db()
row_5.refresh_from_db()
assert row_1.order == Decimal('1.00000000000000000000')
assert row_2.order == Decimal('2.00000000000000000000')
assert row_3.order == Decimal('1.99999999999999999997')
assert row_4.order == Decimal('1.99999999999999999998')
assert row_5.order == Decimal('1.99999999999999999996')
assert row_6.order == Decimal('1.99999999999999999999')
row_7 = handler.create_row(user, table=table, before=row_1)
row_1.refresh_from_db()
row_2.refresh_from_db()
row_3.refresh_from_db()
row_4.refresh_from_db()
row_5.refresh_from_db()
row_6.refresh_from_db()
assert row_1.order == Decimal('1.00000000000000000000')
assert row_2.order == Decimal('2.00000000000000000000')
assert row_3.order == Decimal('1.99999999999999999997')
assert row_4.order == Decimal('1.99999999999999999998')
assert row_5.order == Decimal('1.99999999999999999996')
assert row_6.order == Decimal('1.99999999999999999999')
assert row_7.order == Decimal('0.99999999999999999999')
with pytest.raises(ValidationError):
handler.create_row(user=user, table=table, values={
@ -99,7 +235,20 @@ def test_create_row(data_fixture):
})
model = table.get_model()
assert model.objects.all().count() == 2
rows = model.objects.all()
assert len(rows) == 7
assert rows[0].id == row_7.id
assert rows[1].id == row_1.id
assert rows[2].id == row_5.id
assert rows[3].id == row_3.id
assert rows[4].id == row_4.id
assert rows[5].id == row_6.id
assert rows[6].id == row_2.id
row_2.delete()
row_8 = handler.create_row(user, table=table)
assert row_8.order == Decimal('3.00000000000000000000')
@pytest.mark.django_db

View file

@ -2,6 +2,7 @@ import pytest
from django.db import connection
from django.conf import settings
from decimal import Decimal
from baserow.core.exceptions import UserNotInGroupError
from baserow.contrib.database.table.models import Table
@ -85,7 +86,8 @@ def test_fill_example_table_data(data_fixture):
database = data_fixture.create_database_application(user=user)
table_handler = TableHandler()
table_handler.create_table(user, database, fill_example=True, name='Table 1')
table = table_handler.create_table(user, database, fill_example=True,
name='Table 1')
assert Table.objects.all().count() == 1
assert GridView.objects.all().count() == 1
@ -94,6 +96,13 @@ def test_fill_example_table_data(data_fixture):
assert BooleanField.objects.all().count() == 1
assert GridViewFieldOptions.objects.all().count() == 2
model = table.get_model()
results = model.objects.all()
assert len(results) == 2
assert results[0].order == Decimal('1.00000000000000000000')
assert results[1].order == Decimal('2.00000000000000000000')
@pytest.mark.django_db
def test_fill_table_with_initial_data(data_fixture):
@ -137,6 +146,10 @@ def test_fill_table_with_initial_data(data_fixture):
model = table.get_model()
results = model.objects.all()
assert results[0].order == Decimal('1.00000000000000000000')
assert results[1].order == Decimal('2.00000000000000000000')
assert results[2].order == Decimal('3.00000000000000000000')
assert getattr(results[0], f'field_{text_fields[0].id}') == '1-1'
assert getattr(results[0], f'field_{text_fields[1].id}') == '1-2'
assert getattr(results[0], f'field_{text_fields[2].id}') == '1-3'

View file

@ -1,4 +1,5 @@
import pytest
from decimal import Decimal
from unittest.mock import MagicMock
@ -6,7 +7,10 @@ from django.db import models
from baserow.contrib.database.table.models import Table
from baserow.contrib.database.fields.exceptions import (
OrderByFieldNotPossible, OrderByFieldNotFound
OrderByFieldNotPossible, OrderByFieldNotFound, FilterFieldNotFound
)
from baserow.contrib.database.views.exceptions import (
ViewFilterTypeNotAllowedForField, ViewFilterTypeDoesNotExist
)
@ -24,6 +28,7 @@ def test_group_user_get_next_order(data_fixture):
@pytest.mark.django_db
def test_get_table_model(data_fixture):
default_model_fields_count = 3
table = data_fixture.create_database_table(name='Cars')
text_field = data_fixture.create_text_field(table=table, order=0, name='Color',
text_default='white')
@ -36,7 +41,7 @@ def test_get_table_model(data_fixture):
assert model.__name__ == f'Table{table.id}Model'
assert model._generated_table_model
assert model._meta.db_table == f'database_table_{table.id}'
assert len(model._meta.get_fields()) == 4
assert len(model._meta.get_fields()) == 4 + default_model_fields_count
color_field = model._meta.get_field('color')
horsepower_field = model._meta.get_field('horsepower')
@ -48,7 +53,7 @@ def test_get_table_model(data_fixture):
assert color_field.default == 'white'
assert color_field.null
assert isinstance(horsepower_field, models.IntegerField)
assert isinstance(horsepower_field, models.DecimalField)
assert horsepower_field.verbose_name == 'Horsepower'
assert horsepower_field.db_column == f'field_{number_field.id}'
assert horsepower_field.null
@ -71,7 +76,7 @@ def test_get_table_model(data_fixture):
model_2 = table.get_model(fields=[number_field], field_ids=[text_field.id],
attribute_names=True)
assert len(model_2._meta.get_fields()) == 3
assert len(model_2._meta.get_fields()) == 3 + default_model_fields_count
color_field = model_2._meta.get_field('color')
assert color_field
@ -83,14 +88,14 @@ def test_get_table_model(data_fixture):
model_3 = table.get_model()
assert model_3._meta.db_table == f'database_table_{table.id}'
assert len(model_3._meta.get_fields()) == 4
assert len(model_3._meta.get_fields()) == 4 + default_model_fields_count
field_1 = model_3._meta.get_field(f'field_{text_field.id}')
assert isinstance(field_1, models.TextField)
assert field_1.db_column == f'field_{text_field.id}'
field_2 = model_3._meta.get_field(f'field_{number_field.id}')
assert isinstance(field_2, models.IntegerField)
assert isinstance(field_2, models.DecimalField)
assert field_2.db_column == f'field_{number_field.id}'
field_3 = model_3._meta.get_field(f'field_{boolean_field.id}')
@ -101,7 +106,7 @@ def test_get_table_model(data_fixture):
text_default='orange')
model = table.get_model(attribute_names=True)
field_names = [f.name for f in model._meta.get_fields()]
assert len(field_names) == 5
assert len(field_names) == 5 + default_model_fields_count
assert f'{text_field.model_attribute_name}_field_{text_field.id}' in field_names
assert f'{text_field_2.model_attribute_name}_field_{text_field.id}' in field_names
@ -283,3 +288,119 @@ def test_order_by_fields_string_queryset(data_fixture):
assert results[1].id == row_1.id
assert results[2].id == row_4.id
assert results[3].id == row_2.id
row_5 = model.objects.create(
name='Audi',
color='Red',
price=2000,
description='Old times',
order=Decimal('0.1')
)
row_2.order = Decimal('0.1')
results = model.objects.all().order_by_fields_string(
f'{name_field.id}'
)
assert results[0].id == row_5.id
assert results[1].id == row_2.id
assert results[2].id == row_1.id
assert results[3].id == row_3.id
assert results[4].id == row_4.id
@pytest.mark.django_db
def test_filter_by_fields_object_queryset(data_fixture):
table = data_fixture.create_database_table(name='Cars')
data_fixture.create_database_table(database=table.database)
name_field = data_fixture.create_text_field(table=table, order=0, name='Name')
data_fixture.create_text_field(table=table, order=1, name='Color')
price_field = data_fixture.create_number_field(table=table, order=2, name='Price')
description_field = data_fixture.create_long_text_field(
table=table, order=3, name='Description'
)
model = table.get_model(attribute_names=True)
row_1 = model.objects.create(
name='BMW',
color='Blue',
price=10000,
description='Sports car.'
)
row_2 = model.objects.create(
name='Audi',
color='Orange',
price=20000,
description='This is the most expensive car we have.'
)
model.objects.create(
name='Volkswagen',
color='White',
price=5000,
description='A very old car.'
)
row_4 = model.objects.create(
name='Volkswagen',
color='Green',
price=4000,
description=''
)
with pytest.raises(ValueError):
model.objects.all().filter_by_fields_object(filter_object={
f'filter__field_999999__equal': ['BMW'],
}, filter_type='RANDOM')
with pytest.raises(FilterFieldNotFound):
model.objects.all().filter_by_fields_object(filter_object={
f'filter__field_999999__equal': ['BMW'],
}, filter_type='AND')
with pytest.raises(ViewFilterTypeDoesNotExist):
model.objects.all().filter_by_fields_object(filter_object={
f'filter__field_{name_field.id}__INVALID': ['BMW'],
}, filter_type='AND')
with pytest.raises(ViewFilterTypeNotAllowedForField):
model.objects.all().filter_by_fields_object(filter_object={
f'filter__field_{price_field.id}__contains': '10',
}, filter_type='AND')
# All the entries are not following the correct format and should be ignored.
results = model.objects.all().filter_by_fields_object(filter_object={
f'filter__not__equal': ['BMW'],
f'filter__field_{price_field.id}_equal': '10000',
f'filters__field_{price_field.id}__equal': '10000',
}, filter_type='AND')
assert len(results) == 4
results = model.objects.all().filter_by_fields_object(filter_object={
f'filter__field_{name_field.id}__equal': ['BMW'],
f'filter__field_{price_field.id}__equal': '10000',
}, filter_type='AND')
assert len(results) == 1
assert results[0].id == row_1.id
results = model.objects.all().filter_by_fields_object(filter_object={
f'filter__field_{name_field.id}__equal': ['BMW', 'Audi'],
}, filter_type='AND')
assert len(results) == 0
results = model.objects.all().filter_by_fields_object(filter_object={
f'filter__field_{name_field.id}__equal': ['BMW', 'Audi'],
}, filter_type='OR')
assert len(results) == 2
assert results[0].id == row_1.id
assert results[1].id == row_2.id
results = model.objects.all().filter_by_fields_object(filter_object={
f'filter__field_{price_field.id}__higher_than': '5500',
}, filter_type='AND')
assert len(results) == 2
assert results[0].id == row_1.id
assert results[1].id == row_2.id
results = model.objects.all().filter_by_fields_object(filter_object={
f'filter__field_{description_field.id}__empty': '',
}, filter_type='AND')
assert len(results) == 1
assert results[0].id == row_4.id

View file

@ -397,6 +397,116 @@ def test_contains_not_filter_type(data_fixture):
assert row_2.id in ids
@pytest.mark.django_db
def test_single_select_equal_filter_type(data_fixture):
user = data_fixture.create_user()
table = data_fixture.create_database_table(user=user)
grid_view = data_fixture.create_grid_view(table=table)
field = data_fixture.create_single_select_field(table=table)
option_a = data_fixture.create_select_option(field=field, value='A', color='blue')
option_b = data_fixture.create_select_option(field=field, value='B', color='red')
handler = ViewHandler()
model = table.get_model()
row_1 = model.objects.create(**{
f'field_{field.id}_id': option_a.id,
})
row_2 = model.objects.create(**{
f'field_{field.id}_id': option_b.id,
})
model.objects.create(**{
f'field_{field.id}_id': None,
})
filter = data_fixture.create_view_filter(
view=grid_view,
field=field,
type='single_select_equal',
value=''
)
ids = [r.id for r in handler.apply_filters(grid_view, model.objects.all()).all()]
assert len(ids) == 3
filter.value = str(option_a.id)
filter.save()
ids = [r.id for r in handler.apply_filters(grid_view, model.objects.all()).all()]
assert len(ids) == 1
assert row_1.id in ids
filter.value = str(option_b.id)
filter.save()
ids = [r.id for r in handler.apply_filters(grid_view, model.objects.all()).all()]
assert len(ids) == 1
assert row_2.id in ids
filter.value = '-1'
filter.save()
ids = [r.id for r in handler.apply_filters(grid_view, model.objects.all()).all()]
assert len(ids) == 0
filter.value = 'Test'
filter.save()
ids = [r.id for r in handler.apply_filters(grid_view, model.objects.all()).all()]
assert len(ids) == 3
@pytest.mark.django_db
def test_single_select_not_equal_filter_type(data_fixture):
user = data_fixture.create_user()
table = data_fixture.create_database_table(user=user)
grid_view = data_fixture.create_grid_view(table=table)
field = data_fixture.create_single_select_field(table=table)
option_a = data_fixture.create_select_option(field=field, value='A', color='blue')
option_b = data_fixture.create_select_option(field=field, value='B', color='red')
handler = ViewHandler()
model = table.get_model()
row_1 = model.objects.create(**{
f'field_{field.id}_id': option_a.id,
})
row_2 = model.objects.create(**{
f'field_{field.id}_id': option_b.id,
})
row_3 = model.objects.create(**{
f'field_{field.id}_id': None,
})
filter = data_fixture.create_view_filter(
view=grid_view,
field=field,
type='single_select_not_equal',
value=''
)
ids = [r.id for r in handler.apply_filters(grid_view, model.objects.all()).all()]
assert len(ids) == 3
filter.value = str(option_a.id)
filter.save()
ids = [r.id for r in handler.apply_filters(grid_view, model.objects.all()).all()]
assert len(ids) == 2
assert row_2.id in ids
assert row_3.id in ids
filter.value = str(option_b.id)
filter.save()
ids = [r.id for r in handler.apply_filters(grid_view, model.objects.all()).all()]
assert len(ids) == 2
assert row_1.id in ids
assert row_3.id in ids
filter.value = '-1'
filter.save()
ids = [r.id for r in handler.apply_filters(grid_view, model.objects.all()).all()]
assert len(ids) == 3
filter.value = 'Test'
filter.save()
ids = [r.id for r in handler.apply_filters(grid_view, model.objects.all()).all()]
assert len(ids) == 3
@pytest.mark.django_db
def test_boolean_filter_type(data_fixture):
user = data_fixture.create_user()
@ -1017,6 +1127,8 @@ def test_empty_filter_type(data_fixture):
)
boolean_field = data_fixture.create_boolean_field(table=table)
file_field = data_fixture.create_file_field(table=table)
single_select_field = data_fixture.create_single_select_field(table=table)
option_1 = data_fixture.create_select_option(field=single_select_field)
tmp_table = data_fixture.create_database_table(database=table.database)
tmp_field = data_fixture.create_text_field(table=tmp_table, primary=True)
@ -1039,7 +1151,8 @@ def test_empty_filter_type(data_fixture):
f'field_{date_field.id}': None,
f'field_{date_time_field.id}': None,
f'field_{boolean_field.id}': False,
f'field_{file_field.id}': []
f'field_{file_field.id}': [],
f'field_{single_select_field.id}_id': None
})
row_2 = model.objects.create(**{
f'field_{text_field.id}': 'Value',
@ -1049,7 +1162,8 @@ def test_empty_filter_type(data_fixture):
f'field_{date_field.id}': date(2020, 6, 17),
f'field_{date_time_field.id}': make_aware(datetime(2020, 6, 17, 1, 30, 0), utc),
f'field_{boolean_field.id}': True,
f'field_{file_field.id}': [{'name': 'test_file.png'}]
f'field_{file_field.id}': [{'name': 'test_file.png'}],
f'field_{single_select_field.id}_id': option_1.id
})
getattr(row_2, f'field_{link_row_field.id}').add(tmp_row.id)
row_3 = model.objects.create(**{
@ -1062,7 +1176,8 @@ def test_empty_filter_type(data_fixture):
f'field_{boolean_field.id}': True,
f'field_{file_field.id}': [
{'name': 'test_file.png'}, {'name': 'another_file.jpg'}
]
],
f'field_{single_select_field.id}_id': option_1.id
})
getattr(row_3, f'field_{link_row_field.id}').add(tmp_row.id)
@ -1106,6 +1221,10 @@ def test_empty_filter_type(data_fixture):
filter.save()
assert handler.apply_filters(grid_view, model.objects.all()).get().id == row.id
filter.field = single_select_field
filter.save()
assert handler.apply_filters(grid_view, model.objects.all()).get().id == row.id
@pytest.mark.django_db
def test_not_empty_filter_type(data_fixture):
@ -1127,6 +1246,8 @@ def test_not_empty_filter_type(data_fixture):
)
boolean_field = data_fixture.create_boolean_field(table=table)
file_field = data_fixture.create_file_field(table=table)
single_select_field = data_fixture.create_single_select_field(table=table)
option_1 = data_fixture.create_select_option(field=single_select_field)
tmp_table = data_fixture.create_database_table(database=table.database)
tmp_field = data_fixture.create_text_field(table=tmp_table, primary=True)
@ -1149,7 +1270,8 @@ def test_not_empty_filter_type(data_fixture):
f'field_{date_field.id}': None,
f'field_{date_time_field.id}': None,
f'field_{boolean_field.id}': False,
f'field_{file_field.id}': []
f'field_{file_field.id}': [],
f'field_{single_select_field.id}': None
})
row_2 = model.objects.create(**{
f'field_{text_field.id}': 'Value',
@ -1159,7 +1281,8 @@ def test_not_empty_filter_type(data_fixture):
f'field_{date_field.id}': date(2020, 6, 17),
f'field_{date_time_field.id}': make_aware(datetime(2020, 6, 17, 1, 30, 0), utc),
f'field_{boolean_field.id}': True,
f'field_{file_field.id}': [{'name': 'test_file.png'}]
f'field_{file_field.id}': [{'name': 'test_file.png'}],
f'field_{single_select_field.id}_id': option_1.id
})
getattr(row_2, f'field_{link_row_field.id}').add(tmp_row.id)
@ -1202,3 +1325,7 @@ def test_not_empty_filter_type(data_fixture):
filter.field = file_field
filter.save()
assert handler.apply_filters(grid_view, model.objects.all()).get().id == row_2.id
filter.field = single_select_field
filter.save()
assert handler.apply_filters(grid_view, model.objects.all()).get().id == row_2.id

View file

@ -1,4 +1,5 @@
import pytest
from decimal import Decimal
from baserow.core.exceptions import UserNotInGroupError
from baserow.contrib.database.views.handler import ViewHandler
@ -663,6 +664,20 @@ def test_apply_sortings(data_fixture):
row_ids = [row.id for row in rows]
assert row_ids == [row_4.id, row_5.id, row_6.id, row_1.id, row_2.id, row_3.id]
row_7 = model.objects.create(**{
f'field_{text_field.id}': 'Aaa',
f'field_{number_field.id}': 30,
f'field_{boolean_field.id}': True,
'order': Decimal('0.1')
})
sort.delete()
sort_2.delete()
rows = view_handler.apply_sorting(grid_view, model.objects.all())
row_ids = [row.id for row in rows]
assert row_ids == [row_7.id, row_1.id, row_2.id, row_3.id, row_4.id, row_5.id,
row_6.id]
@pytest.mark.django_db
def test_get_sort(data_fixture):

View file

@ -1,9 +1,28 @@
import pytest
from pytz import timezone
from freezegun import freeze_time
from datetime import datetime
from baserow.core.models import GroupUser
from baserow.core.models import GroupUser, Group
from baserow.contrib.database.models import Database
@pytest.mark.django_db
def test_created_and_updated_on_mixin():
with freeze_time('2020-01-01 12:00'):
group = Group.objects.create(name='Group')
assert group.created_on == datetime(2020, 1, 1, 12, 00, tzinfo=timezone('UTC'))
assert group.updated_on == datetime(2020, 1, 1, 12, 00, tzinfo=timezone('UTC'))
with freeze_time('2020-01-02 12:00'):
group.name = 'Group2'
group.save()
assert group.created_on == datetime(2020, 1, 1, 12, 00, tzinfo=timezone('UTC'))
assert group.updated_on == datetime(2020, 1, 2, 12, 00, tzinfo=timezone('UTC'))
@pytest.mark.django_db
def test_group_user_get_next_order(data_fixture):
user = data_fixture.create_user()

View file

@ -1,4 +1,5 @@
import pytest
from decimal import Decimal
from unittest.mock import MagicMock
from freezegun import freeze_time
@ -65,10 +66,19 @@ def test_create_user():
tables = Table.objects.all().order_by('id')
model_1 = tables[0].get_model()
assert model_1.objects.all().count() == 4
model_1_results = model_1.objects.all()
assert len(model_1_results) == 4
assert model_1_results[0].order == Decimal('1.00000000000000000000')
assert model_1_results[1].order == Decimal('2.00000000000000000000')
assert model_1_results[2].order == Decimal('3.00000000000000000000')
assert model_1_results[3].order == Decimal('4.00000000000000000000')
model_2 = tables[1].get_model()
assert model_2.objects.all().count() == 3
model_2_results = model_2.objects.all()
assert len(model_2_results) == 3
assert model_2_results[0].order == Decimal('1.00000000000000000000')
assert model_2_results[1].order == Decimal('2.00000000000000000000')
assert model_2_results[2].order == Decimal('3.00000000000000000000')
plugin_mock.user_created.assert_called_with(user, group)

View file

@ -2,7 +2,7 @@ from django.db import connection
from baserow.contrib.database.fields.models import (
TextField, LongTextField, NumberField, BooleanField, DateField, LinkRowField,
FileField
FileField, SingleSelectField, SelectOption
)
@ -13,6 +13,21 @@ class FieldFixtures:
model_field = to_model._meta.get_field(field.db_column)
schema_editor.add_field(to_model, model_field)
def create_select_option(self, user=None, **kwargs):
if 'value' not in kwargs:
kwargs['value'] = self.fake.name()
if 'color' not in kwargs:
kwargs['color'] = self.fake.name()
if 'order' not in kwargs:
kwargs['order'] = 0
if 'field' not in kwargs:
kwargs['field'] = self.create_single_select_field(user=user)
return SelectOption.objects.create(**kwargs)
def create_text_field(self, user=None, create_field=True, **kwargs):
if 'table' not in kwargs:
kwargs['table'] = self.create_database_table(user=user)
@ -134,3 +149,20 @@ class FieldFixtures:
self.create_model_field(kwargs['table'], field)
return field
def create_single_select_field(self, user=None, create_field=True, **kwargs):
if 'table' not in kwargs:
kwargs['table'] = self.create_database_table(user=user)
if 'name' not in kwargs:
kwargs['name'] = self.fake.name()
if 'order' not in kwargs:
kwargs['order'] = 0
field = SingleSelectField.objects.create(**kwargs)
if create_field:
self.create_model_field(kwargs['table'], field)
return field

View file

@ -1,5 +1,24 @@
# Changelog
## Released (2021-01-06)
* Allow larger values for the number field and improved the validation.
* Fixed bug where if you have no filters, but the filter type is set to `OR` it always
results in a not matching row state in the web-frontend.
* Fixed bug where the arrow navigation didn't work for the dropdown component in
combination with a search query.
* Fixed bug where the page refreshes if you press enter in an input in the row modal.
* Added filtering by GET parameter to the rows listing endpoint.
* Fixed drifting context menu.
* Store updated and created timestamp for the groups, applications, tables, views,
fields and rows.
* Made the file name editable.
* Made the rows orderable and added the ability to insert a row at a given position.
* Made it possible to include or exclude specific fields when listing rows via the API.
* Implemented a single select field.
* Fixed bug where inserting above or below a row created upon signup doesn't work
correctly.
## Released (2020-12-01)
* Added select_for_update where it was still missing.

View file

@ -0,0 +1,18 @@
.add {
display: inline-block;
padding: 0 5px;
background-color: $color-primary-100;
border-radius: 3px;
color: $color-primary-900;
@include fixed-height(22px, 13px);
&:hover {
background-color: $color-primary-200;
text-decoration: none;
}
}
.add__icon {
font-size: 12px;
}

View file

@ -1,3 +1,5 @@
@import 'colors';
@import 'add';
@import 'button';
@import 'alert';
@import 'form';
@ -19,6 +21,7 @@
@import 'fields/date';
@import 'fields/link_row';
@import 'fields/file';
@import 'fields/single_select';
@import 'views/grid';
@import 'views/grid/text';
@import 'views/grid/long_text';
@ -27,6 +30,7 @@
@import 'views/grid/date';
@import 'views/grid/link_row';
@import 'views/grid/file';
@import 'views/grid/single_select';
@import 'box_page';
@import 'loading';
@import 'notifications';
@ -50,3 +54,6 @@
@import 'select_application';
@import 'upload_files';
@import 'file_field_modal';
@import 'select_options';
@import 'select_options_listing';
@import 'color_select';

View file

@ -4,4 +4,14 @@
padding: 3px 6px;
border-radius: 3px;
background-color: $color-neutral-100;
&.api-docs__code--small {
font-size: 11px;
padding: 2px 4px;
}
&.api-docs__code--clickable:hover {
background-color: $color-neutral-200;
cursor: pointer;
}
}

View file

@ -31,4 +31,24 @@
padding-right: 20px;
}
}
.api-docs__table-without-border {
th,
td {
border-bottom: none;
padding-bottom: 0;
}
& + tr {
th,
td {
padding-top: 10px;
}
}
}
}
.api-docs__table-content {
font-size: 14px;
line-height: 170%;
}

View file

@ -0,0 +1,38 @@
.color-select-context {
width: 212px;
}
.color-select-context__colors {
display: flex;
flex-wrap: wrap;
padding: 6px;
}
.color-select-context__color {
position: relative;
flex: 0 0 28px;
height: 28px;
margin: 6px;
border-radius: 3px;
&:nth-child(5n+5) {
margin-right: 0;
}
&:not(.active):hover {
box-shadow: 0 0 2px rgba(0, 0, 0, 0.08);
}
&.active::before {
font-family: $font-awesome-font-family;
font-weight: $font-awesome-font-weight;
content: fa-content($fa-var-check);
color: $color-primary-900;
text-align: center;
font-size: 13px;
transform: translateX(-50%) translateY(-50%);
@include fa-icon;
@include absolute(50%, auto, auto, 50%);
}
}

View file

@ -0,0 +1,59 @@
.background-color--light-blue {
background-color: $color-primary-100;
}
.background-color--light-gray {
background-color: $color-neutral-100;
}
.background-color--light-green {
background-color: $color-success-100;
}
.background-color--light-orange {
background-color: $color-warning-100;
}
.background-color--light-red {
background-color: $color-error-100;
}
.background-color--blue {
background-color: $color-primary-200;
}
.background-color--gray {
background-color: $color-neutral-200;
}
.background-color--green {
background-color: $color-success-200;
}
.background-color--orange {
background-color: $color-warning-200;
}
.background-color--red {
background-color: $color-error-200;
}
.background-color--dark-blue {
background-color: $color-primary-300;
}
.background-color--dark-gray {
background-color: $color-neutral-300;
}
.background-color--dark-green {
background-color: $color-success-300;
}
.background-color--dark-orange {
background-color: $color-warning-300;
}
.background-color--dark-red {
background-color: $color-error-300;
}

View file

@ -85,22 +85,3 @@
background-color: $color-neutral-100;
}
}
.field-file__add {
display: inline-block;
padding: 0 5px;
background-color: $color-primary-100;
border-radius: 3px;
color: $color-primary-900;
@include fixed-height(22px, 13px);
&:hover {
background-color: $color-primary-200;
text-decoration: none;
}
}
.field-file__add-icon {
font-size: 12px;
}

View file

@ -40,22 +40,3 @@
color: $color-neutral-500;
}
}
.field-link-row__add {
display: inline-block;
padding: 0 5px;
background-color: $color-primary-100;
border-radius: 3px;
color: $color-primary-900;
@include fixed-height(22px, 13px);
&:hover {
background-color: $color-primary-200;
text-decoration: none;
}
}
.field-link-row__add-icon {
font-size: 12px;
}

View file

@ -0,0 +1,28 @@
.field-single-select__dropdown-item.hover {
background-color: $color-neutral-100;
}
.field-single-select__dropdown-link {
display: block;
padding: 6px 10px;
&:hover {
text-decoration: none;
}
}
.field-single-select__dropdown-option {
@extend %ellipsis;
display: inline-block;
color: $color-primary-900;
border-radius: 20px;
padding: 0 10px;
max-width: 100%;
@include fixed-height(20px, 12px);
&.field-single-select__dropdown-option--align-32 {
margin-top: 5px;
}
}

View file

@ -23,6 +23,12 @@
@include fixed-height($file-field-modal-head-height, 14px);
}
.file-field-modal__rename {
font-size: 12px;
margin-left: 4px;
color: $white;
}
.file-field-modal__close {
border-radius: 3px;
color: $white;

View file

@ -95,6 +95,10 @@
line-height: 30px;
}
.filters__value-dropdown {
width: 130px;
}
.filters_footer {
display: flex;
flex-direction: row;

View file

@ -139,15 +139,23 @@
}
}
.select__description {
padding-bottom: 14px;
text-align: center;
}
.select__footer {
border-top: 1px solid $color-neutral-200;
}
.select__footer-button {
position: relative;
display: block;
padding: 0 12px;
color: $color-neutral-600;
user-select: none;
border-bottom-left-radius: 2px;
border-bottom-right-radius: 2px;
@include fixed-height(36px, 14px);
@ -159,6 +167,10 @@
background-color: $color-neutral-100;
text-decoration: none;
}
&.button--loading {
background-color: $color-neutral-200;
}
}
.select__footer-multiple {

View file

@ -0,0 +1,36 @@
.select-options {
// Nothing
}
.select-options__item {
display: flex;
margin: 8px 0;
}
.select-options__color {
flex: 0 0 28px;
line-height: 28px;
text-align: center;
color: $color-primary-900;
border-radius: 3px;
font-size: 14px;
margin-right: 12px;
}
.select-options__value {
flex: 1 1 100%;
padding: 4px 8px;
min-width: 0;
}
.select-options__remove {
flex: 0 0 28px;
line-height: 28px;
margin-left: 4px;
text-align: center;
color: $color-neutral-600;
&:hover {
color: $color-neutral-400;
}
}

View file

@ -0,0 +1,23 @@
.select-options-listing {
display: flex;
width: 100%;
margin: 4px 0;
}
.select-options-listing__id {
@include fixed-height(20px, 12px);
margin-right: 10px;
}
.select-options-listing__value {
@extend %ellipsis;
display: inline-block;
color: $color-primary-900;
border-radius: 20px;
padding: 0 10px;
max-width: 100%;
@include fixed-height(20px, 12px);
}

View file

@ -0,0 +1,37 @@
.grid-field-single-select {
position: relative;
display: block;
height: 32px;
padding: 0 10px;
width: 100%;
user-select: none;
.grid-view__cell.active &:hover {
cursor: pointer;
}
&.grid-field-single-select--selected {
padding-right: 32px;
}
}
.grid-field-single-select__option {
@extend %ellipsis;
display: inline-block;
color: $color-primary-900;
border-radius: 20px;
padding: 0 10px;
max-width: 100%;
margin-top: 6px;
@include fixed-height(20px, 12px);
}
.grid-field-single-select__icon {
font-size: 11px;
color: $color-primary-900;
transform: translateY(-50%);
@include absolute(50%, 10px, auto, auto);
}

View file

@ -53,10 +53,20 @@
margin-bottom: 40px !important;
}
.margin-right-1 {
margin-right: 8px;
}
.resizing-horizontal {
cursor: col-resize;
}
// This selector can only be used to temporarily show an element for calculating
// positions.
.forced-block {
display: block !important;
}
@keyframes spin {
0% {
transform: rotate(0);

View file

@ -0,0 +1,47 @@
<template>
<Context ref="context" class="color-select-context">
<div class="color-select-context__colors">
<a
v-for="(color, index) in colors"
:key="color + '-' + index"
class="color-select-context__color"
:class="
'background-color--' +
color +
' ' +
(color === active ? 'active' : '')
"
@click="select(color)"
></a>
</div>
</Context>
</template>
<script>
import context from '@baserow/modules/core/mixins/context'
import { colors } from '@baserow/modules/core/utils/colors'
export default {
name: 'CreateFieldContext',
mixins: [context],
data() {
return {
active: '',
}
},
computed: {
colors() {
return colors
},
},
methods: {
setActive(color) {
this.active = color
},
select(color) {
this.$emit('selected', color)
this.hide()
},
},
}
</script>

View file

@ -63,17 +63,20 @@ export default {
*/
show(target, vertical, horizontal, offset) {
const isElementOrigin = isDomElement(target)
const updatePosition = () => {
const css = isElementOrigin
? this.calculatePositionElement(target, vertical, horizontal, offset)
: this.calculatePositionFixed(target, vertical, horizontal, offset)
const css = isElementOrigin
? this.calculatePositionElement(target, vertical, horizontal, offset)
: this.calculatePositionFixed(target, vertical, horizontal, offset)
// Set the calculated positions of the context.
for (const key in css) {
const value = css[key] !== null ? Math.ceil(css[key]) + 'px' : 'auto'
this.$el.style[key] = value
// Set the calculated positions of the context.
for (const key in css) {
const value = css[key] !== null ? Math.ceil(css[key]) + 'px' : 'auto'
this.$el.style[key] = value
}
}
updatePosition()
// If we store the element who opened the context menu we can exclude the element
// when clicked outside of this element.
this.opener = isElementOrigin ? target : null
@ -101,6 +104,12 @@ export default {
}
}
document.body.addEventListener('click', this.$el.clickOutsideEvent)
this.$el.updatePositionEvent = (event) => {
updatePosition()
}
window.addEventListener('scroll', this.$el.updatePositionEvent, true)
window.addEventListener('resize', this.$el.updatePositionEvent)
},
/**
* Hide the context menu and make sure the body event is removed.
@ -114,16 +123,33 @@ export default {
}
document.body.removeEventListener('click', this.$el.clickOutsideEvent)
window.removeEventListener('scroll', this.$el.updatePositionEvent, true)
window.removeEventListener('resize', this.$el.updatePositionEvent)
},
/**
* Calculates the absolute position of the context based on the original clicked
* element.
* element. If the target element is not visible, it might mean that we can't
* figure out the correct position, so in that case we force the element to be
* visible.
*/
calculatePositionElement(target, vertical, horizontal, offset) {
const visible =
window.getComputedStyle(target).getPropertyValue('display') !== 'none'
// If the target is not visible then we can't calculate the position, so we
// temporarily need to show the element forcefully.
if (!visible) {
target.classList.add('forced-block')
}
const targetRect = target.getBoundingClientRect()
const contextRect = this.$el.getBoundingClientRect()
const positions = { top: null, right: null, bottom: null, left: null }
if (!visible) {
target.classList.remove('forced-block')
}
// Calculate if top, bottom, left and right positions are possible.
const canTop = targetRect.top - contextRect.height - offset > 0
const canBottom =

View file

@ -1,6 +1,6 @@
<template>
<div class="dropdown">
<a class="dropdown__selected" @click="show()">
<div class="dropdown" :class="{ 'dropdown--floating': !showInput }">
<a v-if="showInput" class="dropdown__selected" @click="show()">
<template v-if="hasValue()">
<i
v-if="selectedIcon"
@ -34,269 +34,10 @@
</template>
<script>
import { isElement } from '@baserow/modules/core/utils/dom'
import dropdown from '@baserow/modules/core/mixins/dropdown'
// @TODO focus on tab
export default {
name: 'Dropdown',
props: {
value: {
type: [String, Number, Boolean, Object],
required: false,
default: null,
},
searchText: {
type: String,
required: false,
default: 'Search',
},
showSearch: {
type: Boolean,
required: false,
default: true,
},
},
data() {
return {
loaded: false,
open: false,
name: null,
icon: null,
query: '',
hover: null,
}
},
computed: {
selectedName() {
return this.getSelectedProperty(this.value, 'name')
},
selectedIcon() {
return this.getSelectedProperty(this.value, 'icon')
},
},
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: {
/**
* Returns true if there is a value.
* @return {boolean}
*/
hasValue() {
return this.value === 0 ? true : !!this.value
},
/**
* Shows the lists of choices, so a user can change the value.
*/
show() {
this.open = true
this.hover = this.value
this.$emit('show')
this.$nextTick(() => {
// 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
// have to hide them.
this.$el.clickOutsideEvent = (event) => {
if (
// Check if the context menu is still open
this.open &&
// If the click was outside the context element because we want to ignore
// clicks inside it.
!isElement(this.$el, event.target)
) {
this.hide()
}
}
document.body.addEventListener('click', this.$el.clickOutsideEvent)
this.$el.keydownEvent = (event) => {
if (
// Check if the context menu is still open
this.open &&
// Check if the user has hit either of the keys we care about. If not,
// ignore.
(event.code === 'ArrowUp' || event.code === 'ArrowDown')
) {
// Prevent scrolling up and down while pressing the up and down key.
event.stopPropagation()
event.preventDefault()
this.handleUpAndDownArrowPress(event)
}
// Allow the Enter key to select the value that is currently being hovered
// over.
if (this.open && event.code === 'Enter') {
// Prevent submitting the whole form when pressing the enter key while the
// dropdown is open.
event.preventDefault()
this.select(this.hover)
}
}
document.body.addEventListener('keydown', this.$el.keydownEvent)
},
/**
* Hides the list of choices
*/
hide() {
this.open = false
this.$emit('hide')
// Make sure that all the items are visible the next time we open the dropdown.
this.query = ''
this.search(this.query)
document.body.removeEventListener('click', this.$el.clickOutsideEvent)
document.body.removeEventListener('keydown', this.$el.keydownEvent)
},
/**
* Selects a new value which will also be
*/
select(value) {
this.$emit('input', value)
this.$emit('change', value)
this.hide()
},
/**
* If not empty it will only show children that contain the given query.
*/
search(query) {
this.$children.forEach((item) => {
item.search(query)
})
},
/**
* Loops over all children to see if any of the values match with given value. If
* so the requested property of the child is returned
*/
getSelectedProperty(value, property) {
for (const i in this.$children) {
const item = this.$children[i]
if (item.value === value) {
return item[property]
}
}
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()
},
/**
* Method that is called when the arrow up or arrow down key is pressed. Based on
* the index of the current child, the next child enabled child is set as hover.
*/
handleUpAndDownArrowPress(event) {
const hoverIndex = this.$children.findIndex(
(item) => item.value === this.hover
)
const nextItem = this.getNextChild(hoverIndex, event)
if (nextItem) {
this.hover = nextItem.value
this.$refs.items.scrollTop = this.getScrollTopAmountForNextChild(
nextItem
)
}
},
/**
* Recursively calculate the next enabled child index based on the arrow up or
* arrow down event.
*/
getNextChild(currentIndex, event) {
// Derive our new index based off of the key pressed
if (event.code === 'ArrowUp') {
currentIndex--
}
if (event.code === 'ArrowDown') {
currentIndex++
}
// Check if the new index is invalid
if (currentIndex < 0 || currentIndex > this.$children.length - 1) {
return null
}
const nextItem = this.$children[currentIndex]
if (nextItem.disabled) {
// If the expected nextItem is disabled, we want to skip over it
return this.getNextChild(currentIndex, event)
}
return nextItem
},
/**
* When scrolling up and down between options with the keyboard, this
* method calculates the expected behavior to the user considering
* disabled items that need to be skipped and a limited dropdown
* window in which to scroll
*/
getScrollTopAmountForNextChild(itemToScrollTo) {
// If the element to scroll to is below the current dropdown's
// bottom scroll position, then scroll so that the item to scroll to
// is the last viewable item in the dropdown window.
if (
itemToScrollTo.$el.offsetTop >
this.$refs.items.scrollTop + this.$refs.items.clientHeight
) {
return (
itemToScrollTo.$el.offsetTop -
itemToScrollTo.$el.clientHeight -
(this.$refs.items.clientHeight - itemToScrollTo.$el.clientHeight)
)
}
// If the element to scroll to is above our current scroll position
// in the window, we need to scroll to the item and position it as
// the top item in the scroll window.
if (
itemToScrollTo.$el.offsetTop <
this.$refs.items.scrollTop + this.$refs.items.offsetTop
) {
// To figure out how much to scroll, we need the top and bottom
// margin of the element we're scrolling to
const style =
itemToScrollTo.$el.currentStyle ||
window.getComputedStyle(itemToScrollTo.$el)
return (
itemToScrollTo.$el.offsetTop -
this.$refs.search.clientHeight -
(parseInt(style.marginTop) + parseInt(style.marginBottom))
)
}
return this.$refs.items.scrollTop
},
},
mixins: [dropdown],
}
</script>

View file

@ -24,57 +24,10 @@
</template>
<script>
import dropdownItem from '@baserow/modules/core/mixins/dropdownItem'
export default {
name: 'DropdownItem',
props: {
value: {
type: [String, Number, Boolean, Object],
required: true,
},
name: {
type: String,
required: true,
},
icon: {
type: String,
required: false,
default: null,
},
disabled: {
type: Boolean,
required: false,
default: false,
},
},
data() {
return {
query: '',
}
},
methods: {
select(value, disabled) {
if (!disabled) {
this.$parent.select(value)
}
},
hover(value, disabled) {
if (!disabled && this.$parent.hover !== value) {
this.$parent.hover = value
}
},
search(query) {
this.query = query
},
isVisible(query) {
const regex = new RegExp('(' + query + ')', 'i')
return this.name.match(regex)
},
isActive(value) {
return this.$parent.value === value
},
isHovering(value) {
return this.$parent.hover === value
},
},
mixins: [dropdownItem],
}
</script>

View file

@ -42,6 +42,7 @@ export default {
*/
edit() {
this.editing = true
this.$emit('editing', true)
this.$nextTick(() => {
focusEnd(this.$refs.editable)
})
@ -53,6 +54,7 @@ export default {
*/
change() {
this.editing = false
this.$emit('editing', false)
if (this.oldValue === this.newValue) {
return

View file

@ -10,8 +10,14 @@ export default {
event.stopPropagation()
}
el.addEventListener('wheel', el.preventParentScrollDirectiveEvent)
el.addEventListener('touchstart', el.preventParentScrollDirectiveEvent)
el.addEventListener('touchend', el.preventParentScrollDirectiveEvent)
el.addEventListener('touchmove', el.preventParentScrollDirectiveEvent)
},
unbind(el) {
el.removeEventListener('wheel', el.preventParentScrollDirectiveEvent)
el.removeEventListener('touchstart', el.preventParentScrollDirectiveEvent)
el.removeEventListener('touchend', el.preventParentScrollDirectiveEvent)
el.removeEventListener('touchmove', el.preventParentScrollDirectiveEvent)
},
}

View file

@ -0,0 +1,271 @@
import { isDomElement, isElement } from '@baserow/modules/core/utils/dom'
export default {
props: {
value: {
type: [String, Number, Boolean, Object],
required: false,
default: null,
},
searchText: {
type: String,
required: false,
default: 'Search',
},
showSearch: {
type: Boolean,
required: false,
default: true,
},
showInput: {
type: Boolean,
required: false,
default: true,
},
},
data() {
return {
loaded: false,
open: false,
name: null,
icon: null,
query: '',
hasItems: true,
hover: null,
}
},
computed: {
selectedName() {
return this.getSelectedProperty(this.value, 'name')
},
selectedIcon() {
return this.getSelectedProperty(this.value, 'icon')
},
},
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: {
/**
* Returns true if there is a value.
* @return {boolean}
*/
hasValue() {
return this.value === 0 ? true : !!this.value
},
/**
* Toggles the open state of the dropdown menu.
*/
toggle(target, value) {
if (value === undefined) {
value = !this.open
}
if (value) {
this.show(target)
} else {
this.hide()
}
},
/**
* Shows the lists of choices, so a user can change the value.
*/
show(target) {
const isElementOrigin = isDomElement(target)
this.open = true
this.hover = this.value
this.opener = isElementOrigin ? target : null
this.$emit('show')
this.$nextTick(() => {
// 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
// have to hide them.
this.$el.clickOutsideEvent = (event) => {
if (
// Check if the context menu is still open
this.open &&
// If the click was outside the context element because we want to ignore
// clicks inside it.
!isElement(this.$el, event.target) &&
// If the click was not on the opener because he can trigger the toggle
// method.
!isElement(this.opener, event.target)
) {
this.hide()
}
}
document.body.addEventListener('click', this.$el.clickOutsideEvent)
this.$el.keydownEvent = (event) => {
if (
// Check if the context menu is still open
this.open &&
// Check if the user has hit either of the keys we care about. If not,
// ignore.
(event.code === 'ArrowUp' || event.code === 'ArrowDown')
) {
// Prevent scrolling up and down while pressing the up and down key.
event.stopPropagation()
event.preventDefault()
this.handleUpAndDownArrowPress(event)
}
// Allow the Enter key to select the value that is currently being hovered
// over.
if (this.open && event.code === 'Enter') {
// Prevent submitting the whole form when pressing the enter key while the
// dropdown is open.
event.preventDefault()
this.select(this.hover)
}
}
document.body.addEventListener('keydown', this.$el.keydownEvent)
},
/**
* Hides the list of choices
*/
hide() {
this.open = false
this.$emit('hide')
// Make sure that all the items are visible the next time we open the dropdown.
this.query = ''
this.search(this.query)
document.body.removeEventListener('click', this.$el.clickOutsideEvent)
document.body.removeEventListener('keydown', this.$el.keydownEvent)
},
/**
* Selects a new value which will also be
*/
select(value) {
this.$emit('input', value)
this.$emit('change', value)
this.hide()
},
/**
* If not empty it will only show children that contain the given query.
*/
search(query) {
this.hasItems = query === ''
this.$children.forEach((item) => {
if (item.search(query)) {
this.hasItems = true
}
})
},
/**
* Loops over all children to see if any of the values match with given value. If
* so the requested property of the child is returned
*/
getSelectedProperty(value, property) {
for (const i in this.$children) {
const item = this.$children[i]
if (item.value === value) {
return item[property]
}
}
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()
},
/**
* Method that is called when the arrow up or arrow down key is pressed. Based on
* the index of the current child, the next child enabled child is set as hover.
*/
handleUpAndDownArrowPress(event) {
const children = this.$children.filter(
(child) => !child.disabled && child.isVisible(this.query)
)
let index = children.findIndex((item) => item.value === this.hover)
index = event.code === 'ArrowUp' ? index - 1 : index + 1
// Check if the new index is within the allowed range.
if (index < 0 || index > children.length - 1) {
return
}
const next = children[index]
this.hover = next.value
this.$refs.items.scrollTop = this.getScrollTopAmountForNextChild(next)
},
/**
* When scrolling up and down between options with the keyboard, this
* method calculates the expected behavior to the user considering
* disabled items that need to be skipped and a limited dropdown
* window in which to scroll
*/
getScrollTopAmountForNextChild(itemToScrollTo) {
// If the element to scroll to is below the current dropdown's
// bottom scroll position, then scroll so that the item to scroll to
// is the last viewable item in the dropdown window.
if (
itemToScrollTo.$el.offsetTop >
this.$refs.items.scrollTop + this.$refs.items.clientHeight
) {
return (
itemToScrollTo.$el.offsetTop -
itemToScrollTo.$el.clientHeight -
(this.$refs.items.clientHeight - itemToScrollTo.$el.clientHeight)
)
}
// If the element to scroll to is above our current scroll position
// in the window, we need to scroll to the item and position it as
// the top item in the scroll window.
if (
itemToScrollTo.$el.offsetTop <
this.$refs.items.scrollTop + this.$refs.items.offsetTop
) {
// To figure out how much to scroll, we need the top and bottom
// margin of the element we're scrolling to
const style =
itemToScrollTo.$el.currentStyle ||
window.getComputedStyle(itemToScrollTo.$el)
return (
itemToScrollTo.$el.offsetTop -
this.$refs.search.clientHeight -
(parseInt(style.marginTop) + parseInt(style.marginBottom))
)
}
return this.$refs.items.scrollTop
},
},
}

View file

@ -0,0 +1,55 @@
import { escapeRegExp } from '@baserow/modules/core/utils/string'
export default {
props: {
value: {
validator: () => true,
required: true,
},
name: {
type: String,
required: true,
},
icon: {
type: String,
required: false,
default: null,
},
disabled: {
type: Boolean,
required: false,
default: false,
},
},
data() {
return {
query: '',
}
},
methods: {
select(value, disabled) {
if (!disabled) {
this.$parent.select(value)
}
},
hover(value, disabled) {
if (!disabled && this.$parent.hover !== value) {
this.$parent.hover = value
}
},
search(query) {
this.query = query
return this.isVisible(query)
},
isVisible(query) {
const regex = new RegExp('(' + escapeRegExp(query) + ')', 'i')
return this.name.match(regex)
},
isActive(value) {
return this.$parent.value === value
},
isHovering(value) {
return this.$parent.hover === value
},
},
}

View file

@ -1,3 +1,5 @@
import { clone } from '@baserow/modules/core/utils/object'
/**
* This mixin introduces some helper functions for form components where the
* whole component existence is based on being a form.
@ -34,7 +36,19 @@ export default {
}
return Object.keys(this.defaultValues).reduce((result, key) => {
if (this.allowedValues.includes(key)) {
result[key] = this.defaultValues[key]
let value = this.defaultValues[key]
// If the value is an array or object, it could be that it contains
// references and we actually need a copy of the value here so that we don't
// directly change existing variables when editing form values.
if (
Array.isArray(value) ||
(typeof value === 'object' && value !== null)
) {
value = clone(value)
}
result[key] = value
}
return result
}, {})

View file

@ -185,6 +185,16 @@
<div class="control__elements">
<div style="width: 200px;">
<Dropdown v-model="longDropdown">
<DropdownItem
:key="'some-2'"
:name="'Something'"
:value="'some-2'"
></DropdownItem>
<DropdownItem
:key="'test-3'"
:name="'Test 3'"
:value="'test3'"
></DropdownItem>
<DropdownItem
v-for="i in [
0,
@ -209,6 +219,53 @@
:value="i"
:disabled="i === 7"
></DropdownItem>
<DropdownItem
:key="'test-1'"
:name="'Test 1'"
:value="'test1'"
></DropdownItem>
<DropdownItem
:key="'test-2'"
:name="'Test 2'"
:value="'test2'"
></DropdownItem>
</Dropdown>
</div>
</div>
</div>
<div class="control">
<label class="control__label">
Dropdown by link {{ dropdown }}
</label>
<div class="control__elements">
<div style="width: 200px;">
<a
ref="dropdownLink"
@click="$refs.dropdown1.toggle($refs.dropdownLink)"
>Open dropdown</a
>
<Dropdown
ref="dropdown1"
v-model="dropdown"
:show-input="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>

View file

@ -0,0 +1,17 @@
export const colors = [
'light-blue',
'light-green',
'light-orange',
'light-red',
'light-gray',
'blue',
'green',
'orange',
'red',
'gray',
'dark-blue',
'dark-green',
'dark-orange',
'dark-red',
'dark-gray',
]

View file

@ -56,3 +56,7 @@ export const isValidEmail = (str) => {
export const isSecureURL = (str) => {
return str.toLowerCase().substr(0, 5) === 'https'
}
export const escapeRegExp = (string) => {
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}

View file

@ -0,0 +1,82 @@
<template>
<div class="select-options">
<div
v-for="(item, index) in value"
:key="item.id"
class="select-options__item"
>
<a
:ref="'color-select-' + index"
:class="'select-options__color' + ' background-color--' + item.color"
@click="openColor(index)"
>
<i class="fas fa-caret-down"></i>
</a>
<input
v-model="item.value"
class="input select-options__value"
@input="$emit('input', value)"
/>
<a class="select-options__remove" @click.stop.prevent="remove(index)">
<i class="fas fa-times"></i>
</a>
</div>
<a class="add" @click="add()">
<i class="fas fa-plus add__icon"></i>
Add an option
</a>
<ColorSelectContext
ref="colorContext"
@selected="updateColor(colorContextSelected, $event)"
></ColorSelectContext>
</div>
</template>
<script>
import ColorSelectContext from '@baserow/modules/core/components/ColorSelectContext'
import { colors } from '@baserow/modules/core/utils/colors'
export default {
name: 'FieldSelectOptions',
components: { ColorSelectContext },
props: {
value: {
type: Array,
required: true,
},
},
data() {
return {
colorContextSelected: -1,
}
},
methods: {
remove(index) {
this.$refs.colorContext.hide()
this.value.splice(index, 1)
this.$emit('input', this.value)
},
add() {
this.value.push({
value: '',
color: colors[Math.floor(Math.random() * colors.length)],
})
this.$emit('input', this.value)
},
openColor(index) {
this.colorContextSelected = index
this.$refs.colorContext.setActive(this.value[index].color)
this.$refs.colorContext.toggle(
this.$refs['color-select-' + index][0],
'bottom',
'left',
4
)
},
updateColor(index, color) {
this.value[index].color = color
this.$emit('input', this.value)
},
},
}
</script>

View file

@ -0,0 +1,121 @@
<template>
<div
class="dropdown"
:class="{ 'dropdown--floating': !showInput }"
@contextmenu.stop
>
<a v-if="showInput" class="dropdown__selected" @click="show()">
<div
v-if="hasValue()"
class="field-single-select__dropdown-option field-single-select__dropdown-option--align-32"
:class="'background-color--' + selectedColor"
>
{{ selectedName }}
</div>
<i class="dropdown__toggle-icon fas fa-caret-down"></i>
</a>
<div class="dropdown__items" :class="{ hidden: !open }">
<div v-if="showSearch" class="select__search">
<i class="select__search-icon fas fa-search"></i>
<input
ref="search"
v-model="query"
type="text"
class="select__search-input"
:placeholder="searchText"
@keyup="search(query)"
/>
</div>
<ul ref="items" v-prevent-parent-scroll class="select__items">
<FieldSingleSelectDropdownItem
:name="''"
:value="null"
:color="''"
></FieldSingleSelectDropdownItem>
<FieldSingleSelectDropdownItem
v-for="option in options"
:key="option.id"
:name="option.value"
:value="option.id"
:color="option.color"
></FieldSingleSelectDropdownItem>
</ul>
<template v-if="canCreateOption">
<div class="select__description">
Option not found
</div>
<div class="select__footer">
<a
class="select__footer-button"
:class="{ 'button--loading': createOptionLoading }"
@click="createOption(query)"
>
<i class="fas fa-plus"></i>
Create {{ query }}
</a>
</div>
</template>
</div>
</div>
</template>
<script>
import dropdown from '@baserow/modules/core/mixins/dropdown'
import FieldSingleSelectDropdownItem from '@baserow/modules/database/components/field/FieldSingleSelectDropdownItem'
export default {
name: 'FieldSingleSelectDropdown',
components: { FieldSingleSelectDropdownItem },
mixins: [dropdown],
props: {
options: {
type: Array,
required: true,
},
allowCreateOption: {
type: Boolean,
required: false,
default: false,
},
},
data() {
return {
createOptionLoading: false,
}
},
computed: {
canCreateOption() {
return this.allowCreateOption && this.query !== '' && !this.hasItems
},
selectedColor() {
return this.getSelectedProperty(this.value, 'color')
},
},
methods: {
forceRefreshSelectedValue() {
this._computedWatchers.selectedColor.run()
dropdown.methods.forceRefreshSelectedValue.call(this)
},
createOption(value) {
if (this.createOptionLoading) {
return
}
this.createOptionLoading = true
const done = (success) => {
this.createOptionLoading = false
// If the option was created successfully whe have to find that option, select
// it and hide the dropdown.
if (success) {
const option = this.options.find((o) => o.value === value)
if (option !== undefined) {
this.select(option.id)
}
}
}
this.$emit('create-option', { value, done })
},
},
}
</script>

View file

@ -0,0 +1,40 @@
<template>
<li
class="field-single-select__dropdown-item"
:class="{
hidden: !isVisible(query),
active: isActive(value),
disabled: disabled,
hover: isHovering(value),
}"
>
<a
class="field-single-select__dropdown-link"
@click="select(value, disabled)"
@mousemove="hover(value, disabled)"
>
<div
class="field-single-select__dropdown-option"
:class="'background-color--' + color"
>
{{ name }}
</div>
</a>
</li>
</template>
<script>
import dropdownItem from '@baserow/modules/core/mixins/dropdownItem'
export default {
name: 'FieldSingleSelectDropdownItem',
mixins: [dropdownItem],
props: {
color: {
type: String,
required: false,
default: '',
},
},
}
</script>

View file

@ -0,0 +1,37 @@
<template>
<div>
<div class="control">
<label class="control__label control__label--small">Options</label>
<div class="control__elements">
<FieldSelectOptions
v-model="values.select_options"
></FieldSelectOptions>
</div>
</div>
</div>
</template>
<script>
import form from '@baserow/modules/core/mixins/form'
import fieldSubForm from '@baserow/modules/database/mixins/fieldSubForm'
import FieldSelectOptions from '@baserow/modules/database/components/field/FieldSelectOptions'
export default {
name: 'FieldNumberSubForm',
components: { FieldSelectOptions },
mixins: [form, fieldSubForm],
data() {
return {
allowedValues: ['select_options'],
values: {
select_options: [],
},
}
},
methods: {
isFormValid() {
return true
},
},
}
</script>

View file

@ -8,7 +8,27 @@
<div class="file-field-modal">
<div class="file-field-modal__head">
<div class="file-field-modal__name">
<template v-if="preview">{{ preview.visible_name }}</template>
<template v-if="preview">
<Editable
ref="rename"
:value="preview.visible_name"
@change="
$emit('renamed', {
value: files,
index: selected,
value: $event.value,
})
"
@editing="renaming = $event"
></Editable>
<a
v-show="!renaming"
class="file-field-modal__rename"
@click="$refs.rename.edit()"
>
<i class="fa fa-pen"></i>
</a>
</template>
</div>
<a class="file-field-modal__close" @click="hide()">
<i class="fas fa-times"></i>
@ -97,6 +117,7 @@ export default {
},
data() {
return {
renaming: false,
selected: 0,
}
},
@ -137,6 +158,12 @@ export default {
}
},
keyup(event) {
// When we are renaming we want the arrow keys to be available to move the
// cursor.
if (this.renaming) {
return
}
// If left arrow
if (event.keyCode === 37) {
this.previous()

View file

@ -19,9 +19,9 @@
<script>
import rowEditField from '@baserow/modules/database/mixins/rowEditField'
import rowEditFieldInput from '@baserow/modules/database/mixins/rowEditFieldInput'
import EmailField from '@baserow/modules/database/mixins/EmailField'
import emailField from '@baserow/modules/database/mixins/emailField'
export default {
mixins: [rowEditField, rowEditFieldInput, EmailField],
mixins: [rowEditField, rowEditFieldInput, emailField],
}
</script>

View file

@ -18,13 +18,24 @@
</div>
<div class="field-file__description">
<div class="field-file__name">
{{ file.visible_name }}
<Editable
:ref="'rename-' + index"
:value="file.visible_name"
@change="renameFile(value, index, $event.value)"
></Editable>
</div>
<div class="field-file__info">
{{ getDate(file.uploaded_at) }} - {{ file.size | formatBytes }}
</div>
</div>
<div class="field-file__actions">
<a
v-tooltip="'rename'"
class="field-file__action"
@click="$refs['rename-' + index][0].edit()"
>
<i class="fas fa-pen"></i>
</a>
<a
v-tooltip="'download'"
target="_blank"
@ -43,8 +54,8 @@
</div>
</li>
</ul>
<a class="field-file__add" @click.prevent="showModal()">
<i class="fas fa-plus field-file__add-icon"></i>
<a class="add" @click.prevent="showModal()">
<i class="fas fa-plus add__icon"></i>
Add a file
</a>
<UserFilesModal
@ -55,6 +66,7 @@
ref="fileModal"
:files="value"
@removed="removeFile(value, $event)"
@renamed="renameFile(value, $event.index, $event.value)"
></FileFieldModal>
</div>
</template>

View file

@ -19,8 +19,8 @@
</a>
</li>
</ul>
<a class="field-link-row__add" @click.prevent="$refs.selectModal.show()">
<i class="fas fa-plus field-link-row__add-icon"></i>
<a class="add" @click.prevent="$refs.selectModal.show()">
<i class="fas fa-plus add__icon"></i>
Add another link
</a>
<SelectRowModal

View file

@ -0,0 +1,21 @@
<template>
<div class="control__elements">
<FieldSingleSelectDropdown
:value="valueId"
:options="field.select_options"
:allow-create-option="true"
@input="updateValue($event, value)"
@create-option="createOption($event)"
></FieldSingleSelectDropdown>
</div>
</template>
<script>
import rowEditField from '@baserow/modules/database/mixins/rowEditField'
import singleSelectField from '@baserow/modules/database/mixins/singleSelectField'
export default {
name: 'RowEditFieldSingleSelectVue',
mixins: [rowEditField, singleSelectField],
}
</script>

View file

@ -3,32 +3,30 @@
<h2 v-if="primary !== undefined" class="box__title">
{{ getHeading(primary, row) }}
</h2>
<form>
<RowEditModalField
v-for="field in getFields(fields, primary)"
:ref="'field-' + field.id"
:key="'row-edit-field-' + field.id"
<RowEditModalField
v-for="field in getFields(fields, primary)"
:ref="'field-' + field.id"
:key="'row-edit-field-' + field.id"
:table="table"
:field="field"
:row="row"
@update="update"
@field-updated="$emit('field-updated')"
@field-deleted="$emit('field-deleted')"
></RowEditModalField>
<div class="actions">
<a
ref="createFieldContextLink"
@click="$refs.createFieldContext.toggle($refs.createFieldContextLink)"
>
<i class="fas fa-plus"></i>
add field
</a>
<CreateFieldContext
ref="createFieldContext"
:table="table"
:field="field"
:row="row"
@update="update"
@field-updated="$emit('field-updated')"
@field-deleted="$emit('field-deleted')"
></RowEditModalField>
<div class="actions">
<a
ref="createFieldContextLink"
@click="$refs.createFieldContext.toggle($refs.createFieldContextLink)"
>
<i class="fas fa-plus"></i>
add field
</a>
<CreateFieldContext
ref="createFieldContext"
:table="table"
></CreateFieldContext>
</div>
</form>
></CreateFieldContext>
</div>
</Modal>
</template>

View file

@ -0,0 +1,52 @@
<template>
<FieldSingleSelectDropdown
:value="copy"
:options="field.select_options"
class="dropdown--floating filters__value-dropdown"
@input="input"
></FieldSingleSelectDropdown>
</template>
<script>
import FieldSingleSelectDropdown from '@baserow/modules/database/components/field/FieldSingleSelectDropdown'
export default {
name: 'ViewFilterTypeSelectOptions',
components: { FieldSingleSelectDropdown },
props: {
value: {
type: String,
required: true,
},
fieldId: {
type: Number,
required: true,
},
primary: {
type: Object,
required: true,
},
fields: {
type: Array,
required: true,
},
},
computed: {
copy() {
const value = this.value
return value === '' ? null : parseInt(value) || null
},
field() {
return this.primary.id === this.fieldId
? this.primary
: this.fields.find((f) => f.id === this.fieldId)
},
},
methods: {
input(value) {
value = value === null ? '' : value.toString()
this.$emit('input', value)
},
},
}
</script>

View file

@ -287,6 +287,18 @@
</div>
<Context ref="rowContext">
<ul class="context__menu">
<li>
<a @click=";[addRow(selectedRow), $refs.rowContext.hide()]">
<i class="context__menu-icon fas fa-fw fa-arrow-up"></i>
Insert row above
</a>
</li>
<li>
<a @click=";[addRowAfter(selectedRow), $refs.rowContext.hide()]">
<i class="context__menu-icon fas fa-fw fa-arrow-down"></i>
Insert row below
</a>
</li>
<li>
<a
@click="
@ -571,7 +583,7 @@ export default {
this.$refs.scrollbars.update()
}
},
async addRow() {
async addRow(before = null) {
try {
await this.$store.dispatch('view/grid/create', {
view: this.view,
@ -579,11 +591,28 @@ export default {
// We need a list of all fields including the primary one here.
fields: [this.primary].concat(...this.fields),
values: {},
before,
})
} catch (error) {
notifyIf(error, 'row')
}
},
/**
* Because it is only possible to add a new row before another row, we have to
* figure out which row is below the given row and insert before that one. If the
* next row is not found, we can safely assume it is the last row and add it last.
*/
addRowAfter(row) {
const rows = this.$store.getters['view/grid/getAllRows']
const index = rows.findIndex((r) => r.id === row.id)
let nextRow = null
if (index !== -1 && rows.length > index + 1) {
nextRow = rows[index + 1]
}
this.addRow(nextRow)
},
showRowContext(event, row) {
this.selectedRow = row
this.$refs.rowContext.toggle(

View file

@ -31,10 +31,10 @@
<script>
import gridField from '@baserow/modules/database/mixins/gridField'
import gridFieldInput from '@baserow/modules/database/mixins/gridFieldInput'
import EmailField from '@baserow/modules/database/mixins/EmailField'
import emailField from '@baserow/modules/database/mixins/emailField'
export default {
mixins: [gridField, gridFieldInput, EmailField],
mixins: [gridField, gridFieldInput, emailField],
methods: {
afterEdit() {
this.$nextTick(() => {

View file

@ -66,6 +66,7 @@
:files="value"
@hidden="hideModal"
@removed="removeFile(value, $event)"
@renamed="renameFile(value, $event.index, $event.value)"
></FileFieldModal>
</div>
</template>

View file

@ -0,0 +1,105 @@
<template>
<div ref="cell" class="grid-view__cell" :class="{ active: selected }">
<div
ref="dropdownLink"
class="grid-field-single-select"
:class="{ 'grid-field-single-select--selected': selected }"
@click="toggleDropdown()"
>
<div
v-if="value !== null"
class="grid-field-single-select__option"
:class="'background-color--' + value.color"
>
{{ value.value }}
</div>
<i
v-if="selected"
class="fa fa-caret-down grid-field-single-select__icon"
></i>
</div>
<FieldSingleSelectDropdown
v-if="selected"
ref="dropdown"
:value="valueId"
:options="field.select_options"
:show-input="false"
:allow-create-option="true"
class="dropdown--floating grid-field-single-select__dropdown"
@show="editing = true"
@hide="editing = false"
@input="updateValue($event, value)"
@create-option="createOption($event)"
></FieldSingleSelectDropdown>
</div>
</template>
<script>
import gridField from '@baserow/modules/database/mixins/gridField'
import { isCharacterKeyPress } from '@baserow/modules/core/utils/events'
import singleSelectField from '@baserow/modules/database/mixins/singleSelectField'
export default {
mixins: [gridField, singleSelectField],
data() {
return {
editing: false,
}
},
methods: {
toggleDropdown(value, query) {
if (!this.selected) {
return
}
this.$refs.dropdown.toggle(this.$refs.dropdownLink, value, query)
},
hideDropdown() {
this.$refs.dropdown.hide()
},
select() {
this.$el.keydownEvent = (event) => {
// If the tab or arrow keys are pressed we don't want to do anything because
// the GridViewField component will select the next field.
const ignoredKeys = [9, 37, 38, 39, 40]
if (ignoredKeys.includes(event.keyCode)) {
return
}
// When the escape key is pressed while editing the value we can hide the
// dropdown.
if (event.keyCode === 27 && this.editing) {
this.hideDropdown()
return
}
// When the enter key is pressed when not editing the value we want to show the
// dropdown.
if (
!this.editing &&
(event.keyCode === 13 || isCharacterKeyPress(event))
) {
this.toggleDropdown()
}
}
document.body.addEventListener('keydown', this.$el.keydownEvent)
},
beforeUnSelect() {
this.hideDropdown()
document.body.removeEventListener('keydown', this.$el.keydownEvent)
},
canSelectNext() {
return !this.editing
},
canCopy() {
return !this.editing
},
canPaste() {
return !this.editing
},
canEmpty() {
return !this.editing
},
},
}
</script>

View file

@ -1,4 +1,5 @@
import moment from 'moment'
import BigNumber from 'bignumber.js'
import { isValidURL, isValidEmail } from '@baserow/modules/core/utils/string'
import { Registerable } from '@baserow/modules/core/registry'
@ -7,6 +8,7 @@ import FieldNumberSubForm from '@baserow/modules/database/components/field/Field
import FieldTextSubForm from '@baserow/modules/database/components/field/FieldTextSubForm'
import FieldDateSubForm from '@baserow/modules/database/components/field/FieldDateSubForm'
import FieldLinkRowSubForm from '@baserow/modules/database/components/field/FieldLinkRowSubForm'
import FieldSingleSelectSubForm from '@baserow/modules/database/components/field/FieldSingleSelectSubForm'
import GridViewFieldText from '@baserow/modules/database/components/view/grid/GridViewFieldText'
import GridViewFieldLongText from '@baserow/modules/database/components/view/grid/GridViewFieldLongText'
@ -17,6 +19,7 @@ import GridViewFieldNumber from '@baserow/modules/database/components/view/grid/
import GridViewFieldBoolean from '@baserow/modules/database/components/view/grid/GridViewFieldBoolean'
import GridViewFieldDate from '@baserow/modules/database/components/view/grid/GridViewFieldDate'
import GridViewFieldFile from '@baserow/modules/database/components/view/grid/GridViewFieldFile'
import GridViewFieldSingleSelect from '@baserow/modules/database/components/view/grid/GridViewFieldSingleSelect'
import RowEditFieldText from '@baserow/modules/database/components/row/RowEditFieldText'
import RowEditFieldLongText from '@baserow/modules/database/components/row/RowEditFieldLongText'
@ -27,6 +30,7 @@ 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 RowEditFieldFile from '@baserow/modules/database/components/row/RowEditFieldFile'
import RowEditFieldSingleSelect from '@baserow/modules/database/components/row/RowEditFieldSingleSelect'
import { trueString } from '@baserow/modules/database/utils/constants'
@ -446,6 +450,10 @@ export class LinkRowFieldType extends FieldType {
}
export class NumberFieldType extends FieldType {
static getMaxNumberLength() {
return 50
}
static getType() {
return 'number'
}
@ -493,7 +501,12 @@ export class NumberFieldType extends FieldType {
*/
prepareValueForPaste(field, clipboardData) {
const value = clipboardData.getData('text')
if (isNaN(parseFloat(value)) || !isFinite(value)) {
if (
isNaN(parseFloat(value)) ||
!isFinite(value) ||
value.split('.')[0].replace('-', '').length >
NumberFieldType.getMaxNumberLength()
) {
return null
}
return this.constructor.formatNumber(field, value)
@ -510,15 +523,15 @@ export class NumberFieldType extends FieldType {
}
const decimalPlaces =
field.number_type === 'DECIMAL' ? field.number_decimal_places : 0
let number = parseFloat(value)
if (!field.number_negative && number < 0) {
let number = new BigNumber(value)
if (!field.number_negative && number.isLessThan(0)) {
number = 0
}
return number.toFixed(decimalPlaces)
}
getDocsDataType(field) {
return field.number_type === 'DECIMAL' ? 'decimal' : 'integer'
return field.number_type === 'DECIMAL' ? 'decimal' : 'number'
}
getDocsDescription(field) {
@ -880,3 +893,101 @@ export class FileFieldType extends FieldType {
]
}
}
export class SingleSelectFieldType extends FieldType {
static getType() {
return 'single_select'
}
getIconClass() {
return 'chevron-circle-down '
}
getName() {
return 'Single select'
}
getFormComponent() {
return FieldSingleSelectSubForm
}
getGridViewFieldComponent() {
return GridViewFieldSingleSelect
}
getRowEditFieldComponent() {
return RowEditFieldSingleSelect
}
getSort(name, order) {
return (a, b) => {
const stringA = a[name] === null ? '' : '' + a[name].value
const stringB = b[name] === null ? '' : '' + b[name].value
return order === 'ASC'
? stringA.localeCompare(stringB)
: stringB.localeCompare(stringA)
}
}
prepareValueForUpdate(field, value) {
if (value === undefined || value === null) {
return null
}
return value.id
}
prepareValueForCopy(field, value) {
if (value === undefined || value === null) {
return ''
}
return value.id
}
prepareValueForPaste(field, clipboardData) {
const value = parseInt(clipboardData.getData('text'))
for (let i = 0; i <= field.select_options.length; i++) {
const option = field.select_options[i]
if (option.id === value) {
return option
}
}
}
getDocsDataType() {
return 'integer'
}
getDocsDescription(field) {
const options = field.select_options
.map(
(option) =>
// @TODO move this template to a component.
`<div class="select-options-listing">
<div class="select-options-listing__id">${option.id}</div>
<div class="select-options-listing__value background-color--${option.color}">${option.value}</div>
</div>
`
)
.join('\n')
return `
Accepts an integer representing the chosen select option id or null if none is selected.
<br />
${options}
`
}
getDocsRequestExample() {
return 1
}
getDocsResponseExample() {
return {
id: 1,
value: 'Option',
color: 'light-blue',
}
}
}

Some files were not shown because too many files have changed in this diff Show more