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

See merge request 
This commit is contained in:
Bram Wiepjes 2021-07-11 18:02:38 +00:00
commit ccd98b090b
128 changed files with 6474 additions and 943 deletions
backend
changelog.md
docs/getting-started
web-frontend/modules

View file

@ -1,6 +1,6 @@
from .extensions import ( # noqa: F401
PolymorphicMappingSerializerExtension,
PolymorphicCustomFieldRegistrySerializerExtension,
DiscriminatorMappingSerializerExtension,
DiscriminatorCustomFieldsMappingSerializerExtension,
)

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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="*",
)

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View 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",
),
]

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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"] == []

View file

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

View file

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

View file

@ -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 = []

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

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

View file

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

View file

@ -156,6 +156,7 @@
id: application.id,
update: orderApplications,
handle: '[data-sortable-handle]',
marginTop: -1.5,
}"
:application="application"
:group="selectedGroup"

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -29,6 +29,7 @@
update: orderTables,
marginLeft: 34,
marginRight: 10,
marginTop: -1.5,
}"
:database="application"
:table="table"

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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