mirror of
https://gitlab.com/bramw/baserow.git
synced 2025-04-10 23:50:12 +00:00
Merge branch '173-form-view' into 'develop'
Resolve "Form view" Closes #494 and #173 See merge request bramw/baserow!277
This commit is contained in:
commit
ccd98b090b
128 changed files with 6474 additions and 943 deletions
backend
src/baserow
api
__init__.py
applications
decorators.pyextensions.pyopenapi.pyserializers.pytemplates
user_files
utils.pyconfig/settings
contrib/database
core
tests
baserow
api/user_files
contrib/database
fixtures
docs/getting-started
web-frontend/modules
core
assets/scss
components
directives
mixins
plugins
utils
database/components
|
@ -1,6 +1,6 @@
|
|||
from .extensions import ( # noqa: F401
|
||||
PolymorphicMappingSerializerExtension,
|
||||
PolymorphicCustomFieldRegistrySerializerExtension,
|
||||
DiscriminatorMappingSerializerExtension,
|
||||
DiscriminatorCustomFieldsMappingSerializerExtension,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
@ -10,7 +10,7 @@ from drf_spectacular.openapi import OpenApiParameter, OpenApiTypes
|
|||
from baserow.api.decorators import validate_body, map_exceptions
|
||||
from baserow.api.errors import ERROR_USER_NOT_IN_GROUP, ERROR_GROUP_DOES_NOT_EXIST
|
||||
from baserow.api.schemas import get_error_schema
|
||||
from baserow.api.utils import PolymorphicMappingSerializer
|
||||
from baserow.api.utils import DiscriminatorMappingSerializer
|
||||
from baserow.api.applications.errors import (
|
||||
ERROR_APPLICATION_DOES_NOT_EXIST,
|
||||
ERROR_APPLICATION_NOT_IN_GROUP,
|
||||
|
@ -55,7 +55,7 @@ class AllApplicationsView(APIView):
|
|||
"groups that the user has access to are going to be listed here."
|
||||
),
|
||||
responses={
|
||||
200: PolymorphicMappingSerializer(
|
||||
200: DiscriminatorMappingSerializer(
|
||||
"Applications", application_type_serializers, many=True
|
||||
),
|
||||
400: get_error_schema(["ERROR_USER_NOT_IN_GROUP"]),
|
||||
|
@ -108,7 +108,7 @@ class ApplicationsView(APIView):
|
|||
"type. An application always belongs to a single group."
|
||||
),
|
||||
responses={
|
||||
200: PolymorphicMappingSerializer(
|
||||
200: DiscriminatorMappingSerializer(
|
||||
"Applications", application_type_serializers, many=True
|
||||
),
|
||||
400: get_error_schema(["ERROR_USER_NOT_IN_GROUP"]),
|
||||
|
@ -160,7 +160,7 @@ class ApplicationsView(APIView):
|
|||
),
|
||||
request=ApplicationCreateSerializer,
|
||||
responses={
|
||||
200: PolymorphicMappingSerializer(
|
||||
200: DiscriminatorMappingSerializer(
|
||||
"Applications", application_type_serializers
|
||||
),
|
||||
400: get_error_schema(
|
||||
|
@ -209,7 +209,7 @@ class ApplicationView(APIView):
|
|||
),
|
||||
request=ApplicationCreateSerializer,
|
||||
responses={
|
||||
200: PolymorphicMappingSerializer(
|
||||
200: DiscriminatorMappingSerializer(
|
||||
"Applications", application_type_serializers
|
||||
),
|
||||
400: get_error_schema(
|
||||
|
@ -250,7 +250,7 @@ class ApplicationView(APIView):
|
|||
),
|
||||
request=ApplicationUpdateSerializer,
|
||||
responses={
|
||||
200: PolymorphicMappingSerializer(
|
||||
200: DiscriminatorMappingSerializer(
|
||||
"Applications", application_type_serializers
|
||||
),
|
||||
400: get_error_schema(
|
||||
|
|
|
@ -109,7 +109,7 @@ def validate_body(serializer_class, partial=False):
|
|||
|
||||
|
||||
def validate_body_custom_fields(
|
||||
registry, base_serializer_class=None, type_attribute_name="type"
|
||||
registry, base_serializer_class=None, type_attribute_name="type", partial=False
|
||||
):
|
||||
"""
|
||||
This decorator can validate the request data dynamically using the generated
|
||||
|
@ -126,6 +126,8 @@ def validate_body_custom_fields(
|
|||
:param type_attribute_name: The attribute name containing the type value in the
|
||||
request data.
|
||||
:type type_attribute_name: str
|
||||
:param partial: Whether the data is a partial update.
|
||||
:type partial: bool
|
||||
:raises RequestBodyValidationException: When the `type` is not provided.
|
||||
:raises ValueError: When the `data` attribute is already in the kwargs. This
|
||||
decorator tries to add the `data` attribute, but cannot do that if it is
|
||||
|
@ -157,6 +159,7 @@ def validate_body_custom_fields(
|
|||
request.data,
|
||||
base_serializer_class=base_serializer_class,
|
||||
type_attribute_name=type_attribute_name,
|
||||
partial=partial,
|
||||
)
|
||||
return func(*args, **kwargs)
|
||||
|
||||
|
|
|
@ -2,7 +2,94 @@ from drf_spectacular.extensions import OpenApiSerializerExtension
|
|||
from drf_spectacular.plumbing import force_instance
|
||||
|
||||
|
||||
class PolymorphicMappingSerializerExtension(OpenApiSerializerExtension):
|
||||
class MappingSerializerExtension(OpenApiSerializerExtension):
|
||||
"""
|
||||
This OpenAPI serializer extension makes it possible to easily define a mapping of
|
||||
serializers. The anyOf attribute will be used, which will give users the option
|
||||
to choose a response type without having a discriminator field. It can be used
|
||||
for a response and request.
|
||||
|
||||
Example:
|
||||
@auto_schema(
|
||||
responses={
|
||||
200: MappingSerializer(
|
||||
"Applications",
|
||||
{
|
||||
'car': CarSerializer,
|
||||
'boat': BoatSerializer
|
||||
}
|
||||
),
|
||||
}
|
||||
)
|
||||
"""
|
||||
|
||||
target_class = "baserow.api.utils.MappingSerializer"
|
||||
|
||||
def get_name(self):
|
||||
return self.target.component_name
|
||||
|
||||
def map_serializer(self, auto_schema, direction):
|
||||
return self._map_serializer(auto_schema, direction, self.target.mapping)
|
||||
|
||||
def _map_serializer(self, auto_schema, direction, mapping):
|
||||
sub_components = []
|
||||
|
||||
for key, serializer_class in mapping.items():
|
||||
sub_serializer = force_instance(serializer_class)
|
||||
resolved_sub_serializer = auto_schema.resolve_serializer(
|
||||
sub_serializer, direction
|
||||
)
|
||||
sub_components.append((key, resolved_sub_serializer.ref))
|
||||
|
||||
return {"anyOf": [ref for _, ref in sub_components]}
|
||||
|
||||
|
||||
class CustomFieldRegistryMappingSerializerExtension(MappingSerializerExtension):
|
||||
"""
|
||||
This OpenAPI serializer extension automatically generates a mapping of the
|
||||
`CustomFieldsInstanceMixin` in the `CustomFieldsRegistryMixin`. The anyOf attribute
|
||||
will be used, which will give users the option to choose a response type without
|
||||
having a discriminator field. It can be used for a response and request.
|
||||
|
||||
Example:
|
||||
@auto_schema(
|
||||
responses={
|
||||
200: CustomFieldRegistryMappingSerializer(
|
||||
'ExampleName',
|
||||
field_type_registry,
|
||||
many=True
|
||||
),
|
||||
}
|
||||
)
|
||||
"""
|
||||
|
||||
target_class = "baserow.api.utils.CustomFieldRegistryMappingSerializer"
|
||||
|
||||
def get_name(self):
|
||||
part_1 = self.target.registry.name.title()
|
||||
part_2 = self.target.base_class.__name__
|
||||
return f"{part_1}{part_2}"
|
||||
|
||||
def map_serializer(self, auto_schema, direction):
|
||||
try:
|
||||
base_ref_name = getattr(getattr(self.target.base_class, "Meta"), "ref_name")
|
||||
except AttributeError:
|
||||
base_ref_name = None
|
||||
|
||||
mapping = {
|
||||
types.type: types.get_serializer_class(
|
||||
base_class=self.target.base_class,
|
||||
meta_ref_name=(
|
||||
f"{types.type} {base_ref_name}" if base_ref_name else None
|
||||
),
|
||||
)
|
||||
for types in self.target.registry.registry.values()
|
||||
}
|
||||
|
||||
return self._map_serializer(auto_schema, direction, mapping)
|
||||
|
||||
|
||||
class DiscriminatorMappingSerializerExtension(OpenApiSerializerExtension):
|
||||
"""
|
||||
This OpenAPI serializer extension makes it possible to easily define polymorphic
|
||||
relationships. It can be used for a response and request.
|
||||
|
@ -10,11 +97,11 @@ class PolymorphicMappingSerializerExtension(OpenApiSerializerExtension):
|
|||
Example:
|
||||
@auto_schema(
|
||||
responses={
|
||||
200: PolymorphicMappingSerializer(
|
||||
200: DiscriminatorMappingSerializer(
|
||||
'ExampleName',
|
||||
{
|
||||
'car': CarSerializer,
|
||||
'boat': BoarSerializer
|
||||
'boat': BoatSerializer
|
||||
},
|
||||
many=True
|
||||
),
|
||||
|
@ -22,7 +109,7 @@ class PolymorphicMappingSerializerExtension(OpenApiSerializerExtension):
|
|||
)
|
||||
"""
|
||||
|
||||
target_class = "baserow.api.utils.PolymorphicMappingSerializer"
|
||||
target_class = "baserow.api.utils.DiscriminatorMappingSerializer"
|
||||
|
||||
def get_name(self):
|
||||
return self.target.component_name
|
||||
|
@ -51,8 +138,8 @@ class PolymorphicMappingSerializerExtension(OpenApiSerializerExtension):
|
|||
}
|
||||
|
||||
|
||||
class PolymorphicCustomFieldRegistrySerializerExtension(
|
||||
PolymorphicMappingSerializerExtension
|
||||
class DiscriminatorCustomFieldsMappingSerializerExtension(
|
||||
DiscriminatorMappingSerializerExtension
|
||||
):
|
||||
"""
|
||||
This OpenAPI serializer extension automatically generates a mapping of the
|
||||
|
@ -62,7 +149,7 @@ class PolymorphicCustomFieldRegistrySerializerExtension(
|
|||
Example:
|
||||
@auto_schema(
|
||||
responses={
|
||||
200: PolymorphicCustomFieldRegistrySerializer(
|
||||
200: DiscriminatorCustomFieldsMappingSerializer(
|
||||
'ExampleName',
|
||||
field_type_registry,
|
||||
many=True
|
||||
|
@ -71,7 +158,7 @@ class PolymorphicCustomFieldRegistrySerializerExtension(
|
|||
)
|
||||
"""
|
||||
|
||||
target_class = "baserow.api.utils.PolymorphicCustomFieldRegistrySerializer"
|
||||
target_class = "baserow.api.utils.DiscriminatorCustomFieldsMappingSerializer"
|
||||
|
||||
def get_name(self):
|
||||
part_1 = self.target.registry.name.title()
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
from drf_spectacular.openapi import AutoSchema as RegularAutoSchema
|
||||
|
||||
from .utils import (
|
||||
PolymorphicMappingSerializer,
|
||||
PolymorphicCustomFieldRegistrySerializer,
|
||||
DiscriminatorMappingSerializer,
|
||||
DiscriminatorCustomFieldsMappingSerializer,
|
||||
)
|
||||
|
||||
|
||||
|
@ -14,8 +14,8 @@ class AutoSchema(RegularAutoSchema):
|
|||
"""
|
||||
|
||||
if (
|
||||
isinstance(serializer, PolymorphicMappingSerializer)
|
||||
or isinstance(serializer, PolymorphicCustomFieldRegistrySerializer)
|
||||
isinstance(serializer, DiscriminatorMappingSerializer)
|
||||
or isinstance(serializer, DiscriminatorCustomFieldsMappingSerializer)
|
||||
) and serializer.many:
|
||||
return True
|
||||
|
||||
|
|
|
@ -1,12 +1,8 @@
|
|||
from rest_framework import serializers
|
||||
|
||||
from baserow.contrib.database.api.views.grid.serializers import (
|
||||
GridViewFieldOptionsField,
|
||||
)
|
||||
|
||||
|
||||
def get_example_pagination_serializer_class(
|
||||
results_serializer_class, add_field_options=False
|
||||
results_serializer_class, additional_fields=None, serializer_name=None
|
||||
):
|
||||
"""
|
||||
Generates a pagination like response serializer that has the provided serializer
|
||||
|
@ -15,9 +11,14 @@ def get_example_pagination_serializer_class(
|
|||
|
||||
:param results_serializer_class: The serializer class that needs to be added as
|
||||
results.
|
||||
:param add_field_options: When true will include the field_options field on the
|
||||
returned serializer.
|
||||
:type results_serializer_class: Serializer
|
||||
:param additional_fields: A dict containing additional fields that must be added
|
||||
to the serializer. The fields are going to be placed at the root of the
|
||||
serializer.
|
||||
:type additional_fields: dict
|
||||
:param serializer_name: The class name of the serializer. Generated serializer
|
||||
should be unique because serializer with the same class name are reused.
|
||||
:type serializer_name: str
|
||||
:return: The generated pagination serializer.
|
||||
:rtype: Serializer
|
||||
"""
|
||||
|
@ -33,10 +34,11 @@ def get_example_pagination_serializer_class(
|
|||
"results": results_serializer_class(many=True),
|
||||
}
|
||||
|
||||
serializer_name = "PaginationSerializer"
|
||||
if add_field_options:
|
||||
fields["field_options"] = GridViewFieldOptionsField(required=False)
|
||||
serializer_name = serializer_name + "WithFieldOptions"
|
||||
if additional_fields:
|
||||
fields.update(**additional_fields)
|
||||
|
||||
if not serializer_name:
|
||||
serializer_name = "PaginationSerializer"
|
||||
|
||||
return type(
|
||||
serializer_name + results_serializer_class.__name__,
|
||||
|
|
|
@ -9,7 +9,7 @@ from baserow.api.templates.serializers import TemplateCategoriesSerializer
|
|||
from baserow.api.decorators import map_exceptions
|
||||
from baserow.api.errors import ERROR_USER_NOT_IN_GROUP, ERROR_GROUP_DOES_NOT_EXIST
|
||||
from baserow.api.schemas import get_error_schema
|
||||
from baserow.api.utils import PolymorphicMappingSerializer
|
||||
from baserow.api.utils import DiscriminatorMappingSerializer
|
||||
from baserow.api.applications.serializers import get_application_serializer
|
||||
from baserow.api.applications.views import application_type_serializers
|
||||
from baserow.core.models import TemplateCategory
|
||||
|
@ -74,7 +74,7 @@ class InstallTemplateView(APIView):
|
|||
"created applications."
|
||||
),
|
||||
responses={
|
||||
200: PolymorphicMappingSerializer(
|
||||
200: DiscriminatorMappingSerializer(
|
||||
"Applications", application_type_serializers, many=True
|
||||
),
|
||||
400: get_error_schema(
|
||||
|
|
|
@ -5,6 +5,7 @@ from drf_spectacular.types import OpenApiTypes
|
|||
|
||||
from django.conf import settings
|
||||
from django.core.files.storage import default_storage
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from baserow.core.models import UserFile
|
||||
from baserow.core.user_files.handler import UserFileHandler
|
||||
|
@ -70,3 +71,55 @@ class UserFileSerializer(
|
|||
@extend_schema_field(OpenApiTypes.STR)
|
||||
def get_name(self, instance):
|
||||
return instance.name
|
||||
|
||||
|
||||
@extend_schema_field(UserFileSerializer)
|
||||
class UserFileField(serializers.Field):
|
||||
"""
|
||||
This field can be used for validating user provided user files, which means a
|
||||
user has provided a dict containing the user file name. It will check if that
|
||||
user file exists and returns that instance. Vice versa, a user file instance will
|
||||
be serialized when converted to data by the serializer.
|
||||
|
||||
Example:
|
||||
Serializer(data={
|
||||
"user_file": {"name": "filename.jpg"}
|
||||
}).data == {"user_file": UserFile(...)}
|
||||
|
||||
The field can also be used for serializing a user file. The value must then be
|
||||
provided as instance to the serializer.
|
||||
|
||||
Example:
|
||||
Serializer({
|
||||
"user_file": UserFile(...)
|
||||
}).data == {"user_file": {"name": "filename.jpg", ...}}
|
||||
"""
|
||||
|
||||
default_error_messages = {
|
||||
"invalid_value": _("The value must be an object containing the file name."),
|
||||
"invalid_user_file": _("The provided user file does not exist."),
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
allow_null = kwargs.pop("allow_null", True)
|
||||
default = kwargs.pop("default", None)
|
||||
super().__init__(allow_null=allow_null, default=default, *args, **kwargs)
|
||||
|
||||
def to_internal_value(self, data):
|
||||
if isinstance(data, UserFile):
|
||||
return data
|
||||
|
||||
if not isinstance(data, dict) or not isinstance(data.get("name"), str):
|
||||
self.fail("invalid_value")
|
||||
|
||||
try:
|
||||
user_file = UserFile.objects.all().name(data["name"]).get()
|
||||
except UserFile.DoesNotExist:
|
||||
self.fail("invalid_user_file")
|
||||
|
||||
return user_file
|
||||
|
||||
def to_representation(self, value):
|
||||
if isinstance(value, UserFile) and self.parent.instance is not None:
|
||||
return UserFileSerializer(value).data
|
||||
return value
|
||||
|
|
|
@ -95,7 +95,12 @@ def validate_data(serializer_class, data, partial=False):
|
|||
|
||||
|
||||
def validate_data_custom_fields(
|
||||
type_name, registry, data, base_serializer_class=None, type_attribute_name="type"
|
||||
type_name,
|
||||
registry,
|
||||
data,
|
||||
base_serializer_class=None,
|
||||
type_attribute_name="type",
|
||||
partial=False,
|
||||
):
|
||||
"""
|
||||
Validates the provided data with the serializer generated by the registry based on
|
||||
|
@ -113,6 +118,8 @@ def validate_data_custom_fields(
|
|||
:type base_serializer_class: ModelSerializer
|
||||
:param type_attribute_name: The attribute key name that contains the type value.
|
||||
:type type_attribute_name: str
|
||||
:param partial: Whether the data is a partial update.
|
||||
:type partial: bool
|
||||
:raises RequestBodyValidationException: When the type is not a valid choice.
|
||||
:return: The validated data.
|
||||
:rtype: dict
|
||||
|
@ -136,7 +143,7 @@ def validate_data_custom_fields(
|
|||
|
||||
serializer_kwargs = {"base_class": base_serializer_class}
|
||||
serializer_class = type_instance.get_serializer_class(**serializer_kwargs)
|
||||
return validate_data(serializer_class, data)
|
||||
return validate_data(serializer_class, data, partial=partial)
|
||||
|
||||
|
||||
def get_request(args):
|
||||
|
@ -184,7 +191,9 @@ def type_from_data_or_registry(
|
|||
return registry.get_by_model(model_instance.specific_class).type
|
||||
|
||||
|
||||
def get_serializer_class(model, field_names, field_overrides=None, base_class=None):
|
||||
def get_serializer_class(
|
||||
model, field_names, field_overrides=None, base_class=None, meta_ref_name=None
|
||||
):
|
||||
"""
|
||||
Generates a model serializer based on the provided field names and field overrides.
|
||||
|
||||
|
@ -197,18 +206,23 @@ def get_serializer_class(model, field_names, field_overrides=None, base_class=No
|
|||
:type field_overrides: dict
|
||||
:param base_class: The class that must be extended.
|
||||
:type base_class: ModelSerializer
|
||||
:param meta_ref_name: Optionally a custom ref name can be set. If not provided,
|
||||
then the class name of the model and base class are used.
|
||||
:type meta_ref_name: str
|
||||
:return: The generated model serializer containing the provided fields.
|
||||
:rtype: ModelSerializer
|
||||
"""
|
||||
|
||||
model_ = model
|
||||
meta_ref_name = model_.__name__
|
||||
|
||||
if not field_overrides:
|
||||
field_overrides = {}
|
||||
|
||||
if base_class:
|
||||
meta_ref_name += base_class.__name__
|
||||
if not meta_ref_name:
|
||||
meta_ref_name = model_.__name__
|
||||
|
||||
if base_class:
|
||||
meta_ref_name += base_class.__name__
|
||||
|
||||
if not base_class:
|
||||
base_class = ModelSerializer
|
||||
|
@ -232,9 +246,35 @@ def get_serializer_class(model, field_names, field_overrides=None, base_class=No
|
|||
return type(str(model_.__name__ + "Serializer"), (base_class,), attrs)
|
||||
|
||||
|
||||
class PolymorphicCustomFieldRegistrySerializer:
|
||||
class MappingSerializer:
|
||||
"""
|
||||
A placeholder class for the `PolymorphicCustomFieldRegistrySerializerExtension`
|
||||
A placeholder class for the `MappingSerializerExtension` extension class.
|
||||
"""
|
||||
|
||||
def __init__(self, component_name, mapping, name, many=False):
|
||||
self.read_only = False
|
||||
self.component_name = component_name
|
||||
self.mapping = mapping
|
||||
self.name = name
|
||||
self.many = many
|
||||
|
||||
|
||||
class CustomFieldRegistryMappingSerializer:
|
||||
"""
|
||||
A placeholder class for the `CustomFieldRegistryMappingSerializerExtension`
|
||||
extension class.
|
||||
"""
|
||||
|
||||
def __init__(self, registry, base_class, many=False):
|
||||
self.read_only = False
|
||||
self.registry = registry
|
||||
self.base_class = base_class
|
||||
self.many = many
|
||||
|
||||
|
||||
class DiscriminatorCustomFieldsMappingSerializer:
|
||||
"""
|
||||
A placeholder class for the `DiscriminatorCustomFieldsMappingSerializerExtension`
|
||||
extension class.
|
||||
"""
|
||||
|
||||
|
@ -246,9 +286,10 @@ class PolymorphicCustomFieldRegistrySerializer:
|
|||
self.many = many
|
||||
|
||||
|
||||
class PolymorphicMappingSerializer:
|
||||
class DiscriminatorMappingSerializer:
|
||||
"""
|
||||
A placeholder class for the `PolymorphicMappingSerializerExtension` extension class.
|
||||
A placeholder class for the `DiscriminatorMappingSerializerExtension` extension
|
||||
class.
|
||||
"""
|
||||
|
||||
def __init__(self, component_name, mapping, type_field_name="type", many=False):
|
||||
|
|
|
@ -233,6 +233,7 @@ SPECTACULAR_SETTINGS = {
|
|||
{"name": "Database table view filters"},
|
||||
{"name": "Database table view sortings"},
|
||||
{"name": "Database table grid view"},
|
||||
{"name": "Database table form view"},
|
||||
{"name": "Database table rows"},
|
||||
{"name": "Database table export"},
|
||||
{"name": "Database tokens"},
|
||||
|
|
|
@ -1,11 +1,11 @@
|
|||
from django.conf.urls import url
|
||||
|
||||
from baserow.contrib.database.views.registries import view_type_registry
|
||||
from .views import ExportJobView, ExportTableView
|
||||
|
||||
|
||||
app_name = "baserow.contrib.database.api.export"
|
||||
|
||||
urlpatterns = view_type_registry.api_urls + [
|
||||
urlpatterns = [
|
||||
url(
|
||||
r"table/(?P<table_id>[0-9]+)/$",
|
||||
ExportTableView.as_view(),
|
||||
|
|
|
@ -14,7 +14,7 @@ from baserow.api.errors import (
|
|||
ERROR_USER_NOT_IN_GROUP,
|
||||
)
|
||||
from baserow.api.schemas import get_error_schema
|
||||
from baserow.api.utils import validate_data, PolymorphicMappingSerializer
|
||||
from baserow.api.utils import validate_data, DiscriminatorMappingSerializer
|
||||
from baserow.contrib.database.api.export.errors import (
|
||||
ERROR_EXPORT_JOB_DOES_NOT_EXIST,
|
||||
ERROR_TABLE_ONLY_EXPORT_UNSUPPORTED,
|
||||
|
@ -44,7 +44,7 @@ from baserow.core.exceptions import UserNotInGroup
|
|||
User = get_user_model()
|
||||
|
||||
# A placeholder serializer only used to generate correct api documentation.
|
||||
CreateExportJobSerializer = PolymorphicMappingSerializer(
|
||||
CreateExportJobSerializer = DiscriminatorMappingSerializer(
|
||||
"Export",
|
||||
lazy(table_exporter_registry.get_option_serializer_map, dict)(),
|
||||
type_field_name="exporter_type",
|
||||
|
|
|
@ -12,7 +12,7 @@ from baserow.contrib.database.fields.registries import field_type_registry
|
|||
|
||||
|
||||
class FieldSerializer(serializers.ModelSerializer):
|
||||
type = serializers.SerializerMethodField()
|
||||
type = serializers.SerializerMethodField(help_text="The type of the related field.")
|
||||
|
||||
class Meta:
|
||||
model = Field
|
||||
|
@ -65,17 +65,17 @@ class UpdateFieldSerializer(serializers.ModelSerializer):
|
|||
|
||||
class LinkRowValueSerializer(serializers.Serializer):
|
||||
id = serializers.IntegerField(
|
||||
help_text="The unique identifier of the row in the " "related table."
|
||||
read_only=True,
|
||||
help_text="The unique identifier of the row in the related table.",
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
value_field_name = kwargs.pop("value_field_name", "value")
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields["value"] = serializers.CharField(
|
||||
help_text="The primary field's value as a string of the row in the "
|
||||
"related table.",
|
||||
source=value_field_name,
|
||||
required=False,
|
||||
source="*",
|
||||
)
|
||||
|
||||
|
||||
|
|
|
@ -11,7 +11,7 @@ from drf_spectacular.openapi import OpenApiParameter, OpenApiTypes
|
|||
from baserow.api.decorators import validate_body_custom_fields, map_exceptions
|
||||
from baserow.api.utils import validate_data_custom_fields, type_from_data_or_registry
|
||||
from baserow.api.errors import ERROR_USER_NOT_IN_GROUP
|
||||
from baserow.api.utils import PolymorphicCustomFieldRegistrySerializer
|
||||
from baserow.api.utils import DiscriminatorCustomFieldsMappingSerializer
|
||||
from baserow.api.schemas import get_error_schema
|
||||
from baserow.core.exceptions import UserNotInGroup
|
||||
from baserow.contrib.database.api.tables.errors import ERROR_TABLE_DOES_NOT_EXIST
|
||||
|
@ -77,7 +77,7 @@ class FieldsView(APIView):
|
|||
"table's column."
|
||||
),
|
||||
responses={
|
||||
200: PolymorphicCustomFieldRegistrySerializer(
|
||||
200: DiscriminatorCustomFieldsMappingSerializer(
|
||||
field_type_registry, FieldSerializer, many=True
|
||||
),
|
||||
400: get_error_schema(["ERROR_USER_NOT_IN_GROUP"]),
|
||||
|
@ -134,11 +134,11 @@ class FieldsView(APIView):
|
|||
"group. Depending on the type, different properties can optionally be "
|
||||
"set."
|
||||
),
|
||||
request=PolymorphicCustomFieldRegistrySerializer(
|
||||
request=DiscriminatorCustomFieldsMappingSerializer(
|
||||
field_type_registry, CreateFieldSerializer
|
||||
),
|
||||
responses={
|
||||
200: PolymorphicCustomFieldRegistrySerializer(
|
||||
200: DiscriminatorCustomFieldsMappingSerializer(
|
||||
field_type_registry, FieldSerializer
|
||||
),
|
||||
400: get_error_schema(
|
||||
|
@ -212,7 +212,7 @@ class FieldView(APIView):
|
|||
"could be returned."
|
||||
),
|
||||
responses={
|
||||
200: PolymorphicCustomFieldRegistrySerializer(
|
||||
200: DiscriminatorCustomFieldsMappingSerializer(
|
||||
field_type_registry, FieldSerializer
|
||||
),
|
||||
400: get_error_schema(["ERROR_USER_NOT_IN_GROUP"]),
|
||||
|
@ -253,11 +253,11 @@ class FieldView(APIView):
|
|||
"rarely happens. If a data value cannot be converted it is set to `null` "
|
||||
"so data might go lost."
|
||||
),
|
||||
request=PolymorphicCustomFieldRegistrySerializer(
|
||||
request=DiscriminatorCustomFieldsMappingSerializer(
|
||||
field_type_registry, UpdateFieldSerializer
|
||||
),
|
||||
responses={
|
||||
200: PolymorphicCustomFieldRegistrySerializer(
|
||||
200: DiscriminatorCustomFieldsMappingSerializer(
|
||||
field_type_registry, FieldSerializer
|
||||
),
|
||||
400: get_error_schema(
|
||||
|
|
|
@ -19,7 +19,9 @@ class RowSerializer(serializers.ModelSerializer):
|
|||
extra_kwargs = {"id": {"read_only": True}, "order": {"read_only": True}}
|
||||
|
||||
|
||||
def get_row_serializer_class(model, base_class=None, is_response=False, field_ids=None):
|
||||
def get_row_serializer_class(
|
||||
model, base_class=None, is_response=False, field_ids=None, field_kwargs=None
|
||||
):
|
||||
"""
|
||||
Generates a Django rest framework model serializer based on the available fields
|
||||
that belong to this model. For each table field, used to generate this serializer,
|
||||
|
@ -39,10 +41,16 @@ def get_row_serializer_class(model, base_class=None, is_response=False, field_id
|
|||
the serializer. By default all the fields of the model are going to be
|
||||
included. Note that the field id must exist in the model in order to work.
|
||||
:type field_ids: list or None
|
||||
:param field_kwargs: A dict containing additional kwargs per field. The key must
|
||||
be the field name and the value a dict containing the kwargs.
|
||||
:type field_kwargs: dict
|
||||
:return: The generated serializer.
|
||||
:rtype: ModelSerializer
|
||||
"""
|
||||
|
||||
if not field_kwargs:
|
||||
field_kwargs = {}
|
||||
|
||||
field_objects = model._field_objects
|
||||
field_names = [
|
||||
field["name"]
|
||||
|
@ -50,9 +58,13 @@ def get_row_serializer_class(model, base_class=None, is_response=False, field_id
|
|||
if field_ids is None or field["field"].id in field_ids
|
||||
]
|
||||
field_overrides = {
|
||||
field["name"]: field["type"].get_response_serializer_field(field["field"])
|
||||
field["name"]: field["type"].get_response_serializer_field(
|
||||
field["field"], **field_kwargs.get(field["name"], {})
|
||||
)
|
||||
if is_response
|
||||
else field["type"].get_serializer_field(field["field"])
|
||||
else field["type"].get_serializer_field(
|
||||
field["field"], **field_kwargs.get(field["name"], {})
|
||||
)
|
||||
for field in field_objects.values()
|
||||
if field_ids is None or field["field"].id in field_ids
|
||||
}
|
||||
|
@ -132,8 +144,3 @@ def get_example_row_serializer_class(add_id=False):
|
|||
example_pagination_row_serializer_class = get_example_pagination_serializer_class(
|
||||
get_example_row_serializer_class(True)
|
||||
)
|
||||
example_pagination_row_serializer_class_with_field_options = (
|
||||
get_example_pagination_serializer_class(
|
||||
get_example_row_serializer_class(True), add_field_options=True
|
||||
)
|
||||
)
|
||||
|
|
|
@ -563,6 +563,7 @@ class RowMoveView(APIView):
|
|||
"to the end. If the `before_row_id` is provided then the row related to "
|
||||
"the `row_id` parameter is moved before that row. If the `before_row_id` "
|
||||
"parameter is not provided, then the row will be moved to the end.",
|
||||
request=None,
|
||||
responses={
|
||||
200: get_example_row_serializer_class(True),
|
||||
400: get_error_schema(["ERROR_USER_NOT_IN_GROUP"]),
|
||||
|
|
|
@ -51,3 +51,13 @@ ERROR_VIEW_SORT_FIELD_NOT_SUPPORTED = (
|
|||
HTTP_400_BAD_REQUEST,
|
||||
"The field does not support view sorting.",
|
||||
)
|
||||
ERROR_UNRELATED_FIELD = (
|
||||
"ERROR_UNRELATED_FIELD",
|
||||
HTTP_400_BAD_REQUEST,
|
||||
"The field is not related to the provided view.",
|
||||
)
|
||||
ERROR_VIEW_DOES_NOT_SUPPORT_FIELD_OPTIONS = (
|
||||
"ERROR_VIEW_DOES_NOT_SUPPORT_FIELD_OPTIONS",
|
||||
HTTP_400_BAD_REQUEST,
|
||||
"This view model does not support field options.",
|
||||
)
|
||||
|
|
|
@ -0,0 +1,13 @@
|
|||
from rest_framework.status import HTTP_400_BAD_REQUEST, HTTP_404_NOT_FOUND
|
||||
|
||||
|
||||
ERROR_FORM_DOES_NOT_EXIST = (
|
||||
"ERROR_FORM_DOES_NOT_EXIST",
|
||||
HTTP_404_NOT_FOUND,
|
||||
"The requested form does not exist.",
|
||||
)
|
||||
ERROR_FORM_VIEW_FIELD_TYPE_IS_NOT_SUPPORTED = (
|
||||
"ERROR_FORM_VIEW_FIELD_TYPE_IS_NOT_SUPPORTED",
|
||||
HTTP_400_BAD_REQUEST,
|
||||
"The {e.field_type} field type is not compatible with the form view.",
|
||||
)
|
|
@ -0,0 +1,76 @@
|
|||
from rest_framework import serializers
|
||||
from drf_spectacular.types import OpenApiTypes
|
||||
from drf_spectacular.utils import extend_schema_field
|
||||
|
||||
from baserow.api.user_files.serializers import UserFileField
|
||||
from baserow.contrib.database.api.fields.serializers import FieldSerializer
|
||||
from baserow.contrib.database.fields.models import Field
|
||||
from baserow.contrib.database.views.models import FormView, FormViewFieldOptions
|
||||
from baserow.contrib.database.fields.registries import field_type_registry
|
||||
|
||||
|
||||
class FormViewFieldOptionsSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = FormViewFieldOptions
|
||||
fields = ("name", "description", "enabled", "required", "order")
|
||||
|
||||
|
||||
class PublicFormViewFieldSerializer(FieldSerializer):
|
||||
class Meta:
|
||||
model = Field
|
||||
fields = (
|
||||
"id",
|
||||
"type",
|
||||
)
|
||||
|
||||
|
||||
class PublicFormViewFieldOptionsSerializer(FieldSerializer):
|
||||
field = serializers.SerializerMethodField(
|
||||
help_text="The properties of the related field. These can be used to construct "
|
||||
"the correct input. Additional properties could be added depending on the "
|
||||
"field type."
|
||||
)
|
||||
name = serializers.SerializerMethodField(
|
||||
help_text="If provided, then this value will be visible above the field input.",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = FormViewFieldOptions
|
||||
fields = ("name", "description", "required", "order", "field")
|
||||
|
||||
# @TODO show correct API docs discriminated by field type.
|
||||
@extend_schema_field(PublicFormViewFieldSerializer)
|
||||
def get_field(self, instance):
|
||||
return field_type_registry.get_serializer(
|
||||
instance.field, PublicFormViewFieldSerializer
|
||||
).data
|
||||
|
||||
@extend_schema_field(OpenApiTypes.STR)
|
||||
def get_name(self, instance):
|
||||
return instance.name or instance.field.name
|
||||
|
||||
|
||||
class PublicFormViewSerializer(serializers.ModelSerializer):
|
||||
cover_image = UserFileField(
|
||||
help_text="The user file cover image that is displayed at the top of the form.",
|
||||
)
|
||||
logo_image = UserFileField(
|
||||
help_text="The user file logo image that is displayed at the top of the form.",
|
||||
)
|
||||
fields = PublicFormViewFieldOptionsSerializer(
|
||||
many=True, source="active_field_options"
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = FormView
|
||||
fields = ("title", "description", "cover_image", "logo_image", "fields")
|
||||
|
||||
|
||||
class FormViewSubmittedSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = FormView
|
||||
fields = (
|
||||
"submit_action",
|
||||
"submit_action_message",
|
||||
"submit_action_redirect_url",
|
||||
)
|
28
backend/src/baserow/contrib/database/api/views/form/urls.py
Normal file
28
backend/src/baserow/contrib/database/api/views/form/urls.py
Normal file
|
@ -0,0 +1,28 @@
|
|||
from django.conf.urls import url
|
||||
|
||||
from .views import (
|
||||
RotateFormViewSlugView,
|
||||
SubmitFormViewView,
|
||||
FormViewLinkRowFieldLookupView,
|
||||
)
|
||||
|
||||
|
||||
app_name = "baserow.contrib.database.api.views.form"
|
||||
|
||||
urlpatterns = [
|
||||
url(
|
||||
r"(?P<view_id>[0-9]+)/rotate-slug/$",
|
||||
RotateFormViewSlugView.as_view(),
|
||||
name="rotate_slug",
|
||||
),
|
||||
url(
|
||||
r"(?P<slug>[-\w]+)/submit/$",
|
||||
SubmitFormViewView.as_view(),
|
||||
name="submit",
|
||||
),
|
||||
url(
|
||||
r"(?P<slug>[-\w]+)/link-row-field-lookup/(?P<field_id>[0-9]+)/$",
|
||||
FormViewLinkRowFieldLookupView.as_view(),
|
||||
name="link_row_field_lookup",
|
||||
),
|
||||
]
|
244
backend/src/baserow/contrib/database/api/views/form/views.py
Normal file
244
backend/src/baserow/contrib/database/api/views/form/views.py
Normal file
|
@ -0,0 +1,244 @@
|
|||
from drf_spectacular.openapi import OpenApiParameter, OpenApiTypes
|
||||
from drf_spectacular.utils import extend_schema
|
||||
from rest_framework.permissions import IsAuthenticated, AllowAny
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
from rest_framework.fields import empty
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.conf import settings
|
||||
|
||||
from baserow.api.decorators import map_exceptions
|
||||
from baserow.api.errors import ERROR_USER_NOT_IN_GROUP
|
||||
from baserow.api.schemas import get_error_schema
|
||||
from baserow.api.utils import validate_data
|
||||
from baserow.api.pagination import PageNumberPagination
|
||||
from baserow.api.serializers import get_example_pagination_serializer_class
|
||||
from baserow.contrib.database.api.views.errors import ERROR_VIEW_DOES_NOT_EXIST
|
||||
from baserow.contrib.database.api.rows.serializers import (
|
||||
get_row_serializer_class,
|
||||
get_example_row_serializer_class,
|
||||
)
|
||||
from baserow.contrib.database.api.fields.errors import ERROR_FIELD_DOES_NOT_EXIST
|
||||
from baserow.contrib.database.api.fields.serializers import LinkRowValueSerializer
|
||||
from baserow.contrib.database.fields.models import LinkRowField
|
||||
from baserow.contrib.database.fields.exceptions import FieldDoesNotExist
|
||||
from baserow.contrib.database.views.exceptions import ViewDoesNotExist
|
||||
from baserow.contrib.database.views.handler import ViewHandler
|
||||
from baserow.contrib.database.views.models import FormView, FormViewFieldOptions
|
||||
from baserow.contrib.database.views.registries import view_type_registry
|
||||
from baserow.contrib.database.views.validators import required_validator
|
||||
from baserow.core.exceptions import UserNotInGroup
|
||||
|
||||
from .errors import ERROR_FORM_DOES_NOT_EXIST
|
||||
from .serializers import PublicFormViewSerializer, FormViewSubmittedSerializer
|
||||
|
||||
form_view_serializer_class = view_type_registry.get("form").get_serializer_class()
|
||||
|
||||
|
||||
class RotateFormViewSlugView(APIView):
|
||||
permission_classes = (IsAuthenticated,)
|
||||
|
||||
@extend_schema(
|
||||
parameters=[
|
||||
OpenApiParameter(
|
||||
name="view_id",
|
||||
location=OpenApiParameter.PATH,
|
||||
type=OpenApiTypes.INT,
|
||||
required=True,
|
||||
description="Rotates the slug of the form view related to the provided "
|
||||
"value.",
|
||||
)
|
||||
],
|
||||
tags=["Database table form view"],
|
||||
operation_id="rotate_database_table_form_view_slug",
|
||||
description=(
|
||||
"Rotates the unique slug of the form view by replacing it with a new "
|
||||
"value. This would mean that the publicly shared URL of the form will "
|
||||
"change. Everyone that knew the URL won't have access to the form anymore."
|
||||
),
|
||||
request=None,
|
||||
responses={
|
||||
200: form_view_serializer_class(many=True),
|
||||
400: get_error_schema(["ERROR_USER_NOT_IN_GROUP"]),
|
||||
404: get_error_schema(["ERROR_VIEW_DOES_NOT_EXIST"]),
|
||||
},
|
||||
)
|
||||
@map_exceptions(
|
||||
{
|
||||
UserNotInGroup: ERROR_USER_NOT_IN_GROUP,
|
||||
ViewDoesNotExist: ERROR_VIEW_DOES_NOT_EXIST,
|
||||
}
|
||||
)
|
||||
def post(self, request, view_id):
|
||||
"""Rotates the slug of a form view."""
|
||||
|
||||
handler = ViewHandler()
|
||||
form = ViewHandler().get_view(view_id, FormView)
|
||||
form = handler.rotate_form_view_slug(request.user, form)
|
||||
return Response(form_view_serializer_class(form).data)
|
||||
|
||||
|
||||
class SubmitFormViewView(APIView):
|
||||
permission_classes = (AllowAny,)
|
||||
|
||||
@extend_schema(
|
||||
parameters=[
|
||||
OpenApiParameter(
|
||||
name="slug",
|
||||
location=OpenApiParameter.PATH,
|
||||
type=OpenApiTypes.STR,
|
||||
required=True,
|
||||
description="The slug related to the form form.",
|
||||
)
|
||||
],
|
||||
tags=["Database table form view"],
|
||||
operation_id="get_meta_database_table_form_view",
|
||||
description=(
|
||||
"Returns the meta data related to the form view if the form is publicly "
|
||||
"shared or if the user has access to the related group. This data can be "
|
||||
"used to construct a form with the right fields."
|
||||
),
|
||||
responses={
|
||||
200: PublicFormViewSerializer,
|
||||
404: get_error_schema(["ERROR_FORM_DOES_NOT_EXIST"]),
|
||||
},
|
||||
)
|
||||
@map_exceptions(
|
||||
{
|
||||
ViewDoesNotExist: ERROR_FORM_DOES_NOT_EXIST,
|
||||
}
|
||||
)
|
||||
def get(self, request, slug):
|
||||
form = ViewHandler().get_public_form_view_by_slug(request.user, slug)
|
||||
serializer = PublicFormViewSerializer(form)
|
||||
return Response(serializer.data)
|
||||
|
||||
@extend_schema(
|
||||
parameters=[
|
||||
OpenApiParameter(
|
||||
name="slug",
|
||||
location=OpenApiParameter.PATH,
|
||||
type=OpenApiTypes.STR,
|
||||
required=True,
|
||||
description="The slug related to the form.",
|
||||
)
|
||||
],
|
||||
tags=["Database table form view"],
|
||||
operation_id="submit_database_table_form_view",
|
||||
description=(
|
||||
"Submits the form if the form is publicly shared or if the user has "
|
||||
"access to the related group. The provided data will be validated based "
|
||||
"on the fields that are in the form and the rules per field. If valid, "
|
||||
"a new row will be created in the table."
|
||||
),
|
||||
request=get_example_row_serializer_class(False),
|
||||
responses={
|
||||
200: FormViewSubmittedSerializer,
|
||||
404: get_error_schema(["ERROR_FORM_DOES_NOT_EXIST"]),
|
||||
},
|
||||
)
|
||||
@map_exceptions(
|
||||
{
|
||||
ViewDoesNotExist: ERROR_FORM_DOES_NOT_EXIST,
|
||||
}
|
||||
)
|
||||
def post(self, request, slug):
|
||||
handler = ViewHandler()
|
||||
form = handler.get_public_form_view_by_slug(request.user, slug)
|
||||
model = form.table.get_model()
|
||||
|
||||
options = form.active_field_options
|
||||
field_kwargs = {
|
||||
model._field_objects[option.field_id]["name"]: {
|
||||
"required": True,
|
||||
"default": empty,
|
||||
"validators": [required_validator],
|
||||
}
|
||||
for option in options
|
||||
if option.required
|
||||
}
|
||||
field_ids = [option.field_id for option in options]
|
||||
validation_serializer = get_row_serializer_class(
|
||||
model, field_ids=field_ids, field_kwargs=field_kwargs
|
||||
)
|
||||
data = validate_data(validation_serializer, request.data)
|
||||
|
||||
handler.submit_form_view(form, data, model, options)
|
||||
return Response(FormViewSubmittedSerializer(form).data)
|
||||
|
||||
|
||||
class FormViewLinkRowFieldLookupView(APIView):
|
||||
permission_classes = (AllowAny,)
|
||||
|
||||
@extend_schema(
|
||||
parameters=[
|
||||
OpenApiParameter(
|
||||
name="slug",
|
||||
location=OpenApiParameter.PATH,
|
||||
type=OpenApiTypes.STR,
|
||||
required=True,
|
||||
description="The slug related to the form.",
|
||||
),
|
||||
OpenApiParameter(
|
||||
name="field_id",
|
||||
location=OpenApiParameter.PATH,
|
||||
type=OpenApiTypes.INT,
|
||||
required=True,
|
||||
description="The field id of the link row field.",
|
||||
),
|
||||
],
|
||||
tags=["Database table form view"],
|
||||
operation_id="database_table_form_view_link_row_field_lookup",
|
||||
description=(
|
||||
"If the form is publicly shared or if an authenticated user has access to "
|
||||
"the related group, then this endpoint can be used to do a value lookup of "
|
||||
"the link row fields that are included in the form. Normally it is not "
|
||||
"possible for a not authenticated visitor to fetch the rows of a table. "
|
||||
"This endpoint makes it possible to fetch the id and primary field value "
|
||||
"of the related table of a link row included in the form view."
|
||||
),
|
||||
responses={
|
||||
200: get_example_pagination_serializer_class(LinkRowValueSerializer),
|
||||
404: get_error_schema(
|
||||
["ERROR_FORM_DOES_NOT_EXIST", "ERROR_FIELD_DOES_NOT_EXIST"]
|
||||
),
|
||||
},
|
||||
)
|
||||
@map_exceptions(
|
||||
{
|
||||
ViewDoesNotExist: ERROR_FORM_DOES_NOT_EXIST,
|
||||
FieldDoesNotExist: ERROR_FIELD_DOES_NOT_EXIST,
|
||||
}
|
||||
)
|
||||
def get(self, request, slug, field_id):
|
||||
handler = ViewHandler()
|
||||
form = handler.get_public_form_view_by_slug(request.user, slug)
|
||||
link_row_field_content_type = ContentType.objects.get_for_model(LinkRowField)
|
||||
|
||||
try:
|
||||
field_option = FormViewFieldOptions.objects.get(
|
||||
field_id=field_id,
|
||||
form_view=form,
|
||||
enabled=True,
|
||||
field__content_type=link_row_field_content_type,
|
||||
)
|
||||
except FormViewFieldOptions.DoesNotExist:
|
||||
raise FieldDoesNotExist("The form view field option does not exist.")
|
||||
|
||||
search = request.GET.get("search")
|
||||
link_row_field = field_option.field.specific
|
||||
table = link_row_field.link_row_table
|
||||
primary_field = table.field_set.filter(primary=True).first()
|
||||
model = table.get_model(fields=[primary_field], field_ids=[])
|
||||
queryset = model.objects.all().enhance_by_fields()
|
||||
|
||||
if search:
|
||||
queryset = queryset.search_all_fields(search)
|
||||
|
||||
paginator = PageNumberPagination(limit_page_size=settings.ROW_PAGE_SIZE_LIMIT)
|
||||
page = paginator.paginate_queryset(queryset, request, self)
|
||||
serializer = LinkRowValueSerializer(
|
||||
page,
|
||||
many=True,
|
||||
)
|
||||
return paginator.get_paginated_response(serializer.data)
|
|
@ -1,4 +1,4 @@
|
|||
from rest_framework.status import HTTP_404_NOT_FOUND, HTTP_400_BAD_REQUEST
|
||||
from rest_framework.status import HTTP_404_NOT_FOUND
|
||||
|
||||
|
||||
ERROR_GRID_DOES_NOT_EXIST = (
|
||||
|
@ -6,9 +6,3 @@ ERROR_GRID_DOES_NOT_EXIST = (
|
|||
HTTP_404_NOT_FOUND,
|
||||
"The requested grid view does not exist.",
|
||||
)
|
||||
|
||||
ERROR_UNRELATED_FIELD = (
|
||||
"ERROR_UNRELATED_FIELD",
|
||||
HTTP_400_BAD_REQUEST,
|
||||
"The field is not related to the provided grid view.",
|
||||
)
|
||||
|
|
|
@ -1,32 +0,0 @@
|
|||
grid_view_field_options_schema = {
|
||||
"type": "object",
|
||||
"description": "An object containing the field id as key and the "
|
||||
"properties related to view as value.",
|
||||
"properties": {
|
||||
"1": {
|
||||
"type": "object",
|
||||
"description": "Properties of field with id 1 of the related view.",
|
||||
"properties": {
|
||||
"width": {
|
||||
"type": "integer",
|
||||
"example": 200,
|
||||
"description": "The width of the table field in the related view.",
|
||||
},
|
||||
"hidden": {
|
||||
"type": "boolean",
|
||||
"example": True,
|
||||
"description": "Whether or not the field should be hidden in the "
|
||||
"current view.",
|
||||
},
|
||||
"order": {
|
||||
"type": "integer",
|
||||
"example": 0,
|
||||
"description": "The position that the field has within the view, "
|
||||
"lowest first. If there is another field with the "
|
||||
"same order value then the field with the lowest "
|
||||
"id must be shown first.",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
|
@ -1,100 +1,6 @@
|
|||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from drf_spectacular.openapi import OpenApiSerializerFieldExtension
|
||||
|
||||
from rest_framework import serializers
|
||||
|
||||
from baserow.contrib.database.views.models import GridView, GridViewFieldOptions
|
||||
from .schemas import grid_view_field_options_schema
|
||||
|
||||
|
||||
class GridViewFieldOptionsField(serializers.Field):
|
||||
default_error_messages = {
|
||||
"invalid_key": _("Field option key must be numeric."),
|
||||
"invalid_value": _("Must be valid field options."),
|
||||
}
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
kwargs["source"] = "*"
|
||||
kwargs["read_only"] = False
|
||||
super().__init__(**kwargs)
|
||||
|
||||
def to_internal_value(self, data):
|
||||
"""
|
||||
This method only validates if the provided data dict is in the correct
|
||||
format. Not if the field id actually exists.
|
||||
|
||||
Example format:
|
||||
{
|
||||
FIELD_ID: {
|
||||
width: 200
|
||||
}
|
||||
}
|
||||
|
||||
:param data: The data that needs to be validated.
|
||||
:type data: dict
|
||||
:return: The validated dict.
|
||||
:rtype: dict
|
||||
"""
|
||||
|
||||
internal = {}
|
||||
for key, value in data.items():
|
||||
if not (isinstance(key, int) or (isinstance(key, str) and key.isnumeric())):
|
||||
self.fail("invalid_key")
|
||||
serializer = GridViewFieldOptionsSerializer(data=value)
|
||||
if not serializer.is_valid():
|
||||
self.fail("invalid_value")
|
||||
internal[int(key)] = serializer.data
|
||||
return internal
|
||||
|
||||
def to_representation(self, value):
|
||||
"""
|
||||
If the provided value is a GridView instance we need to fetch the options from
|
||||
the database. We can easily use the `get_field_options` of the GridView for
|
||||
that and format the dict the way we want.
|
||||
|
||||
If the provided value is a dict it means the field options have already been
|
||||
provided and validated once, so we can just return that value. The variant is
|
||||
used when we want to validate the input.
|
||||
|
||||
:param value: The prepared value that needs to be serialized.
|
||||
:type value: GridView or dict
|
||||
:return: A dictionary containing the
|
||||
:rtype: dict
|
||||
"""
|
||||
|
||||
if isinstance(value, GridView):
|
||||
# If the fields are in the context we can pass them into the
|
||||
# `get_field_options` call so that they don't have to be fetched from the
|
||||
# database again.
|
||||
fields = self.context.get("fields")
|
||||
return {
|
||||
field_options.field_id: GridViewFieldOptionsSerializer(
|
||||
field_options
|
||||
).data
|
||||
for field_options in value.get_field_options(True, fields)
|
||||
}
|
||||
else:
|
||||
return value
|
||||
|
||||
|
||||
class GridViewFieldOptionsFieldFix(OpenApiSerializerFieldExtension):
|
||||
target_class = (
|
||||
"baserow.contrib.database.api.views.grid.serializers."
|
||||
"GridViewFieldOptionsField"
|
||||
)
|
||||
|
||||
def map_serializer_field(self, auto_schema, direction):
|
||||
return grid_view_field_options_schema
|
||||
|
||||
|
||||
class GridViewSerializer(serializers.ModelSerializer):
|
||||
field_options = GridViewFieldOptionsField(required=False)
|
||||
filters_disabled = serializers.BooleanField(required=False)
|
||||
|
||||
class Meta:
|
||||
model = GridView
|
||||
fields = ("field_options", "filters_disabled")
|
||||
from baserow.contrib.database.views.models import GridViewFieldOptions
|
||||
|
||||
|
||||
class GridViewFieldOptionsSerializer(serializers.ModelSerializer):
|
||||
|
|
|
@ -9,25 +9,24 @@ from baserow.api.decorators import map_exceptions, allowed_includes, validate_bo
|
|||
from baserow.api.errors import ERROR_USER_NOT_IN_GROUP
|
||||
from baserow.api.pagination import PageNumberPagination
|
||||
from baserow.api.schemas import get_error_schema
|
||||
from baserow.api.serializers import get_example_pagination_serializer_class
|
||||
from baserow.contrib.database.api.rows.serializers import (
|
||||
get_example_row_serializer_class,
|
||||
)
|
||||
from baserow.contrib.database.api.rows.serializers import (
|
||||
get_row_serializer_class,
|
||||
RowSerializer,
|
||||
example_pagination_row_serializer_class_with_field_options,
|
||||
)
|
||||
from baserow.contrib.database.api.views.serializers import FieldOptionsField
|
||||
from baserow.contrib.database.api.views.grid.serializers import (
|
||||
GridViewSerializer,
|
||||
)
|
||||
from baserow.contrib.database.views.exceptions import (
|
||||
ViewDoesNotExist,
|
||||
UnrelatedFieldError,
|
||||
GridViewFieldOptionsSerializer,
|
||||
)
|
||||
from baserow.contrib.database.views.registries import view_type_registry
|
||||
from baserow.contrib.database.views.exceptions import ViewDoesNotExist
|
||||
from baserow.contrib.database.views.handler import ViewHandler
|
||||
from baserow.contrib.database.views.models import GridView
|
||||
from baserow.core.exceptions import UserNotInGroup
|
||||
from .errors import ERROR_GRID_DOES_NOT_EXIST, ERROR_UNRELATED_FIELD
|
||||
from .errors import ERROR_GRID_DOES_NOT_EXIST
|
||||
from .serializers import GridViewFilterSerializer
|
||||
|
||||
|
||||
|
@ -122,7 +121,15 @@ class GridViewView(APIView):
|
|||
"`list_database_table_view_sortings` endpoints."
|
||||
),
|
||||
responses={
|
||||
200: example_pagination_row_serializer_class_with_field_options,
|
||||
200: get_example_pagination_serializer_class(
|
||||
get_example_row_serializer_class(True),
|
||||
additional_fields={
|
||||
"field_options": FieldOptionsField(
|
||||
serializer_class=GridViewFieldOptionsSerializer, required=False
|
||||
)
|
||||
},
|
||||
serializer_name="PaginationSerializerWithGridViewFieldOptions",
|
||||
),
|
||||
400: get_error_schema(["ERROR_USER_NOT_IN_GROUP"]),
|
||||
404: get_error_schema(["ERROR_GRID_DOES_NOT_EXIST"]),
|
||||
},
|
||||
|
@ -148,6 +155,7 @@ class GridViewView(APIView):
|
|||
|
||||
view_handler = ViewHandler()
|
||||
view = view_handler.get_view(view_id, GridView)
|
||||
view_type = view_type_registry.get_by_model(view)
|
||||
|
||||
view.table.database.group.has_user(
|
||||
request.user, raise_error=True, allow_if_template=True
|
||||
|
@ -172,13 +180,9 @@ class GridViewView(APIView):
|
|||
response = paginator.get_paginated_response(serializer.data)
|
||||
|
||||
if field_options:
|
||||
# The serializer has the GridViewFieldOptionsField which fetches the
|
||||
# field options from the database and creates them if they don't exist,
|
||||
# but when added to the context the fields don't have to be fetched from
|
||||
# the database again when checking if they exist.
|
||||
context = {"fields": [o["field"] for o in model._field_objects.values()]}
|
||||
serialized_view = GridViewSerializer(view, context=context).data
|
||||
response.data["field_options"] = serialized_view["field_options"]
|
||||
serializer_class = view_type.get_field_options_serializer_class()
|
||||
response.data.update(**serializer_class(view, context=context).data)
|
||||
|
||||
return response
|
||||
|
||||
|
@ -240,62 +244,3 @@ class GridViewView(APIView):
|
|||
)
|
||||
serializer = serializer_class(results, many=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
@extend_schema(
|
||||
parameters=[
|
||||
OpenApiParameter(
|
||||
name="view_id",
|
||||
location=OpenApiParameter.PATH,
|
||||
type=OpenApiTypes.INT,
|
||||
required=False,
|
||||
description="Updates the field related to the provided `view_id` "
|
||||
"parameter.",
|
||||
)
|
||||
],
|
||||
tags=["Database table grid view"],
|
||||
operation_id="update_database_table_grid_view_field_options",
|
||||
description=(
|
||||
"Updates the field options of a `grid` view. The field options are unique "
|
||||
"options per field for a view. This could for example be used to update "
|
||||
"the field width if the user changes it."
|
||||
),
|
||||
request=GridViewSerializer,
|
||||
responses={
|
||||
200: GridViewSerializer,
|
||||
400: get_error_schema(
|
||||
[
|
||||
"ERROR_USER_NOT_IN_GROUP",
|
||||
"ERROR_UNRELATED_FIELD",
|
||||
"ERROR_REQUEST_BODY_VALIDATION",
|
||||
]
|
||||
),
|
||||
404: get_error_schema(["ERROR_GRID_DOES_NOT_EXIST"]),
|
||||
},
|
||||
)
|
||||
@map_exceptions(
|
||||
{
|
||||
UserNotInGroup: ERROR_USER_NOT_IN_GROUP,
|
||||
ViewDoesNotExist: ERROR_GRID_DOES_NOT_EXIST,
|
||||
UnrelatedFieldError: ERROR_UNRELATED_FIELD,
|
||||
}
|
||||
)
|
||||
@validate_body(GridViewSerializer)
|
||||
def patch(self, request, view_id, data):
|
||||
"""
|
||||
Updates the field options for the provided grid view.
|
||||
|
||||
The following example body data will only update the width of the FIELD_ID
|
||||
and leaves the others untouched.
|
||||
{
|
||||
FIELD_ID: {
|
||||
'width': 200
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
handler = ViewHandler()
|
||||
view = handler.get_view(view_id, GridView)
|
||||
handler.update_grid_view_field_options(
|
||||
request.user, view, data["field_options"]
|
||||
)
|
||||
return Response(GridViewSerializer(view).data)
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
from django.utils.functional import lazy
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from drf_spectacular.utils import extend_schema_field
|
||||
from drf_spectacular.openapi import OpenApiTypes
|
||||
|
@ -13,6 +14,82 @@ from baserow.contrib.database.views.registries import (
|
|||
from baserow.contrib.database.views.models import View, ViewFilter, ViewSort
|
||||
|
||||
|
||||
class FieldOptionsField(serializers.Field):
|
||||
default_error_messages = {
|
||||
"invalid_key": _("Field option key must be numeric."),
|
||||
"invalid_value": _("Must be valid field options."),
|
||||
}
|
||||
|
||||
def __init__(self, serializer_class, **kwargs):
|
||||
self.serializer_class = serializer_class
|
||||
self._spectacular_annotation = {
|
||||
"field": serializers.DictField(
|
||||
child=serializer_class(),
|
||||
help_text="An object containing the field id as key and the properties "
|
||||
"related to view as value.",
|
||||
)
|
||||
}
|
||||
kwargs["source"] = "*"
|
||||
kwargs["read_only"] = False
|
||||
super().__init__(**kwargs)
|
||||
|
||||
def to_internal_value(self, data):
|
||||
"""
|
||||
This method only passes the validation if the provided data dict is in the
|
||||
correct format. Not if the field id actually exists.
|
||||
|
||||
Example format:
|
||||
{
|
||||
FIELD_ID: {
|
||||
"value": 200
|
||||
}
|
||||
}
|
||||
|
||||
:param data: The data that needs to be validated.
|
||||
:type data: dict
|
||||
:return: The validated dict.
|
||||
:rtype: dict
|
||||
"""
|
||||
|
||||
internal = {}
|
||||
for key, value in data.items():
|
||||
if not (isinstance(key, int) or (isinstance(key, str) and key.isnumeric())):
|
||||
self.fail("invalid_key")
|
||||
serializer = self.serializer_class(data=value)
|
||||
if not serializer.is_valid():
|
||||
self.fail("invalid_value")
|
||||
internal[int(key)] = serializer.data
|
||||
return internal
|
||||
|
||||
def to_representation(self, value):
|
||||
"""
|
||||
If the provided value is a GridView instance we need to fetch the options from
|
||||
the database. We can easily use the `get_field_options` of the GridView for
|
||||
that and format the dict the way we want.
|
||||
|
||||
If the provided value is a dict it means the field options have already been
|
||||
provided and validated once, so we can just return that value. The variant is
|
||||
used when we want to validate the input.
|
||||
|
||||
:param value: The prepared value that needs to be serialized.
|
||||
:type value: GridView or dict
|
||||
:return: A dictionary containing the
|
||||
:rtype: dict
|
||||
"""
|
||||
|
||||
if isinstance(value, View):
|
||||
# If the fields are in the context we can pass them into the
|
||||
# `get_field_options` call so that they don't have to be fetched from the
|
||||
# database again.
|
||||
fields = self.context.get("fields")
|
||||
return {
|
||||
field_options.field_id: self.serializer_class(field_options).data
|
||||
for field_options in value.get_field_options(True, fields)
|
||||
}
|
||||
else:
|
||||
return value
|
||||
|
||||
|
||||
class ViewFilterSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = ViewFilter
|
||||
|
@ -121,7 +198,9 @@ class ViewSerializer(serializers.ModelSerializer):
|
|||
|
||||
|
||||
class CreateViewSerializer(serializers.ModelSerializer):
|
||||
type = serializers.ChoiceField(choices=lazy(view_type_registry.get_types, list)())
|
||||
type = serializers.ChoiceField(
|
||||
choices=lazy(view_type_registry.get_types, list)(), required=True
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = View
|
||||
|
@ -130,6 +209,7 @@ class CreateViewSerializer(serializers.ModelSerializer):
|
|||
|
||||
class UpdateViewSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
ref_name = "view update"
|
||||
model = View
|
||||
fields = ("name", "filter_type", "filters_disabled")
|
||||
extra_kwargs = {
|
||||
|
|
|
@ -10,6 +10,7 @@ from .views import (
|
|||
ViewFilterView,
|
||||
ViewSortingsView,
|
||||
ViewSortView,
|
||||
ViewFieldOptionsView,
|
||||
)
|
||||
|
||||
|
||||
|
@ -33,4 +34,9 @@ urlpatterns = view_type_registry.api_urls + [
|
|||
ViewSortingsView.as_view(),
|
||||
name="list_sortings",
|
||||
),
|
||||
url(
|
||||
r"(?P<view_id>[0-9]+)/field-options/$",
|
||||
ViewFieldOptionsView.as_view(),
|
||||
name="field_options",
|
||||
),
|
||||
]
|
||||
|
|
|
@ -13,9 +13,16 @@ from baserow.api.decorators import (
|
|||
map_exceptions,
|
||||
allowed_includes,
|
||||
)
|
||||
from baserow.api.utils import validate_data_custom_fields
|
||||
from baserow.api.utils import (
|
||||
validate_data_custom_fields,
|
||||
validate_data,
|
||||
MappingSerializer,
|
||||
)
|
||||
from baserow.api.errors import ERROR_USER_NOT_IN_GROUP
|
||||
from baserow.api.utils import PolymorphicCustomFieldRegistrySerializer
|
||||
from baserow.api.utils import (
|
||||
DiscriminatorCustomFieldsMappingSerializer,
|
||||
CustomFieldRegistryMappingSerializer,
|
||||
)
|
||||
from baserow.api.schemas import get_error_schema
|
||||
from baserow.core.exceptions import UserNotInGroup
|
||||
from baserow.contrib.database.api.fields.errors import ERROR_FIELD_NOT_IN_TABLE
|
||||
|
@ -37,6 +44,8 @@ from baserow.contrib.database.views.exceptions import (
|
|||
ViewSortNotSupported,
|
||||
ViewSortFieldAlreadyExist,
|
||||
ViewSortFieldNotSupported,
|
||||
UnrelatedFieldError,
|
||||
ViewDoesNotSupportFieldOptions,
|
||||
)
|
||||
|
||||
from .serializers import (
|
||||
|
@ -61,6 +70,15 @@ from .errors import (
|
|||
ERROR_VIEW_SORT_NOT_SUPPORTED,
|
||||
ERROR_VIEW_SORT_FIELD_ALREADY_EXISTS,
|
||||
ERROR_VIEW_SORT_FIELD_NOT_SUPPORTED,
|
||||
ERROR_UNRELATED_FIELD,
|
||||
ERROR_VIEW_DOES_NOT_SUPPORT_FIELD_OPTIONS,
|
||||
)
|
||||
|
||||
|
||||
view_field_options_mapping_serializer = MappingSerializer(
|
||||
"ViewFieldOptions",
|
||||
view_type_registry.get_field_options_serializer_map(),
|
||||
"view_type",
|
||||
)
|
||||
|
||||
|
||||
|
@ -108,7 +126,7 @@ class ViewsView(APIView):
|
|||
"are going to be added. Each type can have different properties."
|
||||
),
|
||||
responses={
|
||||
200: PolymorphicCustomFieldRegistrySerializer(
|
||||
200: DiscriminatorCustomFieldsMappingSerializer(
|
||||
view_type_registry, ViewSerializer, many=True
|
||||
),
|
||||
400: get_error_schema(["ERROR_USER_NOT_IN_GROUP"]),
|
||||
|
@ -179,11 +197,11 @@ class ViewsView(APIView):
|
|||
"group. Depending on the type, different properties can optionally be "
|
||||
"set."
|
||||
),
|
||||
request=PolymorphicCustomFieldRegistrySerializer(
|
||||
request=DiscriminatorCustomFieldsMappingSerializer(
|
||||
view_type_registry, CreateViewSerializer
|
||||
),
|
||||
responses={
|
||||
200: PolymorphicCustomFieldRegistrySerializer(
|
||||
200: DiscriminatorCustomFieldsMappingSerializer(
|
||||
view_type_registry, ViewSerializer
|
||||
),
|
||||
400: get_error_schema(
|
||||
|
@ -194,7 +212,7 @@ class ViewsView(APIView):
|
|||
)
|
||||
@transaction.atomic
|
||||
@validate_body_custom_fields(
|
||||
view_type_registry, base_serializer_class=CreateViewSerializer
|
||||
view_type_registry, base_serializer_class=CreateViewSerializer, partial=True
|
||||
)
|
||||
@map_exceptions(
|
||||
{
|
||||
|
@ -206,8 +224,12 @@ class ViewsView(APIView):
|
|||
def post(self, request, data, table_id, filters, sortings):
|
||||
"""Creates a new view for a user."""
|
||||
|
||||
type_name = data.pop("type")
|
||||
field_type = view_type_registry.get(type_name)
|
||||
table = TableHandler().get_table(table_id)
|
||||
view = ViewHandler().create_view(request.user, table, data.pop("type"), **data)
|
||||
|
||||
with field_type.map_api_exceptions():
|
||||
view = ViewHandler().create_view(request.user, table, type_name, **data)
|
||||
|
||||
serializer = view_type_registry.get_serializer(
|
||||
view, ViewSerializer, filters=filters, sortings=sortings
|
||||
|
@ -248,7 +270,7 @@ class ViewView(APIView):
|
|||
"could be returned."
|
||||
),
|
||||
responses={
|
||||
200: PolymorphicCustomFieldRegistrySerializer(
|
||||
200: DiscriminatorCustomFieldsMappingSerializer(
|
||||
view_type_registry, ViewSerializer
|
||||
),
|
||||
400: get_error_schema(["ERROR_USER_NOT_IN_GROUP"]),
|
||||
|
@ -301,11 +323,11 @@ class ViewView(APIView):
|
|||
"related database's group. The type cannot be changed. It depends on the "
|
||||
"existing type which properties can be changed."
|
||||
),
|
||||
request=PolymorphicCustomFieldRegistrySerializer(
|
||||
request=CustomFieldRegistryMappingSerializer(
|
||||
view_type_registry, UpdateViewSerializer
|
||||
),
|
||||
responses={
|
||||
200: PolymorphicCustomFieldRegistrySerializer(
|
||||
200: DiscriminatorCustomFieldsMappingSerializer(
|
||||
view_type_registry, ViewSerializer
|
||||
),
|
||||
400: get_error_schema(
|
||||
|
@ -332,9 +354,11 @@ class ViewView(APIView):
|
|||
view_type_registry,
|
||||
request.data,
|
||||
base_serializer_class=UpdateViewSerializer,
|
||||
partial=True,
|
||||
)
|
||||
|
||||
view = ViewHandler().update_view(request.user, view, **data)
|
||||
with view_type.map_api_exceptions():
|
||||
view = ViewHandler().update_view(request.user, view, **data)
|
||||
|
||||
serializer = view_type_registry.get_serializer(
|
||||
view, ViewSerializer, filters=filters, sortings=sortings
|
||||
|
@ -908,3 +932,107 @@ class ViewSortView(APIView):
|
|||
ViewHandler().delete_sort(request.user, view)
|
||||
|
||||
return Response(status=204)
|
||||
|
||||
|
||||
class ViewFieldOptionsView(APIView):
|
||||
permission_classes = (IsAuthenticated,)
|
||||
|
||||
@extend_schema(
|
||||
parameters=[
|
||||
OpenApiParameter(
|
||||
name="view_id",
|
||||
location=OpenApiParameter.PATH,
|
||||
type=OpenApiTypes.INT,
|
||||
description="Responds with field options related to the provided "
|
||||
"value.",
|
||||
)
|
||||
],
|
||||
tags=["Database table views"],
|
||||
operation_id="get_database_table_view_field_options",
|
||||
description="Responds with the fields options of the provided view if the "
|
||||
"authenticated user has access to the related group.",
|
||||
responses={
|
||||
200: view_field_options_mapping_serializer,
|
||||
400: get_error_schema(
|
||||
[
|
||||
"ERROR_USER_NOT_IN_GROUP",
|
||||
"ERROR_VIEW_DOES_NOT_SUPPORT_FIELD_OPTIONS",
|
||||
]
|
||||
),
|
||||
404: get_error_schema(["ERROR_VIEW_DOES_NOT_EXIST"]),
|
||||
},
|
||||
)
|
||||
@map_exceptions(
|
||||
{
|
||||
UserNotInGroup: ERROR_USER_NOT_IN_GROUP,
|
||||
ViewDoesNotExist: ERROR_VIEW_DOES_NOT_EXIST,
|
||||
ViewDoesNotSupportFieldOptions: ERROR_VIEW_DOES_NOT_SUPPORT_FIELD_OPTIONS,
|
||||
}
|
||||
)
|
||||
def get(self, request, view_id):
|
||||
"""Returns the field options of the view."""
|
||||
|
||||
view = ViewHandler().get_view(view_id).specific
|
||||
view.table.database.group.has_user(
|
||||
request.user, raise_error=True, allow_if_template=True
|
||||
)
|
||||
view_type = view_type_registry.get_by_model(view)
|
||||
|
||||
try:
|
||||
serializer_class = view_type.get_field_options_serializer_class()
|
||||
except ValueError:
|
||||
raise ViewDoesNotSupportFieldOptions(
|
||||
"The view type does not have a `field_options_serializer_class`"
|
||||
)
|
||||
|
||||
return Response(serializer_class(view).data)
|
||||
|
||||
@extend_schema(
|
||||
parameters=[
|
||||
OpenApiParameter(
|
||||
name="view_id",
|
||||
location=OpenApiParameter.PATH,
|
||||
type=OpenApiTypes.INT,
|
||||
description="Updates the field options related to the provided value.",
|
||||
)
|
||||
],
|
||||
tags=["Database table views"],
|
||||
operation_id="update_database_table_view_field_options",
|
||||
description="Updates the field options of a view. The field options differ "
|
||||
"per field type This could for example be used to update the field width of "
|
||||
"a `grid` view if the user changes it.",
|
||||
request=view_field_options_mapping_serializer,
|
||||
responses={
|
||||
200: view_field_options_mapping_serializer,
|
||||
400: get_error_schema(
|
||||
[
|
||||
"ERROR_USER_NOT_IN_GROUP",
|
||||
"ERROR_VIEW_DOES_NOT_SUPPORT_FIELD_OPTIONS",
|
||||
]
|
||||
),
|
||||
404: get_error_schema(["ERROR_VIEW_DOES_NOT_EXIST"]),
|
||||
},
|
||||
)
|
||||
@transaction.atomic
|
||||
@map_exceptions(
|
||||
{
|
||||
UserNotInGroup: ERROR_USER_NOT_IN_GROUP,
|
||||
ViewDoesNotExist: ERROR_VIEW_DOES_NOT_EXIST,
|
||||
UnrelatedFieldError: ERROR_UNRELATED_FIELD,
|
||||
ViewDoesNotSupportFieldOptions: ERROR_VIEW_DOES_NOT_SUPPORT_FIELD_OPTIONS,
|
||||
}
|
||||
)
|
||||
def patch(self, request, view_id):
|
||||
"""Updates the field option of the view."""
|
||||
|
||||
handler = ViewHandler()
|
||||
view = handler.get_view(view_id).specific
|
||||
view_type = view_type_registry.get_by_model(view)
|
||||
serializer_class = view_type.get_field_options_serializer_class()
|
||||
data = validate_data(serializer_class, request.data)
|
||||
|
||||
with view_type.map_api_exceptions():
|
||||
handler.update_field_options(request.user, view, data["field_options"])
|
||||
|
||||
serializer = serializer_class(view)
|
||||
return Response(serializer.data)
|
||||
|
|
|
@ -87,9 +87,10 @@ class DatabaseConfig(AppConfig):
|
|||
field_converter_registry.register(LinkRowFieldConverter())
|
||||
field_converter_registry.register(FileFieldConverter())
|
||||
|
||||
from .views.view_types import GridViewType
|
||||
from .views.view_types import GridViewType, FormViewType
|
||||
|
||||
view_type_registry.register(GridViewType())
|
||||
view_type_registry.register(FormViewType())
|
||||
|
||||
from .views.view_filters import (
|
||||
EqualViewFilterType,
|
||||
|
|
|
@ -98,13 +98,18 @@ class CharFieldMatchingRegexFieldType(FieldType, ABC):
|
|||
return value
|
||||
|
||||
def get_serializer_field(self, instance, **kwargs):
|
||||
required = kwargs.get("required", False)
|
||||
validators = kwargs.pop("validators", None) or []
|
||||
validators.append(self.validator)
|
||||
return serializers.CharField(
|
||||
required=False,
|
||||
allow_null=True,
|
||||
allow_blank=True,
|
||||
validators=[self.validator],
|
||||
max_length=self.max_length,
|
||||
**kwargs,
|
||||
**{
|
||||
"required": required,
|
||||
"allow_null": not required,
|
||||
"allow_blank": not required,
|
||||
"validators": validators,
|
||||
"max_length": self.max_length,
|
||||
**kwargs,
|
||||
}
|
||||
)
|
||||
|
||||
def get_model_field(self, instance, **kwargs):
|
||||
|
@ -142,12 +147,15 @@ class TextFieldType(FieldType):
|
|||
serializer_field_names = ["text_default"]
|
||||
|
||||
def get_serializer_field(self, instance, **kwargs):
|
||||
required = kwargs.get("required", False)
|
||||
return serializers.CharField(
|
||||
required=False,
|
||||
allow_null=True,
|
||||
allow_blank=True,
|
||||
default=instance.text_default or None,
|
||||
**kwargs,
|
||||
**{
|
||||
"required": required,
|
||||
"allow_null": not required,
|
||||
"allow_blank": not required,
|
||||
"default": instance.text_default or None,
|
||||
**kwargs,
|
||||
}
|
||||
)
|
||||
|
||||
def get_model_field(self, instance, **kwargs):
|
||||
|
@ -167,8 +175,14 @@ class LongTextFieldType(FieldType):
|
|||
model_class = LongTextField
|
||||
|
||||
def get_serializer_field(self, instance, **kwargs):
|
||||
required = kwargs.get("required", False)
|
||||
return serializers.CharField(
|
||||
required=False, allow_null=True, allow_blank=True, **kwargs
|
||||
**{
|
||||
"required": required,
|
||||
"allow_null": not required,
|
||||
"allow_blank": not required,
|
||||
**kwargs,
|
||||
}
|
||||
)
|
||||
|
||||
def get_model_field(self, instance, **kwargs):
|
||||
|
@ -194,8 +208,14 @@ class URLFieldType(FieldType):
|
|||
return value
|
||||
|
||||
def get_serializer_field(self, instance, **kwargs):
|
||||
required = kwargs.get("required", False)
|
||||
return serializers.URLField(
|
||||
required=False, allow_null=True, allow_blank=True, **kwargs
|
||||
**{
|
||||
"required": required,
|
||||
"allow_null": not required,
|
||||
"allow_blank": not required,
|
||||
**kwargs,
|
||||
}
|
||||
)
|
||||
|
||||
def get_model_field(self, instance, **kwargs):
|
||||
|
@ -241,6 +261,8 @@ class NumberFieldType(FieldType):
|
|||
return value
|
||||
|
||||
def get_serializer_field(self, instance, **kwargs):
|
||||
required = kwargs.get("required", False)
|
||||
|
||||
kwargs["decimal_places"] = (
|
||||
0
|
||||
if instance.number_type == NUMBER_TYPE_INTEGER
|
||||
|
@ -251,10 +273,12 @@ class NumberFieldType(FieldType):
|
|||
kwargs["min_value"] = 0
|
||||
|
||||
return serializers.DecimalField(
|
||||
max_digits=self.MAX_DIGITS + kwargs["decimal_places"],
|
||||
required=False,
|
||||
allow_null=True,
|
||||
**kwargs,
|
||||
**{
|
||||
"max_digits": self.MAX_DIGITS + kwargs["decimal_places"],
|
||||
"required": required,
|
||||
"allow_null": not required,
|
||||
**kwargs,
|
||||
}
|
||||
)
|
||||
|
||||
def get_export_value(self, value, field_object):
|
||||
|
@ -330,7 +354,9 @@ class BooleanFieldType(FieldType):
|
|||
model_class = BooleanField
|
||||
|
||||
def get_serializer_field(self, instance, **kwargs):
|
||||
return serializers.BooleanField(required=False, default=False, **kwargs)
|
||||
return serializers.BooleanField(
|
||||
**{"required": False, "default": False, **kwargs}
|
||||
)
|
||||
|
||||
def get_model_field(self, instance, **kwargs):
|
||||
return models.BooleanField(default=False, **kwargs)
|
||||
|
@ -403,12 +429,16 @@ class DateFieldType(FieldType):
|
|||
return value.strftime(python_format)
|
||||
|
||||
def get_serializer_field(self, instance, **kwargs):
|
||||
kwargs["required"] = False
|
||||
kwargs["allow_null"] = True
|
||||
required = kwargs.get("required", False)
|
||||
|
||||
if instance.date_include_time:
|
||||
return serializers.DateTimeField(**kwargs)
|
||||
return serializers.DateTimeField(
|
||||
**{"required": required, "allow_null": not required, **kwargs}
|
||||
)
|
||||
else:
|
||||
return serializers.DateField(**kwargs)
|
||||
return serializers.DateField(
|
||||
**{"required": required, "allow_null": not required, **kwargs}
|
||||
)
|
||||
|
||||
def get_model_field(self, instance, **kwargs):
|
||||
kwargs["null"] = True
|
||||
|
@ -587,7 +617,7 @@ class LinkRowFieldType(FieldType):
|
|||
inner_value, inner_field_object
|
||||
)
|
||||
|
||||
return ",".join(
|
||||
return ", ".join(
|
||||
self._get_and_map_pk_values(
|
||||
field_object, value, map_to_human_readable_value
|
||||
)
|
||||
|
@ -661,7 +691,11 @@ class LinkRowFieldType(FieldType):
|
|||
"""
|
||||
|
||||
return serializers.ListField(
|
||||
child=serializers.IntegerField(min_value=0), required=False, **kwargs
|
||||
**{
|
||||
"child": serializers.IntegerField(min_value=0),
|
||||
"required": False,
|
||||
**kwargs,
|
||||
}
|
||||
)
|
||||
|
||||
def get_response_serializer_field(self, instance, **kwargs):
|
||||
|
@ -672,22 +706,8 @@ class LinkRowFieldType(FieldType):
|
|||
be used to include the primary field's value in the response as a string.
|
||||
"""
|
||||
|
||||
primary_field_name = None
|
||||
|
||||
if hasattr(instance, "_related_model"):
|
||||
related_model = instance._related_model
|
||||
primary_field = next(
|
||||
object
|
||||
for object in related_model._field_objects.values()
|
||||
if object["field"].primary
|
||||
)
|
||||
if primary_field:
|
||||
primary_field_name = primary_field["name"]
|
||||
|
||||
return serializers.ListSerializer(
|
||||
child=LinkRowValueSerializer(
|
||||
value_field_name=primary_field_name, required=False, **kwargs
|
||||
)
|
||||
child=LinkRowValueSerializer(**{"required": False, **kwargs})
|
||||
)
|
||||
|
||||
def get_serializer_help_text(self, instance):
|
||||
|
@ -1043,6 +1063,7 @@ class EmailFieldType(CharFieldMatchingRegexFieldType):
|
|||
class FileFieldType(FieldType):
|
||||
type = "file"
|
||||
model_class = FileField
|
||||
can_be_in_form_view = False
|
||||
|
||||
def prepare_value_for_db(self, instance, value):
|
||||
if value is None:
|
||||
|
@ -1095,11 +1116,19 @@ class FileFieldType(FieldType):
|
|||
return user_files
|
||||
|
||||
def get_serializer_field(self, instance, **kwargs):
|
||||
required = kwargs.get("required", False)
|
||||
return serializers.ListSerializer(
|
||||
child=FileFieldRequestSerializer(),
|
||||
required=False,
|
||||
allow_null=True,
|
||||
**kwargs,
|
||||
**{
|
||||
"child": FileFieldRequestSerializer(),
|
||||
"required": required,
|
||||
"allow_null": not required,
|
||||
**kwargs,
|
||||
}
|
||||
)
|
||||
|
||||
def get_response_serializer_field(self, instance, **kwargs):
|
||||
return FileFieldResponseSerializer(
|
||||
**{"many": True, "required": False, **kwargs}
|
||||
)
|
||||
|
||||
def get_export_value(self, value, field_object):
|
||||
|
@ -1126,10 +1155,7 @@ class FileFieldType(FieldType):
|
|||
file["visible_name"],
|
||||
)
|
||||
|
||||
return ",".join(file_names)
|
||||
|
||||
def get_response_serializer_field(self, instance, **kwargs):
|
||||
return FileFieldResponseSerializer(many=True, required=False, **kwargs)
|
||||
return ", ".join(file_names)
|
||||
|
||||
def get_serializer_help_text(self, instance):
|
||||
return (
|
||||
|
@ -1256,15 +1282,21 @@ class SingleSelectFieldType(FieldType):
|
|||
raise ValidationError(f"The provided value is not a valid option.")
|
||||
|
||||
def get_serializer_field(self, instance, **kwargs):
|
||||
required = kwargs.get("required", False)
|
||||
return serializers.PrimaryKeyRelatedField(
|
||||
queryset=SelectOption.objects.filter(field=instance),
|
||||
required=False,
|
||||
allow_null=True,
|
||||
**kwargs,
|
||||
**{
|
||||
"queryset": SelectOption.objects.filter(field=instance),
|
||||
"required": required,
|
||||
"allow_null": not required,
|
||||
**kwargs,
|
||||
}
|
||||
)
|
||||
|
||||
def get_response_serializer_field(self, instance, **kwargs):
|
||||
return SelectOptionSerializer(required=False, allow_null=True, **kwargs)
|
||||
required = kwargs.get("required", False)
|
||||
return SelectOptionSerializer(
|
||||
**{"required": required, "allow_null": not required, **kwargs}
|
||||
)
|
||||
|
||||
def get_serializer_help_text(self, instance):
|
||||
return (
|
||||
|
|
|
@ -62,6 +62,9 @@ class FieldType(
|
|||
can_have_select_options = False
|
||||
"""Indicates whether the field can have select options."""
|
||||
|
||||
can_be_in_form_view = True
|
||||
"""Indicates whether the field is compatible with the form view."""
|
||||
|
||||
def prepare_value_for_db(self, instance, value):
|
||||
"""
|
||||
When a row is created or updated all the values are going to be prepared for the
|
||||
|
|
|
@ -0,0 +1,228 @@
|
|||
# Generated by Django 2.2.11 on 2021-06-28 21:51
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import secrets
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("core", "0007_userlogentry"),
|
||||
("database", "0033_unique_field_names"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="FormView",
|
||||
fields=[
|
||||
(
|
||||
"view_ptr",
|
||||
models.OneToOneField(
|
||||
auto_created=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
parent_link=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
to="database.View",
|
||||
),
|
||||
),
|
||||
(
|
||||
"slug",
|
||||
models.SlugField(
|
||||
default=secrets.token_urlsafe,
|
||||
help_text="The unique slug where the form can be accessed "
|
||||
"publicly on.",
|
||||
unique=True,
|
||||
db_index=True,
|
||||
),
|
||||
),
|
||||
(
|
||||
"public",
|
||||
models.BooleanField(
|
||||
default=False,
|
||||
help_text="Indicates whether the form is publicly accessible "
|
||||
"to visitors and if they can fill it out.",
|
||||
),
|
||||
),
|
||||
(
|
||||
"title",
|
||||
models.TextField(
|
||||
blank=True,
|
||||
help_text="The title that is displayed at the beginning of "
|
||||
"the form.",
|
||||
),
|
||||
),
|
||||
(
|
||||
"description",
|
||||
models.TextField(
|
||||
blank=True,
|
||||
help_text="The description that is displayed at the beginning "
|
||||
"of the form.",
|
||||
),
|
||||
),
|
||||
(
|
||||
"submit_action",
|
||||
models.CharField(
|
||||
choices=[("MESSAGE", "Message"), ("REDIRECT", "Redirect")],
|
||||
default="MESSAGE",
|
||||
help_text="The action that must be performed after the visitor "
|
||||
"has filled out the form.",
|
||||
max_length=32,
|
||||
),
|
||||
),
|
||||
(
|
||||
"submit_action_message",
|
||||
models.TextField(
|
||||
blank=True,
|
||||
help_text="If the `submit_action` is MESSAGE, then this "
|
||||
"message will be shown to the visitor after "
|
||||
"submitting the form.",
|
||||
),
|
||||
),
|
||||
(
|
||||
"submit_action_redirect_url",
|
||||
models.URLField(
|
||||
blank=True,
|
||||
help_text="If the `submit_action` is REDIRECT,then the "
|
||||
"visitors will be redirected to the this URL after submitting "
|
||||
"the form.",
|
||||
),
|
||||
),
|
||||
(
|
||||
"cover_image",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="form_view_cover_image",
|
||||
to="core.UserFile",
|
||||
help_text="The user file cover image that is displayed at the "
|
||||
"top of the form.",
|
||||
),
|
||||
),
|
||||
(
|
||||
"logo_image",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="form_view_logo_image",
|
||||
to="core.UserFile",
|
||||
help_text="The user file logo image that is displayed at the "
|
||||
"top of the form.",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"abstract": False,
|
||||
},
|
||||
bases=("database.view",),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="gridviewfieldoptions",
|
||||
name="hidden",
|
||||
field=models.BooleanField(
|
||||
default=False,
|
||||
help_text="Whether or not the field should be hidden in the current "
|
||||
"view.",
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="gridviewfieldoptions",
|
||||
name="order",
|
||||
field=models.SmallIntegerField(
|
||||
default=32767,
|
||||
help_text="The position that the field has within the view, lowest "
|
||||
"first. If there is another field with the same order value "
|
||||
"then the field with the lowest id must be shown first.",
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="gridviewfieldoptions",
|
||||
name="width",
|
||||
field=models.PositiveIntegerField(
|
||||
default=200,
|
||||
help_text="The width of the table field in the related view.",
|
||||
),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="FormViewFieldOptions",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.AutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"name",
|
||||
models.CharField(
|
||||
blank=True,
|
||||
help_text="By default, the name of the related field will be "
|
||||
"shown to the visitor. Optionally another name can "
|
||||
"be used by setting this name.",
|
||||
max_length=255,
|
||||
),
|
||||
),
|
||||
(
|
||||
"description",
|
||||
models.TextField(
|
||||
blank=True,
|
||||
help_text="If provided, then this value be will be shown under "
|
||||
"the field name.",
|
||||
),
|
||||
),
|
||||
(
|
||||
"enabled",
|
||||
models.BooleanField(
|
||||
default=False,
|
||||
help_text="Indicates whether the field is included in the "
|
||||
"form.",
|
||||
),
|
||||
),
|
||||
(
|
||||
"required",
|
||||
models.BooleanField(
|
||||
default=True,
|
||||
help_text="Indicates whether the field is required for the "
|
||||
"visitor to fill out.",
|
||||
),
|
||||
),
|
||||
(
|
||||
"order",
|
||||
models.SmallIntegerField(
|
||||
default=32767,
|
||||
help_text="The order that the field has in the form. Lower "
|
||||
"value is first.",
|
||||
),
|
||||
),
|
||||
(
|
||||
"field",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE, to="database.Field"
|
||||
),
|
||||
),
|
||||
(
|
||||
"form_view",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to="database.FormView",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"ordering": ("order", "field_id"),
|
||||
},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="formview",
|
||||
name="field_options",
|
||||
field=models.ManyToManyField(
|
||||
through="database.FormViewFieldOptions", to="database.Field"
|
||||
),
|
||||
),
|
||||
]
|
|
@ -1,7 +1,14 @@
|
|||
from baserow.core.models import Application
|
||||
|
||||
from .table.models import Table
|
||||
from .views.models import View, GridView, GridViewFieldOptions, ViewFilter
|
||||
from .views.models import (
|
||||
View,
|
||||
GridView,
|
||||
GridViewFieldOptions,
|
||||
FormView,
|
||||
FormViewFieldOptions,
|
||||
ViewFilter,
|
||||
)
|
||||
from .fields.models import (
|
||||
Field,
|
||||
TextField,
|
||||
|
@ -22,6 +29,8 @@ __all__ = [
|
|||
"View",
|
||||
"GridView",
|
||||
"GridViewFieldOptions",
|
||||
"FormView",
|
||||
"FormViewFieldOptions",
|
||||
"ViewFilter",
|
||||
"Field",
|
||||
"TextField",
|
||||
|
|
|
@ -51,7 +51,7 @@ class DatabasePlugin(Plugin):
|
|||
active_field = field_handler.create_field(
|
||||
user, table, BooleanFieldType.type, name="Active"
|
||||
)
|
||||
view_handler.update_grid_view_field_options(
|
||||
view_handler.update_field_options(
|
||||
user,
|
||||
customers_view,
|
||||
{notes_field.id: {"width": 400}, active_field.id: {"width": 100}},
|
||||
|
@ -90,7 +90,7 @@ class DatabasePlugin(Plugin):
|
|||
model.objects.create(
|
||||
name="Amazon", active=False, started=date(2018, 1, 1), order=3
|
||||
)
|
||||
view_handler.update_grid_view_field_options(
|
||||
view_handler.update_field_options(
|
||||
user,
|
||||
projects_view,
|
||||
{active_field.id: {"width": 100}},
|
||||
|
|
|
@ -219,7 +219,8 @@ class RowHandler:
|
|||
|
||||
def create_row(self, user, table, values=None, model=None, before=None):
|
||||
"""
|
||||
Creates a new row for a given table with the provided values.
|
||||
Creates a new row for a given table with the provided values if the user
|
||||
belongs to the related group. It also calls the row_created signal.
|
||||
|
||||
:param user: The user of whose behalf the row is created.
|
||||
:type user: User
|
||||
|
@ -238,12 +239,42 @@ class RowHandler:
|
|||
:rtype: Model
|
||||
"""
|
||||
|
||||
if not values:
|
||||
values = {}
|
||||
if not model:
|
||||
model = table.get_model()
|
||||
|
||||
group = table.database.group
|
||||
group.has_user(user, raise_error=True)
|
||||
|
||||
instance = self.force_create_row(table, values, model, before)
|
||||
|
||||
row_created.send(
|
||||
self, row=instance, before=before, user=user, table=table, model=model
|
||||
)
|
||||
|
||||
return instance
|
||||
|
||||
def force_create_row(self, table, values=None, model=None, before=None):
|
||||
"""
|
||||
Creates a new row for a given table with the provided values.
|
||||
|
||||
:param table: The table for which to create a row for.
|
||||
:type table: Table
|
||||
:param values: The values that must be set upon creating the row. The keys must
|
||||
be the field ids.
|
||||
:type values: dict
|
||||
: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
|
||||
:return: The created row instance.
|
||||
:rtype: Model
|
||||
"""
|
||||
|
||||
if not values:
|
||||
values = {}
|
||||
|
||||
if not model:
|
||||
model = table.get_model()
|
||||
|
||||
|
@ -255,10 +286,6 @@ class RowHandler:
|
|||
for name, value in manytomany_values.items():
|
||||
getattr(instance, name).set(value)
|
||||
|
||||
row_created.send(
|
||||
self, row=instance, before=before, user=user, table=table, model=model
|
||||
)
|
||||
|
||||
return instance
|
||||
|
||||
def update_row(self, user, table, row_id, values, model=None):
|
||||
|
|
|
@ -248,9 +248,7 @@ class TableHandler:
|
|||
|
||||
field_options = {notes.id: {"width": 400}, active.id: {"width": 100}}
|
||||
fields = [notes, active]
|
||||
view_handler.update_grid_view_field_options(
|
||||
user, view, field_options, fields=fields
|
||||
)
|
||||
view_handler.update_field_options(user, view, field_options, fields=fields)
|
||||
|
||||
model = table.get_model(attribute_names=True)
|
||||
model.objects.create(name="Tesla", active=True, order=1)
|
||||
|
|
|
@ -265,12 +265,28 @@ class Table(
|
|||
},
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
"""
|
||||
When the model instance is rendered to a string, then we want to return the
|
||||
primary field value in human readable format.
|
||||
"""
|
||||
|
||||
field = self._field_objects.get(self._primary_field_id, None)
|
||||
|
||||
if not field:
|
||||
return f"unnamed row {self.id}"
|
||||
|
||||
return field["type"].get_human_readable_value(
|
||||
getattr(self, field["name"]), field
|
||||
)
|
||||
|
||||
attrs = {
|
||||
"Meta": meta,
|
||||
"__module__": "database.models",
|
||||
# An indication that the model is a generated table model.
|
||||
"_generated_table_model": True,
|
||||
"_table_id": self.id,
|
||||
"_primary_field_id": -1,
|
||||
# An object containing the table fields, field types and the chosen names
|
||||
# with the table field id as key.
|
||||
"_field_objects": {},
|
||||
|
@ -285,6 +301,7 @@ class Table(
|
|||
db_index=True,
|
||||
default=1,
|
||||
),
|
||||
"__str__": __str__,
|
||||
}
|
||||
|
||||
# Construct a query to fetch all the fields of that table. We need to include
|
||||
|
@ -318,9 +335,9 @@ class Table(
|
|||
field = field.specific
|
||||
field_type = field_type_registry.get_by_model(field)
|
||||
field_name = field.db_column
|
||||
|
||||
# If attribute_names is True we will not use 'field_{id}' as attribute name,
|
||||
# but we will rather use a name the user provided.
|
||||
|
||||
if attribute_names:
|
||||
field_name = field.model_attribute_name
|
||||
if trashed:
|
||||
|
@ -343,6 +360,8 @@ class Table(
|
|||
"type": field_type,
|
||||
"name": field_name,
|
||||
}
|
||||
if field.primary:
|
||||
attrs["_primary_field_id"] = field.id
|
||||
|
||||
# Add the field to the attribute dict that is used to generate the model.
|
||||
# All the kwargs that are passed to the `get_model_field` method are going
|
||||
|
|
|
@ -79,3 +79,19 @@ class ViewSortFieldAlreadyExist(Exception):
|
|||
|
||||
class ViewSortFieldNotSupported(Exception):
|
||||
"""Raised when a field does not supports sorting in a view."""
|
||||
|
||||
|
||||
class ViewDoesNotSupportFieldOptions(Exception):
|
||||
"""Raised when a view type does not support field options."""
|
||||
|
||||
|
||||
class FormViewFieldTypeIsNotSupported(Exception):
|
||||
"""Raised when someone tries to enable an unsupported form view field."""
|
||||
|
||||
def __init__(self, field_type, *args, **kwargs):
|
||||
self.field_type = field_type
|
||||
super().__init__(
|
||||
f"The field type {field_type} is not compatible with the form view.",
|
||||
*args,
|
||||
**kwargs,
|
||||
)
|
||||
|
|
|
@ -1,11 +1,18 @@
|
|||
from django.db.models import F
|
||||
from django.core.exceptions import FieldDoesNotExist, ValidationError
|
||||
|
||||
from baserow.core.trash.handler import TrashHandler
|
||||
from baserow.core.utils import (
|
||||
extract_allowed,
|
||||
set_allowed_attrs,
|
||||
get_model_reference_field_name,
|
||||
)
|
||||
from baserow.contrib.database.fields.exceptions import FieldNotInTable
|
||||
from baserow.contrib.database.fields.field_filters import FilterBuilder
|
||||
from baserow.contrib.database.fields.models import Field
|
||||
from baserow.contrib.database.fields.registries import field_type_registry
|
||||
from baserow.core.trash.handler import TrashHandler
|
||||
from baserow.core.utils import extract_allowed, set_allowed_attrs
|
||||
from baserow.contrib.database.rows.handler import RowHandler
|
||||
from baserow.contrib.database.rows.signals import row_created
|
||||
from .exceptions import (
|
||||
ViewDoesNotExist,
|
||||
ViewNotInTable,
|
||||
|
@ -17,8 +24,10 @@ from .exceptions import (
|
|||
ViewSortNotSupported,
|
||||
ViewSortFieldAlreadyExist,
|
||||
ViewSortFieldNotSupported,
|
||||
ViewDoesNotSupportFieldOptions,
|
||||
)
|
||||
from .models import View, GridViewFieldOptions, ViewFilter, ViewSort
|
||||
from .validators import EMPTY_VALUES
|
||||
from .models import View, ViewFilter, ViewSort, FormView
|
||||
from .registries import view_type_registry, view_filter_type_registry
|
||||
from .signals import (
|
||||
view_created,
|
||||
|
@ -31,7 +40,7 @@ from .signals import (
|
|||
view_sort_created,
|
||||
view_sort_updated,
|
||||
view_sort_deleted,
|
||||
grid_view_field_options_updated,
|
||||
view_field_options_updated,
|
||||
)
|
||||
|
||||
|
||||
|
@ -196,17 +205,15 @@ class ViewHandler:
|
|||
|
||||
view_deleted.send(self, view_id=view_id, view=view, user=user)
|
||||
|
||||
def update_grid_view_field_options(
|
||||
self, user, grid_view, field_options, fields=None
|
||||
):
|
||||
def update_field_options(self, user, view, field_options, fields=None):
|
||||
"""
|
||||
Updates the field options with the provided values if the field id exists in
|
||||
the table related to the grid view.
|
||||
the table related to the view.
|
||||
|
||||
:param user: The user on whose behalf the request is made.
|
||||
:type user: User
|
||||
:param grid_view: The grid view for which the field options need to be updated.
|
||||
:type grid_view: GridView
|
||||
:param view: The view for which the field options need to be updated.
|
||||
:type view: View
|
||||
:param field_options: A dict with the field ids as the key and a dict
|
||||
containing the values that need to be updated as value.
|
||||
:type field_options: dict
|
||||
|
@ -217,22 +224,42 @@ class ViewHandler:
|
|||
provided view.
|
||||
"""
|
||||
|
||||
grid_view.table.database.group.has_user(user, raise_error=True)
|
||||
view.table.database.group.has_user(user, raise_error=True)
|
||||
|
||||
if not fields:
|
||||
fields = Field.objects.filter(table=grid_view.table)
|
||||
fields = Field.objects.filter(table=view.table)
|
||||
|
||||
try:
|
||||
model = view._meta.get_field("field_options").remote_field.through
|
||||
except FieldDoesNotExist:
|
||||
raise ViewDoesNotSupportFieldOptions(
|
||||
"This view does not support field options."
|
||||
)
|
||||
|
||||
field_name = get_model_reference_field_name(model, View)
|
||||
|
||||
if not field_name:
|
||||
raise ValueError(
|
||||
"The model doesn't have a relationship with the View model or any "
|
||||
"descendants."
|
||||
)
|
||||
|
||||
view_type = view_type_registry.get_by_model(view.specific_class)
|
||||
field_options = view_type.before_field_options_update(
|
||||
view, field_options, fields
|
||||
)
|
||||
|
||||
allowed_field_ids = [field.id for field in fields]
|
||||
for field_id, options in field_options.items():
|
||||
if int(field_id) not in allowed_field_ids:
|
||||
raise UnrelatedFieldError(
|
||||
f"The field id {field_id} is not related to " f"the grid view."
|
||||
f"The field id {field_id} is not related to the view."
|
||||
)
|
||||
GridViewFieldOptions.objects.update_or_create(
|
||||
grid_view=grid_view, field_id=field_id, defaults=options
|
||||
model.objects.update_or_create(
|
||||
field_id=field_id, defaults=options, **{field_name: view}
|
||||
)
|
||||
|
||||
grid_view_field_options_updated.send(self, grid_view=grid_view, user=user)
|
||||
view_field_options_updated.send(self, view=view, user=user)
|
||||
|
||||
def field_type_changed(self, field):
|
||||
"""
|
||||
|
@ -741,3 +768,106 @@ class ViewHandler:
|
|||
if search is not None:
|
||||
queryset = queryset.search_all_fields(search)
|
||||
return queryset
|
||||
|
||||
def rotate_form_view_slug(self, user, form):
|
||||
"""
|
||||
Rotates the slug of the provided form view.
|
||||
|
||||
:param user: The user on whose behalf the form view is updated.
|
||||
:type user: User
|
||||
:param form: The form view instance that needs to be updated.
|
||||
:type form: View
|
||||
:return: The updated view instance.
|
||||
:rtype: View
|
||||
"""
|
||||
|
||||
if not isinstance(form, FormView):
|
||||
raise ValueError("The provided form is not an instance of FormView.")
|
||||
|
||||
group = form.table.database.group
|
||||
group.has_user(user, raise_error=True)
|
||||
|
||||
form.rotate_slug()
|
||||
form.save()
|
||||
|
||||
view_updated.send(self, view=form, user=user)
|
||||
|
||||
return form
|
||||
|
||||
def get_public_form_view_by_slug(self, user, slug):
|
||||
"""
|
||||
Returns the form view related to the provided slug if the form related to the
|
||||
slug is public or if the user has access to the related group.
|
||||
|
||||
:param user: The user on whose behalf the form is requested.
|
||||
:type user: User
|
||||
:param slug: The slug of the form view.
|
||||
:type slug: str
|
||||
:return: The requested form view that belongs to the form with the slug.
|
||||
:rtype: FormView
|
||||
"""
|
||||
|
||||
try:
|
||||
form = FormView.objects.get(slug=slug)
|
||||
except (FormView.DoesNotExist, ValidationError):
|
||||
raise ViewDoesNotExist("The form does not exist.")
|
||||
|
||||
if not form.public and (
|
||||
not user or not form.table.database.group.has_user(user)
|
||||
):
|
||||
raise ViewDoesNotExist("The form does not exist.")
|
||||
|
||||
return form
|
||||
|
||||
def submit_form_view(self, form, values, model=None, enabled_field_options=None):
|
||||
"""
|
||||
Handles when a form is submitted. It will validate the data by checking if
|
||||
the required fields are provided and not empty and it will create a new row
|
||||
based on those values.
|
||||
|
||||
:param form: The form view that is submitted.
|
||||
:type form: FormView
|
||||
:param values: The submitted values that need to be used when creating the row.
|
||||
:type values: dict
|
||||
:param model: If the model is already generated, it can be provided here.
|
||||
:type model: Model | None
|
||||
:param enabled_field_options: If the enabled field options have already been
|
||||
fetched, they can be provided here.
|
||||
:type enabled_field_options: QuerySet | list | None
|
||||
:return: The newly created row.
|
||||
:rtype: Model
|
||||
"""
|
||||
|
||||
table = form.table
|
||||
|
||||
if not model:
|
||||
model = table.get_model()
|
||||
|
||||
if not enabled_field_options:
|
||||
enabled_field_options = form.active_field_options
|
||||
|
||||
allowed_field_names = []
|
||||
field_errors = {}
|
||||
|
||||
# Loop over all field options, find the name in the model and check if the
|
||||
# required values are provided. If not, a validation error is raised.
|
||||
for field in enabled_field_options:
|
||||
field_name = model._field_objects[field.field_id]["name"]
|
||||
allowed_field_names.append(field_name)
|
||||
|
||||
if field.required and (
|
||||
field_name not in values or values[field_name] in EMPTY_VALUES
|
||||
):
|
||||
field_errors[field_name] = ["This field is required."]
|
||||
|
||||
if len(field_errors) > 0:
|
||||
raise ValidationError(field_errors)
|
||||
|
||||
allowed_values = extract_allowed(values, allowed_field_names)
|
||||
instance = RowHandler().force_create_row(table, allowed_values, model)
|
||||
|
||||
row_created.send(
|
||||
self, row=instance, before=None, user=None, table=table, model=model
|
||||
)
|
||||
|
||||
return instance
|
||||
|
|
|
@ -1,20 +1,25 @@
|
|||
import secrets
|
||||
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.db import models
|
||||
|
||||
from baserow.core.utils import get_model_reference_field_name
|
||||
from baserow.core.models import UserFile
|
||||
from baserow.core.mixins import (
|
||||
OrderableMixin,
|
||||
PolymorphicContentTypeMixin,
|
||||
CreatedAndUpdatedOnMixin,
|
||||
)
|
||||
from baserow.contrib.database.fields.field_filters import (
|
||||
FILTER_TYPE_AND,
|
||||
FILTER_TYPE_OR,
|
||||
)
|
||||
from baserow.contrib.database.fields.models import Field
|
||||
from baserow.contrib.database.views.registries import view_type_registry
|
||||
from baserow.contrib.database.mixins import (
|
||||
ParentTableTrashableModelMixin,
|
||||
ParentFieldTrashableModelMixin,
|
||||
)
|
||||
from baserow.core.mixins import (
|
||||
OrderableMixin,
|
||||
PolymorphicContentTypeMixin,
|
||||
CreatedAndUpdatedOnMixin,
|
||||
)
|
||||
|
||||
FILTER_TYPES = ((FILTER_TYPE_AND, "And"), (FILTER_TYPE_OR, "Or"))
|
||||
|
||||
|
@ -22,6 +27,13 @@ SORT_ORDER_ASC = "ASC"
|
|||
SORT_ORDER_DESC = "DESC"
|
||||
SORT_ORDER_CHOICES = ((SORT_ORDER_ASC, "Ascending"), (SORT_ORDER_DESC, "Descending"))
|
||||
|
||||
FORM_VIEW_SUBMIT_ACTION_MESSAGE = "MESSAGE"
|
||||
FORM_VIEW_SUBMIT_ACTION_REDIRECT = "REDIRECT"
|
||||
FORM_VIEW_SUBMIT_ACTION_CHOICES = (
|
||||
(FORM_VIEW_SUBMIT_ACTION_MESSAGE, "Message"),
|
||||
(FORM_VIEW_SUBMIT_ACTION_REDIRECT, "Redirect"),
|
||||
)
|
||||
|
||||
|
||||
def get_default_view_content_type():
|
||||
return ContentType.objects.get_for_model(View)
|
||||
|
@ -64,6 +76,60 @@ class View(
|
|||
queryset = View.objects.filter(table=table)
|
||||
return cls.get_highest_order_of_queryset(queryset) + 1
|
||||
|
||||
def get_field_options(self, create_if_not_exists=False, fields=None):
|
||||
"""
|
||||
Each field can have unique options per view. This method returns those
|
||||
options per field type and can optionally create the missing ones. This method
|
||||
only works if the `field_options` property is a ManyToManyField with a relation
|
||||
to a field options model.
|
||||
|
||||
:param create_if_not_exists: If true the missing GridViewFieldOptions are
|
||||
going to be created. If a fields has been created at a later moment it
|
||||
could be possible that they don't exist yet. If this value is True, the
|
||||
missing relationships are created in that case.
|
||||
:type create_if_not_exists: bool
|
||||
:param fields: If all the fields related to the table of this grid view have
|
||||
already been fetched, they can be provided here to avoid having to fetch
|
||||
them for a second time. This is only needed if `create_if_not_exists` is
|
||||
True.
|
||||
:type fields: list
|
||||
:return: A list of field options instances related to this grid view.
|
||||
:rtype: list or QuerySet
|
||||
"""
|
||||
|
||||
view_type = view_type_registry.get_by_model(self.specific_class)
|
||||
through_model = view_type.field_options_model_class
|
||||
|
||||
if not through_model:
|
||||
raise ValueError(
|
||||
f"The view type {view_type.type} does not support field " f"options."
|
||||
)
|
||||
|
||||
field_name = get_model_reference_field_name(through_model, View)
|
||||
|
||||
if not field_name:
|
||||
raise ValueError(
|
||||
"The through model doesn't have a relationship with the View model or "
|
||||
"any descendants."
|
||||
)
|
||||
|
||||
field_options = through_model.objects.filter(**{field_name: self})
|
||||
|
||||
if create_if_not_exists:
|
||||
field_options = list(field_options)
|
||||
if not fields:
|
||||
fields = Field.objects.filter(table=self.table)
|
||||
|
||||
existing_field_ids = [options.field_id for options in field_options]
|
||||
for field in fields:
|
||||
if field.id not in existing_field_ids:
|
||||
field_option = through_model.objects.create(
|
||||
**{field_name: self, "field": field}
|
||||
)
|
||||
field_options.append(field_option)
|
||||
|
||||
return field_options
|
||||
|
||||
|
||||
class ViewFilter(ParentFieldTrashableModelMixin, models.Model):
|
||||
view = models.ForeignKey(
|
||||
|
@ -120,53 +186,131 @@ class ViewSort(ParentFieldTrashableModelMixin, models.Model):
|
|||
class GridView(View):
|
||||
field_options = models.ManyToManyField(Field, through="GridViewFieldOptions")
|
||||
|
||||
def get_field_options(self, create_if_not_exists=False, fields=None):
|
||||
"""
|
||||
Each field can have unique options per view. This method returns those
|
||||
options per field type and can optionally create the missing ones.
|
||||
|
||||
:param create_if_not_exists: If true the missing GridViewFieldOptions are
|
||||
going to be created. If a fields has been created at a later moment it
|
||||
could be possible that they don't exist yet. If this value is True, the
|
||||
missing relationships are created in that case.
|
||||
:type create_if_not_exists: bool
|
||||
:param fields: If all the fields related to the table of this grid view have
|
||||
already been fetched, they can be provided here to avoid having to fetch
|
||||
them for a second time. This is only needed if `create_if_not_exists` is
|
||||
True.
|
||||
:type fields: list
|
||||
:return: A list of field options instances related to this grid view.
|
||||
:rtype: list
|
||||
"""
|
||||
|
||||
field_options = GridViewFieldOptions.objects.filter(grid_view=self)
|
||||
|
||||
if create_if_not_exists:
|
||||
field_options = list(field_options)
|
||||
if not fields:
|
||||
fields = Field.objects.filter(table=self.table)
|
||||
|
||||
existing_field_ids = [options.field_id for options in field_options]
|
||||
for field in fields:
|
||||
if field.id not in existing_field_ids:
|
||||
field_option = GridViewFieldOptions.objects.create(
|
||||
grid_view=self, field=field
|
||||
)
|
||||
field_options.append(field_option)
|
||||
|
||||
return field_options
|
||||
|
||||
|
||||
class GridViewFieldOptions(ParentFieldTrashableModelMixin, models.Model):
|
||||
grid_view = models.ForeignKey(GridView, on_delete=models.CASCADE)
|
||||
field = models.ForeignKey(Field, on_delete=models.CASCADE)
|
||||
# The defaults should be the same as in the `fieldCreated` of the `GridViewType`
|
||||
# abstraction in the web-frontend.
|
||||
width = models.PositiveIntegerField(default=200)
|
||||
hidden = models.BooleanField(default=False)
|
||||
width = models.PositiveIntegerField(
|
||||
default=200,
|
||||
help_text="The width of the table field in the related view.",
|
||||
)
|
||||
hidden = models.BooleanField(
|
||||
default=False,
|
||||
help_text="Whether or not the field should be hidden in the current view.",
|
||||
)
|
||||
# The default value is the maximum value of the small integer field because a newly
|
||||
# created field must always be last.
|
||||
order = models.SmallIntegerField(default=32767)
|
||||
order = models.SmallIntegerField(
|
||||
default=32767,
|
||||
help_text="The position that the field has within the view, lowest first. If "
|
||||
"there is another field with the same order value then the field with the "
|
||||
"lowest id must be shown first.",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ("field_id",)
|
||||
|
||||
|
||||
class FormView(View):
|
||||
field_options = models.ManyToManyField(Field, through="FormViewFieldOptions")
|
||||
slug = models.SlugField(
|
||||
default=secrets.token_urlsafe,
|
||||
help_text="The unique slug where the form can be accessed publicly on.",
|
||||
unique=True,
|
||||
db_index=True,
|
||||
)
|
||||
public = models.BooleanField(
|
||||
default=False,
|
||||
help_text="Indicates whether the form is publicly accessible to visitors and "
|
||||
"if they can fill it out.",
|
||||
)
|
||||
title = models.TextField(
|
||||
blank=True,
|
||||
help_text="The title that is displayed at the beginning of the form.",
|
||||
)
|
||||
description = models.TextField(
|
||||
blank=True,
|
||||
help_text="The description that is displayed at the beginning of the form.",
|
||||
)
|
||||
cover_image = models.ForeignKey(
|
||||
UserFile,
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="form_view_cover_image",
|
||||
help_text="The user file cover image that is displayed at the top of the form.",
|
||||
)
|
||||
logo_image = models.ForeignKey(
|
||||
UserFile,
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="form_view_logo_image",
|
||||
help_text="The user file logo image that is displayed at the top of the form.",
|
||||
)
|
||||
submit_action = models.CharField(
|
||||
max_length=32,
|
||||
choices=FORM_VIEW_SUBMIT_ACTION_CHOICES,
|
||||
default=FORM_VIEW_SUBMIT_ACTION_MESSAGE,
|
||||
help_text="The action that must be performed after the visitor has filled out "
|
||||
"the form.",
|
||||
)
|
||||
submit_action_message = models.TextField(
|
||||
blank=True,
|
||||
help_text=f"If the `submit_action` is {FORM_VIEW_SUBMIT_ACTION_MESSAGE}, "
|
||||
f"then this message will be shown to the visitor after submitting the form.",
|
||||
)
|
||||
submit_action_redirect_url = models.URLField(
|
||||
blank=True,
|
||||
help_text=f"If the `submit_action` is {FORM_VIEW_SUBMIT_ACTION_REDIRECT},"
|
||||
f"then the visitors will be redirected to the this URL after submitting the "
|
||||
f"form.",
|
||||
)
|
||||
|
||||
def rotate_slug(self):
|
||||
self.slug = secrets.token_urlsafe()
|
||||
|
||||
@property
|
||||
def active_field_options(self):
|
||||
return (
|
||||
FormViewFieldOptions.objects.filter(form_view=self, enabled=True)
|
||||
.select_related("field")
|
||||
.order_by("order")
|
||||
)
|
||||
|
||||
|
||||
class FormViewFieldOptions(ParentFieldTrashableModelMixin, models.Model):
|
||||
form_view = models.ForeignKey(FormView, on_delete=models.CASCADE)
|
||||
field = models.ForeignKey(Field, on_delete=models.CASCADE)
|
||||
name = models.CharField(
|
||||
max_length=255,
|
||||
blank=True,
|
||||
help_text="By default, the name of the related field will be shown to the "
|
||||
"visitor. Optionally another name can be used by setting this name.",
|
||||
)
|
||||
description = models.TextField(
|
||||
blank=True,
|
||||
help_text="If provided, then this value be will be shown under the field name.",
|
||||
)
|
||||
enabled = models.BooleanField(
|
||||
default=False, help_text="Indicates whether the field is included in the form."
|
||||
)
|
||||
required = models.BooleanField(
|
||||
default=True,
|
||||
help_text="Indicates whether the field is required for the visitor to fill "
|
||||
"out.",
|
||||
)
|
||||
# The default value is the maximum value of the small integer field because a newly
|
||||
# created field must always be last.
|
||||
order = models.SmallIntegerField(
|
||||
default=32767,
|
||||
help_text="The order that the field has in the form. Lower value is first.",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = (
|
||||
"order",
|
||||
"field_id",
|
||||
)
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
from rest_framework.serializers import Serializer
|
||||
|
||||
from baserow.contrib.database.fields.field_filters import OptionallyAnnotatedQ
|
||||
from baserow.core.registry import (
|
||||
Instance,
|
||||
|
@ -9,6 +11,7 @@ from baserow.core.registry import (
|
|||
APIUrlsRegistryMixin,
|
||||
APIUrlsInstanceMixin,
|
||||
ImportExportMixin,
|
||||
MapAPIExceptionsInstanceMixin,
|
||||
)
|
||||
from .exceptions import (
|
||||
ViewTypeAlreadyRegistered,
|
||||
|
@ -19,6 +22,7 @@ from .exceptions import (
|
|||
|
||||
|
||||
class ViewType(
|
||||
MapAPIExceptionsInstanceMixin,
|
||||
APIUrlsInstanceMixin,
|
||||
CustomFieldsInstanceMixin,
|
||||
ModelInstanceMixin,
|
||||
|
@ -71,6 +75,19 @@ class ViewType(
|
|||
sort to the view.
|
||||
"""
|
||||
|
||||
field_options_model_class = None
|
||||
"""
|
||||
The model class of the through table that contains the field options. The model
|
||||
must have a foreign key to the field and to the view.
|
||||
"""
|
||||
|
||||
field_options_serializer_class = None
|
||||
"""
|
||||
The serializer class to serialize the field options model. It will be used in the
|
||||
API to update and list the field option, but it is also used to broadcast field
|
||||
option changes.
|
||||
"""
|
||||
|
||||
def export_serialized(self, view):
|
||||
"""
|
||||
Exports the view to a serialized dict that can be imported by the
|
||||
|
@ -193,6 +210,62 @@ class ViewType(
|
|||
model = view.table.get_model()
|
||||
return model._field_objects.values(), model
|
||||
|
||||
def get_field_options_serializer_class(self):
|
||||
"""
|
||||
Generates a serializer that has the `field_options` property as a
|
||||
`FieldOptionsField`. This serializer can be used by the API to validate or list
|
||||
the field options.
|
||||
|
||||
:raises ValueError: When the related view type does not have a field options
|
||||
serializer class.
|
||||
:return: The generated serializer.
|
||||
:rtype: Serializer
|
||||
"""
|
||||
|
||||
from baserow.contrib.database.api.views.serializers import FieldOptionsField
|
||||
|
||||
if not self.field_options_serializer_class:
|
||||
raise ValueError(
|
||||
f"The view type {self.type} does not have a field options serializer "
|
||||
f"class."
|
||||
)
|
||||
|
||||
meta = type(
|
||||
"Meta",
|
||||
(),
|
||||
{"ref_name": self.type + " view field options"},
|
||||
)
|
||||
|
||||
attrs = {
|
||||
"Meta": meta,
|
||||
"field_options": FieldOptionsField(
|
||||
serializer_class=self.field_options_serializer_class
|
||||
),
|
||||
}
|
||||
|
||||
return type(
|
||||
str("Generated" + self.type.capitalize() + "ViewFieldOptionsSerializer"),
|
||||
(Serializer,),
|
||||
attrs,
|
||||
)
|
||||
|
||||
def before_field_options_update(self, view, field_options, fields):
|
||||
"""
|
||||
Called before the field options are updated related to the provided view.
|
||||
|
||||
:param view: The view for which the field options need to be updated.
|
||||
:type view: View
|
||||
:param field_options: A dict with the field ids as the key and a dict
|
||||
containing the values that need to be updated as value.
|
||||
:type field_options: dict
|
||||
:param fields: Optionally a list of fields can be provided so that they don't
|
||||
have to be fetched again.
|
||||
:return: The updated field options.
|
||||
:rtype: dict
|
||||
"""
|
||||
|
||||
return field_options
|
||||
|
||||
|
||||
class ViewTypeRegistry(
|
||||
APIUrlsRegistryMixin, CustomFieldsRegistryMixin, ModelRegistryMixin, Registry
|
||||
|
@ -207,6 +280,12 @@ class ViewTypeRegistry(
|
|||
does_not_exist_exception_class = ViewTypeDoesNotExist
|
||||
already_registered_exception_class = ViewTypeAlreadyRegistered
|
||||
|
||||
def get_field_options_serializer_map(self):
|
||||
return {
|
||||
view_type.type: view_type.get_field_options_serializer_class()
|
||||
for view_type in self.registry.values()
|
||||
}
|
||||
|
||||
|
||||
class ViewFilterType(Instance):
|
||||
"""
|
||||
|
|
|
@ -13,5 +13,4 @@ view_filter_deleted = Signal()
|
|||
view_sort_created = Signal()
|
||||
view_sort_updated = Signal()
|
||||
view_sort_deleted = Signal()
|
||||
|
||||
grid_view_field_options_updated = Signal()
|
||||
view_field_options_updated = Signal()
|
||||
|
|
9
backend/src/baserow/contrib/database/views/validators.py
Normal file
9
backend/src/baserow/contrib/database/views/validators.py
Normal file
|
@ -0,0 +1,9 @@
|
|||
from django.core.exceptions import ValidationError
|
||||
|
||||
|
||||
EMPTY_VALUES = (None, "", b"", [], (), {}, False)
|
||||
|
||||
|
||||
def required_validator(value):
|
||||
if value in EMPTY_VALUES:
|
||||
raise ValidationError("This field is required.", code="required")
|
|
@ -1,13 +1,30 @@
|
|||
from django.urls import path, include
|
||||
|
||||
from rest_framework.serializers import CharField
|
||||
|
||||
from baserow.api.user_files.serializers import UserFileField
|
||||
from baserow.contrib.database.api.views.form.errors import (
|
||||
ERROR_FORM_VIEW_FIELD_TYPE_IS_NOT_SUPPORTED,
|
||||
)
|
||||
from baserow.contrib.database.fields.registries import field_type_registry
|
||||
from baserow.contrib.database.api.views.grid.serializers import (
|
||||
GridViewFieldOptionsSerializer,
|
||||
)
|
||||
from baserow.contrib.database.api.views.form.serializers import (
|
||||
FormViewFieldOptionsSerializer,
|
||||
)
|
||||
|
||||
from .handler import ViewHandler
|
||||
from .models import GridView, GridViewFieldOptions
|
||||
from .models import GridView, GridViewFieldOptions, FormView, FormViewFieldOptions
|
||||
from .registries import ViewType
|
||||
from .exceptions import FormViewFieldTypeIsNotSupported
|
||||
|
||||
|
||||
class GridViewType(ViewType):
|
||||
type = "grid"
|
||||
model_class = GridView
|
||||
field_options_model_class = GridViewFieldOptions
|
||||
field_options_serializer_class = GridViewFieldOptionsSerializer
|
||||
|
||||
def get_api_urls(self):
|
||||
from baserow.contrib.database.api.views.grid import urls as api_urls
|
||||
|
@ -84,3 +101,73 @@ class GridViewType(ViewType):
|
|||
for field_id in ordered_visible_fields:
|
||||
ordered_field_objects.append(model._field_objects[field_id])
|
||||
return ordered_field_objects, model
|
||||
|
||||
|
||||
class FormViewType(ViewType):
|
||||
type = "form"
|
||||
model_class = FormView
|
||||
can_filter = False
|
||||
can_sort = False
|
||||
field_options_model_class = FormViewFieldOptions
|
||||
field_options_serializer_class = FormViewFieldOptionsSerializer
|
||||
allowed_fields = [
|
||||
"public",
|
||||
"title",
|
||||
"description",
|
||||
"cover_image",
|
||||
"logo_image",
|
||||
"submit_action",
|
||||
"submit_action_message",
|
||||
"submit_action_redirect_url",
|
||||
]
|
||||
serializer_field_names = [
|
||||
"slug",
|
||||
"public",
|
||||
"title",
|
||||
"description",
|
||||
"cover_image",
|
||||
"logo_image",
|
||||
"submit_action",
|
||||
"submit_action_message",
|
||||
"submit_action_redirect_url",
|
||||
]
|
||||
serializer_field_overrides = {
|
||||
"slug": CharField(
|
||||
read_only=True,
|
||||
help_text="The unique slug that can be used to construct a public URL.",
|
||||
),
|
||||
"cover_image": UserFileField(
|
||||
required=False,
|
||||
help_text="The cover image that must be displayed at the top of the form.",
|
||||
),
|
||||
"logo_image": UserFileField(
|
||||
required=False,
|
||||
help_text="The logo image that must be displayed at the top of the form.",
|
||||
),
|
||||
}
|
||||
api_exceptions_map = {
|
||||
FormViewFieldTypeIsNotSupported: ERROR_FORM_VIEW_FIELD_TYPE_IS_NOT_SUPPORTED,
|
||||
}
|
||||
|
||||
def get_api_urls(self):
|
||||
from baserow.contrib.database.api.views.form import urls as api_urls
|
||||
|
||||
return [
|
||||
path("form/", include(api_urls, namespace=self.type)),
|
||||
]
|
||||
|
||||
def before_field_options_update(self, view, field_options, fields):
|
||||
"""
|
||||
Checks if a field type that is incompatible with the form view is being
|
||||
enabled.
|
||||
"""
|
||||
|
||||
fields_dict = {field.id: field for field in fields}
|
||||
for field_id, options in field_options.items():
|
||||
field = fields_dict.get(int(field_id), None)
|
||||
if options.get("enabled") and field:
|
||||
field_type = field_type_registry.get_by_model(field.specific_class)
|
||||
if not field_type.can_be_in_form_view:
|
||||
raise FormViewFieldTypeIsNotSupported(field_type.type)
|
||||
|
||||
return field_options
|
||||
|
|
|
@ -10,7 +10,6 @@ from baserow.contrib.database.api.views.serializers import (
|
|||
ViewFilterSerializer,
|
||||
ViewSortSerializer,
|
||||
)
|
||||
from baserow.contrib.database.api.views.grid.serializers import GridViewSerializer
|
||||
|
||||
|
||||
@receiver(view_signals.view_created)
|
||||
|
@ -21,7 +20,10 @@ def view_created(sender, view, user, **kwargs):
|
|||
{
|
||||
"type": "view_created",
|
||||
"view": view_type_registry.get_serializer(
|
||||
view, ViewSerializer, filters=True, sortings=True
|
||||
view,
|
||||
ViewSerializer,
|
||||
filters=True,
|
||||
sortings=True,
|
||||
).data,
|
||||
},
|
||||
getattr(user, "web_socket_id", None),
|
||||
|
@ -172,19 +174,19 @@ def view_sort_deleted(sender, view_sort_id, view_sort, user, **kwargs):
|
|||
)
|
||||
|
||||
|
||||
@receiver(view_signals.grid_view_field_options_updated)
|
||||
def grid_view_field_options_updated(sender, grid_view, user, **kwargs):
|
||||
@receiver(view_signals.view_field_options_updated)
|
||||
def view_field_options_updated(sender, view, user, **kwargs):
|
||||
table_page_type = page_registry.get("table")
|
||||
view_type = view_type_registry.get_by_model(view.specific_class)
|
||||
serializer_class = view_type.get_field_options_serializer_class()
|
||||
transaction.on_commit(
|
||||
lambda: table_page_type.broadcast(
|
||||
{
|
||||
"type": "grid_view_field_options_updated",
|
||||
"grid_view_id": grid_view.id,
|
||||
"grid_view_field_options": GridViewSerializer(grid_view).data[
|
||||
"field_options"
|
||||
],
|
||||
"type": "view_field_options_updated",
|
||||
"view_id": view.id,
|
||||
"field_options": serializer_class(view).data["field_options"],
|
||||
},
|
||||
getattr(user, "web_socket_id", None),
|
||||
table_id=grid_view.table_id,
|
||||
table_id=view.table_id,
|
||||
)
|
||||
)
|
||||
|
|
|
@ -310,7 +310,7 @@ class ModelRegistryMixin:
|
|||
return value
|
||||
|
||||
raise self.does_not_exist_exception_class(
|
||||
f"The {self.name} model instance " f"{model_instance} does not exist."
|
||||
f"The {self.name} model instance {model_instance} does not exist."
|
||||
)
|
||||
|
||||
|
||||
|
|
|
@ -7,6 +7,7 @@ import math
|
|||
|
||||
from collections import namedtuple
|
||||
|
||||
from django.db.models import ForeignKey
|
||||
from django.db.models.fields import NOT_PROVIDED
|
||||
|
||||
|
||||
|
@ -247,3 +248,34 @@ def truncate_middle(content, max_length, middle="..."):
|
|||
right = content[-end:] if end else ""
|
||||
|
||||
return f"{left}{middle}{right}"
|
||||
|
||||
|
||||
def get_model_reference_field_name(lookup_model, target_model):
|
||||
"""
|
||||
Figures out what the name of the field related to the `target_model` is in the
|
||||
`lookup_model`. So if the `lookup_model` has a ForeignKey pointing to the
|
||||
`target_model`, then the name of that field will be returned.
|
||||
|
||||
:param lookup_model: The model that should contain the ForeignKey pointing at the
|
||||
`target_model`.
|
||||
:type lookup_model: Model
|
||||
:param target_model: The model that the ForeignKey in the `lookup_model` must be
|
||||
pointing to.
|
||||
:type target_model: Model
|
||||
:return: The name of the field.
|
||||
:rtype: str | None
|
||||
"""
|
||||
|
||||
# We have to loop over all the fields, check if it is a ForeignKey and check if
|
||||
# the related model is the `target_model`. We can't use isinstance to check if
|
||||
# the model is a child of View because that doesn't work with models, so we need
|
||||
# to create a tuple of parent classes and check if the `target_model` is in them.
|
||||
for field in lookup_model._meta.get_fields():
|
||||
if isinstance(field, ForeignKey) and field.related_model:
|
||||
classes = tuple(field.related_model._meta.parents.keys()) + (
|
||||
field.related_model,
|
||||
)
|
||||
if any([target_model == c for c in classes]):
|
||||
return field.name
|
||||
|
||||
return None
|
||||
|
|
|
@ -0,0 +1,40 @@
|
|||
import pytest
|
||||
|
||||
from rest_framework.serializers import Serializer
|
||||
|
||||
from baserow.api.user_files.serializers import UserFileField
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_user_file_field(data_fixture):
|
||||
user_file_1 = data_fixture.create_user_file()
|
||||
|
||||
class TmpSerializer(Serializer):
|
||||
user_file = UserFileField(allow_null=True)
|
||||
|
||||
serializer = TmpSerializer(data={"user_file": "invalid"})
|
||||
assert not serializer.is_valid()
|
||||
assert serializer.errors["user_file"][0].code == "invalid_value"
|
||||
|
||||
serializer = TmpSerializer(data={"user_file": {"invalid": user_file_1.name}})
|
||||
assert not serializer.is_valid()
|
||||
assert serializer.errors["user_file"][0].code == "invalid_value"
|
||||
|
||||
serializer = TmpSerializer(data={"user_file": {"name": "not_existing.jpg"}})
|
||||
assert not serializer.is_valid()
|
||||
assert serializer.errors["user_file"][0].code == "invalid_user_file"
|
||||
|
||||
serializer = TmpSerializer(data={"user_file": None})
|
||||
assert serializer.is_valid()
|
||||
assert serializer.data["user_file"] is None
|
||||
|
||||
serializer = TmpSerializer(data={"user_file": {"name": user_file_1.name}})
|
||||
assert serializer.is_valid()
|
||||
assert serializer.data["user_file"].id == user_file_1.id
|
||||
|
||||
serializer = TmpSerializer({"user_file": user_file_1})
|
||||
assert serializer.data["user_file"]["name"] == user_file_1.name
|
||||
assert "url" in serializer.data["user_file"]
|
||||
|
||||
serializer = TmpSerializer({"user_file": None})
|
||||
assert serializer.data["user_file"] is None
|
|
@ -0,0 +1,684 @@
|
|||
import pytest
|
||||
|
||||
from rest_framework.status import HTTP_200_OK, HTTP_400_BAD_REQUEST, HTTP_404_NOT_FOUND
|
||||
|
||||
from django.shortcuts import reverse
|
||||
|
||||
from baserow.contrib.database.views.models import FormView
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_create_form_view(api_client, data_fixture):
|
||||
user, token = data_fixture.create_user_and_token()
|
||||
table = data_fixture.create_database_table(user=user)
|
||||
user_file_1 = data_fixture.create_user_file()
|
||||
user_file_2 = data_fixture.create_user_file()
|
||||
|
||||
response = api_client.post(
|
||||
reverse("api:database:views:list", kwargs={"table_id": table.id}),
|
||||
{
|
||||
"name": "Test Form",
|
||||
"type": "form",
|
||||
},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"JWT {token}",
|
||||
)
|
||||
response_json = response.json()
|
||||
assert response.status_code == HTTP_200_OK
|
||||
assert len(response_json["slug"]) == 43
|
||||
assert response_json["type"] == "form"
|
||||
assert response_json["name"] == "Test Form"
|
||||
assert response_json["table_id"] == table.id
|
||||
assert response_json["public"] is False
|
||||
assert response_json["title"] == ""
|
||||
assert response_json["description"] == ""
|
||||
assert response_json["cover_image"] is None
|
||||
assert response_json["logo_image"] is None
|
||||
assert response_json["submit_action"] == "MESSAGE"
|
||||
assert response_json["submit_action_redirect_url"] == ""
|
||||
|
||||
form = FormView.objects.all()[0]
|
||||
assert response_json["id"] == form.id
|
||||
assert response_json["name"] == form.name
|
||||
assert response_json["order"] == form.order
|
||||
assert response_json["slug"] == str(form.slug)
|
||||
assert form.table_id == table.id
|
||||
assert form.public is False
|
||||
assert form.title == ""
|
||||
assert form.description == ""
|
||||
assert form.cover_image is None
|
||||
assert form.logo_image is None
|
||||
assert form.submit_action == "MESSAGE"
|
||||
assert form.submit_action_redirect_url == ""
|
||||
assert "filters" not in response_json
|
||||
assert "sortings" not in response_json
|
||||
|
||||
response = api_client.post(
|
||||
reverse("api:database:views:list", kwargs={"table_id": table.id}),
|
||||
{
|
||||
"slug": "Test",
|
||||
"name": "Test Form 2",
|
||||
"type": "form",
|
||||
"public": True,
|
||||
"title": "Title",
|
||||
"description": "Description",
|
||||
"cover_image": {"name": user_file_1.name},
|
||||
"logo_image": {"name": user_file_2.name},
|
||||
"submit_action": "REDIRECT",
|
||||
"submit_action_redirect_url": "https://localhost",
|
||||
},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"JWT {token}",
|
||||
)
|
||||
response_json = response.json()
|
||||
assert response.status_code == HTTP_200_OK
|
||||
assert response_json["slug"] != "Test"
|
||||
assert response_json["name"] == "Test Form 2"
|
||||
assert response_json["type"] == "form"
|
||||
assert response_json["public"] is True
|
||||
assert response_json["title"] == "Title"
|
||||
assert response_json["description"] == "Description"
|
||||
assert response_json["cover_image"]["name"] == user_file_1.name
|
||||
assert response_json["logo_image"]["name"] == user_file_2.name
|
||||
assert response_json["submit_action"] == "REDIRECT"
|
||||
assert response_json["submit_action_redirect_url"] == "https://localhost"
|
||||
|
||||
form = FormView.objects.all()[1]
|
||||
assert form.name == "Test Form 2"
|
||||
assert form.order == 2
|
||||
assert form.table == table
|
||||
assert form.public is True
|
||||
assert form.title == "Title"
|
||||
assert form.description == "Description"
|
||||
assert form.cover_image_id == user_file_1.id
|
||||
assert form.logo_image_id == user_file_2.id
|
||||
assert form.submit_action == "REDIRECT"
|
||||
assert form.submit_action_redirect_url == "https://localhost"
|
||||
|
||||
response = api_client.post(
|
||||
reverse("api:database:views:list", kwargs={"table_id": table.id}),
|
||||
{
|
||||
"type": "form",
|
||||
"name": "Test",
|
||||
"cover_image": None,
|
||||
"logo_image": {"name": user_file_2.name},
|
||||
},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"JWT {token}",
|
||||
)
|
||||
response_json = response.json()
|
||||
assert response.status_code == HTTP_200_OK
|
||||
assert response_json["cover_image"] is None
|
||||
assert response_json["logo_image"]["name"] == user_file_2.name
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_update_form_view(api_client, data_fixture):
|
||||
user, token = data_fixture.create_user_and_token()
|
||||
table = data_fixture.create_database_table(user=user)
|
||||
view = data_fixture.create_form_view(table=table)
|
||||
user_file_1 = data_fixture.create_user_file()
|
||||
user_file_2 = data_fixture.create_user_file()
|
||||
|
||||
url = reverse("api:database:views:item", kwargs={"view_id": view.id})
|
||||
response = api_client.patch(
|
||||
url,
|
||||
{
|
||||
"slug": "test",
|
||||
"name": "Test Form 2",
|
||||
"type": "form",
|
||||
"public": True,
|
||||
"title": "Title",
|
||||
"description": "Description",
|
||||
"cover_image": {"name": user_file_1.name},
|
||||
"logo_image": {"name": user_file_2.name},
|
||||
"submit_action": "REDIRECT",
|
||||
"submit_action_redirect_url": "https://localhost",
|
||||
},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"JWT {token}",
|
||||
)
|
||||
response_json = response.json()
|
||||
assert response.status_code == HTTP_200_OK
|
||||
assert response_json["slug"] != "test"
|
||||
assert response_json["slug"] == str(view.slug)
|
||||
assert response_json["name"] == "Test Form 2"
|
||||
assert response_json["type"] == "form"
|
||||
assert response_json["title"] == "Title"
|
||||
assert response_json["description"] == "Description"
|
||||
assert response_json["cover_image"]["name"] == user_file_1.name
|
||||
assert response_json["logo_image"]["name"] == user_file_2.name
|
||||
assert response_json["submit_action"] == "REDIRECT"
|
||||
assert response_json["submit_action_redirect_url"] == "https://localhost"
|
||||
|
||||
form = FormView.objects.all()[0]
|
||||
assert form.name == "Test Form 2"
|
||||
assert form.table == table
|
||||
assert form.public is True
|
||||
assert form.title == "Title"
|
||||
assert form.description == "Description"
|
||||
assert form.cover_image_id == user_file_1.id
|
||||
assert form.logo_image_id == user_file_2.id
|
||||
assert form.submit_action == "REDIRECT"
|
||||
assert form.submit_action_redirect_url == "https://localhost"
|
||||
|
||||
url = reverse("api:database:views:item", kwargs={"view_id": view.id})
|
||||
response = api_client.patch(
|
||||
url,
|
||||
{
|
||||
"cover_image": {"name": user_file_2.name},
|
||||
},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"JWT {token}",
|
||||
)
|
||||
response_json = response.json()
|
||||
assert response.status_code == HTTP_200_OK
|
||||
assert response_json["cover_image"]["name"] == user_file_2.name
|
||||
assert response_json["logo_image"]["name"] == user_file_2.name
|
||||
|
||||
url = reverse("api:database:views:item", kwargs={"view_id": view.id})
|
||||
response = api_client.patch(
|
||||
url,
|
||||
{
|
||||
"cover_image": None,
|
||||
},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"JWT {token}",
|
||||
)
|
||||
response_json = response.json()
|
||||
print(response_json)
|
||||
assert response.status_code == HTTP_200_OK
|
||||
assert response_json["cover_image"] is None
|
||||
assert response_json["logo_image"]["name"] == user_file_2.name
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_rotate_slug(api_client, data_fixture):
|
||||
user, token = data_fixture.create_user_and_token()
|
||||
table = data_fixture.create_database_table(user=user)
|
||||
view = data_fixture.create_form_view(table=table)
|
||||
view_2 = data_fixture.create_form_view()
|
||||
old_slug = str(view.slug)
|
||||
|
||||
url = reverse("api:database:views:form:rotate_slug", kwargs={"view_id": view_2.id})
|
||||
response = api_client.post(url, format="json", HTTP_AUTHORIZATION=f"JWT {token}")
|
||||
assert response.status_code == HTTP_400_BAD_REQUEST
|
||||
assert response.json()["error"] == "ERROR_USER_NOT_IN_GROUP"
|
||||
|
||||
url = reverse("api:database:views:form:rotate_slug", kwargs={"view_id": 99999})
|
||||
response = api_client.post(url, format="json", HTTP_AUTHORIZATION=f"JWT {token}")
|
||||
assert response.status_code == HTTP_404_NOT_FOUND
|
||||
|
||||
url = reverse("api:database:views:form:rotate_slug", kwargs={"view_id": view.id})
|
||||
response = api_client.post(
|
||||
url,
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"JWT {token}",
|
||||
)
|
||||
response_json = response.json()
|
||||
assert response.status_code == HTTP_200_OK
|
||||
assert response_json["slug"] != old_slug
|
||||
assert len(response_json["slug"]) == 43
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_meta_submit_form_view(api_client, data_fixture):
|
||||
user, token = data_fixture.create_user_and_token()
|
||||
user_2, token_2 = data_fixture.create_user_and_token()
|
||||
table = data_fixture.create_database_table(user=user)
|
||||
user_file_1 = data_fixture.create_user_file()
|
||||
user_file_2 = data_fixture.create_user_file()
|
||||
form = data_fixture.create_form_view(
|
||||
table=table,
|
||||
title="Title",
|
||||
description="Description",
|
||||
cover_image=user_file_1,
|
||||
logo_image=user_file_2,
|
||||
)
|
||||
text_field = data_fixture.create_text_field(table=table)
|
||||
number_field = data_fixture.create_number_field(table=table)
|
||||
disabled_field = data_fixture.create_text_field(table=table)
|
||||
data_fixture.create_form_view_field_option(
|
||||
form,
|
||||
text_field,
|
||||
name="Text field title",
|
||||
description="Text field description",
|
||||
required=True,
|
||||
enabled=True,
|
||||
order=1,
|
||||
)
|
||||
data_fixture.create_form_view_field_option(
|
||||
form, number_field, required=False, enabled=True, order=2
|
||||
)
|
||||
data_fixture.create_form_view_field_option(
|
||||
form, disabled_field, required=False, enabled=False, order=3
|
||||
)
|
||||
|
||||
url = reverse("api:database:views:form:submit", kwargs={"slug": "NOT_EXISTING"})
|
||||
response = api_client.get(url, format="json")
|
||||
assert response.status_code == HTTP_404_NOT_FOUND
|
||||
assert response.json()["error"] == "ERROR_FORM_DOES_NOT_EXIST"
|
||||
|
||||
url = reverse("api:database:views:form:submit", kwargs={"slug": form.slug})
|
||||
response = api_client.get(url, format="json")
|
||||
assert response.status_code == HTTP_404_NOT_FOUND
|
||||
assert response.json()["error"] == "ERROR_FORM_DOES_NOT_EXIST"
|
||||
|
||||
url = reverse("api:database:views:form:submit", kwargs={"slug": form.slug})
|
||||
response = api_client.get(url, format="json", HTTP_AUTHORIZATION=f"JWT {token_2}")
|
||||
assert response.status_code == HTTP_404_NOT_FOUND
|
||||
assert response.json()["error"] == "ERROR_FORM_DOES_NOT_EXIST"
|
||||
|
||||
url = reverse("api:database:views:form:submit", kwargs={"slug": form.slug})
|
||||
response = api_client.get(url, format="json", HTTP_AUTHORIZATION=f"JWT {token}")
|
||||
assert response.status_code == HTTP_200_OK
|
||||
|
||||
form.public = True
|
||||
form.save()
|
||||
|
||||
url = reverse("api:database:views:form:submit", kwargs={"slug": form.slug})
|
||||
response = api_client.get(url, format="json")
|
||||
assert response.status_code == HTTP_200_OK
|
||||
response_json = response.json()
|
||||
|
||||
assert response_json["title"] == "Title"
|
||||
assert response_json["description"] == "Description"
|
||||
assert response_json["cover_image"]["name"] == user_file_1.name
|
||||
assert response_json["logo_image"]["name"] == user_file_2.name
|
||||
assert len(response_json["fields"]) == 2
|
||||
assert response_json["fields"][0] == {
|
||||
"name": "Text field title",
|
||||
"description": "Text field description",
|
||||
"required": True,
|
||||
"order": 1,
|
||||
"field": {"id": text_field.id, "type": "text", "text_default": ""},
|
||||
}
|
||||
assert response_json["fields"][1] == {
|
||||
"name": number_field.name,
|
||||
"description": "",
|
||||
"required": False,
|
||||
"order": 2,
|
||||
"field": {
|
||||
"id": number_field.id,
|
||||
"type": "number",
|
||||
"number_type": "INTEGER",
|
||||
"number_decimal_places": 1,
|
||||
"number_negative": False,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_submit_form_view(api_client, data_fixture):
|
||||
user, token = data_fixture.create_user_and_token()
|
||||
user_2, token_2 = data_fixture.create_user_and_token()
|
||||
table = data_fixture.create_database_table(user=user)
|
||||
form = data_fixture.create_form_view(
|
||||
table=table,
|
||||
submit_action_message="Test",
|
||||
submit_action_redirect_url="https://baserow.io",
|
||||
)
|
||||
text_field = data_fixture.create_text_field(table=table)
|
||||
number_field = data_fixture.create_number_field(table=table)
|
||||
disabled_field = data_fixture.create_text_field(table=table)
|
||||
data_fixture.create_form_view_field_option(
|
||||
form, text_field, required=True, enabled=True, order=1
|
||||
)
|
||||
data_fixture.create_form_view_field_option(
|
||||
form, number_field, required=False, enabled=True, order=2
|
||||
)
|
||||
data_fixture.create_form_view_field_option(
|
||||
form, disabled_field, required=False, enabled=False, order=3
|
||||
)
|
||||
|
||||
url = reverse("api:database:views:form:submit", kwargs={"slug": "NOT_EXISTING"})
|
||||
response = api_client.post(url, {}, format="json")
|
||||
assert response.status_code == HTTP_404_NOT_FOUND
|
||||
assert response.json()["error"] == "ERROR_FORM_DOES_NOT_EXIST"
|
||||
|
||||
url = reverse("api:database:views:form:submit", kwargs={"slug": form.slug})
|
||||
response = api_client.post(url, {}, format="json")
|
||||
assert response.status_code == HTTP_404_NOT_FOUND
|
||||
assert response.json()["error"] == "ERROR_FORM_DOES_NOT_EXIST"
|
||||
|
||||
url = reverse("api:database:views:form:submit", kwargs={"slug": form.slug})
|
||||
response = api_client.post(
|
||||
url, {}, format="json", HTTP_AUTHORIZATION=f"JWT" f" {token_2}"
|
||||
)
|
||||
assert response.status_code == HTTP_404_NOT_FOUND
|
||||
assert response.json()["error"] == "ERROR_FORM_DOES_NOT_EXIST"
|
||||
|
||||
url = reverse("api:database:views:form:submit", kwargs={"slug": form.slug})
|
||||
response = api_client.post(
|
||||
url,
|
||||
{
|
||||
# We do not provide the text field value, but that one is required and we
|
||||
# provide a wrong value for the number field, so we expect two errors.
|
||||
f"field_{number_field.id}": {},
|
||||
},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"JWT" f" {token}",
|
||||
)
|
||||
assert response.status_code == HTTP_400_BAD_REQUEST
|
||||
response_json = response.json()
|
||||
assert response_json["error"] == "ERROR_REQUEST_BODY_VALIDATION"
|
||||
assert len(response_json["detail"]) == 2
|
||||
assert response_json["detail"][f"field_{text_field.id}"][0]["code"] == "required"
|
||||
assert response_json["detail"][f"field_{number_field.id}"][0]["code"] == "invalid"
|
||||
|
||||
url = reverse("api:database:views:form:submit", kwargs={"slug": form.slug})
|
||||
response = api_client.post(
|
||||
url,
|
||||
{
|
||||
f"field_{text_field.id}": "Valid",
|
||||
f"field_{number_field.id}": 0,
|
||||
},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"JWT" f" {token}",
|
||||
)
|
||||
assert response.status_code == HTTP_200_OK
|
||||
response_json = response.json()
|
||||
assert response_json == {
|
||||
"submit_action": "MESSAGE",
|
||||
"submit_action_message": "Test",
|
||||
"submit_action_redirect_url": "https://baserow.io",
|
||||
}
|
||||
|
||||
model = table.get_model()
|
||||
all = model.objects.all()
|
||||
assert len(all) == 1
|
||||
assert getattr(all[0], f"field_{text_field.id}") == "Valid"
|
||||
assert getattr(all[0], f"field_{number_field.id}") == 0
|
||||
|
||||
form.public = True
|
||||
form.save()
|
||||
|
||||
url = reverse("api:database:views:form:submit", kwargs={"slug": form.slug})
|
||||
response = api_client.post(
|
||||
url,
|
||||
{},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == HTTP_400_BAD_REQUEST
|
||||
response_json = response.json()
|
||||
assert response_json["error"] == "ERROR_REQUEST_BODY_VALIDATION"
|
||||
assert len(response_json["detail"]) == 1
|
||||
assert response_json["detail"][f"field_{text_field.id}"][0]["code"] == "required"
|
||||
|
||||
url = reverse("api:database:views:form:submit", kwargs={"slug": form.slug})
|
||||
response = api_client.post(
|
||||
url,
|
||||
{
|
||||
f"field_{text_field.id}": "A value",
|
||||
f"field_{disabled_field.id}": "Value",
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == HTTP_200_OK
|
||||
|
||||
model = table.get_model()
|
||||
all = model.objects.all()
|
||||
assert len(all) == 2
|
||||
assert getattr(all[1], f"field_{text_field.id}") == "A value"
|
||||
assert getattr(all[1], f"field_{number_field.id}") is None
|
||||
assert getattr(all[1], f"field_{disabled_field.id}") is None
|
||||
|
||||
date_field = data_fixture.create_date_field(table=table)
|
||||
file_field = data_fixture.create_file_field(table=table)
|
||||
url_field = data_fixture.create_url_field(table=table)
|
||||
single_select_field = data_fixture.create_single_select_field(table=table)
|
||||
boolean_field = data_fixture.create_boolean_field(table=table)
|
||||
phone_field = data_fixture.create_phone_number_field(table=table)
|
||||
data_fixture.create_form_view_field_option(
|
||||
form, file_field, required=True, enabled=True
|
||||
)
|
||||
data_fixture.create_form_view_field_option(
|
||||
form, url_field, required=True, enabled=True
|
||||
)
|
||||
data_fixture.create_form_view_field_option(
|
||||
form, single_select_field, required=True, enabled=True
|
||||
)
|
||||
data_fixture.create_form_view_field_option(
|
||||
form, boolean_field, required=True, enabled=True
|
||||
)
|
||||
data_fixture.create_form_view_field_option(
|
||||
form, date_field, required=True, enabled=True
|
||||
)
|
||||
data_fixture.create_form_view_field_option(
|
||||
form, phone_field, required=True, enabled=True
|
||||
)
|
||||
|
||||
url = reverse("api:database:views:form:submit", kwargs={"slug": form.slug})
|
||||
response = api_client.post(
|
||||
url,
|
||||
{
|
||||
f"field_{text_field.id}": "",
|
||||
f"field_{date_field.id}": None,
|
||||
f"field_{file_field.id}": [],
|
||||
f"field_{url_field.id}": "",
|
||||
f"field_{single_select_field.id}": "",
|
||||
f"field_{boolean_field.id}": False,
|
||||
f"field_{phone_field.id}": "",
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == HTTP_400_BAD_REQUEST
|
||||
response_json = response.json()
|
||||
assert response_json["error"] == "ERROR_REQUEST_BODY_VALIDATION"
|
||||
assert len(response_json["detail"]) == 7
|
||||
|
||||
url = reverse("api:database:views:form:submit", kwargs={"slug": form.slug})
|
||||
response = api_client.post(
|
||||
url,
|
||||
{},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == HTTP_400_BAD_REQUEST
|
||||
response_json = response.json()
|
||||
assert response_json["error"] == "ERROR_REQUEST_BODY_VALIDATION"
|
||||
assert len(response_json["detail"]) == 7
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_form_view_link_row_lookup_view(api_client, data_fixture):
|
||||
user, token = data_fixture.create_user_and_token()
|
||||
user_2, token_2 = data_fixture.create_user_and_token()
|
||||
database = data_fixture.create_database_application(user=user)
|
||||
table = data_fixture.create_database_table(database=database)
|
||||
lookup_table = data_fixture.create_database_table(database=database)
|
||||
form = data_fixture.create_form_view(table=table)
|
||||
form_2 = data_fixture.create_form_view()
|
||||
text_field = data_fixture.create_text_field(table=table)
|
||||
link_row_field = data_fixture.create_link_row_field(
|
||||
table=table, link_row_table=lookup_table
|
||||
)
|
||||
disabled_link_row_field = data_fixture.create_link_row_field(
|
||||
table=table, link_row_table=lookup_table
|
||||
)
|
||||
unrelated_link_row_field = data_fixture.create_link_row_field(
|
||||
table=table, link_row_table=lookup_table
|
||||
)
|
||||
primary_related_field = data_fixture.create_text_field(
|
||||
table=lookup_table, primary=True
|
||||
)
|
||||
data_fixture.create_text_field(table=lookup_table)
|
||||
data_fixture.create_form_view_field_option(
|
||||
form, text_field, required=True, enabled=True, order=1
|
||||
)
|
||||
data_fixture.create_form_view_field_option(
|
||||
form, link_row_field, required=True, enabled=True, order=2
|
||||
)
|
||||
data_fixture.create_form_view_field_option(
|
||||
form, disabled_link_row_field, required=True, enabled=False, order=3
|
||||
)
|
||||
data_fixture.create_form_view_field_option(
|
||||
form_2, unrelated_link_row_field, required=True, enabled=True, order=1
|
||||
)
|
||||
|
||||
lookup_model = lookup_table.get_model()
|
||||
i1 = lookup_model.objects.create(**{f"field_{primary_related_field.id}": "Test 1"})
|
||||
i2 = lookup_model.objects.create(**{f"field_{primary_related_field.id}": "Test 2"})
|
||||
i3 = lookup_model.objects.create(**{f"field_{primary_related_field.id}": "Test 3"})
|
||||
|
||||
# Anonymous, not existing slug.
|
||||
url = reverse(
|
||||
"api:database:views:form:link_row_field_lookup",
|
||||
kwargs={"slug": "NOT_EXISTING", "field_id": link_row_field.id},
|
||||
)
|
||||
response = api_client.get(url, {})
|
||||
assert response.status_code == HTTP_404_NOT_FOUND
|
||||
assert response.json()["error"] == "ERROR_FORM_DOES_NOT_EXIST"
|
||||
|
||||
# Anonymous, existing slug, but form is not public.
|
||||
url = reverse(
|
||||
"api:database:views:form:link_row_field_lookup",
|
||||
kwargs={"slug": form.slug, "field_id": link_row_field.id},
|
||||
)
|
||||
response = api_client.get(url, format="json")
|
||||
assert response.status_code == HTTP_404_NOT_FOUND
|
||||
assert response.json()["error"] == "ERROR_FORM_DOES_NOT_EXIST"
|
||||
|
||||
# user that doesn't have access to the group, existing slug, but form is not public.
|
||||
url = reverse(
|
||||
"api:database:views:form:link_row_field_lookup",
|
||||
kwargs={"slug": form.slug, "field_id": link_row_field.id},
|
||||
)
|
||||
response = api_client.get(
|
||||
url,
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"JWT" f" {token_2}",
|
||||
)
|
||||
assert response.status_code == HTTP_404_NOT_FOUND
|
||||
assert response.json()["error"] == "ERROR_FORM_DOES_NOT_EXIST"
|
||||
|
||||
# valid user, existing slug, but invalid wrong field type.
|
||||
url = reverse(
|
||||
"api:database:views:form:link_row_field_lookup",
|
||||
kwargs={"slug": form.slug, "field_id": text_field.id},
|
||||
)
|
||||
response = api_client.get(
|
||||
url,
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"JWT" f" {token}",
|
||||
)
|
||||
assert response.status_code == HTTP_404_NOT_FOUND
|
||||
assert response.json()["error"] == "ERROR_FIELD_DOES_NOT_EXIST"
|
||||
|
||||
# valid user, existing slug, but invalid wrong field type.
|
||||
url = reverse(
|
||||
"api:database:views:form:link_row_field_lookup",
|
||||
kwargs={"slug": form.slug, "field_id": 0},
|
||||
)
|
||||
response = api_client.get(
|
||||
url,
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"JWT" f" {token}",
|
||||
)
|
||||
assert response.status_code == HTTP_404_NOT_FOUND
|
||||
assert response.json()["error"] == "ERROR_FIELD_DOES_NOT_EXIST"
|
||||
|
||||
# valid user, existing slug, but disabled link row field.
|
||||
url = reverse(
|
||||
"api:database:views:form:link_row_field_lookup",
|
||||
kwargs={"slug": form.slug, "field_id": disabled_link_row_field.id},
|
||||
)
|
||||
response = api_client.get(
|
||||
url,
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"JWT" f" {token}",
|
||||
)
|
||||
assert response.status_code == HTTP_404_NOT_FOUND
|
||||
assert response.json()["error"] == "ERROR_FIELD_DOES_NOT_EXIST"
|
||||
|
||||
# valid user, existing slug, but unrelated link row field.
|
||||
url = reverse(
|
||||
"api:database:views:form:link_row_field_lookup",
|
||||
kwargs={"slug": form.slug, "field_id": unrelated_link_row_field.id},
|
||||
)
|
||||
response = api_client.get(
|
||||
url,
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"JWT" f" {token}",
|
||||
)
|
||||
assert response.status_code == HTTP_404_NOT_FOUND
|
||||
assert response.json()["error"] == "ERROR_FIELD_DOES_NOT_EXIST"
|
||||
|
||||
form.public = True
|
||||
form.save()
|
||||
|
||||
# anonymous, existing slug, public form, correct link row field.
|
||||
url = reverse(
|
||||
"api:database:views:form:link_row_field_lookup",
|
||||
kwargs={"slug": form.slug, "field_id": link_row_field.id},
|
||||
)
|
||||
response = api_client.get(
|
||||
url,
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == HTTP_200_OK
|
||||
response_json = response.json()
|
||||
assert response_json["count"] == 3
|
||||
assert len(response_json["results"]) == 3
|
||||
assert response_json["results"][0]["id"] == i1.id
|
||||
assert response_json["results"][0]["value"] == "Test 1"
|
||||
assert len(response_json["results"][0]) == 2
|
||||
assert response_json["results"][1]["id"] == i2.id
|
||||
assert response_json["results"][2]["id"] == i3.id
|
||||
|
||||
# same as before only now with search.
|
||||
url = reverse(
|
||||
"api:database:views:form:link_row_field_lookup",
|
||||
kwargs={"slug": form.slug, "field_id": link_row_field.id},
|
||||
)
|
||||
response = api_client.get(
|
||||
f"{url}?search=Test 2",
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == HTTP_200_OK
|
||||
response_json = response.json()
|
||||
assert response_json["count"] == 1
|
||||
assert len(response_json["results"]) == 1
|
||||
assert response_json["results"][0]["id"] == i2.id
|
||||
assert response_json["results"][0]["value"] == "Test 2"
|
||||
|
||||
# same as before only now with pagination
|
||||
url = reverse(
|
||||
"api:database:views:form:link_row_field_lookup",
|
||||
kwargs={"slug": form.slug, "field_id": link_row_field.id},
|
||||
)
|
||||
response = api_client.get(
|
||||
f"{url}?size=1&page=2",
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == HTTP_200_OK
|
||||
response_json = response.json()
|
||||
assert response_json["count"] == 3
|
||||
assert response_json["next"] is not None
|
||||
assert len(response_json["results"]) == 1
|
||||
assert response_json["results"][0]["id"] == i2.id
|
||||
assert response_json["results"][0]["value"] == "Test 2"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_test_enable_form_view_file_field_options(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)
|
||||
form_view = data_fixture.create_form_view(table=table)
|
||||
file_field = data_fixture.create_file_field(table=table)
|
||||
|
||||
url = reverse("api:database:views:field_options", kwargs={"view_id": form_view.id})
|
||||
response = api_client.patch(
|
||||
url,
|
||||
{"field_options": {file_field.id: {"enabled": True}}},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"JWT {token}",
|
||||
)
|
||||
response_json = response.json()
|
||||
assert response.status_code == HTTP_400_BAD_REQUEST
|
||||
assert response_json["error"] == "ERROR_FORM_VIEW_FIELD_TYPE_IS_NOT_SUPPORTED"
|
||||
assert (
|
||||
response_json["detail"]
|
||||
== "The file field type is not compatible with the form view."
|
||||
)
|
|
@ -9,6 +9,8 @@ from rest_framework.status import (
|
|||
|
||||
from django.shortcuts import reverse
|
||||
|
||||
from baserow.contrib.database.views.models import GridView
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_list_rows(api_client, data_fixture):
|
||||
|
@ -350,7 +352,7 @@ def test_list_filtered_rows(api_client, data_fixture):
|
|||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_patch_grid_view(api_client, data_fixture):
|
||||
def test_patch_grid_view_field_options(api_client, data_fixture):
|
||||
user, token = data_fixture.create_user_and_token(
|
||||
email="test@test.nl", password="password", first_name="Test1"
|
||||
)
|
||||
|
@ -362,9 +364,8 @@ def test_patch_grid_view(api_client, data_fixture):
|
|||
# so that the GridViewFieldOptions entry is not created. This should
|
||||
# automatically be created when the page is fetched.
|
||||
number_field = data_fixture.create_number_field(table=table)
|
||||
grid_2 = data_fixture.create_grid_view()
|
||||
|
||||
url = reverse("api:database:views:grid:list", kwargs={"view_id": grid.id})
|
||||
url = reverse("api:database:views:field_options", kwargs={"view_id": grid.id})
|
||||
response = api_client.patch(
|
||||
url,
|
||||
{"field_options": {text_field.id: {"width": 300, "hidden": True}}},
|
||||
|
@ -391,7 +392,7 @@ def test_patch_grid_view(api_client, data_fixture):
|
|||
assert options[1].hidden is False
|
||||
assert options[1].order == 32767
|
||||
|
||||
url = reverse("api:database:views:grid:list", kwargs={"view_id": grid.id})
|
||||
url = reverse("api:database:views:field_options", kwargs={"view_id": grid.id})
|
||||
response = api_client.patch(
|
||||
url,
|
||||
{
|
||||
|
@ -418,7 +419,7 @@ def test_patch_grid_view(api_client, data_fixture):
|
|||
assert options[1].field_id == number_field.id
|
||||
assert options[1].hidden is True
|
||||
|
||||
url = reverse("api:database:views:grid:list", kwargs={"view_id": grid.id})
|
||||
url = reverse("api:database:views:field_options", kwargs={"view_id": grid.id})
|
||||
response = api_client.patch(
|
||||
url,
|
||||
{
|
||||
|
@ -449,13 +450,13 @@ def test_patch_grid_view(api_client, data_fixture):
|
|||
assert options[1].field_id == number_field.id
|
||||
assert options[1].hidden is False
|
||||
|
||||
url = reverse("api:database:views:grid:list", kwargs={"view_id": grid.id})
|
||||
url = reverse("api:database:views:field_options", kwargs={"view_id": grid.id})
|
||||
response = api_client.patch(
|
||||
url, {}, format="json", HTTP_AUTHORIZATION=f"JWT {token}"
|
||||
url, {"field_options": {}}, format="json", HTTP_AUTHORIZATION=f"JWT {token}"
|
||||
)
|
||||
assert response.status_code == HTTP_200_OK
|
||||
|
||||
url = reverse("api:database:views:grid:list", kwargs={"view_id": grid.id})
|
||||
url = reverse("api:database:views:field_options", kwargs={"view_id": grid.id})
|
||||
response = api_client.patch(
|
||||
url,
|
||||
{"field_options": {"RANDOM_FIELD": "TEST"}},
|
||||
|
@ -467,29 +468,7 @@ def test_patch_grid_view(api_client, data_fixture):
|
|||
assert response_json["error"] == "ERROR_REQUEST_BODY_VALIDATION"
|
||||
assert response_json["detail"]["field_options"][0]["code"] == "invalid_key"
|
||||
|
||||
url = reverse("api:database:views:grid:list", kwargs={"view_id": grid.id})
|
||||
response = api_client.patch(
|
||||
url,
|
||||
{"field_options": {99999: {"width": 100}}},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"JWT {token}",
|
||||
)
|
||||
response_json = response.json()
|
||||
assert response.status_code == HTTP_400_BAD_REQUEST
|
||||
assert response_json["error"] == "ERROR_UNRELATED_FIELD"
|
||||
|
||||
url = reverse("api:database:views:grid:list", kwargs={"view_id": grid.id})
|
||||
response = api_client.patch(
|
||||
url,
|
||||
{"field_options": {99999: {"hidden": True}}},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"JWT {token}",
|
||||
)
|
||||
response_json = response.json()
|
||||
assert response.status_code == HTTP_400_BAD_REQUEST
|
||||
assert response_json["error"] == "ERROR_UNRELATED_FIELD"
|
||||
|
||||
url = reverse("api:database:views:grid:list", kwargs={"view_id": grid.id})
|
||||
url = reverse("api:database:views:field_options", kwargs={"view_id": grid.id})
|
||||
response = api_client.patch(
|
||||
url,
|
||||
{"field_options": {1: {"width": "abc"}}},
|
||||
|
@ -501,7 +480,7 @@ def test_patch_grid_view(api_client, data_fixture):
|
|||
assert response_json["error"] == "ERROR_REQUEST_BODY_VALIDATION"
|
||||
assert response_json["detail"]["field_options"][0]["code"] == "invalid_value"
|
||||
|
||||
url = reverse("api:database:views:grid:list", kwargs={"view_id": grid.id})
|
||||
url = reverse("api:database:views:field_options", kwargs={"view_id": grid.id})
|
||||
response = api_client.patch(
|
||||
url,
|
||||
{"field_options": {1: {"hidden": "abc"}}},
|
||||
|
@ -513,12 +492,193 @@ def test_patch_grid_view(api_client, data_fixture):
|
|||
assert response_json["error"] == "ERROR_REQUEST_BODY_VALIDATION"
|
||||
assert response_json["detail"]["field_options"][0]["code"] == "invalid_value"
|
||||
|
||||
url = reverse("api:database:views:grid:list", kwargs={"view_id": 999})
|
||||
response = api_client.patch(url, **{"HTTP_AUTHORIZATION": f"JWT {token}"})
|
||||
assert response.status_code == HTTP_404_NOT_FOUND
|
||||
assert response.json()["error"] == "ERROR_GRID_DOES_NOT_EXIST"
|
||||
|
||||
url = reverse("api:database:views:grid:list", kwargs={"view_id": grid_2.id})
|
||||
response = api_client.patch(url, **{"HTTP_AUTHORIZATION": f"JWT {token}"})
|
||||
@pytest.mark.django_db
|
||||
def test_create_grid_view(api_client, data_fixture):
|
||||
user, token = data_fixture.create_user_and_token()
|
||||
table = data_fixture.create_database_table(user=user)
|
||||
table_2 = data_fixture.create_database_table()
|
||||
|
||||
response = api_client.post(
|
||||
reverse("api:database:views:list", kwargs={"table_id": table.id}),
|
||||
{"name": "Test 1", "type": "NOT_EXISTING"},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"JWT {token}",
|
||||
)
|
||||
response_json = response.json()
|
||||
assert response.status_code == HTTP_400_BAD_REQUEST
|
||||
assert response_json["error"] == "ERROR_REQUEST_BODY_VALIDATION"
|
||||
assert response_json["detail"]["type"][0]["code"] == "invalid_choice"
|
||||
|
||||
response = api_client.post(
|
||||
reverse("api:database:views:list", kwargs={"table_id": 99999}),
|
||||
{"name": "Test 1", "type": "grid"},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"JWT {token}",
|
||||
)
|
||||
assert response.status_code == HTTP_404_NOT_FOUND
|
||||
assert response.json()["error"] == "ERROR_TABLE_DOES_NOT_EXIST"
|
||||
|
||||
response = api_client.post(
|
||||
reverse("api:database:views:list", kwargs={"table_id": table_2.id}),
|
||||
{"name": "Test 1", "type": "grid"},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"JWT {token}",
|
||||
)
|
||||
assert response.status_code == HTTP_400_BAD_REQUEST
|
||||
assert response.json()["error"] == "ERROR_USER_NOT_IN_GROUP"
|
||||
|
||||
url = reverse("api:database:views:list", kwargs={"table_id": table_2.id})
|
||||
response = api_client.get(url)
|
||||
assert response.status_code == HTTP_401_UNAUTHORIZED
|
||||
|
||||
response = api_client.post(
|
||||
reverse("api:database:views:list", kwargs={"table_id": table.id}),
|
||||
{
|
||||
"name": "Test 1",
|
||||
"type": "grid",
|
||||
"filter_type": "OR",
|
||||
"filters_disabled": True,
|
||||
},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"JWT {token}",
|
||||
)
|
||||
response_json = response.json()
|
||||
assert response.status_code == HTTP_200_OK
|
||||
assert response_json["type"] == "grid"
|
||||
assert response_json["filter_type"] == "OR"
|
||||
assert response_json["filters_disabled"] is True
|
||||
|
||||
grid = GridView.objects.filter()[0]
|
||||
assert response_json["id"] == grid.id
|
||||
assert response_json["name"] == grid.name
|
||||
assert response_json["order"] == grid.order
|
||||
assert response_json["filter_type"] == grid.filter_type
|
||||
assert response_json["filters_disabled"] == grid.filters_disabled
|
||||
assert "filters" not in response_json
|
||||
assert "sortings" not in response_json
|
||||
|
||||
response = api_client.post(
|
||||
"{}?include=filters,sortings".format(
|
||||
reverse("api:database:views:list", kwargs={"table_id": table.id})
|
||||
),
|
||||
{
|
||||
"name": "Test 2",
|
||||
"type": "grid",
|
||||
"filter_type": "AND",
|
||||
"filters_disabled": False,
|
||||
},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"JWT {token}",
|
||||
)
|
||||
response_json = response.json()
|
||||
assert response.status_code == HTTP_200_OK
|
||||
assert response_json["name"] == "Test 2"
|
||||
assert response_json["type"] == "grid"
|
||||
assert response_json["filter_type"] == "AND"
|
||||
assert response_json["filters_disabled"] is False
|
||||
assert response_json["filters"] == []
|
||||
assert response_json["sortings"] == []
|
||||
|
||||
response = api_client.post(
|
||||
"{}".format(reverse("api:database:views:list", kwargs={"table_id": table.id})),
|
||||
{"name": "Test 3", "type": "grid"},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"JWT {token}",
|
||||
)
|
||||
response_json = response.json()
|
||||
assert response.status_code == HTTP_200_OK
|
||||
assert response_json["name"] == "Test 3"
|
||||
assert response_json["type"] == "grid"
|
||||
assert response_json["filter_type"] == "AND"
|
||||
assert response_json["filters_disabled"] is False
|
||||
assert "filters" not in response_json
|
||||
assert "sortings" not in response_json
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_update_grid_view(api_client, data_fixture):
|
||||
user, token = data_fixture.create_user_and_token()
|
||||
user_2, token_2 = data_fixture.create_user_and_token()
|
||||
table = data_fixture.create_database_table(user=user)
|
||||
table_2 = data_fixture.create_database_table(user=user_2)
|
||||
view = data_fixture.create_grid_view(table=table)
|
||||
view_2 = data_fixture.create_grid_view(table=table_2)
|
||||
|
||||
url = reverse("api:database:views:item", kwargs={"view_id": view_2.id})
|
||||
response = api_client.patch(
|
||||
url, {"name": "Test 1"}, format="json", HTTP_AUTHORIZATION=f"JWT {token}"
|
||||
)
|
||||
response_json = response.json()
|
||||
assert response.status_code == HTTP_400_BAD_REQUEST
|
||||
assert response_json["error"] == "ERROR_USER_NOT_IN_GROUP"
|
||||
|
||||
url = reverse("api:database:views:item", kwargs={"view_id": 999999})
|
||||
response = api_client.patch(
|
||||
url, {"name": "Test 1"}, format="json", HTTP_AUTHORIZATION=f"JWT {token}"
|
||||
)
|
||||
assert response.status_code == HTTP_404_NOT_FOUND
|
||||
assert response.json()["error"] == "ERROR_VIEW_DOES_NOT_EXIST"
|
||||
|
||||
url = reverse("api:database:views:item", kwargs={"view_id": view.id})
|
||||
response = api_client.patch(
|
||||
url,
|
||||
{"UNKNOWN_FIELD": "Test 1"},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"JWT {token}",
|
||||
)
|
||||
assert response.status_code == HTTP_200_OK
|
||||
|
||||
url = reverse("api:database:views:item", kwargs={"view_id": view.id})
|
||||
response = api_client.patch(
|
||||
url, {"name": "Test 1"}, format="json", HTTP_AUTHORIZATION=f"JWT {token}"
|
||||
)
|
||||
response_json = response.json()
|
||||
assert response.status_code == HTTP_200_OK
|
||||
assert response_json["id"] == view.id
|
||||
assert response_json["name"] == "Test 1"
|
||||
assert response_json["filter_type"] == "AND"
|
||||
assert not response_json["filters_disabled"]
|
||||
|
||||
view.refresh_from_db()
|
||||
assert view.name == "Test 1"
|
||||
assert view.filter_type == "AND"
|
||||
assert not view.filters_disabled
|
||||
|
||||
url = reverse("api:database:views:item", kwargs={"view_id": view.id})
|
||||
response = api_client.patch(
|
||||
url,
|
||||
{
|
||||
"filter_type": "OR",
|
||||
"filters_disabled": True,
|
||||
},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"JWT {token}",
|
||||
)
|
||||
response_json = response.json()
|
||||
assert response.status_code == HTTP_200_OK
|
||||
assert response_json["id"] == view.id
|
||||
assert response_json["filter_type"] == "OR"
|
||||
assert response_json["filters_disabled"]
|
||||
assert "filters" not in response_json
|
||||
assert "sortings" not in response_json
|
||||
|
||||
view.refresh_from_db()
|
||||
assert view.filter_type == "OR"
|
||||
assert view.filters_disabled
|
||||
|
||||
filter_1 = data_fixture.create_view_filter(view=view)
|
||||
url = reverse("api:database:views:item", kwargs={"view_id": view.id})
|
||||
response = api_client.patch(
|
||||
"{}?include=filters,sortings".format(url),
|
||||
{"filter_type": "AND"},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"JWT {token}",
|
||||
)
|
||||
response_json = response.json()
|
||||
assert response.status_code == HTTP_200_OK
|
||||
assert response_json["id"] == view.id
|
||||
assert response_json["filter_type"] == "AND"
|
||||
assert response_json["filters_disabled"] is True
|
||||
assert response_json["filters"][0]["id"] == filter_1.id
|
||||
assert response_json["sortings"] == []
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import pytest
|
||||
from unittest.mock import patch
|
||||
|
||||
from rest_framework.status import (
|
||||
HTTP_200_OK,
|
||||
|
@ -9,12 +10,14 @@ from rest_framework.status import (
|
|||
)
|
||||
|
||||
from django.shortcuts import reverse
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
|
||||
from baserow.contrib.database.views.models import ViewFilter, ViewSort, GridView
|
||||
from baserow.contrib.database.views.models import View, ViewFilter, ViewSort, GridView
|
||||
from baserow.contrib.database.views.registries import (
|
||||
view_type_registry,
|
||||
view_filter_type_registry,
|
||||
)
|
||||
from baserow.contrib.database.views.view_types import GridViewType
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
|
@ -185,109 +188,6 @@ def test_list_views_including_sortings(api_client, data_fixture):
|
|||
assert response_json[1]["sortings"][0]["id"] == sort_3.id
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_create_view(api_client, data_fixture):
|
||||
user, token = data_fixture.create_user_and_token()
|
||||
table = data_fixture.create_database_table(user=user)
|
||||
table_2 = data_fixture.create_database_table()
|
||||
|
||||
response = api_client.post(
|
||||
reverse("api:database:views:list", kwargs={"table_id": table.id}),
|
||||
{"name": "Test 1", "type": "NOT_EXISTING"},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"JWT {token}",
|
||||
)
|
||||
response_json = response.json()
|
||||
assert response.status_code == HTTP_400_BAD_REQUEST
|
||||
assert response_json["error"] == "ERROR_REQUEST_BODY_VALIDATION"
|
||||
assert response_json["detail"]["type"][0]["code"] == "invalid_choice"
|
||||
|
||||
response = api_client.post(
|
||||
reverse("api:database:views:list", kwargs={"table_id": 99999}),
|
||||
{"name": "Test 1", "type": "grid"},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"JWT {token}",
|
||||
)
|
||||
assert response.status_code == HTTP_404_NOT_FOUND
|
||||
assert response.json()["error"] == "ERROR_TABLE_DOES_NOT_EXIST"
|
||||
|
||||
response = api_client.post(
|
||||
reverse("api:database:views:list", kwargs={"table_id": table_2.id}),
|
||||
{"name": "Test 1", "type": "grid"},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"JWT {token}",
|
||||
)
|
||||
assert response.status_code == HTTP_400_BAD_REQUEST
|
||||
assert response.json()["error"] == "ERROR_USER_NOT_IN_GROUP"
|
||||
|
||||
url = reverse("api:database:views:list", kwargs={"table_id": table_2.id})
|
||||
response = api_client.get(url)
|
||||
assert response.status_code == HTTP_401_UNAUTHORIZED
|
||||
|
||||
response = api_client.post(
|
||||
reverse("api:database:views:list", kwargs={"table_id": table.id}),
|
||||
{
|
||||
"name": "Test 1",
|
||||
"type": "grid",
|
||||
"filter_type": "OR",
|
||||
"filters_disabled": True,
|
||||
},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"JWT {token}",
|
||||
)
|
||||
response_json = response.json()
|
||||
assert response.status_code == HTTP_200_OK
|
||||
assert response_json["type"] == "grid"
|
||||
assert response_json["filter_type"] == "OR"
|
||||
assert response_json["filters_disabled"] is True
|
||||
|
||||
grid = GridView.objects.filter()[0]
|
||||
assert response_json["id"] == grid.id
|
||||
assert response_json["name"] == grid.name
|
||||
assert response_json["order"] == grid.order
|
||||
assert response_json["filter_type"] == grid.filter_type
|
||||
assert response_json["filters_disabled"] == grid.filters_disabled
|
||||
assert "filters" not in response_json
|
||||
assert "sortings" not in response_json
|
||||
|
||||
response = api_client.post(
|
||||
"{}?include=filters,sortings".format(
|
||||
reverse("api:database:views:list", kwargs={"table_id": table.id})
|
||||
),
|
||||
{
|
||||
"name": "Test 2",
|
||||
"type": "grid",
|
||||
"filter_type": "AND",
|
||||
"filters_disabled": False,
|
||||
},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"JWT {token}",
|
||||
)
|
||||
response_json = response.json()
|
||||
assert response.status_code == HTTP_200_OK
|
||||
assert response_json["name"] == "Test 2"
|
||||
assert response_json["type"] == "grid"
|
||||
assert response_json["filter_type"] == "AND"
|
||||
assert response_json["filters_disabled"] is False
|
||||
assert response_json["filters"] == []
|
||||
assert response_json["sortings"] == []
|
||||
|
||||
response = api_client.post(
|
||||
"{}".format(reverse("api:database:views:list", kwargs={"table_id": table.id})),
|
||||
{"name": "Test 3", "type": "grid"},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"JWT {token}",
|
||||
)
|
||||
response_json = response.json()
|
||||
assert response.status_code == HTTP_200_OK
|
||||
assert response_json["name"] == "Test 3"
|
||||
assert response_json["type"] == "grid"
|
||||
assert response_json["filter_type"] == "AND"
|
||||
assert response_json["filters_disabled"] is False
|
||||
assert "filters" not in response_json
|
||||
assert "sortings" not in response_json
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_get_view(api_client, data_fixture):
|
||||
user, token = data_fixture.create_user_and_token()
|
||||
|
@ -352,94 +252,6 @@ def test_get_view(api_client, data_fixture):
|
|||
assert response.json()["error"] == "ERROR_VIEW_DOES_NOT_EXIST"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_update_view(api_client, data_fixture):
|
||||
user, token = data_fixture.create_user_and_token()
|
||||
user_2, token_2 = data_fixture.create_user_and_token()
|
||||
table = data_fixture.create_database_table(user=user)
|
||||
table_2 = data_fixture.create_database_table(user=user_2)
|
||||
view = data_fixture.create_grid_view(table=table)
|
||||
view_2 = data_fixture.create_grid_view(table=table_2)
|
||||
|
||||
url = reverse("api:database:views:item", kwargs={"view_id": view_2.id})
|
||||
response = api_client.patch(
|
||||
url, {"name": "Test 1"}, format="json", HTTP_AUTHORIZATION=f"JWT {token}"
|
||||
)
|
||||
response_json = response.json()
|
||||
assert response.status_code == HTTP_400_BAD_REQUEST
|
||||
assert response_json["error"] == "ERROR_USER_NOT_IN_GROUP"
|
||||
|
||||
url = reverse("api:database:views:item", kwargs={"view_id": 999999})
|
||||
response = api_client.patch(
|
||||
url, {"name": "Test 1"}, format="json", HTTP_AUTHORIZATION=f"JWT {token}"
|
||||
)
|
||||
assert response.status_code == HTTP_404_NOT_FOUND
|
||||
assert response.json()["error"] == "ERROR_VIEW_DOES_NOT_EXIST"
|
||||
|
||||
url = reverse("api:database:views:item", kwargs={"view_id": view.id})
|
||||
response = api_client.patch(
|
||||
url,
|
||||
{"UNKNOWN_FIELD": "Test 1"},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"JWT {token}",
|
||||
)
|
||||
assert response.status_code == HTTP_200_OK
|
||||
|
||||
url = reverse("api:database:views:item", kwargs={"view_id": view.id})
|
||||
response = api_client.patch(
|
||||
url, {"name": "Test 1"}, format="json", HTTP_AUTHORIZATION=f"JWT {token}"
|
||||
)
|
||||
response_json = response.json()
|
||||
assert response.status_code == HTTP_200_OK
|
||||
assert response_json["id"] == view.id
|
||||
assert response_json["name"] == "Test 1"
|
||||
assert response_json["filter_type"] == "AND"
|
||||
assert not response_json["filters_disabled"]
|
||||
|
||||
view.refresh_from_db()
|
||||
assert view.name == "Test 1"
|
||||
assert view.filter_type == "AND"
|
||||
assert not view.filters_disabled
|
||||
|
||||
url = reverse("api:database:views:item", kwargs={"view_id": view.id})
|
||||
response = api_client.patch(
|
||||
url,
|
||||
{
|
||||
"filter_type": "OR",
|
||||
"filters_disabled": True,
|
||||
},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"JWT {token}",
|
||||
)
|
||||
response_json = response.json()
|
||||
assert response.status_code == HTTP_200_OK
|
||||
assert response_json["id"] == view.id
|
||||
assert response_json["filter_type"] == "OR"
|
||||
assert response_json["filters_disabled"]
|
||||
assert "filters" not in response_json
|
||||
assert "sortings" not in response_json
|
||||
|
||||
view.refresh_from_db()
|
||||
assert view.filter_type == "OR"
|
||||
assert view.filters_disabled
|
||||
|
||||
filter_1 = data_fixture.create_view_filter(view=view)
|
||||
url = reverse("api:database:views:item", kwargs={"view_id": view.id})
|
||||
response = api_client.patch(
|
||||
"{}?include=filters,sortings".format(url),
|
||||
{"filter_type": "AND"},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"JWT {token}",
|
||||
)
|
||||
response_json = response.json()
|
||||
assert response.status_code == HTTP_200_OK
|
||||
assert response_json["id"] == view.id
|
||||
assert response_json["filter_type"] == "AND"
|
||||
assert response_json["filters_disabled"] is True
|
||||
assert response_json["filters"][0]["id"] == filter_1.id
|
||||
assert response_json["sortings"] == []
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_delete_view(api_client, data_fixture):
|
||||
user, token = data_fixture.create_user_and_token()
|
||||
|
@ -1332,3 +1144,122 @@ def test_delete_view_sort(api_client, data_fixture):
|
|||
)
|
||||
assert response.status_code == 204
|
||||
assert ViewSort.objects.all().count() == 1
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_get_view_field_options(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)
|
||||
grid = data_fixture.create_grid_view(table=table)
|
||||
grid_2 = data_fixture.create_grid_view()
|
||||
|
||||
class GridViewWithNormalViewModel(GridViewType):
|
||||
field_options_serializer_class = None
|
||||
|
||||
url = reverse("api:database:views:field_options", kwargs={"view_id": grid.id})
|
||||
response = api_client.get(
|
||||
url,
|
||||
HTTP_AUTHORIZATION=f"JWT {token}",
|
||||
)
|
||||
response_json = response.json()
|
||||
assert response.status_code == HTTP_200_OK
|
||||
assert len(response_json["field_options"]) == 0
|
||||
|
||||
url = reverse("api:database:views:field_options", kwargs={"view_id": 999999})
|
||||
response = api_client.get(url, **{"HTTP_AUTHORIZATION": f"JWT {token}"})
|
||||
assert response.status_code == HTTP_404_NOT_FOUND
|
||||
assert response.json()["error"] == "ERROR_VIEW_DOES_NOT_EXIST"
|
||||
|
||||
url = reverse("api:database:views:field_options", kwargs={"view_id": grid_2.id})
|
||||
response = api_client.get(url, **{"HTTP_AUTHORIZATION": f"JWT {token}"})
|
||||
assert response.status_code == HTTP_400_BAD_REQUEST
|
||||
assert response.json()["error"] == "ERROR_USER_NOT_IN_GROUP"
|
||||
|
||||
with patch.dict(
|
||||
view_type_registry.registry, {"grid": GridViewWithNormalViewModel()}
|
||||
):
|
||||
url = reverse("api:database:views:field_options", kwargs={"view_id": grid.id})
|
||||
response = api_client.get(url, **{"HTTP_AUTHORIZATION": f"JWT {token}"})
|
||||
assert response.status_code == HTTP_400_BAD_REQUEST
|
||||
assert response.json()["error"] == "ERROR_VIEW_DOES_NOT_SUPPORT_FIELD_OPTIONS"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_patch_view_field_options(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)
|
||||
grid = data_fixture.create_grid_view(table=table)
|
||||
grid_2 = data_fixture.create_grid_view()
|
||||
|
||||
class GridViewWithoutFieldOptions(GridViewType):
|
||||
model_class = View
|
||||
|
||||
url = reverse("api:database:views:field_options", kwargs={"view_id": grid.id})
|
||||
response = api_client.patch(
|
||||
url,
|
||||
{"field_options": {}},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"JWT {token}",
|
||||
)
|
||||
response_json = response.json()
|
||||
assert response.status_code == HTTP_200_OK
|
||||
assert len(response_json["field_options"]) == 0
|
||||
|
||||
url = reverse("api:database:views:field_options", kwargs={"view_id": grid.id})
|
||||
response = api_client.patch(
|
||||
url,
|
||||
{"field_options": {"RANDOM_FIELD": "TEST"}},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"JWT {token}",
|
||||
)
|
||||
response_json = response.json()
|
||||
assert response.status_code == HTTP_400_BAD_REQUEST
|
||||
assert response_json["error"] == "ERROR_REQUEST_BODY_VALIDATION"
|
||||
assert response_json["detail"]["field_options"][0]["code"] == "invalid_key"
|
||||
|
||||
url = reverse("api:database:views:field_options", kwargs={"view_id": grid.id})
|
||||
response = api_client.patch(
|
||||
url,
|
||||
{"field_options": {99999: {}}},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"JWT {token}",
|
||||
)
|
||||
response_json = response.json()
|
||||
assert response.status_code == HTTP_400_BAD_REQUEST
|
||||
assert response_json["error"] == "ERROR_UNRELATED_FIELD"
|
||||
|
||||
url = reverse("api:database:views:field_options", kwargs={"view_id": 999999})
|
||||
response = api_client.patch(url, **{"HTTP_AUTHORIZATION": f"JWT {token}"})
|
||||
assert response.status_code == HTTP_404_NOT_FOUND
|
||||
assert response.json()["error"] == "ERROR_VIEW_DOES_NOT_EXIST"
|
||||
|
||||
url = reverse("api:database:views:field_options", kwargs={"view_id": grid_2.id})
|
||||
response = api_client.patch(
|
||||
url,
|
||||
{"field_options": {}},
|
||||
format="json",
|
||||
**{"HTTP_AUTHORIZATION": f"JWT {token}"},
|
||||
)
|
||||
assert response.status_code == HTTP_400_BAD_REQUEST
|
||||
assert response.json()["error"] == "ERROR_USER_NOT_IN_GROUP"
|
||||
|
||||
# This test should be last because we change the content type of the grid view.
|
||||
with patch.dict(
|
||||
view_type_registry.registry, {"grid": GridViewWithoutFieldOptions()}
|
||||
):
|
||||
grid.content_type = ContentType.objects.get(app_label="database", model="view")
|
||||
grid.save()
|
||||
url = reverse("api:database:views:field_options", kwargs={"view_id": grid.id})
|
||||
response = api_client.patch(
|
||||
url,
|
||||
{"field_options": {}},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"JWT {token}",
|
||||
)
|
||||
response_json = response.json()
|
||||
assert response.status_code == HTTP_400_BAD_REQUEST
|
||||
assert response_json["error"] == "ERROR_VIEW_DOES_NOT_SUPPORT_FIELD_OPTIONS"
|
||||
|
|
|
@ -519,11 +519,11 @@ def test_human_readable_values(data_fixture):
|
|||
"date_us": "02/01/2020",
|
||||
"datetime_eu": "01/02/2020 01:23",
|
||||
"datetime_us": "02/01/2020 01:23",
|
||||
"decimal_link_row": "1.234,-123.456,unnamed row 3",
|
||||
"decimal_link_row": "1.234, -123.456, unnamed row 3",
|
||||
"email": "test@example.com",
|
||||
"file": "a.txt,b.txt",
|
||||
"file_link_row": "name.txt,unnamed row 2",
|
||||
"link_row": "linked_row_1,linked_row_2,unnamed row 3",
|
||||
"file": "a.txt, b.txt",
|
||||
"file_link_row": "name.txt, unnamed row 2",
|
||||
"link_row": "linked_row_1, linked_row_2, unnamed row 3",
|
||||
"long_text": "long_text",
|
||||
"negative_decimal": "-1.2",
|
||||
"negative_int": "-1",
|
||||
|
|
|
@ -3,6 +3,8 @@ import pytest
|
|||
# noinspection PyPep8Naming
|
||||
from django.db import connection
|
||||
from django.db.migrations.executor import MigrationExecutor
|
||||
from django.db import DEFAULT_DB_ALIAS
|
||||
from django.core.management import call_command
|
||||
|
||||
migrate_from = [("database", "0032_trash")]
|
||||
migrate_to = [("database", "0033_unique_field_names")]
|
||||
|
@ -73,6 +75,9 @@ def test_migration_fixes_duplicate_field_names(
|
|||
MigratedField,
|
||||
)
|
||||
|
||||
# We need to apply the latest migration otherwise other tests might fail.
|
||||
call_command("migrate", verbosity=0, database=DEFAULT_DB_ALIAS)
|
||||
|
||||
|
||||
# noinspection PyPep8Naming
|
||||
@pytest.mark.django_db
|
||||
|
@ -136,6 +141,9 @@ def test_migration_handles_existing_fields_with_underscore_number(
|
|||
MigratedField,
|
||||
)
|
||||
|
||||
# We need to apply the latest migration otherwise other tests might fail.
|
||||
call_command("migrate", verbosity=0, database=DEFAULT_DB_ALIAS)
|
||||
|
||||
|
||||
# noinspection PyPep8Naming
|
||||
@pytest.mark.django_db
|
||||
|
@ -180,6 +188,9 @@ def test_backwards_migration_restores_field_names(
|
|||
BackwardsMigratedField,
|
||||
)
|
||||
|
||||
# We need to apply the latest migration otherwise other tests might fail.
|
||||
call_command("migrate", verbosity=0, database=DEFAULT_DB_ALIAS)
|
||||
|
||||
|
||||
# noinspection PyPep8Naming
|
||||
@pytest.mark.django_db
|
||||
|
@ -248,6 +259,9 @@ def test_migration_fixes_duplicate_field_names_and_reserved_names(
|
|||
MigratedField,
|
||||
)
|
||||
|
||||
# We need to apply the latest migration otherwise other tests might fail.
|
||||
call_command("migrate", verbosity=0, database=DEFAULT_DB_ALIAS)
|
||||
|
||||
|
||||
def make_fields_with_names(field_names, table_id, content_type_id, Field):
|
||||
fields = []
|
||||
|
|
|
@ -148,6 +148,21 @@ def test_get_table_model(data_fixture):
|
|||
assert fields[text_field_2.id]["name"] == f"field_{text_field_2.id}"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_get_table_model_to_str(data_fixture):
|
||||
table = data_fixture.create_database_table()
|
||||
table_without_primary = data_fixture.create_database_table()
|
||||
text_field = data_fixture.create_text_field(table=table, primary=True)
|
||||
|
||||
model = table.get_model()
|
||||
instance = model.objects.create(**{f"field_{text_field.id}": "Value"})
|
||||
assert str(instance) == "Value"
|
||||
|
||||
model_without_primary = table_without_primary.get_model()
|
||||
instance = model_without_primary.objects.create()
|
||||
assert str(instance) == f"unnamed row {instance.id}"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_enhance_by_fields_queryset(data_fixture):
|
||||
table = data_fixture.create_database_table(name="Cars")
|
||||
|
|
|
@ -2,9 +2,17 @@ import pytest
|
|||
from unittest.mock import patch
|
||||
from decimal import Decimal
|
||||
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
from baserow.core.exceptions import UserNotInGroup
|
||||
from baserow.contrib.database.views.handler import ViewHandler
|
||||
from baserow.contrib.database.views.models import View, GridView, ViewFilter, ViewSort
|
||||
from baserow.contrib.database.views.models import (
|
||||
View,
|
||||
GridView,
|
||||
FormView,
|
||||
ViewFilter,
|
||||
ViewSort,
|
||||
)
|
||||
from baserow.contrib.database.views.registries import (
|
||||
view_type_registry,
|
||||
view_filter_type_registry,
|
||||
|
@ -22,6 +30,8 @@ from baserow.contrib.database.views.exceptions import (
|
|||
ViewSortNotSupported,
|
||||
ViewSortFieldAlreadyExist,
|
||||
ViewSortFieldNotSupported,
|
||||
ViewDoesNotSupportFieldOptions,
|
||||
FormViewFieldTypeIsNotSupported,
|
||||
)
|
||||
from baserow.contrib.database.fields.models import Field
|
||||
from baserow.contrib.database.fields.handler import FieldHandler
|
||||
|
@ -75,7 +85,7 @@ def test_get_view(data_fixture):
|
|||
|
||||
@pytest.mark.django_db
|
||||
@patch("baserow.contrib.database.views.signals.view_created.send")
|
||||
def test_create_view(send_mock, data_fixture):
|
||||
def test_create_grid_view(send_mock, data_fixture):
|
||||
user = data_fixture.create_user()
|
||||
user_2 = data_fixture.create_user()
|
||||
table = data_fixture.create_database_table(user=user)
|
||||
|
@ -146,7 +156,7 @@ def test_create_view(send_mock, data_fixture):
|
|||
|
||||
@pytest.mark.django_db
|
||||
@patch("baserow.contrib.database.views.signals.view_updated.send")
|
||||
def test_update_view(send_mock, data_fixture):
|
||||
def test_update_grid_view(send_mock, data_fixture):
|
||||
user = data_fixture.create_user()
|
||||
user_2 = data_fixture.create_user()
|
||||
table = data_fixture.create_database_table(user=user)
|
||||
|
@ -178,6 +188,189 @@ def test_update_view(send_mock, data_fixture):
|
|||
assert grid.filters_disabled
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@patch("baserow.contrib.database.views.signals.view_deleted.send")
|
||||
def test_delete_grid_view(send_mock, data_fixture):
|
||||
user = data_fixture.create_user()
|
||||
user_2 = data_fixture.create_user()
|
||||
table = data_fixture.create_database_table(user=user)
|
||||
grid = data_fixture.create_grid_view(table=table)
|
||||
|
||||
handler = ViewHandler()
|
||||
|
||||
with pytest.raises(UserNotInGroup):
|
||||
handler.delete_view(user=user_2, view=grid)
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
handler.delete_view(user=user_2, view=object())
|
||||
|
||||
view_id = grid.id
|
||||
|
||||
assert View.objects.all().count() == 1
|
||||
handler.delete_view(user=user, view=grid)
|
||||
assert View.objects.all().count() == 0
|
||||
|
||||
send_mock.assert_called_once()
|
||||
assert send_mock.call_args[1]["view_id"] == view_id
|
||||
assert send_mock.call_args[1]["view"].id == view_id
|
||||
assert send_mock.call_args[1]["user"].id == user.id
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_trashed_fields_are_not_included_in_grid_view_field_options(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_1 = data_fixture.create_text_field(table=table)
|
||||
field_2 = data_fixture.create_text_field(table=table)
|
||||
|
||||
ViewHandler().update_field_options(
|
||||
user=user,
|
||||
view=grid_view,
|
||||
field_options={str(field_1.id): {"width": 150}, field_2.id: {"width": 250}},
|
||||
)
|
||||
options = grid_view.get_field_options()
|
||||
assert options.count() == 2
|
||||
|
||||
TrashHandler.trash(user, table.database.group, table.database, field_1)
|
||||
|
||||
options = grid_view.get_field_options()
|
||||
assert options.count() == 1
|
||||
|
||||
with pytest.raises(UnrelatedFieldError):
|
||||
ViewHandler().update_field_options(
|
||||
user=user,
|
||||
view=grid_view,
|
||||
field_options={
|
||||
field_1.id: {"width": 150},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@patch("baserow.contrib.database.views.signals.view_created.send")
|
||||
def test_create_form_view(send_mock, data_fixture):
|
||||
user = data_fixture.create_user()
|
||||
user_file_1 = data_fixture.create_user_file()
|
||||
user_file_2 = data_fixture.create_user_file()
|
||||
table = data_fixture.create_database_table(user=user)
|
||||
|
||||
handler = ViewHandler()
|
||||
view = handler.create_view(user=user, table=table, type_name="form", name="Form")
|
||||
|
||||
send_mock.assert_called_once()
|
||||
assert send_mock.call_args[1]["view"].id == view.id
|
||||
assert send_mock.call_args[1]["user"].id == user.id
|
||||
|
||||
assert View.objects.all().count() == 1
|
||||
assert FormView.objects.all().count() == 1
|
||||
|
||||
form = FormView.objects.all().first()
|
||||
assert len(str(form.slug)) == 43
|
||||
assert form.name == "Form"
|
||||
assert form.order == 1
|
||||
assert form.table == table
|
||||
assert form.title == ""
|
||||
assert form.description == ""
|
||||
assert form.cover_image is None
|
||||
assert form.logo_image is None
|
||||
assert form.submit_action == "MESSAGE"
|
||||
assert form.submit_action_redirect_url == ""
|
||||
|
||||
form = handler.create_view(
|
||||
user=user,
|
||||
table=table,
|
||||
type_name="form",
|
||||
slug="test-slug",
|
||||
name="Form 2",
|
||||
public=True,
|
||||
title="Test form",
|
||||
description="Test form description",
|
||||
cover_image=user_file_1,
|
||||
logo_image=user_file_2,
|
||||
submit_action="REDIRECT",
|
||||
submit_action_redirect_url="https://localhost",
|
||||
)
|
||||
|
||||
assert View.objects.all().count() == 2
|
||||
assert FormView.objects.all().count() == 2
|
||||
assert form.slug != "test-slug"
|
||||
assert len(form.slug) == 43
|
||||
assert form.name == "Form 2"
|
||||
assert form.order == 2
|
||||
assert form.table == table
|
||||
assert form.public is True
|
||||
assert form.title == "Test form"
|
||||
assert form.description == "Test form description"
|
||||
assert form.cover_image_id == user_file_1.id
|
||||
assert form.logo_image_id == user_file_2.id
|
||||
assert form.submit_action == "REDIRECT"
|
||||
assert form.submit_action_redirect_url == "https://localhost"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@patch("baserow.contrib.database.views.signals.view_updated.send")
|
||||
def test_update_form_view(send_mock, data_fixture):
|
||||
user = data_fixture.create_user()
|
||||
table = data_fixture.create_database_table(user=user)
|
||||
form = data_fixture.create_form_view(table=table)
|
||||
user_file_1 = data_fixture.create_user_file()
|
||||
user_file_2 = data_fixture.create_user_file()
|
||||
|
||||
handler = ViewHandler()
|
||||
view = handler.update_view(
|
||||
user=user,
|
||||
view=form,
|
||||
slug="Test slug",
|
||||
name="Form 2",
|
||||
public=True,
|
||||
title="Test form",
|
||||
description="Test form description",
|
||||
cover_image=user_file_1,
|
||||
logo_image=user_file_2,
|
||||
submit_action="REDIRECT",
|
||||
submit_action_redirect_url="https://localhost",
|
||||
)
|
||||
|
||||
send_mock.assert_called_once()
|
||||
assert send_mock.call_args[1]["view"].id == view.id
|
||||
assert send_mock.call_args[1]["user"].id == user.id
|
||||
|
||||
form.refresh_from_db()
|
||||
assert form.slug != "test-slug"
|
||||
assert len(str(form.slug)) == 43
|
||||
assert form.name == "Form 2"
|
||||
assert form.table == table
|
||||
assert form.public is True
|
||||
assert form.title == "Test form"
|
||||
assert form.description == "Test form description"
|
||||
assert form.cover_image_id == user_file_1.id
|
||||
assert form.logo_image_id == user_file_2.id
|
||||
assert form.submit_action == "REDIRECT"
|
||||
assert form.submit_action_redirect_url == "https://localhost"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@patch("baserow.contrib.database.views.signals.view_deleted.send")
|
||||
def test_delete_form_view(send_mock, data_fixture):
|
||||
user = data_fixture.create_user()
|
||||
table = data_fixture.create_database_table(user=user)
|
||||
data_fixture.create_text_field(table=table)
|
||||
form = data_fixture.create_form_view(table=table)
|
||||
|
||||
handler = ViewHandler()
|
||||
view_id = form.id
|
||||
|
||||
assert View.objects.all().count() == 1
|
||||
handler.delete_view(user=user, view=form)
|
||||
assert View.objects.all().count() == 0
|
||||
|
||||
send_mock.assert_called_once()
|
||||
assert send_mock.call_args[1]["view_id"] == view_id
|
||||
assert send_mock.call_args[1]["view"].id == view_id
|
||||
assert send_mock.call_args[1]["user"].id == user.id
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@patch("baserow.contrib.database.views.signals.views_reordered.send")
|
||||
def test_order_views(send_mock, data_fixture):
|
||||
|
@ -255,39 +448,8 @@ def test_delete_view(send_mock, data_fixture):
|
|||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_trashed_fields_are_not_included_in_grid_view_field_options(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_1 = data_fixture.create_text_field(table=table)
|
||||
field_2 = data_fixture.create_text_field(table=table)
|
||||
|
||||
ViewHandler().update_grid_view_field_options(
|
||||
user=user,
|
||||
grid_view=grid_view,
|
||||
field_options={str(field_1.id): {"width": 150}, field_2.id: {"width": 250}},
|
||||
)
|
||||
options = grid_view.get_field_options()
|
||||
assert options.count() == 2
|
||||
|
||||
TrashHandler.trash(user, table.database.group, table.database, field_1)
|
||||
|
||||
options = grid_view.get_field_options()
|
||||
assert options.count() == 1
|
||||
|
||||
with pytest.raises(UnrelatedFieldError):
|
||||
ViewHandler().update_grid_view_field_options(
|
||||
user=user,
|
||||
grid_view=grid_view,
|
||||
field_options={
|
||||
field_1.id: {"width": 150},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@patch("baserow.contrib.database.views.signals.grid_view_field_options_updated.send")
|
||||
def test_update_grid_view_field_options(send_mock, data_fixture):
|
||||
@patch("baserow.contrib.database.views.signals.view_field_options_updated.send")
|
||||
def test_update_field_options(send_mock, data_fixture):
|
||||
user = data_fixture.create_user()
|
||||
table = data_fixture.create_database_table(user=user)
|
||||
grid_view = data_fixture.create_grid_view(table=table)
|
||||
|
@ -296,50 +458,61 @@ def test_update_grid_view_field_options(send_mock, data_fixture):
|
|||
field_3 = data_fixture.create_text_field()
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
ViewHandler().update_grid_view_field_options(
|
||||
ViewHandler().update_field_options(
|
||||
user=user,
|
||||
grid_view=grid_view,
|
||||
view=grid_view,
|
||||
field_options={
|
||||
"strange_format": {"height": 150},
|
||||
},
|
||||
)
|
||||
|
||||
with pytest.raises(UserNotInGroup):
|
||||
ViewHandler().update_grid_view_field_options(
|
||||
ViewHandler().update_field_options(
|
||||
user=data_fixture.create_user(),
|
||||
grid_view=grid_view,
|
||||
view=grid_view,
|
||||
field_options={
|
||||
"strange_format": {"height": 150},
|
||||
},
|
||||
)
|
||||
|
||||
with pytest.raises(UnrelatedFieldError):
|
||||
ViewHandler().update_grid_view_field_options(
|
||||
ViewHandler().update_field_options(
|
||||
user=user,
|
||||
grid_view=grid_view,
|
||||
view=grid_view,
|
||||
field_options={
|
||||
99999: {"width": 150},
|
||||
},
|
||||
)
|
||||
|
||||
with pytest.raises(UnrelatedFieldError):
|
||||
ViewHandler().update_grid_view_field_options(
|
||||
ViewHandler().update_field_options(
|
||||
user=user,
|
||||
grid_view=grid_view,
|
||||
view=grid_view,
|
||||
field_options={
|
||||
field_3.id: {"width": 150},
|
||||
},
|
||||
)
|
||||
|
||||
ViewHandler().update_grid_view_field_options(
|
||||
with pytest.raises(ViewDoesNotSupportFieldOptions):
|
||||
ViewHandler().update_field_options(
|
||||
user=user,
|
||||
# The View object does not have the `field_options` field, so we expect
|
||||
# it to fail.
|
||||
view=View.objects.get(pk=grid_view.id),
|
||||
field_options={
|
||||
field_1.id: {"width": 150},
|
||||
},
|
||||
)
|
||||
|
||||
ViewHandler().update_field_options(
|
||||
user=user,
|
||||
grid_view=grid_view,
|
||||
view=grid_view,
|
||||
field_options={str(field_1.id): {"width": 150}, field_2.id: {"width": 250}},
|
||||
)
|
||||
options_4 = grid_view.get_field_options()
|
||||
|
||||
send_mock.assert_called_once()
|
||||
assert send_mock.call_args[1]["grid_view"].id == grid_view.id
|
||||
assert send_mock.call_args[1]["view"].id == grid_view.id
|
||||
assert send_mock.call_args[1]["user"].id == user.id
|
||||
assert len(options_4) == 2
|
||||
assert options_4[0].width == 150
|
||||
|
@ -348,9 +521,9 @@ def test_update_grid_view_field_options(send_mock, data_fixture):
|
|||
assert options_4[1].field_id == field_2.id
|
||||
|
||||
field_4 = data_fixture.create_text_field(table=table)
|
||||
ViewHandler().update_grid_view_field_options(
|
||||
ViewHandler().update_field_options(
|
||||
user=user,
|
||||
grid_view=grid_view,
|
||||
view=grid_view,
|
||||
field_options={field_2.id: {"width": 300}, field_4.id: {"width": 50}},
|
||||
)
|
||||
options_4 = grid_view.get_field_options()
|
||||
|
@ -363,6 +536,31 @@ def test_update_grid_view_field_options(send_mock, data_fixture):
|
|||
assert options_4[2].field_id == field_4.id
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_enable_form_view_file_field(data_fixture):
|
||||
user = data_fixture.create_user()
|
||||
table = data_fixture.create_database_table(user=user)
|
||||
form_view = data_fixture.create_form_view(table=table)
|
||||
file_field = data_fixture.create_file_field(table=table)
|
||||
|
||||
with pytest.raises(FormViewFieldTypeIsNotSupported):
|
||||
ViewHandler().update_field_options(
|
||||
user=user,
|
||||
view=form_view,
|
||||
field_options={
|
||||
file_field.id: {"enabled": True},
|
||||
},
|
||||
)
|
||||
|
||||
ViewHandler().update_field_options(
|
||||
user=user,
|
||||
view=form_view,
|
||||
field_options={
|
||||
file_field.id: {"enabled": False},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_field_type_changed(data_fixture):
|
||||
user = data_fixture.create_user()
|
||||
|
@ -1056,3 +1254,119 @@ def test_delete_sort(send_mock, data_fixture):
|
|||
|
||||
assert ViewSort.objects.all().count() == 1
|
||||
assert ViewSort.objects.filter(pk=sort_1.pk).count() == 0
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@patch("baserow.contrib.database.views.signals.view_updated.send")
|
||||
def test_rotate_form_view_slug(send_mock, data_fixture):
|
||||
user = data_fixture.create_user()
|
||||
user_2 = data_fixture.create_user()
|
||||
table = data_fixture.create_database_table(user=user)
|
||||
form = data_fixture.create_form_view(table=table)
|
||||
old_slug = str(form.slug)
|
||||
|
||||
handler = ViewHandler()
|
||||
|
||||
with pytest.raises(UserNotInGroup):
|
||||
handler.rotate_form_view_slug(user=user_2, form=form)
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
handler.rotate_form_view_slug(user=user, form=object())
|
||||
|
||||
handler.rotate_form_view_slug(user=user, form=form)
|
||||
|
||||
send_mock.assert_called_once()
|
||||
assert send_mock.call_args[1]["view"].id == form.id
|
||||
assert send_mock.call_args[1]["user"].id == user.id
|
||||
|
||||
form.refresh_from_db()
|
||||
assert str(form.slug) != old_slug
|
||||
assert len(str(form.slug)) == 43
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_get_public_form_view_by_slug(data_fixture):
|
||||
user = data_fixture.create_user()
|
||||
user_2 = data_fixture.create_user()
|
||||
form = data_fixture.create_form_view(user=user)
|
||||
|
||||
handler = ViewHandler()
|
||||
|
||||
with pytest.raises(ViewDoesNotExist):
|
||||
handler.get_public_form_view_by_slug(user_2, "not_existing")
|
||||
|
||||
with pytest.raises(ViewDoesNotExist):
|
||||
handler.get_public_form_view_by_slug(
|
||||
user_2, "a3f1493a-9229-4889-8531-6a65e745602e"
|
||||
)
|
||||
|
||||
with pytest.raises(ViewDoesNotExist):
|
||||
handler.get_public_form_view_by_slug(user_2, form.slug)
|
||||
|
||||
form2 = handler.get_public_form_view_by_slug(user, form.slug)
|
||||
assert form.id == form2.id
|
||||
|
||||
form.public = True
|
||||
form.save()
|
||||
|
||||
form2 = handler.get_public_form_view_by_slug(user_2, form.slug)
|
||||
assert form.id == form2.id
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@patch("baserow.contrib.database.rows.signals.row_created.send")
|
||||
def test_submit_form_view(send_mock, data_fixture):
|
||||
table = data_fixture.create_database_table()
|
||||
form = data_fixture.create_form_view(table=table)
|
||||
text_field = data_fixture.create_text_field(table=table)
|
||||
number_field = data_fixture.create_number_field(table=table)
|
||||
boolean_field = data_fixture.create_boolean_field(table=table)
|
||||
data_fixture.create_form_view_field_option(
|
||||
form, text_field, required=True, enabled=True
|
||||
)
|
||||
data_fixture.create_form_view_field_option(
|
||||
form, number_field, required=False, enabled=True
|
||||
)
|
||||
data_fixture.create_form_view_field_option(
|
||||
form, boolean_field, required=True, enabled=False
|
||||
)
|
||||
|
||||
handler = ViewHandler()
|
||||
|
||||
with pytest.raises(ValidationError) as e:
|
||||
handler.submit_form_view(form=form, values={})
|
||||
|
||||
with pytest.raises(ValidationError) as e:
|
||||
handler.submit_form_view(form=form, values={f"field_{number_field.id}": 0})
|
||||
|
||||
assert f"field_{text_field.id}" in e.value.error_dict
|
||||
|
||||
instance = handler.submit_form_view(
|
||||
form=form, values={f"field_{text_field.id}": "Text value"}
|
||||
)
|
||||
|
||||
send_mock.assert_called_once()
|
||||
assert send_mock.call_args[1]["row"].id == instance.id
|
||||
assert send_mock.call_args[1]["user"] is None
|
||||
assert send_mock.call_args[1]["table"].id == table.id
|
||||
assert send_mock.call_args[1]["before"] is None
|
||||
assert send_mock.call_args[1]["model"]._generated_table_model
|
||||
|
||||
handler.submit_form_view(
|
||||
form=form,
|
||||
values={
|
||||
f"field_{text_field.id}": "Another value",
|
||||
f"field_{number_field.id}": 10,
|
||||
f"field_{boolean_field.id}": True,
|
||||
},
|
||||
)
|
||||
|
||||
model = table.get_model()
|
||||
all = model.objects.all()
|
||||
assert len(all) == 2
|
||||
assert getattr(all[0], f"field_{text_field.id}") == "Text value"
|
||||
assert getattr(all[0], f"field_{number_field.id}") is None
|
||||
assert not getattr(all[0], f"field_{boolean_field.id}")
|
||||
assert getattr(all[1], f"field_{text_field.id}") == "Another value"
|
||||
assert getattr(all[1], f"field_{number_field.id}") == 10
|
||||
assert not getattr(all[1], f"field_{boolean_field.id}")
|
||||
|
|
|
@ -2,13 +2,14 @@ import pytest
|
|||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_grid_view_get_field_options(data_fixture):
|
||||
def test_view_get_field_options(data_fixture):
|
||||
table = data_fixture.create_database_table()
|
||||
table_2 = data_fixture.create_database_table()
|
||||
data_fixture.create_text_field(table=table_2)
|
||||
field_1 = data_fixture.create_text_field(table=table)
|
||||
field_2 = data_fixture.create_text_field(table=table)
|
||||
grid_view = data_fixture.create_grid_view(table=table)
|
||||
form_view = data_fixture.create_form_view(table=table)
|
||||
|
||||
field_options = grid_view.get_field_options()
|
||||
assert len(field_options) == 2
|
||||
|
@ -35,3 +36,26 @@ def test_grid_view_get_field_options(data_fixture):
|
|||
assert field_options[0].field_id == field_1.id
|
||||
assert field_options[1].field_id == field_2.id
|
||||
assert field_options[2].field_id == field_3.id
|
||||
|
||||
field_options = form_view.get_field_options(create_if_not_exists=False)
|
||||
assert len(field_options) == 2
|
||||
assert field_options[0].field_id == field_1.id
|
||||
assert field_options[0].name == ""
|
||||
assert field_options[0].description == ""
|
||||
assert field_options[0].field_id == field_1.id
|
||||
assert field_options[1].field_id == field_2.id
|
||||
|
||||
field_options = form_view.get_field_options(create_if_not_exists=True)
|
||||
assert len(field_options) == 3
|
||||
assert field_options[0].field_id == field_1.id
|
||||
assert field_options[0].field_id == field_1.id
|
||||
assert field_options[1].field_id == field_2.id
|
||||
assert field_options[2].field_id == field_3.id
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_rotate_form_view_slug(data_fixture):
|
||||
form_view = data_fixture.create_form_view()
|
||||
old_slug = str(form_view.slug)
|
||||
form_view.rotate_slug()
|
||||
assert str(form_view.slug) != old_slug
|
||||
|
|
|
@ -176,21 +176,21 @@ def test_view_sort_deleted(mock_broadcast_to_channel_group, data_fixture):
|
|||
|
||||
@pytest.mark.django_db(transaction=True)
|
||||
@patch("baserow.ws.registries.broadcast_to_channel_group")
|
||||
def test_grid_view_field_options_updated(mock_broadcast_to_channel_group, data_fixture):
|
||||
def test_view_field_options_updated(mock_broadcast_to_channel_group, data_fixture):
|
||||
user = data_fixture.create_user()
|
||||
table = data_fixture.create_database_table(user=user)
|
||||
text_field = data_fixture.create_text_field(table=table)
|
||||
grid_view = data_fixture.create_grid_view(table=table)
|
||||
|
||||
ViewHandler().update_grid_view_field_options(
|
||||
ViewHandler().update_field_options(
|
||||
user=user,
|
||||
grid_view=grid_view,
|
||||
view=grid_view,
|
||||
field_options={str(text_field.id): {"width": 150}},
|
||||
)
|
||||
|
||||
mock_broadcast_to_channel_group.delay.assert_called_once()
|
||||
args = mock_broadcast_to_channel_group.delay.call_args
|
||||
assert args[0][0] == f"table-{table.id}"
|
||||
assert args[0][1]["type"] == "grid_view_field_options_updated"
|
||||
assert args[0][1]["grid_view_id"] == grid_view.id
|
||||
assert args[0][1]["grid_view_field_options"][text_field.id]["width"] == 150
|
||||
assert args[0][1]["type"] == "view_field_options_updated"
|
||||
assert args[0][1]["view_id"] == grid_view.id
|
||||
assert args[0][1]["field_options"][text_field.id]["width"] == 150
|
||||
|
|
27
backend/tests/fixtures/view.py
vendored
27
backend/tests/fixtures/view.py
vendored
|
@ -2,6 +2,8 @@ from baserow.contrib.database.fields.models import Field
|
|||
from baserow.contrib.database.views.models import (
|
||||
GridView,
|
||||
GridViewFieldOptions,
|
||||
FormView,
|
||||
FormViewFieldOptions,
|
||||
ViewFilter,
|
||||
ViewSort,
|
||||
)
|
||||
|
@ -33,6 +35,31 @@ class ViewFixtures:
|
|||
grid_view=grid_view, field=field, **kwargs
|
||||
)
|
||||
|
||||
def create_form_view(self, user=None, **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
|
||||
|
||||
form_view = FormView.objects.create(**kwargs)
|
||||
self.create_form_view_field_options(form_view)
|
||||
return form_view
|
||||
|
||||
def create_form_view_field_options(self, form_view, **kwargs):
|
||||
return [
|
||||
self.create_form_view_field_option(form_view, field, **kwargs)
|
||||
for field in Field.objects.filter(table=form_view.table)
|
||||
]
|
||||
|
||||
def create_form_view_field_option(self, form_view, field, **kwargs):
|
||||
return FormViewFieldOptions.objects.create(
|
||||
form_view=form_view, field=field, **kwargs
|
||||
)
|
||||
|
||||
def create_view_filter(self, user=None, **kwargs):
|
||||
if "view" not in kwargs:
|
||||
kwargs["view"] = self.create_grid_view(user)
|
||||
|
|
|
@ -3,6 +3,9 @@
|
|||
## Unreleased
|
||||
|
||||
* Made it possible to list table field meta-data with a token.
|
||||
* Added form view.
|
||||
* The API endpoint to update the grid view field options has been moved to
|
||||
`/api/database/views/{view_id}/field-options/`.
|
||||
* The email field's validation is now consistent and much more permissive allowing most
|
||||
values which look like email addresses.
|
||||
* Add trash where deleted apps, groups, tables, fields and rows can be restored
|
||||
|
@ -13,7 +16,8 @@
|
|||
* Support building Baserow out of the box on Ubuntu by lowering the required docker
|
||||
version to build Baserow down to 19.03.
|
||||
* Disallow duplicate field names in the same table, blank field names or field names
|
||||
called 'order' and 'id'. Existing invalid field names will be fixed automatically.
|
||||
called 'order' and 'id'. Existing invalid field names will be fixed automatically.
|
||||
|
||||
|
||||
## Released (2021-06-02)
|
||||
|
||||
|
|
|
@ -149,4 +149,4 @@ are subscribed to the page.
|
|||
* `view_sort_created`
|
||||
* `view_sort_updated`
|
||||
* `view_sort_deleted`
|
||||
* `grid_view_field_options_updated`
|
||||
* `view_field_options_updated`
|
||||
|
|
|
@ -30,6 +30,7 @@
|
|||
@import 'views/grid/link_row';
|
||||
@import 'views/grid/file';
|
||||
@import 'views/grid/single_select';
|
||||
@import 'views/form';
|
||||
@import 'box_page';
|
||||
@import 'loading';
|
||||
@import 'notifications';
|
||||
|
|
|
@ -14,6 +14,22 @@
|
|||
margin-right: 4%;
|
||||
}
|
||||
}
|
||||
|
||||
&.choice-items--inline {
|
||||
width: auto;
|
||||
|
||||
li {
|
||||
flex: 0 0;
|
||||
|
||||
&:not(:nth-child(2n+2)) {
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
&:not(:last-child) {
|
||||
margin-right: 20px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.choice-items__link {
|
||||
|
|
|
@ -46,6 +46,20 @@
|
|||
}
|
||||
}
|
||||
|
||||
.select__items-loading {
|
||||
position: relative;
|
||||
height: 32px;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
margin-top: -7px;
|
||||
margin-left: -7px;
|
||||
|
||||
@include loading(14px);
|
||||
@include absolute(50%, auto, 0, 50%);
|
||||
}
|
||||
}
|
||||
|
||||
%select__item-size {
|
||||
@include fixed-height(32px, 14px);
|
||||
}
|
||||
|
|
625
web-frontend/modules/core/assets/scss/components/views/form.scss
Normal file
625
web-frontend/modules/core/assets/scss/components/views/form.scss
Normal file
|
@ -0,0 +1,625 @@
|
|||
.form-view__sidebar {
|
||||
@include absolute(0, auto, 0, 0);
|
||||
|
||||
width: 300px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.form-view__sidebar-fields {
|
||||
padding: 30px;
|
||||
}
|
||||
|
||||
.form-view__sidebar-fields-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.form-view__sidebar-fields-title {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.form-view__sidebar-fields-actions {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
|
||||
li {
|
||||
display: inline;
|
||||
|
||||
&:not(:last-child) {
|
||||
margin-right: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.form-view__sidebar-fields-description {
|
||||
font-size: 13px;
|
||||
color: $color-primary-900;
|
||||
margin: 18px 0;
|
||||
}
|
||||
|
||||
.form-view__sidebar-fields-list {
|
||||
position: relative;
|
||||
margin: 12px 0;
|
||||
}
|
||||
|
||||
.form-view__sidebar-fields-item-wrapper {
|
||||
padding: 6px 0;
|
||||
}
|
||||
|
||||
.form-view__sidebar-fields-item {
|
||||
position: relative;
|
||||
display: flex;
|
||||
border: solid 1px $color-neutral-300;
|
||||
border-radius: 3px;
|
||||
background-color: $white;
|
||||
padding: 0 30px 0 12px;
|
||||
|
||||
&::after {
|
||||
@extend .fas;
|
||||
|
||||
content: fa-content($fa-var-arrow-right);
|
||||
width: 24px;
|
||||
line-height: 14px;
|
||||
text-align: center;
|
||||
color: $color-primary-900;
|
||||
margin-top: -7px;
|
||||
display: none;
|
||||
|
||||
@include absolute(50%, 0, auto, auto);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
text-decoration: none;
|
||||
|
||||
&::after {
|
||||
@extend .fas;
|
||||
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
&.form-view__sidebar-fields-item--disabled,
|
||||
&.form-view__sidebar-fields-item--incompatible {
|
||||
cursor: inherit;
|
||||
|
||||
&::after {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
&.form-view__sidebar-fields-item--incompatible {
|
||||
cursor: inherit;
|
||||
background-color: $color-neutral-50;
|
||||
|
||||
&::after {
|
||||
display: block;
|
||||
content: fa-content($fa-var-exclamation-triangle);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.form-view__sidebar-fields-icon {
|
||||
flex: 0 0 20px;
|
||||
line-height: 36px;
|
||||
font-size: 13px;
|
||||
color: $color-neutral-700;
|
||||
}
|
||||
|
||||
.form-view__sidebar-fields-name {
|
||||
@extend %ellipsis;
|
||||
|
||||
width: 100%;
|
||||
line-height: 36px;
|
||||
font-size: 13px;
|
||||
color: $color-primary-900;
|
||||
}
|
||||
|
||||
.form-view__preview {
|
||||
@include absolute(0, 0, 0, 300px);
|
||||
|
||||
overflow: auto;
|
||||
padding: 30px 30px 0 30px;
|
||||
}
|
||||
|
||||
.form-view__page {
|
||||
min-height: 100%;
|
||||
background-color: $white;
|
||||
|
||||
&.form-view__page--rounded {
|
||||
overflow: hidden;
|
||||
border-top-left-radius: 12px;
|
||||
border-top-right-radius: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.form-view__file-container {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.form-view__file {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
line-height: 48px;
|
||||
padding: 0 40px;
|
||||
background-color: $color-neutral-100;
|
||||
border: 2px dashed $color-neutral-300;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
border-radius: 12px;
|
||||
color: $color-neutral-600;
|
||||
overflow: hidden;
|
||||
|
||||
&:hover {
|
||||
text-decoration: none;
|
||||
background-color: darken($color-neutral-100, 2%);
|
||||
}
|
||||
|
||||
* {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&.form-view__file--dragging,
|
||||
&.form-view__file--uploading {
|
||||
color: darken($color-neutral-100, 2%);
|
||||
background-color: darken($color-neutral-100, 2%);
|
||||
}
|
||||
|
||||
&.form-view__file--error {
|
||||
color: darken($color-neutral-100, 2%);
|
||||
border-color: $color-error-500;
|
||||
}
|
||||
}
|
||||
|
||||
.form-view__file-progress {
|
||||
@include absolute(auto, auto, 0, 0);
|
||||
|
||||
height: 6px;
|
||||
background-color: $color-primary-500;
|
||||
}
|
||||
|
||||
.form-view__file-dragging,
|
||||
.form-view__file-uploading,
|
||||
.form-view__file-error {
|
||||
@include absolute(50%, auto, auto, 50%);
|
||||
|
||||
display: none;
|
||||
width: 100px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
line-height: 14px;
|
||||
text-align: center;
|
||||
margin-left: -50px;
|
||||
margin-top: -6.5px;
|
||||
color: $color-primary-900;
|
||||
}
|
||||
|
||||
.form-view__file-dragging {
|
||||
.form-view__file--dragging & {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.form-view__file-uploading {
|
||||
.form-view__file--uploading & {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.form-view__file-error {
|
||||
color: $color-error-500;
|
||||
|
||||
.form-view__file--error & {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.form_view__file-delete {
|
||||
@include absolute(0, 0, 0, 0);
|
||||
|
||||
display: none;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background-color: rgba(0, 0, 0, 0.1);
|
||||
color: $color-error-500;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
|
||||
&:hover {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
:hover > & {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
i {
|
||||
margin-right: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
.form-view__file-floating-error {
|
||||
@include absolute(auto, auto, -40px, auto);
|
||||
}
|
||||
|
||||
.form-view__edit {
|
||||
font-size: 14px;
|
||||
line-height: 14px;
|
||||
color: $color-neutral-500;
|
||||
padding-left: 8px;
|
||||
|
||||
&:hover {
|
||||
color: $color-primary-900;
|
||||
}
|
||||
|
||||
&::after {
|
||||
@extend .fas;
|
||||
|
||||
content: fa-content($fa-var-pencil-alt);
|
||||
}
|
||||
|
||||
&.form-view__edit--hidden {
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
.form-view__cover {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 120px;
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
background-size: cover;
|
||||
background-color: $color-neutral-200;
|
||||
}
|
||||
|
||||
.form-view__body {
|
||||
padding: 20px;
|
||||
max-width: 100%;
|
||||
width: 680px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.form-view__heading {
|
||||
padding: 20px 20px 10px 20px;
|
||||
}
|
||||
|
||||
.form_view__logo {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.form_view__logo-img {
|
||||
display: block;
|
||||
max-width: 180px;
|
||||
}
|
||||
|
||||
.form-view__title {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
margin: 0 0 20px 0;
|
||||
}
|
||||
|
||||
.form-view__description {
|
||||
font-size: 16px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.form-view__no-fields {
|
||||
margin-top: 30px;
|
||||
padding: 30px;
|
||||
border: solid 1px $color-neutral-200;
|
||||
border-radius: 6px;
|
||||
font-size: 16px;
|
||||
color: $color-neutral-600;
|
||||
line-height: 160%;
|
||||
}
|
||||
|
||||
.form-view__fields {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.form-view__field-wrapper {
|
||||
padding: 7px 0;
|
||||
}
|
||||
|
||||
.form-view__field {
|
||||
position: relative;
|
||||
border-radius: 12px;
|
||||
|
||||
&.form-view__field--editable {
|
||||
border: 1px solid transparent;
|
||||
|
||||
&.form-view__field--selected {
|
||||
z-index: 2;
|
||||
border-color: $color-neutral-200;
|
||||
}
|
||||
|
||||
&:not(.form-view__field--selected) {
|
||||
&::before {
|
||||
content: "";
|
||||
z-index: 1;
|
||||
|
||||
@include absolute(0);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
cursor: pointer;
|
||||
background-color: $color-neutral-100;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.form-view__field-head {
|
||||
display: none;
|
||||
background-color: $color-neutral-100;
|
||||
line-height: 40px;
|
||||
padding: 0 20px;
|
||||
width: 100%;
|
||||
justify-content: left;
|
||||
border-top-left-radius: 13px;
|
||||
border-top-right-radius: 13px;
|
||||
|
||||
.form-view__field--selected & {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
|
||||
.form-view__field-head-handle {
|
||||
width: 12px;
|
||||
height: 20px;
|
||||
background-image: radial-gradient($color-neutral-200 40%, transparent 40%);
|
||||
background-size: 4px 4px;
|
||||
background-repeat: repeat;
|
||||
margin: 10px 10px 0 0;
|
||||
|
||||
&:hover {
|
||||
background-image: radial-gradient($color-neutral-500 40%, transparent 40%);
|
||||
}
|
||||
}
|
||||
|
||||
.form-view__field-head-icon {
|
||||
flex: 0 0 22px;
|
||||
font-size: 13px;
|
||||
color: $color-neutral-600;
|
||||
}
|
||||
|
||||
.form-view__field-head-name {
|
||||
@extend %ellipsis;
|
||||
|
||||
max-width: 100%;
|
||||
font-size: 13px;
|
||||
color: $color-primary-900;
|
||||
}
|
||||
|
||||
.form-view__field-head-options {
|
||||
margin: 0 12px 0 8px;
|
||||
color: $color-neutral-600;
|
||||
|
||||
&:hover {
|
||||
color: $color-primary-900;
|
||||
}
|
||||
}
|
||||
|
||||
.form-view__field-head-hide {
|
||||
margin-left: auto;
|
||||
color: $color-neutral-600;
|
||||
|
||||
&:hover {
|
||||
color: $color-primary-900;
|
||||
}
|
||||
}
|
||||
|
||||
.form-view__field-inner {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.form-view__field-name {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.form-view__field-description {
|
||||
font-size: 14px;
|
||||
line-height: 160%;
|
||||
margin-bottom: 20px;
|
||||
color: $color-neutral-500;
|
||||
}
|
||||
|
||||
.form-view-field-edit {
|
||||
display: none;
|
||||
|
||||
.form-view__field--selected & {
|
||||
display: inline;
|
||||
}
|
||||
}
|
||||
|
||||
.form-view__field-options {
|
||||
display: none;
|
||||
margin-top: 20px;
|
||||
|
||||
.form-view__field--selected & {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.form-view__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 20px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.form-view__powered-by {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.form-view__powered-by-logo {
|
||||
max-width: 92px;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.form-view__meta {
|
||||
border-top: solid 1px $color-neutral-200;
|
||||
}
|
||||
|
||||
.form-view__meta-email {
|
||||
padding-left: 36px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.form-view__meta-message-textarea {
|
||||
line-height: 160%;
|
||||
}
|
||||
|
||||
.form-view__meta-controls {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.view-form__create-link {
|
||||
display: block;
|
||||
line-height: 56px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
padding: 0 20px;
|
||||
color: $color-primary-900;
|
||||
border-radius: 6px;
|
||||
|
||||
&:hover {
|
||||
text-decoration: none;
|
||||
background-color: $color-neutral-100;
|
||||
}
|
||||
|
||||
&.view-form__create-link--disabled {
|
||||
color: $color-neutral-500;
|
||||
background-color: transparent;
|
||||
|
||||
&:hover {
|
||||
cursor: inherit;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.view-form__create-link-icon {
|
||||
margin-right: 20px;
|
||||
color: $color-warning-500;
|
||||
|
||||
.view-form__create-link--disabled & {
|
||||
color: $color-neutral-500;
|
||||
}
|
||||
}
|
||||
|
||||
.view-form__shared-link {
|
||||
padding: 25px 25px;
|
||||
}
|
||||
|
||||
.view-form__shared-link-title {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.view-form__shared-link-description {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.view-form__shared-link-content {
|
||||
display: flex;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.view-form__shared-link-box {
|
||||
border-radius: 3px;
|
||||
background-color: $color-neutral-100;
|
||||
line-height: 32px;
|
||||
padding: 0 8px;
|
||||
font-family: monospace;
|
||||
font-size: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.view-form__shared-link-action {
|
||||
position: relative;
|
||||
width: 32px;
|
||||
margin-left: 8px;
|
||||
line-height: 32px;
|
||||
color: $color-primary-900;
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
border-radius: 3px;
|
||||
|
||||
&:not(.view-form__shared-link-action--loading):hover {
|
||||
background-color: $color-neutral-100;
|
||||
}
|
||||
|
||||
&.view-form__shared-link-action--loading {
|
||||
color: $white;
|
||||
cursor: inherit;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
margin-top: -7px;
|
||||
margin-left: -7px;
|
||||
z-index: 1;
|
||||
|
||||
@include loading(14px);
|
||||
@include absolute(50%, auto, auto, 50%);
|
||||
}
|
||||
}
|
||||
|
||||
&.view-form__shared-link-action--disabled {
|
||||
color: $color-primary-900;
|
||||
background-color: transparent;
|
||||
|
||||
&:hover {
|
||||
cursor: inherit;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.view-form__shared-link-foot {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.view-form__shared-link-disable {
|
||||
color: $color-error-500;
|
||||
}
|
||||
|
||||
.form-view__submitted {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 40px 20px;
|
||||
width: 100%;
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.form-view__submitted-message {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
line-height: 160%;
|
||||
margin-bottom: 40px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.form-view__redirecting-description {
|
||||
font-size: 15px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.form-view__redirecting-loading {
|
||||
position: relative;
|
||||
height: 14px;
|
||||
margin-bottom: 20px;
|
||||
}
|
|
@ -17,10 +17,18 @@
|
|||
text-align: right;
|
||||
}
|
||||
|
||||
.background-white {
|
||||
background-color: $white;
|
||||
}
|
||||
|
||||
.color-primary {
|
||||
color: $color-primary-500 !important;
|
||||
}
|
||||
|
||||
.color-warning {
|
||||
color: $color-warning-500 !important;
|
||||
}
|
||||
|
||||
.margin-top-0 {
|
||||
margin-top: 0 !important;
|
||||
}
|
||||
|
@ -57,7 +65,7 @@
|
|||
margin-bottom: 30px !important;
|
||||
}
|
||||
|
||||
.margin-bottm-4 {
|
||||
.margin-bottom-4 {
|
||||
margin-bottom: 40px !important;
|
||||
}
|
||||
|
||||
|
|
|
@ -105,7 +105,7 @@ export default {
|
|||
const text = (event.originalEvent || event).clipboardData.getData(
|
||||
'text/plain'
|
||||
)
|
||||
document.execCommand('insertHTML', false, text)
|
||||
document.execCommand('insertText', false, text)
|
||||
},
|
||||
/**
|
||||
* If a key is pressed and it is an enter or esc key the change event will be called
|
||||
|
|
176
web-frontend/modules/core/components/PaginatedDropdown.vue
Normal file
176
web-frontend/modules/core/components/PaginatedDropdown.vue
Normal file
|
@ -0,0 +1,176 @@
|
|||
<template>
|
||||
<div
|
||||
class="dropdown"
|
||||
:class="{
|
||||
'dropdown--floating': !showInput,
|
||||
'dropdown--disabled': disabled,
|
||||
}"
|
||||
>
|
||||
<a v-if="showInput" class="dropdown__selected" @click="show()">
|
||||
<template v-if="displayName !== null">
|
||||
{{ displayName }}
|
||||
</template>
|
||||
<template v-else>Make a choice</template>
|
||||
<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"
|
||||
@input="search"
|
||||
/>
|
||||
</div>
|
||||
<ul
|
||||
ref="items"
|
||||
v-auto-overflow-scroll
|
||||
class="select__items"
|
||||
@scroll="scroll"
|
||||
>
|
||||
<DropdownItem :name="''" :value="null"></DropdownItem>
|
||||
<DropdownItem
|
||||
v-for="result in results"
|
||||
:key="result[idName]"
|
||||
:name="result[valueName]"
|
||||
:value="result[idName]"
|
||||
></DropdownItem>
|
||||
<div v-if="loading" class="select__items-loading"></div>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import debounce from 'lodash/debounce'
|
||||
|
||||
import dropdown from '@baserow/modules/core/mixins/dropdown'
|
||||
import { notifyIf } from '@baserow/modules/core/utils/error'
|
||||
|
||||
export default {
|
||||
name: 'Dropdown',
|
||||
mixins: [dropdown],
|
||||
props: {
|
||||
fetchPage: {
|
||||
type: Function,
|
||||
required: true,
|
||||
},
|
||||
// The attribute name that contains the identifier in the fetched results.
|
||||
idName: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: 'id',
|
||||
},
|
||||
// The attribute name that contains the display value in the fetched results.
|
||||
valueName: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: 'value',
|
||||
},
|
||||
fetchOnOpen: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
fetched: false,
|
||||
displayName: null,
|
||||
count: 0,
|
||||
page: 1,
|
||||
loading: false,
|
||||
results: [],
|
||||
}
|
||||
},
|
||||
/**
|
||||
* When the component is first created, we immediately fetch the first page.
|
||||
*/
|
||||
async created() {
|
||||
if (!this.fetchOnOpen) {
|
||||
this.fetched = true
|
||||
this.results = await this.fetch(this.page, this.query)
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
/**
|
||||
* Because the dropdown items could be destroyed in case of a search and because we
|
||||
* don't need reactivity, we store a copy of the name as display name as soon as it
|
||||
* has changed.
|
||||
*/
|
||||
select(value) {
|
||||
dropdown.methods.select.call(this, value)
|
||||
this.displayName = this.getSelectedProperty(value, 'name')
|
||||
},
|
||||
async fetch(page = 1, search = null) {
|
||||
this.page = page
|
||||
this.loading = true
|
||||
|
||||
try {
|
||||
const { data } = await this.fetchPage(page, search)
|
||||
this.count = data.count
|
||||
this.loading = false
|
||||
return data.results
|
||||
} catch (e) {
|
||||
this.loading = false
|
||||
notifyIf(e)
|
||||
return []
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Because the results change when you search, we need to reset the state before
|
||||
* searching. Otherwise there could be conflicting results.
|
||||
*/
|
||||
search() {
|
||||
this.results = []
|
||||
this.page = 1
|
||||
this.count = 0
|
||||
this.loading = true
|
||||
this._search()
|
||||
},
|
||||
/**
|
||||
* Small debounce when searching to prevent a lot of requests to the backend.
|
||||
*/
|
||||
_search: debounce(async function () {
|
||||
this.results = await this.fetch(this.page, this.query)
|
||||
}, 400),
|
||||
/**
|
||||
* When the user scrolls in the results, we can check if the user is near the end
|
||||
* and if so a new page will be loaded.
|
||||
*/
|
||||
async scroll() {
|
||||
const items = this.$refs.items
|
||||
const max = items.scrollHeight - items.clientHeight
|
||||
|
||||
if (
|
||||
!this.loading &&
|
||||
this.results.length < this.count &&
|
||||
items.scrollTop > max - 30
|
||||
) {
|
||||
this.results.push(...(await this.fetch(this.page + 1, this.query)))
|
||||
}
|
||||
},
|
||||
async show(...args) {
|
||||
dropdown.methods.show.call(this, ...args)
|
||||
if (!this.fetched) {
|
||||
this.fetched = true
|
||||
this.results = await this.fetch(this.page, this.query)
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Normally, when the dropdown hides, the search is reset, but in this case we
|
||||
* don't want to do that because otherwise results are refreshed everytime the
|
||||
* user closes dropdown.
|
||||
*/
|
||||
hide() {
|
||||
this.open = false
|
||||
this.$emit('hide')
|
||||
document.body.removeEventListener('click', this.$el.clickOutsideEvent)
|
||||
document.body.removeEventListener('keydown', this.$el.keydownEvent)
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
|
@ -20,7 +20,7 @@
|
|||
<GroupsContextItem
|
||||
v-for="group in searchAndSort(groups)"
|
||||
:key="group.id"
|
||||
v-sortable="{ id: group.id, update: order }"
|
||||
v-sortable="{ id: group.id, update: order, marginTop: -1.5 }"
|
||||
:group="group"
|
||||
@selected="hide"
|
||||
></GroupsContextItem>
|
||||
|
|
|
@ -156,6 +156,7 @@
|
|||
id: application.id,
|
||||
update: orderApplications,
|
||||
handle: '[data-sortable-handle]',
|
||||
marginTop: -1.5,
|
||||
}"
|
||||
:application="application"
|
||||
:group="selectedGroup"
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
import { findScrollableParent } from '@baserow/modules/core/utils/dom'
|
||||
|
||||
/**
|
||||
* This directive can by used to enable vertical drag and drop sorting of an array of
|
||||
* items. When the dragging starts, it simply shows a target position and will call a
|
||||
|
@ -32,6 +34,7 @@
|
|||
* ```
|
||||
*/
|
||||
let parent
|
||||
let scrollableParent
|
||||
let indicator
|
||||
|
||||
export default {
|
||||
|
@ -49,6 +52,10 @@ export default {
|
|||
: el
|
||||
|
||||
el.mousedownEvent = (event) => {
|
||||
if (!el.sortableEnabled) {
|
||||
return
|
||||
}
|
||||
|
||||
el.sortableMoved = false
|
||||
el.sortableStartClientX = event.clientX
|
||||
el.sortableStartClientY = event.clientY
|
||||
|
@ -68,6 +75,7 @@ export default {
|
|||
document.body.addEventListener('keydown', el.keydownEvent)
|
||||
|
||||
parent = el.parentNode
|
||||
scrollableParent = findScrollableParent(parent) || parent
|
||||
indicator = document.createElement('div')
|
||||
indicator.classList.add('sortable-position-indicator')
|
||||
parent.insertBefore(indicator, parent.firstChild)
|
||||
|
@ -90,6 +98,8 @@ export default {
|
|||
},
|
||||
update(el, binding) {
|
||||
el.sortableId = binding.value.id
|
||||
el.sortableEnabled =
|
||||
binding.value.enabled || binding.value.enabled === undefined
|
||||
el.sortableMarginLeft = binding.value.marginLeft
|
||||
el.sortableMarginRight = binding.value.marginRight
|
||||
el.sortableMarginTop = binding.value.marginTop
|
||||
|
@ -161,7 +171,7 @@ export default {
|
|||
const afterRect = all[all.length - 1].getBoundingClientRect()
|
||||
const top =
|
||||
(before
|
||||
? beforeRect.top - indicator.clientHeight
|
||||
? beforeRect.top - indicator.clientHeight / 2
|
||||
: afterRect.top + afterRect.height) -
|
||||
parentRect.top +
|
||||
parent.scrollTop +
|
||||
|
@ -179,12 +189,14 @@ export default {
|
|||
// moving the element close to the end of the view port at the top or bottom
|
||||
// side, we might need to initiate that process.
|
||||
if (
|
||||
parent.scrollHeight > parent.clientHeight &&
|
||||
scrollableParent.scrollHeight > scrollableParent.clientHeight &&
|
||||
(!el.sortableAutoScrolling || !startAutoScroll)
|
||||
) {
|
||||
const parentHeight = parentRect.bottom - parentRect.top
|
||||
const scrollableParentRect = scrollableParent.getBoundingClientRect()
|
||||
const parentHeight =
|
||||
scrollableParentRect.bottom - scrollableParentRect.top
|
||||
const side = Math.ceil((parentHeight / 100) * 10)
|
||||
const autoScrollMouseTop = event.clientY - parentRect.top
|
||||
const autoScrollMouseTop = event.clientY - scrollableParentRect.top
|
||||
const autoScrollMouseBottom = parentHeight - autoScrollMouseTop
|
||||
let speed = 0
|
||||
|
||||
|
@ -194,11 +206,11 @@ export default {
|
|||
speed = 3 - Math.ceil((Math.max(0, autoScrollMouseBottom) / side) * 3)
|
||||
}
|
||||
|
||||
// If the speed is either a position or negative, so not 0, we know that we
|
||||
// If the speed is either a positive or negative, so not 0, we know that we
|
||||
// need to start auto scrolling.
|
||||
if (speed !== 0) {
|
||||
el.sortableAutoScrolling = true
|
||||
parent.scrollTop += speed
|
||||
scrollableParent.scrollTop += speed
|
||||
el.sortableScrollTimeout = setTimeout(() => {
|
||||
binding.def.move(el, binding, null, false)
|
||||
}, 10)
|
||||
|
|
143
web-frontend/modules/core/directives/userFileUpload.js
Normal file
143
web-frontend/modules/core/directives/userFileUpload.js
Normal file
|
@ -0,0 +1,143 @@
|
|||
import UserFileService from '@baserow/modules/core/services/userFile'
|
||||
|
||||
/**
|
||||
* This directive can be used to enable drag and drop (or click) user file upload.
|
||||
* The selected files will be uploaded as a user file and the response, containing
|
||||
* the file name, url, thumbnails etc will be communicated via the `done` function.
|
||||
* The dragging and uploading state is communicated by calling the provided functions.
|
||||
* It is possible for users to drag and drop a file inside the element. Clicking on the
|
||||
* element opens a file picker.
|
||||
*
|
||||
* Example:
|
||||
* <div
|
||||
* v-user-file-upload="{
|
||||
* check: (file) => {
|
||||
* // Check if the file is accepted.
|
||||
* return true
|
||||
* },
|
||||
* enter: () => {
|
||||
* // Entered dragging state.
|
||||
* },
|
||||
* leave: () => {
|
||||
* // Leaving dragging state.
|
||||
* },
|
||||
* progress: (event) => {
|
||||
* // Upload progress changes.
|
||||
* const percentage = Math.round((event.loaded * 100) / event.total)
|
||||
* console.log(percentage)
|
||||
* },
|
||||
* done: (file) => {
|
||||
* // The file has been uploaded.
|
||||
* console.log(file)
|
||||
* },
|
||||
* }"
|
||||
* >
|
||||
* Drop here
|
||||
* </div>
|
||||
*/
|
||||
export default {
|
||||
/**
|
||||
* Registers all the needed drop, drag and click events needed.
|
||||
*/
|
||||
bind(el, binding, vnode) {
|
||||
binding.def.update(el, binding)
|
||||
|
||||
// The input that is needed to open the file chooser.
|
||||
el.userFileUploadElement = document.createElement('input')
|
||||
el.userFileUploadElement.type = 'file'
|
||||
el.userFileUploadElement.style.display = 'none'
|
||||
el.userFileUploadElement.addEventListener('change', (event) => {
|
||||
binding.def.upload(el, event, vnode.context.$client)
|
||||
})
|
||||
|
||||
// The counter that is used to calculate if the user is dragging a file over the
|
||||
// drop zone. It could be that enter and leave event are going are called
|
||||
// multiple times without reentering the drop zone.
|
||||
el.userFileUploadDragCounter = 0
|
||||
|
||||
el.userFileUploadDrop = (event) => {
|
||||
event.preventDefault()
|
||||
binding.def.upload(el, event, vnode.context.$client)
|
||||
}
|
||||
el.addEventListener('drop', el.userFileUploadDrop)
|
||||
|
||||
el.userFileUploadDragOver = (event) => {
|
||||
event.preventDefault()
|
||||
}
|
||||
el.addEventListener('dragover', el.userFileUploadDragOver)
|
||||
|
||||
el.userFileUploadDragEnter = (event) => {
|
||||
event.preventDefault()
|
||||
if (el.userFileUploadDragCounter === 0) {
|
||||
el.userFileUploadValue.enter()
|
||||
}
|
||||
el.userFileUploadDragCounter++
|
||||
}
|
||||
el.addEventListener('dragenter', el.userFileUploadDragEnter)
|
||||
|
||||
el.userFileUploadDragLeave = () => {
|
||||
el.userFileUploadDragCounter--
|
||||
if (el.userFileUploadDragCounter === 0) {
|
||||
el.userFileUploadValue.leave()
|
||||
}
|
||||
}
|
||||
el.addEventListener('dragleave', el.userFileUploadDragLeave)
|
||||
|
||||
el.userFileUploadClick = (event) => {
|
||||
event.preventDefault()
|
||||
el.userFileUploadElement.click(event)
|
||||
}
|
||||
el.addEventListener('click', el.userFileUploadClick)
|
||||
},
|
||||
/**
|
||||
* Removes all the events registered in the bind function.
|
||||
*/
|
||||
unbind(el) {
|
||||
el.removeEventListener('drop', el.userFileUploadDrop)
|
||||
el.removeEventListener('dragover', el.userFileUploadDragOver)
|
||||
el.removeEventListener('dragenter', el.userFileUploadDragEnter)
|
||||
el.removeEventListener('dragleave', el.userFileUploadDragLeave)
|
||||
el.removeEventListener('click', el.userFileUploadClick)
|
||||
},
|
||||
update(el, binding) {
|
||||
const defaults = {
|
||||
check() {
|
||||
return true
|
||||
},
|
||||
enter() {},
|
||||
leave() {},
|
||||
progress() {},
|
||||
done() {},
|
||||
error() {},
|
||||
}
|
||||
el.userFileUploadValue = Object.assign(defaults, binding.value)
|
||||
},
|
||||
/**
|
||||
* Called when a file must be uploaded. The progress will be shared by calling the
|
||||
* provided functions.
|
||||
*/
|
||||
async upload(el, event, client) {
|
||||
const files = event.target.files
|
||||
? event.target.files
|
||||
: event.dataTransfer.files
|
||||
|
||||
if (
|
||||
files === null ||
|
||||
files.length === 0 ||
|
||||
!el.userFileUploadValue.check(files[0])
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const { data } = await UserFileService(client).uploadFile(
|
||||
files[0],
|
||||
el.userFileUploadValue.progress
|
||||
)
|
||||
|
||||
el.userFileUploadValue.done(data)
|
||||
} catch (e) {
|
||||
el.userFileUploadValue.error(e)
|
||||
}
|
||||
},
|
||||
}
|
|
@ -149,7 +149,9 @@ export default {
|
|||
document.body.addEventListener('keydown', this.$el.keydownEvent)
|
||||
},
|
||||
/**
|
||||
* Hides the list of choices
|
||||
* Hides the list of choices. If something change in this method, you might need
|
||||
* to update the hide method of the `PaginatedDropdown` component because it
|
||||
* contains a partial copy of this code.
|
||||
*/
|
||||
hide() {
|
||||
this.open = false
|
||||
|
|
|
@ -26,6 +26,7 @@ import preventParentScroll from '@baserow/modules/core/directives/preventParentS
|
|||
import tooltip from '@baserow/modules/core/directives/tooltip'
|
||||
import sortable from '@baserow/modules/core/directives/sortable'
|
||||
import autoOverflowScroll from '@baserow/modules/core/directives/autoOverflowScroll'
|
||||
import userFileUpload from '@baserow/modules/core/directives/userFileUpload'
|
||||
|
||||
Vue.component('Context', Context)
|
||||
Vue.component('Modal', Modal)
|
||||
|
@ -48,3 +49,4 @@ Vue.directive('preventParentScroll', preventParentScroll)
|
|||
Vue.directive('tooltip', tooltip)
|
||||
Vue.directive('sortable', sortable)
|
||||
Vue.directive('autoOverflowScroll', autoOverflowScroll)
|
||||
Vue.directive('userFileUpload', userFileUpload)
|
||||
|
|
|
@ -40,3 +40,18 @@ export const focusEnd = (element) => {
|
|||
selection.addRange(range)
|
||||
element.focus()
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the closest scrollable parent element of the provided element.
|
||||
*/
|
||||
export const findScrollableParent = (element) => {
|
||||
if (element == null) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (element.scrollHeight > element.clientHeight) {
|
||||
return element
|
||||
} else {
|
||||
return findScrollableParent(element.parentNode)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -7,6 +7,9 @@
|
|||
>
|
||||
<i class="fas fa-check check"></i>
|
||||
</div>
|
||||
<div v-show="touched && !valid" class="error">
|
||||
{{ error }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
@ -24,6 +27,7 @@ export default {
|
|||
const oldValue = !!value
|
||||
const newValue = !value
|
||||
this.$emit('update', newValue, oldValue)
|
||||
this.touch()
|
||||
},
|
||||
},
|
||||
}
|
||||
|
|
|
@ -7,6 +7,7 @@
|
|||
v-model="date"
|
||||
type="text"
|
||||
class="input input--large"
|
||||
:class="{ 'input--error': touched && !valid }"
|
||||
:placeholder="getDatePlaceholder(field)"
|
||||
:disabled="readOnly"
|
||||
@keyup.enter="$refs.date.blur()"
|
||||
|
@ -42,6 +43,7 @@
|
|||
v-model="time"
|
||||
type="text"
|
||||
class="input input--large"
|
||||
:class="{ 'input--error': touched && !valid }"
|
||||
:placeholder="getTimePlaceholder(field)"
|
||||
:disabled="readOnly"
|
||||
@keyup.enter="$refs.time.blur()"
|
||||
|
@ -60,6 +62,9 @@
|
|||
></TimeSelectContext>
|
||||
</div>
|
||||
</div>
|
||||
<div v-show="touched && !valid" class="error">
|
||||
{{ error }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
|
|
@ -5,14 +5,14 @@
|
|||
v-model="copy"
|
||||
type="text"
|
||||
class="input input--large"
|
||||
:class="{ 'input--error': !isValid() }"
|
||||
:class="{ 'input--error': touched && !valid }"
|
||||
:disabled="readOnly"
|
||||
@keyup.enter="$refs.input.blur()"
|
||||
@focus="select()"
|
||||
@blur="unselect()"
|
||||
/>
|
||||
<div v-show="!isValid()" class="error">
|
||||
{{ getError() }}
|
||||
<div v-show="touched && !valid" class="error">
|
||||
{{ error }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
@ -20,9 +20,8 @@
|
|||
<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'
|
||||
|
||||
export default {
|
||||
mixins: [rowEditField, rowEditFieldInput, emailField],
|
||||
mixins: [rowEditField, rowEditFieldInput],
|
||||
}
|
||||
</script>
|
||||
|
|
|
@ -60,6 +60,9 @@
|
|||
<i class="fas fa-plus add__icon"></i>
|
||||
Add a file
|
||||
</a>
|
||||
<div v-show="touched && !valid" class="error">
|
||||
{{ error }}
|
||||
</div>
|
||||
<UserFilesModal
|
||||
v-if="!readOnly"
|
||||
ref="uploadModal"
|
||||
|
@ -95,6 +98,18 @@ export default {
|
|||
getDate(value) {
|
||||
return moment.utc(value).format('MMM Do YYYY [at] H:mm')
|
||||
},
|
||||
removeFile(...args) {
|
||||
fileField.methods.removeFile.call(this, ...args)
|
||||
this.touch()
|
||||
},
|
||||
addFiles(...args) {
|
||||
fileField.methods.addFiles.call(this, ...args)
|
||||
this.touch()
|
||||
},
|
||||
renameFile(...args) {
|
||||
fileField.methods.renameFile.call(this, ...args)
|
||||
this.touch()
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
|
|
@ -24,6 +24,9 @@
|
|||
<i class="fas fa-plus add__icon"></i>
|
||||
Add another link
|
||||
</a>
|
||||
<div v-show="touched && !valid" class="error">
|
||||
{{ error }}
|
||||
</div>
|
||||
<SelectRowModal
|
||||
v-if="!readOnly"
|
||||
ref="selectModal"
|
||||
|
@ -42,5 +45,15 @@ import SelectRowModal from '@baserow/modules/database/components/row/SelectRowMo
|
|||
export default {
|
||||
components: { SelectRowModal },
|
||||
mixins: [rowEditField, linkRowField],
|
||||
methods: {
|
||||
removeValue(...args) {
|
||||
linkRowField.methods.removeValue.call(this, ...args)
|
||||
this.touch()
|
||||
},
|
||||
addValue(...args) {
|
||||
linkRowField.methods.addValue.call(this, ...args)
|
||||
this.touch()
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
|
|
@ -5,10 +5,14 @@
|
|||
v-model="copy"
|
||||
type="text"
|
||||
class="input input--large field-long-text"
|
||||
:class="{ 'input--error': touched && !valid }"
|
||||
:disabled="readOnly"
|
||||
@focus="select()"
|
||||
@blur="unselect()"
|
||||
/>
|
||||
<div v-show="touched && !valid" class="error">
|
||||
{{ error }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
|
|
@ -5,14 +5,14 @@
|
|||
v-model="copy"
|
||||
type="text"
|
||||
class="input input--large field-number"
|
||||
:class="{ 'input--error': !isValid() }"
|
||||
:class="{ 'input--error': touched && !valid }"
|
||||
:disabled="readOnly"
|
||||
@keyup.enter="$refs.input.blur()"
|
||||
@focus="select()"
|
||||
@blur="unselect()"
|
||||
/>
|
||||
<div v-show="!isValid()" class="error">
|
||||
{{ getError() }}
|
||||
<div v-show="touched && !valid" class="error">
|
||||
{{ error }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
@ -5,14 +5,14 @@
|
|||
v-model="copy"
|
||||
type="tel"
|
||||
class="input input--large"
|
||||
:class="{ 'input--error': !isValid() }"
|
||||
:class="{ 'input--error': touched && !valid }"
|
||||
:disabled="readOnly"
|
||||
@keyup.enter="$refs.input.blur()"
|
||||
@focus="select()"
|
||||
@blur="unselect()"
|
||||
/>
|
||||
<div v-show="!isValid()" class="error">
|
||||
{{ getError() }}
|
||||
<div v-show="touched && !valid" class="error">
|
||||
{{ error }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
@ -20,9 +20,8 @@
|
|||
<script>
|
||||
import rowEditField from '@baserow/modules/database/mixins/rowEditField'
|
||||
import rowEditFieldInput from '@baserow/modules/database/mixins/rowEditFieldInput'
|
||||
import phoneNumberField from '@baserow/modules/database/mixins/phoneNumberField'
|
||||
|
||||
export default {
|
||||
mixins: [rowEditField, rowEditFieldInput, phoneNumberField],
|
||||
mixins: [rowEditField, rowEditFieldInput],
|
||||
}
|
||||
</script>
|
||||
|
|
|
@ -5,9 +5,14 @@
|
|||
:options="field.select_options"
|
||||
:allow-create-option="true"
|
||||
:disabled="readOnly"
|
||||
:class="{ 'dropdown--error': touched && !valid }"
|
||||
@input="updateValue($event, value)"
|
||||
@create-option="createOption($event)"
|
||||
@hide="touch()"
|
||||
></FieldSingleSelectDropdown>
|
||||
<div v-show="touched && !valid" class="error">
|
||||
{{ error }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
@ -18,5 +23,10 @@ import singleSelectField from '@baserow/modules/database/mixins/singleSelectFiel
|
|||
export default {
|
||||
name: 'RowEditFieldSingleSelectVue',
|
||||
mixins: [rowEditField, singleSelectField],
|
||||
methods: {
|
||||
updateValue(...args) {
|
||||
singleSelectField.methods.updateValue.call(this, ...args)
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
|
|
@ -5,11 +5,15 @@
|
|||
v-model="copy"
|
||||
type="text"
|
||||
class="input input--large"
|
||||
:class="{ 'input--error': touched && !valid }"
|
||||
:disabled="readOnly"
|
||||
@keyup.enter="$refs.input.blur()"
|
||||
@focus="select()"
|
||||
@blur="unselect()"
|
||||
/>
|
||||
<div v-show="touched && !valid" class="error">
|
||||
{{ error }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
|
|
@ -5,14 +5,14 @@
|
|||
v-model="copy"
|
||||
type="text"
|
||||
class="input input--large"
|
||||
:class="{ 'input--error': !isValid() }"
|
||||
:class="{ 'input--error': touched && !valid }"
|
||||
:disabled="readOnly"
|
||||
@keyup.enter="$refs.input.blur()"
|
||||
@focus="select()"
|
||||
@blur="unselect()"
|
||||
/>
|
||||
<div v-show="!isValid()" class="error">
|
||||
{{ getError() }}
|
||||
<div v-show="touched && !valid" class="error">
|
||||
{{ error }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
@ -20,9 +20,8 @@
|
|||
<script>
|
||||
import rowEditField from '@baserow/modules/database/mixins/rowEditField'
|
||||
import rowEditFieldInput from '@baserow/modules/database/mixins/rowEditFieldInput'
|
||||
import URLField from '@baserow/modules/database/mixins/URLField'
|
||||
|
||||
export default {
|
||||
mixins: [rowEditField, rowEditFieldInput, URLField],
|
||||
mixins: [rowEditField, rowEditFieldInput],
|
||||
}
|
||||
</script>
|
||||
|
|
|
@ -29,6 +29,7 @@
|
|||
update: orderTables,
|
||||
marginLeft: 34,
|
||||
marginRight: 10,
|
||||
marginTop: -1.5,
|
||||
}"
|
||||
:database="application"
|
||||
:table="table"
|
||||
|
|
|
@ -22,8 +22,8 @@
|
|||
>
|
||||
<template v-if="hasSelectedView">
|
||||
<i
|
||||
class="header__filter-icon header-filter-icon--view fas"
|
||||
:class="'fa-' + view._.type.iconClass"
|
||||
class="header__filter-icon header-filter-icon--view fas fa-fw"
|
||||
:class="view._.type.colorClass + ' fa-' + view._.type.iconClass"
|
||||
></i>
|
||||
<span class="header__filter-name header__filter-name--forced">
|
||||
<EditableViewName ref="rename" :view="view"></EditableViewName>
|
||||
|
|
|
@ -20,7 +20,7 @@
|
|||
<ViewsContextItem
|
||||
v-for="view in searchAndOrder(views)"
|
||||
:key="view.id"
|
||||
v-sortable="{ id: view.id, update: order }"
|
||||
v-sortable="{ id: view.id, update: order, marginTop: -1.5 }"
|
||||
:view="view"
|
||||
:table="table"
|
||||
:read-only="readOnly"
|
||||
|
|
|
@ -10,8 +10,8 @@
|
|||
<a class="select__item-link" @click="$emit('selected', view)">
|
||||
<div class="select__item-name">
|
||||
<i
|
||||
class="select__item-icon fas fa-fw color-primary"
|
||||
:class="'fa-' + view._.type.iconClass"
|
||||
class="select__item-icon fas fa-fw"
|
||||
:class="view._.type.colorClass + ' fa-' + view._.type.iconClass"
|
||||
></i>
|
||||
<EditableViewName ref="rename" :view="view"></EditableViewName>
|
||||
</div>
|
||||
|
|
|
@ -0,0 +1,62 @@
|
|||
<template>
|
||||
<div class="form-view__field-wrapper">
|
||||
<div class="form-view__field">
|
||||
<div class="form-view__field-inner">
|
||||
<div class="form-view__field-name">
|
||||
{{ field.name }}
|
||||
</div>
|
||||
<div class="form-view__field-description">
|
||||
{{ field.description }}
|
||||
</div>
|
||||
<component
|
||||
:is="getFieldComponent()"
|
||||
ref="field"
|
||||
:slug="slug"
|
||||
:field="field.field"
|
||||
:value="value"
|
||||
:read-only="false"
|
||||
:required="field.required"
|
||||
:touched="field._.touched"
|
||||
@update="$emit('input', $event)"
|
||||
@touched="field._.touched = true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import FieldContext from '@baserow/modules/database/components/field/FieldContext'
|
||||
|
||||
export default {
|
||||
name: 'FormPageField',
|
||||
components: { FieldContext },
|
||||
props: {
|
||||
slug: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
value: {
|
||||
required: true,
|
||||
validator: () => true,
|
||||
},
|
||||
field: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
getFieldComponent() {
|
||||
return this.$registry
|
||||
.get('field', this.field.field.type)
|
||||
.getFormViewFieldComponent()
|
||||
},
|
||||
focus() {
|
||||
this.$el.scrollIntoView({ behavior: 'smooth' })
|
||||
},
|
||||
isValid() {
|
||||
return this.$refs.field.isValid()
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
133
web-frontend/modules/database/components/view/form/FormView.vue
Normal file
133
web-frontend/modules/database/components/view/form/FormView.vue
Normal file
|
@ -0,0 +1,133 @@
|
|||
<template>
|
||||
<div class="form-view">
|
||||
<FormViewSidebar
|
||||
:table="table"
|
||||
:view="view"
|
||||
:fields="disabledFields"
|
||||
:enabled-fields="enabledFields"
|
||||
:read-only="readOnly"
|
||||
:store-prefix="storePrefix"
|
||||
@ordered-fields="orderFields"
|
||||
></FormViewSidebar>
|
||||
<FormViewPreview
|
||||
:table="table"
|
||||
:view="view"
|
||||
:fields="enabledFields"
|
||||
:read-only="readOnly"
|
||||
:store-prefix="storePrefix"
|
||||
@ordered-fields="orderFields"
|
||||
></FormViewPreview>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { maxPossibleOrderValue } from '@baserow/modules/database/viewTypes'
|
||||
import formViewHelpers from '@baserow/modules/database/mixins/formViewHelpers'
|
||||
import FormViewSidebar from '@baserow/modules/database/components/view/form/FormViewSidebar'
|
||||
import FormViewPreview from '@baserow/modules/database/components/view/form/FormViewPreview'
|
||||
import { notifyIf } from '@baserow/modules/core/utils/error'
|
||||
|
||||
export default {
|
||||
name: 'FormView',
|
||||
components: { FormViewSidebar, FormViewPreview },
|
||||
mixins: [formViewHelpers],
|
||||
props: {
|
||||
primary: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
fields: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
view: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
table: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
database: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
readOnly: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
sortedFields() {
|
||||
const fields = this.fields.slice()
|
||||
fields.unshift(this.primary)
|
||||
return fields.sort((a, b) => {
|
||||
const orderA = this.getFieldOption(a.id, 'order', maxPossibleOrderValue)
|
||||
const orderB = this.getFieldOption(b.id, 'order', maxPossibleOrderValue)
|
||||
|
||||
// First by order.
|
||||
if (orderA > orderB) {
|
||||
return 1
|
||||
} else if (orderA < orderB) {
|
||||
return -1
|
||||
}
|
||||
|
||||
// Then by id.
|
||||
if (a.id < b.id) {
|
||||
return -1
|
||||
} else if (a.id > b.id) {
|
||||
return 1
|
||||
} else {
|
||||
return 0
|
||||
}
|
||||
})
|
||||
},
|
||||
disabledFields() {
|
||||
return this.sortedFields.filter((field) => {
|
||||
return !this.getFieldOption(field.id, 'enabled', false)
|
||||
})
|
||||
},
|
||||
enabledFields() {
|
||||
return this.sortedFields.filter((field) => {
|
||||
return this.getFieldOption(field.id, 'enabled', false)
|
||||
})
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
getFieldOption(fieldId, value, fallback) {
|
||||
return this.fieldOptions[fieldId]
|
||||
? this.fieldOptions[fieldId][value]
|
||||
: fallback
|
||||
},
|
||||
async orderFields(order) {
|
||||
// If the fields are ordered in the preview, then the disabled fields are missing
|
||||
// from the order. Because we want to preserve those order, they need to be added
|
||||
// to the end of the order to the order.
|
||||
this.disabledFields.forEach((field) => {
|
||||
if (!order.includes(field.id)) {
|
||||
order.push(field.id)
|
||||
}
|
||||
})
|
||||
|
||||
// Same goes for the enabled fields. If the disabled fields are ordered, then we
|
||||
// want the enabled fields to keep the right order.
|
||||
const prepend = []
|
||||
this.enabledFields.forEach((field) => {
|
||||
if (!order.includes(field.id)) {
|
||||
prepend.push(field.id)
|
||||
}
|
||||
})
|
||||
order.unshift(...prepend)
|
||||
|
||||
try {
|
||||
await this.$store.dispatch(
|
||||
this.storePrefix + 'view/form/updateFieldOptionsOrder',
|
||||
{ form: this.view, order }
|
||||
)
|
||||
} catch (error) {
|
||||
notifyIf(error, 'view')
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
|
@ -0,0 +1,162 @@
|
|||
<template>
|
||||
<div class="form-view__field-wrapper">
|
||||
<div
|
||||
class="form-view__field form-view__field--editable"
|
||||
:class="{ 'form-view__field--selected': selected }"
|
||||
@click="select()"
|
||||
>
|
||||
<div class="form-view__field-head">
|
||||
<a
|
||||
v-show="!readOnly"
|
||||
class="form-view__field-head-handle"
|
||||
data-field-handle
|
||||
></a>
|
||||
<div class="form-view__field-head-icon">
|
||||
<i class="fas fa-fw" :class="'fa-' + field._.type.iconClass"></i>
|
||||
</div>
|
||||
<div class="form-view__field-head-name">{{ field.name }}</div>
|
||||
<a
|
||||
v-if="!readOnly"
|
||||
v-tooltip="'Remove field'"
|
||||
class="form-view__field-head-hide"
|
||||
@click="$emit('hide', field)"
|
||||
>
|
||||
<i class="fas fa-eye-slash"></i>
|
||||
</a>
|
||||
</div>
|
||||
<div class="form-view__field-inner">
|
||||
<div class="form-view__field-name">
|
||||
<Editable
|
||||
ref="name"
|
||||
:value="fieldOptions.name || field.name"
|
||||
@change="$emit('updated-field-options', { name: $event.value })"
|
||||
@editing="editingName = $event"
|
||||
></Editable>
|
||||
<a
|
||||
v-if="!readOnly"
|
||||
class="form-view__edit form-view-field-edit"
|
||||
:class="{ 'form-view__edit--hidden': editingName }"
|
||||
@click="$refs.name.edit()"
|
||||
></a>
|
||||
</div>
|
||||
<div class="form-view__field-description">
|
||||
<Editable
|
||||
ref="description"
|
||||
:value="fieldOptions.description"
|
||||
@change="
|
||||
$emit('updated-field-options', { description: $event.value })
|
||||
"
|
||||
@editing="editingDescription = $event"
|
||||
></Editable>
|
||||
<a
|
||||
v-if="!readOnly"
|
||||
class="form-view__edit form-view-field-edit"
|
||||
:class="{ 'form-view__edit--hidden': editingDescription }"
|
||||
@click="$refs.description.edit()"
|
||||
></a>
|
||||
</div>
|
||||
<component
|
||||
:is="getFieldComponent()"
|
||||
ref="field"
|
||||
:slug="view.slug"
|
||||
:field="field"
|
||||
:value="value"
|
||||
:read-only="readOnly"
|
||||
:lazy-load="true"
|
||||
@update="updateValue"
|
||||
/>
|
||||
<div class="form-view__field-options">
|
||||
<SwitchInput
|
||||
:value="fieldOptions.required"
|
||||
:large="true"
|
||||
:disabled="readOnly"
|
||||
@input="$emit('updated-field-options', { required: $event })"
|
||||
>required</SwitchInput
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { isElement } from '@baserow/modules/core/utils/dom'
|
||||
import FieldContext from '@baserow/modules/database/components/field/FieldContext'
|
||||
|
||||
export default {
|
||||
name: 'FormViewField',
|
||||
components: { FieldContext },
|
||||
props: {
|
||||
table: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
view: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
field: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
fieldOptions: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
readOnly: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
selected: false,
|
||||
editingName: false,
|
||||
editingDescription: false,
|
||||
value: null,
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
field: {
|
||||
deep: true,
|
||||
handler() {
|
||||
this.resetValue()
|
||||
},
|
||||
},
|
||||
},
|
||||
created() {
|
||||
this.resetValue()
|
||||
},
|
||||
methods: {
|
||||
select() {
|
||||
this.selected = true
|
||||
this.$el.clickOutsideEvent = (event) => {
|
||||
if (
|
||||
this.selected &&
|
||||
// If the user not clicked inside the field.
|
||||
!isElement(this.$el, event.target)
|
||||
) {
|
||||
this.unselect()
|
||||
}
|
||||
}
|
||||
document.body.addEventListener('click', this.$el.clickOutsideEvent)
|
||||
},
|
||||
unselect() {
|
||||
this.selected = false
|
||||
document.body.removeEventListener('click', this.$el.clickOutsideEvent)
|
||||
},
|
||||
updateValue(value) {
|
||||
this.value = value
|
||||
},
|
||||
getFieldType() {
|
||||
return this.$registry.get('field', this.field.type)
|
||||
},
|
||||
getFieldComponent() {
|
||||
return this.getFieldType().getFormViewFieldComponent()
|
||||
},
|
||||
resetValue() {
|
||||
this.value = this.getFieldType().getEmptyValue(this.field)
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
|
@ -0,0 +1,63 @@
|
|||
<template>
|
||||
<div class="control__elements">
|
||||
<PaginatedDropdown
|
||||
:fetch-page="fetchPage"
|
||||
:value="dropdownValue"
|
||||
:class="{ 'dropdown--error': touched && !valid }"
|
||||
:fetch-on-open="lazyLoad"
|
||||
@input="updateValue($event)"
|
||||
@hide="touch()"
|
||||
></PaginatedDropdown>
|
||||
<div v-show="touched && !valid" class="error">
|
||||
{{ error }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import PaginatedDropdown from '@baserow/modules/core/components/PaginatedDropdown'
|
||||
import rowEditField from '@baserow/modules/database/mixins/rowEditField'
|
||||
import FormService from '@baserow/modules/database/services/view/form'
|
||||
|
||||
export default {
|
||||
name: 'FormViewFieldLinkRow',
|
||||
components: { PaginatedDropdown },
|
||||
mixins: [rowEditField],
|
||||
props: {
|
||||
slug: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
/**
|
||||
* In some cases, for example in the form view preview, we only want to fetch the
|
||||
* first related rows after the user has opened the dropdown. This will prevent a
|
||||
* race condition where the enabled state of the field might not yet been updated
|
||||
* before we fetch the related rows. If the state has not yet been changed in the
|
||||
* backend, it will result in an error.
|
||||
*/
|
||||
lazyLoad: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
dropdownValue() {
|
||||
return this.value.length === 0 ? null : this.value[0].id
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
fetchPage(page, search) {
|
||||
return FormService(this.$client).linkRowFieldLookup(
|
||||
this.slug,
|
||||
this.field.id,
|
||||
page,
|
||||
search
|
||||
)
|
||||
},
|
||||
updateValue(value) {
|
||||
this.$emit('update', value === null ? [] : [{ id: value }], this.value)
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
|
@ -0,0 +1,133 @@
|
|||
<template>
|
||||
<ul v-if="!tableLoading" class="header__filter header__filter--full-width">
|
||||
<li class="header__filter-item">
|
||||
<a
|
||||
ref="contextLink"
|
||||
class="header__filter-link"
|
||||
:class="{ 'active--warning': view.public }"
|
||||
@click="$refs.context.toggle($refs.contextLink, 'bottom', 'left', 4)"
|
||||
>
|
||||
<i class="header__filter-icon fas fa-share-square"></i>
|
||||
<span class="header__filter-name">Share form</span>
|
||||
</a>
|
||||
<Context ref="context" class="view-form__shared-link-context">
|
||||
<a
|
||||
v-if="!view.public"
|
||||
class="view-form__create-link"
|
||||
:class="{ 'view-form__create-link--disabled': readOnly }"
|
||||
@click.stop="!readOnly && updateForm({ public: true })"
|
||||
>
|
||||
<i class="fas fa-share-square view-form__create-link-icon"></i>
|
||||
Create a private shareable link to the form
|
||||
</a>
|
||||
<div v-else class="view-form__shared-link">
|
||||
<div class="view-form__shared-link-title">
|
||||
This form is currently shared via a private link
|
||||
</div>
|
||||
<div class="view-form__shared-link-description">
|
||||
People who have the link can see the form in an empty state.
|
||||
</div>
|
||||
<div class="view-form__shared-link-content">
|
||||
<div class="view-form__shared-link-box">
|
||||
{{ formUrl }}
|
||||
</div>
|
||||
<a
|
||||
v-tooltip="'Copy URL'"
|
||||
class="view-form__shared-link-action"
|
||||
@click="copyFormUrlToClipboard()"
|
||||
>
|
||||
<i class="fas fa-copy"></i>
|
||||
<Copied ref="copied"></Copied>
|
||||
</a>
|
||||
</div>
|
||||
<div v-if="!readOnly" class="view-form__shared-link-foot">
|
||||
<a
|
||||
class="view-form__shared-link-disable"
|
||||
@click.stop="updateForm({ public: false })"
|
||||
>
|
||||
<i class="fas fa-times"></i>
|
||||
disable shared link
|
||||
</a>
|
||||
<a @click.prevent="$refs.rotateSlugModal.show()">
|
||||
<i class="fas fa-sync"></i>
|
||||
generate new url
|
||||
</a>
|
||||
<FormViewRotateSlugModal
|
||||
ref="rotateSlugModal"
|
||||
:view="view"
|
||||
:store-prefix="storePrefix"
|
||||
></FormViewRotateSlugModal>
|
||||
</div>
|
||||
</div>
|
||||
</Context>
|
||||
</li>
|
||||
<li class="header__filter-item">
|
||||
<a
|
||||
:href="formUrl"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="header__filter-link"
|
||||
>
|
||||
<i class="header__filter-icon fas fa-eye"></i>
|
||||
<span class="header__filter-name">Preview</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapState } from 'vuex'
|
||||
|
||||
import formViewHelpers from '@baserow/modules/database/mixins/formViewHelpers'
|
||||
import { copyToClipboard } from '@baserow/modules/database/utils/clipboard'
|
||||
import FormViewRotateSlugModal from '@baserow/modules/database/components/view/form/FormViewRotateSlugModal'
|
||||
|
||||
export default {
|
||||
name: 'FormViewHeader',
|
||||
components: { FormViewRotateSlugModal },
|
||||
mixins: [formViewHelpers],
|
||||
props: {
|
||||
view: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
fields: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
primary: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
readOnly: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
rotateSlugLoading: false,
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
formUrl() {
|
||||
return (
|
||||
this.$env.PUBLIC_WEB_FRONTEND_URL +
|
||||
this.$nuxt.$router.resolve({
|
||||
name: 'database-table-form',
|
||||
params: { slug: this.view.slug },
|
||||
}).href
|
||||
)
|
||||
},
|
||||
...mapState({
|
||||
tableLoading: (state) => state.table.loading,
|
||||
}),
|
||||
},
|
||||
methods: {
|
||||
copyFormUrlToClipboard() {
|
||||
copyToClipboard(this.formUrl)
|
||||
this.$refs.copied.show()
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
|
@ -0,0 +1,73 @@
|
|||
<template>
|
||||
<a
|
||||
v-user-file-upload="getFileUploadSettings()"
|
||||
class="form-view__file"
|
||||
:class="{
|
||||
'form-view__file--dragging': dragging,
|
||||
'form-view__file--uploading': uploading,
|
||||
'form-view__file--error': !isImage,
|
||||
}"
|
||||
>
|
||||
<slot></slot>
|
||||
<div class="form-view__file-dragging">Drop here</div>
|
||||
<div class="form-view__file-uploading">Uploading</div>
|
||||
<div class="form-view__file-error">Not an image</div>
|
||||
<div
|
||||
v-if="uploading"
|
||||
class="form-view__file-progress"
|
||||
:style="{ width: percentage + '%' }"
|
||||
></div>
|
||||
</a>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { notifyIf } from '@baserow/modules/core/utils/error'
|
||||
|
||||
export default {
|
||||
name: 'FormViewImageUpload',
|
||||
data() {
|
||||
return {
|
||||
dragging: false,
|
||||
uploading: false,
|
||||
percentage: 0,
|
||||
isImage: true,
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
getFileUploadSettings() {
|
||||
return {
|
||||
check: (file) => {
|
||||
const imageTypes = ['image/jpeg', 'image/jpg', 'image/png']
|
||||
const isImage = imageTypes.includes(file.type)
|
||||
this.isImage = isImage
|
||||
return isImage
|
||||
},
|
||||
enter: () => {
|
||||
this.dragging = true
|
||||
},
|
||||
leave: () => {
|
||||
this[name].dragging = false
|
||||
},
|
||||
progress: (event) => {
|
||||
const percentage = Math.round((event.loaded * 100) / event.total)
|
||||
this.dragging = false
|
||||
this.uploading = true
|
||||
this.percentage = percentage
|
||||
},
|
||||
done: (file) => {
|
||||
this.dragging = false
|
||||
this.uploading = false
|
||||
this.percentage = 0
|
||||
this.$emit('uploaded', file)
|
||||
},
|
||||
error: (error) => {
|
||||
this.dragging = false
|
||||
this.uploading = false
|
||||
this.percentage = 0
|
||||
notifyIf(error)
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
|
@ -0,0 +1,126 @@
|
|||
<template>
|
||||
<div class="form-view__meta">
|
||||
<div class="form-view__body">
|
||||
<div class="form-view__meta-controls">
|
||||
<div class="control">
|
||||
<label class="control__label">When the form is submitted</label>
|
||||
<div class="control__elements">
|
||||
<ul class="choice-items choice-items--inline" z>
|
||||
<li>
|
||||
<a
|
||||
class="choice-items__link"
|
||||
:class="{
|
||||
active: view.submit_action === 'MESSAGE',
|
||||
disabled: readOnly,
|
||||
}"
|
||||
@click="
|
||||
!readOnly &&
|
||||
view.submit_action !== 'MESSAGE' &&
|
||||
$emit('updated-form', { submit_action: 'MESSAGE' })
|
||||
"
|
||||
>Show a message</a
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
class="choice-items__link"
|
||||
:class="{
|
||||
active: view.submit_action === 'REDIRECT',
|
||||
disabled: readOnly,
|
||||
}"
|
||||
@click="
|
||||
!readOnly &&
|
||||
view.submit_action !== 'REDIRECT' &&
|
||||
$emit('updated-form', { submit_action: 'REDIRECT' })
|
||||
"
|
||||
>Redirect to URL</a
|
||||
>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="view.submit_action === 'MESSAGE'" class="control">
|
||||
<label class="control__label">The message</label>
|
||||
<div class="control__elements">
|
||||
<textarea
|
||||
v-model="submit_action_message"
|
||||
type="text"
|
||||
class="input form-view__meta-message-textarea"
|
||||
placeholder="The message"
|
||||
rows="3"
|
||||
:disabled="readOnly"
|
||||
@blur="
|
||||
$emit('updated-form', {
|
||||
submit_action_message,
|
||||
})
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="view.submit_action === 'REDIRECT'" class="control">
|
||||
<label class="control__label">The URL</label>
|
||||
<div class="control__elements">
|
||||
<input
|
||||
v-model="submit_action_redirect_url"
|
||||
type="text"
|
||||
class="input"
|
||||
placeholder="The URL"
|
||||
:disabled="readOnly"
|
||||
@blur="
|
||||
;[
|
||||
$v.submit_action_redirect_url.$touch(),
|
||||
!$v.submit_action_redirect_url.$error &&
|
||||
$emit('updated-form', {
|
||||
submit_action_redirect_url,
|
||||
}),
|
||||
]
|
||||
"
|
||||
/>
|
||||
<div v-if="$v.submit_action_redirect_url.$error" class="error">
|
||||
Please enter a valid URL
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { required, url } from 'vuelidate/lib/validators'
|
||||
|
||||
export default {
|
||||
name: 'FormViewMeta',
|
||||
props: {
|
||||
view: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
readOnly: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
submit_action_message: '',
|
||||
submit_action_redirect_url: '',
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
'view.submit_action_message'(value) {
|
||||
this.submit_action_message = value
|
||||
},
|
||||
'view.submit_action_redirect_url'(value) {
|
||||
this.submit_action_redirect_url = value
|
||||
},
|
||||
},
|
||||
created() {
|
||||
this.submit_action_message = this.view.submit_action_message
|
||||
this.submit_action_redirect_url = this.view.submit_action_redirect_url
|
||||
},
|
||||
validations: {
|
||||
submit_action_redirect_url: { required, url },
|
||||
},
|
||||
}
|
||||
</script>
|
|
@ -0,0 +1,16 @@
|
|||
<template>
|
||||
<div class="form-view__powered-by">
|
||||
Powered by
|
||||
<a
|
||||
href="https://baserow.io"
|
||||
target="_blank"
|
||||
title="Baserow - open source no-code database tool and Airtable alternative"
|
||||
>
|
||||
<img
|
||||
class="form-view__powered-by-logo"
|
||||
src="@baserow/modules/core/static/img/logo.svg"
|
||||
alt="Baserow - open source no-code database tool and Airtable alternative"
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
</template>
|
|
@ -0,0 +1,168 @@
|
|||
<template>
|
||||
<div class="form-view__preview">
|
||||
<div class="form-view__page form-view__page--rounded">
|
||||
<div
|
||||
class="form-view__cover"
|
||||
:style="{
|
||||
'background-image': view.cover_image
|
||||
? `url(${view.cover_image.url})`
|
||||
: null,
|
||||
}"
|
||||
>
|
||||
<template v-if="!readOnly">
|
||||
<FormViewImageUpload
|
||||
v-if="!view.cover_image"
|
||||
@uploaded="updateForm({ cover_image: $event })"
|
||||
>Add a cover image
|
||||
</FormViewImageUpload>
|
||||
<a
|
||||
v-else
|
||||
class="form_view__file-delete"
|
||||
@click="updateForm({ cover_image: null })"
|
||||
>
|
||||
<i class="fas fa-times"></i>
|
||||
Remove
|
||||
</a>
|
||||
</template>
|
||||
</div>
|
||||
<div class="form-view__body">
|
||||
<div class="form-view__heading">
|
||||
<div v-if="view.logo_image !== null" class="form_view__logo">
|
||||
<img
|
||||
class="form_view__logo-img"
|
||||
:src="view.logo_image.url"
|
||||
width="200"
|
||||
/>
|
||||
<a
|
||||
v-if="!readOnly"
|
||||
class="form_view__file-delete"
|
||||
@click="updateForm({ logo_image: null })"
|
||||
>
|
||||
<i class="fas fa-times"></i>
|
||||
Remove
|
||||
</a>
|
||||
</div>
|
||||
<FormViewImageUpload
|
||||
v-else-if="!readOnly"
|
||||
class="margin-bottom-3"
|
||||
@uploaded="updateForm({ logo_image: $event })"
|
||||
>Add a logo
|
||||
</FormViewImageUpload>
|
||||
<h1 class="form-view__title">
|
||||
<Editable
|
||||
ref="title"
|
||||
:value="view.title"
|
||||
@change="updateForm({ title: $event.value })"
|
||||
@editing="editingTitle = $event"
|
||||
></Editable>
|
||||
<a
|
||||
v-if="!readOnly"
|
||||
class="form-view__edit"
|
||||
:class="{ 'form-view__edit--hidden': editingTitle }"
|
||||
@click="$refs.title.edit()"
|
||||
></a>
|
||||
</h1>
|
||||
<p class="form-view__description">
|
||||
<Editable
|
||||
ref="description"
|
||||
:value="view.description"
|
||||
@change="updateForm({ description: $event.value })"
|
||||
@editing="editingDescription = $event"
|
||||
></Editable>
|
||||
<a
|
||||
v-if="!readOnly"
|
||||
class="form-view__edit"
|
||||
:class="{ 'form-view__edit--hidden': editingDescription }"
|
||||
@click="$refs.description.edit()"
|
||||
></a>
|
||||
</p>
|
||||
<div v-if="fields.length === 0" class="form-view__no-fields">
|
||||
This form doesn't have any fields. Click on a field in the left
|
||||
sidebar to add one.
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-view__fields">
|
||||
<FormViewField
|
||||
v-for="field in fields"
|
||||
:key="field.id"
|
||||
v-sortable="{
|
||||
enabled: !readOnly,
|
||||
id: field.id,
|
||||
update: order,
|
||||
handle: '[data-field-handle]',
|
||||
}"
|
||||
:table="table"
|
||||
:view="view"
|
||||
:field="field"
|
||||
:field-options="fieldOptions[field.id]"
|
||||
:read-only="readOnly"
|
||||
@hide="updateFieldOptionsOfField(view, field, { enabled: false })"
|
||||
@updated-field-options="
|
||||
updateFieldOptionsOfField(view, field, $event)
|
||||
"
|
||||
>
|
||||
</FormViewField>
|
||||
</div>
|
||||
<div class="form-view__actions">
|
||||
<FormViewPoweredBy></FormViewPoweredBy>
|
||||
<div class="form-view__submit">
|
||||
<a class="button button--primary button--large">Submit</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<FormViewMeta
|
||||
:view="view"
|
||||
:read-only="readOnly"
|
||||
@updated-form="updateForm($event)"
|
||||
></FormViewMeta>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import FormViewField from '@baserow/modules/database/components/view/form/FormViewField'
|
||||
import FormViewMeta from '@baserow/modules/database/components/view/form/FormViewMeta'
|
||||
import FormViewImageUpload from '@baserow/modules/database/components/view/form/FormViewImageUpload'
|
||||
import formViewHelpers from '@baserow/modules/database/mixins/formViewHelpers'
|
||||
import FormViewPoweredBy from '@baserow/modules/database/components/view/form/FormViewPoweredBy'
|
||||
|
||||
export default {
|
||||
name: 'FormViewPreview',
|
||||
components: {
|
||||
FormViewPoweredBy,
|
||||
FormViewField,
|
||||
FormViewMeta,
|
||||
FormViewImageUpload,
|
||||
},
|
||||
mixins: [formViewHelpers],
|
||||
props: {
|
||||
table: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
view: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
fields: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
readOnly: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
editingTitle: false,
|
||||
editingDescription: false,
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
order(order) {
|
||||
this.$emit('ordered-fields', order)
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
|
@ -0,0 +1,70 @@
|
|||
<template>
|
||||
<Modal>
|
||||
<h2 class="box__title">Refresh URL</h2>
|
||||
<Error :error="error"></Error>
|
||||
<div>
|
||||
<p>
|
||||
Are you sure that you want to refresh the URL of {{ view.name }}? After
|
||||
refreshing, a new URL will be generated and it will not be possible to
|
||||
access the form via the old URL. Everyone that you have shared the URL
|
||||
with, won't be able to access the form.
|
||||
</p>
|
||||
<div class="actions">
|
||||
<div class="align-right">
|
||||
<button
|
||||
class="button button--large button--primary"
|
||||
:class="{ 'button--loading': loading }"
|
||||
:disabled="loading"
|
||||
@click="rotateSlug()"
|
||||
>
|
||||
Generate new URL
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import modal from '@baserow/modules/core/mixins/modal'
|
||||
import error from '@baserow/modules/core/mixins/error'
|
||||
import formViewHelpers from '@baserow/modules/database/mixins/formViewHelpers'
|
||||
import FormService from '@baserow/modules/database/services/view/form'
|
||||
|
||||
export default {
|
||||
name: 'FormViewRotateSlugModal',
|
||||
mixins: [modal, error, formViewHelpers],
|
||||
props: {
|
||||
view: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
loading: false,
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async rotateSlug() {
|
||||
this.hideError()
|
||||
this.loading = true
|
||||
|
||||
try {
|
||||
const { data } = await FormService(this.$client).rotateSlug(
|
||||
this.view.id
|
||||
)
|
||||
await this.$store.dispatch('view/forceUpdate', {
|
||||
view: this.view,
|
||||
values: data,
|
||||
})
|
||||
this.hide()
|
||||
} catch (error) {
|
||||
this.handleError(error, 'table')
|
||||
}
|
||||
|
||||
this.loading = false
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue