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:
commit
3f2f87a10f
113 changed files with 4735 additions and 652 deletions
LICENSEREADME.md
backend
setup.py
changelog.mdsrc/baserow
api/user
config/settings
contrib/database
api
config.pydatabase_routers.pydb
fields
management/commands
migrations
0021_auto_20201215_2047.py0022_row_order.py0023_convert_int_to_bigint.py0024_selectoption_singleselectfield.py
plugins.pyrows
table
views
core
tests
baserow
api/users
contrib/database
api
fields
rows
views/grid
db
field
rows
table
view
core
fixtures
web-frontend/modules
core
assets/scss
components
add.scssall.scss
helpers.scssapi_docs
color_select.scsscolors.scssfields
file_field_modal.scssfilters.scssselect.scssselect_options.scssselect_options_listing.scssviews/grid
components
directives
mixins
pages
utils
database
2
LICENSE
2
LICENSE
|
@ -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
|
||||
|
|
|
@ -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.
|
||||
|
||||

|
||||
|
||||
## 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.
|
||||
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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'},
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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)(),
|
||||
|
|
|
@ -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()
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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())
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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."""
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
21
backend/src/baserow/contrib/database/fields/fields.py
Normal file
21
backend/src/baserow/contrib/database/fields/fields.py
Normal 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
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
]
|
|
@ -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)
|
||||
]
|
|
@ -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),
|
||||
]
|
|
@ -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'),
|
||||
},
|
||||
),
|
||||
]
|
|
@ -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}},
|
||||
|
|
|
@ -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():
|
||||
|
|
|
@ -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):
|
||||
"""
|
||||
|
|
|
@ -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
|
||||
)
|
||||
|
||||
|
|
|
@ -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."""
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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),
|
||||
),
|
||||
]
|
|
@ -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
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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]
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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'
|
||||
)
|
||||
|
|
|
@ -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'],
|
||||
|
|
|
@ -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={
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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')"
|
||||
)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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'})
|
||||
|
|
|
@ -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]
|
|
@ -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
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
34
backend/tests/fixtures/field.py
vendored
34
backend/tests/fixtures/field.py
vendored
|
@ -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
|
||||
|
|
19
changelog.md
19
changelog.md
|
@ -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.
|
||||
|
|
18
web-frontend/modules/core/assets/scss/components/add.scss
Normal file
18
web-frontend/modules/core/assets/scss/components/add.scss
Normal 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;
|
||||
}
|
|
@ -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';
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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%;
|
||||
}
|
||||
|
|
|
@ -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%);
|
||||
}
|
||||
}
|
59
web-frontend/modules/core/assets/scss/components/colors.scss
Normal file
59
web-frontend/modules/core/assets/scss/components/colors.scss
Normal 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;
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
|
|
|
@ -95,6 +95,10 @@
|
|||
line-height: 30px;
|
||||
}
|
||||
|
||||
.filters__value-dropdown {
|
||||
width: 130px;
|
||||
}
|
||||
|
||||
.filters_footer {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
|
@ -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);
|
||||
}
|
|
@ -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);
|
||||
|
|
47
web-frontend/modules/core/components/ColorSelectContext.vue
Normal file
47
web-frontend/modules/core/components/ColorSelectContext.vue
Normal 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>
|
|
@ -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 =
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
},
|
||||
}
|
||||
|
|
271
web-frontend/modules/core/mixins/dropdown.js
Normal file
271
web-frontend/modules/core/mixins/dropdown.js
Normal 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
|
||||
},
|
||||
},
|
||||
}
|
55
web-frontend/modules/core/mixins/dropdownItem.js
Normal file
55
web-frontend/modules/core/mixins/dropdownItem.js
Normal 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
|
||||
},
|
||||
},
|
||||
}
|
|
@ -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
|
||||
}, {})
|
||||
|
|
|
@ -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>
|
||||
|
|
17
web-frontend/modules/core/utils/colors.js
Normal file
17
web-frontend/modules/core/utils/colors.js
Normal 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',
|
||||
]
|
|
@ -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, '\\$&')
|
||||
}
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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()
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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>
|
|
@ -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(
|
||||
|
|
|
@ -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(() => {
|
||||
|
|
|
@ -66,6 +66,7 @@
|
|||
:files="value"
|
||||
@hidden="hideModal"
|
||||
@removed="removeFile(value, $event)"
|
||||
@renamed="renameFile(value, $event.index, $event.value)"
|
||||
></FileFieldModal>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
@ -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>
|
|
@ -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
Loading…
Add table
Reference in a new issue