diff --git a/backend/src/baserow/config/settings/base.py b/backend/src/baserow/config/settings/base.py index 55d556422..f1d547ddf 100644 --- a/backend/src/baserow/config/settings/base.py +++ b/backend/src/baserow/config/settings/base.py @@ -1184,6 +1184,11 @@ if POSTHOG_ENABLED: else: posthog.disabled = True +BASEROW_BUILDER_DOMAINS = os.getenv("BASEROW_BUILDER_DOMAINS", None) +BASEROW_BUILDER_DOMAINS = ( + BASEROW_BUILDER_DOMAINS.split(",") if BASEROW_BUILDER_DOMAINS is not None else [] +) + # Indicates whether we are running the tests or not. Set to True in the test.py settings # file used by pytest.ini TESTS = False diff --git a/backend/src/baserow/contrib/builder/api/domains/errors.py b/backend/src/baserow/contrib/builder/api/domains/errors.py index 3ab6e336a..dccc8906c 100644 --- a/backend/src/baserow/contrib/builder/api/domains/errors.py +++ b/backend/src/baserow/contrib/builder/api/domains/errors.py @@ -11,3 +11,16 @@ ERROR_DOMAIN_NOT_IN_BUILDER = ( HTTP_400_BAD_REQUEST, "The domain id {e.domain_id} does not belong to the builder.", ) + +ERROR_DOMAIN_NAME_NOT_UNIQUE = ( + "ERROR_DOMAIN_NAME_NOT_UNIQUE", + HTTP_400_BAD_REQUEST, + "The domain name {e.domain_name} already exists.", +) + +ERROR_SUB_DOMAIN_HAS_INVALID_DOMAIN_NAME = ( + "ERROR_SUB_DOMAIN_HAS_INVALID_DOMAIN_NAME", + HTTP_400_BAD_REQUEST, + "The subdomain {e.domain_name} has an invalid domain name, " + "you can only use {e.available_domain_names}", +) diff --git a/backend/src/baserow/contrib/builder/api/domains/serializers.py b/backend/src/baserow/contrib/builder/api/domains/serializers.py index beb7c85ec..7b5251232 100644 --- a/backend/src/baserow/contrib/builder/api/domains/serializers.py +++ b/backend/src/baserow/contrib/builder/api/domains/serializers.py @@ -1,5 +1,7 @@ from typing import List +from django.utils.functional import lazy + from drf_spectacular.types import OpenApiTypes from drf_spectacular.utils import extend_schema_field from rest_framework import serializers @@ -8,6 +10,7 @@ from baserow.api.services.serializers import PublicServiceSerializer from baserow.contrib.builder.api.pages.serializers import PathParamSerializer from baserow.contrib.builder.data_sources.models import DataSource from baserow.contrib.builder.domains.models import Domain +from baserow.contrib.builder.domains.registries import domain_type_registry from baserow.contrib.builder.elements.models import Element from baserow.contrib.builder.elements.registries import element_type_registry from baserow.contrib.builder.models import Builder @@ -16,9 +19,15 @@ from baserow.core.services.registries import service_type_registry class DomainSerializer(serializers.ModelSerializer): + type = serializers.SerializerMethodField(help_text="The type of the domain.") + + @extend_schema_field(OpenApiTypes.STR) + def get_type(self, instance): + return domain_type_registry.get_by_model(instance.specific_class).type + class Meta: model = Domain - fields = ("id", "domain_name", "order", "builder_id", "last_published") + fields = ("id", "type", "domain_name", "order", "builder_id", "last_published") extra_kwargs = { "id": {"read_only": True}, "builder_id": {"read_only": True}, @@ -27,9 +36,37 @@ class DomainSerializer(serializers.ModelSerializer): class CreateDomainSerializer(serializers.ModelSerializer): + type = serializers.ChoiceField( + choices=lazy(domain_type_registry.get_types, list)(), + required=True, + help_text="The type of the domain.", + ) + class Meta: model = Domain - fields = ("domain_name",) + fields = ( + "type", + "domain_name", + ) + + +class UpdateDomainSerializer(serializers.ModelSerializer): + type = serializers.ChoiceField( + choices=lazy(domain_type_registry.get_types, list)(), + required=False, + help_text="The type of the domain.", + ) + + domain_name = serializers.CharField( + required=False, help_text=Domain._meta.get_field("domain_name").help_text + ) + + class Meta: + model = Domain + fields = ( + "type", + "domain_name", + ) class OrderDomainsSerializer(serializers.Serializer): diff --git a/backend/src/baserow/contrib/builder/api/domains/views.py b/backend/src/baserow/contrib/builder/api/domains/views.py index beefe8194..24187320e 100644 --- a/backend/src/baserow/contrib/builder/api/domains/views.py +++ b/backend/src/baserow/contrib/builder/api/domains/views.py @@ -10,29 +10,43 @@ from rest_framework.status import HTTP_202_ACCEPTED from rest_framework.views import APIView from baserow.api.applications.errors import ERROR_APPLICATION_DOES_NOT_EXIST -from baserow.api.decorators import map_exceptions, validate_body +from baserow.api.decorators import ( + map_exceptions, + validate_body, + validate_body_custom_fields, +) from baserow.api.jobs.serializers import JobSerializer from baserow.api.schemas import CLIENT_SESSION_ID_SCHEMA_PARAMETER, get_error_schema -from baserow.api.utils import DiscriminatorCustomFieldsMappingSerializer +from baserow.api.utils import ( + DiscriminatorCustomFieldsMappingSerializer, + type_from_data_or_registry, + validate_data_custom_fields, +) from baserow.contrib.builder.api.data_sources.serializers import DataSourceSerializer from baserow.contrib.builder.api.domains.errors import ( ERROR_DOMAIN_DOES_NOT_EXIST, + ERROR_DOMAIN_NAME_NOT_UNIQUE, ERROR_DOMAIN_NOT_IN_BUILDER, + ERROR_SUB_DOMAIN_HAS_INVALID_DOMAIN_NAME, ) from baserow.contrib.builder.api.domains.serializers import ( CreateDomainSerializer, DomainSerializer, OrderDomainsSerializer, PublicBuilderSerializer, + UpdateDomainSerializer, ) from baserow.contrib.builder.api.pages.errors import ERROR_PAGE_DOES_NOT_EXIST from baserow.contrib.builder.data_sources.service import DataSourceService from baserow.contrib.builder.domains.exceptions import ( DomainDoesNotExist, + DomainNameNotUniqueError, DomainNotInBuilder, + SubDomainHasInvalidDomainName, ) from baserow.contrib.builder.domains.handler import DomainHandler from baserow.contrib.builder.domains.models import Domain +from baserow.contrib.builder.domains.registries import domain_type_registry from baserow.contrib.builder.domains.service import DomainService from baserow.contrib.builder.elements.registries import element_type_registry from baserow.contrib.builder.elements.service import ElementService @@ -66,9 +80,13 @@ class DomainsView(APIView): tags=["Builder domains"], operation_id="create_builder_domain", description="Creates a new domain for an application builder", - request=CreateDomainSerializer, + request=DiscriminatorCustomFieldsMappingSerializer( + domain_type_registry, CreateDomainSerializer, request=True + ), responses={ - 200: DomainSerializer, + 200: DiscriminatorCustomFieldsMappingSerializer( + domain_type_registry, DomainSerializer + ), 400: get_error_schema( [ "ERROR_USER_NOT_IN_GROUP", @@ -82,17 +100,22 @@ class DomainsView(APIView): @map_exceptions( { ApplicationDoesNotExist: ERROR_APPLICATION_DOES_NOT_EXIST, + SubDomainHasInvalidDomainName: ERROR_SUB_DOMAIN_HAS_INVALID_DOMAIN_NAME, } ) - @validate_body(CreateDomainSerializer) + @validate_body_custom_fields( + domain_type_registry, base_serializer_class=CreateDomainSerializer + ) def post(self, request, data: Dict, builder_id: int): builder = BuilderHandler().get_builder(builder_id) + type_name = data.pop("type") + domain_type = domain_type_registry.get(type_name) domain = DomainService().create_domain( - request.user, builder, data["domain_name"] + request.user, domain_type, builder, **data ) - serializer = DomainSerializer(domain) + serializer = domain_type_registry.get_serializer(domain, DomainSerializer) return Response(serializer.data) @extend_schema( @@ -109,7 +132,9 @@ class DomainsView(APIView): operation_id="get_builder_domains", description="Gets all the domains of a builder", responses={ - 200: DomainSerializer(many=True), + 200: DiscriminatorCustomFieldsMappingSerializer( + domain_type_registry, DomainSerializer, many=True + ), 400: get_error_schema( [ "ERROR_USER_NOT_IN_GROUP", @@ -132,9 +157,12 @@ class DomainsView(APIView): builder, ) - serializer = DomainSerializer(domains, many=True) + data = [ + domain_type_registry.get_serializer(domain, DomainSerializer).data + for domain in domains + ] - return Response(serializer.data) + return Response(data) class DomainView(APIView): @@ -151,9 +179,11 @@ class DomainView(APIView): tags=["Builder domains"], operation_id="update_builder_domain", description="Updates an existing domain of an application builder", - request=CreateDomainSerializer, + request=UpdateDomainSerializer, responses={ - 200: DomainSerializer, + 200: DiscriminatorCustomFieldsMappingSerializer( + domain_type_registry, DomainSerializer + ), 400: get_error_schema( [ "ERROR_USER_NOT_IN_GROUP", @@ -167,19 +197,35 @@ class DomainView(APIView): @map_exceptions( { DomainDoesNotExist: ERROR_DOMAIN_DOES_NOT_EXIST, + DomainNameNotUniqueError: ERROR_DOMAIN_NAME_NOT_UNIQUE, + SubDomainHasInvalidDomainName: ERROR_SUB_DOMAIN_HAS_INVALID_DOMAIN_NAME, } ) - @validate_body(CreateDomainSerializer) - def patch(self, request, data: Dict, domain_id: int): - base_queryset = Domain.objects.select_for_update(of=("self",)) + def patch(self, request, domain_id: int): + base_queryset = Domain.objects - domain = DomainService().get_domain( - request.user, domain_id, base_queryset=base_queryset + domain = ( + DomainService() + .get_domain(request.user, domain_id, base_queryset=base_queryset) + .specific + ) + domain_type = type_from_data_or_registry( + request.data, domain_type_registry, domain + ) + + data = validate_data_custom_fields( + domain_type.type, + domain_type_registry, + request.data, + base_serializer_class=UpdateDomainSerializer, + partial=True, ) domain_updated = DomainService().update_domain(request.user, domain, **data) - serializer = DomainSerializer(domain_updated) + serializer = domain_type_registry.get_serializer( + domain_updated, DomainSerializer + ) return Response(serializer.data) @extend_schema( diff --git a/backend/src/baserow/contrib/builder/apps.py b/backend/src/baserow/contrib/builder/apps.py index f6aa0493e..8249c71fe 100644 --- a/backend/src/baserow/contrib/builder/apps.py +++ b/backend/src/baserow/contrib/builder/apps.py @@ -151,6 +151,12 @@ class BuilderConfig(AppConfig): element_type_registry.register(InputTextElementType()) element_type_registry.register(ColumnElementType()) + from .domains.domain_types import CustomDomainType, SubDomainType + from .domains.registries import domain_type_registry + + domain_type_registry.register(CustomDomainType()) + domain_type_registry.register(SubDomainType()) + from .domains.trash_types import DomainTrashableItemType trash_item_type_registry.register(DomainTrashableItemType()) diff --git a/backend/src/baserow/contrib/builder/domains/domain_types.py b/backend/src/baserow/contrib/builder/domains/domain_types.py new file mode 100644 index 000000000..f694fe0f8 --- /dev/null +++ b/backend/src/baserow/contrib/builder/domains/domain_types.py @@ -0,0 +1,41 @@ +from typing import Dict + +from django.conf import settings + +from baserow.contrib.builder.domains.exceptions import SubDomainHasInvalidDomainName +from baserow.contrib.builder.domains.models import CustomDomain, SubDomain +from baserow.contrib.builder.domains.registries import DomainType + + +class CustomDomainType(DomainType): + type = "custom" + model_class = CustomDomain + + +class SubDomainType(DomainType): + type = "sub_domain" + model_class = SubDomain + + def prepare_values(self, values: Dict) -> Dict: + domain_name = values.get("domain_name", None) + + if domain_name is not None: + self._validate_domain_name(domain_name) + + return values + + def _validate_domain_name(self, domain_name: str): + """ + Checks if the subdomain uses a domain name that is among the available domain + names defined in settings. + + :param domain_name: The name that is being proposed + :raises SubDomainHasInvalidDomainName: If the domain name is not registered + """ + + for domain in settings.BASEROW_BUILDER_DOMAINS: + if domain_name.endswith(f".{domain}"): + # The domain suffix is valid + return + + raise SubDomainHasInvalidDomainName(domain_name) diff --git a/backend/src/baserow/contrib/builder/domains/exceptions.py b/backend/src/baserow/contrib/builder/domains/exceptions.py index 07e86d121..43779284e 100644 --- a/backend/src/baserow/contrib/builder/domains/exceptions.py +++ b/backend/src/baserow/contrib/builder/domains/exceptions.py @@ -1,3 +1,6 @@ +from django.conf import settings + + class DomainDoesNotExist(Exception): """Raised when trying to get a domain that doesn't exist.""" @@ -12,3 +15,27 @@ class DomainNotInBuilder(Exception): *args, **kwargs, ) + + +class DomainNameNotUniqueError(Exception): + """Raised when trying to set a domain name that already exists""" + + def __init__(self, domain_name, *args, **kwargs): + self.domain_name = domain_name + super().__init__( + f"The domain name {domain_name} already exists", *args, **kwargs + ) + + +class SubDomainHasInvalidDomainName(Exception): + """Raised when a subdomain is using an invalid domain name""" + + def __init__(self, domain_name, *args, **kwargs): + self.domain_name = domain_name + self.available_domain_names = settings.BASEROW_BUILDER_DOMAINS + super().__init__( + f"The subdomain {domain_name} has an invalid domain name, you can only use " + f"{self.available_domain_names}", + *args, + **kwargs, + ) diff --git a/backend/src/baserow/contrib/builder/domains/handler.py b/backend/src/baserow/contrib/builder/domains/handler.py index 73479b806..03d25f5c2 100644 --- a/backend/src/baserow/contrib/builder/domains/handler.py +++ b/backend/src/baserow/contrib/builder/domains/handler.py @@ -1,23 +1,30 @@ -from typing import List +from typing import Iterable, List, cast from django.core.files.storage import default_storage from django.db.models import QuerySet +from django.db.utils import IntegrityError from django.utils import timezone from baserow.contrib.builder.domains.exceptions import ( DomainDoesNotExist, + DomainNameNotUniqueError, DomainNotInBuilder, ) from baserow.contrib.builder.domains.models import Domain +from baserow.contrib.builder.domains.registries import DomainType from baserow.contrib.builder.exceptions import BuilderDoesNotExist from baserow.contrib.builder.models import Builder +from baserow.core.db import specific_iterator from baserow.core.exceptions import IdDoesNotExist from baserow.core.registries import ImportExportConfig, application_type_registry from baserow.core.trash.handler import TrashHandler -from baserow.core.utils import Progress +from baserow.core.utils import Progress, extract_allowed class DomainHandler: + allowed_fields_create = ["domain_name"] + allowed_fields_update = ["domain_name", "last_published"] + def get_domain(self, domain_id: int, base_queryset: QuerySet = None) -> Domain: """ Gets a domain by ID @@ -39,20 +46,20 @@ class DomainHandler: def get_domains( self, builder: Builder, base_queryset: QuerySet = None - ) -> QuerySet[Domain]: + ) -> Iterable[Domain]: """ Gets all the domains of a builder. :param builder: The builder we are trying to get all domains for :param base_queryset: Can be provided to already filter or apply performance improvements to the queryset when it's being executed - :return: A queryset with all the domains + :return: An iterable of all the specific domains """ if base_queryset is None: base_queryset = Domain.objects - return base_queryset.filter(builder=builder) + return specific_iterator(base_queryset.filter(builder=builder)) def get_public_builder_by_domain_name(self, domain_name: str) -> Builder: """ @@ -78,20 +85,31 @@ class DomainHandler: return domain.published_to - def create_domain(self, builder: Builder, domain_name: str) -> Domain: + def create_domain( + self, domain_type: DomainType, builder: Builder, **kwargs + ) -> Domain: """ Creates a new domain + :param domain_type: The type of domain that's being created :param builder: The builder the domain belongs to - :param domain_name: The name of the domain + :param kwargs: Additional attributes of the domain :return: The newly created domain instance """ last_order = Domain.get_last_order(builder) - domain = Domain.objects.create( - builder=builder, domain_name=domain_name, order=last_order + + model_class = cast(Domain, domain_type.model_class) + + allowed_values = extract_allowed( + kwargs, self.allowed_fields_create + domain_type.allowed_fields ) + prepared_values = domain_type.prepare_values(allowed_values) + + domain = model_class(builder=builder, order=last_order, **prepared_values) + domain.save() + return domain def delete_domain(self, domain: Domain): @@ -112,10 +130,23 @@ class DomainHandler: :return: The updated domain """ - for key, value in kwargs.items(): + domain_type = domain.get_type() + + allowed_values = extract_allowed( + kwargs, self.allowed_fields_update + domain_type.allowed_fields + ) + + prepared_values = domain_type.prepare_values(allowed_values) + + for key, value in prepared_values.items(): setattr(domain, key, value) - domain.save() + try: + domain.save() + except IntegrityError as error: + if "unique" in str(error) and "domain_name" in prepared_values: + raise DomainNameNotUniqueError(prepared_values["domain_name"]) + raise error return domain diff --git a/backend/src/baserow/contrib/builder/domains/models.py b/backend/src/baserow/contrib/builder/domains/models.py index b9180422c..164c2eda0 100644 --- a/backend/src/baserow/contrib/builder/domains/models.py +++ b/backend/src/baserow/contrib/builder/domains/models.py @@ -1,16 +1,21 @@ +from django.contrib.contenttypes.models import ContentType from django.db import models from django.db.models import CASCADE, SET_NULL import validators from rest_framework.exceptions import ValidationError +from baserow.contrib.builder.domains.registries import domain_type_registry from baserow.core.jobs.mixins import JobWithUserIpAddress from baserow.core.jobs.models import Job from baserow.core.mixins import ( HierarchicalModelMixin, OrderableMixin, + PolymorphicContentTypeMixin, TrashableModelMixin, + WithRegistry, ) +from baserow.core.registry import ModelRegistryMixin def validate_domain(value: str): @@ -29,7 +34,24 @@ def validate_domain(value: str): raise ValidationError("Invalid domain syntax") -class Domain(HierarchicalModelMixin, TrashableModelMixin, OrderableMixin, models.Model): +def get_default_domain_content_type(): + return ContentType.objects.get_for_model(CustomDomain) + + +class Domain( + HierarchicalModelMixin, + TrashableModelMixin, + OrderableMixin, + WithRegistry, + PolymorphicContentTypeMixin, + models.Model, +): + content_type = models.ForeignKey( + ContentType, + verbose_name="content type", + related_name="builder_domains", + on_delete=models.SET(get_default_domain_content_type), + ) builder = models.ForeignKey( "builder.Builder", on_delete=CASCADE, @@ -63,6 +85,18 @@ class Domain(HierarchicalModelMixin, TrashableModelMixin, OrderableMixin, models queryset = Domain.objects.filter(builder=builder) return cls.get_highest_order_of_queryset(queryset) + 1 + @staticmethod + def get_type_registry() -> ModelRegistryMixin: + return domain_type_registry + + +class CustomDomain(Domain): + pass + + +class SubDomain(Domain): + pass + class PublishDomainJob(JobWithUserIpAddress, Job): domain: Domain = models.ForeignKey(Domain, null=True, on_delete=models.SET_NULL) diff --git a/backend/src/baserow/contrib/builder/domains/registries.py b/backend/src/baserow/contrib/builder/domains/registries.py new file mode 100644 index 000000000..912dd3222 --- /dev/null +++ b/backend/src/baserow/contrib/builder/domains/registries.py @@ -0,0 +1,34 @@ +from abc import ABC +from typing import Dict + +from baserow.core.registry import ( + CustomFieldsInstanceMixin, + CustomFieldsRegistryMixin, + Instance, + ModelInstanceMixin, + ModelRegistryMixin, + Registry, +) + + +class DomainType(Instance, ModelInstanceMixin, CustomFieldsInstanceMixin, ABC): + def prepare_values(self, values: Dict) -> Dict: + """ + Called before a domain is saved/updates to validate the data or transform the + data before it is being saved. + :param values: The values that are about to be saved + :return: The values after validation/transformation + """ + + return values + + +class DomainTypeRegistry(Registry, ModelRegistryMixin, CustomFieldsRegistryMixin): + """ + Contains all the registered domain types. + """ + + name = "domain_type" + + +domain_type_registry = DomainTypeRegistry() diff --git a/backend/src/baserow/contrib/builder/domains/service.py b/backend/src/baserow/contrib/builder/domains/service.py index be61b21dd..fe0280c57 100644 --- a/backend/src/baserow/contrib/builder/domains/service.py +++ b/backend/src/baserow/contrib/builder/domains/service.py @@ -29,6 +29,7 @@ from baserow.core.utils import Progress, extract_allowed from .job_types import PublishDomainJobType from .operations import PublishDomainOperationType +from .registries import DomainType class DomainService: @@ -121,14 +122,19 @@ class DomainService: return builder def create_domain( - self, user: AbstractUser, builder: Builder, domain_name: str + self, + user: AbstractUser, + domain_type: DomainType, + builder: Builder, + **kwargs, ) -> Domain: """ Creates a new domain :param user: The user trying to create the domain + :param domain_type: The type of domain that's being created :param builder: The builder the domain belongs to - :param domain_name: The name of the domain + :param kwargs: Additional attributes of the domain :return: The newly created domain instance """ @@ -139,7 +145,7 @@ class DomainService: context=builder, ) - domain = self.handler.create_domain(builder, domain_name) + domain = self.handler.create_domain(domain_type, builder, **kwargs) domain_created.send(self, domain=domain, user=user) diff --git a/backend/src/baserow/contrib/builder/migrations/0018_sub_domains.py b/backend/src/baserow/contrib/builder/migrations/0018_sub_domains.py new file mode 100644 index 000000000..e8aeb5447 --- /dev/null +++ b/backend/src/baserow/contrib/builder/migrations/0018_sub_domains.py @@ -0,0 +1,73 @@ +# Generated by Django 3.2.21 on 2023-09-14 13:34 + +import django.db.models.deletion +from django.db import migrations, models + +import baserow.contrib.builder.domains.models + + +def get_default_domain_content_type_id(): + return baserow.contrib.builder.domains.models.get_default_domain_content_type().id + + +class Migration(migrations.Migration): + dependencies = [ + ("contenttypes", "0002_remove_content_type_name"), + ("builder", "0017_mainthemeconfigblock"), + ] + + operations = [ + migrations.CreateModel( + name="CustomDomain", + fields=[ + ( + "domain_ptr", + models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + serialize=False, + to="builder.domain", + ), + ), + ], + options={ + "abstract": False, + }, + bases=("builder.domain",), + ), + migrations.CreateModel( + name="SubDomain", + fields=[ + ( + "domain_ptr", + models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + serialize=False, + to="builder.domain", + ), + ), + ], + options={ + "abstract": False, + }, + bases=("builder.domain",), + ), + migrations.AddField( + model_name="domain", + name="content_type", + field=models.ForeignKey( + default=get_default_domain_content_type_id, + on_delete=models.SET( + baserow.contrib.builder.domains.models.get_default_domain_content_type + ), + related_name="builder_domains", + to="contenttypes.contenttype", + verbose_name="content type", + ), + ), + ] diff --git a/backend/src/baserow/contrib/builder/migrations/0019_migrate_existing_domains.py b/backend/src/baserow/contrib/builder/migrations/0019_migrate_existing_domains.py new file mode 100644 index 000000000..639804e64 --- /dev/null +++ b/backend/src/baserow/contrib/builder/migrations/0019_migrate_existing_domains.py @@ -0,0 +1,34 @@ +from django.db import migrations, transaction + +from baserow.contrib.builder.domains.models import CustomDomain, Domain + + +def forward(apps, schema_editor): + """ + This migration introduces polymorphism, so we need to decide which type of + domain all the existing domains now become. In this case we are turning + all the existing domains into custom domains. + """ + + with transaction.atomic(): + domains = Domain.objects.all() + + for domain in domains: + domain.delete() + CustomDomain.objects.create( + domain_name=domain.domain_name, + order=domain.order, + builder=domain.builder, + ) + + +def reverse(apps, schema_editor): + ... + + +class Migration(migrations.Migration): + dependencies = [("builder", "0018_sub_domains")] + + operations = [ + migrations.RunPython(forward, reverse), + ] diff --git a/backend/src/baserow/contrib/builder/populate.py b/backend/src/baserow/contrib/builder/populate.py index 9d021558f..3b7acc2da 100644 --- a/backend/src/baserow/contrib/builder/populate.py +++ b/backend/src/baserow/contrib/builder/populate.py @@ -4,7 +4,7 @@ from django.db import transaction from faker import Faker from baserow.contrib.builder.data_sources.handler import DataSourceHandler -from baserow.contrib.builder.domains.models import Domain +from baserow.contrib.builder.domains.models import CustomDomain from baserow.contrib.builder.elements.handler import ElementHandler from baserow.contrib.builder.elements.registries import element_type_registry from baserow.contrib.builder.models import Builder @@ -38,12 +38,18 @@ def load_test_data(): user, workspace, "builder", name="Back to local website" ) - Domain.objects.filter(domain_name="test1.getbaserow.io").delete() - Domain.objects.filter(domain_name="test2.getbaserow.io").delete() - Domain.objects.filter(domain_name="test3.getbaserow.io").delete() - Domain.objects.create(builder=builder, domain_name="test1.getbaserow.io", order=1) - Domain.objects.create(builder=builder, domain_name="test2.getbaserow.io", order=2) - Domain.objects.create(builder=builder, domain_name="test3.getbaserow.io", order=3) + CustomDomain.objects.filter(domain_name="test1.getbaserow.io").delete() + CustomDomain.objects.filter(domain_name="test2.getbaserow.io").delete() + CustomDomain.objects.filter(domain_name="test3.getbaserow.io").delete() + CustomDomain.objects.create( + builder=builder, domain_name="test1.getbaserow.io", order=1 + ) + CustomDomain.objects.create( + builder=builder, domain_name="test2.getbaserow.io", order=2 + ) + CustomDomain.objects.create( + builder=builder, domain_name="test3.getbaserow.io", order=3 + ) integration_type = integration_type_registry.get("local_baserow") diff --git a/backend/src/baserow/test_utils/fixtures/domain.py b/backend/src/baserow/test_utils/fixtures/domain.py index ef72f3082..b1fe601d2 100644 --- a/backend/src/baserow/test_utils/fixtures/domain.py +++ b/backend/src/baserow/test_utils/fixtures/domain.py @@ -1,8 +1,14 @@ -from baserow.contrib.builder.domains.models import Domain +from baserow.contrib.builder.domains.models import CustomDomain, SubDomain class DomainFixtures: - def create_builder_domain(self, user=None, **kwargs): + def create_builder_custom_domain(self, user=None, **kwargs): + return self.create_builder_domain(CustomDomain, user, **kwargs) + + def create_builder_sub_domain(self, user=None, **kwargs): + return self.create_builder_domain(SubDomain, user, **kwargs) + + def create_builder_domain(self, model_class, user=None, **kwargs): if user is None: user = self.create_user() @@ -15,6 +21,6 @@ class DomainFixtures: if "order" not in kwargs: kwargs["order"] = 0 - domain = Domain.objects.create(**kwargs) + domain = model_class.objects.create(**kwargs) return domain diff --git a/backend/tests/baserow/contrib/builder/api/domains/test_domain_public_views.py b/backend/tests/baserow/contrib/builder/api/domains/test_domain_public_views.py index a5e2401d0..f56cd11ed 100644 --- a/backend/tests/baserow/contrib/builder/api/domains/test_domain_public_views.py +++ b/backend/tests/baserow/contrib/builder/api/domains/test_domain_public_views.py @@ -18,7 +18,7 @@ def test_get_public_builder_by_domain_name(api_client, data_fixture): page = data_fixture.create_builder_page(user=user, builder=builder_to) page2 = data_fixture.create_builder_page(user=user, builder=builder_to) - domain = data_fixture.create_builder_domain( + domain = data_fixture.create_builder_custom_domain( domain_name="test.getbaserow.io", published_to=builder_to ) @@ -56,7 +56,7 @@ def test_get_builder_missing_domain_name(api_client, data_fixture): page = data_fixture.create_builder_page(user=user) page2 = data_fixture.create_builder_page(builder=page.builder, user=user) - domain = data_fixture.create_builder_domain( + domain = data_fixture.create_builder_custom_domain( domain_name="test.getbaserow.io", published_to=page.builder ) @@ -78,7 +78,7 @@ def test_get_non_public_builder(api_client, data_fixture): user, token = data_fixture.create_user_and_token() page = data_fixture.create_builder_page(user=user) page2 = data_fixture.create_builder_page(builder=page.builder, user=user) - domain = data_fixture.create_builder_domain( + domain = data_fixture.create_builder_custom_domain( domain_name="test.getbaserow.io", builder=page.builder ) @@ -148,7 +148,7 @@ def test_publish_builder(mock_run_async_job, api_client, data_fixture): page = data_fixture.create_builder_page(builder=builder_from, user=user) page2 = data_fixture.create_builder_page(builder=builder_from, user=user) - domain = data_fixture.create_builder_domain( + domain = data_fixture.create_builder_custom_domain( domain_name="test.getbaserow.io", builder=builder_from ) @@ -182,7 +182,7 @@ def test_get_elements_of_public_builder(api_client, data_fixture): element2 = data_fixture.create_builder_heading_element(page=page) element3 = data_fixture.create_builder_paragraph_element(page=page) - domain = data_fixture.create_builder_domain( + domain = data_fixture.create_builder_custom_domain( domain_name="test.getbaserow.io", published_to=page.builder, builder=builder_from, @@ -238,7 +238,7 @@ def test_get_data_source_of_public_builder(api_client, data_fixture): page=page ) - domain = data_fixture.create_builder_domain( + domain = data_fixture.create_builder_custom_domain( domain_name="test.getbaserow.io", published_to=page.builder, builder=builder_from, diff --git a/backend/tests/baserow/contrib/builder/api/domains/test_domain_views.py b/backend/tests/baserow/contrib/builder/api/domains/test_domain_views.py index 9c06e4799..b4986fc87 100644 --- a/backend/tests/baserow/contrib/builder/api/domains/test_domain_views.py +++ b/backend/tests/baserow/contrib/builder/api/domains/test_domain_views.py @@ -1,3 +1,4 @@ +from django.test.utils import override_settings from django.urls import reverse import pytest @@ -9,13 +10,15 @@ from rest_framework.status import ( HTTP_404_NOT_FOUND, ) +from baserow.contrib.builder.domains.domain_types import CustomDomainType, SubDomainType + @pytest.mark.django_db def test_get_domains(api_client, data_fixture): user, token = data_fixture.create_user_and_token() builder = data_fixture.create_builder_application(user=user) - domain_one = data_fixture.create_builder_domain(builder=builder) - domain_two = data_fixture.create_builder_domain(builder=builder) + domain_one = data_fixture.create_builder_custom_domain(builder=builder) + domain_two = data_fixture.create_builder_custom_domain(builder=builder) url = reverse( "api:builder:builder_id:domains:list", kwargs={"builder_id": builder.id} @@ -62,7 +65,7 @@ def test_create_domain(api_client, data_fixture): ) response = api_client.post( url, - {"domain_name": domain_name}, + {"type": CustomDomainType.type, "domain_name": domain_name}, format="json", HTTP_AUTHORIZATION=f"JWT {token}", ) @@ -82,7 +85,7 @@ def test_create_domain_user_not_in_workspace(api_client, data_fixture): ) response = api_client.post( url, - {"domain_name": "test.com"}, + {"type": CustomDomainType.type, "domain_name": "test.com"}, format="json", HTTP_AUTHORIZATION=f"JWT {token}", ) @@ -98,7 +101,7 @@ def test_create_domain_application_does_not_exist(api_client, data_fixture): url = reverse("api:builder:builder_id:domains:list", kwargs={"builder_id": 9999}) response = api_client.post( url, - {"domain_name": "test.com"}, + {"type": CustomDomainType.type, "domain_name": "test.com"}, format="json", HTTP_AUTHORIZATION=f"JWT {token}", ) @@ -114,14 +117,14 @@ def test_create_domain_domain_already_exists(api_client, data_fixture): domain_name = "test.com" - data_fixture.create_builder_domain(builder=builder, domain_name=domain_name) + data_fixture.create_builder_custom_domain(builder=builder, domain_name=domain_name) url = reverse( "api:builder:builder_id:domains:list", kwargs={"builder_id": builder.id} ) response = api_client.post( url, - {"domain_name": domain_name}, + {"type": CustomDomainType.type, "domain_name": domain_name}, format="json", HTTP_AUTHORIZATION=f"JWT {token}", ) @@ -142,7 +145,7 @@ def test_create_domain_invalid_domain_name(api_client, data_fixture): ) response = api_client.post( url, - {"domain_name": domain_name}, + {"type": CustomDomainType.type, "domain_name": domain_name}, format="json", HTTP_AUTHORIZATION=f"JWT {token}", ) @@ -151,11 +154,36 @@ def test_create_domain_invalid_domain_name(api_client, data_fixture): assert response.json()["error"] == "ERROR_REQUEST_BODY_VALIDATION" +@pytest.mark.django_db +@override_settings(BASEROW_BUILDER_DOMAINS=["test.com"]) +def test_create_invalid_sub_domain(api_client, data_fixture): + user, token = data_fixture.create_user_and_token() + builder = data_fixture.create_builder_application(user=user) + + domain_name = "hello.nottest.com" + + url = reverse( + "api:builder:builder_id:domains:list", kwargs={"builder_id": builder.id} + ) + response = api_client.post( + url, + {"type": SubDomainType.type, "domain_name": domain_name}, + format="json", + HTTP_AUTHORIZATION=f"JWT {token}", + ) + + response_json = response.json() + assert response.status_code == HTTP_400_BAD_REQUEST + assert response_json["error"] == "ERROR_SUB_DOMAIN_HAS_INVALID_DOMAIN_NAME" + assert "test.com" in response_json["detail"] + assert "nottest.com" in response_json["detail"] + + @pytest.mark.django_db def test_update_domain(api_client, data_fixture): user, token = data_fixture.create_user_and_token() builder = data_fixture.create_builder_application(user=user) - domain = data_fixture.create_builder_domain( + domain = data_fixture.create_builder_custom_domain( builder=builder, domain_name="something.com" ) @@ -187,12 +215,45 @@ def test_update_domain_domain_does_not_exist(api_client, data_fixture): assert response.json()["error"] == "ERROR_DOMAIN_DOES_NOT_EXIST" +@pytest.mark.django_db +def test_update_domain_with_same_name(api_client, data_fixture): + user, token = data_fixture.create_user_and_token() + domain = data_fixture.create_builder_custom_domain(user=user) + + url = reverse("api:builder:domains:item", kwargs={"domain_id": domain.id}) + response = api_client.patch( + url, + {"domain_name": domain.domain_name}, + format="json", + HTTP_AUTHORIZATION=f"JWT {token}", + ) + + assert response.status_code == HTTP_200_OK + + +@pytest.mark.django_db +def test_update_domain_name_uniqueness(api_client, data_fixture): + user, token = data_fixture.create_user_and_token() + domain = data_fixture.create_builder_custom_domain(user=user) + domain_2 = data_fixture.create_builder_custom_domain(user=user) + + url = reverse("api:builder:domains:item", kwargs={"domain_id": domain.id}) + response = api_client.patch( + url, + {"domain_name": domain_2.domain_name}, + format="json", + HTTP_AUTHORIZATION=f"JWT {token}", + ) + + assert response.status_code == HTTP_400_BAD_REQUEST, response.json() + + @pytest.mark.django_db def test_order_domains(api_client, data_fixture): user, token = data_fixture.create_user_and_token() builder = data_fixture.create_builder_application(user=user) - domain_one = data_fixture.create_builder_domain(builder=builder, order=1) - domain_two = data_fixture.create_builder_domain(builder=builder, order=2) + domain_one = data_fixture.create_builder_custom_domain(builder=builder, order=1) + domain_two = data_fixture.create_builder_custom_domain(builder=builder, order=2) url = reverse( "api:builder:builder_id:domains:order", kwargs={"builder_id": builder.id} @@ -211,8 +272,8 @@ def test_order_domains(api_client, data_fixture): def test_order_domains_user_not_in_workspace(api_client, data_fixture): user, token = data_fixture.create_user_and_token() builder = data_fixture.create_builder_application() - domain_one = data_fixture.create_builder_domain(builder=builder, order=1) - domain_two = data_fixture.create_builder_domain(builder=builder, order=2) + domain_one = data_fixture.create_builder_custom_domain(builder=builder, order=1) + domain_two = data_fixture.create_builder_custom_domain(builder=builder, order=2) url = reverse( "api:builder:builder_id:domains:order", kwargs={"builder_id": builder.id} @@ -232,8 +293,8 @@ def test_order_domains_user_not_in_workspace(api_client, data_fixture): def test_order_domains_domain_not_in_builder(api_client, data_fixture): user, token = data_fixture.create_user_and_token() builder = data_fixture.create_builder_application(user=user) - domain_one = data_fixture.create_builder_domain(builder=builder, order=1) - domain_two = data_fixture.create_builder_domain(order=2) + domain_one = data_fixture.create_builder_custom_domain(builder=builder, order=1) + domain_two = data_fixture.create_builder_custom_domain(order=2) url = reverse( "api:builder:builder_id:domains:order", kwargs={"builder_id": builder.id} @@ -253,8 +314,8 @@ def test_order_domains_domain_not_in_builder(api_client, data_fixture): def test_order_domains_application_does_not_exist(api_client, data_fixture): user, token = data_fixture.create_user_and_token() builder = data_fixture.create_builder_application(user=user) - domain_one = data_fixture.create_builder_domain(builder=builder, order=1) - domain_two = data_fixture.create_builder_domain(builder=builder, order=2) + domain_one = data_fixture.create_builder_custom_domain(builder=builder, order=1) + domain_two = data_fixture.create_builder_custom_domain(builder=builder, order=2) url = reverse("api:builder:builder_id:domains:order", kwargs={"builder_id": 99999}) response = api_client.post( @@ -272,7 +333,7 @@ def test_order_domains_application_does_not_exist(api_client, data_fixture): def test_delete_domain(api_client, data_fixture): user, token = data_fixture.create_user_and_token() builder = data_fixture.create_builder_application(user=user) - domain = data_fixture.create_builder_domain(builder=builder, order=1) + domain = data_fixture.create_builder_custom_domain(builder=builder, order=1) url = reverse("api:builder:domains:item", kwargs={"domain_id": domain.id}) response = api_client.delete( @@ -288,7 +349,7 @@ def test_delete_domain(api_client, data_fixture): def test_delete_domain_user_not_in_workspace(api_client, data_fixture): user, token = data_fixture.create_user_and_token() builder = data_fixture.create_builder_application() - domain = data_fixture.create_builder_domain(builder=builder, order=1) + domain = data_fixture.create_builder_custom_domain(builder=builder, order=1) url = reverse("api:builder:domains:item", kwargs={"domain_id": domain.id}) response = api_client.delete( diff --git a/backend/tests/baserow/contrib/builder/domains/test_domain_handler.py b/backend/tests/baserow/contrib/builder/domains/test_domain_handler.py index 4e868c75e..de8f8b52a 100644 --- a/backend/tests/baserow/contrib/builder/domains/test_domain_handler.py +++ b/backend/tests/baserow/contrib/builder/domains/test_domain_handler.py @@ -1,5 +1,6 @@ import pytest +from baserow.contrib.builder.domains.domain_types import CustomDomainType from baserow.contrib.builder.domains.exceptions import ( DomainDoesNotExist, DomainNotInBuilder, @@ -13,7 +14,7 @@ from baserow.core.utils import Progress @pytest.mark.django_db def test_get_domain(data_fixture): - domain = data_fixture.create_builder_domain() + domain = data_fixture.create_builder_custom_domain() assert DomainHandler().get_domain(domain.id).id == domain.id @@ -25,7 +26,7 @@ def test_get_domain_domain_does_not_exist(data_fixture): @pytest.mark.django_db def test_get_domain_base_queryset(data_fixture, django_assert_num_queries): - domain = data_fixture.create_builder_domain() + domain = data_fixture.create_builder_custom_domain() # Without selecting related domain = DomainHandler().get_domain(domain.id) @@ -42,8 +43,8 @@ def test_get_domain_base_queryset(data_fixture, django_assert_num_queries): @pytest.mark.django_db def test_get_domains(data_fixture): builder = data_fixture.create_builder_application() - domain_one = data_fixture.create_builder_domain(builder=builder) - domain_two = data_fixture.create_builder_domain(builder=builder) + domain_one = data_fixture.create_builder_custom_domain(builder=builder) + domain_two = data_fixture.create_builder_custom_domain(builder=builder) domains = list(DomainHandler().get_domains(builder)) domain_ids = [domain.id for domain in domains] @@ -58,7 +59,9 @@ def test_create_domain(data_fixture): builder = data_fixture.create_builder_application() expected_order = Domain.get_last_order(builder) - domain = DomainHandler().create_domain(builder, "test.com") + domain = DomainHandler().create_domain( + CustomDomainType(), builder, domain_name="test.com" + ) assert domain.order == expected_order assert domain.domain_name == "test.com" @@ -66,7 +69,7 @@ def test_create_domain(data_fixture): @pytest.mark.django_db def test_delete_domain(data_fixture): - domain = data_fixture.create_builder_domain() + domain = data_fixture.create_builder_custom_domain() DomainHandler().delete_domain(domain) @@ -75,7 +78,7 @@ def test_delete_domain(data_fixture): @pytest.mark.django_db def test_update_domain(data_fixture): - domain = data_fixture.create_builder_domain(domain_name="test.com") + domain = data_fixture.create_builder_custom_domain(domain_name="test.com") DomainHandler().update_domain(domain, domain_name="new.com") @@ -87,8 +90,8 @@ def test_update_domain(data_fixture): @pytest.mark.django_db def test_order_domains(data_fixture): builder = data_fixture.create_builder_application() - domain_one = data_fixture.create_builder_domain(builder=builder, order=1) - domain_two = data_fixture.create_builder_domain(builder=builder, order=2) + domain_one = data_fixture.create_builder_custom_domain(builder=builder, order=1) + domain_two = data_fixture.create_builder_custom_domain(builder=builder, order=2) assert DomainHandler().order_domains(builder, [domain_two.id, domain_one.id]) == [ domain_two.id, @@ -105,8 +108,8 @@ def test_order_domains(data_fixture): @pytest.mark.django_db def test_order_domains_domain_not_in_builder(data_fixture): builder = data_fixture.create_builder_application() - domain_one = data_fixture.create_builder_domain(builder=builder, order=1) - domain_two = data_fixture.create_builder_domain(builder=builder, order=2) + domain_one = data_fixture.create_builder_custom_domain(builder=builder, order=1) + domain_two = data_fixture.create_builder_custom_domain(builder=builder, order=2) base_qs = Domain.objects.filter(id=domain_two.id) @@ -120,7 +123,7 @@ def test_order_domains_domain_not_in_builder(data_fixture): def test_get_public_builder_by_name(data_fixture): builder = data_fixture.create_builder_application() builder_to = data_fixture.create_builder_application(workspace=None) - domain1 = data_fixture.create_builder_domain( + domain1 = data_fixture.create_builder_custom_domain( builder=builder, published_to=builder_to ) @@ -132,7 +135,7 @@ def test_get_public_builder_by_name(data_fixture): @pytest.mark.django_db def test_get_published_builder_by_missing_domain_name(data_fixture): builder = data_fixture.create_builder_application() - domain1 = data_fixture.create_builder_domain(builder=builder) + domain1 = data_fixture.create_builder_custom_domain(builder=builder) with pytest.raises(BuilderDoesNotExist): DomainHandler().get_public_builder_by_domain_name(domain1.domain_name) @@ -142,7 +145,7 @@ def test_get_published_builder_by_missing_domain_name(data_fixture): def test_get_published_builder_for_trashed_builder(data_fixture): builder = data_fixture.create_builder_application(trashed=True) builder_to = data_fixture.create_builder_application(workspace=None) - domain1 = data_fixture.create_builder_domain( + domain1 = data_fixture.create_builder_custom_domain( builder=builder, published_to=builder_to ) @@ -151,7 +154,7 @@ def test_get_published_builder_for_trashed_builder(data_fixture): builder = data_fixture.create_builder_application() builder_to = data_fixture.create_builder_application(workspace=None) - domain1 = data_fixture.create_builder_domain( + domain1 = data_fixture.create_builder_custom_domain( builder=builder, published_to=builder_to, trashed=True ) @@ -163,7 +166,7 @@ def test_get_published_builder_for_trashed_builder(data_fixture): def test_domain_publishing(data_fixture): builder = data_fixture.create_builder_application() - domain1 = data_fixture.create_builder_domain(builder=builder) + domain1 = data_fixture.create_builder_custom_domain(builder=builder) page1 = data_fixture.create_builder_page(builder=builder) page2 = data_fixture.create_builder_page(builder=builder) diff --git a/backend/tests/baserow/contrib/builder/domains/test_domain_permission_manager.py b/backend/tests/baserow/contrib/builder/domains/test_domain_permission_manager.py index 0a2535e3d..3f94f652e 100644 --- a/backend/tests/baserow/contrib/builder/domains/test_domain_permission_manager.py +++ b/backend/tests/baserow/contrib/builder/domains/test_domain_permission_manager.py @@ -22,10 +22,10 @@ def test_allow_public_builder_manager_type(data_fixture): user = data_fixture.create_user() builder = data_fixture.create_builder_application(user=user) builder_to = data_fixture.create_builder_application(workspace=None) - domain1 = data_fixture.create_builder_domain( + domain1 = data_fixture.create_builder_custom_domain( builder=builder, published_to=builder_to ) - domain2 = data_fixture.create_builder_domain(builder=builder) + domain2 = data_fixture.create_builder_custom_domain(builder=builder) public_page = data_fixture.create_builder_page(builder=builder_to) non_public_page = data_fixture.create_builder_page(builder=builder) diff --git a/backend/tests/baserow/contrib/builder/domains/test_domain_service.py b/backend/tests/baserow/contrib/builder/domains/test_domain_service.py index 9f30a0efa..4b3017141 100644 --- a/backend/tests/baserow/contrib/builder/domains/test_domain_service.py +++ b/backend/tests/baserow/contrib/builder/domains/test_domain_service.py @@ -2,6 +2,7 @@ from unittest.mock import patch import pytest +from baserow.contrib.builder.domains.domain_types import CustomDomainType from baserow.contrib.builder.domains.exceptions import DomainNotInBuilder from baserow.contrib.builder.domains.models import Domain from baserow.contrib.builder.domains.service import DomainService @@ -15,7 +16,9 @@ def test_domain_created_signal_sent(domain_created_mock, data_fixture): user = data_fixture.create_user() builder = data_fixture.create_builder_application(user=user) - domain = DomainService().create_domain(user, builder, "test") + domain = DomainService().create_domain( + user, CustomDomainType(), builder, domain_name="test" + ) assert domain_created_mock.called_with(domain=domain, user=user) @@ -26,7 +29,9 @@ def test_create_domain_user_not_in_workspace(data_fixture): builder = data_fixture.create_builder_application() with pytest.raises(UserNotInWorkspace): - DomainService().create_domain(user, builder, "test") + DomainService().create_domain( + user, CustomDomainType, builder, domain_name="test" + ) @patch("baserow.contrib.builder.domains.service.domain_deleted") @@ -34,7 +39,7 @@ def test_create_domain_user_not_in_workspace(data_fixture): def test_domain_deleted_signal_sent(domain_deleted_mock, data_fixture): user = data_fixture.create_user() builder = data_fixture.create_builder_application(user=user) - domain = data_fixture.create_builder_domain(builder=builder) + domain = data_fixture.create_builder_custom_domain(builder=builder) DomainService().delete_domain(user, domain) @@ -49,7 +54,9 @@ def test_delete_domain_user_not_in_workspace(data_fixture): user_unrelated = data_fixture.create_user() builder = data_fixture.create_builder_application(user=user) - domain = DomainService().create_domain(user, builder, "test") + domain = DomainService().create_domain( + user, CustomDomainType(), builder, domain_name="test" + ) with pytest.raises(UserNotInWorkspace): DomainService().delete_domain(user_unrelated, domain) @@ -61,7 +68,7 @@ def test_delete_domain_user_not_in_workspace(data_fixture): def test_get_domain_user_not_in_workspace(data_fixture): user = data_fixture.create_user() builder = data_fixture.create_builder_application() - domain = data_fixture.create_builder_domain(builder=builder) + domain = data_fixture.create_builder_custom_domain(builder=builder) with pytest.raises(UserNotInWorkspace): DomainService().get_domain(user, domain.id) @@ -71,7 +78,7 @@ def test_get_domain_user_not_in_workspace(data_fixture): def test_get_domains_user_not_in_workspace(data_fixture): user = data_fixture.create_user() builder = data_fixture.create_builder_application() - domain = data_fixture.create_builder_domain(builder=builder) + domain = data_fixture.create_builder_custom_domain(builder=builder) with pytest.raises(UserNotInWorkspace): DomainService().get_domains(user, builder) @@ -81,8 +88,8 @@ def test_get_domains_user_not_in_workspace(data_fixture): def test_get_domains_partial_permissions(data_fixture, stub_check_permissions): user = data_fixture.create_user() builder = data_fixture.create_builder_application(user=user) - domain_with_access = data_fixture.create_builder_domain(builder=builder) - domain_without_access = data_fixture.create_builder_domain(builder=builder) + domain_with_access = data_fixture.create_builder_custom_domain(builder=builder) + domain_without_access = data_fixture.create_builder_custom_domain(builder=builder) def exclude_domain_without_access( actor, @@ -107,7 +114,7 @@ def test_get_domains_partial_permissions(data_fixture, stub_check_permissions): def test_domain_updated_signal_sent(domain_updated_mock, data_fixture): user = data_fixture.create_user() builder = data_fixture.create_builder_application(user=user) - domain = data_fixture.create_builder_domain(builder=builder) + domain = data_fixture.create_builder_custom_domain(builder=builder) DomainService().update_domain(user, domain, domain_name="new.com") @@ -118,7 +125,7 @@ def test_domain_updated_signal_sent(domain_updated_mock, data_fixture): def test_update_domain_user_not_in_workspace(data_fixture): user = data_fixture.create_user() builder = data_fixture.create_builder_application() - domain = data_fixture.create_builder_domain(builder=builder) + domain = data_fixture.create_builder_custom_domain(builder=builder) with pytest.raises(UserNotInWorkspace): DomainService().update_domain(user, domain, domain_name="test.com") @@ -128,7 +135,7 @@ def test_update_domain_user_not_in_workspace(data_fixture): def test_update_domain_invalid_values(data_fixture): user = data_fixture.create_user() builder = data_fixture.create_builder_application(user=user) - domain = data_fixture.create_builder_domain(builder=builder) + domain = data_fixture.create_builder_custom_domain(builder=builder) domain_updated = DomainService().update_domain(user, domain, nonsense="hello") @@ -140,8 +147,8 @@ def test_update_domain_invalid_values(data_fixture): def test_domains_reordered_signal_sent(domains_reordered_mock, data_fixture): user = data_fixture.create_user() builder = data_fixture.create_builder_application(user=user) - domain_one = data_fixture.create_builder_domain(builder=builder, order=1) - domain_two = data_fixture.create_builder_domain(builder=builder, order=2) + domain_one = data_fixture.create_builder_custom_domain(builder=builder, order=1) + domain_two = data_fixture.create_builder_custom_domain(builder=builder, order=2) full_order = DomainService().order_domains( user, builder, [domain_two.id, domain_one.id] @@ -156,8 +163,8 @@ def test_domains_reordered_signal_sent(domains_reordered_mock, data_fixture): def test_order_domains_user_not_in_workspace(data_fixture): user = data_fixture.create_user() builder = data_fixture.create_builder_application() - domain_one = data_fixture.create_builder_domain(builder=builder, order=1) - domain_two = data_fixture.create_builder_domain(builder=builder, order=2) + domain_one = data_fixture.create_builder_custom_domain(builder=builder, order=1) + domain_two = data_fixture.create_builder_custom_domain(builder=builder, order=2) with pytest.raises(UserNotInWorkspace): DomainService().order_domains(user, builder, [domain_two.id, domain_one.id]) @@ -167,8 +174,8 @@ def test_order_domains_user_not_in_workspace(data_fixture): def test_order_domains_domain_not_in_builder(data_fixture): user = data_fixture.create_user() builder = data_fixture.create_builder_application(user=user) - domain_one = data_fixture.create_builder_domain(builder=builder, order=1) - domain_two = data_fixture.create_builder_domain(order=2) + domain_one = data_fixture.create_builder_custom_domain(builder=builder, order=1) + domain_two = data_fixture.create_builder_custom_domain(order=2) with pytest.raises(DomainNotInBuilder): DomainService().order_domains(user, builder, [domain_two.id, domain_one.id]) @@ -179,7 +186,7 @@ def test_get_published_builder_by_domain_name(data_fixture): user = data_fixture.create_user() builder = data_fixture.create_builder_application(user=user) builder_to = data_fixture.create_builder_application(workspace=None) - domain1 = data_fixture.create_builder_domain( + domain1 = data_fixture.create_builder_custom_domain( builder=builder, published_to=builder_to ) @@ -197,7 +204,7 @@ def test_get_published_builder_by_domain_name_unauthorized( user = data_fixture.create_user() builder = data_fixture.create_builder_application(user=user) builder_to = data_fixture.create_builder_application(workspace=None) - domain1 = data_fixture.create_builder_domain( + domain1 = data_fixture.create_builder_custom_domain( builder=builder, published_to=builder_to ) @@ -213,7 +220,7 @@ def test_async_publish_domain(mock_run_async_job, data_fixture): user = data_fixture.create_user() builder = data_fixture.create_builder_application(user=user) - domain1 = data_fixture.create_builder_domain(builder=builder) + domain1 = data_fixture.create_builder_custom_domain(builder=builder) job = DomainService().async_publish(user, domain1) @@ -225,7 +232,7 @@ def test_async_publish_domain(mock_run_async_job, data_fixture): @pytest.mark.django_db(transaction=True) def test_async_publish_domain_no_permission(data_fixture, stub_check_permissions): user = data_fixture.create_user() - domain1 = data_fixture.create_builder_domain() + domain1 = data_fixture.create_builder_custom_domain() with stub_check_permissions(raise_permission_denied=True), pytest.raises( PermissionException @@ -239,7 +246,7 @@ def test_publish_domain(domain_updated_mock, data_fixture): user = data_fixture.create_user() builder = data_fixture.create_builder_application(user=user) - domain1 = data_fixture.create_builder_domain(builder=builder) + domain1 = data_fixture.create_builder_custom_domain(builder=builder) progress = Progress(100) domain = DomainService().publish(user, domain1, progress) @@ -252,7 +259,7 @@ def test_publish_domain_unauthorized(data_fixture, stub_check_permissions): user = data_fixture.create_user() builder = data_fixture.create_builder_application(user=user) - domain1 = data_fixture.create_builder_domain(builder=builder) + domain1 = data_fixture.create_builder_custom_domain(builder=builder) progress = Progress(100) diff --git a/backend/tests/baserow/contrib/builder/domains/test_publish_domain_job_type.py b/backend/tests/baserow/contrib/builder/domains/test_publish_domain_job_type.py index ad662b89e..090d46209 100644 --- a/backend/tests/baserow/contrib/builder/domains/test_publish_domain_job_type.py +++ b/backend/tests/baserow/contrib/builder/domains/test_publish_domain_job_type.py @@ -9,7 +9,7 @@ from baserow.core.jobs.handler import JobHandler def test_publish_domain_job_type(data_fixture): user = data_fixture.create_user() builder = data_fixture.create_builder_application(user=user) - domain1 = data_fixture.create_builder_domain(builder=builder) + domain1 = data_fixture.create_builder_custom_domain(builder=builder) publish_domain_job = JobHandler().create_and_start_job( user, diff --git a/backend/tests/baserow/contrib/builder/test_builder_application_type.py b/backend/tests/baserow/contrib/builder/test_builder_application_type.py index b852d443a..862567ccb 100644 --- a/backend/tests/baserow/contrib/builder/test_builder_application_type.py +++ b/backend/tests/baserow/contrib/builder/test_builder_application_type.py @@ -410,7 +410,7 @@ def test_builder_application_import(data_fixture): def test_delete_builder_application_with_published_builder(data_fixture): builder = data_fixture.create_builder_application() builder_to = data_fixture.create_builder_application(workspace=None) - domain1 = data_fixture.create_builder_domain( + domain1 = data_fixture.create_builder_custom_domain( builder=builder, published_to=builder_to ) diff --git a/docker-compose.local-build.yml b/docker-compose.local-build.yml index 523c3e181..ca90bd07a 100644 --- a/docker-compose.local-build.yml +++ b/docker-compose.local-build.yml @@ -139,6 +139,7 @@ x-backend-variables: &backend-variables BASEROW_DISABLE_LOCKED_MIGRATIONS: BASEROW_USE_PG_FULLTEXT_SEARCH: BASEROW_AUTO_VACUUM: + BASEROW_BUILDER_DOMAINS: services: # A caddy reverse proxy sitting in-front of all the services. Responsible for routing @@ -206,6 +207,7 @@ services: BASEROW_USE_PG_FULLTEXT_SEARCH: BASEROW_UNIQUE_ROW_VALUES_SIZE_LIMIT: BASEROW_ROW_PAGE_SIZE_LIMIT: + BASEROW_BUILDER_DOMAINS: depends_on: - backend networks: diff --git a/docker-compose.no-caddy.yml b/docker-compose.no-caddy.yml index d31cd8d4d..3c7f3a505 100644 --- a/docker-compose.no-caddy.yml +++ b/docker-compose.no-caddy.yml @@ -158,6 +158,7 @@ x-backend-variables: &backend-variables BASEROW_DISABLE_LOCKED_MIGRATIONS: BASEROW_USE_PG_FULLTEXT_SEARCH: BASEROW_AUTO_VACUUM: + BASEROW_BUILDER_DOMAINS: services: backend: @@ -201,6 +202,7 @@ services: BASEROW_USE_PG_FULLTEXT_SEARCH: BASEROW_UNIQUE_ROW_VALUES_SIZE_LIMIT: BASEROW_ROW_PAGE_SIZE_LIMIT: + BASEROW_BUILDER_DOMAINS: depends_on: - backend networks: diff --git a/docker-compose.yml b/docker-compose.yml index 7866bffb1..5081a1765 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -157,6 +157,7 @@ x-backend-variables: &backend-variables BASEROW_DISABLE_LOCKED_MIGRATIONS: BASEROW_USE_PG_FULLTEXT_SEARCH: BASEROW_AUTO_VACUUM: + BASEROW_BUILDER_DOMAINS: services: # A caddy reverse proxy sitting in-front of all the services. Responsible for routing @@ -220,6 +221,7 @@ services: POSTHOG_HOST: BASEROW_UNIQUE_ROW_VALUES_SIZE_LIMIT: BASEROW_ROW_PAGE_SIZE_LIMIT: + BASEROW_BUILDER_DOMAINS: depends_on: - backend networks: diff --git a/docs/installation/configuration.md b/docs/installation/configuration.md index 9e36d1324..320662bae 100644 --- a/docs/installation/configuration.md +++ b/docs/installation/configuration.md @@ -139,6 +139,12 @@ The installation methods referred to in the variable descriptions are: | BASEROW\_INITIAL\_CREATE\_SYNC\_TABLE\_DATA\_LIMIT | The maximum number of rows you can import in a synchronous way | 5000 | | BASEROW\_MAX\_ROW\_REPORT\_ERROR\_COUNT | The maximum row error count tolerated before a file import fails. Before this max error count the import will continue and the non failing rows will be imported and after it, no rows are imported at all. | 30 | +### Backend Application Builder Configuration +| Name | Description | Defaults | +|---------------------------|--------------------------------------------------------------------------------------------------------------------------|------------------------| +| BASEROW\_BUILDER\_DOMAINS | A comma separated list of domain names that can be used as the domains to create sub domains in the application builder. | | + + ### User file upload Configuration Baserow needs somewhere to store the following types of files: @@ -242,6 +248,8 @@ domain than your Baserow, you need to make sure CORS is configured correctly. | BASEROW_MAX_SNAPSHOTS_PER_GROUP | Controls how many application snapshots can be created per workspace. | -1 (unlimited) | | BASEROW\_USE\_PG\_FULLTEXT\_SEARCH | By default, Baserow will use Postgres full-text as its search backend. If the product is installed on a system with limited disk space, and less accurate results / degraded search performance is acceptable, then switch this setting off by setting it to false. | true | | BASEROW\_UNIQUE\_ROW\_VALUES\_SIZE\_LIMIT | Sets the limit for the automatic detection of multiselect options when converting a text field to a multiselect field. Increase the value to detect more options automatically, but consider performance implications. | 100 | +| BASEROW\_BUILDER\_DOMAINS | A comma separated list of domain names that can be used as the domains to create sub domains in the application builder. | | + ### SSO Configuration | Name | Description | Defaults | diff --git a/web-frontend/modules/builder/components/domain/CustomDomainForm.vue b/web-frontend/modules/builder/components/domain/CustomDomainForm.vue index 530cd8b33..4dea4dfb6 100644 --- a/web-frontend/modules/builder/components/domain/CustomDomainForm.vue +++ b/web-frontend/modules/builder/components/domain/CustomDomainForm.vue @@ -29,26 +29,25 @@ </div> <template v-if="serverErrors.domain_name"> <div v-if="serverErrors.domain_name.code === 'invalid'" class="error"> - {{ $t('customDomainForm.invalidDomain') }} + {{ $t('domainForm.invalidDomain') }} </div> <div v-if="serverErrors.domain_name.code === 'unique'" class="error"> - {{ $t('customDomainForm.notUniqueDomain') }} - </div></template - > + {{ $t('domainForm.notUniqueDomain') }} + </div> + </template> </FormElement> </form> </template> <script> -import form from '@baserow/modules/core/mixins/form' import { required, maxLength } from 'vuelidate/lib/validators' +import domainForm from '@baserow/modules/builder/mixins/domainForm' export default { - name: 'DomainForm', - mixins: [form], + name: 'CustomDomainForm', + mixins: [domainForm], data() { return { - serverErrors: { domain_name: null }, values: { domain_name: '', }, @@ -64,22 +63,5 @@ export default { }, } }, - methods: { - handleServerError(error) { - if (error.handler.code !== 'ERROR_REQUEST_BODY_VALIDATION') return false - - this.serverErrors = Object.fromEntries( - Object.entries(error.handler.detail || {}).map(([key, value]) => [ - key, - value[0], - ]) - ) - - return true - }, - hasError() { - return !this.isFormValid() || this.serverErrors.domain_name !== null - }, - }, } </script> diff --git a/web-frontend/modules/builder/components/domain/DomainForm.vue b/web-frontend/modules/builder/components/domain/DomainForm.vue index 99fc6ae00..36cbf7c50 100644 --- a/web-frontend/modules/builder/components/domain/DomainForm.vue +++ b/web-frontend/modules/builder/components/domain/DomainForm.vue @@ -4,15 +4,15 @@ <Error :error="error"></Error> <FormElement class="control"> <Dropdown - v-model="selectedDomainType" + v-model="selectedDomain" :show-search="false" class="domain-settings__domain-type" > <DropdownItem - v-for="domainType in domainTypes" - :key="domainType.getType()" - :name="domainType.name" - :value="domainType.getType()" + v-for="option in options" + :key="option.value.domain" + :name="option.name" + :value="option.value" /> </Dropdown> </FormElement> @@ -20,7 +20,9 @@ :is="currentDomainType.formComponent" ref="domainForm" :builder="builder" + :domain="selectedDomain.domain" @submitted="createDomain($event)" + @error="formHasError = $event" /> <div class="actions"> <a @click="hideForm"> @@ -29,7 +31,7 @@ </a> <button class="button button--large" - :disabled="createLoading || formHasError()" + :disabled="createLoading || formHasError" :class="{ 'button--loading': createLoading }" @click="onSubmit" > @@ -58,17 +60,29 @@ export default { }, data() { return { - selectedDomainType: 'custom', + selectedDomain: { type: 'custom', domain: 'custom' }, createLoading: false, + formHasError: false, } }, computed: { domainTypes() { - return this.$registry.getAll('domain') + return Object.values(this.$registry.getAll('domain')) || [] + }, + selectedDomainType() { + return this.selectedDomain.type }, currentDomainType() { return this.$registry.get('domain', this.selectedDomainType) }, + options() { + return this.domainTypes.map((domainType) => domainType.options).flat() + }, + }, + watch: { + selectedDomainType() { + this.$refs?.domainForm?.reset() + }, }, methods: { ...mapActions({ @@ -92,13 +106,6 @@ export default { } this.createLoading = false }, - formHasError() { - if (this.$refs.domainForm) { - return this.$refs.domainForm.hasError() - } else { - return false - } - }, handleAnyError(error) { if ( !this.$refs.domainForm || diff --git a/web-frontend/modules/builder/components/domain/SubDomainDetails.vue b/web-frontend/modules/builder/components/domain/SubDomainDetails.vue new file mode 100644 index 000000000..58128815b --- /dev/null +++ b/web-frontend/modules/builder/components/domain/SubDomainDetails.vue @@ -0,0 +1,28 @@ +<template> + <div> + <p> + {{ $t('subDomainDetails.text') }} + </p> + <div class="actions actions--right"> + <a + class="button button--error" + :class="{ 'button--loading': domain._.loading }" + @click="$emit('delete')" + > + {{ $t('action.delete') }} + </a> + </div> + </div> +</template> + +<script> +export default { + name: 'SubDomainDetails', + props: { + domain: { + type: Object, + required: true, + }, + }, +} +</script> diff --git a/web-frontend/modules/builder/components/domain/SubDomainForm.vue b/web-frontend/modules/builder/components/domain/SubDomainForm.vue new file mode 100644 index 000000000..155e2441b --- /dev/null +++ b/web-frontend/modules/builder/components/domain/SubDomainForm.vue @@ -0,0 +1,59 @@ +<template> + <form @submit.prevent="submit"> + <FormElement :error="fieldHasErrors('domain_name')" class="control"> + <FormInput + v-model="domainPrefix" + :label="$t('subDomainForm.domainNameLabel')" + :error=" + $v.values.domain_name.$dirty && !$v.values.domain_name.required + ? $t('error.requiredField') + : $v.values.domain_name.$dirty && !$v.values.domain_name.maxLength + ? $t('error.maxLength', { max: 255 }) + : serverErrors.domain_name && + serverErrors.domain_name.code === 'invalid' + ? $t('domainForm.invalidDomain') + : serverErrors.domain_name && + serverErrors.domain_name.code === 'unique' + ? $t('domainForm.notUniqueDomain') + : '' + " + @input="serverErrors.domain_name = null" + > + <template #suffix> .{{ domain }} </template> + </FormInput> + </FormElement> + </form> +</template> + +<script> +import { maxLength, required } from 'vuelidate/lib/validators' +import domainForm from '@baserow/modules/builder/mixins/domainForm' + +export default { + name: 'SubDomainForm', + mixins: [domainForm], + data() { + return { + domainPrefix: '', + values: { + domain_name: '', + }, + } + }, + watch: { + domainPrefix(value) { + this.values.domain_name = `${value}.${this.domain}` + }, + }, + validations() { + return { + values: { + domain_name: { + required, + maxLength: maxLength(255), + }, + }, + } + }, +} +</script> diff --git a/web-frontend/modules/builder/domainTypes.js b/web-frontend/modules/builder/domainTypes.js index f4471be1e..e9a694565 100644 --- a/web-frontend/modules/builder/domainTypes.js +++ b/web-frontend/modules/builder/domainTypes.js @@ -1,6 +1,8 @@ import { Registerable } from '@baserow/modules/core/registry' import CustomDomainDetails from '@baserow/modules/builder/components/domain/CustomDomainDetails' import CustomDomainForm from '@baserow/modules/builder/components/domain/CustomDomainForm' +import SubDomainForm from '@baserow/modules/builder/components/domain/SubDomainForm' +import SubDomainDetails from '@baserow/modules/builder/components/domain/SubDomainDetails' export class DomainType extends Registerable { get name() { @@ -10,6 +12,14 @@ export class DomainType extends Registerable { get detailsComponent() { return null } + + get formComponent() { + return null + } + + get options() { + return [] + } } export class CustomDomainType extends DomainType { @@ -28,4 +38,44 @@ export class CustomDomainType extends DomainType { get formComponent() { return CustomDomainForm } + + get options() { + return [ + { + name: this.app.i18n.t('domainTypes.customName'), + value: { + type: this.getType(), + domain: 'custom', + }, + }, + ] + } +} + +export class SubDomainType extends DomainType { + static getType() { + return 'sub_domain' + } + + get name() { + return this.app.i18n.t('domainTypes.subDomainName') + } + + get options() { + return this.app.$config.BASEROW_BUILDER_DOMAINS.map((domain) => ({ + name: this.app.i18n.t('domainTypes.subDomain', { domain }), + value: { + type: this.getType(), + domain, + }, + })) + } + + get detailsComponent() { + return SubDomainDetails + } + + get formComponent() { + return SubDomainForm + } } diff --git a/web-frontend/modules/builder/locales/en.json b/web-frontend/modules/builder/locales/en.json index 18ffe9dbc..6917a9b1f 100644 --- a/web-frontend/modules/builder/locales/en.json +++ b/web-frontend/modules/builder/locales/en.json @@ -155,17 +155,27 @@ "hostHeader": "Host", "valueHeader": "Value" }, + "domainForm": { + "invalidDomain": "The provided domain name is invalid", + "notUniqueDomain": "The provided domain is already used" + }, "customDomainForm": { - "domainNameLabel": "Domain name", - "invalidDomain": "The provided domain name is invalid", - "notUniqueDomain": "The provided domain is already used" + "domainNameLabel": "Domain name" + }, + "subDomainForm": { + "domainNameLabel": "Domain name" + }, + "subDomainDetails": { + "text": "The DNS settings of the domain have already been configured and checked. It works without making any additional changes." }, "domainCard": { "refresh": "Refresh settings", "detailLabel": "Show details" }, "domainTypes": { - "customName": "Custom domain" + "customName": "Custom domain", + "subDomainName": "Subdomain", + "subDomain": "Subdomain of {domain}" }, "linkElement": { "noValue": "Unnamed..." diff --git a/web-frontend/modules/builder/mixins/domainForm.js b/web-frontend/modules/builder/mixins/domainForm.js new file mode 100644 index 000000000..78777d6e9 --- /dev/null +++ b/web-frontend/modules/builder/mixins/domainForm.js @@ -0,0 +1,46 @@ +import form from '@baserow/modules/core/mixins/form' + +export default { + mixins: [form], + props: { + domain: { + type: String, + required: true, + }, + }, + data() { + return { + serverErrors: {}, + } + }, + computed: { + hasServerErrors() { + return Object.values(this.serverErrors).some((value) => value !== null) + }, + hasError() { + return !this.isFormValid() || this.hasServerErrors + }, + }, + watch: { + hasError: { + handler(value) { + this.$emit('error', value) + }, + immediate: true, + }, + }, + methods: { + handleServerError(error) { + if (error.handler.code !== 'ERROR_REQUEST_BODY_VALIDATION') return false + + this.serverErrors = Object.fromEntries( + Object.entries(error.handler.detail || {}).map(([key, value]) => [ + key, + value[0], + ]) + ) + + return true + }, + }, +} diff --git a/web-frontend/modules/builder/plugin.js b/web-frontend/modules/builder/plugin.js index cd27559ef..ce4b519d9 100644 --- a/web-frontend/modules/builder/plugin.js +++ b/web-frontend/modules/builder/plugin.js @@ -49,7 +49,10 @@ import { VisibilityPageSidePanelType, StylePageSidePanelType, } from '@baserow/modules/builder/pageSidePanelTypes' -import { CustomDomainType } from '@baserow/modules/builder/domainTypes' +import { + CustomDomainType, + SubDomainType, +} from '@baserow/modules/builder/domainTypes' import { PagePageSettingsType } from '@baserow/modules/builder/pageSettingsTypes' import { TextPathParamType, @@ -158,6 +161,7 @@ export default (context) => { app.$registry.register('pageSidePanel', new EventsPageSidePanelType(context)) app.$registry.register('domain', new CustomDomainType(context)) + app.$registry.register('domain', new SubDomainType(context)) app.$registry.register('pageSettings', new PagePageSettingsType(context)) diff --git a/web-frontend/modules/builder/services/domain.js b/web-frontend/modules/builder/services/domain.js index 86431c783..4c351adbe 100644 --- a/web-frontend/modules/builder/services/domain.js +++ b/web-frontend/modules/builder/services/domain.js @@ -1,16 +1,10 @@ export default (client) => { return { - async fetchAll(builderId) { - // TODO Manually add the domain type while the backend doesn't support it. - const result = await client.get(`builder/${builderId}/domains/`) - result.data = result.data.map((domain) => ({ type: 'custom', ...domain })) - return result + fetchAll(builderId) { + return client.get(`builder/${builderId}/domains/`) }, - async create(builderId, { type, ...data }) { - // TODO For now we manage the type manually. - const result = await client.post(`builder/${builderId}/domains/`, data) - result.data.type = 'custom' - return result + create(builderId, data) { + return client.post(`builder/${builderId}/domains/`, data) }, delete(domainId) { return client.delete(`builder/domains/${domainId}/`) diff --git a/web-frontend/modules/builder/store/domain.js b/web-frontend/modules/builder/store/domain.js index 3f725507a..0e8c44d54 100644 --- a/web-frontend/modules/builder/store/domain.js +++ b/web-frontend/modules/builder/store/domain.js @@ -54,10 +54,10 @@ const actions = { commit('SET_ITEMS', { domains }) }, - async create({ commit }, { builderId, ...data }) { + async create({ commit }, { builderId, type, ...data }) { const { data: domain } = await DomainService(this.$client).create( builderId, - data + { type, ...data } ) commit('ADD_ITEM', { domain }) diff --git a/web-frontend/modules/core/assets/scss/components/builder/domains_settings.scss b/web-frontend/modules/core/assets/scss/components/builder/domains_settings.scss index 4b3421701..05c7d9fc6 100644 --- a/web-frontend/modules/core/assets/scss/components/builder/domains_settings.scss +++ b/web-frontend/modules/core/assets/scss/components/builder/domains_settings.scss @@ -1,6 +1,10 @@ .domain-settings__domain-type { display: inline-block; - width: 30%; + width: fit-content; + + .dropdown__items { + width: min-content; + } } .domains-settings__loading { diff --git a/web-frontend/modules/core/assets/scss/components/form_input.scss b/web-frontend/modules/core/assets/scss/components/form_input.scss index 4600ab417..e88fca185 100644 --- a/web-frontend/modules/core/assets/scss/components/form_input.scss +++ b/web-frontend/modules/core/assets/scss/components/form_input.scss @@ -1,5 +1,5 @@ .form-input { - display: block; + display: flex; border: 1px solid $color-neutral-400; position: relative; @include rounded($rounded); @@ -96,3 +96,14 @@ display: none; } } + +.form-input__suffix { + display: flex; + align-items: center; + width: fit-content; + border-left: 1px solid $color-neutral-400; + appearance: none; + padding: 0 12px; + outline: none; + line-height: 100%; +} diff --git a/web-frontend/modules/core/components/FormInput.vue b/web-frontend/modules/core/components/FormInput.vue index bc3857430..018579076 100644 --- a/web-frontend/modules/core/components/FormInput.vue +++ b/web-frontend/modules/core/components/FormInput.vue @@ -43,6 +43,11 @@ class="form-input__icon fas" :class="[`fa-${icon}`]" /> + <div class="form-input__suffix disabled"> + <div> + <slot name="suffix"></slot> + </div> + </div> </div> <div v-if="hasError" class="error"> {{ error }} diff --git a/web-frontend/modules/core/mixins/dropdown.js b/web-frontend/modules/core/mixins/dropdown.js index ceae0b051..e0dc059d8 100644 --- a/web-frontend/modules/core/mixins/dropdown.js +++ b/web-frontend/modules/core/mixins/dropdown.js @@ -5,6 +5,7 @@ import { } from '@baserow/modules/core/utils/dom' import dropdownHelpers from './dropdownHelpers' +import _ from 'lodash' export default { mixins: [dropdownHelpers], @@ -325,7 +326,7 @@ export default { getSelectedProperty(value, property) { for (const i in this.getDropdownItemComponents()) { const item = this.getDropdownItemComponents()[i] - if (item.value === value) { + if (_.isEqual(item.value, value)) { return item[property] } } @@ -338,7 +339,7 @@ export default { hasValue() { for (const i in this.getDropdownItemComponents()) { const item = this.getDropdownItemComponents()[i] - if (item.value === this.value) { + if (_.isEqual(item.value, this.value)) { return true } } @@ -366,7 +367,9 @@ export default { ) const isArrowUp = event.key === 'ArrowUp' - let index = children.findIndex((item) => item.value === this.hover) + let index = children.findIndex((item) => + _.isEqual(item.value, this.hover) + ) index = isArrowUp ? index - 1 : index + 1 // Check if the new index is within the allowed range. diff --git a/web-frontend/modules/core/module.js b/web-frontend/modules/core/module.js index a924479b6..86851d2bc 100644 --- a/web-frontend/modules/core/module.js +++ b/web-frontend/modules/core/module.js @@ -91,6 +91,9 @@ export default function CoreModule(options) { process.env.BASEROW_UNIQUE_ROW_VALUES_SIZE_LIMIT ?? 100, BASEROW_ROW_PAGE_SIZE_LIMIT: parseInt(process.env.BASEROW_ROW_PAGE_SIZE_LIMIT) ?? 200, + BASEROW_BUILDER_DOMAINS: process.env.BASEROW_BUILDER_DOMAINS + ? process.env.BASEROW_BUILDER_DOMAINS.split(',') + : [], } const locales = [